@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.
Files changed (63) hide show
  1. package/dist/BottomSheet/BottomSheet.d.ts +41 -35
  2. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  3. package/dist/BottomSheet/BottomSheet.js +222 -75
  4. package/dist/BottomSheet/snapOffsets.d.ts +54 -0
  5. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -0
  6. package/dist/BottomSheet/snapOffsets.js +101 -0
  7. package/dist/BottomSheet/useSheetGestures.d.ts +55 -12
  8. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  9. package/dist/BottomSheet/useSheetGestures.js +383 -40
  10. package/dist/Chart/index.d.ts +1 -1
  11. package/dist/Chart/index.d.ts.map +1 -1
  12. package/dist/Chart/index.js +1 -1
  13. package/dist/Stepper/Step.d.ts +1 -1
  14. package/dist/Stepper/Step.js +1 -1
  15. package/dist/Stepper/Stepper.d.ts +1 -1
  16. package/dist/Stepper/Stepper.js +1 -1
  17. package/dist/index.js +1 -1
  18. package/dist/lab.css +15 -8
  19. package/package.json +3 -2
  20. package/src/BottomSheet/BottomSheet.doc.mjs +13 -13
  21. package/src/BottomSheet/BottomSheet.test.tsx +81 -4
  22. package/src/BottomSheet/BottomSheet.tsx +333 -97
  23. package/src/BottomSheet/snapOffsets.test.ts +146 -0
  24. package/src/BottomSheet/snapOffsets.ts +126 -0
  25. package/src/BottomSheet/useSheetGestures.doc.mjs +7 -6
  26. package/src/BottomSheet/useSheetGestures.test.ts +315 -65
  27. package/src/BottomSheet/useSheetGestures.ts +515 -55
  28. package/src/Chart/ChartArea.doc.mjs +1 -1
  29. package/src/Chart/ChartBar.doc.mjs +1 -1
  30. package/src/Chart/ChartCandlestick.doc.mjs +1 -1
  31. package/src/Chart/ChartDot.doc.mjs +1 -1
  32. package/src/Chart/ChartDotGL.doc.mjs +1 -1
  33. package/src/Chart/ChartDotGLInteractive.doc.mjs +1 -1
  34. package/src/Chart/ChartHeatmapGL.doc.mjs +1 -1
  35. package/src/Chart/ChartLine.doc.mjs +1 -1
  36. package/src/Chart/ChartStreamGL.doc.mjs +1 -1
  37. package/src/Chart/index.ts +0 -1
  38. package/src/Chat/ChatEmojiPicker.doc.mjs +1 -1
  39. package/src/Chat/ChatReactionBar.doc.mjs +1 -1
  40. package/src/Chat/ChatTypingIndicator.doc.mjs +1 -1
  41. package/src/Chat/ChatUnreadDivider.doc.mjs +1 -1
  42. package/src/ChatReasoning/ChatReasoning.doc.mjs +1 -1
  43. package/src/CircularProgress/CircularProgress.doc.mjs +3 -3
  44. package/src/CodeEditor/CodeEditor.doc.mjs +1 -1
  45. package/src/Drawer/Drawer.doc.mjs +3 -3
  46. package/src/InfoTip/InfoTip.doc.mjs +3 -3
  47. package/src/LogStream/LogStream.doc.mjs +1 -1
  48. package/src/Radial/RadialChart.doc.mjs +1 -1
  49. package/src/RichTextEditor/RichTextEditor.doc.mjs +1 -1
  50. package/src/SVGIcon/SVGIcon.doc.mjs +1 -1
  51. package/src/Sankey/SankeyChart.doc.mjs +1 -1
  52. package/src/Schedule/Schedule.doc.mjs +1 -1
  53. package/src/Stat/Stat.doc.mjs +3 -3
  54. package/src/Stepper/Step.doc.mjs +1 -1
  55. package/src/Stepper/Step.tsx +1 -1
  56. package/src/Stepper/Stepper.doc.mjs +3 -3
  57. package/src/Stepper/Stepper.tsx +1 -1
  58. package/src/ThreeD/3DBar.doc.mjs +1 -1
  59. package/src/ThreeD/3DChart.doc.mjs +1 -1
  60. package/src/ThreeD/3DScatter.doc.mjs +1 -1
  61. package/src/ThreeD/3DScatterGL.doc.mjs +1 -1
  62. package/src/ThreeD/3DSurface.doc.mjs +1 -1
  63. package/src/index.ts +1 -1
