@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.
- package/dist/BottomSheet/BottomSheet.d.ts +41 -35
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +222 -75
- package/dist/BottomSheet/snapOffsets.d.ts +54 -0
- package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
- package/dist/BottomSheet/snapOffsets.js +101 -0
- package/dist/BottomSheet/useSheetGestures.d.ts +55 -12
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
- package/dist/BottomSheet/useSheetGestures.js +383 -40
- package/dist/Chart/index.d.ts +1 -1
- package/dist/Chart/index.d.ts.map +1 -1
- package/dist/Chart/index.js +1 -1
- package/dist/Stepper/Step.d.ts +1 -1
- package/dist/Stepper/Step.js +1 -1
- package/dist/Stepper/Stepper.d.ts +1 -1
- package/dist/Stepper/Stepper.js +1 -1
- package/dist/index.js +1 -1
- package/dist/lab.css +15 -8
- package/package.json +3 -2
- package/src/BottomSheet/BottomSheet.doc.mjs +13 -13
- package/src/BottomSheet/BottomSheet.test.tsx +81 -4
- package/src/BottomSheet/BottomSheet.tsx +333 -97
- package/src/BottomSheet/snapOffsets.test.ts +146 -0
- package/src/BottomSheet/snapOffsets.ts +126 -0
- package/src/BottomSheet/useSheetGestures.doc.mjs +7 -6
- package/src/BottomSheet/useSheetGestures.test.ts +315 -65
- package/src/BottomSheet/useSheetGestures.ts +515 -55
- package/src/Chart/ChartArea.doc.mjs +1 -1
- package/src/Chart/ChartBar.doc.mjs +1 -1
- package/src/Chart/ChartCandlestick.doc.mjs +1 -1
- package/src/Chart/ChartDot.doc.mjs +1 -1
- package/src/Chart/ChartDotGL.doc.mjs +1 -1
- package/src/Chart/ChartDotGLInteractive.doc.mjs +1 -1
- package/src/Chart/ChartHeatmapGL.doc.mjs +1 -1
- package/src/Chart/ChartLine.doc.mjs +1 -1
- package/src/Chart/ChartStreamGL.doc.mjs +1 -1
- package/src/Chart/index.ts +0 -1
- package/src/Chat/ChatEmojiPicker.doc.mjs +1 -1
- package/src/Chat/ChatReactionBar.doc.mjs +1 -1
- package/src/Chat/ChatTypingIndicator.doc.mjs +1 -1
- package/src/Chat/ChatUnreadDivider.doc.mjs +1 -1
- package/src/ChatReasoning/ChatReasoning.doc.mjs +1 -1
- package/src/CircularProgress/CircularProgress.doc.mjs +3 -3
- package/src/CodeEditor/CodeEditor.doc.mjs +1 -1
- package/src/Drawer/Drawer.doc.mjs +3 -3
- package/src/InfoTip/InfoTip.doc.mjs +3 -3
- package/src/LogStream/LogStream.doc.mjs +1 -1
- package/src/Radial/RadialChart.doc.mjs +1 -1
- package/src/RichTextEditor/RichTextEditor.doc.mjs +1 -1
- package/src/SVGIcon/SVGIcon.doc.mjs +1 -1
- package/src/Sankey/SankeyChart.doc.mjs +1 -1
- package/src/Schedule/Schedule.doc.mjs +1 -1
- package/src/Stat/Stat.doc.mjs +3 -3
- package/src/Stepper/Step.doc.mjs +1 -1
- package/src/Stepper/Step.tsx +1 -1
- package/src/Stepper/Stepper.doc.mjs +3 -3
- package/src/Stepper/Stepper.tsx +1 -1
- package/src/ThreeD/3DBar.doc.mjs +1 -1
- package/src/ThreeD/3DChart.doc.mjs +1 -1
- package/src/ThreeD/3DScatter.doc.mjs +1 -1
- package/src/ThreeD/3DScatterGL.doc.mjs +1 -1
- package/src/ThreeD/3DSurface.doc.mjs +1 -1
- 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,
|
|
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
|
|
12
|
-
* and swipe-to-dismiss.
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
20
|
-
*
|
|
21
|
-
*
|
|
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 {
|
|
39
|
-
import {
|
|
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
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
tall: '
|
|
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
|
-
//
|
|
57
|
-
//
|
|
58
|
-
//
|
|
59
|
-
const
|
|
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
|
-
//
|
|
63
|
-
//
|
|
64
|
-
//
|
|
65
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
|
|
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:
|
|
76
|
-
marginInline: 'auto',
|
|
148
|
+
maxWidth: 640,
|
|
77
149
|
backgroundColor: colorVars['--color-background-surface'],
|
|
78
|
-
borderStartStartRadius: radiusVars['--radius-
|
|
79
|
-
borderStartEndRadius: radiusVars['--radius-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
-
|
|
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
|
-
|
|
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:
|
|
104
|
-
height:
|
|
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
|
-
|
|
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
|
-
//
|
|
115
|
-
//
|
|
116
|
-
|
|
117
|
-
|
|
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.
|
|
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
|
-
*
|
|
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
|
-
* - `'
|
|
148
|
-
* - `'
|
|
149
|
-
* - `'tall'` —
|
|
150
|
-
*
|
|
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
|
-
*
|
|
153
|
-
* height.
|
|
154
|
-
*
|
|
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.
|
|
165
|
-
* (
|
|
166
|
-
*
|
|
167
|
-
*
|
|
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 = '
|
|
288
|
+
height = 'capped',
|
|
186
289
|
xstyle,
|
|
187
290
|
...props
|
|
188
291
|
}: BottomSheetProps) {
|
|
189
|
-
const
|
|
190
|
-
const
|
|
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
|
-
<
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
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
|
-
|
|
215
|
-
{
|
|
216
|
-
{...
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
</
|
|
458
|
+
</dialog>
|
|
223
459
|
);
|
|
224
460
|
}
|
|
225
461
|
|