@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
@@ -4,7 +4,7 @@
4
4
 
5
5
  /**
6
6
  * @file BottomSheetSwitcher.tsx
7
- * @input Uses React context, StyleX, theme tokens, shared layer dismissal, focus/scroll-lock hooks, BottomSheetSwitcherContext
7
+ * @input Uses React context, StyleX, shared layer dismissal, BottomSheetSwitcherContext
8
8
  * @output Exports BottomSheetSwitcher and BottomSheetSwitcherProps
9
9
  * @position Core switcher for mutually exclusive BottomSheet flows
10
10
  *
@@ -15,14 +15,37 @@
15
15
  * down at the same time until their top edges align; otherwise it stays
16
16
  * stationary. The previous sheet fades only after both motions complete.
17
17
  *
18
- * All child sheets render as panels inside one switcher-owned `<dialog>`. A
19
- * scrim flow calls showModal() once and keeps that native top-layer dialog open
20
- * across every handoff. A no-scrim flow calls show() on the same non-modal shell.
21
- * This keeps one modal boundary and one native ::backdrop without a portal.
18
+ * Every child sheet owns a native `<dialog>`, exactly like a standalone
19
+ * sheet: a scrim flow presents each sheet with showModal(), a no-scrim flow
20
+ * with show(). The browser then does what a modal dialog does -- the page is
21
+ * inert, focus stays in the top-layer dialog, Escape and the platform close
22
+ * arrive as `cancel`. The switcher is the coordinator: it decides which
23
+ * sheet dialog is presented, retained, or hidden, keeps one scrim painted
24
+ * across a handoff (the retained sheet's backdrop until it fades, then the
25
+ * new sheet's, swapped without a fade), holds the flow's one page scroll
26
+ * lock (the shared useScrollLock, as Dialog uses, acquired once while the
27
+ * flow is presented and released after its exit, never per step) and the
28
+ * one layer-dismissal registration, and returns focus to the flow's opener
29
+ * when the flow ends, which no single dialog can know.
30
+ *
31
+ * The switcher's one element is a flow wrapper. In a scrim flow it has no box
32
+ * (`display: contents`): the dialogs sit in the top layer. In a no-scrim flow
33
+ * it is the isolated surface that orders the sheet dialogs locally, the way
34
+ * the shared shell once did, so no sheet needs a page-level value of its own.
35
+ *
36
+ * Its `ref` resolves to the host dialog: the one presenting the active sheet,
37
+ * the exiting sheet's through its exit, and the last host's (or the first
38
+ * mounted sheet's) while the flow is closed, so the ref is never null while a
39
+ * sheet is mounted. `id` and `data-testid` render on the host dialog only:
40
+ * a flow that mounts open carries them on the active sheet's dialog in server
41
+ * markup too; a flow that mounts closed carries them from the first commit's
42
+ * synchronous re-render on, and its server markup carries them on no element.
43
+ * Every other dialog prop and handler, `onCancel` included, applies to every
44
+ * sheet dialog; the browser raises `cancel` on the dialog it presents.
22
45
  *
23
46
  * SYNC: When modified, update these files to stay in sync:
24
47
  * - /packages/core/src/BottomSheet/BottomSheet.tsx
25
- * - /packages/core/src/BottomSheet/BottomSheetEdgeTint.tsx
48
+ * - /packages/core/src/BottomSheet/BottomSheetSwitcherContext.ts
26
49
  * - /packages/core/src/BottomSheet/BottomSheetSwitcher.doc.mjs
27
50
  * - /packages/core/src/BottomSheet/BottomSheetSwitcher.spec.md
28
51
  * - /packages/core/src/BottomSheet/BottomSheetSwitcher.test.tsx
@@ -45,78 +68,42 @@ import {
45
68
  import * as stylex from '@stylexjs/stylex';
46
69
  import type {BaseProps} from '../BaseProps';
47
70
  import type {DialogPurpose} from '../Dialog';
48
- import {colorVars, durationVars, easeVars} from '../theme/tokens.stylex';
49
71
  import {useScrollLock} from '../hooks';
50
- import {
51
- useFocusTrap,
52
- useFocusTrapEscapeCompatibilitySignal,
53
- } from '../hooks/useFocusTrap';
54
- import {LayerDepthProvider} from '../Layer/LayerDepthContext';
72
+ import {useFocusTrapEscapeCompatibilitySignal} from '../hooks/useFocusTrap';
55
73
  import {dispatchLayerEscapeKeyDown} from '../Layer/layerStack';
56
74
  import {useLayerDismissal} from '../Layer/useLayerDismissal';
57
- import {composeEventHandlers, mergeProps} from '../utils';
58
- import {BottomSheetEdgeTint} from './BottomSheetEdgeTint';
75
+ import {composeEventHandlers} from '../utils';
76
+ import {mergeRefs} from '../utils/mergeRefs';
59
77
  import {
60
78
  BottomSheetSwitcherContext,
61
79
  type BottomSheetSwitcherContextValue,
80
+ type BottomSheetSwitcherDialogProps,
62
81
  type BottomSheetSwitcherPhase,
63
82
  type BottomSheetSwitcherTransitionEvent,
64
83
  } from './BottomSheetSwitcherContext';
65
84
 
66
- import {useMergedRefs} from '../hooks/useMergedRefs';
67
85
  const styles = stylex.create({
68
- dialog: {
69
- position: 'fixed',
70
- inset: 0,
71
- width: '100dvw',
72
- height: '100dvh',
73
- maxWidth: 'none',
74
- maxHeight: 'none',
75
- margin: 0,
76
- padding: 0,
77
- borderWidth: 0,
78
- borderStyle: 'none',
79
- backgroundColor: 'transparent',
80
- overflow: 'visible',
81
- display: 'none',
82
- outline: 'none',
86
+ // A scrim flow's dialogs live in the top layer; the wrapper has no box.
87
+ flow: {
88
+ display: 'contents',
83
89
  },
84
- dialogOpen: {
90
+ // show() leaves a no-scrim flow in the normal rendering tree, where DOM
91
+ // order would decide which sheet dialog paints in front during a handoff.
92
+ // The wrapper is then the flow's isolated surface: the sheet dialogs order
93
+ // themselves locally inside it (`spec:AST-027` FR2), and the wrapper itself
94
+ // keeps the one overlay value the shared shell carried before, which
95
+ // `spec:AST-027` records as the non-modal migration gap. It passes pointer
96
+ // input through to the live page; each sheet panel opts back in. Consumers
97
+ // of this non-modal mode must still avoid transformed or clipping ancestors.
98
+ flowNonModal: {
85
99
  display: 'block',
86
- },
87
- // show() leaves a no-scrim flow in the normal rendering tree. The shell must
88
- // pass pointer input through to the live page; each sheet panel opts back in.
89
- // width/height 100% avoids overflowing a transformed containing block, but
90
- // consumers of this non-modal mode must still avoid clipping ancestors.
91
- dialogNonModal: {
92
- pointerEvents: 'none',
93
- zIndex: 1000,
100
+ position: 'fixed',
101
+ inset: 0,
94
102
  width: '100%',
95
103
  height: '100%',
96
- },
97
- scrim: {
98
- '::backdrop': {
99
- backgroundColor: colorVars['--color-overlay'],
100
- opacity: {
101
- default: 'var(--_sheet-scrim-opacity, 1)',
102
- '@starting-style': 0,
103
- },
104
- transitionProperty: 'opacity, display',
105
- transitionDuration: durationVars['--duration-medium'],
106
- transitionTimingFunction: easeVars['--ease-standard'],
107
- transitionBehavior: 'allow-discrete',
108
- '@media (prefers-reduced-motion: reduce)': {
109
- transitionDuration: '0.01s',
110
- },
111
- },
112
- },
113
- // The flow's dim leaves with its last panel. Same rule, same reasoning, as
114
- // a standalone sheet's -- see `scrimClosing` in BottomSheet.tsx. A handoff
115
- // between two sheets is not a close, and keeps the entrance curve.
116
- scrimClosing: {
117
- '::backdrop': {
118
- transitionTimingFunction: 'linear',
119
- },
104
+ pointerEvents: 'none',
105
+ isolation: 'isolate',
106
+ zIndex: 1000,
120
107
  },
121
108
  });
122
109
 
@@ -179,11 +166,54 @@ function alignmentOffsetForElements(
179
166
  return Math.max(0, enteringTop - retainedElement.getBoundingClientRect().top);
180
167
  }
181
168
 
169
+ /**
170
+ * Keeps one object identity while its own enumerable entries are unchanged, so
171
+ * the consumer's rest props can sit in a memoized context value without
172
+ * refreshing every sheet host on each switcher render.
173
+ */
174
+ function useShallowStable<T extends object>(value: T): T {
175
+ const stableRef = useRef(value);
176
+ const previous = stableRef.current;
177
+ const previousKeys = Object.keys(previous) as (keyof T)[];
178
+ const nextKeys = Object.keys(value) as (keyof T)[];
179
+ const isSame =
180
+ previousKeys.length === nextKeys.length &&
181
+ nextKeys.every(key => Object.is(previous[key], value[key]));
182
+ if (!isSame) {
183
+ stableRef.current = value;
184
+ }
185
+ return stableRef.current;
186
+ }
187
+
188
+ /**
189
+ * Hands the flow's scrim to `dialog` without a fade. The backdrop's opacity
190
+ * transition would otherwise ease from the transparent value the entering
191
+ * dialog was presented with, dipping the page's dim for one motion token
192
+ * exactly when the previous dialog's backdrop disappears. Even a near-zero
193
+ * duration paints its first frame at the start value, so the swap disables
194
+ * the transition outright, flushes style so the new value is committed under
195
+ * that rule, then restores the token for later gesture and exit fades.
196
+ */
197
+ function takeOverScrim(dialog: HTMLDialogElement, opacity: number): void {
198
+ dialog.style.setProperty('--_sheet-scrim-duration', '0s');
199
+ dialog.style.setProperty('--_sheet-scrim-opacity', String(opacity));
200
+ if (typeof getComputedStyle === 'function') {
201
+ void getComputedStyle(dialog, '::backdrop').opacity;
202
+ }
203
+ dialog.style.removeProperty('--_sheet-scrim-duration');
204
+ }
205
+
182
206
  export interface BottomSheetSwitcherProps extends BaseProps<HTMLDialogElement> {
183
- /** Ref forwarded to the shared native dialog. */
207
+ /**
208
+ * Ref resolved to the native dialog hosting the flow: the active sheet's
209
+ * dialog, which changes on a handoff; the exiting sheet's through its exit;
210
+ * and, while the flow is closed, the dialog that hosted it last (or the
211
+ * first mounted sheet's), so it is never null while a sheet is mounted,
212
+ * from the first commit on.
213
+ */
184
214
  ref?: React.Ref<HTMLDialogElement>;
185
215
 
186
- /** Called when the shared native dialog receives a cancel event. */
216
+ /** Called when the active sheet's native dialog receives a cancel event. */
187
217
  onCancel?: (event: SyntheticEvent<HTMLDialogElement>) => void;
188
218
 
189
219
  /**
@@ -198,7 +228,7 @@ export interface BottomSheetSwitcherProps extends BaseProps<HTMLDialogElement> {
198
228
  onActiveSheetChange: (sheetId: string | null) => void;
199
229
 
200
230
  /**
201
- * Whether to open the shared dialog modally with its native ::backdrop.
231
+ * Whether each sheet dialog opens modally with a native ::backdrop.
202
232
  * Disable for a viewport-anchored, non-modal flow over an interactive page.
203
233
  * @default true
204
234
  */
@@ -209,8 +239,8 @@ export interface BottomSheetSwitcherProps extends BaseProps<HTMLDialogElement> {
209
239
  }
210
240
 
211
241
  /**
212
- * Coordinates a set of BottomSheets so zero or one is active at a time inside
213
- * one shared native dialog. During a handoff the previous panel stays visible
242
+ * Coordinates a set of BottomSheets so zero or one is active at a time, each
243
+ * in its own native dialog. During a handoff the previous panel stays visible
214
244
  * and inert beneath the entering panel, then fades after motion completes.
215
245
  *
216
246
  * @example
@@ -229,22 +259,40 @@ export function BottomSheetSwitcher({
229
259
  hasScrim = true,
230
260
  children,
231
261
  ref,
232
- xstyle,
233
- className,
234
- style,
235
262
  onCancel,
236
263
  onClick,
237
264
  onKeyDown,
238
- ...props
265
+ className,
266
+ style,
267
+ xstyle,
268
+ ...restDialogProps
239
269
  }: BottomSheetSwitcherProps) {
240
- const dialogRef = useRef<HTMLDialogElement | null>(null);
241
- const dialogModeRef = useRef<'modal' | 'non-modal' | null>(null);
270
+ // Every sheet host applies the switcher's dialog surface to its own dialog.
271
+ const dialogProps = useShallowStable<BottomSheetSwitcherDialogProps>({
272
+ ...restDialogProps,
273
+ className,
274
+ style,
275
+ xstyle,
276
+ });
277
+ const hostDialogRef = useRef<HTMLDialogElement | null>(null);
278
+ const sheetDialogsRef = useRef(new Map<string, HTMLDialogElement>());
279
+ // Sheet ids in the order their dialogs mounted (tree order on first mount).
280
+ // State, not a ref: the resting host below is rendered -- it carries the
281
+ // consumer's `id` -- so a sheet mounting or leaving must re-render.
282
+ const [sheetOrder, setSheetOrder] = useState<ReadonlyArray<string>>([]);
283
+ // The sheet that hosted the flow last, kept while the flow is closed so the
284
+ // ref and `id` stay on the dialog the person last used. State adjusted
285
+ // during render (not a ref read during render): it changes exactly when
286
+ // the presented host does, and the next render reads it.
287
+ const [lastHostSheet, setLastHostSheet] = useState<string | null>(null);
288
+ const presentationOrderRef = useRef(new Map<string, number>());
289
+ const presentationCounterRef = useRef(0);
290
+ const modalFlowRef = useRef(false);
242
291
  const triggerRef = useRef<HTMLElement | null>(null);
243
292
  const sheetElementsRef = useRef(new Map<string, HTMLElement>());
244
293
  const committedActiveSheetRef = useRef(activeSheet);
245
- const [sheetLabels, setSheetLabels] = useState<ReadonlyMap<string, string>>(
246
- () => new Map(),
247
- );
294
+ const scrimOpacityRef = useRef(1);
295
+ const scrimOwnerRef = useRef<string | null>(null);
248
296
  const [sheetPurposes, setSheetPurposes] = useState<
249
297
  ReadonlyMap<string, DialogPurpose>
250
298
  >(() => new Map());
@@ -261,11 +309,42 @@ export function BottomSheetSwitcher({
261
309
  activeSheet,
262
310
  )
263
311
  : transition;
312
+ const presentedActiveSheet =
313
+ activeSheet != null && !unmountedSheetIds.has(activeSheet)
314
+ ? activeSheet
315
+ : null;
316
+ const presentedRetainedSheet =
317
+ visibleTransition.retainedSheet != null &&
318
+ !unmountedSheetIds.has(visibleTransition.retainedSheet)
319
+ ? visibleTransition.retainedSheet
320
+ : null;
264
321
  const isFlowVisible =
265
- (activeSheet != null && !unmountedSheetIds.has(activeSheet)) ||
266
- (visibleTransition.retainedSheet != null &&
267
- !unmountedSheetIds.has(visibleTransition.retainedSheet));
322
+ presentedActiveSheet != null || presentedRetainedSheet != null;
268
323
  const isModal = hasScrim && isFlowVisible;
324
+ // The dialog that hosts the flow for its ref, its single-instance
325
+ // attributes, and its dismissal registration: the active sheet's, the
326
+ // retained sheet's while the flow closes through its exit, and while the
327
+ // flow is closed the dialog that hosted it last (or the first mounted
328
+ // sheet's before any open), so the ref resolves to a closed dialog element
329
+ // rather than to null, as the shared dialog did. Which sheet mounts first
330
+ // is known only once its dialog registers during the first commit: the
331
+ // ref is attached to it in that same commit (below), and the rendered
332
+ // `id` follows in the synchronous re-render that commit schedules, before
333
+ // the first paint. Server markup therefore carries the `id` on no element.
334
+ const presentedHostSheet = presentedActiveSheet ?? presentedRetainedSheet;
335
+ if (presentedHostSheet != null && presentedHostSheet !== lastHostSheet) {
336
+ setLastHostSheet(presentedHostSheet);
337
+ }
338
+ const restingHostSheet =
339
+ lastHostSheet != null && sheetOrder.includes(lastHostSheet)
340
+ ? lastHostSheet
341
+ : (sheetOrder[0] ?? null);
342
+ const hostSheet = presentedHostSheet ?? restingHostSheet;
343
+ // The dialog whose backdrop paints the flow's one scrim. A retained sheet
344
+ // keeps it through a handoff so the entering dialog, presented above it in
345
+ // the top layer, does not dim the previous panel beneath its own backdrop.
346
+ const scrimOwnerSheet = presentedRetainedSheet ?? presentedActiveSheet;
347
+ const isFlowClosing = isFlowVisible && activeSheet == null;
269
348
  const activeSheetPurpose =
270
349
  (activeSheet == null ? null : sheetPurposes.get(activeSheet)) ?? 'info';
271
350
  const allowsEscapeDismiss = activeSheetPurpose !== 'required';
@@ -299,59 +378,102 @@ export function BottomSheetSwitcher({
299
378
  onActiveSheetChange(null);
300
379
  }
301
380
  }, [allowsLightDismiss, onActiveSheetChange]);
302
- const {containerRef} = useFocusTrap<HTMLDialogElement>({
303
- isActive: isModal,
304
- });
305
- // Before the shared dismissal stack, this modal trap supplied `onEscape`, so
306
- // the released compatibility shim reported it as active. Keep that signal
307
- // without registering the switcher twice in the one shared stack.
381
+ // Focus containment is the native modal dialog's: showModal() makes the
382
+ // page inert, so Tab cannot leave the active sheet. The released read-only
383
+ // `hasActiveFocusTrapEscape()` signal still reports a modal flow as a trap,
384
+ // as it did when a JavaScript trap backed it.
308
385
  useFocusTrapEscapeCompatibilitySignal(isModal);
386
+ // One lock for the whole modal flow, as on a shared dialog: `isModal` stays
387
+ // true through a handoff (two dialogs open) and through the exit motion, so
388
+ // the shared counter is taken once and given back once.
389
+ useScrollLock(isModal);
309
390
  const {shouldDismissOnCloseRequest} = useLayerDismissal({
310
391
  isActive: isFlowVisible,
311
392
  escapeBehavior: allowsEscapeDismiss ? 'close' : 'block',
312
393
  onDismiss: dismissOnEscape,
313
- getContainer: () => dialogRef.current,
314
- isPresent: () => dialogRef.current?.open ?? false,
394
+ getContainer: () => hostDialogRef.current,
395
+ isPresent: () => hostDialogRef.current?.open ?? false,
315
396
  });
316
- useScrollLock(isModal);
317
397
 
318
- // Open one shared shell for the complete flow. Modal flows enter the native
319
- // top layer once; handoffs only swap panels inside it. The final panel owns
320
- // the exit timing, so isFlowVisible becomes false only when it is safe to
321
- // close the dialog and restore focus.
398
+ // Point the consumer ref and the dismissal registration at the host dialog.
399
+ // Sheet hosts register their dialogs during commit, so by the time this
400
+ // layout effect runs the map already names the dialog for `hostSheet` --
401
+ // and, in the first commit of a flow that mounts closed, the first
402
+ // registered dialog before `sheetOrder` has caught up, so a parent's own
403
+ // mount effects already find the ref resolved. The ref is attached the way
404
+ // React attaches one: a callback ref's returned cleanup runs on detach, and
405
+ // only a ref without one receives null. Attachment follows the resolved
406
+ // element, not the render that resolved it: the re-render that catches
407
+ // `sheetOrder` up resolves the same dialog and leaves the ref alone.
408
+ const attachedRef = useRef<{
409
+ ref: React.Ref<HTMLDialogElement> | undefined;
410
+ host: HTMLDialogElement;
411
+ detach: (() => void) | undefined;
412
+ } | null>(null);
322
413
  useLayoutEffect(() => {
323
- const dialog = dialogRef.current;
324
- if (dialog == null) {
414
+ const host =
415
+ hostSheet == null
416
+ ? (sheetDialogsRef.current.values().next().value ?? null)
417
+ : (sheetDialogsRef.current.get(hostSheet) ?? null);
418
+ hostDialogRef.current = host;
419
+ const attached = attachedRef.current;
420
+ if (attached?.host === host && attached.ref === ref) {
325
421
  return;
326
422
  }
423
+ attached?.detach?.();
424
+ if (host == null) {
425
+ attachedRef.current = null;
426
+ return;
427
+ }
428
+ const detach = mergeRefs(ref)(host);
429
+ attachedRef.current = {
430
+ ref,
431
+ host,
432
+ detach: typeof detach === 'function' ? detach : undefined,
433
+ };
434
+ }, [hostSheet, ref, sheetOrder]);
435
+ useLayoutEffect(
436
+ () => () => {
437
+ hostDialogRef.current = null;
438
+ attachedRef.current?.detach?.();
439
+ attachedRef.current = null;
440
+ },
441
+ [],
442
+ );
327
443
 
444
+ // Focus return is native per dialog: each showModal() records the focused
445
+ // element and close() focuses it again. That serves a one-sheet flow, but
446
+ // the second sheet of a flow records a control inside the first, which is
447
+ // hidden by the time the flow ends, so the browser's return lands nowhere.
448
+ // The flow therefore remembers its own opener -- the page element focused
449
+ // before the first modal present -- and focuses it after the last dialog
450
+ // closes. Sheet hosts ask before presenting modally, so the capture sees
451
+ // the page element; hosts present in their own layout effects, which run
452
+ // before the one below, so the restore follows the last dialog's close.
453
+ const onBeforeSheetPresent = useCallback((sheetId: string) => {
454
+ if (triggerRef.current == null) {
455
+ triggerRef.current = document.activeElement as HTMLElement | null;
456
+ }
457
+ modalFlowRef.current = true;
458
+ presentationCounterRef.current += 1;
459
+ presentationOrderRef.current.set(sheetId, presentationCounterRef.current);
460
+ }, []);
461
+ useLayoutEffect(() => {
328
462
  if (isFlowVisible) {
329
- const nextMode = hasScrim ? 'modal' : 'non-modal';
330
- if (dialog.open && dialogModeRef.current !== nextMode) {
331
- dialog.close();
463
+ if (!hasScrim) {
464
+ modalFlowRef.current = false;
332
465
  }
333
- if (!dialog.open) {
334
- if (hasScrim && triggerRef.current == null) {
335
- triggerRef.current = document.activeElement as HTMLElement | null;
336
- }
337
- if (hasScrim) {
338
- dialog.showModal();
339
- } else {
340
- dialog.show();
341
- }
342
- }
343
- dialogModeRef.current = nextMode;
344
466
  return;
345
467
  }
346
-
347
- if (dialog.open) {
348
- dialog.close();
349
- }
350
- if (dialogModeRef.current === 'modal') {
351
- triggerRef.current?.focus();
468
+ if (modalFlowRef.current) {
469
+ // Like the browser's own return on close(), without scrolling: the
470
+ // page kept its scroll offset under the lock, and an opener scrolled
471
+ // out of view must not drag the page back to itself.
472
+ triggerRef.current?.focus({preventScroll: true});
352
473
  }
353
474
  triggerRef.current = null;
354
- dialogModeRef.current = null;
475
+ modalFlowRef.current = false;
476
+ presentationOrderRef.current.clear();
355
477
  }, [hasScrim, isFlowVisible]);
356
478
 
357
479
  const getSheetPhase = useCallback(
@@ -377,6 +499,11 @@ export function BottomSheetSwitcher({
377
499
  [visibleTransition],
378
500
  );
379
501
 
502
+ const isScrimOwner = useCallback(
503
+ (sheetId: string) => sheetId === scrimOwnerSheet,
504
+ [scrimOwnerSheet],
505
+ );
506
+
380
507
  const registerSheetElement = useCallback(
381
508
  (sheetId: string, element: HTMLElement | null) => {
382
509
  if (element == null) {
@@ -407,28 +534,44 @@ export function BottomSheetSwitcher({
407
534
  [],
408
535
  );
409
536
 
410
- const registerSheetLabel = useCallback(
411
- (sheetId: string, label: string | null) => {
412
- setSheetLabels(current => {
413
- if (label == null) {
414
- if (!current.has(sheetId)) {
415
- return current;
416
- }
417
- const next = new Map(current);
418
- next.delete(sheetId);
419
- return next;
420
- }
421
- if (current.get(sheetId) === label) {
422
- return current;
423
- }
424
- const next = new Map(current);
425
- next.set(sheetId, label);
426
- return next;
427
- });
537
+ const registerSheetDialog = useCallback(
538
+ (sheetId: string, dialog: HTMLDialogElement | null) => {
539
+ if (dialog == null) {
540
+ sheetDialogsRef.current.delete(sheetId);
541
+ presentationOrderRef.current.delete(sheetId);
542
+ setSheetOrder(current =>
543
+ current.includes(sheetId)
544
+ ? current.filter(id => id !== sheetId)
545
+ : current,
546
+ );
547
+ } else {
548
+ sheetDialogsRef.current.set(sheetId, dialog);
549
+ setSheetOrder(current =>
550
+ current.includes(sheetId) ? current : [...current, sheetId],
551
+ );
552
+ }
428
553
  },
429
554
  [],
430
555
  );
431
556
 
557
+ const isCoveredInTopLayer = useCallback((sheetId: string) => {
558
+ const order = presentationOrderRef.current;
559
+ const own = order.get(sheetId);
560
+ if (own == null) {
561
+ return false;
562
+ }
563
+ for (const [otherId, stamp] of order) {
564
+ if (
565
+ otherId !== sheetId &&
566
+ stamp > own &&
567
+ sheetDialogsRef.current.get(otherId)?.open
568
+ ) {
569
+ return true;
570
+ }
571
+ }
572
+ return false;
573
+ }, []);
574
+
432
575
  const registerSheetPurpose = useCallback(
433
576
  (sheetId: string, purpose: DialogPurpose | null) => {
434
577
  setSheetPurposes(current => {
@@ -533,67 +676,64 @@ export function BottomSheetSwitcher({
533
676
  [],
534
677
  );
535
678
 
536
- const setScrimOpacity = useCallback((opacity: number) => {
537
- dialogRef.current?.style.setProperty(
538
- '--_sheet-scrim-opacity',
539
- String(opacity),
540
- );
541
- }, []);
679
+ // One scrim for the flow. Only the owning dialog's backdrop carries the
680
+ // current opacity; every other presented dialog keeps its backdrop clear.
681
+ // When ownership moves — the retained dialog has faded and closes, or a
682
+ // rapid replacement hides it — the new owner takes the value without a
683
+ // fade, in the same layout pass that closes the old one.
684
+ const applyScrimOpacity = useCallback(() => {
685
+ const owner =
686
+ scrimOwnerSheet == null
687
+ ? null
688
+ : (sheetDialogsRef.current.get(scrimOwnerSheet) ?? null);
689
+ // A fresh open keeps the backdrop's @starting-style fade; only a hand
690
+ // from one presented dialog to another swaps without one.
691
+ const ownerChanged =
692
+ scrimOwnerRef.current != null &&
693
+ scrimOwnerRef.current !== scrimOwnerSheet;
694
+ scrimOwnerRef.current = scrimOwnerSheet;
695
+ for (const [sheetId, dialog] of sheetDialogsRef.current) {
696
+ if (dialog === owner) {
697
+ if (ownerChanged && dialog.open) {
698
+ takeOverScrim(dialog, scrimOpacityRef.current);
699
+ } else {
700
+ dialog.style.setProperty(
701
+ '--_sheet-scrim-opacity',
702
+ String(scrimOpacityRef.current),
703
+ );
704
+ }
705
+ } else if (sheetId !== scrimOwnerSheet) {
706
+ dialog.style.setProperty('--_sheet-scrim-opacity', '0');
707
+ }
708
+ }
709
+ }, [scrimOwnerSheet]);
542
710
  const onSheetScrimOpacityChange = useCallback(
543
711
  (sheetId: string, opacity: number) => {
544
712
  // A pointer captured by the outgoing sheet can keep delivering gesture
545
713
  // events after a handoff. Only the currently committed sheet owns the
546
- // shared backdrop, so stale gesture updates must not reach the dialog.
714
+ // flow's scrim value, so stale gesture updates must not reach it.
547
715
  if (sheetId !== committedActiveSheetRef.current) {
548
716
  return;
549
717
  }
550
- setScrimOpacity(opacity);
718
+ scrimOpacityRef.current = opacity;
719
+ applyScrimOpacity();
551
720
  },
552
- [setScrimOpacity],
721
+ [applyScrimOpacity],
553
722
  );
554
723
 
724
+ // A committed selection resets the flow's scrim value -- 1 while a sheet
725
+ // is active, 0 for the exit fade -- and writes it to the owning backdrop in
726
+ // the same effect; a gesture then adjusts the value through
727
+ // `onSheetScrimOpacityChange` until the next selection.
555
728
  useLayoutEffect(() => {
556
- setScrimOpacity(activeSheet == null ? 0 : 1);
557
- }, [activeSheet, setScrimOpacity]);
558
-
559
- const contextValue = useMemo<BottomSheetSwitcherContextValue>(
560
- () => ({
561
- activeSheet,
562
- hasScrim,
563
- onActiveSheetChange,
564
- getSheetPhase,
565
- getSheetAlignmentOffset,
566
- registerSheetElement,
567
- registerSheetLabel,
568
- registerSheetPurpose,
569
- onSheetEnterStart,
570
- onSheetTransitionComplete,
571
- onSheetScrimOpacityChange,
572
- }),
573
- [
574
- activeSheet,
575
- getSheetAlignmentOffset,
576
- getSheetPhase,
577
- hasScrim,
578
- onActiveSheetChange,
579
- onSheetEnterStart,
580
- onSheetScrimOpacityChange,
581
- onSheetTransitionComplete,
582
- registerSheetElement,
583
- registerSheetLabel,
584
- registerSheetPurpose,
585
- ],
586
- );
587
-
588
- const activeLabel =
589
- (activeSheet == null ? null : sheetLabels.get(activeSheet)) ??
590
- (visibleTransition.retainedSheet == null
591
- ? undefined
592
- : sheetLabels.get(visibleTransition.retainedSheet));
593
-
594
- // A `cancel` that bubbles up from a step is not a close request for the
595
- // flow: a file input fires one when its picker is dismissed, and a step
596
- // renders no dialog of its own, so it lands here. It is left alone.
729
+ scrimOpacityRef.current = activeSheet == null ? 0 : 1;
730
+ applyScrimOpacity();
731
+ }, [applyScrimOpacity, activeSheet, hasScrim, isFlowVisible]);
732
+
733
+ // A `cancel` that bubbles up from inside a step is not a close request for
734
+ // the flow: a file input fires one when its picker is dismissed, and it
735
+ // reaches the step's dialog. Only a `cancel` fired at a sheet dialog itself
736
+ // dismisses; the bubbled one is left alone.
597
737
  const handleCancel = useCallback(
598
738
  (event: SyntheticEvent<HTMLDialogElement>) => {
599
739
  if (event.target !== event.currentTarget) {
@@ -618,47 +758,88 @@ export function BottomSheetSwitcher({
618
758
  );
619
759
  const handleClick = useCallback(
620
760
  (event: ReactMouseEvent<HTMLDialogElement>) => {
621
- if (hasScrim && event.target === event.currentTarget) {
761
+ // A `::backdrop` click targets its dialog element. Only the active
762
+ // sheet's backdrop is the flow's scrim; a retained dialog is inert and
763
+ // a hidden one closed, so neither can be clicked, but be explicit.
764
+ if (
765
+ hasScrim &&
766
+ event.target === event.currentTarget &&
767
+ event.currentTarget === hostDialogRef.current
768
+ ) {
622
769
  dismissOnLightInteraction();
623
770
  }
624
771
  },
625
772
  [dismissOnLightInteraction, hasScrim],
626
773
  );
627
-
628
- const dialogStyleProps = stylex.props(
629
- styles.dialog,
630
- isFlowVisible && styles.dialogOpen,
631
- hasScrim && styles.scrim,
632
- hasScrim && isFlowVisible && activeSheet == null && styles.scrimClosing,
633
- !hasScrim && styles.dialogNonModal,
634
- xstyle,
774
+ const onDialogCancel = useMemo(
775
+ () => composeEventHandlers(onCancel, handleCancel),
776
+ [handleCancel, onCancel],
635
777
  );
636
- const dialogPresentationProps = mergeProps(
637
- dialogStyleProps,
638
- className,
639
- style,
778
+ const onDialogClick = useMemo(
779
+ () => composeEventHandlers(onClick, handleClick),
780
+ [handleClick, onClick],
781
+ );
782
+ const onDialogKeyDown = useMemo(
783
+ () => composeEventHandlers(onKeyDown, handleKeyDown),
784
+ [handleKeyDown, onKeyDown],
785
+ );
786
+
787
+ const contextValue = useMemo<BottomSheetSwitcherContextValue>(
788
+ () => ({
789
+ activeSheet,
790
+ hasScrim,
791
+ onActiveSheetChange,
792
+ getSheetPhase,
793
+ getSheetAlignmentOffset,
794
+ hostSheet,
795
+ isScrimOwner,
796
+ isFlowClosing,
797
+ registerSheetElement,
798
+ registerSheetDialog,
799
+ registerSheetPurpose,
800
+ onBeforeSheetPresent,
801
+ isCoveredInTopLayer,
802
+ onSheetEnterStart,
803
+ onSheetTransitionComplete,
804
+ onSheetScrimOpacityChange,
805
+ dialogProps,
806
+ onDialogCancel,
807
+ onDialogClick,
808
+ onDialogKeyDown,
809
+ }),
810
+ [
811
+ activeSheet,
812
+ dialogProps,
813
+ getSheetAlignmentOffset,
814
+ getSheetPhase,
815
+ hasScrim,
816
+ hostSheet,
817
+ isCoveredInTopLayer,
818
+ isFlowClosing,
819
+ isScrimOwner,
820
+ onActiveSheetChange,
821
+ onBeforeSheetPresent,
822
+ onDialogCancel,
823
+ onDialogClick,
824
+ onDialogKeyDown,
825
+ onSheetEnterStart,
826
+ onSheetScrimOpacityChange,
827
+ onSheetTransitionComplete,
828
+ registerSheetDialog,
829
+ registerSheetElement,
830
+ registerSheetPurpose,
831
+ ],
640
832
  );
641
- const ariaLabel =
642
- props['aria-label'] ??
643
- (props['aria-labelledby'] == null ? activeLabel : undefined);
644
833
 
645
834
  return (
646
835
  <BottomSheetSwitcherContext value={contextValue}>
647
- <dialog
648
- {...props}
649
- {...dialogPresentationProps}
650
- ref={useMergedRefs(ref, dialogRef, containerRef)}
651
- aria-label={ariaLabel}
652
- aria-modal={isModal ? 'true' : undefined}
653
- onCancel={composeEventHandlers(onCancel, handleCancel)}
654
- onClick={composeEventHandlers(onClick, handleClick)}
655
- onKeyDown={composeEventHandlers(onKeyDown, handleKeyDown)}
656
- {...(activeSheetPurpose === 'required'
657
- ? {role: 'alertdialog'}
658
- : undefined)}>
659
- <LayerDepthProvider>{children}</LayerDepthProvider>
660
- <BottomSheetEdgeTint />
661
- </dialog>
836
+ <div
837
+ {...stylex.props(
838
+ styles.flow,
839
+ !hasScrim && isFlowVisible && styles.flowNonModal,
840
+ )}>
841
+ {children}
842
+ </div>
662
843
  </BottomSheetSwitcherContext>
663
844
  );
664
845
  }