@astryxdesign/core 0.7.3 → 0.7.4-canary.3f6ea7d

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.
Files changed (116) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +18 -6
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +268 -58
  4. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +41 -12
  5. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -1
  6. package/dist/BottomSheet/BottomSheetSwitcher.js +294 -165
  7. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +51 -1
  8. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -1
  9. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -1
  10. package/dist/Carousel/Carousel.d.ts.map +1 -1
  11. package/dist/Carousel/Carousel.js +7 -11
  12. package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
  13. package/dist/Chat/useTriggerMenu.js +46 -7
  14. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  15. package/dist/ComplexSelector/ComplexSelector.js +6 -1
  16. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  17. package/dist/ContextMenu/ContextMenu.js +12 -0
  18. package/dist/DateInput/DateInput.d.ts.map +1 -1
  19. package/dist/DateInput/DateInput.js +6 -1
  20. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  21. package/dist/DateInput/NativeDateField.js +6 -1
  22. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  23. package/dist/DateInput/TouchDateField.js +9 -1
  24. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  25. package/dist/DateRangeInput/DateRangeInput.js +6 -1
  26. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  27. package/dist/DateTimeInput/DateTimeInput.js +6 -1
  28. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  29. package/dist/DateTimeInput/TouchDateTimeField.js +9 -1
  30. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  31. package/dist/MultiSelector/MultiSelector.js +6 -1
  32. package/dist/Selector/Selector.d.ts.map +1 -1
  33. package/dist/Selector/Selector.js +6 -1
  34. package/dist/TextArea/TextArea.d.ts.map +1 -1
  35. package/dist/TextArea/TextArea.js +6 -1
  36. package/dist/TextInput/TextInput.d.ts.map +1 -1
  37. package/dist/TextInput/TextInput.js +6 -1
  38. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  39. package/dist/TimeInput/TimeInput.js +6 -1
  40. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  41. package/dist/TimeInput/TouchTimeField.js +9 -1
  42. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  43. package/dist/Tokenizer/Tokenizer.js +9 -1
  44. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  45. package/dist/Typeahead/BaseTypeahead.js +7 -1
  46. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  47. package/dist/Typeahead/Typeahead.js +9 -1
  48. package/dist/astryx.css +2 -0
  49. package/dist/hooks/useBusyAnnouncement.d.ts +22 -0
  50. package/dist/hooks/useBusyAnnouncement.d.ts.map +1 -0
  51. package/dist/hooks/useBusyAnnouncement.js +87 -0
  52. package/dist/hooks/useMenuPress.d.ts +2 -2
  53. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  54. package/dist/hooks/useMenuPress.js +5 -1
  55. package/dist/hooks/useTypeahead.d.ts.map +1 -1
  56. package/dist/hooks/useTypeahead.js +7 -2
  57. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  58. package/dist/i18n/generated-locales/en.generated.js +1 -0
  59. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  60. package/dist/i18n/generated-locales/pseudo.generated.js +1 -0
  61. package/locales/en.json +6 -2
  62. package/locales/pseudo.json +3 -0
  63. package/package.json +4 -4
  64. package/src/BottomSheet/BottomSheet.doc.mjs +2 -2
  65. package/src/BottomSheet/BottomSheet.spec.md +2 -5
  66. package/src/BottomSheet/BottomSheet.tsx +260 -47
  67. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +4 -2
  68. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +11 -10
  69. package/src/BottomSheet/BottomSheetSwitcher.spec.md +28 -35
  70. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +830 -48
  71. package/src/BottomSheet/BottomSheetSwitcher.tsx +396 -215
  72. package/src/BottomSheet/BottomSheetSwitcherContext.ts +57 -2
  73. package/src/Carousel/Carousel.test.tsx +8 -12
  74. package/src/Carousel/Carousel.tsx +6 -13
  75. package/src/Chat/ChatComposerInput.test.tsx +167 -1
  76. package/src/Chat/useTriggerMenu.tsx +55 -6
  77. package/src/ComplexSelector/ComplexSelector.tsx +5 -1
  78. package/src/ContextMenu/ContextMenu.test.tsx +34 -0
  79. package/src/ContextMenu/ContextMenu.tsx +13 -1
  80. package/src/DateInput/DateInput.tsx +5 -1
  81. package/src/DateInput/DateInputTouch.test.tsx +45 -0
  82. package/src/DateInput/NativeDateField.test.tsx +10 -2
  83. package/src/DateInput/NativeDateField.tsx +5 -1
  84. package/src/DateInput/TouchDateField.tsx +14 -2
  85. package/src/DateRangeInput/DateRangeInput.tsx +5 -1
  86. package/src/DateTimeInput/DateTimeInput.tsx +5 -1
  87. package/src/DateTimeInput/TouchDateTimeField.tsx +14 -2
  88. package/src/MultiSelector/MultiSelector.test.tsx +18 -4
  89. package/src/MultiSelector/MultiSelector.tsx +5 -1
  90. package/src/ProgressBar/ProgressBar.test.tsx +4 -21
  91. package/src/ProgressBar/__tests__/ProgressBar.a11y.chromium.spec.ts +114 -0
  92. package/src/ProgressBar/__tests__/ProgressBar.a11y.known-failures.ts +12 -0
  93. package/src/ProgressBar/__tests__/ProgressBar.a11y.renders.tsx +38 -0
  94. package/src/ProgressBar/__tests__/ProgressBar.a11y.states.ts +123 -0
  95. package/src/ProgressBar/__tests__/ProgressBar.a11y.test.tsx +107 -0
  96. package/src/Selector/Selector.test.tsx +22 -5
  97. package/src/Selector/Selector.tsx +5 -1
  98. package/src/TextArea/TextArea.test.tsx +9 -1
  99. package/src/TextArea/TextArea.tsx +5 -1
  100. package/src/TextInput/TextInput.tsx +5 -1
  101. package/src/TimeInput/TimeInput.tsx +5 -1
  102. package/src/TimeInput/TouchTimeField.tsx +12 -2
  103. package/src/Tokenizer/Tokenizer.test.tsx +17 -12
  104. package/src/Tokenizer/Tokenizer.tsx +10 -1
  105. package/src/Typeahead/BaseTypeahead.tsx +14 -1
  106. package/src/Typeahead/Typeahead.test.tsx +32 -41
  107. package/src/Typeahead/Typeahead.tsx +10 -1
  108. package/src/__tests__/BusyOwnerAnnouncement.test.tsx +470 -0
  109. package/src/hooks/__tests__/MenuPressShadowRoot.a11y.chromium.spec.ts +50 -0
  110. package/src/hooks/useBusyAnnouncement.ts +100 -0
  111. package/src/hooks/useMenuPress.test.tsx +27 -1
  112. package/src/hooks/useMenuPress.ts +12 -3
  113. package/src/hooks/useTypeahead.test.tsx +42 -1
  114. package/src/hooks/useTypeahead.ts +7 -2
  115. package/src/i18n/generated-locales/en.generated.ts +1 -0
  116. package/src/i18n/generated-locales/pseudo.generated.ts +1 -0
