@ds-mo/ui 2.1.1 → 2.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/README.md +1 -1
  2. package/dist/.build-stamp +1 -1
  3. package/dist/components/ds-bar-nav.js +1 -1
  4. package/dist/components/ds-bar-nav.js.map +1 -1
  5. package/dist/components/ds-breadcrumb.js +1 -1
  6. package/dist/components/ds-button-group.js +1 -1
  7. package/dist/components/ds-button-group.js.map +1 -1
  8. package/dist/components/ds-button-unfilled-icon.d.ts +11 -0
  9. package/dist/components/ds-button-unfilled-icon.js +2 -0
  10. package/dist/components/ds-button.js +1 -1
  11. package/dist/components/ds-checkbox.js +1 -1
  12. package/dist/components/ds-field.js +1 -1
  13. package/dist/components/ds-menu.js +1 -1
  14. package/dist/components/ds-panel-nav.js +1 -1
  15. package/dist/components/ds-panel-nav.js.map +1 -1
  16. package/dist/components/ds-panel-tools.js +1 -1
  17. package/dist/components/ds-panel-tools.js.map +1 -1
  18. package/dist/components/ds-select.js +1 -1
  19. package/dist/components/ds-slider.js +1 -1
  20. package/dist/components/ds-tab-group-nav.js +1 -1
  21. package/dist/components/ds-tab-group.js +1 -1
  22. package/dist/components/ds-tab-group.js.map +1 -1
  23. package/dist/components/ds-toggle.js +1 -1
  24. package/dist/components/p-Bl-GTn9s.js +2 -0
  25. package/dist/components/p-Bl-GTn9s.js.map +1 -0
  26. package/dist/components/{p-CsO880L-.js → p-CD3yg7ue.js} +2 -2
  27. package/dist/components/p-CZpCmCiq.js +2 -0
  28. package/dist/components/p-CZpCmCiq.js.map +1 -0
  29. package/dist/components/p-YzIqPOqL.js +2 -0
  30. package/dist/components/p-YzIqPOqL.js.map +1 -0
  31. package/dist/types/components/BarNav/BarNav.d.ts +8 -7
  32. package/dist/types/components/BarNav/bar-nav-tabs-menu-utils.d.ts +3 -0
  33. package/dist/types/components/BarNav/bar-nav-types.d.ts +0 -11
  34. package/dist/types/components/BarNav/index.d.ts +1 -1
  35. package/dist/types/components/ButtonUnfilledIcon/ButtonUnfilledIcon.d.ts +33 -0
  36. package/dist/types/components/ButtonUnfilledIcon/index.d.ts +2 -0
  37. package/dist/types/components/Menu/Menu.d.ts +3 -0
  38. package/dist/types/components.d.ts +154 -98
  39. package/package.json +1 -1
  40. package/src/angular/index.ts +1 -1
  41. package/src/angular/proxies.ts +34 -32
  42. package/src/react/components.ts +1 -1
  43. package/src/react/ds-button-unfilled-icon.ts +33 -0
  44. package/src/wc/components/BarNav/BarNav.tsx +126 -133
  45. package/src/wc/components/BarNav/bar-nav-tabs-menu-utils.ts +55 -0
  46. package/src/wc/components/BarNav/bar-nav-types.ts +0 -12
  47. package/src/wc/components/BarNav/index.ts +1 -1
  48. package/src/wc/components/ButtonUnfilledIcon/ButtonUnfilledIcon.tsx +114 -0
  49. package/src/wc/components/ButtonUnfilledIcon/index.ts +1 -0
  50. package/src/wc/components/Menu/Menu.tsx +18 -7
  51. package/src/wc/components/PanelNav/PanelNav.tsx +12 -9
  52. package/src/wc/components/PanelTools/PanelTools.tsx +4 -2
  53. package/src/wc/components/TabGroup/TabGroup.tsx +1 -0
  54. package/src/wc/components/TabGroupNav/TabGroupNav.tsx +1 -0
  55. package/src/wc/components.d.ts +154 -98
  56. package/src/wc/utils/focus-ring.css +33 -0
  57. package/dist/components/ds-bar-nav-action.d.ts +0 -11
  58. package/dist/components/ds-bar-nav-action.js +0 -2
  59. package/dist/components/p-CCZFMcLe.js +0 -2
  60. package/dist/components/p-CCZFMcLe.js.map +0 -1
  61. package/dist/components/p-CuuuDjJJ.js +0 -2
  62. package/dist/components/p-CuuuDjJJ.js.map +0 -1
  63. package/dist/components/p-DlsexsFg.js +0 -2
  64. package/dist/components/p-DlsexsFg.js.map +0 -1
  65. package/dist/types/components/BarNavAction/BarNavAction.d.ts +0 -21
  66. package/dist/types/components/BarNavAction/index.d.ts +0 -2
  67. package/src/react/ds-bar-nav-action.ts +0 -27
  68. package/src/wc/components/BarNavAction/BarNavAction.tsx +0 -78
  69. package/src/wc/components/BarNavAction/index.ts +0 -1
  70. /package/dist/components/{ds-bar-nav-action.js.map → ds-button-unfilled-icon.js.map} +0 -0
  71. /package/dist/components/{p-CsO880L-.js.map → p-CD3yg7ue.js.map} +0 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ds-mo/ui",
