panelui-native 0.51.1 → 0.53.0
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/README.md +2 -1
- package/lib/module/components/candlestick-chart/index.js +1161 -0
- package/lib/module/components/candlestick-chart/index.js.map +1 -0
- package/lib/module/components/combobox/index.js +73 -7
- package/lib/module/components/combobox/index.js.map +1 -1
- package/lib/module/components/context-menu/index.js +529 -0
- package/lib/module/components/context-menu/index.js.map +1 -0
- package/lib/module/components/menu/index.js +18 -10
- package/lib/module/components/menu/index.js.map +1 -1
- package/lib/module/components/popover/index.js +71 -7
- package/lib/module/components/popover/index.js.map +1 -1
- package/lib/module/components/sortable/index.js +159 -23
- package/lib/module/components/sortable/index.js.map +1 -1
- package/lib/module/components/tabs/index.js +56 -15
- package/lib/module/components/tabs/index.js.map +1 -1
- package/lib/module/components/time-picker/index.js +295 -31
- package/lib/module/components/time-picker/index.js.map +1 -1
- package/lib/module/index.js +3 -1
- package/lib/module/index.js.map +1 -1
- package/lib/typescript/src/components/candlestick-chart/index.d.ts +278 -0
- package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -0
- package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
- package/lib/typescript/src/components/context-menu/index.d.ts +270 -0
- package/lib/typescript/src/components/context-menu/index.d.ts.map +1 -0
- package/lib/typescript/src/components/menu/index.d.ts +20 -20
- package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
- package/lib/typescript/src/components/popover/index.d.ts +51 -1
- package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
- package/lib/typescript/src/components/sortable/index.d.ts +13 -3
- package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
- package/lib/typescript/src/components/tabs/index.d.ts +34 -2
- package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
- package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +3 -1
- package/lib/typescript/src/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/components/candlestick-chart/index.tsx +1360 -0
- package/src/components/combobox/index.tsx +84 -6
- package/src/components/context-menu/index.tsx +658 -0
- package/src/components/menu/index.tsx +17 -10
- package/src/components/popover/index.tsx +94 -6
- package/src/components/sortable/index.tsx +208 -22
- package/src/components/tabs/index.tsx +82 -16
- package/src/components/time-picker/index.tsx +330 -35
- package/src/index.ts +27 -0
|
@@ -140,6 +140,16 @@ const MEASURE_WIDTH = 400;
|
|
|
140
140
|
|
|
141
141
|
export type TabsVariant = 'segmented' | 'underline' | 'pill' | 'expanding';
|
|
142
142
|
|
|
143
|
+
/**
|
|
144
|
+
* How much of an inactive panel survives a switch away from it.
|
|
145
|
+
*
|
|
146
|
+
* `false` unmounts it. `true` keeps it mounted but takes it out of layout, so
|
|
147
|
+
* it costs nothing to have around. `'measured'` keeps it laid out at full size
|
|
148
|
+
* as well — the expensive option, and the only one a child that sizes itself
|
|
149
|
+
* from its parent can be built inside while it is hidden.
|
|
150
|
+
*/
|
|
151
|
+
export type TabsKeepMounted = boolean | 'measured';
|
|
152
|
+
|
|
143
153
|
const tabsVariants = tv({
|
|
144
154
|
slots: {
|
|
145
155
|
list: 'flex-row',
|
|
@@ -246,7 +256,7 @@ interface TabsContextValue {
|
|
|
246
256
|
variant: TabsVariant;
|
|
247
257
|
scrollable: boolean;
|
|
248
258
|
setScrollable: (scrollable: boolean) => void;
|
|
249
|
-
keepMounted:
|
|
259
|
+
keepMounted: TabsKeepMounted;
|
|
250
260
|
swipeable: boolean;
|
|
251
261
|
/**
|
|
252
262
|
* How far the visible panel is displaced from its resting place, in points.
|
|
@@ -292,8 +302,29 @@ export interface TabsProps extends ViewProps {
|
|
|
292
302
|
* Keep inactive panels mounted and hidden instead of unmounting them, so a
|
|
293
303
|
* scroll position or a half-filled form survives a switch away and back.
|
|
294
304
|
* Costs the render of every panel up front.
|
|
305
|
+
*
|
|
306
|
+
* `true` hides a kept panel with `display: none`, which also takes it out of
|
|
307
|
+
* layout: it is mounted, but it has no size. That is what makes it cheap, and
|
|
308
|
+
* it is enough for a panel whose content sizes itself — a column of views, a
|
|
309
|
+
* form, a `ScrollView` of known children.
|
|
310
|
+
*
|
|
311
|
+
* It is *not* enough for a child that decides what to render by measuring the
|
|
312
|
+
* space it has been given. A virtualised list asks its parent how tall it is
|
|
313
|
+
* and fills that many rows; asked inside a panel of zero height it answers
|
|
314
|
+
* zero rows, and the whole first render still lands on the frame the tab
|
|
315
|
+
* becomes visible — the stall this flag looks like it should have avoided.
|
|
316
|
+
*
|
|
317
|
+
* `'measured'` is for that case. A kept panel stays laid out at the full size
|
|
318
|
+
* of the tab set, and is hidden by not being drawn rather than by being
|
|
319
|
+
* removed from layout: a list inside it measures, renders its rows and
|
|
320
|
+
* settles while it is still hidden, so becoming visible costs nothing.
|
|
321
|
+
*
|
|
322
|
+
* The trade is real and is why it is not the default — every kept panel lays
|
|
323
|
+
* out and draws, up front and on every size change, so a five-tab set builds
|
|
324
|
+
* five panels' worth of rows to show one. Reach for it when a panel is slow
|
|
325
|
+
* to appear and its content is virtualised; leave it at `true` otherwise.
|
|
295
326
|
*/
|
|
296
|
-
keepMounted?:
|
|
327
|
+
keepMounted?: TabsKeepMounted;
|
|
297
328
|
/**
|
|
298
329
|
* Move between tabs by dragging sideways on the panel, as well as by
|
|
299
330
|
* pressing the triggers.
|
|
@@ -310,7 +341,9 @@ export interface TabsProps extends ViewProps {
|
|
|
310
341
|
* something is moving*, so a panel that is slow to build stops being a pause
|
|
311
342
|
* before it appears and starts being a stutter in the movement. If a swipe
|
|
312
343
|
* feels heavier than a press on the same tab set, the panel is expensive to
|
|
313
|
-
* mount
|
|
344
|
+
* mount — and the answer is whichever `keepMounted` actually keeps its
|
|
345
|
+
* content built, which for a virtualised list is `'measured'` rather than
|
|
346
|
+
* `true`. Turning this off hides the cost rather than removing it.
|
|
314
347
|
*/
|
|
315
348
|
swipeable?: boolean;
|
|
316
349
|
children: ReactNode;
|
|
@@ -928,11 +961,41 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
|
|
|
928
961
|
|
|
929
962
|
if (!active && !context.keepMounted) return null;
|
|
930
963
|
|
|
964
|
+
/*
|
|
965
|
+
* Under `keepMounted='measured'` a hidden panel keeps its size instead of
|
|
966
|
+
* losing it. It is taken out of the flow and stretched over the tab set, so
|
|
967
|
+
* it is laid out at the same size as the visible panel without contributing
|
|
968
|
+
* its height to the parent, and it is hidden by being transparent rather
|
|
969
|
+
* than by `display: none`.
|
|
970
|
+
*
|
|
971
|
+
* That distinction is the whole point: `display: none` lays a panel out at
|
|
972
|
+
* zero size, and a child that sizes itself from its parent — a virtualised
|
|
973
|
+
* list deciding how many rows to render — renders nothing at all inside one.
|
|
974
|
+
* Given a real size while still hidden, it builds its rows now rather than on
|
|
975
|
+
* the frame the tab is switched to.
|
|
976
|
+
*
|
|
977
|
+
* `opacity: 0` is set once and never animated, so it costs a composite and
|
|
978
|
+
* not a per-frame redraw; the negative `zIndex` keeps it behind the visible
|
|
979
|
+
* panel rather than over it, whatever order the panels are written in.
|
|
980
|
+
*/
|
|
981
|
+
const measured = context.keepMounted === 'measured';
|
|
982
|
+
const hiddenStyle = measured
|
|
983
|
+
? ({
|
|
984
|
+
position: 'absolute',
|
|
985
|
+
top: 0,
|
|
986
|
+
left: 0,
|
|
987
|
+
right: 0,
|
|
988
|
+
bottom: 0,
|
|
989
|
+
opacity: 0,
|
|
990
|
+
zIndex: -1,
|
|
991
|
+
} as const)
|
|
992
|
+
: ({ display: 'none' } as const);
|
|
993
|
+
|
|
931
994
|
/*
|
|
932
995
|
* Hidden rather than unmounted under `keepMounted`, and hidden thoroughly:
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
*
|
|
996
|
+
* it is not drawn, it takes no touches, and the accessibility props take it
|
|
997
|
+
* out of the reading order too. A screen reader walking through three panels
|
|
998
|
+
* of a tab set it cannot see is worse than no tabs at all.
|
|
936
999
|
*/
|
|
937
1000
|
const panel = (
|
|
938
1001
|
<Animated.View
|
|
@@ -953,19 +1016,21 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
|
|
|
953
1016
|
// window would be most of the way across it.
|
|
954
1017
|
//
|
|
955
1018
|
// Zero is ignored. Every panel reports into the same value, and a
|
|
956
|
-
// kept
|
|
957
|
-
//
|
|
1019
|
+
// panel kept with `display: none` measures as nothing — letting it say
|
|
1020
|
+
// so would leave the visible panel with no width. A `'measured'` one
|
|
1021
|
+
// is stretched over the tab set and reports the same width as the
|
|
1022
|
+
// visible panel, so it is agreeing rather than overwriting.
|
|
958
1023
|
const measured = event.nativeEvent.layout.width;
|
|
959
1024
|
if (measured > 0) width.value = measured;
|
|
960
1025
|
}}
|
|
961
1026
|
/*
|
|
962
1027
|
* The follow style goes on the panel that is moving, and only that one.
|
|
963
|
-
* Under `keepMounted` every other panel is
|
|
964
|
-
*
|
|
965
|
-
*
|
|
966
|
-
*
|
|
1028
|
+
* Under `keepMounted` every other panel is hidden and cannot be seen to
|
|
1029
|
+
* move — but an animated style still subscribes them all to the offset,
|
|
1030
|
+
* so a five-tab set ran five mappers per frame to reposition four views
|
|
1031
|
+
* nobody was looking at.
|
|
967
1032
|
*/
|
|
968
|
-
style={[!active &&
|
|
1033
|
+
style={[!active && hiddenStyle, swipeable && active && followStyle, style]}
|
|
969
1034
|
pointerEvents={active ? 'auto' : 'none'}
|
|
970
1035
|
accessibilityElementsHidden={!active}
|
|
971
1036
|
importantForAccessibility={active ? 'auto' : 'no-hide-descendants'}
|
|
@@ -976,9 +1041,10 @@ function TabsContent({ className, value, children, style, ...props }: TabsConten
|
|
|
976
1041
|
</Animated.View>
|
|
977
1042
|
);
|
|
978
1043
|
|
|
979
|
-
// Only the visible panel carries the gesture. A kept-mounted panel
|
|
980
|
-
//
|
|
981
|
-
// each of them would put several competing
|
|
1044
|
+
// Only the visible panel carries the gesture. A kept-mounted panel takes no
|
|
1045
|
+
// touches anyway — `pointerEvents` is off on it whichever way it is hidden —
|
|
1046
|
+
// but attaching a detector to each of them would put several competing
|
|
1047
|
+
// recognisers in the same tree.
|
|
982
1048
|
if (!swipeable || !active) return panel;
|
|
983
1049
|
|
|
984
1050
|
return <GestureDetector gesture={pan}>{panel}</GestureDetector>;
|
|
@@ -153,6 +153,58 @@ const timePickerVariants = tv({
|
|
|
153
153
|
},
|
|
154
154
|
});
|
|
155
155
|
|
|
156
|
+
/**
|
|
157
|
+
* The times a face may offer, and where a value sits among them.
|
|
158
|
+
*
|
|
159
|
+
* A scale or a list that runs the whole day while the picker only accepts part
|
|
160
|
+
* of it is a control that invites a choice in order to refuse it: the finger
|
|
161
|
+
* reaches the end, the root clamps, and the row springs back to where it
|
|
162
|
+
* started having said nothing about why. Offering only what will be accepted
|
|
163
|
+
* means the end of the scale *is* the last bookable time, and dragging to it
|
|
164
|
+
* picks it.
|
|
165
|
+
*
|
|
166
|
+
* The position is the nearest row rather than a division, because a filtered
|
|
167
|
+
* list no longer starts at midnight and its indices are its own.
|
|
168
|
+
*/
|
|
169
|
+
function useBoundedTimes(
|
|
170
|
+
minuteStep: number,
|
|
171
|
+
minTime: TimeValue | undefined,
|
|
172
|
+
maxTime: TimeValue | undefined,
|
|
173
|
+
value: TimeValue
|
|
174
|
+
) {
|
|
175
|
+
const times = useMemo(() => {
|
|
176
|
+
const all = timesOfDay(minuteStep);
|
|
177
|
+
if (!minTime && !maxTime) return all;
|
|
178
|
+
const low = minTime ? timeToMinutes(minTime) : Number.NEGATIVE_INFINITY;
|
|
179
|
+
const high = maxTime ? timeToMinutes(maxTime) : Number.POSITIVE_INFINITY;
|
|
180
|
+
const kept = all.filter((time) => {
|
|
181
|
+
const at = timeToMinutes(time);
|
|
182
|
+
return at >= low && at <= high;
|
|
183
|
+
});
|
|
184
|
+
// A span too narrow to contain a single step would leave nothing to scroll.
|
|
185
|
+
// The bounds are the caller's mistake there, and an empty face is worse.
|
|
186
|
+
return kept.length > 0 ? kept : all;
|
|
187
|
+
}, [minuteStep, minTime, maxTime]);
|
|
188
|
+
|
|
189
|
+
const index = useMemo(() => {
|
|
190
|
+
const target = timeToMinutes(value);
|
|
191
|
+
let nearest = 0;
|
|
192
|
+
let smallest = Number.POSITIVE_INFINITY;
|
|
193
|
+
for (let i = 0; i < times.length; i += 1) {
|
|
194
|
+
const time = times[i];
|
|
195
|
+
if (!time) continue;
|
|
196
|
+
const away = Math.abs(timeToMinutes(time) - target);
|
|
197
|
+
if (away < smallest) {
|
|
198
|
+
smallest = away;
|
|
199
|
+
nearest = i;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return nearest;
|
|
203
|
+
}, [times, value]);
|
|
204
|
+
|
|
205
|
+
return { times, index };
|
|
206
|
+
}
|
|
207
|
+
|
|
156
208
|
/* -------------------------------------------------------------------------- */
|
|
157
209
|
/* One snapping column */
|
|
158
210
|
/* -------------------------------------------------------------------------- */
|
|
@@ -161,6 +213,12 @@ interface ColumnProps<T> {
|
|
|
161
213
|
items: readonly T[];
|
|
162
214
|
/** Index of the selected item. Drives the scroll position. */
|
|
163
215
|
index: number;
|
|
216
|
+
/**
|
|
217
|
+
* Bumped every time the root has looked at a reported time, whether or not
|
|
218
|
+
* it accepted one. See `Column`'s resync effect for why the index alone is
|
|
219
|
+
* not enough to know a column is resting somewhere it should not be.
|
|
220
|
+
*/
|
|
221
|
+
syncToken: number;
|
|
164
222
|
onIndexChange: (index: number) => void;
|
|
165
223
|
label: (item: T) => string;
|
|
166
224
|
disabled?: boolean;
|
|
@@ -183,6 +241,7 @@ interface ColumnProps<T> {
|
|
|
183
241
|
function Column<T>({
|
|
184
242
|
items,
|
|
185
243
|
index,
|
|
244
|
+
syncToken,
|
|
186
245
|
onIndexChange,
|
|
187
246
|
label,
|
|
188
247
|
disabled,
|
|
@@ -208,24 +267,135 @@ function Column<T>({
|
|
|
208
267
|
offset.value = event.contentOffset.y;
|
|
209
268
|
});
|
|
210
269
|
|
|
270
|
+
/*
|
|
271
|
+
* Whether the list is still moving, and whether it owes itself a correction
|
|
272
|
+
* once it stops.
|
|
273
|
+
*
|
|
274
|
+
* Nothing may scroll the column programmatically while it is gliding. A
|
|
275
|
+
* `scrollTo` issued against a running deceleration fights it, and the list
|
|
276
|
+
* stops dead somewhere between two rows and takes no further touches — a
|
|
277
|
+
* flick reads as the wheel freezing. So a correction that arrives mid-flight
|
|
278
|
+
* is remembered rather than performed, and applied at the moment the list
|
|
279
|
+
* comes to rest.
|
|
280
|
+
*/
|
|
281
|
+
const moving = useRef(false);
|
|
282
|
+
const owedResync = useRef(false);
|
|
283
|
+
const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
284
|
+
|
|
285
|
+
// The effect below closes over `index`; `atRest` runs from a scroll handler
|
|
286
|
+
// long after that render, so it needs the current one rather than the one it
|
|
287
|
+
// captured.
|
|
288
|
+
const latestIndex = useRef(index);
|
|
289
|
+
latestIndex.current = index;
|
|
290
|
+
|
|
291
|
+
const cancelTimer = useCallback(() => {
|
|
292
|
+
if (settleTimer.current === null) return;
|
|
293
|
+
clearTimeout(settleTimer.current);
|
|
294
|
+
settleTimer.current = null;
|
|
295
|
+
}, []);
|
|
296
|
+
|
|
297
|
+
/*
|
|
298
|
+
* Unanimated, and that is load-bearing rather than a matter of taste.
|
|
299
|
+
*
|
|
300
|
+
* An animated programmatic scroll raises the same begin and end momentum
|
|
301
|
+
* events a finger does, so the correction reports itself as a gesture, which
|
|
302
|
+
* commits, which corrects again — the column rides up and down and never
|
|
303
|
+
* settles. Placing it outright raises nothing, so the correction ends where
|
|
304
|
+
* it starts. It is also the honest movement: this is the row that was always
|
|
305
|
+
* holding, not a journey to it.
|
|
306
|
+
*/
|
|
307
|
+
const snapTo = useCallback((to: number) => {
|
|
308
|
+
resting.current = to;
|
|
309
|
+
ref.current?.scrollTo({ y: to * ROW_HEIGHT, animated: false });
|
|
310
|
+
}, []);
|
|
311
|
+
|
|
312
|
+
/*
|
|
313
|
+
* Runs on the token as well as the index, and that is the whole point.
|
|
314
|
+
*
|
|
315
|
+
* A reported row can be refused — by `minTime`, by `maxTime`, or by a step
|
|
316
|
+
* the root rounds to. When it is, the value does not change, so `index` does
|
|
317
|
+
* not change either, and an effect watching the index alone never learns that
|
|
318
|
+
* anything happened. The column was left resting on a row the picker had not
|
|
319
|
+
* accepted, showing one time while reporting another, and no later drag could
|
|
320
|
+
* put it right: it had already recorded that row as where it sits.
|
|
321
|
+
*
|
|
322
|
+
* The token changes on every reported time, refused or not, so the column is
|
|
323
|
+
* always told to check itself, and the check is the same one it always was.
|
|
324
|
+
*/
|
|
211
325
|
useEffect(() => {
|
|
212
326
|
if (index === resting.current) return;
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
327
|
+
if (moving.current) {
|
|
328
|
+
owedResync.current = true;
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
331
|
+
snapTo(index);
|
|
332
|
+
}, [index, syncToken, snapTo]);
|
|
333
|
+
|
|
334
|
+
/** The list has genuinely stopped. Pay off a correction if one is owed. */
|
|
335
|
+
const atRest = useCallback(() => {
|
|
336
|
+
cancelTimer();
|
|
337
|
+
moving.current = false;
|
|
338
|
+
if (!owedResync.current) return;
|
|
339
|
+
owedResync.current = false;
|
|
340
|
+
if (latestIndex.current !== resting.current) snapTo(latestIndex.current);
|
|
341
|
+
}, [cancelTimer, snapTo]);
|
|
342
|
+
|
|
343
|
+
const startMoving = useCallback(() => {
|
|
344
|
+
cancelTimer();
|
|
345
|
+
moving.current = true;
|
|
346
|
+
}, [cancelTimer]);
|
|
347
|
+
|
|
348
|
+
const settleAt = useCallback(
|
|
349
|
+
(y: number) => {
|
|
350
|
+
const next = Math.round(y / ROW_HEIGHT);
|
|
220
351
|
const clamped = Math.min(Math.max(next, 0), items.length - 1);
|
|
221
352
|
if (clamped === resting.current) return;
|
|
222
353
|
resting.current = clamped;
|
|
354
|
+
/*
|
|
355
|
+
* This report supersedes any correction that was owed. The correction was
|
|
356
|
+
* worked out against a row the column has now left, and paying it off
|
|
357
|
+
* afterwards would drag the column back to a row nobody chose.
|
|
358
|
+
*/
|
|
359
|
+
owedResync.current = false;
|
|
223
360
|
selectionTick();
|
|
224
361
|
onIndexChange(clamped);
|
|
225
362
|
},
|
|
226
363
|
[items.length, onIndexChange]
|
|
227
364
|
);
|
|
228
365
|
|
|
366
|
+
/*
|
|
367
|
+
* A flick reports once, when it stops — not twice.
|
|
368
|
+
*
|
|
369
|
+
* Momentum begins a frame *after* the drag ends, so the end of a drag cannot
|
|
370
|
+
* tell a flick from a release by itself. It arms a short timer, and momentum
|
|
371
|
+
* starting cancels it; a drag that stops dead has no momentum to cancel it
|
|
372
|
+
* and settles on the timer. Reporting the release offset directly, as this
|
|
373
|
+
* used to, committed a row the finger was in the middle of flying past, and
|
|
374
|
+
* the picker then had two answers for one gesture.
|
|
375
|
+
*/
|
|
376
|
+
const onDragEnd = useCallback(
|
|
377
|
+
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
378
|
+
const y = event.nativeEvent.contentOffset.y;
|
|
379
|
+
cancelTimer();
|
|
380
|
+
settleTimer.current = setTimeout(() => {
|
|
381
|
+
settleAt(y);
|
|
382
|
+
atRest();
|
|
383
|
+
}, 80);
|
|
384
|
+
},
|
|
385
|
+
[atRest, cancelTimer, settleAt]
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
const onMomentumEnd = useCallback(
|
|
389
|
+
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
390
|
+
cancelTimer();
|
|
391
|
+
settleAt(event.nativeEvent.contentOffset.y);
|
|
392
|
+
atRest();
|
|
393
|
+
},
|
|
394
|
+
[atRest, cancelTimer, settleAt]
|
|
395
|
+
);
|
|
396
|
+
|
|
397
|
+
useEffect(() => cancelTimer, [cancelTimer]);
|
|
398
|
+
|
|
229
399
|
const selectedItem = items[index];
|
|
230
400
|
|
|
231
401
|
return (
|
|
@@ -242,11 +412,14 @@ function Column<T>({
|
|
|
242
412
|
snapToOffsets={snapToOffsets}
|
|
243
413
|
disableIntervalMomentum
|
|
244
414
|
decelerationRate="fast"
|
|
245
|
-
/* Both, because only one of them fires: a flick ends in
|
|
246
|
-
slow drag released on a snap point ends without any.
|
|
247
|
-
case leaves a column that looks settled and has
|
|
248
|
-
|
|
249
|
-
|
|
415
|
+
/* Both ends are wired, because only one of them fires: a flick ends in
|
|
416
|
+
momentum, and a slow drag released on a snap point ends without any.
|
|
417
|
+
Missing the drag case leaves a column that looks settled and has
|
|
418
|
+
reported nothing. */
|
|
419
|
+
onScrollBeginDrag={startMoving}
|
|
420
|
+
onScrollEndDrag={onDragEnd}
|
|
421
|
+
onMomentumScrollBegin={startMoving}
|
|
422
|
+
onMomentumScrollEnd={onMomentumEnd}
|
|
250
423
|
/* The rows fade rather than unmount, so a recycled row would pop in at
|
|
251
424
|
full opacity mid-scroll. A day of minutes is 60 views; keeping them
|
|
252
425
|
all is cheaper than the flicker. */
|
|
@@ -284,11 +457,19 @@ function ColumnRow({
|
|
|
284
457
|
}) {
|
|
285
458
|
const slots = timePickerVariants();
|
|
286
459
|
|
|
460
|
+
/*
|
|
461
|
+
* The row either side of the centre stays a number you can read. It used to
|
|
462
|
+
* drop to just over half opacity and shrink by an eighth at one row out,
|
|
463
|
+
* which on a settling column reads as the digits smearing rather than as
|
|
464
|
+
* depth — and the neighbours are exactly the rows being compared against
|
|
465
|
+
* while choosing. The far rows still fall away, since that fall is what makes
|
|
466
|
+
* the column look like a wheel instead of a list.
|
|
467
|
+
*/
|
|
287
468
|
const style = useAnimatedStyle(() => {
|
|
288
469
|
const distance = Math.abs(offset.value / ROW_HEIGHT - index);
|
|
289
470
|
return {
|
|
290
|
-
opacity: interpolate(distance, [0, 1, 2], [1, 0.
|
|
291
|
-
transform: [{ scale: interpolate(distance, [0, 1], [1, 0.
|
|
471
|
+
opacity: interpolate(distance, [0, 1, 2], [1, 0.75, 0.25], 'clamp'),
|
|
472
|
+
transform: [{ scale: interpolate(distance, [0, 1], [1, 0.94], 'clamp') }],
|
|
292
473
|
};
|
|
293
474
|
});
|
|
294
475
|
|
|
@@ -311,8 +492,22 @@ function ColumnRow({
|
|
|
311
492
|
interface FaceProps {
|
|
312
493
|
value: TimeValue;
|
|
313
494
|
onValueChange: (value: TimeValue) => void;
|
|
495
|
+
/**
|
|
496
|
+
* Bumped every time the root has looked at a reported time, so a face can
|
|
497
|
+
* tell "nothing changed because nothing was reported" from "nothing changed
|
|
498
|
+
* because what I reported was refused". Only the scrolling faces use it.
|
|
499
|
+
*/
|
|
500
|
+
syncToken: number;
|
|
314
501
|
hourCycle: HourCycle;
|
|
315
502
|
minuteStep: number;
|
|
503
|
+
/**
|
|
504
|
+
* The span the picker will accept, so a face that lists whole times can
|
|
505
|
+
* offer only those. A face that does not list whole times — the wheel, whose
|
|
506
|
+
* columns each hold one part of one — ignores these and is corrected by the
|
|
507
|
+
* root instead.
|
|
508
|
+
*/
|
|
509
|
+
minTime?: TimeValue;
|
|
510
|
+
maxTime?: TimeValue;
|
|
316
511
|
locale?: string;
|
|
317
512
|
disabled?: boolean;
|
|
318
513
|
/** Ruler face only — the other two never draw a number of their own. */
|
|
@@ -322,6 +517,7 @@ interface FaceProps {
|
|
|
322
517
|
function WheelFace({
|
|
323
518
|
value,
|
|
324
519
|
onValueChange,
|
|
520
|
+
syncToken,
|
|
325
521
|
hourCycle,
|
|
326
522
|
minuteStep,
|
|
327
523
|
locale,
|
|
@@ -376,7 +572,17 @@ function WheelFace({
|
|
|
376
572
|
const setMeridiem = useCallback(
|
|
377
573
|
(index: number) => {
|
|
378
574
|
const next = index === 0 ? 'am' : 'pm';
|
|
379
|
-
|
|
575
|
+
/*
|
|
576
|
+
* Reported even when the column came back to the half it started on.
|
|
577
|
+
* Staying silent there left the root with nothing to answer, so a column
|
|
578
|
+
* dragged away and released short of a swap had no way to be told to sit
|
|
579
|
+
* back down — the other two columns had the same hole, and this is the
|
|
580
|
+
* same fix.
|
|
581
|
+
*/
|
|
582
|
+
if (next === meridiemOf(value.hour)) {
|
|
583
|
+
onValueChange({ ...value });
|
|
584
|
+
return;
|
|
585
|
+
}
|
|
380
586
|
onValueChange({
|
|
381
587
|
...value,
|
|
382
588
|
hour: next === 'pm' ? value.hour + 12 : value.hour - 12,
|
|
@@ -397,6 +603,7 @@ function WheelFace({
|
|
|
397
603
|
<Column
|
|
398
604
|
items={hours}
|
|
399
605
|
index={hourIndex}
|
|
606
|
+
syncToken={syncToken}
|
|
400
607
|
onIndexChange={setHour}
|
|
401
608
|
label={(hour) => (hourCycle === 24 ? padTwo(hour) : String(hour))}
|
|
402
609
|
disabled={disabled}
|
|
@@ -406,6 +613,7 @@ function WheelFace({
|
|
|
406
613
|
<Column
|
|
407
614
|
items={minutes}
|
|
408
615
|
index={minuteIndex}
|
|
616
|
+
syncToken={syncToken}
|
|
409
617
|
onIndexChange={setMinute}
|
|
410
618
|
label={padTwo}
|
|
411
619
|
disabled={disabled}
|
|
@@ -416,6 +624,7 @@ function WheelFace({
|
|
|
416
624
|
<Column
|
|
417
625
|
items={meridiems}
|
|
418
626
|
index={meridiemIndex}
|
|
627
|
+
syncToken={syncToken}
|
|
419
628
|
onIndexChange={setMeridiem}
|
|
420
629
|
label={(entry) => entry}
|
|
421
630
|
disabled={disabled}
|
|
@@ -535,17 +744,16 @@ function handEnd(turn: number, length: number) {
|
|
|
535
744
|
function ClockFaceLayout({
|
|
536
745
|
value,
|
|
537
746
|
onValueChange,
|
|
747
|
+
syncToken,
|
|
538
748
|
hourCycle,
|
|
539
749
|
minuteStep,
|
|
750
|
+
minTime,
|
|
751
|
+
maxTime,
|
|
540
752
|
locale,
|
|
541
753
|
disabled,
|
|
542
754
|
}: FaceProps) {
|
|
543
755
|
const slots = timePickerVariants();
|
|
544
|
-
const times
|
|
545
|
-
const index = Math.min(
|
|
546
|
-
times.length - 1,
|
|
547
|
-
Math.round(timeToMinutes(value) / minuteStep)
|
|
548
|
-
);
|
|
756
|
+
const { times, index } = useBoundedTimes(minuteStep, minTime, maxTime, value);
|
|
549
757
|
|
|
550
758
|
const setIndex = useCallback(
|
|
551
759
|
(next: number) => {
|
|
@@ -561,6 +769,7 @@ function ClockFaceLayout({
|
|
|
561
769
|
<Column
|
|
562
770
|
items={times}
|
|
563
771
|
index={index}
|
|
772
|
+
syncToken={syncToken}
|
|
564
773
|
onIndexChange={setIndex}
|
|
565
774
|
label={(time) => formatTime(time, { hourCycle, locale })}
|
|
566
775
|
disabled={disabled}
|
|
@@ -590,19 +799,18 @@ function ClockFaceLayout({
|
|
|
590
799
|
function RulerFace({
|
|
591
800
|
value,
|
|
592
801
|
onValueChange,
|
|
802
|
+
syncToken,
|
|
593
803
|
hourCycle,
|
|
594
804
|
minuteStep,
|
|
805
|
+
minTime,
|
|
806
|
+
maxTime,
|
|
595
807
|
locale,
|
|
596
808
|
disabled,
|
|
597
809
|
readout = 'default',
|
|
598
810
|
}: FaceProps) {
|
|
599
811
|
const slots = timePickerVariants({ readout });
|
|
600
812
|
const ref = useRef<ScrollView>(null);
|
|
601
|
-
const times
|
|
602
|
-
const index = Math.min(
|
|
603
|
-
times.length - 1,
|
|
604
|
-
Math.round(timeToMinutes(value) / minuteStep)
|
|
605
|
-
);
|
|
813
|
+
const { times, index } = useBoundedTimes(minuteStep, minTime, maxTime, value);
|
|
606
814
|
const resting = useRef(index);
|
|
607
815
|
/*
|
|
608
816
|
* Measured, not assumed: the scroller is narrower than the panel by whatever
|
|
@@ -622,18 +830,58 @@ function RulerFace({
|
|
|
622
830
|
[times]
|
|
623
831
|
);
|
|
624
832
|
|
|
833
|
+
// The scale runs the same rest-and-resync machinery the wheel's columns do,
|
|
834
|
+
// on the other axis and for the same two reasons: a refused tick moves
|
|
835
|
+
// nothing and has to be corrected, and a correction must never be scrolled
|
|
836
|
+
// into a scale that is still gliding.
|
|
837
|
+
const moving = useRef(false);
|
|
838
|
+
const owedResync = useRef(false);
|
|
839
|
+
const settleTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
840
|
+
const latestIndex = useRef(index);
|
|
841
|
+
latestIndex.current = index;
|
|
842
|
+
|
|
843
|
+
const cancelTimer = useCallback(() => {
|
|
844
|
+
if (settleTimer.current === null) return;
|
|
845
|
+
clearTimeout(settleTimer.current);
|
|
846
|
+
settleTimer.current = null;
|
|
847
|
+
}, []);
|
|
848
|
+
|
|
849
|
+
// Unanimated, for the reason given on the wheel's columns: an animated
|
|
850
|
+
// programmatic scroll reports itself as a gesture and the correction loops.
|
|
851
|
+
const snapTo = useCallback((to: number) => {
|
|
852
|
+
resting.current = to;
|
|
853
|
+
ref.current?.scrollTo({ x: to * TICK_SPACING, animated: false });
|
|
854
|
+
}, []);
|
|
855
|
+
|
|
625
856
|
useEffect(() => {
|
|
626
857
|
if (index === resting.current) return;
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
858
|
+
if (moving.current) {
|
|
859
|
+
owedResync.current = true;
|
|
860
|
+
return;
|
|
861
|
+
}
|
|
862
|
+
snapTo(index);
|
|
863
|
+
}, [index, syncToken, snapTo]);
|
|
864
|
+
|
|
865
|
+
const atRest = useCallback(() => {
|
|
866
|
+
cancelTimer();
|
|
867
|
+
moving.current = false;
|
|
868
|
+
if (!owedResync.current) return;
|
|
869
|
+
owedResync.current = false;
|
|
870
|
+
if (latestIndex.current !== resting.current) snapTo(latestIndex.current);
|
|
871
|
+
}, [cancelTimer, snapTo]);
|
|
872
|
+
|
|
873
|
+
const startMoving = useCallback(() => {
|
|
874
|
+
cancelTimer();
|
|
875
|
+
moving.current = true;
|
|
876
|
+
}, [cancelTimer]);
|
|
877
|
+
|
|
878
|
+
const settleAt = useCallback(
|
|
879
|
+
(x: number) => {
|
|
880
|
+
const next = Math.round(x / TICK_SPACING);
|
|
634
881
|
const clamped = Math.min(Math.max(next, 0), times.length - 1);
|
|
635
882
|
if (clamped === resting.current) return;
|
|
636
883
|
resting.current = clamped;
|
|
884
|
+
owedResync.current = false;
|
|
637
885
|
selectionTick();
|
|
638
886
|
const time = times[clamped];
|
|
639
887
|
if (time) onValueChange(time);
|
|
@@ -641,6 +889,29 @@ function RulerFace({
|
|
|
641
889
|
[onValueChange, times]
|
|
642
890
|
);
|
|
643
891
|
|
|
892
|
+
const onDragEnd = useCallback(
|
|
893
|
+
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
894
|
+
const x = event.nativeEvent.contentOffset.x;
|
|
895
|
+
cancelTimer();
|
|
896
|
+
settleTimer.current = setTimeout(() => {
|
|
897
|
+
settleAt(x);
|
|
898
|
+
atRest();
|
|
899
|
+
}, 80);
|
|
900
|
+
},
|
|
901
|
+
[atRest, cancelTimer, settleAt]
|
|
902
|
+
);
|
|
903
|
+
|
|
904
|
+
const onMomentumEnd = useCallback(
|
|
905
|
+
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
|
|
906
|
+
cancelTimer();
|
|
907
|
+
settleAt(event.nativeEvent.contentOffset.x);
|
|
908
|
+
atRest();
|
|
909
|
+
},
|
|
910
|
+
[atRest, cancelTimer, settleAt]
|
|
911
|
+
);
|
|
912
|
+
|
|
913
|
+
useEffect(() => cancelTimer, [cancelTimer]);
|
|
914
|
+
|
|
644
915
|
const stepsPerHour = Math.max(1, Math.round(60 / minuteStep));
|
|
645
916
|
|
|
646
917
|
/*
|
|
@@ -678,8 +949,10 @@ function RulerFace({
|
|
|
678
949
|
snapToOffsets={snapToOffsets}
|
|
679
950
|
disableIntervalMomentum
|
|
680
951
|
decelerationRate="fast"
|
|
681
|
-
|
|
682
|
-
onScrollEndDrag={
|
|
952
|
+
onScrollBeginDrag={startMoving}
|
|
953
|
+
onScrollEndDrag={onDragEnd}
|
|
954
|
+
onMomentumScrollBegin={startMoving}
|
|
955
|
+
onMomentumScrollEnd={onMomentumEnd}
|
|
683
956
|
accessibilityRole="adjustable"
|
|
684
957
|
accessibilityLabel="Time"
|
|
685
958
|
accessibilityValue={{ text: formatTime(value, { hourCycle, locale }) }}
|
|
@@ -879,10 +1152,29 @@ function TimePickerRoot({
|
|
|
879
1152
|
* itself using — putting both here means every layout is bounded by the same
|
|
880
1153
|
* rule and none of them has to carry it.
|
|
881
1154
|
*/
|
|
1155
|
+
/*
|
|
1156
|
+
* Counts refusals, not reports.
|
|
1157
|
+
*
|
|
1158
|
+
* A face has no way of knowing that the row it landed on was bounded away by
|
|
1159
|
+
* `minTime` or `maxTime`, or rounded to a different one by the step — from
|
|
1160
|
+
* where it stands, a refusal and a value that simply did not move are the
|
|
1161
|
+
* same silence. This changes when a report was refused, which is the one
|
|
1162
|
+
* thing the faces were missing, and is what lets a column put itself back on
|
|
1163
|
+
* the row that actually holds.
|
|
1164
|
+
*
|
|
1165
|
+
* Only on refusal, though. Bumping it on every report re-rendered all three
|
|
1166
|
+
* columns each time any one of them settled, for no answer any of them
|
|
1167
|
+
* needed.
|
|
1168
|
+
*/
|
|
1169
|
+
const [refusals, setRefusals] = useState(0);
|
|
1170
|
+
|
|
882
1171
|
const commit = useCallback(
|
|
883
1172
|
(next: TimeValue) => {
|
|
884
1173
|
const bounded = clampTime(roundToStep(next, step), minTime, maxTime);
|
|
885
|
-
if (isSameTime(bounded, selected))
|
|
1174
|
+
if (isSameTime(bounded, selected)) {
|
|
1175
|
+
setRefusals((count) => count + 1);
|
|
1176
|
+
return;
|
|
1177
|
+
}
|
|
886
1178
|
if (!isValueControlled) setInternalValue(bounded);
|
|
887
1179
|
onValueChange?.(bounded);
|
|
888
1180
|
},
|
|
@@ -898,7 +1190,10 @@ function TimePickerRoot({
|
|
|
898
1190
|
const face: FaceProps = {
|
|
899
1191
|
value: draft,
|
|
900
1192
|
onValueChange: commit,
|
|
1193
|
+
syncToken: refusals,
|
|
901
1194
|
hourCycle,
|
|
1195
|
+
minTime,
|
|
1196
|
+
maxTime,
|
|
902
1197
|
minuteStep: step,
|
|
903
1198
|
locale,
|
|
904
1199
|
disabled,
|