@@ -315,6 +315,7 @@ const runtimeCatalog = {
315
315
  '@astryx.chatToolCalls.status.complete': '⟦Çóɱþłéţé⟧',
316
316
  '@astryx.chatToolCalls.status.error': '⟦Ƒàíłéð⟧',
317
317
  '@astryx.chatToolCalls.groupLabel': '⟦{count} ţóół çàłłš⟧',
318
+ '@astryx.chatTriggerMenu.searching': '⟦Šéàřçĥíñĝ…⟧',
318
319
  '@astryx.chatTriggerMenu.suggestions': '⟦Šúĝĝéšţíóñš⟧',
319
320
  '@astryx.citation.label': '⟦Çíţàţíóñ {number}: {title}⟧',
320
321
  '@astryx.codeBlock.copied': '⟦Çóþíéð⟧',
package/locales/en.json CHANGED
@@ -901,7 +901,7 @@
901
901
  },
902
902
  "@astryx.typeahead.loading": {
903
903
  "defaultMessage": "Loading",
904
- "description": "Screen-reader-only name for a spinner shown inside a Typeahead dropdown while suggestions are being fetched. Present-progressive form."
904
+ "description": "Screen-reader-only name for the spinner a Typeahead, Tokenizer or directly used BaseTypeahead shows while suggestions are being fetched; also announced once through the shared announcement channel when each search starts. Present-progressive form."
905
905
  },
906
906
  "@astryx.typeahead.searchResults": {
907
907
  "defaultMessage": "Search results",
@@ -1169,7 +1169,7 @@
1169
1169
  },
1170
1170
  "@astryx.spinner.loading": {
1171
1171
  "defaultMessage": "Loading",
1172
- "description": "Screen-reader-only default name for a standalone Spinner's role=\"status\" element, used when the consumer supplies neither `aria-label` nor a visible string label. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge."
1172
+ "description": "Screen-reader-only default name for a standalone Spinner's role=\"status\" element, used when the consumer supplies neither `aria-label` nor a visible string label; also announced once when a busy input or selector starts loading, in place of its decorative Spinner. Present-progressive form. Kept separate from `button.loading` and `typeahead.loading` — translations may diverge."
1173
1173
  },
1174
1174
  "@astryx.chatComposerDrawer.expand": {
1175
1175
  "defaultMessage": "Expand {label}",
@@ -1231,6 +1231,10 @@
1231
1231
  "defaultMessage": "{count} tool calls",
1232
1232
  "description": "Summary label shown in the group header when multiple tool calls are expanded. {count} is the number of calls."
1233
1233
  },
