@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
@@ -11,24 +11,73 @@
11
11
 
12
12
  import {describe, it, expect} from 'vitest';
13
13
  import {
14
- snapFractionsToHeights,
15
14
  computeDetentOffsets,
15
+ isValidSnapPoint,
16
16
  nearestOffset,
17
+ peekOffsetFor,
17
18
  resolveSettleOffset,
19
+ resolveSnapPoints,
18
20
  scrimOpacityForOffset,
19
- isPeekOffset,
20
21
  DETENT_DEDUP_PX,
21
22
  } from './snapOffsets';
22
23
 
23
- describe('snapFractionsToHeights', () => {
24
- it('scales in-range fractions to px', () => {
25
- expect(snapFractionsToHeights([0.5, 0.92], 800)).toEqual([400, 736]);
24
+ describe('resolveSnapPoints', () => {
25
+ it('reads a bare number as a fraction of the viewport', () => {
26
+ expect(resolveSnapPoints([0.5, 0.92], 800)).toEqual([400, 736]);
26
27
  });
27
- it('drops out-of-range fractions', () => {
28
- expect(snapFractionsToHeights([0, 0.5, 1, 1.2, -0.3], 1000)).toEqual([
28
+
29
+ it('reads a percentage as the same fraction', () => {
30
+ expect(resolveSnapPoints(['50%', '92%'], 800)).toEqual([400, 736]);
31
+ });
32
+
33
+ it('reads a px length as an absolute height', () => {
34
+ expect(resolveSnapPoints(['320px'], 800)).toEqual([320]);
35
+ });
36
+
37
+ it('keeps the caller order, mixing forms', () => {
38
+ expect(resolveSnapPoints(['120px', 0.5, '90%'], 800)).toEqual([
39
+ 120, 400, 720,
40
+ ]);
41
+ });
42
+
43
+ it('accepts fractional and unit-cased lengths, and surrounding space', () => {
44
+ expect(resolveSnapPoints(['12.5%', '0.5PX', ' 80px '], 800)).toEqual([
45
+ 100, 0.5, 80,
46
+ ]);
47
+ });
48
+
49
+ it('drops numbers outside the (0, 1] fraction range', () => {
50
+ // 200 is the px mistake — a bare number is never a length.
51
+ expect(resolveSnapPoints([0, 0.5, 1, 1.2, -0.3, 200], 1000)).toEqual([
29
52
  500, 1000,
30
53
  ]);
31
54
  });
55
+
56
+ it('drops lengths it cannot resolve by arithmetic on the viewport', () => {
57
+ expect(
58
+ resolveSnapPoints(
59
+ ['50', '30rem', '50vh', 'calc(50% - 20px)', '-10px', '', '0px'],
60
+ 800,
61
+ ),
62
+ ).toEqual([]);
63
+ });
64
+ });
65
+
66
+ describe('isValidSnapPoint', () => {
67
+ it('accepts the three supported forms', () => {
68
+ expect(isValidSnapPoint(0.5)).toBe(true);
69
+ expect(isValidSnapPoint(1)).toBe(true);
70
+ expect(isValidSnapPoint('50%')).toBe(true);
71
+ expect(isValidSnapPoint('320px')).toBe(true);
72
+ });
73
+
74
+ it('rejects what resolveSnapPoints would silently drop', () => {
75
+ expect(isValidSnapPoint(200)).toBe(false);
76
+ expect(isValidSnapPoint(0)).toBe(false);
77
+ expect(isValidSnapPoint('50')).toBe(false);
78
+ expect(isValidSnapPoint('50vh')).toBe(false);
79
+ expect(isValidSnapPoint('calc(50% - 20px)')).toBe(false);
80
+ });
32
81
  });
33
82
 
34
83
  describe('computeDetentOffsets', () => {
@@ -100,66 +149,84 @@ describe('resolveSettleOffset', () => {
100
149
  });
101
150
  });
102
151
 
103
- describe('isPeekOffset', () => {
104
- const offsets = [0, 100, 200]; // full, mid, peek
105
-
106
- it('is true only for the shortest detent', () => {
107
- expect(isPeekOffset(200, offsets)).toBe(true);
108
- expect(isPeekOffset(100, offsets)).toBe(false);
109
- expect(isPeekOffset(0, offsets)).toBe(false);
152
+ describe('peekOffsetFor', () => {
153
+ it('is the shortest stop when that stop is a sliver', () => {
154
+ // sheet 800; stops at 800, 400 and 100 visible px. The 100px stop is an
155
+ // eighth of the sheet, well inside the quarter that makes a peek.
156
+ expect(peekOffsetFor([0, 400, 700], 800)).toBe(700);
110
157
  });
111
158
 
112
- it('tolerates sub-pixel rounding around the peek', () => {
113
- expect(isPeekOffset(200.4, offsets)).toBe(true);
114
- expect(isPeekOffset(199.6, offsets)).toBe(true);
115
- expect(isPeekOffset(196, offsets)).toBe(false);
159
+ it('has no peek when the shortest stop is a working height', () => {
160
+ // A half-height stop lays its content out and keeps a full scrim; it is
161
+ // the sheet the caller asked for, not a glance at one.
162
+ expect(peekOffsetFor([0, 400], 800)).toBeNull();
116
163
  });
117
164
 
118
165
  it('has no peek when the sheet has no collapsed stop', () => {
119
- expect(isPeekOffset(200, [0])).toBe(false);
120
- expect(isPeekOffset(0, [0])).toBe(false);
166
+ expect(peekOffsetFor([0], 800)).toBeNull();
121
167
  });
122
168
 
123
- it('treats the only collapsed stop as the peek', () => {
124
- expect(isPeekOffset(200, [0, 200])).toBe(true);
169
+ it('takes the quarter mark itself as a peek', () => {
170
+ expect(peekOffsetFor([0, 600], 800)).toBe(600);
125
171
  });
126
172
 
127
- it('agrees with the detent scrimOpacityForOffset treats as the peek', () => {
128
- const dismissOffset = 280;
129
- const peek = offsets[offsets.length - 1];
130
- expect(isPeekOffset(peek, offsets)).toBe(true);
131
- expect(scrimOpacityForOffset(peek, offsets, dismissOffset)).toBeCloseTo(
132
- 0.3,
133
- );
173
+ it('has no peek without a measured sheet', () => {
174
+ expect(peekOffsetFor([0, 600], 0)).toBeNull();
134
175
  });
135
176
  });
136
177
 
137
178
  describe('scrimOpacityForOffset', () => {
138
- describe('with a peek detent (>= 2 detents)', () => {
179
+ describe('with a peek detent', () => {
139
180
  const offsets = [0, 100, 200]; // full, mid, peek
181
+ const peekOffset = 200;
140
182
  const dismissOffset = 280;
141
183
 
142
184
  it('is full at or above the mid detent', () => {
143
- expect(scrimOpacityForOffset(0, offsets, dismissOffset)).toBe(1);
144
- expect(scrimOpacityForOffset(100, offsets, dismissOffset)).toBe(1);
145
- expect(scrimOpacityForOffset(60, offsets, dismissOffset)).toBe(1);
185
+ expect(scrimOpacityForOffset(0, offsets, dismissOffset, peekOffset)).toBe(
186
+ 1,
187
+ );
188
+ expect(
189
+ scrimOpacityForOffset(100, offsets, dismissOffset, peekOffset),
190
+ ).toBe(1);
191
+ expect(
192
+ scrimOpacityForOffset(60, offsets, dismissOffset, peekOffset),
193
+ ).toBe(1);
146
194
  });
147
195
 
148
196
  it('fades linearly from the mid detent toward the peek floor', () => {
149
197
  // Halfway from mid (100) to peek (200), opacity is halfway from 1 to the
150
198
  // 0.3 floor => 0.65.
151
- expect(scrimOpacityForOffset(150, offsets, dismissOffset)).toBeCloseTo(
152
- 0.65,
153
- );
199
+ expect(
200
+ scrimOpacityForOffset(150, offsets, dismissOffset, peekOffset),
201
+ ).toBeCloseTo(0.65);
154
202
  });
155
203
 
156
204
  it('thins to the peek floor at or below the peek detent (still modal)', () => {
157
- expect(scrimOpacityForOffset(200, offsets, dismissOffset)).toBeCloseTo(
158
- 0.3,
159
- );
160
- expect(scrimOpacityForOffset(240, offsets, dismissOffset)).toBeCloseTo(
161
- 0.3,
162
- );
205
+ expect(
206
+ scrimOpacityForOffset(200, offsets, dismissOffset, peekOffset),
207
+ ).toBeCloseTo(0.3);
208
+ expect(
209
+ scrimOpacityForOffset(240, offsets, dismissOffset, peekOffset),
210
+ ).toBeCloseTo(0.3);
211
+ });
212
+ });
213
+
214
+ describe('with collapsed stops but no peek among them', () => {
215
+ // The shortest stop is a working height, so the scrim is full there and
216
+ // only fades once the sheet is being dragged out below it.
217
+ const offsets = [0, 100];
218
+ const dismissOffset = 160;
219
+
220
+ it('stays full at every stop, including the shortest', () => {
221
+ expect(scrimOpacityForOffset(0, offsets, dismissOffset, null)).toBe(1);
222
+ expect(scrimOpacityForOffset(100, offsets, dismissOffset, null)).toBe(1);
223
+ });
224
+
225
+ it('fades only across the dismiss overshoot below the shortest stop', () => {
226
+ expect(
227
+ scrimOpacityForOffset(130, offsets, dismissOffset, null),
228
+ ).toBeCloseTo(0.5);
229
+ expect(scrimOpacityForOffset(160, offsets, dismissOffset, null)).toBe(0);
163
230
  });
164
231
  });
165
232
 
@@ -168,14 +235,14 @@ describe('scrimOpacityForOffset', () => {
168
235
  const dismissOffset = 160;
169
236
 
170
237
  it('stays full until the drag begins collapsing', () => {
171
- expect(scrimOpacityForOffset(0, offsets, dismissOffset)).toBe(1);
238
+ expect(scrimOpacityForOffset(0, offsets, dismissOffset, null)).toBe(1);
172
239
  });
173
240
 
174
241
  it('fades across the dismiss overshoot toward the threshold', () => {
175
- expect(scrimOpacityForOffset(80, offsets, dismissOffset)).toBeCloseTo(
176
- 0.5,
177
- );
178
- expect(scrimOpacityForOffset(160, offsets, dismissOffset)).toBe(0);
242
+ expect(
243
+ scrimOpacityForOffset(80, offsets, dismissOffset, null),
244
+ ).toBeCloseTo(0.5);
245
+ expect(scrimOpacityForOffset(160, offsets, dismissOffset, null)).toBe(0);
179
246
  });
180
247
  });
181
248
  });
@@ -3,9 +3,9 @@
3
3
  /**
4
4
  * @file snapOffsets.ts
5
5
  * @input Pure geometry helpers — no React, no DOM.
6
- * @output Converts snap points to translateY offsets for the sheet surface.
7
- * @position Internal to BottomSheet; consumed by useSheetGestures, tested by
8
- * snapOffsets.test.ts.
6
+ * @output Resolves snap points and converts them to translateY offsets.
7
+ * @position Internal to BottomSheet; consumed by useSheetGestures and
8
+ * BottomSheetPanel, tested by snapOffsets.test.ts.
9
9
  *
10
10
  * A detent is represented as an offset in px from the fully-open position
11
11
  * (0 = fully open, larger = more collapsed). The panel may render that offset
@@ -14,6 +14,21 @@
14
14
  * geometry can be unit-tested without a DOM.
15
15
  */
16
16
 
17
+ /**
18
+ * A height the sheet can rest at, expressed as the sheet's VISIBLE height:
19
+ *
20
+ * - a number in `(0, 1]` — a fraction of the viewport (`0.5` is half the
21
+ * screen), so the stop tracks rotation and window resizes;
22
+ * - a `'<n>%'` string — the same thing spelled in CSS;
23
+ * - a `'<n>px'` string — an absolute height that does not scale.
24
+ *
25
+ * Anything else — a unitless string, `calc()`, `vh`, `rem`, a number outside
26
+ * `(0, 1]` — is ignored with a dev warning. Percentages resolve against the
27
+ * layout viewport (`window.innerHeight`), the same box the height budgets are
28
+ * written against, so the mobile keyboard never moves a stop.
29
+ */
30
+ export type BottomSheetSnapPoint = number | string;
31
+
17
32
  /**
18
33
  * Detents whose resting offsets land within this many px of each other are
19
34
  * treated as the same stop (the taller one wins). Stops the sheet from having
@@ -22,12 +37,71 @@
22
37
  */
23
38
  export const DETENT_DEDUP_PX = 48;
24
39
 
25
- /** Resolve snap points given as viewport fractions (0, 1] to px heights. */
26
- export function snapFractionsToHeights(
27
- fractions: ReadonlyArray<number>,
40
+ /**
41
+ * Largest share of the fully open sheet the shortest stop may fill and still
42
+ * be a peek. Above it the stop is a working surface — it lays its content out
43
+ * and keeps a full scrim — so a two-stop sheet like `[0.5]` behaves like the
44
+ * half-height panel it asks for rather than like a glance.
45
+ */
46
+ export const PEEK_MAX_HEIGHT_RATIO = 0.25;
47
+
48
+ // A snap point string: a positive number with a `px` or `%` unit. Deliberately
49
+ // narrow — every accepted unit has to be resolvable by arithmetic on the
50
+ // viewport height, because this runs inside the drag loop. Units that need the
51
+ // DOM to resolve (`rem`, `em`, `calc()`) would cost a layout per frame.
52
+ const SNAP_POINT_PATTERN = /^(\d+(?:\.\d+)?|\.\d+)(px|%)$/i;
53
+
54
+ /**
55
+ * Resolve one snap point to a visible height in px, or `null` when it is not a
56
+ * snap point this sheet can honor.
57
+ */
58
+ function parseSnapPoint(
59
+ point: BottomSheetSnapPoint,
60
+ viewportPx: number,
61
+ ): number | null {
62
+ if (typeof point === 'number') {
63
+ // A bare number is a viewport fraction. Anything above 1 is a px value in
64
+ // disguise; the caller warns rather than guessing which was meant.
65
+ return Number.isFinite(point) && point > 0 && point <= 1
66
+ ? point * viewportPx
67
+ : null;
68
+ }
69
+ const match = SNAP_POINT_PATTERN.exec(point.trim());
70
+ if (match === null) {
71
+ return null;
72
+ }
73
+ const value = Number.parseFloat(match[1]);
74
+ if (!(value > 0)) {
75
+ return null;
76
+ }
77
+ return match[2].toLowerCase() === '%' ? (value / 100) * viewportPx : value;
78
+ }
79
+
80
+ /**
81
+ * Whether a snap point can be resolved at all. The viewport only scales the
82
+ * result, so validity is independent of it.
83
+ */
84
+ export function isValidSnapPoint(point: BottomSheetSnapPoint): boolean {
85
+ return parseSnapPoint(point, 1) !== null;
86
+ }
87
+
88
+ /**
89
+ * Resolve snap points to candidate visible heights in px, dropping the ones
90
+ * this sheet cannot honor. Callers warn about those separately — this stays
91
+ * silent because it runs on every drag frame.
92
+ */
93
+ export function resolveSnapPoints(
94
+ points: ReadonlyArray<BottomSheetSnapPoint>,
28
95
  viewportPx: number,
29
96
  ): number[] {
30
- return fractions.filter(f => f > 0 && f <= 1).map(f => f * viewportPx);
97
+ const heights: number[] = [];
98
+ for (const point of points) {
99
+ const height = parseSnapPoint(point, viewportPx);
100
+ if (height !== null) {
101
+ heights.push(height);
102
+ }
103
+ }
104
+ return heights;
31
105
  }
32
106
 
33
107
  /**
@@ -81,46 +155,58 @@ export function nearestOffset(
81
155
  export const MIN_PEEK_SCRIM_OPACITY = 0.3;
82
156
 
83
157
  /**
84
- * Whether `offset` is the peek detent: the shortest stop, where the sheet is a
85
- * glance rather than a working surface. A sheet with no collapsed stop at all
86
- * has no peek the same rule `scrimOpacityForOffset` thins the scrim by.
158
+ * The peek detent's offset, or `null` when this sheet has no peek.
159
+ *
160
+ * A peek is the shortest stop AND a sliver at most `PEEK_MAX_HEIGHT_RATIO`
161
+ * of the fully open sheet. It is the one detent the sheet does NOT express as
162
+ * layout height: at a sliver there is nothing useful to lay out, and reflowing
163
+ * the content into it (then back out on the way up) is churn the user sees, so
164
+ * it keeps the full layout height and slides below the viewport instead. It is
165
+ * also the only stop that thins the scrim.
87
166
  *
88
- * The peek is the one detent the sheet does NOT express as layout height: at a
89
- * sliver of viewport there is nothing useful to lay out, and reflowing the
90
- * content into it (then back out on the way up) is churn the user sees. It
91
- * keeps the full layout height and slides below the viewport instead.
167
+ * A sheet whose shortest stop is a working height `[0.5]`, say has no
168
+ * peek: half a screen of content deserves to be laid out at half a screen, and
169
+ * a scrim that thinned there would read as a dismissed sheet.
92
170
  */
93
- export function isPeekOffset(
94
- offset: number,
171
+ export function peekOffsetFor(
95
172
  offsets: ReadonlyArray<number>,
96
- tolerancePx: number = 0.5,
97
- ): boolean {
98
- if (offsets.length < 2) {
99
- return false;
173
+ visibleSheetHeight: number,
174
+ ): number | null {
175
+ if (offsets.length < 2 || visibleSheetHeight <= 0) {
176
+ return null;
100
177
  }
101
- return Math.abs(offset - offsets[offsets.length - 1]) <= tolerancePx;
178
+ const shortest = offsets[offsets.length - 1];
179
+ const heightAtShortest = visibleSheetHeight - shortest;
180
+ return heightAtShortest <= PEEK_MAX_HEIGHT_RATIO * visibleSheetHeight
181
+ ? shortest
182
+ : null;
102
183
  }
103
184
 
104
185
  /**
105
186
  * Scrim opacity (1 = fully visible) for a drag/settle `offset`.
106
- * The scrim stays full while the sheet is at or above its second-shortest
107
- * detent, then fades as it collapses onto the shortest ("peek") detent a
108
- * glance state that thins the backdrop to `MIN_PEEK_SCRIM_OPACITY` (not fully
109
- * gone, since the sheet is still modal) and holds there below it.
110
- * A single-detent sheet has no peek, so it instead fades all the way to 0
111
- * across the dismiss overshoot toward `dismissOffset` (the sheet is leaving).
187
+ *
188
+ * The scrim is full down to the shortest stop that is still a working surface,
189
+ * and only thins past it onto a peek, where it holds at
190
+ * `MIN_PEEK_SCRIM_OPACITY` (a glance state, and the sheet is still modal), or
191
+ * out through the dismiss overshoot toward `dismissOffset`, where it clears
192
+ * completely because the sheet is leaving.
193
+ *
194
+ * So a sheet whose stops are all working heights keeps a full scrim at every
195
+ * one of them; only the sliver of a peek, or a sheet on its way out, dims less.
112
196
  */
113
197
  export function scrimOpacityForOffset(
114
198
  offset: number,
115
199
  offsets: ReadonlyArray<number>,
116
200
  dismissOffset: number,
201
+ peekOffset: number | null,
117
202
  ): number {
118
- const shortest = offsets[offsets.length - 1];
119
- const hasPeek = offsets.length >= 2;
120
- const fadeStart = hasPeek ? offsets[offsets.length - 2] : 0;
121
- const fadeEnd = hasPeek ? shortest : dismissOffset;
122
- // Peek is a resting state on a still-modal sheet, so keep a floor; the
123
- // dismiss overshoot is a sheet on its way out, so let it clear completely.
203
+ const hasPeek = peekOffset !== null;
204
+ // The last stop that is still a working surface — the peek's neighbor when
205
+ // there is a peek, otherwise the shortest stop itself.
206
+ const fadeStart = hasPeek
207
+ ? offsets[offsets.length - 2]
208
+ : offsets[offsets.length - 1];
209
+ const fadeEnd = hasPeek ? peekOffset : dismissOffset;
124
210
  const floor = hasPeek ? MIN_PEEK_SCRIM_OPACITY : 0;
125
211
  if (offset <= fadeStart) {
126
212
  return 1;
@@ -129,7 +129,9 @@ describe('useSheetGestures', () => {
129
129
  expect(onDismiss).not.toHaveBeenCalled();
130
130
  expect(hook.result.current.settledOffset).toBe(200);
131
131
  expect(onSnap).toHaveBeenLastCalledWith(200);
132
- expect(onScrimOpacity).toHaveBeenLastCalledWith(0.3);
132
+ // The 200px stop is half the sheet — a working surface, not a peek — so
133
+ // the backdrop stays full there.
134
+ expect(onScrimOpacity).toHaveBeenLastCalledWith(1);
133
135
  });
134
136
 
135
137
  it('expands to the tallest detent on a fast upward flick', () => {
@@ -218,13 +220,62 @@ describe('useSheetGestures', () => {
218
220
  expect(hook.result.current.settledLayoutOffset).toBe(0);
219
221
  });
220
222
 
221
- it('treats the only collapsed stop as a peek, like the scrim does', () => {
223
+ it('re-anchors to the same stop when the snap heights change at rest', () => {
224
+ const onDismiss = vi.fn();
225
+ const hook = renderHook(
226
+ (props: UseSheetGesturesOptions) => useSheetGestures(props),
227
+ {
228
+ initialProps: {
229
+ isOpen: true,
230
+ onDismiss,
231
+ snapHeights: () => [200],
232
+ },
233
+ },
234
+ );
235
+ const t = makeTarget();
236
+
237
+ // Settle on the one collapsed stop: 400 - 200 = 200px of travel.
238
+ down(hook, 0, 0, t);
239
+ move(hook, 180, 700, t);
240
+ up(hook, 180, 1100, t);
241
+ act(() => hook.result.current.completeScrollAreaSettle());
242
+ expect(hook.result.current.settledOffset).toBe(200);
243
+
244
+ // A new resolver is a new set of stops. The sheet is resting on the one it
245
+ // names, so it follows: a 150px stop is 250px of travel. The resolver is
246
+ // read during render, not from a passive effect — the re-anchor runs in a
247
+ // layout effect, which would otherwise still see the previous one.
248
+ hook.rerender({
249
+ isOpen: true,
250
+ onDismiss,
251
+ snapHeights: () => [150],
252
+ });
253
+
254
+ expect(hook.result.current.settledOffset).toBe(250);
255
+ expect(hook.result.current.settledLayoutOffset).toBe(250);
256
+ });
257
+
258
+ it('resizes the scrolling area at a collapsed stop that is a working height', () => {
259
+ // A 200px stop on the 400px sheet is half of it: the scrolling area takes
260
+ // that travel as layout height rather than sliding the sheet away.
222
261
  const {hook} = setup({snapHeights: () => [200]});
223
262
  const t = makeTarget();
224
263
  down(hook, 0, 0, t);
225
264
  move(hook, 180, 700, t);
226
265
  up(hook, 180, 1100, t);
227
266
  expect(hook.result.current.settledOffset).toBe(200);
267
+ expect(hook.result.current.settledLayoutOffset).toBe(200);
268
+ });
269
+
270
+ it('treats the only collapsed stop as a peek when it is a sliver', () => {
271
+ // 60px of a 400px sheet is inside the quarter that makes a peek, so the
272
+ // sheet keeps its full layout height and slides below the viewport.
273
+ const {hook} = setup({snapHeights: () => [60]});
274
+ const t = makeTarget();
275
+ down(hook, 0, 0, t);
276
+ move(hook, 320, 700, t);
277
+ up(hook, 320, 1100, t);
278
+ expect(hook.result.current.settledOffset).toBe(340);
228
279
  expect(hook.result.current.settledLayoutOffset).toBe(0);
229
280
  });
230
281
 
@@ -297,20 +348,22 @@ describe('useSheetGestures', () => {
297
348
 
298
349
  expect(onDismiss).not.toHaveBeenCalled();
299
350
  expect(hook.result.current.settledOffset).toBe(200);
300
- expect(onScrimOpacity).toHaveBeenLastCalledWith(0.3);
351
+ // Rebounding restores the scrim the 200px stop rests under — full, since
352
+ // half a sheet is a working surface rather than a glance.
353
+ expect(onScrimOpacity).toHaveBeenLastCalledWith(1);
301
354
  });
302
355
 
303
356
  it('fades the scrim from full toward the peek floor as it collapses onto the peek detent', () => {
304
357
  const onScrimOpacity = vi.fn();
305
- // Sheet 400, detents at 200 and 300 -> offsets [0, 100, 200]. The fade
306
- // spans the mid detent (offset 100) to the peek detent (offset 200):
307
- // full at/above 100, thinned to the peek floor (0.3) at/below 200.
308
- const {hook} = setup({snapHeights: () => [200, 300], onScrimOpacity});
358
+ // Sheet 400, stops at 300 and a 60px sliver -> offsets [0, 100, 340]. The
359
+ // fade spans the mid detent (offset 100) to the peek (offset 340): full
360
+ // at/above 100, thinned to the peek floor (0.3) at/below 340.
361
+ const {hook} = setup({snapHeights: () => [60, 300], onScrimOpacity});
309
362
  const t = makeTarget();
310
363
  down(hook, 0, 0, t);
311
364
  move(hook, 60, 200, t); // offset 60, above the mid detent -> full
312
- move(hook, 150, 600, t); // offset 150, halfway -> between 1 and 0.3 (~0.65)
313
- move(hook, 220, 1000, t); // offset 220, past the peek -> peek floor
365
+ move(hook, 220, 600, t); // offset 220, halfway -> between 1 and 0.3 (~0.65)
366
+ move(hook, 360, 1000, t); // offset 360, past the peek -> peek floor
314
367
  const values = onScrimOpacity.mock.calls.map(c => Number(c[0]));
315
368
  expect(values[0]).toBe(1);
316
369
  expect(values[1]).toBeGreaterThan(0.55);
@@ -320,14 +373,14 @@ describe('useSheetGestures', () => {
320
373
 
321
374
  it('thins the scrim to the peek floor when settled at the peek detent (still modal)', () => {
322
375
  const onScrimOpacity = vi.fn();
323
- const {hook} = setup({snapHeights: () => [200, 300], onScrimOpacity});
376
+ const {hook} = setup({snapHeights: () => [60, 300], onScrimOpacity});
324
377
  const t = makeTarget();
325
- // Slow drag down to the peek detent (offset 200).
378
+ // Slow drag down to the peek detent (offset 340).
326
379
  down(hook, 0, 0, t);
327
- move(hook, 100, 400, t);
328
- move(hook, 200, 900, t);
329
- up(hook, 200, 1400, t);
330
- expect(hook.result.current.settledOffset).toBe(200);
380
+ move(hook, 170, 400, t);
381
+ move(hook, 340, 900, t);
382
+ up(hook, 340, 1400, t);
383
+ expect(hook.result.current.settledOffset).toBe(340);
331
384
  // Last reported opacity (on settle) is the peek floor, not fully hidden —
332
385
  // the sheet is still modal, so the backdrop keeps a minimum dim.
333
386
  const values = onScrimOpacity.mock.calls.map(c => Number(c[0]));
@@ -726,6 +779,51 @@ describe('useSheetGestures', () => {
726
779
  return el;
727
780
  }
728
781
 
782
+ // iOS Safari raises PointerEvents for a finger under the SAME numeric id
783
+ // it puts in `Touch.identifier`, so a drag the touch path starts is keyed
784
+ // to a live pointer. WebKit takes that pointer's capture straight back,
785
+ // and the `lostpointercapture` that follows used to cancel the drag one
786
+ // event after it began — leaving the sheet inert for the rest of the pull
787
+ // while the handle still worked. Every browser that keeps the two id
788
+ // spaces apart hides this, which is why it only showed up on device.
789
+ it('survives a lostpointercapture for the finger driving it', () => {
790
+ const {hook} = setup({snapHeights: () => [200]});
791
+ const el = midDetentScroller(hook, 600); // parked at the content end
792
+ act(() => {
793
+ touch(el, 'touchstart', 500, 7);
794
+ touch(el, 'touchmove', 450, 7); // promotes at the armed bottom edge
795
+ });
796
+ expect(hook.result.current.isDragging).toBe(true);
797
+ act(() => {
798
+ hook.result.current.bodyProps.onLostPointerCapture(
799
+ pointerEvent(450, 0, el, 7), // same id as the touch: iOS does this
800
+ );
801
+ });
802
+ expect(hook.result.current.isDragging).toBe(true);
803
+ act(() => {
804
+ touch(el, 'touchmove', 400, 7);
805
+ });
806
+ // 100px of pull past the touchstart, from the 200px detent.
807
+ expect(hook.result.current.dragOffset).toBe(100);
808
+ });
809
+
810
+ // `pointercancel` for that same finger arrives the moment WebKit claims
811
+ // the gesture. Ending the drag on it settles the sheet mid-pull.
812
+ it('does not end the touch drag on a pointercancel for that finger', () => {
813
+ const {hook} = setup({snapHeights: () => [200]});
814
+ const el = midDetentScroller(hook, 600);
815
+ act(() => {
816
+ touch(el, 'touchstart', 500, 7);
817
+ touch(el, 'touchmove', 450, 7);
818
+ });
819
+ act(() => {
820
+ hook.result.current.bodyProps.onPointerCancel(
821
+ pointerEvent(450, 0, el, 7),
822
+ );
823
+ });
824
+ expect(hook.result.current.isDragging).toBe(true);
825
+ });
826
+
729
827
  it('hands off when the content runs out under a moving finger', () => {
730
828
  const {hook} = setup({snapHeights: () => [200]});
731
829
  const el = midDetentScroller(hook, 300); // finger lands mid-content