@astryxdesign/core 0.4.4 → 0.4.5-canary.1dc3e5d

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 (176) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/dist/BottomSheet/BottomSheet.d.ts +11 -2
  3. package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
  4. package/dist/BottomSheet/BottomSheet.js +4 -0
  5. package/dist/BottomSheet/BottomSheetPanel.d.ts +5 -2
  6. package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
  7. package/dist/BottomSheet/BottomSheetPanel.js +56 -13
  8. package/dist/BottomSheet/index.d.ts +1 -1
  9. package/dist/BottomSheet/index.d.ts.map +1 -1
  10. package/dist/BottomSheet/snapOffsets.d.ts +57 -20
  11. package/dist/BottomSheet/snapOffsets.d.ts.map +1 -1
  12. package/dist/BottomSheet/snapOffsets.js +106 -30
  13. package/dist/BottomSheet/useSheetGestures.d.ts +13 -8
  14. package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
  15. package/dist/BottomSheet/useSheetGestures.js +160 -64
  16. package/dist/Calendar/Calendar.d.ts +17 -0
  17. package/dist/Calendar/Calendar.d.ts.map +1 -1
  18. package/dist/Calendar/Calendar.js +34 -3
  19. package/dist/Calendar/getStandaloneShortWeekdayNames.d.ts +14 -0
  20. package/dist/Calendar/getStandaloneShortWeekdayNames.d.ts.map +1 -0
  21. package/dist/Calendar/getStandaloneShortWeekdayNames.js +26 -0
  22. package/dist/Calendar/hooks/useCalendarConstraints.d.ts +22 -1
  23. package/dist/Calendar/hooks/useCalendarConstraints.d.ts.map +1 -1
  24. package/dist/Calendar/hooks/useCalendarConstraints.js +26 -4
  25. package/dist/Calendar/hooks/useCalendarDays.d.ts.map +1 -1
  26. package/dist/Calendar/hooks/useCalendarDays.js +14 -10
  27. package/dist/Calendar/standaloneShortWeekdayNames.generated.d.ts +47 -0
  28. package/dist/Calendar/standaloneShortWeekdayNames.generated.d.ts.map +1 -0
  29. package/dist/Calendar/standaloneShortWeekdayNames.generated.js +40 -0
  30. package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
  31. package/dist/CheckboxInput/CheckboxInput.js +9 -0
  32. package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
  33. package/dist/ComplexSelector/ComplexSelector.js +6 -1
  34. package/dist/DateInput/DateInput.d.ts.map +1 -1
  35. package/dist/DateInput/DateInput.js +6 -1
  36. package/dist/DateRangeInput/DateRangeInput.d.ts +23 -1
  37. package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
  38. package/dist/DateRangeInput/DateRangeInput.js +38 -3
  39. package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
  40. package/dist/DateTimeInput/DateTimeInput.js +7 -2
  41. package/dist/Field/FieldLabel.d.ts +2 -1
  42. package/dist/Field/FieldLabel.d.ts.map +1 -1
  43. package/dist/Field/FieldLabel.js +21 -3
  44. package/dist/Field/InputClearButton.d.ts +2 -1
  45. package/dist/Field/InputClearButton.d.ts.map +1 -1
  46. package/dist/Field/InputClearButton.js +6 -1
  47. package/dist/FormLayout/FormLayout.d.ts +24 -2
  48. package/dist/FormLayout/FormLayout.d.ts.map +1 -1
  49. package/dist/FormLayout/FormLayout.js +7 -2
  50. package/dist/FormLayout/FormLayoutContext.d.ts +15 -2
  51. package/dist/FormLayout/FormLayoutContext.d.ts.map +1 -1
  52. package/dist/FormLayout/FormLayoutContext.js +19 -4
  53. package/dist/FormLayout/index.d.ts +1 -1
  54. package/dist/FormLayout/index.d.ts.map +1 -1
  55. package/dist/Markdown/Markdown.d.ts +8 -0
  56. package/dist/Markdown/Markdown.d.ts.map +1 -1
  57. package/dist/Markdown/Markdown.js +31 -3
  58. package/dist/Markdown/parser.d.ts +14 -2
  59. package/dist/Markdown/parser.d.ts.map +1 -1
  60. package/dist/Markdown/parser.js +50 -2
  61. package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
  62. package/dist/MultiSelector/MultiSelector.js +6 -1
  63. package/dist/NumberInput/NumberInput.d.ts.map +1 -1
  64. package/dist/NumberInput/NumberInput.js +6 -1
  65. package/dist/Outline/parseOutlineFromMarkdown.d.ts +2 -0
  66. package/dist/Outline/parseOutlineFromMarkdown.d.ts.map +1 -1
  67. package/dist/Outline/parseOutlineFromMarkdown.js +5 -31
  68. package/dist/RadioList/RadioList.d.ts.map +1 -1
  69. package/dist/RadioList/RadioList.js +12 -1
  70. package/dist/Selector/Selector.d.ts.map +1 -1
  71. package/dist/Selector/Selector.js +6 -1
  72. package/dist/StatusDot/StatusDot.d.ts +43 -3
  73. package/dist/StatusDot/StatusDot.d.ts.map +1 -1
  74. package/dist/StatusDot/StatusDot.js +42 -5
  75. package/dist/Switch/Switch.d.ts.map +1 -1
  76. package/dist/Switch/Switch.js +9 -0
  77. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
  78. package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
  79. package/dist/TextArea/TextArea.d.ts.map +1 -1
  80. package/dist/TextArea/TextArea.js +6 -1
  81. package/dist/TextInput/TextInput.d.ts.map +1 -1
  82. package/dist/TextInput/TextInput.js +6 -1
  83. package/dist/TimeInput/TimeInput.d.ts.map +1 -1
  84. package/dist/TimeInput/TimeInput.js +6 -1
  85. package/dist/hooks/useResolvedRequired.d.ts +19 -0
  86. package/dist/hooks/useResolvedRequired.d.ts.map +1 -0
  87. package/dist/hooks/useResolvedRequired.js +40 -0
  88. package/dist/utils/plainDate.d.ts +6 -0
  89. package/dist/utils/plainDate.d.ts.map +1 -1
  90. package/dist/utils/plainDate.js +12 -0
  91. package/locales/af-ZA.json +958 -0
  92. package/locales/ar-SA.json +998 -0
  93. package/locales/ca-ES.json +974 -0
  94. package/locales/cs-CZ.json +990 -0
  95. package/locales/da-DK.json +966 -0
  96. package/locales/de-DE.json +966 -0
  97. package/locales/el-GR.json +990 -0
  98. package/locales/en.json +4 -0
  99. package/locales/es-ES.json +978 -0
  100. package/locales/fi-FI.json +990 -0
  101. package/locales/fr-FR.json +928 -0
  102. package/locales/he-IL.json +990 -0
  103. package/locales/hu-HU.json +990 -0
  104. package/locales/it-IT.json +978 -0
  105. package/locales/ja-JP.json +998 -0
  106. package/locales/ko-KR.json +990 -0
  107. package/locales/nl-NL.json +954 -0
  108. package/locales/no-NO.json +978 -0
  109. package/locales/pl-PL.json +982 -0
  110. package/locales/pseudo.json +3 -0
  111. package/locales/pt-BR.json +982 -0
  112. package/locales/pt-PT.json +982 -0
  113. package/locales/ro-RO.json +982 -0
  114. package/locales/ru-RU.json +990 -0
  115. package/locales/sr-SP.json +990 -0
  116. package/locales/sv-SE.json +986 -0
  117. package/locales/tr-TR.json +990 -0
  118. package/locales/uk-UA.json +990 -0
  119. package/locales/vi-VN.json +986 -0
  120. package/locales/zh-CN.json +998 -0
  121. package/locales/zh-TW.json +998 -0
  122. package/package.json +3 -3
  123. package/src/BottomSheet/BottomSheet.doc.mjs +36 -3
  124. package/src/BottomSheet/BottomSheet.test.tsx +153 -2
  125. package/src/BottomSheet/BottomSheet.tsx +16 -2
  126. package/src/BottomSheet/BottomSheetPanel.tsx +67 -13
  127. package/src/BottomSheet/index.ts +5 -1
  128. package/src/BottomSheet/snapOffsets.test.ts +114 -47
  129. package/src/BottomSheet/snapOffsets.ts +119 -33
  130. package/src/BottomSheet/useSheetGestures.test.ts +113 -15
  131. package/src/BottomSheet/useSheetGestures.ts +196 -100
  132. package/src/Calendar/Calendar.doc.mjs +16 -0
  133. package/src/Calendar/Calendar.test.tsx +137 -0
  134. package/src/Calendar/Calendar.tsx +65 -2
  135. package/src/Calendar/getStandaloneShortWeekdayNames.test.ts +54 -0
  136. package/src/Calendar/getStandaloneShortWeekdayNames.ts +38 -0
  137. package/src/Calendar/hooks/useCalendarConstraints.ts +54 -3
  138. package/src/Calendar/hooks/useCalendarDays.ts +13 -10
  139. package/src/Calendar/standaloneShortWeekdayNames.generated.ts +50 -0
  140. package/src/CheckboxInput/CheckboxInput.tsx +6 -0
  141. package/src/ComplexSelector/ComplexSelector.tsx +3 -1
  142. package/src/DateInput/DateInput.tsx +3 -1
  143. package/src/DateRangeInput/DateRangeInput.doc.mjs +16 -0
  144. package/src/DateRangeInput/DateRangeInput.test.tsx +81 -1
  145. package/src/DateRangeInput/DateRangeInput.tsx +72 -1
  146. package/src/DateTimeInput/DateTimeInput.tsx +4 -2
  147. package/src/Field/Field.doc.mjs +1 -0
  148. package/src/Field/FieldLabel.tsx +20 -4
  149. package/src/Field/InputClearButton.test.tsx +25 -0
  150. package/src/Field/InputClearButton.tsx +4 -1
  151. package/src/FormLayout/FormLayout.doc.mjs +13 -0
  152. package/src/FormLayout/FormLayout.test.tsx +181 -4
  153. package/src/FormLayout/FormLayout.tsx +33 -2
  154. package/src/FormLayout/FormLayoutContext.ts +22 -7
  155. package/src/FormLayout/index.ts +1 -1
  156. package/src/Markdown/Markdown.doc.mjs +3 -0
  157. package/src/Markdown/Markdown.test.tsx +78 -0
  158. package/src/Markdown/Markdown.tsx +41 -1
  159. package/src/Markdown/parser.ts +60 -2
  160. package/src/MultiSelector/MultiSelector.tsx +3 -1
  161. package/src/NumberInput/NumberInput.tsx +3 -1
  162. package/src/Outline/Outline.doc.mjs +2 -0
  163. package/src/Outline/parseOutlineFromMarkdown.ts +10 -40
  164. package/src/RadioList/RadioList.tsx +9 -1
  165. package/src/Selector/Selector.tsx +3 -1
  166. package/src/StatusDot/StatusDot.doc.mjs +13 -0
  167. package/src/StatusDot/StatusDot.test.tsx +115 -8
  168. package/src/StatusDot/StatusDot.tsx +74 -9
  169. package/src/Switch/Switch.tsx +6 -0
  170. package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
  171. package/src/TextArea/TextArea.tsx +3 -1
  172. package/src/TextInput/TextInput.tsx +3 -1
  173. package/src/TimeInput/TimeInput.tsx +3 -1
  174. package/src/hooks/useResolvedRequired.ts +42 -0
  175. package/src/utils/plainDate.test.ts +50 -0
  176. package/src/utils/plainDate.ts +12 -0
