@ds-mo/ui 15.10.2 → 15.11.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 +12 -11
- package/dist/angular/ds-shell-app.d.ts +3 -8
- package/dist/angular/ds-shell-tools.d.ts +3 -8
- 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-page-title.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-calendar.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-overview.js.map +1 -1
- package/dist/components/ds-card-setting.js +1 -1
- package/dist/components/ds-chart.js +1 -1
- package/dist/components/ds-chart.js.map +1 -1
- package/dist/components/ds-code-block.js +1 -1
- package/dist/components/ds-data-customize.js +1 -1
- package/dist/components/ds-data-filter.js +1 -1
- package/dist/components/ds-data-group.js +1 -1
- package/dist/components/ds-data-preferences.js +1 -1
- package/dist/components/ds-data-saved-views.js +1 -1
- package/dist/components/ds-data-search.js +1 -1
- package/dist/components/ds-data-sort.js +1 -1
- package/dist/components/ds-filter-menu.js +1 -1
- package/dist/components/ds-input-date.js +1 -1
- package/dist/components/ds-input-time.js +1 -1
- package/dist/components/ds-input.js +1 -1
- package/dist/components/ds-markdown.js +1 -1
- package/dist/components/ds-menu.js +1 -1
- package/dist/components/ds-message-actions.js +1 -1
- package/dist/components/ds-message-composer.js +1 -1
- package/dist/components/ds-message-scroller.js +1 -1
- package/dist/components/ds-mobile-bar-nav.js +1 -1
- package/dist/components/ds-mobile-header.js +1 -1
- package/dist/components/ds-mobile-section-switcher.js +1 -1
- package/dist/components/ds-mobile-sheet-nav.js +1 -1
- package/dist/components/ds-modal.js +1 -1
- package/dist/components/ds-pagination.js +1 -1
- package/dist/components/ds-panel-nav.js +1 -1
- package/dist/components/ds-panel-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-scroll-overlay.js +1 -1
- package/dist/components/ds-select.js +1 -1
- package/dist/components/ds-shell-app.js +1 -1
- package/dist/components/ds-shell-app.js.map +1 -1
- package/dist/components/ds-shell-page.js +1 -1
- package/dist/components/ds-shell-tools.js +1 -1
- package/dist/components/ds-table.js +1 -1
- package/dist/components/ds-table.js.map +1 -1
- package/dist/components/ds-toast.js +1 -1
- package/dist/components/ds-toast.js.map +1 -1
- package/dist/components/ds-tooltip.js +1 -1
- package/dist/components/{p-PNY7auMh.js → p-B5rN3slq.js} +2 -2
- package/dist/components/{p-kQ6wvk-r.js → p-B9Q7fXxW.js} +2 -2
- package/dist/components/p-BNnw_O4e.js +2 -0
- package/dist/components/p-BNnw_O4e.js.map +1 -0
- package/dist/components/{p-BgpzSEWv.js → p-BT6xQogY.js} +2 -2
- package/dist/components/{p-YmJYwnhW.js → p-Ba-dSshd.js} +2 -2
- package/dist/components/{p-YmJYwnhW.js.map → p-Ba-dSshd.js.map} +1 -1
- package/dist/components/{p-DGIVqwT9.js → p-BkajUPiA.js} +2 -2
- package/dist/components/{p-lJeAJKa5.js → p-BqIRExej.js} +2 -2
- package/dist/components/{p-BtSEQ-FW.js → p-C8D853vs.js} +2 -2
- package/dist/components/{p-CHYYRdRJ.js → p-CEB8RFih.js} +2 -2
- package/dist/components/{p-DuCCuJKI.js → p-CGKjjJZm.js} +2 -2
- package/dist/components/{p-2ZV8JA88.js → p-CMrHRjjH.js} +2 -2
- package/dist/components/{p-DwuqJhR5.js → p-CPNA8raE.js} +2 -2
- package/dist/components/{p-ioGH5SoA.js → p-CVYjLzYI.js} +2 -2
- package/dist/components/p-CVYjLzYI.js.map +1 -0
- package/dist/components/p-CW-7vLUB.js +2 -0
- package/dist/components/p-CW-7vLUB.js.map +1 -0
- package/dist/components/{p-BrB05_X8.js → p-CaNMVLhm.js} +2 -2
- package/dist/components/{p-hmvu1lmP.js → p-Cxk1A7Sx.js} +2 -2
- package/dist/components/p-Cxk1A7Sx.js.map +1 -0
- package/dist/components/{p-BjLGayCj.js → p-D-cZ6cRI.js} +2 -2
- package/dist/components/{p-DQeUT9o2.js → p-D4r6oWyQ.js} +2 -2
- package/dist/components/{p-DoRgzvup.js → p-DGDF4NMV.js} +2 -2
- package/dist/components/{p-DSpKpoSG.js → p-DKG26pp5.js} +2 -2
- package/dist/components/p-DfphSI9S.js +2 -0
- package/dist/components/p-DfphSI9S.js.map +1 -0
- package/dist/components/p-DfwvHxYi.js +2 -0
- package/dist/components/p-DfwvHxYi.js.map +1 -0
- package/dist/components/{p-BQE2Uj4p.js → p-DfzXyVoM.js} +2 -2
- package/dist/components/{p-BQE2Uj4p.js.map → p-DfzXyVoM.js.map} +1 -1
- package/dist/components/{p-z_8aZHHo.js → p-DixX2_Bd.js} +2 -2
- package/dist/components/{p-CipTqNIr.js → p-Djkk338n.js} +2 -2
- package/dist/components/{p-Dx7wJaLv.js → p-DvMCOJwc.js} +2 -2
- package/dist/components/{p-ChsBscqZ.js → p-DwFnt0W0.js} +2 -2
- package/dist/components/{p-C0xrqZxm.js → p-OFJpWJcN.js} +2 -2
- package/dist/components/{p-Bx_72ylJ.js → p-S5QCVAdH.js} +2 -2
- package/dist/docs/components.json +13 -13
- package/dist/lib/utils/index.js +3 -1
- package/dist/lib/utils/index.js.map +2 -2
- package/dist/lib/utils/resolve-css-length-px.d.ts +1 -1
- package/dist/mcp-data/registry/card-overview.json +1 -1
- package/dist/mcp-data/registry/chart.json +3 -3
- package/dist/mcp-data/registry/mobile-bar-nav.json +3 -3
- package/dist/mcp-data/registry/mobile-header.json +3 -3
- package/dist/mcp-data/registry/mobile-section-switcher.json +3 -2
- package/dist/mcp-data/registry/mobile-sheet-nav.json +6 -6
- package/dist/mcp-data/registry/modal.json +1 -1
- package/dist/mcp-data/registry/panel-tools.json +1 -1
- package/dist/mcp-data/registry/registry.json +11 -10
- package/dist/mcp-data/registry/shell-app.json +3 -3
- package/dist/mcp-data/registry/shell-tools.json +4 -4
- package/dist/mcp-data/registry/table.json +2 -2
- package/dist/mcp-data/registry/toast.json +1 -1
- package/dist/react/ds-shell-app.d.ts +2 -5
- package/dist/react/ds-shell-tools.d.ts +2 -5
- package/dist/styles/responsive.css +293 -0
- package/dist/types/components/ShellApp/ShellApp.d.ts +2 -5
- package/dist/types/components/ShellTools/ShellTools.d.ts +2 -5
- package/dist/types/components.d.ts +4 -10
- package/dist/types/utils/resolve-css-length-px.d.ts +1 -1
- package/package.json +2 -1
- package/dist/components/p-BfdYb8L4.js +0 -2
- package/dist/components/p-BfdYb8L4.js.map +0 -1
- package/dist/components/p-BuwYiPCW.js +0 -2
- package/dist/components/p-BuwYiPCW.js.map +0 -1
- package/dist/components/p-cktJHlYv.js +0 -2
- package/dist/components/p-cktJHlYv.js.map +0 -1
- package/dist/components/p-hmvu1lmP.js.map +0 -1
- package/dist/components/p-iEhBF5Oi.js +0 -2
- package/dist/components/p-iEhBF5Oi.js.map +0 -1
- package/dist/components/p-ioGH5SoA.js.map +0 -1
- /package/dist/components/{p-PNY7auMh.js.map → p-B5rN3slq.js.map} +0 -0
- /package/dist/components/{p-kQ6wvk-r.js.map → p-B9Q7fXxW.js.map} +0 -0
- /package/dist/components/{p-BgpzSEWv.js.map → p-BT6xQogY.js.map} +0 -0
- /package/dist/components/{p-DGIVqwT9.js.map → p-BkajUPiA.js.map} +0 -0
- /package/dist/components/{p-lJeAJKa5.js.map → p-BqIRExej.js.map} +0 -0
- /package/dist/components/{p-BtSEQ-FW.js.map → p-C8D853vs.js.map} +0 -0
- /package/dist/components/{p-CHYYRdRJ.js.map → p-CEB8RFih.js.map} +0 -0
- /package/dist/components/{p-DuCCuJKI.js.map → p-CGKjjJZm.js.map} +0 -0
- /package/dist/components/{p-2ZV8JA88.js.map → p-CMrHRjjH.js.map} +0 -0
- /package/dist/components/{p-DwuqJhR5.js.map → p-CPNA8raE.js.map} +0 -0
- /package/dist/components/{p-BrB05_X8.js.map → p-CaNMVLhm.js.map} +0 -0
- /package/dist/components/{p-BjLGayCj.js.map → p-D-cZ6cRI.js.map} +0 -0
- /package/dist/components/{p-DQeUT9o2.js.map → p-D4r6oWyQ.js.map} +0 -0
- /package/dist/components/{p-DoRgzvup.js.map → p-DGDF4NMV.js.map} +0 -0
- /package/dist/components/{p-DSpKpoSG.js.map → p-DKG26pp5.js.map} +0 -0
- /package/dist/components/{p-z_8aZHHo.js.map → p-DixX2_Bd.js.map} +0 -0
- /package/dist/components/{p-CipTqNIr.js.map → p-Djkk338n.js.map} +0 -0
- /package/dist/components/{p-Dx7wJaLv.js.map → p-DvMCOJwc.js.map} +0 -0
- /package/dist/components/{p-ChsBscqZ.js.map → p-DwFnt0W0.js.map} +0 -0
- /package/dist/components/{p-C0xrqZxm.js.map → p-OFJpWJcN.js.map} +0 -0
- /package/dist/components/{p-Bx_72ylJ.js.map → p-S5QCVAdH.js.map} +0 -0
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
export declare function clearCssLengthPxCache(): void;
|
|
3
3
|
/**
|
|
4
4
|
* Resolve a CSS length to pixels for layout math. Dynamic expressions are read
|
|
5
|
-
* from the
|
|
5
|
+
* from the body (application token scope) or explicit component scope on every call. Relative
|
|
6
6
|
* font units use that scope; percentage lengths use the viewport's inline size.
|
|
7
7
|
*/
|
|
8
8
|
export declare function resolveCssLengthPx(value: number | string | undefined, fallback: number | string, context?: Element): number;
|
|
@@ -401,7 +401,7 @@
|
|
|
401
401
|
},
|
|
402
402
|
{
|
|
403
403
|
"path": "src/wc/components/CardOverview/CardOverview.tsx",
|
|
404
|
-
"content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n forceUpdate,\n h,\n Host,\n Prop,\n State,\n} from '@stencil/core';\nimport { resolveCssLengthPx, TOKEN_DEFAULTS } from '../../utils';\nimport { ConnectionTasks } from '../../utils/connection-tasks';\nimport type { TextColor, TextVariant } from '../Text/text-types';\nimport type { MetricTrend } from '../../utils/metric-change';\nimport type {\n CardOverviewLayout,\n CardOverviewVariant,\n OverviewMetric,\n OverviewScore,\n} from './card-overview-types';\nimport {\n findNextOverviewMetricIndex,\n resolveCardOverviewCollapseGeometry,\n resolveOverviewGridColumns,\n resolveOverviewRovingIndex,\n} from './card-overview-controller';\n\n/**\n * Metric cells below this width drop a grid column rather than compress further.\n *\n * A literal rather than a token: this is a layout threshold for a grid cell, and\n * the dimension scale has no token for that. The 200px tokens it could borrow\n * (`menu-width-xs`, `form-width-xs`, `panel-width-2xs`) all name other\n * components, so binding to one would couple this grid to an unrelated contract.\n * Consumers override with the `metricMinWidth` prop.\n */\nconst DEFAULT_METRIC_MIN_WIDTH = '200px';\n\n/** Skeleton cells rendered while loading with no metrics supplied yet. */\nconst DEFAULT_LOADING_METRIC_COUNT = 5;\n\n/** Maximum number of comparable measures the summary bar presents at once. */\nconst MAX_METRIC_COUNT = 7;\n\nconst TREND_COLORS: Record<MetricTrend['tone'], TextColor> = {\n positive: 'var(--color-always-dark-foreground-positive)',\n negative: 'var(--color-always-dark-foreground-negative)',\n neutral: 'var(--color-always-dark-foreground-secondary)',\n};\n\nconst ALWAYS_DARK_PRIMARY: TextColor = 'var(--color-always-dark-foreground-primary)';\nconst ALWAYS_DARK_SECONDARY: TextColor = 'var(--color-always-dark-foreground-secondary)';\n\n@Component({\n tag: 'ds-card-overview',\n styleUrl: 'CardOverview.css',\n scoped: true,\n})\nexport class CardOverview {\n @Element() el!: HTMLElement;\n\n /** Full summary card, or the condensed 48px summary bar. */\n @Prop() variant: CardOverviewVariant = 'default';\n\n /** Intrinsic responsive layout, or a page-owned single-column stack. */\n @Prop() layout: CardOverviewLayout = 'auto';\n\n /** Optional nonselectable safety score rendered as the first grid cell. */\n @Prop() score: OverviewScore | undefined;\n\n /** Fixed current date or range. Replaced by content in the `period` slot. */\n @Prop() periodLabel: string = '';\n\n /** Copy between the current period and comparison control, usually `vs.`. */\n @Prop() comparisonLabel: string = '';\n\n /** Measures rendered in the responsive grid. Only the first seven are shown. */\n @Prop() metrics: OverviewMetric[] = [];\n\n /**\n * Width a metric cell may shrink to before the grid drops a column. The grid\n * uses this threshold while choosing an evenly distributed column count.\n */\n @Prop() metricMinWidth: string = DEFAULT_METRIC_MIN_WIDTH;\n\n /** Replace the score and metrics with skeletons while data resolves. */\n @Prop() isLoading: boolean = false;\n\n /** Message shown in place of the score when its figure cannot be resolved. */\n @Prop() scoreErrorMessage: string | undefined;\n\n /** Accessible name for the region. */\n @Prop() overviewLabel: string = 'Overview';\n\n /**\n * Page-controlled visual collapse from the full card (`0`) toward its 48px\n * compact handoff height (`1`). The component preserves its expanded flow\n * height, keeps the period bar stationary, and moves/clips the grid beneath\n * it. The page still owns sticky positioning and the final compact handoff.\n */\n @Prop() scrollCollapseProgress: number = 0;\n\n /** Emitted when a metric that is not inactive is activated. */\n @Event() dsMetricSelect!: EventEmitter<OverviewMetric>;\n\n /** Roving tab stop across selectable metrics. */\n @State() private focusedMetricIndex: number = 0;\n @State() private expandedHeight: number = 0;\n @State() private gridColumns: number | undefined;\n\n private layoutEl?: HTMLElement;\n private layoutResizeObserver: ResizeObserver | null = null;\n private layoutResizeFrame: number | undefined;\n private hasLoaded = false;\n private readonly connectionTasks = new ConnectionTasks(() => this.el.isConnected);\n /** The node the observer is bound to, which a later render can replace. */\n private observedLayoutEl?: HTMLElement;\n\n connectedCallback() {\n if (this.hasLoaded) this.observeLayout();\n }\n\n componentDidLoad() {\n this.hasLoaded = true;\n this.observeLayout();\n }\n\n componentDidRender() {\n if (!this.el.isConnected) return;\n // Re-bind after HMR when DidLoad's observer was dropped, and whenever a\n // render swapped the layout node out from under the observer: the ref points\n // at the new node while the observer still watches the detached one, which\n // never resizes again. The grid would then keep its first measured column\n // count for the life of the component.\n if (!this.layoutResizeObserver || this.observedLayoutEl !== this.layoutEl) {\n this.observeLayout();\n } else {\n this.updateLayoutGeometry();\n }\n }\n\n disconnectedCallback() {\n this.connectionTasks.cancel();\n this.layoutResizeObserver?.disconnect();\n this.layoutResizeObserver = null;\n this.layoutResizeFrame = undefined;\n this.observedLayoutEl = undefined;\n }\n\n private get visibleMetrics(): OverviewMetric[] {\n return this.metrics.slice(0, MAX_METRIC_COUNT);\n }\n\n private get visibleCellCount(): number {\n const metricCount = this.isLoading ? this.loadingMetricCount : this.visibleMetrics.length;\n return 1 + metricCount;\n }\n\n /** Preserve an authored data shape while loading, or use the standard six-cell grid. */\n private get loadingMetricCount(): number {\n return this.visibleMetrics.length || DEFAULT_LOADING_METRIC_COUNT;\n }\n\n private get resolvedScrollCollapseProgress(): number {\n if (this.variant === 'compact' || !Number.isFinite(this.scrollCollapseProgress)) return 0;\n return Math.min(1, Math.max(0, this.scrollCollapseProgress));\n }\n\n private get scrollCollapseGeometry() {\n const compactHeight = resolveCssLengthPx(TOKEN_DEFAULTS.size600, 48);\n return resolveCardOverviewCollapseGeometry({\n variant: this.variant,\n progress: this.resolvedScrollCollapseProgress,\n expandedHeight: this.expandedHeight,\n compactHeight,\n });\n }\n\n private observeLayout() {\n const layout = this.layoutEl;\n if (!this.el.isConnected || !layout) return;\n\n this.connectionTasks.cancelFrame(this.layoutResizeFrame);\n this.layoutResizeFrame = undefined;\n\n const bounds = layout.getBoundingClientRect();\n this.updateLayoutGeometry(bounds.width, bounds.height);\n if (typeof ResizeObserver === 'undefined') return;\n\n this.layoutResizeObserver?.disconnect();\n this.layoutResizeObserver = new ResizeObserver(entries => {\n const rect = entries[0]?.contentRect;\n if (!rect) return;\n this.connectionTasks.cancelFrame(this.layoutResizeFrame);\n this.layoutResizeFrame = this.connectionTasks.frame(() => {\n this.layoutResizeFrame = undefined;\n if (this.layoutEl !== layout) return;\n this.updateLayoutGeometry(rect.width, rect.height);\n });\n });\n this.layoutResizeObserver.observe(layout);\n this.observedLayoutEl = layout;\n }\n\n private updateLayoutGeometry(width?: number, height?: number) {\n const layout = this.layoutEl;\n if (!this.el.isConnected || !layout) return;\n\n const bounds =\n width === undefined || height === undefined ? layout.getBoundingClientRect() : null;\n const resolvedWidth = width ?? bounds?.width ?? 0;\n const resolvedHeight = height ?? bounds?.height ?? 0;\n\n let changed = false;\n\n if (resolvedHeight > 0 && Math.abs(resolvedHeight - this.expandedHeight) >= 0.5) {\n this.expandedHeight = resolvedHeight;\n changed = true;\n }\n\n const columns = resolveOverviewGridColumns({\n cellCount: this.visibleCellCount,\n availableWidth: resolvedWidth,\n minCellWidth: resolveCssLengthPx(this.metricMinWidth, DEFAULT_METRIC_MIN_WIDTH),\n });\n if (columns !== this.gridColumns) {\n this.gridColumns = columns;\n changed = true;\n }\n\n // The column count is written straight to the host rather than through the\n // rendered style object. Measurement happens in `componentDidLoad` and in the\n // ResizeObserver callback, both outside a render cycle, and the host's style\n // is not patched again after the first render — so a rendered value would stay\n // pinned to whatever the first paint produced, before any width was known.\n if (this.gridColumns) {\n this.el.style.setProperty('--ds-card-overview-grid-columns', String(this.gridColumns));\n } else {\n this.el.style.removeProperty('--ds-card-overview-grid-columns');\n }\n\n if (changed) forceUpdate(this);\n }\n\n /** Index of the roving tab stop, skipping inactive metrics. */\n private get rovingIndex(): number {\n return resolveOverviewRovingIndex(this.visibleMetrics, this.focusedMetricIndex);\n }\n\n private selectMetric(metric: OverviewMetric, index: number) {\n if (metric.isInactive) return;\n this.focusedMetricIndex = index;\n this.dsMetricSelect.emit(metric);\n }\n\n /** Move the roving tab stop, wrapping and skipping inactive metrics. */\n private moveFocus(from: number, step: number) {\n const metrics = this.visibleMetrics;\n const next = findNextOverviewMetricIndex(metrics, from, step < 0 ? -1 : 1);\n if (next < 0) return;\n\n this.focusedMetricIndex = next;\n const cells = this.el.querySelectorAll<HTMLElement>('.card-overview__metric-action');\n cells[next]?.focus();\n }\n\n private handleMetricKeyDown = (event: KeyboardEvent, index: number) => {\n switch (event.key) {\n case 'ArrowRight':\n case 'ArrowDown':\n event.preventDefault();\n this.moveFocus(index, 1);\n break;\n case 'ArrowLeft':\n case 'ArrowUp':\n event.preventDefault();\n this.moveFocus(index, -1);\n break;\n default:\n break;\n }\n };\n\n /**\n * A skeleton bar matching the type metrics of the text it stands in for, so the\n * loading state holds the same shape and rhythm as the resolved content.\n *\n * `background` is always passed so the base and shimmer follow the card's\n * permanently dark surface in both themes.\n */\n private bar(textVariant: TextVariant, width: string, className?: string) {\n return (\n <ds-skeleton\n class={className}\n variant=\"text\"\n textVariant={textVariant}\n width={width}\n background=\"always-dark\"\n />\n );\n }\n\n /**\n * Arrow and change in one text node.\n *\n * The arrow is a typeface glyph rather than an icon so it inherits the exact\n * size, weight, and baseline of the value beside it; an icon would need its\n * own size scale and would drift whenever the type scale changed.\n *\n * Tone is supplied by the caller and never inferred here.\n */\n private renderTrend(trend: MetricTrend | undefined, variant: TextVariant) {\n if (!trend) return null;\n return (\n <ds-text\n as=\"span\"\n class={{\n 'card-overview__trend': true,\n 'ds-control-label-box': true,\n [`card-overview__trend--${trend.tone}`]: true,\n }}\n variant={variant}\n color={TREND_COLORS[trend.tone]}\n fontFeature=\"tabular-nums\"\n >\n {trend.direction === 'up' ? '↑' : '↓'} {trend.value}\n </ds-text>\n );\n }\n\n private renderScore() {\n if (this.isLoading) {\n return (\n <div class=\"card-overview__score\" part=\"score\">\n <div class=\"card-overview__score-content\">\n <ds-score size=\"lg\" variant=\"dense\" isLoading></ds-score>\n <div class=\"card-overview__score-copy\">\n {this.bar('text-body-small', '64px', 'card-overview__score-label-spacer')}\n {this.bar('text-body-medium', '28px')}\n </div>\n </div>\n </div>\n );\n }\n\n const unavailableMessage =\n this.scoreErrorMessage || (!this.score ? 'Score unavailable' : undefined);\n if (unavailableMessage) {\n return (\n /* eslint-disable-next-line compomo/prefer-direct-ds-text -- The score track owns the grid divider and 8px outer inset. */\n <div class=\"card-overview__score\" part=\"score\">\n <ds-text\n as=\"span\"\n class=\"card-overview__score-content card-overview__score-content--error\"\n variant=\"text-body-medium\"\n color=\"inherit\"\n role=\"alert\"\n >\n {unavailableMessage}\n </ds-text>\n </div>\n );\n }\n\n const score = this.score;\n if (!score) return null;\n\n return (\n <div class=\"card-overview__score\" part=\"score\">\n <div class=\"card-overview__score-content\">\n <ds-score\n size=\"lg\"\n variant=\"dense\"\n value={score.value}\n level={score.level}\n label=\"Safety score\"\n />\n <div class=\"card-overview__score-copy\">\n <ds-text\n as=\"span\"\n class=\"card-overview__score-label-spacer ds-control-label-box\"\n variant=\"text-body-small\"\n color={ALWAYS_DARK_SECONDARY}\n aria-hidden=\"true\"\n >\n \n </ds-text>\n {this.renderTrend(score.trend, 'text-body-medium')}\n </div>\n </div>\n </div>\n );\n }\n\n private renderMetric(metric: OverviewMetric, index: number) {\n const selectable = !metric.isInactive;\n const label = (\n <ds-text\n as=\"span\"\n class=\"card-overview__metric-label ds-control-label-box\"\n variant=\"text-body-small\"\n color={ALWAYS_DARK_SECONDARY}\n wrap=\"nowrap\"\n >\n {metric.label}\n </ds-text>\n );\n\n return (\n <div class=\"card-overview__metric\" part=\"metric\">\n <div\n class={{\n 'card-overview__metric-action': true,\n 'card-overview__metric-action--inactive': !selectable,\n // Selection targets get the shared wash; press scaling is not used here\n // so the grid keeps its columns aligned. See docs/control-press-policy.md.\n 'ds-interaction-fill': selectable,\n 'ds-interaction-fill--on-always-dark': selectable,\n 'ds-focus-ring-inset': selectable,\n }}\n role={selectable ? 'button' : undefined}\n tabIndex={selectable && index === this.rovingIndex ? 0 : -1}\n onClick={() => this.selectMetric(metric, index)}\n onKeyDown={event => this.handleMetricKeyDown(event, index)}\n onFocusin={() => {\n if (selectable) this.focusedMetricIndex = index;\n }}\n >\n <div class=\"ds-interaction-fill__content card-overview__metric-content\">\n {metric.labelTooltip ? (\n <ds-tooltip label={metric.labelTooltip} side=\"top\" size=\"sm\">\n {label}\n </ds-tooltip>\n ) : (\n label\n )}\n <div class=\"card-overview__metric-figure\">\n <ds-text\n as=\"span\"\n class=\"card-overview__metric-value ds-control-label-box\"\n variant=\"text-body-medium\"\n color={ALWAYS_DARK_PRIMARY}\n wrap=\"nowrap\"\n fontFeature=\"tabular-nums\"\n >\n {metric.value}\n </ds-text>\n {this.renderTrend(metric.trend, 'text-body-medium')}\n </div>\n </div>\n </div>\n </div>\n );\n }\n\n private renderMetrics() {\n if (this.isLoading) {\n return [\n this.renderScore(),\n ...Array.from({ length: this.loadingMetricCount }, (_, index) => (\n <div class=\"card-overview__metric\" key={`loading-${index}`}>\n <div class=\"card-overview__metric-action card-overview__metric-action--inactive\">\n <div class=\"card-overview__metric-content\">\n {this.bar('text-body-small', '35%', 'card-overview__metric-label')}\n <div class=\"card-overview__metric-figure\">\n {this.bar('text-body-medium', '40px')}\n {/* Match renderTrend's type canvas and the score trend's 28px width. */}\n {this.bar('text-body-medium', '28px')}\n </div>\n </div>\n </div>\n </div>\n )),\n ];\n }\n\n return [\n this.renderScore(),\n ...this.visibleMetrics.map((metric, index) => this.renderMetric(metric, index)),\n ];\n }\n\n render() {\n const compact = this.variant === 'compact';\n const collapse = this.scrollCollapseGeometry;\n\n // Omit absent custom properties rather than handing them an undefined value.\n // Stencil serialises undefined to the string \"undefined\", which is invalid in\n // `repeat()` and voids the whole grid-template-columns declaration — the grid\n // then collapses to one implicit column instead of using the `auto-fit`\n // fallback baked into the stylesheet.\n const customProperties: Record<string, string> = {\n '--ds-card-overview-metric-min': this.metricMinWidth,\n };\n if (this.gridColumns) {\n customProperties['--ds-card-overview-grid-columns'] = String(this.gridColumns);\n }\n if (collapse.active) {\n customProperties['--ds-card-overview-expanded-height'] = `${collapse.expandedHeight}px`;\n customProperties['--ds-card-overview-visible-height'] = `${collapse.visibleHeight}px`;\n customProperties['--ds-card-overview-content-offset'] = `${collapse.offset}px`;\n }\n\n return (\n <Host\n class={{\n 'card-overview': true,\n 'card-overview--compact': compact,\n 'card-overview--stacked': !compact && this.layout === 'stacked',\n 'card-overview--scroll-collapsing': collapse.active,\n }}\n role=\"region\"\n aria-label={this.overviewLabel}\n aria-busy={this.isLoading ? 'true' : undefined}\n style={customProperties}\n >\n <div\n class={{\n 'card-overview__surface': true,\n // Split shadow and inset highlight, so the opaque surface cannot cover\n // the highlight the way a combined elevation shadow would.\n 'ds-control-elevation': true,\n 'ds-control-elevation--sm': !compact,\n 'ds-control-elevation--floating': compact,\n }}\n >\n <div class=\"card-overview__clip\">\n <div\n ref={element => {\n this.layoutEl = (element as HTMLElement) ?? undefined;\n }}\n class={{\n 'card-overview__layout': true,\n }}\n >\n <div class=\"card-overview__summary\">\n <div class=\"card-overview__header ds-control--md\">\n <div class=\"card-overview__period\">\n <div class=\"card-overview__period-current\">\n <slot name=\"period\">\n {this.periodLabel && (\n /* eslint-disable-next-line compomo/prefer-direct-ds-text -- Fixed copy uses the same structural frame and label inset as a slotted Select. */\n <div class=\"card-overview__period-fixed ds-control-frame\">\n <ds-text\n as=\"span\"\n class=\"ds-control-label-box\"\n variant=\"text-body-medium\"\n emphasis\n color={ALWAYS_DARK_PRIMARY}\n >\n {this.periodLabel}\n </ds-text>\n </div>\n )}\n </slot>\n </div>\n {this.comparisonLabel && (\n <ds-text\n as=\"span\"\n class=\"card-overview__period-comparison ds-control-label-box\"\n variant=\"text-body-medium\"\n color={ALWAYS_DARK_SECONDARY}\n >\n {this.comparisonLabel}\n </ds-text>\n )}\n </div>\n <div class=\"card-overview__filter\">\n <slot name=\"filter\" />\n </div>\n </div>\n </div>\n\n {!compact && (\n <div class=\"card-overview__body\">\n <div class=\"card-overview__metrics\" part=\"metrics\">\n {this.renderMetrics()}\n </div>\n\n <slot name=\"footer\" />\n </div>\n )}\n </div>\n </div>\n </div>\n </Host>\n );\n }\n}\n",
|
|
404
|
+
"content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n forceUpdate,\n h,\n Host,\n Prop,\n State,\n} from '@stencil/core';\nimport { resolveCssLengthPx, TOKEN_DEFAULTS } from '../../utils';\nimport { ConnectionTasks } from '../../utils/connection-tasks';\nimport type { TextColor, TextVariant } from '../Text/text-types';\nimport type { MetricTrend } from '../../utils/metric-change';\nimport type {\n CardOverviewLayout,\n CardOverviewVariant,\n OverviewMetric,\n OverviewScore,\n} from './card-overview-types';\nimport {\n findNextOverviewMetricIndex,\n resolveCardOverviewCollapseGeometry,\n resolveOverviewGridColumns,\n resolveOverviewRovingIndex,\n} from './card-overview-controller';\n\n/**\n * Metric cells below this width drop a grid column rather than compress further.\n *\n * A literal rather than a token: this is a layout threshold for a grid cell, and\n * the dimension scale has no token for that. The 200px tokens it could borrow\n * (`menu-width-xs`, `form-width-xs`, `panel-width-2xs`) all name other\n * components, so binding to one would couple this grid to an unrelated contract.\n * Consumers override with the `metricMinWidth` prop.\n */\nconst DEFAULT_METRIC_MIN_WIDTH = '200px';\n\n/** Skeleton cells rendered while loading with no metrics supplied yet. */\nconst DEFAULT_LOADING_METRIC_COUNT = 5;\n\n/** Maximum number of comparable measures the summary bar presents at once. */\nconst MAX_METRIC_COUNT = 7;\n\nconst TREND_COLORS: Record<MetricTrend['tone'], TextColor> = {\n positive: 'var(--color-always-dark-foreground-positive)',\n negative: 'var(--color-always-dark-foreground-negative)',\n neutral: 'var(--color-always-dark-foreground-secondary)',\n};\n\nconst ALWAYS_DARK_PRIMARY: TextColor = 'var(--color-always-dark-foreground-primary)';\nconst ALWAYS_DARK_SECONDARY: TextColor = 'var(--color-always-dark-foreground-secondary)';\n\n@Component({\n tag: 'ds-card-overview',\n styleUrl: 'CardOverview.css',\n scoped: true,\n})\nexport class CardOverview {\n @Element() el!: HTMLElement;\n\n /** Full summary card, or the condensed 48px summary bar. */\n @Prop() variant: CardOverviewVariant = 'default';\n\n /** Intrinsic responsive layout, or a page-owned single-column stack. */\n @Prop() layout: CardOverviewLayout = 'auto';\n\n /** Optional nonselectable safety score rendered as the first grid cell. */\n @Prop() score: OverviewScore | undefined;\n\n /** Fixed current date or range. Replaced by content in the `period` slot. */\n @Prop() periodLabel: string = '';\n\n /** Copy between the current period and comparison control, usually `vs.`. */\n @Prop() comparisonLabel: string = '';\n\n /** Measures rendered in the responsive grid. Only the first seven are shown. */\n @Prop() metrics: OverviewMetric[] = [];\n\n /**\n * Width a metric cell may shrink to before the grid drops a column. The grid\n * uses this threshold while choosing an evenly distributed column count.\n */\n @Prop() metricMinWidth: string = DEFAULT_METRIC_MIN_WIDTH;\n\n /** Replace the score and metrics with skeletons while data resolves. */\n @Prop() isLoading: boolean = false;\n\n /** Message shown in place of the score when its figure cannot be resolved. */\n @Prop() scoreErrorMessage: string | undefined;\n\n /** Accessible name for the region. */\n @Prop() overviewLabel: string = 'Overview';\n\n /**\n * Page-controlled visual collapse from the full card (`0`) toward its 48px\n * compact handoff height (`1`). The component preserves its expanded flow\n * height, keeps the period bar stationary, and moves/clips the grid beneath\n * it. The page still owns sticky positioning and the final compact handoff.\n */\n @Prop() scrollCollapseProgress: number = 0;\n\n /** Emitted when a metric that is not inactive is activated. */\n @Event() dsMetricSelect!: EventEmitter<OverviewMetric>;\n\n /** Roving tab stop across selectable metrics. */\n @State() private focusedMetricIndex: number = 0;\n @State() private expandedHeight: number = 0;\n @State() private gridColumns: number | undefined;\n\n private layoutEl?: HTMLElement;\n private layoutResizeObserver: ResizeObserver | null = null;\n private layoutResizeFrame: number | undefined;\n private hasLoaded = false;\n private readonly connectionTasks = new ConnectionTasks(() => this.el.isConnected);\n /** The node the observer is bound to, which a later render can replace. */\n private observedLayoutEl?: HTMLElement;\n\n connectedCallback() {\n if (this.hasLoaded) this.observeLayout();\n }\n\n componentDidLoad() {\n this.hasLoaded = true;\n this.observeLayout();\n }\n\n componentDidRender() {\n if (!this.el.isConnected) return;\n // Re-bind after HMR when DidLoad's observer was dropped, and whenever a\n // render swapped the layout node out from under the observer: the ref points\n // at the new node while the observer still watches the detached one, which\n // never resizes again. The grid would then keep its first measured column\n // count for the life of the component.\n if (!this.layoutResizeObserver || this.observedLayoutEl !== this.layoutEl) {\n this.observeLayout();\n } else {\n this.updateLayoutGeometry();\n }\n }\n\n disconnectedCallback() {\n this.connectionTasks.cancel();\n this.layoutResizeObserver?.disconnect();\n this.layoutResizeObserver = null;\n this.layoutResizeFrame = undefined;\n this.observedLayoutEl = undefined;\n }\n\n private get visibleMetrics(): OverviewMetric[] {\n return this.metrics.slice(0, MAX_METRIC_COUNT);\n }\n\n private get visibleCellCount(): number {\n const metricCount = this.isLoading ? this.loadingMetricCount : this.visibleMetrics.length;\n return 1 + metricCount;\n }\n\n /** Preserve an authored data shape while loading, or use the standard six-cell grid. */\n private get loadingMetricCount(): number {\n return this.visibleMetrics.length || DEFAULT_LOADING_METRIC_COUNT;\n }\n\n private get resolvedScrollCollapseProgress(): number {\n if (this.variant === 'compact' || !Number.isFinite(this.scrollCollapseProgress)) return 0;\n return Math.min(1, Math.max(0, this.scrollCollapseProgress));\n }\n\n private get scrollCollapseGeometry() {\n const compactHeight = resolveCssLengthPx(TOKEN_DEFAULTS.size600, 48);\n return resolveCardOverviewCollapseGeometry({\n variant: this.variant,\n progress: this.resolvedScrollCollapseProgress,\n expandedHeight: this.expandedHeight,\n compactHeight,\n });\n }\n\n private observeLayout() {\n const layout = this.layoutEl;\n if (!this.el.isConnected || !layout) return;\n\n this.connectionTasks.cancelFrame(this.layoutResizeFrame);\n this.layoutResizeFrame = undefined;\n\n const bounds = layout.getBoundingClientRect();\n this.updateLayoutGeometry(bounds.width, bounds.height);\n if (typeof ResizeObserver === 'undefined') return;\n\n this.layoutResizeObserver?.disconnect();\n this.layoutResizeObserver = new ResizeObserver(entries => {\n const rect = entries[0]?.contentRect;\n if (!rect) return;\n this.connectionTasks.cancelFrame(this.layoutResizeFrame);\n this.layoutResizeFrame = this.connectionTasks.frame(() => {\n this.layoutResizeFrame = undefined;\n if (this.layoutEl !== layout) return;\n this.updateLayoutGeometry(rect.width, rect.height);\n });\n });\n this.layoutResizeObserver.observe(layout);\n this.observedLayoutEl = layout;\n }\n\n private updateLayoutGeometry(width?: number, height?: number) {\n const layout = this.layoutEl;\n if (!this.el.isConnected || !layout) return;\n\n const bounds =\n width === undefined || height === undefined ? layout.getBoundingClientRect() : null;\n const resolvedWidth = width ?? bounds?.width ?? 0;\n const resolvedHeight = height ?? bounds?.height ?? 0;\n\n let changed = false;\n\n if (resolvedHeight > 0 && Math.abs(resolvedHeight - this.expandedHeight) >= 0.5) {\n this.expandedHeight = resolvedHeight;\n changed = true;\n }\n\n const columns = resolveOverviewGridColumns({\n cellCount: this.visibleCellCount,\n availableWidth: resolvedWidth,\n minCellWidth: resolveCssLengthPx(this.metricMinWidth, DEFAULT_METRIC_MIN_WIDTH),\n });\n if (columns !== this.gridColumns) {\n this.gridColumns = columns;\n changed = true;\n }\n\n // The column count is written straight to the host rather than through the\n // rendered style object. Measurement happens in `componentDidLoad` and in the\n // ResizeObserver callback, both outside a render cycle, and the host's style\n // is not patched again after the first render — so a rendered value would stay\n // pinned to whatever the first paint produced, before any width was known.\n if (this.gridColumns) {\n this.el.style.setProperty('--ds-card-overview-grid-columns', String(this.gridColumns));\n } else {\n this.el.style.removeProperty('--ds-card-overview-grid-columns');\n }\n\n if (changed) forceUpdate(this);\n }\n\n /** Index of the roving tab stop, skipping inactive metrics. */\n private get rovingIndex(): number {\n return resolveOverviewRovingIndex(this.visibleMetrics, this.focusedMetricIndex);\n }\n\n private selectMetric(metric: OverviewMetric, index: number) {\n if (metric.isInactive) return;\n this.focusedMetricIndex = index;\n this.dsMetricSelect.emit(metric);\n }\n\n /** Move the roving tab stop, wrapping and skipping inactive metrics. */\n private moveFocus(from: number, step: number) {\n const metrics = this.visibleMetrics;\n const next = findNextOverviewMetricIndex(metrics, from, step < 0 ? -1 : 1);\n if (next < 0) return;\n\n this.focusedMetricIndex = next;\n const cells = this.el.querySelectorAll<HTMLElement>('.card-overview__metric-action');\n cells[next]?.focus();\n }\n\n private handleMetricKeyDown = (event: KeyboardEvent, index: number) => {\n switch (event.key) {\n case 'ArrowRight':\n case 'ArrowDown':\n event.preventDefault();\n this.moveFocus(index, 1);\n break;\n case 'ArrowLeft':\n case 'ArrowUp':\n event.preventDefault();\n this.moveFocus(index, -1);\n break;\n default:\n break;\n }\n };\n\n /**\n * A skeleton bar matching the type metrics of the text it stands in for, so the\n * loading state holds the same shape and rhythm as the resolved content.\n *\n * `background` is always passed so the base and shimmer follow the card's\n * permanently dark surface in both themes.\n */\n private bar(textVariant: TextVariant, width: string, className?: string) {\n return (\n <ds-skeleton\n class={className}\n variant=\"text\"\n textVariant={textVariant}\n width={width}\n background=\"always-dark\"\n />\n );\n }\n\n /**\n * Arrow and change in one text node.\n *\n * The arrow is a typeface glyph rather than an icon so it inherits the exact\n * size, weight, and baseline of the value beside it; an icon would need its\n * own size scale and would drift whenever the type scale changed.\n *\n * Tone is supplied by the caller and never inferred here.\n */\n private renderTrend(trend: MetricTrend | undefined, variant: TextVariant) {\n if (!trend) return null;\n return (\n <ds-text\n as=\"span\"\n class={{\n 'card-overview__trend': true,\n 'ds-control-label-box': true,\n [`card-overview__trend--${trend.tone}`]: true,\n }}\n variant={variant}\n color={TREND_COLORS[trend.tone]}\n fontFeature=\"tabular-nums\"\n >\n {trend.direction === 'up' ? '↑' : '↓'} {trend.value}\n </ds-text>\n );\n }\n\n private renderScore() {\n if (this.isLoading) {\n return (\n <div class=\"card-overview__score\" part=\"score\">\n <div class=\"card-overview__score-content\">\n <ds-score size=\"lg\" variant=\"dense\" isLoading></ds-score>\n <div class=\"card-overview__score-copy\">\n {this.bar(\n 'text-body-small',\n 'var(--dimension-size-800)',\n 'card-overview__score-label-spacer'\n )}\n {this.bar(\n 'text-body-medium',\n 'calc(var(--dimension-size-300) + var(--dimension-space-050))'\n )}\n </div>\n </div>\n </div>\n );\n }\n\n const unavailableMessage =\n this.scoreErrorMessage || (!this.score ? 'Score unavailable' : undefined);\n if (unavailableMessage) {\n return (\n /* eslint-disable-next-line compomo/prefer-direct-ds-text -- The score track owns the grid divider and 8px outer inset. */\n <div class=\"card-overview__score\" part=\"score\">\n <ds-text\n as=\"span\"\n class=\"card-overview__score-content card-overview__score-content--error\"\n variant=\"text-body-medium\"\n color=\"inherit\"\n role=\"alert\"\n >\n {unavailableMessage}\n </ds-text>\n </div>\n );\n }\n\n const score = this.score;\n if (!score) return null;\n\n return (\n <div class=\"card-overview__score\" part=\"score\">\n <div class=\"card-overview__score-content\">\n <ds-score\n size=\"lg\"\n variant=\"dense\"\n value={score.value}\n level={score.level}\n label=\"Safety score\"\n />\n <div class=\"card-overview__score-copy\">\n <ds-text\n as=\"span\"\n class=\"card-overview__score-label-spacer ds-control-label-box\"\n variant=\"text-body-small\"\n color={ALWAYS_DARK_SECONDARY}\n aria-hidden=\"true\"\n >\n \n </ds-text>\n {this.renderTrend(score.trend, 'text-body-medium')}\n </div>\n </div>\n </div>\n );\n }\n\n private renderMetric(metric: OverviewMetric, index: number) {\n const selectable = !metric.isInactive;\n const label = (\n <ds-text\n as=\"span\"\n class=\"card-overview__metric-label ds-control-label-box\"\n variant=\"text-body-small\"\n color={ALWAYS_DARK_SECONDARY}\n wrap=\"nowrap\"\n >\n {metric.label}\n </ds-text>\n );\n\n return (\n <div class=\"card-overview__metric\" part=\"metric\">\n <div\n class={{\n 'card-overview__metric-action': true,\n 'card-overview__metric-action--inactive': !selectable,\n // Selection targets get the shared wash; press scaling is not used here\n // so the grid keeps its columns aligned. See docs/control-press-policy.md.\n 'ds-interaction-fill': selectable,\n 'ds-interaction-fill--on-always-dark': selectable,\n 'ds-focus-ring-inset': selectable,\n }}\n role={selectable ? 'button' : undefined}\n tabIndex={selectable && index === this.rovingIndex ? 0 : -1}\n onClick={() => this.selectMetric(metric, index)}\n onKeyDown={event => this.handleMetricKeyDown(event, index)}\n onFocusin={() => {\n if (selectable) this.focusedMetricIndex = index;\n }}\n >\n <div class=\"ds-interaction-fill__content card-overview__metric-content\">\n {metric.labelTooltip ? (\n <ds-tooltip label={metric.labelTooltip} side=\"top\" size=\"sm\">\n {label}\n </ds-tooltip>\n ) : (\n label\n )}\n <div class=\"card-overview__metric-figure\">\n <ds-text\n as=\"span\"\n class=\"card-overview__metric-value ds-control-label-box\"\n variant=\"text-body-medium\"\n color={ALWAYS_DARK_PRIMARY}\n wrap=\"nowrap\"\n fontFeature=\"tabular-nums\"\n >\n {metric.value}\n </ds-text>\n {this.renderTrend(metric.trend, 'text-body-medium')}\n </div>\n </div>\n </div>\n </div>\n );\n }\n\n private renderMetrics() {\n if (this.isLoading) {\n return [\n this.renderScore(),\n ...Array.from({ length: this.loadingMetricCount }, (_, index) => (\n <div class=\"card-overview__metric\" key={`loading-${index}`}>\n <div class=\"card-overview__metric-action card-overview__metric-action--inactive\">\n <div class=\"card-overview__metric-content\">\n {this.bar('text-body-small', '35%', 'card-overview__metric-label')}\n <div class=\"card-overview__metric-figure\">\n {this.bar('text-body-medium', 'var(--dimension-size-500)')}\n {/* Match renderTrend's type canvas and the score trend's 28px width. */}\n {this.bar(\n 'text-body-medium',\n 'calc(var(--dimension-size-300) + var(--dimension-space-050))'\n )}\n </div>\n </div>\n </div>\n </div>\n )),\n ];\n }\n\n return [\n this.renderScore(),\n ...this.visibleMetrics.map((metric, index) => this.renderMetric(metric, index)),\n ];\n }\n\n render() {\n const compact = this.variant === 'compact';\n const collapse = this.scrollCollapseGeometry;\n\n // Omit absent custom properties rather than handing them an undefined value.\n // Stencil serialises undefined to the string \"undefined\", which is invalid in\n // `repeat()` and voids the whole grid-template-columns declaration — the grid\n // then collapses to one implicit column instead of using the `auto-fit`\n // fallback baked into the stylesheet.\n const customProperties: Record<string, string> = {\n '--ds-card-overview-metric-min': this.metricMinWidth,\n };\n if (this.gridColumns) {\n customProperties['--ds-card-overview-grid-columns'] = String(this.gridColumns);\n }\n if (collapse.active) {\n customProperties['--ds-card-overview-expanded-height'] = `${collapse.expandedHeight}px`;\n customProperties['--ds-card-overview-visible-height'] = `${collapse.visibleHeight}px`;\n customProperties['--ds-card-overview-content-offset'] = `${collapse.offset}px`;\n }\n\n return (\n <Host\n class={{\n 'card-overview': true,\n 'card-overview--compact': compact,\n 'card-overview--stacked': !compact && this.layout === 'stacked',\n 'card-overview--scroll-collapsing': collapse.active,\n }}\n role=\"region\"\n aria-label={this.overviewLabel}\n aria-busy={this.isLoading ? 'true' : undefined}\n style={customProperties}\n >\n <div\n class={{\n 'card-overview__surface': true,\n // Split shadow and inset highlight, so the opaque surface cannot cover\n // the highlight the way a combined elevation shadow would.\n 'ds-control-elevation': true,\n 'ds-control-elevation--sm': !compact,\n 'ds-control-elevation--floating': compact,\n }}\n >\n <div class=\"card-overview__clip\">\n <div\n ref={element => {\n this.layoutEl = (element as HTMLElement) ?? undefined;\n }}\n class={{\n 'card-overview__layout': true,\n }}\n >\n <div class=\"card-overview__summary\">\n <div class=\"card-overview__header ds-control--md\">\n <div class=\"card-overview__period\">\n <div class=\"card-overview__period-current\">\n <slot name=\"period\">\n {this.periodLabel && (\n /* eslint-disable-next-line compomo/prefer-direct-ds-text -- Fixed copy uses the same structural frame and label inset as a slotted Select. */\n <div class=\"card-overview__period-fixed ds-control-frame\">\n <ds-text\n as=\"span\"\n class=\"ds-control-label-box\"\n variant=\"text-body-medium\"\n emphasis\n color={ALWAYS_DARK_PRIMARY}\n >\n {this.periodLabel}\n </ds-text>\n </div>\n )}\n </slot>\n </div>\n {this.comparisonLabel && (\n <ds-text\n as=\"span\"\n class=\"card-overview__period-comparison ds-control-label-box\"\n variant=\"text-body-medium\"\n color={ALWAYS_DARK_SECONDARY}\n >\n {this.comparisonLabel}\n </ds-text>\n )}\n </div>\n <div class=\"card-overview__filter\">\n <slot name=\"filter\" />\n </div>\n </div>\n </div>\n\n {!compact && (\n <div class=\"card-overview__body\">\n <div class=\"card-overview__metrics\" part=\"metrics\">\n {this.renderMetrics()}\n </div>\n\n <slot name=\"footer\" />\n </div>\n )}\n </div>\n </div>\n </div>\n </Host>\n );\n }\n}\n",
|
|
405
405
|
"type": "registry:ui"
|
|
406
406
|
},
|
|
407
407
|
{
|
|
@@ -41,7 +41,7 @@
|
|
|
41
41
|
],
|
|
42
42
|
"responsiveBehavior": [
|
|
43
43
|
"The chart observes its container unless width is fixed and recompiles geometry at CSS pixel dimensions.",
|
|
44
|
-
"Height uses an authored height or aspectRatio, fills the chart region supplied by CardChart, and otherwise
|
|
44
|
+
"Height uses an authored height or aspectRatio, fills the chart region supplied by CardChart, and otherwise derives its default height from the shared size scale.",
|
|
45
45
|
"Measured guide labels reserve margins and thin colliding ticks while retaining boundary ticks."
|
|
46
46
|
],
|
|
47
47
|
"frameworkCaveats": {
|
|
@@ -256,12 +256,12 @@
|
|
|
256
256
|
"files": [
|
|
257
257
|
{
|
|
258
258
|
"path": "src/wc/components/Chart/Chart.css",
|
|
259
|
-
"content": "@import '../../utils/forced-colors.css';\n\n:host {\n position: relative;\n display: block;\n width: var(--ds-chart-width, 100%);\n max-width: 100%;\n height: var(--ds-chart-height,
|
|
259
|
+
"content": "@import '../../utils/forced-colors.css';\n\n:host {\n position: relative;\n display: block;\n width: var(--ds-chart-width, 100%);\n max-width: 100%;\n height: var(--ds-chart-height, calc(var(--dimension-size-base) * 40));\n font-family: var(--typography-font-family-ui);\n}\n\n.chart__surface {\n display: block;\n width: 100%;\n height: 100%;\n overflow: hidden;\n outline: none;\n touch-action: none;\n}\n\n.chart__surface:focus-visible {\n outline: var(--dimension-stroke-width-025) solid var(--color-border-focus);\n outline-offset: var(--dimension-space-025);\n}\n\n.chart__interaction-surface {\n fill: transparent;\n pointer-events: all;\n}\n\n.chart__grid,\n.chart__axis-line,\n.chart__plot-boundary,\n.chart__mark {\n vector-effect: non-scaling-stroke;\n}\n\n.chart__grid {\n stroke: var(--color-foreground-quaternary);\n stroke-width: var(--dimension-stroke-width-012);\n}\n\n.chart__plot-boundary {\n stroke: var(--color-foreground-quaternary);\n stroke-width: var(--dimension-stroke-width-012);\n}\n\n.chart__axis-line {\n stroke: var(--color-foreground-tertiary);\n stroke-width: var(--dimension-stroke-width-012);\n}\n\n.chart__tick,\n.chart__text-mark {\n fill: var(--color-foreground-secondary);\n font-family: var(--typography-font-family-ui);\n font-size: var(--typography-fontsize-xs);\n font-weight: var(--typography-weight-medium);\n letter-spacing: var(--typography-letterspacing-positive);\n text-transform: uppercase;\n}\n\n.chart__axis-title {\n fill: var(--color-foreground-primary);\n font-family: var(--typography-font-family-ui);\n font-size: var(--typography-fontsize-xs);\n font-weight: var(--typography-weight-semibold);\n letter-spacing: var(--typography-letterspacing-positive);\n text-transform: uppercase;\n}\n\n.chart__center-value {\n fill: var(--color-foreground-primary);\n font-family: var(--typography-font-family-ui);\n font-size: var(--typography-fontsize-xl);\n font-weight: var(--typography-weight-semibold);\n letter-spacing: var(--typography-letterspacing-negative-double);\n}\n\n.chart__center-caption {\n fill: var(--color-foreground-secondary);\n font-family: var(--typography-font-family-ui);\n font-size: var(--typography-fontsize-md);\n font-weight: var(--typography-weight-regular);\n letter-spacing: var(--typography-letterspacing-negative-half);\n}\n\n.chart__focus {\n fill: none;\n stroke: var(--color-border-focus);\n stroke-width: var(--dimension-stroke-width-025);\n vector-effect: non-scaling-stroke;\n pointer-events: none;\n}\n\n.chart__focus-guide {\n stroke: var(--color-foreground-quaternary);\n stroke-width: var(--dimension-stroke-width-012);\n vector-effect: non-scaling-stroke;\n pointer-events: none;\n}\n\n.chart__focus-point {\n vector-effect: non-scaling-stroke;\n pointer-events: none;\n}\n\n.chart__description,\n.chart__status {\n position: absolute;\n width: var(--dimension-stroke-width-012);\n height: var(--dimension-stroke-width-012);\n overflow: hidden;\n clip: rect(0 0 0 0);\n clip-path: inset(50%);\n white-space: nowrap;\n}\n\n@media (forced-colors: active) {\n .chart__mark,\n .chart__focus,\n .chart__focus-point {\n forced-color-adjust: none;\n }\n\n .chart__focus {\n stroke: var(--ds-forced-color-focus);\n }\n}\n",
|
|
260
260
|
"type": "registry:ui"
|
|
261
261
|
},
|
|
262
262
|
{
|
|
263
263
|
"path": "src/wc/components/Chart/Chart.tsx",
|
|
264
|
-
"content": "import { dateFormatter, numberFormatter, resolveFormatLocale } from '../../utils/intl-formatters';\nimport {\n Component,\n Element,\n Event,\n EventEmitter,\n Prop,\n State,\n Watch,\n h,\n Host,\n} from '@stencil/core';\nimport type {\n ChartDefinition,\n ChartPoint,\n ChartTooltipItem,\n ChartTooltipOptions,\n} from '../../utils/chart-grammar';\nimport {\n compileChartScene,\n findChartFocus,\n type ChartScene,\n type ChartSceneNode,\n} from '../../utils/chart-scene';\nimport { resolveChartTheme } from '../../utils/chart-theme';\n\nconst DEFAULT_WIDTH = 480;\nconst DEFAULT_HEIGHT = 320;\n\nexport type ChartFocusSource = 'pointer' | 'keyboard';\n\nexport interface ChartFocusChangeDetail {\n primary: ChartPoint | null;\n points: readonly ChartPoint[];\n source: ChartFocusSource;\n}\n\ninterface FocusState {\n primary: ChartPoint;\n points: ChartPoint[];\n source: ChartFocusSource;\n}\n\ninterface PointerAnchor {\n x: number;\n y: number;\n}\n\n@Component({\n tag: 'ds-chart',\n styleUrl: 'Chart.css',\n scoped: true,\n})\nexport class Chart {\n @Element() el!: HTMLElement;\n\n /** Typed chart definition. Set as a JavaScript property, not an HTML attribute. */\n @Prop() definition!: ChartDefinition;\n /** Required accessible name for the visualization. */\n @Prop() label!: string;\n /** Optional longer accessible description. */\n @Prop() description?: string;\n /** Locale used by axes and default tooltip formatters. Defaults to English (en-US). */\n @Prop() locale?: string;\n /** Fixed surface width. Otherwise the chart observes its container. */\n @Prop() width?: number;\n /** Product-owned surface height. Wins over aspectRatio. */\n @Prop() height?: number;\n /** Derives height from the current width when height is not supplied. */\n @Prop() aspectRatio?: number;\n\n /** Fires whenever pointer or keyboard focus resolves to semantic chart points. */\n @Event() dsChartFocusChange!: EventEmitter<ChartFocusChangeDetail>;\n\n @State() private surfaceWidth = DEFAULT_WIDTH;\n @State() private containerHeight?: number;\n @State() private scene?: ChartScene;\n @State() private focusState?: FocusState;\n @State() private pointerAnchor?: PointerAnchor;\n\n private resizeObserver?: ResizeObserver;\n private resizeFrame?: number;\n private compileFrame?: number;\n private hasLoaded = false;\n private connectionGeneration = 0;\n private warnedConflictingHeight = false;\n private measuredScene?: ChartScene;\n private measuredFont = '';\n private measuredLabels = new Map<string, { width: number; height: number }>();\n private descriptionId = `ds-chart-description-${Math.random().toString(36).slice(2)}`;\n private clipId = `ds-chart-clip-${Math.random().toString(36).slice(2)}`;\n\n componentWillLoad() {\n if (this.width && this.width > 0) this.surfaceWidth = this.width;\n this.compile();\n }\n\n componentDidLoad() {\n this.hasLoaded = true;\n this.connect();\n }\n\n connectedCallback() {\n if (this.hasLoaded) this.connect();\n }\n\n componentDidRender() {\n this.measureRenderedLabels();\n }\n\n disconnectedCallback() {\n this.connectionGeneration += 1;\n this.resizeObserver?.disconnect();\n this.resizeObserver = undefined;\n if (this.resizeFrame !== undefined) cancelAnimationFrame(this.resizeFrame);\n if (this.compileFrame !== undefined) cancelAnimationFrame(this.compileFrame);\n this.resizeFrame = undefined;\n this.compileFrame = undefined;\n this.measuredScene = undefined;\n this.measuredLabels.clear();\n }\n\n @Watch('definition')\n @Watch('locale')\n @Watch('height')\n @Watch('aspectRatio')\n onDefinitionInputChange() {\n this.scheduleCompile();\n }\n\n @Watch('width')\n onWidthChange() {\n if (this.width && this.width > 0) this.surfaceWidth = this.width;\n else this.measureHost();\n this.scheduleCompile();\n }\n\n private get surfaceHeight(): number {\n if (this.height && this.height > 0) {\n if (this.aspectRatio && !this.warnedConflictingHeight) {\n this.warnedConflictingHeight = true;\n console.warn('[ds-chart] height wins when both height and aspectRatio are supplied.');\n }\n return this.height;\n }\n if (this.aspectRatio && this.aspectRatio > 0) {\n return Math.max(1, this.surfaceWidth / this.aspectRatio);\n }\n return this.containerHeight ?? DEFAULT_HEIGHT;\n }\n\n private connect() {\n if (!this.el.isConnected) return;\n // A detached chart may return with new props, dimensions, or inherited fonts.\n this.measuredLabels.clear();\n this.connectResizeObserver();\n this.scheduleCompile();\n this.waitForFonts();\n }\n\n private connectResizeObserver() {\n this.resizeObserver?.disconnect();\n if (typeof ResizeObserver !== 'undefined') {\n this.resizeObserver = new ResizeObserver(entries => {\n const entry = entries[0];\n if (!entry || !this.el.isConnected) return;\n if (this.resizeFrame !== undefined) cancelAnimationFrame(this.resizeFrame);\n this.resizeFrame = requestAnimationFrame(() => {\n this.resizeFrame = undefined;\n this.applyMeasuredSize(entry.contentRect.width, entry.contentRect.height);\n });\n });\n this.resizeObserver.observe(this.el);\n }\n this.measureHost();\n }\n\n private measureHost() {\n if (!this.el.isConnected) return;\n const bounds = this.el.getBoundingClientRect();\n this.applyMeasuredSize(bounds.width, bounds.height);\n }\n\n private applyMeasuredSize(width: number, height: number) {\n let changed = false;\n if (!this.width && width > 0) {\n const nextWidth = Math.round(width * 100) / 100;\n if (nextWidth !== this.surfaceWidth) {\n this.surfaceWidth = nextWidth;\n changed = true;\n }\n }\n if (!this.height && !this.aspectRatio && height > 0) {\n const nextHeight = Math.round(height * 100) / 100;\n if (nextHeight !== this.containerHeight) {\n this.containerHeight = nextHeight;\n changed = true;\n }\n }\n if (changed) {\n this.scheduleCompile();\n }\n }\n\n private scheduleCompile() {\n if (!this.el.isConnected) return;\n if (this.compileFrame !== undefined) cancelAnimationFrame(this.compileFrame);\n this.compileFrame = requestAnimationFrame(() => {\n this.compileFrame = undefined;\n this.compile();\n });\n }\n\n private compile() {\n if (!this.definition || !(this.surfaceWidth > 0) || !(this.surfaceHeight > 0)) return;\n const previousKey = this.focusState?.primary.sceneKey;\n const usedLabels = new Set<string>();\n const scene = compileChartScene(\n this.definition,\n this.surfaceWidth,\n this.surfaceHeight,\n resolveFormatLocale(this.locale),\n text => {\n usedLabels.add(text);\n return this.measuredLabels.get(text) ?? { width: text.length * 7, height: 14 };\n },\n resolveChartTheme(this.el)\n );\n // Retain only measurements consulted by this definition, including thinned ticks.\n for (const text of this.measuredLabels.keys()) {\n if (!usedLabels.has(text)) this.measuredLabels.delete(text);\n }\n this.scene = scene;\n if (previousKey) {\n const restored = scene.points.find(point => point.sceneKey === previousKey);\n if (restored && this.focusState) {\n const grouped = findChartFocus(\n scene.points,\n scene.focus,\n restored.x,\n restored.y,\n scene.maxFocusDistance,\n scene.arcHitRegions\n );\n this.focusState = grouped ? { ...grouped, source: this.focusState.source } : undefined;\n } else {\n this.focusState = undefined;\n }\n }\n }\n\n private waitForFonts() {\n if (!document.fonts) return;\n const generation = this.connectionGeneration;\n void document.fonts.ready.then(() => {\n if (!this.el.isConnected || generation !== this.connectionGeneration) return;\n this.measuredLabels.clear();\n this.scheduleCompile();\n });\n }\n\n private measureRenderedLabels() {\n if (!this.el.isConnected) return;\n const labels = this.el.querySelectorAll<SVGTextElement>('[data-chart-measure]');\n // Guide labels share the tick font recipe. A theme change must invalidate\n // its metrics even when this render only changes focus, rather than the scene.\n const style = labels[0] ? getComputedStyle(labels[0]) : undefined;\n const font = style\n ? [\n style.font,\n style.letterSpacing,\n style.fontFeatureSettings,\n style.fontVariationSettings,\n style.textTransform,\n ].join('|')\n : '';\n if (this.measuredScene === this.scene && this.measuredFont === font) return;\n if (this.measuredFont !== font) this.measuredLabels.clear();\n this.measuredFont = font;\n this.measuredScene = this.scene;\n let changed = false;\n labels.forEach(label => {\n const text = label.textContent ?? '';\n let box: DOMRect;\n try {\n box = label.getBBox();\n } catch {\n return;\n }\n const previous = this.measuredLabels.get(text);\n if (\n !previous ||\n Math.abs(previous.width - box.width) > 0.5 ||\n Math.abs(previous.height - box.height) > 0.5\n ) {\n this.measuredLabels.set(text, { width: box.width, height: box.height });\n changed = true;\n }\n });\n if (changed) this.scheduleCompile();\n }\n\n private resolveFocus(x: number, y: number, source: ChartFocusSource) {\n if (!this.scene) return;\n const result = findChartFocus(\n this.scene.points,\n this.scene.focus,\n x,\n y,\n this.scene.maxFocusDistance,\n this.scene.arcHitRegions\n );\n if (!result) {\n this.clearFocus(source);\n return;\n }\n if (\n this.focusState?.primary.sceneKey === result.primary.sceneKey &&\n this.focusState.source === source\n )\n return;\n this.focusState = { ...result, source };\n this.dsChartFocusChange.emit({ ...result, source });\n }\n\n private clearFocus(source: ChartFocusSource) {\n if (!this.focusState) return;\n this.focusState = undefined;\n this.dsChartFocusChange.emit({ primary: null, points: [], source });\n }\n\n private onPointerMove = (event: PointerEvent) => {\n const svg = event.currentTarget as SVGSVGElement;\n const rect = svg.getBoundingClientRect();\n const x = event.clientX - rect.left;\n const y = event.clientY - rect.top;\n this.pointerAnchor = this.scene?.arcHitRegions.length ? { x, y } : undefined;\n this.resolveFocus(x, y, 'pointer');\n };\n\n private onPointerLeave = () => {\n this.pointerAnchor = undefined;\n this.clearFocus('pointer');\n };\n\n private keyboardPoints(): ChartPoint[] {\n if (!this.scene) return [];\n return [...this.scene.points].sort((a, b) => a.x - b.x || a.y - b.y);\n }\n\n private onKeyDown = (event: KeyboardEvent) => {\n const points = this.keyboardPoints();\n if (points.length === 0) return;\n if (event.key === 'Escape') {\n event.preventDefault();\n this.clearFocus('keyboard');\n return;\n }\n const current = this.focusState\n ? points.findIndex(point => point.sceneKey === this.focusState?.primary.sceneKey)\n : -1;\n let next: number | undefined;\n if (event.key === 'Home') next = 0;\n else if (event.key === 'End') next = points.length - 1;\n else if (event.key === 'ArrowRight' || event.key === 'ArrowDown')\n next = Math.min(points.length - 1, current + 1);\n else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp')\n next = Math.max(0, current < 0 ? 0 : current - 1);\n else return;\n event.preventDefault();\n const point = points[next ?? 0] ?? points[0];\n this.resolveFocus(point.x, point.y, 'keyboard');\n };\n\n private renderNode(node: ChartSceneNode, className = 'chart__mark') {\n const resolvedClassName = node.className ?? className;\n const presentation = {\n fill: node.style.fill,\n stroke: node.style.stroke,\n opacity: node.style.opacity,\n 'fill-opacity': node.style.fillOpacity,\n 'stroke-opacity': node.style.strokeOpacity,\n 'stroke-width': node.style.strokeWidth,\n 'stroke-dasharray': node.style.strokeDasharray || undefined,\n };\n if (node.type === 'path')\n return (\n <path\n key={node.key}\n class={resolvedClassName}\n d={node.d}\n transform={node.transform}\n {...presentation}\n />\n );\n if (node.type === 'rect') {\n return (\n <rect\n key={node.key}\n class={resolvedClassName}\n x={node.x}\n y={node.y}\n width={node.width}\n height={node.height}\n rx={node.style.radius}\n {...presentation}\n />\n );\n }\n if (node.type === 'circle')\n return (\n <circle\n key={node.key}\n class={resolvedClassName}\n cx={node.x}\n cy={node.y}\n r={node.radius}\n {...presentation}\n />\n );\n if (node.type === 'line')\n return (\n <line\n key={node.key}\n class={resolvedClassName}\n x1={node.x1}\n x2={node.x2}\n y1={node.y1}\n y2={node.y2}\n {...presentation}\n />\n );\n return (\n <text\n key={node.key}\n class={node.className ?? `${className} chart__text-mark`}\n data-chart-measure={node.measure ? '' : undefined}\n x={node.x}\n y={node.y}\n dx={node.dx}\n dy={node.dy}\n text-anchor={node.style.textAnchor}\n font-family={node.style.fontFamily}\n font-size={node.style.fontSize}\n font-weight={node.style.fontWeight}\n dominant-baseline={node.dominantBaseline ?? 'middle'}\n transform={node.rotate ? `rotate(${node.rotate} ${node.x} ${node.y})` : undefined}\n {...presentation}\n >\n {node.text}\n </text>\n );\n }\n\n private renderMarkLayers(scene: ChartScene) {\n const layers: Array<{ clipped: boolean; nodes: ChartSceneNode[] }> = [];\n scene.nodes.forEach(node => {\n const clipped = scene.clip && node.clipToPlot !== false;\n const current = layers[layers.length - 1];\n if (current?.clipped === clipped) current.nodes.push(node);\n else layers.push({ clipped, nodes: [node] });\n });\n return layers.map((layer, index) => (\n <g\n key={`marks-${index}`}\n class=\"chart__marks\"\n aria-hidden=\"true\"\n clip-path={layer.clipped ? `url(#${this.clipId})` : undefined}\n >\n {layer.nodes.map(node => this.renderNode(node))}\n </g>\n ));\n }\n\n private focusedDotPoints(scene: ChartScene): ChartPoint[] {\n if (!this.focusState) return [];\n const focusDotPointKeys = new Set(scene.focusDotPointKeys);\n return this.focusState.points.filter(point => focusDotPointKeys.has(point.sceneKey));\n }\n\n private tooltipContent(): { heading?: string; items: ChartTooltipItem[] } | undefined {\n if (!this.scene || !this.focusState || this.scene.tooltip === false) return undefined;\n const options: ChartTooltipOptions =\n typeof this.scene.tooltip === 'object' ? this.scene.tooltip : {};\n const locale = resolveFormatLocale(this.locale);\n const points = this.focusState.points;\n const formatDate = (value: Date) =>\n dateFormatter(locale, {\n year: 'numeric',\n month: 'short',\n day: 'numeric',\n timeZone: 'UTC',\n }).format(value);\n const formatValue = (point: ChartPoint) => {\n const value = point.value ?? point.yValue;\n return (\n options.format?.(point, locale) ??\n (typeof value === 'number'\n ? numberFormatter(locale, { maximumFractionDigits: 2 }).format(value)\n : value instanceof Date\n ? formatDate(value)\n : String(value))\n );\n };\n return {\n heading:\n points.length > 1\n ? (options.formatGroupHeading?.(points, locale) ??\n (this.focusState.primary.xValue instanceof Date\n ? formatDate(this.focusState.primary.xValue)\n : String(this.focusState.primary.xValue)))\n : undefined,\n items: points.map(\n point =>\n options.formatGroupItem?.(point, locale) ?? {\n label: point.groupLabel ?? point.markId,\n value: formatValue(point),\n color: point.color,\n }\n ),\n };\n }\n\n private statusText(content: ReturnType<Chart['tooltipContent']>): string {\n if (!content) return '';\n return [content.heading, ...content.items.map(item => `${item.label}: ${item.value}`)]\n .filter(Boolean)\n .join('. ');\n }\n\n render() {\n const scene = this.scene;\n const tooltip = this.tooltipContent();\n const focus = this.focusState?.primary;\n const tooltipAnchor =\n focus &&\n this.focusState?.source === 'pointer' &&\n scene?.arcHitRegions.length &&\n this.pointerAnchor\n ? this.pointerAnchor\n : focus;\n const focusedDotPoints = scene ? this.focusedDotPoints(scene) : [];\n const groupedPointFocus =\n scene?.coordinate === 'cartesian' && scene.focus === 'group-x' && focusedDotPoints.length > 1;\n const center =\n focus && scene?.center?.focused\n ? scene.center.focused(focus, resolveFormatLocale(this.locale))\n : scene?.center;\n return (\n <Host\n class=\"chart\"\n style={{\n '--ds-chart-width': this.width ? `${this.width}px` : '100%',\n '--ds-chart-height':\n this.height || this.aspectRatio\n ? `${this.surfaceHeight}px`\n : 'var(--ds-chart-container-height, 320px)',\n }}\n >\n {this.description && (\n <span id={this.descriptionId} class=\"chart__description\">\n {this.description}\n </span>\n )}\n {scene && (\n /* eslint-disable-next-line compomo/prefer-ds-icon -- SVG is the chart renderer. */\n <svg\n class=\"chart__surface\"\n width={scene.width}\n height={scene.height}\n viewBox={`0 0 ${scene.width} ${scene.height}`}\n role=\"img\"\n aria-label={this.label || 'Chart'}\n aria-describedby={this.description ? this.descriptionId : undefined}\n tabindex={0}\n onPointerMove={this.onPointerMove}\n onPointerLeave={this.onPointerLeave}\n onKeyDown={this.onKeyDown}\n >\n <defs aria-hidden=\"true\">\n <clipPath id={this.clipId}>\n <rect\n x={scene.plot.left}\n y={scene.plot.top}\n width={scene.plot.width}\n height={scene.plot.height}\n />\n </clipPath>\n </defs>\n <rect\n class=\"chart__interaction-surface\"\n x=\"0\"\n y=\"0\"\n width={scene.width}\n height={scene.height}\n aria-hidden=\"true\"\n />\n <g class=\"chart__guides\" aria-hidden=\"true\">\n {scene.guides.map(node => this.renderNode(node, 'chart__grid chart__polar-guide'))}\n {scene.yAxis.line && (\n <line\n class=\"chart__axis-line\"\n x1={scene.plot.left}\n x2={scene.plot.left}\n y1={scene.plot.top}\n y2={scene.plot.bottom}\n />\n )}\n {scene.yAxis.ticks.map(tick => (\n <g key={`y-${tick.key}`}>\n {scene.yAxis.grid && (\n <line\n class=\"chart__grid\"\n x1={scene.plot.left}\n x2={scene.plot.right}\n y1={tick.position}\n y2={tick.position}\n />\n )}\n <line\n class=\"chart__axis-line chart__tick-stub\"\n x1={scene.plot.left - scene.yAxis.tickSize}\n x2={scene.plot.left}\n y1={tick.position}\n y2={tick.position}\n />\n {tick.labelVisible && (\n <text\n data-chart-measure\n class=\"chart__tick chart__tick--y\"\n x={scene.plot.left - scene.yAxis.tickSize - scene.yAxis.tickPadding}\n y={tick.position}\n text-anchor=\"end\"\n dominant-baseline=\"middle\"\n >\n {tick.label}\n </text>\n )}\n </g>\n ))}\n {scene.xAxis.line && (\n <line\n class=\"chart__axis-line\"\n x1={scene.plot.left}\n x2={scene.plot.right}\n y1={scene.plot.bottom}\n y2={scene.plot.bottom}\n />\n )}\n {scene.xAxis.ticks.map(tick => (\n <g key={`x-${tick.key}`}>\n {scene.xAxis.grid && (\n <line\n class=\"chart__grid\"\n x1={tick.position}\n x2={tick.position}\n y1={scene.plot.top}\n y2={scene.plot.bottom}\n />\n )}\n <line\n class=\"chart__axis-line chart__tick-stub\"\n x1={tick.position}\n x2={tick.position}\n y1={scene.plot.bottom}\n y2={scene.plot.bottom + scene.xAxis.tickSize}\n />\n {tick.labelVisible && (\n <text\n data-chart-measure\n class=\"chart__tick chart__tick--x\"\n x={tick.position}\n y={scene.plot.bottom + scene.xAxis.tickSize + scene.xAxis.tickPadding}\n text-anchor=\"middle\"\n dominant-baseline=\"hanging\"\n transform={\n scene.xAxis.rotate\n ? `rotate(${scene.xAxis.rotate} ${tick.position} ${scene.plot.bottom + scene.xAxis.tickSize + scene.xAxis.tickPadding})`\n : undefined\n }\n >\n {tick.label}\n </text>\n )}\n </g>\n ))}\n {scene.xAxis.label && (\n <text\n class=\"chart__axis-title\"\n x={(scene.plot.left + scene.plot.right) / 2}\n y={scene.height - 4}\n text-anchor=\"middle\"\n >\n {scene.xAxis.label}\n </text>\n )}\n {scene.yAxis.label && (\n <text\n class=\"chart__axis-title\"\n x=\"8\"\n y={(scene.plot.top + scene.plot.bottom) / 2}\n text-anchor=\"middle\"\n dominant-baseline=\"middle\"\n transform={`rotate(-90 8 ${(scene.plot.top + scene.plot.bottom) / 2})`}\n >\n {scene.yAxis.label}\n </text>\n )}\n {scene.coordinate === 'cartesian' && (\n <g class=\"chart__plot-frame\">\n <line\n class=\"chart__plot-boundary chart__plot-boundary--top\"\n x1={scene.plot.left}\n x2={scene.plot.right}\n y1={scene.plot.top}\n y2={scene.plot.top}\n />\n <line\n class=\"chart__plot-boundary chart__plot-boundary--right\"\n x1={scene.plot.right}\n x2={scene.plot.right}\n y1={scene.plot.top}\n y2={scene.plot.bottom}\n />\n </g>\n )}\n </g>\n {this.renderMarkLayers(scene)}\n {groupedPointFocus && focus && (\n <line\n class=\"chart__focus-guide\"\n x1={focus.x}\n x2={focus.x}\n y1={scene.plot.top}\n y2={scene.plot.bottom}\n aria-hidden=\"true\"\n />\n )}\n {focusedDotPoints.map(point => (\n <circle\n key={`focus-${point.sceneKey}`}\n class=\"chart__focus-point\"\n cx={point.x}\n cy={point.y}\n r={scene.focusDotCircleRadius}\n fill={point.color}\n stroke=\"var(--color-background-primary)\"\n stroke-width={scene.focusDotHaloWidth}\n aria-hidden=\"true\"\n />\n ))}\n {focus && focusedDotPoints.length === 0 && (\n <circle class=\"chart__focus\" cx={focus.x} cy={focus.y} r=\"6\" aria-hidden=\"true\" />\n )}\n {center?.value && (\n <text\n class=\"chart__center-value\"\n x={(scene.plot.left + scene.plot.right) / 2}\n y={(scene.plot.top + scene.plot.bottom) / 2 - (center.caption ? 10 : 0)}\n text-anchor=\"middle\"\n dominant-baseline=\"middle\"\n aria-hidden=\"true\"\n >\n {center.value}\n </text>\n )}\n {center?.caption && (\n <text\n class=\"chart__center-caption\"\n x={(scene.plot.left + scene.plot.right) / 2}\n y={(scene.plot.top + scene.plot.bottom) / 2 + 14}\n text-anchor=\"middle\"\n dominant-baseline=\"middle\"\n aria-hidden=\"true\"\n >\n {center.caption}\n </text>\n )}\n </svg>\n )}\n {tooltip && tooltipAnchor && (\n <ds-tooltip-chart\n heading={tooltip.heading}\n items={tooltip.items}\n x={tooltipAnchor.x}\n y={tooltipAnchor.y}\n />\n )}\n <span class=\"chart__status\" aria-live=\"polite\" aria-atomic=\"true\">\n {this.statusText(tooltip)}\n </span>\n </Host>\n );\n }\n}\n",
|
|
264
|
+
"content": "import { dateFormatter, numberFormatter, resolveFormatLocale } from '../../utils/intl-formatters';\nimport {\n Component,\n Element,\n Event,\n EventEmitter,\n Prop,\n State,\n Watch,\n h,\n Host,\n} from '@stencil/core';\nimport type {\n ChartDefinition,\n ChartPoint,\n ChartTooltipItem,\n ChartTooltipOptions,\n} from '../../utils/chart-grammar';\nimport {\n compileChartScene,\n findChartFocus,\n type ChartScene,\n type ChartSceneNode,\n} from '../../utils/chart-scene';\nimport { resolveChartTheme } from '../../utils/chart-theme';\n\nconst DEFAULT_WIDTH = 480;\nconst DEFAULT_HEIGHT = 320;\n\nexport type ChartFocusSource = 'pointer' | 'keyboard';\n\nexport interface ChartFocusChangeDetail {\n primary: ChartPoint | null;\n points: readonly ChartPoint[];\n source: ChartFocusSource;\n}\n\ninterface FocusState {\n primary: ChartPoint;\n points: ChartPoint[];\n source: ChartFocusSource;\n}\n\ninterface PointerAnchor {\n x: number;\n y: number;\n}\n\n@Component({\n tag: 'ds-chart',\n styleUrl: 'Chart.css',\n scoped: true,\n})\nexport class Chart {\n @Element() el!: HTMLElement;\n\n /** Typed chart definition. Set as a JavaScript property, not an HTML attribute. */\n @Prop() definition!: ChartDefinition;\n /** Required accessible name for the visualization. */\n @Prop() label!: string;\n /** Optional longer accessible description. */\n @Prop() description?: string;\n /** Locale used by axes and default tooltip formatters. Defaults to English (en-US). */\n @Prop() locale?: string;\n /** Fixed surface width. Otherwise the chart observes its container. */\n @Prop() width?: number;\n /** Product-owned surface height. Wins over aspectRatio. */\n @Prop() height?: number;\n /** Derives height from the current width when height is not supplied. */\n @Prop() aspectRatio?: number;\n\n /** Fires whenever pointer or keyboard focus resolves to semantic chart points. */\n @Event() dsChartFocusChange!: EventEmitter<ChartFocusChangeDetail>;\n\n @State() private surfaceWidth = DEFAULT_WIDTH;\n @State() private containerHeight?: number;\n @State() private scene?: ChartScene;\n @State() private focusState?: FocusState;\n @State() private pointerAnchor?: PointerAnchor;\n\n private resizeObserver?: ResizeObserver;\n private resizeFrame?: number;\n private compileFrame?: number;\n private hasLoaded = false;\n private connectionGeneration = 0;\n private warnedConflictingHeight = false;\n private measuredScene?: ChartScene;\n private measuredFont = '';\n private measuredLabels = new Map<string, { width: number; height: number }>();\n private descriptionId = `ds-chart-description-${Math.random().toString(36).slice(2)}`;\n private clipId = `ds-chart-clip-${Math.random().toString(36).slice(2)}`;\n\n componentWillLoad() {\n if (this.width && this.width > 0) this.surfaceWidth = this.width;\n this.compile();\n }\n\n componentDidLoad() {\n this.hasLoaded = true;\n this.connect();\n }\n\n connectedCallback() {\n if (this.hasLoaded) this.connect();\n }\n\n componentDidRender() {\n this.measureRenderedLabels();\n }\n\n disconnectedCallback() {\n this.connectionGeneration += 1;\n this.resizeObserver?.disconnect();\n this.resizeObserver = undefined;\n if (this.resizeFrame !== undefined) cancelAnimationFrame(this.resizeFrame);\n if (this.compileFrame !== undefined) cancelAnimationFrame(this.compileFrame);\n this.resizeFrame = undefined;\n this.compileFrame = undefined;\n this.measuredScene = undefined;\n this.measuredLabels.clear();\n }\n\n @Watch('definition')\n @Watch('locale')\n @Watch('height')\n @Watch('aspectRatio')\n onDefinitionInputChange() {\n this.scheduleCompile();\n }\n\n @Watch('width')\n onWidthChange() {\n if (this.width && this.width > 0) this.surfaceWidth = this.width;\n else this.measureHost();\n this.scheduleCompile();\n }\n\n private get surfaceHeight(): number {\n if (this.height && this.height > 0) {\n if (this.aspectRatio && !this.warnedConflictingHeight) {\n this.warnedConflictingHeight = true;\n console.warn('[ds-chart] height wins when both height and aspectRatio are supplied.');\n }\n return this.height;\n }\n if (this.aspectRatio && this.aspectRatio > 0) {\n return Math.max(1, this.surfaceWidth / this.aspectRatio);\n }\n return this.containerHeight ?? DEFAULT_HEIGHT;\n }\n\n private connect() {\n if (!this.el.isConnected) return;\n // A detached chart may return with new props, dimensions, or inherited fonts.\n this.measuredLabels.clear();\n this.connectResizeObserver();\n this.scheduleCompile();\n this.waitForFonts();\n }\n\n private connectResizeObserver() {\n this.resizeObserver?.disconnect();\n if (typeof ResizeObserver !== 'undefined') {\n this.resizeObserver = new ResizeObserver(entries => {\n const entry = entries[0];\n if (!entry || !this.el.isConnected) return;\n if (this.resizeFrame !== undefined) cancelAnimationFrame(this.resizeFrame);\n this.resizeFrame = requestAnimationFrame(() => {\n this.resizeFrame = undefined;\n this.applyMeasuredSize(entry.contentRect.width, entry.contentRect.height);\n });\n });\n this.resizeObserver.observe(this.el);\n }\n this.measureHost();\n }\n\n private measureHost() {\n if (!this.el.isConnected) return;\n const bounds = this.el.getBoundingClientRect();\n this.applyMeasuredSize(bounds.width, bounds.height);\n }\n\n private applyMeasuredSize(width: number, height: number) {\n let changed = false;\n if (!this.width && width > 0) {\n const nextWidth = Math.round(width * 100) / 100;\n if (nextWidth !== this.surfaceWidth) {\n this.surfaceWidth = nextWidth;\n changed = true;\n }\n }\n if (!this.height && !this.aspectRatio && height > 0) {\n const nextHeight = Math.round(height * 100) / 100;\n if (nextHeight !== this.containerHeight) {\n this.containerHeight = nextHeight;\n changed = true;\n }\n }\n if (changed) {\n this.scheduleCompile();\n }\n }\n\n private scheduleCompile() {\n if (!this.el.isConnected) return;\n if (this.compileFrame !== undefined) cancelAnimationFrame(this.compileFrame);\n this.compileFrame = requestAnimationFrame(() => {\n this.compileFrame = undefined;\n this.compile();\n });\n }\n\n private compile() {\n if (!this.definition || !(this.surfaceWidth > 0) || !(this.surfaceHeight > 0)) return;\n const previousKey = this.focusState?.primary.sceneKey;\n const usedLabels = new Set<string>();\n const scene = compileChartScene(\n this.definition,\n this.surfaceWidth,\n this.surfaceHeight,\n resolveFormatLocale(this.locale),\n text => {\n usedLabels.add(text);\n return this.measuredLabels.get(text) ?? { width: text.length * 7, height: 14 };\n },\n resolveChartTheme(this.el)\n );\n // Retain only measurements consulted by this definition, including thinned ticks.\n for (const text of this.measuredLabels.keys()) {\n if (!usedLabels.has(text)) this.measuredLabels.delete(text);\n }\n this.scene = scene;\n if (previousKey) {\n const restored = scene.points.find(point => point.sceneKey === previousKey);\n if (restored && this.focusState) {\n const grouped = findChartFocus(\n scene.points,\n scene.focus,\n restored.x,\n restored.y,\n scene.maxFocusDistance,\n scene.arcHitRegions\n );\n this.focusState = grouped ? { ...grouped, source: this.focusState.source } : undefined;\n } else {\n this.focusState = undefined;\n }\n }\n }\n\n private waitForFonts() {\n if (!document.fonts) return;\n const generation = this.connectionGeneration;\n void document.fonts.ready.then(() => {\n if (!this.el.isConnected || generation !== this.connectionGeneration) return;\n this.measuredLabels.clear();\n this.scheduleCompile();\n });\n }\n\n private measureRenderedLabels() {\n if (!this.el.isConnected) return;\n const labels = this.el.querySelectorAll<SVGTextElement>('[data-chart-measure]');\n // Guide labels share the tick font recipe. A theme change must invalidate\n // its metrics even when this render only changes focus, rather than the scene.\n const style = labels[0] ? getComputedStyle(labels[0]) : undefined;\n const font = style\n ? [\n style.font,\n style.letterSpacing,\n style.fontFeatureSettings,\n style.fontVariationSettings,\n style.textTransform,\n ].join('|')\n : '';\n if (this.measuredScene === this.scene && this.measuredFont === font) return;\n if (this.measuredFont !== font) this.measuredLabels.clear();\n this.measuredFont = font;\n this.measuredScene = this.scene;\n let changed = false;\n labels.forEach(label => {\n const text = label.textContent ?? '';\n let box: DOMRect;\n try {\n box = label.getBBox();\n } catch {\n return;\n }\n const previous = this.measuredLabels.get(text);\n if (\n !previous ||\n Math.abs(previous.width - box.width) > 0.5 ||\n Math.abs(previous.height - box.height) > 0.5\n ) {\n this.measuredLabels.set(text, { width: box.width, height: box.height });\n changed = true;\n }\n });\n if (changed) this.scheduleCompile();\n }\n\n private resolveFocus(x: number, y: number, source: ChartFocusSource) {\n if (!this.scene) return;\n const result = findChartFocus(\n this.scene.points,\n this.scene.focus,\n x,\n y,\n this.scene.maxFocusDistance,\n this.scene.arcHitRegions\n );\n if (!result) {\n this.clearFocus(source);\n return;\n }\n if (\n this.focusState?.primary.sceneKey === result.primary.sceneKey &&\n this.focusState.source === source\n )\n return;\n this.focusState = { ...result, source };\n this.dsChartFocusChange.emit({ ...result, source });\n }\n\n private clearFocus(source: ChartFocusSource) {\n if (!this.focusState) return;\n this.focusState = undefined;\n this.dsChartFocusChange.emit({ primary: null, points: [], source });\n }\n\n private onPointerMove = (event: PointerEvent) => {\n const svg = event.currentTarget as SVGSVGElement;\n const rect = svg.getBoundingClientRect();\n const x = event.clientX - rect.left;\n const y = event.clientY - rect.top;\n this.pointerAnchor = this.scene?.arcHitRegions.length ? { x, y } : undefined;\n this.resolveFocus(x, y, 'pointer');\n };\n\n private onPointerLeave = () => {\n this.pointerAnchor = undefined;\n this.clearFocus('pointer');\n };\n\n private keyboardPoints(): ChartPoint[] {\n if (!this.scene) return [];\n return [...this.scene.points].sort((a, b) => a.x - b.x || a.y - b.y);\n }\n\n private onKeyDown = (event: KeyboardEvent) => {\n const points = this.keyboardPoints();\n if (points.length === 0) return;\n if (event.key === 'Escape') {\n event.preventDefault();\n this.clearFocus('keyboard');\n return;\n }\n const current = this.focusState\n ? points.findIndex(point => point.sceneKey === this.focusState?.primary.sceneKey)\n : -1;\n let next: number | undefined;\n if (event.key === 'Home') next = 0;\n else if (event.key === 'End') next = points.length - 1;\n else if (event.key === 'ArrowRight' || event.key === 'ArrowDown')\n next = Math.min(points.length - 1, current + 1);\n else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp')\n next = Math.max(0, current < 0 ? 0 : current - 1);\n else return;\n event.preventDefault();\n const point = points[next ?? 0] ?? points[0];\n this.resolveFocus(point.x, point.y, 'keyboard');\n };\n\n private renderNode(node: ChartSceneNode, className = 'chart__mark') {\n const resolvedClassName = node.className ?? className;\n const presentation = {\n fill: node.style.fill,\n stroke: node.style.stroke,\n opacity: node.style.opacity,\n 'fill-opacity': node.style.fillOpacity,\n 'stroke-opacity': node.style.strokeOpacity,\n 'stroke-width': node.style.strokeWidth,\n 'stroke-dasharray': node.style.strokeDasharray || undefined,\n };\n if (node.type === 'path')\n return (\n <path\n key={node.key}\n class={resolvedClassName}\n d={node.d}\n transform={node.transform}\n {...presentation}\n />\n );\n if (node.type === 'rect') {\n return (\n <rect\n key={node.key}\n class={resolvedClassName}\n x={node.x}\n y={node.y}\n width={node.width}\n height={node.height}\n rx={node.style.radius}\n {...presentation}\n />\n );\n }\n if (node.type === 'circle')\n return (\n <circle\n key={node.key}\n class={resolvedClassName}\n cx={node.x}\n cy={node.y}\n r={node.radius}\n {...presentation}\n />\n );\n if (node.type === 'line')\n return (\n <line\n key={node.key}\n class={resolvedClassName}\n x1={node.x1}\n x2={node.x2}\n y1={node.y1}\n y2={node.y2}\n {...presentation}\n />\n );\n return (\n <text\n key={node.key}\n class={node.className ?? `${className} chart__text-mark`}\n data-chart-measure={node.measure ? '' : undefined}\n x={node.x}\n y={node.y}\n dx={node.dx}\n dy={node.dy}\n text-anchor={node.style.textAnchor}\n font-family={node.style.fontFamily}\n font-size={node.style.fontSize}\n font-weight={node.style.fontWeight}\n dominant-baseline={node.dominantBaseline ?? 'middle'}\n transform={node.rotate ? `rotate(${node.rotate} ${node.x} ${node.y})` : undefined}\n {...presentation}\n >\n {node.text}\n </text>\n );\n }\n\n private renderMarkLayers(scene: ChartScene) {\n const layers: Array<{ clipped: boolean; nodes: ChartSceneNode[] }> = [];\n scene.nodes.forEach(node => {\n const clipped = scene.clip && node.clipToPlot !== false;\n const current = layers[layers.length - 1];\n if (current?.clipped === clipped) current.nodes.push(node);\n else layers.push({ clipped, nodes: [node] });\n });\n return layers.map((layer, index) => (\n <g\n key={`marks-${index}`}\n class=\"chart__marks\"\n aria-hidden=\"true\"\n clip-path={layer.clipped ? `url(#${this.clipId})` : undefined}\n >\n {layer.nodes.map(node => this.renderNode(node))}\n </g>\n ));\n }\n\n private focusedDotPoints(scene: ChartScene): ChartPoint[] {\n if (!this.focusState) return [];\n const focusDotPointKeys = new Set(scene.focusDotPointKeys);\n return this.focusState.points.filter(point => focusDotPointKeys.has(point.sceneKey));\n }\n\n private tooltipContent(): { heading?: string; items: ChartTooltipItem[] } | undefined {\n if (!this.scene || !this.focusState || this.scene.tooltip === false) return undefined;\n const options: ChartTooltipOptions =\n typeof this.scene.tooltip === 'object' ? this.scene.tooltip : {};\n const locale = resolveFormatLocale(this.locale);\n const points = this.focusState.points;\n const formatDate = (value: Date) =>\n dateFormatter(locale, {\n year: 'numeric',\n month: 'short',\n day: 'numeric',\n timeZone: 'UTC',\n }).format(value);\n const formatValue = (point: ChartPoint) => {\n const value = point.value ?? point.yValue;\n return (\n options.format?.(point, locale) ??\n (typeof value === 'number'\n ? numberFormatter(locale, { maximumFractionDigits: 2 }).format(value)\n : value instanceof Date\n ? formatDate(value)\n : String(value))\n );\n };\n return {\n heading:\n points.length > 1\n ? (options.formatGroupHeading?.(points, locale) ??\n (this.focusState.primary.xValue instanceof Date\n ? formatDate(this.focusState.primary.xValue)\n : String(this.focusState.primary.xValue)))\n : undefined,\n items: points.map(\n point =>\n options.formatGroupItem?.(point, locale) ?? {\n label: point.groupLabel ?? point.markId,\n value: formatValue(point),\n color: point.color,\n }\n ),\n };\n }\n\n private statusText(content: ReturnType<Chart['tooltipContent']>): string {\n if (!content) return '';\n return [content.heading, ...content.items.map(item => `${item.label}: ${item.value}`)]\n .filter(Boolean)\n .join('. ');\n }\n\n render() {\n const scene = this.scene;\n const tooltip = this.tooltipContent();\n const focus = this.focusState?.primary;\n const tooltipAnchor =\n focus &&\n this.focusState?.source === 'pointer' &&\n scene?.arcHitRegions.length &&\n this.pointerAnchor\n ? this.pointerAnchor\n : focus;\n const focusedDotPoints = scene ? this.focusedDotPoints(scene) : [];\n const groupedPointFocus =\n scene?.coordinate === 'cartesian' && scene.focus === 'group-x' && focusedDotPoints.length > 1;\n const center =\n focus && scene?.center?.focused\n ? scene.center.focused(focus, resolveFormatLocale(this.locale))\n : scene?.center;\n return (\n <Host\n class=\"chart\"\n style={{\n '--ds-chart-width': this.width ? `${this.width}px` : '100%',\n '--ds-chart-height':\n this.height || this.aspectRatio\n ? `${this.surfaceHeight}px`\n : 'var(--ds-chart-container-height, calc(var(--dimension-size-base) * 40))',\n }}\n >\n {this.description && (\n <span id={this.descriptionId} class=\"chart__description\">\n {this.description}\n </span>\n )}\n {scene && (\n /* eslint-disable-next-line compomo/prefer-ds-icon -- SVG is the chart renderer. */\n <svg\n class=\"chart__surface\"\n width={scene.width}\n height={scene.height}\n viewBox={`0 0 ${scene.width} ${scene.height}`}\n role=\"img\"\n aria-label={this.label || 'Chart'}\n aria-describedby={this.description ? this.descriptionId : undefined}\n tabindex={0}\n onPointerMove={this.onPointerMove}\n onPointerLeave={this.onPointerLeave}\n onKeyDown={this.onKeyDown}\n >\n <defs aria-hidden=\"true\">\n <clipPath id={this.clipId}>\n <rect\n x={scene.plot.left}\n y={scene.plot.top}\n width={scene.plot.width}\n height={scene.plot.height}\n />\n </clipPath>\n </defs>\n <rect\n class=\"chart__interaction-surface\"\n x=\"0\"\n y=\"0\"\n width={scene.width}\n height={scene.height}\n aria-hidden=\"true\"\n />\n <g class=\"chart__guides\" aria-hidden=\"true\">\n {scene.guides.map(node => this.renderNode(node, 'chart__grid chart__polar-guide'))}\n {scene.yAxis.line && (\n <line\n class=\"chart__axis-line\"\n x1={scene.plot.left}\n x2={scene.plot.left}\n y1={scene.plot.top}\n y2={scene.plot.bottom}\n />\n )}\n {scene.yAxis.ticks.map(tick => (\n <g key={`y-${tick.key}`}>\n {scene.yAxis.grid && (\n <line\n class=\"chart__grid\"\n x1={scene.plot.left}\n x2={scene.plot.right}\n y1={tick.position}\n y2={tick.position}\n />\n )}\n <line\n class=\"chart__axis-line chart__tick-stub\"\n x1={scene.plot.left - scene.yAxis.tickSize}\n x2={scene.plot.left}\n y1={tick.position}\n y2={tick.position}\n />\n {tick.labelVisible && (\n <text\n data-chart-measure\n class=\"chart__tick chart__tick--y\"\n x={scene.plot.left - scene.yAxis.tickSize - scene.yAxis.tickPadding}\n y={tick.position}\n text-anchor=\"end\"\n dominant-baseline=\"middle\"\n >\n {tick.label}\n </text>\n )}\n </g>\n ))}\n {scene.xAxis.line && (\n <line\n class=\"chart__axis-line\"\n x1={scene.plot.left}\n x2={scene.plot.right}\n y1={scene.plot.bottom}\n y2={scene.plot.bottom}\n />\n )}\n {scene.xAxis.ticks.map(tick => (\n <g key={`x-${tick.key}`}>\n {scene.xAxis.grid && (\n <line\n class=\"chart__grid\"\n x1={tick.position}\n x2={tick.position}\n y1={scene.plot.top}\n y2={scene.plot.bottom}\n />\n )}\n <line\n class=\"chart__axis-line chart__tick-stub\"\n x1={tick.position}\n x2={tick.position}\n y1={scene.plot.bottom}\n y2={scene.plot.bottom + scene.xAxis.tickSize}\n />\n {tick.labelVisible && (\n <text\n data-chart-measure\n class=\"chart__tick chart__tick--x\"\n x={tick.position}\n y={scene.plot.bottom + scene.xAxis.tickSize + scene.xAxis.tickPadding}\n text-anchor=\"middle\"\n dominant-baseline=\"hanging\"\n transform={\n scene.xAxis.rotate\n ? `rotate(${scene.xAxis.rotate} ${tick.position} ${scene.plot.bottom + scene.xAxis.tickSize + scene.xAxis.tickPadding})`\n : undefined\n }\n >\n {tick.label}\n </text>\n )}\n </g>\n ))}\n {scene.xAxis.label && (\n <text\n class=\"chart__axis-title\"\n x={(scene.plot.left + scene.plot.right) / 2}\n y={scene.height - 4}\n text-anchor=\"middle\"\n >\n {scene.xAxis.label}\n </text>\n )}\n {scene.yAxis.label && (\n <text\n class=\"chart__axis-title\"\n x=\"8\"\n y={(scene.plot.top + scene.plot.bottom) / 2}\n text-anchor=\"middle\"\n dominant-baseline=\"middle\"\n transform={`rotate(-90 8 ${(scene.plot.top + scene.plot.bottom) / 2})`}\n >\n {scene.yAxis.label}\n </text>\n )}\n {scene.coordinate === 'cartesian' && (\n <g class=\"chart__plot-frame\">\n <line\n class=\"chart__plot-boundary chart__plot-boundary--top\"\n x1={scene.plot.left}\n x2={scene.plot.right}\n y1={scene.plot.top}\n y2={scene.plot.top}\n />\n <line\n class=\"chart__plot-boundary chart__plot-boundary--right\"\n x1={scene.plot.right}\n x2={scene.plot.right}\n y1={scene.plot.top}\n y2={scene.plot.bottom}\n />\n </g>\n )}\n </g>\n {this.renderMarkLayers(scene)}\n {groupedPointFocus && focus && (\n <line\n class=\"chart__focus-guide\"\n x1={focus.x}\n x2={focus.x}\n y1={scene.plot.top}\n y2={scene.plot.bottom}\n aria-hidden=\"true\"\n />\n )}\n {focusedDotPoints.map(point => (\n <circle\n key={`focus-${point.sceneKey}`}\n class=\"chart__focus-point\"\n cx={point.x}\n cy={point.y}\n r={scene.focusDotCircleRadius}\n fill={point.color}\n stroke=\"var(--color-background-primary)\"\n stroke-width={scene.focusDotHaloWidth}\n aria-hidden=\"true\"\n />\n ))}\n {focus && focusedDotPoints.length === 0 && (\n <circle class=\"chart__focus\" cx={focus.x} cy={focus.y} r=\"6\" aria-hidden=\"true\" />\n )}\n {center?.value && (\n <text\n class=\"chart__center-value\"\n x={(scene.plot.left + scene.plot.right) / 2}\n y={(scene.plot.top + scene.plot.bottom) / 2 - (center.caption ? 10 : 0)}\n text-anchor=\"middle\"\n dominant-baseline=\"middle\"\n aria-hidden=\"true\"\n >\n {center.value}\n </text>\n )}\n {center?.caption && (\n <text\n class=\"chart__center-caption\"\n x={(scene.plot.left + scene.plot.right) / 2}\n y={(scene.plot.top + scene.plot.bottom) / 2 + 14}\n text-anchor=\"middle\"\n dominant-baseline=\"middle\"\n aria-hidden=\"true\"\n >\n {center.caption}\n </text>\n )}\n </svg>\n )}\n {tooltip && tooltipAnchor && (\n <ds-tooltip-chart\n heading={tooltip.heading}\n items={tooltip.items}\n x={tooltipAnchor.x}\n y={tooltipAnchor.y}\n />\n )}\n <span class=\"chart__status\" aria-live=\"polite\" aria-atomic=\"true\">\n {this.statusText(tooltip)}\n </span>\n </Host>\n );\n }\n}\n",
|
|
265
265
|
"type": "registry:ui"
|
|
266
266
|
}
|
|
267
267
|
]
|
|
@@ -42,7 +42,7 @@
|
|
|
42
42
|
],
|
|
43
43
|
"responsiveBehavior": [
|
|
44
44
|
"Render only below the mobile breakpoint; its primary surface extends through the bottom safe-area inset when the application enables viewport-fit=cover.",
|
|
45
|
-
"The primary-background bar
|
|
45
|
+
"The primary-background bar uses shared chrome spacing and medium control recipes; the responsive profile enlarges both consistently. Its two icon-only groups anchor to opposite edges with a divider between Menu and current area.",
|
|
46
46
|
"Bar chrome and its divider are not text-selectable and suppress the iOS touch callout."
|
|
47
47
|
],
|
|
48
48
|
"references": [
|
|
@@ -448,12 +448,12 @@
|
|
|
448
448
|
"files": [
|
|
449
449
|
{
|
|
450
450
|
"path": "src/wc/components/MobileBarNav/MobileBarNav.css",
|
|
451
|
-
"content": "@import '../../utils/chrome-layout.css';\n@import '../../utils/focus-ring.css';\n@import '../../utils/interaction-fill.css';\n\n:host {\n display: block;\n min-width: 0;\n background: var(--color-background-primary);\n color: var(--color-foreground-tertiary);\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n}\n\n.mobile-bar-nav {\n justify-content: space-between;\n min-width: 0;\n padding-block-end: max(var(--ds-chrome-padding), env(safe-area-inset-bottom));\n
|
|
451
|
+
"content": "@import '../../utils/chrome-layout.css';\n@import '../../utils/radius.css';\n@import '../../utils/control-density.css';\n@import '../../utils/focus-ring.css';\n@import '../../utils/interaction-fill.css';\n\n:host {\n display: block;\n min-width: 0;\n background: var(--color-background-primary);\n color: var(--color-foreground-tertiary);\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n}\n\n.mobile-bar-nav {\n position: relative;\n justify-content: space-between;\n min-width: 0;\n padding-block-end: max(var(--ds-chrome-padding), env(safe-area-inset-bottom));\n background: var(--color-background-primary);\n -webkit-user-select: none;\n user-select: none;\n -webkit-touch-callout: none;\n}\n\n/* Keep the divider inside the shared medium bar geometry, like MobileHeader. */\n.mobile-bar-nav::before {\n content: '';\n position: absolute;\n inset-inline: 0;\n inset-block-start: 0;\n height: var(--dimension-stroke-width-012);\n background: var(--color-border-tertiary);\n pointer-events: none;\n}\n\n.mobile-bar-nav__group {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-100);\n}\n\n.mobile-bar-nav__item {\n appearance: none;\n flex: 0 0 var(--ds-control-height);\n width: var(--ds-control-height);\n height: var(--ds-control-height);\n padding: var(--ds-control-padding-inline);\n box-sizing: border-box;\n border: 0;\n border-radius: var(--ds-control-radius);\n background: transparent;\n color: inherit;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n cursor: default;\n}\n\n.mobile-bar-nav__item--selected {\n color: var(--color-foreground-primary);\n}\n\n/*\n * Safari can retain its native :active state after a direct touch. On coarse\n * touch-only pointers, the component-owned class provides one deterministic\n * press frame and clears it after release/cancel.\n */\n@media (hover: none) and (pointer: coarse) {\n .mobile-bar-nav__item.ds-interaction-fill:active:not(:disabled)::after {\n background: transparent;\n }\n\n .mobile-bar-nav__item--pressed.ds-interaction-fill::after,\n .mobile-bar-nav__item--pressed.ds-interaction-fill:active:not(:disabled)::after {\n background: var(--ds-interaction-pressed);\n }\n}\n\n.mobile-bar-nav__divider {\n flex: 0 0 var(--dimension-stroke-width-012);\n width: var(--dimension-stroke-width-012);\n height: var(--dimension-iconography-md);\n background: var(--color-border-tertiary);\n pointer-events: none;\n -webkit-user-select: none;\n user-select: none;\n}\n\n.mobile-bar-nav__icon {\n position: relative;\n display: inline-flex;\n align-items: center;\n justify-content: center;\n width: var(--ds-control-icon);\n height: var(--ds-control-icon);\n}\n\n.mobile-bar-nav__dot {\n position: absolute;\n inset-block-start: calc(var(--dimension-space-025) * -1);\n inset-inline-end: calc(var(--dimension-space-025) * -1);\n}\n",
|
|
452
452
|
"type": "registry:ui"
|
|
453
453
|
},
|
|
454
454
|
{
|
|
455
455
|
"path": "src/wc/components/MobileBarNav/MobileBarNav.tsx",
|
|
456
|
-
"content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Method,\n Prop,\n State,\n} from '@stencil/core';\nimport {\n shouldEmitMobileDestinationChange,\n type MobileDestination,\n} from '../../shell/shell-responsive';\nimport type { PanelNavItem } from '../PanelNav/panel-nav-types';\nimport type { MobileBarNavDestinationDetail } from './mobile-bar-nav-types';\n\ninterface DestinationConfig {\n id: MobileDestination;\n icon: string;\n label: string;\n dot: boolean;\n}\n\nconst MINIMUM_DIRECT_PRESS_MS = 120;\n\n@Component({\n tag: 'ds-mobile-bar-nav',\n styleUrl: 'MobileBarNav.css',\n scoped: true,\n})\nexport class MobileBarNav {\n @Element() el!: HTMLElement;\n\n @Prop() activeDestination: MobileDestination = 'area';\n /** Context destination retained while a different global tool is active. */\n @Prop() primaryDestination: 'area' | 'help' = 'area';\n @Prop() currentArea: PanelNavItem = {\n id: 'tracking',\n icon: 'MapPage',\n label: 'Tracking',\n };\n @Prop() sheetNavExpanded: boolean = false;\n @Prop() menuLabel: string = 'Menu';\n @Prop() searchLabel: string = 'Search';\n /** Render Activity directly, or retain the optional grouped Inbox destination. */\n @Prop() activityMode: 'direct' | 'inbox' = 'direct';\n @Prop() activityLabel: string = 'Activity';\n @Prop() inboxLabel: string = 'Inbox';\n @Prop() messagesLabel: string = 'Messages';\n @Prop() agentsLabel: string = 'Agents';\n @Prop() helpLabel: string = 'Help & Support';\n @Prop() searchDot: boolean = false;\n @Prop() activityDot: boolean = false;\n @Prop() inboxDot: boolean = false;\n @Prop() messagesDot: boolean = false;\n @Prop() agentsDot: boolean = false;\n\n @State() private pressedItemId: string | null = null;\n\n @Event() dsSheetNavToggle!: EventEmitter<boolean>;\n @Event() dsDestinationChange!: EventEmitter<MobileBarNavDestinationDetail>;\n\n private activePointerId: number | null = null;\n private pressedAt = 0;\n private pressClearTimer: ReturnType<typeof setTimeout> | null = null;\n\n disconnectedCallback() {\n this.clearPressedItem();\n }\n\n private clearPressTimer() {\n if (this.pressClearTimer === null) return;\n clearTimeout(this.pressClearTimer);\n this.pressClearTimer = null;\n }\n\n private clearPressedItem() {\n this.clearPressTimer();\n this.activePointerId = null;\n this.pressedItemId = null;\n }\n\n private beginDirectPress(itemId: string, event: PointerEvent) {\n if (!event.isPrimary || event.pointerType === 'mouse' || event.button !== 0) return;\n\n this.clearPressTimer();\n this.activePointerId = event.pointerId;\n this.pressedAt = performance.now();\n this.pressedItemId = itemId;\n\n const target = event.currentTarget as HTMLElement;\n try {\n target.setPointerCapture(event.pointerId);\n } catch {\n // Pointer capture can fail if Safari has already cancelled the stream.\n }\n }\n\n private finishDirectPress(event: PointerEvent) {\n if (event.pointerId !== this.activePointerId) return;\n\n this.activePointerId = null;\n const remaining = Math.max(0, MINIMUM_DIRECT_PRESS_MS - (performance.now() - this.pressedAt));\n\n this.clearPressTimer();\n this.pressClearTimer = setTimeout(() => {\n this.pressClearTimer = null;\n this.pressedItemId = null;\n }, remaining);\n }\n\n private cancelDirectPress(event: PointerEvent) {\n if (event.pointerId !== this.activePointerId) return;\n this.clearPressedItem();\n }\n\n private pressHandlers(itemId: string) {\n return {\n onPointerDown: (event: PointerEvent) => this.beginDirectPress(itemId, event),\n onPointerUp: (event: PointerEvent) => this.finishDirectPress(event),\n onPointerCancel: (event: PointerEvent) => this.cancelDirectPress(event),\n onLostPointerCapture: (event: PointerEvent) => this.finishDirectPress(event),\n };\n }\n\n private destinationConfig(): DestinationConfig[] {\n const currentArea =\n this.primaryDestination === 'help' || this.activeDestination === 'help'\n ? {\n id: 'help' as const,\n icon: 'QuestionCircle',\n label: this.helpLabel,\n dot: false,\n }\n : {\n id: 'area' as const,\n icon: this.currentArea.icon || 'MapPage',\n label: this.currentArea.label || 'Area',\n dot: false,\n };\n\n const activity =\n this.activityMode === 'direct'\n ? {\n id: 'activity' as const,\n icon: 'Bell',\n label: this.activityLabel,\n dot: this.activityDot,\n }\n : {\n id: 'inbox' as const,\n icon: 'Inbox',\n label: this.inboxLabel,\n dot: this.inboxDot,\n };\n\n return [\n currentArea,\n { id: 'search', icon: 'MagnifyingGlass', label: this.searchLabel, dot: this.searchDot },\n activity,\n {\n id: 'messages',\n icon: 'MessageBubbleStack',\n label: this.messagesLabel,\n dot: this.messagesDot,\n },\n { id: 'agents', icon: 'AIAsterisk', label: this.agentsLabel, dot: this.agentsDot },\n ];\n }\n\n private selectDestination(destination: MobileDestination) {\n if (\n !shouldEmitMobileDestinationChange(this.activeDestination, destination, this.sheetNavExpanded)\n ) {\n return;\n }\n this.dsDestinationChange.emit({ destination });\n // Owners may replace the visible mobile stage (and transiently drop focus\n // in WebKit) while handling the controlled destination change.\n requestAnimationFrame(() => {\n void this.focusDestination(destination);\n });\n }\n\n @Method()\n async focusDestination(destination: MobileDestination | 'sheet-nav') {\n const target = this.el.querySelector<HTMLElement>(\n `#${\n destination === 'sheet-nav'\n ? 'ds-mobile-sheet-nav-trigger'\n : `ds-mobile-bar-${destination}-trigger`\n }`\n );\n target?.focus({ preventScroll: true });\n }\n\n private renderDestination(item: DestinationConfig) {\n const selected = !this.sheetNavExpanded && item.id === this.activeDestination;\n return (\n <button\n id={`ds-mobile-bar-${item.id}-trigger`}\n type=\"button\"\n class={{\n 'mobile-bar-nav__item': true,\n 'mobile-bar-nav__item--selected': selected,\n 'mobile-bar-nav__item--pressed': this.pressedItemId === item.id,\n 'ds-focus-ring-inset': true,\n 'ds-interaction-fill': true,\n }}\n aria-label={item.label}\n aria-current={selected ? 'page' : undefined}\n onClick={() => this.selectDestination(item.id)}\n {...this.pressHandlers(item.id)}\n >\n <span class=\"mobile-bar-nav__icon ds-interaction-fill__content\">\n <ds-icon name={item.icon} size=\"
|
|
456
|
+
"content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Method,\n Prop,\n State,\n} from '@stencil/core';\nimport {\n shouldEmitMobileDestinationChange,\n type MobileDestination,\n} from '../../shell/shell-responsive';\nimport type { PanelNavItem } from '../PanelNav/panel-nav-types';\nimport type { MobileBarNavDestinationDetail } from './mobile-bar-nav-types';\n\ninterface DestinationConfig {\n id: MobileDestination;\n icon: string;\n label: string;\n dot: boolean;\n}\n\nconst MINIMUM_DIRECT_PRESS_MS = 120;\n\n@Component({\n tag: 'ds-mobile-bar-nav',\n styleUrl: 'MobileBarNav.css',\n scoped: true,\n})\nexport class MobileBarNav {\n @Element() el!: HTMLElement;\n\n @Prop() activeDestination: MobileDestination = 'area';\n /** Context destination retained while a different global tool is active. */\n @Prop() primaryDestination: 'area' | 'help' = 'area';\n @Prop() currentArea: PanelNavItem = {\n id: 'tracking',\n icon: 'MapPage',\n label: 'Tracking',\n };\n @Prop() sheetNavExpanded: boolean = false;\n @Prop() menuLabel: string = 'Menu';\n @Prop() searchLabel: string = 'Search';\n /** Render Activity directly, or retain the optional grouped Inbox destination. */\n @Prop() activityMode: 'direct' | 'inbox' = 'direct';\n @Prop() activityLabel: string = 'Activity';\n @Prop() inboxLabel: string = 'Inbox';\n @Prop() messagesLabel: string = 'Messages';\n @Prop() agentsLabel: string = 'Agents';\n @Prop() helpLabel: string = 'Help & Support';\n @Prop() searchDot: boolean = false;\n @Prop() activityDot: boolean = false;\n @Prop() inboxDot: boolean = false;\n @Prop() messagesDot: boolean = false;\n @Prop() agentsDot: boolean = false;\n\n @State() private pressedItemId: string | null = null;\n\n @Event() dsSheetNavToggle!: EventEmitter<boolean>;\n @Event() dsDestinationChange!: EventEmitter<MobileBarNavDestinationDetail>;\n\n private activePointerId: number | null = null;\n private pressedAt = 0;\n private pressClearTimer: ReturnType<typeof setTimeout> | null = null;\n\n disconnectedCallback() {\n this.clearPressedItem();\n }\n\n private clearPressTimer() {\n if (this.pressClearTimer === null) return;\n clearTimeout(this.pressClearTimer);\n this.pressClearTimer = null;\n }\n\n private clearPressedItem() {\n this.clearPressTimer();\n this.activePointerId = null;\n this.pressedItemId = null;\n }\n\n private beginDirectPress(itemId: string, event: PointerEvent) {\n if (!event.isPrimary || event.pointerType === 'mouse' || event.button !== 0) return;\n\n this.clearPressTimer();\n this.activePointerId = event.pointerId;\n this.pressedAt = performance.now();\n this.pressedItemId = itemId;\n\n const target = event.currentTarget as HTMLElement;\n try {\n target.setPointerCapture(event.pointerId);\n } catch {\n // Pointer capture can fail if Safari has already cancelled the stream.\n }\n }\n\n private finishDirectPress(event: PointerEvent) {\n if (event.pointerId !== this.activePointerId) return;\n\n this.activePointerId = null;\n const remaining = Math.max(0, MINIMUM_DIRECT_PRESS_MS - (performance.now() - this.pressedAt));\n\n this.clearPressTimer();\n this.pressClearTimer = setTimeout(() => {\n this.pressClearTimer = null;\n this.pressedItemId = null;\n }, remaining);\n }\n\n private cancelDirectPress(event: PointerEvent) {\n if (event.pointerId !== this.activePointerId) return;\n this.clearPressedItem();\n }\n\n private pressHandlers(itemId: string) {\n return {\n onPointerDown: (event: PointerEvent) => this.beginDirectPress(itemId, event),\n onPointerUp: (event: PointerEvent) => this.finishDirectPress(event),\n onPointerCancel: (event: PointerEvent) => this.cancelDirectPress(event),\n onLostPointerCapture: (event: PointerEvent) => this.finishDirectPress(event),\n };\n }\n\n private destinationConfig(): DestinationConfig[] {\n const currentArea =\n this.primaryDestination === 'help' || this.activeDestination === 'help'\n ? {\n id: 'help' as const,\n icon: 'QuestionCircle',\n label: this.helpLabel,\n dot: false,\n }\n : {\n id: 'area' as const,\n icon: this.currentArea.icon || 'MapPage',\n label: this.currentArea.label || 'Area',\n dot: false,\n };\n\n const activity =\n this.activityMode === 'direct'\n ? {\n id: 'activity' as const,\n icon: 'Bell',\n label: this.activityLabel,\n dot: this.activityDot,\n }\n : {\n id: 'inbox' as const,\n icon: 'Inbox',\n label: this.inboxLabel,\n dot: this.inboxDot,\n };\n\n return [\n currentArea,\n { id: 'search', icon: 'MagnifyingGlass', label: this.searchLabel, dot: this.searchDot },\n activity,\n {\n id: 'messages',\n icon: 'MessageBubbleStack',\n label: this.messagesLabel,\n dot: this.messagesDot,\n },\n { id: 'agents', icon: 'AIAsterisk', label: this.agentsLabel, dot: this.agentsDot },\n ];\n }\n\n private selectDestination(destination: MobileDestination) {\n if (\n !shouldEmitMobileDestinationChange(this.activeDestination, destination, this.sheetNavExpanded)\n ) {\n return;\n }\n this.dsDestinationChange.emit({ destination });\n // Owners may replace the visible mobile stage (and transiently drop focus\n // in WebKit) while handling the controlled destination change.\n requestAnimationFrame(() => {\n void this.focusDestination(destination);\n });\n }\n\n @Method()\n async focusDestination(destination: MobileDestination | 'sheet-nav') {\n const target = this.el.querySelector<HTMLElement>(\n `#${\n destination === 'sheet-nav'\n ? 'ds-mobile-sheet-nav-trigger'\n : `ds-mobile-bar-${destination}-trigger`\n }`\n );\n target?.focus({ preventScroll: true });\n }\n\n private renderDestination(item: DestinationConfig) {\n const selected = !this.sheetNavExpanded && item.id === this.activeDestination;\n return (\n <button\n id={`ds-mobile-bar-${item.id}-trigger`}\n type=\"button\"\n class={{\n 'mobile-bar-nav__item': true,\n 'ds-control--md': true,\n 'mobile-bar-nav__item--selected': selected,\n 'mobile-bar-nav__item--pressed': this.pressedItemId === item.id,\n 'ds-focus-ring-inset': true,\n 'ds-interaction-fill': true,\n }}\n aria-label={item.label}\n aria-current={selected ? 'page' : undefined}\n onClick={() => this.selectDestination(item.id)}\n {...this.pressHandlers(item.id)}\n >\n <span class=\"mobile-bar-nav__icon ds-interaction-fill__content\">\n <ds-icon name={item.icon} size=\"md\" color=\"inherit\" />\n {item.dot && (\n <ds-badge class=\"mobile-bar-nav__dot\" variant=\"dot\" label=\"\" aria-hidden=\"true\" />\n )}\n </span>\n </button>\n );\n }\n\n render() {\n const [currentArea, ...tools] = this.destinationConfig();\n\n return (\n <Host>\n <nav class=\"mobile-bar-nav ds-chrome-row ds-chrome-space--md\" aria-label=\"Primary\">\n <div class=\"mobile-bar-nav__group mobile-bar-nav__group--context\">\n <button\n id=\"ds-mobile-sheet-nav-trigger\"\n type=\"button\"\n class={{\n 'mobile-bar-nav__item': true,\n 'ds-control--md': true,\n 'mobile-bar-nav__item--selected': this.sheetNavExpanded,\n 'mobile-bar-nav__item--pressed': this.pressedItemId === 'sheet-nav',\n 'ds-focus-ring-inset': true,\n 'ds-interaction-fill': true,\n }}\n aria-label={this.menuLabel}\n aria-expanded={String(this.sheetNavExpanded)}\n aria-controls=\"ds-mobile-sheet-nav\"\n onClick={() => this.dsSheetNavToggle.emit(!this.sheetNavExpanded)}\n {...this.pressHandlers('sheet-nav')}\n >\n <span class=\"mobile-bar-nav__icon ds-interaction-fill__content\">\n <ds-icon\n name={this.sheetNavExpanded ? 'Cross' : 'Hamburger'}\n size=\"md\"\n color=\"inherit\"\n />\n </span>\n </button>\n <span class=\"mobile-bar-nav__divider\" aria-hidden=\"true\" />\n {this.renderDestination(currentArea)}\n </div>\n <div class=\"mobile-bar-nav__group mobile-bar-nav__group--tools\">\n {tools.map(item => this.renderDestination(item))}\n </div>\n </nav>\n </Host>\n );\n }\n}\n",
|
|
457
457
|
"type": "registry:ui"
|
|
458
458
|
},
|
|
459
459
|
{
|
|
@@ -47,7 +47,7 @@
|
|
|
47
47
|
"Brand tone changes surface and foreground without changing geometry or interaction ownership."
|
|
48
48
|
],
|
|
49
49
|
"responsiveBehavior": [
|
|
50
|
-
"The primary
|
|
50
|
+
"The primary lane, subordinate lane, Mobile Sheet Nav header, and Mobile Bar Nav share a medium baseline; the global responsive profile supplies their mobile enlargement once.",
|
|
51
51
|
"Equal flexible side lanes keep the center mathematically aligned when actions are asymmetric; segmented headers allocate the usable lane in a 1:4:1 ratio."
|
|
52
52
|
],
|
|
53
53
|
"references": [
|
|
@@ -387,12 +387,12 @@
|
|
|
387
387
|
"files": [
|
|
388
388
|
{
|
|
389
389
|
"path": "src/wc/components/MobileHeader/MobileHeader.css",
|
|
390
|
-
"content": "@import '../../utils/chrome-layout.css';\n\n:host {\n display: block;\n min-width: 0;\n box-sizing: border-box;\n background: var(--color-background-primary);\n color: var(--color-foreground-primary);\n}\n\n:host([tone='brand']) {\n background: var(--color-background-bold-brand);\n color: var(--color-foreground-on-bold);\n}\n\n.mobile-header {\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%;\n min-width: 0;\n box-sizing: border-box;\n background: inherit;\n color: inherit;\n}\n\n.mobile-header__primary {\n grid-template-columns: minmax(var(--dimension-size-
|
|
390
|
+
"content": "@import '../../utils/chrome-layout.css';\n\n:host {\n display: block;\n min-width: 0;\n box-sizing: border-box;\n background: var(--color-background-primary);\n color: var(--color-foreground-primary);\n}\n\n:host([tone='brand']) {\n background: var(--color-background-bold-brand);\n color: var(--color-foreground-on-bold);\n}\n\n.mobile-header {\n position: relative;\n display: flex;\n flex-direction: column;\n width: 100%;\n min-width: 0;\n box-sizing: border-box;\n background: inherit;\n color: inherit;\n}\n\n.mobile-header__primary {\n grid-template-columns: minmax(var(--dimension-size-400), 1fr) minmax(0, auto) minmax(\n var(--dimension-size-400),\n 1fr\n );\n width: 100%;\n height: calc(var(--dimension-size-400) + var(--dimension-space-200));\n min-width: 0;\n background: inherit;\n color: inherit;\n}\n\n.mobile-header__primary--segmented {\n grid-template-columns:\n minmax(var(--dimension-size-400), 1fr)\n minmax(0, 4fr)\n minmax(var(--dimension-size-400), 1fr);\n}\n\n.mobile-header__primary--segmented .mobile-header__center,\n.mobile-header__primary--segmented ds-tab-group {\n width: 100%;\n}\n\n:host(:not([tone='brand'])) .mobile-header::after {\n content: '';\n position: absolute;\n inset-inline: 0;\n inset-block-end: 0;\n height: var(--dimension-stroke-width-012);\n background: var(--color-border-tertiary);\n pointer-events: none;\n}\n\n.mobile-header__subsections {\n display: flex;\n align-items: center;\n justify-content: center;\n min-width: 0;\n height: var(--dimension-size-600);\n padding: var(--dimension-space-100) var(--dimension-space-200);\n box-sizing: border-box;\n}\n\n.mobile-header__subsections ds-mobile-section-switcher {\n min-width: 0;\n max-width: 100%;\n}\n\n.mobile-header__lane {\n display: flex;\n align-items: center;\n gap: var(--dimension-space-050);\n min-width: 0;\n}\n\n.mobile-header__lane--leading {\n justify-content: flex-start;\n}\n\n.mobile-header__lane--trailing {\n justify-content: flex-end;\n}\n\n.mobile-header__center {\n position: relative;\n min-width: 0;\n max-width: 100%;\n justify-self: center;\n}\n\n.mobile-header__heading {\n display: block;\n min-width: 0;\n max-width: 100%;\n text-align: center;\n}\n\n.mobile-header__primary--inline-subsections .mobile-header__center,\n.mobile-header__primary--combined-subsections .mobile-header__center {\n display: flex;\n align-items: center;\n justify-content: center;\n gap: var(--dimension-space-100);\n width: 100%;\n}\n\n.mobile-header__combined-subsections {\n min-width: 0;\n max-width: 100%;\n}\n\n.mobile-header__primary--inline-subsections .mobile-header__heading {\n flex: 0 1 auto;\n width: auto;\n}\n\n.mobile-header__inline-subsections {\n flex: 0 1 auto;\n min-width: 0;\n max-width: 60%;\n}\n\n.mobile-header__semantic-heading {\n position: absolute;\n width: var(--dimension-stroke-width-012);\n height: var(--dimension-stroke-width-012);\n padding: 0;\n margin: calc(var(--dimension-stroke-width-012) * -1);\n overflow: hidden;\n clip: rect(0, 0, 0, 0);\n white-space: nowrap;\n border: 0;\n}\n\n::slotted([slot='leading']),\n::slotted([slot='trailing']) {\n flex: 0 0 auto;\n}\n",
|
|
391
391
|
"type": "registry:ui"
|
|
392
392
|
},
|
|
393
393
|
{
|
|
394
394
|
"path": "src/wc/components/MobileHeader/MobileHeader.tsx",
|
|
395
|
-
"content": "import { Component, Event, EventEmitter, h, Host, Prop } from '@stencil/core';\nimport { getSelectableTabs, type TabGroupItem, type TabItem } from '../TabGroup/tab-item-utils';\nimport type { TabGroupSize } from '../TabGroup/TabGroup';\nimport type {\n MobileHeaderHeadingLevel,\n MobileHeaderSectionsPresentation,\n MobileHeaderTone,\n} from './mobile-header-types';\n\n@Component({\n tag: 'ds-mobile-header',\n styleUrl: 'MobileHeader.css',\n scoped: true,\n})\nexport class MobileHeader {\n /** Static title used when no peer sections are supplied. */\n @Prop() heading: string = '';\n /** Semantic heading level for the active mobile screen. */\n @Prop() headingLevel: MobileHeaderHeadingLevel = 'h1';\n /** Controlled peer sections. Their selected label replaces the static title. */\n @Prop() sections: TabGroupItem[] = [];\n /** Controlled selected section id. */\n @Prop() value: string = '';\n /** Accessible name for the section chooser. */\n @Prop() sectionsAriaLabel: string = 'Change page section';\n /** Popup switcher or an inline segmented TabGroup for peer sections. */\n @Prop() sectionsPresentation: MobileHeaderSectionsPresentation = 'switcher';\n /** Density for the segmented sections presentation. */\n @Prop() sectionsSize: TabGroupSize = 'md';\n /** Controlled child sections within the selected page or detail screen. */\n @Prop() subsections: TabItem[] = [];\n /** Controlled selected child-section id. */\n @Prop() subvalue: string = '';\n /** Accessible name for the child-section chooser. */\n @Prop() subsectionsAriaLabel: string = 'Change page subsection';\n /** Place local sections below, beside, or combined with the page title in one sheet trigger. */\n @Prop() subsectionsPlacement: 'below' | 'inline' | 'combined' = 'below';\n /** Default page chrome or bold-brand workflow chrome. */\n @Prop({ reflect: true }) tone: MobileHeaderTone = 'default';\n\n /** Section selection intent. */\n @Event() dsSectionChange!: EventEmitter<string>;\n /** Child-section selection intent. */\n @Event() dsSubsectionChange!: EventEmitter<string>;\n\n private get resolvedSections(): TabGroupItem[] {\n return this.sections ?? [];\n }\n\n private get resolvedSubsections(): TabItem[] {\n return this.subsections ?? [];\n }\n\n private get selectedLabel(): string {\n const selectable = getSelectableTabs(this.resolvedSections);\n return (\n selectable.find(section => section.id === this.value)?.label ??\n selectable[0]?.label ??\n this.heading\n );\n }\n\n private handleSectionChange = (event: CustomEvent<string>) => {\n event.stopPropagation();\n this.dsSectionChange.emit(event.detail);\n };\n\n private handleSubsectionChange = (event: CustomEvent<string>) => {\n event.stopPropagation();\n this.dsSubsectionChange.emit(event.detail);\n };\n\n render() {\n const Heading = this.headingLevel;\n const selectable = getSelectableTabs(this.resolvedSections);\n const hasSwitcher = selectable.length > 1;\n const hasSubsections = getSelectableTabs(this.resolvedSubsections).length > 1;\n const hasSegmentedSections = hasSwitcher && this.sectionsPresentation === 'segmented';\n const inlineSubsections =\n hasSubsections && !hasSwitcher && this.subsectionsPlacement === 'inline';\n const combinedSubsections =\n hasSubsections && !hasSwitcher && this.subsectionsPlacement === 'combined';\n const foreground = this.tone === 'brand' ? 'on-bold' : 'primary';\n\n return (\n <Host>\n <header class=\"mobile-header\">\n <div\n class={{\n 'mobile-header__primary': true,\n 'mobile-header__primary--segmented': hasSegmentedSections,\n 'mobile-header__primary--inline-subsections': inlineSubsections,\n 'mobile-header__primary--combined-subsections': combinedSubsections,\n 'ds-chrome-grid': true,\n 'ds-chrome-space--md': true,\n }}\n >\n <div class=\"mobile-header__lane mobile-header__lane--leading\">\n <slot name=\"leading\" />\n </div>\n <div class=\"mobile-header__center\">\n {combinedSubsections ? (\n [\n <span\n class=\"mobile-header__semantic-heading\"\n role=\"heading\"\n aria-level={this.headingLevel === 'h1' ? '1' : '2'}\n >\n {this.selectedLabel}\n </span>,\n <nav\n class=\"mobile-header__combined-subsections\"\n aria-label={this.subsectionsAriaLabel}\n >\n <ds-mobile-section-switcher\n presentation=\"sheet\"\n pageLabel={this.selectedLabel}\n sections={this.resolvedSubsections}\n value={this.subvalue}\n navigationLabel={this.subsectionsAriaLabel}\n onDsChange={this.handleSubsectionChange}\n />\n </nav>,\n ]\n ) : hasSwitcher ? (\n [\n <span\n class=\"mobile-header__semantic-heading\"\n role=\"heading\"\n aria-level={this.headingLevel === 'h1' ? '1' : '2'}\n >\n {this.selectedLabel}\n </span>,\n this.sectionsPresentation === 'segmented' ? (\n <ds-tab-group\n tabs={this.resolvedSections}\n value={this.value}\n size={this.sectionsSize}\n width=\"fill\"\n ariaLabel={this.sectionsAriaLabel}\n onDsChange={this.handleSectionChange}\n />\n ) : (\n <ds-mobile-section-switcher\n presentation={hasSubsections ? 'menu' : 'sheet'}\n sections={this.resolvedSections}\n value={this.value}\n navigationLabel={this.sectionsAriaLabel}\n onDsChange={this.handleSectionChange}\n />\n ),\n ]\n ) : (\n <ds-text\n class=\"mobile-header__heading\"\n as={Heading}\n variant=\"text-body-
|
|
395
|
+
"content": "import { Component, Event, EventEmitter, h, Host, Prop } from '@stencil/core';\nimport { getSelectableTabs, type TabGroupItem, type TabItem } from '../TabGroup/tab-item-utils';\nimport type { TabGroupSize } from '../TabGroup/TabGroup';\nimport type {\n MobileHeaderHeadingLevel,\n MobileHeaderSectionsPresentation,\n MobileHeaderTone,\n} from './mobile-header-types';\n\n@Component({\n tag: 'ds-mobile-header',\n styleUrl: 'MobileHeader.css',\n scoped: true,\n})\nexport class MobileHeader {\n /** Static title used when no peer sections are supplied. */\n @Prop() heading: string = '';\n /** Semantic heading level for the active mobile screen. */\n @Prop() headingLevel: MobileHeaderHeadingLevel = 'h1';\n /** Controlled peer sections. Their selected label replaces the static title. */\n @Prop() sections: TabGroupItem[] = [];\n /** Controlled selected section id. */\n @Prop() value: string = '';\n /** Accessible name for the section chooser. */\n @Prop() sectionsAriaLabel: string = 'Change page section';\n /** Popup switcher or an inline segmented TabGroup for peer sections. */\n @Prop() sectionsPresentation: MobileHeaderSectionsPresentation = 'switcher';\n /** Density for the segmented sections presentation. */\n @Prop() sectionsSize: TabGroupSize = 'md';\n /** Controlled child sections within the selected page or detail screen. */\n @Prop() subsections: TabItem[] = [];\n /** Controlled selected child-section id. */\n @Prop() subvalue: string = '';\n /** Accessible name for the child-section chooser. */\n @Prop() subsectionsAriaLabel: string = 'Change page subsection';\n /** Place local sections below, beside, or combined with the page title in one sheet trigger. */\n @Prop() subsectionsPlacement: 'below' | 'inline' | 'combined' = 'below';\n /** Default page chrome or bold-brand workflow chrome. */\n @Prop({ reflect: true }) tone: MobileHeaderTone = 'default';\n\n /** Section selection intent. */\n @Event() dsSectionChange!: EventEmitter<string>;\n /** Child-section selection intent. */\n @Event() dsSubsectionChange!: EventEmitter<string>;\n\n private get resolvedSections(): TabGroupItem[] {\n return this.sections ?? [];\n }\n\n private get resolvedSubsections(): TabItem[] {\n return this.subsections ?? [];\n }\n\n private get selectedLabel(): string {\n const selectable = getSelectableTabs(this.resolvedSections);\n return (\n selectable.find(section => section.id === this.value)?.label ??\n selectable[0]?.label ??\n this.heading\n );\n }\n\n private handleSectionChange = (event: CustomEvent<string>) => {\n event.stopPropagation();\n this.dsSectionChange.emit(event.detail);\n };\n\n private handleSubsectionChange = (event: CustomEvent<string>) => {\n event.stopPropagation();\n this.dsSubsectionChange.emit(event.detail);\n };\n\n render() {\n const Heading = this.headingLevel;\n const selectable = getSelectableTabs(this.resolvedSections);\n const hasSwitcher = selectable.length > 1;\n const hasSubsections = getSelectableTabs(this.resolvedSubsections).length > 1;\n const hasSegmentedSections = hasSwitcher && this.sectionsPresentation === 'segmented';\n const inlineSubsections =\n hasSubsections && !hasSwitcher && this.subsectionsPlacement === 'inline';\n const combinedSubsections =\n hasSubsections && !hasSwitcher && this.subsectionsPlacement === 'combined';\n const foreground = this.tone === 'brand' ? 'on-bold' : 'primary';\n\n return (\n <Host>\n <header class=\"mobile-header\">\n <div\n class={{\n 'mobile-header__primary': true,\n 'mobile-header__primary--segmented': hasSegmentedSections,\n 'mobile-header__primary--inline-subsections': inlineSubsections,\n 'mobile-header__primary--combined-subsections': combinedSubsections,\n 'ds-chrome-grid': true,\n 'ds-chrome-space--md': true,\n }}\n >\n <div class=\"mobile-header__lane mobile-header__lane--leading\">\n <slot name=\"leading\" />\n </div>\n <div class=\"mobile-header__center\">\n {combinedSubsections ? (\n [\n <span\n class=\"mobile-header__semantic-heading\"\n role=\"heading\"\n aria-level={this.headingLevel === 'h1' ? '1' : '2'}\n >\n {this.selectedLabel}\n </span>,\n <nav\n class=\"mobile-header__combined-subsections\"\n aria-label={this.subsectionsAriaLabel}\n >\n <ds-mobile-section-switcher\n presentation=\"sheet\"\n pageLabel={this.selectedLabel}\n sections={this.resolvedSubsections}\n value={this.subvalue}\n navigationLabel={this.subsectionsAriaLabel}\n onDsChange={this.handleSubsectionChange}\n />\n </nav>,\n ]\n ) : hasSwitcher ? (\n [\n <span\n class=\"mobile-header__semantic-heading\"\n role=\"heading\"\n aria-level={this.headingLevel === 'h1' ? '1' : '2'}\n >\n {this.selectedLabel}\n </span>,\n this.sectionsPresentation === 'segmented' ? (\n <ds-tab-group\n tabs={this.resolvedSections}\n value={this.value}\n size={this.sectionsSize}\n width=\"fill\"\n ariaLabel={this.sectionsAriaLabel}\n onDsChange={this.handleSectionChange}\n />\n ) : (\n <ds-mobile-section-switcher\n presentation={hasSubsections ? 'menu' : 'sheet'}\n sections={this.resolvedSections}\n value={this.value}\n navigationLabel={this.sectionsAriaLabel}\n onDsChange={this.handleSectionChange}\n />\n ),\n ]\n ) : (\n <ds-text\n class=\"mobile-header__heading\"\n as={Heading}\n variant=\"text-body-medium\"\n emphasis\n color={foreground}\n lineTruncation={1}\n >\n {this.selectedLabel}\n </ds-text>\n )}\n {inlineSubsections ? this.renderSubsections() : null}\n </div>\n <div class=\"mobile-header__lane mobile-header__lane--trailing\">\n <slot name=\"trailing\" />\n </div>\n </div>\n {hasSubsections && !inlineSubsections && !combinedSubsections ? (\n <nav class=\"mobile-header__subsections\" aria-label={this.subsectionsAriaLabel}>\n <ds-mobile-section-switcher\n sections={this.resolvedSubsections}\n value={this.subvalue}\n navigationLabel={this.subsectionsAriaLabel}\n onDsChange={this.handleSubsectionChange}\n />\n </nav>\n ) : null}\n </header>\n </Host>\n );\n }\n\n private renderSubsections() {\n return (\n <nav class=\"mobile-header__inline-subsections\" aria-label={this.subsectionsAriaLabel}>\n <ds-mobile-section-switcher\n sections={this.resolvedSubsections}\n value={this.subvalue}\n navigationLabel={this.subsectionsAriaLabel}\n onDsChange={this.handleSubsectionChange}\n />\n </nav>\n );\n }\n}\n",
|
|
396
396
|
"type": "registry:ui"
|
|
397
397
|
},
|
|
398
398
|
{
|