panelui-native 0.66.1 → 0.68.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 (129) hide show
  1. package/README.md +21 -1
  2. package/lib/module/components/area-chart/index.js +18 -1
  3. package/lib/module/components/area-chart/index.js.map +1 -1
  4. package/lib/module/components/bar-chart/index.js +18 -1
  5. package/lib/module/components/bar-chart/index.js.map +1 -1
  6. package/lib/module/components/candlestick-chart/index.js +18 -1
  7. package/lib/module/components/candlestick-chart/index.js.map +1 -1
  8. package/lib/module/components/form/index.js +7 -5
  9. package/lib/module/components/form/index.js.map +1 -1
  10. package/lib/module/components/form/typed-form.js +11 -0
  11. package/lib/module/components/form/typed-form.js.map +1 -0
  12. package/lib/module/components/form/use-form.js +4 -0
  13. package/lib/module/components/form/use-form.js.map +1 -1
  14. package/lib/module/components/heatmap-chart/index.js +27 -1
  15. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  16. package/lib/module/components/line-chart/index.js +18 -1
  17. package/lib/module/components/line-chart/index.js.map +1 -1
  18. package/lib/module/components/message-scroller/index.js +172 -10
  19. package/lib/module/components/message-scroller/index.js.map +1 -1
  20. package/lib/module/components/message-scroller/message-scroller-math.js +23 -0
  21. package/lib/module/components/message-scroller/message-scroller-math.js.map +1 -1
  22. package/lib/module/components/meter/index.js +250 -0
  23. package/lib/module/components/meter/index.js.map +1 -0
  24. package/lib/module/components/meter/meter-scale.js +88 -0
  25. package/lib/module/components/meter/meter-scale.js.map +1 -0
  26. package/lib/module/components/planner/index.js +714 -0
  27. package/lib/module/components/planner/index.js.map +1 -0
  28. package/lib/module/components/planner/planner-entries.js +124 -0
  29. package/lib/module/components/planner/planner-entries.js.map +1 -0
  30. package/lib/module/components/radar-chart/index.js +18 -1
  31. package/lib/module/components/radar-chart/index.js.map +1 -1
  32. package/lib/module/components/scatter-chart/index.js +18 -1
  33. package/lib/module/components/scatter-chart/index.js.map +1 -1
  34. package/lib/module/components/skeleton/index.js +45 -7
  35. package/lib/module/components/skeleton/index.js.map +1 -1
  36. package/lib/module/components/spinner/index.js +63 -14
  37. package/lib/module/components/spinner/index.js.map +1 -1
  38. package/lib/module/components/time-picker/index.js +46 -21
  39. package/lib/module/components/time-picker/index.js.map +1 -1
  40. package/lib/module/components/time-picker/ruler-window.js +25 -0
  41. package/lib/module/components/time-picker/ruler-window.js.map +1 -0
  42. package/lib/module/hooks/breakpoint-contract.js +29 -0
  43. package/lib/module/hooks/breakpoint-contract.js.map +1 -0
  44. package/lib/module/hooks/index.js +1 -1
  45. package/lib/module/hooks/index.js.map +1 -1
  46. package/lib/module/hooks/use-breakpoint.js +46 -1
  47. package/lib/module/hooks/use-breakpoint.js.map +1 -1
  48. package/lib/module/index.js +3 -1
  49. package/lib/module/index.js.map +1 -1
  50. package/lib/module/primitives/chart-accessibility.js +68 -0
  51. package/lib/module/primitives/chart-accessibility.js.map +1 -0
  52. package/lib/typescript/src/components/area-chart/index.d.ts +2 -1
  53. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  54. package/lib/typescript/src/components/bar-chart/index.d.ts +2 -1
  55. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
  56. package/lib/typescript/src/components/candlestick-chart/index.d.ts +2 -1
  57. package/lib/typescript/src/components/candlestick-chart/index.d.ts.map +1 -1
  58. package/lib/typescript/src/components/form/index.d.ts +18 -5
  59. package/lib/typescript/src/components/form/index.d.ts.map +1 -1
  60. package/lib/typescript/src/components/form/typed-form.d.ts +7 -0
  61. package/lib/typescript/src/components/form/typed-form.d.ts.map +1 -0
  62. package/lib/typescript/src/components/form/use-form.d.ts +4 -0
  63. package/lib/typescript/src/components/form/use-form.d.ts.map +1 -1
  64. package/lib/typescript/src/components/heatmap-chart/index.d.ts +2 -1
  65. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  66. package/lib/typescript/src/components/line-chart/index.d.ts +2 -1
  67. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  68. package/lib/typescript/src/components/message-scroller/index.d.ts +30 -2
  69. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -1
  70. package/lib/typescript/src/components/message-scroller/message-scroller-math.d.ts +10 -0
  71. package/lib/typescript/src/components/message-scroller/message-scroller-math.d.ts.map +1 -1
  72. package/lib/typescript/src/components/meter/index.d.ts +270 -0
  73. package/lib/typescript/src/components/meter/index.d.ts.map +1 -0
  74. package/lib/typescript/src/components/meter/meter-scale.d.ts +57 -0
  75. package/lib/typescript/src/components/meter/meter-scale.d.ts.map +1 -0
  76. package/lib/typescript/src/components/planner/index.d.ts +274 -0
  77. package/lib/typescript/src/components/planner/index.d.ts.map +1 -0
  78. package/lib/typescript/src/components/planner/planner-entries.d.ts +75 -0
  79. package/lib/typescript/src/components/planner/planner-entries.d.ts.map +1 -0
  80. package/lib/typescript/src/components/radar-chart/index.d.ts +4 -0
  81. package/lib/typescript/src/components/radar-chart/index.d.ts.map +1 -1
  82. package/lib/typescript/src/components/scatter-chart/index.d.ts +2 -1
  83. package/lib/typescript/src/components/scatter-chart/index.d.ts.map +1 -1
  84. package/lib/typescript/src/components/skeleton/index.d.ts +25 -2
  85. package/lib/typescript/src/components/skeleton/index.d.ts.map +1 -1
  86. package/lib/typescript/src/components/spinner/index.d.ts +25 -2
  87. package/lib/typescript/src/components/spinner/index.d.ts.map +1 -1
  88. package/lib/typescript/src/components/time-picker/index.d.ts +5 -6
  89. package/lib/typescript/src/components/time-picker/index.d.ts.map +1 -1
  90. package/lib/typescript/src/components/time-picker/ruler-window.d.ts +12 -0
  91. package/lib/typescript/src/components/time-picker/ruler-window.d.ts.map +1 -0
  92. package/lib/typescript/src/hooks/breakpoint-contract.d.ts +6 -0
  93. package/lib/typescript/src/hooks/breakpoint-contract.d.ts.map +1 -0
  94. package/lib/typescript/src/hooks/index.d.ts +1 -1
  95. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  96. package/lib/typescript/src/hooks/use-breakpoint.d.ts +22 -0
  97. package/lib/typescript/src/hooks/use-breakpoint.d.ts.map +1 -1
  98. package/lib/typescript/src/index.d.ts +3 -1
  99. package/lib/typescript/src/index.d.ts.map +1 -1
  100. package/lib/typescript/src/primitives/chart-accessibility.d.ts +33 -0
  101. package/lib/typescript/src/primitives/chart-accessibility.d.ts.map +1 -0
  102. package/lib/typescript/types/form-api.type-test.d.ts +2 -0
  103. package/lib/typescript/types/form-api.type-test.d.ts.map +1 -0
  104. package/package.json +28 -2
  105. package/src/components/area-chart/index.tsx +27 -2
  106. package/src/components/bar-chart/index.tsx +27 -2
  107. package/src/components/candlestick-chart/index.tsx +30 -2
  108. package/src/components/form/index.tsx +32 -5
  109. package/src/components/form/typed-form.ts +13 -0
  110. package/src/components/form/use-form.ts +9 -0
  111. package/src/components/heatmap-chart/index.tsx +49 -3
  112. package/src/components/line-chart/index.tsx +27 -2
  113. package/src/components/message-scroller/index.tsx +234 -6
  114. package/src/components/message-scroller/message-scroller-math.ts +38 -0
  115. package/src/components/meter/index.tsx +318 -0
  116. package/src/components/meter/meter-scale.ts +109 -0
  117. package/src/components/planner/index.tsx +886 -0
  118. package/src/components/planner/planner-entries.ts +146 -0
  119. package/src/components/radar-chart/index.tsx +24 -0
  120. package/src/components/scatter-chart/index.tsx +27 -2
  121. package/src/components/skeleton/index.tsx +59 -5
  122. package/src/components/spinner/index.tsx +80 -13
  123. package/src/components/time-picker/index.tsx +57 -29
  124. package/src/components/time-picker/ruler-window.ts +24 -0
  125. package/src/hooks/breakpoint-contract.ts +31 -0
  126. package/src/hooks/index.ts +4 -0
  127. package/src/hooks/use-breakpoint.ts +61 -10
  128. package/src/index.ts +30 -0
  129. package/src/primitives/chart-accessibility.ts +102 -0
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The parts of a planner that are arithmetic rather than rendering: which
3
+ * entries fall on which day, which category an entry belongs to, what a day
4
+ * says to a screen reader, and what the month adds up to.
5
+ *
6
+ * Kept out of the component so they can be tested without a renderer, and so
7
+ * the day cell stays a function of its inputs rather than of when it ran.
8
+ *
9
+ * Nothing here imports anything. The tests run this file through `node --test`
10
+ * with type stripping rather than a bundler, so an extensionless import of a
11
+ * sibling module — which is how the rest of the library is written — does not
12
+ * resolve. That is why the one line of date arithmetic below is here instead
13
+ * of coming from `utils/date`.
14
+ */
15
+
16
+ /** The minimum an entry has to be for the grid to place it. */
17
+ export interface PlannerDatedEntry {
18
+ date: Date;
19
+ /** Matches a `PlannerCategory` id. An entry without one is uncategorised. */
20
+ category?: string;
21
+ }
22
+
23
+ export interface PlannerCountedCategory {
24
+ id: string;
25
+ label: string;
26
+ count: number;
27
+ }
28
+
29
+ /** A day's key: local midnight, so two times on the same day agree. */
30
+ export function dayKey(date: Date): number {
31
+ return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
32
+ }
33
+
34
+ /**
35
+ * Entries grouped by the day they fall on, each day in the order it was given.
36
+ *
37
+ * A map rather than a lookup per cell: a month draws 42 cells, and asking each
38
+ * of them to filter the whole list is 42 passes over it every render.
39
+ */
40
+ export function bucketByDay<T extends PlannerDatedEntry>(
41
+ entries: readonly T[]
42
+ ): Map<number, T[]> {
43
+ const days = new Map<number, T[]>();
44
+ for (const entry of entries) {
45
+ const key = dayKey(entry.date);
46
+ const bucket = days.get(key);
47
+ if (bucket) bucket.push(entry);
48
+ else days.set(key, [entry]);
49
+ }
50
+ return days;
51
+ }
52
+
53
+ /** What falls on one day. Always an array, so a cell has nothing to guard. */
54
+ export function entriesOn<T extends PlannerDatedEntry>(
55
+ days: Map<number, T[]>,
56
+ date: Date
57
+ ): T[] {
58
+ return days.get(dayKey(date)) ?? EMPTY;
59
+ }
60
+
61
+ /*
62
+ * One frozen array for every empty day, rather than a new `[]` each call. A
63
+ * month is mostly empty days, and a fresh array per cell per render defeats
64
+ * every memo below it for a day where nothing changed.
65
+ */
66
+ const EMPTY: never[] = Object.freeze([]) as never[];
67
+
68
+ /**
69
+ * How many entries a cell draws before it stops and says how many are left.
70
+ *
71
+ * `limit` of 0 means draw none of them — the day still carries its marker and
72
+ * its spoken label, which is the whole content of a compact planner.
73
+ */
74
+ export function visibleEntries<T>(entries: readonly T[], limit: number): {
75
+ shown: T[];
76
+ overflow: number;
77
+ } {
78
+ const cap = Math.max(0, Math.floor(limit));
79
+ if (entries.length <= cap) return { shown: [...entries], overflow: 0 };
80
+ return { shown: entries.slice(0, cap), overflow: entries.length - cap };
81
+ }
82
+
83
+ /**
84
+ * What a day says when it is read out.
85
+ *
86
+ * The date on its own is what a date picker says, and it is not enough here: a
87
+ * day in a planner differs from the one beside it by what is on it, and a
88
+ * marker that carries that difference in colour alone carries it to nobody who
89
+ * cannot see the colour. So the count and the categories are spoken.
90
+ *
91
+ * `dateLabel` is passed in rather than formatted here, so the grid and the
92
+ * label agree about the calendar system and the locale.
93
+ */
94
+ export function dayAccessibilityLabel<T extends PlannerDatedEntry>(
95
+ dateLabel: string,
96
+ entries: readonly T[],
97
+ categoryLabels: ReadonlyMap<string, string>
98
+ ): string {
99
+ if (entries.length === 0) return `${dateLabel}, nothing planned`;
100
+
101
+ const named: string[] = [];
102
+ for (const entry of entries) {
103
+ const label = entry.category ? categoryLabels.get(entry.category) : undefined;
104
+ if (label && !named.includes(label)) named.push(label);
105
+ }
106
+
107
+ const count = `${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`;
108
+ return named.length > 0
109
+ ? `${dateLabel}, ${count}: ${named.join(', ')}`
110
+ : `${dateLabel}, ${count}`;
111
+ }
112
+
113
+ /**
114
+ * What the month adds up to, per category, in the order the categories were
115
+ * declared — so the legend and the totals beside it never disagree about which
116
+ * one comes first.
117
+ *
118
+ * Entries outside `month` are left out. The grid draws the days either side of
119
+ * the month it is showing, and counting those would make a total that changes
120
+ * with the week the month happens to start on.
121
+ */
122
+ export function summariseMonth<T extends PlannerDatedEntry>(
123
+ entries: readonly T[],
124
+ categories: readonly { id: string; label: string }[],
125
+ isInMonth: (date: Date) => boolean
126
+ ): { total: number; categories: PlannerCountedCategory[] } {
127
+ const counts = new Map<string, number>();
128
+ let total = 0;
129
+
130
+ for (const entry of entries) {
131
+ if (!isInMonth(entry.date)) continue;
132
+ total += 1;
133
+ if (entry.category) {
134
+ counts.set(entry.category, (counts.get(entry.category) ?? 0) + 1);
135
+ }
136
+ }
137
+
138
+ return {
139
+ total,
140
+ categories: categories.map(({ id, label }) => ({
141
+ id,
142
+ label,
143
+ count: counts.get(id) ?? 0,
144
+ })),
145
+ };
146
+ }
@@ -59,6 +59,7 @@ import Animated, {
59
59
  } from 'react-native-reanimated';
