panelui-native 0.68.0 → 0.71.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 (93) hide show
  1. package/README.md +12 -2
  2. package/lib/module/components/accordion/index.js +28 -9
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/button/index.js +14 -7
  5. package/lib/module/components/button/index.js.map +1 -1
  6. package/lib/module/components/collapsible/index.js +280 -0
  7. package/lib/module/components/collapsible/index.js.map +1 -0
  8. package/lib/module/components/flow/index.js +66 -3
  9. package/lib/module/components/flow/index.js.map +1 -1
  10. package/lib/module/components/live-line-chart/index.js +103 -9
  11. package/lib/module/components/live-line-chart/index.js.map +1 -1
  12. package/lib/module/components/live-line-chart/live-line-accessibility.js +47 -0
  13. package/lib/module/components/live-line-chart/live-line-accessibility.js.map +1 -0
  14. package/lib/module/components/live-line-chart/live-line-lifecycle.js +33 -0
  15. package/lib/module/components/live-line-chart/live-line-lifecycle.js.map +1 -0
  16. package/lib/module/components/marquee/index.js +194 -0
  17. package/lib/module/components/marquee/index.js.map +1 -0
  18. package/lib/module/components/meter/index.js +38 -20
  19. package/lib/module/components/meter/index.js.map +1 -1
  20. package/lib/module/components/meter/meter-scale.js +62 -8
  21. package/lib/module/components/meter/meter-scale.js.map +1 -1
  22. package/lib/module/components/planner/index.js +133 -59
  23. package/lib/module/components/planner/index.js.map +1 -1
  24. package/lib/module/components/planner/planner-announcement.js +30 -0
  25. package/lib/module/components/planner/planner-announcement.js.map +1 -0
  26. package/lib/module/components/planner/planner-entries.js +15 -7
  27. package/lib/module/components/planner/planner-entries.js.map +1 -1
  28. package/lib/module/components/planner/planner-grid-navigation.js +33 -0
  29. package/lib/module/components/planner/planner-grid-navigation.js.map +1 -0
  30. package/lib/module/components/planner/planner-lifecycle.js +45 -0
  31. package/lib/module/components/planner/planner-lifecycle.js.map +1 -0
  32. package/lib/module/components/popover/index.js +2 -2
  33. package/lib/module/components/popover/index.js.map +1 -1
  34. package/lib/module/index.js +2 -0
  35. package/lib/module/index.js.map +1 -1
  36. package/lib/module/primitives/focus-restoration-store.js +52 -0
  37. package/lib/module/primitives/focus-restoration-store.js.map +1 -0
  38. package/lib/module/primitives/portal.js +36 -2
  39. package/lib/module/primitives/portal.js.map +1 -1
  40. package/lib/typescript/src/components/accordion/index.d.ts +4 -0
  41. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  43. package/lib/typescript/src/components/collapsible/index.d.ts +76 -0
  44. package/lib/typescript/src/components/collapsible/index.d.ts.map +1 -0
  45. package/lib/typescript/src/components/flow/index.d.ts +7 -1
  46. package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
  47. package/lib/typescript/src/components/live-line-chart/index.d.ts +21 -7
  48. package/lib/typescript/src/components/live-line-chart/index.d.ts.map +1 -1
  49. package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts +22 -0
  50. package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts.map +1 -0
  51. package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts +23 -0
  52. package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts.map +1 -0
  53. package/lib/typescript/src/components/marquee/index.d.ts +73 -0
  54. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -0
  55. package/lib/typescript/src/components/meter/index.d.ts +7 -4
  56. package/lib/typescript/src/components/meter/index.d.ts.map +1 -1
  57. package/lib/typescript/src/components/meter/meter-scale.d.ts +39 -0
  58. package/lib/typescript/src/components/meter/meter-scale.d.ts.map +1 -1
  59. package/lib/typescript/src/components/planner/index.d.ts.map +1 -1
  60. package/lib/typescript/src/components/planner/planner-announcement.d.ts +15 -0
  61. package/lib/typescript/src/components/planner/planner-announcement.d.ts.map +1 -0
  62. package/lib/typescript/src/components/planner/planner-entries.d.ts +1 -1
  63. package/lib/typescript/src/components/planner/planner-entries.d.ts.map +1 -1
  64. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts +4 -0
  65. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts.map +1 -0
  66. package/lib/typescript/src/components/planner/planner-lifecycle.d.ts +20 -0
  67. package/lib/typescript/src/components/planner/planner-lifecycle.d.ts.map +1 -0
  68. package/lib/typescript/src/index.d.ts +2 -0
  69. package/lib/typescript/src/index.d.ts.map +1 -1
  70. package/lib/typescript/src/primitives/focus-restoration-store.d.ts +24 -0
  71. package/lib/typescript/src/primitives/focus-restoration-store.d.ts.map +1 -0
  72. package/lib/typescript/src/primitives/portal.d.ts +4 -0
  73. package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
  74. package/package.json +36 -3
  75. package/src/components/accordion/index.tsx +31 -8
  76. package/src/components/button/index.tsx +11 -4
  77. package/src/components/collapsible/index.tsx +278 -0
  78. package/src/components/flow/index.tsx +93 -14
  79. package/src/components/live-line-chart/index.tsx +130 -13
  80. package/src/components/live-line-chart/live-line-accessibility.ts +61 -0
  81. package/src/components/live-line-chart/live-line-lifecycle.ts +63 -0
  82. package/src/components/marquee/index.tsx +232 -0
  83. package/src/components/meter/index.tsx +49 -26
  84. package/src/components/meter/meter-scale.ts +84 -8
  85. package/src/components/planner/index.tsx +182 -71
  86. package/src/components/planner/planner-announcement.ts +37 -0
  87. package/src/components/planner/planner-entries.ts +16 -7
  88. package/src/components/planner/planner-grid-navigation.ts +45 -0
  89. package/src/components/planner/planner-lifecycle.ts +76 -0
  90. package/src/components/popover/index.tsx +3 -3
  91. package/src/index.ts +13 -0
  92. package/src/primitives/focus-restoration-store.ts +70 -0
  93. package/src/primitives/portal.tsx +34 -2
