@astryxdesign/core 0.4.2 → 0.4.3-canary.00bed29

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 (214) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +79 -45
  3. package/dist/Avatar/Avatar.d.ts.map +1 -1
  4. package/dist/Avatar/Avatar.js +3 -19
  5. package/dist/Banner/Banner.d.ts +9 -2
  6. package/dist/Banner/Banner.d.ts.map +1 -1
  7. package/dist/Banner/Banner.js +84 -33
  8. package/dist/Banner/index.d.ts +14 -12
  9. package/dist/Banner/index.d.ts.map +1 -1
  10. package/dist/Banner/index.js +10 -8
  11. package/dist/BottomSheet/BottomSheet.d.ts +66 -0
  12. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  13. package/dist/BottomSheet/BottomSheet.js +392 -0
  14. package/dist/BottomSheet/BottomSheetPanel.d.ts +60 -0
  15. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -0
  16. package/dist/BottomSheet/BottomSheetPanel.js +398 -0
  17. package/dist/BottomSheet/BottomSheetSwitcher.d.ts +61 -0
  18. package/dist/BottomSheet/BottomSheetSwitcher.d.ts.map +1 -0
  19. package/dist/BottomSheet/BottomSheetSwitcher.js +410 -0
  20. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts +21 -0
  21. package/dist/BottomSheet/BottomSheetSwitcherContext.d.ts.map +1 -0
  22. package/dist/BottomSheet/BottomSheetSwitcherContext.js +13 -0
  23. package/dist/BottomSheet/index.d.ts +11 -0
  24. package/dist/BottomSheet/index.d.ts.map +1 -0
  25. package/dist/BottomSheet/index.js +12 -0
  26. package/dist/BottomSheet/snapOffsets.d.ts +72 -0
  27. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  28. package/dist/BottomSheet/snapOffsets.js +130 -0
  29. package/dist/BottomSheet/useMobileKeyboard.d.ts +35 -0
  30. package/dist/BottomSheet/useMobileKeyboard.d.ts.map +1 -0
  31. package/dist/BottomSheet/useMobileKeyboard.js +373 -0
  32. package/dist/BottomSheet/useSheetGestures.d.ts +180 -0
  33. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  34. package/dist/BottomSheet/useSheetGestures.js +942 -0
  35. package/dist/Chat/ChatComposerInput.d.ts.map +1 -1
  36. package/dist/Chat/ChatComposerInput.js +3 -4
  37. package/dist/Chat/ChatTokenizedText.js +1 -1
  38. package/dist/ComplexSelector/ComplexSelector.d.ts +38 -4
  39. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  40. package/dist/ComplexSelector/ComplexSelector.js +98 -31
  41. package/dist/ComplexSelector/index.d.ts +2 -2
  42. package/dist/ComplexSelector/index.d.ts.map +1 -1
  43. package/dist/ComplexSelector/index.js +1 -1
  44. package/dist/ContextMenu/ContextMenu.d.ts.map +1 -1
  45. package/dist/ContextMenu/ContextMenu.js +4 -2
  46. package/dist/DateInput/DateInput.d.ts.map +1 -1
  47. package/dist/DateInput/DateInput.js +9 -1
  48. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  49. package/dist/DateTimeInput/DateTimeInput.js +31 -1
  50. package/dist/Dialog/Dialog.d.ts.map +1 -1
  51. package/dist/Dialog/Dialog.js +2 -1
  52. package/dist/Lightbox/Lightbox.d.ts.map +1 -1
  53. package/dist/Lightbox/Lightbox.js +9 -3
  54. package/dist/Markdown/parser.d.ts.map +1 -1
  55. package/dist/Markdown/parser.js +55 -12
  56. package/dist/MobileNav/MobileNav.d.ts +12 -0
  57. package/dist/MobileNav/MobileNav.d.ts.map +1 -1
  58. package/dist/MobileNav/MobileNav.js +102 -30
  59. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  60. package/dist/MultiSelector/MultiSelector.js +21 -7
  61. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  62. package/dist/NumberInput/NumberInput.js +9 -1
  63. package/dist/PowerSearch/PowerSearch.d.ts.map +1 -1
  64. package/dist/PowerSearch/PowerSearch.js +4 -1
  65. package/dist/PowerSearch/PowerSearchEditPopover.js +1 -1
  66. package/dist/PowerSearch/formatFilterValue.d.ts.map +1 -1
  67. package/dist/PowerSearch/formatFilterValue.js +2 -4
  68. package/dist/Selector/Selector.d.ts.map +1 -1
  69. package/dist/Selector/Selector.js +14 -3
  70. package/dist/Switch/Switch.d.ts.map +1 -1
  71. package/dist/Switch/Switch.js +5 -1
  72. package/dist/Table/columnUtils.d.ts.map +1 -1
  73. package/dist/Table/columnUtils.js +4 -1
  74. package/dist/TextArea/TextArea.d.ts +6 -3
  75. package/dist/TextArea/TextArea.d.ts.map +1 -1
  76. package/dist/TextArea/TextArea.js +17 -6
  77. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  78. package/dist/TimeInput/TimeInput.js +8 -1
  79. package/dist/Tokenizer/Tokenizer.js +11 -5
  80. package/dist/Tooltip/useTooltip.d.ts.map +1 -1
  81. package/dist/Tooltip/useTooltip.js +4 -1
  82. package/dist/TreeList/TreeList.js +2 -1
  83. package/dist/Typeahead/BaseTypeahead.d.ts.map +1 -1
  84. package/dist/Typeahead/BaseTypeahead.js +5 -3
  85. package/dist/Typeahead/Typeahead.d.ts.map +1 -1
  86. package/dist/Typeahead/Typeahead.js +10 -1
  87. package/dist/astryx.css +25 -2
  88. package/dist/hooks/index.d.ts +8 -1
  89. package/dist/hooks/index.d.ts.map +1 -1
  90. package/dist/hooks/index.js +8 -1
  91. package/dist/hooks/useFocusTrap.d.ts +0 -11
  92. package/dist/hooks/useFocusTrap.d.ts.map +1 -1
  93. package/dist/hooks/useFocusTrap.js +1 -11
  94. package/dist/hooks/useInputStatusIcon.d.ts.map +1 -1
  95. package/dist/hooks/useInputStatusIcon.js +1 -0
  96. package/dist/index.d.ts +1 -0
  97. package/dist/index.d.ts.map +1 -1
  98. package/dist/index.js +1 -0
  99. package/dist/theme/defineTheme.d.ts +30 -7
  100. package/dist/theme/defineTheme.d.ts.map +1 -1
  101. package/dist/theme/defineTheme.js +36 -47
  102. package/dist/theme/expandColorScale.d.ts +17 -2
  103. package/dist/theme/expandColorScale.d.ts.map +1 -1
  104. package/dist/theme/expandColorScale.js +63 -33
  105. package/dist/theme/mergeComponents.d.ts +20 -0
  106. package/dist/theme/mergeComponents.d.ts.map +1 -0
  107. package/dist/theme/mergeComponents.js +56 -0
  108. package/dist/theme/onMediaTokens.d.ts +6 -1
  109. package/dist/theme/onMediaTokens.d.ts.map +1 -1
  110. package/dist/theme/onMediaTokens.js +11 -3
  111. package/dist/utils/characters.d.ts +27 -0
  112. package/dist/utils/characters.d.ts.map +1 -0
  113. package/dist/utils/characters.js +83 -0
  114. package/dist/utils/ime.d.ts +5 -0
  115. package/dist/utils/ime.d.ts.map +1 -0
  116. package/dist/utils/ime.js +63 -0
  117. package/dist/utils/index.d.ts +2 -0
  118. package/dist/utils/index.d.ts.map +1 -1
  119. package/dist/utils/index.js +3 -1
  120. package/locales/en.json +48 -0
  121. package/locales/pseudo.json +36 -0
  122. package/package.json +9 -8
  123. package/src/Avatar/Avatar.test.tsx +6 -1
  124. package/src/Avatar/Avatar.tsx +3 -21
  125. package/src/Banner/Banner.doc.mjs +12 -10
  126. package/src/Banner/Banner.test.tsx +108 -0
  127. package/src/Banner/Banner.tsx +124 -38
  128. package/src/Banner/index.ts +15 -13
  129. package/src/BottomSheet/BottomSheet.doc.mjs +266 -0
  130. package/src/BottomSheet/BottomSheet.test.tsx +2114 -0
  131. package/src/BottomSheet/BottomSheet.tsx +545 -0
  132. package/src/BottomSheet/BottomSheetPanel.test.tsx +240 -0
  133. package/src/BottomSheet/BottomSheetPanel.tsx +547 -0
  134. package/src/BottomSheet/BottomSheetSwitcher.doc.mjs +219 -0
  135. package/src/BottomSheet/BottomSheetSwitcher.test.tsx +850 -0
  136. package/src/BottomSheet/BottomSheetSwitcher.tsx +628 -0
  137. package/src/BottomSheet/BottomSheetSwitcherContext.ts +51 -0
  138. package/src/BottomSheet/index.ts +15 -0
  139. package/src/BottomSheet/snapOffsets.test.ts +187 -0
  140. package/src/BottomSheet/snapOffsets.ts +159 -0
  141. package/src/BottomSheet/useMobileKeyboard.ts +499 -0
  142. package/src/BottomSheet/useSheetGestures.test.ts +1014 -0
  143. package/src/BottomSheet/useSheetGestures.ts +1407 -0
  144. package/src/Chat/ChatComposerInput.tsx +3 -4
  145. package/src/Chat/ChatTokenizedText.tsx +1 -1
  146. package/src/ComplexSelector/ComplexSelector.doc.mjs +55 -6
  147. package/src/ComplexSelector/ComplexSelector.test.tsx +197 -6
  148. package/src/ComplexSelector/ComplexSelector.tsx +156 -28
  149. package/src/ComplexSelector/index.ts +3 -1
  150. package/src/ContextMenu/ContextMenu.tsx +4 -2
  151. package/src/DateInput/DateInput.test.tsx +21 -0
  152. package/src/DateInput/DateInput.tsx +9 -1
  153. package/src/DateTimeInput/DateTimeInput.doc.mjs +4 -0
  154. package/src/DateTimeInput/DateTimeInput.test.tsx +137 -0
  155. package/src/DateTimeInput/DateTimeInput.tsx +39 -2
  156. package/src/Dialog/Dialog.tsx +2 -1
  157. package/src/FileInput/FileInput.test.tsx +53 -9
  158. package/src/Lightbox/Lightbox.test.tsx +49 -28
  159. package/src/Lightbox/Lightbox.tsx +7 -3
  160. package/src/Markdown/parser.test.ts +53 -0
  161. package/src/Markdown/parser.ts +53 -12
  162. package/src/MobileNav/MobileNav.tsx +128 -37
  163. package/src/MobileNav/MobileNavCloseEdgeCases.test.tsx +426 -0
  164. package/src/MobileNav/MobileNavCloseTiming.test.ts +58 -0
  165. package/src/MobileNav/MobileNavCloseVisibility.test.tsx +172 -0
  166. package/src/MultiSelector/MultiSelector.test.tsx +195 -51
  167. package/src/MultiSelector/MultiSelector.tsx +22 -8
  168. package/src/NumberInput/NumberInput.test.tsx +18 -0
  169. package/src/NumberInput/NumberInput.tsx +9 -1
  170. package/src/PowerSearch/PowerSearch.test.tsx +48 -3
  171. package/src/PowerSearch/PowerSearch.tsx +4 -1
  172. package/src/PowerSearch/PowerSearchEditPopover.tsx +1 -1
  173. package/src/PowerSearch/formatFilterValue.test.ts +22 -0
  174. package/src/PowerSearch/formatFilterValue.ts +2 -4
  175. package/src/Selector/Selector.test.tsx +85 -26
  176. package/src/Selector/Selector.tsx +13 -4
  177. package/src/Switch/Switch.test.tsx +16 -0
  178. package/src/Switch/Switch.tsx +7 -0
  179. package/src/Table/Table.test.tsx +6 -0
  180. package/src/Table/columnUtils.ts +3 -1
  181. package/src/TextArea/TextArea.doc.mjs +1 -1
  182. package/src/TextArea/TextArea.test.tsx +72 -0
  183. package/src/TextArea/TextArea.tsx +26 -8
  184. package/src/TimeInput/TimeInput.test.tsx +23 -0
  185. package/src/TimeInput/TimeInput.tsx +8 -0
  186. package/src/Tokenizer/Tokenizer.test.tsx +50 -32
  187. package/src/Tokenizer/Tokenizer.tsx +5 -5
  188. package/src/Tooltip/useTooltip.tsx +4 -1
  189. package/src/TreeList/TreeList.doc.mjs +2 -2
  190. package/src/TreeList/TreeList.tsx +1 -1
  191. package/src/Typeahead/BaseTypeahead.tsx +10 -3
  192. package/src/Typeahead/Typeahead.test.tsx +116 -7
  193. package/src/Typeahead/Typeahead.tsx +10 -1
  194. package/src/__tests__/stubMatchMedia.test.ts +79 -0
  195. package/src/__tests__/stubMatchMedia.ts +95 -0
  196. package/src/hooks/index.ts +9 -5
  197. package/src/hooks/useFocusTrap.ts +1 -14
  198. package/src/hooks/useInputStatusIcon.test.tsx +19 -0
  199. package/src/hooks/useInputStatusIcon.tsx +7 -0
  200. package/src/index.ts +1 -0
  201. package/src/theme/defineTheme.test.ts +127 -0
  202. package/src/theme/defineTheme.ts +70 -55
  203. package/src/theme/expandColorScale.test.ts +138 -0
  204. package/src/theme/expandColorScale.ts +89 -44
  205. package/src/theme/extensibleAxes.test.ts +365 -0
  206. package/src/theme/mergeComponents.ts +59 -0
  207. package/src/theme/onMediaTokens.ts +9 -2
  208. package/src/utils/characters.test.ts +141 -0
  209. package/src/utils/characters.ts +88 -0
  210. package/src/utils/ime.test.ts +32 -0
  211. package/src/utils/ime.ts +69 -0
  212. package/src/utils/index.ts +4 -0
  213. package/dist/astryx.umd.js +0 -147
  214. package/dist/astryx.umd.js.map +0 -7
