@ds-mo/ui 13.11.0 → 13.12.0
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 +6 -3
- package/dist/agent.json +306 -16
- package/dist/angular/ds-mobile-header.d.ts +1 -1
- package/dist/angular/ds-mobile-header.js +3 -3
- package/dist/angular/ds-shell-page.d.ts +1 -1
- package/dist/angular/ds-shell-page.js +3 -3
- package/dist/angular/ds-tab-group.d.ts +1 -1
- package/dist/angular/ds-tab-group.js +3 -3
- package/dist/angular/ds-table.d.ts +28 -0
- package/dist/angular/ds-table.js +54 -0
- package/dist/angular/ds-tag.d.ts +1 -1
- package/dist/angular/ds-tag.js +3 -3
- package/dist/angular/index.d.ts +1 -1
- package/dist/angular/index.js +1 -0
- package/dist/angular/proxies.d.ts +1 -0
- package/dist/angular/proxies.js +1 -0
- 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-bar-workflow.js.map +1 -1
- package/dist/components/ds-card-overview.js +1 -1
- package/dist/components/ds-menu.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-panel-sub-nav.js +1 -1
- package/dist/components/ds-radio.js +1 -1
- package/dist/components/ds-scroll-overlay.js +1 -1
- package/dist/components/ds-scroll-overlay.js.map +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-skeleton.js +1 -1
- package/dist/components/ds-slider.js +1 -1
- package/dist/components/ds-switch.js +1 -1
- package/dist/components/ds-tab-group.js +1 -1
- package/dist/components/ds-table.d.ts +11 -0
- package/dist/components/ds-table.js +2 -0
- package/dist/components/ds-table.js.map +1 -0
- package/dist/components/ds-tag.js +1 -1
- package/dist/components/ds-tag.js.map +1 -1
- package/dist/components/ds-toast.js +1 -1
- package/dist/components/ds-typing-indicator.js +1 -1
- package/dist/components/index.d.ts +30 -0
- package/dist/components/p-BFXN4ioL.js +2 -0
- package/dist/components/p-BFXN4ioL.js.map +1 -0
- package/dist/components/{p-DgglLVCj.js → p-BYre-ZDg.js} +2 -2
- package/dist/components/p-B_eqa09C.js +2 -0
- package/dist/components/p-B_eqa09C.js.map +1 -0
- package/dist/components/{p-BnGJrBPG.js → p-Bj7KrXzw.js} +2 -2
- package/dist/components/p-BxJurXEn.js +2 -0
- package/dist/components/p-BxJurXEn.js.map +1 -0
- package/dist/components/{p-Dg9qiMDa.js → p-C9StMCWg.js} +2 -2
- package/dist/components/p-CE82aQZr.js +2 -0
- package/dist/components/p-CE82aQZr.js.map +1 -0
- package/dist/components/p-CFNG0K3m.js +2 -0
- package/dist/components/p-CFNG0K3m.js.map +1 -0
- package/dist/components/{p-BQ7Nzx9A.js → p-Ch5yT3UA.js} +2 -2
- package/dist/components/{p-DJGdoPMG.js → p-DKftz0O8.js} +2 -2
- package/dist/components/p-DxygUurH.js +2 -0
- package/dist/components/p-DxygUurH.js.map +1 -0
- package/dist/components/{p-DCN04sgt.js → p-x9jO4lZ7.js} +2 -2
- package/dist/components/{p-DCN04sgt.js.map → p-x9jO4lZ7.js.map} +1 -1
- package/dist/docs/components.json +1567 -74
- package/dist/lib/components/MobileHeader/mobile-header-types.d.ts +3 -2
- package/dist/lib/components/ShellApp/shell-app-types.d.ts +4 -1
- package/dist/lib/components/ShellPage/shell-page-types.d.ts +5 -0
- package/dist/lib/shell/index.d.ts +1 -0
- package/dist/mcp-data/registry/bar-title.json +2 -1
- package/dist/mcp-data/registry/card-overview.json +1 -1
- package/dist/mcp-data/registry/mobile-header.json +48 -10
- package/dist/mcp-data/registry/mobile-sheet-nav.json +2 -2
- package/dist/mcp-data/registry/registry.json +308 -15
- package/dist/mcp-data/registry/shell-app.json +6 -3
- package/dist/mcp-data/registry/shell-page.json +32 -8
- package/dist/mcp-data/registry/shell-tools.json +4 -4
- package/dist/mcp-data/registry/tab-group.json +22 -2
- package/dist/mcp-data/registry/table.json +758 -0
- package/dist/mcp-data/registry/tag.json +22 -3
- package/dist/react/components.d.ts +1 -0
- package/dist/react/components.js +1 -0
- package/dist/react/ds-table.d.ts +16 -0
- package/dist/react/ds-table.js +18 -0
- package/dist/styles/table.css +677 -0
- package/dist/types/components/MobileHeader/MobileHeader.d.ts +8 -3
- package/dist/types/components/MobileHeader/index.d.ts +1 -1
- package/dist/types/components/MobileHeader/mobile-header-types.d.ts +3 -2
- package/dist/types/components/ShellApp/ShellApp.d.ts +8 -0
- package/dist/types/components/ShellApp/shell-app-types.d.ts +4 -1
- package/dist/types/components/ShellPage/ShellPage.d.ts +3 -1
- package/dist/types/components/ShellPage/index.d.ts +1 -1
- package/dist/types/components/ShellPage/shell-page-types.d.ts +1 -0
- package/dist/types/components/TabGroup/TabGroup.d.ts +3 -0
- package/dist/types/components/Table/Table.d.ts +115 -0
- package/dist/types/components/Table/index.d.ts +2 -0
- package/dist/types/components/Table/table-model.d.ts +29 -0
- package/dist/types/components/Table/table-types.d.ts +178 -0
- package/dist/types/components/Tag/Tag.d.ts +2 -0
- package/dist/types/components.d.ts +406 -8
- package/dist/types/shell/index.d.ts +1 -0
- package/package.json +3 -2
- package/dist/components/p-B_nHX-BW.js +0 -2
- package/dist/components/p-B_nHX-BW.js.map +0 -1
- package/dist/components/p-CVmtbj-6.js +0 -2
- package/dist/components/p-CVmtbj-6.js.map +0 -1
- package/dist/components/p-DW9p068S.js +0 -2
- package/dist/components/p-DW9p068S.js.map +0 -1
- package/dist/components/p-Xftomzsv.js +0 -2
- package/dist/components/p-Xftomzsv.js.map +0 -1
- package/dist/components/p-iyiOFsws.js +0 -2
- package/dist/components/p-iyiOFsws.js.map +0 -1
- /package/dist/components/{p-DgglLVCj.js.map → p-BYre-ZDg.js.map} +0 -0
- /package/dist/components/{p-BnGJrBPG.js.map → p-Bj7KrXzw.js.map} +0 -0
- /package/dist/components/{p-Dg9qiMDa.js.map → p-C9StMCWg.js.map} +0 -0
- /package/dist/components/{p-BQ7Nzx9A.js.map → p-Ch5yT3UA.js.map} +0 -0
- /package/dist/components/{p-DJGdoPMG.js.map → p-DKftz0O8.js.map} +0 -0
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"component:ds-shell-app",
|
|
33
33
|
"component:ds-panel-tool-header",
|
|
34
34
|
"component:ds-mobile-header",
|
|
35
|
-
"component:ds-
|
|
35
|
+
"component:ds-tab-group",
|
|
36
36
|
"component:ds-mobile-bar-nav"
|
|
37
37
|
],
|
|
38
38
|
"patterns": [
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
"accessibility": [
|
|
42
42
|
"Only the active mobile tool view participates in focus or the accessibility tree.",
|
|
43
43
|
"Header Back and application-owned header actions preserve the PanelTools event contract.",
|
|
44
|
-
"Inbox uses
|
|
44
|
+
"Mobile Inbox uses a named label-only TabGroup for Stacks and Activity; Messages remains a direct mobile destination."
|
|
45
45
|
],
|
|
46
46
|
"states": [
|
|
47
47
|
"The application owns open and activeTool and replaces headers when tool depth changes.",
|
|
@@ -51,7 +51,7 @@
|
|
|
51
51
|
],
|
|
52
52
|
"responsiveBehavior": [
|
|
53
53
|
"Desktop and tablet compose PanelTools-compatible rail and drawer chrome.",
|
|
54
|
-
"Mobile renders one centered MobileHeader for every tool; Stacks and Activity use Inbox
|
|
54
|
+
"Mobile renders one centered MobileHeader for every tool; Stacks and Activity use the large segmented Inbox control, while Messages remains a direct tool destination."
|
|
55
55
|
],
|
|
56
56
|
"frameworkCaveats": {
|
|
57
57
|
"customElements": [
|
|
@@ -415,7 +415,7 @@
|
|
|
415
415
|
},
|
|
416
416
|
{
|
|
417
417
|
"path": "src/wc/components/ShellTools/ShellTools.tsx",
|
|
418
|
-
"content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Method,\n Prop,\n Watch,\n} from '@stencil/core';\nimport {\n PANEL_TOOLS_LABELS,\n type PanelToolsHeaderConfig,\n type PanelToolsHeaders,\n type PanelToolsItem,\n type PanelToolsToolId,\n} from '../PanelTools/panel-tools-types';\nimport {\n orderPanelToolsItems,\n reconcilePanelToolsAvailability,\n resolvePanelToolActivation,\n} from '../PanelTools/panel-tools-utils';\nimport {\n isShellInboxTool,\n resolveAvailableInboxTool,\n type ShellInboxToolId,\n type ShellResponsiveMode,\n} from '../../shell/shell-responsive';\n\n@Component({\n tag: 'ds-shell-tools',\n styleUrl: 'ShellTools.css',\n scoped: true,\n})\nexport class ShellTools {\n @Element() el!: HTMLElement;\n\n /** Resolved by ShellApp; mobile uses a full-stage tool presentation. */\n @Prop({ attribute: 'responsive-mode', reflect: true })\n responsiveMode: ShellResponsiveMode = 'desktop';\n @Prop({ mutable: true, reflect: true }) open: boolean = false;\n @Prop({ mutable: true, attribute: 'active-tool', reflect: true })\n activeTool: PanelToolsToolId | '' = '';\n @Prop({ mutable: true, reflect: true }) presentation: 'drawer' | 'fullscreen' = 'drawer';\n @Prop({ attribute: 'fullscreen-header-mode', reflect: true })\n fullscreenHeaderMode: 'shared' | 'split' = 'shared';\n @Prop() items: PanelToolsItem[] = [];\n @Prop() headers: PanelToolsHeaders = {};\n @Prop({ attribute: 'storage-key' }) storageKey: string = '';\n @Prop() toolsLabel: string = 'Tools';\n @Prop() toolShortcutsLabel: string = 'Tool shortcuts';\n @Prop() inboxLabel: string = 'Inbox';\n @Prop() inboxNavigationLabel: string = 'Inbox sections';\n\n @Watch('presentation')\n presentationChanged(next: 'drawer' | 'fullscreen', previous: 'drawer' | 'fullscreen') {\n if (next === previous) return;\n\n /*\n * Consumers update ShellTools and their slotted master/detail layout in the\n * same render. Forward the request immediately so PanelTools can conceal\n * its surface until its matching render class commits on the next frame.\n */\n if (this.panelToolsEl && this.panelToolsEl.getAttribute('presentation') !== next) {\n this.panelToolsEl.setAttribute('presentation', next);\n }\n }\n\n @Event({ bubbles: true, composed: true }) dsToolChange!: EventEmitter<{\n id: PanelToolsToolId;\n selected: boolean;\n }>;\n @Event({ bubbles: true, composed: true }) dsPresentationChange!: EventEmitter<{\n presentation: 'drawer' | 'fullscreen';\n }>;\n @Event({ bubbles: true, composed: true }) dsHeaderBack!: EventEmitter<{\n tool: PanelToolsToolId;\n }>;\n @Event({ bubbles: true, composed: true }) dsHeaderAction!: EventEmitter<{\n tool: PanelToolsToolId;\n id: string;\n }>;\n\n private panelToolsEl: HTMLDsPanelToolsElement | null = null;\n\n private get resolvedItems(): PanelToolsItem[] {\n return this.items ?? [];\n }\n\n private get availableInboxTools(): ShellInboxToolId[] {\n return this.resolvedItems\n .filter(\n item =>\n !item.isInactive &&\n (item.mobileDestination === 'inbox' || isShellInboxTool(item.id))\n )\n .map(item => item.id as ShellInboxToolId);\n }\n\n private get mobileViewOrder(): PanelToolsToolId[] {\n return orderPanelToolsItems(this.resolvedItems).map(item => item.id);\n }\n\n private toolLabel(id: PanelToolsToolId): string {\n const item = this.resolvedItems.find(candidate => candidate.id === id);\n return (\n item?.label ??\n item?.ariaLabel ??\n PANEL_TOOLS_LABELS[id as keyof typeof PANEL_TOOLS_LABELS] ??\n id\n );\n }\n\n private get mobileActiveTool(): PanelToolsToolId | '' {\n const item = this.resolvedItems.find(candidate => candidate.id === this.activeTool);\n const inbox =\n item?.mobileDestination === 'inbox' || isShellInboxTool(this.activeTool);\n if (!inbox) return this.activeTool;\n return resolveAvailableInboxTool(this.activeTool, this.availableInboxTools);\n }\n\n componentWillLoad() {\n this.reconcileAvailability();\n }\n\n @Watch('items')\n handleItemsChange() {\n this.reconcileAvailability();\n }\n\n private reconcileAvailability() {\n const next = reconcilePanelToolsAvailability(this.resolvedItems, this.open, this.activeTool);\n this.open = next.open;\n this.activeTool = next.activeTool;\n }\n\n private resolvedHeaders(): PanelToolsHeaders {\n return this.headers ?? {};\n }\n\n private activeHeader(tool: PanelToolsToolId): PanelToolsHeaderConfig {\n return this.resolvedHeaders()[tool] ?? {};\n }\n\n private activateMobileTool(id: PanelToolsToolId) {\n const item = this.resolvedItems.find(entry => entry.id === id);\n if (!item || item.isInactive) return;\n const next = resolvePanelToolActivation(this.open, this.activeTool, id);\n this.open = next.open;\n this.activeTool = next.activeTool;\n this.dsToolChange.emit({ id, selected: next.selected });\n }\n\n private selectInboxTool = (event: CustomEvent<string>) => {\n const id = event.detail;\n if (!this.availableInboxTools.includes(id) || id === this.mobileActiveTool) return;\n this.open = true;\n this.activeTool = id;\n this.dsToolChange.emit({ id, selected: true });\n };\n\n private handlePanelToolChange = (\n event: CustomEvent<{ id: PanelToolsToolId; selected: boolean }>\n ) => {\n this.activeTool = event.detail.id;\n this.open = event.detail.selected;\n };\n\n private handlePanelPresentationChange = (\n event: CustomEvent<{ presentation: 'drawer' | 'fullscreen' }>\n ) => {\n this.presentation = event.detail.presentation;\n };\n\n private handleMobileHeaderAction = (\n tool: PanelToolsToolId,\n id: string,\n event: CustomEvent<MouseEvent>\n ) => {\n event.stopPropagation();\n this.dsHeaderAction.emit({ tool, id });\n };\n\n /** Match PanelTools' imperative activation contract in every responsive mode. */\n @Method()\n async activateTool(id: PanelToolsToolId) {\n if (this.responsiveMode !== 'mobile') {\n await this.panelToolsEl?.activateTool(id);\n return;\n }\n this.activateMobileTool(id);\n }\n\n /** Close the active global tool surface. */\n @Method()\n async closeDrawer() {\n if (this.responsiveMode !== 'mobile') {\n await this.panelToolsEl?.closeDrawer();\n return;\n }\n if (!this.open) return;\n const id = this.activeTool;\n this.open = false;\n if (id) this.dsToolChange.emit({ id, selected: false });\n }\n\n /** Focus an application-owned action in the visible shared tool header. */\n @Method()\n async focusHeaderAction(id: string) {\n if (this.responsiveMode !== 'mobile') {\n await this.panelToolsEl?.focusHeaderAction(id);\n return;\n }\n const action = this.el.querySelector(`[data-header-action-id=\"${CSS.escape(id)}\"]`) as\n | (HTMLElement & { setFocus?: () => Promise<void> })\n | null;\n await action?.setFocus?.();\n }\n\n private renderForwardedSlots() {\n return this.mobileViewOrder.flatMap(id => [\n <slot name={id} slot={id} />,\n <slot name={`${id}-view`} slot={`${id}-view`} />,\n ]);\n }\n\n private renderDesktop() {\n return (\n <div class=\"shell-tools__desktop\">\n <ds-panel-tools\n open={this.open}\n activeTool={this.activeTool}\n presentation={this.presentation}\n fullscreenHeaderMode={this.fullscreenHeaderMode}\n items={this.resolvedItems}\n headers={this.resolvedHeaders()}\n storageKey={this.storageKey}\n toolsLabel={this.toolsLabel}\n toolShortcutsLabel={this.toolShortcutsLabel}\n ref={element => {\n this.panelToolsEl = element as HTMLDsPanelToolsElement | null;\n }}\n onDsToolChange={this.handlePanelToolChange}\n onDsPresentationChange={this.handlePanelPresentationChange}\n >\n {this.renderForwardedSlots()}\n </ds-panel-tools>\n </div>\n );\n }\n\n private renderMobileHeader(tool: PanelToolsToolId) {\n const header = this.activeHeader(tool);\n const configuredTitle = header.title?.trim();\n const title = configuredTitle || this.toolLabel(tool);\n const actions = (header.actions ?? []).filter(action => action.id !== 'fullscreen');\n const sections =\n isShellInboxTool(tool) && !header.showBack\n ? this.availableInboxTools.map(id => ({\n id,\n label: this.toolLabel(id),\n }))\n : [];\n\n return (\n <ds-mobile-header\n class=\"shell-tools__mobile-header\"\n heading={title}\n headingLevel=\"h2\"\n sections={sections}\n value={tool}\n sectionsAriaLabel={this.inboxNavigationLabel}\n onDsSectionChange={this.selectInboxTool}\n >\n {header.showBack ? (\n <ds-tooltip\n slot=\"leading\"\n label={header.backAriaLabel || 'Back'}\n side=\"bottom\"\n size=\"sm\"\n >\n <ds-button-unfilled\n variant=\"icon\"\n icon={header.backIcon || 'ChevronLeft'}\n size=\"md\"\n aria-label={header.backAriaLabel || 'Back'}\n activeFill={false}\n hasBorder={false}\n onDsClick={() => this.dsHeaderBack.emit({ tool })}\n />\n </ds-tooltip>\n ) : null}\n {actions.map(action => (\n <ds-tooltip\n slot=\"trailing\"\n key={action.id}\n label={action.ariaLabel}\n side=\"bottom\"\n size=\"sm\"\n >\n <ds-button-unfilled\n id={action.triggerId || undefined}\n data-header-action-id={action.id}\n variant=\"icon\"\n icon={action.icon}\n size=\"md\"\n aria-label={action.ariaLabel}\n haspopup={action.haspopup}\n controls={action.controls}\n expanded={action.expanded}\n pressed={action.pressed}\n isActive={!!action.expanded}\n isInactive={action.isInactive}\n activeFill={false}\n hasBorder={false}\n onDsClick={(event: CustomEvent<MouseEvent>) =>\n this.handleMobileHeaderAction(tool, action.id, event)}\n />\n </ds-tooltip>\n ))}\n </ds-mobile-header>\n );\n }\n\n private renderMobile() {\n const tool = this.mobileActiveTool;\n return (\n <div\n class=\"shell-tools__mobile\"\n aria-hidden={this.open && tool ? undefined : 'true'}\n inert={this.open && tool ? undefined : true}\n >\n {tool ? this.renderMobileHeader(tool) : null}\n <div class=\"shell-tools__mobile-body\">\n {this.mobileViewOrder.map(id => {\n const active = this.open && id === tool;\n return (\n <div\n class={{\n 'shell-tools__view': true,\n 'shell-tools__view--active': active,\n }}\n hidden={!active}\n aria-hidden={active ? undefined : 'true'}\n inert={active ? undefined : true}\n >\n <slot name={`${id}-view`}>\n <slot name={id} />\n </slot>\n </div>\n );\n })}\n </div>\n </div>\n );\n }\n\n render() {\n const mobile = this.responsiveMode === 'mobile';\n return (\n <Host\n role={\n mobile\n ? this.presentation === 'fullscreen'\n ? 'dialog'\n : 'complementary'\n : undefined\n }\n aria-modal={mobile && this.presentation === 'fullscreen' ? 'true' : undefined}\n aria-label={mobile ? this.toolsLabel : undefined}\n >\n {mobile ? this.renderMobile() : this.renderDesktop()}\n </Host>\n );\n }\n}\n",
|
|
418
|
+
"content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Method,\n Prop,\n Watch,\n} from '@stencil/core';\nimport {\n PANEL_TOOLS_LABELS,\n type PanelToolsHeaderConfig,\n type PanelToolsHeaders,\n type PanelToolsItem,\n type PanelToolsToolId,\n} from '../PanelTools/panel-tools-types';\nimport {\n orderPanelToolsItems,\n reconcilePanelToolsAvailability,\n resolvePanelToolActivation,\n} from '../PanelTools/panel-tools-utils';\nimport {\n isShellInboxTool,\n resolveAvailableInboxTool,\n type ShellInboxToolId,\n type ShellResponsiveMode,\n} from '../../shell/shell-responsive';\n\n@Component({\n tag: 'ds-shell-tools',\n styleUrl: 'ShellTools.css',\n scoped: true,\n})\nexport class ShellTools {\n @Element() el!: HTMLElement;\n\n /** Resolved by ShellApp; mobile uses a full-stage tool presentation. */\n @Prop({ attribute: 'responsive-mode', reflect: true })\n responsiveMode: ShellResponsiveMode = 'desktop';\n @Prop({ mutable: true, reflect: true }) open: boolean = false;\n @Prop({ mutable: true, attribute: 'active-tool', reflect: true })\n activeTool: PanelToolsToolId | '' = '';\n @Prop({ mutable: true, reflect: true }) presentation: 'drawer' | 'fullscreen' = 'drawer';\n @Prop({ attribute: 'fullscreen-header-mode', reflect: true })\n fullscreenHeaderMode: 'shared' | 'split' = 'shared';\n @Prop() items: PanelToolsItem[] = [];\n @Prop() headers: PanelToolsHeaders = {};\n @Prop({ attribute: 'storage-key' }) storageKey: string = '';\n @Prop() toolsLabel: string = 'Tools';\n @Prop() toolShortcutsLabel: string = 'Tool shortcuts';\n @Prop() inboxLabel: string = 'Inbox';\n @Prop() inboxNavigationLabel: string = 'Inbox sections';\n\n @Watch('presentation')\n presentationChanged(next: 'drawer' | 'fullscreen', previous: 'drawer' | 'fullscreen') {\n if (next === previous) return;\n\n /*\n * Consumers update ShellTools and their slotted master/detail layout in the\n * same render. Forward the request immediately so PanelTools can conceal\n * its surface until its matching render class commits on the next frame.\n */\n if (this.panelToolsEl && this.panelToolsEl.getAttribute('presentation') !== next) {\n this.panelToolsEl.setAttribute('presentation', next);\n }\n }\n\n @Event({ bubbles: true, composed: true }) dsToolChange!: EventEmitter<{\n id: PanelToolsToolId;\n selected: boolean;\n }>;\n @Event({ bubbles: true, composed: true }) dsPresentationChange!: EventEmitter<{\n presentation: 'drawer' | 'fullscreen';\n }>;\n @Event({ bubbles: true, composed: true }) dsHeaderBack!: EventEmitter<{\n tool: PanelToolsToolId;\n }>;\n @Event({ bubbles: true, composed: true }) dsHeaderAction!: EventEmitter<{\n tool: PanelToolsToolId;\n id: string;\n }>;\n\n private panelToolsEl: HTMLDsPanelToolsElement | null = null;\n\n private get resolvedItems(): PanelToolsItem[] {\n return this.items ?? [];\n }\n\n private get availableInboxTools(): ShellInboxToolId[] {\n return this.resolvedItems\n .filter(\n item =>\n !item.isInactive &&\n (item.mobileDestination === 'inbox' || isShellInboxTool(item.id))\n )\n .map(item => item.id as ShellInboxToolId);\n }\n\n private get mobileViewOrder(): PanelToolsToolId[] {\n return orderPanelToolsItems(this.resolvedItems).map(item => item.id);\n }\n\n private toolLabel(id: PanelToolsToolId): string {\n const item = this.resolvedItems.find(candidate => candidate.id === id);\n return (\n item?.label ??\n item?.ariaLabel ??\n PANEL_TOOLS_LABELS[id as keyof typeof PANEL_TOOLS_LABELS] ??\n id\n );\n }\n\n private get mobileActiveTool(): PanelToolsToolId | '' {\n const item = this.resolvedItems.find(candidate => candidate.id === this.activeTool);\n const inbox =\n item?.mobileDestination === 'inbox' || isShellInboxTool(this.activeTool);\n if (!inbox) return this.activeTool;\n return resolveAvailableInboxTool(this.activeTool, this.availableInboxTools);\n }\n\n componentWillLoad() {\n this.reconcileAvailability();\n }\n\n @Watch('items')\n handleItemsChange() {\n this.reconcileAvailability();\n }\n\n private reconcileAvailability() {\n const next = reconcilePanelToolsAvailability(this.resolvedItems, this.open, this.activeTool);\n this.open = next.open;\n this.activeTool = next.activeTool;\n }\n\n private resolvedHeaders(): PanelToolsHeaders {\n return this.headers ?? {};\n }\n\n private activeHeader(tool: PanelToolsToolId): PanelToolsHeaderConfig {\n return this.resolvedHeaders()[tool] ?? {};\n }\n\n private activateMobileTool(id: PanelToolsToolId) {\n const item = this.resolvedItems.find(entry => entry.id === id);\n if (!item || item.isInactive) return;\n const next = resolvePanelToolActivation(this.open, this.activeTool, id);\n this.open = next.open;\n this.activeTool = next.activeTool;\n this.dsToolChange.emit({ id, selected: next.selected });\n }\n\n private selectInboxTool = (event: CustomEvent<string>) => {\n const id = event.detail;\n if (!this.availableInboxTools.includes(id) || id === this.mobileActiveTool) return;\n this.open = true;\n this.activeTool = id;\n this.dsToolChange.emit({ id, selected: true });\n };\n\n private handlePanelToolChange = (\n event: CustomEvent<{ id: PanelToolsToolId; selected: boolean }>\n ) => {\n this.activeTool = event.detail.id;\n this.open = event.detail.selected;\n };\n\n private handlePanelPresentationChange = (\n event: CustomEvent<{ presentation: 'drawer' | 'fullscreen' }>\n ) => {\n this.presentation = event.detail.presentation;\n };\n\n private handleMobileHeaderAction = (\n tool: PanelToolsToolId,\n id: string,\n event: CustomEvent<MouseEvent>\n ) => {\n event.stopPropagation();\n this.dsHeaderAction.emit({ tool, id });\n };\n\n /** Match PanelTools' imperative activation contract in every responsive mode. */\n @Method()\n async activateTool(id: PanelToolsToolId) {\n if (this.responsiveMode !== 'mobile') {\n await this.panelToolsEl?.activateTool(id);\n return;\n }\n this.activateMobileTool(id);\n }\n\n /** Close the active global tool surface. */\n @Method()\n async closeDrawer() {\n if (this.responsiveMode !== 'mobile') {\n await this.panelToolsEl?.closeDrawer();\n return;\n }\n if (!this.open) return;\n const id = this.activeTool;\n this.open = false;\n if (id) this.dsToolChange.emit({ id, selected: false });\n }\n\n /** Focus an application-owned action in the visible shared tool header. */\n @Method()\n async focusHeaderAction(id: string) {\n if (this.responsiveMode !== 'mobile') {\n await this.panelToolsEl?.focusHeaderAction(id);\n return;\n }\n const action = this.el.querySelector(`[data-header-action-id=\"${CSS.escape(id)}\"]`) as\n | (HTMLElement & { setFocus?: () => Promise<void> })\n | null;\n await action?.setFocus?.();\n }\n\n private renderForwardedSlots() {\n return this.mobileViewOrder.flatMap(id => [\n <slot name={id} slot={id} />,\n <slot name={`${id}-view`} slot={`${id}-view`} />,\n ]);\n }\n\n private renderDesktop() {\n return (\n <div class=\"shell-tools__desktop\">\n <ds-panel-tools\n open={this.open}\n activeTool={this.activeTool}\n presentation={this.presentation}\n fullscreenHeaderMode={this.fullscreenHeaderMode}\n items={this.resolvedItems}\n headers={this.resolvedHeaders()}\n storageKey={this.storageKey}\n toolsLabel={this.toolsLabel}\n toolShortcutsLabel={this.toolShortcutsLabel}\n ref={element => {\n this.panelToolsEl = element as HTMLDsPanelToolsElement | null;\n }}\n onDsToolChange={this.handlePanelToolChange}\n onDsPresentationChange={this.handlePanelPresentationChange}\n >\n {this.renderForwardedSlots()}\n </ds-panel-tools>\n </div>\n );\n }\n\n private renderMobileHeader(tool: PanelToolsToolId) {\n const header = this.activeHeader(tool);\n const configuredTitle = header.title?.trim();\n const title = configuredTitle || this.toolLabel(tool);\n const actions = (header.actions ?? []).filter(action => action.id !== 'fullscreen');\n const inboxRoot = isShellInboxTool(tool) && !header.showBack;\n const sections =\n inboxRoot\n ? this.availableInboxTools.map(id => {\n const item = this.resolvedItems.find(candidate => candidate.id === id);\n return {\n id,\n label: this.toolLabel(id),\n variant: 'label' as const,\n dot: item?.dot,\n };\n })\n : [];\n\n return (\n <ds-mobile-header\n class=\"shell-tools__mobile-header\"\n heading={title}\n headingLevel=\"h2\"\n sections={sections}\n value={tool}\n sectionsAriaLabel={this.inboxNavigationLabel}\n sectionsPresentation={inboxRoot ? 'segmented' : 'switcher'}\n sectionsSize=\"lg\"\n onDsSectionChange={this.selectInboxTool}\n >\n {header.showBack ? (\n <ds-tooltip\n slot=\"leading\"\n label={header.backAriaLabel || 'Back'}\n side=\"bottom\"\n size=\"sm\"\n >\n <ds-button-unfilled\n variant=\"icon\"\n icon={header.backIcon || 'ChevronLeft'}\n size=\"md\"\n aria-label={header.backAriaLabel || 'Back'}\n activeFill={false}\n hasBorder={false}\n onDsClick={() => this.dsHeaderBack.emit({ tool })}\n />\n </ds-tooltip>\n ) : null}\n {actions.map(action => (\n <ds-tooltip\n slot=\"trailing\"\n key={action.id}\n label={action.ariaLabel}\n side=\"bottom\"\n size=\"sm\"\n >\n <ds-button-unfilled\n id={action.triggerId || undefined}\n data-header-action-id={action.id}\n variant=\"icon\"\n icon={action.icon}\n size=\"md\"\n aria-label={action.ariaLabel}\n haspopup={action.haspopup}\n controls={action.controls}\n expanded={action.expanded}\n pressed={action.pressed}\n isActive={!!action.expanded}\n isInactive={action.isInactive}\n activeFill={false}\n hasBorder={false}\n onDsClick={(event: CustomEvent<MouseEvent>) =>\n this.handleMobileHeaderAction(tool, action.id, event)}\n />\n </ds-tooltip>\n ))}\n </ds-mobile-header>\n );\n }\n\n private renderMobile() {\n const tool = this.mobileActiveTool;\n return (\n <div\n class=\"shell-tools__mobile\"\n aria-hidden={this.open && tool ? undefined : 'true'}\n inert={this.open && tool ? undefined : true}\n >\n {tool ? this.renderMobileHeader(tool) : null}\n <div class=\"shell-tools__mobile-body\">\n {this.mobileViewOrder.map(id => {\n const active = this.open && id === tool;\n return (\n <div\n class={{\n 'shell-tools__view': true,\n 'shell-tools__view--active': active,\n }}\n hidden={!active}\n aria-hidden={active ? undefined : 'true'}\n inert={active ? undefined : true}\n >\n <slot name={`${id}-view`}>\n <slot name={id} />\n </slot>\n </div>\n );\n })}\n </div>\n </div>\n );\n }\n\n render() {\n const mobile = this.responsiveMode === 'mobile';\n return (\n <Host\n role={\n mobile\n ? this.presentation === 'fullscreen'\n ? 'dialog'\n : 'complementary'\n : undefined\n }\n aria-modal={mobile && this.presentation === 'fullscreen' ? 'true' : undefined}\n aria-label={mobile ? this.toolsLabel : undefined}\n >\n {mobile ? this.renderMobile() : this.renderDesktop()}\n </Host>\n );\n }\n}\n",
|
|
419
419
|
"type": "registry:ui"
|
|
420
420
|
}
|
|
421
421
|
]
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"The parent owns the canonical selected value and adjacent view visibility, and should update both from dsChange.",
|
|
66
66
|
"A missing, empty, inactive, or unknown selected value is invalid when enabled tabs exist; the component does not choose a fallback selection.",
|
|
67
67
|
"Selection is singular and follows keyboard focus.",
|
|
68
|
+
"Small, medium, and large use the shared 24px, 32px, and 40px control-density recipes for the outer track. Each segment uses the same-size inset recipe at 20px, 28px, and 36px while preserving that density's typography, icon, gap, and label metrics.",
|
|
68
69
|
"Use one consistent content variant across every selectable tab in a group.",
|
|
69
70
|
"Omitted background is for primary and secondary surfaces; pass the faint context explicitly on faint surfaces.",
|
|
70
71
|
"Only the omitted default context emphasizes visible selected label text and keeps the filled track; explicit faint follows the other surface contexts with a transparent track, stable text weight, surface border, and neutral active fill.",
|
|
@@ -72,6 +73,7 @@
|
|
|
72
73
|
],
|
|
73
74
|
"responsiveBehavior": [
|
|
74
75
|
"The component remains a single horizontal row and does not become vertical or wrap.",
|
|
76
|
+
"Use the large size when a mobile composition aligns the group with 40px shell controls; size does not change automatically at a breakpoint.",
|
|
75
77
|
"The consuming layout owns narrow-width placement and overflow, or replaces the row with a more suitable navigation pattern."
|
|
76
78
|
],
|
|
77
79
|
"frameworkCaveats": {
|
|
@@ -117,6 +119,15 @@
|
|
|
117
119
|
"required": false,
|
|
118
120
|
"mutable": false
|
|
119
121
|
},
|
|
122
|
+
"size": {
|
|
123
|
+
"type": "TabGroupSize",
|
|
124
|
+
"resolvedType": "\"lg\" | \"md\" | \"sm\"",
|
|
125
|
+
"attribute": "size",
|
|
126
|
+
"default": "'md'",
|
|
127
|
+
"required": false,
|
|
128
|
+
"mutable": false,
|
|
129
|
+
"description": "Control density: 24px small, 32px medium, or 40px large track."
|
|
130
|
+
},
|
|
120
131
|
"tabs": {
|
|
121
132
|
"type": "TabGroupItem[]",
|
|
122
133
|
"resolvedType": "TabGroupItem[]",
|
|
@@ -168,6 +179,15 @@
|
|
|
168
179
|
"required": false,
|
|
169
180
|
"mutable": false
|
|
170
181
|
},
|
|
182
|
+
"size": {
|
|
183
|
+
"type": "TabGroupSize",
|
|
184
|
+
"resolvedType": "\"lg\" | \"md\" | \"sm\"",
|
|
185
|
+
"attribute": "size",
|
|
186
|
+
"default": "'md'",
|
|
187
|
+
"required": false,
|
|
188
|
+
"mutable": false,
|
|
189
|
+
"description": "Control density: 24px small, 32px medium, or 40px large track."
|
|
190
|
+
},
|
|
171
191
|
"tabs": {
|
|
172
192
|
"type": "TabGroupItem[]",
|
|
173
193
|
"resolvedType": "TabGroupItem[]",
|
|
@@ -238,12 +258,12 @@
|
|
|
238
258
|
"files": [
|
|
239
259
|
{
|
|
240
260
|
"path": "src/wc/components/TabGroup/TabGroup.css",
|
|
241
|
-
"content": "@import '../../utils/control-density.css';\n@import '../../utils/focus-ring.css';\n@import '../../utils/interaction-fill.css';\n@import '../../utils/control-inactive.css';\n\n:host {\n display: block;\n --_tab-group-surface-border: var(--color-border-tertiary);\n --_tab-group-divider: var(--color-border-secondary);\n}\n\n:host(.tab-group-host--on-medium) {\n --_tab-group-surface-border: var(--color-border-on-medium-background-tertiary);\n --_tab-group-divider: var(--color-border-on-medium-background-secondary);\n}\n\n:host(.tab-group-host--on-bold) {\n --_tab-group-surface-border: var(--color-border-on-bold-background-tertiary);\n --_tab-group-divider: var(--color-border-on-bold-background-secondary);\n}\n\n:host(.tab-group-host--on-strong) {\n --_tab-group-surface-border: var(--color-border-on-strong-background-tertiary);\n --_tab-group-divider: var(--color-border-on-strong-background-secondary);\n}\n\n:host(.tab-group-host--on-translucent) {\n --_tab-group-surface-border: var(--color-translucent-border-tertiary);\n --_tab-group-divider: var(--color-translucent-border-secondary);\n}\n\n:host(.tab-group-host--on-inverted) {\n --_tab-group-surface-border: var(--color-inverted-border-tertiary);\n --_tab-group-divider: var(--color-inverted-border-secondary);\n}\n\n:host(.tab-group-host--on-media) {\n --_tab-group-surface-border: var(--color-media-border-tertiary);\n --_tab-group-divider: var(--color-media-border-secondary);\n}\n\n:host(.tab-group-host--on-always-dark) {\n --_tab-group-surface-border: var(--color-always-dark-border-tertiary);\n --_tab-group-divider: var(--color-always-dark-border-secondary);\n}\n\n.tab-list {\n display: flex;\n align-items: center;\n gap: var(--
|
|
261
|
+
"content": "@import '../../utils/control-density.css';\n@import '../../utils/control-density-inset.css';\n@import '../../utils/focus-ring.css';\n@import '../../utils/interaction-fill.css';\n@import '../../utils/control-inactive.css';\n\n:host {\n display: block;\n --_tab-group-surface-border: var(--color-border-tertiary);\n --_tab-group-divider: var(--color-border-secondary);\n}\n\n:host(.tab-group-host--on-medium) {\n --_tab-group-surface-border: var(--color-border-on-medium-background-tertiary);\n --_tab-group-divider: var(--color-border-on-medium-background-secondary);\n}\n\n:host(.tab-group-host--on-bold) {\n --_tab-group-surface-border: var(--color-border-on-bold-background-tertiary);\n --_tab-group-divider: var(--color-border-on-bold-background-secondary);\n}\n\n:host(.tab-group-host--on-strong) {\n --_tab-group-surface-border: var(--color-border-on-strong-background-tertiary);\n --_tab-group-divider: var(--color-border-on-strong-background-secondary);\n}\n\n:host(.tab-group-host--on-translucent) {\n --_tab-group-surface-border: var(--color-translucent-border-tertiary);\n --_tab-group-divider: var(--color-translucent-border-secondary);\n}\n\n:host(.tab-group-host--on-inverted) {\n --_tab-group-surface-border: var(--color-inverted-border-tertiary);\n --_tab-group-divider: var(--color-inverted-border-secondary);\n}\n\n:host(.tab-group-host--on-media) {\n --_tab-group-surface-border: var(--color-media-border-tertiary);\n --_tab-group-divider: var(--color-media-border-secondary);\n}\n\n:host(.tab-group-host--on-always-dark) {\n --_tab-group-surface-border: var(--color-always-dark-border-tertiary);\n --_tab-group-divider: var(--color-always-dark-border-secondary);\n}\n\n.tab-list {\n display: flex;\n align-items: center;\n gap: var(--ds-control-gap);\n position: relative;\n width: max-content;\n max-width: 100%;\n height: var(--ds-control-height);\n padding: calc(var(--dimension-space-025) - var(--dimension-stroke-width-012));\n background-color: var(--color-background-secondary);\n border: var(--dimension-stroke-width-012) solid var(--_tab-group-surface-border);\n border-radius: var(--dimension-radius-half);\n box-sizing: border-box;\n}\n\n:host(.tab-group-host--surface) .tab-list {\n background-color: transparent;\n}\n\n.tab {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: var(--ds-control-gap);\n padding: 0 var(--ds-control-padding-inline);\n background-color: transparent;\n border: none;\n border-radius: var(--dimension-radius-half);\n cursor: default;\n -webkit-user-select: none;\n user-select: none;\n color: var(--color-foreground-secondary);\n transition: color var(--effect-motion-short-2);\n flex-shrink: 0;\n height: var(--ds-control-height);\n min-width: var(--ds-control-height);\n box-sizing: border-box;\n --_dot: var(--color-foreground-bold-brand);\n /* Hover/press: `.ds-interaction-fill` fills cover the complete segment. */\n}\n\n.tab--selected {\n color: var(--color-foreground-primary);\n background-color: var(--color-background-primary);\n /* Inset stroke on interaction-fill ::after — stays above hover wash. */\n --ds-interaction-border-width: var(--dimension-stroke-width-012);\n --ds-interaction-border-color: var(--_tab-group-surface-border);\n}\n\n:host(.tab-group-host--surface) .tab--selected {\n background-color: var(--ds-interaction-active);\n}\n\n.tab--selected:not(.ds-interaction-fill) {\n box-shadow: inset 0 0 0 var(--ds-interaction-border-width) var(--ds-interaction-border-color);\n}\n\n/* Inactive: `.ds-control-inactive` (50% opacity) — applied via class in TSX. */\n\n.tab__content {\n position: relative;\n z-index: 2;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n gap: var(--ds-control-gap);\n min-width: 0;\n}\n\n.tab__content--dot {\n padding-right: var(--dimension-space-050);\n}\n\n.tab__label {\n padding: 0 var(--ds-control-label-inset);\n white-space: nowrap;\n}\n\n.tab__icon {\n flex-shrink: 0;\n}\n\n.tab__dot {\n position: absolute;\n top: 0;\n right: 0;\n z-index: 2;\n pointer-events: none;\n}\n\n.on-medium {\n color: var(--color-foreground-on-medium-background-secondary);\n}\n\n.on-medium.tab--selected { color: var(--color-foreground-on-medium-background-primary); }\n\n.on-bold {\n color: var(--color-foreground-on-bold-background-secondary);\n}\n\n.on-bold.tab--selected { color: var(--color-foreground-on-bold-background-primary); }\n\n.on-strong {\n color: var(--color-foreground-on-strong-background-secondary);\n}\n\n.on-strong.tab--selected { color: var(--color-foreground-on-strong-background-primary); }\n\n.on-translucent { color: var(--color-translucent-foreground-secondary); }\n\n.on-translucent.tab--selected { color: var(--color-translucent-foreground-primary); }\n\n.on-inverted { color: var(--color-inverted-foreground-secondary); }\n\n.on-inverted.tab--selected { color: var(--color-inverted-foreground-primary); }\n\n.on-media { color: var(--color-media-foreground-secondary); }\n\n.on-media.tab--selected { color: var(--color-media-foreground-primary); }\n\n.on-always-dark {\n color: var(--color-always-dark-foreground-secondary);\n}\n\n.on-always-dark.tab--selected { color: var(--color-always-dark-foreground-primary); }\n\n.tab-divider {\n display: flex;\n align-items: center;\n justify-content: center;\n box-sizing: border-box;\n width: var(--dimension-stroke-width-012);\n align-self: center;\n padding: 0;\n flex-shrink: 0;\n pointer-events: none;\n}\n\n.tab-divider__line {\n width: var(--dimension-stroke-width-012);\n height: var(--typography-lineheight-sm);\n flex-shrink: 0;\n background-color: var(--_tab-group-divider);\n}\n",
|
|
242
262
|
"type": "registry:ui"
|
|
243
263
|
},
|
|
244
264
|
{
|
|
245
265
|
"path": "src/wc/components/TabGroup/TabGroup.tsx",
|
|
246
|
-
"content": "import { Component, Prop, Event, EventEmitter, Element, Listen, Watch, h, Host } from '@stencil/core';\nimport {\n getSelectableTabs,\n isTabDivider,\n type TabGroupItem,\n type TabItemTab,\n} from './tab-item-utils';\n\nexport type TabBackground =\n | 'faint'\n | 'medium'\n | 'bold'\n | 'strong'\n | 'translucent'\n | 'inverted'\n | 'media'\n | 'always-dark';\n\n@Component({\n tag: 'ds-tab-group',\n styleUrl: 'TabGroup.css',\n scoped: true,\n})\nexport class TabGroup {\n @Element() el!: HTMLElement;\n\n @Prop({ mutable: true }) value: string = '';\n @Prop() tabs: TabGroupItem[] = [];\n @Prop() background: TabBackground | undefined;\n @Prop({ attribute: 'aria-label' }) ariaLabel: string | null = null;\n @Prop({ attribute: 'aria-labelledby' }) ariaLabelledby: string | undefined;\n\n @Event() dsChange!: EventEmitter<string>;\n\n private get selectableTabs(): TabItemTab[] {\n return getSelectableTabs(this.tabs);\n }\n\n private get focusableTabId(): string {\n return this.selectableTabs.some(tab => tab.id === this.value && !tab.isInactive)\n ? this.value\n : (this.selectableTabs.find(tab => !tab.isInactive)?.id ?? '');\n }\n\n private selectTab(id: string, options?: { focus?: boolean }) {\n this.value = id;\n this.dsChange.emit(id);\n if (options?.focus !== false) {\n this.focusTab(id);\n }\n }\n\n private focusTab(id: string) {\n const btn = this.el.querySelector(`[data-tab-id=\"${id}\"]`) as HTMLElement | null;\n btn?.focus({ preventScroll: true });\n }\n\n /** Keep focus on the selected tab when value changes externally (e.g. BarNav menu). */\n @Watch('value')\n onValueChange(next: string, prev: string | undefined) {\n if (prev === undefined || next === prev) return;\n\n requestAnimationFrame(() => {\n const active = document.activeElement as HTMLElement | null;\n if (!active || !this.el.contains(active)) return;\n\n const activeId = active.getAttribute('data-tab-id');\n if (activeId === next) return;\n\n const focusId = this.selectableTabs.some(tab => tab.id === next && !tab.isInactive)\n ? next\n : this.focusableTabId;\n const nextTab = this.el.querySelector(`[data-tab-id=\"${focusId}\"]`) as HTMLElement | null;\n if (nextTab) {\n nextTab.focus({ preventScroll: true });\n } else {\n active.blur();\n }\n });\n }\n\n /**\n * Find the next non-inactive tab index relative to `from`, stepping by `step`.\n * Returns `from` unchanged if no other enabled tab exists.\n */\n private findEnabled(from: number, step: 1 | -1): number {\n const tabs = this.selectableTabs;\n const len = tabs.length;\n for (let n = 0; n < len; n++) {\n const i = (from + step * (n + 1) + len * (n + 1)) % len;\n if (!tabs[i]?.isInactive) return i;\n }\n return from;\n }\n\n @Listen('keydown')\n handleKeyDown(e: KeyboardEvent) {\n const tabs = this.selectableTabs;\n if (!tabs.length) return;\n\n const focusedId = (e.target as Element | null)?.closest<HTMLElement>('[data-tab-id]')?.dataset['tabId'];\n const currentIdx = tabs.findIndex(t => t.id === (focusedId ?? this.focusableTabId));\n\n let nextIdx: number | null = null;\n\n if (e.key === 'ArrowRight') {\n nextIdx = this.findEnabled(currentIdx, 1);\n } else if (e.key === 'ArrowLeft') {\n nextIdx = this.findEnabled(currentIdx, -1);\n }\n\n if (e.key === 'Home') {\n const first = tabs.findIndex(t => !t.isInactive);\n nextIdx = first === -1 ? null : first;\n } else if (e.key === 'End') {\n const lastEnabled = [...tabs].reverse().findIndex(t => !t.isInactive);\n nextIdx = lastEnabled === -1 ? null : tabs.length - 1 - lastEnabled;\n }\n\n if (nextIdx !== null) {\n e.preventDefault();\n const next = tabs[nextIdx];\n this.selectTab(next.id);\n }\n }\n\n private getBgClass(): string {\n if (!this.background) return '';\n if (this.background === 'always-dark') return 'on-always-dark';\n return `on-${this.background}`;\n }\n\n render() {\n const bgClass = this.getBgClass();\n const focusableTabId = this.focusableTabId;\n\n return (\n <Host\n class={{\n 'tab-group-host': true,\n 'tab-group-host--surface': !!bgClass,\n [`tab-group-host--${bgClass}`]: !!bgClass,\n }}\n >\n <div\n role=\"tablist\"\n class=\"tab-list\"\n aria-label={this.ariaLabel}\n aria-labelledby={this.ariaLabelledby}\n >\n {this.tabs.map((tab, index) => {\n if (isTabDivider(tab)) {\n return (\n <div\n key={`divider-${index}`}\n class=\"tab-divider\"\n aria-hidden=\"true\"\n >\n <div class=\"tab-divider__line\" />\n </div>\n );\n }\n\n const isSelected = tab.id === this.value;\n const variant = tab.variant ?? 'label';\n const showIcon = variant === 'icon' || variant === 'icon-label';\n const showLabel = variant === 'label' || variant === 'icon-label';\n const emphasizeLabel = isSelected && !bgClass;\n return (\n <button\n key={tab.id}\n type=\"button\"\n role=\"tab\"\n data-tab-id={tab.id}\n class={{\n tab: true,\n 'tab--selected': isSelected,\n 'ds-control--
|
|
266
|
+
"content": "import { Component, Prop, Event, EventEmitter, Element, Listen, Watch, h, Host } from '@stencil/core';\nimport { CONTROL_TEXT_VARIANT } from '../../utils';\nimport {\n getSelectableTabs,\n isTabDivider,\n type TabGroupItem,\n type TabItemTab,\n} from './tab-item-utils';\n\nexport type TabBackground =\n | 'faint'\n | 'medium'\n | 'bold'\n | 'strong'\n | 'translucent'\n | 'inverted'\n | 'media'\n | 'always-dark';\n\nexport type TabGroupSize = 'sm' | 'md' | 'lg';\n\n@Component({\n tag: 'ds-tab-group',\n styleUrl: 'TabGroup.css',\n scoped: true,\n})\nexport class TabGroup {\n @Element() el!: HTMLElement;\n\n @Prop({ mutable: true }) value: string = '';\n @Prop() tabs: TabGroupItem[] = [];\n /** Control density: 24px small, 32px medium, or 40px large track. */\n @Prop() size: TabGroupSize = 'md';\n @Prop() background: TabBackground | undefined;\n @Prop({ attribute: 'aria-label' }) ariaLabel: string | null = null;\n @Prop({ attribute: 'aria-labelledby' }) ariaLabelledby: string | undefined;\n\n @Event() dsChange!: EventEmitter<string>;\n\n private get selectableTabs(): TabItemTab[] {\n return getSelectableTabs(this.tabs);\n }\n\n private get focusableTabId(): string {\n return this.selectableTabs.some(tab => tab.id === this.value && !tab.isInactive)\n ? this.value\n : (this.selectableTabs.find(tab => !tab.isInactive)?.id ?? '');\n }\n\n private selectTab(id: string, options?: { focus?: boolean }) {\n this.value = id;\n this.dsChange.emit(id);\n if (options?.focus !== false) {\n this.focusTab(id);\n }\n }\n\n private focusTab(id: string) {\n const btn = this.el.querySelector(`[data-tab-id=\"${id}\"]`) as HTMLElement | null;\n btn?.focus({ preventScroll: true });\n }\n\n /** Keep focus on the selected tab when value changes externally (e.g. BarNav menu). */\n @Watch('value')\n onValueChange(next: string, prev: string | undefined) {\n if (prev === undefined || next === prev) return;\n\n requestAnimationFrame(() => {\n const active = document.activeElement as HTMLElement | null;\n if (!active || !this.el.contains(active)) return;\n\n const activeId = active.getAttribute('data-tab-id');\n if (activeId === next) return;\n\n const focusId = this.selectableTabs.some(tab => tab.id === next && !tab.isInactive)\n ? next\n : this.focusableTabId;\n const nextTab = this.el.querySelector(`[data-tab-id=\"${focusId}\"]`) as HTMLElement | null;\n if (nextTab) {\n nextTab.focus({ preventScroll: true });\n } else {\n active.blur();\n }\n });\n }\n\n /**\n * Find the next non-inactive tab index relative to `from`, stepping by `step`.\n * Returns `from` unchanged if no other enabled tab exists.\n */\n private findEnabled(from: number, step: 1 | -1): number {\n const tabs = this.selectableTabs;\n const len = tabs.length;\n for (let n = 0; n < len; n++) {\n const i = (from + step * (n + 1) + len * (n + 1)) % len;\n if (!tabs[i]?.isInactive) return i;\n }\n return from;\n }\n\n @Listen('keydown')\n handleKeyDown(e: KeyboardEvent) {\n const tabs = this.selectableTabs;\n if (!tabs.length) return;\n\n const focusedId = (e.target as Element | null)?.closest<HTMLElement>('[data-tab-id]')?.dataset['tabId'];\n const currentIdx = tabs.findIndex(t => t.id === (focusedId ?? this.focusableTabId));\n\n let nextIdx: number | null = null;\n\n if (e.key === 'ArrowRight') {\n nextIdx = this.findEnabled(currentIdx, 1);\n } else if (e.key === 'ArrowLeft') {\n nextIdx = this.findEnabled(currentIdx, -1);\n }\n\n if (e.key === 'Home') {\n const first = tabs.findIndex(t => !t.isInactive);\n nextIdx = first === -1 ? null : first;\n } else if (e.key === 'End') {\n const lastEnabled = [...tabs].reverse().findIndex(t => !t.isInactive);\n nextIdx = lastEnabled === -1 ? null : tabs.length - 1 - lastEnabled;\n }\n\n if (nextIdx !== null) {\n e.preventDefault();\n const next = tabs[nextIdx];\n this.selectTab(next.id);\n }\n }\n\n private getBgClass(): string {\n if (!this.background) return '';\n if (this.background === 'always-dark') return 'on-always-dark';\n return `on-${this.background}`;\n }\n\n render() {\n const bgClass = this.getBgClass();\n const focusableTabId = this.focusableTabId;\n\n return (\n <Host\n class={{\n 'tab-group-host': true,\n [`tab-group-host--${this.size}`]: true,\n [`ds-control--${this.size}`]: true,\n 'tab-group-host--surface': !!bgClass,\n [`tab-group-host--${bgClass}`]: !!bgClass,\n }}\n >\n <div\n role=\"tablist\"\n class=\"tab-list\"\n aria-label={this.ariaLabel}\n aria-labelledby={this.ariaLabelledby}\n >\n {this.tabs.map((tab, index) => {\n if (isTabDivider(tab)) {\n return (\n <div\n key={`divider-${index}`}\n class=\"tab-divider\"\n aria-hidden=\"true\"\n >\n <div class=\"tab-divider__line\" />\n </div>\n );\n }\n\n const isSelected = tab.id === this.value;\n const variant = tab.variant ?? 'label';\n const showIcon = variant === 'icon' || variant === 'icon-label';\n const showLabel = variant === 'label' || variant === 'icon-label';\n const emphasizeLabel = isSelected && !bgClass;\n return (\n <button\n key={tab.id}\n type=\"button\"\n role=\"tab\"\n data-tab-id={tab.id}\n class={{\n tab: true,\n 'tab--selected': isSelected,\n [`ds-control--${this.size}`]: true,\n 'ds-control--inset': true,\n 'ds-focus-ring-inset': true,\n 'ds-interaction-fill': !tab.isInactive,\n 'ds-interaction-fill--on-faint': bgClass === 'on-faint',\n 'ds-interaction-fill--on-medium': bgClass === 'on-medium',\n 'ds-interaction-fill--on-bold': bgClass === 'on-bold',\n 'ds-interaction-fill--on-strong': bgClass === 'on-strong',\n 'ds-interaction-fill--on-translucent': bgClass === 'on-translucent',\n 'ds-interaction-fill--on-inverted': bgClass === 'on-inverted',\n 'ds-interaction-fill--on-media': bgClass === 'on-media',\n 'ds-interaction-fill--on-always-dark': bgClass === 'on-always-dark',\n 'ds-control-inactive': !!tab.isInactive,\n [bgClass]: !!bgClass,\n }}\n aria-label={variant === 'icon' ? tab.label : undefined}\n aria-selected={isSelected}\n aria-disabled={tab.isInactive ? 'true' : undefined}\n aria-controls={tab.panelId ?? undefined}\n disabled={tab.isInactive}\n tabIndex={tab.id === focusableTabId ? 0 : -1}\n onClick={() => !tab.isInactive && this.selectTab(tab.id)}\n >\n <span class={{\n tab__content: true,\n [`tab__content--${variant}`]: true,\n 'tab__content--dot': !!tab.dot,\n }}>\n {showIcon && (\n <ds-icon\n class=\"tab__icon\"\n name={tab.icon}\n size={this.size}\n color=\"inherit\"\n />\n )}\n {showLabel && (\n <ds-text\n class=\"tab__label\"\n as=\"span\"\n variant={CONTROL_TEXT_VARIANT[this.size]}\n emphasis={emphasizeLabel}\n color=\"inherit\"\n >\n {tab.label}\n </ds-text>\n )}\n {tab.dot && (\n <ds-badge\n class=\"tab__dot\"\n variant=\"dot\"\n hasRing={false}\n label=\"\"\n aria-hidden=\"true\"\n />\n )}\n </span>\n </button>\n );\n })}\n </div>\n </Host>\n );\n }\n}\n",
|
|
247
267
|
"type": "registry:ui"
|
|
248
268
|
},
|
|
249
269
|
{
|