@ds-mo/ui 12.7.0 → 12.8.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/.build-stamp +1 -1
- package/dist/agent-patterns.json +22 -9
- package/dist/agent.json +230 -18
- package/dist/angular/ds-button-filled.d.ts +1 -1
- package/dist/angular/ds-button-filled.js +3 -3
- package/dist/angular/ds-button-unfilled.d.ts +1 -1
- package/dist/angular/ds-button-unfilled.js +3 -3
- package/dist/angular/ds-card-overview.d.ts +19 -0
- package/dist/angular/ds-card-overview.js +42 -0
- package/dist/angular/index.d.ts +1 -1
- package/dist/angular/index.js +1 -0
- package/dist/angular/proxies.d.ts +1 -0
- package/dist/angular/proxies.js +1 -0
- package/dist/components/ds-agent-activity.js +1 -1
- package/dist/components/ds-agent-response.js +1 -1
- package/dist/components/ds-agent-source-list.js +1 -1
- package/dist/components/ds-agent-tool-call.js +1 -1
- package/dist/components/ds-attachment-list.js +1 -1
- package/dist/components/ds-badge.js +1 -1
- package/dist/components/ds-bar-nav.js +1 -1
- package/dist/components/ds-bar-title.js +1 -1
- package/dist/components/ds-bar-title.js.map +1 -1
- package/dist/components/ds-bar-workflow.js +1 -1
- package/dist/components/ds-breadcrumb.js +1 -1
- package/dist/components/ds-button-filled.js +1 -1
- package/dist/components/ds-button-unfilled.js +1 -1
- package/dist/components/ds-card-data-viz-bar.js +1 -1
- package/dist/components/ds-card-data-viz-bar.js.map +1 -1
- package/dist/components/ds-card-data-viz-donut.js +1 -1
- package/dist/components/ds-card-data-viz-donut.js.map +1 -1
- package/dist/components/ds-card-data-viz-line.js +1 -1
- package/dist/components/ds-card-data-viz-line.js.map +1 -1
- package/dist/components/ds-card-overview.d.ts +11 -0
- package/dist/components/ds-card-overview.js +2 -0
- package/dist/components/ds-card-overview.js.map +1 -0
- package/dist/components/ds-card-setting.js +1 -1
- package/dist/components/ds-card-shell-data-viz.js +1 -1
- package/dist/components/ds-chart-bar-stacked.js +1 -1
- package/dist/components/ds-chart-bar-stacked.js.map +1 -1
- package/dist/components/ds-chart-bar.js +1 -1
- package/dist/components/ds-chart-bar.js.map +1 -1
- package/dist/components/ds-chart-donut.js +1 -1
- package/dist/components/ds-chart-donut.js.map +1 -1
- package/dist/components/ds-chart-legend.js +1 -1
- package/dist/components/ds-chart-line.js +1 -1
- package/dist/components/ds-chart-line.js.map +1 -1
- package/dist/components/ds-checkbox.js +1 -1
- package/dist/components/ds-chip.js +1 -1
- package/dist/components/ds-code-block.js +1 -1
- package/dist/components/ds-conversation-list-item.js +1 -1
- package/dist/components/ds-conversation-list-item.js.map +1 -1
- package/dist/components/ds-conversation-list-section.js +1 -1
- package/dist/components/ds-conversation-list.js +1 -1
- package/dist/components/ds-conversation-list.js.map +1 -1
- package/dist/components/ds-divider.js +1 -1
- package/dist/components/ds-empty-state.js +1 -1
- package/dist/components/ds-field.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-bubble.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-message-scroller.js.map +1 -1
- package/dist/components/ds-message.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-modal.js.map +1 -1
- package/dist/components/ds-panel-nav.js +1 -1
- package/dist/components/ds-panel-nav.js.map +1 -1
- package/dist/components/ds-panel-sub-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-radio.js +1 -1
- package/dist/components/ds-scroll-overlay.js +1 -1
- package/dist/components/ds-scroll-overlay.js.map +1 -1
- package/dist/components/ds-select-multi.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-gradient-picker.js +1 -1
- package/dist/components/ds-shell-gradient-picker.js.map +1 -1
- package/dist/components/ds-shell-gradient-swatch.js +1 -1
- package/dist/components/ds-shell-page.js +1 -1
- package/dist/components/ds-shell-page.js.map +1 -1
- package/dist/components/ds-shell-tools.js +1 -1
- package/dist/components/ds-skeleton.js +1 -1
- package/dist/components/ds-skeleton.js.map +1 -1
- package/dist/components/ds-slider.js +1 -1
- package/dist/components/ds-slider.js.map +1 -1
- package/dist/components/ds-swatch-picker.js +1 -1
- package/dist/components/ds-switch.js +1 -1
- package/dist/components/ds-tab-group.js +1 -1
- package/dist/components/ds-tag.js +1 -1
- package/dist/components/ds-text.js +1 -1
- package/dist/components/ds-toast.js +1 -1
- package/dist/components/ds-toast.js.map +1 -1
- package/dist/components/ds-tooltip-data-viz.js +1 -1
- package/dist/components/ds-tooltip.js +1 -1
- package/dist/components/ds-typing-indicator.js +1 -1
- package/dist/components/p-2foAUsDN.js +2 -0
- package/dist/components/p-2foAUsDN.js.map +1 -0
- package/dist/components/{p-C9h13YnX.js → p-38PfdMkF.js} +2 -2
- package/dist/components/p-69lHhJiX.js +2 -0
- package/dist/components/p-69lHhJiX.js.map +1 -0
- package/dist/components/{p-CnAkobDW.js → p-B6NvVdeO.js} +2 -2
- package/dist/components/p-B6Vuayt6.js +2 -0
- package/dist/components/p-B6Vuayt6.js.map +1 -0
- package/dist/components/p-B6vE3ujd.js +2 -0
- package/dist/components/p-B6vE3ujd.js.map +1 -0
- package/dist/components/p-BBePKRHC.js +2 -0
- package/dist/components/p-BBePKRHC.js.map +1 -0
- package/dist/components/{p-BT5hgg5i.js → p-BFRMwl93.js} +2 -2
- package/dist/components/p-BKOlvCGS.js +2 -0
- package/dist/components/p-BKOlvCGS.js.map +1 -0
- package/dist/components/{p-C7bZ-jiU.js → p-BO5aqZcf.js} +2 -2
- package/dist/components/p-BX0BMhgz.js +2 -0
- package/dist/components/p-BX0BMhgz.js.map +1 -0
- package/dist/components/{p-C_9ZH0A3.js → p-BXaieghV.js} +2 -2
- package/dist/components/p-BlHzmszE.js +2 -0
- package/dist/components/{p-Bl001G0Y.js.map → p-BlHzmszE.js.map} +1 -1
- package/dist/components/p-By_s1wNN.js +2 -0
- package/dist/components/p-By_s1wNN.js.map +1 -0
- package/dist/components/p-CNgPwd11.js +2 -0
- package/dist/components/{p-1darPzys.js.map → p-CNgPwd11.js.map} +1 -1
- package/dist/components/{p-BcfC6phQ.js → p-CNjOyUQu.js} +2 -2
- package/dist/components/p-CNjOyUQu.js.map +1 -0
- package/dist/components/{p-k0Hmj1KV.js → p-CPwpgUWT.js} +2 -2
- package/dist/components/{p-DIa73GJP.js → p-CU2673IJ.js} +2 -2
- package/dist/components/{p-Crsn82rz.js → p-CsX3ICz7.js} +2 -2
- package/dist/components/{p-B-9A0e_0.js → p-D0e77tkd.js} +2 -2
- package/dist/components/p-D1bNvIpb.js +2 -0
- package/dist/components/p-D1bNvIpb.js.map +1 -0
- package/dist/components/{p-CvyTZ4Mg.js → p-D9u1_OEZ.js} +2 -2
- package/dist/components/{p-B_5nxOeB.js → p-DB9XJm2c.js} +2 -2
- package/dist/components/{p-CUAYsK44.js → p-DIFUsoM-.js} +2 -2
- package/dist/components/p-DhKvqh5G.js +2 -0
- package/dist/components/p-DhKvqh5G.js.map +1 -0
- package/dist/components/{p-guQ7hv6W.js → p-DhLmoCe_.js} +2 -2
- package/dist/components/{p-CqWV4Hl_.js → p-DmAHufd5.js} +2 -2
- package/dist/components/p-DzM3SzPz.js +2 -0
- package/dist/components/p-DzM3SzPz.js.map +1 -0
- package/dist/components/{p-DNFd4Ao-.js → p-G3qIxpQj.js} +2 -2
- package/dist/components/{p-C6xC48hG.js → p-Re2kPrXm.js} +2 -2
- package/dist/components/{p--eKXTylf.js → p-Utr23NC-.js} +2 -2
- package/dist/components/{p-5-QAFB4j.js → p-Vdufoj7G.js} +2 -2
- package/dist/components/{p-BuiZcSga.js → p-huw1oRbt.js} +2 -2
- package/dist/components/p-huw1oRbt.js.map +1 -0
- package/dist/components/p-ukB-dPOK.js +2 -0
- package/dist/components/{p-pgxlkvUy.js.map → p-ukB-dPOK.js.map} +1 -1
- package/dist/components/p-yhj1lIPx.js +2 -0
- package/dist/docs/components.json +599 -11
- package/dist/lib/components/Menu/menu-position.d.ts +6 -6
- package/dist/lib/toast/toast-types.d.ts +3 -3
- package/dist/lib/utils/anchored-position.d.ts +34 -0
- package/dist/lib/utils/choice-popup-alignment.d.ts +2 -2
- package/dist/lib/utils/index.d.ts +2 -0
- package/dist/lib/utils/index.js +31 -0
- package/dist/lib/utils/index.js.map +3 -3
- package/dist/lib/utils/metric-change.d.ts +52 -0
- package/dist/lib/utils/token-defaults.d.ts +3 -0
- package/dist/mcp-data/registry/agent-activity.json +1 -1
- package/dist/mcp-data/registry/agent-response.json +1 -1
- package/dist/mcp-data/registry/agent-source-list.json +1 -1
- package/dist/mcp-data/registry/agent-tool-call.json +1 -1
- package/dist/mcp-data/registry/attachment-list.json +1 -1
- package/dist/mcp-data/registry/avatar.json +1 -1
- package/dist/mcp-data/registry/badge.json +1 -1
- package/dist/mcp-data/registry/bar-nav.json +1 -1
- package/dist/mcp-data/registry/bar-title.json +2 -2
- package/dist/mcp-data/registry/bar-workflow.json +1 -1
- package/dist/mcp-data/registry/breadcrumb.json +1 -1
- package/dist/mcp-data/registry/button-filled.json +122 -6
- package/dist/mcp-data/registry/button-unfilled.json +49 -5
- package/dist/mcp-data/registry/card-data-viz-bar.json +2 -2
- package/dist/mcp-data/registry/card-data-viz-donut.json +2 -1
- package/dist/mcp-data/registry/card-data-viz-line.json +2 -2
- package/dist/mcp-data/registry/card-overview.json +317 -0
- package/dist/mcp-data/registry/card-setting.json +1 -1
- package/dist/mcp-data/registry/card-shell-data-viz.json +1 -1
- package/dist/mcp-data/registry/chart-bar-stacked.json +14 -9
- package/dist/mcp-data/registry/chart-bar.json +15 -10
- package/dist/mcp-data/registry/chart-donut.json +3 -2
- package/dist/mcp-data/registry/chart-legend.json +1 -1
- package/dist/mcp-data/registry/chart-line.json +15 -10
- package/dist/mcp-data/registry/checkbox.json +1 -1
- package/dist/mcp-data/registry/chip.json +1 -1
- package/dist/mcp-data/registry/code-block.json +1 -1
- package/dist/mcp-data/registry/conversation-list-item.json +1 -1
- package/dist/mcp-data/registry/conversation-list-section.json +1 -1
- package/dist/mcp-data/registry/conversation-list.json +1 -1
- package/dist/mcp-data/registry/divider.json +1 -1
- package/dist/mcp-data/registry/empty-state.json +1 -1
- package/dist/mcp-data/registry/field.json +1 -1
- package/dist/mcp-data/registry/icon.json +1 -1
- package/dist/mcp-data/registry/input.json +1 -1
- package/dist/mcp-data/registry/loader.json +1 -1
- package/dist/mcp-data/registry/markdown.json +1 -1
- package/dist/mcp-data/registry/menu.json +3 -3
- package/dist/mcp-data/registry/message-bubble.json +1 -1
- package/dist/mcp-data/registry/message-composer.json +1 -1
- package/dist/mcp-data/registry/message-scroller.json +1 -1
- package/dist/mcp-data/registry/message.json +1 -1
- package/dist/mcp-data/registry/mobile-bar-nav.json +1 -1
- package/dist/mcp-data/registry/mobile-header.json +1 -1
- package/dist/mcp-data/registry/mobile-section-switcher.json +1 -1
- package/dist/mcp-data/registry/mobile-sheet-nav.json +1 -1
- package/dist/mcp-data/registry/modal.json +1 -1
- package/dist/mcp-data/registry/panel-nav.json +1 -1
- package/dist/mcp-data/registry/panel-sub-nav.json +1 -1
- package/dist/mcp-data/registry/panel-tool-header.json +1 -1
- package/dist/mcp-data/registry/panel-tool-search.json +1 -1
- package/dist/mcp-data/registry/panel-tools.json +3 -2
- package/dist/mcp-data/registry/radio.json +1 -1
- package/dist/mcp-data/registry/registry.json +233 -18
- package/dist/mcp-data/registry/scroll-overlay.json +1 -1
- package/dist/mcp-data/registry/select-multi.json +1 -1
- package/dist/mcp-data/registry/select.json +1 -1
- package/dist/mcp-data/registry/shell-app.json +1 -1
- package/dist/mcp-data/registry/shell-gradient-picker.json +1 -1
- package/dist/mcp-data/registry/shell-gradient-swatch.json +1 -1
- package/dist/mcp-data/registry/shell-page.json +5 -4
- package/dist/mcp-data/registry/shell-tools.json +1 -1
- package/dist/mcp-data/registry/skeleton.json +1 -1
- package/dist/mcp-data/registry/slider.json +1 -1
- package/dist/mcp-data/registry/swatch-picker.json +1 -1
- package/dist/mcp-data/registry/switch.json +1 -1
- package/dist/mcp-data/registry/tab-group.json +1 -1
- package/dist/mcp-data/registry/tag.json +1 -1
- package/dist/mcp-data/registry/text.json +1 -1
- package/dist/mcp-data/registry/toast.json +2 -2
- package/dist/mcp-data/registry/tooltip-data-viz.json +3 -1
- package/dist/mcp-data/registry/tooltip.json +3 -8
- package/dist/mcp-data/registry/typing-indicator.json +1 -1
- package/dist/react/components.d.ts +1 -0
- package/dist/react/components.js +1 -0
- package/dist/react/ds-card-overview.d.ts +12 -0
- package/dist/react/ds-card-overview.js +12 -0
- package/dist/styles/control-elevation.css +11 -0
- package/dist/types/components/ButtonFilled/ButtonFilled.d.ts +32 -0
- package/dist/types/components/ButtonUnfilled/ButtonUnfilled.d.ts +20 -0
- package/dist/types/components/CardOverview/CardOverview.d.ts +59 -0
- package/dist/types/components/CardOverview/card-overview-types.d.ts +32 -0
- package/dist/types/components/CardOverview/index.d.ts +2 -0
- package/dist/types/components/ChartBar/ChartBar.d.ts +11 -0
- package/dist/types/components/ChartBarStacked/ChartBarStacked.d.ts +11 -0
- package/dist/types/components/ChartLine/ChartLine.d.ts +11 -0
- package/dist/types/components/Menu/Menu.d.ts +7 -4
- package/dist/types/components/Menu/menu-position.d.ts +6 -6
- package/dist/types/components/ShellPage/ShellPage.d.ts +1 -0
- package/dist/types/components/Tooltip/Tooltip.d.ts +1 -4
- package/dist/types/components.d.ts +212 -2
- package/dist/types/toast/toast-types.d.ts +3 -3
- package/dist/types/utils/anchored-position-controller.d.ts +96 -0
- package/dist/types/utils/anchored-position.d.ts +35 -0
- package/dist/types/utils/cartesian-chart-layout.d.ts +13 -0
- package/dist/types/utils/chart-viewport-controller.d.ts +21 -0
- package/dist/types/utils/choice-popup-alignment.d.ts +2 -2
- package/dist/types/utils/index.d.ts +2 -0
- package/dist/types/utils/metric-change.d.ts +53 -0
- package/dist/types/utils/select-controller.d.ts +3 -3
- package/dist/types/utils/token-defaults.d.ts +3 -0
- package/package.json +3 -3
- package/dist/components/p-1darPzys.js +0 -2
- package/dist/components/p-B8C_yfLQ.js +0 -2
- package/dist/components/p-B8C_yfLQ.js.map +0 -1
- package/dist/components/p-BcfC6phQ.js.map +0 -1
- package/dist/components/p-Bl001G0Y.js +0 -2
- package/dist/components/p-BuiZcSga.js.map +0 -1
- package/dist/components/p-Bv9frkvW.js +0 -2
- package/dist/components/p-Bv9frkvW.js.map +0 -1
- package/dist/components/p-C0-K8vnW.js +0 -2
- package/dist/components/p-C0-K8vnW.js.map +0 -1
- package/dist/components/p-C2ym_Ich.js +0 -2
- package/dist/components/p-C_ewSXTT.js +0 -2
- package/dist/components/p-C_ewSXTT.js.map +0 -1
- package/dist/components/p-Ca9-fnFl.js +0 -2
- package/dist/components/p-Ca9-fnFl.js.map +0 -1
- package/dist/components/p-CydY-v7X.js +0 -2
- package/dist/components/p-CydY-v7X.js.map +0 -1
- package/dist/components/p-De7NquTI.js +0 -2
- package/dist/components/p-De7NquTI.js.map +0 -1
- package/dist/components/p-Dg3xLna0.js +0 -2
- package/dist/components/p-Dg3xLna0.js.map +0 -1
- package/dist/components/p-pgxlkvUy.js +0 -2
- package/dist/lib/utils/anchored-popup.d.ts +0 -23
- package/dist/types/components/Tooltip/tooltip-position.d.ts +0 -21
- package/dist/types/utils/anchored-popup.d.ts +0 -24
- /package/dist/components/{p-C9h13YnX.js.map → p-38PfdMkF.js.map} +0 -0
- /package/dist/components/{p-CnAkobDW.js.map → p-B6NvVdeO.js.map} +0 -0
- /package/dist/components/{p-BT5hgg5i.js.map → p-BFRMwl93.js.map} +0 -0
- /package/dist/components/{p-C7bZ-jiU.js.map → p-BO5aqZcf.js.map} +0 -0
- /package/dist/components/{p-C_9ZH0A3.js.map → p-BXaieghV.js.map} +0 -0
- /package/dist/components/{p-k0Hmj1KV.js.map → p-CPwpgUWT.js.map} +0 -0
- /package/dist/components/{p-DIa73GJP.js.map → p-CU2673IJ.js.map} +0 -0
- /package/dist/components/{p-Crsn82rz.js.map → p-CsX3ICz7.js.map} +0 -0
- /package/dist/components/{p-B-9A0e_0.js.map → p-D0e77tkd.js.map} +0 -0
- /package/dist/components/{p-CvyTZ4Mg.js.map → p-D9u1_OEZ.js.map} +0 -0
- /package/dist/components/{p-B_5nxOeB.js.map → p-DB9XJm2c.js.map} +0 -0
- /package/dist/components/{p-CUAYsK44.js.map → p-DIFUsoM-.js.map} +0 -0
- /package/dist/components/{p-guQ7hv6W.js.map → p-DhLmoCe_.js.map} +0 -0
- /package/dist/components/{p-CqWV4Hl_.js.map → p-DmAHufd5.js.map} +0 -0
- /package/dist/components/{p-DNFd4Ao-.js.map → p-G3qIxpQj.js.map} +0 -0
- /package/dist/components/{p-C6xC48hG.js.map → p-Re2kPrXm.js.map} +0 -0
- /package/dist/components/{p--eKXTylf.js.map → p-Utr23NC-.js.map} +0 -0
- /package/dist/components/{p-5-QAFB4j.js.map → p-Vdufoj7G.js.map} +0 -0
- /package/dist/components/{p-C2ym_Ich.js.map → p-yhj1lIPx.js.map} +0 -0
|
@@ -708,7 +708,7 @@
|
|
|
708
708
|
"complexPropertyNote": "Assign these non-primitive values as JavaScript properties: options, sections.",
|
|
709
709
|
"peerDependencies": {
|
|
710
710
|
"required": [
|
|
711
|
-
"@ds-mo/tokens ^
|
|
711
|
+
"@ds-mo/tokens ^5.0.0",
|
|
712
712
|
"@ds-mo/icons ^6.4.0"
|
|
713
713
|
],
|
|
714
714
|
"frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
|
|
@@ -65,7 +65,8 @@
|
|
|
65
65
|
"A ShellPage-managed BarTitle remains concealed until headerCapacity is available and its rendered variant matches the resolved presentation, preventing a default-variant flash during route activation.",
|
|
66
66
|
"responsiveMode=mobile hides and inerts the desktop header, exposes mobile-header, and disables desktop scroll-compaction geometry.",
|
|
67
67
|
"Update headerCapacity synchronously with shell layout state so opening tools or crossing application breakpoints cannot briefly render the wrong presentation.",
|
|
68
|
-
"
|
|
68
|
+
"The default content inset is 32px in desktop mode and 16px in tablet and mobile modes, while preserving the larger bottom scroll space.",
|
|
69
|
+
"On roomy pages, the expanded header scrolls upward until its title and action row reaches the compact row position, while its bottom divider progressively extends from the expanded inset to edge-to-edge; it then snaps variants without moving that shared visual anchor.",
|
|
69
70
|
"Scroll compaction preserves the expanded page flow offset so content never jumps beneath the sticky header.",
|
|
70
71
|
"ShellPage relies on ShellApp's content scroller and never introduces nested scrolling.",
|
|
71
72
|
"BarWorkflow remains compact and is not assigned BarTitle's capacity or scroll variants."
|
|
@@ -203,7 +204,7 @@
|
|
|
203
204
|
},
|
|
204
205
|
"peerDependencies": {
|
|
205
206
|
"required": [
|
|
206
|
-
"@ds-mo/tokens ^
|
|
207
|
+
"@ds-mo/tokens ^5.0.0",
|
|
207
208
|
"@ds-mo/icons ^6.4.0"
|
|
208
209
|
],
|
|
209
210
|
"frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
|
|
@@ -218,12 +219,12 @@
|
|
|
218
219
|
"files": [
|
|
219
220
|
{
|
|
220
221
|
"path": "src/wc/components/ShellPage/ShellPage.css",
|
|
221
|
-
"content": ":host {\n display: block;\n position: relative;\n min-width: 0;\n min-height: 100%;\n box-sizing: border-box;\n background: var(--color-background-primary);\n}\n\n.shell-page__scroll-sentinel {\n position: absolute;\n inset-block-start: var(--ds-shell-page-header-travel, 0);\n inset-inline-start: 0;\n width: var(--dimension-stroke-width-012);\n height: var(--dimension-stroke-width-012);\n pointer-events: none;\n}\n\n.shell-page__sticky-header {\n position: sticky;\n inset-block-start: var(--ds-shell-page-sticky-offset, 0);\n z-index: var(--dimension-z-index-raised);\n min-width: 0;\n background: var(--color-background-primary);\n overflow: visible;\n overflow-anchor: none;\n}\n\n.shell-page__desktop-header ::slotted(ds-bar-title),\n.shell-page__desktop-header ::slotted(ds-bar-workflow),\n.shell-page__mobile-header ::slotted(ds-mobile-header) {\n display: block;\n min-width: 0;\n}\n\n.shell-page__desktop-header[hidden],\n.shell-page__mobile-header[hidden] {\n display: none;\n}\n\n:host(.shell-page-host--mobile) .shell-page__scroll-sentinel,\n:host(.shell-page-host--mobile) .shell-page__flow-spacer {\n display: none;\n}\n\n.shell-page__flow-spacer {\n height: var(--ds-shell-page-flow-spacer, 0);\n background: var(--color-background-primary);\n}\n\n.shell-page__content {\n min-width: 0;\n box-sizing: border-box;\n background: var(--color-background-primary);\n}\n\n:host(.shell-page-host--inset-default) .shell-page__content {\n padding: var(--dimension-space-400) var(--dimension-space-400) var(--dimension-space-800);\n}\n\n:host(.shell-page-host--inset-none) .shell-page__content {\n padding: 0;\n}\n",
|
|
222
|
+
"content": ":host {\n display: block;\n position: relative;\n min-width: 0;\n min-height: 100%;\n box-sizing: border-box;\n background: var(--color-background-primary);\n}\n\n.shell-page__scroll-sentinel {\n position: absolute;\n inset-block-start: var(--ds-shell-page-header-travel, 0);\n inset-inline-start: 0;\n width: var(--dimension-stroke-width-012);\n height: var(--dimension-stroke-width-012);\n pointer-events: none;\n}\n\n.shell-page__sticky-header {\n position: sticky;\n inset-block-start: var(--ds-shell-page-sticky-offset, 0);\n z-index: var(--dimension-z-index-raised);\n min-width: 0;\n background: var(--color-background-primary);\n overflow: visible;\n overflow-anchor: none;\n}\n\n.shell-page__desktop-header ::slotted(ds-bar-title),\n.shell-page__desktop-header ::slotted(ds-bar-workflow),\n.shell-page__mobile-header ::slotted(ds-mobile-header) {\n display: block;\n min-width: 0;\n}\n\n.shell-page__desktop-header[hidden],\n.shell-page__mobile-header[hidden] {\n display: none;\n}\n\n:host(.shell-page-host--mobile) .shell-page__scroll-sentinel,\n:host(.shell-page-host--mobile) .shell-page__flow-spacer {\n display: none;\n}\n\n.shell-page__flow-spacer {\n height: var(--ds-shell-page-flow-spacer, 0);\n background: var(--color-background-primary);\n}\n\n.shell-page__content {\n min-width: 0;\n box-sizing: border-box;\n background: var(--color-background-primary);\n}\n\n:host(.shell-page-host--inset-default) .shell-page__content {\n padding: var(--dimension-space-400) var(--dimension-space-400) var(--dimension-space-800);\n}\n\n:host(.shell-page-host--inset-default[responsive-mode='tablet']) .shell-page__content,\n:host(.shell-page-host--inset-default[responsive-mode='mobile']) .shell-page__content {\n padding: var(--dimension-space-200) var(--dimension-space-200) var(--dimension-space-800);\n}\n\n:host(.shell-page-host--inset-none) .shell-page__content {\n padding: 0;\n}\n",
|
|
222
223
|
"type": "registry:ui"
|
|
223
224
|
},
|
|
224
225
|
{
|
|
225
226
|
"path": "src/wc/components/ShellPage/ShellPage.tsx",
|
|
226
|
-
"content": "import { Component, Element, h, Host, Prop, State, Watch } from '@stencil/core';\nimport type { ShellResponsiveMode } from '../../shell/shell-responsive';\nimport { resolveCssLengthPx } from '../../utils/resolve-css-length-px';\nimport type { BarTitleVariant } from '../BarTitle/bar-title-types';\nimport { resolveShellPageHeaderVariant } from './shell-page-responsive';\nimport type {\n ShellPageCapacity,\n ShellPageContentInset,\n ShellPageHeaderPresentation,\n} from './shell-page-types';\n\ntype BarTitleElement = HTMLElement & { variant: BarTitleVariant };\n\n@Component({\n tag: 'ds-shell-page',\n styleUrl: 'ShellPage.css',\n scoped: true,\n})\nexport class ShellPage {\n private static readonly MAX_HEADER_REVEAL_FRAMES = 12;\n private static readonly MAX_HEADER_GEOMETRY_FRAMES = 12;\n\n @Element() el!: HTMLElement;\n\n /** Automatic capacity/scroll behavior, or an explicit header variant override. */\n @Prop() headerPresentation: ShellPageHeaderPresentation = 'auto';\n\n /** Available page-header capacity supplied by the owning application shell. */\n @Prop() headerCapacity?: ShellPageCapacity;\n\n /** Standard page gutters, or no inset for full-bleed page content. */\n @Prop() contentInset: ShellPageContentInset = 'default';\n\n /** Explicit shell breakpoint presentation. Mobile uses the dedicated mobile-header slot. */\n @Prop({ attribute: 'responsive-mode', reflect: true })\n responsiveMode: ShellResponsiveMode = 'desktop';\n\n @State() private pageTopVisible = true;\n\n private sentinelEl: HTMLElement | null = null;\n private spacerEl: HTMLElement | null = null;\n private stickyHeaderEl: HTMLElement | null = null;\n private headerEl: BarTitleElement | null = null;\n private expandedHeaderHeight = 0;\n private headerTravel = 0;\n private expandedGeometryFrozen = false;\n private headerResizeObserver: ResizeObserver | null = null;\n private headerMutationObserver: MutationObserver | null = null;\n private headerRevealFrame: number | null = null;\n private headerGeometryFrame: number | null = null;\n private scrollRoot: HTMLElement | Window | null = null;\n\n @Watch('headerCapacity')\n @Watch('headerPresentation')\n handleHeaderContractChange() {\n this.syncHeaderVariant(true);\n }\n\n @Watch('responsiveMode')\n handleResponsiveModeChange() {\n this.observeHeader(this.responsiveMode === 'mobile' ? null : this.findHeader());\n }\n\n componentDidLoad() {\n this.observeHeader(this.responsiveMode === 'mobile' ? null : this.findHeader());\n this.connectScrollRoot();\n }\n\n componentDidRender() {\n this.syncHeaderVariant();\n }\n\n disconnectedCallback() {\n this.headerResizeObserver?.disconnect();\n this.headerResizeObserver = null;\n this.headerMutationObserver?.disconnect();\n this.headerMutationObserver = null;\n this.cancelHeaderReveal();\n this.cancelHeaderGeometrySync();\n this.scrollRoot?.removeEventListener('scroll', this.handleScroll);\n this.scrollRoot = null;\n }\n\n private get effectiveVariant(): BarTitleVariant {\n return resolveShellPageHeaderVariant(\n this.headerPresentation,\n this.headerCapacity ?? 'roomy',\n this.pageTopVisible\n );\n }\n\n private get headerContractResolved(): boolean {\n return this.headerPresentation !== 'auto' || this.headerCapacity !== undefined;\n }\n\n private get isScrollCompacted(): boolean {\n return (\n this.headerPresentation === 'auto' && this.headerCapacity === 'roomy' && !this.pageTopVisible\n );\n }\n\n private composedParent(element: Element): Element | null {\n return (element as HTMLElement).assignedSlot?.parentElement ?? element.parentElement;\n }\n\n private findScrollRoot(): HTMLElement | Window {\n let ancestor = this.composedParent(this.el);\n while (ancestor && ancestor !== document.documentElement) {\n const overflow = getComputedStyle(ancestor).overflowY;\n if (overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay') {\n return ancestor as HTMLElement;\n }\n ancestor = this.composedParent(ancestor);\n }\n return window;\n }\n\n private connectScrollRoot() {\n const next = this.findScrollRoot();\n if (next === this.scrollRoot) return;\n this.scrollRoot?.removeEventListener('scroll', this.handleScroll);\n this.scrollRoot = next;\n this.scrollRoot.addEventListener('scroll', this.handleScroll, { passive: true });\n this.handleScroll();\n }\n\n private handleScroll = () => {\n if (!this.sentinelEl || !this.scrollRoot) return;\n const rootTop =\n this.scrollRoot === window ? 0 : (this.scrollRoot as HTMLElement).getBoundingClientRect().top;\n const nextPageTopVisible = this.sentinelEl.getBoundingClientRect().top >= rootTop;\n if (\n !nextPageTopVisible &&\n this.pageTopVisible &&\n this.headerEl?.classList.contains(this.variantClass('expanded'))\n ) {\n this.captureExpandedHeaderGeometry(this.headerEl);\n this.expandedGeometryFrozen = true;\n }\n if (\n nextPageTopVisible &&\n this.expandedGeometryFrozen &&\n this.el.getBoundingClientRect().top >= rootTop\n ) {\n this.expandedGeometryFrozen = false;\n if (this.headerEl?.classList.contains(this.variantClass('expanded'))) {\n this.captureExpandedHeaderGeometry(this.headerEl);\n }\n }\n this.pageTopVisible = nextPageTopVisible;\n };\n\n private findHeader(): BarTitleElement | null {\n return this.el.querySelector<BarTitleElement>('ds-bar-title');\n }\n\n private get compactHeaderHeight(): number {\n return resolveCssLengthPx('--dimension-size-600', 0);\n }\n\n private setHeaderTravel(distance: number) {\n const next = Math.max(0, distance);\n if (Math.abs(next - this.headerTravel) < 0.5) return;\n this.headerTravel = next;\n this.el.style.setProperty('--ds-shell-page-header-travel', `${next}px`);\n this.handleScroll();\n }\n\n private measureHeaderTravel(header: BarTitleElement) {\n const anchor = header.querySelector<HTMLElement>('[data-shell-page-header-anchor]');\n const compactHeight = this.compactHeaderHeight;\n if (!anchor || compactHeight <= 0) return;\n\n const headerRect = header.getBoundingClientRect();\n const anchorRect = anchor.getBoundingClientRect();\n if (headerRect.height <= 0 || anchorRect.height <= 0) return;\n\n const expandedAnchorTop = anchorRect.top - headerRect.top;\n const compactAnchorTop = Math.max(0, (compactHeight - anchorRect.height) / 2);\n this.setHeaderTravel(expandedAnchorTop - compactAnchorTop);\n }\n\n private captureExpandedHeaderGeometry(header: BarTitleElement) {\n this.expandedHeaderHeight = header.getBoundingClientRect().height;\n this.measureHeaderTravel(header);\n }\n\n private setSpacerHeight(height: number) {\n if (!this.spacerEl) return;\n this.spacerEl.style.setProperty('--ds-shell-page-flow-spacer', `${Math.max(0, height)}px`);\n }\n\n private variantClass(variant: BarTitleVariant): string {\n return `bar-title-host--${variant}`;\n }\n\n private setStickyHeaderOffset(renderedVariant: BarTitleVariant) {\n this.stickyHeaderEl?.style.setProperty(\n '--ds-shell-page-sticky-offset',\n renderedVariant === 'expanded' &&\n this.headerPresentation === 'auto' &&\n this.headerCapacity === 'roomy'\n ? `${-this.headerTravel}px`\n : '0px'\n );\n }\n\n private syncRenderedHeaderGeometry(header: BarTitleElement) {\n const renderedVariant = this.effectiveVariant;\n if (!header.classList.contains(this.variantClass(renderedVariant))) return;\n\n // Update both flow-preservation pieces before reading layout. Otherwise a\n // reverse compact → expanded render can briefly contain the tall header and\n // the compact spacer together, giving browser scroll anchoring a transient\n // height change to compensate.\n this.setSpacerHeight(\n this.isScrollCompacted ? this.expandedHeaderHeight - this.compactHeaderHeight : 0\n );\n this.setStickyHeaderOffset(renderedVariant);\n\n if (renderedVariant === 'expanded' && !this.expandedGeometryFrozen) {\n this.captureExpandedHeaderGeometry(header);\n this.setStickyHeaderOffset(renderedVariant);\n }\n }\n\n private cancelHeaderGeometrySync() {\n if (this.headerGeometryFrame !== null) cancelAnimationFrame(this.headerGeometryFrame);\n this.headerGeometryFrame = null;\n }\n\n private scheduleHeaderGeometrySync(header: BarTitleElement) {\n this.cancelHeaderGeometrySync();\n let framesRemaining = ShellPage.MAX_HEADER_GEOMETRY_FRAMES;\n let framesElapsed = 0;\n const sync = () => {\n if (this.headerEl !== header) return;\n framesRemaining -= 1;\n framesElapsed += 1;\n // In WebKit a child's componentDidLoad can precede its assignment into\n // the parent's rendered slot. Revalidate after ShellApp has had a frame\n // to expose the actual content scroller.\n this.connectScrollRoot();\n this.syncRenderedHeaderGeometry(header);\n const geometryReady =\n this.effectiveVariant !== 'expanded' ||\n (this.expandedHeaderHeight > 0 && this.headerTravel > 0);\n if ((geometryReady && framesElapsed >= 2) || framesRemaining <= 0) {\n this.headerGeometryFrame = null;\n return;\n }\n this.headerGeometryFrame = requestAnimationFrame(sync);\n };\n this.headerGeometryFrame = requestAnimationFrame(sync);\n }\n\n private cancelHeaderReveal() {\n if (this.headerRevealFrame !== null) cancelAnimationFrame(this.headerRevealFrame);\n this.headerRevealFrame = null;\n this.headerEl?.removeAttribute('data-shell-page-syncing');\n }\n\n private revealHeaderWhenSynced(header: BarTitleElement, variant: BarTitleVariant) {\n // Stencil normally re-renders within a frame or two of a prop change. Stop\n // polling at the cap, but fail closed: a stale variant must never paint.\n let framesRemaining = ShellPage.MAX_HEADER_REVEAL_FRAMES;\n const reveal = () => {\n if (this.headerEl !== header) {\n header.removeAttribute('data-shell-page-syncing');\n return;\n }\n framesRemaining -= 1;\n if (header.classList.contains(this.variantClass(variant))) {\n header.removeAttribute('data-shell-page-syncing');\n this.headerRevealFrame = null;\n return;\n }\n if (framesRemaining <= 0) {\n this.headerRevealFrame = null;\n return;\n }\n this.headerRevealFrame = requestAnimationFrame(reveal);\n };\n this.headerRevealFrame = requestAnimationFrame(reveal);\n }\n\n private syncHeaderVariant(concealUntilSynced = false) {\n const header = this.headerEl;\n if (!header) return;\n\n if (!this.headerContractResolved) {\n this.cancelHeaderReveal();\n header.setAttribute('data-shell-page-syncing', '');\n return;\n }\n\n const next = this.effectiveVariant;\n const renderedVariantIsStale = !header.classList.contains(this.variantClass(next));\n if (!renderedVariantIsStale) this.syncRenderedHeaderGeometry(header);\n if (concealUntilSynced) {\n this.cancelHeaderReveal();\n header.setAttribute('data-shell-page-syncing', '');\n }\n if (header.variant !== next) header.variant = next;\n if (concealUntilSynced) {\n this.revealHeaderWhenSynced(header, next);\n }\n }\n\n private observeHeader(header: BarTitleElement | null) {\n this.cancelHeaderReveal();\n this.cancelHeaderGeometrySync();\n this.headerResizeObserver?.disconnect();\n this.headerResizeObserver = null;\n this.headerMutationObserver?.disconnect();\n this.headerMutationObserver = null;\n this.headerEl = header;\n this.expandedHeaderHeight = 0;\n this.expandedGeometryFrozen = false;\n this.setHeaderTravel(0);\n this.setSpacerHeight(0);\n this.stickyHeaderEl?.style.setProperty('--ds-shell-page-sticky-offset', '0px');\n\n if (!header) return;\n if (header.classList.contains(this.variantClass('expanded'))) {\n this.captureExpandedHeaderGeometry(header);\n }\n if (typeof ResizeObserver !== 'undefined') {\n this.headerResizeObserver = new ResizeObserver(() => {\n this.syncRenderedHeaderGeometry(header);\n });\n this.headerResizeObserver.observe(header);\n }\n if (typeof MutationObserver !== 'undefined') {\n this.headerMutationObserver = new MutationObserver(() => {\n this.syncRenderedHeaderGeometry(header);\n });\n this.headerMutationObserver.observe(header, {\n attributes: true,\n attributeFilter: ['class'],\n });\n }\n this.syncHeaderVariant(true);\n this.scheduleHeaderGeometrySync(header);\n }\n\n private handleHeaderSlotChange = (event: Event) => {\n if (this.responsiveMode === 'mobile') return;\n const slot = event.target as HTMLSlotElement;\n const header = slot\n .assignedElements()\n .find(element => element.tagName.toLowerCase() === 'ds-bar-title');\n this.observeHeader((header as BarTitleElement | undefined) ?? null);\n };\n\n render() {\n const mobile = this.responsiveMode === 'mobile';\n return (\n <Host\n role=\"main\"\n class={{\n 'shell-page-host--inset-default': this.contentInset === 'default',\n 'shell-page-host--inset-none': this.contentInset === 'none',\n 'shell-page-host--mobile': mobile,\n [`shell-page-host--header-${this.effectiveVariant}`]: true,\n }}\n >\n <div\n ref={el => {\n this.sentinelEl = el ?? null;\n }}\n class=\"shell-page__scroll-sentinel\"\n aria-hidden=\"true\"\n />\n <div\n ref={el => {\n this.stickyHeaderEl = el ?? null;\n }}\n class=\"shell-page__sticky-header\"\n >\n <div\n class=\"shell-page__desktop-header\"\n hidden={mobile}\n aria-hidden={mobile ? 'true' : undefined}\n inert={mobile ? true : undefined}\n >\n <slot name=\"header\" onSlotchange={this.handleHeaderSlotChange} />\n </div>\n <div\n class=\"shell-page__mobile-header\"\n hidden={!mobile}\n aria-hidden={!mobile ? 'true' : undefined}\n inert={!mobile ? true : undefined}\n >\n <slot name=\"mobile-header\" />\n </div>\n </div>\n <div\n ref={el => {\n this.spacerEl = el ?? null;\n }}\n class=\"shell-page__flow-spacer\"\n aria-hidden=\"true\"\n />\n <div class=\"shell-page__content\">\n <slot />\n </div>\n </Host>\n );\n }\n}\n",
|
|
227
|
+
"content": "import { Component, Element, h, Host, Prop, State, Watch } from '@stencil/core';\nimport type { ShellResponsiveMode } from '../../shell/shell-responsive';\nimport { resolveCssLengthPx } from '../../utils/resolve-css-length-px';\nimport type { BarTitleVariant } from '../BarTitle/bar-title-types';\nimport { resolveShellPageHeaderVariant } from './shell-page-responsive';\nimport type {\n ShellPageCapacity,\n ShellPageContentInset,\n ShellPageHeaderPresentation,\n} from './shell-page-types';\n\ntype BarTitleElement = HTMLElement & { variant: BarTitleVariant };\n\n@Component({\n tag: 'ds-shell-page',\n styleUrl: 'ShellPage.css',\n scoped: true,\n})\nexport class ShellPage {\n private static readonly MAX_HEADER_REVEAL_FRAMES = 12;\n private static readonly MAX_HEADER_GEOMETRY_FRAMES = 12;\n\n @Element() el!: HTMLElement;\n\n /** Automatic capacity/scroll behavior, or an explicit header variant override. */\n @Prop() headerPresentation: ShellPageHeaderPresentation = 'auto';\n\n /** Available page-header capacity supplied by the owning application shell. */\n @Prop() headerCapacity?: ShellPageCapacity;\n\n /** Standard page gutters, or no inset for full-bleed page content. */\n @Prop() contentInset: ShellPageContentInset = 'default';\n\n /** Explicit shell breakpoint presentation. Mobile uses the dedicated mobile-header slot. */\n @Prop({ attribute: 'responsive-mode', reflect: true })\n responsiveMode: ShellResponsiveMode = 'desktop';\n\n @State() private pageTopVisible = true;\n\n private sentinelEl: HTMLElement | null = null;\n private spacerEl: HTMLElement | null = null;\n private stickyHeaderEl: HTMLElement | null = null;\n private headerEl: BarTitleElement | null = null;\n private expandedHeaderHeight = 0;\n private headerTravel = 0;\n private expandedGeometryFrozen = false;\n private headerResizeObserver: ResizeObserver | null = null;\n private headerMutationObserver: MutationObserver | null = null;\n private headerRevealFrame: number | null = null;\n private headerGeometryFrame: number | null = null;\n private scrollRoot: HTMLElement | Window | null = null;\n\n @Watch('headerCapacity')\n @Watch('headerPresentation')\n handleHeaderContractChange() {\n this.syncHeaderVariant(true);\n }\n\n @Watch('responsiveMode')\n handleResponsiveModeChange() {\n this.observeHeader(this.responsiveMode === 'mobile' ? null : this.findHeader());\n }\n\n componentDidLoad() {\n this.observeHeader(this.responsiveMode === 'mobile' ? null : this.findHeader());\n this.connectScrollRoot();\n }\n\n componentDidRender() {\n this.syncHeaderVariant();\n }\n\n disconnectedCallback() {\n this.headerResizeObserver?.disconnect();\n this.headerResizeObserver = null;\n this.headerMutationObserver?.disconnect();\n this.headerMutationObserver = null;\n this.cancelHeaderReveal();\n this.cancelHeaderGeometrySync();\n this.headerEl?.style.removeProperty('--ds-bar-title-divider-inset');\n this.scrollRoot?.removeEventListener('scroll', this.handleScroll);\n this.scrollRoot = null;\n }\n\n private get effectiveVariant(): BarTitleVariant {\n return resolveShellPageHeaderVariant(\n this.headerPresentation,\n this.headerCapacity ?? 'roomy',\n this.pageTopVisible\n );\n }\n\n private get headerContractResolved(): boolean {\n return this.headerPresentation !== 'auto' || this.headerCapacity !== undefined;\n }\n\n private get isScrollCompacted(): boolean {\n return (\n this.headerPresentation === 'auto' && this.headerCapacity === 'roomy' && !this.pageTopVisible\n );\n }\n\n private composedParent(element: Element): Element | null {\n return (element as HTMLElement).assignedSlot?.parentElement ?? element.parentElement;\n }\n\n private findScrollRoot(): HTMLElement | Window {\n let ancestor = this.composedParent(this.el);\n while (ancestor && ancestor !== document.documentElement) {\n const overflow = getComputedStyle(ancestor).overflowY;\n if (overflow === 'auto' || overflow === 'scroll' || overflow === 'overlay') {\n return ancestor as HTMLElement;\n }\n ancestor = this.composedParent(ancestor);\n }\n return window;\n }\n\n private connectScrollRoot() {\n const next = this.findScrollRoot();\n if (next === this.scrollRoot) return;\n this.scrollRoot?.removeEventListener('scroll', this.handleScroll);\n this.scrollRoot = next;\n this.scrollRoot.addEventListener('scroll', this.handleScroll, { passive: true });\n this.handleScroll();\n }\n\n private handleScroll = () => {\n if (!this.sentinelEl || !this.scrollRoot) return;\n const rootTop =\n this.scrollRoot === window ? 0 : (this.scrollRoot as HTMLElement).getBoundingClientRect().top;\n this.syncHeaderDividerInset(rootTop);\n const nextPageTopVisible = this.sentinelEl.getBoundingClientRect().top >= rootTop;\n if (\n !nextPageTopVisible &&\n this.pageTopVisible &&\n this.headerEl?.classList.contains(this.variantClass('expanded'))\n ) {\n this.captureExpandedHeaderGeometry(this.headerEl);\n this.expandedGeometryFrozen = true;\n }\n if (\n nextPageTopVisible &&\n this.expandedGeometryFrozen &&\n this.el.getBoundingClientRect().top >= rootTop\n ) {\n this.expandedGeometryFrozen = false;\n if (this.headerEl?.classList.contains(this.variantClass('expanded'))) {\n this.captureExpandedHeaderGeometry(this.headerEl);\n }\n }\n this.pageTopVisible = nextPageTopVisible;\n };\n\n private findHeader(): BarTitleElement | null {\n return this.el.querySelector<BarTitleElement>('ds-bar-title');\n }\n\n private get compactHeaderHeight(): number {\n return resolveCssLengthPx('--dimension-size-600', 0);\n }\n\n private syncHeaderDividerInset(rootTop?: number) {\n const header = this.headerEl;\n if (!header) return;\n\n const ownsScrollTransition =\n this.headerPresentation === 'auto' && this.headerCapacity === 'roomy';\n const expandedInset = resolveCssLengthPx('--dimension-space-400', 0);\n if (\n !ownsScrollTransition ||\n !this.sentinelEl ||\n !this.scrollRoot ||\n this.headerTravel <= 0 ||\n expandedInset <= 0\n ) {\n header.style.removeProperty('--ds-bar-title-divider-inset');\n return;\n }\n\n const scrollRootTop =\n rootTop ??\n (this.scrollRoot === window\n ? 0\n : (this.scrollRoot as HTMLElement).getBoundingClientRect().top);\n const remainingTravel = this.sentinelEl.getBoundingClientRect().top - scrollRootTop;\n const progress = Math.min(1, Math.max(0, 1 - remainingTravel / this.headerTravel));\n header.style.setProperty('--ds-bar-title-divider-inset', `${expandedInset * (1 - progress)}px`);\n }\n\n private setHeaderTravel(distance: number) {\n const next = Math.max(0, distance);\n if (Math.abs(next - this.headerTravel) < 0.5) return;\n this.headerTravel = next;\n this.el.style.setProperty('--ds-shell-page-header-travel', `${next}px`);\n this.handleScroll();\n }\n\n private measureHeaderTravel(header: BarTitleElement) {\n const anchor = header.querySelector<HTMLElement>('[data-shell-page-header-anchor]');\n const compactHeight = this.compactHeaderHeight;\n if (!anchor || compactHeight <= 0) return;\n\n const headerRect = header.getBoundingClientRect();\n const anchorRect = anchor.getBoundingClientRect();\n if (headerRect.height <= 0 || anchorRect.height <= 0) return;\n\n const expandedAnchorTop = anchorRect.top - headerRect.top;\n const compactAnchorTop = Math.max(0, (compactHeight - anchorRect.height) / 2);\n this.setHeaderTravel(expandedAnchorTop - compactAnchorTop);\n }\n\n private captureExpandedHeaderGeometry(header: BarTitleElement) {\n this.expandedHeaderHeight = header.getBoundingClientRect().height;\n this.measureHeaderTravel(header);\n }\n\n private setSpacerHeight(height: number) {\n if (!this.spacerEl) return;\n this.spacerEl.style.setProperty('--ds-shell-page-flow-spacer', `${Math.max(0, height)}px`);\n }\n\n private variantClass(variant: BarTitleVariant): string {\n return `bar-title-host--${variant}`;\n }\n\n private setStickyHeaderOffset(renderedVariant: BarTitleVariant) {\n this.stickyHeaderEl?.style.setProperty(\n '--ds-shell-page-sticky-offset',\n renderedVariant === 'expanded' &&\n this.headerPresentation === 'auto' &&\n this.headerCapacity === 'roomy'\n ? `${-this.headerTravel}px`\n : '0px'\n );\n }\n\n private syncRenderedHeaderGeometry(header: BarTitleElement) {\n const renderedVariant = this.effectiveVariant;\n if (!header.classList.contains(this.variantClass(renderedVariant))) return;\n\n // Update both flow-preservation pieces before reading layout. Otherwise a\n // reverse compact → expanded render can briefly contain the tall header and\n // the compact spacer together, giving browser scroll anchoring a transient\n // height change to compensate.\n this.setSpacerHeight(\n this.isScrollCompacted ? this.expandedHeaderHeight - this.compactHeaderHeight : 0\n );\n this.setStickyHeaderOffset(renderedVariant);\n\n if (renderedVariant === 'expanded' && !this.expandedGeometryFrozen) {\n this.captureExpandedHeaderGeometry(header);\n this.setStickyHeaderOffset(renderedVariant);\n }\n }\n\n private cancelHeaderGeometrySync() {\n if (this.headerGeometryFrame !== null) cancelAnimationFrame(this.headerGeometryFrame);\n this.headerGeometryFrame = null;\n }\n\n private scheduleHeaderGeometrySync(header: BarTitleElement) {\n this.cancelHeaderGeometrySync();\n let framesRemaining = ShellPage.MAX_HEADER_GEOMETRY_FRAMES;\n let framesElapsed = 0;\n const sync = () => {\n if (this.headerEl !== header) return;\n framesRemaining -= 1;\n framesElapsed += 1;\n // In WebKit a child's componentDidLoad can precede its assignment into\n // the parent's rendered slot. Revalidate after ShellApp has had a frame\n // to expose the actual content scroller.\n this.connectScrollRoot();\n this.syncRenderedHeaderGeometry(header);\n const geometryReady =\n this.effectiveVariant !== 'expanded' ||\n (this.expandedHeaderHeight > 0 && this.headerTravel > 0);\n if ((geometryReady && framesElapsed >= 2) || framesRemaining <= 0) {\n this.headerGeometryFrame = null;\n return;\n }\n this.headerGeometryFrame = requestAnimationFrame(sync);\n };\n this.headerGeometryFrame = requestAnimationFrame(sync);\n }\n\n private cancelHeaderReveal() {\n if (this.headerRevealFrame !== null) cancelAnimationFrame(this.headerRevealFrame);\n this.headerRevealFrame = null;\n this.headerEl?.removeAttribute('data-shell-page-syncing');\n }\n\n private revealHeaderWhenSynced(header: BarTitleElement, variant: BarTitleVariant) {\n // Stencil normally re-renders within a frame or two of a prop change. Stop\n // polling at the cap, but fail closed: a stale variant must never paint.\n let framesRemaining = ShellPage.MAX_HEADER_REVEAL_FRAMES;\n const reveal = () => {\n if (this.headerEl !== header) {\n header.removeAttribute('data-shell-page-syncing');\n return;\n }\n framesRemaining -= 1;\n if (header.classList.contains(this.variantClass(variant))) {\n header.removeAttribute('data-shell-page-syncing');\n this.headerRevealFrame = null;\n return;\n }\n if (framesRemaining <= 0) {\n this.headerRevealFrame = null;\n return;\n }\n this.headerRevealFrame = requestAnimationFrame(reveal);\n };\n this.headerRevealFrame = requestAnimationFrame(reveal);\n }\n\n private syncHeaderVariant(concealUntilSynced = false) {\n const header = this.headerEl;\n if (!header) return;\n\n this.syncHeaderDividerInset();\n if (!this.headerContractResolved) {\n this.cancelHeaderReveal();\n header.setAttribute('data-shell-page-syncing', '');\n return;\n }\n\n const next = this.effectiveVariant;\n const renderedVariantIsStale = !header.classList.contains(this.variantClass(next));\n if (!renderedVariantIsStale) this.syncRenderedHeaderGeometry(header);\n if (concealUntilSynced) {\n this.cancelHeaderReveal();\n header.setAttribute('data-shell-page-syncing', '');\n }\n if (header.variant !== next) header.variant = next;\n if (concealUntilSynced) {\n this.revealHeaderWhenSynced(header, next);\n }\n }\n\n private observeHeader(header: BarTitleElement | null) {\n this.cancelHeaderReveal();\n this.cancelHeaderGeometrySync();\n this.headerResizeObserver?.disconnect();\n this.headerResizeObserver = null;\n this.headerMutationObserver?.disconnect();\n this.headerMutationObserver = null;\n this.headerEl?.style.removeProperty('--ds-bar-title-divider-inset');\n this.headerEl = header;\n this.expandedHeaderHeight = 0;\n this.expandedGeometryFrozen = false;\n this.setHeaderTravel(0);\n this.setSpacerHeight(0);\n this.stickyHeaderEl?.style.setProperty('--ds-shell-page-sticky-offset', '0px');\n\n if (!header) return;\n if (header.classList.contains(this.variantClass('expanded'))) {\n this.captureExpandedHeaderGeometry(header);\n }\n if (typeof ResizeObserver !== 'undefined') {\n this.headerResizeObserver = new ResizeObserver(() => {\n this.syncRenderedHeaderGeometry(header);\n });\n this.headerResizeObserver.observe(header);\n }\n if (typeof MutationObserver !== 'undefined') {\n this.headerMutationObserver = new MutationObserver(() => {\n this.syncRenderedHeaderGeometry(header);\n });\n this.headerMutationObserver.observe(header, {\n attributes: true,\n attributeFilter: ['class'],\n });\n }\n this.syncHeaderVariant(true);\n this.scheduleHeaderGeometrySync(header);\n }\n\n private handleHeaderSlotChange = (event: Event) => {\n if (this.responsiveMode === 'mobile') return;\n const slot = event.target as HTMLSlotElement;\n const header = slot\n .assignedElements()\n .find(element => element.tagName.toLowerCase() === 'ds-bar-title');\n this.observeHeader((header as BarTitleElement | undefined) ?? null);\n };\n\n render() {\n const mobile = this.responsiveMode === 'mobile';\n return (\n <Host\n role=\"main\"\n class={{\n 'shell-page-host--inset-default': this.contentInset === 'default',\n 'shell-page-host--inset-none': this.contentInset === 'none',\n 'shell-page-host--mobile': mobile,\n [`shell-page-host--header-${this.effectiveVariant}`]: true,\n }}\n >\n <div\n ref={el => {\n this.sentinelEl = el ?? null;\n }}\n class=\"shell-page__scroll-sentinel\"\n aria-hidden=\"true\"\n />\n <div\n ref={el => {\n this.stickyHeaderEl = el ?? null;\n }}\n class=\"shell-page__sticky-header\"\n >\n <div\n class=\"shell-page__desktop-header\"\n hidden={mobile}\n aria-hidden={mobile ? 'true' : undefined}\n inert={mobile ? true : undefined}\n >\n <slot name=\"header\" onSlotchange={this.handleHeaderSlotChange} />\n </div>\n <div\n class=\"shell-page__mobile-header\"\n hidden={!mobile}\n aria-hidden={!mobile ? 'true' : undefined}\n inert={!mobile ? true : undefined}\n >\n <slot name=\"mobile-header\" />\n </div>\n </div>\n <div\n ref={el => {\n this.spacerEl = el ?? null;\n }}\n class=\"shell-page__flow-spacer\"\n aria-hidden=\"true\"\n />\n <div class=\"shell-page__content\">\n <slot />\n </div>\n </Host>\n );\n }\n}\n",
|
|
227
228
|
"type": "registry:ui"
|
|
228
229
|
},
|
|
229
230
|
{
|
|
@@ -421,7 +421,7 @@
|
|
|
421
421
|
"complexPropertyNote": "Assign these non-primitive values as JavaScript properties: headers, items.",
|
|
422
422
|
"peerDependencies": {
|
|
423
423
|
"required": [
|
|
424
|
-
"@ds-mo/tokens ^
|
|
424
|
+
"@ds-mo/tokens ^5.0.0",
|
|
425
425
|
"@ds-mo/icons ^6.4.0"
|
|
426
426
|
],
|
|
427
427
|
"frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
|
|
@@ -629,7 +629,7 @@
|
|
|
629
629
|
"complexPropertyNote": "Assign these non-primitive values as JavaScript properties: formatOptions, valueTexts.",
|
|
630
630
|
"peerDependencies": {
|
|
631
631
|
"required": [
|
|
632
|
-
"@ds-mo/tokens ^
|
|
632
|
+
"@ds-mo/tokens ^5.0.0",
|
|
633
633
|
"@ds-mo/icons ^6.4.0"
|
|
634
634
|
],
|
|
635
635
|
"frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
|
|
@@ -199,7 +199,7 @@
|
|
|
199
199
|
"complexPropertyNote": "Assign these non-primitive values as JavaScript properties: options, sections.",
|
|
200
200
|
"peerDependencies": {
|
|
201
201
|
"required": [
|
|
202
|
-
"@ds-mo/tokens ^
|
|
202
|
+
"@ds-mo/tokens ^5.0.0",
|
|
203
203
|
"@ds-mo/icons ^6.4.0"
|
|
204
204
|
],
|
|
205
205
|
"frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
|
|
@@ -218,7 +218,7 @@
|
|
|
218
218
|
"complexPropertyNote": "Assign these non-primitive values as JavaScript properties: tabs.",
|
|
219
219
|
"peerDependencies": {
|
|
220
220
|
"required": [
|
|
221
|
-
"@ds-mo/tokens ^
|
|
221
|
+
"@ds-mo/tokens ^5.0.0",
|
|
222
222
|
"@ds-mo/icons ^6.4.0"
|
|
223
223
|
],
|
|
224
224
|
"frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
|
|
@@ -307,7 +307,7 @@
|
|
|
307
307
|
"complexPropertyNote": "Assign these non-primitive values as JavaScript properties: manager, swipeDirections.",
|
|
308
308
|
"peerDependencies": {
|
|
309
309
|
"required": [
|
|
310
|
-
"@ds-mo/tokens ^
|
|
310
|
+
"@ds-mo/tokens ^5.0.0",
|
|
311
311
|
"@ds-mo/icons ^6.4.0"
|
|
312
312
|
],
|
|
313
313
|
"frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
|
|
@@ -330,7 +330,7 @@
|
|
|
330
330
|
},
|
|
331
331
|
{
|
|
332
332
|
"path": "src/wc/components/Toast/Toast.tsx",
|
|
333
|
-
"content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Prop,\n State,\n Watch,\n} from '@stencil/core';\nimport {\n resolveCssLengthPx,\n resolveCssTimeMs,\n resolveMotionTimeMs,\n TOKEN_DEFAULTS,\n} from '../../utils';\nimport {\n toastManager as defaultToastManager,\n type ToastActionEventDetail,\n type ToastCloseContext,\n type ToastCloseEventDetail,\n type ToastEventDetail,\n type ToastManager,\n type ToastRecord,\n type ToastSwipeDirection,\n} from '../../toast';\nimport { computeTooltipPosition } from '../Tooltip/tooltip-position';\n\ninterface ToastTimer {\n timerKey: number;\n remaining: number;\n startedAt: number;\n handle: ReturnType<typeof setTimeout> | null;\n}\n\ninterface SwipeState {\n id: string;\n x: number;\n y: number;\n direction?: ToastSwipeDirection;\n}\n\ninterface AnchoredPosition {\n x: number;\n y: number;\n side: 'top' | 'right' | 'bottom' | 'left';\n}\n\ninterface ActiveSwipe {\n id: string;\n pointerId: number;\n pointerType: string;\n startX: number;\n startY: number;\n maxDistance: number;\n cancelled: boolean;\n axis: 'x' | 'y' | null;\n}\n\nconst SWIPE_THRESHOLD = 40;\nconst SWIPE_REVERSE_CANCEL_THRESHOLD = 10;\nconst GLOBAL_STACK_HOVER = '__global-stack__';\nconst INTERACTIVE_SWIPE_SELECTOR =\n 'button,a,input,textarea,select,[role=\"button\"],[data-ds-toast-swipe-ignore]';\n\nconst attachedManagers = new WeakMap<ToastManager, Toast>();\nconst connectedToastRegions = new Set<Toast>();\nlet activeToastRegion: Toast | null = null;\nlet toastInstanceCounter = 0;\n\n@Component({\n tag: 'ds-toast',\n styleUrl: 'Toast.css',\n scoped: true,\n})\nexport class Toast {\n @Element() el!: HTMLElement;\n\n /** Manager that supplies toast records. Assign as a JavaScript property. */\n @Prop() manager: ToastManager = defaultToastManager;\n /** Maximum visible global-stack records. Limited records remain mounted and inert. */\n @Prop() limit: number = 3;\n /** Default auto-dismiss delay. Use 0 for persistent records. */\n @Prop() timeout: number | string = TOKEN_DEFAULTS.animationDelayLong2;\n /** Accessible label for the notification region. */\n @Prop() label: string = 'Notifications';\n /** Localized accessible label for every toast close action. */\n @Prop() closeLabel: string = 'Close notification';\n /** Keep the global stack 16px above the persistent mobile shell bar below 768px. */\n @Prop({ attribute: 'avoid-mobile-shell-bar', reflect: true })\n avoidMobileShellBar: boolean = false;\n /** Allowed swipe-to-dismiss directions. Assign as a JavaScript property. */\n @Prop() swipeDirections: ToastSwipeDirection[] = ['down', 'right'];\n\n @State() private records: readonly ToastRecord[] = [];\n @State() private expanded = false;\n @State() private keyboardActive = false;\n @State() private swipe: SwipeState | null = null;\n @State() private layoutVersion = 0;\n\n @Event() dsToastClose!: EventEmitter<ToastCloseEventDetail>;\n @Event() dsToastRemove!: EventEmitter<ToastEventDetail>;\n @Event() dsToastAction!: EventEmitter<ToastActionEventDetail>;\n\n private readonly instanceId = ++toastInstanceCounter;\n private readonly domIds = new Map<string, number>();\n private readonly timers = new Map<string, ToastTimer>();\n private readonly closeWatchdogs = new Map<string, ReturnType<typeof setTimeout>>();\n private readonly closeFrames = new Map<string, number>();\n private readonly closeEvents = new Set<string>();\n private readonly activationFrames = new Map<string, number>();\n private readonly pauseReasons = new Set<string>();\n private readonly hoveredIds = new Set<string>();\n private readonly heights = new Map<string, number>();\n private readonly measuredUpdateKeys = new Map<string, number>();\n private readonly anchoredPositions = new Map<string, AnchoredPosition>();\n private readonly dismissedSwipeDirections = new Map<string, ToastSwipeDirection>();\n private readonly observedElements = new Set<Element>();\n private readonly pendingRemovals: Array<{\n manager: ToastManager;\n context: ToastCloseContext;\n }> = [];\n\n private unsubscribe: (() => void) | null = null;\n private attachedManager: ToastManager | null = null;\n private previousFocus: HTMLElement | null = null;\n private resizeObserver: ResizeObserver | null = null;\n private mutationObserver: MutationObserver | null = null;\n private layoutFrame: number | null = null;\n private activeSwipe: ActiveSwipe | null = null;\n private nextDomId = 0;\n private connected = false;\n private pendingHoverLeave = false;\n\n connectedCallback() {\n this.connect();\n }\n\n componentDidLoad() {\n this.connect();\n }\n\n private connect() {\n if (this.connected) return;\n this.connected = true;\n this.resizeObserver ??= new ResizeObserver(() => this.scheduleLayout());\n this.mutationObserver ??= new MutationObserver(() => this.scheduleLayout());\n this.mutationObserver.observe(document.body, { childList: true, subtree: true });\n if (!this.unsubscribe) this.attachManager(this.manager);\n window.addEventListener('blur', this.handleWindowBlur);\n window.addEventListener('focus', this.handleWindowFocus);\n window.addEventListener('resize', this.handleWindowResize);\n window.addEventListener('scroll', this.scheduleLayout, true);\n document.addEventListener('keydown', this.handleDocumentKeyDown);\n document.addEventListener('visibilitychange', this.handleVisibilityChange);\n connectedToastRegions.add(this);\n // eslint-disable-next-line @typescript-eslint/no-this-alias -- module coordinator tracks the active connected viewport\n if (!activeToastRegion) activeToastRegion = this;\n }\n\n componentDidRender() {\n this.syncObservedElements();\n this.flushPendingRemovals();\n this.scheduleLayout();\n }\n\n disconnectedCallback() {\n this.connected = false;\n this.flushPendingRemovals();\n this.detachManager();\n this.resetManagerState();\n this.resizeObserver?.disconnect();\n this.resizeObserver = null;\n this.mutationObserver?.disconnect();\n this.mutationObserver = null;\n this.observedElements.clear();\n if (this.layoutFrame !== null) cancelAnimationFrame(this.layoutFrame);\n this.layoutFrame = null;\n window.removeEventListener('blur', this.handleWindowBlur);\n window.removeEventListener('focus', this.handleWindowFocus);\n window.removeEventListener('resize', this.handleWindowResize);\n window.removeEventListener('scroll', this.scheduleLayout, true);\n document.removeEventListener('keydown', this.handleDocumentKeyDown);\n document.removeEventListener('visibilitychange', this.handleVisibilityChange);\n connectedToastRegions.delete(this);\n if (activeToastRegion === this) {\n const remaining = Array.from(connectedToastRegions);\n activeToastRegion = remaining[remaining.length - 1] ?? null;\n }\n }\n\n @Watch('manager')\n onManagerChange(next: ToastManager) {\n this.detachManager();\n this.resetManagerState();\n if (this.connected) this.attachManager(next);\n }\n\n @Watch('timeout')\n onTimeoutChange() {\n for (const timer of this.timers.values()) {\n if (timer.handle) clearTimeout(timer.handle);\n }\n this.timers.clear();\n this.syncTimers();\n }\n\n private attachManager(manager: ToastManager) {\n const attached = attachedManagers.get(manager);\n if (attached && attached !== this) {\n console.warn('[ds-toast] A manager can control only one connected ds-toast instance.');\n return;\n }\n attachedManagers.set(manager, this);\n this.attachedManager = manager;\n this.unsubscribe = manager.subscribe(records => {\n this.records = records;\n this.syncLifecycle(records);\n this.scheduleLayout();\n });\n }\n\n private detachManager() {\n this.unsubscribe?.();\n this.unsubscribe = null;\n if (this.attachedManager && attachedManagers.get(this.attachedManager) === this) {\n attachedManagers.delete(this.attachedManager);\n }\n this.attachedManager = null;\n }\n\n private resetManagerState() {\n this.flushPendingRemovals();\n if (this.el.contains(document.activeElement)) this.restorePreviousFocus();\n this.clearAllTimers();\n for (const frame of this.activationFrames.values()) cancelAnimationFrame(frame);\n for (const frame of this.closeFrames.values()) cancelAnimationFrame(frame);\n this.activationFrames.clear();\n this.closeFrames.clear();\n this.closeEvents.clear();\n this.pauseReasons.clear();\n this.hoveredIds.clear();\n this.pendingHoverLeave = false;\n this.heights.clear();\n this.measuredUpdateKeys.clear();\n this.anchoredPositions.clear();\n this.dismissedSwipeDirections.clear();\n this.domIds.clear();\n this.activeSwipe = null;\n this.swipe = null;\n this.expanded = false;\n this.keyboardActive = false;\n this.records = [];\n this.resizeObserver?.disconnect();\n this.observedElements.clear();\n }\n\n private syncObservedElements() {\n const next = new Set<Element>(\n this.el.querySelectorAll<HTMLElement>('.toast-positioner'),\n );\n for (const record of this.records) {\n if (!record.positioner) continue;\n const anchor = this.resolveAnchor(record.positioner.anchor);\n if (anchor) next.add(anchor);\n }\n\n for (const element of this.observedElements) {\n if (!next.has(element)) {\n this.resizeObserver?.unobserve(element);\n this.observedElements.delete(element);\n }\n }\n for (const element of next) {\n if (this.observedElements.has(element)) continue;\n this.resizeObserver?.observe(element);\n this.observedElements.add(element);\n }\n }\n\n private flushPendingRemovals() {\n for (const pending of this.pendingRemovals.splice(0)) {\n pending.manager.notifyRemove(pending.context);\n this.dsToastRemove.emit({\n id: pending.context.id,\n toast: pending.context.toast,\n });\n }\n }\n\n private syncLifecycle(records: readonly ToastRecord[]) {\n const recordIds = new Set(records.map(record => record.id));\n for (const id of this.hoveredIds) {\n if (id !== GLOBAL_STACK_HOVER && !recordIds.has(id)) this.hoveredIds.delete(id);\n }\n const hasEndingRecords = records.some(\n record => record.transitionStatus === 'ending',\n );\n if (this.pendingHoverLeave && !hasEndingRecords && !this.hoveredIds.size) {\n this.pendingHoverLeave = false;\n this.expanded = false;\n this.resumeTimers('hover');\n } else if (!this.hoveredIds.size && !this.pendingHoverLeave) {\n this.resumeTimers('hover');\n }\n\n for (const [id, timer] of this.timers) {\n if (recordIds.has(id)) continue;\n if (timer.handle) clearTimeout(timer.handle);\n this.timers.delete(id);\n }\n\n for (const [id, watchdog] of this.closeWatchdogs) {\n if (recordIds.has(id)) continue;\n clearTimeout(watchdog);\n this.closeWatchdogs.delete(id);\n }\n\n for (const [id, frame] of this.activationFrames) {\n if (recordIds.has(id)) continue;\n cancelAnimationFrame(frame);\n this.activationFrames.delete(id);\n }\n\n for (const [id, frame] of this.closeFrames) {\n if (recordIds.has(id)) continue;\n cancelAnimationFrame(frame);\n this.closeFrames.delete(id);\n }\n\n for (const id of this.domIds.keys()) {\n if (recordIds.has(id)) continue;\n this.closeEvents.delete(id);\n this.domIds.delete(id);\n this.heights.delete(id);\n this.measuredUpdateKeys.delete(id);\n this.anchoredPositions.delete(id);\n this.dismissedSwipeDirections.delete(id);\n }\n\n for (const record of records) {\n this.ensureDomId(record.id);\n if (\n record.transitionStatus === 'starting' &&\n !this.activationFrames.has(record.id)\n ) {\n const manager = this.attachedManager;\n const frame = requestAnimationFrame(() => {\n this.activationFrames.delete(record.id);\n manager?.activate(record.id);\n });\n this.activationFrames.set(record.id, frame);\n }\n\n if (record.transitionStatus === 'ending') {\n this.clearToastTimer(record.id);\n this.beginRemoval(record);\n } else {\n const watchdog = this.closeWatchdogs.get(record.id);\n if (watchdog) clearTimeout(watchdog);\n this.closeWatchdogs.delete(record.id);\n const closeFrame = this.closeFrames.get(record.id);\n if (closeFrame) cancelAnimationFrame(closeFrame);\n this.closeFrames.delete(record.id);\n this.closeEvents.delete(record.id);\n }\n }\n\n this.syncTimers();\n }\n\n private syncTimers() {\n const activeIds = new Set<string>();\n for (const record of this.records) {\n if (record.transitionStatus !== 'active') continue;\n activeIds.add(record.id);\n const duration = this.resolveToastTimeout(record);\n if (duration <= 0) {\n this.clearToastTimer(record.id);\n continue;\n }\n\n const existing = this.timers.get(record.id);\n if (!existing || existing.timerKey !== record.timerKey) {\n this.clearToastTimer(record.id);\n this.timers.set(record.id, {\n timerKey: record.timerKey,\n remaining: duration,\n startedAt: 0,\n handle: null,\n });\n }\n if (!this.isPaused) this.startToastTimer(record.id);\n }\n\n for (const id of this.timers.keys()) {\n if (!activeIds.has(id)) this.clearToastTimer(id);\n }\n }\n\n private resolveToastTimeout(record: ToastRecord): number {\n const value = record.timeout ?? this.timeout;\n if (typeof value === 'number') return Math.max(0, value);\n return resolveCssTimeMs(value, TOKEN_DEFAULTS.animationDelayLong2);\n }\n\n private startToastTimer(id: string) {\n const timer = this.timers.get(id);\n if (!timer || timer.handle || timer.remaining <= 0) return;\n timer.startedAt = Date.now();\n timer.handle = setTimeout(() => {\n timer.handle = null;\n timer.remaining = 0;\n this.manager.close(id, 'timeout');\n }, timer.remaining);\n }\n\n private pauseTimers(reason: string) {\n const wasPaused = this.isPaused;\n this.pauseReasons.add(reason);\n if (wasPaused) return;\n const now = Date.now();\n for (const timer of this.timers.values()) {\n if (!timer.handle) continue;\n clearTimeout(timer.handle);\n timer.handle = null;\n timer.remaining = Math.max(0, timer.remaining - (now - timer.startedAt));\n }\n }\n\n private resumeTimers(reason: string) {\n this.pauseReasons.delete(reason);\n if (this.isPaused) return;\n for (const id of this.timers.keys()) this.startToastTimer(id);\n }\n\n private get isPaused(): boolean {\n return this.pauseReasons.size > 0;\n }\n\n private clearToastTimer(id: string) {\n const timer = this.timers.get(id);\n if (timer?.handle) clearTimeout(timer.handle);\n this.timers.delete(id);\n }\n\n private clearAllTimers() {\n for (const timer of this.timers.values()) {\n if (timer.handle) clearTimeout(timer.handle);\n }\n for (const watchdog of this.closeWatchdogs.values()) clearTimeout(watchdog);\n this.timers.clear();\n this.closeWatchdogs.clear();\n }\n\n private beginRemoval(record: ToastRecord) {\n if (!this.closeEvents.has(record.id)) {\n this.closeEvents.add(record.id);\n this.dsToastClose.emit({\n id: record.id,\n reason: record.closeReason ?? 'programmatic',\n toast: record,\n });\n this.moveFocusAfterClose(record.id);\n }\n if (\n this.closeWatchdogs.has(record.id) ||\n this.closeFrames.has(record.id)\n ) return;\n\n const duration = resolveMotionTimeMs(\n TOKEN_DEFAULTS.motionShort3,\n TOKEN_DEFAULTS.animationDurationShort3,\n );\n if (duration <= 0) {\n queueMicrotask(() => this.completeRemoval(record));\n return;\n }\n const frame = requestAnimationFrame(() => {\n this.closeFrames.delete(record.id);\n const current = this.records.find(candidate => candidate.id === record.id);\n if (!current || current.transitionStatus !== 'ending') return;\n this.closeWatchdogs.set(\n record.id,\n setTimeout(() => this.completeRemoval(current), duration),\n );\n });\n this.closeFrames.set(record.id, frame);\n }\n\n private completeRemoval(record: ToastRecord) {\n const current = this.records.find(candidate => candidate.id === record.id);\n if (!current || current.transitionStatus !== 'ending') return;\n const watchdog = this.closeWatchdogs.get(record.id);\n if (watchdog) clearTimeout(watchdog);\n this.closeWatchdogs.delete(record.id);\n const closeFrame = this.closeFrames.get(record.id);\n if (closeFrame) cancelAnimationFrame(closeFrame);\n this.closeFrames.delete(record.id);\n const manager = this.attachedManager;\n const context = manager?.remove(record.id);\n if (manager && context) this.pendingRemovals.push({ manager, context });\n }\n\n private handleAnimationComplete(record: ToastRecord) {\n if (record.transitionStatus === 'ending') this.completeRemoval(record);\n }\n\n private bindAnimationCancel(element: HTMLDivElement | undefined, id: string) {\n if (!element || element.dataset['cancelBound'] === 'true') return;\n element.dataset['cancelBound'] = 'true';\n element.addEventListener('animationcancel', () => {\n const record = this.records.find(candidate => candidate.id === id);\n if (record) this.handleAnimationComplete(record);\n });\n }\n\n private handleWindowBlur = () => this.pauseTimers('window');\n private handleWindowFocus = () => this.resumeTimers('window');\n private handleVisibilityChange = () => {\n if (document.hidden) this.pauseTimers('document');\n else this.resumeTimers('document');\n };\n\n private handlePointerEnter(id: string) {\n // eslint-disable-next-line @typescript-eslint/no-this-alias -- pointer interaction selects the viewport that receives F6\n activeToastRegion = this;\n this.pendingHoverLeave = false;\n this.hoveredIds.add(id);\n this.expanded = true;\n this.pauseTimers('hover');\n }\n\n private handlePointerLeave(id: string) {\n this.hoveredIds.delete(id);\n if (this.hoveredIds.size) return;\n if (this.records.some(record => record.transitionStatus === 'ending')) {\n this.pendingHoverLeave = true;\n return;\n }\n this.expanded = false;\n this.resumeTimers('hover');\n }\n\n private handleFocusIn = () => {\n const active = document.activeElement;\n if (!(active instanceof HTMLElement) || !active.matches(':focus-visible')) return;\n // eslint-disable-next-line @typescript-eslint/no-this-alias -- module coordinator routes F6 to the last focused viewport\n activeToastRegion = this;\n this.keyboardActive = true;\n this.expanded = true;\n this.pauseTimers('focus');\n };\n\n private handleFocusOut = (event: FocusEvent) => {\n const next = event.relatedTarget;\n if (next instanceof Node && this.el.contains(next)) return;\n if (!this.keyboardActive) return;\n this.keyboardActive = false;\n this.expanded = this.hoveredIds.size > 0;\n this.resumeTimers('focus');\n };\n\n private handleDocumentKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'F6') return;\n const eligible = Array.from(connectedToastRegions).filter(\n region => region.activeRecords.length > 0,\n );\n const target =\n activeToastRegion && activeToastRegion.activeRecords.length\n ? activeToastRegion\n : eligible[eligible.length - 1];\n if (target !== this) return;\n event.preventDefault();\n if (!this.el.contains(document.activeElement)) {\n this.previousFocus = document.activeElement as HTMLElement | null;\n }\n this.keyboardActive = true;\n this.regionElement?.focus();\n };\n\n private handleRegionKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n const target = event.target as HTMLElement;\n const positioner = target.closest<HTMLElement>('.toast-positioner');\n const id = positioner?.dataset['toastId'] ?? this.activeRecords[0]?.id;\n if (id) {\n event.preventDefault();\n this.manager.close(id, 'escape');\n }\n return;\n }\n\n if (event.key === 'Tab' && event.shiftKey && event.target === this.regionElement) {\n event.preventDefault();\n this.restorePreviousFocus();\n }\n };\n\n private get regionElement(): HTMLElement | null {\n return this.el.querySelector<HTMLElement>('.toast-region');\n }\n\n private restorePreviousFocus() {\n this.previousFocus?.focus?.();\n this.previousFocus = null;\n this.keyboardActive = false;\n }\n\n private moveFocusAfterClose(id: string) {\n const active = document.activeElement;\n const closing = this.positionerElement(id);\n if (!active || !closing?.contains(active)) return;\n requestAnimationFrame(() => {\n const next = this.activeRecords.find(record => record.id !== id);\n const control = next\n ? this.positionerElement(next.id)?.querySelector<\n HTMLElement & { setFocus?: () => Promise<void> }\n >('ds-button-unfilled')\n : null;\n if (control?.setFocus) void control.setFocus();\n else if (next) this.regionElement?.focus();\n else this.restorePreviousFocus();\n });\n }\n\n private handleAction(record: ToastRecord, event: CustomEvent<MouseEvent>) {\n const detail: ToastActionEventDetail = {\n id: record.id,\n toast: record,\n originalEvent: event.detail,\n };\n this.dsToastAction.emit(detail);\n record.action?.onAction?.({\n id: record.id,\n data: record.data,\n manager: this.manager,\n originalEvent: event.detail,\n });\n }\n\n private handlePointerDown(record: ToastRecord, event: PointerEvent) {\n if (event.button !== 0) return;\n const target = event.target as Element;\n if (target.closest(INTERACTIVE_SWIPE_SELECTOR)) return;\n\n const surface = event.currentTarget as HTMLElement;\n surface.setPointerCapture(event.pointerId);\n this.activeSwipe = {\n id: record.id,\n pointerId: event.pointerId,\n pointerType: event.pointerType,\n startX: event.clientX,\n startY: event.clientY,\n maxDistance: 0,\n cancelled: false,\n axis: null,\n };\n this.swipe = { id: record.id, x: 0, y: 0 };\n if (event.pointerType === 'touch') this.pauseTimers('swipe');\n }\n\n private handlePointerMove(event: PointerEvent) {\n const active = this.activeSwipe;\n if (!active || active.pointerId !== event.pointerId || active.cancelled) return;\n const dx = event.clientX - active.startX;\n const dy = event.clientY - active.startY;\n if (!active.axis && Math.max(Math.abs(dx), Math.abs(dy)) > 1) {\n active.axis = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';\n }\n if (!active.axis) return;\n const horizontal = active.axis === 'x';\n const direction: ToastSwipeDirection = horizontal\n ? dx >= 0 ? 'right' : 'left'\n : dy >= 0 ? 'down' : 'up';\n const allowed = this.swipeDirections.includes(direction);\n const rawDistance = horizontal ? Math.abs(dx) : Math.abs(dy);\n const distance = allowed ? rawDistance : Math.sqrt(rawDistance);\n const signed = (horizontal ? dx : dy) < 0 ? -distance : distance;\n const x = horizontal ? signed : 0;\n const y = horizontal ? 0 : signed;\n\n if (active.maxDistance - distance >= SWIPE_REVERSE_CANCEL_THRESHOLD) {\n active.cancelled = true;\n this.cancelSwipe();\n return;\n }\n active.maxDistance = Math.max(active.maxDistance, distance);\n if (rawDistance > 1) event.preventDefault();\n this.swipe = { id: active.id, x, y, direction: allowed ? direction : undefined };\n }\n\n private handlePointerUp(event: PointerEvent) {\n const active = this.activeSwipe;\n if (!active || active.pointerId !== event.pointerId) return;\n const swipe = this.swipe;\n const distance = swipe ? Math.max(Math.abs(swipe.x), Math.abs(swipe.y)) : 0;\n const shouldDismiss =\n !active.cancelled &&\n !!swipe?.direction &&\n distance >= SWIPE_THRESHOLD;\n const id = active.id;\n const wasTouch = active.pointerType === 'touch';\n this.activeSwipe = null;\n this.swipe = null;\n if (wasTouch) this.resumeTimers('swipe');\n if (shouldDismiss && swipe.direction) {\n this.dismissedSwipeDirections.set(id, swipe.direction);\n this.manager.close(id, 'swipe');\n }\n }\n\n private handlePointerCancel(event: PointerEvent) {\n if (this.activeSwipe?.pointerId !== event.pointerId) return;\n this.cancelSwipe();\n }\n\n private cancelSwipe() {\n const wasTouch = this.activeSwipe?.pointerType === 'touch';\n this.activeSwipe = null;\n this.swipe = null;\n if (wasTouch) this.resumeTimers('swipe');\n }\n\n private get touchAction(): string {\n const horizontal =\n this.swipeDirections.includes('left') || this.swipeDirections.includes('right');\n const vertical =\n this.swipeDirections.includes('up') || this.swipeDirections.includes('down');\n if (horizontal && vertical) return 'none';\n if (horizontal) return 'pan-y';\n if (vertical) return 'pan-x';\n return 'auto';\n }\n\n private handleWindowResize = () => {\n this.heights.clear();\n this.measuredUpdateKeys.clear();\n this.layoutVersion += 1;\n this.scheduleLayout();\n };\n\n private scheduleLayout = () => {\n if (this.layoutFrame !== null) return;\n this.layoutFrame = requestAnimationFrame(() => {\n this.layoutFrame = null;\n this.measureLayout();\n });\n };\n\n private measureNaturalHeight(element: HTMLElement, expandedWidth: boolean): number {\n const surface = element.querySelector<HTMLElement>('.toast-surface');\n if (!surface) return 0;\n const previousWidth = element.style.width;\n const previousTransition = element.style.transition;\n const previousHeight = surface.style.height;\n\n if (expandedWidth) {\n element.style.transition = 'none';\n element.style.width = '100%';\n }\n surface.style.height = 'auto';\n const height = surface.getBoundingClientRect().height;\n\n surface.style.height = previousHeight;\n if (expandedWidth) {\n element.style.width = previousWidth;\n element.getBoundingClientRect();\n element.style.transition = previousTransition;\n }\n return height;\n }\n\n private measureLayout() {\n let changed = false;\n for (const record of this.records) {\n const element = this.positionerElement(record.id);\n if (!element) continue;\n let height = this.heights.get(record.id) ?? 0;\n const needsHeightMeasurement =\n height <= 0 || this.measuredUpdateKeys.get(record.id) !== record.updateKey;\n if (needsHeightMeasurement) {\n height = this.measureNaturalHeight(element, !record.positioner);\n if (height > 0) {\n if (this.heights.get(record.id) !== height) changed = true;\n this.heights.set(record.id, height);\n this.measuredUpdateKeys.set(record.id, record.updateKey);\n }\n }\n\n if (!record.positioner) continue;\n const anchor = this.resolveAnchor(record.positioner.anchor);\n if (!anchor || height <= 0) {\n if (this.anchoredPositions.delete(record.id)) changed = true;\n continue;\n }\n const rect = element.getBoundingClientRect();\n const sideOffset = resolveCssLengthPx(\n record.positioner.sideOffset,\n TOKEN_DEFAULTS.space050,\n );\n const alignOffset = resolveCssLengthPx(record.positioner.alignOffset, 0);\n const position = computeTooltipPosition({\n anchorRect: anchor.getBoundingClientRect(),\n popupWidth: rect.width,\n popupHeight: height,\n side: record.positioner.side ?? 'top',\n align: record.positioner.align ?? 'center',\n sideOffsetPx: sideOffset,\n alignOffsetPx: alignOffset,\n viewportPadPx: resolveCssLengthPx(undefined, TOKEN_DEFAULTS.space200),\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n });\n const current = this.anchoredPositions.get(record.id);\n if (\n !current ||\n current.x !== position.x ||\n current.y !== position.y ||\n current.side !== position.resolvedSide\n ) {\n this.anchoredPositions.set(record.id, {\n x: position.x,\n y: position.y,\n side: position.resolvedSide,\n });\n changed = true;\n }\n }\n if (changed) this.layoutVersion += 1;\n }\n\n private resolveAnchor(anchor: HTMLElement | string): HTMLElement | null {\n return typeof anchor === 'string' ? document.getElementById(anchor) : anchor;\n }\n\n private positionerElement(id: string): HTMLElement | null {\n return (\n Array.from(this.el.querySelectorAll<HTMLElement>('.toast-positioner'))\n .find(element => element.dataset['toastId'] === id) ?? null\n );\n }\n\n private ensureDomId(id: string): number {\n const current = this.domIds.get(id);\n if (current) return current;\n const next = ++this.nextDomId;\n this.domIds.set(id, next);\n return next;\n }\n\n private get activeRecords(): ToastRecord[] {\n return this.records.filter(record => record.transitionStatus !== 'ending');\n }\n\n private globalLayout() {\n const globalRecords = this.records.filter(record => !record.positioner);\n let offset = 0;\n let visibleIndex = 0;\n const gap = resolveCssLengthPx(undefined, TOKEN_DEFAULTS.space200);\n const layouts = new Map<string, { index: number; offset: number; limited: boolean }>();\n\n globalRecords.forEach((record, domIndex) => {\n if (record.transitionStatus === 'ending') {\n layouts.set(record.id, { index: domIndex, offset, limited: false });\n return;\n }\n\n const limited = visibleIndex >= Math.max(1, this.limit);\n layouts.set(record.id, { index: visibleIndex, offset, limited });\n offset += (this.heights.get(record.id) ?? 0) + gap;\n visibleIndex += 1;\n });\n return layouts;\n }\n\n private renderToast(\n record: ToastRecord,\n layout: { index: number; offset: number; limited: boolean },\n ) {\n const domId = this.ensureDomId(record.id);\n const titleId = `ds-toast-${this.instanceId}-${domId}-title`;\n const descriptionId = `ds-toast-${this.instanceId}-${domId}-description`;\n const swipe = this.swipe?.id === record.id ? this.swipe : null;\n const swipeDirection = swipe?.direction ?? this.dismissedSwipeDirections.get(record.id);\n const anchored = !!record.positioner;\n const anchoredPosition = this.anchoredPositions.get(record.id);\n const hiddenHighPriority = record.priority === 'high' && !this.keyboardActive;\n const focusTabIndex = this.keyboardActive && !layout.limited ? 0 : -1;\n const toastHeight = this.heights.get(record.id);\n\n return (\n <div\n ref={(element?: HTMLDivElement) => this.bindAnimationCancel(element, record.id)}\n class={{\n 'toast-positioner': true,\n 'toast-positioner--anchored': anchored,\n 'toast-positioner--ending': record.transitionStatus === 'ending',\n }}\n data-toast-id={record.id}\n data-behind={!anchored && layout.index > 0 ? '' : undefined}\n data-limited={layout.limited ? '' : undefined}\n data-positioned={anchored ? String(!!anchoredPosition) : undefined}\n data-side={anchoredPosition?.side}\n inert={layout.limited ? true : undefined}\n aria-hidden={layout.limited ? 'true' : undefined}\n style={{\n '--toast-index': String(layout.index),\n '--toast-offset-y': `${layout.offset}px`,\n '--toast-height': toastHeight ? `${toastHeight}px` : undefined,\n zIndex: String(this.records.length - layout.index),\n left: anchoredPosition ? `${anchoredPosition.x}px` : undefined,\n top: anchoredPosition ? `${anchoredPosition.y}px` : undefined,\n }}\n onAnimationEnd={() => this.handleAnimationComplete(record)}\n >\n <div\n class={{\n 'toast-surface': true,\n 'toast-surface--swiping': !!swipe,\n }}\n role={record.priority === 'high' ? 'alertdialog' : 'dialog'}\n aria-modal=\"false\"\n aria-labelledby={\n record.title ? titleId : record.description ? descriptionId : undefined\n }\n aria-describedby={record.title && record.description ? descriptionId : undefined}\n aria-hidden={hiddenHighPriority ? 'true' : undefined}\n data-type={record.type}\n data-priority={record.priority}\n data-transition-status={record.transitionStatus}\n data-swiping={swipe ? '' : undefined}\n data-swipe-direction={swipeDirection}\n style={{\n '--toast-swipe-movement-x': `${swipe?.x ?? 0}px`,\n '--toast-swipe-movement-y': `${swipe?.y ?? 0}px`,\n touchAction: this.touchAction,\n }}\n onPointerDown={(event: PointerEvent) => this.handlePointerDown(record, event)}\n onPointerMove={(event: PointerEvent) => this.handlePointerMove(event)}\n onPointerUp={(event: PointerEvent) => this.handlePointerUp(event)}\n onPointerCancel={(event: PointerEvent) => this.handlePointerCancel(event)}\n onPointerEnter={\n anchored ? () => this.handlePointerEnter(record.id) : undefined\n }\n onPointerLeave={\n anchored ? () => this.handlePointerLeave(record.id) : undefined\n }\n >\n <div class=\"toast-content\">\n <div class=\"toast-copy\">\n {record.title && (\n <ds-text\n class=\"toast-title\"\n as=\"div\"\n variant=\"text-title-small\"\n color=\"primary\"\n textId={titleId}\n >\n {record.title}\n </ds-text>\n )}\n {record.description && (\n <ds-text\n class=\"toast-description\"\n as=\"div\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n textId={descriptionId}\n >\n {record.description}\n </ds-text>\n )}\n </div>\n {record.action && (\n <ds-button-unfilled\n class=\"toast-action\"\n variant=\"label\"\n size=\"md\"\n type=\"button\"\n label={record.action.label}\n aria-label={record.action.ariaLabel}\n hasBorder\n focusTabIndex={focusTabIndex}\n aria-hidden={!this.keyboardActive ? 'true' : undefined}\n onDsClick={(event: CustomEvent<MouseEvent>) =>\n this.handleAction(record, event)\n }\n />\n )}\n </div>\n <ds-button-unfilled\n class=\"toast-close\"\n variant=\"icon\"\n size=\"md\"\n type=\"button\"\n icon=\"Cross\"\n aria-label={this.closeLabel}\n hasBorder={false}\n activeFill={false}\n focusTabIndex={focusTabIndex}\n aria-hidden={!this.keyboardActive ? 'true' : undefined}\n onDsClick={() => this.manager.close(record.id, 'close-button')}\n />\n </div>\n </div>\n );\n }\n\n render() {\n void this.layoutVersion;\n const globalLayouts = this.globalLayout();\n const globalRecords = this.records.filter(record => !record.positioner);\n const anchoredRecords = this.records.filter(record => !!record.positioner);\n const highPriority = this.records.filter(\n record =>\n record.priority === 'high' &&\n record.transitionStatus !== 'ending' &&\n !globalLayouts.get(record.id)?.limited,\n );\n\n return (\n <Host>\n <section\n class=\"toast-region\"\n role=\"region\"\n aria-label={this.label}\n aria-live=\"polite\"\n aria-atomic=\"false\"\n aria-relevant=\"additions text\"\n tabIndex={-1}\n onFocusin={this.handleFocusIn}\n onFocusout={(event: FocusEvent) => this.handleFocusOut(event)}\n onKeyDown={(event: KeyboardEvent) => this.handleRegionKeyDown(event)}\n >\n <div\n class=\"toast-viewport\"\n data-expanded={this.expanded ? '' : undefined}\n onPointerEnter={() => this.handlePointerEnter(GLOBAL_STACK_HOVER)}\n onPointerLeave={() => this.handlePointerLeave(GLOBAL_STACK_HOVER)}\n >\n {globalRecords.map(record =>\n this.renderToast(\n record,\n globalLayouts.get(record.id) ?? { index: 0, offset: 0, limited: false },\n )\n )}\n </div>\n <div class=\"toast-anchored-layer\">\n {anchoredRecords.map(record =>\n this.renderToast(record, { index: 0, offset: 0, limited: false })\n )}\n </div>\n <div class=\"toast-assertive-announcer ds-visually-hidden\">\n {highPriority.map(record => (\n <div role=\"alert\" aria-atomic=\"true\">\n {[record.title, record.description].filter(Boolean).join('. ')}\n </div>\n ))}\n </div>\n </section>\n </Host>\n );\n }\n}\n",
|
|
333
|
+
"content": "import {\n Component,\n Element,\n Event,\n EventEmitter,\n h,\n Host,\n Prop,\n State,\n Watch,\n} from '@stencil/core';\nimport {\n resolveCssLengthPx,\n resolveCssTimeMs,\n resolveMotionTimeMs,\n TOKEN_DEFAULTS,\n} from '../../utils';\nimport {\n toastManager as defaultToastManager,\n type ToastActionEventDetail,\n type ToastCloseContext,\n type ToastCloseEventDetail,\n type ToastEventDetail,\n type ToastManager,\n type ToastRecord,\n type ToastSwipeDirection,\n} from '../../toast';\nimport { computeAnchoredPosition } from '../../utils/anchored-position';\n\ninterface ToastTimer {\n timerKey: number;\n remaining: number;\n startedAt: number;\n handle: ReturnType<typeof setTimeout> | null;\n}\n\ninterface SwipeState {\n id: string;\n x: number;\n y: number;\n direction?: ToastSwipeDirection;\n}\n\ninterface AnchoredPosition {\n x: number;\n y: number;\n side: 'top' | 'right' | 'bottom' | 'left';\n}\n\ninterface ActiveSwipe {\n id: string;\n pointerId: number;\n pointerType: string;\n startX: number;\n startY: number;\n maxDistance: number;\n cancelled: boolean;\n axis: 'x' | 'y' | null;\n}\n\nconst SWIPE_THRESHOLD = 40;\nconst SWIPE_REVERSE_CANCEL_THRESHOLD = 10;\nconst GLOBAL_STACK_HOVER = '__global-stack__';\nconst INTERACTIVE_SWIPE_SELECTOR =\n 'button,a,input,textarea,select,[role=\"button\"],[data-ds-toast-swipe-ignore]';\n\nconst attachedManagers = new WeakMap<ToastManager, Toast>();\nconst connectedToastRegions = new Set<Toast>();\nlet activeToastRegion: Toast | null = null;\nlet toastInstanceCounter = 0;\n\n@Component({\n tag: 'ds-toast',\n styleUrl: 'Toast.css',\n scoped: true,\n})\nexport class Toast {\n @Element() el!: HTMLElement;\n\n /** Manager that supplies toast records. Assign as a JavaScript property. */\n @Prop() manager: ToastManager = defaultToastManager;\n /** Maximum visible global-stack records. Limited records remain mounted and inert. */\n @Prop() limit: number = 3;\n /** Default auto-dismiss delay. Use 0 for persistent records. */\n @Prop() timeout: number | string = TOKEN_DEFAULTS.animationDelayLong2;\n /** Accessible label for the notification region. */\n @Prop() label: string = 'Notifications';\n /** Localized accessible label for every toast close action. */\n @Prop() closeLabel: string = 'Close notification';\n /** Keep the global stack 16px above the persistent mobile shell bar below 768px. */\n @Prop({ attribute: 'avoid-mobile-shell-bar', reflect: true })\n avoidMobileShellBar: boolean = false;\n /** Allowed swipe-to-dismiss directions. Assign as a JavaScript property. */\n @Prop() swipeDirections: ToastSwipeDirection[] = ['down', 'right'];\n\n @State() private records: readonly ToastRecord[] = [];\n @State() private expanded = false;\n @State() private keyboardActive = false;\n @State() private swipe: SwipeState | null = null;\n @State() private layoutVersion = 0;\n\n @Event() dsToastClose!: EventEmitter<ToastCloseEventDetail>;\n @Event() dsToastRemove!: EventEmitter<ToastEventDetail>;\n @Event() dsToastAction!: EventEmitter<ToastActionEventDetail>;\n\n private readonly instanceId = ++toastInstanceCounter;\n private readonly domIds = new Map<string, number>();\n private readonly timers = new Map<string, ToastTimer>();\n private readonly closeWatchdogs = new Map<string, ReturnType<typeof setTimeout>>();\n private readonly closeFrames = new Map<string, number>();\n private readonly closeEvents = new Set<string>();\n private readonly activationFrames = new Map<string, number>();\n private readonly pauseReasons = new Set<string>();\n private readonly hoveredIds = new Set<string>();\n private readonly heights = new Map<string, number>();\n private readonly measuredUpdateKeys = new Map<string, number>();\n private readonly anchoredPositions = new Map<string, AnchoredPosition>();\n private readonly dismissedSwipeDirections = new Map<string, ToastSwipeDirection>();\n private readonly observedElements = new Set<Element>();\n private readonly pendingRemovals: Array<{\n manager: ToastManager;\n context: ToastCloseContext;\n }> = [];\n\n private unsubscribe: (() => void) | null = null;\n private attachedManager: ToastManager | null = null;\n private previousFocus: HTMLElement | null = null;\n private resizeObserver: ResizeObserver | null = null;\n private mutationObserver: MutationObserver | null = null;\n private layoutFrame: number | null = null;\n private activeSwipe: ActiveSwipe | null = null;\n private nextDomId = 0;\n private connected = false;\n private pendingHoverLeave = false;\n\n connectedCallback() {\n this.connect();\n }\n\n componentDidLoad() {\n this.connect();\n }\n\n private connect() {\n if (this.connected) return;\n this.connected = true;\n this.resizeObserver ??= new ResizeObserver(() => this.scheduleLayout());\n this.mutationObserver ??= new MutationObserver(() => this.scheduleLayout());\n this.mutationObserver.observe(document.body, { childList: true, subtree: true });\n if (!this.unsubscribe) this.attachManager(this.manager);\n window.addEventListener('blur', this.handleWindowBlur);\n window.addEventListener('focus', this.handleWindowFocus);\n window.addEventListener('resize', this.handleWindowResize);\n window.addEventListener('scroll', this.scheduleLayout, true);\n document.addEventListener('keydown', this.handleDocumentKeyDown);\n document.addEventListener('visibilitychange', this.handleVisibilityChange);\n connectedToastRegions.add(this);\n // eslint-disable-next-line @typescript-eslint/no-this-alias -- module coordinator tracks the active connected viewport\n if (!activeToastRegion) activeToastRegion = this;\n }\n\n componentDidRender() {\n this.syncObservedElements();\n this.flushPendingRemovals();\n this.scheduleLayout();\n }\n\n disconnectedCallback() {\n this.connected = false;\n this.flushPendingRemovals();\n this.detachManager();\n this.resetManagerState();\n this.resizeObserver?.disconnect();\n this.resizeObserver = null;\n this.mutationObserver?.disconnect();\n this.mutationObserver = null;\n this.observedElements.clear();\n if (this.layoutFrame !== null) cancelAnimationFrame(this.layoutFrame);\n this.layoutFrame = null;\n window.removeEventListener('blur', this.handleWindowBlur);\n window.removeEventListener('focus', this.handleWindowFocus);\n window.removeEventListener('resize', this.handleWindowResize);\n window.removeEventListener('scroll', this.scheduleLayout, true);\n document.removeEventListener('keydown', this.handleDocumentKeyDown);\n document.removeEventListener('visibilitychange', this.handleVisibilityChange);\n connectedToastRegions.delete(this);\n if (activeToastRegion === this) {\n const remaining = Array.from(connectedToastRegions);\n activeToastRegion = remaining[remaining.length - 1] ?? null;\n }\n }\n\n @Watch('manager')\n onManagerChange(next: ToastManager) {\n this.detachManager();\n this.resetManagerState();\n if (this.connected) this.attachManager(next);\n }\n\n @Watch('timeout')\n onTimeoutChange() {\n for (const timer of this.timers.values()) {\n if (timer.handle) clearTimeout(timer.handle);\n }\n this.timers.clear();\n this.syncTimers();\n }\n\n private attachManager(manager: ToastManager) {\n const attached = attachedManagers.get(manager);\n if (attached && attached !== this) {\n console.warn('[ds-toast] A manager can control only one connected ds-toast instance.');\n return;\n }\n attachedManagers.set(manager, this);\n this.attachedManager = manager;\n this.unsubscribe = manager.subscribe(records => {\n this.records = records;\n this.syncLifecycle(records);\n this.scheduleLayout();\n });\n }\n\n private detachManager() {\n this.unsubscribe?.();\n this.unsubscribe = null;\n if (this.attachedManager && attachedManagers.get(this.attachedManager) === this) {\n attachedManagers.delete(this.attachedManager);\n }\n this.attachedManager = null;\n }\n\n private resetManagerState() {\n this.flushPendingRemovals();\n if (this.el.contains(document.activeElement)) this.restorePreviousFocus();\n this.clearAllTimers();\n for (const frame of this.activationFrames.values()) cancelAnimationFrame(frame);\n for (const frame of this.closeFrames.values()) cancelAnimationFrame(frame);\n this.activationFrames.clear();\n this.closeFrames.clear();\n this.closeEvents.clear();\n this.pauseReasons.clear();\n this.hoveredIds.clear();\n this.pendingHoverLeave = false;\n this.heights.clear();\n this.measuredUpdateKeys.clear();\n this.anchoredPositions.clear();\n this.dismissedSwipeDirections.clear();\n this.domIds.clear();\n this.activeSwipe = null;\n this.swipe = null;\n this.expanded = false;\n this.keyboardActive = false;\n this.records = [];\n this.resizeObserver?.disconnect();\n this.observedElements.clear();\n }\n\n private syncObservedElements() {\n const next = new Set<Element>(\n this.el.querySelectorAll<HTMLElement>('.toast-positioner'),\n );\n for (const record of this.records) {\n if (!record.positioner) continue;\n const anchor = this.resolveAnchor(record.positioner.anchor);\n if (anchor) next.add(anchor);\n }\n\n for (const element of this.observedElements) {\n if (!next.has(element)) {\n this.resizeObserver?.unobserve(element);\n this.observedElements.delete(element);\n }\n }\n for (const element of next) {\n if (this.observedElements.has(element)) continue;\n this.resizeObserver?.observe(element);\n this.observedElements.add(element);\n }\n }\n\n private flushPendingRemovals() {\n for (const pending of this.pendingRemovals.splice(0)) {\n pending.manager.notifyRemove(pending.context);\n this.dsToastRemove.emit({\n id: pending.context.id,\n toast: pending.context.toast,\n });\n }\n }\n\n private syncLifecycle(records: readonly ToastRecord[]) {\n const recordIds = new Set(records.map(record => record.id));\n for (const id of this.hoveredIds) {\n if (id !== GLOBAL_STACK_HOVER && !recordIds.has(id)) this.hoveredIds.delete(id);\n }\n const hasEndingRecords = records.some(\n record => record.transitionStatus === 'ending',\n );\n if (this.pendingHoverLeave && !hasEndingRecords && !this.hoveredIds.size) {\n this.pendingHoverLeave = false;\n this.expanded = false;\n this.resumeTimers('hover');\n } else if (!this.hoveredIds.size && !this.pendingHoverLeave) {\n this.resumeTimers('hover');\n }\n\n for (const [id, timer] of this.timers) {\n if (recordIds.has(id)) continue;\n if (timer.handle) clearTimeout(timer.handle);\n this.timers.delete(id);\n }\n\n for (const [id, watchdog] of this.closeWatchdogs) {\n if (recordIds.has(id)) continue;\n clearTimeout(watchdog);\n this.closeWatchdogs.delete(id);\n }\n\n for (const [id, frame] of this.activationFrames) {\n if (recordIds.has(id)) continue;\n cancelAnimationFrame(frame);\n this.activationFrames.delete(id);\n }\n\n for (const [id, frame] of this.closeFrames) {\n if (recordIds.has(id)) continue;\n cancelAnimationFrame(frame);\n this.closeFrames.delete(id);\n }\n\n for (const id of this.domIds.keys()) {\n if (recordIds.has(id)) continue;\n this.closeEvents.delete(id);\n this.domIds.delete(id);\n this.heights.delete(id);\n this.measuredUpdateKeys.delete(id);\n this.anchoredPositions.delete(id);\n this.dismissedSwipeDirections.delete(id);\n }\n\n for (const record of records) {\n this.ensureDomId(record.id);\n if (\n record.transitionStatus === 'starting' &&\n !this.activationFrames.has(record.id)\n ) {\n const manager = this.attachedManager;\n const frame = requestAnimationFrame(() => {\n this.activationFrames.delete(record.id);\n manager?.activate(record.id);\n });\n this.activationFrames.set(record.id, frame);\n }\n\n if (record.transitionStatus === 'ending') {\n this.clearToastTimer(record.id);\n this.beginRemoval(record);\n } else {\n const watchdog = this.closeWatchdogs.get(record.id);\n if (watchdog) clearTimeout(watchdog);\n this.closeWatchdogs.delete(record.id);\n const closeFrame = this.closeFrames.get(record.id);\n if (closeFrame) cancelAnimationFrame(closeFrame);\n this.closeFrames.delete(record.id);\n this.closeEvents.delete(record.id);\n }\n }\n\n this.syncTimers();\n }\n\n private syncTimers() {\n const activeIds = new Set<string>();\n for (const record of this.records) {\n if (record.transitionStatus !== 'active') continue;\n activeIds.add(record.id);\n const duration = this.resolveToastTimeout(record);\n if (duration <= 0) {\n this.clearToastTimer(record.id);\n continue;\n }\n\n const existing = this.timers.get(record.id);\n if (!existing || existing.timerKey !== record.timerKey) {\n this.clearToastTimer(record.id);\n this.timers.set(record.id, {\n timerKey: record.timerKey,\n remaining: duration,\n startedAt: 0,\n handle: null,\n });\n }\n if (!this.isPaused) this.startToastTimer(record.id);\n }\n\n for (const id of this.timers.keys()) {\n if (!activeIds.has(id)) this.clearToastTimer(id);\n }\n }\n\n private resolveToastTimeout(record: ToastRecord): number {\n const value = record.timeout ?? this.timeout;\n if (typeof value === 'number') return Math.max(0, value);\n return resolveCssTimeMs(value, TOKEN_DEFAULTS.animationDelayLong2);\n }\n\n private startToastTimer(id: string) {\n const timer = this.timers.get(id);\n if (!timer || timer.handle || timer.remaining <= 0) return;\n timer.startedAt = Date.now();\n timer.handle = setTimeout(() => {\n timer.handle = null;\n timer.remaining = 0;\n this.manager.close(id, 'timeout');\n }, timer.remaining);\n }\n\n private pauseTimers(reason: string) {\n const wasPaused = this.isPaused;\n this.pauseReasons.add(reason);\n if (wasPaused) return;\n const now = Date.now();\n for (const timer of this.timers.values()) {\n if (!timer.handle) continue;\n clearTimeout(timer.handle);\n timer.handle = null;\n timer.remaining = Math.max(0, timer.remaining - (now - timer.startedAt));\n }\n }\n\n private resumeTimers(reason: string) {\n this.pauseReasons.delete(reason);\n if (this.isPaused) return;\n for (const id of this.timers.keys()) this.startToastTimer(id);\n }\n\n private get isPaused(): boolean {\n return this.pauseReasons.size > 0;\n }\n\n private clearToastTimer(id: string) {\n const timer = this.timers.get(id);\n if (timer?.handle) clearTimeout(timer.handle);\n this.timers.delete(id);\n }\n\n private clearAllTimers() {\n for (const timer of this.timers.values()) {\n if (timer.handle) clearTimeout(timer.handle);\n }\n for (const watchdog of this.closeWatchdogs.values()) clearTimeout(watchdog);\n this.timers.clear();\n this.closeWatchdogs.clear();\n }\n\n private beginRemoval(record: ToastRecord) {\n if (!this.closeEvents.has(record.id)) {\n this.closeEvents.add(record.id);\n this.dsToastClose.emit({\n id: record.id,\n reason: record.closeReason ?? 'programmatic',\n toast: record,\n });\n this.moveFocusAfterClose(record.id);\n }\n if (\n this.closeWatchdogs.has(record.id) ||\n this.closeFrames.has(record.id)\n ) return;\n\n const duration = resolveMotionTimeMs(\n TOKEN_DEFAULTS.motionShort3,\n TOKEN_DEFAULTS.animationDurationShort3,\n );\n if (duration <= 0) {\n queueMicrotask(() => this.completeRemoval(record));\n return;\n }\n const frame = requestAnimationFrame(() => {\n this.closeFrames.delete(record.id);\n const current = this.records.find(candidate => candidate.id === record.id);\n if (!current || current.transitionStatus !== 'ending') return;\n this.closeWatchdogs.set(\n record.id,\n setTimeout(() => this.completeRemoval(current), duration),\n );\n });\n this.closeFrames.set(record.id, frame);\n }\n\n private completeRemoval(record: ToastRecord) {\n const current = this.records.find(candidate => candidate.id === record.id);\n if (!current || current.transitionStatus !== 'ending') return;\n const watchdog = this.closeWatchdogs.get(record.id);\n if (watchdog) clearTimeout(watchdog);\n this.closeWatchdogs.delete(record.id);\n const closeFrame = this.closeFrames.get(record.id);\n if (closeFrame) cancelAnimationFrame(closeFrame);\n this.closeFrames.delete(record.id);\n const manager = this.attachedManager;\n const context = manager?.remove(record.id);\n if (manager && context) this.pendingRemovals.push({ manager, context });\n }\n\n private handleAnimationComplete(record: ToastRecord) {\n if (record.transitionStatus === 'ending') this.completeRemoval(record);\n }\n\n private bindAnimationCancel(element: HTMLDivElement | undefined, id: string) {\n if (!element || element.dataset['cancelBound'] === 'true') return;\n element.dataset['cancelBound'] = 'true';\n element.addEventListener('animationcancel', () => {\n const record = this.records.find(candidate => candidate.id === id);\n if (record) this.handleAnimationComplete(record);\n });\n }\n\n private handleWindowBlur = () => this.pauseTimers('window');\n private handleWindowFocus = () => this.resumeTimers('window');\n private handleVisibilityChange = () => {\n if (document.hidden) this.pauseTimers('document');\n else this.resumeTimers('document');\n };\n\n private handlePointerEnter(id: string) {\n // eslint-disable-next-line @typescript-eslint/no-this-alias -- pointer interaction selects the viewport that receives F6\n activeToastRegion = this;\n this.pendingHoverLeave = false;\n this.hoveredIds.add(id);\n this.expanded = true;\n this.pauseTimers('hover');\n }\n\n private handlePointerLeave(id: string) {\n this.hoveredIds.delete(id);\n if (this.hoveredIds.size) return;\n if (this.records.some(record => record.transitionStatus === 'ending')) {\n this.pendingHoverLeave = true;\n return;\n }\n this.expanded = false;\n this.resumeTimers('hover');\n }\n\n private handleFocusIn = () => {\n const active = document.activeElement;\n if (!(active instanceof HTMLElement) || !active.matches(':focus-visible')) return;\n // eslint-disable-next-line @typescript-eslint/no-this-alias -- module coordinator routes F6 to the last focused viewport\n activeToastRegion = this;\n this.keyboardActive = true;\n this.expanded = true;\n this.pauseTimers('focus');\n };\n\n private handleFocusOut = (event: FocusEvent) => {\n const next = event.relatedTarget;\n if (next instanceof Node && this.el.contains(next)) return;\n if (!this.keyboardActive) return;\n this.keyboardActive = false;\n this.expanded = this.hoveredIds.size > 0;\n this.resumeTimers('focus');\n };\n\n private handleDocumentKeyDown = (event: KeyboardEvent) => {\n if (event.key !== 'F6') return;\n const eligible = Array.from(connectedToastRegions).filter(\n region => region.activeRecords.length > 0,\n );\n const target =\n activeToastRegion && activeToastRegion.activeRecords.length\n ? activeToastRegion\n : eligible[eligible.length - 1];\n if (target !== this) return;\n event.preventDefault();\n if (!this.el.contains(document.activeElement)) {\n this.previousFocus = document.activeElement as HTMLElement | null;\n }\n this.keyboardActive = true;\n this.regionElement?.focus();\n };\n\n private handleRegionKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n const target = event.target as HTMLElement;\n const positioner = target.closest<HTMLElement>('.toast-positioner');\n const id = positioner?.dataset['toastId'] ?? this.activeRecords[0]?.id;\n if (id) {\n event.preventDefault();\n this.manager.close(id, 'escape');\n }\n return;\n }\n\n if (event.key === 'Tab' && event.shiftKey && event.target === this.regionElement) {\n event.preventDefault();\n this.restorePreviousFocus();\n }\n };\n\n private get regionElement(): HTMLElement | null {\n return this.el.querySelector<HTMLElement>('.toast-region');\n }\n\n private restorePreviousFocus() {\n this.previousFocus?.focus?.();\n this.previousFocus = null;\n this.keyboardActive = false;\n }\n\n private moveFocusAfterClose(id: string) {\n const active = document.activeElement;\n const closing = this.positionerElement(id);\n if (!active || !closing?.contains(active)) return;\n requestAnimationFrame(() => {\n const next = this.activeRecords.find(record => record.id !== id);\n const control = next\n ? this.positionerElement(next.id)?.querySelector<\n HTMLElement & { setFocus?: () => Promise<void> }\n >('ds-button-unfilled')\n : null;\n if (control?.setFocus) void control.setFocus();\n else if (next) this.regionElement?.focus();\n else this.restorePreviousFocus();\n });\n }\n\n private handleAction(record: ToastRecord, event: CustomEvent<MouseEvent>) {\n const detail: ToastActionEventDetail = {\n id: record.id,\n toast: record,\n originalEvent: event.detail,\n };\n this.dsToastAction.emit(detail);\n record.action?.onAction?.({\n id: record.id,\n data: record.data,\n manager: this.manager,\n originalEvent: event.detail,\n });\n }\n\n private handlePointerDown(record: ToastRecord, event: PointerEvent) {\n if (event.button !== 0) return;\n const target = event.target as Element;\n if (target.closest(INTERACTIVE_SWIPE_SELECTOR)) return;\n\n const surface = event.currentTarget as HTMLElement;\n surface.setPointerCapture(event.pointerId);\n this.activeSwipe = {\n id: record.id,\n pointerId: event.pointerId,\n pointerType: event.pointerType,\n startX: event.clientX,\n startY: event.clientY,\n maxDistance: 0,\n cancelled: false,\n axis: null,\n };\n this.swipe = { id: record.id, x: 0, y: 0 };\n if (event.pointerType === 'touch') this.pauseTimers('swipe');\n }\n\n private handlePointerMove(event: PointerEvent) {\n const active = this.activeSwipe;\n if (!active || active.pointerId !== event.pointerId || active.cancelled) return;\n const dx = event.clientX - active.startX;\n const dy = event.clientY - active.startY;\n if (!active.axis && Math.max(Math.abs(dx), Math.abs(dy)) > 1) {\n active.axis = Math.abs(dx) >= Math.abs(dy) ? 'x' : 'y';\n }\n if (!active.axis) return;\n const horizontal = active.axis === 'x';\n const direction: ToastSwipeDirection = horizontal\n ? dx >= 0 ? 'right' : 'left'\n : dy >= 0 ? 'down' : 'up';\n const allowed = this.swipeDirections.includes(direction);\n const rawDistance = horizontal ? Math.abs(dx) : Math.abs(dy);\n const distance = allowed ? rawDistance : Math.sqrt(rawDistance);\n const signed = (horizontal ? dx : dy) < 0 ? -distance : distance;\n const x = horizontal ? signed : 0;\n const y = horizontal ? 0 : signed;\n\n if (active.maxDistance - distance >= SWIPE_REVERSE_CANCEL_THRESHOLD) {\n active.cancelled = true;\n this.cancelSwipe();\n return;\n }\n active.maxDistance = Math.max(active.maxDistance, distance);\n if (rawDistance > 1) event.preventDefault();\n this.swipe = { id: active.id, x, y, direction: allowed ? direction : undefined };\n }\n\n private handlePointerUp(event: PointerEvent) {\n const active = this.activeSwipe;\n if (!active || active.pointerId !== event.pointerId) return;\n const swipe = this.swipe;\n const distance = swipe ? Math.max(Math.abs(swipe.x), Math.abs(swipe.y)) : 0;\n const shouldDismiss =\n !active.cancelled &&\n !!swipe?.direction &&\n distance >= SWIPE_THRESHOLD;\n const id = active.id;\n const wasTouch = active.pointerType === 'touch';\n this.activeSwipe = null;\n this.swipe = null;\n if (wasTouch) this.resumeTimers('swipe');\n if (shouldDismiss && swipe.direction) {\n this.dismissedSwipeDirections.set(id, swipe.direction);\n this.manager.close(id, 'swipe');\n }\n }\n\n private handlePointerCancel(event: PointerEvent) {\n if (this.activeSwipe?.pointerId !== event.pointerId) return;\n this.cancelSwipe();\n }\n\n private cancelSwipe() {\n const wasTouch = this.activeSwipe?.pointerType === 'touch';\n this.activeSwipe = null;\n this.swipe = null;\n if (wasTouch) this.resumeTimers('swipe');\n }\n\n private get touchAction(): string {\n const horizontal =\n this.swipeDirections.includes('left') || this.swipeDirections.includes('right');\n const vertical =\n this.swipeDirections.includes('up') || this.swipeDirections.includes('down');\n if (horizontal && vertical) return 'none';\n if (horizontal) return 'pan-y';\n if (vertical) return 'pan-x';\n return 'auto';\n }\n\n private handleWindowResize = () => {\n this.heights.clear();\n this.measuredUpdateKeys.clear();\n this.layoutVersion += 1;\n this.scheduleLayout();\n };\n\n private scheduleLayout = () => {\n if (this.layoutFrame !== null) return;\n this.layoutFrame = requestAnimationFrame(() => {\n this.layoutFrame = null;\n this.measureLayout();\n });\n };\n\n private measureNaturalHeight(element: HTMLElement, expandedWidth: boolean): number {\n const surface = element.querySelector<HTMLElement>('.toast-surface');\n if (!surface) return 0;\n const previousWidth = element.style.width;\n const previousTransition = element.style.transition;\n const previousHeight = surface.style.height;\n\n if (expandedWidth) {\n element.style.transition = 'none';\n element.style.width = '100%';\n }\n surface.style.height = 'auto';\n const height = surface.getBoundingClientRect().height;\n\n surface.style.height = previousHeight;\n if (expandedWidth) {\n element.style.width = previousWidth;\n element.getBoundingClientRect();\n element.style.transition = previousTransition;\n }\n return height;\n }\n\n private measureLayout() {\n let changed = false;\n for (const record of this.records) {\n const element = this.positionerElement(record.id);\n if (!element) continue;\n let height = this.heights.get(record.id) ?? 0;\n const needsHeightMeasurement =\n height <= 0 || this.measuredUpdateKeys.get(record.id) !== record.updateKey;\n if (needsHeightMeasurement) {\n height = this.measureNaturalHeight(element, !record.positioner);\n if (height > 0) {\n if (this.heights.get(record.id) !== height) changed = true;\n this.heights.set(record.id, height);\n this.measuredUpdateKeys.set(record.id, record.updateKey);\n }\n }\n\n if (!record.positioner) continue;\n const anchor = this.resolveAnchor(record.positioner.anchor);\n if (!anchor || height <= 0) {\n if (this.anchoredPositions.delete(record.id)) changed = true;\n continue;\n }\n const rect = element.getBoundingClientRect();\n const sideOffset = resolveCssLengthPx(\n record.positioner.sideOffset,\n TOKEN_DEFAULTS.space050,\n );\n const alignOffset = resolveCssLengthPx(record.positioner.alignOffset, 0);\n const position = computeAnchoredPosition({\n anchorRect: anchor.getBoundingClientRect(),\n popupWidth: rect.width,\n popupHeight: height,\n side: record.positioner.side ?? 'top',\n align: record.positioner.align ?? 'center',\n sideOffsetPx: sideOffset,\n alignOffsetPx: alignOffset,\n viewportPadPx: resolveCssLengthPx(undefined, TOKEN_DEFAULTS.space200),\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n });\n const current = this.anchoredPositions.get(record.id);\n if (\n !current ||\n current.x !== position.x ||\n current.y !== position.y ||\n current.side !== position.resolvedSide\n ) {\n this.anchoredPositions.set(record.id, {\n x: position.x,\n y: position.y,\n side: position.resolvedSide,\n });\n changed = true;\n }\n }\n if (changed) this.layoutVersion += 1;\n }\n\n private resolveAnchor(anchor: HTMLElement | string): HTMLElement | null {\n return typeof anchor === 'string' ? document.getElementById(anchor) : anchor;\n }\n\n private positionerElement(id: string): HTMLElement | null {\n return (\n Array.from(this.el.querySelectorAll<HTMLElement>('.toast-positioner'))\n .find(element => element.dataset['toastId'] === id) ?? null\n );\n }\n\n private ensureDomId(id: string): number {\n const current = this.domIds.get(id);\n if (current) return current;\n const next = ++this.nextDomId;\n this.domIds.set(id, next);\n return next;\n }\n\n private get activeRecords(): ToastRecord[] {\n return this.records.filter(record => record.transitionStatus !== 'ending');\n }\n\n private globalLayout() {\n const globalRecords = this.records.filter(record => !record.positioner);\n let offset = 0;\n let visibleIndex = 0;\n const gap = resolveCssLengthPx(undefined, TOKEN_DEFAULTS.space200);\n const layouts = new Map<string, { index: number; offset: number; limited: boolean }>();\n\n globalRecords.forEach((record, domIndex) => {\n if (record.transitionStatus === 'ending') {\n layouts.set(record.id, { index: domIndex, offset, limited: false });\n return;\n }\n\n const limited = visibleIndex >= Math.max(1, this.limit);\n layouts.set(record.id, { index: visibleIndex, offset, limited });\n offset += (this.heights.get(record.id) ?? 0) + gap;\n visibleIndex += 1;\n });\n return layouts;\n }\n\n private renderToast(\n record: ToastRecord,\n layout: { index: number; offset: number; limited: boolean },\n ) {\n const domId = this.ensureDomId(record.id);\n const titleId = `ds-toast-${this.instanceId}-${domId}-title`;\n const descriptionId = `ds-toast-${this.instanceId}-${domId}-description`;\n const swipe = this.swipe?.id === record.id ? this.swipe : null;\n const swipeDirection = swipe?.direction ?? this.dismissedSwipeDirections.get(record.id);\n const anchored = !!record.positioner;\n const anchoredPosition = this.anchoredPositions.get(record.id);\n const hiddenHighPriority = record.priority === 'high' && !this.keyboardActive;\n const focusTabIndex = this.keyboardActive && !layout.limited ? 0 : -1;\n const toastHeight = this.heights.get(record.id);\n\n return (\n <div\n ref={(element?: HTMLDivElement) => this.bindAnimationCancel(element, record.id)}\n class={{\n 'toast-positioner': true,\n 'toast-positioner--anchored': anchored,\n 'toast-positioner--ending': record.transitionStatus === 'ending',\n }}\n data-toast-id={record.id}\n data-behind={!anchored && layout.index > 0 ? '' : undefined}\n data-limited={layout.limited ? '' : undefined}\n data-positioned={anchored ? String(!!anchoredPosition) : undefined}\n data-side={anchoredPosition?.side}\n inert={layout.limited ? true : undefined}\n aria-hidden={layout.limited ? 'true' : undefined}\n style={{\n '--toast-index': String(layout.index),\n '--toast-offset-y': `${layout.offset}px`,\n '--toast-height': toastHeight ? `${toastHeight}px` : undefined,\n zIndex: String(this.records.length - layout.index),\n left: anchoredPosition ? `${anchoredPosition.x}px` : undefined,\n top: anchoredPosition ? `${anchoredPosition.y}px` : undefined,\n }}\n onAnimationEnd={() => this.handleAnimationComplete(record)}\n >\n <div\n class={{\n 'toast-surface': true,\n 'toast-surface--swiping': !!swipe,\n }}\n role={record.priority === 'high' ? 'alertdialog' : 'dialog'}\n aria-modal=\"false\"\n aria-labelledby={\n record.title ? titleId : record.description ? descriptionId : undefined\n }\n aria-describedby={record.title && record.description ? descriptionId : undefined}\n aria-hidden={hiddenHighPriority ? 'true' : undefined}\n data-type={record.type}\n data-priority={record.priority}\n data-transition-status={record.transitionStatus}\n data-swiping={swipe ? '' : undefined}\n data-swipe-direction={swipeDirection}\n style={{\n '--toast-swipe-movement-x': `${swipe?.x ?? 0}px`,\n '--toast-swipe-movement-y': `${swipe?.y ?? 0}px`,\n touchAction: this.touchAction,\n }}\n onPointerDown={(event: PointerEvent) => this.handlePointerDown(record, event)}\n onPointerMove={(event: PointerEvent) => this.handlePointerMove(event)}\n onPointerUp={(event: PointerEvent) => this.handlePointerUp(event)}\n onPointerCancel={(event: PointerEvent) => this.handlePointerCancel(event)}\n onPointerEnter={\n anchored ? () => this.handlePointerEnter(record.id) : undefined\n }\n onPointerLeave={\n anchored ? () => this.handlePointerLeave(record.id) : undefined\n }\n >\n <div class=\"toast-content\">\n <div class=\"toast-copy\">\n {record.title && (\n <ds-text\n class=\"toast-title\"\n as=\"div\"\n variant=\"text-title-small\"\n color=\"primary\"\n textId={titleId}\n >\n {record.title}\n </ds-text>\n )}\n {record.description && (\n <ds-text\n class=\"toast-description\"\n as=\"div\"\n variant=\"text-body-medium\"\n color=\"secondary\"\n textId={descriptionId}\n >\n {record.description}\n </ds-text>\n )}\n </div>\n {record.action && (\n <ds-button-unfilled\n class=\"toast-action\"\n variant=\"label\"\n size=\"md\"\n type=\"button\"\n label={record.action.label}\n aria-label={record.action.ariaLabel}\n hasBorder\n focusTabIndex={focusTabIndex}\n aria-hidden={!this.keyboardActive ? 'true' : undefined}\n onDsClick={(event: CustomEvent<MouseEvent>) =>\n this.handleAction(record, event)\n }\n />\n )}\n </div>\n <ds-button-unfilled\n class=\"toast-close\"\n variant=\"icon\"\n size=\"md\"\n type=\"button\"\n icon=\"Cross\"\n aria-label={this.closeLabel}\n hasBorder={false}\n activeFill={false}\n focusTabIndex={focusTabIndex}\n aria-hidden={!this.keyboardActive ? 'true' : undefined}\n onDsClick={() => this.manager.close(record.id, 'close-button')}\n />\n </div>\n </div>\n );\n }\n\n render() {\n void this.layoutVersion;\n const globalLayouts = this.globalLayout();\n const globalRecords = this.records.filter(record => !record.positioner);\n const anchoredRecords = this.records.filter(record => !!record.positioner);\n const highPriority = this.records.filter(\n record =>\n record.priority === 'high' &&\n record.transitionStatus !== 'ending' &&\n !globalLayouts.get(record.id)?.limited,\n );\n\n return (\n <Host>\n <section\n class=\"toast-region\"\n role=\"region\"\n aria-label={this.label}\n aria-live=\"polite\"\n aria-atomic=\"false\"\n aria-relevant=\"additions text\"\n tabIndex={-1}\n onFocusin={this.handleFocusIn}\n onFocusout={(event: FocusEvent) => this.handleFocusOut(event)}\n onKeyDown={(event: KeyboardEvent) => this.handleRegionKeyDown(event)}\n >\n <div\n class=\"toast-viewport\"\n data-expanded={this.expanded ? '' : undefined}\n onPointerEnter={() => this.handlePointerEnter(GLOBAL_STACK_HOVER)}\n onPointerLeave={() => this.handlePointerLeave(GLOBAL_STACK_HOVER)}\n >\n {globalRecords.map(record =>\n this.renderToast(\n record,\n globalLayouts.get(record.id) ?? { index: 0, offset: 0, limited: false },\n )\n )}\n </div>\n <div class=\"toast-anchored-layer\">\n {anchoredRecords.map(record =>\n this.renderToast(record, { index: 0, offset: 0, limited: false })\n )}\n </div>\n <div class=\"toast-assertive-announcer ds-visually-hidden\">\n {highPriority.map(record => (\n <div role=\"alert\" aria-atomic=\"true\">\n {[record.title, record.description].filter(Boolean).join('. ')}\n </div>\n ))}\n </div>\n </section>\n </Host>\n );\n }\n}\n",
|
|
334
334
|
"type": "registry:ui"
|
|
335
335
|
}
|
|
336
336
|
]
|
|
@@ -51,6 +51,8 @@
|
|
|
51
51
|
"The owning chart mounts the callout when a local interaction starts and unmounts it when that interaction ends.",
|
|
52
52
|
"Coordinate updates track within one mounted interaction session without restarting the show delay.",
|
|
53
53
|
"Placement flips horizontally or vertically when the rendered callout approaches a viewport edge.",
|
|
54
|
+
"Placement is chosen as a quadrant rather than computed as coordinates: the component measures its own rendered rect and flips its side and align props, which CSS consumes. It therefore does not use the shared anchored-position utilities, which resolve an anchor element into x and y.",
|
|
55
|
+
"This separation is deliberate. The callout follows the pointer instead of an element, so it has no anchor rect to resolve; adopting the shared geometry would mean rewriting its CSS to consume coordinates for no behavioral gain.",
|
|
54
56
|
"Reduced motion preserves the final visible state without an opacity transition."
|
|
55
57
|
],
|
|
56
58
|
"responsiveBehavior": [
|
|
@@ -193,7 +195,7 @@
|
|
|
193
195
|
},
|
|
194
196
|
"peerDependencies": {
|
|
195
197
|
"required": [
|
|
196
|
-
"@ds-mo/tokens ^
|
|
198
|
+
"@ds-mo/tokens ^5.0.0",
|
|
197
199
|
"@ds-mo/icons ^6.4.0"
|
|
198
200
|
],
|
|
199
201
|
"frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
|
|
@@ -89,7 +89,7 @@
|
|
|
89
89
|
},
|
|
90
90
|
{
|
|
91
91
|
"label": "Positioning unit coverage",
|
|
92
|
-
"path": "tests/
|
|
92
|
+
"path": "tests/anchored-position.test.ts"
|
|
93
93
|
}
|
|
94
94
|
]
|
|
95
95
|
},
|
|
@@ -277,7 +277,7 @@
|
|
|
277
277
|
},
|
|
278
278
|
"peerDependencies": {
|
|
279
279
|
"required": [
|
|
280
|
-
"@ds-mo/tokens ^
|
|
280
|
+
"@ds-mo/tokens ^5.0.0",
|
|
281
281
|
"@ds-mo/icons ^6.4.0"
|
|
282
282
|
],
|
|
283
283
|
"frameworks": "Custom Elements; React 18/19 wrappers; Angular 19-22 standalone adapters."
|
|
@@ -299,12 +299,7 @@
|
|
|
299
299
|
},
|
|
300
300
|
{
|
|
301
301
|
"path": "src/wc/components/Tooltip/Tooltip.tsx",
|
|
302
|
-
"content": "import { Component, Prop, Element, Watch, h, Host } from '@stencil/core';\nimport {\n CONTROL_TEXT_VARIANT,\n resolveCssLengthPx,\n resolveCssTimeMs,\n resolveMotionTimeMs,\n TOKEN_CSS_LENGTHS,\n TOKEN_DEFAULTS,\n} from '../../utils';\nimport type { TextVariant } from '../Text/text-types';\nimport { shortcutKeyLabels } from '../../utils/shortcut-key';\nimport { computeTooltipPosition } from './tooltip-position';\n// Side-effect: register `ds-text` — the portal builds it via createElement, not JSX.\nimport '../Text/Text';\n\nexport type TooltipSide = 'top' | 'right' | 'bottom' | 'left';\nexport type TooltipAlign = 'start' | 'center' | 'end';\nexport type TooltipSize = 'md' | 'sm' | 'xs';\n\nconst FALLBACK_HEIGHT: Record<TooltipSize, string> = {\n md: TOKEN_DEFAULTS.size400,\n sm: TOKEN_DEFAULTS.size300,\n xs: TOKEN_DEFAULTS.size200,\n};\n\nlet lastDismissedAt = 0;\nlet tooltipIdCounter = 0;\n/** Currently open tooltip — force-cleared on warm handoff so tips don't cross-fade. */\nlet activeTooltip: Tooltip | null = null;\n\ntype DsTextEl = HTMLElement & {\n as: string;\n variant: TextVariant;\n emphasis: boolean;\n color: string;\n wrap: string;\n};\n\nfunction createDsText(opts: {\n variant: TextVariant;\n emphasis?: boolean;\n color?: string;\n wrap?: 'wrap' | 'nowrap' | 'balance' | 'pretty';\n text: string;\n}): DsTextEl {\n const el = document.createElement('ds-text') as DsTextEl;\n el.as = 'span';\n el.variant = opts.variant;\n el.emphasis = opts.emphasis ?? false;\n el.color = opts.color ?? 'inherit';\n if (opts.wrap) el.wrap = opts.wrap;\n el.textContent = opts.text;\n return el;\n}\n\n/**\n * Control-density label styles for portaled ds-text.\n * Scoped CSS requires `sc-ds-tooltip` on the host, but Text's `<Host class>`\n * overwrites imperative className — so these must be inline to stick.\n */\nfunction styleTooltipLabel(el: HTMLElement) {\n el.style.flexShrink = '0';\n el.style.minWidth = 'max-content';\n el.style.maxWidth = 'none';\n el.style.padding = '0 var(--ds-control-label-inset, var(--dimension-space-025))';\n el.style.color = 'var(--color-foreground-primary)';\n}\n\n/**\n * Imperative body portal for the popup.\n * Stencil must not own the portaled node — moving a VDOM child to `document.body`\n * causes recreate → re-portal → @State loops that freeze the page.\n */\n@Component({\n tag: 'ds-tooltip',\n styleUrl: 'Tooltip.css',\n scoped: true,\n})\nexport class Tooltip {\n @Element() el!: HTMLElement;\n\n /**\n * Tooltip text. Empty/whitespace skips show — useful when the wrapper must stay\n * mounted for layout/animation stability (e.g. PanelNav expand/collapse).\n */\n @Prop() label!: string;\n\n /** Control density (height, padding, type). */\n @Prop() size: TooltipSize = 'md';\n\n @Prop() side: TooltipSide = 'top';\n @Prop() align: TooltipAlign = 'center';\n /** Gap between anchor and tooltip — number (px) or TokoMo length. */\n @Prop() sideOffset: number | string = TOKEN_CSS_LENGTHS.space050;\n /** Cross-axis offset — number (px) or TokoMo length. */\n @Prop() alignOffset: number | string = 0;\n /**\n * Hover show delay before the tooltip appears.\n * Default: `--effect-animation-delay-medium-3` (1000ms). Accepts a number (ms)\n * or a TokoMo time token / `var(--effect-animation-delay-*)`. Applies to the\n * initial hover/focus show only — after a recent dismiss, reopen is instant.\n * Prefer the default; override only for denser chrome or rare/destructive actions.\n */\n @Prop() delay: number | string = TOKEN_DEFAULTS.animationDelayMedium3;\n @Prop() shortcutKey: string | undefined;\n @Prop() shortcutKeyPosition: 'start' | 'end' = 'end';\n\n private tooltipId = `ds-tooltip-${++tooltipIdCounter}`;\n private delayTimer: ReturnType<typeof setTimeout> | null = null;\n private closeTimer: ReturnType<typeof setTimeout> | null = null;\n private anchor: HTMLElement | null = null;\n private popupEl: HTMLElement | null = null;\n private positionObserver: ResizeObserver | null = null;\n private anchorObserver: MutationObserver | null = null;\n private positionFrame: number | null = null;\n private skipEnterAnimation = false;\n private isOpen = false;\n private lastInteractionWasKeyboard = false;\n private pointerEnterHandler = (event: PointerEvent) => {\n if (event.pointerType === 'touch') return;\n this.show();\n };\n private pointerLeaveHandler = (event: PointerEvent) => {\n if (event.pointerType === 'touch') return;\n this.hide();\n };\n private pointerDownHandler = (event: PointerEvent) => {\n this.lastInteractionWasKeyboard = false;\n if (event.pointerType === 'touch' && (this.isOpen || this.popupEl)) this.hideInstant();\n };\n private keyDownHandler = () => {\n this.lastInteractionWasKeyboard = true;\n };\n /** focusin/focusout so nested focus targets (e.g. ds-button-* inner button) still open the tip. */\n private focusHandler = () => {\n if (this.lastInteractionWasKeyboard || this.anchor?.matches(':focus-visible')) this.show();\n };\n private blurHandler: (e: FocusEvent) => void = (e) => {\n const next = e.relatedTarget as Node | null;\n if (next && this.anchor?.contains(next)) return;\n this.hide();\n };\n private scrollResizeHandler = () => {\n if (this.positionFrame !== null) return;\n this.positionFrame = requestAnimationFrame(() => {\n this.positionFrame = null;\n this.calculatePosition();\n });\n };\n private escapeHandler = (event: KeyboardEvent) => {\n if (event.key !== 'Escape' || !this.isOpen) return;\n event.preventDefault();\n this.hide();\n };\n\n componentDidLoad() {\n this.bindAnchor();\n this.anchorObserver = new MutationObserver(() => this.handleSlotChange());\n this.anchorObserver.observe(this.el, { childList: true });\n }\n\n disconnectedCallback() {\n if (activeTooltip === this) activeTooltip = null;\n this.anchorObserver?.disconnect();\n this.anchorObserver = null;\n this.unbindAnchor();\n this.clearTimers();\n this.destroyPopup();\n }\n\n @Watch('label')\n @Watch('size')\n @Watch('side')\n @Watch('align')\n @Watch('sideOffset')\n @Watch('alignOffset')\n @Watch('shortcutKey')\n @Watch('shortcutKeyPosition')\n onContentOrPositionChange() {\n if (!this.label?.trim()) {\n if (this.isOpen || this.popupEl) this.hideInstant();\n return;\n }\n if (!this.popupEl || !this.isOpen) return;\n this.renderPopupContent();\n this.schedulePosition();\n }\n\n private get viewportPadPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.space050, TOKEN_DEFAULTS.space050);\n }\n\n private get sideOffsetPx(): number {\n return resolveCssLengthPx(this.sideOffset, TOKEN_DEFAULTS.space050);\n }\n\n private get alignOffsetPx(): number {\n return resolveCssLengthPx(this.alignOffset, 0);\n }\n\n private get hoverDelayMs(): number {\n return resolveCssTimeMs(this.delay, TOKEN_DEFAULTS.animationDelayMedium3);\n }\n\n private get instantReopenMs(): number {\n return resolveCssTimeMs(\n TOKEN_DEFAULTS.animationDurationMedium1,\n TOKEN_DEFAULTS.animationDurationMedium1,\n );\n }\n\n private get fadeOutMs(): number {\n return resolveMotionTimeMs(TOKEN_DEFAULTS.motionShort3, TOKEN_DEFAULTS.animationDurationShort3);\n }\n\n private get tooltipFallbackWidthPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.tooltipFallbackWidth, TOKEN_DEFAULTS.tooltipFallbackWidth);\n }\n\n private get tooltipFallbackHeightPx(): number {\n const token = FALLBACK_HEIGHT[this.size];\n return resolveCssLengthPx(token, token);\n }\n\n private bindAnchor() {\n const slot = this.el.querySelector('slot') as HTMLSlotElement | null;\n const slotted = slot?.assignedElements?.() ?? [];\n const child = (slotted[0] ?? this.el.firstElementChild) as HTMLElement | null;\n if (!child || child === this.anchor) return;\n this.anchor = child;\n child.addEventListener('pointerenter', this.pointerEnterHandler);\n child.addEventListener('pointerleave', this.pointerLeaveHandler);\n child.addEventListener('pointerdown', this.pointerDownHandler);\n child.addEventListener('keydown', this.keyDownHandler);\n child.addEventListener('focusin', this.focusHandler);\n child.addEventListener('focusout', this.blurHandler);\n }\n\n private unbindAnchor() {\n if (!this.anchor) return;\n this.unlinkDescribedBy();\n this.anchor.removeEventListener('pointerenter', this.pointerEnterHandler);\n this.anchor.removeEventListener('pointerleave', this.pointerLeaveHandler);\n this.anchor.removeEventListener('pointerdown', this.pointerDownHandler);\n this.anchor.removeEventListener('keydown', this.keyDownHandler);\n this.anchor.removeEventListener('focusin', this.focusHandler);\n this.anchor.removeEventListener('focusout', this.blurHandler);\n this.anchor = null;\n }\n\n private linkDescribedBy() {\n if (!this.anchor || !this.popupEl) return;\n const ids = new Set((this.anchor.getAttribute('aria-describedby') ?? '').split(/\\s+/).filter(Boolean));\n ids.add(this.tooltipId);\n this.anchor.setAttribute('aria-describedby', Array.from(ids).join(' '));\n }\n\n private unlinkDescribedBy() {\n if (!this.anchor) return;\n const ids = (this.anchor.getAttribute('aria-describedby') ?? '')\n .split(/\\s+/)\n .filter(id => id && id !== this.tooltipId);\n if (ids.length) this.anchor.setAttribute('aria-describedby', ids.join(' '));\n else this.anchor.removeAttribute('aria-describedby');\n }\n\n private handleSlotChange = () => {\n const slot = this.el.querySelector('slot') as HTMLSlotElement | null;\n const next = (slot?.assignedElements?.()[0] ?? this.el.firstElementChild) as HTMLElement | null;\n if (next === this.anchor) return;\n if (this.isOpen || this.popupEl) this.hideInstant();\n this.unbindAnchor();\n this.bindAnchor();\n };\n\n private clearTimers() {\n if (this.delayTimer) { clearTimeout(this.delayTimer); this.delayTimer = null; }\n if (this.closeTimer) { clearTimeout(this.closeTimer); this.closeTimer = null; }\n }\n\n private setupOpenListeners() {\n window.addEventListener('scroll', this.scrollResizeHandler, true);\n window.addEventListener('resize', this.scrollResizeHandler);\n document.addEventListener('keydown', this.escapeHandler);\n }\n\n private teardownOpenListeners() {\n window.removeEventListener('scroll', this.scrollResizeHandler, true);\n window.removeEventListener('resize', this.scrollResizeHandler);\n document.removeEventListener('keydown', this.escapeHandler);\n if (this.positionFrame !== null) {\n cancelAnimationFrame(this.positionFrame);\n this.positionFrame = null;\n }\n }\n\n private destroyPopup() {\n this.teardownOpenListeners();\n this.positionObserver?.disconnect();\n this.positionObserver = null;\n this.unlinkDescribedBy();\n if (!this.popupEl) return;\n this.popupEl.remove();\n this.popupEl = null;\n }\n\n /**\n * Position after `ds-text` upgrades and lays out. Measuring synchronously in\n * `mountPopup` reads a collapsed/fallback width and places left/right tips wrong.\n */\n private schedulePosition() {\n if (!this.popupEl) return;\n\n const reapplyLabelStyles = () => {\n const label = this.popupEl?.querySelector(':scope > .tooltip-inner > ds-text');\n if (label instanceof HTMLElement) styleTooltipLabel(label);\n };\n\n requestAnimationFrame(() => {\n reapplyLabelStyles();\n this.calculatePosition();\n requestAnimationFrame(() => {\n reapplyLabelStyles();\n this.calculatePosition();\n });\n });\n\n this.positionObserver?.disconnect();\n this.positionObserver = new ResizeObserver(() => this.calculatePosition());\n this.positionObserver.observe(this.popupEl);\n if (this.anchor) this.positionObserver.observe(this.anchor);\n }\n\n /** Drop the open tip immediately (warm handoff to another tooltip). */\n private hideInstant() {\n this.clearTimers();\n lastDismissedAt = Date.now();\n if (activeTooltip === this) activeTooltip = null;\n this.destroyPopup();\n this.isOpen = false;\n this.skipEnterAnimation = false;\n }\n\n private show() {\n // Empty label = wrapper present for DOM stability (e.g. PanelNav expand/collapse)\n // but no tip should appear.\n if (!this.label?.trim()) return;\n this.clearTimers();\n let instant = Date.now() - lastDismissedAt < this.instantReopenMs;\n if (activeTooltip && activeTooltip !== this) {\n activeTooltip.hideInstant();\n instant = true;\n }\n // eslint-disable-next-line @typescript-eslint/no-this-alias -- module singleton tracks the active instance\n activeTooltip = this;\n this.skipEnterAnimation = instant;\n if (instant) {\n this.mountPopup();\n return;\n }\n this.delayTimer = setTimeout(() => {\n this.delayTimer = null;\n if (!this.label?.trim()) return;\n this.mountPopup();\n }, this.hoverDelayMs);\n }\n\n private hide() {\n this.clearTimers();\n if (!this.popupEl && !this.isOpen) return;\n lastDismissedAt = Date.now();\n if (!this.popupEl) {\n this.isOpen = false;\n return;\n }\n this.popupEl.classList.remove('tooltip-popup--instant');\n this.popupEl.classList.add('tooltip-popup--closing');\n if (this.fadeOutMs === 0) {\n if (activeTooltip === this) activeTooltip = null;\n this.destroyPopup();\n this.isOpen = false;\n this.skipEnterAnimation = false;\n return;\n }\n this.closeTimer = setTimeout(() => {\n this.closeTimer = null;\n if (activeTooltip === this) activeTooltip = null;\n this.destroyPopup();\n this.isOpen = false;\n this.skipEnterAnimation = false;\n }, this.fadeOutMs);\n }\n\n private mountPopup() {\n if (!this.popupEl) {\n const tip = document.createElement('div');\n tip.id = this.tooltipId;\n tip.setAttribute('role', 'tooltip');\n // `sc-ds-tooltip` is required for Stencil scoped CSS to match outside the host.\n tip.className = 'tooltip-popup sc-ds-tooltip';\n Object.assign(tip.style, {\n position: 'fixed',\n left: '0',\n top: '0',\n zIndex: 'var(--dimension-z-index-tooltip)',\n pointerEvents: 'none',\n });\n document.body.appendChild(tip);\n this.popupEl = tip;\n }\n\n this.setupOpenListeners();\n this.linkDescribedBy();\n this.popupEl.classList.toggle('tooltip-popup--instant', this.skipEnterAnimation);\n this.popupEl.classList.remove('tooltip-popup--closing');\n this.renderPopupContent();\n this.schedulePosition();\n this.isOpen = true;\n }\n\n private renderPopupContent() {\n if (!this.popupEl) return;\n const textVariant = CONTROL_TEXT_VARIANT[this.size];\n const density =\n this.size === 'md' ? 'ds-control--md' : this.size === 'sm' ? 'ds-control--sm' : 'ds-control--xs';\n // Every layout node needs `sc-ds-tooltip` so scoped selectors (`.foo.sc-ds-tooltip`) match.\n const sc = 'sc-ds-tooltip';\n\n const inner = document.createElement('div');\n inner.className = `tooltip-inner ${density} ${sc}`;\n\n const appendShortcut = (position: 'start' | 'end') => {\n if (!this.shortcutKey || this.shortcutKeyPosition !== position) return;\n const group = document.createElement('span');\n group.className = `key-hint-group ${sc}`;\n group.setAttribute('aria-hidden', 'true');\n\n for (const label of shortcutKeyLabels(this.shortcutKey)) {\n const hint = document.createElement('span');\n const wide = Array.from(label).length > 1 ? ' key-hint--wide' : '';\n hint.className = `key-hint${wide} ${sc}`;\n const text = document.createElement('span');\n text.className = `key-hint__label ${sc}`;\n text.textContent = label;\n hint.appendChild(text);\n group.appendChild(hint);\n }\n inner.appendChild(group);\n };\n\n appendShortcut('start');\n\n // ds-text is the layout box (metric-box + 2px control label-inset).\n // Inline styles required — see styleTooltipLabel.\n const label = createDsText({ variant: textVariant, wrap: 'nowrap', text: this.label });\n styleTooltipLabel(label);\n inner.appendChild(label);\n\n appendShortcut('end');\n\n this.popupEl.replaceChildren(inner);\n }\n\n private calculatePosition() {\n if (!this.anchor || !this.popupEl) return;\n const tip = this.popupEl;\n const a = this.anchor.getBoundingClientRect();\n const tw = tip.offsetWidth || this.tooltipFallbackWidthPx;\n const th = tip.offsetHeight || this.tooltipFallbackHeightPx;\n const { x, y, resolvedSide } = computeTooltipPosition({\n anchorRect: a,\n popupWidth: tw,\n popupHeight: th,\n side: this.side,\n align: this.align,\n sideOffsetPx: this.sideOffsetPx,\n alignOffsetPx: this.alignOffsetPx,\n viewportPadPx: this.viewportPadPx,\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n });\n tip.dataset.side = resolvedSide;\n tip.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;\n }\n\n render() {\n return (\n <Host style={{ display: 'contents' }}>\n <slot onSlotchange={this.handleSlotChange} />\n </Host>\n );\n }\n}\n",
|
|
303
|
-
"type": "registry:ui"
|
|
304
|
-
},
|
|
305
|
-
{
|
|
306
|
-
"path": "src/wc/components/Tooltip/tooltip-position.ts",
|
|
307
|
-
"content": "import type { TooltipAlign, TooltipSide } from './Tooltip';\n\nexport interface TooltipPositionInput {\n anchorRect: Pick<DOMRectReadOnly, 'top' | 'left' | 'right' | 'bottom' | 'width' | 'height'>;\n popupWidth: number;\n popupHeight: number;\n side: TooltipSide;\n align: TooltipAlign;\n sideOffsetPx: number;\n alignOffsetPx: number;\n viewportPadPx: number;\n viewportWidth: number;\n viewportHeight: number;\n}\n\nexport interface TooltipPosition {\n x: number;\n y: number;\n resolvedSide: TooltipSide;\n}\n\nconst OPPOSITE_SIDE: Record<TooltipSide, TooltipSide> = {\n top: 'bottom',\n right: 'left',\n bottom: 'top',\n left: 'right',\n};\n\nfunction rawPosition(\n input: TooltipPositionInput,\n side: TooltipSide,\n): Pick<TooltipPosition, 'x' | 'y'> {\n const {\n anchorRect: anchor,\n popupWidth,\n popupHeight,\n align,\n sideOffsetPx,\n alignOffsetPx,\n } = input;\n\n if (side === 'top' || side === 'bottom') {\n return {\n x:\n align === 'start'\n ? anchor.left + alignOffsetPx\n : align === 'end'\n ? anchor.right - popupWidth + alignOffsetPx\n : anchor.left + anchor.width / 2 - popupWidth / 2 + alignOffsetPx,\n y: side === 'top'\n ? anchor.top - popupHeight - sideOffsetPx\n : anchor.bottom + sideOffsetPx,\n };\n }\n\n return {\n x: side === 'left'\n ? anchor.left - popupWidth - sideOffsetPx\n : anchor.right + sideOffsetPx,\n y:\n align === 'start'\n ? anchor.top + alignOffsetPx\n : align === 'end'\n ? anchor.bottom - popupHeight + alignOffsetPx\n : anchor.top + anchor.height / 2 - popupHeight / 2 + alignOffsetPx,\n };\n}\n\nfunction mainAxisOverflow(\n input: TooltipPositionInput,\n side: TooltipSide,\n position: Pick<TooltipPosition, 'x' | 'y'>,\n): number {\n const {\n popupWidth,\n popupHeight,\n viewportPadPx,\n viewportWidth,\n viewportHeight,\n } = input;\n\n if (side === 'top' || side === 'bottom') {\n return Math.max(viewportPadPx - position.y, 0) +\n Math.max(position.y + popupHeight - (viewportHeight - viewportPadPx), 0);\n }\n\n return Math.max(viewportPadPx - position.x, 0) +\n Math.max(position.x + popupWidth - (viewportWidth - viewportPadPx), 0);\n}\n\n/** Flip on the preferred axis when needed, then clamp within the viewport. */\nexport function computeTooltipPosition(input: TooltipPositionInput): TooltipPosition {\n const preferred = rawPosition(input, input.side);\n const oppositeSide = OPPOSITE_SIDE[input.side];\n const opposite = rawPosition(input, oppositeSide);\n const preferredOverflow = mainAxisOverflow(input, input.side, preferred);\n const oppositeOverflow = mainAxisOverflow(input, oppositeSide, opposite);\n const useOpposite = preferredOverflow > 0 && oppositeOverflow < preferredOverflow;\n const resolvedSide = useOpposite ? oppositeSide : input.side;\n const position = useOpposite ? opposite : preferred;\n const maxX = Math.max(input.viewportPadPx, input.viewportWidth - input.popupWidth - input.viewportPadPx);\n const maxY = Math.max(input.viewportPadPx, input.viewportHeight - input.popupHeight - input.viewportPadPx);\n\n return {\n x: Math.min(Math.max(position.x, input.viewportPadPx), maxX),\n y: Math.min(Math.max(position.y, input.viewportPadPx), maxY),\n resolvedSide,\n };\n}\n",
|
|
302
|
+
"content": "import { Component, Prop, Element, Watch, h, Host } from '@stencil/core';\nimport {\n CONTROL_TEXT_VARIANT,\n resolveCssLengthPx,\n resolveCssTimeMs,\n resolveMotionTimeMs,\n TOKEN_CSS_LENGTHS,\n TOKEN_DEFAULTS,\n} from '../../utils';\nimport type { TextVariant } from '../Text/text-types';\nimport { shortcutKeyLabels } from '../../utils/shortcut-key';\nimport { AnchoredPositionController } from '../../utils/anchored-position-controller';\n// Side-effect: register `ds-text` — the portal builds it via createElement, not JSX.\nimport '../Text/Text';\n\nexport type TooltipSide = 'top' | 'right' | 'bottom' | 'left';\nexport type TooltipAlign = 'start' | 'center' | 'end';\nexport type TooltipSize = 'md' | 'sm' | 'xs';\n\nconst FALLBACK_HEIGHT: Record<TooltipSize, string> = {\n md: TOKEN_DEFAULTS.size400,\n sm: TOKEN_DEFAULTS.size300,\n xs: TOKEN_DEFAULTS.size200,\n};\n\nlet lastDismissedAt = 0;\nlet tooltipIdCounter = 0;\n/** Currently open tooltip — force-cleared on warm handoff so tips don't cross-fade. */\nlet activeTooltip: Tooltip | null = null;\n\ntype DsTextEl = HTMLElement & {\n as: string;\n variant: TextVariant;\n emphasis: boolean;\n color: string;\n wrap: string;\n};\n\nfunction createDsText(opts: {\n variant: TextVariant;\n emphasis?: boolean;\n color?: string;\n wrap?: 'wrap' | 'nowrap' | 'balance' | 'pretty';\n text: string;\n}): DsTextEl {\n const el = document.createElement('ds-text') as DsTextEl;\n el.as = 'span';\n el.variant = opts.variant;\n el.emphasis = opts.emphasis ?? false;\n el.color = opts.color ?? 'inherit';\n if (opts.wrap) el.wrap = opts.wrap;\n el.textContent = opts.text;\n return el;\n}\n\n/**\n * Control-density label styles for portaled ds-text.\n * Scoped CSS requires `sc-ds-tooltip` on the host, but Text's `<Host class>`\n * overwrites imperative className — so these must be inline to stick.\n */\nfunction styleTooltipLabel(el: HTMLElement) {\n el.style.flexShrink = '0';\n el.style.minWidth = 'max-content';\n el.style.maxWidth = 'none';\n el.style.padding = '0 var(--ds-control-label-inset, var(--dimension-space-025))';\n el.style.color = 'var(--color-foreground-primary)';\n}\n\n/**\n * Imperative body portal for the popup.\n * Stencil must not own the portaled node — moving a VDOM child to `document.body`\n * causes recreate → re-portal → @State loops that freeze the page.\n */\n@Component({\n tag: 'ds-tooltip',\n styleUrl: 'Tooltip.css',\n scoped: true,\n})\nexport class Tooltip {\n @Element() el!: HTMLElement;\n\n /**\n * Tooltip text. Empty/whitespace skips show — useful when the wrapper must stay\n * mounted for layout/animation stability (e.g. PanelNav expand/collapse).\n */\n @Prop() label!: string;\n\n /** Control density (height, padding, type). */\n @Prop() size: TooltipSize = 'md';\n\n @Prop() side: TooltipSide = 'top';\n @Prop() align: TooltipAlign = 'center';\n /** Gap between anchor and tooltip — number (px) or TokoMo length. */\n @Prop() sideOffset: number | string = TOKEN_CSS_LENGTHS.space050;\n /** Cross-axis offset — number (px) or TokoMo length. */\n @Prop() alignOffset: number | string = 0;\n /**\n * Hover show delay before the tooltip appears.\n * Default: `--effect-animation-delay-medium-3` (1000ms). Accepts a number (ms)\n * or a TokoMo time token / `var(--effect-animation-delay-*)`. Applies to the\n * initial hover/focus show only — after a recent dismiss, reopen is instant.\n * Prefer the default; override only for denser chrome or rare/destructive actions.\n */\n @Prop() delay: number | string = TOKEN_DEFAULTS.animationDelayMedium3;\n @Prop() shortcutKey: string | undefined;\n @Prop() shortcutKeyPosition: 'start' | 'end' = 'end';\n\n private tooltipId = `ds-tooltip-${++tooltipIdCounter}`;\n private delayTimer: ReturnType<typeof setTimeout> | null = null;\n private closeTimer: ReturnType<typeof setTimeout> | null = null;\n private anchor: HTMLElement | null = null;\n private popupEl: HTMLElement | null = null;\n private anchorObserver: MutationObserver | null = null;\n private readonly position = new AnchoredPositionController({\n getAnchor: () => this.anchor,\n getPopup: () => this.popupEl,\n measure: (anchor, tip) => ({\n anchorRect: anchor.getBoundingClientRect(),\n // Fall back to token metrics until `ds-text` upgrades and reports a width.\n popupWidth: tip.offsetWidth || this.tooltipFallbackWidthPx,\n popupHeight: tip.offsetHeight || this.tooltipFallbackHeightPx,\n side: this.side,\n align: this.align,\n sideOffsetPx: this.sideOffsetPx,\n alignOffsetPx: this.alignOffsetPx,\n viewportPadPx: this.viewportPadPx,\n viewportWidth: window.innerWidth,\n viewportHeight: window.innerHeight,\n }),\n apply: ({ x, y, resolvedSide }) => {\n if (!this.popupEl) return;\n this.popupEl.dataset.side = resolvedSide;\n this.popupEl.style.transform = `translate(${Math.round(x)}px, ${Math.round(y)}px)`;\n },\n liveUpdate: 'frame',\n observeResize: true,\n });\n private skipEnterAnimation = false;\n private isOpen = false;\n private lastInteractionWasKeyboard = false;\n private pointerEnterHandler = (event: PointerEvent) => {\n if (event.pointerType === 'touch') return;\n this.show();\n };\n private pointerLeaveHandler = (event: PointerEvent) => {\n if (event.pointerType === 'touch') return;\n this.hide();\n };\n private pointerDownHandler = (event: PointerEvent) => {\n this.lastInteractionWasKeyboard = false;\n if (event.pointerType === 'touch' && (this.isOpen || this.popupEl)) this.hideInstant();\n };\n private keyDownHandler = () => {\n this.lastInteractionWasKeyboard = true;\n };\n /** focusin/focusout so nested focus targets (e.g. ds-button-* inner button) still open the tip. */\n private focusHandler = () => {\n if (this.lastInteractionWasKeyboard || this.anchor?.matches(':focus-visible')) this.show();\n };\n private blurHandler: (e: FocusEvent) => void = (e) => {\n const next = e.relatedTarget as Node | null;\n if (next && this.anchor?.contains(next)) return;\n this.hide();\n };\n private escapeHandler = (event: KeyboardEvent) => {\n if (event.key !== 'Escape' || !this.isOpen) return;\n event.preventDefault();\n this.hide();\n };\n\n componentDidLoad() {\n this.bindAnchor();\n this.anchorObserver = new MutationObserver(() => this.handleSlotChange());\n this.anchorObserver.observe(this.el, { childList: true });\n }\n\n disconnectedCallback() {\n if (activeTooltip === this) activeTooltip = null;\n this.anchorObserver?.disconnect();\n this.anchorObserver = null;\n this.unbindAnchor();\n this.clearTimers();\n this.destroyPopup();\n }\n\n @Watch('label')\n @Watch('size')\n @Watch('side')\n @Watch('align')\n @Watch('sideOffset')\n @Watch('alignOffset')\n @Watch('shortcutKey')\n @Watch('shortcutKeyPosition')\n onContentOrPositionChange() {\n if (!this.label?.trim()) {\n if (this.isOpen || this.popupEl) this.hideInstant();\n return;\n }\n if (!this.popupEl || !this.isOpen) return;\n this.renderPopupContent();\n this.schedulePosition();\n }\n\n private get viewportPadPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.space050, TOKEN_DEFAULTS.space050);\n }\n\n private get sideOffsetPx(): number {\n return resolveCssLengthPx(this.sideOffset, TOKEN_DEFAULTS.space050);\n }\n\n private get alignOffsetPx(): number {\n return resolveCssLengthPx(this.alignOffset, 0);\n }\n\n private get hoverDelayMs(): number {\n return resolveCssTimeMs(this.delay, TOKEN_DEFAULTS.animationDelayMedium3);\n }\n\n private get instantReopenMs(): number {\n return resolveCssTimeMs(\n TOKEN_DEFAULTS.animationDurationMedium1,\n TOKEN_DEFAULTS.animationDurationMedium1,\n );\n }\n\n private get fadeOutMs(): number {\n return resolveMotionTimeMs(TOKEN_DEFAULTS.motionShort3, TOKEN_DEFAULTS.animationDurationShort3);\n }\n\n private get tooltipFallbackWidthPx(): number {\n return resolveCssLengthPx(TOKEN_DEFAULTS.tooltipFallbackWidth, TOKEN_DEFAULTS.tooltipFallbackWidth);\n }\n\n private get tooltipFallbackHeightPx(): number {\n const token = FALLBACK_HEIGHT[this.size];\n return resolveCssLengthPx(token, token);\n }\n\n private bindAnchor() {\n const slot = this.el.querySelector('slot') as HTMLSlotElement | null;\n const slotted = slot?.assignedElements?.() ?? [];\n const child = (slotted[0] ?? this.el.firstElementChild) as HTMLElement | null;\n if (!child || child === this.anchor) return;\n this.anchor = child;\n child.addEventListener('pointerenter', this.pointerEnterHandler);\n child.addEventListener('pointerleave', this.pointerLeaveHandler);\n child.addEventListener('pointerdown', this.pointerDownHandler);\n child.addEventListener('keydown', this.keyDownHandler);\n child.addEventListener('focusin', this.focusHandler);\n child.addEventListener('focusout', this.blurHandler);\n }\n\n private unbindAnchor() {\n if (!this.anchor) return;\n this.unlinkDescribedBy();\n this.anchor.removeEventListener('pointerenter', this.pointerEnterHandler);\n this.anchor.removeEventListener('pointerleave', this.pointerLeaveHandler);\n this.anchor.removeEventListener('pointerdown', this.pointerDownHandler);\n this.anchor.removeEventListener('keydown', this.keyDownHandler);\n this.anchor.removeEventListener('focusin', this.focusHandler);\n this.anchor.removeEventListener('focusout', this.blurHandler);\n this.anchor = null;\n }\n\n private linkDescribedBy() {\n if (!this.anchor || !this.popupEl) return;\n const ids = new Set((this.anchor.getAttribute('aria-describedby') ?? '').split(/\\s+/).filter(Boolean));\n ids.add(this.tooltipId);\n this.anchor.setAttribute('aria-describedby', Array.from(ids).join(' '));\n }\n\n private unlinkDescribedBy() {\n if (!this.anchor) return;\n const ids = (this.anchor.getAttribute('aria-describedby') ?? '')\n .split(/\\s+/)\n .filter(id => id && id !== this.tooltipId);\n if (ids.length) this.anchor.setAttribute('aria-describedby', ids.join(' '));\n else this.anchor.removeAttribute('aria-describedby');\n }\n\n private handleSlotChange = () => {\n const slot = this.el.querySelector('slot') as HTMLSlotElement | null;\n const next = (slot?.assignedElements?.()[0] ?? this.el.firstElementChild) as HTMLElement | null;\n if (next === this.anchor) return;\n if (this.isOpen || this.popupEl) this.hideInstant();\n this.unbindAnchor();\n this.bindAnchor();\n };\n\n private clearTimers() {\n if (this.delayTimer) { clearTimeout(this.delayTimer); this.delayTimer = null; }\n if (this.closeTimer) { clearTimeout(this.closeTimer); this.closeTimer = null; }\n }\n\n private setupOpenListeners() {\n // Binds scroll/resize + the resize observer; placement itself is scheduled\n // by schedulePosition() so the label restyle stays interleaved.\n this.position.observe();\n document.addEventListener('keydown', this.escapeHandler);\n }\n\n private teardownOpenListeners() {\n this.position.unobserve();\n document.removeEventListener('keydown', this.escapeHandler);\n }\n\n private destroyPopup() {\n this.teardownOpenListeners();\n this.unlinkDescribedBy();\n if (!this.popupEl) return;\n this.popupEl.remove();\n this.popupEl = null;\n }\n\n /**\n * Position after `ds-text` upgrades and lays out. Measuring synchronously in\n * `mountPopup` reads a collapsed/fallback width and places left/right tips wrong.\n */\n private schedulePosition() {\n if (!this.popupEl) return;\n\n const reapplyLabelStyles = () => {\n const label = this.popupEl?.querySelector(':scope > .tooltip-inner > ds-text');\n if (label instanceof HTMLElement) styleTooltipLabel(label);\n };\n\n requestAnimationFrame(() => {\n reapplyLabelStyles();\n this.position.update();\n requestAnimationFrame(() => {\n reapplyLabelStyles();\n this.position.update();\n });\n });\n\n // The popup remounts on every show, so re-target the observer bound in start().\n this.position.observeResizeTargets();\n }\n\n /** Drop the open tip immediately (warm handoff to another tooltip). */\n private hideInstant() {\n this.clearTimers();\n lastDismissedAt = Date.now();\n if (activeTooltip === this) activeTooltip = null;\n this.destroyPopup();\n this.isOpen = false;\n this.skipEnterAnimation = false;\n }\n\n private show() {\n // Empty label = wrapper present for DOM stability (e.g. PanelNav expand/collapse)\n // but no tip should appear.\n if (!this.label?.trim()) return;\n this.clearTimers();\n let instant = Date.now() - lastDismissedAt < this.instantReopenMs;\n if (activeTooltip && activeTooltip !== this) {\n activeTooltip.hideInstant();\n instant = true;\n }\n // eslint-disable-next-line @typescript-eslint/no-this-alias -- module singleton tracks the active instance\n activeTooltip = this;\n this.skipEnterAnimation = instant;\n if (instant) {\n this.mountPopup();\n return;\n }\n this.delayTimer = setTimeout(() => {\n this.delayTimer = null;\n if (!this.label?.trim()) return;\n this.mountPopup();\n }, this.hoverDelayMs);\n }\n\n private hide() {\n this.clearTimers();\n if (!this.popupEl && !this.isOpen) return;\n lastDismissedAt = Date.now();\n if (!this.popupEl) {\n this.isOpen = false;\n return;\n }\n this.popupEl.classList.remove('tooltip-popup--instant');\n this.popupEl.classList.add('tooltip-popup--closing');\n if (this.fadeOutMs === 0) {\n if (activeTooltip === this) activeTooltip = null;\n this.destroyPopup();\n this.isOpen = false;\n this.skipEnterAnimation = false;\n return;\n }\n this.closeTimer = setTimeout(() => {\n this.closeTimer = null;\n if (activeTooltip === this) activeTooltip = null;\n this.destroyPopup();\n this.isOpen = false;\n this.skipEnterAnimation = false;\n }, this.fadeOutMs);\n }\n\n private mountPopup() {\n if (!this.popupEl) {\n const tip = document.createElement('div');\n tip.id = this.tooltipId;\n tip.setAttribute('role', 'tooltip');\n // `sc-ds-tooltip` is required for Stencil scoped CSS to match outside the host.\n tip.className = 'tooltip-popup sc-ds-tooltip';\n Object.assign(tip.style, {\n position: 'fixed',\n left: '0',\n top: '0',\n zIndex: 'var(--dimension-z-index-tooltip)',\n pointerEvents: 'none',\n });\n document.body.appendChild(tip);\n this.popupEl = tip;\n }\n\n this.setupOpenListeners();\n this.linkDescribedBy();\n this.popupEl.classList.toggle('tooltip-popup--instant', this.skipEnterAnimation);\n this.popupEl.classList.remove('tooltip-popup--closing');\n this.renderPopupContent();\n this.schedulePosition();\n this.isOpen = true;\n }\n\n private renderPopupContent() {\n if (!this.popupEl) return;\n const textVariant = CONTROL_TEXT_VARIANT[this.size];\n const density =\n this.size === 'md' ? 'ds-control--md' : this.size === 'sm' ? 'ds-control--sm' : 'ds-control--xs';\n // Every layout node needs `sc-ds-tooltip` so scoped selectors (`.foo.sc-ds-tooltip`) match.\n const sc = 'sc-ds-tooltip';\n\n const inner = document.createElement('div');\n inner.className = `tooltip-inner ${density} ${sc}`;\n\n const appendShortcut = (position: 'start' | 'end') => {\n if (!this.shortcutKey || this.shortcutKeyPosition !== position) return;\n const group = document.createElement('span');\n group.className = `key-hint-group ${sc}`;\n group.setAttribute('aria-hidden', 'true');\n\n for (const label of shortcutKeyLabels(this.shortcutKey)) {\n const hint = document.createElement('span');\n const wide = Array.from(label).length > 1 ? ' key-hint--wide' : '';\n hint.className = `key-hint${wide} ${sc}`;\n const text = document.createElement('span');\n text.className = `key-hint__label ${sc}`;\n text.textContent = label;\n hint.appendChild(text);\n group.appendChild(hint);\n }\n inner.appendChild(group);\n };\n\n appendShortcut('start');\n\n // ds-text is the layout box (metric-box + 2px control label-inset).\n // Inline styles required — see styleTooltipLabel.\n const label = createDsText({ variant: textVariant, wrap: 'nowrap', text: this.label });\n styleTooltipLabel(label);\n inner.appendChild(label);\n\n appendShortcut('end');\n\n this.popupEl.replaceChildren(inner);\n }\n\n render() {\n return (\n <Host style={{ display: 'contents' }}>\n <slot onSlotchange={this.handleSlotChange} />\n </Host>\n );\n }\n}\n",
|
|
308
303
|
"type": "registry:ui"
|
|
309
304
|
}
|
|
310
305
|
]
|