@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.
- package/dist/BottomSheet/BottomSheet.d.ts +2 -1
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +23 -9
- package/package.json +3 -3
- package/src/BottomSheet/BottomSheet.doc.mjs +83 -24
- package/src/BottomSheet/BottomSheet.test.tsx +53 -0
- package/src/BottomSheet/BottomSheet.tsx +22 -8
|
@@ -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
|
|
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
|
|
239
|
-
//
|
|
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(
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
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: [
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
{
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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:
|
|
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:
|
|
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
|
-
{
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
|
423
|
-
//
|
|
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(
|
|
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-
|
|
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
|