3
- "version": "2.1.1",
3
+ "version": "2.2.1",
4
4
  "description": "CompoMo — composable web components styled with TokoMo design tokens",
5
5
  "keywords": [
6
6
  "web-components",
@@ -7,10 +7,10 @@ export const DIRECTIVES = [
7
7
  d.DsBadge,
8
8
  d.DsBanner,
9
9
  d.DsBarNav,
10
- d.DsBarNavAction,
11
10
  d.DsBreadcrumb,
12
11
  d.DsButton,
13
12
  d.DsButtonGroup,
13
+ d.DsButtonUnfilledIcon,
14
14
  d.DsCard,
15
15
  d.DsCheckbox,
16
16
  d.DsChip,
@@ -139,36 +139,6 @@ export declare interface DsBarNav extends Components.DsBarNav {
139
139
  }
140
140
 
141
141
 
142
- @ProxyCmp({
143
- inputs: ['ariaLabel', 'background', 'dot', 'icon', 'inactive', 'selected']
144
- })
145
- @Component({
146
- selector: 'ds-bar-nav-action',
147
- changeDetection: ChangeDetectionStrategy.OnPush,
148
- template: '<ng-content></ng-content>',
149
- // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
150
- inputs: ['ariaLabel', 'background', 'dot', 'icon', 'inactive', 'selected'],
151
- outputs: ['dsChange'],
152
- standalone: false
153
- })
154
- export class DsBarNavAction {
155
- protected el: HTMLDsBarNavActionElement;
156
- @Output() dsChange = new EventEmitter<CustomEvent<boolean>>();
157
- constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) {
158
- c.detach();
159
- this.el = r.nativeElement;
160
- }
161
- }
162
-
163
-
164
- export declare interface DsBarNavAction extends Components.DsBarNavAction {
165
- /**
166
- * Emits the new selected value (!selected) on click.
167
- */
168
- dsChange: EventEmitter<CustomEvent<boolean>>;
169
- }
170
-
171
-
172
142
  @ProxyCmp({
173
143
  inputs: ['items', 'separator']
174
144
  })
@@ -263,6 +233,38 @@ export declare interface DsButtonGroup extends Components.DsButtonGroup {
263
233
  }
264
234
 
265
235
 
236
+ @ProxyCmp({
237
+ inputs: ['activeFill', 'ariaLabel', 'background', 'controls', 'dot', 'expanded', 'hasBorder', 'haspopup', 'icon', 'inactive', 'isActive', 'pressed', 'type'],
238
+ methods: ['setFocus']
239
+ })
240
+ @Component({
241
+ selector: 'ds-button-unfilled-icon',
242
+ changeDetection: ChangeDetectionStrategy.OnPush,
243
+ template: '<ng-content></ng-content>',
244
+ // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
245
+ inputs: ['activeFill', 'ariaLabel', 'background', 'controls', 'dot', 'expanded', 'hasBorder', 'haspopup', 'icon', 'inactive', 'isActive', 'pressed', 'type'],
246
+ outputs: ['dsClick', 'dsChange'],
247
+ standalone: false
248
+ })
249
+ export class DsButtonUnfilledIcon {
250
+ protected el: HTMLDsButtonUnfilledIconElement;
251
+ @Output() dsClick = new EventEmitter<CustomEvent<MouseEvent>>();
252
+ @Output() dsChange = new EventEmitter<CustomEvent<boolean>>();
253
+ constructor(c: ChangeDetectorRef, r: ElementRef, protected z: NgZone) {
254
+ c.detach();
255
+ this.el = r.nativeElement;
256
+ }
257
+ }
258
+
259
+
260
+ export declare interface DsButtonUnfilledIcon extends Components.DsButtonUnfilledIcon {
261
+
262
+ dsClick: EventEmitter<CustomEvent<MouseEvent>>;
263
+
264
+ dsChange: EventEmitter<CustomEvent<boolean>>;
265
+ }
266
+
267
+
266
268
  @ProxyCmp({
267
269
  inputs: ['elevation', 'radius']
268
270
  })
@@ -548,14 +550,14 @@ export declare interface DsLoader extends Components.DsLoader {}
548
550
 
549
551
 
550
552
  @ProxyCmp({
551
- inputs: ['align', 'alignOffset', 'anchor', 'anchorId', 'items', 'menuWidth', 'minWidth', 'open', 'sections', 'side', 'sideOffset']
553
+ inputs: ['align', 'alignOffset', 'anchor', 'anchorId', 'initialFocusVisible', 'items', 'menuWidth', 'minWidth', 'open', 'sections', 'side', 'sideOffset']
552
554
  })
553
555
  @Component({
554
556
  selector: 'ds-menu',
555
557
  changeDetection: ChangeDetectionStrategy.OnPush,
556
558
  template: '<ng-content></ng-content>',
557
559
  // eslint-disable-next-line @angular-eslint/no-inputs-metadata-property
558
- inputs: ['align', 'alignOffset', 'anchor', 'anchorId', 'items', 'menuWidth', 'minWidth', 'open', 'sections', 'side', 'sideOffset'],
560
+ inputs: ['align', 'alignOffset', 'anchor', 'anchorId', 'initialFocusVisible', 'items', 'menuWidth', 'minWidth', 'open', 'sections', 'side', 'sideOffset'],
559
561
  outputs: ['dsClose', 'dsSelect'],
560
562
  standalone: false
561
563
  })
@@ -8,10 +8,10 @@ export { DsAccordion } from "./ds-accordion.js";
8
8
  export { DsAppShell } from "./ds-app-shell.js";
9
9
  export { DsBadge } from "./ds-badge.js";
10
10
  export { DsBanner } from "./ds-banner.js";
11
- export { DsBarNavAction } from "./ds-bar-nav-action.js";
12
11
  export { DsBarNav } from "./ds-bar-nav.js";
13
12
  export { DsBreadcrumb } from "./ds-breadcrumb.js";
14
13
  export { DsButtonGroup } from "./ds-button-group.js";
14
+ export { DsButtonUnfilledIcon } from "./ds-button-unfilled-icon.js";
15
15
  export { DsButton } from "./ds-button.js";
16
16
  export { DsCard } from "./ds-card.js";
17
17
  export { DsCheckbox } from "./ds-checkbox.js";
@@ -0,0 +1,33 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * This file was automatically generated by the Stencil React Output Target.
5
+ * Changes to this file may cause incorrect behavior and will be lost if the code is regenerated.
6
+ */
7
+
8
+ /* eslint-disable */
9
+
10
+ import type { EventName, StencilReactComponent } from '@stencil/react-output-target/runtime';
11
+ import { createComponent } from '@stencil/react-output-target/runtime';
12
+ import React from 'react';
13
+
14
+ import { type DsButtonUnfilledIconCustomEvent } from "@ds-mo/ui";
15
+ import type { Components } from "@ds-mo/ui/dist/components";
16
+ import { DsButtonUnfilledIcon as DsButtonUnfilledIconElement, defineCustomElement as defineDsButtonUnfilledIcon } from "@ds-mo/ui/dist/components/ds-button-unfilled-icon.js";
17
+
18
+ export type DsButtonUnfilledIconEvents = {
19
+ onDsClick: EventName<DsButtonUnfilledIconCustomEvent<MouseEvent>>,
20
+ onDsChange: EventName<DsButtonUnfilledIconCustomEvent<boolean>>
21
+ };
22
+
23
+ export const DsButtonUnfilledIcon: StencilReactComponent<DsButtonUnfilledIconElement, DsButtonUnfilledIconEvents, Components.DsButtonUnfilledIcon> = /*@__PURE__*/ createComponent<DsButtonUnfilledIconElement, DsButtonUnfilledIconEvents, Components.DsButtonUnfilledIcon>({
24
+ tagName: 'ds-button-unfilled-icon',
25
+ elementClass: DsButtonUnfilledIconElement,
26
+ // @ts-ignore - ignore potential React type mismatches between the Stencil Output Target and your project.
27
+ react: React,
28
+ events: {
29
+ onDsClick: 'dsClick',
30
+ onDsChange: 'dsChange'
31
+ } as DsButtonUnfilledIconEvents,
32
+ defineCustomElement: defineDsButtonUnfilledIcon
33
+ });
@@ -24,10 +24,10 @@ import {
24
24
  type QueryableHost,
25
25
  } from './bar-nav-dom-utils';
26
26
  import {
27
- getActiveTab,
28
- getActiveTabLabel,
29
27
  tabsOverflowContainer,
30
- tabsToMenuSections,
28
+ tabsToOverflowMenuSections,
29
+ trimTrailingDividers,
30
+ visibleTabCountForWidth,
31
31
  } from './bar-nav-tabs-menu-utils';
32
32
  import {
33
33
  CHROME_TRANSITION_END,
@@ -83,7 +83,9 @@ export class BarNav {
83
83
  @State() private menuOpen = false;
84
84
  /** When false, defer showing expanded tabs or the collapsed trigger until intrinsic width is measured (avoids a one-frame flash of the full tab row on narrow viewports). */
85
85
  @State() private tabLayoutCommitted = false;
86
- @State() private triggerLabelTruncated = false;
86
+ @State() private visibleTabs: BarNavTab[] = [];
87
+ @State() private overflowTabs: BarNavTab[] = [];
88
+ @State() private menuInitialFocusVisible = false;
87
89
 
88
90
  private static readonly HOST_PROP_SYNC_BUDGET = 8;
89
91
  private static readonly INTRINSIC_WIDTH_RETRY_MAX = 3;
@@ -91,9 +93,7 @@ export class BarNav {
91
93
  private static readonly OVERFLOW_HYSTERESIS_PX = 8;
92
94
 
93
95
  private headerEl: HTMLElement | null = null;
94
- private triggerEl: HTMLButtonElement | null = null;
95
- private triggerLabelTextEl: HTMLElement | null = null;
96
- private triggerLabelObserver: ResizeObserver | null = null;
96
+ private triggerEl: (HTMLElement & { setFocus?: () => Promise<void> }) | null = null;
97
97
  private menuEl: HTMLDsMenuElement | null = null;
98
98
  private visibleTabGroupEl: QueryableHost | null = null;
99
99
  private probeTabGroupEl: QueryableHost | null = null;
@@ -104,7 +104,6 @@ export class BarNav {
104
104
  private readonly panelToolsTransition = new ChromeTransitionDepth();
105
105
  private readonly overflowCoalescer = createRafCoalescer(() => {
106
106
  this.updateTabsCollapsed();
107
- this.updateTriggerLabelTruncation();
108
107
  });
109
108
  private chromeTransitionShell: HTMLElement | null = null;
110
109
  /** Matches `basePath` once tabs + URL are reconciled for the active section. */
@@ -119,12 +118,8 @@ export class BarNav {
119
118
  return this.value;
120
119
  }
121
120
 
122
- private get activeTabLabel(): string {
123
- return getActiveTabLabel(this.resolvedTabs, this.effectiveValue);
124
- }
125
-
126
- private get activeTabHasDot(): boolean {
127
- return !!getActiveTab(this.resolvedTabs, this.effectiveValue)?.dot;
121
+ private get hasOverflowTabs(): boolean {
122
+ return this.tabLayoutCommitted && this.tabsCollapsed && this.overflowTabs.length > 0;
128
123
  }
129
124
 
130
125
  private digestTabsConfig(tabs: BarNavTab[]): string {
@@ -135,8 +130,11 @@ export class BarNav {
135
130
  this.tabLayoutCommitted = false;
136
131
  this.tabsCollapsed = false;
137
132
  this.menuOpen = false;
133
+ this.menuInitialFocusVisible = false;
138
134
  this.intrinsicWidthRetryCount = 0;
139
135
  this.tabLayoutPendingFrames = 0;
136
+ this.visibleTabs = [];
137
+ this.overflowTabs = [];
140
138
  }
141
139
 
142
140
  @Watch('tabs')
@@ -165,15 +163,13 @@ export class BarNav {
165
163
  onTabsCollapsedChange(collapsed: boolean, prevCollapsed: boolean | undefined) {
166
164
  if (!collapsed) {
167
165
  this.menuOpen = false;
166
+ this.menuInitialFocusVisible = false;
168
167
  this.menuEl = null;
169
- this.triggerLabelTruncated = false;
170
- this.syncTriggerLabelObserver();
171
168
  }
172
169
  this.scheduleOverflowCheck();
173
170
  if (collapsed) {
174
171
  requestAnimationFrame(() => {
175
172
  this.scheduleOverflowCheck();
176
- this.syncTriggerLabelObserver();
177
173
  });
178
174
  }
179
175
  if (collapsed || prevCollapsed === undefined || prevCollapsed === collapsed) return;
@@ -200,16 +196,12 @@ export class BarNav {
200
196
  }
201
197
  this.scheduleOverflowCheck();
202
198
  this.syncMenuAnchor();
203
- if (this.tabsCollapsed) {
204
- this.updateTriggerLabelTruncation();
205
- }
206
199
  }
207
200
 
208
201
  disconnectedCallback() {
209
202
  this.resizeObserver?.disconnect();
210
203
  this.resizeObserver = null;
211
204
  this.overflowCoalescer.cancel();
212
- this.syncTriggerLabelObserver(true);
213
205
  this.unbindChromeTransitionListeners();
214
206
  }
215
207
 
@@ -303,40 +295,6 @@ export class BarNav {
303
295
  this.overflowCoalescer.schedule();
304
296
  }
305
297
 
306
- private syncTriggerLabelObserver(disconnectOnly = false) {
307
- this.triggerLabelObserver?.disconnect();
308
- this.triggerLabelObserver = null;
309
-
310
- if (disconnectOnly || !this.tabsCollapsed || !this.triggerLabelTextEl) {
311
- return;
312
- }
313
-
314
- this.updateTriggerLabelTruncation();
315
-
316
- if (typeof ResizeObserver === 'undefined') return;
317
-
318
- this.triggerLabelObserver = new ResizeObserver(() => this.updateTriggerLabelTruncation());
319
- this.triggerLabelObserver.observe(this.triggerLabelTextEl);
320
- if (this.triggerEl) {
321
- this.triggerLabelObserver.observe(this.triggerEl);
322
- }
323
- }
324
-
325
- private updateTriggerLabelTruncation() {
326
- const el = this.triggerLabelTextEl;
327
- if (!el || !this.tabsCollapsed) {
328
- if (this.triggerLabelTruncated) {
329
- this.triggerLabelTruncated = false;
330
- }
331
- return;
332
- }
333
-
334
- const truncated = el.scrollWidth > el.clientWidth + 1;
335
- if (truncated !== this.triggerLabelTruncated) {
336
- this.triggerLabelTruncated = truncated;
337
- }
338
- }
339
-
340
298
  /** Width available for the tab row or collapsed trigger. */
341
299
  private getLeftZoneAvailableWidth(): number {
342
300
  if (!this.headerEl) return 0;
@@ -353,6 +311,63 @@ export class BarNav {
353
311
  return tabList?.scrollWidth ?? 0;
354
312
  }
355
313
 
314
+ private getHeaderGap(): number {
315
+ if (!this.headerEl) return 0;
316
+ const style = getComputedStyle(this.headerEl);
317
+ return parseFloat(style.columnGap || style.gap) || 0;
318
+ }
319
+
320
+ private getOverflowTriggerReserveWidth(): number {
321
+ if (!this.triggerEl) {
322
+ return 40;
323
+ }
324
+ return this.triggerEl.getBoundingClientRect().width + this.getHeaderGap();
325
+ }
326
+
327
+ private getProbeTabMetrics():
328
+ | {
329
+ itemWidths: number[];
330
+ itemGap: number;
331
+ listExtraWidth: number;
332
+ }
333
+ | null {
334
+ const tabList = getTabListFromTabGroup(this.probeTabGroupEl);
335
+ if (!tabList) return null;
336
+
337
+ const children = Array.from(tabList.children) as HTMLElement[];
338
+ const itemWidths = children.map(child => child.getBoundingClientRect().width);
339
+ if (itemWidths.length === 0 || itemWidths.some(width => width === 0)) return null;
340
+
341
+ const style = getComputedStyle(tabList);
342
+ const itemGap = parseFloat(style.columnGap || style.gap) || 0;
343
+ const childrenWidth = itemWidths.reduce((sum, width) => sum + width, 0);
344
+ const gapWidth = itemGap * Math.max(0, itemWidths.length - 1);
345
+ const listExtraWidth = Math.max(0, tabList.scrollWidth - childrenWidth - gapWidth);
346
+
347
+ return { itemWidths, itemGap, listExtraWidth };
348
+ }
349
+
350
+ private setOverflowTabLayout(visibleTabs: BarNavTab[], overflowTabs: BarNavTab[]) {
351
+ const nextCollapsed = overflowTabs.length > 0;
352
+ const visibleChanged =
353
+ this.digestTabsConfig(visibleTabs) !== this.digestTabsConfig(this.visibleTabs);
354
+ const overflowChanged =
355
+ this.digestTabsConfig(overflowTabs) !== this.digestTabsConfig(this.overflowTabs);
356
+
357
+ if (visibleChanged) {
358
+ this.visibleTabs = visibleTabs;
359
+ }
360
+ if (overflowChanged) {
361
+ this.overflowTabs = overflowTabs;
362
+ }
363
+ if (nextCollapsed !== this.tabsCollapsed) {
364
+ this.tabsCollapsed = nextCollapsed;
365
+ if (!nextCollapsed) {
366
+ this.menuOpen = false;
367
+ }
368
+ }
369
+ }
370
+
356
371
  private scheduleIntrinsicWidthRetry() {
357
372
  if (this.intrinsicWidthRetryCount >= BarNav.INTRINSIC_WIDTH_RETRY_MAX) return;
358
373
  this.intrinsicWidthRetryCount++;
@@ -362,18 +377,17 @@ export class BarNav {
362
377
  private forceTabLayoutCommit(fallbackCollapsed: boolean) {
363
378
  this.intrinsicWidthRetryCount = 0;
364
379
  this.tabLayoutPendingFrames = 0;
365
- this.tabsCollapsed = fallbackCollapsed;
366
- if (!fallbackCollapsed) {
367
- this.menuOpen = false;
368
- }
380
+ this.setOverflowTabLayout(
381
+ fallbackCollapsed ? [] : this.resolvedTabs,
382
+ fallbackCollapsed ? this.resolvedTabs : [],
383
+ );
369
384
  this.tabLayoutCommitted = true;
370
385
  }
371
386
 
372
387
  private updateTabsCollapsed() {
373
388
  if (!this.headerEl || this.resolvedTabs.length === 0 || this.hideTabsForDetailRoute) {
374
389
  if (this.tabsCollapsed) {
375
- this.tabsCollapsed = false;
376
- this.menuOpen = false;
390
+ this.setOverflowTabLayout([], []);
377
391
  }
378
392
  this.intrinsicWidthRetryCount = 0;
379
393
  this.tabLayoutPendingFrames = 0;
@@ -381,8 +395,9 @@ export class BarNav {
381
395
  return;
382
396
  }
383
397
 
398
+ const metrics = this.getProbeTabMetrics();
384
399
  const intrinsicWidth = this.getTabsIntrinsicWidth();
385
- if (intrinsicWidth === 0) {
400
+ if (!metrics || intrinsicWidth === 0) {
386
401
  this.tabLayoutPendingFrames += 1;
387
402
  if (this.intrinsicWidthRetryCount >= BarNav.INTRINSIC_WIDTH_RETRY_MAX) {
388
403
  this.forceTabLayoutCommit(false);
@@ -403,18 +418,27 @@ export class BarNav {
403
418
  BarNav.OVERFLOW_HYSTERESIS_PX,
404
419
  );
405
420
 
406
- if (shouldCollapse !== this.tabsCollapsed) {
407
- this.tabsCollapsed = shouldCollapse;
408
- if (!shouldCollapse) {
409
- this.menuOpen = false;
410
- }
421
+ if (!shouldCollapse) {
422
+ this.setOverflowTabLayout(this.resolvedTabs, []);
423
+ this.tabLayoutCommitted = true;
424
+ return;
411
425
  }
412
426
 
427
+ const visibleCount = visibleTabCountForWidth(
428
+ metrics.itemWidths,
429
+ this.getLeftZoneAvailableWidth(),
430
+ this.getOverflowTriggerReserveWidth(),
431
+ metrics.itemGap,
432
+ metrics.listExtraWidth,
433
+ );
434
+ const visibleTabs = trimTrailingDividers(this.resolvedTabs.slice(0, visibleCount));
435
+ const overflowTabs = this.resolvedTabs.slice(visibleTabs.length);
436
+ this.setOverflowTabLayout(visibleTabs, overflowTabs);
413
437
  this.tabLayoutCommitted = true;
414
438
  }
415
439
 
416
440
  private syncMenuAnchor() {
417
- if (!this.menuEl || !this.triggerEl || !this.tabsCollapsed) return;
441
+ if (!this.menuEl || !this.triggerEl || !this.hasOverflowTabs) return;
418
442
  this.menuEl.anchor = this.triggerEl;
419
443
  this.syncMenuSections();
420
444
  }
@@ -425,10 +449,12 @@ export class BarNav {
425
449
  this.selectTab(String(detail.value));
426
450
  }
427
451
  this.menuOpen = false;
452
+ this.menuInitialFocusVisible = false;
428
453
  }
429
454
 
430
455
  private handleMenuClose() {
431
456
  this.menuOpen = false;
457
+ this.menuInitialFocusVisible = false;
432
458
  }
433
459
 
434
460
  /** Batch tabs/basePath/currentUrl updates so URL derivation never runs with a mixed section. */
@@ -483,7 +509,7 @@ export class BarNav {
483
509
 
484
510
  private syncMenuSections() {
485
511
  if (!this.menuEl) return;
486
- this.menuEl.sections = tabsToMenuSections(this.resolvedTabs, this.effectiveValue);
512
+ this.menuEl.sections = tabsToOverflowMenuSections(this.overflowTabs, this.effectiveValue);
487
513
  }
488
514
 
489
515
  private syncValueFromUrl() {
@@ -528,28 +554,30 @@ export class BarNav {
528
554
  this.selectTab((e as CustomEvent<string>).detail);
529
555
  }
530
556
 
531
- private toggleTabMenu() {
557
+ private toggleTabMenu(options?: { focusVisible?: boolean }) {
532
558
  if (this.menuOpen) {
533
559
  this.menuOpen = false;
560
+ this.menuInitialFocusVisible = false;
534
561
  return;
535
562
  }
536
563
 
537
564
  if (this.menuEl && this.triggerEl) {
538
565
  this.menuEl.anchor = this.triggerEl;
539
- this.menuEl.minWidth = `${this.triggerEl.offsetWidth}px`;
566
+ this.menuEl.minWidth = '';
540
567
  this.syncMenuSections();
541
568
  }
542
569
 
570
+ this.menuInitialFocusVisible = options?.focusVisible ?? false;
543
571
  this.menuOpen = true;
544
572
  }
545
573
 
546
574
  private handleTriggerKeyDown(e: KeyboardEvent) {
547
- if (!this.tabsCollapsed) return;
575
+ if (!this.hasOverflowTabs) return;
548
576
 
549
577
  if (!this.menuOpen) {
550
578
  if (e.key === 'ArrowDown' || e.key === 'ArrowUp' || e.key === 'Enter' || e.key === ' ') {
551
579
  e.preventDefault();
552
- this.toggleTabMenu();
580
+ this.toggleTabMenu({ focusVisible: true });
553
581
  }
554
582
  return;
555
583
  }
@@ -557,7 +585,8 @@ export class BarNav {
557
585
  if (e.key === 'Escape') {
558
586
  e.preventDefault();
559
587
  this.menuOpen = false;
560
- this.triggerEl?.focus();
588
+ this.menuInitialFocusVisible = false;
589
+ void this.triggerEl?.setFocus?.();
561
590
  }
562
591
  }
563
592
 
@@ -567,6 +596,7 @@ export class BarNav {
567
596
  const hasTabs =
568
597
  sectionReady && this.resolvedTabs.length > 0 && !this.hideTabsForDetailRoute;
569
598
  const tabGroupKey = this.basePath || 'no-section';
599
+ const renderedTabs = this.hasOverflowTabs ? this.visibleTabs : this.resolvedTabs;
570
600
 
571
601
  return (
572
602
  <Host>
@@ -576,7 +606,7 @@ export class BarNav {
576
606
  'bar-nav--dashboard': this.navStyle === 'dashboard',
577
607
  'bar-nav--settings': this.navStyle === 'settings',
578
608
  'bar-nav--tabs-collapsed':
579
- hasTabs && this.tabLayoutCommitted && this.tabsCollapsed,
609
+ hasTabs && this.hasOverflowTabs,
580
610
  }}
581
611
  ref={el => {
582
612
  this.headerEl = el as HTMLElement;
@@ -605,7 +635,7 @@ export class BarNav {
605
635
  </div>
606
636
  )}
607
637
 
608
- {hasTabs && this.tabLayoutCommitted && !this.tabsCollapsed && (
638
+ {hasTabs && this.tabLayoutCommitted && renderedTabs.length > 0 && (
609
639
  <div class="bar-nav__left">
610
640
  <ds-tab-group-nav
611
641
  key={`visible-${tabGroupKey}`}
@@ -613,7 +643,7 @@ export class BarNav {
613
643
  ref={el => {
614
644
  this.visibleTabGroupEl = el ?? null;
615
645
  }}
616
- tabs={this.resolvedTabs}
646
+ tabs={renderedTabs}
617
647
  value={this.effectiveValue}
618
648
  onDsChange={(e: Event) => this.handleTabChange(e)}
619
649
  />
@@ -628,76 +658,39 @@ export class BarNav {
628
658
  <span class="bar-nav__heading text-body-medium-emphasis">{this.heading}</span>
629
659
  )}
630
660
 
631
- {hasTabs && this.tabLayoutCommitted && this.tabsCollapsed && (
632
- <button
633
- type="button"
661
+ {hasTabs && this.hasOverflowTabs && (
662
+ <ds-button-unfilled-icon
634
663
  class={{
635
- 'bar-nav__tab-trigger': true,
636
- 'bar-nav__tab-trigger--open': this.menuOpen,
664
+ 'bar-nav__overflow-trigger': true,
637
665
  }}
638
- ref={el => {
639
- this.triggerEl = el as HTMLButtonElement;
666
+ icon="Ellipses"
667
+ isActive={this.menuOpen}
668
+ activeFill={false}
669
+ ref={(el?: HTMLDsButtonUnfilledIconElement) => {
670
+ this.triggerEl = (el as (HTMLElement & { setFocus?: () => Promise<void> })) ?? null;
640
671
  }}
641
- aria-haspopup="menu"
642
- aria-expanded={this.menuOpen}
643
- aria-label={`${this.activeTabLabel} tabs`}
644
- onClick={() => this.toggleTabMenu()}
672
+ haspopup="menu"
673
+ expanded={this.menuOpen}
674
+ aria-label="More tabs"
675
+ onDsClick={() => this.toggleTabMenu({ focusVisible: false })}
645
676
  onKeyDown={(e: KeyboardEvent) => this.handleTriggerKeyDown(e)}
646
- >
647
- <span
648
- class={{
649
- 'bar-nav__tab-trigger-label': true,
650
- 'bar-nav__tab-trigger-label--dot': this.activeTabHasDot,
651
- 'bar-nav__tab-trigger-label--truncated': this.triggerLabelTruncated,
652
- }}
653
- >
654
- <span
655
- class={{
656
- 'bar-nav__tab-trigger-label-text': true,
657
- 'bar-nav__tab-trigger-label-text--truncated': this.triggerLabelTruncated,
658
- 'text-body-medium-emphasis': true,
659
- }}
660
- ref={el => {
661
- const next = el ?? null;
662
- if (next === this.triggerLabelTextEl) return;
663
- this.triggerLabelTextEl = next;
664
- if (this.tabsCollapsed) {
665
- this.syncTriggerLabelObserver();
666
- }
667
- }}
668
- >
669
- {this.activeTabLabel}
670
- </span>
671
- {this.activeTabHasDot && (
672
- <ds-badge
673
- class="bar-nav__tab-trigger-dot"
674
- variant="dot"
675
- background="var(--_bar-nav-bg)"
676
- label=""
677
- aria-hidden="true"
678
- />
679
- )}
680
- </span>
681
- <ds-icon
682
- name="ChevronDown"
683
- size="md"
684
- color="inherit"
685
- class="bar-nav__tab-trigger-chevron"
686
- />
687
- </button>
677
+ />
688
678
  )}
689
679
 
690
680
  </header>
691
681
 
692
- {hasTabs && this.tabLayoutCommitted && this.tabsCollapsed && (
682
+ {hasTabs && this.hasOverflowTabs && (
693
683
  <ds-menu
694
684
  ref={el => {
695
685
  this.menuEl = (el as HTMLDsMenuElement) ?? null;
696
686
  }}
697
687
  open={this.menuOpen}
698
- sections={tabsToMenuSections(this.resolvedTabs, this.effectiveValue)}
688
+ sections={tabsToOverflowMenuSections(this.overflowTabs, this.effectiveValue)}
699
689
  side="bottom"
700
- align="start"
690
+ align="end"
691
+ sideOffset="calc(var(--dimension-space-100) + var(--dimension-space-050))"
692
+ alignOffset="var(--dimension-space-050)"
693
+ initialFocusVisible={this.menuInitialFocusVisible}
701
694
  onDsSelect={(e: Event) => this.handleMenuSelect(e)}
702
695
  onDsClose={() => this.handleMenuClose()}
703
696
  />