@astryxdesign/core 0.7.3 → 0.7.4-canary.10b832e

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 (131) 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/ChatMessageList.d.ts.map +1 -1
  13. package/dist/Chat/ChatMessageList.js +8 -1
  14. package/dist/Chat/useTriggerMenu.d.ts.map +1 -1
  15. package/dist/Chat/useTriggerMenu.js +46 -7
  16. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  17. package/dist/CheckboxInput/CheckboxInput.js +6 -0
  18. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  19. package/dist/ComplexSelector/ComplexSelector.js +6 -1
  20. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  21. package/dist/ContextMenu/ContextMenu.js +12 -0
  22. package/dist/DateInput/DateInput.d.ts.map +1 -1
  23. package/dist/DateInput/DateInput.js +6 -1
  24. package/dist/DateInput/NativeDateField.d.ts.map +1 -1
  25. package/dist/DateInput/NativeDateField.js +6 -1
  26. package/dist/DateInput/TouchDateField.d.ts.map +1 -1
  27. package/dist/DateInput/TouchDateField.js +9 -1
  28. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  29. package/dist/DateRangeInput/DateRangeInput.js +6 -1
  30. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  31. package/dist/DateTimeInput/DateTimeInput.js +6 -1
  32. package/dist/DateTimeInput/TouchDateTimeField.d.ts.map +1 -1
  33. package/dist/DateTimeInput/TouchDateTimeField.js +9 -1
  34. package/dist/DropdownMenu/DropdownMenuSubMenu.d.ts.map +1 -1
  35. package/dist/DropdownMenu/DropdownMenuSubMenu.js +7 -1
  36. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  37. package/dist/MultiSelector/MultiSelector.js +6 -1
  38. package/dist/Selector/Selector.d.ts.map +1 -1
  39. package/dist/Selector/Selector.js +6 -1
  40. package/dist/TextArea/TextArea.d.ts.map +1 -1
  41. package/dist/TextArea/TextArea.js +6 -1
  42. package/dist/TextInput/TextInput.d.ts.map +1 -1
  43. package/dist/TextInput/TextInput.js +6 -1
  44. package/dist/Thumbnail/Thumbnail.d.ts.map +1 -1
  45. package/dist/Thumbnail/Thumbnail.js +9 -2
  46. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  47. package/dist/TimeInput/TimeInput.js +6 -1
  48. package/dist/TimeInput/TouchTimeField.d.ts.map +1 -1
  49. package/dist/TimeInput/TouchTimeField.js +9 -1
  50. package/dist/Tokenizer/Tokenizer.d.ts.map +1 -1
  51. package/dist/Tokenizer/Tokenizer.js +9 -1
  52. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  53. package/dist/Typeahead/BaseTypeahead.js +7 -1
  54. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  55. package/dist/Typeahead/Typeahead.js +9 -1
  56. package/dist/astryx.css +2 -0
  57. package/dist/hooks/useBusyAnnouncement.d.ts +22 -0
  58. package/dist/hooks/useBusyAnnouncement.d.ts.map +1 -0
  59. package/dist/hooks/useBusyAnnouncement.js +87 -0
  60. package/dist/hooks/useMenuPress.d.ts +2 -2
  61. package/dist/hooks/useMenuPress.d.ts.map +1 -1
  62. package/dist/hooks/useMenuPress.js +5 -1
  63. package/dist/hooks/useTypeahead.d.ts.map +1 -1
  64. package/dist/hooks/useTypeahead.js +7 -2
  65. package/dist/i18n/generated-locales/en.generated.d.ts.map +1 -1
  66. package/dist/i18n/generated-locales/en.generated.js +1 -0
  67. package/dist/i18n/generated-locales/pseudo.generated.d.ts.map +1 -1
  68. package/dist/i18n/generated-locales/pseudo.generated.js +1 -0
  69. package/locales/en.json +6 -2
  70. package/locales/pseudo.json +3 -0
  71. package/package.json +4 -4
  72. package/src/BottomSheet/BottomSheet.doc.mjs +2 -2
  73. package/src/BottomSheet/BottomSheet.spec.md +2 -5
  74. package/src/BottomSheet/BottomSheet.tsx +260 -47
  75. package/src/BottomSheet/BottomSheetEdgeTint.test.tsx +4 -2
  76. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +11 -10
  77. package/src/BottomSheet/BottomSheetSwitcher.spec.md +28 -35
  78. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +830 -48
  79. package/src/BottomSheet/BottomSheetSwitcher.tsx +396 -215
  80. package/src/BottomSheet/BottomSheetSwitcherContext.ts +57 -2
  81. package/src/Carousel/Carousel.test.tsx +8 -12
  82. package/src/Carousel/Carousel.tsx +6 -13
  83. package/src/Chat/ChatComposerInput.test.tsx +167 -1
  84. package/src/Chat/ChatMessageList.test.tsx +6 -2
  85. package/src/Chat/ChatMessageList.tsx +7 -1
  86. package/src/Chat/__tests__/ChatMessageList.a11y.chromium.spec.ts +20 -12
  87. package/src/Chat/useTriggerMenu.tsx +55 -6
  88. package/src/CheckboxInput/CheckboxInput.tsx +6 -0
  89. package/src/ComplexSelector/ComplexSelector.tsx +5 -1
  90. package/src/ContextMenu/ContextMenu.test.tsx +34 -0
  91. package/src/ContextMenu/ContextMenu.tsx +13 -1
  92. package/src/DateInput/DateInput.tsx +5 -1
  93. package/src/DateInput/DateInputTouch.test.tsx +45 -0
  94. package/src/DateInput/NativeDateField.test.tsx +10 -2
  95. package/src/DateInput/NativeDateField.tsx +5 -1
  96. package/src/DateInput/TouchDateField.tsx +14 -2
  97. package/src/DateRangeInput/DateRangeInput.tsx +5 -1
  98. package/src/DateTimeInput/DateTimeInput.tsx +5 -1
  99. package/src/DateTimeInput/TouchDateTimeField.tsx +14 -2
  100. package/src/DropdownMenu/DropdownMenuSubMenu.tsx +10 -1
  101. package/src/MultiSelector/MultiSelector.test.tsx +18 -4
  102. package/src/MultiSelector/MultiSelector.tsx +5 -1
  103. package/src/ProgressBar/ProgressBar.test.tsx +4 -21
  104. package/src/ProgressBar/__tests__/ProgressBar.a11y.chromium.spec.ts +114 -0
  105. package/src/ProgressBar/__tests__/ProgressBar.a11y.known-failures.ts +12 -0
  106. package/src/ProgressBar/__tests__/ProgressBar.a11y.renders.tsx +38 -0
  107. package/src/ProgressBar/__tests__/ProgressBar.a11y.states.ts +123 -0
  108. package/src/ProgressBar/__tests__/ProgressBar.a11y.test.tsx +107 -0
  109. package/src/Selector/Selector.test.tsx +22 -5
  110. package/src/Selector/Selector.tsx +5 -1
  111. package/src/TextArea/TextArea.test.tsx +9 -1
  112. package/src/TextArea/TextArea.tsx +5 -1
  113. package/src/TextInput/TextInput.tsx +5 -1
  114. package/src/Thumbnail/Thumbnail.test.tsx +6 -1
  115. package/src/Thumbnail/Thumbnail.tsx +12 -3
  116. package/src/TimeInput/TimeInput.tsx +5 -1
  117. package/src/TimeInput/TouchTimeField.tsx +12 -2
  118. package/src/Tokenizer/Tokenizer.test.tsx +17 -12
  119. package/src/Tokenizer/Tokenizer.tsx +10 -1
  120. package/src/Typeahead/BaseTypeahead.tsx +14 -1
  121. package/src/Typeahead/Typeahead.test.tsx +32 -41
  122. package/src/Typeahead/Typeahead.tsx +10 -1
  123. package/src/__tests__/BusyOwnerAnnouncement.test.tsx +576 -0
  124. package/src/hooks/__tests__/MenuPressShadowRoot.a11y.chromium.spec.ts +50 -0
  125. package/src/hooks/useBusyAnnouncement.ts +100 -0
  126. package/src/hooks/useMenuPress.test.tsx +27 -1
  127. package/src/hooks/useMenuPress.ts +12 -3
  128. package/src/hooks/useTypeahead.test.tsx +42 -1
  129. package/src/hooks/useTypeahead.ts +7 -2
  130. package/src/i18n/generated-locales/en.generated.ts +1 -0
  131. 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
