@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.
- package/CHANGELOG.md +39 -0
- package/dist/BottomSheet/BottomSheet.d.ts +11 -2
- package/dist/BottomSheet/BottomSheet.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheet.js +4 -0
- package/dist/BottomSheet/BottomSheetPanel.d.ts +5 -2
- package/dist/BottomSheet/BottomSheetPanel.d.ts.map +1 -1
- package/dist/BottomSheet/BottomSheetPanel.js +56 -13
- package/dist/BottomSheet/index.d.ts +1 -1
- package/dist/BottomSheet/index.d.ts.map +1 -1
- package/dist/BottomSheet/snapOffsets.d.ts +57 -20
- package/dist/BottomSheet/snapOffsets.d.ts.map +1 -1
- package/dist/BottomSheet/snapOffsets.js +106 -30
- package/dist/BottomSheet/useSheetGestures.d.ts +13 -8
- package/dist/BottomSheet/useSheetGestures.d.ts.map +1 -1
- package/dist/BottomSheet/useSheetGestures.js +160 -64
- package/dist/Calendar/Calendar.d.ts +17 -0
- package/dist/Calendar/Calendar.d.ts.map +1 -1
- package/dist/Calendar/Calendar.js +34 -3
- package/dist/Calendar/getStandaloneShortWeekdayNames.d.ts +14 -0
- package/dist/Calendar/getStandaloneShortWeekdayNames.d.ts.map +1 -0
- package/dist/Calendar/getStandaloneShortWeekdayNames.js +26 -0
- package/dist/Calendar/hooks/useCalendarConstraints.d.ts +22 -1
- package/dist/Calendar/hooks/useCalendarConstraints.d.ts.map +1 -1
- package/dist/Calendar/hooks/useCalendarConstraints.js +26 -4
- package/dist/Calendar/hooks/useCalendarDays.d.ts.map +1 -1
- package/dist/Calendar/hooks/useCalendarDays.js +14 -10
- package/dist/Calendar/standaloneShortWeekdayNames.generated.d.ts +47 -0
- package/dist/Calendar/standaloneShortWeekdayNames.generated.d.ts.map +1 -0
- package/dist/Calendar/standaloneShortWeekdayNames.generated.js +40 -0
- package/dist/CheckboxInput/CheckboxInput.d.ts.map +1 -1
- package/dist/CheckboxInput/CheckboxInput.js +9 -0
- package/dist/ComplexSelector/ComplexSelector.d.ts.map +1 -1
- package/dist/ComplexSelector/ComplexSelector.js +6 -1
- package/dist/DateInput/DateInput.d.ts.map +1 -1
- package/dist/DateInput/DateInput.js +6 -1
- package/dist/DateRangeInput/DateRangeInput.d.ts +23 -1
- package/dist/DateRangeInput/DateRangeInput.d.ts.map +1 -1
- package/dist/DateRangeInput/DateRangeInput.js +38 -3
- package/dist/DateTimeInput/DateTimeInput.d.ts.map +1 -1
- package/dist/DateTimeInput/DateTimeInput.js +7 -2
- package/dist/Field/FieldLabel.d.ts +2 -1
- package/dist/Field/FieldLabel.d.ts.map +1 -1
- package/dist/Field/FieldLabel.js +21 -3
- package/dist/Field/InputClearButton.d.ts +2 -1
- package/dist/Field/InputClearButton.d.ts.map +1 -1
- package/dist/Field/InputClearButton.js +6 -1
- package/dist/FormLayout/FormLayout.d.ts +24 -2
- package/dist/FormLayout/FormLayout.d.ts.map +1 -1
- package/dist/FormLayout/FormLayout.js +7 -2
- package/dist/FormLayout/FormLayoutContext.d.ts +15 -2
- package/dist/FormLayout/FormLayoutContext.d.ts.map +1 -1
- package/dist/FormLayout/FormLayoutContext.js +19 -4
- package/dist/FormLayout/index.d.ts +1 -1
- package/dist/FormLayout/index.d.ts.map +1 -1
- package/dist/Markdown/Markdown.d.ts +8 -0
- package/dist/Markdown/Markdown.d.ts.map +1 -1
- package/dist/Markdown/Markdown.js +31 -3
- package/dist/Markdown/parser.d.ts +14 -2
- package/dist/Markdown/parser.d.ts.map +1 -1
- package/dist/Markdown/parser.js +50 -2
- package/dist/MultiSelector/MultiSelector.d.ts.map +1 -1
- package/dist/MultiSelector/MultiSelector.js +6 -1
- package/dist/NumberInput/NumberInput.d.ts.map +1 -1
- package/dist/NumberInput/NumberInput.js +6 -1
- package/dist/Outline/parseOutlineFromMarkdown.d.ts +2 -0
- package/dist/Outline/parseOutlineFromMarkdown.d.ts.map +1 -1
- package/dist/Outline/parseOutlineFromMarkdown.js +5 -31
- package/dist/RadioList/RadioList.d.ts.map +1 -1
- package/dist/RadioList/RadioList.js +12 -1
- package/dist/Selector/Selector.d.ts.map +1 -1
- package/dist/Selector/Selector.js +6 -1
- package/dist/StatusDot/StatusDot.d.ts +43 -3
- package/dist/StatusDot/StatusDot.d.ts.map +1 -1
- package/dist/StatusDot/StatusDot.js +42 -5
- package/dist/Switch/Switch.d.ts.map +1 -1
- package/dist/Switch/Switch.js +9 -0
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.d.ts.map +1 -1
- package/dist/Table/plugins/rowExpansion/useTableRowExpansion.js +7 -2
- package/dist/TextArea/TextArea.d.ts.map +1 -1
- package/dist/TextArea/TextArea.js +6 -1
- package/dist/TextInput/TextInput.d.ts.map +1 -1
- package/dist/TextInput/TextInput.js +6 -1
- package/dist/TimeInput/TimeInput.d.ts.map +1 -1
- package/dist/TimeInput/TimeInput.js +6 -1
- package/dist/hooks/useResolvedRequired.d.ts +19 -0
- package/dist/hooks/useResolvedRequired.d.ts.map +1 -0
- package/dist/hooks/useResolvedRequired.js +40 -0
- package/dist/utils/plainDate.d.ts +6 -0
- package/dist/utils/plainDate.d.ts.map +1 -1
- package/dist/utils/plainDate.js +12 -0
- package/locales/af-ZA.json +958 -0
- package/locales/ar-SA.json +998 -0
- package/locales/ca-ES.json +974 -0
- package/locales/cs-CZ.json +990 -0
- package/locales/da-DK.json +966 -0
- package/locales/de-DE.json +966 -0
- package/locales/el-GR.json +990 -0
- package/locales/en.json +4 -0
- package/locales/es-ES.json +978 -0
- package/locales/fi-FI.json +990 -0
- package/locales/fr-FR.json +928 -0
- package/locales/he-IL.json +990 -0
- package/locales/hu-HU.json +990 -0
- package/locales/it-IT.json +978 -0
- package/locales/ja-JP.json +998 -0
- package/locales/ko-KR.json +990 -0
- package/locales/nl-NL.json +954 -0
- package/locales/no-NO.json +978 -0
- package/locales/pl-PL.json +982 -0
- package/locales/pseudo.json +3 -0
- package/locales/pt-BR.json +982 -0
- package/locales/pt-PT.json +982 -0
- package/locales/ro-RO.json +982 -0
- package/locales/ru-RU.json +990 -0
- package/locales/sr-SP.json +990 -0
- package/locales/sv-SE.json +986 -0
- package/locales/tr-TR.json +990 -0
- package/locales/uk-UA.json +990 -0
- package/locales/vi-VN.json +986 -0
- package/locales/zh-CN.json +998 -0
- package/locales/zh-TW.json +998 -0
- package/package.json +3 -3
- package/src/BottomSheet/BottomSheet.doc.mjs +36 -3
- package/src/BottomSheet/BottomSheet.test.tsx +153 -2
- package/src/BottomSheet/BottomSheet.tsx +16 -2
- package/src/BottomSheet/BottomSheetPanel.tsx +67 -13
- package/src/BottomSheet/index.ts +5 -1
- package/src/BottomSheet/snapOffsets.test.ts +114 -47
- package/src/BottomSheet/snapOffsets.ts +119 -33
- package/src/BottomSheet/useSheetGestures.test.ts +113 -15
- package/src/BottomSheet/useSheetGestures.ts +196 -100
- package/src/Calendar/Calendar.doc.mjs +16 -0
- package/src/Calendar/Calendar.test.tsx +137 -0
- package/src/Calendar/Calendar.tsx +65 -2
- package/src/Calendar/getStandaloneShortWeekdayNames.test.ts +54 -0
- package/src/Calendar/getStandaloneShortWeekdayNames.ts +38 -0
- package/src/Calendar/hooks/useCalendarConstraints.ts +54 -3
- package/src/Calendar/hooks/useCalendarDays.ts +13 -10
- package/src/Calendar/standaloneShortWeekdayNames.generated.ts +50 -0
- package/src/CheckboxInput/CheckboxInput.tsx +6 -0
- package/src/ComplexSelector/ComplexSelector.tsx +3 -1
- package/src/DateInput/DateInput.tsx +3 -1
- package/src/DateRangeInput/DateRangeInput.doc.mjs +16 -0
- package/src/DateRangeInput/DateRangeInput.test.tsx +81 -1
- package/src/DateRangeInput/DateRangeInput.tsx +72 -1
- package/src/DateTimeInput/DateTimeInput.tsx +4 -2
- package/src/Field/Field.doc.mjs +1 -0
- package/src/Field/FieldLabel.tsx +20 -4
- package/src/Field/InputClearButton.test.tsx +25 -0
- package/src/Field/InputClearButton.tsx +4 -1
- package/src/FormLayout/FormLayout.doc.mjs +13 -0
- package/src/FormLayout/FormLayout.test.tsx +181 -4
- package/src/FormLayout/FormLayout.tsx +33 -2
- package/src/FormLayout/FormLayoutContext.ts +22 -7
- package/src/FormLayout/index.ts +1 -1
- package/src/Markdown/Markdown.doc.mjs +3 -0
- package/src/Markdown/Markdown.test.tsx +78 -0
- package/src/Markdown/Markdown.tsx +41 -1
- package/src/Markdown/parser.ts +60 -2
- package/src/MultiSelector/MultiSelector.tsx +3 -1
- package/src/NumberInput/NumberInput.tsx +3 -1
- package/src/Outline/Outline.doc.mjs +2 -0
- package/src/Outline/parseOutlineFromMarkdown.ts +10 -40
- package/src/RadioList/RadioList.tsx +9 -1
- package/src/Selector/Selector.tsx +3 -1
- package/src/StatusDot/StatusDot.doc.mjs +13 -0
- package/src/StatusDot/StatusDot.test.tsx +115 -8
- package/src/StatusDot/StatusDot.tsx +74 -9
- package/src/Switch/Switch.tsx +6 -0
- package/src/Table/plugins/rowExpansion/useTableRowExpansion.tsx +20 -2
- package/src/TextArea/TextArea.tsx +3 -1
- package/src/TextInput/TextInput.tsx +3 -1
- package/src/TimeInput/TimeInput.tsx +3 -1
- package/src/hooks/useResolvedRequired.ts +42 -0
- package/src/utils/plainDate.test.ts +50 -0
- 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('
|
|
24
|
-
it('
|
|
25
|
-
expect(
|
|
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
|
-
|
|
28
|
-
|
|
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('
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
expect(
|
|
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('
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
expect(
|
|
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(
|
|
120
|
-
expect(isPeekOffset(0, [0])).toBe(false);
|
|
166
|
+
expect(peekOffsetFor([0], 800)).toBeNull();
|
|
121
167
|
});
|
|
122
168
|
|
|
123
|
-
it('
|
|
124
|
-
expect(
|
|
169
|
+
it('takes the quarter mark itself as a peek', () => {
|
|
170
|
+
expect(peekOffsetFor([0, 600], 800)).toBe(600);
|
|
125
171
|
});
|
|
126
172
|
|
|
127
|
-
it('
|
|
128
|
-
|
|
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
|
|
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(
|
|
144
|
-
|
|
145
|
-
|
|
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(
|
|
152
|
-
|
|
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(
|
|
158
|
-
|
|
159
|
-
);
|
|
160
|
-
expect(
|
|
161
|
-
|
|
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(
|
|
176
|
-
|
|
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
|
|
7
|
-
* @position Internal to BottomSheet; consumed by useSheetGestures
|
|
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
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
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
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
|
94
|
-
offset: number,
|
|
171
|
+
export function peekOffsetFor(
|
|
95
172
|
offsets: ReadonlyArray<number>,
|
|
96
|
-
|
|
97
|
-
):
|
|
98
|
-
if (offsets.length < 2) {
|
|
99
|
-
return
|
|
173
|
+
visibleSheetHeight: number,
|
|
174
|
+
): number | null {
|
|
175
|
+
if (offsets.length < 2 || visibleSheetHeight <= 0) {
|
|
176
|
+
return null;
|
|
100
177
|
}
|
|
101
|
-
|
|
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
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
-
|
|
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,
|
|
306
|
-
// spans the mid detent (offset 100) to the peek
|
|
307
|
-
//
|
|
308
|
-
const {hook} = setup({snapHeights: () => [
|
|
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,
|
|
313
|
-
move(hook,
|
|
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: () => [
|
|
376
|
+
const {hook} = setup({snapHeights: () => [60, 300], onScrimOpacity});
|
|
324
377
|
const t = makeTarget();
|
|
325
|
-
// Slow drag down to the peek detent (offset
|
|
378
|
+
// Slow drag down to the peek detent (offset 340).
|
|
326
379
|
down(hook, 0, 0, t);
|
|
327
|
-
move(hook,
|
|
328
|
-
move(hook,
|
|
329
|
-
up(hook,
|
|
330
|
-
expect(hook.result.current.settledOffset).toBe(
|
|
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
|