@@ -0,0 +1,628 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file BottomSheetSwitcher.tsx
7
+ * @input Uses React context, StyleX, theme tokens, focus/scroll-lock hooks, BottomSheetSwitcherContext
8
+ * @output Exports BottomSheetSwitcher and BottomSheetSwitcherProps
9
+ * @position Core switcher for mutually exclusive BottomSheet flows
10
+ *
11
+ * The switcher turns a set of declaratively nested BottomSheets into a
12
+ * controlled single-selection group: `activeSheet` names the one interactive
13
+ * child, or is null when the flow is closed. During a handoff the new sheet
14
+ * enters above the previous sheet. If it is shorter, the previous sheet moves
15
+ * down at the same time until their top edges align; otherwise it stays
16
+ * stationary. The previous sheet fades only after both motions complete.
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.
22
+ *
23
+ * SYNC: When modified, update these files to stay in sync:
24
+ * - /packages/core/src/BottomSheet/BottomSheet.tsx
25
+ * - /packages/core/src/BottomSheet/BottomSheetSwitcher.doc.mjs
26
+ * - /packages/core/src/BottomSheet/BottomSheetSwitcher.test.tsx
27
+ * - /packages/core/src/BottomSheet/index.ts
28
+ * - /apps/storybook/stories/BottomSheetSwitcher.stories.tsx
29
+ * - /packages/cli/assets/templates/blocks/components/BottomSheet/BottomSheetSwitcherShowcase.tsx
30
+ */
31
+
32
+ import {
33
+ useCallback,
34
+ useLayoutEffect,
35
+ useMemo,
36
+ useRef,
37
+ useState,
38
+ type KeyboardEvent as ReactKeyboardEvent,
39
+ type MouseEvent as ReactMouseEvent,
40
+ type ReactNode,
41
+ type SyntheticEvent,
42
+ } from 'react';
43
+ import * as stylex from '@stylexjs/stylex';
44
+ import type {BaseProps} from '../BaseProps';
45
+ import type {DialogPurpose} from '../Dialog';
46
+ import {colorVars, durationVars, easeVars} from '../theme/tokens.stylex';
47
+ import {
48
+ hasActiveFocusTrapEscape,
49
+ isImeKeyEvent,
50
+ useFocusTrap,
51
+ useScrollLock,
52
+ } from '../hooks';
53
+ import {composeEventHandlers, mergeProps, mergeRefs} from '../utils';
54
+ import {
55
+ BottomSheetSwitcherContext,
56
+ type BottomSheetSwitcherContextValue,
57
+ type BottomSheetSwitcherPhase,
58
+ type BottomSheetSwitcherTransitionEvent,
59
+ } from './BottomSheetSwitcherContext';
60
+
61
+ const styles = stylex.create({
62
+ dialog: {
63
+ position: 'fixed',
64
+ inset: 0,
65
+ width: '100dvw',
66
+ height: '100dvh',
67
+ maxWidth: 'none',
68
+ maxHeight: 'none',
69
+ margin: 0,
70
+ padding: 0,
71
+ border: 'none',
72
+ backgroundColor: 'transparent',
73
+ overflow: 'visible',
74
+ display: 'none',
75
+ outline: 'none',
76
+ },
77
+ dialogOpen: {
78
+ display: 'block',
79
+ },
80
+ // show() leaves a no-scrim flow in the normal rendering tree. The shell must
81
+ // pass pointer input through to the live page; each sheet panel opts back in.
82
+ // width/height 100% avoids overflowing a transformed containing block, but
83
+ // consumers of this non-modal mode must still avoid clipping ancestors.
84
+ dialogNonModal: {
85
+ pointerEvents: 'none',
86
+ zIndex: 1000,
87
+ width: '100%',
88
+ height: '100%',
89
+ },
90
+ scrim: {
91
+ '::backdrop': {
92
+ backgroundColor: colorVars['--color-overlay'],
93
+ opacity: {
94
+ default: 'var(--_sheet-scrim-opacity, 1)',
95
+ '@starting-style': 0,
96
+ },
97
+ transitionProperty: 'opacity, display',
98
+ transitionDuration: durationVars['--duration-medium'],
99
+ transitionTimingFunction: easeVars['--ease-standard'],
100
+ transitionBehavior: 'allow-discrete',
101
+ '@media (prefers-reduced-motion: reduce)': {
102
+ transitionDuration: '0.01s',
103
+ },
104
+ },
105
+ },
106
+ });
107
+
108
+ type RetainedSheetPhase = 'covered' | 'aligning' | 'fading' | 'exiting';
109
+
110
+ interface SheetTransitionState {
111
+ enteringSheet: string | null;
112
+ retainedSheet: string | null;
113
+ retainedPhase: RetainedSheetPhase | null;
114
+ alignmentOffset: number;
115
+ isAlignmentComplete: boolean;
116
+ }
117
+
118
+ const IDLE_TRANSITION: SheetTransitionState = {
119
+ enteringSheet: null,
120
+ retainedSheet: null,
121
+ retainedPhase: null,
122
+ alignmentOffset: 0,
123
+ isAlignmentComplete: false,
124
+ };
125
+
126
+ const ALIGNMENT_THRESHOLD_PX = 1;
127
+
128
+ function transitionForActiveSheetChange(
129
+ previousSheet: string | null,
130
+ nextSheet: string | null,
131
+ ): SheetTransitionState {
132
+ if (previousSheet == null) {
133
+ return IDLE_TRANSITION;
134
+ }
135
+ if (nextSheet == null) {
136
+ return {
137
+ enteringSheet: null,
138
+ retainedSheet: previousSheet,
139
+ retainedPhase: 'exiting',
140
+ alignmentOffset: 0,
141
+ isAlignmentComplete: false,
142
+ };
143
+ }
144
+ return {
145
+ enteringSheet: nextSheet,
146
+ retainedSheet: previousSheet,
147
+ retainedPhase: 'covered',
148
+ alignmentOffset: 0,
149
+ isAlignmentComplete: false,
150
+ };
151
+ }
152
+
153
+ function alignmentOffsetForElements(
154
+ enteringElement: HTMLElement | undefined,
155
+ retainedElement: HTMLElement | undefined,
156
+ ): number {
157
+ if (enteringElement == null || retainedElement == null) {
158
+ return 0;
159
+ }
160
+ const enteringPositioner = enteringElement.parentElement;
161
+ const enteringTop =
162
+ enteringPositioner?.getBoundingClientRect().top ??
163
+ enteringElement.getBoundingClientRect().top;
164
+ return Math.max(0, enteringTop - retainedElement.getBoundingClientRect().top);
165
+ }
166
+
167
+ export interface BottomSheetSwitcherProps extends BaseProps<HTMLDialogElement> {
168
+ /** Ref forwarded to the shared native dialog. */
169
+ ref?: React.Ref<HTMLDialogElement>;
170
+
171
+ /** Called when the shared native dialog receives a cancel event. */
172
+ onCancel?: (event: SyntheticEvent<HTMLDialogElement>) => void;
173
+
174
+ /**
175
+ * ID of the interactive BottomSheet, or null when the flow should close.
176
+ * Must match a nested BottomSheet's `sheetId`. The previous sheet may remain
177
+ * visually present and inert while the new sheet enters, moving downward at
178
+ * the same time if the new sheet is shorter, then fade away.
179
+ */
180
+ activeSheet: string | null;
181
+
182
+ /** Called with null when the active sheet requests dismissal. */
183
+ onActiveSheetChange: (sheetId: string | null) => void;
184
+
185
+ /**
186
+ * Whether to open the shared dialog modally with its native ::backdrop.
187
+ * Disable for a viewport-anchored, non-modal flow over an interactive page.
188
+ * @default true
189
+ */
190
+ hasScrim?: boolean;
191
+
192
+ /** BottomSheets identified by unique `sheetId` values. */
193
+ children: ReactNode;
194
+ }
195
+
196
+ /**
197
+ * Coordinates a set of BottomSheets so zero or one is active at a time inside
198
+ * one shared native dialog. During a handoff the previous panel stays visible
199
+ * and inert beneath the entering panel, then fades after motion completes.
200
+ */
201
+ export function BottomSheetSwitcher({
202
+ activeSheet,
203
+ onActiveSheetChange,
204
+ hasScrim = true,
205
+ children,
206
+ ref,
207
+ xstyle,
208
+ className,
209
+ style,
210
+ onCancel,
211
+ onClick,
212
+ onKeyDown,
213
+ ...props
214
+ }: BottomSheetSwitcherProps) {
215
+ const dialogRef = useRef<HTMLDialogElement | null>(null);
216
+ const dialogModeRef = useRef<'modal' | 'non-modal' | null>(null);
217
+ const triggerRef = useRef<HTMLElement | null>(null);
218
+ const sheetElementsRef = useRef(new Map<string, HTMLElement>());
219
+ const committedActiveSheetRef = useRef(activeSheet);
220
+ const [sheetLabels, setSheetLabels] = useState<ReadonlyMap<string, string>>(
221
+ () => new Map(),
222
+ );
223
+ const [sheetPurposes, setSheetPurposes] = useState<
224
+ ReadonlyMap<string, DialogPurpose>
225
+ >(() => new Map());
226
+ const [unmountedSheetIds, setUnmountedSheetIds] = useState<
227
+ ReadonlySet<string>
228
+ >(() => new Set());
229
+ const [transition, setTransition] =
230
+ useState<SheetTransitionState>(IDLE_TRANSITION);
231
+
232
+ const activeSheetChanged = committedActiveSheetRef.current !== activeSheet;
233
+ const visibleTransition = activeSheetChanged
234
+ ? transitionForActiveSheetChange(
235
+ committedActiveSheetRef.current,
236
+ activeSheet,
237
+ )
238
+ : transition;
239
+ const isFlowVisible =
240
+ (activeSheet != null && !unmountedSheetIds.has(activeSheet)) ||
241
+ (visibleTransition.retainedSheet != null &&
242
+ !unmountedSheetIds.has(visibleTransition.retainedSheet));
243
+ const isModal = hasScrim && isFlowVisible;
244
+ const activeSheetPurpose =
245
+ (activeSheet == null ? null : sheetPurposes.get(activeSheet)) ?? 'info';
246
+ const allowsEscapeDismiss = activeSheetPurpose !== 'required';
247
+ const allowsLightDismiss = activeSheetPurpose === 'info';
248
+
249
+ useLayoutEffect(() => {
250
+ const previousActiveSheet = committedActiveSheetRef.current;
251
+ if (previousActiveSheet === activeSheet) {
252
+ return;
253
+ }
254
+ committedActiveSheetRef.current = activeSheet;
255
+ const nextTransition = transitionForActiveSheetChange(
256
+ previousActiveSheet,
257
+ activeSheet,
258
+ );
259
+ setTransition(
260
+ nextTransition.retainedSheet != null &&
261
+ !sheetElementsRef.current.has(nextTransition.retainedSheet)
262
+ ? IDLE_TRANSITION
263
+ : nextTransition,
264
+ );
265
+ }, [activeSheet]);
266
+
267
+ const dismissOnEscape = useCallback(() => {
268
+ if (allowsEscapeDismiss) {
269
+ onActiveSheetChange(null);
270
+ }
271
+ }, [allowsEscapeDismiss, onActiveSheetChange]);
272
+ const dismissOnLightInteraction = useCallback(() => {
273
+ if (allowsLightDismiss) {
274
+ onActiveSheetChange(null);
275
+ }
276
+ }, [allowsLightDismiss, onActiveSheetChange]);
277
+ const {containerRef} = useFocusTrap<HTMLDialogElement>({
278
+ isActive: isModal,
279
+ onEscape: dismissOnEscape,
280
+ });
281
+ useScrollLock(isModal);
282
+
283
+ // Open one shared shell for the complete flow. Modal flows enter the native
284
+ // top layer once; handoffs only swap panels inside it. The final panel owns
285
+ // the exit timing, so isFlowVisible becomes false only when it is safe to
286
+ // close the dialog and restore focus.
287
+ useLayoutEffect(() => {
288
+ const dialog = dialogRef.current;
289
+ if (dialog == null) {
290
+ return;
291
+ }
292
+
293
+ if (isFlowVisible) {
294
+ const nextMode = hasScrim ? 'modal' : 'non-modal';
295
+ if (dialog.open && dialogModeRef.current !== nextMode) {
296
+ dialog.close();
297
+ }
298
+ if (!dialog.open) {
299
+ if (hasScrim && triggerRef.current == null) {
300
+ triggerRef.current = document.activeElement as HTMLElement | null;
301
+ }
302
+ if (hasScrim) {
303
+ dialog.showModal();
304
+ } else {
305
+ dialog.show();
306
+ }
307
+ }
308
+ dialogModeRef.current = nextMode;
309
+ return;
310
+ }
311
+
312
+ if (dialog.open) {
313
+ dialog.close();
314
+ }
315
+ if (dialogModeRef.current === 'modal') {
316
+ triggerRef.current?.focus();
317
+ }
318
+ triggerRef.current = null;
319
+ dialogModeRef.current = null;
320
+ }, [hasScrim, isFlowVisible]);
321
+
322
+ const getSheetPhase = useCallback(
323
+ (sheetId: string): BottomSheetSwitcherPhase => {
324
+ if (sheetId === activeSheet) {
325
+ return sheetId === visibleTransition.enteringSheet
326
+ ? 'entering'
327
+ : 'active';
328
+ }
329
+ if (sheetId === visibleTransition.retainedSheet) {
330
+ return visibleTransition.retainedPhase ?? 'hidden';
331
+ }
332
+ return 'hidden';
333
+ },
334
+ [activeSheet, visibleTransition],
335
+ );
336
+
337
+ const getSheetAlignmentOffset = useCallback(
338
+ (sheetId: string) =>
339
+ visibleTransition.retainedSheet === sheetId
340
+ ? visibleTransition.alignmentOffset
341
+ : 0,
342
+ [visibleTransition],
343
+ );
344
+
345
+ const registerSheetElement = useCallback(
346
+ (sheetId: string, element: HTMLElement | null) => {
347
+ if (element == null) {
348
+ sheetElementsRef.current.delete(sheetId);
349
+ setUnmountedSheetIds(current => {
350
+ if (current.has(sheetId)) {
351
+ return current;
352
+ }
353
+ const next = new Set(current);
354
+ next.add(sheetId);
355
+ return next;
356
+ });
357
+ setTransition(current =>
358
+ current.retainedSheet === sheetId ? IDLE_TRANSITION : current,
359
+ );
360
+ } else {
361
+ sheetElementsRef.current.set(sheetId, element);
362
+ setUnmountedSheetIds(current => {
363
+ if (!current.has(sheetId)) {
364
+ return current;
365
+ }
366
+ const next = new Set(current);
367
+ next.delete(sheetId);
368
+ return next;
369
+ });
370
+ }
371
+ },
372
+ [],
373
+ );
374
+
375
+ const registerSheetLabel = useCallback(
376
+ (sheetId: string, label: string | null) => {
377
+ setSheetLabels(current => {
378
+ if (label == null) {
379
+ if (!current.has(sheetId)) {
380
+ return current;
381
+ }
382
+ const next = new Map(current);
383
+ next.delete(sheetId);
384
+ return next;
385
+ }
386
+ if (current.get(sheetId) === label) {
387
+ return current;
388
+ }
389
+ const next = new Map(current);
390
+ next.set(sheetId, label);
391
+ return next;
392
+ });
393
+ },
394
+ [],
395
+ );
396
+
397
+ const registerSheetPurpose = useCallback(
398
+ (sheetId: string, purpose: DialogPurpose | null) => {
399
+ setSheetPurposes(current => {
400
+ if (purpose == null) {
401
+ if (!current.has(sheetId)) {
402
+ return current;
403
+ }
404
+ const next = new Map(current);
405
+ next.delete(sheetId);
406
+ return next;
407
+ }
408
+ if (current.get(sheetId) === purpose) {
409
+ return current;
410
+ }
411
+ const next = new Map(current);
412
+ next.set(sheetId, purpose);
413
+ return next;
414
+ });
415
+ },
416
+ [],
417
+ );
418
+
419
+ useLayoutEffect(() => {
420
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- clear a retained sheet after its panel unmounts
421
+ setTransition(current =>
422
+ current.retainedSheet != null &&
423
+ unmountedSheetIds.has(current.retainedSheet)
424
+ ? IDLE_TRANSITION
425
+ : current,
426
+ );
427
+ }, [unmountedSheetIds]);
428
+
429
+ const onSheetEnterStart = useCallback((sheetId: string) => {
430
+ setTransition(current => {
431
+ if (
432
+ current.enteringSheet !== sheetId ||
433
+ current.retainedSheet == null ||
434
+ current.retainedPhase !== 'covered'
435
+ ) {
436
+ return current;
437
+ }
438
+ const alignmentOffset = alignmentOffsetForElements(
439
+ sheetElementsRef.current.get(sheetId),
440
+ sheetElementsRef.current.get(current.retainedSheet),
441
+ );
442
+ if (alignmentOffset <= ALIGNMENT_THRESHOLD_PX) {
443
+ return current;
444
+ }
445
+ return {
446
+ ...current,
447
+ retainedPhase: 'aligning',
448
+ alignmentOffset,
449
+ isAlignmentComplete: false,
450
+ };
451
+ });
452
+ }, []);
453
+
454
+ const onSheetTransitionComplete = useCallback(
455
+ ({sheetId, phase}: BottomSheetSwitcherTransitionEvent) => {
456
+ setTransition(current => {
457
+ if (phase === 'entering') {
458
+ if (current.enteringSheet !== sheetId) {
459
+ return current;
460
+ }
461
+ if (current.retainedSheet == null) {
462
+ return IDLE_TRANSITION;
463
+ }
464
+ if (
465
+ current.retainedPhase === 'aligning' &&
466
+ !current.isAlignmentComplete
467
+ ) {
468
+ return {...current, enteringSheet: null};
469
+ }
470
+ return {
471
+ ...current,
472
+ enteringSheet: null,
473
+ retainedPhase: 'fading',
474
+ };
475
+ }
476
+
477
+ if (
478
+ phase === 'aligning' &&
479
+ current.retainedSheet === sheetId &&
480
+ current.retainedPhase === 'aligning'
481
+ ) {
482
+ return current.enteringSheet == null
483
+ ? {...current, retainedPhase: 'fading'}
484
+ : {...current, isAlignmentComplete: true};
485
+ }
486
+
487
+ if (
488
+ (phase === 'fading' || phase === 'exiting') &&
489
+ current.retainedSheet === sheetId &&
490
+ current.retainedPhase === phase
491
+ ) {
492
+ return IDLE_TRANSITION;
493
+ }
494
+
495
+ return current;
496
+ });
497
+ },
498
+ [],
499
+ );
500
+
501
+ const setScrimOpacity = useCallback((opacity: number) => {
502
+ dialogRef.current?.style.setProperty(
503
+ '--_sheet-scrim-opacity',
504
+ String(opacity),
505
+ );
506
+ }, []);
507
+ const onSheetScrimOpacityChange = useCallback(
508
+ (sheetId: string, opacity: number) => {
509
+ // A pointer captured by the outgoing sheet can keep delivering gesture
510
+ // events after a handoff. Only the currently committed sheet owns the
511
+ // shared backdrop, so stale gesture updates must not reach the dialog.
512
+ if (sheetId !== committedActiveSheetRef.current) {
513
+ return;
514
+ }
515
+ setScrimOpacity(opacity);
516
+ },
517
+ [setScrimOpacity],
518
+ );
519
+
520
+ useLayoutEffect(() => {
521
+ setScrimOpacity(activeSheet == null ? 0 : 1);
522
+ }, [activeSheet, setScrimOpacity]);
523
+
524
+ const contextValue = useMemo<BottomSheetSwitcherContextValue>(
525
+ () => ({
526
+ activeSheet,
527
+ hasScrim,
528
+ onActiveSheetChange,
529
+ getSheetPhase,
530
+ getSheetAlignmentOffset,
531
+ registerSheetElement,
532
+ registerSheetLabel,
533
+ registerSheetPurpose,
534
+ onSheetEnterStart,
535
+ onSheetTransitionComplete,
536
+ onSheetScrimOpacityChange,
537
+ }),
538
+ [
539
+ activeSheet,
540
+ getSheetAlignmentOffset,
541
+ getSheetPhase,
542
+ hasScrim,
543
+ onActiveSheetChange,
544
+ onSheetEnterStart,
545
+ onSheetScrimOpacityChange,
546
+ onSheetTransitionComplete,
547
+ registerSheetElement,
548
+ registerSheetLabel,
549
+ registerSheetPurpose,
550
+ ],
551
+ );
552
+
553
+ const activeLabel =
554
+ (activeSheet == null ? null : sheetLabels.get(activeSheet)) ??
555
+ (visibleTransition.retainedSheet == null
556
+ ? undefined
557
+ : sheetLabels.get(visibleTransition.retainedSheet));
558
+
559
+ const handleCancel = useCallback(
560
+ (event: SyntheticEvent<HTMLDialogElement>) => {
561
+ event.preventDefault();
562
+ dismissOnEscape();
563
+ },
564
+ [dismissOnEscape],
565
+ );
566
+ const handleKeyDown = useCallback(
567
+ (event: ReactKeyboardEvent<HTMLDialogElement>) => {
568
+ // Modal Escape is owned by useFocusTrap so nested traps can win. A
569
+ // non-modal switcher has no outer trap, so retain local dismissal while
570
+ // deferring to an active nested layer and ignoring IME cancellation.
571
+ if (
572
+ !isModal &&
573
+ event.key === 'Escape' &&
574
+ !isImeKeyEvent(event.nativeEvent) &&
575
+ !hasActiveFocusTrapEscape()
576
+ ) {
577
+ event.preventDefault();
578
+ dismissOnEscape();
579
+ }
580
+ },
581
+ [dismissOnEscape, isModal],
582
+ );
583
+ const handleClick = useCallback(
584
+ (event: ReactMouseEvent<HTMLDialogElement>) => {
585
+ if (hasScrim && event.target === event.currentTarget) {
586
+ dismissOnLightInteraction();
587
+ }
588
+ },
589
+ [dismissOnLightInteraction, hasScrim],
590
+ );
591
+
592
+ const dialogStyleProps = stylex.props(
593
+ styles.dialog,
594
+ isFlowVisible && styles.dialogOpen,
595
+ hasScrim && styles.scrim,
596
+ !hasScrim && styles.dialogNonModal,
597
+ xstyle,
598
+ );
599
+ const dialogPresentationProps = mergeProps(
600
+ dialogStyleProps,
601
+ className,
602
+ style,
603
+ );
604
+ const ariaLabel =
605
+ props['aria-label'] ??
606
+ (props['aria-labelledby'] == null ? activeLabel : undefined);
607
+
608
+ return (
609
+ <BottomSheetSwitcherContext value={contextValue}>
610
+ <dialog
611
+ {...props}
612
+ {...dialogPresentationProps}
613
+ ref={mergeRefs(ref, dialogRef, containerRef)}
614
+ aria-label={ariaLabel}
615
+ aria-modal={isModal ? 'true' : undefined}
616
+ onCancel={composeEventHandlers(onCancel, handleCancel)}
617
+ onClick={composeEventHandlers(onClick, handleClick)}
618
+ onKeyDown={composeEventHandlers(onKeyDown, handleKeyDown)}
619
+ {...(activeSheetPurpose === 'required'
620
+ ? {role: 'alertdialog'}
621
+ : undefined)}>
622
+ {children}
623
+ </dialog>
624
+ </BottomSheetSwitcherContext>
625
+ );
626
+ }
627
+
628
+ BottomSheetSwitcher.displayName = 'BottomSheetSwitcher';
@@ -0,0 +1,51 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file BottomSheetSwitcherContext.ts
7
+ * @input Uses React context
8
+ * @output Internal phase and lifecycle context shared by BottomSheetSwitcher and BottomSheet
9
+ * @position Private coordination layer for mutually exclusive BottomSheets
10
+ */
11
+
12
+ import {createContext} from 'react';
13
+ import type {DialogPurpose} from '../Dialog';
14
+
15
+ export type BottomSheetSwitcherPhase =
16
+ | 'entering'
17
+ | 'active'
18
+ | 'covered'
19
+ | 'aligning'
20
+ | 'fading'
21
+ | 'exiting'
22
+ | 'hidden';
23
+
24
+ export interface BottomSheetSwitcherTransitionEvent {
25
+ sheetId: string;
26
+ phase: 'entering' | 'aligning' | 'fading' | 'exiting';
27
+ }
28
+
29
+ export interface BottomSheetSwitcherContextValue {
30
+ activeSheet: string | null;
31
+ hasScrim: boolean;
32
+ onActiveSheetChange: (sheetId: string | null) => void;
33
+ getSheetPhase: (sheetId: string) => BottomSheetSwitcherPhase;
34
+ getSheetAlignmentOffset: (sheetId: string) => number;
35
+ registerSheetElement: (sheetId: string, element: HTMLElement | null) => void;
36
+ registerSheetLabel: (sheetId: string, label: string | null) => void;
37
+ registerSheetPurpose: (
38
+ sheetId: string,
39
+ purpose: DialogPurpose | null,
40
+ ) => void;
41
+ onSheetEnterStart: (sheetId: string) => void;
42
+ onSheetTransitionComplete: (
43
+ event: BottomSheetSwitcherTransitionEvent,
44
+ ) => void;
45
+ onSheetScrimOpacityChange: (sheetId: string, opacity: number) => void;
46
+ }
47
+
48
+ export const BottomSheetSwitcherContext =
49
+ createContext<BottomSheetSwitcherContextValue | null>(null);
50
+
51
+ BottomSheetSwitcherContext.displayName = 'BottomSheetSwitcherContext';
@@ -0,0 +1,15 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ 'use client';
4
+
5
+ /**
6
+ * @file index.ts
7
+ * @input BottomSheet.tsx, BottomSheetSwitcher.tsx
8
+ * @output Re-exports the BottomSheet public API
9
+ * @position Component entry point; re-exported by /packages/core/src/index.ts
10
+ */
11
+
12
+ export {BottomSheet} from './BottomSheet';
13
+ export type {BottomSheetHeight, BottomSheetProps} from './BottomSheet';
14
+ export {BottomSheetSwitcher} from './BottomSheetSwitcher';
15
+ export type {BottomSheetSwitcherProps} from './BottomSheetSwitcher';