@@ -36,7 +36,6 @@ function pointerEvent(
36
36
 
37
37
  function makeTarget(): HTMLElement {
38
38
  const el = document.createElement('div');
39
- el.setAttribute('data-astryx-sheet', '');
40
39
  el.setPointerCapture = vi.fn();
41
40
  el.releasePointerCapture = vi.fn();
42
41
  el.getBoundingClientRect = () => ({height: SHEET_HEIGHT}) as DOMRect;
@@ -73,70 +72,174 @@ function setup(options: Partial<UseSheetGesturesOptions> = {}) {
73
72
  return {hook, onDismiss};
74
73
  }
75
74
 
75
+ type Hook = {
76
+ result: {current: ReturnType<typeof useSheetGestures>};
77
+ };
78
+
79
+ function down(hook: Hook, y: number, t: number, target: HTMLElement) {
80
+ // Register the sheet element the way the component does on mount, so the
81
+ // hook can measure its height (it no longer queries the DOM for it).
82
+ act(() => hook.result.current.sheetRef(target));
83
+ act(() =>
84
+ hook.result.current.handleProps.onPointerDown(pointerEvent(y, t, target)),
85
+ );
86
+ }
87
+ function move(hook: Hook, y: number, t: number, target: HTMLElement) {
88
+ act(() =>
89
+ hook.result.current.handleProps.onPointerMove(pointerEvent(y, t, target)),
90
+ );
91
+ }
92
+ function up(hook: Hook, y: number, t: number, target: HTMLElement) {
93
+ act(() =>
94
+ hook.result.current.handleProps.onPointerUp(pointerEvent(y, t, target)),
95
+ );
96
+ }
97
+
76
98
  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
- });
99
+ it('dismisses on a fast downward flick (swipe-to-close)', () => {
100
+ const {hook, onDismiss} = setup({snapHeights: () => [200]});
101
+ const t = makeTarget();
102
+ down(hook, 0, 0, t);
103
+ // 60px in 20ms = 3px/ms, and 60px > the flick distance floor.
104
+ move(hook, 60, 20, t);
105
+ up(hook, 60, 22, t);
92
106
  expect(onDismiss).toHaveBeenCalledTimes(1);
93
107
  });
94
108
 
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);
109
+ it('expands to the tallest detent on a fast upward flick', () => {
110
+ const onSnap = vi.fn();
111
+ const {hook, onDismiss} = setup({snapHeights: () => [200], onSnap});
112
+ const t = makeTarget();
113
+ // Start rested at a lower detent by first snapping there via a slow drag.
114
+ down(hook, 0, 0, t);
115
+ move(hook, 200, 400, t); // slow drag down toward the 200px detent
116
+ up(hook, 200, 800, t);
117
+ onSnap.mockClear();
118
+ // Now flick up fast: 60px in 20ms = 3px/ms, over the distance floor.
119
+ down(hook, 200, 1000, t);
120
+ move(hook, 140, 1020, t);
121
+ up(hook, 140, 1022, t);
122
+ expect(onDismiss).not.toHaveBeenCalled();
123
+ expect(onSnap).toHaveBeenLastCalledWith(SHEET_HEIGHT); // full height
124
+ expect(hook.result.current.settledOffset).toBe(0);
110
125
  });
