@astryxdesign/lab 0.2.0-canary.8d64648 → 0.2.0-canary.8dfd7cf

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 (63) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +41 -35
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +222 -75
  4. package/dist/BottomSheet/snapOffsets.d.ts +54 -0
  5. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  6. package/dist/BottomSheet/snapOffsets.js +101 -0
  7. package/dist/BottomSheet/useSheetGestures.d.ts +55 -12
  8. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  9. package/dist/BottomSheet/useSheetGestures.js +383 -40
  10. package/dist/Chart/index.d.ts +1 -1
  11. package/dist/Chart/index.d.ts.map +1 -1
  12. package/dist/Chart/index.js +1 -1
  13. package/dist/Stepper/Step.d.ts +1 -1
  14. package/dist/Stepper/Step.js +1 -1
  15. package/dist/Stepper/Stepper.d.ts +1 -1
  16. package/dist/Stepper/Stepper.js +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/lab.css +15 -8
  19. package/package.json +3 -2
  20. package/src/BottomSheet/BottomSheet.doc.mjs +13 -13
  21. package/src/BottomSheet/BottomSheet.test.tsx +81 -4
  22. package/src/BottomSheet/BottomSheet.tsx +333 -97
  23. package/src/BottomSheet/snapOffsets.test.ts +146 -0
  24. package/src/BottomSheet/snapOffsets.ts +126 -0
  25. package/src/BottomSheet/useSheetGestures.doc.mjs +7 -6
  26. package/src/BottomSheet/useSheetGestures.test.ts +315 -65
  27. package/src/BottomSheet/useSheetGestures.ts +515 -55
  28. package/src/Chart/ChartArea.doc.mjs +1 -1
  29. package/src/Chart/ChartBar.doc.mjs +1 -1
  30. package/src/Chart/ChartCandlestick.doc.mjs +1 -1
  31. package/src/Chart/ChartDot.doc.mjs +1 -1
  32. package/src/Chart/ChartDotGL.doc.mjs +1 -1
  33. package/src/Chart/ChartDotGLInteractive.doc.mjs +1 -1
  34. package/src/Chart/ChartHeatmapGL.doc.mjs +1 -1
  35. package/src/Chart/ChartLine.doc.mjs +1 -1
  36. package/src/Chart/ChartStreamGL.doc.mjs +1 -1
  37. package/src/Chart/index.ts +0 -1
  38. package/src/Chat/ChatEmojiPicker.doc.mjs +1 -1
  39. package/src/Chat/ChatReactionBar.doc.mjs +1 -1
  40. package/src/Chat/ChatTypingIndicator.doc.mjs +1 -1
  41. package/src/Chat/ChatUnreadDivider.doc.mjs +1 -1
  42. package/src/ChatReasoning/ChatReasoning.doc.mjs +1 -1
  43. package/src/CircularProgress/CircularProgress.doc.mjs +3 -3
  44. package/src/CodeEditor/CodeEditor.doc.mjs +1 -1
  45. package/src/Drawer/Drawer.doc.mjs +3 -3
  46. package/src/InfoTip/InfoTip.doc.mjs +3 -3
  47. package/src/LogStream/LogStream.doc.mjs +1 -1
  48. package/src/Radial/RadialChart.doc.mjs +1 -1
  49. package/src/RichTextEditor/RichTextEditor.doc.mjs +1 -1
  50. package/src/SVGIcon/SVGIcon.doc.mjs +1 -1
  51. package/src/Sankey/SankeyChart.doc.mjs +1 -1
  52. package/src/Schedule/Schedule.doc.mjs +1 -1
  53. package/src/Stat/Stat.doc.mjs +3 -3
  54. package/src/Stepper/Step.doc.mjs +1 -1
  55. package/src/Stepper/Step.tsx +1 -1
  56. package/src/Stepper/Stepper.doc.mjs +3 -3
  57. package/src/Stepper/Stepper.tsx +1 -1
  58. package/src/ThreeD/3DBar.doc.mjs +1 -1
  59. package/src/ThreeD/3DChart.doc.mjs +1 -1
  60. package/src/ThreeD/3DScatter.doc.mjs +1 -1
  61. package/src/ThreeD/3DScatterGL.doc.mjs +1 -1
  62. package/src/ThreeD/3DSurface.doc.mjs +1 -1
  63. package/src/index.ts +1 -1