@@ -32,62 +55,14 @@
32
55
  */
33
56
  import { useCallback, useLayoutEffect, useMemo, useRef, useState } from 'react';
34
57
  import * as stylex from '@stylexjs/stylex';
35
- import "../theme/tokens.stylex.js";
36
- import { colorVars, durationVars, easeVars } from "../theme/tokens.stylex.js";
37
58
  import { useScrollLock } from "../hooks/index.js";
38
- import { useFocusTrap, useFocusTrapEscapeCompatibilitySignal } from "../hooks/useFocusTrap.js";
39
- import { LayerDepthProvider } from "../Layer/LayerDepthContext.js";
59
+ import { useFocusTrapEscapeCompatibilitySignal } from "../hooks/useFocusTrap.js";
40
60
  import { dispatchLayerEscapeKeyDown } from "../Layer/layerStack.js";
41
61
  import { useLayerDismissal } from "../Layer/useLayerDismissal.js";
42
- import { composeEventHandlers, mergeProps } from "../utils/index.js";
43
- import { BottomSheetEdgeTint } from "./BottomSheetEdgeTint.js";
62
+ import { composeEventHandlers } from "../utils/index.js";
63
+ import { mergeRefs } from "../utils/mergeRefs.js";
44
64
  import { BottomSheetSwitcherContext } from "./BottomSheetSwitcherContext.js";
