@ds-mo/ui 14.16.0 → 14.17.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 +1 -1
- package/dist/agent.json +167 -11
- package/dist/angular/ds-table-filter.d.ts +44 -0
- package/dist/angular/ds-table-filter.js +57 -0
- 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-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-unfilled.js +1 -1
- package/dist/components/ds-card-chart.js +1 -1
- package/dist/components/ds-card-overview.js +1 -1
- package/dist/components/ds-card-setting.js +1 -1
- package/dist/components/ds-chart.js +1 -1
- package/dist/components/ds-code-block.js +1 -1
- package/dist/components/ds-filter-menu.js +1 -1
- package/dist/components/ds-filter-menu.js.map +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-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-tool-header.js +1 -1
- package/dist/components/ds-panel-tool-search.js +1 -1
- package/dist/components/ds-panel-tools.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-tools.js +1 -1
- package/dist/components/ds-table-filter.d.ts +11 -0
- package/dist/components/ds-table-filter.js +2 -0
- package/dist/components/ds-table-filter.js.map +1 -0
- package/dist/components/ds-table-saved-views.js +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-toolbar.js +1 -1
- package/dist/components/ds-table-toolbar.js.map +1 -1
- package/dist/components/ds-table.js +1 -1
- package/dist/components/ds-toast.js +1 -1
- package/dist/components/ds-tooltip-chart.js +1 -1
- package/dist/components/ds-tooltip.js +1 -1
- package/dist/components/ds-typing-indicator.js +1 -1
- package/dist/components/{p-3dYwA4i_.js → p-Asi9h3pa.js} +2 -2
- package/dist/components/p-BFSLT0yc.js +2 -0
- package/dist/components/p-BFSLT0yc.js.map +1 -0
- package/dist/components/{p-C4p2i59V.js → p-BHz78C8h.js} +2 -2
- package/dist/components/{p-Dcnd5ZX5.js → p-BKp__CQX.js} +2 -2
- package/dist/components/{p-Ct2GVdSI.js → p-Be1sY58w.js} +2 -2
- package/dist/components/{p-vzxD117T.js → p-Bh67XbfB.js} +2 -2
- package/dist/components/{p-C_TOgX82.js → p-Bn8NryHK.js} +2 -2
- package/dist/components/{p-CaAZ-D08.js → p-C4TgWJi9.js} +2 -2
- package/dist/components/{p-COVcyUAA.js → p-CKmfuK7k.js} +2 -2
- package/dist/components/p-CKmfuK7k.js.map +1 -0
- package/dist/components/{p-EWI6ccs-.js → p-CMFvaY05.js} +2 -2
- package/dist/components/{p-BK-JjdoN.js → p-D4K9CE8a.js} +2 -2
- package/dist/components/{p-CxBE3l74.js → p-DEjh3klB.js} +2 -2
- package/dist/components/{p-NdahPypA.js → p-DFQ9mcbK.js} +2 -2
- package/dist/components/{p-WGZWqh-B.js → p-DHuyj-9J.js} +2 -2
- package/dist/components/{p-BUhShHfb.js → p-DIEK54fV.js} +2 -2
- package/dist/components/p-DOqnJVos.js +2 -0
- package/dist/components/{p-CwkcFMMe.js.map → p-DOqnJVos.js.map} +1 -1
- package/dist/components/{p-DRBTr6TS.js → p-DZ55e3j9.js} +2 -2
- package/dist/components/{p-MxptXwlW.js → p-DsJW5W3W.js} +2 -2
- package/dist/components/p-JPTkZ3Ji.js.map +1 -1
- package/dist/components/{p-DAV32g35.js → p-NzGRAPcF.js} +2 -2
- package/dist/components/p-NzGRAPcF.js.map +1 -0
- package/dist/components/{p-BUrbVZJU.js → p-ZZzC52Ea.js} +2 -2
- package/dist/components/{p-C91izpHV.js → p-__lNIImO.js} +2 -2
- package/dist/components/{p-0IbKxhqM.js → p-rrFMAZkj.js} +2 -2
- package/dist/components/{p-Cr2VT5zv.js → p-ruPTIV6t.js} +2 -2
- package/dist/docs/components.json +435 -6
- package/dist/mcp-data/registry/button-unfilled.json +3 -3
- package/dist/mcp-data/registry/filter-menu.json +5 -5
- package/dist/mcp-data/registry/loader.json +2 -1
- package/dist/mcp-data/registry/registry.json +169 -10
- package/dist/mcp-data/registry/select.json +1 -1
- package/dist/mcp-data/registry/table-filter.json +399 -0
- package/dist/mcp-data/registry/table-saved-views.json +1 -1
- package/dist/mcp-data/registry/table-sort.json +2 -2
- package/dist/mcp-data/registry/table-toolbar.json +9 -9
- package/dist/mcp-data/registry/table.json +1 -0
- package/dist/react/components.d.ts +1 -0
- package/dist/react/components.js +1 -0
- package/dist/react/ds-table-filter.d.ts +17 -0
- package/dist/react/ds-table-filter.js +19 -0
- package/dist/types/components/ButtonUnfilled/ButtonUnfilled.d.ts +2 -0
- package/dist/types/components/FilterMenu/FilterMenu.d.ts +2 -0
- package/dist/types/components/Select/Select.d.ts +2 -0
- package/dist/types/components/TableFilter/TableFilter.d.ts +37 -0
- package/dist/types/components/TableToolbar/TableToolbar.d.ts +0 -6
- package/dist/types/components.d.ts +160 -4
- package/dist/vue/components.d.ts +1 -0
- package/dist/vue/components.js +1 -0
- package/dist/vue/ds-table-filter.d.ts +3 -0
- package/dist/vue/ds-table-filter.js +29 -0
- package/package.json +1 -1
- package/dist/components/p-COVcyUAA.js.map +0 -1
- package/dist/components/p-CwkcFMMe.js +0 -2
- package/dist/components/p-DAV32g35.js.map +0 -1
- /package/dist/components/{p-3dYwA4i_.js.map → p-Asi9h3pa.js.map} +0 -0
- /package/dist/components/{p-C4p2i59V.js.map → p-BHz78C8h.js.map} +0 -0
- /package/dist/components/{p-Dcnd5ZX5.js.map → p-BKp__CQX.js.map} +0 -0
- /package/dist/components/{p-Ct2GVdSI.js.map → p-Be1sY58w.js.map} +0 -0
- /package/dist/components/{p-vzxD117T.js.map → p-Bh67XbfB.js.map} +0 -0
- /package/dist/components/{p-C_TOgX82.js.map → p-Bn8NryHK.js.map} +0 -0
- /package/dist/components/{p-CaAZ-D08.js.map → p-C4TgWJi9.js.map} +0 -0
- /package/dist/components/{p-EWI6ccs-.js.map → p-CMFvaY05.js.map} +0 -0
- /package/dist/components/{p-BK-JjdoN.js.map → p-D4K9CE8a.js.map} +0 -0
- /package/dist/components/{p-CxBE3l74.js.map → p-DEjh3klB.js.map} +0 -0
- /package/dist/components/{p-NdahPypA.js.map → p-DFQ9mcbK.js.map} +0 -0
- /package/dist/components/{p-WGZWqh-B.js.map → p-DHuyj-9J.js.map} +0 -0
- /package/dist/components/{p-BUhShHfb.js.map → p-DIEK54fV.js.map} +0 -0
- /package/dist/components/{p-DRBTr6TS.js.map → p-DZ55e3j9.js.map} +0 -0
- /package/dist/components/{p-MxptXwlW.js.map → p-DsJW5W3W.js.map} +0 -0
- /package/dist/components/{p-BUrbVZJU.js.map → p-ZZzC52Ea.js.map} +0 -0
- /package/dist/components/{p-C91izpHV.js.map → p-__lNIImO.js.map} +0 -0
- /package/dist/components/{p-0IbKxhqM.js.map → p-rrFMAZkj.js.map} +0 -0
- /package/dist/components/{p-Cr2VT5zv.js.map → p-ruPTIV6t.js.map} +0 -0
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
"Set isInset only when the button is nested inside a same-size control. The default single inset removes 4px overall; insetDepth double removes 8px overall so the parent can add 4px per edge while preserving its outer alignment box. Double inset falls back to single at xs to protect minimum icon geometry.",
|
|
68
68
|
"A true pressed toggle and owner-controlled isActive emphasis share the selected visual treatment, but only pressed supplies toggle semantics and dsChange.",
|
|
69
69
|
"General content UI keeps the selected interaction fill and border for pressed or active emphasis.",
|
|
70
|
-
"Shell, navigation, and tool-rail composites use isActive with foreground
|
|
70
|
+
"Shell, navigation, and tool-rail composites use isActive with primary foreground across the complete control and no selected fill or border.",
|
|
71
71
|
"The omitted default surface treatment is for primary and secondary parent surfaces and uses the brand-active selected fill; pass faint explicitly on faint surfaces to use the neutral active fill.",
|
|
72
72
|
"Surface context must match the actual parent surface; translucent, inverted, media, and always-dark treatments are reserved for those surfaces.",
|
|
73
73
|
"Loading prevents duplicate activation without applying inactive opacity or removing the current keyboard focus.",
|
|
@@ -636,12 +636,12 @@
|
|
|
636
636
|
"files": [
|
|
637
637
|
{
|
|
638
638
|
"path": "src/wc/components/ButtonUnfilled/ButtonUnfilled.css",
|
|
639
|
-
"content": "@import '../../utils/button-base.css';\n@import '../../utils/table-caption-control.css';\n\n:host {\n --ds-button-unfilled-fg: var(--color-foreground-secondary);\n --ds-button-unfilled-fg-active: var(--color-foreground-primary);\n --ds-button-unfilled-dot-ring: var(--color-background-secondary);\n --ds-button-unfilled-border: var(--color-border-secondary);\n}\n\n.button-unfilled {\n background: transparent;\n color: var(--ds-button-unfilled-fg);\n /* Hover/press/selected fill: `.ds-interaction-fill` (+ `--selected` / `--on-*`).\n Active foreground changes remain independent from popup-open pressed paint. */\n}\n\n.button-unfilled--active {\n color: var(--ds-button-unfilled-fg-active);\n}\n\n/*\n *
|
|
639
|
+
"content": "@import '../../utils/button-base.css';\n@import '../../utils/table-caption-control.css';\n\n:host {\n --ds-button-unfilled-fg: var(--color-foreground-secondary);\n --ds-button-unfilled-fg-active: var(--color-foreground-primary);\n --ds-button-unfilled-dot-ring: var(--color-background-secondary);\n --ds-button-unfilled-border: var(--color-border-secondary);\n}\n\n.button-unfilled {\n background: transparent;\n color: var(--ds-button-unfilled-fg);\n /* Hover/press/selected fill: `.ds-interaction-fill` (+ `--selected` / `--on-*`).\n Active foreground changes remain independent from popup-open pressed paint. */\n}\n\n.button-unfilled--active {\n color: var(--ds-button-unfilled-fg-active);\n}\n\n/*\n * An open popup is a transient pressed state, not a selected state. Keep the\n * pressed wash visible for the popup's complete rendered lifecycle without\n * promoting the resting foreground or selected fill.\n */\n/* Inset stroke via interaction-fill ::after (above selected / hover washes). */\n.button-unfilled--bordered {\n --ds-interaction-border-color: var(--ds-button-unfilled-border);\n}\n\n/* Inactive: `.ds-control-inactive` (50% opacity). */\n\n/* Foreground / chrome tokens per surface — interaction tokens come from util `--on-*`. */\n.button-unfilled--background-medium {\n --ds-button-unfilled-fg: var(--color-foreground-on-medium-background-secondary);\n --ds-button-unfilled-fg-active: var(--color-foreground-on-medium-background-primary);\n --ds-button-unfilled-dot-ring: var(--color-background-medium-neutral);\n --ds-button-unfilled-border: var(--color-border-on-medium-background-secondary);\n}\n\n.button-unfilled--background-bold {\n --ds-button-unfilled-fg: var(--color-foreground-on-bold-background-secondary);\n --ds-button-unfilled-fg-active: var(--color-foreground-on-bold-background-primary);\n --ds-button-unfilled-dot-ring: var(--color-background-bold-neutral);\n --ds-button-unfilled-border: var(--color-border-on-bold-background-secondary);\n}\n\n.button-unfilled--background-strong {\n --ds-button-unfilled-fg: var(--color-foreground-on-strong-background-secondary);\n --ds-button-unfilled-fg-active: var(--color-foreground-on-strong-background-primary);\n --ds-button-unfilled-dot-ring: var(--color-background-strong-neutral);\n --ds-button-unfilled-border: var(--color-border-on-strong-background-secondary);\n}\n\n.button-unfilled--background-translucent {\n --ds-button-unfilled-fg: var(--color-translucent-foreground-secondary);\n --ds-button-unfilled-fg-active: var(--color-translucent-foreground-primary);\n --ds-button-unfilled-dot-ring: var(--color-translucent-translucent);\n --ds-button-unfilled-border: var(--color-translucent-border-secondary);\n}\n\n.button-unfilled--background-inverted {\n --ds-button-unfilled-fg: var(--color-inverted-foreground-secondary);\n --ds-button-unfilled-fg-active: var(--color-inverted-foreground-primary);\n --ds-button-unfilled-dot-ring: var(--color-inverted-background);\n --ds-button-unfilled-border: var(--color-inverted-border-secondary);\n}\n\n.button-unfilled--background-media {\n --ds-button-unfilled-fg: var(--color-media-foreground-secondary);\n --ds-button-unfilled-fg-active: var(--color-media-foreground-primary);\n --ds-button-unfilled-dot-ring: var(--color-media-background);\n --ds-button-unfilled-border: var(--color-media-border-secondary);\n}\n\n.button-unfilled--on-always-dark {\n --ds-button-unfilled-fg: var(--color-always-dark-foreground-secondary);\n --ds-button-unfilled-fg-active: var(--color-always-dark-foreground-primary);\n --ds-button-unfilled-dot-ring: var(--color-always-dark-background);\n --ds-button-unfilled-border: var(--color-always-dark-border-secondary);\n}\n\n/*\n * After surface ring defaults: selected fill remaps the knock-out to the active\n * wash so the badge halo matches the selected background.\n */\n.button-unfilled.ds-interaction-fill--selected {\n --ds-button-unfilled-dot-ring: var(--ds-interaction-active);\n}\n",
|
|
640
640
|
"type": "registry:ui"
|
|
641
641
|
},
|
|
642
642
|
{
|
|
643
643
|
"path": "src/wc/components/ButtonUnfilled/ButtonUnfilled.tsx",
|
|
644
|
-
"content": "import { Component, Element, Event, EventEmitter, h, Host, Method, Prop, State, Watch } from '@stencil/core';\nimport { controlWidthClass } from '../../utils';\nimport { beginElevatedControlPress } from '../../utils/control-press';\nimport { renderButtonContent } from '../../utils/button-render';\nimport type { ControlInsetDepth } from '../../utils/control-text';\nimport { observeTableCaptionCompact } from '../../utils/table-caption-compact';\nimport type {\n ButtonPopup,\n ButtonSize,\n ButtonVariant,\n ButtonWidth,\n} from '../../utils/button-types';\n\nexport type ButtonUnfilledBackground =\n | 'faint'\n | 'medium'\n | 'bold'\n | 'strong'\n | 'translucent'\n | 'inverted'\n | 'media'\n | 'always-dark';\n\nexport type ButtonUnfilledVariant = ButtonVariant;\nexport type ButtonUnfilledSize = ButtonSize;\nexport type ButtonUnfilledWidth = ButtonWidth;\nexport type ButtonUnfilledPopup = ButtonPopup;\n\n@Component({\n tag: 'ds-button-unfilled',\n styleUrl: 'ButtonUnfilled.css',\n scoped: true,\n})\nexport class ButtonUnfilled {\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: ButtonUnfilledVariant = 'label';\n\n /** Control density (height, padding, icon, type). */\n @Prop() size: ButtonUnfilledSize = '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: ButtonUnfilledWidth = 'hug';\n\n /** Visible text for `label` / `icon-label` variants. */\n @Prop() label: string = '';\n\n /** Icon name passed to <ds-icon> for `icon` / `icon-label` variants. */\n @Prop() icon: string = '';\n\n /**\n * Owner-controlled visual emphasis inside a composite. This does not add\n * toggle semantics and does not emit dsChange.\n */\n @Prop() isActive: boolean = false;\n\n /**\n * When active, render the selected interaction fill.\n * Default `true` for general UI. Shell chrome (nav / tool rails) should pass\n * `false` so selection is foreground-only (primary color, no fill).\n */\n @Prop() activeFill: boolean = true;\n\n /** Show a 1px secondary inset border. Default on; shell chrome can pass `false`. */\n @Prop() hasBorder: boolean = true;\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 /** Show a notification dot at the top-right of the icon zone (icon variant only). */\n @Prop() dot: boolean = false;\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 /** Actual parent surface context. Omit on primary and secondary surfaces. */\n @Prop() background: ButtonUnfilledBackground | undefined;\n\n /** Accessible name override. Required for icon-only buttons. */\n @Prop({ attribute: 'aria-label' }) ariaLabel: string | null = null;\n @Prop() controls: string | undefined;\n /**\n * Controlled disclosure or popup state forwarded to `aria-expanded`.\n * Popup triggers hold only the pressed wash while open; their resting\n * foreground is unchanged. Ordinary disclosures remain visually neutral.\n */\n @Prop() expanded: boolean | undefined;\n @Prop() haspopup: ButtonUnfilledPopup | undefined;\n /**\n * Controlled state for a genuine toggle button. Adds aria-pressed, promotes\n * active styling, and makes activation emit dsChange with the requested state.\n */\n @Prop() pressed: boolean | undefined;\n\n /**\n * Collapse the visible label and chevron to an icon-only control when the\n * owning `ds-table` caption is narrower than 900px. The trigger omits those\n * parts rather than clipping them.\n */\n @Prop({ reflect: true }) collapseLabel: boolean = false;\n\n /**\n * Mark the button as a Menu trigger. Implies `aria-haspopup=\"menu\"` and covers\n * both menu-button shapes:\n *\n * - `label` / `icon-label` — the action *has* a menu; a trailing chevron carries\n * the affordance.\n * - `icon` — the button *is* a menu. No chevron is added, so the glyph must\n * convey the menu on its own. Use `Ellipses` for generic more-options;\n * use a specific icon when the menu has a named purpose, such as\n * `Table` for Customize table.\n *\n * Use `haspopup` directly for non-menu popups.\n */\n @Prop() hasMenu: boolean = false;\n\n /**\n * Native `tabindex` for roving keyboard groups in shell chrome.\n * Omit for the default button tab stop (`0`).\n */\n @Prop({ attribute: 'tab-index' }) focusTabIndex?: number;\n\n @Event() dsClick!: EventEmitter<MouseEvent>;\n @Event() dsChange!: EventEmitter<boolean>;\n\n @State() private captionCompact = false;\n\n private buttonEl: HTMLButtonElement | null = null;\n private captionCompactDisconnect: (() => void) | undefined;\n\n componentDidLoad(): void {\n this.syncCaptionCompactObserver();\n }\n\n disconnectedCallback(): void {\n this.disconnectCaptionCompactObserver();\n }\n\n @Watch('collapseLabel')\n onCollapseLabelChange() {\n this.syncCaptionCompactObserver();\n }\n\n @Method()\n async setFocus() {\n 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 if (this.pressed !== undefined) this.dsChange.emit(!this.pressed);\n };\n\n private get captionIconOnly(): boolean {\n return (\n this.collapseLabel &&\n this.captionCompact &&\n this.variant === 'icon-label' &&\n Boolean(this.icon)\n );\n }\n\n private get visualVariant(): ButtonUnfilledVariant {\n return this.captionIconOnly ? 'icon' : this.variant;\n }\n\n private get showDot(): boolean {\n return this.variant === 'icon' && this.dot && !this.isLoading;\n }\n\n private syncCaptionCompactObserver(): void {\n this.disconnectCaptionCompactObserver();\n if (!this.collapseLabel) {\n if (this.captionCompact) this.captionCompact = false;\n return;\n }\n this.captionCompactDisconnect = observeTableCaptionCompact(this.el, compact => {\n if (this.captionCompact !== compact) this.captionCompact = compact;\n });\n }\n\n private disconnectCaptionCompactObserver(): void {\n this.captionCompactDisconnect?.();\n this.captionCompactDisconnect = undefined;\n }\n\n private get resolvedHaspopup(): ButtonUnfilledPopup | undefined {\n return this.haspopup ?? (this.hasMenu ? 'menu' : undefined);\n }\n\n private get doubleInset(): boolean {\n return this.isInset && this.insetDepth === 'double' && this.size !== 'xs';\n }\n\n /** Only popup triggers hold a pressed visual while expanded. */\n private get expandedPopup(): boolean {\n return this.expanded === true && this.resolvedHaspopup !== undefined && !this.isInactive;\n }\n\n private get visuallyActive(): boolean {\n return this.isActive || this.pressed === true;\n }\n\n /** Knock-out ring: selected fill → active wash; otherwise surface token. */\n private get dotRing(): string {\n if ((this.isActive || this.pressed === true) && this.activeFill) {\n return 'var(--ds-interaction-active)';\n }\n return 'var(--ds-button-unfilled-dot-ring)';\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 render() {\n const bg = this.background;\n const cls: Record<string, boolean> = {\n 'button-unfilled': true,\n 'ds-button': true,\n 'ds-focus-ring-inset': true,\n 'ds-control-press-scale': this.pressScale,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--selected':\n (this.isActive || this.pressed === true) &&\n this.activeFill &&\n !this.isInactive,\n 'ds-interaction-fill--on-faint': bg === 'faint',\n 'ds-interaction-fill--on-medium': bg === 'medium',\n 'ds-interaction-fill--on-bold': bg === 'bold',\n 'ds-interaction-fill--on-strong': bg === 'strong',\n 'ds-interaction-fill--on-translucent': bg === 'translucent',\n 'ds-interaction-fill--on-inverted': bg === 'inverted',\n 'ds-interaction-fill--on-media': bg === 'media',\n 'ds-interaction-fill--on-always-dark': bg === 'always-dark',\n 'button-unfilled--active': this.visuallyActive,\n 'button-unfilled--expanded': this.expandedPopup,\n 'ds-button--expanded': this.expandedPopup,\n 'button-unfilled--bordered': this.hasBorder,\n 'ds-button--bordered': this.hasBorder,\n 'button-unfilled--rounded': this.rounded,\n 'ds-button--rounded': this.rounded,\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-unfilled--icon': this.visualVariant === 'icon',\n 'ds-button--icon': this.visualVariant === 'icon',\n 'button-unfilled--label': this.visualVariant === 'label',\n 'button-unfilled--icon-label': this.visualVariant === 'icon-label',\n 'button-unfilled--background-faint': bg === 'faint',\n 'button-unfilled--background-medium': bg === 'medium',\n 'button-unfilled--background-bold': bg === 'bold',\n 'button-unfilled--background-strong': bg === 'strong',\n 'button-unfilled--background-translucent': bg === 'translucent',\n 'button-unfilled--background-inverted': bg === 'inverted',\n 'button-unfilled--background-media': bg === 'media',\n 'button-unfilled--on-always-dark': bg === 'always-dark',\n };\n\n return (\n <Host\n class={{\n 'button-unfilled-host': true,\n 'button-unfilled-host--icon': this.visualVariant === 'icon',\n 'ds-button-host--icon': this.visualVariant === 'icon',\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-table-caption-control': this.collapseLabel,\n 'ds-table-caption-control--compact': this.captionIconOnly,\n ...controlWidthClass(this.width),\n }}\n tabIndex={-1}\n >\n <button\n ref={el => {\n this.buttonEl = el ?? null;\n }}\n type={this.type}\n class={cls}\n disabled={this.isInactive}\n tabIndex={this.focusTabIndex ?? 0}\n aria-label={this.accessibleName}\n aria-busy={this.isLoading ? 'true' : undefined}\n aria-disabled={this.isLoading ? 'true' : undefined}\n aria-controls={this.controls}\n aria-expanded={this.expanded === undefined ? undefined : String(this.expanded)}\n aria-haspopup={this.resolvedHaspopup}\n aria-pressed={this.pressed === undefined ? undefined : String(this.pressed)}\n onPointerDown={event =>\n beginElevatedControlPress(\n event,\n this.pressScale && !this.isInactive && !this.isLoading,\n )\n }\n onClick={this.handleClick}\n >\n {renderButtonContent({\n namespace: 'button-unfilled',\n variant: this.visualVariant,\n size: this.size,\n label: this.label,\n icon: this.icon,\n hasMenu: this.hasMenu,\n isLoading: this.isLoading,\n dot: {\n visible: this.showDot,\n background: this.dotRing,\n },\n })}\n </button>\n </Host>\n );\n }\n}\n",
|
|
644
|
+
"content": "import { Component, Element, Event, EventEmitter, h, Host, Method, Prop, State, Watch } from '@stencil/core';\nimport { controlWidthClass } from '../../utils';\nimport { beginElevatedControlPress } from '../../utils/control-press';\nimport { renderButtonContent } from '../../utils/button-render';\nimport type { ControlInsetDepth } from '../../utils/control-text';\nimport { observeTableCaptionCompact } from '../../utils/table-caption-compact';\nimport type {\n ButtonPopup,\n ButtonSize,\n ButtonVariant,\n ButtonWidth,\n} from '../../utils/button-types';\n\nexport type ButtonUnfilledBackground =\n | 'faint'\n | 'medium'\n | 'bold'\n | 'strong'\n | 'translucent'\n | 'inverted'\n | 'media'\n | 'always-dark';\n\nexport type ButtonUnfilledVariant = ButtonVariant;\nexport type ButtonUnfilledSize = ButtonSize;\nexport type ButtonUnfilledWidth = ButtonWidth;\nexport type ButtonUnfilledPopup = ButtonPopup;\n\n@Component({\n tag: 'ds-button-unfilled',\n styleUrl: 'ButtonUnfilled.css',\n scoped: true,\n})\nexport class ButtonUnfilled {\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: ButtonUnfilledVariant = 'label';\n\n /** Control density (height, padding, icon, type). */\n @Prop() size: ButtonUnfilledSize = '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: ButtonUnfilledWidth = 'hug';\n\n /** Visible text for `label` / `icon-label` variants. */\n @Prop() label: string = '';\n\n /** Icon name passed to <ds-icon> for `icon` / `icon-label` variants. */\n @Prop() icon: string = '';\n\n /**\n * Owner-controlled visual emphasis inside a composite. This does not add\n * toggle semantics and does not emit dsChange.\n */\n @Prop() isActive: boolean = false;\n\n /**\n * When active, render the selected interaction fill.\n * Default `true` for general UI. Shell chrome (nav / tool rails) should pass\n * `false` so selection is foreground-only (primary color, no fill).\n */\n @Prop() activeFill: boolean = true;\n\n /** Show a 1px secondary inset border. Default on; shell chrome can pass `false`. */\n @Prop() hasBorder: boolean = true;\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 /** Show a notification dot at the top-right of the icon zone (icon variant only). */\n @Prop() dot: boolean = false;\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 /** Actual parent surface context. Omit on primary and secondary surfaces. */\n @Prop() background: ButtonUnfilledBackground | undefined;\n\n /** Accessible name override. Required for icon-only buttons. */\n @Prop({ attribute: 'aria-label' }) ariaLabel: string | null = null;\n @Prop() controls: string | undefined;\n /**\n * Controlled disclosure or popup state forwarded to `aria-expanded`.\n * Popup triggers hold only the pressed wash while open; their resting\n * foreground is unchanged. Ordinary disclosures remain visually neutral.\n */\n @Prop() expanded: boolean | undefined;\n @Prop() haspopup: ButtonUnfilledPopup | undefined;\n /**\n * Controlled state for a genuine toggle button. Adds aria-pressed, promotes\n * active styling, and makes activation emit dsChange with the requested state.\n */\n @Prop() pressed: boolean | undefined;\n\n /**\n * Collapse the visible label and chevron to an icon-only control when the\n * owning `ds-table` caption is narrower than 900px. The trigger omits those\n * parts rather than clipping them.\n */\n @Prop({ reflect: true }) collapseLabel: boolean = false;\n\n /**\n * Mark the button as a Menu trigger. Implies `aria-haspopup=\"menu\"` and covers\n * both menu-button shapes:\n *\n * - `label` / `icon-label` — the action *has* a menu; a trailing chevron carries\n * the affordance.\n * - `icon` — the button *is* a menu. No chevron is added, so the glyph must\n * convey the menu on its own. Use `Ellipses` for generic more-options;\n * use a specific icon when the menu has a named purpose, such as\n * `Table` for Customize table.\n *\n * Use `haspopup` directly for non-menu popups.\n */\n @Prop() hasMenu: boolean = false;\n\n /**\n * Native `tabindex` for roving keyboard groups in shell chrome.\n * Omit for the default button tab stop (`0`).\n */\n @Prop({ attribute: 'tab-index' }) focusTabIndex?: number;\n\n @Event() dsClick!: EventEmitter<MouseEvent>;\n @Event() dsChange!: EventEmitter<boolean>;\n\n @State() private captionCompact = false;\n\n private buttonEl: HTMLButtonElement | null = null;\n private captionCompactDisconnect: (() => void) | undefined;\n private hasLoaded = false;\n\n componentDidLoad(): void {\n this.hasLoaded = true;\n this.syncCaptionCompactObserver();\n }\n\n connectedCallback(): void {\n if (this.hasLoaded) this.syncCaptionCompactObserver();\n }\n\n disconnectedCallback(): void {\n this.disconnectCaptionCompactObserver();\n }\n\n @Watch('collapseLabel')\n onCollapseLabelChange() {\n this.syncCaptionCompactObserver();\n }\n\n @Method()\n async setFocus() {\n 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 if (this.pressed !== undefined) this.dsChange.emit(!this.pressed);\n };\n\n private get captionIconOnly(): boolean {\n return (\n this.collapseLabel &&\n this.captionCompact &&\n this.variant === 'icon-label' &&\n Boolean(this.icon)\n );\n }\n\n private get visualVariant(): ButtonUnfilledVariant {\n return this.captionIconOnly ? 'icon' : this.variant;\n }\n\n private get showDot(): boolean {\n return this.variant === 'icon' && this.dot && !this.isLoading;\n }\n\n private syncCaptionCompactObserver(): void {\n this.disconnectCaptionCompactObserver();\n if (!this.collapseLabel) {\n if (this.captionCompact) this.captionCompact = false;\n return;\n }\n this.captionCompactDisconnect = observeTableCaptionCompact(this.el, compact => {\n if (this.captionCompact !== compact) this.captionCompact = compact;\n });\n }\n\n private disconnectCaptionCompactObserver(): void {\n this.captionCompactDisconnect?.();\n this.captionCompactDisconnect = undefined;\n }\n\n private get resolvedHaspopup(): ButtonUnfilledPopup | undefined {\n return this.haspopup ?? (this.hasMenu ? 'menu' : undefined);\n }\n\n private get doubleInset(): boolean {\n return this.isInset && this.insetDepth === 'double' && this.size !== 'xs';\n }\n\n /** Only popup triggers hold a pressed visual while expanded. */\n private get expandedPopup(): boolean {\n return this.expanded === true && this.resolvedHaspopup !== undefined && !this.isInactive;\n }\n\n private get visuallyActive(): boolean {\n return this.isActive || this.pressed === true;\n }\n\n /** Knock-out ring: selected fill → active wash; otherwise surface token. */\n private get dotRing(): string {\n if ((this.isActive || this.pressed === true) && this.activeFill) {\n return 'var(--ds-interaction-active)';\n }\n return 'var(--ds-button-unfilled-dot-ring)';\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 render() {\n const bg = this.background;\n const cls: Record<string, boolean> = {\n 'button-unfilled': true,\n 'ds-button': true,\n 'ds-focus-ring-inset': true,\n 'ds-control-press-scale': this.pressScale,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--selected':\n (this.isActive || this.pressed === true) &&\n this.activeFill &&\n !this.isInactive,\n 'ds-interaction-fill--on-faint': bg === 'faint',\n 'ds-interaction-fill--on-medium': bg === 'medium',\n 'ds-interaction-fill--on-bold': bg === 'bold',\n 'ds-interaction-fill--on-strong': bg === 'strong',\n 'ds-interaction-fill--on-translucent': bg === 'translucent',\n 'ds-interaction-fill--on-inverted': bg === 'inverted',\n 'ds-interaction-fill--on-media': bg === 'media',\n 'ds-interaction-fill--on-always-dark': bg === 'always-dark',\n 'button-unfilled--active': this.visuallyActive,\n 'button-unfilled--expanded': this.expandedPopup,\n 'ds-button--expanded': this.expandedPopup,\n 'button-unfilled--bordered': this.hasBorder,\n 'ds-button--bordered': this.hasBorder,\n 'button-unfilled--rounded': this.rounded,\n 'ds-button--rounded': this.rounded,\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-unfilled--icon': this.visualVariant === 'icon',\n 'ds-button--icon': this.visualVariant === 'icon',\n 'button-unfilled--label': this.visualVariant === 'label',\n 'button-unfilled--icon-label': this.visualVariant === 'icon-label',\n 'button-unfilled--background-faint': bg === 'faint',\n 'button-unfilled--background-medium': bg === 'medium',\n 'button-unfilled--background-bold': bg === 'bold',\n 'button-unfilled--background-strong': bg === 'strong',\n 'button-unfilled--background-translucent': bg === 'translucent',\n 'button-unfilled--background-inverted': bg === 'inverted',\n 'button-unfilled--background-media': bg === 'media',\n 'button-unfilled--on-always-dark': bg === 'always-dark',\n };\n\n return (\n <Host\n class={{\n 'button-unfilled-host': true,\n 'button-unfilled-host--icon': this.visualVariant === 'icon',\n 'ds-button-host--icon': this.visualVariant === 'icon',\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-table-caption-control': this.collapseLabel,\n 'ds-table-caption-control--compact': this.captionIconOnly,\n ...controlWidthClass(this.width),\n }}\n tabIndex={-1}\n >\n <button\n ref={el => {\n this.buttonEl = el ?? null;\n }}\n type={this.type}\n class={cls}\n disabled={this.isInactive}\n tabIndex={this.focusTabIndex ?? 0}\n aria-label={this.accessibleName}\n aria-busy={this.isLoading ? 'true' : undefined}\n aria-disabled={this.isLoading ? 'true' : undefined}\n aria-controls={this.controls}\n aria-expanded={this.expanded === undefined ? undefined : String(this.expanded)}\n aria-haspopup={this.resolvedHaspopup}\n aria-pressed={this.pressed === undefined ? undefined : String(this.pressed)}\n onPointerDown={event =>\n beginElevatedControlPress(\n event,\n this.pressScale && !this.isInactive && !this.isLoading,\n )\n }\n onClick={this.handleClick}\n >\n {renderButtonContent({\n namespace: 'button-unfilled',\n variant: this.visualVariant,\n size: this.size,\n label: this.label,\n icon: this.icon,\n hasMenu: this.hasMenu,\n isLoading: this.isLoading,\n dot: {\n visible: this.showDot,\n background: this.dotRing,\n },\n })}\n </button>\n </Host>\n );\n }\n}\n",
|
|
645
645
|
"type": "registry:ui"
|
|
646
646
|
}
|
|
647
647
|
]
|
|
@@ -212,7 +212,7 @@
|
|
|
212
212
|
"type": "string",
|
|
213
213
|
"resolvedType": "string",
|
|
214
214
|
"attribute": "menu-label",
|
|
215
|
-
"default": "'
|
|
215
|
+
"default": "'Filters'",
|
|
216
216
|
"required": false,
|
|
217
217
|
"mutable": false,
|
|
218
218
|
"description": "Accessible name for the non-modal filter dialog."
|
|
@@ -266,7 +266,7 @@
|
|
|
266
266
|
"type": "string",
|
|
267
267
|
"resolvedType": "string",
|
|
268
268
|
"attribute": "trigger-label",
|
|
269
|
-
"default": "'
|
|
269
|
+
"default": "'Filters'",
|
|
270
270
|
"required": false,
|
|
271
271
|
"mutable": false,
|
|
272
272
|
"description": "Select trigger text. The selected count is appended automatically."
|
|
@@ -476,7 +476,7 @@
|
|
|
476
476
|
"type": "string",
|
|
477
477
|
"resolvedType": "string",
|
|
478
478
|
"attribute": "menu-label",
|
|
479
|
-
"default": "'
|
|
479
|
+
"default": "'Filters'",
|
|
480
480
|
"required": false,
|
|
481
481
|
"mutable": false,
|
|
482
482
|
"description": "Accessible name for the non-modal filter dialog."
|
|
@@ -530,7 +530,7 @@
|
|
|
530
530
|
"type": "string",
|
|
531
531
|
"resolvedType": "string",
|
|
532
532
|
"attribute": "trigger-label",
|
|
533
|
-
"default": "'
|
|
533
|
+
"default": "'Filters'",
|
|
534
534
|
"required": false,
|
|
535
535
|
"mutable": false,
|
|
536
536
|
"description": "Select trigger text. The selected count is appended automatically."
|
|
@@ -661,7 +661,7 @@
|
|
|
661
661
|
},
|
|
662
662
|
{
|
|
663
663
|
"path": "src/wc/components/FilterMenu/FilterMenu.tsx",
|
|
664
|
-
"content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Listen,\n Prop,\n State,\n Watch,\n} from '@stencil/core';\nimport {\n CONTROL_TEXT_VARIANT,\n controlWidthClass,\n resolveChoicePopupAlignOffset,\n resolveCssLengthPx,\n resolveMotionTimeMs,\n TOKEN_CSS_LENGTHS,\n TOKEN_DEFAULTS,\n type ControlWidth,\n} from '../../utils';\nimport { observeTableCaptionCompact } from '../../utils/table-caption-compact';\nimport type { AnchoredAlign, AnchoredSide } from '../../utils/anchored-position';\nimport { AnchoredPositionController } from '../../utils/anchored-position-controller';\nimport { ChoiceOptionRow } from '../../utils/choice-list-parts';\nimport { choiceListUsesSubtext } from '../../utils/choice-list';\n\nexport type FilterMenuFilterKind = 'single' | 'multiple' | 'boolean' | 'date';\nexport type FilterMenuSize = 'lg' | 'md' | 'sm' | 'xs';\nexport type FilterMenuWidth = ControlWidth;\n\nexport interface FilterMenuOption {\n label: string;\n value: string;\n description?: string;\n isInactive?: boolean;\n}\n\nexport interface FilterMenuFilter {\n id: string;\n label: string;\n kind: FilterMenuFilterKind;\n options?: FilterMenuOption[];\n description?: string;\n fieldLabel?: string;\n /** Draw a standard menu-section divider after this category. */\n divider?: boolean;\n}\n\nexport type FilterMenuValue = string[] | boolean | string;\nexport type FilterMenuValues = Record<string, FilterMenuValue | undefined>;\n\nexport interface FilterMenuChangeDetail {\n filterId: string;\n value: FilterMenuValue;\n}\n\nconst FILTER_MENU_FOCUSABLE_SELECTOR = [\n 'a[href]',\n 'button:not([disabled])',\n 'input:not([disabled]):not([type=\"hidden\"])',\n 'select:not([disabled])',\n 'textarea:not([disabled])',\n '[tabindex]:not([tabindex=\"-1\"])',\n].join(',');\n\nlet filterMenuId = 0;\n\nconst ICON_SIZE: Record<FilterMenuSize, 'lg' | 'md' | 'sm' | 'xs'> = {\n lg: 'lg',\n md: 'md',\n sm: 'sm',\n xs: 'xs',\n};\n\n@Component({\n tag: 'ds-filter-menu',\n styleUrl: 'FilterMenu.css',\n scoped: true,\n})\nexport class FilterMenu {\n @Element() el!: HTMLElement;\n\n /** Controlled popup visibility. */\n @Prop({ mutable: true }) open: boolean = false;\n /** Select trigger text. The selected count is appended automatically. */\n @Prop() triggerLabel: string = 'Filter';\n /** Optional select trigger prefix icon name. */\n @Prop() icon: string | undefined = 'Filters';\n /** Select trigger density. */\n @Prop() size: FilterMenuSize = 'md';\n /** Select trigger width fit. */\n @Prop() width: FilterMenuWidth = 'hug';\n /** Show the surface-aware inset border around the select trigger. */\n @Prop() hasBorder: boolean = true;\n /** Show selected interaction fill when one or more criteria are active. */\n @Prop() activeFill: boolean = false;\n /**\n * Opt into table-caption icon-only chrome below 900px. The trigger omits its\n * visible label and chevron; keep an accessible name via aria-label.\n */\n @Prop({ reflect: true }) collapseLabel: boolean = false;\n /** ID applied to the internal select trigger. */\n @Prop() inputId: string | undefined;\n /** Direct accessible name for the internal select trigger. */\n @Prop({ attribute: 'aria-label' }) ariaLabel: string | null = null;\n /** Product-owned filter categories and option definitions. */\n @Prop() filters: FilterMenuFilter[] = [];\n /** Controlled values keyed by filter id. */\n @Prop() values: FilterMenuValues = {};\n /** Controlled category shown in the option pane. */\n @Prop() activeFilterId: string | undefined;\n /** External trigger element to position against. */\n @Prop() anchor: HTMLElement | undefined;\n /** ID of the external trigger element used for positioning and focus return. */\n @Prop() anchorId: string | undefined;\n /** Preferred popup side; collision handling may flip it. */\n @Prop() side: AnchoredSide = 'bottom';\n /** Cross-axis alignment against the trigger. */\n @Prop() align: AnchoredAlign = 'end';\n /** Gap between trigger and popup — number in px or a TokoMo length. */\n @Prop() sideOffset: number | string = TOKEN_CSS_LENGTHS.space050;\n /** Cross-axis offset — number in px or a TokoMo length. */\n @Prop() alignOffset: number | string = 0;\n /** Popup width. It remains clamped to the viewport by the component recipe. */\n @Prop() menuWidth: string = TOKEN_CSS_LENGTHS.menuWidthLg;\n /** Accessible name for the non-modal filter dialog. */\n @Prop() menuLabel: string = 'Filter';\n /** Accessible name for the category tab list. */\n @Prop() categoriesLabel: string = 'Filter categories';\n /** Footer action and date-clear accessible label. */\n @Prop() clearLabel: string = 'Clear';\n /** Show a visible focus ring on initial entry after keyboard activation. */\n @Prop() initialFocusVisible: boolean = false;\n\n @State() private shouldRender = false;\n @State() private closing = false;\n @State() private positionReady = false;\n @State() private pos: { x: number; y: number } = { x: 0, y: 0 };\n @State() private focusRingVisible = false;\n @State() private activeOptionIndex = 0;\n @State() private captionCompact = false;\n\n /** Requests a controlled value replacement without closing the popup. */\n @Event() dsChange!: EventEmitter<FilterMenuChangeDetail>;\n /** Requests that the consumer clear every filter value. */\n @Event() dsClear!: EventEmitter<void>;\n /** Requests a controlled active-category replacement. */\n @Event() dsActiveFilterChange!: EventEmitter<string>;\n /** Requests that the controlled popup close. */\n @Event() dsClose!: EventEmitter<void>;\n /** Emitted whenever internal select-trigger activation changes popup visibility. */\n @Event() dsOpenChange!: EventEmitter<boolean>;\n /** Emitted after exit motion and rendered popup removal complete. */\n @Event() dsAfterClose!: EventEmitter<void>;\n\n private readonly generatedId = `ds-filter-menu-${++filterMenuId}`;\n private outsideHandler: ((event: MouseEvent) => void) | null = null;\n private triggerElement: HTMLButtonElement | null = null;\n private pendingInitialFocusVisible = false;\n private closeTimer: ReturnType<typeof setTimeout> | null = null;\n private captionCompactDisconnect: (() => void) | undefined;\n private closingSnapshot: {\n filters: FilterMenuFilter[];\n values: FilterMenuValues;\n activeFilterId: string | undefined;\n } | null = null;\n\n private readonly position = new AnchoredPositionController({\n getAnchor: () => this.resolvedAnchor,\n getPopup: () => this.el.querySelector<HTMLElement>('.filter-menu-popup'),\n measure: (anchor, popup) => {\n if (!this.open) return null;\n const anchorRect = anchor.getBoundingClientRect();\n const sectionInsetPx = resolveCssLengthPx(TOKEN_DEFAULTS.space050, TOKEN_DEFAULTS.space050);\n return {\n anchorRect,\n popupWidth: popup.offsetWidth || this.popupFallbackWidth,\n popupHeight: popup.offsetHeight || this.popupFallbackHeight,\n side: this.side,\n align: this.align,\n sideOffsetPx: resolveCssLengthPx(this.sideOffset, TOKEN_DEFAULTS.space050),\n alignOffsetPx: resolveChoicePopupAlignOffset({\n align: this.align,\n alignOffsetPx: resolveCssLengthPx(this.alignOffset, 0),\n sectionInsetPx,\n }),\n viewportPadPx: this.viewportPad,\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n };\n },\n apply: ({ x, y }) => {\n this.pos = { x, y };\n },\n onReady: () => {\n this.positionReady = true;\n },\n liveUpdate: 'double-frame',\n observeResize: true,\n topLayer: true,\n });\n\n componentDidLoad() {\n this.syncCaptionCompactObserver();\n if (this.open) this.onOpenChange(true);\n }\n\n disconnectedCallback() {\n this.disconnectCaptionCompactObserver();\n this.position.unobserve();\n this.teardownListeners();\n }\n\n @Watch('collapseLabel')\n onCollapseLabelChange() {\n this.syncCaptionCompactObserver();\n }\n\n @Watch('open')\n onOpenChange(isOpen: boolean) {\n if (isOpen) {\n this.teardownListeners();\n this.closingSnapshot = null;\n this.shouldRender = true;\n this.closing = false;\n this.positionReady = false;\n this.focusRingVisible = this.pendingInitialFocusVisible || this.initialFocusVisible;\n this.setupListeners();\n this.position.schedule(() => this.focusInitialCategory());\n return;\n }\n\n if (!this.shouldRender) return;\n this.position.cancel();\n this.captureClosingSnapshot();\n this.closing = true;\n this.teardownListeners();\n const duration = resolveMotionTimeMs(\n TOKEN_DEFAULTS.motionShort2,\n TOKEN_DEFAULTS.animationDurationShort3\n );\n if (duration <= 0) {\n this.finishClose();\n return;\n }\n this.closeTimer = setTimeout(() => this.finishClose(), duration);\n }\n\n @Watch('anchor')\n @Watch('anchorId')\n @Watch('side')\n @Watch('align')\n @Watch('sideOffset')\n @Watch('alignOffset')\n @Watch('menuWidth')\n onPositionInputChange() {\n if (this.open) this.position.schedule();\n }\n\n @Listen('keydown')\n handleKeyDown(event: KeyboardEvent) {\n if (!this.shouldRender || this.closing) return;\n if (event.key === 'Escape') {\n event.preventDefault();\n this.close();\n return;\n }\n if (event.key !== 'Tab' || !this.tabLeavesPopup(event)) return;\n event.preventDefault();\n this.close(false);\n this.moveFocusAfterTab(event.shiftKey);\n }\n\n private get resolvedAnchor(): HTMLElement | null {\n if (this.anchor) return this.anchor;\n if (this.anchorId) return document.getElementById(this.anchorId);\n return this.triggerElement;\n }\n\n private get usesInternalTrigger(): boolean {\n return !this.anchor && !this.anchorId;\n }\n\n private get anchorFocusTarget(): HTMLElement | null {\n const anchor = this.resolvedAnchor;\n if (!anchor) return null;\n if (anchor.matches(FILTER_MENU_FOCUSABLE_SELECTOR)) return anchor;\n return anchor.querySelector<HTMLElement>(FILTER_MENU_FOCUSABLE_SELECTOR) ?? anchor;\n }\n\n private get viewportPad(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.space050, TOKEN_DEFAULTS.space050);\n }\n\n private get popupFallbackWidth(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.menuWidthLg, TOKEN_DEFAULTS.menuWidthLg);\n }\n\n private get popupFallbackHeight(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.menuFallbackHeight, TOKEN_DEFAULTS.menuFallbackHeight);\n }\n\n private setupListeners() {\n this.outsideHandler = event => {\n const target = event.target as Node;\n const popup = this.el.querySelector('.filter-menu-popup');\n if (popup?.contains(target) || this.resolvedAnchor?.contains(target)) return;\n this.close();\n };\n document.addEventListener('mousedown', this.outsideHandler, true);\n this.position.observe();\n }\n\n private teardownListeners() {\n this.position.unobserve();\n if (this.outsideHandler) {\n document.removeEventListener('mousedown', this.outsideHandler, true);\n this.outsideHandler = null;\n }\n if (this.closeTimer) {\n clearTimeout(this.closeTimer);\n this.closeTimer = null;\n }\n }\n\n private close(restoreFocus = true) {\n this.captureClosingSnapshot();\n if (restoreFocus) this.focusAnchor();\n this.dsOpenChange.emit(false);\n this.dsClose.emit();\n this.open = false;\n this.onOpenChange(false);\n }\n\n private toggleFromTrigger(event: MouseEvent) {\n if (this.open) {\n this.close();\n return;\n }\n this.pendingInitialFocusVisible = event.detail === 0;\n this.open = true;\n this.dsOpenChange.emit(true);\n this.onOpenChange(true);\n }\n\n private handleTriggerKeyDown(event: KeyboardEvent) {\n if (event.key !== 'ArrowDown' || this.open) return;\n event.preventDefault();\n this.pendingInitialFocusVisible = true;\n this.open = true;\n this.dsOpenChange.emit(true);\n this.onOpenChange(true);\n }\n\n private finishClose() {\n const popup = this.el.querySelector<HTMLElement>('.filter-menu-popup');\n if (popup?.matches(':popover-open')) popup.hidePopover();\n this.shouldRender = false;\n this.closing = false;\n this.closingSnapshot = null;\n this.closeTimer = null;\n this.dsAfterClose.emit();\n }\n\n private captureClosingSnapshot() {\n if (this.closingSnapshot) return;\n this.closingSnapshot = {\n filters: this.filters.map(filter => ({\n ...filter,\n options: filter.options?.map(option => ({ ...option })),\n })),\n values: Object.fromEntries(\n Object.entries(this.values).map(([key, value]) => [\n key,\n Array.isArray(value) ? [...value] : value,\n ])\n ),\n activeFilterId: this.activeFilterId,\n };\n }\n\n private focusAnchor() {\n const anchor = this.resolvedAnchor as\n (HTMLElement & { setFocus?: () => Promise<void> | void }) | null;\n if (anchor?.setFocus) {\n void anchor.setFocus();\n return;\n }\n this.anchorFocusTarget?.focus();\n }\n\n private focusInitialCategory() {\n requestAnimationFrame(() => {\n this.el.querySelector<HTMLElement>('.filter-menu__category[tabindex=\"0\"]')?.focus();\n this.pendingInitialFocusVisible = false;\n });\n }\n\n private tabLeavesPopup(event: KeyboardEvent): boolean {\n const popup = this.el.querySelector<HTMLElement>('.filter-menu-popup');\n if (!popup) return true;\n const focusables = Array.from(\n popup.querySelectorAll<HTMLElement>(FILTER_MENU_FOCUSABLE_SELECTOR)\n ).filter(element => element.getClientRects().length > 0);\n const index = focusables.indexOf(event.target as HTMLElement);\n if (index < 0) return true;\n return event.shiftKey ? index === 0 : index === focusables.length - 1;\n }\n\n private moveFocusAfterTab(backwards: boolean) {\n const anchor = this.anchorFocusTarget;\n if (!anchor) return;\n if (backwards) {\n anchor.focus();\n return;\n }\n const candidates = Array.from(\n document.querySelectorAll<HTMLElement>(FILTER_MENU_FOCUSABLE_SELECTOR)\n ).filter(\n element =>\n !element.closest('.filter-menu-popup') &&\n !element.closest('[inert]') &&\n element.getClientRects().length > 0\n );\n const index = candidates.indexOf(anchor);\n (candidates[index + 1] ?? anchor).focus();\n }\n\n private selectedFilter(filters: FilterMenuFilter[], activeFilterId: string | undefined) {\n return filters.find(filter => filter.id === activeFilterId) ?? filters[0];\n }\n\n private selectedCount(filter: FilterMenuFilter, values: FilterMenuValues): number {\n const value = values[filter.id];\n if (filter.kind === 'multiple') return Array.isArray(value) ? value.length : 0;\n if (filter.kind === 'boolean') return value === true ? 1 : 0;\n return typeof value === 'string' && value.length > 0 ? 1 : 0;\n }\n\n private totalSelected(filters: FilterMenuFilter[], values: FilterMenuValues): number {\n return filters.reduce((total, filter) => total + this.selectedCount(filter, values), 0);\n }\n\n private activeFilterCount(filters: FilterMenuFilter[], values: FilterMenuValues): number {\n return filters.filter(filter => this.selectedCount(filter, values) > 0).length;\n }\n\n private selectCategory(filterId: string, focus = false) {\n this.activeOptionIndex = 0;\n this.dsActiveFilterChange.emit(filterId);\n if (!focus) return;\n requestAnimationFrame(() => {\n this.el.querySelector<HTMLElement>(`[data-filter-category=\"${filterId}\"]`)?.focus();\n });\n }\n\n private handleCategoryKeyDown(\n event: KeyboardEvent,\n filters: FilterMenuFilter[],\n filterId: string\n ) {\n const currentIndex = filters.findIndex(filter => filter.id === filterId);\n let nextIndex: number;\n if (event.key === 'ArrowDown') nextIndex = (currentIndex + 1) % filters.length;\n else if (event.key === 'ArrowUp')\n nextIndex = (currentIndex - 1 + filters.length) % filters.length;\n else if (event.key === 'Home') nextIndex = 0;\n else if (event.key === 'End') nextIndex = filters.length - 1;\n else if (event.key === 'ArrowRight') {\n event.preventDefault();\n requestAnimationFrame(() => {\n this.el\n .querySelector<HTMLElement>(\n '.filter-menu__options [role=\"option\"], .filter-menu__options input'\n )\n ?.focus();\n });\n return;\n } else return;\n\n event.preventDefault();\n this.focusRingVisible = true;\n this.selectCategory(filters[nextIndex].id, true);\n }\n\n private handlePanelKeyDown(event: KeyboardEvent, filterId: string) {\n if (event.key !== 'ArrowLeft') return;\n event.preventDefault();\n this.el.querySelector<HTMLElement>(`[data-filter-category=\"${filterId}\"]`)?.focus();\n }\n\n private handleOptionKeyDown(\n event: KeyboardEvent,\n filter: FilterMenuFilter,\n option: FilterMenuOption,\n index: number,\n values: FilterMenuValues\n ) {\n const options = filter.options ?? [];\n if (event.key === 'ArrowLeft') {\n this.handlePanelKeyDown(event, filter.id);\n return;\n }\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n this.selectOption(filter, option, values);\n return;\n }\n if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;\n event.preventDefault();\n const enabled = options\n .map((candidate, optionIndex) => ({ candidate, optionIndex }))\n .filter(({ candidate }) => !candidate.isInactive)\n .map(({ optionIndex }) => optionIndex);\n if (!enabled.length) return;\n const current = enabled.indexOf(index);\n let next: number;\n if (event.key === 'Home') next = enabled[0];\n else if (event.key === 'End') next = enabled[enabled.length - 1];\n else {\n const direction = event.key === 'ArrowDown' ? 1 : -1;\n const start = current >= 0 ? current : direction === 1 ? -1 : 0;\n next = enabled[(start + direction + enabled.length) % enabled.length];\n }\n this.activeOptionIndex = next;\n this.focusRingVisible = true;\n requestAnimationFrame(() => {\n this.el\n .querySelector<HTMLElement>(`#${this.generatedId}-${filter.id}-option-${next}`)\n ?.focus();\n });\n }\n\n private toggleMultiple(\n filter: FilterMenuFilter,\n option: FilterMenuOption,\n checked: boolean,\n values: FilterMenuValues\n ) {\n if (option.isInactive) return;\n const current = Array.isArray(values[filter.id]) ? (values[filter.id] as string[]) : [];\n const selected = new Set(current);\n if (checked) selected.add(option.value);\n else selected.delete(option.value);\n const ordered = (filter.options ?? [])\n .map(candidate => candidate.value)\n .filter(value => selected.has(value));\n this.dsChange.emit({ filterId: filter.id, value: ordered });\n }\n\n private selectOption(\n filter: FilterMenuFilter,\n option: FilterMenuOption,\n values: FilterMenuValues\n ) {\n if (option.isInactive) return;\n if (filter.kind === 'single') {\n this.dsChange.emit({ filterId: filter.id, value: option.value });\n return;\n }\n const selected = Array.isArray(values[filter.id])\n ? (values[filter.id] as string[]).includes(option.value)\n : false;\n this.toggleMultiple(filter, option, !selected, values);\n }\n\n private renderOptions(filter: FilterMenuFilter, values: FilterMenuValues) {\n const value = values[filter.id];\n if (filter.kind === 'multiple' || filter.kind === 'single') {\n const selected = Array.isArray(value) ? value : [];\n const options = filter.options ?? [];\n const usesSubtext = choiceListUsesSubtext(\n options.map(option => ({\n label: option.label,\n value: option.value,\n subtext: option.description,\n }))\n );\n return options.map((option, index) => {\n const isSelected =\n filter.kind === 'multiple' ? selected.includes(option.value) : value === option.value;\n return (\n <ChoiceOptionRow\n size=\"md\"\n id={`${this.generatedId}-${filter.id}-option-${index}`}\n option={{\n label: option.label,\n value: option.value,\n subtext: option.description,\n isInactive: option.isInactive,\n }}\n selected={isSelected}\n active={index === this.activeOptionIndex}\n focusRingVisible={this.focusRingVisible}\n usesSubtext={usesSubtext}\n tabIndex={index === this.activeOptionIndex ? 0 : -1}\n leading={\n filter.kind === 'multiple' ? (\n <span\n class=\"ds-choice-item__icon ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-checkbox label=\"\" size=\"md\" checked={isSelected} presentation />\n </span>\n ) : undefined\n }\n onFocus={() => {\n this.activeOptionIndex = index;\n }}\n onKeyDown={(event: KeyboardEvent) =>\n this.handleOptionKeyDown(event, filter, option, index, values)\n }\n onHover={() => {\n this.focusRingVisible = false;\n this.activeOptionIndex = index;\n }}\n onSelect={() => this.selectOption(filter, option, values)}\n />\n );\n });\n }\n\n if (filter.kind === 'boolean') {\n const option: FilterMenuOption = {\n label: filter.fieldLabel ?? filter.label,\n value: 'true',\n description: filter.description,\n };\n return (\n <ChoiceOptionRow\n size=\"md\"\n id={`${this.generatedId}-${filter.id}-option-0`}\n option={{\n label: option.label,\n value: option.value,\n subtext: option.description,\n }}\n selected={value === true}\n active\n focusRingVisible={this.focusRingVisible}\n usesSubtext={Boolean(option.description)}\n tabIndex={0}\n leading={\n <span\n class=\"ds-choice-item__icon ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-checkbox label=\"\" size=\"md\" checked={value === true} presentation />\n </span>\n }\n onKeyDown={(event: KeyboardEvent) => {\n if (event.key === 'ArrowLeft') this.handlePanelKeyDown(event, filter.id);\n else if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n this.dsChange.emit({ filterId: filter.id, value: value !== true });\n }\n }}\n onHover={() => {\n this.focusRingVisible = false;\n }}\n onSelect={() => this.dsChange.emit({ filterId: filter.id, value: value !== true })}\n />\n );\n }\n\n const dateValue = typeof value === 'string' ? value : '';\n const inputId = `${this.generatedId}-${filter.id}-input`;\n return (\n <div class=\"filter-menu__date-field\">\n <label htmlFor={inputId}>\n <ds-text as=\"span\" variant=\"text-body-small\" emphasis color=\"primary\">\n {filter.fieldLabel ?? 'Event date'}\n </ds-text>\n </label>\n <div class=\"filter-menu__date-control ds-control-frame ds-control--md\">\n <input\n id={inputId}\n class=\"filter-menu__date-input\"\n type=\"date\"\n value={dateValue}\n onInput={(event: Event) =>\n this.dsChange.emit({\n filterId: filter.id,\n value: (event.target as HTMLInputElement).value,\n })\n }\n />\n {dateValue ? (\n <ds-button-unfilled\n variant=\"icon\"\n icon=\"CrossCircle\"\n size=\"sm\"\n isInset\n hasBorder={false}\n rounded\n ariaLabel={`${this.clearLabel} ${filter.fieldLabel ?? filter.label}`}\n onDsClick={(event: CustomEvent<MouseEvent>) => {\n event.stopPropagation();\n this.dsChange.emit({ filterId: filter.id, value: '' });\n }}\n />\n ) : null}\n </div>\n </div>\n );\n }\n\n private get captionIconOnly(): boolean {\n return (\n this.collapseLabel &&\n this.captionCompact &&\n this.usesInternalTrigger &&\n Boolean(this.icon)\n );\n }\n\n private syncCaptionCompactObserver(): void {\n this.disconnectCaptionCompactObserver();\n if (!this.collapseLabel) {\n if (this.captionCompact) this.captionCompact = false;\n return;\n }\n this.captionCompactDisconnect = observeTableCaptionCompact(this.el, compact => {\n if (this.captionCompact !== compact) this.captionCompact = compact;\n });\n }\n\n private disconnectCaptionCompactObserver(): void {\n this.captionCompactDisconnect?.();\n this.captionCompactDisconnect = undefined;\n }\n\n render() {\n const state =\n this.closing && this.closingSnapshot\n ? this.closingSnapshot\n : {\n filters: this.filters,\n values: this.values,\n activeFilterId: this.activeFilterId,\n };\n const activeFilter = this.selectedFilter(state.filters, state.activeFilterId);\n const totalSelected = this.totalSelected(state.filters, state.values);\n const activeFilterCount = this.activeFilterCount(state.filters, state.values);\n const hasActiveFilters = activeFilterCount > 0;\n const label = `${this.triggerLabel}${hasActiveFilters ? ` · ${activeFilterCount}` : ''}`;\n const textVariant = CONTROL_TEXT_VARIANT[this.size];\n const iconSize = ICON_SIZE[this.size];\n const popupId = `${this.generatedId}-popup`;\n const popupStyle: Record<string, string> = {\n position: 'fixed',\n left: '0',\n top: '0',\n width: this.menuWidth,\n transform: `translate(${Math.round(this.pos.x)}px, ${Math.round(this.pos.y)}px)`,\n zIndex: '9998',\n visibility: this.positionReady ? 'visible' : 'hidden',\n };\n\n return (\n <Host\n class={{\n 'filter-menu-host': true,\n 'filter-menu-host--external-trigger': !this.usesInternalTrigger,\n 'ds-select-trigger-host': this.usesInternalTrigger,\n [`ds-control--${this.size}`]: true,\n 'ds-table-caption-control': this.collapseLabel,\n 'ds-table-caption-control--compact': this.captionIconOnly,\n ...controlWidthClass(this.width),\n }}\n >\n {this.usesInternalTrigger ? (\n <button\n ref={element => {\n this.triggerElement = (element as HTMLButtonElement) ?? null;\n }}\n id={this.inputId ?? `${this.generatedId}-trigger`}\n type=\"button\"\n class={{\n trigger: true,\n 'ds-control-frame': true,\n 'ds-focus-ring-inset': true,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--selected': this.activeFill && hasActiveFilters,\n 'trigger--expanded': this.open || this.closing,\n 'trigger--bordered': this.hasBorder,\n 'trigger--has-value': hasActiveFilters,\n [`ds-control--${this.size}`]: true,\n }}\n role=\"combobox\"\n aria-haspopup=\"dialog\"\n aria-expanded={String(this.open)}\n aria-controls={this.open ? popupId : undefined}\n aria-label={this.ariaLabel ?? this.menuLabel}\n onClick={(event: MouseEvent) => this.toggleFromTrigger(event)}\n onKeyDown={(event: KeyboardEvent) => this.handleTriggerKeyDown(event)}\n >\n {this.icon ? (\n <span\n class=\"trigger__prefix ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-icon name={this.icon} size={iconSize} color=\"inherit\" />\n </span>\n ) : null}\n {this.captionIconOnly ? null : (\n <ds-text\n class=\"trigger__label-box trigger__label trigger__label-content ds-control-label-box ds-interaction-fill__content\"\n as=\"span\"\n variant={textVariant}\n color=\"inherit\"\n lineTruncation={1}\n >\n {label}\n </ds-text>\n )}\n {this.captionIconOnly ? null : (\n <span\n class=\"trigger__chevron ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-icon name=\"ChevronDown\" size={iconSize} color=\"inherit\" />\n </span>\n )}\n </button>\n ) : null}\n\n {this.shouldRender ? (\n <div\n id={popupId}\n popover=\"manual\"\n class={{\n 'filter-menu-popup': true,\n 'ds-choice-popup': true,\n 'ds-choice-popup--closing': this.closing,\n }}\n style={popupStyle}\n role=\"dialog\"\n aria-label={this.menuLabel}\n >\n {activeFilter ? (\n <div class=\"filter-menu\">\n <div class=\"filter-menu__body\">\n <div\n class=\"filter-menu__categories ds-choice-list ds-chrome-column ds-chrome-space--sm\"\n role=\"tablist\"\n aria-label={this.categoriesLabel}\n aria-orientation=\"vertical\"\n >\n {state.filters.map(filter => {\n const active = filter.id === activeFilter.id;\n const count = this.selectedCount(filter, state.values);\n return (\n <div\n key={filter.id}\n class={{\n 'filter-menu__category-section': true,\n 'ds-choice-section--divided': Boolean(filter.divider),\n }}\n >\n <button\n id={`${this.generatedId}-${filter.id}-tab`}\n type=\"button\"\n role=\"tab\"\n data-filter-category={filter.id}\n class={{\n 'filter-menu__category': true,\n 'filter-menu__category--active': active,\n 'ds-choice-item': true,\n 'ds-control-frame': true,\n 'ds-control--md': true,\n 'ds-focus-ring-inset': true,\n 'ds-focus-ring--visible': active && this.focusRingVisible,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--selected': active,\n }}\n aria-selected={String(active)}\n aria-controls={`${this.generatedId}-panel`}\n tabIndex={active ? 0 : -1}\n onMouseDown={() => {\n this.focusRingVisible = false;\n }}\n onClick={() => this.selectCategory(filter.id)}\n onKeyDown={(event: KeyboardEvent) =>\n this.handleCategoryKeyDown(event, state.filters, filter.id)\n }\n >\n <ds-text\n class=\"ds-choice-item__content ds-choice-item__label ds-control-label-box ds-interaction-fill__content\"\n as=\"span\"\n variant=\"text-body-medium\"\n color={active ? 'primary' : 'secondary'}\n >\n {filter.label}\n </ds-text>\n {count > 0 ? (\n <ds-tag\n class=\"ds-choice-item__tag ds-interaction-fill__content\"\n label={String(count)}\n size=\"sm\"\n intent=\"brand\"\n contrast=\"bold\"\n isInset\n rounded\n />\n ) : null}\n </button>\n </div>\n );\n })}\n </div>\n\n <div\n id={`${this.generatedId}-panel`}\n class=\"filter-menu__options\"\n role=\"tabpanel\"\n aria-labelledby={`${this.generatedId}-${activeFilter.id}-tab`}\n >\n <div\n key={activeFilter.id}\n class=\"filter-menu__option-list ds-choice-list ds-chrome-column ds-chrome-space--sm\"\n role=\"listbox\"\n aria-label={activeFilter.label}\n aria-multiselectable={activeFilter.kind === 'multiple' ? 'true' : undefined}\n >\n {this.renderOptions(activeFilter, state.values)}\n </div>\n </div>\n </div>\n\n {totalSelected > 0 ? (\n <div class=\"filter-menu__footer ds-choice-footer\">\n <div class=\"ds-choice-footer__content ds-control--md\">\n <ds-text\n class=\"ds-choice-footer__summary\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n aria-live=\"polite\"\n >\n {totalSelected} selected\n </ds-text>\n <button\n class=\"filter-menu__clear ds-choice-footer__clear ds-text-action\"\n type=\"button\"\n onClick={() => this.dsClear.emit()}\n >\n <ds-text as=\"span\" variant=\"text-body-medium\" color=\"inherit\">\n {this.clearLabel}\n </ds-text>\n </button>\n </div>\n </div>\n ) : null}\n </div>\n ) : null}\n </div>\n ) : null}\n </Host>\n );\n }\n}\n",
|
|
664
|
+
"content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Listen,\n Prop,\n State,\n Watch,\n} from '@stencil/core';\nimport {\n CONTROL_TEXT_VARIANT,\n controlWidthClass,\n resolveChoicePopupAlignOffset,\n resolveCssLengthPx,\n resolveMotionTimeMs,\n TOKEN_CSS_LENGTHS,\n TOKEN_DEFAULTS,\n type ControlWidth,\n} from '../../utils';\nimport { observeTableCaptionCompact } from '../../utils/table-caption-compact';\nimport type { AnchoredAlign, AnchoredSide } from '../../utils/anchored-position';\nimport { AnchoredPositionController } from '../../utils/anchored-position-controller';\nimport { ChoiceOptionRow } from '../../utils/choice-list-parts';\nimport { choiceListUsesSubtext } from '../../utils/choice-list';\n\nexport type FilterMenuFilterKind = 'single' | 'multiple' | 'boolean' | 'date';\nexport type FilterMenuSize = 'lg' | 'md' | 'sm' | 'xs';\nexport type FilterMenuWidth = ControlWidth;\n\nexport interface FilterMenuOption {\n label: string;\n value: string;\n description?: string;\n isInactive?: boolean;\n}\n\nexport interface FilterMenuFilter {\n id: string;\n label: string;\n kind: FilterMenuFilterKind;\n options?: FilterMenuOption[];\n description?: string;\n fieldLabel?: string;\n /** Draw a standard menu-section divider after this category. */\n divider?: boolean;\n}\n\nexport type FilterMenuValue = string[] | boolean | string;\nexport type FilterMenuValues = Record<string, FilterMenuValue | undefined>;\n\nexport interface FilterMenuChangeDetail {\n filterId: string;\n value: FilterMenuValue;\n}\n\nconst FILTER_MENU_FOCUSABLE_SELECTOR = [\n 'a[href]',\n 'button:not([disabled])',\n 'input:not([disabled]):not([type=\"hidden\"])',\n 'select:not([disabled])',\n 'textarea:not([disabled])',\n '[tabindex]:not([tabindex=\"-1\"])',\n].join(',');\n\nlet filterMenuId = 0;\n\nconst ICON_SIZE: Record<FilterMenuSize, 'lg' | 'md' | 'sm' | 'xs'> = {\n lg: 'lg',\n md: 'md',\n sm: 'sm',\n xs: 'xs',\n};\n\n@Component({\n tag: 'ds-filter-menu',\n styleUrl: 'FilterMenu.css',\n scoped: true,\n})\nexport class FilterMenu {\n @Element() el!: HTMLElement;\n\n /** Controlled popup visibility. */\n @Prop({ mutable: true }) open: boolean = false;\n /** Select trigger text. The selected count is appended automatically. */\n @Prop() triggerLabel: string = 'Filters';\n /** Optional select trigger prefix icon name. */\n @Prop() icon: string | undefined = 'Filters';\n /** Select trigger density. */\n @Prop() size: FilterMenuSize = 'md';\n /** Select trigger width fit. */\n @Prop() width: FilterMenuWidth = 'hug';\n /** Show the surface-aware inset border around the select trigger. */\n @Prop() hasBorder: boolean = true;\n /** Show selected interaction fill when one or more criteria are active. */\n @Prop() activeFill: boolean = false;\n /**\n * Opt into table-caption icon-only chrome below 900px. The trigger omits its\n * visible label and chevron; keep an accessible name via aria-label.\n */\n @Prop({ reflect: true }) collapseLabel: boolean = false;\n /** ID applied to the internal select trigger. */\n @Prop() inputId: string | undefined;\n /** Direct accessible name for the internal select trigger. */\n @Prop({ attribute: 'aria-label' }) ariaLabel: string | null = null;\n /** Product-owned filter categories and option definitions. */\n @Prop() filters: FilterMenuFilter[] = [];\n /** Controlled values keyed by filter id. */\n @Prop() values: FilterMenuValues = {};\n /** Controlled category shown in the option pane. */\n @Prop() activeFilterId: string | undefined;\n /** External trigger element to position against. */\n @Prop() anchor: HTMLElement | undefined;\n /** ID of the external trigger element used for positioning and focus return. */\n @Prop() anchorId: string | undefined;\n /** Preferred popup side; collision handling may flip it. */\n @Prop() side: AnchoredSide = 'bottom';\n /** Cross-axis alignment against the trigger. */\n @Prop() align: AnchoredAlign = 'end';\n /** Gap between trigger and popup — number in px or a TokoMo length. */\n @Prop() sideOffset: number | string = TOKEN_CSS_LENGTHS.space050;\n /** Cross-axis offset — number in px or a TokoMo length. */\n @Prop() alignOffset: number | string = 0;\n /** Popup width. It remains clamped to the viewport by the component recipe. */\n @Prop() menuWidth: string = TOKEN_CSS_LENGTHS.menuWidthLg;\n /** Accessible name for the non-modal filter dialog. */\n @Prop() menuLabel: string = 'Filters';\n /** Accessible name for the category tab list. */\n @Prop() categoriesLabel: string = 'Filter categories';\n /** Footer action and date-clear accessible label. */\n @Prop() clearLabel: string = 'Clear';\n /** Show a visible focus ring on initial entry after keyboard activation. */\n @Prop() initialFocusVisible: boolean = false;\n\n @State() private shouldRender = false;\n @State() private closing = false;\n @State() private positionReady = false;\n @State() private pos: { x: number; y: number } = { x: 0, y: 0 };\n @State() private focusRingVisible = false;\n @State() private activeOptionIndex = 0;\n @State() private captionCompact = false;\n\n /** Requests a controlled value replacement without closing the popup. */\n @Event() dsChange!: EventEmitter<FilterMenuChangeDetail>;\n /** Requests that the consumer clear every filter value. */\n @Event() dsClear!: EventEmitter<void>;\n /** Requests a controlled active-category replacement. */\n @Event() dsActiveFilterChange!: EventEmitter<string>;\n /** Requests that the controlled popup close. */\n @Event() dsClose!: EventEmitter<void>;\n /** Emitted whenever internal select-trigger activation changes popup visibility. */\n @Event() dsOpenChange!: EventEmitter<boolean>;\n /** Emitted after exit motion and rendered popup removal complete. */\n @Event() dsAfterClose!: EventEmitter<void>;\n\n private readonly generatedId = `ds-filter-menu-${++filterMenuId}`;\n private outsideHandler: ((event: MouseEvent) => void) | null = null;\n private triggerElement: HTMLButtonElement | null = null;\n private pendingInitialFocusVisible = false;\n private closeTimer: ReturnType<typeof setTimeout> | null = null;\n private captionCompactDisconnect: (() => void) | undefined;\n private hasLoaded = false;\n private closingSnapshot: {\n filters: FilterMenuFilter[];\n values: FilterMenuValues;\n activeFilterId: string | undefined;\n } | null = null;\n\n private readonly position = new AnchoredPositionController({\n getAnchor: () => this.resolvedAnchor,\n getPopup: () => this.el.querySelector<HTMLElement>('.filter-menu-popup'),\n measure: (anchor, popup) => {\n if (!this.open) return null;\n const anchorRect = anchor.getBoundingClientRect();\n const sectionInsetPx = resolveCssLengthPx(TOKEN_DEFAULTS.space050, TOKEN_DEFAULTS.space050);\n return {\n anchorRect,\n popupWidth: popup.offsetWidth || this.popupFallbackWidth,\n popupHeight: popup.offsetHeight || this.popupFallbackHeight,\n side: this.side,\n align: this.align,\n sideOffsetPx: resolveCssLengthPx(this.sideOffset, TOKEN_DEFAULTS.space050),\n alignOffsetPx: resolveChoicePopupAlignOffset({\n align: this.align,\n alignOffsetPx: resolveCssLengthPx(this.alignOffset, 0),\n sectionInsetPx,\n }),\n viewportPadPx: this.viewportPad,\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n };\n },\n apply: ({ x, y }) => {\n this.pos = { x, y };\n },\n onReady: () => {\n this.positionReady = true;\n },\n liveUpdate: 'double-frame',\n observeResize: true,\n topLayer: true,\n });\n\n componentDidLoad() {\n this.hasLoaded = true;\n this.syncCaptionCompactObserver();\n if (this.open) this.onOpenChange(true);\n }\n\n connectedCallback() {\n if (!this.hasLoaded) return;\n this.syncCaptionCompactObserver();\n if (this.open) this.onOpenChange(true);\n }\n\n disconnectedCallback() {\n this.disconnectCaptionCompactObserver();\n this.position.unobserve();\n this.teardownListeners();\n }\n\n @Watch('collapseLabel')\n onCollapseLabelChange() {\n this.syncCaptionCompactObserver();\n }\n\n @Watch('open')\n onOpenChange(isOpen: boolean) {\n if (isOpen) {\n this.teardownListeners();\n this.closingSnapshot = null;\n this.shouldRender = true;\n this.closing = false;\n this.positionReady = false;\n this.focusRingVisible = this.pendingInitialFocusVisible || this.initialFocusVisible;\n this.setupListeners();\n this.position.schedule(() => this.focusInitialCategory());\n return;\n }\n\n if (!this.shouldRender) return;\n this.position.cancel();\n this.captureClosingSnapshot();\n this.closing = true;\n this.teardownListeners();\n const duration = resolveMotionTimeMs(\n TOKEN_DEFAULTS.motionShort2,\n TOKEN_DEFAULTS.animationDurationShort3\n );\n if (duration <= 0) {\n this.finishClose();\n return;\n }\n this.closeTimer = setTimeout(() => this.finishClose(), duration);\n }\n\n @Watch('anchor')\n @Watch('anchorId')\n @Watch('side')\n @Watch('align')\n @Watch('sideOffset')\n @Watch('alignOffset')\n @Watch('menuWidth')\n onPositionInputChange() {\n if (this.open) this.position.schedule();\n }\n\n @Listen('keydown')\n handleKeyDown(event: KeyboardEvent) {\n if (!this.shouldRender || this.closing) return;\n if (event.key === 'Escape') {\n event.preventDefault();\n this.close();\n return;\n }\n if (event.key !== 'Tab' || !this.tabLeavesPopup(event)) return;\n event.preventDefault();\n this.close(false);\n this.moveFocusAfterTab(event.shiftKey);\n }\n\n private get resolvedAnchor(): HTMLElement | null {\n if (this.anchor) return this.anchor;\n if (this.anchorId) return document.getElementById(this.anchorId);\n return this.triggerElement;\n }\n\n private get usesInternalTrigger(): boolean {\n return !this.anchor && !this.anchorId;\n }\n\n private get anchorFocusTarget(): HTMLElement | null {\n const anchor = this.resolvedAnchor;\n if (!anchor) return null;\n if (anchor.matches(FILTER_MENU_FOCUSABLE_SELECTOR)) return anchor;\n return anchor.querySelector<HTMLElement>(FILTER_MENU_FOCUSABLE_SELECTOR) ?? anchor;\n }\n\n private get viewportPad(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.space050, TOKEN_DEFAULTS.space050);\n }\n\n private get popupFallbackWidth(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.menuWidthLg, TOKEN_DEFAULTS.menuWidthLg);\n }\n\n private get popupFallbackHeight(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.menuFallbackHeight, TOKEN_DEFAULTS.menuFallbackHeight);\n }\n\n private setupListeners() {\n this.outsideHandler = event => {\n const target = event.target as Node;\n const popup = this.el.querySelector('.filter-menu-popup');\n if (popup?.contains(target) || this.resolvedAnchor?.contains(target)) return;\n this.close();\n };\n document.addEventListener('mousedown', this.outsideHandler, true);\n this.position.observe();\n }\n\n private teardownListeners() {\n this.position.unobserve();\n if (this.outsideHandler) {\n document.removeEventListener('mousedown', this.outsideHandler, true);\n this.outsideHandler = null;\n }\n if (this.closeTimer) {\n clearTimeout(this.closeTimer);\n this.closeTimer = null;\n }\n }\n\n private close(restoreFocus = true) {\n this.captureClosingSnapshot();\n if (restoreFocus) this.focusAnchor();\n this.dsOpenChange.emit(false);\n this.dsClose.emit();\n this.open = false;\n this.onOpenChange(false);\n }\n\n private toggleFromTrigger(event: MouseEvent) {\n if (this.open) {\n this.close();\n return;\n }\n this.pendingInitialFocusVisible = event.detail === 0;\n this.open = true;\n this.dsOpenChange.emit(true);\n this.onOpenChange(true);\n }\n\n private handleTriggerKeyDown(event: KeyboardEvent) {\n if (event.key !== 'ArrowDown' || this.open) return;\n event.preventDefault();\n this.pendingInitialFocusVisible = true;\n this.open = true;\n this.dsOpenChange.emit(true);\n this.onOpenChange(true);\n }\n\n private finishClose() {\n const popup = this.el.querySelector<HTMLElement>('.filter-menu-popup');\n if (popup?.matches(':popover-open')) popup.hidePopover();\n this.shouldRender = false;\n this.closing = false;\n this.closingSnapshot = null;\n this.closeTimer = null;\n this.dsAfterClose.emit();\n }\n\n private captureClosingSnapshot() {\n if (this.closingSnapshot) return;\n this.closingSnapshot = {\n filters: this.filters.map(filter => ({\n ...filter,\n options: filter.options?.map(option => ({ ...option })),\n })),\n values: Object.fromEntries(\n Object.entries(this.values).map(([key, value]) => [\n key,\n Array.isArray(value) ? [...value] : value,\n ])\n ),\n activeFilterId: this.activeFilterId,\n };\n }\n\n private focusAnchor() {\n const anchor = this.resolvedAnchor as\n (HTMLElement & { setFocus?: () => Promise<void> | void }) | null;\n if (anchor?.setFocus) {\n void anchor.setFocus();\n return;\n }\n this.anchorFocusTarget?.focus();\n }\n\n private focusInitialCategory() {\n requestAnimationFrame(() => {\n this.el.querySelector<HTMLElement>('.filter-menu__category[tabindex=\"0\"]')?.focus();\n this.pendingInitialFocusVisible = false;\n });\n }\n\n private tabLeavesPopup(event: KeyboardEvent): boolean {\n const popup = this.el.querySelector<HTMLElement>('.filter-menu-popup');\n if (!popup) return true;\n const focusables = Array.from(\n popup.querySelectorAll<HTMLElement>(FILTER_MENU_FOCUSABLE_SELECTOR)\n ).filter(element => element.getClientRects().length > 0);\n const index = focusables.indexOf(event.target as HTMLElement);\n if (index < 0) return true;\n return event.shiftKey ? index === 0 : index === focusables.length - 1;\n }\n\n private moveFocusAfterTab(backwards: boolean) {\n const anchor = this.anchorFocusTarget;\n if (!anchor) return;\n if (backwards) {\n anchor.focus();\n return;\n }\n const candidates = Array.from(\n document.querySelectorAll<HTMLElement>(FILTER_MENU_FOCUSABLE_SELECTOR)\n ).filter(\n element =>\n !element.closest('.filter-menu-popup') &&\n !element.closest('[inert]') &&\n element.getClientRects().length > 0\n );\n const index = candidates.indexOf(anchor);\n (candidates[index + 1] ?? anchor).focus();\n }\n\n private selectedFilter(filters: FilterMenuFilter[], activeFilterId: string | undefined) {\n return filters.find(filter => filter.id === activeFilterId) ?? filters[0];\n }\n\n private selectedCount(filter: FilterMenuFilter, values: FilterMenuValues): number {\n const value = values[filter.id];\n if (filter.kind === 'multiple') return Array.isArray(value) ? value.length : 0;\n if (filter.kind === 'boolean') return value === true ? 1 : 0;\n return typeof value === 'string' && value.length > 0 ? 1 : 0;\n }\n\n private totalSelected(filters: FilterMenuFilter[], values: FilterMenuValues): number {\n return filters.reduce((total, filter) => total + this.selectedCount(filter, values), 0);\n }\n\n private activeFilterCount(filters: FilterMenuFilter[], values: FilterMenuValues): number {\n return filters.filter(filter => this.selectedCount(filter, values) > 0).length;\n }\n\n private selectCategory(filterId: string, focus = false) {\n this.activeOptionIndex = 0;\n this.dsActiveFilterChange.emit(filterId);\n if (!focus) return;\n requestAnimationFrame(() => {\n this.el.querySelector<HTMLElement>(`[data-filter-category=\"${filterId}\"]`)?.focus();\n });\n }\n\n private handleCategoryKeyDown(\n event: KeyboardEvent,\n filters: FilterMenuFilter[],\n filterId: string\n ) {\n const currentIndex = filters.findIndex(filter => filter.id === filterId);\n let nextIndex: number;\n if (event.key === 'ArrowDown') nextIndex = (currentIndex + 1) % filters.length;\n else if (event.key === 'ArrowUp')\n nextIndex = (currentIndex - 1 + filters.length) % filters.length;\n else if (event.key === 'Home') nextIndex = 0;\n else if (event.key === 'End') nextIndex = filters.length - 1;\n else if (event.key === 'ArrowRight') {\n event.preventDefault();\n requestAnimationFrame(() => {\n this.el\n .querySelector<HTMLElement>(\n '.filter-menu__options [role=\"option\"], .filter-menu__options input'\n )\n ?.focus();\n });\n return;\n } else return;\n\n event.preventDefault();\n this.focusRingVisible = true;\n this.selectCategory(filters[nextIndex].id, true);\n }\n\n private handlePanelKeyDown(event: KeyboardEvent, filterId: string) {\n if (event.key !== 'ArrowLeft') return;\n event.preventDefault();\n this.el.querySelector<HTMLElement>(`[data-filter-category=\"${filterId}\"]`)?.focus();\n }\n\n private handleOptionKeyDown(\n event: KeyboardEvent,\n filter: FilterMenuFilter,\n option: FilterMenuOption,\n index: number,\n values: FilterMenuValues\n ) {\n const options = filter.options ?? [];\n if (event.key === 'ArrowLeft') {\n this.handlePanelKeyDown(event, filter.id);\n return;\n }\n if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n this.selectOption(filter, option, values);\n return;\n }\n if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;\n event.preventDefault();\n const enabled = options\n .map((candidate, optionIndex) => ({ candidate, optionIndex }))\n .filter(({ candidate }) => !candidate.isInactive)\n .map(({ optionIndex }) => optionIndex);\n if (!enabled.length) return;\n const current = enabled.indexOf(index);\n let next: number;\n if (event.key === 'Home') next = enabled[0];\n else if (event.key === 'End') next = enabled[enabled.length - 1];\n else {\n const direction = event.key === 'ArrowDown' ? 1 : -1;\n const start = current >= 0 ? current : direction === 1 ? -1 : 0;\n next = enabled[(start + direction + enabled.length) % enabled.length];\n }\n this.activeOptionIndex = next;\n this.focusRingVisible = true;\n requestAnimationFrame(() => {\n this.el\n .querySelector<HTMLElement>(`#${this.generatedId}-${filter.id}-option-${next}`)\n ?.focus();\n });\n }\n\n private toggleMultiple(\n filter: FilterMenuFilter,\n option: FilterMenuOption,\n checked: boolean,\n values: FilterMenuValues\n ) {\n if (option.isInactive) return;\n const current = Array.isArray(values[filter.id]) ? (values[filter.id] as string[]) : [];\n const selected = new Set(current);\n if (checked) selected.add(option.value);\n else selected.delete(option.value);\n const ordered = (filter.options ?? [])\n .map(candidate => candidate.value)\n .filter(value => selected.has(value));\n this.dsChange.emit({ filterId: filter.id, value: ordered });\n }\n\n private selectOption(\n filter: FilterMenuFilter,\n option: FilterMenuOption,\n values: FilterMenuValues\n ) {\n if (option.isInactive) return;\n if (filter.kind === 'single') {\n this.dsChange.emit({ filterId: filter.id, value: option.value });\n return;\n }\n const selected = Array.isArray(values[filter.id])\n ? (values[filter.id] as string[]).includes(option.value)\n : false;\n this.toggleMultiple(filter, option, !selected, values);\n }\n\n private renderOptions(filter: FilterMenuFilter, values: FilterMenuValues) {\n const value = values[filter.id];\n if (filter.kind === 'multiple' || filter.kind === 'single') {\n const selected = Array.isArray(value) ? value : [];\n const options = filter.options ?? [];\n const usesSubtext = choiceListUsesSubtext(\n options.map(option => ({\n label: option.label,\n value: option.value,\n subtext: option.description,\n }))\n );\n return options.map((option, index) => {\n const isSelected =\n filter.kind === 'multiple' ? selected.includes(option.value) : value === option.value;\n return (\n <ChoiceOptionRow\n size=\"md\"\n id={`${this.generatedId}-${filter.id}-option-${index}`}\n option={{\n label: option.label,\n value: option.value,\n subtext: option.description,\n isInactive: option.isInactive,\n }}\n selected={isSelected}\n active={index === this.activeOptionIndex}\n focusRingVisible={this.focusRingVisible}\n usesSubtext={usesSubtext}\n tabIndex={index === this.activeOptionIndex ? 0 : -1}\n leading={\n filter.kind === 'multiple' ? (\n <span\n class=\"ds-choice-item__icon ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-checkbox label=\"\" size=\"md\" checked={isSelected} presentation />\n </span>\n ) : undefined\n }\n onFocus={() => {\n this.activeOptionIndex = index;\n }}\n onKeyDown={(event: KeyboardEvent) =>\n this.handleOptionKeyDown(event, filter, option, index, values)\n }\n onHover={() => {\n this.focusRingVisible = false;\n this.activeOptionIndex = index;\n }}\n onSelect={() => this.selectOption(filter, option, values)}\n />\n );\n });\n }\n\n if (filter.kind === 'boolean') {\n const option: FilterMenuOption = {\n label: filter.fieldLabel ?? filter.label,\n value: 'true',\n description: filter.description,\n };\n return (\n <ChoiceOptionRow\n size=\"md\"\n id={`${this.generatedId}-${filter.id}-option-0`}\n option={{\n label: option.label,\n value: option.value,\n subtext: option.description,\n }}\n selected={value === true}\n active\n focusRingVisible={this.focusRingVisible}\n usesSubtext={Boolean(option.description)}\n tabIndex={0}\n leading={\n <span\n class=\"ds-choice-item__icon ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-checkbox label=\"\" size=\"md\" checked={value === true} presentation />\n </span>\n }\n onKeyDown={(event: KeyboardEvent) => {\n if (event.key === 'ArrowLeft') this.handlePanelKeyDown(event, filter.id);\n else if (event.key === 'Enter' || event.key === ' ') {\n event.preventDefault();\n this.dsChange.emit({ filterId: filter.id, value: value !== true });\n }\n }}\n onHover={() => {\n this.focusRingVisible = false;\n }}\n onSelect={() => this.dsChange.emit({ filterId: filter.id, value: value !== true })}\n />\n );\n }\n\n const dateValue = typeof value === 'string' ? value : '';\n const inputId = `${this.generatedId}-${filter.id}-input`;\n return (\n <div class=\"filter-menu__date-field\">\n <label htmlFor={inputId}>\n <ds-text as=\"span\" variant=\"text-body-small\" emphasis color=\"primary\">\n {filter.fieldLabel ?? 'Event date'}\n </ds-text>\n </label>\n <div class=\"filter-menu__date-control ds-control-frame ds-control--md\">\n <input\n id={inputId}\n class=\"filter-menu__date-input\"\n type=\"date\"\n value={dateValue}\n onInput={(event: Event) =>\n this.dsChange.emit({\n filterId: filter.id,\n value: (event.target as HTMLInputElement).value,\n })\n }\n />\n {dateValue ? (\n <ds-button-unfilled\n variant=\"icon\"\n icon=\"CrossCircle\"\n size=\"sm\"\n isInset\n hasBorder={false}\n rounded\n ariaLabel={`${this.clearLabel} ${filter.fieldLabel ?? filter.label}`}\n onDsClick={(event: CustomEvent<MouseEvent>) => {\n event.stopPropagation();\n this.dsChange.emit({ filterId: filter.id, value: '' });\n }}\n />\n ) : null}\n </div>\n </div>\n );\n }\n\n private get captionIconOnly(): boolean {\n return (\n this.collapseLabel &&\n this.captionCompact &&\n this.usesInternalTrigger &&\n Boolean(this.icon)\n );\n }\n\n private syncCaptionCompactObserver(): void {\n this.disconnectCaptionCompactObserver();\n if (!this.collapseLabel) {\n if (this.captionCompact) this.captionCompact = false;\n return;\n }\n this.captionCompactDisconnect = observeTableCaptionCompact(this.el, compact => {\n if (this.captionCompact !== compact) this.captionCompact = compact;\n });\n }\n\n private disconnectCaptionCompactObserver(): void {\n this.captionCompactDisconnect?.();\n this.captionCompactDisconnect = undefined;\n }\n\n render() {\n const state =\n this.closing && this.closingSnapshot\n ? this.closingSnapshot\n : {\n filters: this.filters,\n values: this.values,\n activeFilterId: this.activeFilterId,\n };\n const activeFilter = this.selectedFilter(state.filters, state.activeFilterId);\n const totalSelected = this.totalSelected(state.filters, state.values);\n const activeFilterCount = this.activeFilterCount(state.filters, state.values);\n const hasActiveFilters = activeFilterCount > 0;\n const label = `${this.triggerLabel}${hasActiveFilters ? ` · ${activeFilterCount}` : ''}`;\n const textVariant = CONTROL_TEXT_VARIANT[this.size];\n const iconSize = ICON_SIZE[this.size];\n const popupId = `${this.generatedId}-popup`;\n const popupStyle: Record<string, string> = {\n position: 'fixed',\n left: '0',\n top: '0',\n width: this.menuWidth,\n transform: `translate(${Math.round(this.pos.x)}px, ${Math.round(this.pos.y)}px)`,\n zIndex: '9998',\n visibility: this.positionReady ? 'visible' : 'hidden',\n };\n\n return (\n <Host\n class={{\n 'filter-menu-host': true,\n 'filter-menu-host--external-trigger': !this.usesInternalTrigger,\n 'ds-select-trigger-host': this.usesInternalTrigger,\n [`ds-control--${this.size}`]: true,\n 'ds-table-caption-control': this.collapseLabel,\n 'ds-table-caption-control--compact': this.captionIconOnly,\n ...controlWidthClass(this.width),\n }}\n >\n {this.usesInternalTrigger ? (\n <button\n ref={element => {\n this.triggerElement = (element as HTMLButtonElement) ?? null;\n }}\n id={this.inputId ?? `${this.generatedId}-trigger`}\n type=\"button\"\n class={{\n trigger: true,\n 'ds-control-frame': true,\n 'ds-focus-ring-inset': true,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--selected': this.activeFill && hasActiveFilters,\n 'trigger--expanded': this.open || this.closing,\n 'trigger--bordered': this.hasBorder,\n 'trigger--has-value': hasActiveFilters,\n [`ds-control--${this.size}`]: true,\n }}\n role=\"combobox\"\n aria-haspopup=\"dialog\"\n aria-expanded={String(this.open)}\n aria-controls={this.open ? popupId : undefined}\n aria-label={this.ariaLabel ?? this.menuLabel}\n onClick={(event: MouseEvent) => this.toggleFromTrigger(event)}\n onKeyDown={(event: KeyboardEvent) => this.handleTriggerKeyDown(event)}\n >\n {this.icon ? (\n <span\n class=\"trigger__prefix ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-icon name={this.icon} size={iconSize} color=\"inherit\" />\n </span>\n ) : null}\n {this.captionIconOnly ? null : (\n <ds-text\n class=\"trigger__label-box trigger__label trigger__label-content ds-control-label-box ds-interaction-fill__content\"\n as=\"span\"\n variant={textVariant}\n color=\"inherit\"\n lineTruncation={1}\n >\n {label}\n </ds-text>\n )}\n {this.captionIconOnly ? null : (\n <span\n class=\"trigger__chevron ds-control-icon-box ds-interaction-fill__content\"\n aria-hidden=\"true\"\n >\n <ds-icon name=\"ChevronDown\" size={iconSize} color=\"inherit\" />\n </span>\n )}\n </button>\n ) : null}\n\n {this.shouldRender ? (\n <div\n id={popupId}\n popover=\"manual\"\n class={{\n 'filter-menu-popup': true,\n 'ds-choice-popup': true,\n 'ds-choice-popup--closing': this.closing,\n }}\n style={popupStyle}\n role=\"dialog\"\n aria-label={this.menuLabel}\n >\n {activeFilter ? (\n <div class=\"filter-menu\">\n <div class=\"filter-menu__body\">\n <div\n class=\"filter-menu__categories ds-choice-list ds-chrome-column ds-chrome-space--sm\"\n role=\"tablist\"\n aria-label={this.categoriesLabel}\n aria-orientation=\"vertical\"\n >\n {state.filters.map(filter => {\n const active = filter.id === activeFilter.id;\n const count = this.selectedCount(filter, state.values);\n return (\n <div\n key={filter.id}\n class={{\n 'filter-menu__category-section': true,\n 'ds-choice-section--divided': Boolean(filter.divider),\n }}\n >\n <button\n id={`${this.generatedId}-${filter.id}-tab`}\n type=\"button\"\n role=\"tab\"\n data-filter-category={filter.id}\n class={{\n 'filter-menu__category': true,\n 'filter-menu__category--active': active,\n 'ds-choice-item': true,\n 'ds-control-frame': true,\n 'ds-control--md': true,\n 'ds-focus-ring-inset': true,\n 'ds-focus-ring--visible': active && this.focusRingVisible,\n 'ds-interaction-fill': true,\n 'ds-interaction-fill--selected': active,\n }}\n aria-selected={String(active)}\n aria-controls={`${this.generatedId}-panel`}\n tabIndex={active ? 0 : -1}\n onMouseDown={() => {\n this.focusRingVisible = false;\n }}\n onClick={() => this.selectCategory(filter.id)}\n onKeyDown={(event: KeyboardEvent) =>\n this.handleCategoryKeyDown(event, state.filters, filter.id)\n }\n >\n <ds-text\n class=\"ds-choice-item__content ds-choice-item__label ds-control-label-box ds-interaction-fill__content\"\n as=\"span\"\n variant=\"text-body-medium\"\n color={active ? 'primary' : 'secondary'}\n >\n {filter.label}\n </ds-text>\n {count > 0 ? (\n <ds-tag\n class=\"ds-choice-item__tag ds-interaction-fill__content\"\n label={String(count)}\n size=\"sm\"\n intent=\"brand\"\n contrast=\"bold\"\n isInset\n rounded\n />\n ) : null}\n </button>\n </div>\n );\n })}\n </div>\n\n <div\n id={`${this.generatedId}-panel`}\n class=\"filter-menu__options\"\n role=\"tabpanel\"\n aria-labelledby={`${this.generatedId}-${activeFilter.id}-tab`}\n >\n <div\n key={activeFilter.id}\n class=\"filter-menu__option-list ds-choice-list ds-chrome-column ds-chrome-space--sm\"\n role=\"listbox\"\n aria-label={activeFilter.label}\n aria-multiselectable={activeFilter.kind === 'multiple' ? 'true' : undefined}\n >\n {this.renderOptions(activeFilter, state.values)}\n </div>\n </div>\n </div>\n\n {totalSelected > 0 ? (\n <div class=\"filter-menu__footer ds-choice-footer\">\n <div class=\"ds-choice-footer__content ds-control--md\">\n <ds-text\n class=\"ds-choice-footer__summary\"\n as=\"span\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n aria-live=\"polite\"\n >\n {totalSelected} selected\n </ds-text>\n <button\n class=\"filter-menu__clear ds-choice-footer__clear ds-text-action\"\n type=\"button\"\n onClick={() => this.dsClear.emit()}\n >\n <ds-text as=\"span\" variant=\"text-body-medium\" color=\"inherit\">\n {this.clearLabel}\n </ds-text>\n </button>\n </div>\n </div>\n ) : null}\n </div>\n ) : null}\n </div>\n ) : null}\n </Host>\n );\n }\n}\n",
|
|
665
665
|
"type": "registry:ui"
|
|
666
666
|
}
|
|
667
667
|
]
|
|
@@ -52,6 +52,7 @@
|
|
|
52
52
|
],
|
|
53
53
|
"states": [
|
|
54
54
|
"Loader communicates indeterminate progress only and does not emit completion or failure events.",
|
|
55
|
+
"The segmented glyph rotates clockwise, with its opacity ramp trailing the leading arc.",
|
|
55
56
|
"Omitted color inherits currentColor. Explicit semantic aliases and CSS-variable references match Icon when an independent progress color is required.",
|
|
56
57
|
"Nested Loader inherits a readable color from its owning busy control or region rather than selecting tertiary or quaternary directly.",
|
|
57
58
|
"The owner decides whether to delay Loader visibility for short operations and removes it when the operation settles.",
|
|
@@ -185,7 +186,7 @@
|
|
|
185
186
|
"files": [
|
|
186
187
|
{
|
|
187
188
|
"path": "src/wc/components/Loader/Loader.css",
|
|
188
|
-
"content": "@import '../../utils/visually-hidden.css';\n\n/* ── Base loader (mirrors ds-icon box metrics) ───────────────────────────── */\n\n.loader {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n line-height: 0;\n color: inherit;\n width: var(--_loader-sz, var(--dimension-iconography-md));\n height: var(--_loader-sz, var(--dimension-iconography-md));\n animation: loader-spin var(--effect-animation-duration-long-2) var(--effect-animation-easing-linear) infinite;\n}\n\n.loader__svg {\n display: block;\n flex-shrink: 0;\n width: 100%;\n height: 100%;\n transform: scaleX(-1);\n}\n\n.status {\n display: inline-flex;\n align-items: center;\n}\n\n/* Clockwise continuous spin — 1000ms via long-2. */\n@keyframes loader-spin {\n from { transform: rotate(0deg); }\n to { transform: rotate(360deg); }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .loader {\n animation: none;\n transform: none;\n }\n}\n\n/* ── Sizes (same tokens as ds-icon) ──────────────────────────────────────── */\n\n.loader--size-xs { --_loader-sz: var(--dimension-iconography-xs); }\n.loader--size-sm { --_loader-sz: var(--dimension-iconography-sm); }\n.loader--size-md { --_loader-sz: var(--dimension-iconography-md); }\n.loader--size-lg { --_loader-sz: var(--dimension-iconography-lg); }\n.loader--size-xl { --_loader-sz: var(--dimension-iconography-xl); }\n.loader--size-2xl { --_loader-sz: var(--dimension-iconography-2xl); }\n.loader--size-3xl { --_loader-sz: var(--dimension-iconography-3xl); }\n\n/* ── Colors — hierarchy ──────────────────────────────────────────────────── */\n\n.loader--color-primary { color: var(--color-foreground-primary); }\n.loader--color-secondary { color: var(--color-foreground-secondary); }\n.loader--color-tertiary { color: var(--color-foreground-tertiary); }\n.loader--color-quaternary { color: var(--color-foreground-quaternary); }\n.loader--color-inherit { color: inherit; }\n\n/* ── Colors — intent (bold contrast default) ─────────────────────────────── */\n\n.loader--color-brand { color: var(--color-foreground-bold-brand); }\n.loader--color-negative { color: var(--color-foreground-bold-negative); }\n.loader--color-positive { color: var(--color-foreground-bold-positive); }\n.loader--color-warning { color: var(--color-foreground-bold-warning); }\n.loader--color-caution { color: var(--color-foreground-bold-caution); }\n.loader--color-ai { color: var(--color-foreground-bold-ai); }\n.loader--color-guide { color: var(--color-foreground-bold-guide); }\n.loader--color-neutral { color: var(--color-foreground-bold-neutral); }\n\n/* ── Colors — intent faint ───────────────────────────────────────────────── */\n\n.loader--color-faint-brand { color: var(--color-foreground-faint-brand); }\n.loader--color-faint-negative { color: var(--color-foreground-faint-negative); }\n.loader--color-faint-positive { color: var(--color-foreground-faint-positive); }\n.loader--color-faint-warning { color: var(--color-foreground-faint-warning); }\n.loader--color-faint-caution { color: var(--color-foreground-faint-caution); }\n.loader--color-faint-ai { color: var(--color-foreground-faint-ai); }\n.loader--color-faint-neutral { color: var(--color-foreground-faint-neutral); }\n\n/* ── Colors — intent medium ─────────────────────────────────────────────── */\n\n.loader--color-medium-brand { color: var(--color-foreground-medium-brand); }\n.loader--color-medium-negative { color: var(--color-foreground-medium-negative); }\n.loader--color-medium-positive { color: var(--color-foreground-medium-positive); }\n.loader--color-medium-warning { color: var(--color-foreground-medium-warning); }\n.loader--color-medium-caution { color: var(--color-foreground-medium-caution); }\n.loader--color-medium-ai { color: var(--color-foreground-medium-ai); }\n.loader--color-medium-neutral { color: var(--color-foreground-medium-neutral); }\n\n/* ── Colors — intent bold (explicit) ────────────────────────────────────── */\n\n.loader--color-bold-brand { color: var(--color-foreground-bold-brand); }\n.loader--color-bold-negative { color: var(--color-foreground-bold-negative); }\n.loader--color-bold-positive { color: var(--color-foreground-bold-positive); }\n.loader--color-bold-warning { color: var(--color-foreground-bold-warning); }\n.loader--color-bold-caution { color: var(--color-foreground-bold-caution); }\n.loader--color-bold-ai { color: var(--color-foreground-bold-ai); }\n.loader--color-bold-neutral { color: var(--color-foreground-bold-neutral); }\n\n/* ── Colors — intent strong ──────────────────────────────────────────────── */\n\n.loader--color-strong-brand { color: var(--color-foreground-strong-brand); }\n.loader--color-strong-negative { color: var(--color-foreground-strong-negative); }\n.loader--color-strong-positive { color: var(--color-foreground-strong-positive); }\n.loader--color-strong-warning { color: var(--color-foreground-strong-warning); }\n.loader--color-strong-caution { color: var(--color-foreground-strong-caution); }\n.loader--color-strong-ai { color: var(--color-foreground-strong-ai); }\n.loader--color-strong-neutral { color: var(--color-foreground-strong-neutral); }\n\n/* ── Colors — contextual ────────────────────────────────────────────────── */\n\n.loader--color-on-strong { color: var(--color-foreground-on-strong-background-primary); }\n.loader--color-on-bold { color: var(--color-foreground-on-bold-background-primary); }\n",
|
|
189
|
+
"content": "@import '../../utils/visually-hidden.css';\n\n/* ── Base loader (mirrors ds-icon box metrics) ───────────────────────────── */\n\n.loader {\n display: inline-flex;\n align-items: center;\n justify-content: center;\n flex-shrink: 0;\n line-height: 0;\n color: inherit;\n width: var(--_loader-sz, var(--dimension-iconography-md));\n height: var(--_loader-sz, var(--dimension-iconography-md));\n animation: loader-spin var(--effect-animation-duration-long-2) var(--effect-animation-easing-linear) infinite;\n}\n\n.loader__svg {\n display: block;\n flex-shrink: 0;\n width: 100%;\n height: 100%;\n /* Mirror the opacity ramp so it trails the clockwise rotation on the wrapper. */\n transform: scaleX(-1);\n}\n\n.status {\n display: inline-flex;\n align-items: center;\n}\n\n/* Clockwise continuous spin — 1000ms via long-2. */\n@keyframes loader-spin {\n from { transform: rotate(0deg); }\n to { transform: rotate(360deg); }\n}\n\n@media (prefers-reduced-motion: reduce) {\n .loader {\n animation: none;\n transform: none;\n }\n}\n\n/* ── Sizes (same tokens as ds-icon) ──────────────────────────────────────── */\n\n.loader--size-xs { --_loader-sz: var(--dimension-iconography-xs); }\n.loader--size-sm { --_loader-sz: var(--dimension-iconography-sm); }\n.loader--size-md { --_loader-sz: var(--dimension-iconography-md); }\n.loader--size-lg { --_loader-sz: var(--dimension-iconography-lg); }\n.loader--size-xl { --_loader-sz: var(--dimension-iconography-xl); }\n.loader--size-2xl { --_loader-sz: var(--dimension-iconography-2xl); }\n.loader--size-3xl { --_loader-sz: var(--dimension-iconography-3xl); }\n\n/* ── Colors — hierarchy ──────────────────────────────────────────────────── */\n\n.loader--color-primary { color: var(--color-foreground-primary); }\n.loader--color-secondary { color: var(--color-foreground-secondary); }\n.loader--color-tertiary { color: var(--color-foreground-tertiary); }\n.loader--color-quaternary { color: var(--color-foreground-quaternary); }\n.loader--color-inherit { color: inherit; }\n\n/* ── Colors — intent (bold contrast default) ─────────────────────────────── */\n\n.loader--color-brand { color: var(--color-foreground-bold-brand); }\n.loader--color-negative { color: var(--color-foreground-bold-negative); }\n.loader--color-positive { color: var(--color-foreground-bold-positive); }\n.loader--color-warning { color: var(--color-foreground-bold-warning); }\n.loader--color-caution { color: var(--color-foreground-bold-caution); }\n.loader--color-ai { color: var(--color-foreground-bold-ai); }\n.loader--color-guide { color: var(--color-foreground-bold-guide); }\n.loader--color-neutral { color: var(--color-foreground-bold-neutral); }\n\n/* ── Colors — intent faint ───────────────────────────────────────────────── */\n\n.loader--color-faint-brand { color: var(--color-foreground-faint-brand); }\n.loader--color-faint-negative { color: var(--color-foreground-faint-negative); }\n.loader--color-faint-positive { color: var(--color-foreground-faint-positive); }\n.loader--color-faint-warning { color: var(--color-foreground-faint-warning); }\n.loader--color-faint-caution { color: var(--color-foreground-faint-caution); }\n.loader--color-faint-ai { color: var(--color-foreground-faint-ai); }\n.loader--color-faint-neutral { color: var(--color-foreground-faint-neutral); }\n\n/* ── Colors — intent medium ─────────────────────────────────────────────── */\n\n.loader--color-medium-brand { color: var(--color-foreground-medium-brand); }\n.loader--color-medium-negative { color: var(--color-foreground-medium-negative); }\n.loader--color-medium-positive { color: var(--color-foreground-medium-positive); }\n.loader--color-medium-warning { color: var(--color-foreground-medium-warning); }\n.loader--color-medium-caution { color: var(--color-foreground-medium-caution); }\n.loader--color-medium-ai { color: var(--color-foreground-medium-ai); }\n.loader--color-medium-neutral { color: var(--color-foreground-medium-neutral); }\n\n/* ── Colors — intent bold (explicit) ────────────────────────────────────── */\n\n.loader--color-bold-brand { color: var(--color-foreground-bold-brand); }\n.loader--color-bold-negative { color: var(--color-foreground-bold-negative); }\n.loader--color-bold-positive { color: var(--color-foreground-bold-positive); }\n.loader--color-bold-warning { color: var(--color-foreground-bold-warning); }\n.loader--color-bold-caution { color: var(--color-foreground-bold-caution); }\n.loader--color-bold-ai { color: var(--color-foreground-bold-ai); }\n.loader--color-bold-neutral { color: var(--color-foreground-bold-neutral); }\n\n/* ── Colors — intent strong ──────────────────────────────────────────────── */\n\n.loader--color-strong-brand { color: var(--color-foreground-strong-brand); }\n.loader--color-strong-negative { color: var(--color-foreground-strong-negative); }\n.loader--color-strong-positive { color: var(--color-foreground-strong-positive); }\n.loader--color-strong-warning { color: var(--color-foreground-strong-warning); }\n.loader--color-strong-caution { color: var(--color-foreground-strong-caution); }\n.loader--color-strong-ai { color: var(--color-foreground-strong-ai); }\n.loader--color-strong-neutral { color: var(--color-foreground-strong-neutral); }\n\n/* ── Colors — contextual ────────────────────────────────────────────────── */\n\n.loader--color-on-strong { color: var(--color-foreground-on-strong-background-primary); }\n.loader--color-on-bold { color: var(--color-foreground-on-bold-background-primary); }\n",
|
|
189
190
|
"type": "registry:ui"
|
|
190
191
|
},
|
|
191
192
|
{
|