@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.
Files changed (40) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +95 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -0
  3. package/dist/BottomSheet/BottomSheet.js +150 -0
  4. package/dist/BottomSheet/index.d.ts +9 -0
  5. package/dist/BottomSheet/index.d.ts.map +1 -0
  6. package/dist/BottomSheet/index.js +10 -0
  7. package/dist/BottomSheet/useSheetGestures.d.ts +69 -0
  8. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -0
  9. package/dist/BottomSheet/useSheetGestures.js +163 -0
  10. package/dist/RichTextEditor/RichTextEditor.d.ts +67 -6
  11. package/dist/RichTextEditor/RichTextEditor.d.ts.map +1 -1
  12. package/dist/RichTextEditor/RichTextEditor.js +165 -12
  13. package/dist/RichTextEditor/editorTheme.d.ts.map +1 -1
  14. package/dist/RichTextEditor/editorTheme.js +33 -7
  15. package/dist/RichTextEditor/index.d.ts +1 -1
  16. package/dist/RichTextEditor/index.d.ts.map +1 -1
  17. package/dist/index.d.ts +2 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -0
  20. package/dist/lab.css +23 -0
  21. package/package.json +2 -2
  22. package/src/BottomSheet/BottomSheet.doc.mjs +105 -0
  23. package/src/BottomSheet/BottomSheet.test.tsx +233 -0
  24. package/src/BottomSheet/BottomSheet.tsx +226 -0
  25. package/src/BottomSheet/index.ts +11 -0
  26. package/src/BottomSheet/useSheetGestures.doc.mjs +31 -0
  27. package/src/BottomSheet/useSheetGestures.test.ts +160 -0
  28. package/src/BottomSheet/useSheetGestures.ts +226 -0
  29. package/src/ChatReasoning/ChatReasoning.doc.mjs +49 -2
  30. package/src/CodeEditor/CodeEditor.doc.mjs +67 -2
  31. package/src/LogStream/LogStream.doc.mjs +53 -2
  32. package/src/RichTextEditor/RichTextEditor.doc.mjs +19 -1
  33. package/src/RichTextEditor/RichTextEditor.test.tsx +441 -11
  34. package/src/RichTextEditor/RichTextEditor.tsx +289 -31
  35. package/src/RichTextEditor/editorTheme.ts +63 -3
  36. package/src/RichTextEditor/index.ts +2 -0
  37. package/src/Schedule/Schedule.doc.mjs +143 -3
  38. package/src/Stepper/Stepper.doc.mjs +6 -0
  39. package/src/__tests__/labApiContractDrift.test.tsx +57 -0
  40. 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
+ });