111
126
 
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
- });
127
+ it('does not flick-dismiss on a fast but short nudge', () => {
128
+ const {hook, onDismiss} = setup({snapHeights: () => [200]});
129
+ const t = makeTarget();
130
+ // Fast (20px/10ms = 2px/ms) but only 20px of travel — under the distance
131
+ // floor, so it settles rather than dismissing.
132
+ down(hook, 0, 0, t);
133
+ move(hook, 20, 10, t);
134
+ up(hook, 20, 12, t);
125
135
  expect(onDismiss).not.toHaveBeenCalled();
126
- expect(hook.result.current.dragOffset).toBe(0);
127
- expect(hook.result.current.isDragging).toBe(false);
136
+ });
137
+
138
+ it('settles to the nearest detent on a slow drag', () => {
139
+ const onSnap = vi.fn();
140
+ const {hook, onDismiss} = setup({snapHeights: () => [200], onSnap});
141
+ const t = makeTarget();
142
+ // Detents: full=400 (offset 0) and 200 (offset 200). Drag slowly to ~180
143
+ // offset -> nearest is the 200px detent (offset 200).
144
+ down(hook, 0, 0, t);
145
+ move(hook, 90, 300, t);
146
+ move(hook, 180, 700, t);
147
+ up(hook, 180, 1100, t);
148
+ expect(onDismiss).not.toHaveBeenCalled();
149
+ expect(onSnap).toHaveBeenLastCalledWith(200);
150
+ expect(hook.result.current.settledOffset).toBe(200);
151
+ });
152
+
153
+ it('a downward drag from a middle detent never snaps back up past it', () => {
154
+ // Three detents on a 600px sheet: full (offset 0), mid 360 (offset 240),
155
+ // short 160 (offset 440). Rest at the mid detent, then drag DOWN a modest
156
+ // amount that's still closer to mid than to short. It must not snap back
157
+ // UP to full — only to mid (stay) or further down.
158
+ const onSnap = vi.fn();
159
+ const {hook} = setup({snapHeights: () => [160, 360], onSnap});
160
+ const t = makeTarget();
161
+ t.getBoundingClientRect = () => ({height: 600}) as DOMRect;
162
+ // Drag to the mid detent (offset 240).
163
+ down(hook, 0, 0, t);
164
+ move(hook, 120, 300, t);
165
+ move(hook, 240, 700, t);
166
+ up(hook, 240, 1100, t);
167
+ expect(hook.result.current.settledOffset).toBe(240);
168
+ onSnap.mockClear();
169
+ // Small slow drag DOWN from mid: offset 240 -> ~300.
170
+ down(hook, 300, 2000, t);
171
+ move(hook, 340, 2200, t);
172
+ move(hook, 360, 2600, t);
173
+ up(hook, 360, 3000, t);
174
+ // Must not have jumped back up to full (offset 0).
175
+ expect(hook.result.current.settledOffset).not.toBe(0);
176
+ expect(hook.result.current.settledOffset).toBeGreaterThanOrEqual(240);
177
+ });
178
+
179
+ it('dismisses when dragged well below the shortest detent', () => {
180
+ const {hook, onDismiss} = setup({snapHeights: () => [200]});
181
+ const t = makeTarget();
182
+ // Shortest detent offset = 200 (height 200). Drag slowly past it by
183
+ // >40% of 200 -> offset ~320 dismisses.
184
+ down(hook, 0, 0, t);
185
+ move(hook, 160, 400, t);
186
+ move(hook, 330, 900, t);
187
+ up(hook, 330, 1400, t);
188
+ expect(onDismiss).toHaveBeenCalledTimes(1);
189
+ });
190
+
191
+ it('fades the scrim from full to hidden as it collapses onto the peek detent', () => {
192
+ const onScrimOpacity = vi.fn();
193
+ // Sheet 400, detents at 200 and 300 -> offsets [0, 100, 200]. The fade
194
+ // spans the mid detent (offset 100) to the peek detent (offset 200):
195
+ // full at/above 100, hidden at/below 200.
196
+ const {hook} = setup({snapHeights: () => [200, 300], onScrimOpacity});
197
+ const t = makeTarget();
198
+ down(hook, 0, 0, t);
199
+ move(hook, 60, 200, t); // offset 60, above the mid detent -> full
200
+ move(hook, 150, 600, t); // offset 150, halfway through the fade -> ~0.5
201
+ move(hook, 220, 1000, t); // offset 220, past the peek -> hidden
202
+ const values = onScrimOpacity.mock.calls.map(c => c[0]);
203
+ expect(values[0]).toBe(1);
204
+ expect(values[1]).toBeGreaterThan(0.4);
205
+ expect(values[1]).toBeLessThan(0.6);
206
+ expect(values[values.length - 1]).toBe(0);
207
+ });
208
+
209
+ it('keeps the scrim hidden when settled at the peek detent', () => {
210
+ const onScrimOpacity = vi.fn();
211
+ const {hook} = setup({snapHeights: () => [200, 300], onScrimOpacity});
212
+ const t = makeTarget();
213
+ // Slow drag down to the peek detent (offset 200).
214
+ down(hook, 0, 0, t);
215
+ move(hook, 100, 400, t);
216
+ move(hook, 200, 900, t);
217
+ up(hook, 200, 1400, t);
218
+ expect(hook.result.current.settledOffset).toBe(200);
219
+ // Last reported opacity (on settle) is hidden.
220
+ const values = onScrimOpacity.mock.calls.map(c => c[0]);
221
+ expect(values[values.length - 1]).toBe(0);
222
+ });
223
+
224
+ it('magnetically settles the live drag onto a nearby detent', () => {
225
+ // Detents for a 400px sheet with snaps 300 and 200 -> visible-height snaps
226
+ // become offsets [0, 100, 200]. Approaching the middle detent (offset 100)
227
+ // from just below (raw ~90) — inside MAGNET_RANGE and BELOW the shortest
228
+ // detent so the between-detents magnet applies — should ease toward 100.
229
+ const {hook} = setup({snapHeights: () => [200, 300]});
230
+ const t = makeTarget();
231
+ down(hook, 0, 0, t);
232
+ move(hook, 90, 100, t); // raw offset 90, 10px from the 100 detent
233
+ const off = hook.result.current.dragOffset;
234
+ expect(off).toBeGreaterThan(90); // pulled up toward 100
235
+ expect(off).toBeLessThanOrEqual(100);
128
236
  });