@@ -4,21 +4,22 @@
4
4
 
5
5
  /**
6
6
  * @file BottomSheet.tsx
7
- * @input Uses React, StyleX, theme tokens, Drawer, useSheetGestures, themeProps
7
+ * @input Uses React, StyleX, theme tokens, core hooks (useScrollLock), utils, useSheetGestures
8
8
  * @output Exports BottomSheet component and BottomSheetProps
9
9
  * @position Lab implementation; consumed by index.ts, tested by BottomSheet.test.tsx, demonstrated in Storybook
10
10
  *
11
- * A mobile touch surface that rises from the bottom edge with a grab handle
12
- * and swipe-to-dismiss. Built ON TOP OF the lab Drawer's native `<dialog>`
13
- * engine (`side="bottom"`) rather than forking it — Drawer owns
14
- * showModal/show, the open/close slide animation, focus trap + restore,
15
- * scroll lock, the Escape/scrim contract, and the LIFO overlay registry.
16
- * BottomSheet layers the grab handle and the drag-to-dismiss gesture on top,
17
- * inside the dialog.
11
+ * A mobile touch surface that rises from the bottom edge: grab handle, snap
12
+ * points, and swipe-to-dismiss. It owns a native modal `<dialog>` directly and
13
+ * composes core primitives (`useScrollLock`, `<dialog>.showModal()` for the
14
+ * top layer + focus trap, a `::backdrop` scrim) rather than delegating to a
15
+ * heavier overlay component — so it controls its own sizing and can render a
16
+ * full-height transparent shell with the visible sheet bottom-anchored inside.
17
+ * That shell is what lets the sheet translate freely (including a little past
18
+ * fully-open) without clipping against a fixed dialog edge.
18
19
  *
19
- * The gesture translate is applied to an inner sheet wrapper, not the
20
- * `<dialog>` itself, so Drawer owns the open/close slide and the drag
21
- * translates within it without the two transforms fighting.
20
+ * The drag/snap/dismiss machinery lives in `useSheetGestures`; the offset
21
+ * geometry lives in the pure, tested `snapOffsets` module. Both are internal
22
+ * to BottomSheet and not exported from the lab entry point.
22
23
  *
23
24
  * SYNC: When modified, update these files to stay in sync:
24
25
  * - /packages/lab/src/BottomSheet/BottomSheet.doc.mjs (props table, features, usage)
@@ -27,81 +28,168 @@
27
28
  * - /apps/storybook/stories/BottomSheet.stories.tsx (examples and visual coverage)
28
29
  */
29
30
 
30
- import {type ReactNode} from 'react';
31
+ import {useCallback, useEffect, useRef, type ReactNode} from 'react';
31
32
  import * as stylex from '@stylexjs/stylex';
32
33
  import type {BaseProps} from '@astryxdesign/core';
33
34
  import {
34
35
  colorVars,
36
+ durationVars,
37
+ easeVars,
35
38
  radiusVars,
39
+ shadowVars,
40
+ sizeVars,
36
41
  spacingVars,
37
42
  } from '@astryxdesign/core/theme/tokens.stylex';
38
- import {mergeProps, themeProps} from '@astryxdesign/core/utils';
39
- import {Drawer} from '../Drawer';
43
+ import {useDevWarning, useScrollLock} from '@astryxdesign/core/hooks';
44
+ import {mergeProps, mergeRefs, themeProps} from '@astryxdesign/core/utils';
40
45
  import {useSheetGestures} from './useSheetGestures';
41
46
 
47
+ // Detent fractions of the viewport, ascending (peek / mid / full). Detents
48
+ // that land within DETENT_DEDUP_PX of each other (e.g. a hug height next to a
49
+ // snap) are de-duped in the gesture hook, and any taller than the sheet are
50
+ // dropped, so a full set is safe to offer. The shortest is a glance "peek";
51
+ // the scrim hides as the sheet collapses onto it (see onScrimOpacity).
52
+ const SNAP_FRACTIONS = [0.14, 0.5, 0.92];
53
+
42
54
  /**
43
- * Height budget for each named size. `medium`/`tall` track the viewport so
44
- * the sheet scales with the device; `short` is a fixed peek height. `auto`
45
- * is handled separately (fits content, capped at the `tall` budget).
55
+ * Height budget for each named size, as a fraction of the viewport:
56
+ * - `hug` — fits its content, never taller than 92%.
57
+ * - `capped` — a scrolling mid-height panel (62%).
58
+ * - `tall` — a pinned near-full panel (92%); use when content streams in so
59
+ * the sheet doesn't resize under the user.
46
60
  */
