@astryxdesign/lab 0.4.2-canary.10bcaa1 → 0.4.2-canary.255e810

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.
@@ -22,7 +22,7 @@
22
22
  * - /packages/lab/src/BottomSheet/useSheetGestures.doc.mjs
23
23
  * - /packages/lab/src/BottomSheet/useSheetGestures.test.ts
24
24
  */
25
- import { type CSSProperties, type PointerEvent as ReactPointerEvent } from 'react';
25
+ import { type CSSProperties, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent } from 'react';
26
26
  export interface UseSheetGesturesOptions {
27
27
  /** Whether the owning sheet is open. Drag state resets when it closes. */
28
28
  isOpen: boolean;
@@ -59,6 +59,8 @@ export interface SheetContentProps {
59
59
  }
60
60
  export interface SheetHandleProps {
61
61
  style: CSSProperties;
62
+ onContextMenu: (event: ReactMouseEvent) => void;
63
+ onLostPointerCapture: (event: ReactPointerEvent) => void;
62
64
  onPointerDown: (event: ReactPointerEvent) => void;
63
65
  onPointerMove: (event: ReactPointerEvent) => void;
64
66
  onPointerUp: (event: ReactPointerEvent) => void;
@@ -66,6 +68,8 @@ export interface SheetHandleProps {
66
68
  }
67
69
  export interface SheetBodyProps {
68
70
  ref: (node: HTMLElement | null) => void;
71
+ onContextMenu: (event: ReactMouseEvent) => void;
72
+ onLostPointerCapture: (event: ReactPointerEvent) => void;
69
73
  onPointerDown: (event: ReactPointerEvent) => void;
70
74
  onPointerMove: (event: ReactPointerEvent) => void;
71
75
  onPointerUp: (event: ReactPointerEvent) => void;
@@ -1 +1 @@
1
- {"version":3,"file":"useSheetGestures.d.ts","sourceRoot":"","sources":["../../src/BottomSheet/useSheetGestures.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAML,KAAK,aAAa,EAClB,KAAK,YAAY,IAAI,iBAAiB,EACvC,MAAM,OAAO,CAAC;AAgEf,MAAM,WAAW,uBAAuB;IACtC,0EAA0E;IAC1E,MAAM,EAAE,OAAO,CAAC;IAChB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,gFAAgF;IAChF,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,MAAM,EAAE,CAAC;IAC7B,4DAA4D;IAC5D,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CAC5C;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,aAAa,CAAC;CACtB;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,aAAa,CAAC;IACrB,aAAa,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAClD,aAAa,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAClD,WAAW,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAChD,eAAe,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;CACrD;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,KAAK,IAAI,CAAC;IACxC,aAAa,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAClD,aAAa,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAClD,WAAW,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAChD,eAAe,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;CACrD;AAED,MAAM,WAAW,sBAAsB;IACrC;;;;OAIG;IACH,QAAQ,EAAE,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,KAAK,IAAI,CAAC;IAC7C,0EAA0E;IAC1E,YAAY,EAAE,iBAAiB,CAAC;IAChC,gEAAgE;IAChE,WAAW,EAAE,gBAAgB,CAAC;IAC9B;;;;OAIG;IACH,SAAS,EAAE,cAAc,CAAC;IAC1B,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAC;IACnB,kEAAkE;IAClE,aAAa,EAAE,MAAM,CAAC;IACtB,+CAA+C;IAC/C,UAAU,EAAE,OAAO,CAAC;CACrB;AASD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,gBAAgB,CAAC,EAC/B,MAAM,EACN,UAAiB,EACjB,SAAS,EACT,WAAW,EACX,MAAM,EACN,cAAc,GACf,EAAE,uBAAuB,GAAG,sBAAsB,CAgflD"}
1
+ {"version":3,"file":"useSheetGestures.d.ts","sourceRoot":"","sources":["../../src/BottomSheet/useSheetGestures.ts"],"names":[],"mappings":"AAIA;;;;;;;;;;;;;;;;;;;;;;;GAuBG;AAEH,OAAO,EAML,KAAK,aAAa,EAClB,KAAK,UAAU,IAAI,eAAe,EAClC,KAAK,YAAY,IAAI,iBAAiB,EACvC,MAAM,OAAO,CAAC;AAgEf,MAAM,WAAW,uBAAuB;IACtC,0EAA0E;IAC1E,MAAM,EAAE,OAAO,CAAC;IAChB;;;;OAIG;IACH,UAAU,CAAC,EAAE,OAAO,CAAC;IACrB,gFAAgF;IAChF,SAAS,EAAE,MAAM,IAAI,CAAC;IACtB;;;;;;;OAOG;IACH,WAAW,CAAC,EAAE,MAAM,MAAM,EAAE,CAAC;IAC7B,4DAA4D;IAC5D,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,MAAM,KAAK,IAAI,CAAC;IACpC;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;CAC5C;AAED,MAAM,WAAW,iBAAiB;IAChC,KAAK,EAAE,aAAa,CAAC;CACtB;AAED,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,aAAa,CAAC;IACrB,aAAa,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;IAChD,oBAAoB,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IACzD,aAAa,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAClD,aAAa,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAClD,WAAW,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAChD,eAAe,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;CACrD;AAED,MAAM,WAAW,cAAc;IAC7B,GAAG,EAAE,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,KAAK,IAAI,CAAC;IACxC,aAAa,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;IAChD,oBAAoB,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IACzD,aAAa,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAClD,aAAa,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAClD,WAAW,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;IAChD,eAAe,EAAE,CAAC,KAAK,EAAE,iBAAiB,KAAK,IAAI,CAAC;CACrD;AAED,MAAM,WAAW,sBAAsB;IACrC;;;;OAIG;IACH,QAAQ,EAAE,CAAC,IAAI,EAAE,WAAW,GAAG,IAAI,KAAK,IAAI,CAAC;IAC7C,0EAA0E;IAC1E,YAAY,EAAE,iBAAiB,CAAC;IAChC,gEAAgE;IAChE,WAAW,EAAE,gBAAgB,CAAC;IAC9B;;;;OAIG;IACH,SAAS,EAAE,cAAc,CAAC;IAC1B,kFAAkF;IAClF,UAAU,EAAE,MAAM,CAAC;IACnB,kEAAkE;IAClE,aAAa,EAAE,MAAM,CAAC;IACtB,+CAA+C;IAC/C,UAAU,EAAE,OAAO,CAAC;CACrB;AASD;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,gBAAgB,CAAC,EAC/B,MAAM,EACN,UAAiB,EACjB,SAAS,EACT,WAAW,EACX,MAAM,EACN,cAAc,GACf,EAAE,uBAAuB,GAAG,sBAAsB,CAolBlD"}
@@ -188,6 +188,44 @@ export function useSheetGestures({
188
188
  const detentOffsets = useCallback(height => {
189
189
  return computeDetentOffsets(height, snapHeightsRef.current?.() ?? []);
190
190
  }, []);
191
+ const cancelDrag = useCallback(target => {
192
+ const state = dragStateRef.current;
193
+ if (state == null) {
194
+ return;
195
+ }
196
+
197
+ // Clear first: releasePointerCapture() may synchronously dispatch
198
+ // lostpointercapture, which must observe that this drag is already done.
199
+ dragStateRef.current = null;
200
+ if (target?.hasPointerCapture?.(state.pointerId)) {
201
+ target.releasePointerCapture(state.pointerId);
202
+ }
203
+ setDragOffset(state.baseOffset);
204
+ setIsDragging(false);
205
+
206
+ // An interrupted drag returns to its previous resting detent. Restore
207
+ // the matching scrim opacity as well so the modal shell cannot remain
208
+ // dimmed with its sheet translated out of view.
209
+ const offsets = detentOffsets(state.height);
210
+ const maxOffset = offsets[offsets.length - 1];
211
+ const shortestDetentHeight = state.height - maxOffset;
212
+ const dismissOffset = maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO;
213
+ onScrimOpacityRef.current?.(scrimOpacityForOffset(state.baseOffset, offsets, dismissOffset));
214
+ }, [detentOffsets]);
215
+ useEffect(() => {
216
+ const handleWindowBlur = () => cancelDrag();
217
+ const handleVisibilityChange = () => {
218
+ if (document.visibilityState === 'hidden') {
219
+ cancelDrag();
220
+ }
221
+ };
222
+ window.addEventListener('blur', handleWindowBlur);
223
+ document.addEventListener('visibilitychange', handleVisibilityChange);
224
+ return () => {
225
+ window.removeEventListener('blur', handleWindowBlur);
226
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
227
+ };
228
+ }, [cancelDrag]);
191
229
  const settleFromDrag = useCallback((offset, velocity, height, dir, travel, baseOffset) => {
192
230
  const offsets = detentOffsets(height);
193
231
  const maxOffset = offsets[offsets.length - 1];
@@ -255,12 +293,27 @@ export function useSheetGestures({
255
293
  setIsDragging(true);
256
294
  }, [settledOffset]);
257
295
  const handlePointerDown = useCallback(event => {
296
+ if (event.button !== 0 || !event.isPrimary) {
297
+ return;
298
+ }
258
299
  // The handle has no native focus action. Prevent pointer-down from
259
300
  // moving focus off a form control on a tap; once the pointer actually
260
301
  // moves, BottomSheet dismisses the keyboard as sheet travel begins.
261
302
  event.preventDefault();
262
303
  beginDrag(event, measureHeight());
263
304
  }, [beginDrag, measureHeight]);
305
+ const handleContextMenu = useCallback(event => {
306
+ if (dragStateRef.current == null) {
307
+ return;
308
+ }
309
+ event.preventDefault();
310
+ cancelDrag(event.currentTarget);
311
+ }, [cancelDrag]);
312
+ const handleLostPointerCapture = useCallback(event => {
313
+ if (dragStateRef.current?.pointerId === event.pointerId) {
314
+ cancelDrag();
315
+ }
316
+ }, [cancelDrag]);
264
317
  const handlePointerMove = useCallback(event => {
265
318
  const state = dragStateRef.current;
266
319
  if (!state || state.pointerId !== event.pointerId) {
@@ -303,11 +356,11 @@ export function useSheetGestures({
303
356
  return;
304
357
  }
305
358
  const target = event.currentTarget;
306
- target.releasePointerCapture?.(event.pointerId);
307
359
  const delta = event.clientY - state.startCoord;
308
360
  const offset = Math.max(0, state.baseOffset + delta);
309
361
  const dir = delta === 0 ? 0 : delta > 0 ? 1 : -1;
310
362
  dragStateRef.current = null;
363
+ target.releasePointerCapture?.(event.pointerId);
311
364
  setIsDragging(false);
312
365
  settleFromDrag(offset, state.velocity, state.height || 1, dir, Math.abs(delta), state.baseOffset);
313
366
  }, [settleFromDrag]);
@@ -317,6 +370,9 @@ export function useSheetGestures({
317
370
  // native pan starts.
318
371
  const armedBodyRef = useRef(null);
319
372
  const handleBodyPointerDown = useCallback(event => {
373
+ if (event.button !== 0 || !event.isPrimary) {
374
+ return;
375
+ }
320
376
  const scroller = event.currentTarget;
321
377
  if (scroller.scrollTop > 0) {
322
378
  armedBodyRef.current = null;
@@ -363,11 +419,13 @@ export function useSheetGestures({
363
419
  const touchDragRef = useRef(null);
364
420
  const previousTouchHandlersRef = useRef(null);
365
421
  const beginDragRef = useRef(beginDrag);
422
+ const cancelDragRef = useRef(cancelDrag);
366
423
  const pointerMoveRef = useRef(handlePointerMove);
367
424
  const endDragRef = useRef(endDrag);
368
425
  const measureHeightRef = useRef(measureHeight);
369
426
  useEffect(() => {
370
427
  beginDragRef.current = beginDrag;
428
+ cancelDragRef.current = cancelDrag;
371
429
  pointerMoveRef.current = handlePointerMove;
372
430
  endDragRef.current = endDrag;
373
431
  measureHeightRef.current = measureHeight;
@@ -443,11 +501,18 @@ export function useSheetGestures({
443
501
  };
444
502
  const onTouchEnd = event => {
445
503
  touchDragRef.current = null;
446
- if (dragStateRef.current) {
447
- const t = event.changedTouches[0];
448
- if (t) {
449
- endDragRef.current(asPointer(t, event.currentTarget));
450
- }
504
+ const pointerId = dragStateRef.current?.pointerId;
505
+ if (pointerId == null) {
506
+ return;
507
+ }
508
+ const t = [...event.changedTouches].find(touch => touch.identifier === pointerId);
509
+ const target = event.currentTarget;
510
+ if (t) {
511
+ endDragRef.current(asPointer(t, target));
512
+ } else if (event.touches.length === 0) {
513
+ // Some interrupted multi-touch sequences omit the active touch from
514
+ // changedTouches. If no fingers remain, the drag cannot finish later.
515
+ cancelDragRef.current(target);
451
516
  }
452
517
  };
453
518
  const prev = bodyNodeRef.current;
@@ -498,18 +563,22 @@ export function useSheetGestures({
498
563
  touchAction: 'none',
499
564
  cursor: 'grab'
500
565
  },
566
+ onContextMenu: handleContextMenu,
567
+ onLostPointerCapture: handleLostPointerCapture,
501
568
  onPointerDown: handlePointerDown,
502
569
  onPointerMove: handlePointerMove,
503
570
  onPointerUp: endDrag,
504
571
  onPointerCancel: endDrag
505
- }), [handlePointerDown, handlePointerMove, endDrag]);
572
+ }), [endDrag, handleContextMenu, handleLostPointerCapture, handlePointerDown, handlePointerMove]);
506
573
  const bodyProps = useMemo(() => ({
507
574
  ref: bodyRef,
575
+ onContextMenu: handleContextMenu,
576
+ onLostPointerCapture: handleLostPointerCapture,
508
577
  onPointerDown: handleBodyPointerDown,
509
578
  onPointerMove: handleBodyPointerMove,
510
579
  onPointerUp: handleBodyEnd,
511
580
  onPointerCancel: handleBodyEnd
512
- }), [bodyRef, handleBodyPointerDown, handleBodyPointerMove, handleBodyEnd]);
581
+ }), [bodyRef, handleBodyEnd, handleBodyPointerDown, handleBodyPointerMove, handleContextMenu, handleLostPointerCapture]);
513
582
  return {
514
583
  sheetRef,
515
584
  contentProps,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@astryxdesign/lab",
3
- "version": "0.4.2-canary.10bcaa1",
3
+ "version": "0.4.2-canary.255e810",
4
4
  "description": "Experimental Astryx components — published to npm only under the @canary dist-tag for early testing; never released as a stable (latest) version.",
5
5
  "author": "Meta Open Source",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  },
46
46
  "peerDependencies": {
47
47
  "@stylexjs/stylex": ">=0.10.0",
48
- "@astryxdesign/core": "0.4.2-canary.10bcaa1",
48
+ "@astryxdesign/core": "0.4.2-canary.255e810",
49
49
  "react": ">=19.0.0",
50
50
  "react-dom": ">=19.0.0"
51
51
  },
@@ -55,7 +55,7 @@
55
55
  "d3-shape": "^3.2.0"
56
56
  },
57
57
  "devDependencies": {
58
- "@astryxdesign/cli": "0.4.2-canary.10bcaa1",
58
+ "@astryxdesign/cli": "0.4.2-canary.255e810",
59
59
  "@types/d3-array": "^3.2.2",
60
60
  "@types/d3-scale": "^4.0.9",
61
61
  "@types/d3-shape": "^3.1.8",
@@ -185,7 +185,12 @@ function getHandle(): HTMLElement {
185
185
  // clientY, so dispatch plain events with the coords the handlers read.
186
186
  function drag(handle: HTMLElement, points: {y: number}[]) {
187
187
  const [down, ...rest] = points;
188
- fireEvent.pointerDown(handle, {pointerId: 1, clientY: down.y});
188
+ fireEvent.pointerDown(handle, {
189
+ pointerId: 1,
190
+ clientY: down.y,
191
+ button: 0,
192
+ isPrimary: true,
193
+ });
189
194
  for (const p of rest) {
190
195
  fireEvent.pointerMove(handle, {pointerId: 1, clientY: p.y});
191
196
  }
@@ -216,7 +221,9 @@ function fireTimedPointer(
216
221
  ) {
217
222
  const event = new Event(type, {bubbles: true, cancelable: true});
218
223
  Object.defineProperties(event, {
224
+ button: {value: 0},
219
225
  clientY: {value: y},
226
+ isPrimary: {value: true},
220
227
  pointerId: {value: 1},
221
228
  timeStamp: {value: time},
222
229
  });
@@ -534,6 +541,32 @@ describe('BottomSheet', () => {
534
541
  drag(getHandle(), [{y: 0}, {y: 40}, {y: 120}]);
535
542
  expect(onOpenChange).toHaveBeenCalledWith(false);
536
543
  });
544
+
545
+ it('restores the sheet when a context menu interrupts an active drag', () => {
546
+ const onOpenChange = vi.fn();
547
+ render(
548
+ <BottomSheet isOpen onOpenChange={onOpenChange} label="Filters">
549
+ Content
550
+ </BottomSheet>,
551
+ );
552
+ const handle = getHandle();
553
+ const dialog = screen.getByRole('dialog');
554
+
555
+ fireEvent.pointerDown(handle, {
556
+ pointerId: 1,
557
+ clientY: 0,
558
+ button: 0,
559
+ isPrimary: true,
560
+ });
561
+ fireEvent.pointerMove(handle, {pointerId: 1, clientY: 300});
562
+ expect(getSheet().style.transform).toBe('translateY(300px)');
563
+
564
+ expect(fireEvent.contextMenu(handle)).toBe(false);
565
+
566
+ expect(getSheet().style.transform).toBe('');
567
+ expect(dialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
568
+ expect(onOpenChange).not.toHaveBeenCalled();
569
+ });
537
570
  });
538
571
 
539
572
  describe('height', () => {
@@ -1122,6 +1155,8 @@ describe('BottomSheet', () => {
1122
1155
  const pointerDownAllowed = fireEvent.pointerDown(getHandle(), {
1123
1156
  pointerId: 1,
1124
1157
  clientY: 0,
1158
+ button: 0,
1159
+ isPrimary: true,
1125
1160
  });
1126
1161
  expect(pointerDownAllowed).toBe(false);
1127
1162
  expect(document.activeElement).toBe(input);
@@ -484,6 +484,8 @@ describe('BottomSheetSwitcher', () => {
484
484
  pointerId: 1,
485
485
  clientY: 100,
486
486
  timeStamp: 0,
487
+ button: 0,
488
+ isPrimary: true,
487
489
  });
488
490
  fireEvent.pointerMove(handle, {
489
491
  pointerId: 1,
@@ -665,7 +667,12 @@ describe('BottomSheetSwitcher', () => {
665
667
  }
666
668
 
667
669
  fireEvent.click(dialog);
668
- fireEvent.pointerDown(handle, {pointerId: 1, clientY: 0});
670
+ fireEvent.pointerDown(handle, {
671
+ pointerId: 1,
672
+ clientY: 0,
673
+ button: 0,
674
+ isPrimary: true,
675
+ });
669
676
  fireEvent.pointerMove(handle, {pointerId: 1, clientY: 120});
670
677
  fireEvent.pointerUp(handle, {pointerId: 1, clientY: 120});
671
678
 
@@ -704,7 +711,12 @@ describe('BottomSheetSwitcher', () => {
704
711
  fireEvent.click(dialog);
705
712
  fireEvent.keyDown(dialog, {key: 'Escape'});
706
713
  fireEvent(dialog, new Event('cancel', {cancelable: true}));
707
- fireEvent.pointerDown(handle, {pointerId: 1, clientY: 0});
714
+ fireEvent.pointerDown(handle, {
715
+ pointerId: 1,
716
+ clientY: 0,
717
+ button: 0,
718
+ isPrimary: true,
719
+ });
708
720
  fireEvent.pointerMove(handle, {pointerId: 1, clientY: 120});
709
721
  fireEvent.pointerUp(handle, {pointerId: 1, clientY: 120});
710
722
 
@@ -712,6 +724,40 @@ describe('BottomSheetSwitcher', () => {
712
724
  expect(dialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
713
725
  });
714
726
 
727
+ it('restores the active sheet when a context menu interrupts its drag', () => {
728
+ const onActiveSheetChange = vi.fn();
729
+ render(
730
+ <BottomSheetSwitcher
731
+ activeSheet="details"
732
+ onActiveSheetChange={onActiveSheetChange}>
733
+ <BottomSheet sheetId="details" label="Details">
734
+ Content
735
+ </BottomSheet>
736
+ </BottomSheetSwitcher>,
737
+ );
738
+ const dialog = getSharedDialog();
739
+ const panel = getSheetPanel(dialog);
740
+ const handle = panel.firstElementChild;
741
+ if (!(handle instanceof HTMLElement)) {
742
+ throw new Error('sheet handle not found');
743
+ }
744
+
745
+ fireEvent.pointerDown(handle, {
746
+ pointerId: 1,
747
+ clientY: 0,
748
+ button: 0,
749
+ isPrimary: true,
750
+ });
751
+ fireEvent.pointerMove(handle, {pointerId: 1, clientY: 300});
752
+ expect(panel.style.transform).toBe('translateY(300px)');
753
+
754
+ expect(fireEvent.contextMenu(handle)).toBe(false);
755
+
756
+ expect(panel.style.transform).toBe('');
757
+ expect(dialog).toHaveStyle({'--_sheet-scrim-opacity': '1'});
758
+ expect(onActiveSheetChange).not.toHaveBeenCalled();
759
+ });
760
+
715
761
  it('ignores Escape while an IME composition is active', () => {
716
762
  const onActiveSheetChange = vi.fn();
717
763
  render(
@@ -26,9 +26,13 @@ function pointerEvent(
26
26
  timeStamp: number,
27
27
  target: HTMLElement,
28
28
  pointerId = 1,
29
+ button = 0,
30
+ isPrimary = true,
29
31
  ) {
30
32
  return {
31
33
  pointerId,
34
+ button,
35
+ isPrimary,
32
36
  clientY,
33
37
  timeStamp,
34
38
  currentTarget: target,
@@ -288,6 +292,62 @@ describe('useSheetGestures', () => {
288
292
  );
289
293
  });
290
294
 
295
+ it('returns to the settled detent when a context menu interrupts a drag', () => {
296
+ const onScrimOpacity = vi.fn();
297
+ const {hook, onDismiss} = setup({
298
+ snapHeights: () => [200, 300],
299
+ onScrimOpacity,
300
+ });
301
+ const target = makeTarget();
302
+ down(hook, 0, 0, target);
303
+ move(hook, 300, 1000, target);
304
+
305
+ const preventDefault = vi.fn();
306
+ act(() =>
307
+ hook.result.current.handleProps.onContextMenu({
308
+ currentTarget: target,
309
+ preventDefault,
310
+ } as unknown as React.MouseEvent),
311
+ );
312
+
313
+ expect(preventDefault).toHaveBeenCalledTimes(1);
314
+ expect(hook.result.current.isDragging).toBe(false);
315
+ expect(hook.result.current.contentProps.style.transform).toBeUndefined();
316
+ expect(onScrimOpacity).toHaveBeenLastCalledWith(1);
317
+ expect(onDismiss).not.toHaveBeenCalled();
318
+ });
319
+
320
+ it('returns to the settled detent when pointer capture is lost', () => {
321
+ const {hook, onDismiss} = setup({snapHeights: () => [200]});
322
+ const target = makeTarget();
323
+ down(hook, 0, 0, target);
324
+ move(hook, 300, 1000, target);
325
+
326
+ act(() =>
327
+ hook.result.current.handleProps.onLostPointerCapture(
328
+ pointerEvent(300, 1000, target),
329
+ ),
330
+ );
331
+
332
+ expect(hook.result.current.isDragging).toBe(false);
333
+ expect(hook.result.current.contentProps.style.transform).toBeUndefined();
334
+ expect(onDismiss).not.toHaveBeenCalled();
335
+ });
336
+
337
+ it('does not start a drag from a secondary pointer button', () => {
338
+ const {hook} = setup();
339
+ const target = makeTarget();
340
+ act(() => hook.result.current.sheetRef(target));
341
+
342
+ act(() =>
343
+ hook.result.current.handleProps.onPointerDown(
344
+ pointerEvent(0, 0, target, 1, 2),
345
+ ),
346
+ );
347
+
348
+ expect(hook.result.current.isDragging).toBe(false);
349
+ });
350
+
291
351
  it('rubber-bands an upward drag past the fully-open position', () => {
292
352
  const {hook} = setup({snapHeights: () => [200]});
293
353
  const t = makeTarget();
@@ -394,6 +454,12 @@ describe('useSheetGestures', () => {
394
454
  Object.defineProperty(ev, 'changedTouches', {
395
455
  value: [{identifier: id, clientY: y}],
396
456
  });
457
+ Object.defineProperty(ev, 'touches', {
458
+ value:
459
+ type === 'touchend' || type === 'touchcancel'
460
+ ? []
461
+ : [{identifier: id, clientY: y}],
462
+ });
397
463
  Object.defineProperty(ev, 'currentTarget', {value: el});
398
464
  el.dispatchEvent(ev);
399
465
  return ev;
@@ -432,6 +498,36 @@ describe('useSheetGestures', () => {
432
498
  expect(hook.result.current.isDragging).toBe(true);
433
499
  });
434
500
 
501
+ it('finishes the active drag when another ended touch is listed first', () => {
502
+ const {hook} = setup({snapHeights: () => [200]});
503
+ const el = makeScroller({
504
+ scrollTop: 0,
505
+ clientHeight: 200,
506
+ scrollHeight: 800,
507
+ });
508
+ act(() => hook.result.current.sheetRef(el));
509
+ act(() => hook.result.current.bodyProps.ref(el));
510
+ act(() => {
511
+ touch(el, 'touchstart', 0, 2);
512
+ touch(el, 'touchmove', 300, 2);
513
+
514
+ const end = new Event('touchend', {
515
+ bubbles: true,
516
+ cancelable: true,
517
+ });
518
+ Object.defineProperty(end, 'changedTouches', {
519
+ value: [
520
+ {identifier: 1, clientY: 100},
521
+ {identifier: 2, clientY: 300},
522
+ ],
523
+ });
524
+ Object.defineProperty(end, 'touches', {value: []});
525
+ el.dispatchEvent(end);
526
+ });
527
+
528
+ expect(hook.result.current.isDragging).toBe(false);
529
+ });
530
+
435
531
  it('leaves native scrolling alone in the middle of the content', () => {
436
532
  const {hook} = setup({snapHeights: () => [200]});
437
533
  const el = makeScroller({
@@ -34,6 +34,7 @@ import {
34
34
  useRef,
35
35
  useState,
36
36
  type CSSProperties,
37
+ type MouseEvent as ReactMouseEvent,
37
38
  type PointerEvent as ReactPointerEvent,
38
39
  } from 'react';
39
40
  import {
@@ -137,6 +138,8 @@ export interface SheetContentProps {
137
138
 
138
139
  export interface SheetHandleProps {
139
140
  style: CSSProperties;
141
+ onContextMenu: (event: ReactMouseEvent) => void;
142
+ onLostPointerCapture: (event: ReactPointerEvent) => void;
140
143
  onPointerDown: (event: ReactPointerEvent) => void;
141
144
  onPointerMove: (event: ReactPointerEvent) => void;
142
145
  onPointerUp: (event: ReactPointerEvent) => void;
@@ -145,6 +148,8 @@ export interface SheetHandleProps {
145
148
 
146
149
  export interface SheetBodyProps {
147
150
  ref: (node: HTMLElement | null) => void;
151
+ onContextMenu: (event: ReactMouseEvent) => void;
152
+ onLostPointerCapture: (event: ReactPointerEvent) => void;
148
153
  onPointerDown: (event: ReactPointerEvent) => void;
149
154
  onPointerMove: (event: ReactPointerEvent) => void;
150
155
  onPointerUp: (event: ReactPointerEvent) => void;
@@ -292,6 +297,52 @@ export function useSheetGestures({
292
297
  return computeDetentOffsets(height, snapHeightsRef.current?.() ?? []);
293
298
  }, []);
294
299
 
300
+ const cancelDrag = useCallback(
301
+ (target?: HTMLElement) => {
302
+ const state = dragStateRef.current;
303
+ if (state == null) {
304
+ return;
305
+ }
306
+
307
+ // Clear first: releasePointerCapture() may synchronously dispatch
308
+ // lostpointercapture, which must observe that this drag is already done.
309
+ dragStateRef.current = null;
310
+ if (target?.hasPointerCapture?.(state.pointerId)) {
311
+ target.releasePointerCapture(state.pointerId);
312
+ }
313
+ setDragOffset(state.baseOffset);
314
+ setIsDragging(false);
315
+
316
+ // An interrupted drag returns to its previous resting detent. Restore
317
+ // the matching scrim opacity as well so the modal shell cannot remain
318
+ // dimmed with its sheet translated out of view.
319
+ const offsets = detentOffsets(state.height);
320
+ const maxOffset = offsets[offsets.length - 1];
321
+ const shortestDetentHeight = state.height - maxOffset;
322
+ const dismissOffset =
323
+ maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO;
324
+ onScrimOpacityRef.current?.(
325
+ scrimOpacityForOffset(state.baseOffset, offsets, dismissOffset),
326
+ );
327
+ },
328
+ [detentOffsets],
329
+ );
330
+
331
+ useEffect(() => {
332
+ const handleWindowBlur = () => cancelDrag();
333
+ const handleVisibilityChange = () => {
334
+ if (document.visibilityState === 'hidden') {
335
+ cancelDrag();
336
+ }
337
+ };
338
+ window.addEventListener('blur', handleWindowBlur);
339
+ document.addEventListener('visibilitychange', handleVisibilityChange);
340
+ return () => {
341
+ window.removeEventListener('blur', handleWindowBlur);
342
+ document.removeEventListener('visibilitychange', handleVisibilityChange);
343
+ };
344
+ }, [cancelDrag]);
345
+
295
346
  const settleFromDrag = useCallback(
296
347
  (
297
348
  offset: number,
@@ -379,6 +430,9 @@ export function useSheetGestures({
379
430
 
380
431
  const handlePointerDown = useCallback(
381
432
  (event: ReactPointerEvent) => {
433
+ if (event.button !== 0 || !event.isPrimary) {
434
+ return;
435
+ }
382
436
  // The handle has no native focus action. Prevent pointer-down from
383
437
  // moving focus off a form control on a tap; once the pointer actually
384
438
  // moves, BottomSheet dismisses the keyboard as sheet travel begins.
@@ -388,6 +442,26 @@ export function useSheetGestures({
388
442
  [beginDrag, measureHeight],
389
443
  );
390
444
 
445
+ const handleContextMenu = useCallback(
446
+ (event: ReactMouseEvent) => {
447
+ if (dragStateRef.current == null) {
448
+ return;
449
+ }
450
+ event.preventDefault();
451
+ cancelDrag(event.currentTarget as HTMLElement);
452
+ },
453
+ [cancelDrag],
454
+ );
455
+
456
+ const handleLostPointerCapture = useCallback(
457
+ (event: ReactPointerEvent) => {
458
+ if (dragStateRef.current?.pointerId === event.pointerId) {
459
+ cancelDrag();
460
+ }
461
+ },
462
+ [cancelDrag],
463
+ );
464
+
391
465
  const handlePointerMove = useCallback(
392
466
  (event: ReactPointerEvent) => {
393
467
  const state = dragStateRef.current;
@@ -439,11 +513,11 @@ export function useSheetGestures({
439
513
  return;
440
514
  }
441
515
  const target = event.currentTarget as HTMLElement;
442
- target.releasePointerCapture?.(event.pointerId);
443
516
  const delta = event.clientY - state.startCoord;
444
517
  const offset = Math.max(0, state.baseOffset + delta);
445
518
  const dir = delta === 0 ? 0 : delta > 0 ? 1 : -1;
446
519
  dragStateRef.current = null;
520
+ target.releasePointerCapture?.(event.pointerId);
447
521
  setIsDragging(false);
448
522
  settleFromDrag(
449
523
  offset,
@@ -467,6 +541,9 @@ export function useSheetGestures({
467
541
  } | null>(null);
468
542
 
469
543
  const handleBodyPointerDown = useCallback((event: ReactPointerEvent) => {
544
+ if (event.button !== 0 || !event.isPrimary) {
545
+ return;
546
+ }
470
547
  const scroller = event.currentTarget as HTMLElement;
471
548
  if (scroller.scrollTop > 0) {
472
549
  armedBodyRef.current = null;
@@ -531,11 +608,13 @@ export function useSheetGestures({
531
608
  } | null>(null);
532
609
 
533
610
  const beginDragRef = useRef(beginDrag);
611
+ const cancelDragRef = useRef(cancelDrag);
534
612
  const pointerMoveRef = useRef(handlePointerMove);
535
613
  const endDragRef = useRef(endDrag);
536
614
  const measureHeightRef = useRef(measureHeight);
537
615
  useEffect(() => {
538
616
  beginDragRef.current = beginDrag;
617
+ cancelDragRef.current = cancelDrag;
539
618
  pointerMoveRef.current = handlePointerMove;
540
619
  endDragRef.current = endDrag;
541
620
  measureHeightRef.current = measureHeight;
@@ -624,11 +703,20 @@ export function useSheetGestures({
624
703
 
625
704
  const onTouchEnd = (event: TouchEvent) => {
626
705
  touchDragRef.current = null;
627
- if (dragStateRef.current) {
628
- const t = event.changedTouches[0];
629
- if (t) {
630
- endDragRef.current(asPointer(t, event.currentTarget as HTMLElement));
631
- }
706
+ const pointerId = dragStateRef.current?.pointerId;
707
+ if (pointerId == null) {
708
+ return;
709
+ }
710
+ const t = [...event.changedTouches].find(
711
+ touch => touch.identifier === pointerId,
712
+ );
713
+ const target = event.currentTarget as HTMLElement;
714
+ if (t) {
715
+ endDragRef.current(asPointer(t, target));
716
+ } else if (event.touches.length === 0) {
717
+ // Some interrupted multi-touch sequences omit the active touch from
718
+ // changedTouches. If no fingers remain, the drag cannot finish later.
719
+ cancelDragRef.current(target);
632
720
  }
633
721
  };
634
722
 
@@ -677,23 +765,40 @@ export function useSheetGestures({
677
765
  const handleProps = useMemo<SheetHandleProps>(
678
766
  () => ({
679
767
  style: {touchAction: 'none', cursor: 'grab'},
768
+ onContextMenu: handleContextMenu,
769
+ onLostPointerCapture: handleLostPointerCapture,
680
770
  onPointerDown: handlePointerDown,
681
771
  onPointerMove: handlePointerMove,
682
772
  onPointerUp: endDrag,
683
773
  onPointerCancel: endDrag,
684
774
  }),
685
- [handlePointerDown, handlePointerMove, endDrag],
775
+ [
776
+ endDrag,
777
+ handleContextMenu,
778
+ handleLostPointerCapture,
779
+ handlePointerDown,
780
+ handlePointerMove,
781
+ ],
686
782
  );
687
783
 
688
784
  const bodyProps = useMemo<SheetBodyProps>(
689
785
  () => ({
690
786
  ref: bodyRef,
787
+ onContextMenu: handleContextMenu,
788
+ onLostPointerCapture: handleLostPointerCapture,
691
789
  onPointerDown: handleBodyPointerDown,
692
790
  onPointerMove: handleBodyPointerMove,
693
791
  onPointerUp: handleBodyEnd,
694
792
  onPointerCancel: handleBodyEnd,
695
793
  }),
696
- [bodyRef, handleBodyPointerDown, handleBodyPointerMove, handleBodyEnd],
794
+ [
795
+ bodyRef,
796
+ handleBodyEnd,
797
+ handleBodyPointerDown,
798
+ handleBodyPointerMove,
799
+ handleContextMenu,
800
+ handleLostPointerCapture,
801
+ ],
697
802
  );
698
803
 
699
804
  return {
@@ -241,6 +241,14 @@ export const docs = {
241
241
  },
242
242
  ],
243
243
  },
244
+ theming: {
245
+ targets: [
246
+ {
247
+ className: 'astryx-list-input',
248
+ visualProps: ['state', 'reorderable'],
249
+ },
250
+ ],
251
+ },
244
252
  examples: [
245
253
  {
246
254
  label: 'Controlled guest list',