129
237
 
130
238
  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
- });
239
+ const {hook} = setup({snapHeights: () => [200]});
240
+ const t = makeTarget();
241
+ down(hook, 0, 0, t);
242
+ move(hook, 50, 100, t);
140
243
  expect(hook.result.current.dragOffset).toBe(50);
141
244
  expect(hook.result.current.isDragging).toBe(true);
142
245
  expect(hook.result.current.contentProps.style.transform).toBe(
@@ -144,17 +247,164 @@ describe('useSheetGestures', () => {
144
247
  );
145
248
  });
146
249
 
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));
250
+ it('rubber-bands an upward drag past the fully-open position', () => {
251
+ const {hook} = setup({snapHeights: () => [200]});
252
+ const t = makeTarget();
253
+ down(hook, 100, 0, t);
254
+ move(hook, 20, 100, t); // drag up 80px past the top of a rested-at-top sheet
255
+ // Overscroll is allowed but damped: a resisted fraction of the raw -80,
256
+ // and negative (above the top), not the raw distance and not clamped to 0.
257
+ const off = hook.result.current.dragOffset;
258
+ expect(off).toBeLessThan(0);
259
+ expect(off).toBeGreaterThan(-80); // resisted, so smaller in magnitude
260
+ });
261
+
262
+ it('dismisses a single-height sheet on a slow drag past the floor', () => {
263
+ const {hook, onDismiss} = setup(); // no snapHeights
264
+ const t = makeTarget();
265
+ // Only detent is the full 400px height (offset 0). A slow drag past 40%
266
+ // of it (>160px) with no detent to catch it dismisses.
267
+ down(hook, 0, 0, t);
268
+ move(hook, 120, 400, t);
269
+ move(hook, 240, 900, t);
270
+ up(hook, 240, 1400, t);
271
+ expect(onDismiss).toHaveBeenCalledTimes(1);
272
+ });
273
+
274
+ describe('body overscroll', () => {
275
+ // A scrollable body element nested inside the sheet, with a settable
276
+ // scrollTop so we can simulate "at the top" vs "scrolled".
277
+ function makeBody(scrollTop: number) {
278
+ const body = document.createElement('div');
279
+ Object.defineProperty(body, 'scrollTop', {
280
+ value: scrollTop,
281
+ writable: true,
282
+ });
283
+ body.setPointerCapture = vi.fn();
284
+ body.releasePointerCapture = vi.fn();
285
+ body.getBoundingClientRect = () => ({height: SHEET_HEIGHT}) as DOMRect;
286
+ return body;
287
+ }
288
+ function bodyDown(hook: Hook, y: number, t: number, el: HTMLElement) {
289
+ // Register a sheet element so the hook can measure height (it reads the
290
+ // tracked node, not the DOM). The body stands in with the right height.
291
+ act(() => hook.result.current.sheetRef(el));
292
+ act(() =>
293
+ hook.result.current.bodyProps.onPointerDown(pointerEvent(y, t, el)),
294
+ );
295
+ }
296
+ function bodyMove(hook: Hook, y: number, t: number, el: HTMLElement) {
297
+ act(() =>
298
+ hook.result.current.bodyProps.onPointerMove(pointerEvent(y, t, el)),
299
+ );
300
+ }
301
+
302
+ it('promotes a top-overscroll pull-down into a sheet drag', () => {
303
+ const {hook} = setup({snapHeights: () => [200]});
304
+ const body = makeBody(0); // at the top
305
+ bodyDown(hook, 0, 0, body);
306
+ bodyMove(hook, 40, 100, body); // pull down while at the top
307
+ expect(hook.result.current.isDragging).toBe(true);
308
+ expect(hook.result.current.dragOffset).toBe(40);
309
+ });
310
+
311
+ it('does not hijack scrolling when the body is scrolled down', () => {
312
+ const {hook} = setup({snapHeights: () => [200]});
313
+ const body = makeBody(120); // scrolled, not at the top
314
+ bodyDown(hook, 0, 0, body);
315
+ bodyMove(hook, 40, 100, body);
316
+ expect(hook.result.current.isDragging).toBe(false);
153
317
  });
154
- // Drag UP (clientY decreasing) — no rubber-band overscroll.
155
- act(() => {
156
- handleProps.onPointerMove(pointerEvent(20, 100, target));
318
+
319
+ it('does not start a drag on an upward move from the top', () => {
320
+ const {hook} = setup({snapHeights: () => [200]});
321
+ const body = makeBody(0);
322
+ bodyDown(hook, 100, 0, body);
323
+ bodyMove(hook, 60, 100, body); // moving up = scrolling intent
324
+ expect(hook.result.current.isDragging).toBe(false);
325
+ });
326
+ });
327
+
328
+ describe('body touch handoff', () => {
329
+ // Attach a real DOM node via the body ref, then dispatch touch events. The
330
+ // hook's non-passive touchmove listener is how the handoff works on touch
331
+ // devices (pointer events get cancelled once native panning starts).
332
+ function makeScroller(opts: {
333
+ scrollTop: number;
334
+ clientHeight: number;
335
+ scrollHeight: number;
336
+ }) {
337
+ const sheet = makeTarget();
338
+ const el = document.createElement('div');
339
+ Object.defineProperty(el, 'scrollTop', {
340
+ value: opts.scrollTop,
341
+ writable: true,
342
+ });
343
+ Object.defineProperty(el, 'clientHeight', {value: opts.clientHeight});
344
+ Object.defineProperty(el, 'scrollHeight', {value: opts.scrollHeight});
345
+ el.getBoundingClientRect = () => ({height: SHEET_HEIGHT}) as DOMRect;
346
+ sheet.appendChild(el);
347
+ document.body.appendChild(sheet);
348
+ return el;
349
+ }
350
+ function touch(el: HTMLElement, type: string, y: number, id = 1) {
351
+ const ev = new Event(type, {bubbles: true, cancelable: true});
352
+ // jsdom lacks TouchEvent; attach the fields the handler reads.
353
+ Object.defineProperty(ev, 'changedTouches', {
354
+ value: [{identifier: id, clientY: y}],
355
+ });
356
+ Object.defineProperty(ev, 'currentTarget', {value: el});
357
+ el.dispatchEvent(ev);
358
+ return ev;
359
+ }
360
+
361
+ it('promotes a top pull-down (touch) into a sheet drag', () => {
362
+ const {hook} = setup({snapHeights: () => [200]});
363
+ const el = makeScroller({
364
+ scrollTop: 0,
365
+ clientHeight: 200,
366
+ scrollHeight: 800,
367
+ });
368
+ act(() => hook.result.current.sheetRef(el));
369
+ act(() => hook.result.current.bodyProps.ref(el));
370
+ act(() => {
371
+ touch(el, 'touchstart', 0);
372
+ touch(el, 'touchmove', 50);
373
+ });
374
+ expect(hook.result.current.isDragging).toBe(true);
375
+ });
376
+
377
+ it('promotes a bottom pull-up (touch) into a sheet drag', () => {
378
+ const {hook} = setup({snapHeights: () => [200]});
379
+ // scrolled to the bottom: scrollTop + clientHeight === scrollHeight
380
+ const el = makeScroller({
381
+ scrollTop: 600,
382
+ clientHeight: 200,
383
+ scrollHeight: 800,
384
+ });
385
+ act(() => hook.result.current.sheetRef(el));
386
+ act(() => hook.result.current.bodyProps.ref(el));
387
+ act(() => {
388
+ touch(el, 'touchstart', 300);
389
+ touch(el, 'touchmove', 250); // pull up
390
+ });
391
+ expect(hook.result.current.isDragging).toBe(true);
392
+ });
393
+
394
+ it('leaves native scrolling alone in the middle of the content', () => {
395
+ const {hook} = setup({snapHeights: () => [200]});
396
+ const el = makeScroller({
397
+ scrollTop: 300,
398
+ clientHeight: 200,
399
+ scrollHeight: 800,
400
+ });
401
+ act(() => hook.result.current.sheetRef(el));
402
+ act(() => hook.result.current.bodyProps.ref(el));
403
+ act(() => {
404
+ touch(el, 'touchstart', 300);
405
+ touch(el, 'touchmove', 250);
406
+ });
407
+ expect(hook.result.current.isDragging).toBe(false);
157
408
  });
158
- expect(hook.result.current.dragOffset).toBe(0);
159
409
  });
160
410
  });