@@ -19,14 +19,16 @@
19
19
  * the part the scrolling area gives up as layout height, and the remainder is
20
20
  * a transform. Gestures and snaps only ever move the transform, so they stay
21
21
  * on the compositor; layout height changes at rest, in one transition-free
22
- * render whose visible geometry is identical. The peek detent keeps the full
23
- * height and slides below the viewport instead of reflowing to a sliver.
22
+ * render whose visible geometry is identical. A peek detent — a stop that is
23
+ * only a sliver of the sheet — keeps the full height and slides below the
24
+ * viewport instead of reflowing to that sliver.
24
25
  *
25
- * Those are pixels, and the detents behind them are viewport fractions, so a
26
- * sheet at rest re-resolves its detent on `resize` / `orientationchange` and
27
- * re-anchors to the new geometry without animating. The detent it returns to
28
- * is tracked by index: the pixels stop meaning the same thing when the
29
- * viewport changes, the stop the user chose does not.
26
+ * Those are pixels, and the stops behind them are relative to the viewport, so
27
+ * a sheet at rest re-resolves its detent on `resize` / `orientationchange`,
28
+ * and whenever the host swaps the snap points, and re-anchors to the new
29
+ * geometry without animating. The detent it returns to is tracked by index:
30
+ * the pixels stop meaning the same thing when the viewport changes, the stop
31
+ * the user chose does not.
30
32
  *