47
61
  const HEIGHT_BUDGETS = {
48
- short: '240px',
49
- medium: '50dvh',
50
- tall: '90dvh',
51
- auto: '90dvh',
62
+ hug: '92dvh', // upper bound only; the sheet hugs its content beneath it
63
+ capped: '62dvh',
64
+ tall: '92dvh',
52
65
  } as const;
53
66
 
54
67
  export type BottomSheetHeight = keyof typeof HEIGHT_BUDGETS;
55
68
 
56
- // Sheets on wide touch devices (tablets) shouldn't stretch edge to edge;
57
- // cap and center them. Matches the `sm` layout breakpoint. On phones the
58
- // viewport is narrower than this, so the sheet stays full-width.
59
- const MAX_SHEET_WIDTH = 640;
69
+ // px the sheet extends below its budget as reserved padding, revealed by an
70
+ // upward overdrag rather than showing a gap.
71
+ // SYNC: must match OVERSCROLL_MAX in useSheetGestures.ts (the drag cap).
72
+ const OVERSCROLL_PADDING = 48;
73
+
74
+ /**
75
+ * Default snap detents in px, resolved against the *visual* viewport (like iOS
76
+ * detents), so a mid rest point is ~half the screen regardless of the sheet's
77
+ * own height budget. Read lazily at drag start (no persistent listener) so it
78
+ * reflects the live viewport after a rotation or the virtual keyboard opening.
79
+ * SSR-safe: returns `[]` off the client.
80
+ */
81
+ function defaultSnapHeights(): number[] {
82
+ if (typeof window === 'undefined') {
83
+ return [];
84
+ }
85
+ const vh = window.visualViewport?.height ?? window.innerHeight;
86
+ return SNAP_FRACTIONS.map(f => f * vh);
87
+ }
60
88
 
