@astryxdesign/lab 0.4.0-canary.e18b6db → 0.4.0-canary.faedff7

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.
@@ -17,7 +17,8 @@
17
17
  * layer, focus trap, scrim, scroll lock, background inert); `false` uses
18
18
  * `show()` for a non-modal sheet with no scrim, leaving the page behind
19
19
  * interactive and scrollable (the transparent shell is pointer-events:none so
20
- * taps pass through).
20
+ * taps pass through). Both modes keep the native dialog presented but inert
21
+ * until the slide-out transition ends.
21
22
  *
22
23
  * The drag/snap/dismiss machinery lives in `useSheetGestures`; the offset
23
24
  * geometry lives in the pure, tested `snapOffsets` module. Both are internal
@@ -1 +1 @@
1
- {"version":3,"file":"BottomSheet.d.ts","sourceRoot":"","sources":["../../src/BottomSheet/BottomSheet.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA8BG;AAEH,OAAO,EAAiC,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAErE,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,oBAAoB,CAAC;AA2BlD;;;;;;GAMG;AACH,QAAA,MAAM,cAAc;;;;CAIV,CAAC;AAEX,MAAM,MAAM,iBAAiB,GAAG,MAAM,OAAO,cAAc,CAAC;AAiL5D,MAAM,WAAW,gBAAiB,SAAQ,SAAS,CAAC,iBAAiB,CAAC;IACpE,wDAAwD;IACxD,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IAEnC,8EAA8E;IAC9E,MAAM,EAAE,OAAO,CAAC;IAEhB;;;;OAIG;IACH,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAExC;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd,0EAA0E;IAC1E,QAAQ,EAAE,SAAS,CAAC;IAEpB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,iBAAiB,GAAG,MAAM,GAAG,MAAM,CAAC;IAE7C;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB,oCAAoC;IACpC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,WAAW,CAAC,EAC1B,GAAG,EACH,MAAM,EACN,YAAY,EACZ,KAAK,EACL,QAAQ,EACR,MAAiB,EACjB,QAAe,EACf,MAAM,EACN,GAAG,KAAK,EACT,EAAE,gBAAgB,+BA0LlB;yBApMe,WAAW"}
1
+ {"version":3,"file":"BottomSheet.d.ts","sourceRoot":"","sources":["../../src/BottomSheet/BottomSheet.tsx"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA+BG;AAEH,OAAO,EAA2C,KAAK,SAAS,EAAC,MAAM,OAAO,CAAC;AAE/E,OAAO,KAAK,EAAC,SAAS,EAAC,MAAM,oBAAoB,CAAC;AA2BlD;;;;;;GAMG;AACH,QAAA,MAAM,cAAc;;;;CAIV,CAAC;AAEX,MAAM,MAAM,iBAAiB,GAAG,MAAM,OAAO,cAAc,CAAC;AAoL5D,MAAM,WAAW,gBAAiB,SAAQ,SAAS,CAAC,iBAAiB,CAAC;IACpE,wDAAwD;IACxD,GAAG,CAAC,EAAE,KAAK,CAAC,GAAG,CAAC,iBAAiB,CAAC,CAAC;IAEnC,8EAA8E;IAC9E,MAAM,EAAE,OAAO,CAAC;IAEhB;;;;OAIG;IACH,YAAY,EAAE,CAAC,MAAM,EAAE,OAAO,KAAK,IAAI,CAAC;IAExC;;;OAGG;IACH,KAAK,EAAE,MAAM,CAAC;IAEd,0EAA0E;IAC1E,QAAQ,EAAE,SAAS,CAAC;IAEpB;;;;;;;;;;;;;OAaG;IACH,MAAM,CAAC,EAAE,iBAAiB,GAAG,MAAM,GAAG,MAAM,CAAC;IAE7C;;;;;;;;;;;;OAYG;IACH,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB,oCAAoC;IACpC,aAAa,CAAC,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;;;;;;;;;;;;;;;GAqBG;AACH,wBAAgB,WAAW,CAAC,EAC1B,GAAG,EACH,MAAM,EACN,YAAY,EACZ,KAAK,EACL,QAAQ,EACR,MAAiB,EACjB,QAAe,EACf,MAAM,EACN,GAAG,KAAK,EACT,EAAE,gBAAgB,+BAoMlB;yBA9Me,WAAW"}
@@ -21,7 +21,8 @@
21
21
  * layer, focus trap, scrim, scroll lock, background inert); `false` uses
22
22
  * `show()` for a non-modal sheet with no scrim, leaving the page behind
23
23
  * interactive and scrollable (the transparent shell is pointer-events:none so
24
- * taps pass through).
24
+ * taps pass through). Both modes keep the native dialog presented but inert
25
+ * until the slide-out transition ends.
25
26
  *
26
27
  * The drag/snap/dismiss machinery lives in `useSheetGestures`; the offset
27
28
  * geometry lives in the pure, tested `snapOffsets` module. Both are internal
@@ -33,7 +34,7 @@
33
34
  * - /packages/lab/src/BottomSheet/index.ts (exports if types change)
34
35
  * - /apps/storybook/stories/BottomSheet.stories.tsx (examples and visual coverage)
35
36
  */
36
- import { useCallback, useEffect, useRef } from 'react';
37
+ import { useCallback, useEffect, useRef, useState } from 'react';
37
38
  import * as stylex from '@stylexjs/stylex';
38
39
  import '@astryxdesign/core/theme/tokens.stylex';
39
40
  import { colorVars, durationVars, easeVars, radiusVars, shadowVars, sizeVars, spacingVars } from '@astryxdesign/core/theme/tokens.stylex';
@@ -116,6 +117,10 @@ const styles = {
116
117
  k3aq6I: "x1weeur4",
117
118
  $$css: true
118
119
  },
120
+ sheetExiting: {
121
+ kfzvcC: "x47corl",
122
+ $$css: true
123
+ },
119
124
  budget: {
120
125
  kZKoxP: "x1tpcejd",
121
126
  $$css: true
@@ -159,6 +164,8 @@ export function BottomSheet({
159
164
  xstyle,
160
165
  ...props
161
166
  }) {
167
+ const [isPresented, setIsPresented] = useState(isOpen);
168
+ const isExiting = !isOpen && isPresented;
162
169
  const dialogRef = useRef(null);
163
170
  const triggerRef = useRef(null);
164
171
  const sheetNodeRef = useRef(null);
@@ -191,6 +198,7 @@ export function BottomSheet({
191
198
  return;
192
199
  }
193
200
  if (isOpen) {
201
+ setIsPresented(true);
194
202
  dialog.style.setProperty('--_sheet-scrim-opacity', '1');
195
203
  if (!dialog.open) {
196
204
  // Only remember/restore focus for modal sheets; a non-modal sheet must
@@ -211,6 +219,9 @@ export function BottomSheet({
211
219
  }
212
220
  }
213
221
  } else if (dialog.open) {
222
+ if (hasScrim) {
223
+ dialog.style.setProperty('--_sheet-scrim-opacity', '0');
224
+ }
214
225
  // Wait for the slide-out (sheetClosing, applied this render) before
215
226
  // close() releases the top layer. Timeout backstops a missing
216
227
  // transitionend (reduced motion, hidden tab).
@@ -226,6 +237,7 @@ export function BottomSheet({
226
237
  if (dialog.open) {
227
238
  dialog.close();
228
239
  }
240
+ setIsPresented(false);
229
241
  triggerRef.current?.focus();
230
242
  triggerRef.current = null;
231
243
  };
@@ -235,8 +247,8 @@ export function BottomSheet({
235
247
  }
236
248
  };
237
249
  sheet?.addEventListener('transitionend', onEnd);
238
- // Backstop above --duration-medium (410ms) so transitionend normally
239
- // fires first; this only covers environments that don't emit it.
250
+ // Backstop above --duration-medium so transitionend normally fires
251
+ // first; this only covers environments that don't emit it.
240
252
  const timer = setTimeout(finish, 450);
241
253
  return () => {
242
254
  clearTimeout(timer);
@@ -247,7 +259,7 @@ export function BottomSheet({
247
259
 
248
260
  // Only a modal sheet locks body scroll; a non-modal sheet leaves the page
249
261
  // scrollable behind it.
250
- useScrollLock(isOpen && hasScrim);
262
+ useScrollLock(isPresented && hasScrim);
251
263
 
252
264
  // Enforce an accessible name: label is required by types, but a JS caller
253
265
  // can still pass an empty string, leaving the sheet unnamed.
@@ -302,10 +314,12 @@ export function BottomSheet({
302
314
  7: {
303
315
  className: "xixxii4 x10a8y8t x1x1rfll x7ab17h x1ghz6dp x1717udv xjbqb8w x1rea2x4 x1a2a7pz x1lliihq xnixb3f xni466t xxiuuzi xc0dz0a xft5bk6 x15h3t91 x142juwg x1viac0w x47corl xfo81ep xh8yej3 x5yr21d"
304
316
  }
305
- }[!!isOpen << 2 | !!hasScrim << 1 | !!!hasScrim << 0],
317
+ }[!!(isOpen || isPresented) << 2 | !!hasScrim << 1 | !!!hasScrim << 0],
306
318
  ref: mergeRefs(ref, dialogRef),
307
319
  "aria-label": label,
308
- "aria-modal": hasScrim ? 'true' : undefined,
320
+ "aria-hidden": isExiting ? 'true' : undefined,
321
+ "aria-modal": hasScrim && isOpen ? 'true' : undefined,
322
+ inert: isExiting ? true : undefined,
309
323
  onCancel: handleCancel,
310
324
  onClick: handleClick,
311
325
  onKeyDown: handleKeyDown,
@@ -317,9 +331,9 @@ export function BottomSheet({
317
331
  children: /*#__PURE__*/_jsxs("div", {
318
332
  ref: mergeRefs(sheetRef, sheetNodeRef),
319
333
  tabIndex: -1,
320
- ...mergeProps(themeProps('bottom-sheet'), stylex.props(styles.sheet, isHug ? styles.hugHeight : styles.budget, !isOpen && styles.sheetClosing, xstyle), undefined, {
334
+ ...mergeProps(themeProps('bottom-sheet'), stylex.props(styles.sheet, isHug ? styles.hugHeight : styles.budget, !isOpen && styles.sheetClosing, isExiting && styles.sheetExiting, xstyle), undefined, {
321
335
  ['--_sheet-budget']: budget,
322
- ...contentProps.style
336
+ ...(isOpen ? contentProps.style : {})
323
337
  }),
324
338
  children: [/*#__PURE__*/_jsx("div", {
325
339
  ...{
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/lab",
3
- "version": "0.4.0-canary.e18b6db",
3
+ "version": "0.4.0-canary.faedff7",
4
4
  "description": "Experimental Astryx components — published to npm only under the @canary dist-tag for early testing; never released as a stable (latest) version.",
5
5
  "author": "Meta Open Source",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@stylexjs/stylex": ">=0.10.0",
48
- "@astryxdesign/core": "0.4.0-canary.e18b6db",
48
+ "@astryxdesign/core": "0.4.0-canary.faedff7",
49
49
  "react": ">=19.0.0",
50
50
  "react-dom": ">=19.0.0"
51
51
  },
@@ -55,7 +55,7 @@
55
55
  "d3-shape": "^3.2.0"
56
56
  },
57
57
  "devDependencies": {
58
- "@astryxdesign/cli": "0.4.0-canary.e18b6db",
58
+ "@astryxdesign/cli": "0.4.0-canary.faedff7",
59
59
  "@types/d3-array": "^3.2.2",
60
60
  "@types/d3-scale": "^4.0.9",
61
61
  "@types/d3-shape": "^3.1.8"
@@ -6,59 +6,97 @@ export const docs = {
6
6
  displayName: 'BottomSheet',
7
7
  group: 'BottomSheet',
8
8
  category: 'Overlay',
9
- keywords: ["bottom sheet","sheet","mobile","touch","drag","swipe","dismiss","grab handle","dialog","overlay","modal"],
9
+ keywords: [
10
+ 'bottom sheet',
11
+ 'sheet',
12
+ 'mobile',
13
+ 'touch',
14
+ 'drag',
15
+ 'swipe',
16
+ 'dismiss',
17
+ 'grab handle',
18
+ 'dialog',
19
+ 'overlay',
20
+ 'modal',
21
+ ],
10
22
  theming: {
11
- targets: [
12
- {className: 'astryx-bottom-sheet', visualProps: []},
13
- ],
23
+ targets: [{className: 'astryx-bottom-sheet', visualProps: []}],
14
24
  },
15
- description: 'A mobile touch sheet that rises from the bottom edge, with a grab handle, drag-to-resize snap points, and swipe-to-dismiss. Built on a native modal <dialog>.',
25
+ description:
26
+ 'A mobile touch sheet that rises from the bottom edge, with animated entrance and exit, a grab handle, drag-to-resize snap points, and swipe-to-dismiss. Built on a native modal <dialog>.',
16
27
  props: [
17
28
  {
18
29
  name: 'isOpen',
19
30
  type: 'boolean',
20
- description: 'Whether the sheet is open. Fully controlled; pair with onOpenChange.',
31
+ description:
32
+ 'Whether the sheet is open. Fully controlled; pair with onOpenChange.',
21
33
  required: true,
22
34
  },
23
35
  {
24
36
  name: 'onOpenChange',
25
37
  type: '(isOpen: boolean) => void',
26
- description: 'Called when the sheet opens or closes. The boolean is the requested next state (false on Escape, scrim click, or a swipe past the dismiss threshold). The caller owns the open state.',
38
+ description:
39
+ 'Called when the sheet opens or closes. The boolean is the requested next state (false on Escape, scrim click, or a swipe past the dismiss threshold). The caller owns the open state.',
27
40
  required: true,
28
41
  },
29
42
  {
30
43
  name: 'label',
31
44
  type: 'string',
32
- description: 'Accessible label for the sheet. Required; the sheet has no built-in heading to derive a name from.',
45
+ description:
46
+ 'Accessible label for the sheet. Required; the sheet has no built-in heading to derive a name from.',
33
47
  required: true,
34
48
  },
35
49
  {
36
50
  name: 'children',
37
51
  type: 'ReactNode',
38
- description: 'Sheet content, rendered below the grab handle in a scrollable area.',
52
+ description:
53
+ 'Sheet content, rendered below the grab handle in a scrollable area.',
39
54
  required: true,
40
55
  },
41
56
  {
42
57
  name: 'height',
43
58
  type: "'hug' | 'capped' | 'tall' | number | string",
44
- description: "How tall the sheet is. Named budgets: 'hug' fits its content up to 92% of the viewport, 'capped' is a scrolling mid-height panel (~62%), and 'tall' is a pinned near-full panel (~92%) for content that streams in. Or pass a number (px) / CSS length for a custom budget. The user can drag between snap points regardless. On shorter viewports the sheet fills the available height.",
59
+ description:
60
+ "How tall the sheet is. Named budgets: 'hug' fits its content up to 92% of the viewport, 'capped' is a scrolling mid-height panel (~62%), and 'tall' is a pinned near-full panel (~92%) for content that streams in. Or pass a number (px) / CSS length for a custom budget. The user can drag between snap points regardless. On shorter viewports the sheet fills the available height.",
45
61
  default: "'capped'",
46
62
  },
47
63
  {
48
64
  name: 'hasScrim',
49
65
  type: 'boolean',
50
- description: "Whether to render a scrim, the semi-transparent overlay that covers and blocks the background, behind the sheet. true (default) uses showModal(): top layer, focus trap, ::backdrop scrim, body scroll lock, and tap-scrim-to-dismiss, with the background inert. false still renders a viewport-anchored overlay above the page, not inline content, but uses show() with no scrim, leaving the page behind interactive and scrollable. Escape still closes while focus is inside, and drag/flick-to-dismiss still work.",
66
+ description:
67
+ 'Whether to render a scrim, the semi-transparent overlay that covers and blocks the background, behind the sheet. true (default) uses showModal(): top layer, focus trap, ::backdrop scrim, body scroll lock, and tap-scrim-to-dismiss, with the background inert. false still renders a viewport-anchored overlay above the page, not inline content, but uses show() with no scrim, leaving the page behind interactive and scrollable. Escape still closes while focus is inside, and drag/flick-to-dismiss still work.',
51
68
  default: 'true',
52
69
  },
53
70
  ],
54
71
  usage: {
55
- description: 'A mobile touch surface for filters, actions, and detail views that should rise from the bottom of the screen. Drag the grab handle to resize: a slow drag settles to the nearest snap point (a short peek, ~half, and ~full detent, filtered to those shorter than the sheet), a fast flick down dismisses, a fast flick up expands. Pulling down on the content when it is scrolled to the top also drags the sheet, giving a larger, more forgiving target. The scrim thins to a faint glance state (but never fully clears) as the sheet collapses onto its shortest "peek" detent; the sheet stays modal, so the background remains inert until dismissed; a residual dim keeps that legible. The sheet is modal: focus is trapped while open and restored to the opener on close, and Escape dismisses, so the swipe gesture always has a keyboard equivalent. Content padding clears the home indicator via env(safe-area-inset-bottom).',
72
+ description:
73
+ 'A mobile touch surface for filters, actions, and detail views that should rise from the bottom of the screen. Opening slides the sheet in; closing keeps its native dialog presented but inert until the slide-out and scrim fade complete. Drag the grab handle to resize: a slow drag settles to the nearest snap point (a short peek, ~half, and ~full detent, filtered to those shorter than the sheet), a fast flick down dismisses, a fast flick up expands. Pulling down on the content when it is scrolled to the top also drags the sheet, giving a larger, more forgiving target. The scrim thins to a faint glance state (but never fully clears) as the sheet collapses onto its shortest "peek" detent; the sheet stays modal, so the background remains inert until dismissed; a residual dim keeps that legible. The sheet is modal: focus is trapped while open and restored to the opener after its exit, and Escape dismisses, so the swipe gesture always has a keyboard equivalent. Content padding clears the home indicator via env(safe-area-inset-bottom).',
56
74
  bestPractices: [
57
- { guidance: true, description: 'Use for mobile-first surfaces (filters, share sheets, quick actions) where the content should rise from the bottom edge.' },
58
- { guidance: true, description: 'Keep the caller as the source of truth: derive isOpen from state and clear it in onOpenChange.' },
59
- { guidance: true, description: "Pick the starting height that fits the content: 'hug' for short bounded content, 'capped' for lists, 'tall' for streaming/resizing content; the user can then drag between snap points." },
60
- { guidance: true, description: "Use hasScrim={false} for a floating, no-scrim overlay that must coexist with a live page behind it (e.g. a panel over a map). It remains viewport-anchored rather than rendering inline. Keep the default for focused tasks where the background should be inert." },
61
- { guidance: false, description: 'Use a BottomSheet for desktop inspectors or master-detail; use Drawer (side="end", hasScrim={false}) instead.' },
75
+ {
76
+ guidance: true,
77
+ description:
78
+ 'Use for mobile-first surfaces (filters, share sheets, quick actions) where the content should rise from the bottom edge.',
79
+ },
80
+ {
81
+ guidance: true,
82
+ description:
83
+ 'Keep the caller as the source of truth: derive isOpen from state and clear it in onOpenChange.',
84
+ },
85
+ {
86
+ guidance: true,
87
+ description:
88
+ "Pick the starting height that fits the content: 'hug' for short bounded content, 'capped' for lists, 'tall' for streaming/resizing content; the user can then drag between snap points.",
89
+ },
90
+ {
91
+ guidance: true,
92
+ description:
93
+ 'Use hasScrim={false} for a floating, no-scrim overlay that must coexist with a live page behind it (e.g. a panel over a map). It remains viewport-anchored rather than rendering inline. Keep the default for focused tasks where the background should be inert.',
94
+ },
95
+ {
96
+ guidance: false,
97
+ description:
98
+ 'Use a BottomSheet for desktop inspectors or master-detail; use Drawer (side="end", hasScrim={false}) instead.',
99
+ },
62
100
  ],
63
101
  },
64
102
  examples: [
@@ -110,15 +148,36 @@ export const docs = {
110
148
 
111
149
  /** @type {import('@astryxdesign/cli/authoring').ComponentTranslationDoc} */
112
150
  export const docsDense = {
113
- description: 'mobile touch sheet rising from the bottom edge (native <dialog>): grab handle, drag-to-resize snap points, swipe-to-dismiss, named height scale, modal (default) or non-modal (hasScrim={false}) presentation',
151
+ description:
152
+ 'mobile touch sheet rising from the bottom edge (native <dialog>): grab handle, drag-to-resize snap points, swipe-to-dismiss, named height scale, modal (default) or non-modal (hasScrim={false}) presentation',
114
153
  usage: {
115
- description: 'Mobile surface for filters, actions, and detail views. Drag the handle to resize between snap points; flick down to dismiss, up to expand. Modal: focus trap + restore, and Escape dismisses so swipe has a keyboard equivalent. Content clears the home indicator via safe-area inset.',
154
+ description:
155
+ 'Mobile surface for filters, actions, and detail views. Drag the handle to resize between snap points; flick down to dismiss, up to expand. Modal: focus trap + restore, and Escape dismisses so swipe has a keyboard equivalent. Content clears the home indicator via safe-area inset.',
116
156
  bestPractices: [
117
- { guidance: true, description: 'Use for mobile-first bottom surfaces (filters, share sheets, quick actions).' },
118
- { guidance: true, description: 'Derive isOpen from state; clear it in onOpenChange.' },
119
- { guidance: true, description: "Pick a height that fits: 'hug' for short content, 'capped' for lists, 'tall' for streaming content." },
120
- { guidance: true, description: "hasScrim={false} for a floating no-scrim overlay over a live page; it is not inline. Keep the default for focused tasks (inert background)." },
121
- { guidance: false, description: 'Use for desktop inspectors or master-detail; use Drawer instead.' },
157
+ {
158
+ guidance: true,
159
+ description:
160
+ 'Use for mobile-first bottom surfaces (filters, share sheets, quick actions).',
161
+ },
162
+ {
163
+ guidance: true,
164
+ description: 'Derive isOpen from state; clear it in onOpenChange.',
165
+ },
166
+ {
167
+ guidance: true,
168
+ description:
169
+ "Pick a height that fits: 'hug' for short content, 'capped' for lists, 'tall' for streaming content.",
170
+ },
171
+ {
172
+ guidance: true,
173
+ description:
174
+ 'hasScrim={false} for a floating no-scrim overlay over a live page; it is not inline. Keep the default for focused tasks (inert background).',
175
+ },
176
+ {
177
+ guidance: false,
178
+ description:
179
+ 'Use for desktop inspectors or master-detail; use Drawer instead.',
180
+ },
122
181
  ],
123
182
  },
124
183
  };
@@ -59,6 +59,25 @@ function getSheet(): HTMLElement {
59
59
  return sheet;
60
60
  }
61
61
 
62
+ function finishSheetExit() {
63
+ fireEvent.transitionEnd(getSheet(), {propertyName: 'transform'});
64
+ }
65
+
66
+ function ExitHarness({hasScrim}: {hasScrim?: boolean}) {
67
+ const [isOpen, setIsOpen] = useState(true);
68
+ return (
69
+ <BottomSheet
70
+ isOpen={isOpen}
71
+ onOpenChange={setIsOpen}
72
+ label="Filters"
73
+ hasScrim={hasScrim}>
74
+ <button type="button" onClick={() => setIsOpen(false)}>
75
+ Close sheet
76
+ </button>
77
+ </BottomSheet>
78
+ );
79
+ }
80
+
62
81
  // The grab handle is the panel's first child (decorative, aria-hidden).
63
82
  function getHandle(): HTMLElement {
64
83
  const handle = getSheet().querySelector<HTMLElement>('[aria-hidden="true"]');
@@ -134,6 +153,25 @@ describe('BottomSheet', () => {
134
153
  expect(onOpenChange).toHaveBeenCalledWith(false);
135
154
  });
136
155
 
156
+ it('keeps a standalone modal sheet presented through its exit animation', () => {
157
+ render(<ExitHarness />);
158
+ const dialog = screen.getByRole('dialog', {name: 'Filters'});
159
+
160
+ fireEvent.click(screen.getByRole('button', {name: 'Close sheet'}));
161
+
162
+ expect(dialog).toHaveAttribute('open');
163
+ expect(dialog).toHaveAttribute('inert');
164
+ expect(dialog).toHaveAttribute('aria-hidden', 'true');
165
+ expect(dialog).not.toHaveAttribute('aria-modal');
166
+ expect(dialog).toHaveStyle({'--_sheet-scrim-opacity': '0'});
167
+ expect(document.body.style.position).toBe('fixed');
168
+
169
+ finishSheetExit();
170
+
171
+ expect(dialog).not.toHaveAttribute('open');
172
+ expect(document.body.style.position).not.toBe('fixed');
173
+ });
174
+
137
175
  it('does not dismiss when the sheet surface itself is clicked', () => {
138
176
  const onOpenChange = vi.fn();
139
177
  render(
@@ -238,6 +276,21 @@ describe('BottomSheet', () => {
238
276
  screen.getByRole('textbox', {name: 'Search'}),
239
277
  );
240
278
  });
279
+
280
+ it('keeps a standalone non-modal sheet visible until its exit ends', () => {
281
+ render(<ExitHarness hasScrim={false} />);
282
+ const dialog = screen.getByRole('dialog', {name: 'Filters'});
283
+
284
+ fireEvent.click(screen.getByRole('button', {name: 'Close sheet'}));
285
+
286
+ expect(dialog).toHaveAttribute('open');
287
+ expect(dialog).toHaveAttribute('inert');
288
+ expect(document.body.style.position).not.toBe('fixed');
289
+
290
+ finishSheetExit();
291
+
292
+ expect(dialog).not.toHaveAttribute('open');
293
+ });
241
294
  });
242
295
 
243
296
  describe('grab handle', () => {
@@ -21,7 +21,8 @@
21
21
  * layer, focus trap, scrim, scroll lock, background inert); `false` uses
22
22
  * `show()` for a non-modal sheet with no scrim, leaving the page behind
23
23
  * interactive and scrollable (the transparent shell is pointer-events:none so
24
- * taps pass through).
24
+ * taps pass through). Both modes keep the native dialog presented but inert
25
+ * until the slide-out transition ends.
25
26
  *
26
27
  * The drag/snap/dismiss machinery lives in `useSheetGestures`; the offset
27
28
  * geometry lives in the pure, tested `snapOffsets` module. Both are internal
@@ -34,7 +35,7 @@
34
35
  * - /apps/storybook/stories/BottomSheet.stories.tsx (examples and visual coverage)
35
36
  */
36
37
 
37
- import {useCallback, useEffect, useRef, type ReactNode} from 'react';
38
+ import {useCallback, useEffect, useRef, useState, type ReactNode} from 'react';
38
39
  import * as stylex from '@stylexjs/stylex';
39
40
  import type {BaseProps} from '@astryxdesign/core';
40
41
  import {
@@ -212,6 +213,9 @@ const styles = stylex.create({
212
213
  sheetClosing: {
213
214
  transform: 'translateY(100%)',
214
215
  },
216
+ sheetExiting: {
217
+ pointerEvents: 'none',
218
+ },
215
219
  handleBar: {
216
220
  flexShrink: 0,
217
221
  display: 'flex',
@@ -344,6 +348,8 @@ export function BottomSheet({
344
348
  xstyle,
345
349
  ...props
346
350
  }: BottomSheetProps) {
351
+ const [isPresented, setIsPresented] = useState(isOpen);
352
+ const isExiting = !isOpen && isPresented;
347
353
  const dialogRef = useRef<HTMLDialogElement | null>(null);
348
354
  const triggerRef = useRef<HTMLElement | null>(null);
349
355
  const sheetNodeRef = useRef<HTMLDivElement | null>(null);
@@ -375,6 +381,7 @@ export function BottomSheet({
375
381
  return;
376
382
  }
377
383
  if (isOpen) {
384
+ setIsPresented(true);
378
385
  dialog.style.setProperty('--_sheet-scrim-opacity', '1');
379
386
  if (!dialog.open) {
380
387
  // Only remember/restore focus for modal sheets; a non-modal sheet must
@@ -395,6 +402,9 @@ export function BottomSheet({
395
402
  }
396
403
  }
397
404
  } else if (dialog.open) {
405
+ if (hasScrim) {
406
+ dialog.style.setProperty('--_sheet-scrim-opacity', '0');
407
+ }
398
408
  // Wait for the slide-out (sheetClosing, applied this render) before
399
409
  // close() releases the top layer. Timeout backstops a missing
400
410
  // transitionend (reduced motion, hidden tab).
@@ -410,6 +420,7 @@ export function BottomSheet({
410
420
  if (dialog.open) {
411
421
  dialog.close();
412
422
  }
423
+ setIsPresented(false);
413
424
  triggerRef.current?.focus();
414
425
  triggerRef.current = null;
415
426
  };
@@ -419,8 +430,8 @@ export function BottomSheet({
419
430
  }
420
431
  };
421
432
  sheet?.addEventListener('transitionend', onEnd);
422
- // Backstop above --duration-medium (410ms) so transitionend normally
423
- // fires first; this only covers environments that don't emit it.
433
+ // Backstop above --duration-medium so transitionend normally fires
434
+ // first; this only covers environments that don't emit it.
424
435
  const timer = setTimeout(finish, 450);
425
436
  return () => {
426
437
  clearTimeout(timer);
@@ -431,7 +442,7 @@ export function BottomSheet({
431
442
 
432
443
  // Only a modal sheet locks body scroll; a non-modal sheet leaves the page
433
444
  // scrollable behind it.
434
- useScrollLock(isOpen && hasScrim);
445
+ useScrollLock(isPresented && hasScrim);
435
446
 
436
447
  // Enforce an accessible name: label is required by types, but a JS caller
437
448
  // can still pass an empty string, leaving the sheet unnamed.
@@ -485,7 +496,7 @@ export function BottomSheet({
485
496
  <dialog
486
497
  {...stylex.props(
487
498
  styles.dialog,
488
- isOpen && styles.dialogOpen,
499
+ (isOpen || isPresented) && styles.dialogOpen,
489
500
  // Modal: paint the ::backdrop scrim (top layer). Non-modal: no scrim,
490
501
  // pass taps through to the page, and sit above content via z-index.
491
502
  hasScrim && styles.scrim,
@@ -493,7 +504,9 @@ export function BottomSheet({
493
504
  )}
494
505
  ref={mergeRefs(ref, dialogRef)}
495
506
  aria-label={label}
496
- aria-modal={hasScrim ? 'true' : undefined}
507
+ aria-hidden={isExiting ? 'true' : undefined}
508
+ aria-modal={hasScrim && isOpen ? 'true' : undefined}
509
+ inert={isExiting ? true : undefined}
497
510
  onCancel={handleCancel}
498
511
  onClick={handleClick}
499
512
  onKeyDown={handleKeyDown}
@@ -508,12 +521,13 @@ export function BottomSheet({
508
521
  styles.sheet,
509
522
  isHug ? styles.hugHeight : styles.budget,
510
523
  !isOpen && styles.sheetClosing,
524
+ isExiting && styles.sheetExiting,
511
525
  xstyle,
512
526
  ),
513
527
  undefined,
514
528
  {
515
529
  ['--_sheet-budget' as string]: budget,
516
- ...contentProps.style,
530
+ ...(isOpen ? contentProps.style : {}),
517
531
  },
518
532
  )}>
519
533
  <div