31
33
  * On touch, the scrolling body hands the gesture to the sheet at a scroll
32
34
  * edge. Two shapes, because the browser only offers one of them a choice: a
@@ -66,11 +68,20 @@ import {
66
68
  import {useMediaQuery} from '../hooks';
67
69
  import {
68
70
  computeDetentOffsets,
69
- isPeekOffset,
71
+ peekOffsetFor,
70
72
  resolveSettleOffset,
71
73
  scrimOpacityForOffset,
72
74
  } from './snapOffsets';
73
75
 
76
+ /**
77
+ * The stops a sheet of a given height rests at: offsets from fully-open in px,
78
+ * ascending, plus which of them (if any) is the peek.
79
+ */
80
+ interface SheetDetents {
81
+ offsets: number[];
82
+ peekOffset: number | null;
83
+ }
84
+
74
85
  // A flick (fast throw) dismisses (down) or expands (up) regardless of where
75
86
  // it ends. Requires both a speed and a distance floor so a small nudge
76
87
  // doesn't trigger it.
@@ -82,6 +93,19 @@ const DISMISS_OVERSHOOT_RATIO = 0.4;
82
93
  // Within this many px of a detent, the live drag is magnetically eased toward
83
94
  // it so it "clicks" into place instead of hovering just off the mark.
84
95
  const MAGNET_RANGE = 40;
96
+ // The touch path drives the sheet through the same machinery the pointer path
97
+ // uses, by handing it a pointer-shaped object built from a `Touch`. On iOS
98
+ // Safari that object is indistinguishable from the real thing: WebKit raises
99
+ // PointerEvents for a finger under the SAME numeric id it puts in
100
+ // `Touch.identifier`, so such a drag is keyed to a live pointer and the
101
+ // handlers that guard a mouse drag fire against it. Mark the synthetic object
102
+ // so they can tell the two apart.
103
+ type SyntheticTouchPointer = ReactPointerEvent & {syntheticTouch?: true};
104
+
105
+ function isSyntheticTouch(event: ReactPointerEvent): boolean {
106
+ return (event as SyntheticTouchPointer).syntheticTouch === true;
107
+ }
108
+
85
109
  // Rubber-band factor for dragging up past fully-open, capped at OVERSCROLL_MAX
86
110
  // (the sheet reserves that much bottom padding for the lift to reveal).
87
111
  const OVERSCROLL_RESISTANCE = 0.35;
@@ -192,6 +216,9 @@ export interface UseSheetGesturesOptions {
192
216
  * (rotation, a resized desktop window, collapsing browser chrome). The fully
193
217
  * open height is always the tallest detent. Omit for a single-height sheet
194
218
  * (a drag then only dismisses or springs back).
219
+ *
220
+ * Keep the identity stable: a new function is read as new stops, and
221
+ * re-anchors a resting sheet to them.
195
222
  */
196
223
  snapHeights?: () => number[];
197
224
  /** Notified when the settled visible detent height (px) changes. */
@@ -279,7 +306,7 @@ export interface UseSheetGesturesResult {
279
306
  /**
280
307
  * How much of `settledOffset` is expressed as layout height rather than as a
281
308
  * transform. Equals `settledOffset` at the resizing detents; 0 at fully open
282
- * and at the peek, which keep the sheet's full height (see isPeekOffset).
309
+ * and at the peek, which keep the sheet's full height (see peekOffsetFor).
283
310
  */
284
311
  settledLayoutOffset: number;
285
312
  }
@@ -326,7 +353,7 @@ export function useSheetGestures({
326
353
  const [scrollPreservationInset, setScrollPreservationInset] = useState(0);
327
354
  // How much of the settled travel is expressed as layout height. Equal to
328
355
  // settledOffset for the resizing detents, and 0 at fully open and at the
329
- // peek (see isPeekOffset), which keep the full height and use a transform.
356
+ // peek (see peekOffsetFor), which keep the full height and use a transform.
330
357
  const [settledLayoutOffset, setSettledLayoutOffset] = useState(0);
331
358
  const settledLayoutOffsetRef = useRef(0);
332
359
  // WHICH detent the sheet rests on, as an index into the resolved list. The
@@ -419,21 +446,30 @@ export function useSheetGestures({
419
446
  const isOpenRef = useRef(isOpen);
420
447
  isOpenRef.current = isOpen;
421
448
 
449
+ // The resolver is refreshed during render, not with the callbacks below: the
450
+ // layout effect that re-anchors on a change to it runs BEFORE passive
451
+ // effects, so a resolver parked in one would still be the previous set of
452
+ // stops by the time the re-anchor read it.
453
+ const snapHeightsRef = useRef(snapHeights);
454
+ snapHeightsRef.current = snapHeights;
455
+
422
456
  const onDismissRef = useRef(onDismiss);
423
457
  const canDismissRef = useRef(canDismiss);
424
458
  const onSnapRef = useRef(onSnap);
425
459
  const onScrimOpacityRef = useRef(onScrimOpacity);
426
- const snapHeightsRef = useRef(snapHeights);
427
460
  useEffect(() => {
428
461
  onDismissRef.current = onDismiss;
429
462
  canDismissRef.current = canDismiss;
430
463
  onSnapRef.current = onSnap;
431
464
  onScrimOpacityRef.current = onScrimOpacity;
432
- snapHeightsRef.current = snapHeights;
433
465
  });
434
466
 
435
467
  // Live drag bookkeeping (refs so pointermove doesn't churn renders).
436
468
  const dragStateRef = useRef<{
469
+ // Whether the touch path drives this drag. Such a drag holds no pointer
470
+ // capture and takes its events from `touchmove`, so every real-pointer
471
+ // handler has to leave it alone.
472
+ syntheticTouch: boolean;
437
473
  pointerId: number;
438
474
  startCoord: number;
439
475
  lastCoord: number;
@@ -545,20 +581,22 @@ export function useSheetGestures({
545
581
  return sheetElRef.current?.getBoundingClientRect().height ?? 0;
546
582
  }, []);
547
583
 
548
- // Detent translate offsets (px) from the tallest detent, ascending. Exclude
549
- // the border-box portion reserved below the viewport before comparing the
550
- // candidate visible heights; otherwise every snap point lands that many px
551
- // too low. Snap heights are resolved lazily so they track the viewport.
552
- const detentOffsets = useCallback((height: number): number[] => {
584
+ // Detent translate offsets (px) from the tallest detent, ascending, plus the
585
+ // peek among them. Exclude the border-box portion reserved below the
586
+ // viewport before comparing the candidate visible heights; otherwise every
587
+ // snap point lands that many px too low. Snap heights are resolved lazily so
588
+ // they track the viewport.
589
+ const resolveDetents = useCallback((height: number): SheetDetents => {
553
590
  const visibleSheetHeight = visibleHeightForOffset(
554
591
  height,
555
592
  0,
556
593
  offscreenBlockEndInsetRef.current,
557
594
  );
558
- return computeDetentOffsets(
595
+ const offsets = computeDetentOffsets(
559
596
  visibleSheetHeight,
560
597
  snapHeightsRef.current?.() ?? [],
561
598
  );
599
+ return {offsets, peekOffset: peekOffsetFor(offsets, visibleSheetHeight)};
562
600
  }, []);
563
601
 
564
602
  // The height the sheet WOULD have fully open, right now. While it rests at a
@@ -592,87 +630,105 @@ export function useSheetGestures({
592
630
  //
593
631
  // Re-resolve the SAME detent — by index, the one thing that survives the
594
632
  // units changing — against the new geometry, and re-anchor without
595
- // animating: the viewport moved, not the user's finger, so there is no
633
+ // animating: the geometry moved, not the user's finger, so there is no
596
634
  // gesture to continue and nothing to ease.
597
- useEffect(() => {
598
- if (!isOpen || typeof window === 'undefined') {
635
+ const reanchorToSettledDetent = useCallback(() => {
636
+ // A closed sheet re-anchors on its way back open; a live drag re-measures
637
+ // on its own, and a settle in flight owns the layout until it lands.
638
+ if (
639
+ !isOpenRef.current ||
640
+ dragStateRef.current != null ||
641
+ settlingLayoutOffsetRef.current != null
642
+ ) {
643
+ return;
644
+ }
645
+ const height = measureFullyOpenHeight();
646
+ if (height <= 0) {
647
+ return;
648
+ }
649
+ const {offsets, peekOffset} = resolveDetents(height);
650
+ const index = Math.min(settledDetentIndexRef.current, offsets.length - 1);
651
+ const target = offsets[index];
652
+ const targetLayoutOffset = target === peekOffset ? 0 : target;
653
+ const baseLayoutOffset = settledLayoutOffsetRef.current;
654
+ if (
655
+ height === sheetHeightRef.current &&
656
+ Math.abs(target - activeOffsetRef.current) <= 0.5 &&
657
+ Math.abs(targetLayoutOffset - baseLayoutOffset) <= 0.5
658
+ ) {
599
659
  return;
600
660
  }
601
- const handleViewportChange = () => {
602
- // A live drag re-measures on its own, and a settle in flight owns the
603
- // layout until it lands.
604
- if (
605
- dragStateRef.current != null ||
606
- settlingLayoutOffsetRef.current != null
607
- ) {
608
- return;
609
- }
610
- const height = measureFullyOpenHeight();
611
- if (height <= 0) {
612
- return;
613
- }
614
- const offsets = detentOffsets(height);
615
- const index = Math.min(settledDetentIndexRef.current, offsets.length - 1);
616
- const target = offsets[index];
617
- const targetLayoutOffset = isPeekOffset(target, offsets) ? 0 : target;
618
- const baseLayoutOffset = settledLayoutOffsetRef.current;
619
- if (
620
- height === sheetHeightRef.current &&
621
- Math.abs(target - activeOffsetRef.current) <= 0.5 &&
622
- Math.abs(targetLayoutOffset - baseLayoutOffset) <= 0.5
623
- ) {
624
- return;
625
- }
626
661
 
627
- sheetHeightRef.current = height;
628
- setSheetHeight(height);
629
- settledDetentIndexRef.current = index;
630
- prepareScrollAreaSettle(
631
- baseLayoutOffset,
632
- target,
633
- targetLayoutOffset,
662
+ sheetHeightRef.current = height;
663
+ // Re-anchoring is a measurement: the geometry it reads is only knowable
664
+ // after layout, so the state it corrects can only be set from an effect
665
+ // (or, on the resize path, from the listener). Both writes below are
666
+ // guarded by the equality check above, so a re-anchor that finds nothing
667
+ // to change sets nothing.
668
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- corrects settled geometry from a measurement
669
+ setSheetHeight(height);
670
+ settledDetentIndexRef.current = index;
671
+ prepareScrollAreaSettle(
672
+ baseLayoutOffset,
673
+ target,
674
+ targetLayoutOffset,
675
+ target,
676
+ baseLayoutOffset,
677
+ naturalEndGapFor(bodyNodeRef.current),
678
+ false,
679
+ );
680
+ recordSettledLayoutOffset(targetLayoutOffset);
681
+ // eslint-disable-next-line @eslint-react/set-state-in-effect -- corrects settled geometry from a measurement
682
+ setSettledOffset(target);
683
+ onSnapRef.current?.(
684
+ visibleHeightForOffset(height, target, offscreenBlockEndInsetRef.current),
685
+ );
686
+ const maxOffset = offsets[offsets.length - 1];
687
+ const shortestDetentHeight = visibleHeightForOffset(
688
+ height,
689
+ maxOffset,
690
+ offscreenBlockEndInsetRef.current,
691
+ );
692
+ onScrimOpacityRef.current?.(
693
+ scrimOpacityForOffset(
634
694
  target,
635
- baseLayoutOffset,
636
- naturalEndGapFor(bodyNodeRef.current),
637
- false,
638
- );
639
- recordSettledLayoutOffset(targetLayoutOffset);
640
- setSettledOffset(target);
641
- onSnapRef.current?.(
642
- visibleHeightForOffset(
643
- height,
644
- target,
645
- offscreenBlockEndInsetRef.current,
646
- ),
647
- );
648
- const maxOffset = offsets[offsets.length - 1];
649
- const shortestDetentHeight = visibleHeightForOffset(
650
- height,
651
- maxOffset,
652
- offscreenBlockEndInsetRef.current,
653
- );
654
- onScrimOpacityRef.current?.(
655
- scrimOpacityForOffset(
656
- target,
657
- offsets,
658
- maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO,
659
- ),
660
- );
661
- };
662
- window.addEventListener('resize', handleViewportChange);
663
- window.addEventListener('orientationchange', handleViewportChange);
664
- return () => {
665
- window.removeEventListener('resize', handleViewportChange);
666
- window.removeEventListener('orientationchange', handleViewportChange);
667
- };
695
+ offsets,
696
+ maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO,
697
+ peekOffset,
698
+ ),
699
+ );
668
700
  }, [
669
- detentOffsets,
670
- isOpen,
671
701
  measureFullyOpenHeight,
672
702
  prepareScrollAreaSettle,
673
703
  recordSettledLayoutOffset,
704
+ resolveDetents,
674
705
  ]);
675
706
 
707
+ useEffect(() => {
708
+ if (!isOpen || typeof window === 'undefined') {
709
+ return;
710
+ }
711
+ window.addEventListener('resize', reanchorToSettledDetent);
712
+ window.addEventListener('orientationchange', reanchorToSettledDetent);
713
+ return () => {
714
+ window.removeEventListener('resize', reanchorToSettledDetent);
715
+ window.removeEventListener('orientationchange', reanchorToSettledDetent);
716
+ };
717
+ }, [isOpen, reanchorToSettledDetent]);
718
+
719
+ // The other input to the same geometry: the snap points themselves. A host
720
+ // that swaps them while the sheet rests has moved the stops out from under
721
+ // it, exactly as a rotation does, so re-anchor the same way. Skipped on the
722
+ // first run — the sheet is already anchored to the detents it opened with.
723
+ const hasAnchoredSnapHeightsRef = useRef(false);
724
+ useLayoutEffect(() => {
725
+ if (!hasAnchoredSnapHeightsRef.current) {
726
+ hasAnchoredSnapHeightsRef.current = true;
727
+ return;
728
+ }
729
+ reanchorToSettledDetent();
730
+ }, [reanchorToSettledDetent, snapHeights]);
731
+
676
732
  const cancelDrag = useCallback(
677
733
  (target?: HTMLElement) => {
678
734
  const state = dragStateRef.current;
@@ -701,7 +757,7 @@ export function useSheetGestures({
701
757
  // An interrupted drag returns to its previous resting detent. Restore
702
758
  // the matching scrim opacity as well so the modal shell cannot remain
703
759
  // dimmed with its sheet translated out of view.
704
- const offsets = detentOffsets(state.height);
760
+ const {offsets, peekOffset} = resolveDetents(state.height);
705
761
  const maxOffset = offsets[offsets.length - 1];
706
762
  const shortestDetentHeight = visibleHeightForOffset(
707
763
  state.height,
@@ -711,10 +767,15 @@ export function useSheetGestures({
711
767
  const dismissOffset =
712
768
  maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO;
713
769
  onScrimOpacityRef.current?.(
714
- scrimOpacityForOffset(state.baseOffset, offsets, dismissOffset),
770
+ scrimOpacityForOffset(
771
+ state.baseOffset,
772
+ offsets,
773
+ dismissOffset,
774
+ peekOffset,
775
+ ),
715
776
  );
716
777
  },
717
- [detentOffsets, prepareScrollAreaSettle],
778
+ [prepareScrollAreaSettle, resolveDetents],
718
779
  );
719
780
 
720
781
  useEffect(() => {
@@ -745,7 +806,7 @@ export function useSheetGestures({
745
806
  layoutOffset: number,
746
807
  naturalEndGap: number,
747
808
  ) => {
748
- const offsets = detentOffsets(height);
809
+ const {offsets, peekOffset} = resolveDetents(height);
749
810
  const maxOffset = offsets[offsets.length - 1];
750
811
  const shortestDetentHeight = visibleHeightForOffset(
751
812
  height,
@@ -757,7 +818,7 @@ export function useSheetGestures({
757
818
  const settleAt = (target: number) => {
758
819
  // A peek keeps the full layout height and slides below the viewport;
759
820
  // every taller detent resizes the scrolling area to what it shows.
760
- const targetLayoutOffset = isPeekOffset(target, offsets) ? 0 : target;
821
+ const targetLayoutOffset = target === peekOffset ? 0 : target;
761
822
  prepareScrollAreaSettle(
762
823
  baseLayoutOffset,
763
824
  target,
@@ -780,7 +841,7 @@ export function useSheetGestures({
780
841
  const dismissOffset =
781
842
  maxOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO;
782
843
  onScrimOpacityRef.current?.(
783
- scrimOpacityForOffset(target, offsets, dismissOffset),
844
+ scrimOpacityForOffset(target, offsets, dismissOffset, peekOffset),
784
845
  );
785
846
  if (target !== baseOffset) {
786
847
  hapticTick();
@@ -849,13 +910,22 @@ export function useSheetGestures({
849
910
  const target = resolveSettleOffset(offset, offsets, dir, baseOffset);
850
911
  settleAt(target);
851
912
  },
852
- [detentOffsets, prepareScrollAreaSettle, recordSettledLayoutOffset],
913
+ [prepareScrollAreaSettle, recordSettledLayoutOffset, resolveDetents],
853
914
  );
854
915
 
855
916
  const beginDrag = useCallback(
856
917
  (event: ReactPointerEvent, sheetHeight: number, startCoord?: number) => {
857
918
  const target = event.currentTarget as HTMLElement;
858
- target.setPointerCapture?.(event.pointerId);
919
+ const syntheticTouch = isSyntheticTouch(event);
920
+ // Never capture for a touch drag. The id came from `Touch.identifier`,
921
+ // which on iOS names a live pointer: the capture succeeds, WebKit takes
922
+ // it straight back for its own gesture handling, and the
923
+ // `lostpointercapture` a millisecond later cancels the drag that just
924
+ // started. Touch drags need no capture — the listener is on the
925
+ // scroller itself.
926
+ if (!syntheticTouch) {
927
+ target.setPointerCapture?.(event.pointerId);
928
+ }
859
929
  // `startCoord` lets a body-overscroll drag anchor at the original
860
930
  // pointer-down position (not the promotion point), so the first frame's
861
931
  // delta reflects the full pull distance.
@@ -863,6 +933,7 @@ export function useSheetGestures({
863
933
  const naturalEndGap = naturalEndGapFor(bodyNodeRef.current);
864
934
  const baseLayoutOffset = settledLayoutOffsetRef.current;
865
935
  dragStateRef.current = {
936
+ syntheticTouch,
866
937
  pointerId: event.pointerId,
867
938
  startCoord: start,
868
939
  lastCoord: event.clientY,
@@ -917,6 +988,12 @@ export function useSheetGestures({
917
988
 
918
989
  const handleLostPointerCapture = useCallback(
919
990
  (event: ReactPointerEvent) => {
991
+ // A touch drag never took capture, so this is WebKit reclaiming its own
992
+ // pointer for the finger doing the dragging — not the drag losing its
993
+ // grip.
994
+ if (dragStateRef.current?.syntheticTouch) {
995
+ return;
996
+ }
920
997
  if (dragStateRef.current?.pointerId === event.pointerId) {
921
998
  cancelDrag();
922
999
  }
@@ -930,6 +1007,11 @@ export function useSheetGestures({
930
1007
  if (!state || state.pointerId !== event.pointerId) {
931
1008
  return;
932
1009
  }
1010
+ // Same finger, real pointer event: the touch path already drove this
1011
+ // move. Let it own the drag rather than driving it twice.
1012
+ if (state.syntheticTouch && !isSyntheticTouch(event)) {
1013
+ return;
1014
+ }
933
1015
  const delta = event.clientY - state.startCoord;
934
1016
  const dt = event.timeStamp - state.lastTime;
935
1017
  if (dt > 0) {
@@ -937,7 +1019,7 @@ export function useSheetGestures({
937
1019
  state.lastCoord = event.clientY;
938
1020
  state.lastTime = event.timeStamp;
939
1021
  }
940
- const offsets = detentOffsets(state.height);
1022
+ const {offsets, peekOffset} = resolveDetents(state.height);
941
1023
 
942
1024
  const raw = state.baseOffset + delta;
943
1025
  const maxDetentOffset = offsets[offsets.length - 1];
@@ -979,10 +1061,10 @@ export function useSheetGestures({
979
1061
  const dismissOffset =
980
1062
  floorOffset + shortestDetentHeight * DISMISS_OVERSHOOT_RATIO;
981
1063
  onScrimOpacityRef.current?.(
982
- scrimOpacityForOffset(next, offsets, dismissOffset),
1064
+ scrimOpacityForOffset(next, offsets, dismissOffset, peekOffset),
983
1065
  );
984
1066
  },
985
- [detentOffsets, updateScrollPreservationInset],
1067
+ [resolveDetents, updateScrollPreservationInset],
986
1068
  );
987
1069
 
988
1070
  const endDrag = useCallback(
@@ -991,12 +1073,20 @@ export function useSheetGestures({
991
1073
  if (!state || state.pointerId !== event.pointerId) {
992
1074
  return;
993
1075
  }
1076
+ // `pointercancel` fires for the finger the moment WebKit claims the
1077
+ // gesture; ending a touch drag on it settles the sheet mid-pull. The
1078
+ // touchend handler is what finishes a touch drag.
1079
+ if (state.syntheticTouch && !isSyntheticTouch(event)) {
1080
+ return;
1081
+ }
994
1082
  const target = event.currentTarget as HTMLElement;
995
1083
  const delta = event.clientY - state.startCoord;
996
1084
  const offset = Math.max(0, state.baseOffset + delta);
997
1085
  const dir = delta === 0 ? 0 : delta > 0 ? 1 : -1;
998
1086
  dragStateRef.current = null;
999
- target.releasePointerCapture?.(event.pointerId);
1087
+ if (!state.syntheticTouch) {
1088
+ target.releasePointerCapture?.(event.pointerId);
1089
+ }
1000
1090
  setIsDragging(false);
1001
1091
  settleFromDrag(
1002
1092
  offset,
@@ -1067,6 +1157,11 @@ export function useSheetGestures({
1067
1157
  const handleBodyEnd = useCallback(
1068
1158
  (event: ReactPointerEvent) => {
1069
1159
  armedBodyRef.current = null;
1160
+ // The pointerup/pointercancel for a finger already driving a touch drag
1161
+ // arrives here carrying that drag's own id; touchend ends those.
1162
+ if (dragStateRef.current?.syntheticTouch) {
1163
+ return;
1164
+ }
1070
1165
  if (dragStateRef.current) {
1071
1166
  endDrag(event);
1072
1167
  }
@@ -1118,6 +1213,7 @@ export function useSheetGestures({
1118
1213
  const bodyRef = useCallback((node: HTMLElement | null) => {
1119
1214
  const asPointer = (touch: Touch, target: HTMLElement) =>
1120
1215
  ({
1216
+ syntheticTouch: true,
1121
1217
  pointerId: touch.identifier,
1122
1218
  clientY: touch.clientY,
1123
1219
  timeStamp: Date.now(),
@@ -68,6 +68,18 @@ export const docs = {
68
68
  type: 'Array<(date: Date) => boolean>',
69
69
  description: 'Custom constraint functions.',
70
70
  },
71
+ {
72
+ name: 'maxRangeSpan',
73
+ type: 'number',
74
+ description:
75
+ 'Range mode: max days a range may span, both endpoints counted (7 = a 7-day window). Caps the window from the picked start; before a start is picked every day stays selectable.',
76
+ },
77
+ {
78
+ name: 'minRangeSpan',
79
+ type: 'number',
80
+ description:
81
+ 'Range mode: min days a range must span, both endpoints counted (2 forbids a single-day range). Default 1.',
82
+ },
71
83
  {
72
84
  name: 'focusDate',
73
85
  type: 'ISODateString',
@@ -142,6 +154,8 @@ export const docsZh = {
142
154
  {name: 'min', type: 'ISODateString', description: '可选择的最早日期。'},
143
155
  {name: 'max', type: 'ISODateString', description: '可选择的最晚日期。'},
144
156
  {name: 'dateConstraints', type: 'Array<(date: Date) => boolean>', description: '自定义约束函数。'},
157
+ {name: 'maxRangeSpan', type: 'number', description: '范围模式:范围最多可跨越的天数,含首尾两端(7 = 7 天窗口)。选定起始日后限制窗口大小。'},
158
+ {name: 'minRangeSpan', type: 'number', description: '范围模式:范围最少需跨越的天数,含首尾两端(2 表示禁止单日范围)。默认为 1。'},
145
159
  {name: 'focusDate', type: 'ISODateString', description: '受控可见月份。'},
146
160
  {name: 'onFocusDateChange', type: '(focusDate: ISODateString) => void', description: '导航回调函数。'},
147
161
  {name: 'handleRef', type: 'React.Ref<CalendarHandle>', description: '日历导航的命令式句柄,包括 navigateTo()。'},
@@ -205,6 +219,8 @@ export const docsDense = {
205
219
  min: 'minimum selectable date',
206
220
  max: 'maximum selectable date',
207
221
  dateConstraints: 'custom constraint fns',
222
+ maxRangeSpan: 'range mode: max days a range may span, both ends counted (7 = 7-day window)',
223
+ minRangeSpan: 'range mode: min days a range must span, both ends counted (default 1)',
208
224
  focusDate: 'controlled visible month',
209
225
  onFocusDateChange: 'navigation callback',
210
226
  handleRef: 'imperative navigation handle',