60
60
  import { useCSSVariable } from 'uniwind';
61
61
  import { Text } from '../../primitives/text';
62
+ import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility';
62
63
  import { cn } from '../../utils/cn';
63
64
  import {
64
65
  polarPoint,
@@ -212,6 +213,9 @@ export interface RadarChartProps extends Omit<ViewProps, 'children'> {
212
213
  /** Drop the room reserved for axis labels, for a radar with none. */
213
214
  compact?: boolean;
214
215
  children?: ReactNode;
216
+ /** Accessible data labels and optional activation for each axis row. */
217
+ accessibilityLabelForDatum?: ChartAccessibilityProps<RadarChartDatum>['accessibilityLabelForDatum'];
218
+ onAccessibilityDatumPress?: ChartAccessibilityProps<RadarChartDatum>['onAccessibilityDatumPress'];
215
219
  }
216
220
 
217
221
  /** Imperative handle: re-run the reveal on demand, for a "replay" control. */
@@ -231,6 +235,11 @@ const RadarChartRoot = forwardRef<RadarChartHandle, RadarChartProps>(
231
235
  domain,
232
236
  animationDuration = REVEAL_DURATION,
233
237
  compact = false,
238
+ accessible,
239
+ accessibilityLabel,
240
+ accessibilityHint,
241
+ accessibilityLabelForDatum,
242
+ onAccessibilityDatumPress,
234
243
  children,
235
244
  ...props
236
245
  },
@@ -393,6 +402,19 @@ const RadarChartRoot = forwardRef<RadarChartHandle, RadarChartProps>(
393
402
  <RadarChartContext.Provider value={context}>
394
403
  <View {...props} style={props.style} className={cn('w-full', className)}>
395
404
  {header}
405
+ <ChartAccessibilityData
406
+ chart="Radar chart"
407
+ data={data}
408
+ disabled={accessible === false || status === 'loading'}
409
+ accessibilityLabel={accessibilityLabel}
410
+ accessibilityHint={accessibilityHint}
411
+ accessibilityLabelForDatum={accessibilityLabelForDatum}
412
+ onAccessibilityDatumPress={onAccessibilityDatumPress}
413
+ valueOf={(datum) => [
414
+ [axisKey, datum[axisKey]],
415
+ ...series.map(([key]) => [key, datum[key]] as [string, unknown]),
416
+ ]}
417
+ />
396
418
  <View
397
419
  onLayout={onLayout}
398
420
  style={
@@ -407,6 +429,8 @@ const RadarChartRoot = forwardRef<RadarChartHandle, RadarChartProps>(
407
429
  : { aspectRatio }
408
430
  }
409
431
  className={fixedSize ? 'self-center' : 'w-full'}
432
+ accessibilityElementsHidden
433
+ importantForAccessibility="no-hide-descendants"
410
434
  >
411
435
  {radius > 0 ? (
412
436
  <>
@@ -67,6 +67,7 @@ import Animated, {
67
67
  import Svg, { Circle, G, Line as SvgLine } from 'react-native-svg';
68
68
  import { useCSSVariable } from 'uniwind';
69
69
  import { Text } from '../../primitives/text';
70
+ import { ChartAccessibilityData, type ChartAccessibilityProps } from '../../primitives/chart-accessibility';
70
71
  import { compactNumber, useSeriesColor, xAt, yOf, type Plot } from '../../utils/chart';
71
72
  import { cn } from '../../utils/cn';
72
73
 
@@ -191,7 +192,7 @@ export function useScatterChart() {
191
192
  return { activePoint, xDataKey };
192
193
  }
193
194
 
194
- export interface ScatterChartProps extends ViewProps {
195
+ export interface ScatterChartProps extends ViewProps, ChartAccessibilityProps<ScatterChartDatum> {
195
196
  className?: string;
196
197
  /** The rows. Each one is a point, placed by two of its values. */
197
198
  data: ScatterChartDatum[];
@@ -244,6 +245,11 @@ const ScatterChartRoot = forwardRef<ScatterChartHandle, ScatterChartProps>(
244
245
  xDomain,
245
246
  yDomain,
246
247
  onActivePointChange,
248
+ accessible,
249
+ accessibilityLabel,
250
+ accessibilityHint,
251
+ accessibilityLabelForDatum,
252
+ onAccessibilityDatumPress,
247
253
  compact = false,
248
254
  children,
249
255
  ...props
@@ -476,7 +482,26 @@ const ScatterChartRoot = forwardRef<ScatterChartHandle, ScatterChartProps>(
476
482
  <ScatterChartContext.Provider value={context}>
477
483
  <View {...props} style={props.style} className={cn('w-full', className)}>
478
484
  {header}
479
- <View onLayout={onLayout} style={{ aspectRatio }} className="w-full">
485
+ <ChartAccessibilityData
486
+ chart="Scatter chart"
487
+ data={data}
488
+ disabled={accessible === false || status === 'loading'}
489
+ accessibilityLabel={accessibilityLabel}
490
+ accessibilityHint={accessibilityHint}
491
+ accessibilityLabelForDatum={accessibilityLabelForDatum}
492
+ onAccessibilityDatumPress={onAccessibilityDatumPress}
493
+ valueOf={(datum) => [
494
+ [xDataKey, datum[xDataKey]],
495
+ ...series.map(([key]) => [key, datum[key]] as [string, unknown]),
496
+ ]}
497
+ />
498
+ <View
499
+ onLayout={onLayout}
500
+ style={{ aspectRatio }}
501
+ className="w-full"
502
+ accessibilityElementsHidden
503
+ importantForAccessibility="no-hide-descendants"
504
+ >
480
505
  {plot.width > 0 ? (
481
506
  <>
482
507
  <Svg width="100%" height="100%" style={StyleSheet.absoluteFill}>
@@ -2,31 +2,85 @@ import { memo, useEffect } from 'react';
2
2
  import Animated, {
3
3
  cancelAnimation,
4
4
  useAnimatedStyle,
5
+ useReducedMotion,
5
6
  useSharedValue,
6
7
  withRepeat,
7
8
  withTiming,
8
9
  } from 'react-native-reanimated';
9
10
  import { cn } from '../../utils/cn';
10
11
 
12
+ /** Milliseconds for one half of the pulse. */
13
+ const PULSE_DURATION = 700;
14
+ /** How far down the pulse goes. */
15
+ const PULSE_FLOOR = 0.45;
16
+ /**
17
+ * Where the pulse rests when it is not running. Between the two ends of it, so
18
+ * a still skeleton looks like the same placeholder rather than a solid block.
19
+ */
20
+ const RESTING_OPACITY = 0.7;
21
+
11
22
  export interface SkeletonProps {
12
23
  className?: string;
24
+ /**
25
+ * What is loading, for a screen reader. Setting it makes this skeleton
26
+ * announce as a busy status; leaving it unset keeps the placeholder out of
27
+ * the accessibility tree entirely.
28
+ *
29
+ * A screen full of placeholders needs **one** of them labelled, not all of
30
+ * them — put it on the skeleton standing for the region and leave the rest
31
+ * silent.
32
+ */
33
+ label?: string;
13
34
  }
14
35
 
15
- /** Pulsing placeholder. Opacity animation runs on the UI thread. */
16
- export const Skeleton = memo(function Skeleton({ className }: SkeletonProps) {
17
- const opacity = useSharedValue(1);
36
+ /**
37
+ * Pulsing placeholder for content that has not arrived. The opacity animation
38
+ * runs on the UI thread.
39
+ *
40
+ * A placeholder is a picture of absent content, so it is hidden from assistive
41
+ * technology by default — an unlabelled grey box announces nothing worth
42
+ * hearing, and a screen of them announces it many times over. Pass `label` on
43
+ * the one skeleton that stands for the region to have the wait announced once.
44
+ *
45
+ * Under the platform's reduce-motion setting the pulse stops and the
46
+ * placeholder holds at a middle opacity. Unlike a spinner, a skeleton that
47
+ * stops moving does not read as hung: it is a shape where content will be, and
48
+ * the shape says that on its own.
49
+ */
50
+ export const Skeleton = memo(function Skeleton({
51
+ className,
52
+ label,
53
+ }: SkeletonProps) {
54
+ const reducedMotion = useReducedMotion();
55
+ const opacity = useSharedValue(reducedMotion ? RESTING_OPACITY : 1);
18
56
 
19
57
  useEffect(() => {
20
- opacity.value = withRepeat(withTiming(0.45, { duration: 700 }), -1, true);
58
+ if (reducedMotion) {
59
+ opacity.value = RESTING_OPACITY;
60
+ return undefined;
61
+ }
62
+ opacity.value = 1;
63
+ opacity.value = withRepeat(
64
+ withTiming(PULSE_FLOOR, { duration: PULSE_DURATION }),
65
+ -1,
66
+ true
67
+ );
21
68
  return () => cancelAnimation(opacity);
22
- }, [opacity]);
69
+ }, [opacity, reducedMotion]);
23
70
 
24
71
  const animatedStyle = useAnimatedStyle(() => ({
25
72
  opacity: opacity.value,
26
73
  }));
27
74
 
75
+ const announced = label != null;
76
+
28
77
  return (
29
78
  <Animated.View
79
+ accessibilityRole={announced ? 'progressbar' : undefined}
80
+ accessibilityLabel={label}
81
+ accessibilityState={announced ? { busy: true } : undefined}
82
+ accessibilityElementsHidden={!announced}
83
+ importantForAccessibility={announced ? 'auto' : 'no-hide-descendants'}
30
84
  style={animatedStyle}
31
85
  className={cn('rounded-md bg-skeleton', className)}
32
86
  />
@@ -1,14 +1,23 @@
1
1
  import { memo, useEffect } from 'react';
2
2
  import Animated, {
3
3
  Easing,
4
+ cancelAnimation,
5
+ interpolate,
4
6
  useAnimatedStyle,
7
+ useReducedMotion,
5
8
  useSharedValue,
6
9
  withRepeat,
7
10
  withTiming,
8
- cancelAnimation,
9
11
  } from 'react-native-reanimated';
10
12
  import { tv, type VariantProps } from 'tailwind-variants';
11
13
 
14
+ /** Milliseconds for one full turn. */
15
+ const SPIN_DURATION = 800;
16
+ /** Milliseconds for one half of the fade that stands in for the turn. */
17
+ const PULSE_DURATION = 900;
18
+ /** How far down the fade goes. Faint enough to read as a pulse, not a flicker. */
19
+ const PULSE_FLOOR = 0.35;
20
+
12
21
  const spinnerVariants = tv({
13
22
  base: 'rounded-full border-2 border-muted border-t-primary',
14
23
  variants: {
@@ -25,27 +34,85 @@ const spinnerVariants = tv({
25
34
 
26
35
  export interface SpinnerProps extends VariantProps<typeof spinnerVariants> {
27
36
  className?: string;
37
+ /**
38
+ * What is loading, for a screen reader. Setting it makes the spinner
39
+ * announce as a busy status; leaving it unset keeps it out of the
40
+ * accessibility tree.
41
+ *
42
+ * Leave it unset wherever something around the spinner already says the
43
+ * wait is on — a loading button, a row with its own caption. Set it where
44
+ * the spinner is the only sign, or the wait passes in silence.
45
+ */
46
+ label?: string;
28
47
  }
29
48
 
30
- /** Rotating ring spinner. Animation runs entirely on the UI thread. */
31
- export const Spinner = memo(function Spinner({ className, size }: SpinnerProps) {
32
- const rotation = useSharedValue(0);
49
+ /**
50
+ * Rotating ring for work of unknown length. The animation runs entirely on the
51
+ * UI thread.
52
+ *
53
+ * A bare ring carries no words, so it is hidden from assistive technology
54
+ * unless `label` is given — an unnamed progress indicator announces its role
55
+ * and nothing else, which is noise standing in for information.
56
+ *
57
+ * Under the platform's reduce-motion setting the ring stops turning and fades
58
+ * in place instead. Stopping it dead is the other option and it is the wrong
59
+ * one: a spinner that holds still reads as one that has hung, which is the
60
+ * single thing a spinner exists to rule out. Reduce motion asks for less
61
+ * travel, not for less news.
62
+ */
63
+ export const Spinner = memo(function Spinner({
64
+ className,
65
+ size,
66
+ label,
67
+ }: SpinnerProps) {
68
+ const reducedMotion = useReducedMotion();
69
+ const progress = useSharedValue(0);
33
70
 
34
71
  useEffect(() => {
35
- rotation.value = withRepeat(
36
- withTiming(360, { duration: 800, easing: Easing.linear }),
37
- -1
72
+ progress.value = 0;
73
+ progress.value = withRepeat(
74
+ withTiming(1, {
75
+ duration: reducedMotion ? PULSE_DURATION : SPIN_DURATION,
76
+ easing: reducedMotion ? Easing.inOut(Easing.ease) : Easing.linear,
77
+ }),
78
+ -1,
79
+ // The turn restarts where it began, because a full circle already ends
80
+ // where it started. The fade turns around, because a pulse that snapped
81
+ // back to full brightness would be the flicker the setting is there to
82
+ // avoid.
83
+ reducedMotion
38
84
  );
39
- return () => cancelAnimation(rotation);
40
- }, [rotation]);
85
+ return () => cancelAnimation(progress);
86
+ }, [progress, reducedMotion]);
87
+
88
+ /*
89
+ * One style sets both properties either case needs. The two are swapped on
90
+ * the same view when the setting changes under a mounted spinner, and an
91
+ * animated property that one case drops is not reset by the other — it is
92
+ * left at whatever it was last given, which would strand a half-faded ring
93
+ * at that opacity for the rest of its life.
94
+ */
95
+ const animatedStyle = useAnimatedStyle(() =>
96
+ reducedMotion
97
+ ? {
98
+ opacity: interpolate(progress.value, [0, 1], [1, PULSE_FLOOR]),
99
+ transform: [{ rotate: '0deg' }],
100
+ }
101
+ : {
102
+ opacity: 1,
103
+ transform: [{ rotate: `${progress.value * 360}deg` }],
104
+ }
105
+ );
41
106
 
42
- const animatedStyle = useAnimatedStyle(() => ({
43
- transform: [{ rotate: `${rotation.value}deg` }],
44
- }));
107
+ const announced = label != null;
45
108
 
46
109
  return (
47
110
  <Animated.View
48
- accessibilityRole="progressbar"
111
+ accessibilityRole={announced ? 'progressbar' : undefined}
112
+ accessibilityLabel={label}
113
+ accessibilityState={announced ? { busy: true } : undefined}
114
+ accessibilityElementsHidden={!announced}
115
+ importantForAccessibility={announced ? 'auto' : 'no-hide-descendants'}
49
116
  style={animatedStyle}
50
117
  className={spinnerVariants({ size, className })}
51
118
  />
@@ -36,12 +36,11 @@
36
36
  *
37
37
  * ## Scroll offset, not gesture maths
38
38
  *
39
- * The wheel, the clock's list and the ruler are all scroll views with
40
- * `snapToOffsets`, and the selection is `Math.round(offset / itemSize)`. That
41
- * buys momentum, deceleration, edge bounce and platform-correct fling physics
42
- * for nothing, and none of it would be worth rebuilding on a pan gesture. The
43
- * per-item fade and the ruler's readout follow the offset on the UI thread, so
44
- * scrolling never round-trips through React — only the settled value does.
39
+ * The wheel, the clock's list and the ruler are snapping scroll views, and the
40
+ * selection is `Math.round(offset / itemSize)`. That buys momentum,
41
+ * deceleration, edge bounce and platform-correct fling physics for nothing,
42
+ * and none of it would be worth rebuilding on a pan gesture. The ruler keeps
43
+ * that full scroll range but mounts only an overscanned window of its ticks.
45
44
  */
46
45
  import {
47
46
  useCallback,
@@ -79,6 +78,7 @@ import {
79
78
  accessibilityValueForIndex,
80
79
  indexForAccessibilityAction,
81
80
  } from './accessibility';
81
+ import { rulerWindow } from './ruler-window';
82
82
  import {
83
83
  clampTime,
84
84
  displayHour,
@@ -848,10 +848,20 @@ function RulerFace({
848
848
  */
849
849
  const [width, setWidth] = useState(0);
850
850
  const pad = Math.max(0, width / 2 - TICK_SPACING / 2);
851
+ const [windowIndex, setWindowIndex] = useState(index);
852
+ // A controlled/accessibility jump renders its destination before the effect
853
+ // moves the scroll view there; an in-flight gesture follows the scroll window.
854
+ const windowAnchor = index === resting.current ? windowIndex : index;
855
+ const window = rulerWindow(times.length, windowAnchor);
851
856
 
852
- const snapToOffsets = useMemo(
853
- () => times.map((_, i) => i * TICK_SPACING),
854
- [times]
857
+ const showIndex = useCallback(
858
+ (next: number) => {
859
+ const nextStart = rulerWindow(times.length, next).start;
860
+ setWindowIndex((current) =>
861
+ rulerWindow(times.length, current).start === nextStart ? current : next
862
+ );
863
+ },
864
+ [times.length]
855
865
  );
856
866
 
857
867
  // The scale runs the same rest-and-resync machinery the wheel's columns do,
@@ -872,10 +882,14 @@ function RulerFace({
872
882
 
873
883
  // Unanimated, for the reason given on the wheel's columns: an animated
874
884
  // programmatic scroll reports itself as a gesture and the correction loops.
875
- const snapTo = useCallback((to: number) => {
876
- resting.current = to;
877
- ref.current?.scrollTo({ x: to * TICK_SPACING, animated: false });
878
- }, []);
885
+ const snapTo = useCallback(
886
+ (to: number) => {
887
+ resting.current = to;
888
+ showIndex(to);
889
+ ref.current?.scrollTo({ x: to * TICK_SPACING, animated: false });
890
+ },
891
+ [showIndex]
892
+ );
879
893
 
880
894
  useEffect(() => {
881
895
  if (index === resting.current) return;
@@ -934,6 +948,13 @@ function RulerFace({
934
948
  [atRest, cancelTimer, settleAt]
935
949
  );
936
950
 
951
+ const onScroll = useCallback(
952
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
953
+ showIndex(Math.round(event.nativeEvent.contentOffset.x / TICK_SPACING));
954
+ },
955
+ [showIndex]
956
+ );
957
+
937
958
  useEffect(() => cancelTimer, [cancelTimer]);
938
959
 
939
960
  const stepsPerHour = Math.max(1, Math.round(60 / minuteStep));
@@ -985,9 +1006,11 @@ function RulerFace({
985
1006
  contentOffset={{ x: index * TICK_SPACING, y: 0 }}
986
1007
  scrollEnabled={!disabled}
987
1008
  showsHorizontalScrollIndicator={false}
988
- snapToOffsets={snapToOffsets}
1009
+ snapToInterval={TICK_SPACING}
989
1010
  disableIntervalMomentum
990
1011
  decelerationRate="fast"
1012
+ onScroll={onScroll}
1013
+ scrollEventThrottle={16}
991
1014
  onScrollBeginDrag={startMoving}
992
1015
  onScrollEndDrag={onDragEnd}
993
1016
  onMomentumScrollBegin={startMoving}
@@ -1009,22 +1032,27 @@ function RulerFace({
1009
1032
  at most — which is a legend, not a scale, and reads as clutter
1010
1033
  beside a number set at 36px.
1011
1034
  */}
1012
- {times.map((_, i) => (
1013
- <View
1014
- key={i}
1015
- style={{ width: TICK_SPACING }}
1016
- className="items-center justify-center"
1017
- >
1035
+ <View style={{ width: window.start * TICK_SPACING }} />
1036
+ {times.slice(window.start, window.end).map((_, offset) => {
1037
+ const i = window.start + offset;
1038
+ return (
1018
1039
  <View
1019
- className={cn(
1020
- 'w-0.5 rounded-full',
1021
- // Taller and darker on the hour, so the scale reads as hours
1022
- // rather than as an undifferentiated comb.
1023
- i % stepsPerHour === 0 ? 'h-7 bg-muted-foreground' : 'h-4 bg-border'
1024
- )}
1025
- />
1026
- </View>
1027
- ))}
1040
+ key={i}
1041
+ style={{ width: TICK_SPACING }}
1042
+ className="items-center justify-center"
1043
+ >
1044
+ <View
1045
+ className={cn(
1046
+ 'w-0.5 rounded-full',
1047
+ // Taller and darker on the hour, so the scale reads as hours
1048
+ // rather than as an undifferentiated comb.
1049
+ i % stepsPerHour === 0 ? 'h-7 bg-muted-foreground' : 'h-4 bg-border'
1050
+ )}
1051
+ />
1052
+ </View>
1053
+ );
1054
+ })}
1055
+ <View style={{ width: (times.length - window.end) * TICK_SPACING }} />
1028
1056
  </ScrollView>
1029
1057
 
1030
1058
  {/* Over the scale rather than in it: the indicator marks the centre of
@@ -0,0 +1,24 @@
1
+ /** Maximum tick cells mounted by the ruler, regardless of the minute step. */
2
+ export const RULER_WINDOW_SIZE = 121;
3
+
4
+ const WINDOW_SHIFT = 40;
5
+
6
+ export interface RulerWindow {
7
+ start: number;
8
+ end: number;
9
+ }
10
+
11
+ /**
12
+ * A chunked, overscanned slice around the visible tick. It moves only once per
13
+ * WINDOW_SHIFT ticks, leaving at least forty ticks ahead of a normal scroll.
14
+ */
15
+ export function rulerWindow(itemCount: number, visibleIndex: number): RulerWindow {
16
+ const count = Math.max(0, Math.floor(itemCount));
17
+ if (count <= RULER_WINDOW_SIZE) return { start: 0, end: count };
18
+
19
+ const index = Math.min(Math.max(Math.round(visibleIndex), 0), count - 1);
20
+ const chunk = Math.floor(index / WINDOW_SHIFT) * WINDOW_SHIFT;
21
+ const before = Math.floor((RULER_WINDOW_SIZE - WINDOW_SHIFT) / 2);
22
+ const start = Math.min(Math.max(chunk - before, 0), count - RULER_WINDOW_SIZE);
23
+ return { start, end: start + RULER_WINDOW_SIZE };
24
+ }
@@ -0,0 +1,31 @@
1
+ export type BreakpointDefinition = Readonly<Record<string, number>>;
2
+
3
+ /** Validate declaration order once, when an app creates its responsive contract. */
4
+ export function breakpointEntries<const T extends BreakpointDefinition>(definition: T) {
5
+ const entries = Object.entries(definition) as [keyof T & string, number][];
6
+ if (!entries.length) throw new Error('Breakpoints must declare at least one threshold.');
7
+ let previous = -1;
8
+ for (const [name, threshold] of entries) {
9
+ if (!name || !Number.isFinite(threshold) || threshold < 0) {
10
+ throw new Error(`Breakpoint "${name}" must have a finite, non-negative threshold.`);
11
+ }
12
+ if (threshold <= previous) {
13
+ throw new Error('Breakpoint thresholds must be strictly ascending in declaration order.');
14
+ }
15
+ previous = threshold;
16
+ }
17
+ return entries;
18
+ }
19
+
20
+ /** Largest satisfied name, with `base` as the deterministic below-range fallback. */
21
+ export function breakpointAt<Name extends string>(
22
+ entries: readonly (readonly [Name, number])[],
23
+ width: number
24
+ ): Name | 'base' {
25
+ let current: Name | 'base' = 'base';
26
+ for (const [name, threshold] of entries) {
27
+ if (width < threshold) break;
28
+ current = name;
29
+ }
30
+ return current;
31
+ }
@@ -10,8 +10,12 @@ export {
10
10
  } from './use-disclosure';
11
11
  export {
12
12
  useBreakpoint,
13
+ createBreakpoints,
13
14
  BREAKPOINTS,
14
15
  type Breakpoint,
16
+ type BreakpointContract,
17
+ type BreakpointProviderProps,
18
+ type BreakpointResult,
15
19
  type UseBreakpointResult,
16
20
  } from './use-breakpoint';
17
21
  export { useKeyboard, type UseKeyboardResult } from './use-keyboard';