1234
+ "@astryx.chatTriggerMenu.searching": {
1235
+ "defaultMessage": "Searching…",
1236
+ "description": "Shown, and announced politely to screen readers, while a chat composer's @-mention or slash-command menu waits for async search results. Present participle; trailing `…` is one character."
1237
+ },
1234
1238
  "@astryx.chatTriggerMenu.suggestions": {
1235
1239
  "defaultMessage": "Suggestions",
1236
1240
  "description": "Screen-reader-only fallback label for the popover listbox opened by a trigger character (`@`, `/`) in the chat composer when the trigger provides no specific label."
@@ -923,6 +923,9 @@
923
923
  "@astryx.chatToolCalls.groupLabel": {
924
924
  "defaultMessage": "⟦{count} ţóół çàłłš⟧"
925
925
  },
926
+ "@astryx.chatTriggerMenu.searching": {
927
+ "defaultMessage": "⟦Šéàřçĥíñĝ…⟧"
928
+ },
926
929
  "@astryx.chatTriggerMenu.suggestions": {
927
930
  "defaultMessage": "⟦Šúĝĝéšţíóñš⟧"
928
931
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/core",
3
- "version": "0.7.3",
3
+ "version": "0.7.4-canary.3f6ea7d",
4
4
  "displayName": "Astryx Core",
5
5
  "description": "The component library. Accessible, themeable React components with built-in spacing, dark mode, and StyleX styling.",
6
6
  "author": "Meta Open Source",
@@ -792,6 +792,8 @@
792
792
  "react-dom": ">=19.0.0"
793
793
  },
794
794
  "devDependencies": {
795
+ "@astryxdesign/a11y-spec": "0.7.4-canary.3f6ea7d",
796
+ "@astryxdesign/cli": "0.7.4-canary.3f6ea7d",
795
797
  "@babel/cli": "^7.29.7",
796
798
  "@babel/core": "^7.29.7",
797
799
  "@babel/preset-react": "^7.29.7",
@@ -802,9 +804,7 @@
802
804
  "@testing-library/jest-dom": "^6.6.0",
803
805
  "@testing-library/react": "^16.3.2",
804
806
  "@types/babel__core": "^7.20.5",
805
- "tailwindcss": "^4.3.2",
806
- "@astryxdesign/a11y-spec": "0.0.0",
807
- "@astryxdesign/cli": "0.7.3"
807
+ "tailwindcss": "^4.3.2"
808
808
  },
809
809
  "dependencies": {
810
810
  "intl-messageformat": "^11.2.9"
@@ -95,7 +95,7 @@ export const docs = {
95
95
  derived: [{property: 'padding', expand: 'container'}],
96
96
  },
97
97
  description:
98
- "A mobile touch sheet that rises from the bottom edge, with animated entrance and exit, a grab handle, optional drag-to-resize snap points, and purpose-controlled dismissal. Like Dialog, it is a padded container: content starts at the theme's bottom-sheet padding (--spacing-4 by default), and a lone Section child or bleed children such as Table and Divider align against it. A standalone sheet owns a native <dialog>; inside BottomSheetSwitcher it renders a panel in the switcher's shared dialog. In both modes, ref and shared DOM props target the visual panel <div>.",
98
+ "A mobile touch sheet that rises from the bottom edge, with animated entrance and exit, a grab handle, optional drag-to-resize snap points, and purpose-controlled dismissal. Like Dialog, it is a padded container: content starts at the theme's bottom-sheet padding (--spacing-4 by default), and a lone Section child or bleed children such as Table and Divider align against it. A sheet always owns a native <dialog>; inside BottomSheetSwitcher the switcher presents and closes that dialog as the flow moves between steps. In both modes, ref and shared DOM props target the visual panel <div>.",
99
99
  props: [
100
100
  {
101
101
  name: 'isOpen',
@@ -165,7 +165,7 @@ export const docs = {
165
165
  name: 'hasScrim',
166
166
  type: 'boolean',
167
167
  description:
168
- "For a standalone BottomSheet, whether to render a scrim, the semi-transparent overlay that covers and blocks the background. true (default) uses showModal(): top layer, focus trap, ::backdrop scrim, body scroll lock, and tap-scrim-to-dismiss when purpose='info', with the background inert. false uses show() with no scrim, leaving the page behind interactive and scrollable. For a multi-step flow, configure hasScrim on BottomSheetSwitcher instead; it owns one shared dialog across every child.",
168
+ "For a standalone BottomSheet, whether to render a scrim, the semi-transparent overlay that covers and blocks the background. true (default) uses showModal(): top layer, native focus containment, ::backdrop scrim, body scroll lock, and tap-scrim-to-dismiss when purpose='info', with the background inert. false uses show() with no scrim, leaving the page behind interactive and scrollable. For a multi-step flow, configure hasScrim on BottomSheetSwitcher instead; it applies to every child's dialog.",
169
169
  default: 'true',
170
170
  },
171
171
  ],
@@ -66,9 +66,7 @@ Consumer migration instructions belong in consumer docs and release notes.
66
66
  `architecture:container-padding`.
67
67
  - Standalone sheet presentation, including its optional native-dialog Scrim.
68
68
  - The native dialog every sheet renders, standalone or switcher-managed
69
- (`component:BottomSheetSwitcher` DEC-1). Implementation gap: a
70
- switcher-managed sheet renders a panel inside the switcher's one shared
71
- dialog and owns no dialog of its own.
69
+ (`component:BottomSheetSwitcher` DEC-1).
72
70
 
73
71
  **Does not own / non-goals**
74
72
 
@@ -107,8 +105,7 @@ in `BottomSheet.doc.mjs`.
107
105
  without a Scrim.
108
106
  - A switcher-managed sheet renders the same panel anatomy in its own dialog while
109
107
  BottomSheetSwitcher coordinates that dialog's lifecycle and the flow's one Scrim
110
- (`component:BottomSheetSwitcher` DEC-1). Implementation gap: the panel
111
- renders inside the switcher's one shared dialog.
108
+ (`component:BottomSheetSwitcher` DEC-1).
112
109
 
113
110
  ### Transformation and precedence order
114
111
 
@@ -10,10 +10,22 @@
10
10
  *
11
11
  * BottomSheet selects one of two focused hosts. A standalone host owns its
12
12
  * native dialog lifecycle and joins the shared layer dismissal stack; a
13
- * switcher item participates in the parent's shared dialog, transition state
14
- * machine, and dismissal registration. Both render the same BottomSheetPanel,
15
- * which owns sheet presentation, content padding, gestures, mobile-keyboard
16
- * accommodation, and motion completion.
13
+ * switcher item owns a native dialog of its own too, but hands its lifecycle
14
+ * to the parent's transition state machine, scrim ownership, and dismissal
15
+ * registration. Both render the same BottomSheetPanel, which owns sheet
16
+ * presentation, content padding, gestures, mobile-keyboard accommodation,
17
+ * and motion completion.
18
+ *
19
+ * Every sheet dialog is native first. A scrim-backed sheet is presented with
20
+ * showModal(): the browser makes the page inert and keeps focus inside the
21
+ * dialog, delivers Escape and the platform close as `cancel`, paints the
22
+ * scrim as `::backdrop`, and returns focus to the opener on close(). Page
23
+ * scroll is locked by the shared useScrollLock hook, as for Dialog: a
24
+ * standalone sheet holds it while presented, a switcher holds one for the
25
+ * whole modal flow. The remaining script is what no single dialog can
26
+ * decide: gating `cancel` by `purpose` and the shared dismissal stack, and,
27
+ * in a switcher, handing one scrim between two dialogs and keeping focus in
28
+ * the active sheet when a retained dialog closes.
17
29
  *
18
30
  * SYNC: When modified, update these files to stay in sync:
19
31
  * - /packages/core/src/BottomSheet/BottomSheetPanel.tsx
@@ -44,6 +56,7 @@ import type {DialogPurpose} from '../Dialog';
44
56
  import type {SpacingStep} from '../utils/types';
45
57
  import {colorVars, durationVars, easeVars} from '../theme/tokens.stylex';
46
58
  import {useDevWarning, useScrollLock} from '../hooks';
59
+ import {mergeProps} from '../utils';
47
60
  import {LayerDepthProvider} from '../Layer/LayerDepthContext';
48
61
  import {useLayerDismissal} from '../Layer/useLayerDismissal';
49
62
  import {focusForLastInput} from '../utils/focusReturn';
@@ -97,14 +110,31 @@ const styles = stylex.create({
97
110
  '@starting-style': 0,
98
111
  },
99
112
  transitionProperty: 'opacity, display',
100
- transitionDuration: durationVars['--duration-medium'],
113
+ // A switcher hands its one scrim from a fading dialog to the next by
114
+ // pinning this duration for the swap; a standalone sheet never sets it.
115
+ transitionDuration: `var(--_sheet-scrim-duration, ${durationVars['--duration-medium']})`,
101
116
  transitionTimingFunction: easeVars['--ease-standard'],
102
117
  transitionBehavior: 'allow-discrete',
103
118
  '@media (prefers-reduced-motion: reduce)': {
104
- transitionDuration: '0.01s',
119
+ // The switcher's scrim takeover pins the duration to 0s for one style
120
+ // flush; a literal here would override that pin and paint a frame
121
+ // with no scrim under reduced motion.
122
+ transitionDuration: 'var(--_sheet-scrim-duration, 0.01s)',
105
123
  },
106
124
  },
107
125
  },
126
+ // A non-modal switcher dialog does not enter the top layer, so DOM order
127
+ // would decide which sheet paints in front during a handoff. The switcher's
128
+ // flow wrapper is the isolated surface for that relationship; inside it the
129
+ // sheet dialogs use local values only, the interactive sheet's on top.
130
+ dialogNonModalItem: {
131
+ pointerEvents: 'none',
132
+ width: '100%',
133
+ height: '100%',
134
+ },
135
+ dialogNonModalItemTop: {
136
+ zIndex: 1,
137
+ },
108
138
  /**
109
139
  * The dim leaves with the sheet, on a curve that matches.
110
140
  *
@@ -235,6 +265,43 @@ function focusPanel(panel: HTMLElement | null, isModal: boolean): void {
235
265
  }
236
266
  }
237
267
 
268
+ /**
269
+ * Present a switcher step's dialog modally with no focus to return to.
270
+ * showModal() records the element focused at that moment and close() moves
271
+ * focus back to it, in Chromium and WebKit even when focus is in another
272
+ * dialog by then. For a step that element is the control that advanced the
273
+ * flow (Continue, Back) or the flow's opener, and the dialog closes a fade
274
+ * later, while the user is in the next step: the return would pull focus
275
+ * off the active panel with no user action. Blurring first leaves nothing
276
+ * recorded, so the close moves nothing; the entering step then focuses its
277
+ * own panel, and the switcher returns focus to the opener itself when the
278
+ * flow ends. The blur happens in the same task as the present, so no frame
279
+ * paints without focus.
280
+ */
281
+ function presentStepModally(dialog: HTMLDialogElement): void {
282
+ const focused = dialog.ownerDocument.activeElement;
283
+ if (focused instanceof HTMLElement && focused !== dialog.ownerDocument.body) {
284
+ focused.blur();
285
+ }
286
+ dialog.showModal();
287
+ }
288
+
289
+ /**
290
+ * Present a switcher dialog whose backdrop must start, and stay, clear: the
291
+ * opacity is set with the scrim transition pinned to 0s, the dialog is
292
+ * presented, and the pin is lifted only after a style flush has applied the
293
+ * clear backdrop, so no frame eases from a previous backdrop value.
294
+ */
295
+ function presentWithClearScrim(dialog: HTMLDialogElement): void {
296
+ dialog.style.setProperty('--_sheet-scrim-duration', '0s');
297
+ dialog.style.setProperty('--_sheet-scrim-opacity', '0');
298
+ presentStepModally(dialog);
299
+ if (typeof getComputedStyle === 'function') {
300
+ void getComputedStyle(dialog, '::backdrop').opacity;
301
+ }
302
+ dialog.style.removeProperty('--_sheet-scrim-duration');
303
+ }
304
+
238
305
  function StandaloneBottomSheet({
239
306
  ref,
240
307
  isOpen,
@@ -449,12 +516,21 @@ function SwitcherBottomSheetItem({
449
516
  onActiveSheetChange,
450
517
  getSheetPhase,
451
518
  getSheetAlignmentOffset,
519
+ hostSheet,
520
+ isScrimOwner,
521
+ isFlowClosing,
452
522
  registerSheetElement,
453
- registerSheetLabel,
523
+ registerSheetDialog,
454
524
  registerSheetPurpose,
525
+ onBeforeSheetPresent,
526
+ isCoveredInTopLayer,
455
527
  onSheetEnterStart,
456
528
  onSheetTransitionComplete,
457
529
  onSheetScrimOpacityChange,
530
+ dialogProps,
531
+ onDialogCancel,
532
+ onDialogClick,
533
+ onDialogKeyDown,
458
534
  } = switcher;
459
535
  const hasValidSheetId = typeof sheetId === 'string' && sheetId.length > 0;
460
536
  const phase = hasValidSheetId ? getSheetPhase(sheetId) : 'hidden';
@@ -470,6 +546,10 @@ function SwitcherBottomSheetItem({
470
546
  phase === 'exiting';
471
547
  const isPresented = phase !== 'hidden';
472
548
  const isTopSheet = phase === 'active' || phase === 'entering';
549
+ const ownsScrim = hasValidSheetId && isScrimOwner(sheetId);
550
+ const isHostSheet = hasValidSheetId && hostSheet === sheetId;
551
+ const dialogRef = useRef<HTMLDialogElement | null>(null);
552
+ const dialogModeRef = useRef<'modal' | 'non-modal' | null>(null);
473
553
  const panelRef = useRef<HTMLDivElement | null>(null);
474
554
  const previousPhaseRef = useRef(phase);
475
555
  const previousPhase = previousPhaseRef.current;
@@ -493,6 +573,15 @@ function SwitcherBottomSheetItem({
493
573
  },
494
574
  [hasValidSheetId, registerSheetElement, sheetId],
495
575
  );
576
+ const handleDialogElementChange = useCallback(
577
+ (element: HTMLDialogElement | null) => {
578
+ dialogRef.current = element;
579
+ if (hasValidSheetId) {
580
+ registerSheetDialog(sheetId, element);
581
+ }
582
+ },
583
+ [hasValidSheetId, registerSheetDialog, sheetId],
584
+ );
496
585
  const handleMotionStart = useCallback(
497
586
  (motion: BottomSheetPanelMotion) => {
498
587
  if (motion === 'entering' && hasValidSheetId) {
@@ -522,17 +611,90 @@ function SwitcherBottomSheetItem({
522
611
  if (!hasValidSheetId) {
523
612
  return;
524
613
  }
525
- registerSheetLabel(sheetId, label);
526
- return () => registerSheetLabel(sheetId, null);
527
- }, [hasValidSheetId, label, registerSheetLabel, sheetId]);
614
+ registerSheetPurpose(sheetId, purpose);
615
+ return () => registerSheetPurpose(sheetId, null);
616
+ }, [hasValidSheetId, purpose, registerSheetPurpose, sheetId]);
528
617
 
618
+ // Present this sheet's own dialog while the switcher keeps the sheet on
619
+ // screen, and close it once the sheet is hidden. A scrim flow presents
620
+ // modally: an entering dialog is shown above the retained one in the top
621
+ // layer, and the retained dialog closes only after its panel has faded. A
622
+ // modal dialog that is still open beneath a newer one -- the user went back
623
+ // to a sheet that was still fading -- re-presents to become topmost again:
624
+ // the document is blocked by the topmost modal dialog, so a lower one is
625
+ // inert whatever the switcher calls it.
626
+ //
627
+ // Every present blurs first, so no step dialog records an element to
628
+ // return focus to: a close while another sheet is active moves nothing,
629
+ // and when the flow ends the switcher focuses the flow's opener itself.
529
630
  useLayoutEffect(() => {
530
- if (!hasValidSheetId) {
631
+ const dialog = dialogRef.current;
632
+ if (dialog == null || !hasValidSheetId) {
531
633
  return;
532
634
  }
533
- registerSheetPurpose(sheetId, purpose);
534
- return () => registerSheetPurpose(sheetId, null);
535
- }, [hasValidSheetId, purpose, registerSheetPurpose, sheetId]);
635
+ if (!isPresented) {
636
+ if (dialog.open) {
637
+ dialog.close();
638
+ }
639
+ dialogModeRef.current = null;
640
+ return;
641
+ }
642
+ const nextMode = hasScrim ? 'modal' : 'non-modal';
643
+ if (dialog.open && dialogModeRef.current !== nextMode) {
644
+ dialog.close();
645
+ }
646
+ if (
647
+ dialog.open &&
648
+ hasScrim &&
649
+ isInteractive &&
650
+ isCoveredInTopLayer(sheetId)
651
+ ) {
652
+ dialog.close();
653
+ }
654
+ if (!dialog.open) {
655
+ if (hasScrim) {
656
+ onBeforeSheetPresent(sheetId);
657
+ if (ownsScrim) {
658
+ presentStepModally(dialog);
659
+ } else {
660
+ // Entering above a retained dialog whose backdrop already dims the
661
+ // page: present with a clear backdrop so the page is not dimmed
662
+ // twice and the previous panel stays undimmed beneath this one.
663
+ // Pinned to 0s across the present: a dialog re-presented from the
664
+ // retained position (the user went back while it was fading)
665
+ // closed with its backdrop at 1, and would otherwise ease it down
666
+ // over the scrim duration on top of the new owner's backdrop.
667
+ presentWithClearScrim(dialog);
668
+ }
669
+ } else {
670
+ dialog.show();
671
+ }
672
+ }
673
+ dialogModeRef.current = nextMode;
674
+ }, [
675
+ hasScrim,
676
+ hasValidSheetId,
677
+ isCoveredInTopLayer,
678
+ isInteractive,
679
+ isPresented,
680
+ onBeforeSheetPresent,
681
+ ownsScrim,
682
+ sheetId,
683
+ ]);
684
+
685
+ // A sheet removed while presented -- a consumer unmounts the closing sheet
686
+ // in the same update that closes the flow -- takes its dialog out of the
687
+ // top layer with it, but the element would keep its `open` state. Close it
688
+ // while it is still attached so the native lifecycle ends cleanly.
689
+ useLayoutEffect(
690
+ () => () => {
691
+ const dialog = dialogRef.current;
692
+ if (dialog?.open) {
693
+ dialog.close();
694
+ }
695
+ },
696
+ [],
697
+ );
536
698
 
537
699
  useEffect(() => {
538
700
  if (isInteractive) {
@@ -554,45 +716,96 @@ function SwitcherBottomSheetItem({
554
716
  isInteractive && !label,
555
717
  );
556
718
 
719
+ // The switcher's dialog surface applies to every sheet dialog, except the
720
+ // attributes that identify one element: those render on the host dialog
721
+ // only, the one the switcher's ref resolves to. Of the attributes set
722
+ // explicitly below, only `role` has a consumer value to keep: a required
723
+ // step forces `alertdialog`, otherwise the switcher's `role` stands, as on
724
+ // a shared dialog. `aria-label`/`aria-labelledby` keep consumer precedence
725
+ // above; `aria-modal`, `aria-hidden`, and `inert` are the host's own state.
726
+ const {
727
+ className: switcherClassName,
728
+ style: switcherStyle,
729
+ xstyle: switcherXstyle,
730
+ 'aria-label': switcherAriaLabel,
731
+ 'aria-labelledby': switcherAriaLabelledBy,
732
+ id: switcherId,
733
+ 'data-testid': switcherTestId,
734
+ ...switcherDialogProps
735
+ } = dialogProps;
736
+ const dialogPresentationProps = mergeProps(
737
+ stylex.props(
738
+ styles.dialog,
739
+ isPresented && styles.dialogOpen,
740
+ hasScrim && styles.scrim,
741
+ hasScrim && ownsScrim && isFlowClosing && styles.scrimClosing,
742
+ !hasScrim && styles.dialogNonModalItem,
743
+ !hasScrim && isTopSheet && styles.dialogNonModalItemTop,
744
+ switcherXstyle,
745
+ ),
746
+ switcherClassName,
747
+ switcherStyle,
748
+ );
749
+
557
750
  return (
558
- <div
559
- {...stylex.props(
560
- styles.positioner,
561
- !isPresented && styles.positionerHidden,
562
- isTopSheet && styles.positionerTop,
563
- )}
564
- hidden={!isPresented}
751
+ <dialog
752
+ {...switcherDialogProps}
753
+ {...dialogPresentationProps}
754
+ id={isHostSheet ? switcherId : undefined}
755
+ data-testid={isHostSheet ? switcherTestId : undefined}
756
+ ref={handleDialogElementChange}
757
+ aria-label={
758
+ switcherAriaLabel ??
759
+ (switcherAriaLabelledBy == null ? label : undefined)
760
+ }
761
+ aria-labelledby={switcherAriaLabelledBy}
762
+ aria-modal={hasScrim && isInteractive ? 'true' : undefined}
565
763
  aria-hidden={isInactive ? 'true' : undefined}
566
- inert={isInactive ? true : undefined}>
567
- <BottomSheetPanel
568
- {...props}
569
- ref={ref}
570
- state={panelState}
571
- height={height}
572
- label={label}
573
- snapPoints={snapPoints}
574
- isSwipeDismissAllowed={purpose === 'info'}
575
- isPageScrollLocked={hasScrim}
576
- xstyle={xstyle}
577
- onDismiss={dismissOnSwipe}
578
- onScrimOpacity={handleScrimOpacity}
579
- onElementChange={handlePanelElementChange}
580
- onMotionStart={handleMotionStart}
581
- onMotionComplete={handleMotionComplete}>
582
- {/* A switcher item consumes its parent controller. Its content starts a
583
- fresh ownership scope so a nested BottomSheet is standalone unless
584
- it establishes a nested BottomSheetSwitcher of its own. */}
585
- <BottomSheetSwitcherContext value={null}>
586
- {children}
587
- </BottomSheetSwitcherContext>
588
- </BottomSheetPanel>
589
- </div>
764
+ inert={isInactive ? true : undefined}
765
+ role={purpose === 'required' ? 'alertdialog' : switcherDialogProps.role}
766
+ onCancel={onDialogCancel}
767
+ onClick={onDialogClick}
768
+ onKeyDown={onDialogKeyDown}>
769
+ <div
770
+ {...stylex.props(
771
+ styles.positioner,
772
+ !isPresented && styles.positionerHidden,
773
+ isTopSheet && styles.positionerTop,
774
+ )}
775
+ hidden={!isPresented}
776
+ aria-hidden={isInactive ? 'true' : undefined}
777
+ inert={isInactive ? true : undefined}>
778
+ <BottomSheetPanel
779
+ {...props}
780
+ ref={ref}
781
+ state={panelState}
782
+ height={height}
783
+ label={label}
784
+ snapPoints={snapPoints}
785
+ isSwipeDismissAllowed={purpose === 'info'}
786
+ isPageScrollLocked={hasScrim}
787
+ xstyle={xstyle}
788
+ onDismiss={dismissOnSwipe}
789
+ onScrimOpacity={handleScrimOpacity}
790
+ onElementChange={handlePanelElementChange}
791
+ onMotionStart={handleMotionStart}
792
+ onMotionComplete={handleMotionComplete}>
793
+ {/* A switcher item consumes its parent controller. Its content starts a
794
+ fresh ownership scope so a nested BottomSheet is standalone unless
795
+ it establishes a nested BottomSheetSwitcher of its own. */}
796
+ <BottomSheetSwitcherContext value={null}>
797
+ <LayerDepthProvider>{children}</LayerDepthProvider>
798
+ </BottomSheetSwitcherContext>
799
+ </BottomSheetPanel>
800
+ </div>
801
+ {isPresented && <BottomSheetEdgeTint />}
802
+ </dialog>
590
803
  );
591
804
  }
592
805
 
593
806
  /**
594
- * A mobile touch sheet that either owns a native dialog or participates in a
595
- * BottomSheetSwitcher shared dialog when given a sheetId inside that context.
807
+ * A mobile touch sheet that owns a native dialog. Given a sheetId inside a
808
+ * BottomSheetSwitcher, that switcher coordinates the dialog's lifecycle.
596
809
  */
597
810
  export function BottomSheet(props: BottomSheetProps) {
598
811
  const switcher = use(BottomSheetSwitcherContext);
@@ -127,8 +127,10 @@ describe('BottomSheetEdgeTint', () => {
127
127
  expect(tints()).toHaveLength(0);
128
128
  });
129
129
 
130
- // The switcher owns one shared dialog for the whole flow, so the tint
131
- // belongs to that dialog and must not be minted per child sheet.
130
+ // Each switcher step owns a dialog and tints it while presented: one tint
131
+ // at rest, one per open dialog through a handoff (the retained dialog's
132
+ // sits beneath the entering dialog), none for a sheet that is not
133
+ // presented.
132
134
  it('gives a non-modal switcher flow exactly one tint', () => {
133
135
  render(
134
136
  <BottomSheetSwitcher
@@ -3,10 +3,10 @@
3
3
 
4
4
  const anatomy = [
5
5
  {
6
- name: 'Shared dialog',
6
+ name: 'Sheet dialogs',
7
7
  required: true,
8
8
  description:
9
- 'One native dialog that owns modality, focus, dismissal, and lifecycle for the complete flow.',
9
+ "One native dialog per child sheet, presented and closed by the switcher; the dialog hosting the active sheet carries the flow's ref, id, modality, and dismissal.",
10
10
  },
11
11
  {
12
12
  name: 'Sheet panels',
@@ -18,7 +18,7 @@ const anatomy = [
18
18
  name: 'Scrim',
19
19
  required: false,
20
20
  description:
21
- 'Native dialog backdrop shown by the default scrim-backed modal presentation.',
21
+ 'Native dialog backdrop shown by the default scrim-backed modal presentation; one sheet dialog paints it at a time, so a handoff never dims the page twice.',
22
22
  },
23
23
  ];
24
24
 
@@ -142,18 +142,19 @@ export const docs = {
142
142
  },
143
143
  },
144
144
  description:
145
- 'Coordinates multiple BottomSheets as a mutually exclusive flow. One activeSheet ID selects the only interactive sheet; during a handoff, the new sheet enters above the inert previous sheet. If the new sheet is shorter, the previous sheet simultaneously moves down until their top edges align, then fades after both transforms complete. The switcher owns one shared native <dialog>: modal flows call showModal() once for one top-layer boundary and one ::backdrop across the whole flow, while no-scrim flows use a non-modal show() shell. Its ref and shared DOM props target that dialog.',
145
+ 'Coordinates multiple BottomSheets as a mutually exclusive flow. One activeSheet ID selects the only interactive sheet; during a handoff, the new sheet enters above the inert previous sheet. If the new sheet is shorter, the previous sheet simultaneously moves down until their top edges align, then fades after both transforms complete. Each child sheet renders its own native <dialog>, exactly like a standalone sheet: modal flows present each sheet with showModal(), no-scrim flows with show(). The browser supplies modality: the page is inert and focus stays in the top-layer dialog, so both hold across a handoff; the switcher holds the shared body scroll lock once for the whole modal flow. The switcher coordinates the rest: one scrim paints at a time, focus returns to the element that opened the flow, its ref, id, and data-testid resolve to the dialog hosting the active sheet, and its other dialog props and handlers apply to every sheet dialog.',
146
146
  props: [
147
147
  {
148
148
  name: 'ref',
149
149
  type: 'Ref<HTMLDialogElement>',
150
- description: 'Ref forwarded to the one shared native dialog.',
150
+ description:
151
+ "Ref resolved to the native dialog hosting the flow: the active sheet's dialog, which changes to the next sheet's on a handoff; the exiting sheet's through its exit; and, while the flow is closed, the dialog that hosted it last (or the first mounted sheet's). It is never null while a sheet is mounted, from the first commit on. id and data-testid render on this dialog only: a flow that mounts open carries them on the active sheet's dialog in server markup too; a flow that mounts closed carries them from the first commit's synchronous re-render on, and its server markup carries them on no element. Other dialog props apply to every sheet dialog.",
151
152
  },
152
153
  {
153
154
  name: 'onCancel',
154
155
  type: '(event: SyntheticEvent<HTMLDialogElement>) => void',
155
156
  description:
156
- 'Called before the switcher handles a native dialog cancel request. Calling preventDefault() keeps the controlled flow open.',
157
+ "Called before the switcher handles a native cancel request (Escape or the platform close). Attached to every sheet dialog; the browser raises cancel on the dialog it is presenting, the active sheet's. Calling preventDefault() keeps the controlled flow open.",
157
158
  },
158
159
  {
159
160
  name: 'activeSheet',
@@ -173,7 +174,7 @@ export const docs = {
173
174
  name: 'hasScrim',
174
175
  type: 'boolean',
175
176
  description:
176
- "Whether the shared dialog is modal. true uses showModal() once for one native ::backdrop, focus trap, scroll lock, and click-to-dismiss when the active BottomSheet has purpose='info'. false uses show() with no backdrop and leaves the page interactive; avoid transformed, contained, or clipping ancestors because the non-modal dialog remains in its containing context.",
177
+ "Whether the sheet dialogs are modal. true presents each sheet with showModal() for a native ::backdrop, native focus containment, one body scroll lock held for the whole flow, and click-to-dismiss when the active BottomSheet has purpose='info'; only one backdrop paints at a time. false uses show() with no backdrop and leaves the page interactive; avoid transformed, contained, or clipping ancestors because a non-modal dialog remains in its containing context.",
177
178
  default: 'true',
178
179
  },
179
180
  {
@@ -186,7 +187,7 @@ export const docs = {
186
187
  usage: {
187
188
  anatomy,
188
189
  description:
189
- "Coordinates a multi-step bottom-sheet flow in one shared dialog; set activeSheet to a nested BottomSheet's sheetId to open or switch steps, and to null to close.",
190
+ "Coordinates a multi-step bottom-sheet flow, one native dialog per sheet; set activeSheet to a nested BottomSheet's sheetId to open or switch steps, and to null to close.",
190
191
  bestPractices: [
191
192
  {
192
193
  guidance: true,
@@ -243,11 +244,11 @@ export const docs = {
243
244
  /** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */
244
245
  export const docsDense = {
245
246
  description:
246
- 'controller with one shared native dialog for mutually exclusive multi-step BottomSheets',
247
+ 'controller for mutually exclusive multi-step BottomSheets, one native dialog per sheet',
247
248
  usage: {
248
249
  anatomy,
249
250
  description:
250
- "Coordinates a multi-step bottom-sheet flow in one shared dialog; set activeSheet to a nested BottomSheet's sheetId to open or switch steps, and to null to close.",
251
+ "Coordinates a multi-step bottom-sheet flow, one native dialog per sheet; set activeSheet to a nested BottomSheet's sheetId to open or switch steps, and to null to close.",
251
252
  bestPractices: [
252
253
  {
253
254
  guidance: true,