@ds-mo/ui 14.28.0 → 14.29.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.
- package/dist/.package-ready.json +1 -1
- package/dist/agent-patterns.json +27 -17
- package/dist/agent.json +149 -25
- package/dist/angular/ds-button-filled.d.ts +1 -1
- package/dist/angular/ds-button-filled.js +3 -3
- package/dist/angular/ds-button-unfilled.d.ts +1 -1
- package/dist/angular/ds-button-unfilled.js +3 -3
- package/dist/angular/ds-panel-nav.d.ts +1 -1
- package/dist/angular/ds-panel-nav.js +3 -3
- package/dist/angular/ds-panel-tool-header.d.ts +1 -1
- package/dist/angular/ds-panel-tool-header.js +3 -3
- package/dist/angular/ds-panel-tool-search.d.ts +1 -1
- package/dist/angular/ds-panel-tool-search.js +3 -3
- package/dist/angular/ds-table.d.ts +1 -1
- package/dist/angular/ds-table.js +3 -3
- package/dist/angular/ds-tag.d.ts +1 -1
- package/dist/angular/ds-tag.js +3 -3
- package/dist/components/ds-agent-questionnaire.js +1 -1
- package/dist/components/ds-agent-response.js +1 -1
- package/dist/components/ds-banner.js +1 -1
- package/dist/components/ds-bar-nav.js +1 -1
- package/dist/components/ds-bar-title.js +1 -1
- package/dist/components/ds-bar-workflow.js +1 -1
- package/dist/components/ds-button-filled.js +1 -1
- package/dist/components/ds-button-unfilled.js +1 -1
- package/dist/components/ds-card-chart.js +1 -1
- package/dist/components/ds-card-navigation.js +1 -1
- package/dist/components/ds-card-navigation.js.map +1 -1
- package/dist/components/ds-card-overview.js +1 -1
- package/dist/components/ds-card-overview.js.map +1 -1
- package/dist/components/ds-card-setting.js +1 -1
- package/dist/components/ds-chart-legend.js +1 -1
- package/dist/components/ds-chart-legend.js.map +1 -1
- package/dist/components/ds-checkbox.js +1 -1
- package/dist/components/ds-chip.js +1 -1
- package/dist/components/ds-code-block.js +1 -1
- package/dist/components/ds-conversation-list-item.js +1 -1
- package/dist/components/ds-conversation-list-item.js.map +1 -1
- package/dist/components/ds-filter-menu.js +1 -1
- package/dist/components/ds-input.js +1 -1
- package/dist/components/ds-markdown.js +1 -1
- package/dist/components/ds-menu.js +1 -1
- package/dist/components/ds-message-actions.js +1 -1
- package/dist/components/ds-message-composer.js +1 -1
- package/dist/components/ds-message-scroller.js +1 -1
- package/dist/components/ds-mobile-bar-nav.js +1 -1
- package/dist/components/ds-mobile-header.js +1 -1
- package/dist/components/ds-mobile-section-switcher.js +1 -1
- package/dist/components/ds-mobile-sheet-nav.js +1 -1
- package/dist/components/ds-modal.js +1 -1
- package/dist/components/ds-pagination.js +1 -1
- package/dist/components/ds-panel-nav.js +1 -1
- package/dist/components/ds-panel-sub-nav.js +1 -1
- package/dist/components/ds-panel-sub-nav.js.map +1 -1
- package/dist/components/ds-panel-tool-header.js +1 -1
- package/dist/components/ds-panel-tool-search.js +1 -1
- package/dist/components/ds-panel-tool-search.js.map +1 -1
- package/dist/components/ds-panel-tools.js +1 -1
- package/dist/components/ds-radio.js +1 -1
- package/dist/components/ds-select.js +1 -1
- package/dist/components/ds-shell-app.js +1 -1
- package/dist/components/ds-shell-app.js.map +1 -1
- package/dist/components/ds-shell-page.js +1 -1
- package/dist/components/ds-shell-tools.js +1 -1
- package/dist/components/ds-tab-group.js +1 -1
- package/dist/components/ds-table-filter.js +1 -1
- package/dist/components/ds-table-group.js +1 -1
- package/dist/components/ds-table-group.js.map +1 -1
- package/dist/components/ds-table-saved-views.js +1 -1
- package/dist/components/ds-table-saved-views.js.map +1 -1
- package/dist/components/ds-table-search.js +1 -1
- package/dist/components/ds-table-search.js.map +1 -1
- package/dist/components/ds-table-sort.js +1 -1
- package/dist/components/ds-table-sort.js.map +1 -1
- package/dist/components/ds-table.js +1 -1
- package/dist/components/ds-table.js.map +1 -1
- package/dist/components/ds-tag.js +1 -1
- package/dist/components/ds-textarea.js +1 -1
- package/dist/components/ds-textarea.js.map +1 -1
- package/dist/components/ds-toast.js +1 -1
- package/dist/components/p--yvQzZoO.js +2 -0
- package/dist/components/p--yvQzZoO.js.map +1 -0
- package/dist/components/p-B5AZd41L.js +2 -0
- package/dist/components/p-B5AZd41L.js.map +1 -0
- package/dist/components/p-B5eMVBlz.js +2 -0
- package/dist/components/p-B5eMVBlz.js.map +1 -0
- package/dist/components/p-BGnRFtBa.js +2 -0
- package/dist/components/p-BGnRFtBa.js.map +1 -0
- package/dist/components/p-BIDGQ4tc.js +2 -0
- package/dist/components/p-BIDGQ4tc.js.map +1 -0
- package/dist/components/p-BZDQLjE0.js +2 -0
- package/dist/components/p-BZDQLjE0.js.map +1 -0
- package/dist/components/{p-DmYZLH_L.js → p-BdC7wxhu.js} +2 -2
- package/dist/components/{p-DmYZLH_L.js.map → p-BdC7wxhu.js.map} +1 -1
- package/dist/components/p-BfVlFinS.js +2 -0
- package/dist/components/{p-WM1GTEun.js.map → p-BfVlFinS.js.map} +1 -1
- package/dist/components/p-BmRHJ_jo.js +2 -0
- package/dist/components/p-BmRHJ_jo.js.map +1 -0
- package/dist/components/{p-CxbGZJBU.js → p-BwAWZAHk.js} +2 -2
- package/dist/components/{p-8atLxerP.js → p-By5TDnbF.js} +2 -2
- package/dist/components/{p-BdXUd-tK.js → p-CDXcINTO.js} +2 -2
- package/dist/components/{p-BdXUd-tK.js.map → p-CDXcINTO.js.map} +1 -1
- package/dist/components/{p-DGHeKNfG.js → p-CE8gsv7V.js} +2 -2
- package/dist/components/p-CLcwh1kq.js +2 -0
- package/dist/components/p-CLcwh1kq.js.map +1 -0
- package/dist/components/p-CcK3UiTu.js +2 -0
- package/dist/components/{p-NB-SoVpo.js.map → p-CcK3UiTu.js.map} +1 -1
- package/dist/components/p-Cr5vn5JO.js +2 -0
- package/dist/components/{p-CsW_2w-s.js.map → p-Cr5vn5JO.js.map} +1 -1
- package/dist/components/{p-CW7JhWlS.js → p-Crd3PEUx.js} +2 -2
- package/dist/components/{p-CW7JhWlS.js.map → p-Crd3PEUx.js.map} +1 -1
- package/dist/components/{p-Kp8ifB1g.js → p-Ct90k-nz.js} +2 -2
- package/dist/components/{p-C82vesh_.js → p-Cx2dWjaW.js} +2 -2
- package/dist/components/p-CyoLh8su.js +2 -0
- package/dist/components/p-CyoLh8su.js.map +1 -0
- package/dist/components/p-CysCSinT.js +2 -0
- package/dist/components/{p-BdU6FPu4.js.map → p-CysCSinT.js.map} +1 -1
- package/dist/components/p-CzbsTZt7.js.map +1 -1
- package/dist/components/p-D1pPq-y0.js +2 -0
- package/dist/components/{p-BWDbER5T.js.map → p-D1pPq-y0.js.map} +1 -1
- package/dist/components/{p-B3iy9Rl_.js → p-DF8W85Tz.js} +2 -2
- package/dist/components/p-DZBB8BVn.js +2 -0
- package/dist/components/p-DZBB8BVn.js.map +1 -0
- package/dist/components/p-GbtafFds.js +2 -0
- package/dist/components/p-GbtafFds.js.map +1 -0
- package/dist/components/{p-B_NN7k8H.js → p-K39w53Bw.js} +2 -2
- package/dist/components/p-K39w53Bw.js.map +1 -0
- package/dist/components/p-frKsmPQn.js +2 -0
- package/dist/components/{p-CvB76TJn.js.map → p-frKsmPQn.js.map} +1 -1
- package/dist/docs/components.json +188 -6
- package/dist/lib/components/ButtonFilled/ButtonFilled.d.ts +11 -3
- package/dist/lib/components/PanelTools/panel-tools-types.d.ts +2 -0
- package/dist/lib/components/ShellApp/shell-app-types.d.ts +2 -0
- package/dist/lib/shell/index.js.map +1 -1
- package/dist/lib/utils/choice-list.d.ts +2 -0
- package/dist/mcp-data/registry/bar-nav.json +7 -2
- package/dist/mcp-data/registry/bar-title.json +8 -3
- package/dist/mcp-data/registry/button-filled.json +26 -5
- package/dist/mcp-data/registry/button-unfilled.json +27 -6
- package/dist/mcp-data/registry/filter-menu.json +7 -2
- package/dist/mcp-data/registry/mobile-section-switcher.json +8 -3
- package/dist/mcp-data/registry/panel-nav.json +27 -6
- package/dist/mcp-data/registry/panel-tool-header.json +23 -2
- package/dist/mcp-data/registry/panel-tool-search.json +23 -2
- package/dist/mcp-data/registry/panel-tools.json +7 -2
- package/dist/mcp-data/registry/registry.json +148 -24
- package/dist/mcp-data/registry/select.json +8 -3
- package/dist/mcp-data/registry/shell-app.json +8 -3
- package/dist/mcp-data/registry/shell-page.json +2 -1
- package/dist/mcp-data/registry/shell-tools.json +7 -2
- package/dist/mcp-data/registry/table-group.json +7 -2
- package/dist/mcp-data/registry/table-saved-views.json +7 -2
- package/dist/mcp-data/registry/table-search.json +7 -2
- package/dist/mcp-data/registry/table-sort.json +7 -2
- package/dist/mcp-data/registry/table.json +24 -3
- package/dist/mcp-data/registry/tag.json +24 -3
- package/dist/styles/table.css +1 -0
- package/dist/types/components/BarTitle/BarTitle.d.ts +3 -0
- package/dist/types/components/ButtonFilled/ButtonFilled.d.ts +11 -3
- package/dist/types/components/ButtonUnfilled/ButtonUnfilled.d.ts +11 -5
- package/dist/types/components/MobileSectionSwitcher/MobileSectionSwitcher.d.ts +2 -0
- package/dist/types/components/PanelNav/PanelNav.d.ts +4 -1
- package/dist/types/components/PanelToolHeader/PanelToolHeader.d.ts +2 -0
- package/dist/types/components/PanelToolSearch/PanelToolSearch.d.ts +2 -0
- package/dist/types/components/PanelTools/panel-tools-types.d.ts +2 -0
- package/dist/types/components/ShellApp/ShellApp.d.ts +1 -0
- package/dist/types/components/ShellApp/shell-app-types.d.ts +2 -0
- package/dist/types/components/Table/Table.d.ts +10 -0
- package/dist/types/components/Table/table-row-view.d.ts +6 -1
- package/dist/types/components/TableSort/TableSort.d.ts +1 -0
- package/dist/types/components/Tag/Tag.d.ts +3 -0
- package/dist/types/components.d.ts +69 -6
- package/dist/types/utils/choice-list.d.ts +2 -0
- package/dist/vue/ds-button-filled.js +1 -0
- package/dist/vue/ds-button-unfilled.js +1 -0
- package/dist/vue/ds-panel-nav.js +1 -0
- package/dist/vue/ds-panel-tool-header.js +1 -0
- package/dist/vue/ds-panel-tool-search.js +1 -0
- package/dist/vue/ds-table.js +1 -0
- package/dist/vue/ds-tag.js +1 -0
- package/package.json +1 -1
- package/dist/components/p-6iF6gdoc.js +0 -2
- package/dist/components/p-6iF6gdoc.js.map +0 -1
- package/dist/components/p-BWDbER5T.js +0 -2
- package/dist/components/p-B_NN7k8H.js.map +0 -1
- package/dist/components/p-BdU6FPu4.js +0 -2
- package/dist/components/p-Bj018KMC.js +0 -2
- package/dist/components/p-Bj018KMC.js.map +0 -1
- package/dist/components/p-BxLF8b8a.js +0 -2
- package/dist/components/p-BxLF8b8a.js.map +0 -1
- package/dist/components/p-C1BJgnHY.js +0 -2
- package/dist/components/p-C1BJgnHY.js.map +0 -1
- package/dist/components/p-C78uTz-n.js +0 -2
- package/dist/components/p-C78uTz-n.js.map +0 -1
- package/dist/components/p-COn7l__5.js +0 -2
- package/dist/components/p-COn7l__5.js.map +0 -1
- package/dist/components/p-CsW_2w-s.js +0 -2
- package/dist/components/p-CvB76TJn.js +0 -2
- package/dist/components/p-D4-RkuER.js +0 -2
- package/dist/components/p-D4-RkuER.js.map +0 -1
- package/dist/components/p-DB_tlJ2d.js +0 -2
- package/dist/components/p-DB_tlJ2d.js.map +0 -1
- package/dist/components/p-Hzz2j1Je.js +0 -2
- package/dist/components/p-Hzz2j1Je.js.map +0 -1
- package/dist/components/p-NB-SoVpo.js +0 -2
- package/dist/components/p-P9My22Z1.js +0 -2
- package/dist/components/p-P9My22Z1.js.map +0 -1
- package/dist/components/p-WM1GTEun.js +0 -2
- package/dist/components/p-oAbq-DLk.js +0 -2
- package/dist/components/p-oAbq-DLk.js.map +0 -1
- /package/dist/components/{p-CxbGZJBU.js.map → p-BwAWZAHk.js.map} +0 -0
- /package/dist/components/{p-8atLxerP.js.map → p-By5TDnbF.js.map} +0 -0
- /package/dist/components/{p-DGHeKNfG.js.map → p-CE8gsv7V.js.map} +0 -0
- /package/dist/components/{p-Kp8ifB1g.js.map → p-Ct90k-nz.js.map} +0 -0
- /package/dist/components/{p-C82vesh_.js.map → p-Cx2dWjaW.js.map} +0 -0
- /package/dist/components/{p-B3iy9Rl_.js.map → p-DF8W85Tz.js.map} +0 -0
|
@@ -67,7 +67,8 @@
|
|
|
67
67
|
"On non-tab detail routes, show the parent PanelNav destination label in BarNav while the detail-page title remains in page content.",
|
|
68
68
|
"Dividers and groups visually organize peer route sections only and do not create hierarchy.",
|
|
69
69
|
"The application filters unauthorized sections before passing tabs to BarNav.",
|
|
70
|
-
"Dashboard and Settings route state updates PanelNav groups, BarNav tabs, heading, basePath, currentUrl, and navStyle as one coordinated change."
|
|
70
|
+
"Dashboard and Settings route state updates PanelNav groups, BarNav tabs, heading, basePath, currentUrl, and navStyle as one coordinated change.",
|
|
71
|
+
"The overflow trigger automatically retains the shared surface-open wash through menu exit, separately from the selected route."
|
|
71
72
|
],
|
|
72
73
|
"responsiveBehavior": [
|
|
73
74
|
"Keep the BarNav region present so shell height and main-content origin remain stable across routes.",
|
|
@@ -100,6 +101,10 @@
|
|
|
100
101
|
{
|
|
101
102
|
"label": "Application shell composition",
|
|
102
103
|
"path": "agent/patterns/application-shell/pattern.agent.json"
|
|
104
|
+
},
|
|
105
|
+
{
|
|
106
|
+
"label": "Surface ownership and trigger state",
|
|
107
|
+
"path": "src/docs/SurfaceOwnership.mdx"
|
|
103
108
|
}
|
|
104
109
|
]
|
|
105
110
|
},
|
|
@@ -306,7 +311,7 @@
|
|
|
306
311
|
},
|
|
307
312
|
{
|
|
308
313
|
"path": "src/wc/components/BarNav/BarNav.tsx",
|
|
309
|
-
"content": "import {\n Component,\n Prop,\n Event,\n EventEmitter,\n Element,\n Watch,\n State,\n h,\n Host,\n} from '@stencil/core';\nimport type { NavChromeStyle } from '../../shell/nav-chrome';\nimport type { MenuItemData } from '../Menu/menu-types';\nimport { getSelectableTabs, isTabDivider, type TabItemTab } from '../TabGroup/tab-item-utils';\nimport type { BarNavTab } from './bar-nav-types';\nimport { deriveBarNavValueFromUrl, shouldResyncBarNavProps } from './bar-nav-utils';\nimport {\n tabsOverflowContainer,\n tabsToOverflowMenuSections,\n trimTrailingDividers,\n visibleTabCountForWidth,\n} from './bar-nav-tabs-menu-utils';\nimport {\n CHROME_TRANSITION_END,\n CHROME_TRANSITION_START,\n ChromeTransitionGate,\n createRafCoalescer,\n readChromeTransitionSource,\n readChromeTransitionPhase,\n} from '../../shell/chrome-transition';\n\n@Component({\n tag: 'ds-bar-nav',\n styleUrl: 'BarNav.css',\n scoped: true,\n})\nexport class BarNav {\n /** Style slot: `dashboard` or `settings`. Colors match for now. */\n @Prop({ attribute: 'nav-style', reflect: true }) navStyle: NavChromeStyle = 'dashboard';\n\n @Element() el!: HTMLElement;\n\n /**\n * Tab items for the left section.\n * Set via JS property: `el.tabs = [...]`. Replace the array reference to update.\n */\n @Prop() tabs: BarNavTab[] = [];\n\n /** ID of the currently active tab. Overridden when `currentUrl` + `basePath` are set. */\n @Prop({ mutable: true }) value: string = '';\n\n /**\n * Fallback heading shown when no tabs are provided.\n * When tabs are present the heading is hidden.\n */\n @Prop() heading: string | undefined;\n\n /** Section base path (e.g. `/dashboard/safety`). Used with `currentUrl` to derive `value`. */\n @Prop() basePath: string = '';\n\n /** Current route URL. When set with `basePath`, the active tab is derived automatically. */\n @Prop() currentUrl: string = '';\n @Prop() moreTabsLabel: string = 'More tabs';\n\n /** Emitted when the active tab changes. Detail = tab id. */\n @Event() dsTabChange!: EventEmitter<string>;\n\n @State() private resolvedTabs: BarNavTab[] = [];\n @State() private urlDerivedValue: string = '';\n @State() private hideTabsForDetailRoute = false;\n @State() private tabsCollapsed = false;\n @State() private menuOpen = false;\n @State() private menuTriggerActive = false;\n /** 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). */\n @State() private tabLayoutCommitted = false;\n @State() private visibleTabs: BarNavTab[] = [];\n @State() private overflowTabs: BarNavTab[] = [];\n @State() private menuInitialFocusVisible = false;\n @State() private overflowRovingFocused = false;\n @State() private focusedTabId = '';\n\n private static readonly HOST_PROP_SYNC_BUDGET = 8;\n private static readonly INTRINSIC_WIDTH_RETRY_MAX = 3;\n private static readonly TAB_LAYOUT_COMMIT_MAX_FRAMES = 16;\n private static readonly OVERFLOW_HYSTERESIS_PX = 8;\n\n private headerEl: HTMLElement | null = null;\n private triggerEl: (HTMLElement & { setFocus?: () => Promise<void> }) | null = null;\n private menuEl: HTMLDsMenuElement | null = null;\n private visibleTabListEl: HTMLElement | null = null;\n private probeTabListEl: HTMLElement | null = null;\n private resizeObserver: ResizeObserver | null = null;\n private intrinsicWidthRetryCount = 0;\n private tabLayoutPendingFrames = 0;\n private readonly panelNavTransition = new ChromeTransitionGate();\n private readonly panelToolsTransition = new ChromeTransitionGate();\n private readonly overflowCoalescer = createRafCoalescer(() => {\n this.updateTabsCollapsed();\n });\n private chromeTransitionShell: HTMLElement | null = null;\n /** Matches `basePath` once tabs + URL are reconciled for the active section. */\n @State() private committedSection = '';\n /** Section waiting for URL + tabs to catch up after a cross-area navigation. */\n @State() private pendingSection = '';\n\n private get effectiveValue(): string {\n if (this.currentUrl && this.basePath) {\n return this.urlDerivedValue;\n }\n return this.value;\n }\n\n private get renderedSelectableTabs(): TabItemTab[] {\n const tabs = this.hasOverflowTabs ? this.visibleTabs : this.resolvedTabs;\n return getSelectableTabs(tabs);\n }\n\n private get hasOverflowTabs(): boolean {\n return this.tabLayoutCommitted && this.tabsCollapsed && this.overflowTabs.length > 0;\n }\n\n private syncFocusedTabId(preferred: string, tabs: TabItemTab[]) {\n if (tabs.some(tab => tab.id === preferred && !tab.isInactive)) {\n this.focusedTabId = preferred;\n return;\n }\n const first = tabs.find(tab => !tab.isInactive);\n this.focusedTabId = first?.id ?? '';\n }\n\n private digestTabsConfig(tabs: BarNavTab[]): string {\n return tabs.map(t => (isTabDivider(t) ? '|' : t.id)).join(',');\n }\n\n private resetTabOverflowLayout() {\n this.tabLayoutCommitted = false;\n this.tabsCollapsed = false;\n this.menuOpen = false;\n this.menuTriggerActive = false;\n this.menuInitialFocusVisible = false;\n this.intrinsicWidthRetryCount = 0;\n this.tabLayoutPendingFrames = 0;\n this.visibleTabs = [];\n this.overflowTabs = [];\n }\n\n @Watch('tabs')\n @Watch('currentUrl')\n @Watch('basePath')\n onHostPropsChange() {\n this.applyHostProps();\n }\n\n @Watch('value')\n @Watch('urlDerivedValue')\n onSelectionChange() {\n this.syncFocusedTabId(this.effectiveValue, this.renderedSelectableTabs);\n this.syncMenuSections();\n this.scheduleOverflowCheck();\n }\n\n @Watch('menuOpen')\n onMenuOpenChange(open: boolean) {\n if (this.menuEl) {\n this.menuEl.open = open;\n }\n }\n\n @Watch('tabsCollapsed')\n onTabsCollapsedChange(collapsed: boolean, prevCollapsed: boolean | undefined) {\n if (!collapsed) {\n this.menuOpen = false;\n this.menuTriggerActive = false;\n this.menuInitialFocusVisible = false;\n this.menuEl = null;\n }\n this.scheduleOverflowCheck();\n if (collapsed) {\n requestAnimationFrame(() => {\n this.scheduleOverflowCheck();\n });\n }\n if (collapsed || prevCollapsed === undefined || prevCollapsed === collapsed) return;\n requestAnimationFrame(() => this.scheduleOverflowCheck());\n queueMicrotask(() => this.focusVisibleSelectedTab());\n }\n\n componentWillLoad() {\n this.applyHostProps();\n this.syncFocusedTabId(this.effectiveValue, getSelectableTabs(this.resolvedTabs));\n }\n\n componentDidLoad() {\n this.syncHostPropsIfNeeded();\n this.scheduleDeferredHostPropSync();\n this.setupOverflowObserver();\n this.bindChromeTransitionListeners();\n this.scheduleOverflowCheck();\n }\n\n componentDidRender() {\n if (this.chromeOverflowPaused()) {\n this.syncMenuAnchor();\n return;\n }\n this.scheduleOverflowCheck();\n this.syncMenuAnchor();\n }\n\n disconnectedCallback() {\n this.resizeObserver?.disconnect();\n this.resizeObserver = null;\n this.overflowCoalescer.cancel();\n this.unbindChromeTransitionListeners();\n }\n\n private bindChromeTransitionListeners() {\n const shell = this.el.closest<HTMLElement>('ds-shell-app');\n if (!shell) return;\n this.chromeTransitionShell = shell;\n shell.addEventListener(CHROME_TRANSITION_START, this.onChromeTransitionStart);\n shell.addEventListener(CHROME_TRANSITION_END, this.onChromeTransitionEnd);\n }\n\n private unbindChromeTransitionListeners() {\n if (!this.chromeTransitionShell) return;\n this.chromeTransitionShell.removeEventListener(\n CHROME_TRANSITION_START,\n this.onChromeTransitionStart\n );\n this.chromeTransitionShell.removeEventListener(\n CHROME_TRANSITION_END,\n this.onChromeTransitionEnd\n );\n this.chromeTransitionShell = null;\n }\n\n private onChromeTransitionStart = (event: Event) => {\n const source = readChromeTransitionSource(event);\n if (source === 'panel-nav') {\n this.panelNavTransition.enter();\n return;\n }\n if (source === 'panel-tools') {\n const phase = readChromeTransitionPhase(event) ?? 'opening';\n if (phase === 'closing') {\n this.panelToolsTransition.enter();\n }\n }\n };\n\n private chromeOverflowPaused(): boolean {\n return this.panelNavTransition.isActive || this.panelToolsTransition.isActive;\n }\n\n private onChromeTransitionEnd = (event: Event) => {\n const source = readChromeTransitionSource(event);\n if (source === 'panel-nav') {\n this.panelNavTransition.exit();\n if (!this.panelNavTransition.isActive) {\n this.scheduleOverflowCheck();\n }\n return;\n }\n if (source === 'panel-tools') {\n this.panelToolsTransition.exit();\n this.scheduleOverflowCheck();\n }\n };\n\n /** Re-resolve props assigned by the host after componentWillLoad (Angular ngAfterViewInit). */\n private syncHostPropsIfNeeded() {\n if (shouldResyncBarNavProps(this.resolvedTabs, this.tabs)) {\n this.applyHostProps();\n } else if (this.currentUrl && this.basePath) {\n this.syncValueFromUrl();\n }\n }\n\n /** Poll across animation frames — host props may land without triggering @Watch. */\n private scheduleDeferredHostPropSync() {\n let remaining = BarNav.HOST_PROP_SYNC_BUDGET;\n const tick = () => {\n this.syncHostPropsIfNeeded();\n if (--remaining > 0) {\n requestAnimationFrame(tick);\n }\n };\n queueMicrotask(tick);\n }\n\n private setupOverflowObserver() {\n if (typeof ResizeObserver === 'undefined' || !this.headerEl) return;\n this.resizeObserver?.disconnect();\n this.resizeObserver = new ResizeObserver(() => {\n if (this.chromeOverflowPaused()) return;\n this.scheduleOverflowCheck();\n });\n this.resizeObserver.observe(this.headerEl);\n }\n\n private scheduleOverflowCheck() {\n if (this.chromeOverflowPaused()) return;\n this.overflowCoalescer.schedule();\n }\n\n /** Width available for the tab row or collapsed trigger. */\n private getLeftZoneAvailableWidth(): number {\n if (!this.headerEl) return 0;\n\n const headerStyle = getComputedStyle(this.headerEl);\n const horizontalPadding =\n (parseFloat(headerStyle.paddingLeft) || 0) + (parseFloat(headerStyle.paddingRight) || 0);\n\n return Math.max(0, this.headerEl.clientWidth - horizontalPadding);\n }\n\n private getTabsIntrinsicWidth(): number {\n return this.probeTabListEl?.scrollWidth ?? 0;\n }\n\n private getHeaderGap(): number {\n if (!this.headerEl) return 0;\n const style = getComputedStyle(this.headerEl);\n return parseFloat(style.columnGap || style.gap) || 0;\n }\n\n private getOverflowTriggerReserveWidth(): number {\n if (!this.triggerEl) {\n return 40;\n }\n return this.triggerEl.getBoundingClientRect().width + this.getHeaderGap();\n }\n\n private getProbeTabMetrics(): {\n itemWidths: number[];\n itemGap: number;\n listExtraWidth: number;\n } | null {\n const tabList = this.probeTabListEl;\n if (!tabList) return null;\n\n const children = Array.from(tabList.children) as HTMLElement[];\n const itemWidths = children.map(child => child.getBoundingClientRect().width);\n if (itemWidths.length === 0 || itemWidths.some(width => width === 0)) return null;\n\n const style = getComputedStyle(tabList);\n const itemGap = parseFloat(style.columnGap || style.gap) || 0;\n const childrenWidth = itemWidths.reduce((sum, width) => sum + width, 0);\n const gapWidth = itemGap * Math.max(0, itemWidths.length - 1);\n const listExtraWidth = Math.max(0, tabList.scrollWidth - childrenWidth - gapWidth);\n\n return { itemWidths, itemGap, listExtraWidth };\n }\n\n private setOverflowTabLayout(visibleTabs: BarNavTab[], overflowTabs: BarNavTab[]) {\n const nextCollapsed = overflowTabs.length > 0;\n const visibleChanged =\n this.digestTabsConfig(visibleTabs) !== this.digestTabsConfig(this.visibleTabs);\n const overflowChanged =\n this.digestTabsConfig(overflowTabs) !== this.digestTabsConfig(this.overflowTabs);\n\n if (visibleChanged) {\n this.visibleTabs = visibleTabs;\n }\n if (overflowChanged) {\n this.overflowTabs = overflowTabs;\n }\n if (nextCollapsed !== this.tabsCollapsed) {\n this.tabsCollapsed = nextCollapsed;\n if (!nextCollapsed) {\n this.menuOpen = false;\n this.menuTriggerActive = false;\n }\n }\n this.syncFocusedTabId(\n this.effectiveValue,\n getSelectableTabs(nextCollapsed ? visibleTabs : this.resolvedTabs)\n );\n }\n\n private scheduleIntrinsicWidthRetry() {\n if (this.intrinsicWidthRetryCount >= BarNav.INTRINSIC_WIDTH_RETRY_MAX) return;\n this.intrinsicWidthRetryCount++;\n requestAnimationFrame(() => this.scheduleOverflowCheck());\n }\n\n private forceTabLayoutCommit(fallbackCollapsed: boolean) {\n this.intrinsicWidthRetryCount = 0;\n this.tabLayoutPendingFrames = 0;\n this.setOverflowTabLayout(\n fallbackCollapsed ? [] : this.resolvedTabs,\n fallbackCollapsed ? this.resolvedTabs : []\n );\n this.tabLayoutCommitted = true;\n }\n\n private updateTabsCollapsed() {\n if (!this.headerEl || this.resolvedTabs.length === 0 || this.hideTabsForDetailRoute) {\n if (this.tabsCollapsed) {\n this.setOverflowTabLayout([], []);\n }\n this.intrinsicWidthRetryCount = 0;\n this.tabLayoutPendingFrames = 0;\n this.tabLayoutCommitted = true;\n return;\n }\n\n const metrics = this.getProbeTabMetrics();\n const intrinsicWidth = this.getTabsIntrinsicWidth();\n if (!metrics || intrinsicWidth === 0) {\n this.tabLayoutPendingFrames += 1;\n if (this.intrinsicWidthRetryCount >= BarNav.INTRINSIC_WIDTH_RETRY_MAX) {\n this.forceTabLayoutCommit(false);\n } else if (this.tabLayoutPendingFrames >= BarNav.TAB_LAYOUT_COMMIT_MAX_FRAMES) {\n this.forceTabLayoutCommit(false);\n } else {\n this.scheduleIntrinsicWidthRetry();\n }\n return;\n }\n this.intrinsicWidthRetryCount = 0;\n this.tabLayoutPendingFrames = 0;\n\n const shouldCollapse = tabsOverflowContainer(\n intrinsicWidth,\n this.getLeftZoneAvailableWidth(),\n this.tabsCollapsed,\n BarNav.OVERFLOW_HYSTERESIS_PX\n );\n\n if (!shouldCollapse) {\n this.setOverflowTabLayout(this.resolvedTabs, []);\n this.tabLayoutCommitted = true;\n return;\n }\n\n const visibleCount = visibleTabCountForWidth(\n metrics.itemWidths,\n this.getLeftZoneAvailableWidth(),\n this.getOverflowTriggerReserveWidth(),\n metrics.itemGap,\n metrics.listExtraWidth\n );\n const visibleTabs = trimTrailingDividers(this.resolvedTabs.slice(0, visibleCount));\n const overflowTabs = this.resolvedTabs.slice(visibleTabs.length);\n this.setOverflowTabLayout(visibleTabs, overflowTabs);\n this.tabLayoutCommitted = true;\n }\n\n private syncMenuAnchor() {\n if (!this.menuEl || !this.triggerEl || !this.hasOverflowTabs) return;\n this.menuEl.anchor = this.triggerEl;\n this.syncMenuSections();\n }\n\n private handleMenuSelect(e: Event) {\n const detail = (e as CustomEvent<MenuItemData>).detail;\n if (detail?.value !== undefined) {\n this.selectTab(String(detail.value));\n }\n this.menuOpen = false;\n this.menuInitialFocusVisible = false;\n }\n\n private handleMenuClose() {\n this.menuOpen = false;\n this.menuInitialFocusVisible = false;\n }\n\n private handleMenuAfterClose() {\n if (!this.menuOpen) this.menuTriggerActive = false;\n }\n\n /** Batch tabs/basePath/currentUrl updates so URL derivation never runs with a mixed section. */\n private incomingTabs(): BarNavTab[] {\n return this.tabs ?? [];\n }\n\n private applyHostProps() {\n const nextBasePath = this.basePath;\n const sectionChanged =\n nextBasePath !== '' && this.committedSection !== '' && nextBasePath !== this.committedSection;\n\n if (sectionChanged) {\n this.urlDerivedValue = '';\n this.committedSection = '';\n this.pendingSection = nextBasePath;\n this.resolvedTabs = [];\n this.resetTabOverflowLayout();\n return;\n }\n\n if (this.pendingSection) {\n if (!nextBasePath || nextBasePath !== this.pendingSection) {\n return;\n }\n if (!this.currentUrl.startsWith(this.pendingSection)) {\n return;\n }\n const tabs = this.incomingTabs();\n if (tabs.length === 0) {\n return;\n }\n this.pendingSection = '';\n }\n\n const tabs = this.incomingTabs();\n const tabsConfigChanged =\n this.digestTabsConfig(tabs) !== this.digestTabsConfig(this.resolvedTabs);\n\n this.resolvedTabs = tabs;\n this.syncValueFromUrl();\n this.committedSection = nextBasePath;\n if (tabsConfigChanged) {\n this.resetTabOverflowLayout();\n }\n this.scheduleOverflowCheck();\n }\n\n private syncMenuSections() {\n if (!this.menuEl) return;\n this.menuEl.sections = tabsToOverflowMenuSections(this.overflowTabs, this.effectiveValue);\n }\n\n private syncValueFromUrl() {\n const tabs = this.incomingTabs();\n\n if (!this.currentUrl || !this.basePath) {\n this.urlDerivedValue = '';\n this.hideTabsForDetailRoute = false;\n return;\n }\n\n if (!this.currentUrl.startsWith(this.basePath)) {\n this.urlDerivedValue = '';\n this.hideTabsForDetailRoute = false;\n return;\n }\n\n const { value, hideTabs } = deriveBarNavValueFromUrl(this.currentUrl, this.basePath, tabs);\n this.urlDerivedValue = value;\n this.hideTabsForDetailRoute = hideTabs;\n }\n\n private selectTab(id: string) {\n this.value = id;\n this.dsTabChange.emit(id);\n }\n\n /** Move focus to the selected tab in the visible tab group (after expand). */\n private focusVisibleSelectedTab() {\n const tab = this.visibleTabListEl?.querySelector(\n `[data-tab-id=\"${this.effectiveValue}\"]`\n ) as HTMLElement | null;\n tab?.focus({ preventScroll: true });\n }\n\n private focusVisibleTab(id: string) {\n this.focusedTabId = id;\n const tab = this.visibleTabListEl?.querySelector(`[data-tab-id=\"${id}\"]`) as HTMLElement | null;\n tab?.focus({ preventScroll: true });\n }\n\n private focusLastVisibleTab() {\n const last = [...this.renderedSelectableTabs].reverse().find(tab => !tab.isInactive);\n if (last) this.focusVisibleTab(last.id);\n }\n\n private findEnabledVisibleTab(from: number, step: 1 | -1): number | null {\n const tabs = this.renderedSelectableTabs;\n for (let index = from + step; index >= 0 && index < tabs.length; index += step) {\n if (!tabs[index]?.isInactive) return index;\n }\n return null;\n }\n\n private handleTabKeyDown(e: KeyboardEvent) {\n const tabs = this.renderedSelectableTabs;\n if (!tabs.length) return;\n const targetId = (e.target as HTMLElement | null)?.getAttribute('data-tab-id');\n const focusedId = targetId ?? this.focusedTabId;\n\n if (e.key === 'Enter' || e.key === ' ') {\n const focused = tabs.find(tab => tab.id === focusedId);\n if (!focused || focused.isInactive) return;\n e.preventDefault();\n this.selectTab(focused.id);\n return;\n }\n\n const currentIndex = tabs.findIndex(tab => tab.id === focusedId);\n if (currentIndex < 0) return;\n\n let nextIndex: number | null;\n if (e.key === 'ArrowRight') {\n nextIndex = this.findEnabledVisibleTab(currentIndex, 1);\n if (nextIndex === null && this.hasOverflowTabs) {\n e.preventDefault();\n this.overflowRovingFocused = true;\n void this.triggerEl?.setFocus?.();\n return;\n }\n } else if (e.key === 'ArrowLeft') {\n nextIndex = this.findEnabledVisibleTab(currentIndex, -1);\n } else if (e.key === 'Home') {\n nextIndex = tabs.findIndex(tab => !tab.isInactive);\n } else if (e.key === 'End') {\n const reversedIndex = [...tabs].reverse().findIndex(tab => !tab.isInactive);\n nextIndex = reversedIndex < 0 ? null : tabs.length - 1 - reversedIndex;\n } else {\n return;\n }\n\n if (nextIndex === null || nextIndex < 0 || nextIndex === currentIndex) return;\n e.preventDefault();\n this.focusVisibleTab(tabs[nextIndex].id);\n }\n\n private toggleTabMenu(options?: { focusVisible?: boolean }) {\n if (this.menuOpen) {\n this.menuOpen = false;\n this.menuInitialFocusVisible = false;\n return;\n }\n\n if (this.menuEl && this.triggerEl) {\n this.menuEl.anchor = this.triggerEl;\n this.menuEl.minWidth = '';\n this.syncMenuSections();\n }\n\n this.menuInitialFocusVisible = options?.focusVisible ?? false;\n this.menuTriggerActive = true;\n this.menuOpen = true;\n }\n\n private handleOverflowFocus = () => {\n this.overflowRovingFocused = true;\n };\n\n private handleOverflowBlur = () => {\n this.overflowRovingFocused = false;\n };\n\n private handleTriggerKeyDown(e: KeyboardEvent) {\n if (!this.hasOverflowTabs) return;\n\n if (!this.menuOpen) {\n if (e.key === 'ArrowLeft') {\n e.preventDefault();\n this.overflowRovingFocused = false;\n this.focusLastVisibleTab();\n return;\n }\n\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n this.toggleTabMenu({ focusVisible: true });\n }\n return;\n }\n\n if (e.key === 'Escape') {\n e.preventDefault();\n this.menuOpen = false;\n this.menuInitialFocusVisible = false;\n void this.triggerEl?.setFocus?.();\n }\n }\n\n private renderTabList(tabs: BarNavTab[], probe: boolean) {\n return (\n <div\n role=\"tablist\"\n class=\"bar-nav__tab-list\"\n aria-label={this.heading}\n ref={el => {\n if (probe) {\n const next = (el as HTMLElement) ?? null;\n if (next === this.probeTabListEl) return;\n this.probeTabListEl = next;\n if (next) {\n this.intrinsicWidthRetryCount = 0;\n this.tabLayoutPendingFrames = 0;\n this.scheduleOverflowCheck();\n }\n } else {\n this.visibleTabListEl = (el as HTMLElement) ?? null;\n }\n }}\n onKeyDown={probe ? undefined : (e: KeyboardEvent) => this.handleTabKeyDown(e)}\n onFocusin={\n probe\n ? undefined\n : () => {\n this.overflowRovingFocused = false;\n }\n }\n >\n {tabs.map((tab, index) => {\n if (isTabDivider(tab)) {\n return (\n <div class=\"bar-nav__tab-divider\" key={`divider-${index}`} aria-hidden=\"true\">\n <div class=\"bar-nav__tab-divider-line\" />\n </div>\n );\n }\n\n const isSelected = tab.id === this.effectiveValue;\n return (\n <button\n key={tab.id}\n type=\"button\"\n role=\"tab\"\n data-tab-id={tab.id}\n class={{\n 'bar-nav__tab': true,\n 'bar-nav__tab--selected': isSelected,\n 'ds-control--md': true,\n 'ds-focus-ring-inset': true,\n 'ds-control-inactive': !!tab.isInactive,\n }}\n aria-selected={isSelected ? 'true' : 'false'}\n aria-disabled={tab.isInactive ? 'true' : undefined}\n aria-controls={tab.panelId ?? undefined}\n disabled={tab.isInactive}\n tabIndex={\n !probe && !this.overflowRovingFocused && tab.id === this.focusedTabId ? 0 : -1\n }\n onClick={() => !probe && !tab.isInactive && this.selectTab(tab.id)}\n onFocus={() => {\n if (!probe) this.focusedTabId = tab.id;\n }}\n >\n <span\n class={{\n 'bar-nav__tab-label': true,\n 'ds-control-label-box': true,\n 'ds-control-label-dot': !!tab.dot,\n 'bar-nav__tab-label--dot': !!tab.dot,\n }}\n >\n <ds-text as=\"span\" variant=\"text-body-medium\" emphasis={isSelected} color=\"inherit\">\n {tab.label}\n </ds-text>\n {tab.dot && (\n <ds-badge\n class=\"bar-nav__tab-dot ds-control-label-dot__badge\"\n variant=\"dot\"\n hasRing={false}\n label=\"\"\n aria-hidden=\"true\"\n />\n )}\n </span>\n </button>\n );\n })}\n </div>\n );\n }\n\n render() {\n // Section gating only applies when `basePath` is set (SPA section sync).\n // Tabs without a base path must still render — otherwise a single-tab bar\n // (or any non-routed tabs) silently falls through to an empty header.\n const sectionReady = this.basePath === '' || this.committedSection === this.basePath;\n const hasTabs = sectionReady && this.resolvedTabs.length > 0 && !this.hideTabsForDetailRoute;\n const tabGroupKey = this.basePath || 'no-section';\n const renderedTabs = this.hasOverflowTabs ? this.visibleTabs : this.resolvedTabs;\n\n return (\n <Host>\n <header\n class={{\n 'bar-nav': true,\n 'ds-control--md': true,\n 'ds-chrome-row': true,\n 'ds-chrome-space--md': true,\n 'bar-nav--dashboard': this.navStyle === 'dashboard',\n 'bar-nav--settings': this.navStyle === 'settings',\n 'bar-nav--tabs-collapsed': hasTabs && this.hasOverflowTabs,\n }}\n ref={el => {\n this.headerEl = el as HTMLElement;\n if (el && !this.resizeObserver) {\n this.setupOverflowObserver();\n }\n }}\n >\n {hasTabs && (\n <div class=\"bar-nav__tabs-probe\" aria-hidden=\"true\" inert>\n <div key={`probe-${tabGroupKey}`}>{this.renderTabList(this.resolvedTabs, true)}</div>\n </div>\n )}\n\n {hasTabs && this.tabLayoutCommitted && renderedTabs.length > 0 && (\n <div class=\"bar-nav__left\">\n <div key={`visible-${tabGroupKey}`} class=\"bar-nav__tabs-visible\">\n {this.renderTabList(renderedTabs, false)}\n </div>\n </div>\n )}\n\n {hasTabs && !this.tabLayoutCommitted && (\n <div class=\"bar-nav__tabs-pending\" aria-hidden=\"true\" />\n )}\n\n {!hasTabs && this.heading && (\n <ds-text\n class=\"bar-nav__heading\"\n lineTruncation={1}\n as=\"span\"\n variant=\"text-body-medium\"\n emphasis\n color=\"primary\"\n wrap=\"nowrap\"\n >\n {this.heading}\n </ds-text>\n )}\n\n {hasTabs && this.hasOverflowTabs && (\n <ds-button-unfilled\n variant=\"icon\"\n class={{\n 'bar-nav__overflow-trigger': true,\n }}\n icon=\"ChevronDown\"\n activeFill={false}\n hasBorder={false}\n focusTabIndex={this.overflowRovingFocused ? 0 : -1}\n ref={(el?: HTMLDsButtonUnfilledElement) => {\n this.triggerEl = (el as HTMLElement & { setFocus?: () => Promise<void> }) ?? null;\n }}\n haspopup=\"menu\"\n expanded={this.menuTriggerActive}\n aria-label={this.moreTabsLabel}\n onDsClick={() => this.toggleTabMenu({ focusVisible: false })}\n onFocusin={this.handleOverflowFocus}\n onFocusout={this.handleOverflowBlur}\n onKeyDown={(e: KeyboardEvent) => this.handleTriggerKeyDown(e)}\n />\n )}\n </header>\n\n {hasTabs && this.hasOverflowTabs && (\n <ds-menu\n ref={el => {\n this.menuEl = (el as HTMLDsMenuElement) ?? null;\n }}\n open={this.menuOpen}\n sections={tabsToOverflowMenuSections(this.overflowTabs, this.effectiveValue)}\n side=\"bottom\"\n align=\"end\"\n sideOffset=\"calc(var(--dimension-space-100) + var(--dimension-space-050))\"\n initialFocusVisible={this.menuInitialFocusVisible}\n onDsSelect={(e: Event) => this.handleMenuSelect(e)}\n onDsClose={() => this.handleMenuClose()}\n onDsAfterClose={() => this.handleMenuAfterClose()}\n />\n )}\n </Host>\n );\n }\n}\n",
|
|
314
|
+
"content": "import {\n Component,\n Prop,\n Event,\n EventEmitter,\n Element,\n Watch,\n State,\n h,\n Host,\n} from '@stencil/core';\nimport type { NavChromeStyle } from '../../shell/nav-chrome';\nimport type { MenuItemData } from '../Menu/menu-types';\nimport { getSelectableTabs, isTabDivider, type TabItemTab } from '../TabGroup/tab-item-utils';\nimport type { BarNavTab } from './bar-nav-types';\nimport { deriveBarNavValueFromUrl, shouldResyncBarNavProps } from './bar-nav-utils';\nimport {\n tabsOverflowContainer,\n tabsToOverflowMenuSections,\n trimTrailingDividers,\n visibleTabCountForWidth,\n} from './bar-nav-tabs-menu-utils';\nimport {\n CHROME_TRANSITION_END,\n CHROME_TRANSITION_START,\n ChromeTransitionGate,\n createRafCoalescer,\n readChromeTransitionSource,\n readChromeTransitionPhase,\n} from '../../shell/chrome-transition';\n\n@Component({\n tag: 'ds-bar-nav',\n styleUrl: 'BarNav.css',\n scoped: true,\n})\nexport class BarNav {\n /** Style slot: `dashboard` or `settings`. Colors match for now. */\n @Prop({ attribute: 'nav-style', reflect: true }) navStyle: NavChromeStyle = 'dashboard';\n\n @Element() el!: HTMLElement;\n\n /**\n * Tab items for the left section.\n * Set via JS property: `el.tabs = [...]`. Replace the array reference to update.\n */\n @Prop() tabs: BarNavTab[] = [];\n\n /** ID of the currently active tab. Overridden when `currentUrl` + `basePath` are set. */\n @Prop({ mutable: true }) value: string = '';\n\n /**\n * Fallback heading shown when no tabs are provided.\n * When tabs are present the heading is hidden.\n */\n @Prop() heading: string | undefined;\n\n /** Section base path (e.g. `/dashboard/safety`). Used with `currentUrl` to derive `value`. */\n @Prop() basePath: string = '';\n\n /** Current route URL. When set with `basePath`, the active tab is derived automatically. */\n @Prop() currentUrl: string = '';\n @Prop() moreTabsLabel: string = 'More tabs';\n\n /** Emitted when the active tab changes. Detail = tab id. */\n @Event() dsTabChange!: EventEmitter<string>;\n\n @State() private resolvedTabs: BarNavTab[] = [];\n @State() private urlDerivedValue: string = '';\n @State() private hideTabsForDetailRoute = false;\n @State() private tabsCollapsed = false;\n @State() private menuOpen = false;\n @State() private menuTriggerActive = false;\n /** 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). */\n @State() private tabLayoutCommitted = false;\n @State() private visibleTabs: BarNavTab[] = [];\n @State() private overflowTabs: BarNavTab[] = [];\n @State() private menuInitialFocusVisible = false;\n @State() private overflowRovingFocused = false;\n @State() private focusedTabId = '';\n\n private static readonly HOST_PROP_SYNC_BUDGET = 8;\n private static readonly INTRINSIC_WIDTH_RETRY_MAX = 3;\n private static readonly TAB_LAYOUT_COMMIT_MAX_FRAMES = 16;\n private static readonly OVERFLOW_HYSTERESIS_PX = 8;\n\n private headerEl: HTMLElement | null = null;\n private triggerEl: (HTMLElement & { setFocus?: () => Promise<void> }) | null = null;\n private menuEl: HTMLDsMenuElement | null = null;\n private visibleTabListEl: HTMLElement | null = null;\n private probeTabListEl: HTMLElement | null = null;\n private resizeObserver: ResizeObserver | null = null;\n private intrinsicWidthRetryCount = 0;\n private tabLayoutPendingFrames = 0;\n private readonly panelNavTransition = new ChromeTransitionGate();\n private readonly panelToolsTransition = new ChromeTransitionGate();\n private readonly overflowCoalescer = createRafCoalescer(() => {\n this.updateTabsCollapsed();\n });\n private chromeTransitionShell: HTMLElement | null = null;\n /** Matches `basePath` once tabs + URL are reconciled for the active section. */\n @State() private committedSection = '';\n /** Section waiting for URL + tabs to catch up after a cross-area navigation. */\n @State() private pendingSection = '';\n\n private get effectiveValue(): string {\n if (this.currentUrl && this.basePath) {\n return this.urlDerivedValue;\n }\n return this.value;\n }\n\n private get renderedSelectableTabs(): TabItemTab[] {\n const tabs = this.hasOverflowTabs ? this.visibleTabs : this.resolvedTabs;\n return getSelectableTabs(tabs);\n }\n\n private get hasOverflowTabs(): boolean {\n return this.tabLayoutCommitted && this.tabsCollapsed && this.overflowTabs.length > 0;\n }\n\n private syncFocusedTabId(preferred: string, tabs: TabItemTab[]) {\n if (tabs.some(tab => tab.id === preferred && !tab.isInactive)) {\n this.focusedTabId = preferred;\n return;\n }\n const first = tabs.find(tab => !tab.isInactive);\n this.focusedTabId = first?.id ?? '';\n }\n\n private digestTabsConfig(tabs: BarNavTab[]): string {\n return tabs.map(t => (isTabDivider(t) ? '|' : t.id)).join(',');\n }\n\n private resetTabOverflowLayout() {\n this.tabLayoutCommitted = false;\n this.tabsCollapsed = false;\n this.menuOpen = false;\n this.menuTriggerActive = false;\n this.menuInitialFocusVisible = false;\n this.intrinsicWidthRetryCount = 0;\n this.tabLayoutPendingFrames = 0;\n this.visibleTabs = [];\n this.overflowTabs = [];\n }\n\n @Watch('tabs')\n @Watch('currentUrl')\n @Watch('basePath')\n onHostPropsChange() {\n this.applyHostProps();\n }\n\n @Watch('value')\n @Watch('urlDerivedValue')\n onSelectionChange() {\n this.syncFocusedTabId(this.effectiveValue, this.renderedSelectableTabs);\n this.syncMenuSections();\n this.scheduleOverflowCheck();\n }\n\n @Watch('menuOpen')\n onMenuOpenChange(open: boolean) {\n if (this.menuEl) {\n this.menuEl.open = open;\n }\n }\n\n @Watch('tabsCollapsed')\n onTabsCollapsedChange(collapsed: boolean, prevCollapsed: boolean | undefined) {\n if (!collapsed) {\n this.menuOpen = false;\n this.menuTriggerActive = false;\n this.menuInitialFocusVisible = false;\n this.menuEl = null;\n }\n this.scheduleOverflowCheck();\n if (collapsed) {\n requestAnimationFrame(() => {\n this.scheduleOverflowCheck();\n });\n }\n if (collapsed || prevCollapsed === undefined || prevCollapsed === collapsed) return;\n requestAnimationFrame(() => this.scheduleOverflowCheck());\n queueMicrotask(() => this.focusVisibleSelectedTab());\n }\n\n componentWillLoad() {\n this.applyHostProps();\n this.syncFocusedTabId(this.effectiveValue, getSelectableTabs(this.resolvedTabs));\n }\n\n componentDidLoad() {\n this.syncHostPropsIfNeeded();\n this.scheduleDeferredHostPropSync();\n this.setupOverflowObserver();\n this.bindChromeTransitionListeners();\n this.scheduleOverflowCheck();\n }\n\n componentDidRender() {\n if (this.chromeOverflowPaused()) {\n this.syncMenuAnchor();\n return;\n }\n this.scheduleOverflowCheck();\n this.syncMenuAnchor();\n }\n\n disconnectedCallback() {\n this.resizeObserver?.disconnect();\n this.resizeObserver = null;\n this.overflowCoalescer.cancel();\n this.unbindChromeTransitionListeners();\n }\n\n private bindChromeTransitionListeners() {\n const shell = this.el.closest<HTMLElement>('ds-shell-app');\n if (!shell) return;\n this.chromeTransitionShell = shell;\n shell.addEventListener(CHROME_TRANSITION_START, this.onChromeTransitionStart);\n shell.addEventListener(CHROME_TRANSITION_END, this.onChromeTransitionEnd);\n }\n\n private unbindChromeTransitionListeners() {\n if (!this.chromeTransitionShell) return;\n this.chromeTransitionShell.removeEventListener(\n CHROME_TRANSITION_START,\n this.onChromeTransitionStart\n );\n this.chromeTransitionShell.removeEventListener(\n CHROME_TRANSITION_END,\n this.onChromeTransitionEnd\n );\n this.chromeTransitionShell = null;\n }\n\n private onChromeTransitionStart = (event: Event) => {\n const source = readChromeTransitionSource(event);\n if (source === 'panel-nav') {\n this.panelNavTransition.enter();\n return;\n }\n if (source === 'panel-tools') {\n const phase = readChromeTransitionPhase(event) ?? 'opening';\n if (phase === 'closing') {\n this.panelToolsTransition.enter();\n }\n }\n };\n\n private chromeOverflowPaused(): boolean {\n return this.panelNavTransition.isActive || this.panelToolsTransition.isActive;\n }\n\n private onChromeTransitionEnd = (event: Event) => {\n const source = readChromeTransitionSource(event);\n if (source === 'panel-nav') {\n this.panelNavTransition.exit();\n if (!this.panelNavTransition.isActive) {\n this.scheduleOverflowCheck();\n }\n return;\n }\n if (source === 'panel-tools') {\n this.panelToolsTransition.exit();\n this.scheduleOverflowCheck();\n }\n };\n\n /** Re-resolve props assigned by the host after componentWillLoad (Angular ngAfterViewInit). */\n private syncHostPropsIfNeeded() {\n if (shouldResyncBarNavProps(this.resolvedTabs, this.tabs)) {\n this.applyHostProps();\n } else if (this.currentUrl && this.basePath) {\n this.syncValueFromUrl();\n }\n }\n\n /** Poll across animation frames — host props may land without triggering @Watch. */\n private scheduleDeferredHostPropSync() {\n let remaining = BarNav.HOST_PROP_SYNC_BUDGET;\n const tick = () => {\n this.syncHostPropsIfNeeded();\n if (--remaining > 0) {\n requestAnimationFrame(tick);\n }\n };\n queueMicrotask(tick);\n }\n\n private setupOverflowObserver() {\n if (typeof ResizeObserver === 'undefined' || !this.headerEl) return;\n this.resizeObserver?.disconnect();\n this.resizeObserver = new ResizeObserver(() => {\n if (this.chromeOverflowPaused()) return;\n this.scheduleOverflowCheck();\n });\n this.resizeObserver.observe(this.headerEl);\n }\n\n private scheduleOverflowCheck() {\n if (this.chromeOverflowPaused()) return;\n this.overflowCoalescer.schedule();\n }\n\n /** Width available for the tab row or collapsed trigger. */\n private getLeftZoneAvailableWidth(): number {\n if (!this.headerEl) return 0;\n\n const headerStyle = getComputedStyle(this.headerEl);\n const horizontalPadding =\n (parseFloat(headerStyle.paddingLeft) || 0) + (parseFloat(headerStyle.paddingRight) || 0);\n\n return Math.max(0, this.headerEl.clientWidth - horizontalPadding);\n }\n\n private getTabsIntrinsicWidth(): number {\n return this.probeTabListEl?.scrollWidth ?? 0;\n }\n\n private getHeaderGap(): number {\n if (!this.headerEl) return 0;\n const style = getComputedStyle(this.headerEl);\n return parseFloat(style.columnGap || style.gap) || 0;\n }\n\n private getOverflowTriggerReserveWidth(): number {\n if (!this.triggerEl) {\n return 40;\n }\n return this.triggerEl.getBoundingClientRect().width + this.getHeaderGap();\n }\n\n private getProbeTabMetrics(): {\n itemWidths: number[];\n itemGap: number;\n listExtraWidth: number;\n } | null {\n const tabList = this.probeTabListEl;\n if (!tabList) return null;\n\n const children = Array.from(tabList.children) as HTMLElement[];\n const itemWidths = children.map(child => child.getBoundingClientRect().width);\n if (itemWidths.length === 0 || itemWidths.some(width => width === 0)) return null;\n\n const style = getComputedStyle(tabList);\n const itemGap = parseFloat(style.columnGap || style.gap) || 0;\n const childrenWidth = itemWidths.reduce((sum, width) => sum + width, 0);\n const gapWidth = itemGap * Math.max(0, itemWidths.length - 1);\n const listExtraWidth = Math.max(0, tabList.scrollWidth - childrenWidth - gapWidth);\n\n return { itemWidths, itemGap, listExtraWidth };\n }\n\n private setOverflowTabLayout(visibleTabs: BarNavTab[], overflowTabs: BarNavTab[]) {\n const nextCollapsed = overflowTabs.length > 0;\n const visibleChanged =\n this.digestTabsConfig(visibleTabs) !== this.digestTabsConfig(this.visibleTabs);\n const overflowChanged =\n this.digestTabsConfig(overflowTabs) !== this.digestTabsConfig(this.overflowTabs);\n\n if (visibleChanged) {\n this.visibleTabs = visibleTabs;\n }\n if (overflowChanged) {\n this.overflowTabs = overflowTabs;\n }\n if (nextCollapsed !== this.tabsCollapsed) {\n this.tabsCollapsed = nextCollapsed;\n if (!nextCollapsed) {\n this.menuOpen = false;\n this.menuTriggerActive = false;\n }\n }\n this.syncFocusedTabId(\n this.effectiveValue,\n getSelectableTabs(nextCollapsed ? visibleTabs : this.resolvedTabs)\n );\n }\n\n private scheduleIntrinsicWidthRetry() {\n if (this.intrinsicWidthRetryCount >= BarNav.INTRINSIC_WIDTH_RETRY_MAX) return;\n this.intrinsicWidthRetryCount++;\n requestAnimationFrame(() => this.scheduleOverflowCheck());\n }\n\n private forceTabLayoutCommit(fallbackCollapsed: boolean) {\n this.intrinsicWidthRetryCount = 0;\n this.tabLayoutPendingFrames = 0;\n this.setOverflowTabLayout(\n fallbackCollapsed ? [] : this.resolvedTabs,\n fallbackCollapsed ? this.resolvedTabs : []\n );\n this.tabLayoutCommitted = true;\n }\n\n private updateTabsCollapsed() {\n if (!this.headerEl || this.resolvedTabs.length === 0 || this.hideTabsForDetailRoute) {\n if (this.tabsCollapsed) {\n this.setOverflowTabLayout([], []);\n }\n this.intrinsicWidthRetryCount = 0;\n this.tabLayoutPendingFrames = 0;\n this.tabLayoutCommitted = true;\n return;\n }\n\n const metrics = this.getProbeTabMetrics();\n const intrinsicWidth = this.getTabsIntrinsicWidth();\n if (!metrics || intrinsicWidth === 0) {\n this.tabLayoutPendingFrames += 1;\n if (this.intrinsicWidthRetryCount >= BarNav.INTRINSIC_WIDTH_RETRY_MAX) {\n this.forceTabLayoutCommit(false);\n } else if (this.tabLayoutPendingFrames >= BarNav.TAB_LAYOUT_COMMIT_MAX_FRAMES) {\n this.forceTabLayoutCommit(false);\n } else {\n this.scheduleIntrinsicWidthRetry();\n }\n return;\n }\n this.intrinsicWidthRetryCount = 0;\n this.tabLayoutPendingFrames = 0;\n\n const shouldCollapse = tabsOverflowContainer(\n intrinsicWidth,\n this.getLeftZoneAvailableWidth(),\n this.tabsCollapsed,\n BarNav.OVERFLOW_HYSTERESIS_PX\n );\n\n if (!shouldCollapse) {\n this.setOverflowTabLayout(this.resolvedTabs, []);\n this.tabLayoutCommitted = true;\n return;\n }\n\n const visibleCount = visibleTabCountForWidth(\n metrics.itemWidths,\n this.getLeftZoneAvailableWidth(),\n this.getOverflowTriggerReserveWidth(),\n metrics.itemGap,\n metrics.listExtraWidth\n );\n const visibleTabs = trimTrailingDividers(this.resolvedTabs.slice(0, visibleCount));\n const overflowTabs = this.resolvedTabs.slice(visibleTabs.length);\n this.setOverflowTabLayout(visibleTabs, overflowTabs);\n this.tabLayoutCommitted = true;\n }\n\n private syncMenuAnchor() {\n if (!this.menuEl || !this.triggerEl || !this.hasOverflowTabs) return;\n this.menuEl.anchor = this.triggerEl;\n this.syncMenuSections();\n }\n\n private handleMenuSelect(e: Event) {\n const detail = (e as CustomEvent<MenuItemData>).detail;\n if (detail?.value !== undefined) {\n this.selectTab(String(detail.value));\n }\n this.menuOpen = false;\n this.menuInitialFocusVisible = false;\n }\n\n private handleMenuClose() {\n this.menuOpen = false;\n this.menuInitialFocusVisible = false;\n }\n\n private handleMenuAfterClose() {\n if (!this.menuOpen) this.menuTriggerActive = false;\n }\n\n /** Batch tabs/basePath/currentUrl updates so URL derivation never runs with a mixed section. */\n private incomingTabs(): BarNavTab[] {\n return this.tabs ?? [];\n }\n\n private applyHostProps() {\n const nextBasePath = this.basePath;\n const sectionChanged =\n nextBasePath !== '' && this.committedSection !== '' && nextBasePath !== this.committedSection;\n\n if (sectionChanged) {\n this.urlDerivedValue = '';\n this.committedSection = '';\n this.pendingSection = nextBasePath;\n this.resolvedTabs = [];\n this.resetTabOverflowLayout();\n return;\n }\n\n if (this.pendingSection) {\n if (!nextBasePath || nextBasePath !== this.pendingSection) {\n return;\n }\n if (!this.currentUrl.startsWith(this.pendingSection)) {\n return;\n }\n const tabs = this.incomingTabs();\n if (tabs.length === 0) {\n return;\n }\n this.pendingSection = '';\n }\n\n const tabs = this.incomingTabs();\n const tabsConfigChanged =\n this.digestTabsConfig(tabs) !== this.digestTabsConfig(this.resolvedTabs);\n\n this.resolvedTabs = tabs;\n this.syncValueFromUrl();\n this.committedSection = nextBasePath;\n if (tabsConfigChanged) {\n this.resetTabOverflowLayout();\n }\n this.scheduleOverflowCheck();\n }\n\n private syncMenuSections() {\n if (!this.menuEl) return;\n this.menuEl.sections = tabsToOverflowMenuSections(this.overflowTabs, this.effectiveValue);\n }\n\n private syncValueFromUrl() {\n const tabs = this.incomingTabs();\n\n if (!this.currentUrl || !this.basePath) {\n this.urlDerivedValue = '';\n this.hideTabsForDetailRoute = false;\n return;\n }\n\n if (!this.currentUrl.startsWith(this.basePath)) {\n this.urlDerivedValue = '';\n this.hideTabsForDetailRoute = false;\n return;\n }\n\n const { value, hideTabs } = deriveBarNavValueFromUrl(this.currentUrl, this.basePath, tabs);\n this.urlDerivedValue = value;\n this.hideTabsForDetailRoute = hideTabs;\n }\n\n private selectTab(id: string) {\n this.value = id;\n this.dsTabChange.emit(id);\n }\n\n /** Move focus to the selected tab in the visible tab group (after expand). */\n private focusVisibleSelectedTab() {\n const tab = this.visibleTabListEl?.querySelector(\n `[data-tab-id=\"${this.effectiveValue}\"]`\n ) as HTMLElement | null;\n tab?.focus({ preventScroll: true });\n }\n\n private focusVisibleTab(id: string) {\n this.focusedTabId = id;\n const tab = this.visibleTabListEl?.querySelector(`[data-tab-id=\"${id}\"]`) as HTMLElement | null;\n tab?.focus({ preventScroll: true });\n }\n\n private focusLastVisibleTab() {\n const last = [...this.renderedSelectableTabs].reverse().find(tab => !tab.isInactive);\n if (last) this.focusVisibleTab(last.id);\n }\n\n private findEnabledVisibleTab(from: number, step: 1 | -1): number | null {\n const tabs = this.renderedSelectableTabs;\n for (let index = from + step; index >= 0 && index < tabs.length; index += step) {\n if (!tabs[index]?.isInactive) return index;\n }\n return null;\n }\n\n private handleTabKeyDown(e: KeyboardEvent) {\n const tabs = this.renderedSelectableTabs;\n if (!tabs.length) return;\n const targetId = (e.target as HTMLElement | null)?.getAttribute('data-tab-id');\n const focusedId = targetId ?? this.focusedTabId;\n\n if (e.key === 'Enter' || e.key === ' ') {\n const focused = tabs.find(tab => tab.id === focusedId);\n if (!focused || focused.isInactive) return;\n e.preventDefault();\n this.selectTab(focused.id);\n return;\n }\n\n const currentIndex = tabs.findIndex(tab => tab.id === focusedId);\n if (currentIndex < 0) return;\n\n let nextIndex: number | null;\n if (e.key === 'ArrowRight') {\n nextIndex = this.findEnabledVisibleTab(currentIndex, 1);\n if (nextIndex === null && this.hasOverflowTabs) {\n e.preventDefault();\n this.overflowRovingFocused = true;\n void this.triggerEl?.setFocus?.();\n return;\n }\n } else if (e.key === 'ArrowLeft') {\n nextIndex = this.findEnabledVisibleTab(currentIndex, -1);\n } else if (e.key === 'Home') {\n nextIndex = tabs.findIndex(tab => !tab.isInactive);\n } else if (e.key === 'End') {\n const reversedIndex = [...tabs].reverse().findIndex(tab => !tab.isInactive);\n nextIndex = reversedIndex < 0 ? null : tabs.length - 1 - reversedIndex;\n } else {\n return;\n }\n\n if (nextIndex === null || nextIndex < 0 || nextIndex === currentIndex) return;\n e.preventDefault();\n this.focusVisibleTab(tabs[nextIndex].id);\n }\n\n private toggleTabMenu(options?: { focusVisible?: boolean }) {\n if (this.menuOpen) {\n this.menuOpen = false;\n this.menuInitialFocusVisible = false;\n return;\n }\n\n if (this.menuEl && this.triggerEl) {\n this.menuEl.anchor = this.triggerEl;\n this.menuEl.minWidth = '';\n this.syncMenuSections();\n }\n\n this.menuInitialFocusVisible = options?.focusVisible ?? false;\n this.menuTriggerActive = true;\n this.menuOpen = true;\n }\n\n private handleOverflowFocus = () => {\n this.overflowRovingFocused = true;\n };\n\n private handleOverflowBlur = () => {\n this.overflowRovingFocused = false;\n };\n\n private handleTriggerKeyDown(e: KeyboardEvent) {\n if (!this.hasOverflowTabs) return;\n\n if (!this.menuOpen) {\n if (e.key === 'ArrowLeft') {\n e.preventDefault();\n this.overflowRovingFocused = false;\n this.focusLastVisibleTab();\n return;\n }\n\n if (e.key === 'Enter' || e.key === ' ') {\n e.preventDefault();\n this.toggleTabMenu({ focusVisible: true });\n }\n return;\n }\n\n if (e.key === 'Escape') {\n e.preventDefault();\n this.menuOpen = false;\n this.menuInitialFocusVisible = false;\n void this.triggerEl?.setFocus?.();\n }\n }\n\n private renderTabList(tabs: BarNavTab[], probe: boolean) {\n return (\n <div\n role=\"tablist\"\n class=\"bar-nav__tab-list\"\n aria-label={this.heading}\n ref={el => {\n if (probe) {\n const next = (el as HTMLElement) ?? null;\n if (next === this.probeTabListEl) return;\n this.probeTabListEl = next;\n if (next) {\n this.intrinsicWidthRetryCount = 0;\n this.tabLayoutPendingFrames = 0;\n this.scheduleOverflowCheck();\n }\n } else {\n this.visibleTabListEl = (el as HTMLElement) ?? null;\n }\n }}\n onKeyDown={probe ? undefined : (e: KeyboardEvent) => this.handleTabKeyDown(e)}\n onFocusin={\n probe\n ? undefined\n : () => {\n this.overflowRovingFocused = false;\n }\n }\n >\n {tabs.map((tab, index) => {\n if (isTabDivider(tab)) {\n return (\n <div class=\"bar-nav__tab-divider\" key={`divider-${index}`} aria-hidden=\"true\">\n <div class=\"bar-nav__tab-divider-line\" />\n </div>\n );\n }\n\n const isSelected = tab.id === this.effectiveValue;\n return (\n <button\n key={tab.id}\n type=\"button\"\n role=\"tab\"\n data-tab-id={tab.id}\n class={{\n 'bar-nav__tab': true,\n 'bar-nav__tab--selected': isSelected,\n 'ds-control--md': true,\n 'ds-focus-ring-inset': true,\n 'ds-control-inactive': !!tab.isInactive,\n }}\n aria-selected={isSelected ? 'true' : 'false'}\n aria-disabled={tab.isInactive ? 'true' : undefined}\n aria-controls={tab.panelId ?? undefined}\n disabled={tab.isInactive}\n tabIndex={\n !probe && !this.overflowRovingFocused && tab.id === this.focusedTabId ? 0 : -1\n }\n onClick={() => !probe && !tab.isInactive && this.selectTab(tab.id)}\n onFocus={() => {\n if (!probe) this.focusedTabId = tab.id;\n }}\n >\n <span\n class={{\n 'bar-nav__tab-label': true,\n 'ds-control-label-box': true,\n 'ds-control-label-dot': !!tab.dot,\n 'bar-nav__tab-label--dot': !!tab.dot,\n }}\n >\n <ds-text as=\"span\" variant=\"text-body-medium\" emphasis={isSelected} color=\"inherit\">\n {tab.label}\n </ds-text>\n {tab.dot && (\n <ds-badge\n class=\"bar-nav__tab-dot ds-control-label-dot__badge\"\n variant=\"dot\"\n hasRing={false}\n label=\"\"\n aria-hidden=\"true\"\n />\n )}\n </span>\n </button>\n );\n })}\n </div>\n );\n }\n\n render() {\n // Section gating only applies when `basePath` is set (SPA section sync).\n // Tabs without a base path must still render — otherwise a single-tab bar\n // (or any non-routed tabs) silently falls through to an empty header.\n const sectionReady = this.basePath === '' || this.committedSection === this.basePath;\n const hasTabs = sectionReady && this.resolvedTabs.length > 0 && !this.hideTabsForDetailRoute;\n const tabGroupKey = this.basePath || 'no-section';\n const renderedTabs = this.hasOverflowTabs ? this.visibleTabs : this.resolvedTabs;\n\n return (\n <Host>\n <header\n class={{\n 'bar-nav': true,\n 'ds-control--md': true,\n 'ds-chrome-row': true,\n 'ds-chrome-space--md': true,\n 'bar-nav--dashboard': this.navStyle === 'dashboard',\n 'bar-nav--settings': this.navStyle === 'settings',\n 'bar-nav--tabs-collapsed': hasTabs && this.hasOverflowTabs,\n }}\n ref={el => {\n this.headerEl = el as HTMLElement;\n if (el && !this.resizeObserver) {\n this.setupOverflowObserver();\n }\n }}\n >\n {hasTabs && (\n <div class=\"bar-nav__tabs-probe\" aria-hidden=\"true\" inert>\n <div key={`probe-${tabGroupKey}`}>{this.renderTabList(this.resolvedTabs, true)}</div>\n </div>\n )}\n\n {hasTabs && this.tabLayoutCommitted && renderedTabs.length > 0 && (\n <div class=\"bar-nav__left\">\n <div key={`visible-${tabGroupKey}`} class=\"bar-nav__tabs-visible\">\n {this.renderTabList(renderedTabs, false)}\n </div>\n </div>\n )}\n\n {hasTabs && !this.tabLayoutCommitted && (\n <div class=\"bar-nav__tabs-pending\" aria-hidden=\"true\" />\n )}\n\n {!hasTabs && this.heading && (\n <ds-text\n class=\"bar-nav__heading\"\n lineTruncation={1}\n as=\"span\"\n variant=\"text-body-medium\"\n emphasis\n color=\"primary\"\n wrap=\"nowrap\"\n >\n {this.heading}\n </ds-text>\n )}\n\n {hasTabs && this.hasOverflowTabs && (\n <ds-button-unfilled\n variant=\"icon\"\n class={{\n 'bar-nav__overflow-trigger': true,\n }}\n icon=\"ChevronDown\"\n activeFill={false}\n hasBorder={false}\n focusTabIndex={this.overflowRovingFocused ? 0 : -1}\n ref={(el?: HTMLDsButtonUnfilledElement) => {\n this.triggerEl = (el as HTMLElement & { setFocus?: () => Promise<void> }) ?? null;\n }}\n haspopup=\"menu\"\n expanded={this.menuOpen}\n surfaceOpen={this.menuTriggerActive}\n aria-label={this.moreTabsLabel}\n onDsClick={() => this.toggleTabMenu({ focusVisible: false })}\n onFocusin={this.handleOverflowFocus}\n onFocusout={this.handleOverflowBlur}\n onKeyDown={(e: KeyboardEvent) => this.handleTriggerKeyDown(e)}\n />\n )}\n </header>\n\n {hasTabs && this.hasOverflowTabs && (\n <ds-menu\n ref={el => {\n this.menuEl = (el as HTMLDsMenuElement) ?? null;\n }}\n open={this.menuOpen}\n sections={tabsToOverflowMenuSections(this.overflowTabs, this.effectiveValue)}\n side=\"bottom\"\n align=\"end\"\n sideOffset=\"calc(var(--dimension-space-100) + var(--dimension-space-050))\"\n initialFocusVisible={this.menuInitialFocusVisible}\n onDsSelect={(e: Event) => this.handleMenuSelect(e)}\n onDsClose={() => this.handleMenuClose()}\n onDsAfterClose={() => this.handleMenuAfterClose()}\n />\n )}\n </Host>\n );\n }\n}\n",
|
|
310
315
|
"type": "registry:ui"
|
|
311
316
|
},
|
|
312
317
|
{
|
|
@@ -74,7 +74,8 @@
|
|
|
74
74
|
"expanded renders the larger heading, optional description, inset divider, and a full-width caption breadcrumb row above the title. The breadcrumb begins after 32px top padding; the following title and right-side actions share one top alignment. Compact renders 48px chrome with the existing icon Back action; constrained uses compact chrome and moves an auto-collapse primary action into overflow.",
|
|
75
75
|
"The one visible ordered action collapses by default in constrained presentation. Use never for an exceptional action whose hierarchy must remain visible; explicit overflow actions never leave the shared menu.",
|
|
76
76
|
"showDivider=false removes the bottom divider when the surrounding page composition supplies the boundary; showCompactDivider can independently restore or remove that boundary in compact and constrained variants.",
|
|
77
|
-
"placement=shell-bar is reserved for ShellApp panel section navigation. It remains compact, omits description and breadcrumb presentation, and preserves applicable Back, section, primary, and overflow controls."
|
|
77
|
+
"placement=shell-bar is reserved for ShellApp panel section navigation. It remains compact, omits description and breadcrumb presentation, and preserves applicable Back, section, primary, and overflow controls.",
|
|
78
|
+
"Section, action, split-menu, and overflow triggers automatically retain the shared surface-open wash through exit. The expanded header presentation is layout, not surface ownership, and does not itself acquire a pressed wash."
|
|
78
79
|
],
|
|
79
80
|
"responsiveBehavior": [
|
|
80
81
|
"BarTitle does not observe viewport, shell, scroll, or container state. Its owner selects an explicit variant.",
|
|
@@ -111,6 +112,10 @@
|
|
|
111
112
|
{
|
|
112
113
|
"label": "Cross-browser shell-page and header coverage",
|
|
113
114
|
"path": "tests/e2e/bar-title.spec.ts"
|
|
115
|
+
},
|
|
116
|
+
{
|
|
117
|
+
"label": "Surface ownership and trigger state",
|
|
118
|
+
"path": "src/docs/SurfaceOwnership.mdx"
|
|
114
119
|
}
|
|
115
120
|
]
|
|
116
121
|
},
|
|
@@ -552,12 +557,12 @@
|
|
|
552
557
|
"files": [
|
|
553
558
|
{
|
|
554
559
|
"path": "src/wc/components/BarTitle/BarTitle.css",
|
|
555
|
-
"content": "@import '../../utils/control-density.css';\n@import '../../utils/chrome-header.css';\n@import '../../utils/focus-ring.css';\n@import '../../utils/interaction-fill.css';\n\n:host {\n display: block;\n min-width: 0;\n box-sizing: border-box;\n background: var(--_shell-page-header-background, var(--color-background-primary));\n}\n\n:host([placement='shell-bar']) {\n --_shell-page-header-background: var(--color-background-secondary);\n}\n\n:host-context(ds-shell-app:not([gradient-preset='none']))[placement='shell-bar'],\n:host-context(ds-shell-app:not([gradient-preset='none']))[placement='shell-bar'] .bar-title,\n:host-context(ds-shell-app.shell-app--paper-texture)[placement='shell-bar'],\n:host-context(ds-shell-app.shell-app--paper-texture)[placement='shell-bar'] .bar-title {\n background: transparent;\n}\n\n:host([data-shell-page-syncing]) {\n visibility: hidden;\n}\n\n.bar-title {\n position: relative;\n min-width: 0;\n box-sizing: border-box;\n background: var(--_shell-page-header-background, var(--color-background-primary));\n}\n\n.bar-title::after {\n content: '';\n position: absolute;\n inset: auto var(--ds-bar-title-divider-inset, var(--dimension-space-400)) 0;\n height: var(--dimension-stroke-width-012);\n background: var(--color-border-tertiary);\n pointer-events: none;\n}\n\n.bar-title--divider-hidden::after {\n display: none;\n}\n\n.bar-title__inner {\n position: relative;\n display: flex;\n flex-direction: column;\n align-items: stretch;\n gap: var(--dimension-space-100);\n padding: var(--dimension-space-400);\n box-sizing: border-box;\n}\n\n.bar-title__row {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: var(--dimension-space-100);\n min-height: var(--dimension-size-400);\n min-width: 0;\n}\n\n:host(.bar-title-host--expanded) .bar-title__row {\n gap: var(--dimension-space-400);\n}\n\n.bar-title__leading {\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n gap: var(--dimension-space-100);\n min-width: 0;\n}\n\n.bar-title__title-row {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-100);\n min-width: 0;\n min-height: var(--dimension-size-400);\n}\n\n.bar-title__identity {\n display: flex;\n flex: 0 1 auto;\n align-items: center;\n gap: var(--dimension-space-050);\n min-width: 0;\n}\n\n.bar-title__back,\n.bar-title__section-chevron {\n flex: 0 0 auto;\n}\n\n.bar-title__breadcrumb {\n display: block;\n width: 100%;\n min-width: 0;\n}\n\n.bar-title__heading {\n display: block;\n width: auto;\n min-width: 0;\n user-select: none;\n}\n\n.bar-title__divider {\n width: var(--dimension-stroke-width-012);\n height: var(--dimension-size-250);\n margin-inline-start: var(--dimension-space-100);\n flex: 0 0 var(--dimension-stroke-width-012);\n background: var(--color-border-tertiary);\n}\n\n.bar-title__section-selector {\n display: flex;\n flex: 0 1 auto;\n min-width: 0;\n max-width: var(--dimension-panel-width-sm);\n}\n\n.bar-title__section-trigger {\n --ds-control-radius: var(--dimension-radius-half);\n\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: var(--ds-control-gap);\n height: var(--ds-control-height);\n min-width: 0;\n max-width: 100%;\n padding-inline: var(--ds-control-padding-inline);\n border: none;\n border-radius: var(--ds-control-radius);\n box-sizing: border-box;\n background: transparent;\n color: var(--color-foreground-secondary);\n cursor: default;\n font-family: inherit;\n user-select: none;\n}\n\n.bar-title__section-
|
|
560
|
+
"content": "@import '../../utils/control-density.css';\n@import '../../utils/chrome-header.css';\n@import '../../utils/focus-ring.css';\n@import '../../utils/interaction-fill.css';\n\n:host {\n display: block;\n min-width: 0;\n box-sizing: border-box;\n background: var(--_shell-page-header-background, var(--color-background-primary));\n}\n\n:host([placement='shell-bar']) {\n --_shell-page-header-background: var(--color-background-secondary);\n}\n\n:host-context(ds-shell-app:not([gradient-preset='none']))[placement='shell-bar'],\n:host-context(ds-shell-app:not([gradient-preset='none']))[placement='shell-bar'] .bar-title,\n:host-context(ds-shell-app.shell-app--paper-texture)[placement='shell-bar'],\n:host-context(ds-shell-app.shell-app--paper-texture)[placement='shell-bar'] .bar-title {\n background: transparent;\n}\n\n:host([data-shell-page-syncing]) {\n visibility: hidden;\n}\n\n.bar-title {\n position: relative;\n min-width: 0;\n box-sizing: border-box;\n background: var(--_shell-page-header-background, var(--color-background-primary));\n}\n\n.bar-title::after {\n content: '';\n position: absolute;\n inset: auto var(--ds-bar-title-divider-inset, var(--dimension-space-400)) 0;\n height: var(--dimension-stroke-width-012);\n background: var(--color-border-tertiary);\n pointer-events: none;\n}\n\n.bar-title--divider-hidden::after {\n display: none;\n}\n\n.bar-title__inner {\n position: relative;\n display: flex;\n flex-direction: column;\n align-items: stretch;\n gap: var(--dimension-space-100);\n padding: var(--dimension-space-400);\n box-sizing: border-box;\n}\n\n.bar-title__row {\n display: flex;\n align-items: flex-start;\n justify-content: space-between;\n gap: var(--dimension-space-100);\n min-height: var(--dimension-size-400);\n min-width: 0;\n}\n\n:host(.bar-title-host--expanded) .bar-title__row {\n gap: var(--dimension-space-400);\n}\n\n.bar-title__leading {\n display: flex;\n flex: 1 1 auto;\n flex-direction: column;\n gap: var(--dimension-space-100);\n min-width: 0;\n}\n\n.bar-title__title-row {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-100);\n min-width: 0;\n min-height: var(--dimension-size-400);\n}\n\n.bar-title__identity {\n display: flex;\n flex: 0 1 auto;\n align-items: center;\n gap: var(--dimension-space-050);\n min-width: 0;\n}\n\n.bar-title__back,\n.bar-title__section-chevron {\n flex: 0 0 auto;\n}\n\n.bar-title__breadcrumb {\n display: block;\n width: 100%;\n min-width: 0;\n}\n\n.bar-title__heading {\n display: block;\n width: auto;\n min-width: 0;\n user-select: none;\n}\n\n.bar-title__divider {\n width: var(--dimension-stroke-width-012);\n height: var(--dimension-size-250);\n margin-inline-start: var(--dimension-space-100);\n flex: 0 0 var(--dimension-stroke-width-012);\n background: var(--color-border-tertiary);\n}\n\n.bar-title__section-selector {\n display: flex;\n flex: 0 1 auto;\n min-width: 0;\n max-width: var(--dimension-panel-width-sm);\n}\n\n.bar-title__section-trigger {\n --ds-control-radius: var(--dimension-radius-half);\n\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: var(--ds-control-gap);\n height: var(--ds-control-height);\n min-width: 0;\n max-width: 100%;\n padding-inline: var(--ds-control-padding-inline);\n border: none;\n border-radius: var(--ds-control-radius);\n box-sizing: border-box;\n background: transparent;\n color: var(--color-foreground-secondary);\n cursor: default;\n font-family: inherit;\n user-select: none;\n}\n\n.bar-title__section-label {\n min-width: 0;\n padding-inline: var(--ds-control-label-inset);\n}\n\n.bar-title__actions {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-100);\n flex: 0 0 auto;\n}\n\n.bar-title__description {\n display: block;\n min-width: 0;\n max-width: 100%;\n}\n\n:host(.bar-title-host--compact) .bar-title {\n height: var(--dimension-size-600);\n}\n\n:host(.bar-title-host--compact) .bar-title::after {\n inset-inline: 0;\n}\n\n:host(.bar-title-host--compact) .bar-title__inner {\n flex: 1 1 auto;\n height: 100%;\n min-width: 0;\n padding: 0;\n gap: 0;\n}\n\n:host(.bar-title-host--compact) .bar-title__row {\n height: 100%;\n align-items: center;\n}\n\n:host(.bar-title-host--compact) .bar-title__identity {\n height: var(--dimension-size-400);\n}\n\n:host(.bar-title-host--compact) .bar-title__divider {\n margin-inline-start: 0;\n}\n\n:host(.bar-title-host--compact) .bar-title__heading {\n display: inline-flex;\n flex: 1 1 auto;\n align-items: center;\n height: var(--dimension-size-400);\n padding-inline: calc(var(--ds-control-padding-inline) + var(--ds-control-label-inset));\n box-sizing: border-box;\n}\n",
|
|
556
561
|
"type": "registry:ui"
|
|
557
562
|
},
|
|
558
563
|
{
|
|
559
564
|
"path": "src/wc/components/BarTitle/BarTitle.tsx",
|
|
560
|
-
"content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Prop,\n State,\n Watch,\n} from '@stencil/core';\nimport type { BreadcrumbItem, BreadcrumbSelectDetail } from '../Breadcrumb/breadcrumb-types';\nimport type { MenuItemData, MenuSection } from '../Menu/menu-types';\nimport {\n isBarTitleDivider,\n type BarTitleActionConfigItem,\n type BarTitleActionItem,\n type BarTitleMenuAction,\n type BarTitlePrimaryAction,\n type BarTitleSection,\n type BarTitleSectionItem,\n type BarTitlePlacement,\n type BarTitleSplitAction,\n type BarTitleVariant,\n} from './bar-title-types';\nimport {\n barTitleActionIdIssues,\n barTitleChoiceSections,\n findBarTitleAction,\n overflowBarTitleActionSections,\n resolveBarTitleActionItems,\n visibleBarTitleActions,\n} from './bar-title-actions';\n\nlet nextBarTitleId = 0;\n\ntype FocusableButton = HTMLElement & {\n setFocus?: (segment?: 'primary' | 'menu') => Promise<void>;\n};\n\n@Component({\n tag: 'ds-bar-title',\n styleUrl: 'BarTitle.css',\n scoped: true,\n})\nexport class BarTitle {\n @Element() el!: HTMLElement;\n\n /** The page's single visible h1. */\n @Prop() heading!: string;\n\n /** Optional supporting copy shown in the expanded presentation. */\n @Prop() description: string = '';\n\n /** Show the leading page-level Back action. */\n @Prop({ attribute: 'show-back' }) showBack: boolean = false;\n\n /** Accessible name for the leading Back action. */\n @Prop() backAriaLabel: string = 'Back';\n\n /** Visible parent-page label used by the expanded breadcrumb when breadcrumbs is empty. */\n @Prop() backLabel: string = 'Back';\n\n /** Optional expanded ancestor path. Compact variants continue to use the Back action. */\n @Prop() breadcrumbs: BreadcrumbItem[] = [];\n\n /** Accessible name for the expanded breadcrumb navigation landmark. */\n @Prop() breadcrumbAriaLabel: string = 'Breadcrumb';\n\n /** Optional page sections exposed through the active-section menu. */\n @Prop() sections: BarTitleSectionItem[] = [];\n\n /** Id of the active page section. */\n @Prop() value: string = '';\n\n /** Accessible name for the page-section menu. */\n @Prop() sectionsAriaLabel: string = 'Change page section';\n\n /** The one highest-emphasis page action. */\n @Prop() primaryAction: BarTitlePrimaryAction | null = null;\n\n /** Secondary page actions shown in the overflow menu. Dividers create groups. */\n @Prop() actions: BarTitleActionItem[] = [];\n\n /**\n * Ordered page-header actions. When supplied, this replaces the legacy\n * primaryAction/actions presentation while preserving the same dsAction event.\n */\n @Prop() actionItems?: BarTitleActionConfigItem[];\n\n /** Accessible name for the page-actions menu. */\n @Prop() actionsAriaLabel: string = 'More page actions';\n\n /** Explicit visual/capacity variant. ShellPage owns automatic selection. */\n @Prop() variant: BarTitleVariant = 'expanded';\n\n /** Page-header placement, or compact application-shell bar placement. */\n @Prop({ reflect: true }) placement: BarTitlePlacement = 'page';\n\n /** Draw the page-title divider beneath the header. */\n @Prop() showDivider: boolean = true;\n\n /** Override divider visibility for compact and constrained variants. */\n @Prop() showCompactDivider?: boolean;\n\n /** Emitted when the leading Back action is activated. */\n @Event() dsBack!: EventEmitter<MouseEvent>;\n\n /** Emitted when an authored expanded breadcrumb item is activated. */\n @Event({ cancelable: true }) dsBreadcrumbSelect!: EventEmitter<BreadcrumbSelectDetail>;\n\n /** Emitted with the newly selected page-section id. */\n @Event() dsSectionChange!: EventEmitter<string>;\n\n /** Emitted with the activated primary or overflow action id. */\n @Event() dsAction!: EventEmitter<string>;\n\n @State() private sectionMenuOpen = false;\n @State() private openActionMenuId = '';\n @State() private sectionMenuInitialFocusVisible = false;\n @State() private actionMenuInitialFocusVisible = false;\n\n private readonly instanceId = nextBarTitleId++;\n private readonly sectionMenuTriggerId = `bar-title-section-trigger-${this.instanceId}`;\n private readonly sectionMenuId = `bar-title-section-menu-${this.instanceId}`;\n private readonly actionMenuTriggerId = `bar-title-action-trigger-${this.instanceId}`;\n private readonly actionMenuId = `bar-title-action-menu-${this.instanceId}`;\n private sectionTriggerEl: HTMLButtonElement | null = null;\n private actionTriggerEl: FocusableButton | null = null;\n private actionTriggerEls = new Map<string, FocusableButton>();\n\n componentWillLoad() {\n if (this.el.closest('ds-shell-page')) {\n this.el.setAttribute('data-shell-page-syncing', '');\n }\n this.reportActionIdIssues();\n }\n\n @Watch('sections')\n handleSectionsChange() {\n if (!this.hasSectionSelector) this.closeSectionMenu();\n }\n\n @Watch('actions')\n @Watch('primaryAction')\n @Watch('actionItems')\n handleActionsChange() {\n this.reportActionIdIssues();\n if (!this.availableActionMenuIds.has(this.openActionMenuId)) this.closeActionMenu();\n }\n\n private reportActionIdIssues() {\n for (const issue of barTitleActionIdIssues(this.resolvedActionItems)) {\n console.warn(`[ds-bar-title] ${issue}`);\n }\n }\n\n private get compact(): boolean {\n return this.effectiveVariant !== 'expanded';\n }\n\n private get effectiveVariant(): BarTitleVariant {\n return this.placement === 'shell-bar' ? 'compact' : this.variant;\n }\n\n private get dividerVisible(): boolean {\n return this.compact ? (this.showCompactDivider ?? this.showDivider) : this.showDivider;\n }\n\n private get expandedBreadcrumbItems(): BreadcrumbItem[] {\n if (this.breadcrumbs.length > 0) return this.breadcrumbs;\n if (!this.showBack) return [];\n return [\n {\n id: 'back',\n label: this.backLabel,\n ariaLabel: this.backAriaLabel,\n },\n ];\n }\n\n /** Tooltip copy is fixed and generic; backAriaLabel/actionsAriaLabel stay page-specific. */\n private get backTooltipLabel(): string {\n return 'Go back';\n }\n\n private get primaryCollapsed(): boolean {\n return (\n this.actionItems === undefined &&\n this.effectiveVariant === 'constrained' &&\n this.primaryAction !== null &&\n (this.primaryAction.collapse ?? 'auto') === 'auto'\n );\n }\n\n private get selectableSections(): BarTitleSection[] {\n return this.sections.filter((item): item is BarTitleSection => !isBarTitleDivider(item));\n }\n\n private get hasSectionSelector(): boolean {\n return this.selectableSections.length > 1;\n }\n\n private get effectiveValue(): string {\n return this.selectableSections.some(section => section.id === this.value)\n ? this.value\n : (this.selectableSections[0]?.id ?? '');\n }\n\n private get selectedSectionLabel(): string {\n return this.selectableSections.find(section => section.id === this.effectiveValue)?.label ?? '';\n }\n\n private get sectionTriggerAriaLabel(): string {\n return `${this.sectionsAriaLabel}. Current section: ${this.selectedSectionLabel}`;\n }\n\n private groupsFromSections(): MenuSection[] {\n const groups: MenuSection[] = [];\n let items: MenuItemData[] = [];\n const commit = () => {\n if (items.length > 0) groups.push({ items });\n items = [];\n };\n\n for (const item of this.sections) {\n if (isBarTitleDivider(item)) {\n commit();\n } else {\n items.push({\n label: item.label,\n value: item.id,\n isSelected: item.id === this.effectiveValue,\n isInactive: item.isInactive,\n });\n }\n }\n commit();\n return groups;\n }\n\n private get sectionMenuSections(): MenuSection[] {\n return this.groupsFromSections();\n }\n\n private get resolvedActionItems(): BarTitleActionConfigItem[] {\n return resolveBarTitleActionItems(this.actionItems, this.primaryAction, this.actions);\n }\n\n private get visibleActions(): BarTitleActionConfigItem[] {\n return visibleBarTitleActions(this.resolvedActionItems, this.effectiveVariant);\n }\n\n private get actionMenuSections(): MenuSection[] {\n return overflowBarTitleActionSections(this.resolvedActionItems, this.effectiveVariant);\n }\n\n private get showActionMenuTrigger(): boolean {\n return this.actionMenuSections.length > 0;\n }\n\n private get availableActionMenuIds(): Set<string> {\n const ids = new Set<string>();\n if (this.showActionMenuTrigger) ids.add('__overflow');\n for (const item of this.visibleActions) {\n if (!isBarTitleDivider(item) && (item.type === 'menu' || item.type === 'split')) {\n ids.add(item.id);\n }\n }\n return ids;\n }\n\n private actionMenuDomId(id: string): string {\n const index = this.resolvedActionItems.findIndex(\n item => !isBarTitleDivider(item) && item.id === id\n );\n return `bar-title-action-menu-${this.instanceId}-${index}`;\n }\n\n private actionMenuAnchor(id: string): HTMLElement | undefined {\n if (id === '__overflow') return this.actionTriggerEl ?? undefined;\n const trigger = this.actionTriggerEls.get(id);\n if (!trigger) return undefined;\n const splitMenu = trigger.querySelector<HTMLElement>('.ds-button-split__menu');\n return splitMenu ?? trigger;\n }\n\n private menuSectionsForAction(action: BarTitleMenuAction | BarTitleSplitAction): MenuSection[] {\n return barTitleChoiceSections(action.choices);\n }\n\n private toggleSectionMenu = (event: MouseEvent) => {\n this.sectionMenuInitialFocusVisible = event.detail === 0;\n this.openActionMenuId = '';\n this.sectionMenuOpen = !this.sectionMenuOpen;\n };\n\n private closeSectionMenu = () => {\n this.sectionMenuOpen = false;\n };\n\n private handleSectionSelect = (event: CustomEvent<MenuItemData>) => {\n const id = String(event.detail?.value ?? '');\n const selected = this.selectableSections.find(section => section.id === id);\n if (!selected || selected.isInactive) return;\n this.closeSectionMenu();\n this.dsSectionChange.emit(id);\n requestAnimationFrame(() => this.sectionTriggerEl?.focus());\n };\n\n private toggleActionMenu = (id: string, event: MouseEvent) => {\n this.actionMenuInitialFocusVisible = event.detail === 0;\n this.sectionMenuOpen = false;\n this.openActionMenuId = this.openActionMenuId === id ? '' : id;\n };\n\n private closeActionMenu = () => {\n this.openActionMenuId = '';\n };\n\n private handleActionSelect = (event: CustomEvent<MenuItemData>) => {\n const id = String(event.detail?.value ?? '');\n const action = findBarTitleAction(this.resolvedActionItems, id);\n if (!action || action.isInactive || ('isLoading' in action && action.isLoading)) return;\n const menuId = this.openActionMenuId;\n this.closeActionMenu();\n this.dsAction.emit(id);\n requestAnimationFrame(() => {\n if (menuId === '__overflow') {\n void this.actionTriggerEl?.setFocus?.();\n return;\n }\n const trigger = this.actionTriggerEls.get(menuId);\n if ('setFocus' in (trigger ?? {})) {\n const split = this.resolvedActionItems.some(\n item => !isBarTitleDivider(item) && item.id === menuId && item.type === 'split'\n );\n void trigger?.setFocus?.(split ? 'menu' : undefined);\n }\n });\n };\n\n private renderSectionSelector() {\n if (!this.hasSectionSelector) return null;\n return [\n <div class=\"bar-title__divider\" aria-hidden=\"true\" />,\n <div class=\"bar-title__section-selector\">\n <button\n ref={el => {\n this.sectionTriggerEl = el ?? null;\n }}\n id={this.sectionMenuTriggerId}\n class={{\n 'bar-title__section-trigger': true,\n 'bar-title__section-trigger--expanded': this.sectionMenuOpen,\n 'ds-control--md': true,\n 'ds-focus-ring-inset': true,\n 'ds-interaction-fill': true,\n }}\n type=\"button\"\n aria-haspopup=\"menu\"\n aria-controls={this.sectionMenuId}\n aria-expanded={String(this.sectionMenuOpen)}\n aria-label={this.sectionTriggerAriaLabel}\n onClick={this.toggleSectionMenu}\n >\n <ds-text\n class=\"bar-title__section-label ds-interaction-fill__content\"\n as=\"span\"\n variant=\"text-body-medium\"\n emphasis\n color=\"primary\"\n lineTruncation={1}\n >\n {this.selectedSectionLabel}\n </ds-text>\n <ds-icon\n class=\"bar-title__section-chevron ds-interaction-fill__content\"\n name=\"ChevronUpDown\"\n size=\"md\"\n color=\"inherit\"\n aria-hidden=\"true\"\n />\n </button>\n </div>,\n ];\n }\n\n private renderVisibleAction(action: BarTitleActionConfigItem) {\n if (isBarTitleDivider(action) || action.type === 'overflow') return null;\n const appearance = action.appearance ?? (action.type === 'split' ? 'filled' : 'unfilled');\n const variant: 'icon' | 'icon-label' | 'label' =\n action.type === 'icon' ? 'icon' : action.icon ? 'icon-label' : 'label';\n const menu = action.type === 'menu';\n const menuId = menu ? this.actionMenuDomId(action.id) : undefined;\n\n if (action.type === 'split') {\n const splitMenuId = this.actionMenuDomId(action.id);\n const splitProps = {\n key: action.id,\n ref: (el: FocusableButton | undefined) => {\n if (el) this.actionTriggerEls.set(action.id, el);\n else this.actionTriggerEls.delete(action.id);\n },\n class: 'bar-title__action bar-title__split-action',\n split: true,\n variant,\n label: action.label,\n icon: action.icon ?? '',\n menuAriaLabel: action.menuAriaLabel,\n controls: splitMenuId,\n expanded: this.openActionMenuId === action.id,\n size: 'md' as const,\n type: action.buttonType ?? 'button',\n isInactive: action.isInactive,\n isLoading: action.isLoading,\n onDsClick: () => this.dsAction.emit(action.id),\n onDsMenuClick: (event: CustomEvent<MouseEvent>) =>\n this.toggleActionMenu(action.id, event.detail),\n };\n return appearance === 'filled' ? (\n <ds-button-filled\n {...splitProps}\n intent={action.intent ?? 'brand'}\n contrast={action.contrast ?? 'bold'}\n />\n ) : (\n <ds-button-unfilled {...splitProps} />\n );\n }\n\n const buttonProps = {\n key: action.id,\n ref: (el: FocusableButton | undefined) => {\n if (el) this.actionTriggerEls.set(action.id, el);\n else this.actionTriggerEls.delete(action.id);\n },\n class: `bar-title__action bar-title__action--${action.type}${\n this.actionItems === undefined && action.id === this.primaryAction?.id\n ? ' bar-title__primary-action'\n : ''\n }`,\n variant,\n icon: action.icon ?? '',\n label: action.label,\n ariaLabel: action.ariaLabel ?? (action.type === 'icon' ? action.label : undefined),\n size: 'md' as const,\n type: action.buttonType ?? 'button',\n isInactive: action.isInactive,\n isLoading: action.isLoading,\n controls: menuId,\n expanded: menu ? this.openActionMenuId === action.id : undefined,\n hasMenu: menu,\n onDsClick: (event: CustomEvent<MouseEvent>) => {\n if (menu) this.toggleActionMenu(action.id, event.detail);\n else this.dsAction.emit(action.id);\n },\n };\n\n const button =\n appearance === 'filled' ? (\n <ds-button-filled\n {...buttonProps}\n intent={action.intent ?? 'brand'}\n contrast={action.contrast ?? 'bold'}\n />\n ) : (\n <ds-button-unfilled {...buttonProps} />\n );\n\n return action.type === 'icon' ? (\n <ds-tooltip key={action.id} label={action.label} side=\"bottom\" size=\"sm\">\n {button}\n </ds-tooltip>\n ) : (\n button\n );\n }\n\n private renderActions() {\n if (this.visibleActions.length === 0 && !this.showActionMenuTrigger) return null;\n\n return (\n <div class=\"bar-title__actions\">\n {this.visibleActions.map(action => this.renderVisibleAction(action))}\n {this.showActionMenuTrigger ? (\n <ds-tooltip label=\"Page options\" side=\"bottom\" size=\"sm\">\n <ds-button-unfilled\n ref={el => {\n this.actionTriggerEl = el ?? null;\n }}\n id={this.actionMenuTriggerId}\n class=\"bar-title__more-actions\"\n variant=\"icon\"\n icon=\"Ellipses\"\n aria-label={this.actionsAriaLabel}\n size=\"md\"\n activeFill={!this.compact}\n hasBorder={this.visibleActions.length > 0}\n haspopup=\"menu\"\n controls={this.actionMenuId}\n expanded={this.openActionMenuId === '__overflow'}\n onDsClick={(event: CustomEvent<MouseEvent>) =>\n this.toggleActionMenu('__overflow', event.detail)\n }\n />\n </ds-tooltip>\n ) : null}\n </div>\n );\n }\n\n private renderActionMenus() {\n return this.visibleActions.flatMap(action => {\n if (isBarTitleDivider(action) || (action.type !== 'menu' && action.type !== 'split')) {\n return [];\n }\n const menuLabel = action.menuAriaLabel ?? action.ariaLabel ?? action.label;\n return [\n <ds-menu\n key={action.id}\n id={this.actionMenuDomId(action.id)}\n class=\"bar-title__action-menu\"\n anchor={this.actionMenuAnchor(action.id)}\n align=\"end\"\n menuLabel={menuLabel}\n open={this.openActionMenuId === action.id}\n initialFocusVisible={this.actionMenuInitialFocusVisible}\n sections={this.menuSectionsForAction(action)}\n onDsClose={this.closeActionMenu}\n onDsSelect={this.handleActionSelect}\n />,\n ];\n });\n }\n\n private handleBreadcrumbSelect = (event: CustomEvent<BreadcrumbSelectDetail>) => {\n if (this.breadcrumbs.length === 0) {\n this.dsBack.emit(event.detail.originalEvent);\n return;\n }\n const forwarded = this.dsBreadcrumbSelect.emit(event.detail);\n if (forwarded.defaultPrevented) event.preventDefault();\n };\n\n private renderBreadcrumb() {\n const items = this.expandedBreadcrumbItems;\n if (items.length === 0) return null;\n return (\n <ds-breadcrumb\n class=\"bar-title__breadcrumb\"\n items={items}\n ariaLabel={this.breadcrumbAriaLabel}\n onDsSelect={this.handleBreadcrumbSelect}\n />\n );\n }\n\n private renderBack() {\n if (!this.showBack) return null;\n return (\n <ds-tooltip label={this.backTooltipLabel} side=\"bottom\" size=\"sm\">\n <ds-button-unfilled\n class=\"bar-title__back\"\n variant=\"icon\"\n icon=\"ChevronLeft\"\n aria-label={this.backAriaLabel}\n size=\"md\"\n activeFill={false}\n hasBorder={false}\n onDsClick={(event: CustomEvent<MouseEvent>) => this.dsBack.emit(event.detail)}\n />\n </ds-tooltip>\n );\n }\n\n render() {\n const compact = this.compact;\n return (\n <Host\n class={{\n 'bar-title-host--compact': compact,\n 'bar-title-host--expanded': this.effectiveVariant === 'expanded',\n 'bar-title-host--constrained': this.effectiveVariant === 'constrained',\n 'bar-title-host--has-description': !!this.description,\n 'bar-title-host--has-back': this.showBack,\n 'bar-title-host--has-breadcrumb': !compact && this.expandedBreadcrumbItems.length > 0,\n 'bar-title-host--shell-bar': this.placement === 'shell-bar',\n }}\n >\n <div\n class={{\n 'bar-title': true,\n 'bar-title--divider-hidden': !this.dividerVisible,\n 'ds-chrome-header': compact,\n }}\n >\n <div class=\"bar-title__inner\">\n {!compact ? this.renderBreadcrumb() : null}\n <div class=\"bar-title__row\">\n <div class=\"bar-title__leading\">\n <div class=\"bar-title__title-row\" data-shell-page-header-anchor>\n <div class=\"bar-title__identity\">\n {compact ? this.renderBack() : null}\n <ds-text\n class=\"bar-title__heading ds-control--md\"\n variant={compact ? 'text-title-small' : 'text-title-medium'}\n emphasis\n color=\"primary\"\n as=\"h1\"\n lineTruncation={1}\n >\n {this.heading}\n </ds-text>\n </div>\n {this.renderSectionSelector()}\n </div>\n {this.description && !compact ? (\n <ds-text\n class=\"bar-title__description\"\n variant=\"text-body-small\"\n color=\"secondary\"\n lineTruncation={2}\n wrap=\"wrap\"\n as=\"p\"\n >\n {this.description}\n </ds-text>\n ) : null}\n </div>\n {this.renderActions()}\n </div>\n </div>\n </div>\n\n {this.hasSectionSelector ? (\n <ds-menu\n id={this.sectionMenuId}\n class=\"bar-title__section-menu\"\n anchorId={this.sectionMenuTriggerId}\n menuLabel={this.sectionsAriaLabel}\n open={this.sectionMenuOpen}\n initialFocusVisible={this.sectionMenuInitialFocusVisible}\n sections={this.sectionMenuSections}\n onDsClose={this.closeSectionMenu}\n onDsSelect={this.handleSectionSelect}\n />\n ) : null}\n\n {this.renderActionMenus()}\n\n {this.showActionMenuTrigger ? (\n <ds-menu\n id={this.actionMenuId}\n class=\"bar-title__action-menu\"\n anchorId={this.actionMenuTriggerId}\n align=\"end\"\n menuLabel={this.actionsAriaLabel}\n open={this.openActionMenuId === '__overflow'}\n initialFocusVisible={this.actionMenuInitialFocusVisible}\n sections={this.actionMenuSections}\n onDsClose={this.closeActionMenu}\n onDsSelect={this.handleActionSelect}\n />\n ) : null}\n </Host>\n );\n }\n}\n",
|
|
565
|
+
"content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Prop,\n State,\n Watch,\n} from '@stencil/core';\nimport type { BreadcrumbItem, BreadcrumbSelectDetail } from '../Breadcrumb/breadcrumb-types';\nimport type { MenuItemData, MenuSection } from '../Menu/menu-types';\nimport {\n isBarTitleDivider,\n type BarTitleActionConfigItem,\n type BarTitleActionItem,\n type BarTitleMenuAction,\n type BarTitlePrimaryAction,\n type BarTitleSection,\n type BarTitleSectionItem,\n type BarTitlePlacement,\n type BarTitleSplitAction,\n type BarTitleVariant,\n} from './bar-title-types';\nimport {\n barTitleActionIdIssues,\n barTitleChoiceSections,\n findBarTitleAction,\n overflowBarTitleActionSections,\n resolveBarTitleActionItems,\n visibleBarTitleActions,\n} from './bar-title-actions';\n\nlet nextBarTitleId = 0;\n\ntype FocusableButton = HTMLElement & {\n setFocus?: (segment?: 'primary' | 'menu') => Promise<void>;\n};\n\n@Component({\n tag: 'ds-bar-title',\n styleUrl: 'BarTitle.css',\n scoped: true,\n})\nexport class BarTitle {\n @Element() el!: HTMLElement;\n\n /** The page's single visible h1. */\n @Prop() heading!: string;\n\n /** Optional supporting copy shown in the expanded presentation. */\n @Prop() description: string = '';\n\n /** Show the leading page-level Back action. */\n @Prop({ attribute: 'show-back' }) showBack: boolean = false;\n\n /** Accessible name for the leading Back action. */\n @Prop() backAriaLabel: string = 'Back';\n\n /** Visible parent-page label used by the expanded breadcrumb when breadcrumbs is empty. */\n @Prop() backLabel: string = 'Back';\n\n /** Optional expanded ancestor path. Compact variants continue to use the Back action. */\n @Prop() breadcrumbs: BreadcrumbItem[] = [];\n\n /** Accessible name for the expanded breadcrumb navigation landmark. */\n @Prop() breadcrumbAriaLabel: string = 'Breadcrumb';\n\n /** Optional page sections exposed through the active-section menu. */\n @Prop() sections: BarTitleSectionItem[] = [];\n\n /** Id of the active page section. */\n @Prop() value: string = '';\n\n /** Accessible name for the page-section menu. */\n @Prop() sectionsAriaLabel: string = 'Change page section';\n\n /** The one highest-emphasis page action. */\n @Prop() primaryAction: BarTitlePrimaryAction | null = null;\n\n /** Secondary page actions shown in the overflow menu. Dividers create groups. */\n @Prop() actions: BarTitleActionItem[] = [];\n\n /**\n * Ordered page-header actions. When supplied, this replaces the legacy\n * primaryAction/actions presentation while preserving the same dsAction event.\n */\n @Prop() actionItems?: BarTitleActionConfigItem[];\n\n /** Accessible name for the page-actions menu. */\n @Prop() actionsAriaLabel: string = 'More page actions';\n\n /** Explicit visual/capacity variant. ShellPage owns automatic selection. */\n @Prop() variant: BarTitleVariant = 'expanded';\n\n /** Page-header placement, or compact application-shell bar placement. */\n @Prop({ reflect: true }) placement: BarTitlePlacement = 'page';\n\n /** Draw the page-title divider beneath the header. */\n @Prop() showDivider: boolean = true;\n\n /** Override divider visibility for compact and constrained variants. */\n @Prop() showCompactDivider?: boolean;\n\n /** Emitted when the leading Back action is activated. */\n @Event() dsBack!: EventEmitter<MouseEvent>;\n\n /** Emitted when an authored expanded breadcrumb item is activated. */\n @Event({ cancelable: true }) dsBreadcrumbSelect!: EventEmitter<BreadcrumbSelectDetail>;\n\n /** Emitted with the newly selected page-section id. */\n @Event() dsSectionChange!: EventEmitter<string>;\n\n /** Emitted with the activated primary or overflow action id. */\n @Event() dsAction!: EventEmitter<string>;\n\n @State() private sectionMenuOpen = false;\n @State() private openActionMenuId = '';\n @State() private surfaceActionMenuId = '';\n @State() private sectionSurfaceOpen = false;\n @State() private sectionMenuInitialFocusVisible = false;\n @State() private actionMenuInitialFocusVisible = false;\n\n private readonly instanceId = nextBarTitleId++;\n private readonly sectionMenuTriggerId = `bar-title-section-trigger-${this.instanceId}`;\n private readonly sectionMenuId = `bar-title-section-menu-${this.instanceId}`;\n private readonly actionMenuTriggerId = `bar-title-action-trigger-${this.instanceId}`;\n private readonly actionMenuId = `bar-title-action-menu-${this.instanceId}`;\n private sectionTriggerEl: HTMLButtonElement | null = null;\n private actionTriggerEl: FocusableButton | null = null;\n private actionTriggerEls = new Map<string, FocusableButton>();\n\n componentWillLoad() {\n if (this.el.closest('ds-shell-page')) {\n this.el.setAttribute('data-shell-page-syncing', '');\n }\n this.reportActionIdIssues();\n }\n\n @Watch('sections')\n handleSectionsChange() {\n if (!this.hasSectionSelector) {\n this.closeSectionMenu();\n this.sectionSurfaceOpen = false;\n }\n }\n\n @Watch('actions')\n @Watch('primaryAction')\n @Watch('actionItems')\n handleActionsChange() {\n this.reportActionIdIssues();\n if (!this.availableActionMenuIds.has(this.openActionMenuId)) this.closeActionMenu();\n if (!this.availableActionMenuIds.has(this.surfaceActionMenuId)) this.surfaceActionMenuId = '';\n }\n\n private reportActionIdIssues() {\n for (const issue of barTitleActionIdIssues(this.resolvedActionItems)) {\n console.warn(`[ds-bar-title] ${issue}`);\n }\n }\n\n private get compact(): boolean {\n return this.effectiveVariant !== 'expanded';\n }\n\n private get effectiveVariant(): BarTitleVariant {\n return this.placement === 'shell-bar' ? 'compact' : this.variant;\n }\n\n private get dividerVisible(): boolean {\n return this.compact ? (this.showCompactDivider ?? this.showDivider) : this.showDivider;\n }\n\n private get expandedBreadcrumbItems(): BreadcrumbItem[] {\n if (this.breadcrumbs.length > 0) return this.breadcrumbs;\n if (!this.showBack) return [];\n return [\n {\n id: 'back',\n label: this.backLabel,\n ariaLabel: this.backAriaLabel,\n },\n ];\n }\n\n /** Tooltip copy is fixed and generic; backAriaLabel/actionsAriaLabel stay page-specific. */\n private get backTooltipLabel(): string {\n return 'Go back';\n }\n\n private get primaryCollapsed(): boolean {\n return (\n this.actionItems === undefined &&\n this.effectiveVariant === 'constrained' &&\n this.primaryAction !== null &&\n (this.primaryAction.collapse ?? 'auto') === 'auto'\n );\n }\n\n private get selectableSections(): BarTitleSection[] {\n return this.sections.filter((item): item is BarTitleSection => !isBarTitleDivider(item));\n }\n\n private get hasSectionSelector(): boolean {\n return this.selectableSections.length > 1;\n }\n\n private get effectiveValue(): string {\n return this.selectableSections.some(section => section.id === this.value)\n ? this.value\n : (this.selectableSections[0]?.id ?? '');\n }\n\n private get selectedSectionLabel(): string {\n return this.selectableSections.find(section => section.id === this.effectiveValue)?.label ?? '';\n }\n\n private get sectionTriggerAriaLabel(): string {\n return `${this.sectionsAriaLabel}. Current section: ${this.selectedSectionLabel}`;\n }\n\n private groupsFromSections(): MenuSection[] {\n const groups: MenuSection[] = [];\n let items: MenuItemData[] = [];\n const commit = () => {\n if (items.length > 0) groups.push({ items });\n items = [];\n };\n\n for (const item of this.sections) {\n if (isBarTitleDivider(item)) {\n commit();\n } else {\n items.push({\n label: item.label,\n value: item.id,\n isSelected: item.id === this.effectiveValue,\n isInactive: item.isInactive,\n });\n }\n }\n commit();\n return groups;\n }\n\n private get sectionMenuSections(): MenuSection[] {\n return this.groupsFromSections();\n }\n\n private get resolvedActionItems(): BarTitleActionConfigItem[] {\n return resolveBarTitleActionItems(this.actionItems, this.primaryAction, this.actions);\n }\n\n private get visibleActions(): BarTitleActionConfigItem[] {\n return visibleBarTitleActions(this.resolvedActionItems, this.effectiveVariant);\n }\n\n private get actionMenuSections(): MenuSection[] {\n return overflowBarTitleActionSections(this.resolvedActionItems, this.effectiveVariant);\n }\n\n private get showActionMenuTrigger(): boolean {\n return this.actionMenuSections.length > 0;\n }\n\n private get availableActionMenuIds(): Set<string> {\n const ids = new Set<string>();\n if (this.showActionMenuTrigger) ids.add('__overflow');\n for (const item of this.visibleActions) {\n if (!isBarTitleDivider(item) && (item.type === 'menu' || item.type === 'split')) {\n ids.add(item.id);\n }\n }\n return ids;\n }\n\n private actionMenuDomId(id: string): string {\n const index = this.resolvedActionItems.findIndex(\n item => !isBarTitleDivider(item) && item.id === id\n );\n return `bar-title-action-menu-${this.instanceId}-${index}`;\n }\n\n private actionMenuAnchor(id: string): HTMLElement | undefined {\n if (id === '__overflow') return this.actionTriggerEl ?? undefined;\n const trigger = this.actionTriggerEls.get(id);\n if (!trigger) return undefined;\n const splitMenu = trigger.querySelector<HTMLElement>('.ds-button-split__menu');\n return splitMenu ?? trigger;\n }\n\n private menuSectionsForAction(action: BarTitleMenuAction | BarTitleSplitAction): MenuSection[] {\n return barTitleChoiceSections(action.choices);\n }\n\n private toggleSectionMenu = (event: MouseEvent) => {\n this.sectionMenuInitialFocusVisible = event.detail === 0;\n this.openActionMenuId = '';\n this.sectionMenuOpen = !this.sectionMenuOpen;\n if (this.sectionMenuOpen) this.sectionSurfaceOpen = true;\n };\n\n private closeSectionMenu = () => {\n this.sectionMenuOpen = false;\n };\n\n private handleSectionSelect = (event: CustomEvent<MenuItemData>) => {\n const id = String(event.detail?.value ?? '');\n const selected = this.selectableSections.find(section => section.id === id);\n if (!selected || selected.isInactive) return;\n this.closeSectionMenu();\n this.dsSectionChange.emit(id);\n requestAnimationFrame(() => this.sectionTriggerEl?.focus());\n };\n\n private toggleActionMenu = (id: string, event: MouseEvent) => {\n this.actionMenuInitialFocusVisible = event.detail === 0;\n this.sectionMenuOpen = false;\n this.openActionMenuId = this.openActionMenuId === id ? '' : id;\n if (this.openActionMenuId) this.surfaceActionMenuId = this.openActionMenuId;\n };\n\n private finishActionMenuClose = (event: CustomEvent<void>) => {\n const id = this.surfaceActionMenuId;\n const domId = id === '__overflow' ? this.actionMenuId : this.actionMenuDomId(id);\n if (this.openActionMenuId !== id && (event.target as HTMLElement).id === domId) {\n this.surfaceActionMenuId = '';\n }\n };\n\n private closeActionMenu = () => {\n this.openActionMenuId = '';\n };\n\n private handleActionSelect = (event: CustomEvent<MenuItemData>) => {\n const id = String(event.detail?.value ?? '');\n const action = findBarTitleAction(this.resolvedActionItems, id);\n if (!action || action.isInactive || ('isLoading' in action && action.isLoading)) return;\n const menuId = this.openActionMenuId;\n this.closeActionMenu();\n this.dsAction.emit(id);\n requestAnimationFrame(() => {\n if (menuId === '__overflow') {\n void this.actionTriggerEl?.setFocus?.();\n return;\n }\n const trigger = this.actionTriggerEls.get(menuId);\n if ('setFocus' in (trigger ?? {})) {\n const split = this.resolvedActionItems.some(\n item => !isBarTitleDivider(item) && item.id === menuId && item.type === 'split'\n );\n void trigger?.setFocus?.(split ? 'menu' : undefined);\n }\n });\n };\n\n private renderSectionSelector() {\n if (!this.hasSectionSelector) return null;\n return [\n <div class=\"bar-title__divider\" aria-hidden=\"true\" />,\n <div class=\"bar-title__section-selector\">\n <button\n ref={el => {\n this.sectionTriggerEl = el ?? null;\n }}\n id={this.sectionMenuTriggerId}\n class={{\n 'bar-title__section-trigger': true,\n 'bar-title__section-trigger--expanded': this.sectionMenuOpen,\n 'ds-interaction-fill--surface-open': this.sectionSurfaceOpen,\n 'ds-control--md': true,\n 'ds-focus-ring-inset': true,\n 'ds-interaction-fill': true,\n }}\n type=\"button\"\n aria-haspopup=\"menu\"\n aria-controls={this.sectionMenuId}\n aria-expanded={String(this.sectionMenuOpen)}\n aria-label={this.sectionTriggerAriaLabel}\n onClick={this.toggleSectionMenu}\n >\n <ds-text\n class=\"bar-title__section-label ds-interaction-fill__content\"\n as=\"span\"\n variant=\"text-body-medium\"\n emphasis\n color=\"primary\"\n lineTruncation={1}\n >\n {this.selectedSectionLabel}\n </ds-text>\n <ds-icon\n class=\"bar-title__section-chevron ds-interaction-fill__content\"\n name=\"ChevronUpDown\"\n size=\"md\"\n color=\"inherit\"\n aria-hidden=\"true\"\n />\n </button>\n </div>,\n ];\n }\n\n private renderVisibleAction(action: BarTitleActionConfigItem) {\n if (isBarTitleDivider(action) || action.type === 'overflow') return null;\n const appearance = action.appearance ?? (action.type === 'split' ? 'filled' : 'unfilled');\n const variant: 'icon' | 'icon-label' | 'label' =\n action.type === 'icon' ? 'icon' : action.icon ? 'icon-label' : 'label';\n const menu = action.type === 'menu';\n const menuId = menu ? this.actionMenuDomId(action.id) : undefined;\n\n if (action.type === 'split') {\n const splitMenuId = this.actionMenuDomId(action.id);\n const splitProps = {\n key: action.id,\n ref: (el: FocusableButton | undefined) => {\n if (el) this.actionTriggerEls.set(action.id, el);\n else this.actionTriggerEls.delete(action.id);\n },\n class: 'bar-title__action bar-title__split-action',\n split: true,\n variant,\n label: action.label,\n icon: action.icon ?? '',\n menuAriaLabel: action.menuAriaLabel,\n controls: splitMenuId,\n expanded: this.openActionMenuId === action.id,\n surfaceOpen: this.surfaceActionMenuId === action.id,\n size: 'md' as const,\n type: action.buttonType ?? 'button',\n isInactive: action.isInactive,\n isLoading: action.isLoading,\n onDsClick: () => this.dsAction.emit(action.id),\n onDsMenuClick: (event: CustomEvent<MouseEvent>) =>\n this.toggleActionMenu(action.id, event.detail),\n };\n return appearance === 'filled' ? (\n <ds-button-filled\n {...splitProps}\n intent={action.intent ?? 'brand'}\n contrast={action.contrast ?? 'bold'}\n />\n ) : (\n <ds-button-unfilled {...splitProps} />\n );\n }\n\n const buttonProps = {\n key: action.id,\n ref: (el: FocusableButton | undefined) => {\n if (el) this.actionTriggerEls.set(action.id, el);\n else this.actionTriggerEls.delete(action.id);\n },\n class: `bar-title__action bar-title__action--${action.type}${\n this.actionItems === undefined && action.id === this.primaryAction?.id\n ? ' bar-title__primary-action'\n : ''\n }`,\n variant,\n icon: action.icon ?? '',\n label: action.label,\n ariaLabel: action.ariaLabel ?? (action.type === 'icon' ? action.label : undefined),\n size: 'md' as const,\n type: action.buttonType ?? 'button',\n isInactive: action.isInactive,\n isLoading: action.isLoading,\n controls: menuId,\n expanded: menu ? this.openActionMenuId === action.id : undefined,\n surfaceOpen: menu && this.surfaceActionMenuId === action.id,\n hasMenu: menu,\n onDsClick: (event: CustomEvent<MouseEvent>) => {\n if (menu) this.toggleActionMenu(action.id, event.detail);\n else this.dsAction.emit(action.id);\n },\n };\n\n const button =\n appearance === 'filled' ? (\n <ds-button-filled\n {...buttonProps}\n intent={action.intent ?? 'brand'}\n contrast={action.contrast ?? 'bold'}\n />\n ) : (\n <ds-button-unfilled {...buttonProps} />\n );\n\n return action.type === 'icon' ? (\n <ds-tooltip key={action.id} label={action.label} side=\"bottom\" size=\"sm\">\n {button}\n </ds-tooltip>\n ) : (\n button\n );\n }\n\n private renderActions() {\n if (this.visibleActions.length === 0 && !this.showActionMenuTrigger) return null;\n\n return (\n <div class=\"bar-title__actions\">\n {this.visibleActions.map(action => this.renderVisibleAction(action))}\n {this.showActionMenuTrigger ? (\n <ds-tooltip label=\"Page options\" side=\"bottom\" size=\"sm\">\n <ds-button-unfilled\n ref={el => {\n this.actionTriggerEl = el ?? null;\n }}\n id={this.actionMenuTriggerId}\n class=\"bar-title__more-actions\"\n variant=\"icon\"\n icon=\"Ellipses\"\n aria-label={this.actionsAriaLabel}\n size=\"md\"\n activeFill={!this.compact}\n hasBorder={this.visibleActions.length > 0}\n haspopup=\"menu\"\n controls={this.actionMenuId}\n expanded={this.openActionMenuId === '__overflow'}\n surfaceOpen={this.surfaceActionMenuId === '__overflow'}\n onDsClick={(event: CustomEvent<MouseEvent>) =>\n this.toggleActionMenu('__overflow', event.detail)\n }\n />\n </ds-tooltip>\n ) : null}\n </div>\n );\n }\n\n private renderActionMenus() {\n return this.visibleActions.flatMap(action => {\n if (isBarTitleDivider(action) || (action.type !== 'menu' && action.type !== 'split')) {\n return [];\n }\n const menuLabel = action.menuAriaLabel ?? action.ariaLabel ?? action.label;\n return [\n <ds-menu\n key={action.id}\n id={this.actionMenuDomId(action.id)}\n class=\"bar-title__action-menu\"\n anchor={this.actionMenuAnchor(action.id)}\n align=\"end\"\n menuLabel={menuLabel}\n open={this.openActionMenuId === action.id}\n initialFocusVisible={this.actionMenuInitialFocusVisible}\n sections={this.menuSectionsForAction(action)}\n onDsClose={this.closeActionMenu}\n onDsAfterClose={this.finishActionMenuClose}\n onDsSelect={this.handleActionSelect}\n />,\n ];\n });\n }\n\n private handleBreadcrumbSelect = (event: CustomEvent<BreadcrumbSelectDetail>) => {\n if (this.breadcrumbs.length === 0) {\n this.dsBack.emit(event.detail.originalEvent);\n return;\n }\n const forwarded = this.dsBreadcrumbSelect.emit(event.detail);\n if (forwarded.defaultPrevented) event.preventDefault();\n };\n\n private renderBreadcrumb() {\n const items = this.expandedBreadcrumbItems;\n if (items.length === 0) return null;\n return (\n <ds-breadcrumb\n class=\"bar-title__breadcrumb\"\n items={items}\n ariaLabel={this.breadcrumbAriaLabel}\n onDsSelect={this.handleBreadcrumbSelect}\n />\n );\n }\n\n private renderBack() {\n if (!this.showBack) return null;\n return (\n <ds-tooltip label={this.backTooltipLabel} side=\"bottom\" size=\"sm\">\n <ds-button-unfilled\n class=\"bar-title__back\"\n variant=\"icon\"\n icon=\"ChevronLeft\"\n aria-label={this.backAriaLabel}\n size=\"md\"\n activeFill={false}\n hasBorder={false}\n onDsClick={(event: CustomEvent<MouseEvent>) => this.dsBack.emit(event.detail)}\n />\n </ds-tooltip>\n );\n }\n\n render() {\n const compact = this.compact;\n return (\n <Host\n class={{\n 'bar-title-host--compact': compact,\n 'bar-title-host--expanded': this.effectiveVariant === 'expanded',\n 'bar-title-host--constrained': this.effectiveVariant === 'constrained',\n 'bar-title-host--has-description': !!this.description,\n 'bar-title-host--has-back': this.showBack,\n 'bar-title-host--has-breadcrumb': !compact && this.expandedBreadcrumbItems.length > 0,\n 'bar-title-host--shell-bar': this.placement === 'shell-bar',\n }}\n >\n <div\n class={{\n 'bar-title': true,\n 'bar-title--divider-hidden': !this.dividerVisible,\n 'ds-chrome-header': compact,\n }}\n >\n <div class=\"bar-title__inner\">\n {!compact ? this.renderBreadcrumb() : null}\n <div class=\"bar-title__row\">\n <div class=\"bar-title__leading\">\n <div class=\"bar-title__title-row\" data-shell-page-header-anchor>\n <div class=\"bar-title__identity\">\n {compact ? this.renderBack() : null}\n <ds-text\n class=\"bar-title__heading ds-control--md\"\n variant={compact ? 'text-title-small' : 'text-title-medium'}\n emphasis\n color=\"primary\"\n as=\"h1\"\n lineTruncation={1}\n >\n {this.heading}\n </ds-text>\n </div>\n {this.renderSectionSelector()}\n </div>\n {this.description && !compact ? (\n <ds-text\n class=\"bar-title__description\"\n variant=\"text-body-small\"\n color=\"secondary\"\n lineTruncation={2}\n wrap=\"wrap\"\n as=\"p\"\n >\n {this.description}\n </ds-text>\n ) : null}\n </div>\n {this.renderActions()}\n </div>\n </div>\n </div>\n\n {this.hasSectionSelector ? (\n <ds-menu\n id={this.sectionMenuId}\n class=\"bar-title__section-menu\"\n anchorId={this.sectionMenuTriggerId}\n menuLabel={this.sectionsAriaLabel}\n open={this.sectionMenuOpen}\n initialFocusVisible={this.sectionMenuInitialFocusVisible}\n sections={this.sectionMenuSections}\n onDsClose={this.closeSectionMenu}\n onDsAfterClose={() => {\n if (!this.sectionMenuOpen) this.sectionSurfaceOpen = false;\n }}\n onDsSelect={this.handleSectionSelect}\n />\n ) : null}\n\n {this.renderActionMenus()}\n\n {this.showActionMenuTrigger ? (\n <ds-menu\n id={this.actionMenuId}\n class=\"bar-title__action-menu\"\n anchorId={this.actionMenuTriggerId}\n align=\"end\"\n menuLabel={this.actionsAriaLabel}\n open={this.openActionMenuId === '__overflow'}\n initialFocusVisible={this.actionMenuInitialFocusVisible}\n sections={this.actionMenuSections}\n onDsClose={this.closeActionMenu}\n onDsAfterClose={this.finishActionMenuClose}\n onDsSelect={this.handleActionSelect}\n />\n ) : null}\n </Host>\n );\n }\n}\n",
|
|
561
566
|
"type": "registry:ui"
|
|
562
567
|
},
|
|
563
568
|
{
|
|
@@ -78,10 +78,11 @@
|
|
|
78
78
|
"Physical press scaling is on by default. Disable it only when an owning composite must keep child or background geometry fixed; hover and pressed interaction washes remain available.",
|
|
79
79
|
"Visible labels are emphasized by default. Set labelEmphasis to false when a composed surface deliberately needs the button label to match adjacent regular-weight control labels; this does not change the action hierarchy, foreground, icon, or interaction states.",
|
|
80
80
|
"ButtonFilled has no active, pressed, or selected state; a filled action is a command, not a toggle or a persistent selection.",
|
|
81
|
-
"An open popup is a transient pressed state instead. While
|
|
81
|
+
"An open popup is a transient pressed state instead. While surfaceOpen is true the pressed wash is held for the popup's complete rendered lifecycle and survives hover, so the trigger reads as held open without introducing a selected treatment.",
|
|
82
82
|
"hasMenu supports only the label and icon-label variants. The chevron is what communicates that a menu will open, so an icon-only filled menu button would leave the affordance unstated.",
|
|
83
83
|
"Split mode supports label, icon, and icon-label primary segments. The primary segment emits dsClick and the appended menu segment emits dsMenuClick.",
|
|
84
|
-
"Split mode keeps both segments flush and uses a tertiary on-background separator. Rounded changes the shared outer radius without separating the segments."
|
|
84
|
+
"Split mode keeps both segments flush and uses a tertiary on-background separator. Rounded changes the shared outer radius without separating the segments.",
|
|
85
|
+
"Use surfaceOpen to identify this command as the owner of a visible menu, picker, or panel, even when that surface is distant. Keep the pressed wash through exit motion without adding selected styling or sustained physical scaling. Inline expansion and completed commands do not acquire this treatment."
|
|
85
86
|
],
|
|
86
87
|
"responsiveBehavior": [
|
|
87
88
|
"Use fill width only when the parent layout calls for a full-width action, commonly on narrow form layouts."
|
|
@@ -101,6 +102,10 @@
|
|
|
101
102
|
{
|
|
102
103
|
"label": "Storybook examples",
|
|
103
104
|
"path": "src/wc/components/ButtonFilled/ButtonFilled.stories.ts"
|
|
105
|
+
},
|
|
106
|
+
{
|
|
107
|
+
"label": "Surface ownership and trigger state",
|
|
108
|
+
"path": "src/docs/SurfaceOwnership.mdx"
|
|
104
109
|
}
|
|
105
110
|
]
|
|
106
111
|
},
|
|
@@ -146,7 +151,7 @@
|
|
|
146
151
|
"attribute": "expanded",
|
|
147
152
|
"required": false,
|
|
148
153
|
"mutable": false,
|
|
149
|
-
"description": "Controlled
|
|
154
|
+
"description": "Controlled disclosure state forwarded to aria-expanded.\nUsing expanded alone for surface-open styling is deprecated; set surfaceOpen\nexplicitly to separate the rendered surface lifecycle from disclosure state."
|
|
150
155
|
},
|
|
151
156
|
"hasBorder": {
|
|
152
157
|
"type": "boolean",
|
|
@@ -291,6 +296,14 @@
|
|
|
291
296
|
"mutable": false,
|
|
292
297
|
"description": "Append a separate ChevronDown menu segment while preserving this button's\nvariant, size, intent, contrast, loading, inactive, and width treatment."
|
|
293
298
|
},
|
|
299
|
+
"surfaceOpen": {
|
|
300
|
+
"type": "boolean | undefined",
|
|
301
|
+
"resolvedType": "boolean | undefined",
|
|
302
|
+
"attribute": "surface-open",
|
|
303
|
+
"required": false,
|
|
304
|
+
"mutable": false,
|
|
305
|
+
"description": "The associated menu, picker, or panel is visible, including its exit motion.\nHolds only the pressed wash; does not set ARIA, selection, or press scaling.\nIn split mode this applies only to the menu segment. Explicit false disables\nthe legacy expanded-derived wash; omission preserves that compatibility path."
|
|
306
|
+
},
|
|
294
307
|
"type": {
|
|
295
308
|
"type": "'button' | 'submit' | 'reset'",
|
|
296
309
|
"resolvedType": "\"button\" | \"reset\" | \"submit\"",
|
|
@@ -384,7 +397,7 @@
|
|
|
384
397
|
"attribute": "expanded",
|
|
385
398
|
"required": false,
|
|
386
399
|
"mutable": false,
|
|
387
|
-
"description": "Controlled
|
|
400
|
+
"description": "Controlled disclosure state forwarded to aria-expanded.\nUsing expanded alone for surface-open styling is deprecated; set surfaceOpen\nexplicitly to separate the rendered surface lifecycle from disclosure state."
|
|
388
401
|
},
|
|
389
402
|
"hasBorder": {
|
|
390
403
|
"type": "boolean",
|
|
@@ -529,6 +542,14 @@
|
|
|
529
542
|
"mutable": false,
|
|
530
543
|
"description": "Append a separate ChevronDown menu segment while preserving this button's\nvariant, size, intent, contrast, loading, inactive, and width treatment."
|
|
531
544
|
},
|
|
545
|
+
"surfaceOpen": {
|
|
546
|
+
"type": "boolean | undefined",
|
|
547
|
+
"resolvedType": "boolean | undefined",
|
|
548
|
+
"attribute": "surface-open",
|
|
549
|
+
"required": false,
|
|
550
|
+
"mutable": false,
|
|
551
|
+
"description": "The associated menu, picker, or panel is visible, including its exit motion.\nHolds only the pressed wash; does not set ARIA, selection, or press scaling.\nIn split mode this applies only to the menu segment. Explicit false disables\nthe legacy expanded-derived wash; omission preserves that compatibility path."
|
|
552
|
+
},
|
|
532
553
|
"type": {
|
|
533
554
|
"type": "'button' | 'submit' | 'reset'",
|
|
534
555
|
"resolvedType": "\"button\" | \"reset\" | \"submit\"",
|
|
@@ -633,7 +654,7 @@
|
|
|
633
654
|
},
|
|
634
655
|
{
|
|
635
656
|
"path": "src/wc/components/ButtonFilled/ButtonFilled.tsx",
|
|
636
|
-
"content": "import { Component, Element, Event, EventEmitter, h, Host, Method, Prop } from '@stencil/core';\nimport { controlWidthClass } from '../../utils';\nimport type { ChoiceBackground } from '../../utils/choice-list';\nimport { beginElevatedControlPress } from '../../utils/control-press';\nimport { renderButtonContent } from '../../utils/button-render';\nimport type { ControlInsetDepth } from '../../utils/control-text';\nimport type { ButtonPopup, ButtonSize, ButtonVariant, ButtonWidth } from '../../utils/button-types';\n\nexport type ButtonFilledIntent =\n | 'neutral'\n | 'brand'\n | 'ai'\n | 'negative'\n | 'warning'\n | 'caution'\n | 'positive'\n | 'guide'\n | 'walkthrough';\n\nexport type ButtonFilledContrast = 'bold' | 'strong' | 'medium' | 'faint';\n\nexport type ButtonFilledBackground = ChoiceBackground;\n\nexport type ButtonFilledVariant = ButtonVariant;\nexport type ButtonFilledSize = ButtonSize;\nexport type ButtonFilledWidth = ButtonWidth;\nexport type ButtonFilledPopup = ButtonPopup;\n\n@Component({\n tag: 'ds-button-filled',\n styleUrl: 'ButtonFilled.css',\n scoped: true,\n})\nexport class ButtonFilled {\n @Element() el!: HTMLElement;\n\n /**\n * Content layout. Default is label-only; pass `icon` for icon-only chrome\n * (nav / tool rails) or `icon-label` for leading icon + text.\n */\n @Prop() variant: ButtonFilledVariant = 'label';\n\n /** Control density (height, padding, icon, type). */\n @Prop() size: ButtonFilledSize = 'md';\n\n /** Use reduced outer geometry when nested inside a control of the same size. */\n @Prop() isInset: boolean = false;\n\n /** Single removes 4px overall; double removes 8px overall (xs stays single). */\n @Prop() insetDepth: ControlInsetDepth = 'single';\n\n /** Width fit — hug content (default) or fill the parent. */\n @Prop() width: ButtonFilledWidth = 'hug';\n\n /** Visible text for `label` / `icon-label` variants. */\n @Prop() label: string = '';\n\n /** Whether the visible label uses the emphasized weight. */\n @Prop() labelEmphasis: boolean = true;\n\n /** Icon name passed to <ds-icon> for `icon` / `icon-label` variants. */\n @Prop() icon: string = '';\n\n /** Semantic colour intent. */\n @Prop() intent: ButtonFilledIntent = 'brand';\n\n /**\n * Background fill weight. Foreground uses the paired contrast token:\n * bold → faint, strong → medium, medium → strong, faint → bold.\n */\n @Prop() contrast: ButtonFilledContrast = 'bold';\n\n /** Show a 1px secondary inset border. */\n @Prop() hasBorder: boolean = false;\n\n /**\n * Actual parent surface context for the optional inset border color only.\n * Omit on primary and secondary surfaces.\n */\n @Prop() background: ButtonFilledBackground | undefined;\n\n /** Use the half-radius treatment instead of the default control radius. */\n @Prop() rounded: boolean = false;\n\n /**\n * Scale down during a physical pointer press.\n * Disable when an owning composite requires fixed child or background geometry.\n */\n @Prop() pressScale: boolean = true;\n\n /** Disables interaction. */\n @Prop() isInactive: boolean = false;\n\n /** Shows an inline loader and prevents interaction without applying inactive opacity. */\n @Prop() isLoading: boolean = false;\n\n /** Native button type. */\n @Prop() type: 'button' | 'submit' | 'reset' = 'button';\n\n /** Accessible name override. Required for icon-only buttons. */\n @Prop({ attribute: 'aria-label' }) ariaLabel: string | null = null;\n\n /** ID of the popup this button controls. */\n @Prop() controls: string | undefined;\n\n /**\n * Controlled open state of the popup this button triggers. Holds the pressed\n * wash for the popup's rendered lifecycle. ButtonFilled has no selected state,\n * so an open popup never promotes to an active treatment.\n */\n @Prop() expanded: boolean | undefined;\n\n /** Popup type exposed to assistive technology. */\n @Prop() haspopup: ButtonFilledPopup | undefined;\n\n /**\n * Append a separate ChevronDown menu segment while preserving this button's\n * variant, size, intent, contrast, loading, inactive, and width treatment.\n */\n @Prop() split: boolean = false;\n\n /** Accessible name for the appended menu segment in split mode. */\n @Prop() menuAriaLabel: string = 'More options';\n\n /**\n * This action *has* a menu: implies `aria-haspopup=\"menu\"` and adds the trailing\n * chevron that carries the affordance.\n *\n * Only `label` and `icon-label` are supported. A filled button is never the\n * overflow / more-options control — that role belongs to ButtonUnfilled with an\n * `Ellipses` glyph, which conveys the menu without a chevron.\n *\n * Use `haspopup` directly for non-menu popups.\n */\n @Prop() hasMenu: boolean = false;\n\n @Event() dsClick!: EventEmitter<MouseEvent>;\n @Event() dsMenuClick!: EventEmitter<MouseEvent>;\n\n private buttonEl: HTMLButtonElement | null = null;\n private menuButtonEl: HTMLButtonElement | null = null;\n\n @Method()\n async setFocus(segment: 'primary' | 'menu' = 'primary') {\n (segment === 'menu' ? this.menuButtonEl : this.buttonEl)?.focus();\n }\n\n private handleClick = (event: MouseEvent) => {\n if (this.isInactive || this.isLoading) {\n event.preventDefault();\n event.stopPropagation();\n return;\n }\n this.dsClick.emit(event);\n };\n\n private handleMenuClick = (event: MouseEvent) => {\n if (this.isInactive || this.isLoading) {\n event.preventDefault();\n event.stopPropagation();\n return;\n }\n this.dsMenuClick.emit(event);\n };\n\n private get resolvedHaspopup(): ButtonFilledPopup | undefined {\n return this.haspopup ?? (this.hasMenu ? 'menu' : undefined);\n }\n\n private get accessibleName(): string | undefined {\n if (this.ariaLabel) return this.ariaLabel;\n if (this.isLoading && this.variant === 'label' && this.label) return this.label;\n return undefined;\n }\n\n private get doubleInset(): boolean {\n return this.isInset && this.insetDepth === 'double' && this.size !== 'xs';\n }\n\n private buttonClass(\n variant: ButtonFilledVariant,\n expanded: boolean,\n bordered: boolean\n ): Record<string, boolean> {\n const cls: Record<string, boolean> = {\n 'button-filled': true,\n 'ds-button': true,\n 'ds-focus-ring-inset': true,\n 'ds-control-press-scale': this.pressScale && !this.split,\n 'ds-interaction-fill': !this.isInactive,\n /* Bold is the default filled contrast — on-bold interaction tokens. */\n 'ds-interaction-fill--on-bold': this.contrast === 'bold',\n 'ds-interaction-fill--on-strong': this.contrast === 'strong',\n 'ds-interaction-fill--on-medium': this.contrast === 'medium',\n /* faint → default app interaction tokens (no --on-*). */\n 'button-filled--bordered': bordered,\n 'ds-button--bordered': bordered,\n 'button-filled--expanded': expanded && !this.isInactive,\n 'ds-button--expanded': expanded && !this.isInactive,\n 'ds-control-inactive': this.isInactive,\n 'ds-control--lg': this.size === 'lg',\n 'ds-control--md': this.size === 'md',\n 'ds-control--sm': this.size === 'sm',\n 'ds-control--xs': this.size === 'xs',\n 'ds-control--inset': this.isInset && !this.doubleInset,\n 'ds-control--inset-double': this.doubleInset,\n 'ds-control-frame': true,\n 'button-filled--icon': variant === 'icon',\n 'ds-button--icon': variant === 'icon',\n 'button-filled--label': variant === 'label',\n 'button-filled--icon-label': variant === 'icon-label',\n 'button-filled--rounded': this.rounded,\n 'ds-button--rounded': this.rounded,\n [`button-filled--background-${this.background}`]: this.background !== undefined,\n [`button-filled--intent-${this.intent}`]: true,\n [`button-filled--contrast-${this.contrast}`]: true,\n };\n return cls;\n }\n\n render() {\n const primaryButton = (\n <button\n ref={el => {\n this.buttonEl = el ?? null;\n }}\n type={this.type}\n class={{\n ...this.buttonClass(\n this.variant,\n !this.split && this.expanded === true,\n this.hasBorder && !this.split\n ),\n 'ds-button-split__primary': this.split,\n }}\n disabled={this.isInactive}\n aria-label={this.accessibleName}\n aria-busy={this.isLoading ? 'true' : undefined}\n aria-disabled={this.isLoading ? 'true' : undefined}\n aria-controls={this.split ? undefined : this.controls}\n aria-expanded={\n this.split || this.expanded === undefined ? undefined : String(this.expanded)\n }\n aria-haspopup={this.split ? undefined : this.resolvedHaspopup}\n onPointerDown={event =>\n beginElevatedControlPress(\n event,\n this.pressScale && !this.split && !this.isInactive && !this.isLoading\n )\n }\n onClick={this.handleClick}\n >\n {renderButtonContent({\n namespace: 'button-filled',\n variant: this.variant,\n size: this.size,\n label: this.label,\n labelEmphasis: this.labelEmphasis,\n icon: this.icon,\n hasMenu: !this.split && this.hasMenu,\n isLoading: this.isLoading,\n })}\n </button>\n );\n\n const control = this.split ? (\n <div\n class={{\n 'ds-button-split': true,\n 'ds-button-split--rounded': this.rounded,\n }}\n >\n {primaryButton}\n <span class=\"ds-button-split__divider\" aria-hidden=\"true\" />\n <button\n ref={el => {\n this.menuButtonEl = el ?? null;\n }}\n class={{\n ...this.buttonClass('icon', this.expanded === true, false),\n 'ds-button-split__menu': true,\n }}\n type=\"button\"\n disabled={this.isInactive || this.isLoading}\n aria-label={this.menuAriaLabel}\n aria-controls={this.controls}\n aria-expanded={String(this.expanded === true)}\n aria-haspopup=\"menu\"\n onClick={this.handleMenuClick}\n >\n {renderButtonContent({\n namespace: 'button-filled',\n variant: 'icon',\n size: this.size,\n label: '',\n labelEmphasis: true,\n icon: 'ChevronDown',\n hasMenu: false,\n isLoading: false,\n })}\n </button>\n {this.hasBorder ? <span class=\"ds-button-split__outline\" aria-hidden=\"true\" /> : null}\n </div>\n ) : (\n primaryButton\n );\n\n return (\n <Host\n class={{\n 'button-filled-host': true,\n 'button-filled-host--icon': this.variant === 'icon' && !this.split,\n 'ds-button-host--icon': this.variant === 'icon' && !this.split,\n 'ds-button-split-host': this.split,\n 'ds-button-split-host--primary-icon': this.split && this.variant === 'icon',\n [`button-filled-host--contrast-${this.contrast}`]: this.split,\n [`button-filled-host--background-${this.background}`]:\n this.split && this.background !== undefined,\n 'ds-control--lg': this.size === 'lg',\n 'ds-control--md': this.size === 'md',\n 'ds-control--sm': this.size === 'sm',\n 'ds-control--xs': this.size === 'xs',\n 'ds-control--inset': this.isInset && !this.doubleInset,\n 'ds-control--inset-double': this.doubleInset,\n ...controlWidthClass(this.width),\n }}\n tabIndex={-1}\n >\n {control}\n </Host>\n );\n }\n}\n",
|
|
657
|
+
"content": "import { Component, Element, Event, EventEmitter, h, Host, Method, Prop } from '@stencil/core';\nimport { controlWidthClass } from '../../utils';\nimport type { ChoiceBackground } from '../../utils/choice-list';\nimport { beginElevatedControlPress } from '../../utils/control-press';\nimport { renderButtonContent } from '../../utils/button-render';\nimport type { ControlInsetDepth } from '../../utils/control-text';\nimport type { ButtonPopup, ButtonSize, ButtonVariant, ButtonWidth } from '../../utils/button-types';\n\nexport type ButtonFilledIntent =\n | 'neutral'\n | 'brand'\n | 'ai'\n | 'negative'\n | 'warning'\n | 'caution'\n | 'positive'\n | 'guide'\n | 'walkthrough';\n\nexport type ButtonFilledContrast = 'bold' | 'strong' | 'medium' | 'faint';\n\nexport type ButtonFilledBackground = ChoiceBackground;\n\nexport type ButtonFilledVariant = ButtonVariant;\nexport type ButtonFilledSize = ButtonSize;\nexport type ButtonFilledWidth = ButtonWidth;\nexport type ButtonFilledPopup = ButtonPopup;\n\n@Component({\n tag: 'ds-button-filled',\n styleUrl: 'ButtonFilled.css',\n scoped: true,\n})\nexport class ButtonFilled {\n @Element() el!: HTMLElement;\n\n /**\n * Content layout. Default is label-only; pass `icon` for icon-only chrome\n * (nav / tool rails) or `icon-label` for leading icon + text.\n */\n @Prop() variant: ButtonFilledVariant = 'label';\n\n /** Control density (height, padding, icon, type). */\n @Prop() size: ButtonFilledSize = 'md';\n\n /** Use reduced outer geometry when nested inside a control of the same size. */\n @Prop() isInset: boolean = false;\n\n /** Single removes 4px overall; double removes 8px overall (xs stays single). */\n @Prop() insetDepth: ControlInsetDepth = 'single';\n\n /** Width fit — hug content (default) or fill the parent. */\n @Prop() width: ButtonFilledWidth = 'hug';\n\n /** Visible text for `label` / `icon-label` variants. */\n @Prop() label: string = '';\n\n /** Whether the visible label uses the emphasized weight. */\n @Prop() labelEmphasis: boolean = true;\n\n /** Icon name passed to <ds-icon> for `icon` / `icon-label` variants. */\n @Prop() icon: string = '';\n\n /** Semantic colour intent. */\n @Prop() intent: ButtonFilledIntent = 'brand';\n\n /**\n * Background fill weight. Foreground uses the paired contrast token:\n * bold → faint, strong → medium, medium → strong, faint → bold.\n */\n @Prop() contrast: ButtonFilledContrast = 'bold';\n\n /** Show a 1px secondary inset border. */\n @Prop() hasBorder: boolean = false;\n\n /**\n * Actual parent surface context for the optional inset border color only.\n * Omit on primary and secondary surfaces.\n */\n @Prop() background: ButtonFilledBackground | undefined;\n\n /** Use the half-radius treatment instead of the default control radius. */\n @Prop() rounded: boolean = false;\n\n /**\n * Scale down during a physical pointer press.\n * Disable when an owning composite requires fixed child or background geometry.\n */\n @Prop() pressScale: boolean = true;\n\n /** Disables interaction. */\n @Prop() isInactive: boolean = false;\n\n /** Shows an inline loader and prevents interaction without applying inactive opacity. */\n @Prop() isLoading: boolean = false;\n\n /** Native button type. */\n @Prop() type: 'button' | 'submit' | 'reset' = 'button';\n\n /** Accessible name override. Required for icon-only buttons. */\n @Prop({ attribute: 'aria-label' }) ariaLabel: string | null = null;\n\n /** ID of the popup this button controls. */\n @Prop() controls: string | undefined;\n\n /**\n * Controlled disclosure state forwarded to aria-expanded.\n * Using expanded alone for surface-open styling is deprecated; set surfaceOpen\n * explicitly to separate the rendered surface lifecycle from disclosure state.\n */\n @Prop() expanded: boolean | undefined;\n\n /**\n * The associated menu, picker, or panel is visible, including its exit motion.\n * Holds only the pressed wash; does not set ARIA, selection, or press scaling.\n * In split mode this applies only to the menu segment. Explicit false disables\n * the legacy expanded-derived wash; omission preserves that compatibility path.\n */\n @Prop() surfaceOpen: boolean | undefined;\n\n /** Popup type exposed to assistive technology. */\n @Prop() haspopup: ButtonFilledPopup | undefined;\n\n /**\n * Append a separate ChevronDown menu segment while preserving this button's\n * variant, size, intent, contrast, loading, inactive, and width treatment.\n */\n @Prop() split: boolean = false;\n\n /** Accessible name for the appended menu segment in split mode. */\n @Prop() menuAriaLabel: string = 'More options';\n\n /**\n * This action *has* a menu: implies `aria-haspopup=\"menu\"` and adds the trailing\n * chevron that carries the affordance.\n *\n * Only `label` and `icon-label` are supported. A filled button is never the\n * overflow / more-options control — that role belongs to ButtonUnfilled with an\n * `Ellipses` glyph, which conveys the menu without a chevron.\n *\n * Use `haspopup` directly for non-menu popups.\n */\n @Prop() hasMenu: boolean = false;\n\n @Event() dsClick!: EventEmitter<MouseEvent>;\n @Event() dsMenuClick!: EventEmitter<MouseEvent>;\n\n private buttonEl: HTMLButtonElement | null = null;\n private menuButtonEl: HTMLButtonElement | null = null;\n\n @Method()\n async setFocus(segment: 'primary' | 'menu' = 'primary') {\n (segment === 'menu' ? this.menuButtonEl : this.buttonEl)?.focus();\n }\n\n private handleClick = (event: MouseEvent) => {\n if (this.isInactive || this.isLoading) {\n event.preventDefault();\n event.stopPropagation();\n return;\n }\n this.dsClick.emit(event);\n };\n\n private handleMenuClick = (event: MouseEvent) => {\n if (this.isInactive || this.isLoading) {\n event.preventDefault();\n event.stopPropagation();\n return;\n }\n this.dsMenuClick.emit(event);\n };\n\n private get resolvedHaspopup(): ButtonFilledPopup | undefined {\n return this.haspopup ?? (this.hasMenu ? 'menu' : undefined);\n }\n\n private get resolvedSurfaceOpen(): boolean {\n return (this.surfaceOpen ?? this.expanded === true) && !this.isInactive;\n }\n\n private get accessibleName(): string | undefined {\n if (this.ariaLabel) return this.ariaLabel;\n if (this.isLoading && this.variant === 'label' && this.label) return this.label;\n return undefined;\n }\n\n private get doubleInset(): boolean {\n return this.isInset && this.insetDepth === 'double' && this.size !== 'xs';\n }\n\n private buttonClass(\n variant: ButtonFilledVariant,\n expanded: boolean,\n bordered: boolean\n ): Record<string, boolean> {\n const cls: Record<string, boolean> = {\n 'button-filled': true,\n 'ds-button': true,\n 'ds-focus-ring-inset': true,\n 'ds-control-press-scale': this.pressScale && !this.split,\n 'ds-interaction-fill': !this.isInactive,\n /* Bold is the default filled contrast — on-bold interaction tokens. */\n 'ds-interaction-fill--on-bold': this.contrast === 'bold',\n 'ds-interaction-fill--on-strong': this.contrast === 'strong',\n 'ds-interaction-fill--on-medium': this.contrast === 'medium',\n /* faint → default app interaction tokens (no --on-*). */\n 'button-filled--bordered': bordered,\n 'ds-button--bordered': bordered,\n 'button-filled--expanded': expanded && !this.isInactive,\n 'ds-interaction-fill--surface-open': expanded,\n 'ds-button--expanded': expanded && !this.isInactive,\n 'ds-control-inactive': this.isInactive,\n 'ds-control--lg': this.size === 'lg',\n 'ds-control--md': this.size === 'md',\n 'ds-control--sm': this.size === 'sm',\n 'ds-control--xs': this.size === 'xs',\n 'ds-control--inset': this.isInset && !this.doubleInset,\n 'ds-control--inset-double': this.doubleInset,\n 'ds-control-frame': true,\n 'button-filled--icon': variant === 'icon',\n 'ds-button--icon': variant === 'icon',\n 'button-filled--label': variant === 'label',\n 'button-filled--icon-label': variant === 'icon-label',\n 'button-filled--rounded': this.rounded,\n 'ds-button--rounded': this.rounded,\n [`button-filled--background-${this.background}`]: this.background !== undefined,\n [`button-filled--intent-${this.intent}`]: true,\n [`button-filled--contrast-${this.contrast}`]: true,\n };\n return cls;\n }\n\n render() {\n const primaryButton = (\n <button\n ref={el => {\n this.buttonEl = el ?? null;\n }}\n type={this.type}\n class={{\n ...this.buttonClass(\n this.variant,\n !this.split && this.resolvedSurfaceOpen,\n this.hasBorder && !this.split\n ),\n 'ds-button-split__primary': this.split,\n }}\n disabled={this.isInactive}\n aria-label={this.accessibleName}\n aria-busy={this.isLoading ? 'true' : undefined}\n aria-disabled={this.isLoading ? 'true' : undefined}\n aria-controls={this.split ? undefined : this.controls}\n aria-expanded={\n this.split || this.expanded === undefined ? undefined : String(this.expanded)\n }\n aria-haspopup={this.split ? undefined : this.resolvedHaspopup}\n onPointerDown={event =>\n beginElevatedControlPress(\n event,\n this.pressScale && !this.split && !this.isInactive && !this.isLoading\n )\n }\n onClick={this.handleClick}\n >\n {renderButtonContent({\n namespace: 'button-filled',\n variant: this.variant,\n size: this.size,\n label: this.label,\n labelEmphasis: this.labelEmphasis,\n icon: this.icon,\n hasMenu: !this.split && this.hasMenu,\n isLoading: this.isLoading,\n })}\n </button>\n );\n\n const control = this.split ? (\n <div\n class={{\n 'ds-button-split': true,\n 'ds-button-split--rounded': this.rounded,\n }}\n >\n {primaryButton}\n <span class=\"ds-button-split__divider\" aria-hidden=\"true\" />\n <button\n ref={el => {\n this.menuButtonEl = el ?? null;\n }}\n class={{\n ...this.buttonClass('icon', this.resolvedSurfaceOpen, false),\n 'ds-button-split__menu': true,\n }}\n type=\"button\"\n disabled={this.isInactive || this.isLoading}\n aria-label={this.menuAriaLabel}\n aria-controls={this.controls}\n aria-expanded={String(this.expanded === true)}\n aria-haspopup=\"menu\"\n onClick={this.handleMenuClick}\n >\n {renderButtonContent({\n namespace: 'button-filled',\n variant: 'icon',\n size: this.size,\n label: '',\n labelEmphasis: true,\n icon: 'ChevronDown',\n hasMenu: false,\n isLoading: false,\n })}\n </button>\n {this.hasBorder ? <span class=\"ds-button-split__outline\" aria-hidden=\"true\" /> : null}\n </div>\n ) : (\n primaryButton\n );\n\n return (\n <Host\n class={{\n 'button-filled-host': true,\n 'button-filled-host--icon': this.variant === 'icon' && !this.split,\n 'ds-button-host--icon': this.variant === 'icon' && !this.split,\n 'ds-button-split-host': this.split,\n 'ds-button-split-host--primary-icon': this.split && this.variant === 'icon',\n [`button-filled-host--contrast-${this.contrast}`]: this.split,\n [`button-filled-host--background-${this.background}`]:\n this.split && this.background !== undefined,\n 'ds-control--lg': this.size === 'lg',\n 'ds-control--md': this.size === 'md',\n 'ds-control--sm': this.size === 'sm',\n 'ds-control--xs': this.size === 'xs',\n 'ds-control--inset': this.isInset && !this.doubleInset,\n 'ds-control--inset-double': this.doubleInset,\n ...controlWidthClass(this.width),\n }}\n tabIndex={-1}\n >\n {control}\n </Host>\n );\n }\n}\n",
|
|
637
658
|
"type": "registry:ui"
|
|
638
659
|
}
|
|
639
660
|
]
|