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.
Files changed (45) hide show
  1. package/README.md +2 -1
  2. package/lib/module/components/candlestick-chart/index.js +1161 -0
  3. package/lib/module/components/candlestick-chart/index.js.map +1 -0
  4. package/lib/module/components/combobox/index.js +73 -7
  5. package/lib/module/components/combobox/index.js.map +1 -1
  6. package/lib/module/components/context-menu/index.js +529 -0
  7. package/lib/module/components/context-menu/index.js.map +1 -0
  8. package/lib/module/components/menu/index.js +18 -10
  9. package/lib/module/components/menu/index.js.map +1 -1
  10. package/lib/module/components/popover/index.js +71 -7
  11. package/lib/module/components/popover/index.js.map +1 -1
  12. package/lib/module/components/sortable/index.js +159 -23
  13. package/lib/module/components/sortable/index.js.map +1 -1
  14. package/lib/module/components/tabs/index.js +56 -15
  15. package/lib/module/components/tabs/index.js.map +1 -1
  16. package/lib/module/components/time-picker/index.js +295 -31
  17. package/lib/module/components/time-picker/index.js.map +1 -1
  18. package/lib/module/index.js +3 -1
  19. package/lib/module/index.js.map +1 -1
  20. package/lib/typescript/src/components/candlestick-chart/index.d.ts +278 -0
  21. package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -0
  22. package/lib/typescript/src/components/combobox/index.d.ts.map +1 -1
  23. package/lib/typescript/src/components/context-menu/index.d.ts +270 -0
  24. package/lib/typescript/src/components/context-menu/index.d.ts.map +1 -0
  25. package/lib/typescript/src/components/menu/index.d.ts +20 -20
  26. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  27. package/lib/typescript/src/components/popover/index.d.ts +51 -1
  28. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  29. package/lib/typescript/src/components/sortable/index.d.ts +13 -3
  30. package/lib/typescript/src/components/sortable/index.d.ts.map +1 -1
  31. package/lib/typescript/src/components/tabs/index.d.ts +34 -2
  32. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  33. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  34. package/lib/typescript/src/index.d.ts +3 -1
  35. package/lib/typescript/src/index.d.ts.map +1 -1
  36. package/package.json +1 -1
  37. package/src/components/candlestick-chart/index.tsx +1360 -0
  38. package/src/components/combobox/index.tsx +84 -6
  39. package/src/components/context-menu/index.tsx +658 -0
  40. package/src/components/menu/index.tsx +17 -10
  41. package/src/components/popover/index.tsx +94 -6
  42. package/src/components/sortable/index.tsx +208 -22
  43. package/src/components/tabs/index.tsx +82 -16
  44. package/src/components/time-picker/index.tsx +330 -35
  45. 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: boolean;
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?: boolean;
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; `keepMounted` is the answer, not turning this off.
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
- * `display: none` takes it out of layout, and the accessibility props take
934
- * it out of the reading order too. A screen reader walking through three
935
- * panels of a tab set it cannot see is worse than no tabs at all.
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-mounted one is `display: none` — it measures as nothing, and
957
- // letting it say so would leave the visible panel with no width.
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 `display: none` and cannot be
964
- * seen to move — but an animated style still subscribes them all to the
965
- * offset, so a five-tab set ran five mappers per frame to reposition four
966
- * views nobody was looking at.
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 && { display: 'none' }, swipeable && active && followStyle, style]}
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 is
980
- // `display: none` and takes no touches anyway, but attaching a detector to
981
- // each of them would put several competing recognisers in the same tree.
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
- resting.current = index;
214
- ref.current?.scrollTo({ y: index * ROW_HEIGHT, animated: true });
215
- }, [index]);
216
-
217
- const settle = useCallback(
218
- (event: NativeSyntheticEvent<NativeScrollEvent>) => {
219
- const next = Math.round(event.nativeEvent.contentOffset.y / ROW_HEIGHT);
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 momentum, and a
246
- slow drag released on a snap point ends without any. Missing the drag
247
- case leaves a column that looks settled and has reported nothing. */
248
- onMomentumScrollEnd={settle}
249
- onScrollEndDrag={settle}
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.55, 0.2], 'clamp'),
291
- transform: [{ scale: interpolate(distance, [0, 1], [1, 0.88], 'clamp') }],
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
- if (next === meridiemOf(value.hour)) return;
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 = useMemo(() => timesOfDay(minuteStep), [minuteStep]);
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 = useMemo(() => timesOfDay(minuteStep), [minuteStep]);
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
- resting.current = index;
628
- ref.current?.scrollTo({ x: index * TICK_SPACING, animated: true });
629
- }, [index]);
630
-
631
- const settle = useCallback(
632
- (event: NativeSyntheticEvent<NativeScrollEvent>) => {
633
- const next = Math.round(event.nativeEvent.contentOffset.x / TICK_SPACING);
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
- onMomentumScrollEnd={settle}
682
- onScrollEndDrag={settle}
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)) return;
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,