@@ -11,16 +11,21 @@ import Animated, {
11
11
  import { tv, type VariantProps } from 'tailwind-variants';
12
12
  import { Text } from '../../primitives/text';
13
13
  import {
14
- clamp,
15
14
  colorFor,
16
- fractionOf,
17
- formatValue,
18
15
  litSegments,
16
+ meterSemantics,
17
+ normalizeScale,
18
+ normalizeSegments,
19
19
  type MeterColor,
20
20
  type MeterThreshold,
21
21
  } from './meter-scale';
22
22
 
23
- const SPRING = { damping: 20, stiffness: 180, mass: 0.6 } as const;
23
+ const SPRING = {
24
+ damping: 20,
25
+ stiffness: 180,
26
+ mass: 0.6,
27
+ overshootClamping: true,
28
+ } as const;
24
29
  /** Milliseconds for a segment to light or go out. */
25
30
  const SEGMENT_DURATION = 180;
26
31
  /** How faint an unlit segment sits. Present, but plainly not counted. */
@@ -32,7 +37,7 @@ const meterVariants = tv({
32
37
  header: 'flex-row items-center justify-between gap-2',
33
38
  track: 'w-full overflow-hidden rounded-full',
34
39
  indicator: 'h-full rounded-full',
35
- segments: 'w-full flex-row',
40
+ segments: 'w-full flex-row overflow-hidden',
36
41
  segment: 'flex-1 rounded-full',
37
42
  },
38
43
  variants: {
@@ -70,7 +75,7 @@ export interface MeterProps
70
75
  extends Omit<ViewProps, 'children'>,
71
76
  MeterVariantProps {
72
77
  className?: string;
73
- /** The measurement, somewhere between `minValue` and `maxValue`. */
78
+ /** The measurement. Values outside the scale are clamped to its ends. */
74
79
  value: number;
75
80
  /**
76
81
  * The bottom of the scale — the value at which the bar reads as empty.
@@ -111,7 +116,8 @@ export interface MeterProps
111
116
  /**
112
117
  * Points on the scale where the colour changes, each `{ from, color }`. The
113
118
  * highest one the reading has reached wins, so the order you list them in
114
- * does not matter; below all of them the `color` prop applies.
119
+ * does not matter; below all of them the `color` prop applies. Non-finite
120
+ * `from` values are ignored.
115
121
  *
116
122
  * This is the difference between a meter and a bar: the colour is a
117
123
  * judgement about the reading. Which direction is bad is yours to say —
@@ -125,8 +131,10 @@ export interface MeterProps
125
131
  * say "three out of four" where a bar says "about seventy percent", and a
126
132
  * password is not seventy percent strong.
127
133
  *
128
- * Any value above the floor lights at least one block, so a reading that
129
- * is not empty never looks it.
134
+ * Fractional counts round down and counts above 100 clamp to 100. Invalid,
135
+ * non-positive, or sub-one counts use the continuous bar. Any value above
136
+ * the floor lights at least one block, so a reading that is not empty never
137
+ * looks it.
130
138
  */
131
139
  segments?: number;
132
140
  /** Extra classes for the fill, or for a lit segment. */
@@ -207,8 +215,9 @@ export const Meter = forwardRef<View, MeterProps>(
207
215
  },
208
216
  ref
209
217
  ) => {
210
- const held = clamp(value, minValue, maxValue);
211
- const fraction = fractionOf(value, minValue, maxValue);
218
+ const scale = normalizeScale(value, minValue, maxValue);
219
+ const { value: held, fraction } = scale;
220
+ const segmentCount = normalizeSegments(segments);
212
221
  const resolvedColor = colorFor(held, color ?? 'primary', thresholds);
213
222
  const slots = meterVariants({ color: resolvedColor, size });
214
223
  const reducedMotion = useReducedMotion();
@@ -218,7 +227,7 @@ export const Meter = forwardRef<View, MeterProps>(
218
227
  // Any reading above the floor lights a block, so "a little" never looks
219
228
  // like "none". Rounding down would leave the first quarter of a
220
229
  // four-block meter dark, which is the reading it is least able to afford.
221
- const litCount = segments ? litSegments(fraction, segments) : 0;
230
+ const litCount = litSegments(fraction, segmentCount);
222
231
  const lit = useSharedValue(litCount);
223
232
 
224
233
  // Reduce motion lands on the value instead of springing to it — the
@@ -232,39 +241,47 @@ export const Meter = forwardRef<View, MeterProps>(
232
241
  }, [lit, litCount]);
233
242
 
234
243
  const onLayout = (event: LayoutChangeEvent) => {
235
- trackWidth.value = event.nativeEvent.layout.width;
244
+ const width = event.nativeEvent.layout.width;
245
+ trackWidth.value = Number.isFinite(width) && width > 0 ? width : 0;
236
246
  };
237
247
 
238
248
  const fillStyle = useAnimatedStyle(() => ({
239
249
  width: trackWidth.value * progress.value,
240
250
  }));
241
251
 
242
- const spoken = formatValue(held, fraction, valueLabel, formatOptions);
252
+ const semantics = meterSemantics({
253
+ value: held,
254
+ fraction,
255
+ // The normalized ends, not the raw props: an invalid scale is repaired
256
+ // for the bar, and the spoken reading has to describe the same one.
257
+ minValue: scale.min,
258
+ maxValue: scale.max,
259
+ label,
260
+ accessibilityLabel,
261
+ valueLabel,
262
+ formatOptions,
263
+ });
264
+ const spoken = semantics.text;
243
265
  const showValue = showValueLabel;
244
266
  const hasHeader = label != null || showValue;
245
267
 
246
268
  const accessibility = {
247
269
  accessibilityRole: 'progressbar' as const,
248
- accessibilityLabel: accessibilityLabel ?? label,
249
- accessibilityValue: {
250
- min: minValue,
251
- max: maxValue,
252
- now: held,
253
- // Without this the scale is read as a bare number. `text` is what
254
- // carries the unit — the percent sign, the gigabytes, the word.
255
- text: spoken,
256
- },
270
+ accessibilityLabel: semantics.label,
271
+ // `text` carries the unit or word and intentionally overrides the
272
+ // platform's generic rendering of this numeric range.
273
+ accessibilityValue: semantics.value,
257
274
  };
258
275
 
259
276
  const bar =
260
- segments && segments > 0 ? (
277
+ segmentCount > 0 ? (
261
278
  <View
262
279
  ref={ref}
263
280
  {...accessibility}
264
281
  className={slots.segments({ className })}
265
282
  {...props}
266
283
  >
267
- {Array.from({ length: segments }, (_, index) => (
284
+ {Array.from({ length: segmentCount }, (_, index) => (
268
285
  <Segment
269
286
  key={index}
270
287
  index={index}
@@ -293,7 +310,13 @@ export const Meter = forwardRef<View, MeterProps>(
293
310
 
294
311
  return (
295
312
  <View className={slots.root()}>
296
- <View className={slots.header({ className: headerClassName })}>
313
+ <View
314
+ className={slots.header({ className: headerClassName })}
315
+ // This is the visual rendering of the name and value already owned
316
+ // by the bar below. Hiding the subtree keeps one concise focus stop.
317
+ accessibilityElementsHidden
318
+ importantForAccessibility="no-hide-descendants"
319
+ >
297
320
  {label != null ? (
298
321
  <Text size="sm" weight="medium" numberOfLines={1}>
299
322
  {label}
@@ -29,11 +29,55 @@ export interface MeterThreshold {
29
29
  color: MeterColor;
30
30
  }
31
31
 
32
+ /** A bounded block count keeps malformed input from allocating without limit. */
33
+ export const MAX_METER_SEGMENTS = 100;
34
+
35
+ export interface MeterScale {
36
+ min: number;
37
+ max: number;
38
+ value: number;
39
+ fraction: number;
40
+ }
41
+
42
+ /**
43
+ * Make a public scale safe for layout, animation and native accessibility.
44
+ * Invalid bounds fall back to their documented defaults; if those still do
45
+ * not produce a positive span, the scale honestly collapses at its floor.
46
+ */
47
+ export function normalizeScale(value: number, min: number, max: number): MeterScale {
48
+ const floor = Number.isFinite(min) ? min : 0;
49
+ const candidateMax = Number.isFinite(max) ? max : 100;
50
+ const ceiling = candidateMax > floor ? candidateMax : floor;
51
+ const held = Number.isNaN(value)
52
+ ? floor
53
+ : value === Number.POSITIVE_INFINITY
54
+ ? ceiling
55
+ : value === Number.NEGATIVE_INFINITY
56
+ ? floor
57
+ : Math.min(Math.max(value, floor), ceiling);
58
+ const magnitude = Math.max(Math.abs(floor), Math.abs(ceiling), 1);
59
+ const position =
60
+ ceiling > floor
61
+ ? (held / magnitude - floor / magnitude) /
62
+ (ceiling / magnitude - floor / magnitude)
63
+ : 0;
64
+ return {
65
+ min: floor,
66
+ max: ceiling,
67
+ value: held,
68
+ fraction: Math.min(Math.max(position, 0), 1),
69
+ };
70
+ }
71
+
72
+ /** Positive counts are whole and bounded; zero means the continuous meter. */
73
+ export function normalizeSegments(segments: number | undefined) {
74
+ if (segments == null || !Number.isFinite(segments) || !(segments > 0)) return 0;
75
+ return Math.min(Math.floor(segments), MAX_METER_SEGMENTS);
76
+ }
77
+
32
78
  /** `value` held inside the scale, so a stray number cannot escape the track. */
33
79
  export function clamp(value: number, min: number, max: number) {
34
- if (!(value > min)) return min;
35
- if (value > max) return max;
36
- return value;
80
+ return normalizeScale(value, min, max).value;
37
81
  }
38
82
 
39
83
  /**
@@ -41,9 +85,7 @@ export function clamp(value: number, min: number, max: number) {
41
85
  * meaningful position in it, so it reads as empty rather than dividing by zero.
42
86
  */
43
87
  export function fractionOf(value: number, min: number, max: number) {
44
- const span = max - min;
45
- if (!(span > 0)) return 0;
46
- return clamp((value - min) / span, 0, 1);
88
+ return normalizeScale(value, min, max).fraction;
47
89
  }
48
90
 
49
91
  /**
@@ -62,6 +104,7 @@ export function colorFor(
62
104
  if (!thresholds?.length) return base;
63
105
  let winner: MeterThreshold | undefined;
64
106
  for (const threshold of thresholds) {
107
+ if (!Number.isFinite(threshold.from)) continue;
65
108
  if (value < threshold.from) continue;
66
109
  if (!winner || threshold.from > winner.from) winner = threshold;
67
110
  }
@@ -77,8 +120,10 @@ export function colorFor(
77
120
  * wrong.
78
121
  */
79
122
  export function litSegments(fraction: number, segments: number) {
80
- if (!(segments > 0)) return 0;
81
- return Math.min(Math.ceil(fraction * segments), segments);
123
+ const count = normalizeSegments(segments);
124
+ if (count === 0 || Number.isNaN(fraction) || fraction <= 0) return 0;
125
+ if (!Number.isFinite(fraction) || fraction >= 1) return count;
126
+ return Math.min(Math.ceil(fraction * count), count);
82
127
  }
83
128
 
84
129
  /**
@@ -107,3 +152,34 @@ export function formatValue(
107
152
  }
108
153
  return `${Math.round(fraction * 100)}%`;
109
154
  }
155
+
156
+ /**
157
+ * The meter's single spoken contract. Visual treatments such as segments,
158
+ * thresholds and motion do not belong here: they do not change the reading.
159
+ */
160
+ export function meterSemantics({
161
+ value,
162
+ fraction,
163
+ minValue,
164
+ maxValue,
165
+ label,
166
+ accessibilityLabel,
167
+ valueLabel,
168
+ formatOptions,
169
+ }: {
170
+ value: number;
171
+ fraction: number;
172
+ minValue: number;
173
+ maxValue: number;
174
+ label?: string;
175
+ accessibilityLabel?: string;
176
+ valueLabel?: string;
177
+ formatOptions?: Intl.NumberFormatOptions;
178
+ }) {
179
+ const text = formatValue(value, fraction, valueLabel, formatOptions);
180
+ return {
181
+ label: accessibilityLabel ?? label,
182
+ text,
183
+ value: { min: minValue, max: maxValue, now: value, text },
184
+ };
185
+ }
@@ -64,6 +64,7 @@ import {
64
64
  useContext,
65
65
  useEffect,
66
66
  useMemo,
67
+ useRef,
67
68
  useState,
68
69
  type ReactElement,
69
70
  type ReactNode,
@@ -71,6 +72,7 @@ import {
71
72
  import {
72
73
  AccessibilityInfo,
73
74
  AppState,
75
+ Platform,
74
76
  Pressable,
75
77
  View,
76
78
  type ViewProps,
@@ -107,6 +109,12 @@ import {
107
109
  visibleEntries,
108
110
  type PlannerCountedCategory,
109
111
  } from './planner-entries';
112
+ import { usePlannerMonthAnnouncement } from './planner-announcement';
113
+ import { plannerGridTarget } from './planner-grid-navigation';
114
+ import {
115
+ usePlannerMonthLifecycle,
116
+ usePlannerSelectionLifecycle,
117
+ } from './planner-lifecycle';
110
118
 
111
119
  /** How many of a day's entries a cell draws before it stops and counts. */
112
120
  const DEFAULT_ENTRY_LIMIT = 2;
@@ -114,6 +122,11 @@ const DEFAULT_ENTRY_LIMIT = 2;
114
122
  /** The palette a category takes its dot from when it does not name a colour. */
115
123
  const PALETTE_SIZE = 5;
116
124
 
125
+ /** Stable wrapper keeps the native method's receiver and the hook dependency steady. */
126
+ const announceMonth = (label: string) => {
127
+ AccessibilityInfo.announceForAccessibility(label);
128
+ };
129
+
117
130
  const plannerVariants = tv({
118
131
  slots: {
119
132
  grid: 'gap-1 px-3 pb-3 pt-1',
@@ -203,6 +216,7 @@ interface PlannerContextValue {
203
216
  summary: { total: number; categories: PlannerCountedCategory[] };
204
217
  entryLimit: number;
205
218
  weekStartsOn: number;
219
+ grid: Date[][];
206
220
  locale: DateLocale;
207
221
  system: 'gregory' | 'islamic';
208
222
  isInMonth: (date: Date) => boolean;
@@ -315,34 +329,51 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
315
329
  const system = resolveCalendar(calendar, locale);
316
330
  const palette = usePalette();
317
331
  const today = useToday();
318
-
319
- const [internalMonth, setInternalMonth] = useState(() =>
320
- startOfCalendarMonth(defaultMonth ?? new Date(), system, locale)
332
+ const settleMonth = useCallback(
333
+ (date: Date) => startOfCalendarMonth(date, system, locale),
334
+ [system, locale]
321
335
  );
322
- const month = monthProp
323
- ? startOfCalendarMonth(monthProp, system, locale)
324
- : internalMonth;
325
336
 
337
+ const [month, requestMonth] = usePlannerMonthLifecycle({
338
+ month: monthProp,
339
+ defaultMonth,
340
+ settleMonth,
341
+ onMonthChange,
342
+ });
343
+ /*
344
+ * The announcement is registered against the month a press asks for, and
345
+ * spoken only once a commit arrives carrying it. That is what keeps a
346
+ * controlled parent's rejection silent, so it wraps the lifecycle's setter
347
+ * rather than living inside it — the hook owns which month is current, and
348
+ * this owns whether the change was the user's to hear about.
349
+ */
350
+ const expectMonthAnnouncement = usePlannerMonthAnnouncement({
351
+ monthKey: month.getTime(),
352
+ monthLabel: calendarMonthLabel(month, system, locale),
353
+ announce: announceMonth,
354
+ });
326
355
  const setMonth = useCallback(
327
356
  (next: Date) => {
328
- const settled = startOfCalendarMonth(next, system, locale);
329
- if (!monthProp) setInternalMonth(settled);
330
- onMonthChange?.(settled);
331
- },
332
- [monthProp, onMonthChange, system, locale]
333
- );
334
-
335
- const [internalSelected, setInternalSelected] = useState<Date | null>(defaultSelected);
336
- const selected = selectedProp !== undefined ? selectedProp : internalSelected;
337
- const select = useCallback(
338
- (date: Date | null) => {
339
- if (selectedProp === undefined) setInternalSelected(date);
340
- onSelectedChange?.(date);
357
+ expectMonthAnnouncement(settleMonth(next));
358
+ requestMonth(next);
341
359
  },
342
- [selectedProp, onSelectedChange]
360
+ [expectMonthAnnouncement, requestMonth, settleMonth]
343
361
  );
362
+ const [selected, select] = usePlannerSelectionLifecycle({
363
+ selected: selectedProp,
364
+ defaultSelected,
365
+ onSelectedChange,
366
+ });
344
367
 
345
368
  const days = useMemo(() => bucketByDay(entries), [entries]);
369
+ const normalizedWeekStart =
370
+ weekStartsOn === 'auto'
371
+ ? localeWeekStart(locale)
372
+ : normalizeWeekStart(weekStartsOn);
373
+ const grid = useMemo(
374
+ () => monthGrid(month, normalizedWeekStart, system, locale),
375
+ [month, normalizedWeekStart, system, locale]
376
+ );
346
377
 
347
378
  const isInMonth = useCallback(
348
379
  (date: Date) => isSameCalendarMonth(date, month, system, locale),
@@ -354,34 +385,33 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
354
385
  [categories]
355
386
  );
356
387
 
357
- const colorOf = useCallback(
358
- (id: string | undefined) => {
359
- if (!id) return undefined;
360
- const index = categories.findIndex((category) => category.id === id);
361
- if (index < 0) return undefined;
362
- const category = categories[index]!;
363
- if (category.color) return category.color;
364
- const slot = (category.colorIndex ?? index + 1) - 1;
365
- return palette[((slot % PALETTE_SIZE) + PALETTE_SIZE) % PALETTE_SIZE];
388
+ const categoryColors = useMemo(
389
+ () => {
390
+ const colors = new Map<string, string | undefined>();
391
+ categories.forEach((category, index) => {
392
+ // `findIndex` used to make the first duplicate id authoritative.
393
+ if (colors.has(category.id)) return;
394
+ const slot = (category.colorIndex ?? index + 1) - 1;
395
+ colors.set(
396
+ category.id,
397
+ category.color ??
398
+ palette[((slot % PALETTE_SIZE) + PALETTE_SIZE) % PALETTE_SIZE]
399
+ );
400
+ });
401
+ return colors;
366
402
  },
367
403
  [categories, palette]
368
404
  );
405
+ const colorOf = useCallback(
406
+ (id: string | undefined) => id ? categoryColors.get(id) : undefined,
407
+ [categoryColors]
408
+ );
369
409
 
370
410
  const summary = useMemo(
371
- () => summariseMonth(entries, categories, isInMonth),
372
- [entries, categories, isInMonth]
411
+ () => summariseMonth(days, grid, categories, isInMonth),
412
+ [days, grid, categories, isInMonth]
373
413
  );
374
414
 
375
- /*
376
- * The month is announced rather than left to the cells. Paging moves 42
377
- * labels at once and a screen reader reads none of them, so without this
378
- * the only thing that changes is silent.
379
- */
380
- const monthLabel = calendarMonthLabel(month, system, locale);
381
- useEffect(() => {
382
- AccessibilityInfo.announceForAccessibility(monthLabel);
383
- }, [monthLabel]);
384
-
385
415
  const context = useMemo<PlannerContextValue>(
386
416
  () => ({
387
417
  month,
@@ -395,10 +425,8 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
395
425
  colorOf,
396
426
  summary,
397
427
  entryLimit,
398
- weekStartsOn:
399
- weekStartsOn === 'auto'
400
- ? localeWeekStart(locale)
401
- : normalizeWeekStart(weekStartsOn),
428
+ weekStartsOn: normalizedWeekStart,
429
+ grid,
402
430
  locale,
403
431
  system,
404
432
  isInMonth,
@@ -406,7 +434,8 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
406
434
  }),
407
435
  [
408
436
  month, setMonth, today, selected, select, days, categories, categoryLabels,
409
- colorOf, summary, entryLimit, weekStartsOn, locale, system, isInMonth, onDayPress,
437
+ colorOf, summary, entryLimit, normalizedWeekStart, grid, locale, system,
438
+ isInMonth, onDayPress,
410
439
  ]
411
440
  );
412
441
 
@@ -600,6 +629,21 @@ export interface PlannerGridProps {
600
629
  renderDay?: PlannerDayRenderer;
601
630
  }
602
631
 
632
+ interface PlannerGridKeyDownEvent {
633
+ nativeEvent: { key?: string };
634
+ preventDefault: () => void;
635
+ }
636
+
637
+ interface PlannerGridNavigationContextValue {
638
+ activeIndex: number;
639
+ indexByDay: ReadonlyMap<number, number>;
640
+ register: (index: number, node: View | null) => void;
641
+ focus: (index: number, event: PlannerGridKeyDownEvent) => void;
642
+ makeActive: (index: number) => void;
643
+ }
644
+
645
+ const PlannerGridNavigationContext = createContext<PlannerGridNavigationContextValue | null>(null);
646
+
603
647
  /**
604
648
  * The weekday row and the six weeks below it.
605
649
  *
@@ -609,40 +653,77 @@ export interface PlannerGridProps {
609
653
  * weekday headings are hidden rather than read out 42 times over.
610
654
  */
611
655
  function PlannerGrid({ className, renderDay }: PlannerGridProps) {
612
- const { month, weekStartsOn, locale, system } = usePlanner('Planner.Grid');
656
+ const { grid: weeks, weekStartsOn, locale, selected, today, isInMonth } =
657
+ usePlanner('Planner.Grid');
613
658
  const { grid, week: weekRow, heading } = plannerVariants();
614
-
615
- const weeks = useMemo(
616
- () => monthGrid(month, weekStartsOn, system, locale),
617
- [month, weekStartsOn, system, locale]
618
- );
619
659
  const headings = useMemo(
620
660
  () => weekdayNames(locale, weekStartsOn),
621
661
  [locale, weekStartsOn]
622
662
  );
663
+ const dates = useMemo(() => weeks.flat(), [weeks]);
664
+ const indexByDay = useMemo(
665
+ () => new Map(dates.map((date, index) => [date.getTime(), index])),
666
+ [dates]
667
+ );
668
+ const initialDay = selected ?? today;
669
+ const [activeDay, setActiveDay] = useState(initialDay.getTime());
670
+ const storedIndex = indexByDay.get(activeDay);
671
+ const preferredIndex =
672
+ (selected ? indexByDay.get(startOfDay(selected).getTime()) : undefined) ??
673
+ indexByDay.get(today.getTime()) ??
674
+ dates.findIndex(isInMonth);
675
+ const activeIndex = storedIndex ?? Math.max(0, preferredIndex);
676
+ const refs = useRef(new Map<number, View>());
677
+ const register = useCallback((index: number, node: View | null) => {
678
+ if (node) refs.current.set(index, node);
679
+ else refs.current.delete(index);
680
+ }, []);
681
+ const makeActive = useCallback(
682
+ (index: number) => {
683
+ const date = dates[index];
684
+ if (date) setActiveDay(date.getTime());
685
+ },
686
+ [dates]
687
+ );
688
+ const focus = useCallback(
689
+ (index: number, event: PlannerGridKeyDownEvent) => {
690
+ const target = plannerGridTarget(event.nativeEvent.key ?? '', index, dates.length);
691
+ if (target === null) return;
692
+ event.preventDefault();
693
+ makeActive(target);
694
+ refs.current.get(target)?.focus();
695
+ },
696
+ [dates.length, makeActive]
697
+ );
698
+ const navigation = useMemo<PlannerGridNavigationContextValue>(
699
+ () => ({ activeIndex, indexByDay, register, focus, makeActive }),
700
+ [activeIndex, indexByDay, register, focus, makeActive]
701
+ );
623
702
 
624
703
  return (
625
- <View className={grid({ className })}>
626
- <View
627
- className={weekRow()}
628
- accessibilityElementsHidden
629
- importantForAccessibility="no-hide-descendants"
630
- >
631
- {headings.map((label) => (
632
- <Text key={label} size="xs" muted className={heading()}>
633
- {label.toUpperCase()}
634
- </Text>
635
- ))}
636
- </View>
637
-
638
- {weeks.map((week, index) => (
639
- <View key={index} className={weekRow()}>
640
- {week.map((date) => (
641
- <PlannerDay key={date.getTime()} date={date} renderDay={renderDay} />
704
+ <PlannerGridNavigationContext.Provider value={renderDay ? null : navigation}>
705
+ <View className={grid({ className })}>
706
+ <View
707
+ className={weekRow()}
708
+ accessibilityElementsHidden
709
+ importantForAccessibility="no-hide-descendants"
710
+ >
711
+ {headings.map((label) => (
712
+ <Text key={label} size="xs" muted className={heading()}>
713
+ {label.toUpperCase()}
714
+ </Text>
642
715
  ))}
643
716
  </View>
644
- ))}
645
- </View>
717
+
718
+ {weeks.map((week, index) => (
719
+ <View key={index} className={weekRow()}>
720
+ {week.map((date) => (
721
+ <PlannerDay key={date.getTime()} date={date} renderDay={renderDay} />
722
+ ))}
723
+ </View>
724
+ ))}
725
+ </View>
726
+ </PlannerGridNavigationContext.Provider>
646
727
  );
647
728
  }
648
729
  PlannerGrid.displayName = 'Planner.Grid';
@@ -658,6 +739,10 @@ function PlannerDay({ date, renderDay }: PlannerDayProps) {
658
739
  days, today, selected, select, colorOf, categoryLabels,
659
740
  entryLimit, locale, system, isInMonth, onDayPress,
660
741
  } = usePlanner('Planner.Day');
742
+ const navigation = useContext(PlannerGridNavigationContext);
743
+ const registerGridCell = navigation?.register;
744
+ const focusGridCell = navigation?.focus;
745
+ const makeGridCellActive = navigation?.makeActive;
661
746
 
662
747
  const entries = entriesOn(days, date);
663
748
  const inMonth = isInMonth(date);
@@ -677,6 +762,31 @@ function PlannerDay({ date, renderDay }: PlannerDayProps) {
677
762
  onDayPress?.(date, entries);
678
763
  select(date);
679
764
  };
765
+ const gridIndex = navigation?.indexByDay.get(date.getTime());
766
+ const setRef = useCallback(
767
+ (node: View | null) => {
768
+ if (gridIndex !== undefined) registerGridCell?.(gridIndex, node);
769
+ },
770
+ [gridIndex, registerGridCell]
771
+ );
772
+ const onFocus = useCallback(() => {
773
+ if (gridIndex !== undefined) makeGridCellActive?.(gridIndex);
774
+ }, [gridIndex, makeGridCellActive]);
775
+ const onKeyDown = useCallback(
776
+ (event: PlannerGridKeyDownEvent) => {
777
+ if (gridIndex !== undefined) focusGridCell?.(gridIndex, event);
778
+ },
779
+ [gridIndex, focusGridCell]
780
+ );
781
+ const webGridProps =
782
+ Platform.OS === 'web' && gridIndex !== undefined && navigation
783
+ ? {
784
+ ref: setRef,
785
+ tabIndex: navigation.activeIndex === gridIndex ? (0 as const) : (-1 as const),
786
+ onFocus,
787
+ onKeyDown,
788
+ }
789
+ : {};
680
790
 
681
791
  if (renderDay) {
682
792
  return (
@@ -688,6 +798,7 @@ function PlannerDay({ date, renderDay }: PlannerDayProps) {
688
798
 
689
799
  return (
690
800
  <Pressable
801
+ {...(webGridProps as ViewProps)}
691
802
  onPress={press}
692
803
  accessibilityRole="button"
693
804
  accessibilityLabel={label}
@@ -0,0 +1,37 @@
1
+ import { useCallback, useEffect, useRef } from 'react';
2
+
3
+ interface PlannerMonthAnnouncementOptions {
4
+ /** The settled month currently visible in the grid. */
5
+ monthKey: number;
6
+ monthLabel: string;
7
+ announce: (label: string) => void;
8
+ }
9
+
10
+ /**
11
+ * Announces a month only when the next committed render accepts a user request.
12
+ *
13
+ * Prop-driven months stay silent. In a controlled planner, a render that keeps
14
+ * the previous month rejects the pending request and clears it without speaking.
15
+ */
16
+ export function usePlannerMonthAnnouncement({
17
+ monthKey,
18
+ monthLabel,
19
+ announce,
20
+ }: PlannerMonthAnnouncementOptions): (month: Date) => void {
21
+ const pendingMonth = useRef<number | null>(null);
22
+
23
+ const expectMonth = useCallback((month: Date) => {
24
+ pendingMonth.current = month.getTime();
25
+ }, []);
26
+
27
+ // Deliberately runs after every commit: an unchanged controlled render is
28
+ // how a parent rejects a request, while a changed matching render accepts it.
29
+ useEffect(() => {
30
+ const expected = pendingMonth.current;
31
+ if (expected === null) return;
32
+ pendingMonth.current = null;
33
+ if (expected === monthKey) announce(monthLabel);
34
+ });
35
+
36
+ return expectMonth;
37
+ }