45
- import { useMergedRefs } from "../hooks/useMergedRefs.js";
46
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
47
- const styles = {
48
- dialog: {
49
- kVAEAm: "xixxii4",
50
- kpwlN0: "x10a8y8t",
51
- kzqmXN: "x1o6l61p",
52
- kZKoxP: "xtdtrs8",
53
- ks0D6T: "x1x1rfll",
54
- kskxy: "x7ab17h",
55
- kogj98: "x1ghz6dp",
56
- kmVPX3: "x1717udv",
57
- kMzoRj: "xc342km",
58
- ksu8eU: "xng3xce",
59
- kWkggS: "xjbqb8w",
60
- kVQacm: "x1rea2x4",
61
- k1xSpc: "x1s85apg",
62
- kI3sdo: "x1a2a7pz",
63
- $$css: true
64
- },
65
- dialogOpen: {
66
- k1xSpc: "x1lliihq",
67
- $$css: true
68
- },
69
- dialogNonModal: {
70
- kfzvcC: "x47corl",
71
- kY2c9j: "xfo81ep",
72
- kzqmXN: "xh8yej3",
73
- kZKoxP: "x5yr21d",
74
- $$css: true
75
- },
76
- scrim: {
77
- kGyWv1: "xnixb3f",
78
- k5sjJv: "xni466t xxiuuzi",
79
- kND0Po: "xc0dz0a",
80
- k9an0g: "xft5bk6",
81
- kb4ib: "x15h3t91",
82
- kwHxkk: "x142juwg",
83
- k4oj2o: "x1viac0w",
84
- $$css: true
85
- },
86
- scrimClosing: {
87
- kb4ib: "x1cz9k3x",
88
- $$css: true
89
- }
90
- };
65
+ import { jsx as _jsx } from "react/jsx-runtime";
91
66
  const IDLE_TRANSITION = {
92
67
  enteringSheet: null,
93
68
  retainedSheet: null,
@@ -125,9 +100,44 @@ function alignmentOffsetForElements(enteringElement, retainedElement) {
125
100
  const enteringTop = enteringPositioner?.getBoundingClientRect().top ?? enteringElement.getBoundingClientRect().top;
126
101
  return Math.max(0, enteringTop - retainedElement.getBoundingClientRect().top);
127
102
  }
103
+
104
+ /**
105
+ * Keeps one object identity while its own enumerable entries are unchanged, so
106
+ * the consumer's rest props can sit in a memoized context value without
107
+ * refreshing every sheet host on each switcher render.
108
+ */
109
+ function useShallowStable(value) {
110
+ const stableRef = useRef(value);
111
+ const previous = stableRef.current;
112
+ const previousKeys = Object.keys(previous);
113
+ const nextKeys = Object.keys(value);
114
+ const isSame = previousKeys.length === nextKeys.length && nextKeys.every(key => Object.is(previous[key], value[key]));
115
+ if (!isSame) {
116
+ stableRef.current = value;
117
+ }
118
+ return stableRef.current;
119
+ }
120
+
121
+ /**
122
+ * Hands the flow's scrim to `dialog` without a fade. The backdrop's opacity
123
+ * transition would otherwise ease from the transparent value the entering
124
+ * dialog was presented with, dipping the page's dim for one motion token
125
+ * exactly when the previous dialog's backdrop disappears. Even a near-zero
126
+ * duration paints its first frame at the start value, so the swap disables
127
+ * the transition outright, flushes style so the new value is committed under
128
+ * that rule, then restores the token for later gesture and exit fades.
129
+ */
130
+ function takeOverScrim(dialog, opacity) {
131
+ dialog.style.setProperty('--_sheet-scrim-duration', '0s');
132
+ dialog.style.setProperty('--_sheet-scrim-opacity', String(opacity));
133
+ if (typeof getComputedStyle === 'function') {
134
+ void getComputedStyle(dialog, '::backdrop').opacity;
135
+ }
136
+ dialog.style.removeProperty('--_sheet-scrim-duration');
137
+ }
128
138
  /**
129
- * Coordinates a set of BottomSheets so zero or one is active at a time inside
130
- * one shared native dialog. During a handoff the previous panel stays visible
139
+ * Coordinates a set of BottomSheets so zero or one is active at a time, each
140
+ * in its own native dialog. During a handoff the previous panel stays visible
131
141
  * and inert beneath the entering panel, then fades after motion completes.
132
142
  *
133
143
  * @example
@@ -146,27 +156,70 @@ export function BottomSheetSwitcher({
146
156
  hasScrim = true,
147
157
  children,
148
158
  ref,
149
- xstyle,
150
- className,
151
- style,
152
159
  onCancel,
153
160
  onClick,
154
161
  onKeyDown,
155
- ...props
162
+ className,
163
+ style,
164
+ xstyle,
165
+ ...restDialogProps
156
166
  }) {
157
- const dialogRef = useRef(null);
158
- const dialogModeRef = useRef(null);
167
+ // Every sheet host applies the switcher's dialog surface to its own dialog.
168
+ const dialogProps = useShallowStable({
169
+ ...restDialogProps,
170
+ className,
171
+ style,
172
+ xstyle
173
+ });
174
+ const hostDialogRef = useRef(null);
175
+ const sheetDialogsRef = useRef(new Map());
176
+ // Sheet ids in the order their dialogs mounted (tree order on first mount).
177
+ // State, not a ref: the resting host below is rendered -- it carries the
178
+ // consumer's `id` -- so a sheet mounting or leaving must re-render.
179
+ const [sheetOrder, setSheetOrder] = useState([]);
180
+ // The sheet that hosted the flow last, kept while the flow is closed so the
181
+ // ref and `id` stay on the dialog the person last used. State adjusted
182
+ // during render (not a ref read during render): it changes exactly when
183
+ // the presented host does, and the next render reads it.
184
+ const [lastHostSheet, setLastHostSheet] = useState(null);
185
+ const presentationOrderRef = useRef(new Map());
186
+ const presentationCounterRef = useRef(0);
187
+ const modalFlowRef = useRef(false);
159
188
  const triggerRef = useRef(null);
160
189
  const sheetElementsRef = useRef(new Map());
161
190
  const committedActiveSheetRef = useRef(activeSheet);
162
- const [sheetLabels, setSheetLabels] = useState(() => new Map());
191
+ const scrimOpacityRef = useRef(1);
192
+ const scrimOwnerRef = useRef(null);
163
193
  const [sheetPurposes, setSheetPurposes] = useState(() => new Map());
164
194
  const [unmountedSheetIds, setUnmountedSheetIds] = useState(() => new Set());
165
195
  const [transition, setTransition] = useState(IDLE_TRANSITION);
166
196
  const activeSheetChanged = committedActiveSheetRef.current !== activeSheet;
167
197
  const visibleTransition = activeSheetChanged ? transitionForActiveSheetChange(committedActiveSheetRef.current, activeSheet) : transition;
168
- const isFlowVisible = activeSheet != null && !unmountedSheetIds.has(activeSheet) || visibleTransition.retainedSheet != null && !unmountedSheetIds.has(visibleTransition.retainedSheet);
198
+ const presentedActiveSheet = activeSheet != null && !unmountedSheetIds.has(activeSheet) ? activeSheet : null;
199
+ const presentedRetainedSheet = visibleTransition.retainedSheet != null && !unmountedSheetIds.has(visibleTransition.retainedSheet) ? visibleTransition.retainedSheet : null;
200
+ const isFlowVisible = presentedActiveSheet != null || presentedRetainedSheet != null;
169
201
  const isModal = hasScrim && isFlowVisible;
202
+ // The dialog that hosts the flow for its ref, its single-instance
203
+ // attributes, and its dismissal registration: the active sheet's, the
204
+ // retained sheet's while the flow closes through its exit, and while the
205
+ // flow is closed the dialog that hosted it last (or the first mounted
206
+ // sheet's before any open), so the ref resolves to a closed dialog element
207
+ // rather than to null, as the shared dialog did. Which sheet mounts first
208
+ // is known only once its dialog registers during the first commit: the
209
+ // ref is attached to it in that same commit (below), and the rendered
210
+ // `id` follows in the synchronous re-render that commit schedules, before
211
+ // the first paint. Server markup therefore carries the `id` on no element.
212
+ const presentedHostSheet = presentedActiveSheet ?? presentedRetainedSheet;
213
+ if (presentedHostSheet != null && presentedHostSheet !== lastHostSheet) {
214
+ setLastHostSheet(presentedHostSheet);
215
+ }
216
+ const restingHostSheet = lastHostSheet != null && sheetOrder.includes(lastHostSheet) ? lastHostSheet : sheetOrder[0] ?? null;
217
+ const hostSheet = presentedHostSheet ?? restingHostSheet;
218
+ // The dialog whose backdrop paints the flow's one scrim. A retained sheet
219
+ // keeps it through a handoff so the entering dialog, presented above it in
220
+ // the top layer, does not dim the previous panel beneath its own backdrop.
221
+ const scrimOwnerSheet = presentedRetainedSheet ?? presentedActiveSheet;
222
+ const isFlowClosing = isFlowVisible && activeSheet == null;
170
223
  const activeSheetPurpose = (activeSheet == null ? null : sheetPurposes.get(activeSheet)) ?? 'info';
171
224
  const allowsEscapeDismiss = activeSheetPurpose !== 'required';
172
225
  const allowsLightDismiss = activeSheetPurpose === 'info';
@@ -189,61 +242,96 @@ export function BottomSheetSwitcher({
189
242
  onActiveSheetChange(null);
190
243
  }
191
244
  }, [allowsLightDismiss, onActiveSheetChange]);
192
- const {
193
- containerRef
194
- } = useFocusTrap({
195
- isActive: isModal
196
- });
197
- // Before the shared dismissal stack, this modal trap supplied `onEscape`, so
198
- // the released compatibility shim reported it as active. Keep that signal
199
- // without registering the switcher twice in the one shared stack.
245
+ // Focus containment is the native modal dialog's: showModal() makes the
246
+ // page inert, so Tab cannot leave the active sheet. The released read-only
247
+ // `hasActiveFocusTrapEscape()` signal still reports a modal flow as a trap,
248
+ // as it did when a JavaScript trap backed it.
200
249
  useFocusTrapEscapeCompatibilitySignal(isModal);
250
+ // One lock for the whole modal flow, as on a shared dialog: `isModal` stays
251
+ // true through a handoff (two dialogs open) and through the exit motion, so
252
+ // the shared counter is taken once and given back once.
253
+ useScrollLock(isModal);
201
254
  const {
202
255
  shouldDismissOnCloseRequest
203
256
  } = useLayerDismissal({
204
257
  isActive: isFlowVisible,
205
258
  escapeBehavior: allowsEscapeDismiss ? 'close' : 'block',
206
259
  onDismiss: dismissOnEscape,
207
- getContainer: () => dialogRef.current,
208
- isPresent: () => dialogRef.current?.open ?? false
260
+ getContainer: () => hostDialogRef.current,
261
+ isPresent: () => hostDialogRef.current?.open ?? false
209
262
  });
210
- useScrollLock(isModal);
211
263
 
212
- // Open one shared shell for the complete flow. Modal flows enter the native
213
- // top layer once; handoffs only swap panels inside it. The final panel owns
214
- // the exit timing, so isFlowVisible becomes false only when it is safe to
215
- // close the dialog and restore focus.
264
+ // Point the consumer ref and the dismissal registration at the host dialog.
265
+ // Sheet hosts register their dialogs during commit, so by the time this
266
+ // layout effect runs the map already names the dialog for `hostSheet` --
267
+ // and, in the first commit of a flow that mounts closed, the first
268
+ // registered dialog before `sheetOrder` has caught up, so a parent's own
269
+ // mount effects already find the ref resolved. The ref is attached the way
270
+ // React attaches one: a callback ref's returned cleanup runs on detach, and
271
+ // only a ref without one receives null. Attachment follows the resolved
272
+ // element, not the render that resolved it: the re-render that catches
273
+ // `sheetOrder` up resolves the same dialog and leaves the ref alone.
274
+ const attachedRef = useRef(null);
216
275
  useLayoutEffect(() => {
217
- const dialog = dialogRef.current;
218
- if (dialog == null) {
276
+ const host = hostSheet == null ? sheetDialogsRef.current.values().next().value ?? null : sheetDialogsRef.current.get(hostSheet) ?? null;
277
+ hostDialogRef.current = host;
278
+ const attached = attachedRef.current;
279
+ if (attached?.host === host && attached.ref === ref) {
219
280
  return;
220
281
  }
282
+ attached?.detach?.();
283
+ if (host == null) {
284
+ attachedRef.current = null;
285
+ return;
286
+ }
287
+ const detach = mergeRefs(ref)(host);
288
+ attachedRef.current = {
289
+ ref,
290
+ host,
291
+ detach: typeof detach === 'function' ? detach : undefined
292
+ };
293
+ }, [hostSheet, ref, sheetOrder]);
294
+ useLayoutEffect(() => () => {
295
+ hostDialogRef.current = null;
296
+ attachedRef.current?.detach?.();
297
+ attachedRef.current = null;
298
+ }, []);
299
+
300
+ // Focus return is native per dialog: each showModal() records the focused
301
+ // element and close() focuses it again. That serves a one-sheet flow, but
302
+ // the second sheet of a flow records a control inside the first, which is
303
+ // hidden by the time the flow ends, so the browser's return lands nowhere.
304
+ // The flow therefore remembers its own opener -- the page element focused
305
+ // before the first modal present -- and focuses it after the last dialog
306
+ // closes. Sheet hosts ask before presenting modally, so the capture sees
307
+ // the page element; hosts present in their own layout effects, which run
308
+ // before the one below, so the restore follows the last dialog's close.
309
+ const onBeforeSheetPresent = useCallback(sheetId => {
310
+ if (triggerRef.current == null) {
311
+ triggerRef.current = document.activeElement;
312
+ }
313
+ modalFlowRef.current = true;
314
+ presentationCounterRef.current += 1;
315
+ presentationOrderRef.current.set(sheetId, presentationCounterRef.current);
316
+ }, []);
317
+ useLayoutEffect(() => {
221
318
  if (isFlowVisible) {
222
- const nextMode = hasScrim ? 'modal' : 'non-modal';
223
- if (dialog.open && dialogModeRef.current !== nextMode) {
224
- dialog.close();
225
- }
226
- if (!dialog.open) {
227
- if (hasScrim && triggerRef.current == null) {
228
- triggerRef.current = document.activeElement;
229
- }
230
- if (hasScrim) {
231
- dialog.showModal();
232
- } else {
233
- dialog.show();
234
- }
319
+ if (!hasScrim) {
320
+ modalFlowRef.current = false;
235
321
  }
236
- dialogModeRef.current = nextMode;
237
322
  return;
238
323
  }
239
- if (dialog.open) {
240
- dialog.close();
241
- }
242
- if (dialogModeRef.current === 'modal') {
243
- triggerRef.current?.focus();
324
+ if (modalFlowRef.current) {
325
+ // Like the browser's own return on close(), without scrolling: the
326
+ // page kept its scroll offset under the lock, and an opener scrolled
327
+ // out of view must not drag the page back to itself.
328
+ triggerRef.current?.focus({
329
+ preventScroll: true
330
+ });
244
331
  }
245
332
  triggerRef.current = null;
246
- dialogModeRef.current = null;
333
+ modalFlowRef.current = false;
334
+ presentationOrderRef.current.clear();
247
335
  }, [hasScrim, isFlowVisible]);
248
336
  const getSheetPhase = useCallback(sheetId => {
249
337
  if (sheetId === activeSheet) {
@@ -255,6 +343,7 @@ export function BottomSheetSwitcher({
255
343
  return 'hidden';
256
344
  }, [activeSheet, visibleTransition]);
257
345
  const getSheetAlignmentOffset = useCallback(sheetId => visibleTransition.retainedSheet === sheetId ? visibleTransition.alignmentOffset : 0, [visibleTransition]);
346
+ const isScrimOwner = useCallback(sheetId => sheetId === scrimOwnerSheet, [scrimOwnerSheet]);
258
347
  const registerSheetElement = useCallback((sheetId, element) => {
259
348
  if (element == null) {
260
349
  sheetElementsRef.current.delete(sheetId);
@@ -279,23 +368,28 @@ export function BottomSheetSwitcher({
279
368
  });
280
369
  }
281
370
  }, []);
282
- const registerSheetLabel = useCallback((sheetId, label) => {
283
- setSheetLabels(current => {
284
- if (label == null) {
285
- if (!current.has(sheetId)) {
286
- return current;
287
- }
288
- const next = new Map(current);
289
- next.delete(sheetId);
290
- return next;
291
- }
292
- if (current.get(sheetId) === label) {
293
- return current;
371
+ const registerSheetDialog = useCallback((sheetId, dialog) => {
372
+ if (dialog == null) {
373
+ sheetDialogsRef.current.delete(sheetId);
374
+ presentationOrderRef.current.delete(sheetId);
375
+ setSheetOrder(current => current.includes(sheetId) ? current.filter(id => id !== sheetId) : current);
376
+ } else {
377
+ sheetDialogsRef.current.set(sheetId, dialog);
378
+ setSheetOrder(current => current.includes(sheetId) ? current : [...current, sheetId]);
379
+ }
380
+ }, []);
381
+ const isCoveredInTopLayer = useCallback(sheetId => {
382
+ const order = presentationOrderRef.current;
383
+ const own = order.get(sheetId);
384
+ if (own == null) {
385
+ return false;
386
+ }
387
+ for (const [otherId, stamp] of order) {
388
+ if (otherId !== sheetId && stamp > own && sheetDialogsRef.current.get(otherId)?.open) {
389
+ return true;
294
390
  }
295
- const next = new Map(current);
296
- next.set(sheetId, label);
297
- return next;
298
- });
391
+ }
392
+ return false;
299
393
  }, []);
300
394
  const registerSheetPurpose = useCallback((sheetId, purpose) => {
301
395
  setSheetPurposes(current => {
@@ -375,39 +469,54 @@ export function BottomSheetSwitcher({
375
469
  return current;
376
470
  });
377
471
  }, []);
378
- const setScrimOpacity = useCallback(opacity => {
379
- dialogRef.current?.style.setProperty('--_sheet-scrim-opacity', String(opacity));
380
- }, []);
472
+
473
+ // One scrim for the flow. Only the owning dialog's backdrop carries the
474
+ // current opacity; every other presented dialog keeps its backdrop clear.
475
+ // When ownership moves — the retained dialog has faded and closes, or a
476
+ // rapid replacement hides it — the new owner takes the value without a
477
+ // fade, in the same layout pass that closes the old one.
478
+ const applyScrimOpacity = useCallback(() => {
479
+ const owner = scrimOwnerSheet == null ? null : sheetDialogsRef.current.get(scrimOwnerSheet) ?? null;
480
+ // A fresh open keeps the backdrop's @starting-style fade; only a hand
481
+ // from one presented dialog to another swaps without one.
482
+ const ownerChanged = scrimOwnerRef.current != null && scrimOwnerRef.current !== scrimOwnerSheet;
483
+ scrimOwnerRef.current = scrimOwnerSheet;
484
+ for (const [sheetId, dialog] of sheetDialogsRef.current) {
485
+ if (dialog === owner) {
486
+ if (ownerChanged && dialog.open) {
487
+ takeOverScrim(dialog, scrimOpacityRef.current);
488
+ } else {
489
+ dialog.style.setProperty('--_sheet-scrim-opacity', String(scrimOpacityRef.current));
490
+ }
491
+ } else if (sheetId !== scrimOwnerSheet) {
492
+ dialog.style.setProperty('--_sheet-scrim-opacity', '0');
493
+ }
494
+ }
495
+ }, [scrimOwnerSheet]);
381
496
  const onSheetScrimOpacityChange = useCallback((sheetId, opacity) => {
382
497
  // A pointer captured by the outgoing sheet can keep delivering gesture
383
498
  // events after a handoff. Only the currently committed sheet owns the
384
- // shared backdrop, so stale gesture updates must not reach the dialog.
499
+ // flow's scrim value, so stale gesture updates must not reach it.
385
500
  if (sheetId !== committedActiveSheetRef.current) {
386
501
  return;
387
502
  }
388
- setScrimOpacity(opacity);
389
- }, [setScrimOpacity]);
503
+ scrimOpacityRef.current = opacity;
504
+ applyScrimOpacity();
505
+ }, [applyScrimOpacity]);
506
+
507
+ // A committed selection resets the flow's scrim value -- 1 while a sheet
508
+ // is active, 0 for the exit fade -- and writes it to the owning backdrop in
509
+ // the same effect; a gesture then adjusts the value through
510
+ // `onSheetScrimOpacityChange` until the next selection.
390
511
  useLayoutEffect(() => {
391
- setScrimOpacity(activeSheet == null ? 0 : 1);
392
- }, [activeSheet, setScrimOpacity]);
393
- const contextValue = useMemo(() => ({
394
- activeSheet,
395
- hasScrim,
396
- onActiveSheetChange,
397
- getSheetPhase,
398
- getSheetAlignmentOffset,
399
- registerSheetElement,
400
- registerSheetLabel,
401
- registerSheetPurpose,
402
- onSheetEnterStart,
403
- onSheetTransitionComplete,
404
- onSheetScrimOpacityChange
405
- }), [activeSheet, getSheetAlignmentOffset, getSheetPhase, hasScrim, onActiveSheetChange, onSheetEnterStart, onSheetScrimOpacityChange, onSheetTransitionComplete, registerSheetElement, registerSheetLabel, registerSheetPurpose]);
406
- const activeLabel = (activeSheet == null ? null : sheetLabels.get(activeSheet)) ?? (visibleTransition.retainedSheet == null ? undefined : sheetLabels.get(visibleTransition.retainedSheet));
512
+ scrimOpacityRef.current = activeSheet == null ? 0 : 1;
513
+ applyScrimOpacity();
514
+ }, [applyScrimOpacity, activeSheet, hasScrim, isFlowVisible]);
407
515
 
408
- // A `cancel` that bubbles up from a step is not a close request for the
409
- // flow: a file input fires one when its picker is dismissed, and a step
410
- // renders no dialog of its own, so it lands here. It is left alone.
516
+ // A `cancel` that bubbles up from inside a step is not a close request for
517
+ // the flow: a file input fires one when its picker is dismissed, and it
518
+ // reaches the step's dialog. Only a `cancel` fired at a sheet dialog itself
519
+ // dismisses; the bubbled one is left alone.
411
520
  const handleCancel = useCallback(event => {
412
521
  if (event.target !== event.currentTarget) {
413
522
  return;
@@ -425,30 +534,50 @@ export function BottomSheetSwitcher({
425
534
  dispatchLayerEscapeKeyDown(event.nativeEvent);
426
535
  }, []);
427
536
  const handleClick = useCallback(event => {
428
- if (hasScrim && event.target === event.currentTarget) {
537
+ // A `::backdrop` click targets its dialog element. Only the active
538
+ // sheet's backdrop is the flow's scrim; a retained dialog is inert and
539
+ // a hidden one closed, so neither can be clicked, but be explicit.
540
+ if (hasScrim && event.target === event.currentTarget && event.currentTarget === hostDialogRef.current) {
429
541
  dismissOnLightInteraction();
430
542
  }
431
543
  }, [dismissOnLightInteraction, hasScrim]);
432
- const dialogStyleProps = stylex.props(styles.dialog, isFlowVisible && styles.dialogOpen, hasScrim && styles.scrim, hasScrim && isFlowVisible && activeSheet == null && styles.scrimClosing, !hasScrim && styles.dialogNonModal, xstyle);
433
- const dialogPresentationProps = mergeProps(dialogStyleProps, className, style);
434
- const ariaLabel = props['aria-label'] ?? (props['aria-labelledby'] == null ? activeLabel : undefined);
544
+ const onDialogCancel = useMemo(() => composeEventHandlers(onCancel, handleCancel), [handleCancel, onCancel]);
545
+ const onDialogClick = useMemo(() => composeEventHandlers(onClick, handleClick), [handleClick, onClick]);
546
+ const onDialogKeyDown = useMemo(() => composeEventHandlers(onKeyDown, handleKeyDown), [handleKeyDown, onKeyDown]);
547
+ const contextValue = useMemo(() => ({
548
+ activeSheet,
549
+ hasScrim,
550
+ onActiveSheetChange,
551
+ getSheetPhase,
552
+ getSheetAlignmentOffset,
553
+ hostSheet,
554
+ isScrimOwner,
555
+ isFlowClosing,
556
+ registerSheetElement,
557
+ registerSheetDialog,
558
+ registerSheetPurpose,
559
+ onBeforeSheetPresent,
560
+ isCoveredInTopLayer,
561
+ onSheetEnterStart,
562
+ onSheetTransitionComplete,
563
+ onSheetScrimOpacityChange,
564
+ dialogProps,
565
+ onDialogCancel,
566
+ onDialogClick,
567
+ onDialogKeyDown
568
+ }), [activeSheet, dialogProps, getSheetAlignmentOffset, getSheetPhase, hasScrim, hostSheet, isCoveredInTopLayer, isFlowClosing, isScrimOwner, onActiveSheetChange, onBeforeSheetPresent, onDialogCancel, onDialogClick, onDialogKeyDown, onSheetEnterStart, onSheetScrimOpacityChange, onSheetTransitionComplete, registerSheetDialog, registerSheetElement, registerSheetPurpose]);
435
569
  return /*#__PURE__*/_jsx(BottomSheetSwitcherContext, {
436
570
  value: contextValue,
437
- children: /*#__PURE__*/_jsxs("dialog", {
438
- ...props,
439
- ...dialogPresentationProps,
440
- ref: useMergedRefs(ref, dialogRef, containerRef),
441
- "aria-label": ariaLabel,
442
- "aria-modal": isModal ? 'true' : undefined,
443
- onCancel: composeEventHandlers(onCancel, handleCancel),
444
- onClick: composeEventHandlers(onClick, handleClick),
445
- onKeyDown: composeEventHandlers(onKeyDown, handleKeyDown),
446
- ...(activeSheetPurpose === 'required' ? {
447
- role: 'alertdialog'
448
- } : undefined),
449
- children: [/*#__PURE__*/_jsx(LayerDepthProvider, {
450
- children: children
451
- }), /*#__PURE__*/_jsx(BottomSheetEdgeTint, {})]
571
+ children: /*#__PURE__*/_jsx("div", {
572
+ ...{
573
+ 0: {
574
+ className: "xjp7ctv"
575
+ },
576
+ 1: {
577
+ className: "x1lliihq xixxii4 x10a8y8t xh8yej3 x5yr21d x47corl xc8icb0 xfo81ep"
578
+ }
579
+ }[!!(!hasScrim && isFlowVisible) << 0],
580
+ children: children
452
581
  })
453
582
  });
454
583
  }
@@ -1,21 +1,71 @@
1
+ /**
2
+ * @file BottomSheetSwitcherContext.ts
3
+ * @input Uses React context
4
+ * @output Internal phase, lifecycle, and host-dialog context shared by BottomSheetSwitcher and BottomSheet
5
+ * @position Private coordination layer for mutually exclusive BottomSheets
6
+ *
7
+ * Every child sheet renders its own native `<dialog>`; the switcher's only
8
+ * element is a flow wrapper. This context carries the phase machine, the
9
+ * host-dialog selection, and the switcher's dialog-level props and handlers
10
+ * down to those sheet hosts.
11
+ */
12
+ import type { KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, SyntheticEvent } from 'react';
13
+ import type { BaseProps } from '../BaseProps';
1
14
  import type { DialogPurpose } from '../Dialog';
2
15
  export type BottomSheetSwitcherPhase = 'entering' | 'active' | 'covered' | 'aligning' | 'fading' | 'exiting' | 'hidden';
3
16
  export interface BottomSheetSwitcherTransitionEvent {
4
17
  sheetId: string;
5
18
  phase: 'entering' | 'aligning' | 'fading' | 'exiting';
6
19
  }
20
+ /**
21
+ * The switcher's own dialog surface, applied by every sheet host. `ref` is not
22
+ * part of it: the switcher resolves its ref to the host sheet's dialog, and
23
+ * the single-instance attributes (`id`, `data-testid`) render there only.
24
+ */
25
+ export type BottomSheetSwitcherDialogProps = Omit<BaseProps<HTMLDialogElement>, 'onClick' | 'onKeyDown'>;
7
26
  export interface BottomSheetSwitcherContextValue {
8
27
  activeSheet: string | null;
9
28
  hasScrim: boolean;
10
29
  onActiveSheetChange: (sheetId: string | null) => void;
11
30
  getSheetPhase: (sheetId: string) => BottomSheetSwitcherPhase;
12
31
  getSheetAlignmentOffset: (sheetId: string) => number;
32
+ /**
33
+ * The sheet whose dialog hosts the flow: the active sheet, the exiting
34
+ * sheet through its exit, and the last host (or the first mounted sheet)
35
+ * while the flow is closed. The switcher's ref and its single-instance
36
+ * attributes resolve to this sheet's dialog.
37
+ */
38
+ hostSheet: string | null;
39
+ /**
40
+ * Whether this sheet's dialog paints the flow's one scrim right now. During
41
+ * a handoff the retained sheet keeps it, so the entering dialog's backdrop
42
+ * stays transparent above the still-visible previous panel.
43
+ */
44
+ isScrimOwner: (sheetId: string) => boolean;
45
+ /** The flow is closing: the owning backdrop fades out on the exit curve. */
46
+ isFlowClosing: boolean;
13
47
  registerSheetElement: (sheetId: string, element: HTMLElement | null) => void;
14
- registerSheetLabel: (sheetId: string, label: string | null) => void;
48
+ registerSheetDialog: (sheetId: string, dialog: HTMLDialogElement | null) => void;
15
49
  registerSheetPurpose: (sheetId: string, purpose: DialogPurpose | null) => void;
50
+ /**
51
+ * Called by a sheet host just before it presents its dialog modally, so the
52
+ * switcher can remember the page element that opened the flow.
53
+ */
54
+ onBeforeSheetPresent: (sheetId: string) => void;
55
+ /**
56
+ * Whether a modal sheet dialog that is already open sits beneath another
57
+ * open sheet dialog in the top layer, so it must re-present to become the
58
+ * topmost layer again.
59
+ */
60
+ isCoveredInTopLayer: (sheetId: string) => boolean;
16
61
  onSheetEnterStart: (sheetId: string) => void;
17
62
  onSheetTransitionComplete: (event: BottomSheetSwitcherTransitionEvent) => void;
18
63
  onSheetScrimOpacityChange: (sheetId: string, opacity: number) => void;
64
+ /** Consumer dialog props the switcher forwards to every sheet dialog. */
65
+ dialogProps: BottomSheetSwitcherDialogProps;
66
+ onDialogCancel: (event: SyntheticEvent<HTMLDialogElement>) => void;
67
+ onDialogClick: (event: ReactMouseEvent<HTMLDialogElement>) => void;
68
+ onDialogKeyDown: (event: ReactKeyboardEvent<HTMLDialogElement>) => void;
19
69
  }
20
70
  export declare const BottomSheetSwitcherContext: import("react").Context<BottomSheetSwitcherContextValue | null>;
21
71
  //# sourceMappingURL=BottomSheetSwitcherContext.d.ts.map