61
89
  const styles = stylex.create({
62
- // Inner wrapper that carries the live drag translate AND the visible sheet
63
- // surface (background, rounded top, width cap). The translate must live on
64
- // the painted surface — not the <dialog> — so the whole sheet follows the
65
- // swipe instead of the content sliding out of a fixed white panel. Drawer
66
- // owns the open/close slide on the <dialog>; this wrapper adds the gesture
67
- // offset within it so the two transforms don't fight. Safe-area padding
68
- // clears the home indicator on notched devices.
90
+ // A full-viewport transparent shell: it supplies the top layer, focus trap,
91
+ // and ::backdrop scrim but paints nothing and doesn't clip, so the sheet can
92
+ // translate past fully-open without hitting a fixed dialog edge. It stays
93
+ // hit-testable (no pointer-events:none) so taps on the transparent area
94
+ // reach onClick and dismiss the sheet — the positioner below passes those
95
+ // taps through to here, while the sheet itself re-enables pointer events.
96
+ dialog: {
97
+ position: 'fixed',
98
+ inset: 0,
99
+ width: '100dvw',
100
+ height: '100dvh',
101
+ maxWidth: 'none',
102
+ maxHeight: 'none',
103
+ margin: 0,
104
+ padding: 0,
105
+ border: 'none',
106
+ backgroundColor: 'transparent',
107
+ overflow: 'visible',
108
+ display: 'none',
109
+ outline: 'none',
110
+ },
111
+ dialogOpen: {
112
+ display: 'block',
113
+ },
114
+ // Opacity is a var the drag handler lowers toward dismissal;
115
+ // @starting-style animates the entrance fade-in.
116
+ scrim: {
117
+ '::backdrop': {
118
+ backgroundColor: colorVars['--color-overlay'],
119
+ backdropFilter: 'blur(2px)',
120
+ opacity: {
121
+ default: 'var(--_sheet-scrim-opacity, 1)',
122
+ '@starting-style': 0,
123
+ },
124
+ transitionProperty: 'opacity, display',
125
+ transitionDuration: durationVars['--duration-medium'],
126
+ transitionTimingFunction: easeVars['--ease-standard'],
127
+ transitionBehavior: 'allow-discrete',
128
+ '@media (prefers-reduced-motion: reduce)': {
129
+ transitionDuration: '0.01s',
130
+ },
131
+ },
132
+ },
133
+ positioner: {
134
+ position: 'absolute',
135
+ insetInline: 0,
136
+ insetBlockEnd: 0,
137
+ display: 'flex',
138
+ justifyContent: 'center',
139
+ pointerEvents: 'none',
140
+ },
69
141
  sheet: {
142
+ pointerEvents: 'auto',
143
+ boxSizing: 'border-box',
70
144
  display: 'flex',
71
145
  flexDirection: 'column',
72
146
  minHeight: 0,
73
- height: '100%',
74
147
  width: '100%',
75
- maxWidth: MAX_SHEET_WIDTH,
76
- marginInline: 'auto',
148
+ maxWidth: 640,
77
149
  backgroundColor: colorVars['--color-background-surface'],
78
- borderStartStartRadius: radiusVars['--radius-page'],
79
- borderStartEndRadius: radiusVars['--radius-page'],
150
+ borderStartStartRadius: radiusVars['--radius-container'],
151
+ borderStartEndRadius: radiusVars['--radius-container'],
152
+ boxShadow: shadowVars['--shadow-high'],
153
+ // tabIndex=-1 makes this the initial focus target; it's a programmatic
154
+ // landing spot, not an interactive control, so suppress the focus ring.
155
+ outline: 'none',
80
156
  overflow: 'hidden',
81
- paddingBlockEnd: `env(safe-area-inset-bottom, 0px)`,
157
+ // Reserved overdrag zone: extra bottom padding (surface bg + home-indicator
158
+ // clearance) that a small upward drag reveals, negated by an equal negative
159
+ // margin so at rest it sits BELOW the fold — the true sheet stays pinned to
160
+ // the bottom edge. Budget heights add the same amount back (see `budget` /
161
+ // `hugHeight`) so the visible height is unchanged.
162
+ paddingBlockEnd: `calc(env(safe-area-inset-bottom, 0px) + ${OVERSCROLL_PADDING}px)`,
163
+ marginBlockEnd: `${-OVERSCROLL_PADDING}px`,
164
+ transform: {
165
+ default: 'translateY(0)',
166
+ '@starting-style': 'translateY(100%)',
167
+ },
168
+ transitionProperty: 'transform',
169
+ transitionDuration: durationVars['--duration-medium'],
170
+ transitionTimingFunction: easeVars['--ease-standard'],
82
171
  willChange: 'transform',
172
+ '@media (prefers-reduced-motion: reduce)': {
173
+ transitionDuration: '0.01s',
174
+ },
83
175
  },
84
- // Neutralize Drawer's <dialog> surface so only the swipeable wrapper paints.
85
- // Without this the dialog's white background + shadow stay fixed while the
86
- // wrapper slides, leaving a static panel behind the drag. The scrim behind
87
- // the sheet provides the depth separation a shadow would.
88
- dialogSurface: {
89
- backgroundColor: 'transparent',
90
- boxShadow: 'none',
91
- borderBlockStartWidth: 0,
176
+ sheetClosing: {
177
+ transform: 'translateY(100%)',
92
178
  },
93
179
  handleBar: {
94
180
  flexShrink: 0,
95
181
  display: 'flex',
96
182
  alignItems: 'center',
97
183
  justifyContent: 'center',
98
- paddingBlock: spacingVars['--spacing-2'],
184
+ // The whole strip is the drag target: 48px (top of the spacing scale,
185
+ // above Apple's 44px / WCAG 2.5.8's 24px), no overlap tricks.
186
+ height: spacingVars['--spacing-12'],
99
187
  touchAction: 'none',
100
188
  cursor: 'grab',
101
189
  },
102
190
  handlePill: {
103
- width: 36,
104
- height: 4,
191
+ width: sizeVars['--size-element-lg'],
192
+ height: spacingVars['--spacing-1'],
105
193
  borderRadius: radiusVars['--radius-full'],
106
194
  backgroundColor: colorVars['--color-border'],
107
195
  },
@@ -109,16 +197,26 @@ const styles = stylex.create({
109
197
  flexGrow: 1,
110
198
  minHeight: 0,
111
199
  overflowY: 'auto',
112
- overscrollBehavior: 'contain',
200
+ // No overscroll bounce inside the sheet — a pull-down at the top edge is
201
+ // handed off to the sheet drag (see useSheetGestures' touch handling)
202
+ // rather than rubber-banding the content.
203
+ overscrollBehavior: 'none',
204
+ // Allow native vertical scrolling of the content; the at-top pull-down is
205
+ // intercepted via a non-passive touchmove listener, not by blocking
206
+ // touch-action (which would suppress scrolling entirely).
207
+ touchAction: 'pan-y',
113
208
  },
114
- // `auto` fits content instead of filling the height budget; the budget
115
- // becomes an upper bound. Applied to the <dialog> (overrides Drawer's
116
- // block-size treatment, which is applied earlier in the props chain).
117
- autoHeight: {
209
+ // Both budgets add the overdrag padding back to the height so the visible
210
+ // height (minus the off-screen padding) matches the intended budget.
211
+ budget: {
212
+ height: `calc(var(--_sheet-budget) + ${OVERSCROLL_PADDING}px)`,
213
+ },
214
+ hugHeight: {
118
215
  height: 'fit-content',
119
- maxHeight: HEIGHT_BUDGETS.auto,
216
+ maxHeight: `calc(${HEIGHT_BUDGETS.hug} + ${OVERSCROLL_PADDING}px)`,
120
217
  },
121
218
  });
219
+
122
220
  export interface BottomSheetProps extends BaseProps<HTMLDialogElement> {
123
221
  /** Ref forwarded to the underlying <dialog> element. */
124
222
  ref?: React.Ref<HTMLDialogElement>;
@@ -127,8 +225,8 @@ export interface BottomSheetProps extends BaseProps<HTMLDialogElement> {
127
225
  isOpen: boolean;
128
226
 
129
227
  /**
130
- * Called when the sheet opens or closes. The boolean is the requested
131
- * next state (`false` on Escape, scrim click, or a swipe past the dismiss
228
+ * Called when the sheet opens or closes. The boolean is the requested next
229
+ * state (`false` on Escape, scrim click, or a swipe past the dismiss
132
230
  * threshold). The caller owns the open state.
133
231
  */
134
232
  onOpenChange: (isOpen: boolean) => void;
@@ -143,28 +241,32 @@ export interface BottomSheetProps extends BaseProps<HTMLDialogElement> {
143
241
  children: ReactNode;
144
242
 
145
243
  /**
146
- * How tall the sheet is:
147
- * - `'short'` — a fixed peek height
148
- * - `'medium'` — half the viewport
149
- * - `'tall'` — nearly the full viewport
150
- * - `'auto'` — fits its content, capped at the `'tall'` budget
244
+ * How tall the sheet is. A named budget, or any explicit height:
245
+ * - `'hug'` — fits its content, never taller than 92% of the viewport.
246
+ * - `'capped'` — a scrolling mid-height panel (~62%).
247
+ * - `'tall'` — a pinned near-full panel (~92%); use when content streams in
248
+ * so the sheet doesn't resize under the user.
249
+ * - a `number` (px) or CSS length string (e.g. `'70dvh'`, `480`) for a
250
+ * custom budget.
151
251
  *
152
- * On viewports shorter than the budget the sheet fills the available
153
- * height.
154
- * @default 'medium'
252
+ * The user can still drag between snap points regardless of the starting
253
+ * height. On viewports shorter than the budget the sheet fills the
254
+ * available height.
255
+ * @default 'capped'
155
256
  */
156
- height?: BottomSheetHeight;
257
+ height?: BottomSheetHeight | number | string;
157
258
 
158
259
  /** Test ID for the root element. */
159
260
  'data-testid'?: string;
160
261
  }
161
262
 
162
263
  /**
163
- * A mobile touch sheet that rises from the bottom edge, with a grab handle
164
- * and swipe-to-dismiss. Built on the lab Drawer's `<dialog>` engine
165
- * (`side="bottom"`): Drawer owns the open/close slide, focus trap + restore,
166
- * scroll lock, and the Escape/scrim contract, while BottomSheet layers the
167
- * grab handle and drag-to-dismiss on top.
264
+ * A mobile touch sheet that rises from the bottom edge, with a grab handle,
265
+ * drag-to-resize snap points, and swipe-to-dismiss. It owns a native modal
266
+ * `<dialog>` (top layer, focus trap, `::backdrop` scrim) and locks body scroll
267
+ * while open; a slow drag settles to the nearest snap point, a flick down
268
+ * dismisses, and Escape closes — so the swipe always has a keyboard
269
+ * equivalent.
168
270
  *
169
271
  * @example
170
272
  * ```
@@ -178,48 +280,182 @@ export interface BottomSheetProps extends BaseProps<HTMLDialogElement> {
178
280
  * ```
179
281
  */
180
282
  export function BottomSheet({
283
+ ref,
181
284
  isOpen,
182
285
  onOpenChange,
183
286
  label,
184
287
  children,
185
- height = 'medium',
288
+ height = 'capped',
186
289
  xstyle,
187
290
  ...props
188
291
  }: BottomSheetProps) {
189
- const close = () => onOpenChange(false);
190
- const {contentProps, handleProps} = useSheetGestures({
292
+ const dialogRef = useRef<HTMLDialogElement | null>(null);
293
+ const triggerRef = useRef<HTMLElement | null>(null);
294
+ const sheetNodeRef = useRef<HTMLDivElement | null>(null);
295
+ const close = useCallback(() => onOpenChange(false), [onOpenChange]);
296
+
297
+ // Set imperatively (not via state) so a 60fps drag doesn't re-render. The
298
+ // hook reports the scrim opacity for the current detent (hidden at the peek).
299
+ const handleScrimOpacity = useCallback((opacity: number) => {
300
+ dialogRef.current?.style.setProperty(
301
+ '--_sheet-scrim-opacity',
302
+ String(opacity),
303
+ );
304
+ }, []);
305
+
306
+ const {contentProps, handleProps, bodyProps, sheetRef} = useSheetGestures({
191
307
  isOpen,
192
308
  onDismiss: close,
309
+ snapHeights: defaultSnapHeights,
310
+ onScrimOpacity: handleScrimOpacity,
193
311
  });
194
312
 
313
+ // showModal() enters the top layer with a focus trap + ::backdrop; on close
314
+ // we animate out (below) then restore focus to the opener.
315
+ useEffect(() => {
316
+ const dialog = dialogRef.current;
317
+ if (!dialog) {
318
+ return;
319
+ }
320
+ if (isOpen) {
321
+ triggerRef.current = document.activeElement as HTMLElement | null;
322
+ dialog.style.setProperty('--_sheet-scrim-opacity', '1');
323
+ if (!dialog.open) {
324
+ dialog.showModal();
325
+ // Override showModal()'s default (first tabbable) to land on the panel,
326
+ // so AT reads from the label — unless a descendant sets data-autofocus.
327
+ const autofocus = dialog.querySelector<HTMLElement>('[data-autofocus]');
328
+ if (autofocus) {
329
+ autofocus.focus();
330
+ } else {
331
+ sheetNodeRef.current?.focus();
332
+ }
333
+ }
334
+ } else if (dialog.open) {
335
+ // Wait for the slide-out (sheetClosing, applied this render) before
336
+ // close() releases the top layer. Timeout backstops a missing
337
+ // transitionend (reduced motion, hidden tab).
338
+ const sheet = sheetNodeRef.current;
339
+ let done = false;
340
+ const finish = () => {
341
+ if (done) {
342
+ return;
343
+ }
344
+ done = true;
345
+ clearTimeout(timer);
346
+ sheet?.removeEventListener('transitionend', onEnd);
347
+ if (dialog.open) {
348
+ dialog.close();
349
+ }
350
+ triggerRef.current?.focus();
351
+ triggerRef.current = null;
352
+ };
353
+ const onEnd = (event: TransitionEvent) => {
354
+ if (event.target === sheet && event.propertyName === 'transform') {
355
+ finish();
356
+ }
357
+ };
358
+ sheet?.addEventListener('transitionend', onEnd);
359
+ // Backstop above --duration-medium (410ms) so transitionend normally
360
+ // fires first; this only covers environments that don't emit it.
361
+ const timer = setTimeout(finish, 450);
362
+ return () => {
363
+ clearTimeout(timer);
364
+ sheet?.removeEventListener('transitionend', onEnd);
365
+ };
366
+ }
367
+ }, [isOpen]);
368
+
369
+ useScrollLock(isOpen);
370
+
371
+ // Enforce an accessible name: label is required by types, but a JS caller
372
+ // can still pass an empty string, leaving the sheet unnamed.
373
+ useDevWarning(
374
+ 'BottomSheet',
375
+ 'requires a non-empty `label` for an accessible name; the open sheet ' +
376
+ 'has no built-in heading to derive one from.',
377
+ isOpen && !label,
378
+ );
379
+
380
+ const handleCancel = useCallback(
381
+ (event: React.SyntheticEvent<HTMLDialogElement>) => {
382
+ event.preventDefault();
383
+ close();
384
+ },
385
+ [close],
386
+ );
387
+ // Explicit Escape handler in addition to the native `cancel` event, for
388
+ // environments that don't synthesize `cancel`.
389
+ const handleKeyDown = useCallback(
390
+ (event: React.KeyboardEvent<HTMLDialogElement>) => {
391
+ if (event.key === 'Escape') {
392
+ event.preventDefault();
393
+ close();
394
+ }
395
+ },
396
+ [close],
397
+ );
398
+ const handleClick = useCallback(
399
+ (event: React.MouseEvent<HTMLDialogElement>) => {
400
+ if (event.target === event.currentTarget) {
401
+ close();
402
+ }
403
+ },
404
+ [close],
405
+ );
406
+
407
+ const isNamed = typeof height === 'string' && height in HEIGHT_BUDGETS;
408
+ const budget = isNamed
409
+ ? HEIGHT_BUDGETS[height as BottomSheetHeight]
410
+ : typeof height === 'number'
411
+ ? `${height}px`
412
+ : height;
413
+ const isHug = height === 'hug';
414
+
195
415
  return (
196
- <Drawer
197
- isOpen={isOpen}
198
- onClose={close}
199
- side="bottom"
200
- size={HEIGHT_BUDGETS[height]}
201
- label={label}
202
- hasScrim
203
- hasCloseButton={false}
204
- xstyle={[styles.dialogSurface, height === 'auto' && styles.autoHeight]}
416
+ <dialog
417
+ {...stylex.props(
418
+ styles.dialog,
419
+ isOpen && styles.dialogOpen,
420
+ styles.scrim,
421
+ )}
422
+ ref={mergeRefs(ref, dialogRef)}
423
+ aria-label={label}
424
+ aria-modal="true"
425
+ onCancel={handleCancel}
426
+ onClick={handleClick}
427
+ onKeyDown={handleKeyDown}
205
428
  {...props}>
206
- <div
207
- data-astryx-sheet=""
208
- {...mergeProps(
209
- themeProps('bottom-sheet'),
210
- stylex.props(styles.sheet, xstyle),
211
- contentProps.style,
212
- )}>
429
+ <div {...stylex.props(styles.positioner)}>
213
430
  <div
214
- data-astryx-sheet-handle=""
215
- {...stylex.props(styles.handleBar)}
216
- {...handleProps}
217
- aria-hidden="true">
218
- <div {...stylex.props(styles.handlePill)} />
431
+ ref={mergeRefs(sheetRef, sheetNodeRef)}
432
+ tabIndex={-1}
433
+ {...mergeProps(
434
+ themeProps('bottom-sheet'),
435
+ stylex.props(
436
+ styles.sheet,
437
+ isHug ? styles.hugHeight : styles.budget,
438
+ !isOpen && styles.sheetClosing,
439
+ xstyle,
440
+ ),
441
+ undefined,
442
+ {
443
+ ['--_sheet-budget' as string]: budget,
444
+ ...contentProps.style,
445
+ },
446
+ )}>
447
+ <div
448
+ {...stylex.props(styles.handleBar)}
449
+ {...handleProps}
450
+ aria-hidden="true">
451
+ <div {...stylex.props(styles.handlePill)} />
452
+ </div>
453
+ <div {...stylex.props(styles.body)} {...bodyProps}>
454
+ {children}
455
+ </div>
219
456
  </div>
220
- <div {...stylex.props(styles.body)}>{children}</div>
221
457
  </div>
222
- </Drawer>
458
+ </dialog>
223
459
  );
224
460
  }
225
461