@astryxdesign/lab 0.1.9-canary.d2d84f8 → 0.1.9-canary.e00a241
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 +95 -0
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
- package/dist/BottomSheet/BottomSheet.js +150 -0
- package/dist/BottomSheet/index.d.ts +9 -0
- package/dist/BottomSheet/index.d.ts.map +1 -0
- package/dist/BottomSheet/index.js +10 -0
- package/dist/BottomSheet/useSheetGestures.d.ts +69 -0
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
- package/dist/BottomSheet/useSheetGestures.js +163 -0
- package/dist/RichTextEditor/RichTextEditor.d.ts +67 -6
- package/dist/RichTextEditor/RichTextEditor.d.ts.map +1 -1
- package/dist/RichTextEditor/RichTextEditor.js +165 -12
- package/dist/RichTextEditor/editorTheme.d.ts.map +1 -1
- package/dist/RichTextEditor/editorTheme.js +33 -7
- package/dist/RichTextEditor/index.d.ts +1 -1
- package/dist/RichTextEditor/index.d.ts.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -0
- package/dist/lab.css +23 -0
- package/package.json +2 -2
- package/src/BottomSheet/BottomSheet.doc.mjs +105 -0
- package/src/BottomSheet/BottomSheet.test.tsx +233 -0
- package/src/BottomSheet/BottomSheet.tsx +226 -0
- package/src/BottomSheet/index.ts +11 -0
- package/src/BottomSheet/useSheetGestures.doc.mjs +31 -0
- package/src/BottomSheet/useSheetGestures.test.ts +160 -0
- package/src/BottomSheet/useSheetGestures.ts +226 -0
- package/src/ChatReasoning/ChatReasoning.doc.mjs +49 -2
- package/src/CodeEditor/CodeEditor.doc.mjs +67 -2
- package/src/LogStream/LogStream.doc.mjs +53 -2
- package/src/RichTextEditor/RichTextEditor.doc.mjs +19 -1
- package/src/RichTextEditor/RichTextEditor.test.tsx +441 -11
- package/src/RichTextEditor/RichTextEditor.tsx +289 -31
- package/src/RichTextEditor/editorTheme.ts +63 -3
- package/src/RichTextEditor/index.ts +2 -0
- package/src/Schedule/Schedule.doc.mjs +143 -3
- package/src/Stepper/Stepper.doc.mjs +6 -0
- package/src/__tests__/labApiContractDrift.test.tsx +57 -0
- package/src/index.ts +5 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file BottomSheet.test.tsx
|
|
5
|
+
* @input Uses vitest, @testing-library/react, BottomSheet component
|
|
6
|
+
* @output Unit tests for BottomSheet component behavior
|
|
7
|
+
* @position Lab testing; validates BottomSheet.tsx implementation
|
|
8
|
+
*
|
|
9
|
+
* SYNC: When BottomSheet.tsx changes, update tests to match new behavior
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
13
|
+
import {render, screen, fireEvent, act} from '@testing-library/react';
|
|
14
|
+
import {useState} from 'react';
|
|
15
|
+
import {BottomSheet} from './BottomSheet';
|
|
16
|
+
|
|
17
|
+
// jsdom doesn't implement <dialog> open/close or pointer capture; stub them.
|
|
18
|
+
beforeEach(() => {
|
|
19
|
+
HTMLDialogElement.prototype.showModal = vi.fn(function (
|
|
20
|
+
this: HTMLDialogElement,
|
|
21
|
+
) {
|
|
22
|
+
this.setAttribute('open', '');
|
|
23
|
+
});
|
|
24
|
+
HTMLDialogElement.prototype.show = vi.fn(function (this: HTMLDialogElement) {
|
|
25
|
+
this.setAttribute('open', '');
|
|
26
|
+
});
|
|
27
|
+
HTMLDialogElement.prototype.close = vi.fn(function (this: HTMLDialogElement) {
|
|
28
|
+
this.removeAttribute('open');
|
|
29
|
+
});
|
|
30
|
+
if (!Element.prototype.setPointerCapture) {
|
|
31
|
+
Element.prototype.setPointerCapture = vi.fn();
|
|
32
|
+
Element.prototype.releasePointerCapture = vi.fn();
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
vi.stubGlobal(
|
|
36
|
+
'matchMedia',
|
|
37
|
+
vi.fn().mockReturnValue({
|
|
38
|
+
matches: false,
|
|
39
|
+
media: '',
|
|
40
|
+
onchange: null,
|
|
41
|
+
addEventListener: vi.fn(),
|
|
42
|
+
removeEventListener: vi.fn(),
|
|
43
|
+
addListener: vi.fn(),
|
|
44
|
+
removeListener: vi.fn(),
|
|
45
|
+
dispatchEvent: vi.fn(),
|
|
46
|
+
}),
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
afterEach(() => {
|
|
51
|
+
vi.unstubAllGlobals();
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
function getHandle(): HTMLElement {
|
|
55
|
+
const handle = document.querySelector<HTMLElement>(
|
|
56
|
+
'[data-astryx-sheet-handle]',
|
|
57
|
+
);
|
|
58
|
+
if (!handle) {
|
|
59
|
+
throw new Error('grab handle not found');
|
|
60
|
+
}
|
|
61
|
+
return handle;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Drive a pointer drag on the grab handle. jsdom PointerEvents don't carry
|
|
65
|
+
// clientY, so dispatch plain events with the coords the handlers read.
|
|
66
|
+
function drag(handle: HTMLElement, points: Array<{y: number}>) {
|
|
67
|
+
const [down, ...rest] = points;
|
|
68
|
+
fireEvent.pointerDown(handle, {pointerId: 1, clientY: down.y});
|
|
69
|
+
for (const p of rest) {
|
|
70
|
+
fireEvent.pointerMove(handle, {pointerId: 1, clientY: p.y});
|
|
71
|
+
}
|
|
72
|
+
const last = points[points.length - 1];
|
|
73
|
+
fireEvent.pointerUp(handle, {pointerId: 1, clientY: last.y});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('BottomSheet', () => {
|
|
77
|
+
it('renders children when open and applies the accessible label', () => {
|
|
78
|
+
render(
|
|
79
|
+
<BottomSheet isOpen onOpenChange={() => {}} label="Filters">
|
|
80
|
+
Sheet content
|
|
81
|
+
</BottomSheet>,
|
|
82
|
+
);
|
|
83
|
+
const dialog = screen.getByRole('dialog');
|
|
84
|
+
expect(dialog).toBeInTheDocument();
|
|
85
|
+
expect(dialog).toHaveAccessibleName('Filters');
|
|
86
|
+
expect(screen.getByText('Sheet content')).toBeInTheDocument();
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('does not show when isOpen is false', () => {
|
|
90
|
+
render(
|
|
91
|
+
<BottomSheet isOpen={false} onOpenChange={() => {}} label="Filters">
|
|
92
|
+
Hidden
|
|
93
|
+
</BottomSheet>,
|
|
94
|
+
);
|
|
95
|
+
expect(HTMLDialogElement.prototype.showModal).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('opens modally (showModal + aria-modal)', () => {
|
|
99
|
+
render(
|
|
100
|
+
<BottomSheet isOpen onOpenChange={() => {}} label="Filters">
|
|
101
|
+
Content
|
|
102
|
+
</BottomSheet>,
|
|
103
|
+
);
|
|
104
|
+
expect(HTMLDialogElement.prototype.showModal).toHaveBeenCalled();
|
|
105
|
+
expect(screen.getByRole('dialog')).toHaveAttribute('aria-modal', 'true');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('requests close on Escape', () => {
|
|
109
|
+
const onOpenChange = vi.fn();
|
|
110
|
+
render(
|
|
111
|
+
<BottomSheet isOpen onOpenChange={onOpenChange} label="Filters">
|
|
112
|
+
Content
|
|
113
|
+
</BottomSheet>,
|
|
114
|
+
);
|
|
115
|
+
fireEvent.keyDown(screen.getByRole('dialog'), {key: 'Escape'});
|
|
116
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('requests close when the scrim (dialog element itself) is clicked', () => {
|
|
120
|
+
const onOpenChange = vi.fn();
|
|
121
|
+
render(
|
|
122
|
+
<BottomSheet isOpen onOpenChange={onOpenChange} label="Filters">
|
|
123
|
+
Content
|
|
124
|
+
</BottomSheet>,
|
|
125
|
+
);
|
|
126
|
+
fireEvent.click(screen.getByRole('dialog'));
|
|
127
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('grab handle', () => {
|
|
131
|
+
it('renders a decorative handle hidden from assistive tech', () => {
|
|
132
|
+
render(
|
|
133
|
+
<BottomSheet isOpen onOpenChange={() => {}} label="Filters">
|
|
134
|
+
Content
|
|
135
|
+
</BottomSheet>,
|
|
136
|
+
);
|
|
137
|
+
const handle = getHandle();
|
|
138
|
+
expect(handle).toHaveAttribute('aria-hidden', 'true');
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('swipe to dismiss', () => {
|
|
143
|
+
it('requests close when dragged past the dismiss threshold', () => {
|
|
144
|
+
const onOpenChange = vi.fn();
|
|
145
|
+
render(
|
|
146
|
+
<BottomSheet isOpen onOpenChange={onOpenChange} label="Filters">
|
|
147
|
+
Content
|
|
148
|
+
</BottomSheet>,
|
|
149
|
+
);
|
|
150
|
+
// No measured height in jsdom (0) -> any downward drag dismisses via
|
|
151
|
+
// the distance branch (offset > 0.25) once released downward.
|
|
152
|
+
drag(getHandle(), [{y: 0}, {y: 40}, {y: 120}]);
|
|
153
|
+
expect(onOpenChange).toHaveBeenCalledWith(false);
|
|
154
|
+
});
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
describe('height', () => {
|
|
158
|
+
it('renders for each named height without error', () => {
|
|
159
|
+
for (const height of ['short', 'medium', 'tall', 'auto'] as const) {
|
|
160
|
+
const {unmount} = render(
|
|
161
|
+
<BottomSheet
|
|
162
|
+
isOpen
|
|
163
|
+
onOpenChange={() => {}}
|
|
164
|
+
label="Filters"
|
|
165
|
+
height={height}>
|
|
166
|
+
Content
|
|
167
|
+
</BottomSheet>,
|
|
168
|
+
);
|
|
169
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
|
170
|
+
unmount();
|
|
171
|
+
}
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
describe('focus restore', () => {
|
|
176
|
+
function Harness() {
|
|
177
|
+
const [isOpen, setIsOpen] = useState(false);
|
|
178
|
+
return (
|
|
179
|
+
<>
|
|
180
|
+
<button type="button" onClick={() => setIsOpen(true)}>
|
|
181
|
+
Open sheet
|
|
182
|
+
</button>
|
|
183
|
+
<BottomSheet isOpen={isOpen} onOpenChange={setIsOpen} label="Filters">
|
|
184
|
+
<button type="button" onClick={() => setIsOpen(false)}>
|
|
185
|
+
Done
|
|
186
|
+
</button>
|
|
187
|
+
</BottomSheet>
|
|
188
|
+
</>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
it('restores focus to the opener after close', async () => {
|
|
193
|
+
vi.useFakeTimers();
|
|
194
|
+
try {
|
|
195
|
+
render(<Harness />);
|
|
196
|
+
const opener = screen.getByRole('button', {name: 'Open sheet'});
|
|
197
|
+
opener.focus();
|
|
198
|
+
fireEvent.click(opener);
|
|
199
|
+
fireEvent.click(screen.getByRole('button', {name: 'Done'}));
|
|
200
|
+
await act(async () => {
|
|
201
|
+
vi.runAllTimers();
|
|
202
|
+
});
|
|
203
|
+
expect(document.activeElement).toBe(opener);
|
|
204
|
+
} finally {
|
|
205
|
+
vi.useRealTimers();
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe('reduced motion', () => {
|
|
211
|
+
it('opens without throwing when prefers-reduced-motion is set', () => {
|
|
212
|
+
vi.stubGlobal(
|
|
213
|
+
'matchMedia',
|
|
214
|
+
vi.fn().mockReturnValue({
|
|
215
|
+
matches: true,
|
|
216
|
+
media: '(prefers-reduced-motion: reduce)',
|
|
217
|
+
onchange: null,
|
|
218
|
+
addEventListener: vi.fn(),
|
|
219
|
+
removeEventListener: vi.fn(),
|
|
220
|
+
addListener: vi.fn(),
|
|
221
|
+
removeListener: vi.fn(),
|
|
222
|
+
dispatchEvent: vi.fn(),
|
|
223
|
+
}),
|
|
224
|
+
);
|
|
225
|
+
render(
|
|
226
|
+
<BottomSheet isOpen onOpenChange={() => {}} label="Filters">
|
|
227
|
+
Content
|
|
228
|
+
</BottomSheet>,
|
|
229
|
+
);
|
|
230
|
+
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
});
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
'use client';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @file BottomSheet.tsx
|
|
7
|
+
* @input Uses React, StyleX, theme tokens, Drawer, useSheetGestures, themeProps
|
|
8
|
+
* @output Exports BottomSheet component and BottomSheetProps
|
|
9
|
+
* @position Lab implementation; consumed by index.ts, tested by BottomSheet.test.tsx, demonstrated in Storybook
|
|
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.
|
|
18
|
+
*
|
|
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.
|
|
22
|
+
*
|
|
23
|
+
* SYNC: When modified, update these files to stay in sync:
|
|
24
|
+
* - /packages/lab/src/BottomSheet/BottomSheet.doc.mjs (props table, features, usage)
|
|
25
|
+
* - /packages/lab/src/BottomSheet/BottomSheet.test.tsx (tests for new/changed behavior)
|
|
26
|
+
* - /packages/lab/src/BottomSheet/index.ts (exports if types change)
|
|
27
|
+
* - /apps/storybook/stories/BottomSheet.stories.tsx (examples and visual coverage)
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import {type ReactNode} from 'react';
|
|
31
|
+
import * as stylex from '@stylexjs/stylex';
|
|
32
|
+
import type {BaseProps} from '@astryxdesign/core';
|
|
33
|
+
import {
|
|
34
|
+
colorVars,
|
|
35
|
+
radiusVars,
|
|
36
|
+
spacingVars,
|
|
37
|
+
} from '@astryxdesign/core/theme/tokens.stylex';
|
|
38
|
+
import {mergeProps, themeProps} from '@astryxdesign/core/utils';
|
|
39
|
+
import {Drawer} from '../Drawer';
|
|
40
|
+
import {useSheetGestures} from './useSheetGestures';
|
|
41
|
+
|
|
42
|
+
/**
|
|
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).
|
|
46
|
+
*/
|
|
47
|
+
const HEIGHT_BUDGETS = {
|
|
48
|
+
short: '240px',
|
|
49
|
+
medium: '50dvh',
|
|
50
|
+
tall: '90dvh',
|
|
51
|
+
auto: '90dvh',
|
|
52
|
+
} as const;
|
|
53
|
+
|
|
54
|
+
export type BottomSheetHeight = keyof typeof HEIGHT_BUDGETS;
|
|
55
|
+
|
|
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;
|
|
60
|
+
|
|
61
|
+
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.
|
|
69
|
+
sheet: {
|
|
70
|
+
display: 'flex',
|
|
71
|
+
flexDirection: 'column',
|
|
72
|
+
minHeight: 0,
|
|
73
|
+
height: '100%',
|
|
74
|
+
width: '100%',
|
|
75
|
+
maxWidth: MAX_SHEET_WIDTH,
|
|
76
|
+
marginInline: 'auto',
|
|
77
|
+
backgroundColor: colorVars['--color-background-surface'],
|
|
78
|
+
borderStartStartRadius: radiusVars['--radius-page'],
|
|
79
|
+
borderStartEndRadius: radiusVars['--radius-page'],
|
|
80
|
+
overflow: 'hidden',
|
|
81
|
+
paddingBlockEnd: `env(safe-area-inset-bottom, 0px)`,
|
|
82
|
+
willChange: 'transform',
|
|
83
|
+
},
|
|
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,
|
|
92
|
+
},
|
|
93
|
+
handleBar: {
|
|
94
|
+
flexShrink: 0,
|
|
95
|
+
display: 'flex',
|
|
96
|
+
alignItems: 'center',
|
|
97
|
+
justifyContent: 'center',
|
|
98
|
+
paddingBlock: spacingVars['--spacing-2'],
|
|
99
|
+
touchAction: 'none',
|
|
100
|
+
cursor: 'grab',
|
|
101
|
+
},
|
|
102
|
+
handlePill: {
|
|
103
|
+
width: 36,
|
|
104
|
+
height: 4,
|
|
105
|
+
borderRadius: radiusVars['--radius-full'],
|
|
106
|
+
backgroundColor: colorVars['--color-border'],
|
|
107
|
+
},
|
|
108
|
+
body: {
|
|
109
|
+
flexGrow: 1,
|
|
110
|
+
minHeight: 0,
|
|
111
|
+
overflowY: 'auto',
|
|
112
|
+
overscrollBehavior: 'contain',
|
|
113
|
+
},
|
|
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: {
|
|
118
|
+
height: 'fit-content',
|
|
119
|
+
maxHeight: HEIGHT_BUDGETS.auto,
|
|
120
|
+
},
|
|
121
|
+
});
|
|
122
|
+
export interface BottomSheetProps extends BaseProps<HTMLDialogElement> {
|
|
123
|
+
/** Ref forwarded to the underlying <dialog> element. */
|
|
124
|
+
ref?: React.Ref<HTMLDialogElement>;
|
|
125
|
+
|
|
126
|
+
/** Whether the sheet is open. Fully controlled — pair with `onOpenChange`. */
|
|
127
|
+
isOpen: boolean;
|
|
128
|
+
|
|
129
|
+
/**
|
|
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
|
|
132
|
+
* threshold). The caller owns the open state.
|
|
133
|
+
*/
|
|
134
|
+
onOpenChange: (isOpen: boolean) => void;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Accessible label for the sheet (required — the sheet has no built-in
|
|
138
|
+
* heading to derive a name from).
|
|
139
|
+
*/
|
|
140
|
+
label: string;
|
|
141
|
+
|
|
142
|
+
/** Sheet content, rendered below the grab handle in a scrollable area. */
|
|
143
|
+
children: ReactNode;
|
|
144
|
+
|
|
145
|
+
/**
|
|
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
|
|
151
|
+
*
|
|
152
|
+
* On viewports shorter than the budget the sheet fills the available
|
|
153
|
+
* height.
|
|
154
|
+
* @default 'medium'
|
|
155
|
+
*/
|
|
156
|
+
height?: BottomSheetHeight;
|
|
157
|
+
|
|
158
|
+
/** Test ID for the root element. */
|
|
159
|
+
'data-testid'?: string;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
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.
|
|
168
|
+
*
|
|
169
|
+
* @example
|
|
170
|
+
* ```
|
|
171
|
+
* const [isOpen, setIsOpen] = useState(false);
|
|
172
|
+
* <BottomSheet
|
|
173
|
+
* isOpen={isOpen}
|
|
174
|
+
* onOpenChange={setIsOpen}
|
|
175
|
+
* label="Filters">
|
|
176
|
+
* <FilterControls />
|
|
177
|
+
* </BottomSheet>
|
|
178
|
+
* ```
|
|
179
|
+
*/
|
|
180
|
+
export function BottomSheet({
|
|
181
|
+
isOpen,
|
|
182
|
+
onOpenChange,
|
|
183
|
+
label,
|
|
184
|
+
children,
|
|
185
|
+
height = 'medium',
|
|
186
|
+
xstyle,
|
|
187
|
+
...props
|
|
188
|
+
}: BottomSheetProps) {
|
|
189
|
+
const close = () => onOpenChange(false);
|
|
190
|
+
const {contentProps, handleProps} = useSheetGestures({
|
|
191
|
+
isOpen,
|
|
192
|
+
onDismiss: close,
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
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]}
|
|
205
|
+
{...props}>
|
|
206
|
+
<div
|
|
207
|
+
data-astryx-sheet=""
|
|
208
|
+
{...mergeProps(
|
|
209
|
+
themeProps('bottom-sheet'),
|
|
210
|
+
stylex.props(styles.sheet, xstyle),
|
|
211
|
+
contentProps.style,
|
|
212
|
+
)}>
|
|
213
|
+
<div
|
|
214
|
+
data-astryx-sheet-handle=""
|
|
215
|
+
{...stylex.props(styles.handleBar)}
|
|
216
|
+
{...handleProps}
|
|
217
|
+
aria-hidden="true">
|
|
218
|
+
<div {...stylex.props(styles.handlePill)} />
|
|
219
|
+
</div>
|
|
220
|
+
<div {...stylex.props(styles.body)}>{children}</div>
|
|
221
|
+
</div>
|
|
222
|
+
</Drawer>
|
|
223
|
+
);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
BottomSheet.displayName = 'BottomSheet';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file index.ts
|
|
5
|
+
* @input BottomSheet.tsx
|
|
6
|
+
* @output Re-exports BottomSheet component and BottomSheetProps type
|
|
7
|
+
* @position Lab entry point for the BottomSheet directory
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export {BottomSheet} from './BottomSheet';
|
|
11
|
+
export type {BottomSheetProps} from './BottomSheet';
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/** @type {import('../../../core/src/docs-types').HookDoc} */
|
|
4
|
+
export const docs = {
|
|
5
|
+
name: 'useSheetGestures',
|
|
6
|
+
displayName: 'useSheetGestures',
|
|
7
|
+
keywords: ['sheet', 'drag', 'swipe', 'dismiss', 'gesture', 'pointer', 'touch', 'bottom sheet'],
|
|
8
|
+
params: [
|
|
9
|
+
{
|
|
10
|
+
name: 'options',
|
|
11
|
+
type: 'UseSheetGesturesOptions',
|
|
12
|
+
description: 'isOpen and onDismiss. Internal to BottomSheet; not exported from the lab entry point.',
|
|
13
|
+
required: true,
|
|
14
|
+
},
|
|
15
|
+
],
|
|
16
|
+
returns: [
|
|
17
|
+
{
|
|
18
|
+
name: 'result',
|
|
19
|
+
type: 'UseSheetGesturesResult',
|
|
20
|
+
description: 'contentProps (spread on the sliding surface for the live translate + touch-action guard), handleProps (spread on the grab handle for the pointer drag), plus dragOffset and isDragging for callers that want to drive their own animation.',
|
|
21
|
+
},
|
|
22
|
+
],
|
|
23
|
+
usage: {
|
|
24
|
+
description:
|
|
25
|
+
'Drag-to-dismiss machinery for the bottom sheet. Tracks a pointer drag down the block axis, translates the sliding surface live, and on release either dismisses (past a distance or velocity threshold) or springs back to fully open. Pointer-events based (one path for mouse + touch), SSR-safe, and respects prefers-reduced-motion. Escape (routed by the owning dialog) provides the keyboard equivalent of the swipe.',
|
|
26
|
+
bestPractices: [
|
|
27
|
+
{ guidance: true, description: 'Spread handleProps on the grab-handle element and contentProps on the sliding surface (the panel that carries the translate).' },
|
|
28
|
+
{ guidance: false, description: 'Wire onDismiss to anything other than the owning sheet close; it fires when a swipe crosses the dismiss threshold.' },
|
|
29
|
+
],
|
|
30
|
+
},
|
|
31
|
+
};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// Copyright (c) Meta Platforms, Inc. and affiliates.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @file useSheetGestures.test.ts
|
|
5
|
+
* @input Uses vitest, @testing-library/react renderHook, useSheetGestures
|
|
6
|
+
* @output Unit tests for useSheetGestures behavior
|
|
7
|
+
* @position Testing; validates useSheetGestures.ts implementation
|
|
8
|
+
*
|
|
9
|
+
* SYNC: When useSheetGestures.ts changes, update tests to match new behavior
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {describe, it, expect, vi, beforeEach, afterEach} from 'vitest';
|
|
13
|
+
import {act, renderHook} from '@testing-library/react';
|
|
14
|
+
import {
|
|
15
|
+
useSheetGestures,
|
|
16
|
+
type UseSheetGesturesOptions,
|
|
17
|
+
} from './useSheetGestures';
|
|
18
|
+
|
|
19
|
+
const SHEET_HEIGHT = 400;
|
|
20
|
+
|
|
21
|
+
// A fake pointer event object good enough for the handlers, which only read
|
|
22
|
+
// pointerId, clientY, timeStamp, and currentTarget's capture + measure APIs.
|
|
23
|
+
function pointerEvent(
|
|
24
|
+
clientY: number,
|
|
25
|
+
timeStamp: number,
|
|
26
|
+
target: HTMLElement,
|
|
27
|
+
pointerId = 1,
|
|
28
|
+
) {
|
|
29
|
+
return {
|
|
30
|
+
pointerId,
|
|
31
|
+
clientY,
|
|
32
|
+
timeStamp,
|
|
33
|
+
currentTarget: target,
|
|
34
|
+
} as unknown as React.PointerEvent;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function makeTarget(): HTMLElement {
|
|
38
|
+
const el = document.createElement('div');
|
|
39
|
+
el.setAttribute('data-astryx-sheet', '');
|
|
40
|
+
el.setPointerCapture = vi.fn();
|
|
41
|
+
el.releasePointerCapture = vi.fn();
|
|
42
|
+
el.getBoundingClientRect = () => ({height: SHEET_HEIGHT}) as DOMRect;
|
|
43
|
+
return el;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
beforeEach(() => {
|
|
47
|
+
vi.stubGlobal(
|
|
48
|
+
'matchMedia',
|
|
49
|
+
vi.fn().mockReturnValue({
|
|
50
|
+
matches: false,
|
|
51
|
+
addEventListener: vi.fn(),
|
|
52
|
+
removeEventListener: vi.fn(),
|
|
53
|
+
}),
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
afterEach(() => {
|
|
58
|
+
vi.unstubAllGlobals();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
function setup(options: Partial<UseSheetGesturesOptions> = {}) {
|
|
62
|
+
const onDismiss = vi.fn();
|
|
63
|
+
const hook = renderHook(
|
|
64
|
+
(props: UseSheetGesturesOptions) => useSheetGestures(props),
|
|
65
|
+
{
|
|
66
|
+
initialProps: {
|
|
67
|
+
isOpen: true,
|
|
68
|
+
onDismiss,
|
|
69
|
+
...options,
|
|
70
|
+
} as UseSheetGesturesOptions,
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
return {hook, onDismiss};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe('useSheetGestures', () => {
|
|
77
|
+
it('dismisses when dragged past the distance threshold (slow drag)', () => {
|
|
78
|
+
const {hook, onDismiss} = setup();
|
|
79
|
+
const target = makeTarget();
|
|
80
|
+
const {handleProps} = hook.result.current;
|
|
81
|
+
act(() => {
|
|
82
|
+
handleProps.onPointerDown(pointerEvent(0, 0, target));
|
|
83
|
+
});
|
|
84
|
+
// Drag down slowly, well past 25% of 400px, low velocity.
|
|
85
|
+
act(() => {
|
|
86
|
+
handleProps.onPointerMove(pointerEvent(60, 200, target));
|
|
87
|
+
handleProps.onPointerMove(pointerEvent(140, 600, target));
|
|
88
|
+
});
|
|
89
|
+
act(() => {
|
|
90
|
+
handleProps.onPointerUp(pointerEvent(140, 620, target));
|
|
91
|
+
});
|
|
92
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('dismisses on a fast flick even below the distance threshold', () => {
|
|
96
|
+
const {hook, onDismiss} = setup();
|
|
97
|
+
const target = makeTarget();
|
|
98
|
+
const {handleProps} = hook.result.current;
|
|
99
|
+
act(() => {
|
|
100
|
+
handleProps.onPointerDown(pointerEvent(0, 0, target));
|
|
101
|
+
});
|
|
102
|
+
// Small distance (30px < 100px) but very fast (30px / 10ms = 3px/ms).
|
|
103
|
+
act(() => {
|
|
104
|
+
handleProps.onPointerMove(pointerEvent(30, 10, target));
|
|
105
|
+
});
|
|
106
|
+
act(() => {
|
|
107
|
+
handleProps.onPointerUp(pointerEvent(30, 12, target));
|
|
108
|
+
});
|
|
109
|
+
expect(onDismiss).toHaveBeenCalledTimes(1);
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('springs back (no dismiss) below the threshold', () => {
|
|
113
|
+
const {hook, onDismiss} = setup();
|
|
114
|
+
const target = makeTarget();
|
|
115
|
+
const {handleProps} = hook.result.current;
|
|
116
|
+
act(() => {
|
|
117
|
+
handleProps.onPointerDown(pointerEvent(0, 0, target));
|
|
118
|
+
});
|
|
119
|
+
act(() => {
|
|
120
|
+
handleProps.onPointerMove(pointerEvent(20, 300, target));
|
|
121
|
+
});
|
|
122
|
+
act(() => {
|
|
123
|
+
handleProps.onPointerUp(pointerEvent(20, 620, target));
|
|
124
|
+
});
|
|
125
|
+
expect(onDismiss).not.toHaveBeenCalled();
|
|
126
|
+
expect(hook.result.current.dragOffset).toBe(0);
|
|
127
|
+
expect(hook.result.current.isDragging).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('translates the surface live during a drag', () => {
|
|
131
|
+
const {hook} = setup();
|
|
132
|
+
const target = makeTarget();
|
|
133
|
+
const {handleProps} = hook.result.current;
|
|
134
|
+
act(() => {
|
|
135
|
+
handleProps.onPointerDown(pointerEvent(0, 0, target));
|
|
136
|
+
});
|
|
137
|
+
act(() => {
|
|
138
|
+
handleProps.onPointerMove(pointerEvent(50, 100, target));
|
|
139
|
+
});
|
|
140
|
+
expect(hook.result.current.dragOffset).toBe(50);
|
|
141
|
+
expect(hook.result.current.isDragging).toBe(true);
|
|
142
|
+
expect(hook.result.current.contentProps.style.transform).toBe(
|
|
143
|
+
'translateY(50px)',
|
|
144
|
+
);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('ignores upward drag past the fully-open position', () => {
|
|
148
|
+
const {hook} = setup();
|
|
149
|
+
const target = makeTarget();
|
|
150
|
+
const {handleProps} = hook.result.current;
|
|
151
|
+
act(() => {
|
|
152
|
+
handleProps.onPointerDown(pointerEvent(100, 0, target));
|
|
153
|
+
});
|
|
154
|
+
// Drag UP (clientY decreasing) — no rubber-band overscroll.
|
|
155
|
+
act(() => {
|
|
156
|
+
handleProps.onPointerMove(pointerEvent(20, 100, target));
|
|
157
|
+
});
|
|
158
|
+
expect(hook.result.current.dragOffset).toBe(0);
|
|
159
|
+
});
|
|
160
|
+
});
|