panelui-native 0.68.0 → 0.73.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 (206) hide show
  1. package/README.md +13 -2
  2. package/lib/module/components/accordion/index.js +38 -13
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/area-chart/index.js +1 -0
  5. package/lib/module/components/area-chart/index.js.map +1 -1
  6. package/lib/module/components/breadcrumb/index.js +2 -1
  7. package/lib/module/components/breadcrumb/index.js.map +1 -1
  8. package/lib/module/components/button/index.js +21 -12
  9. package/lib/module/components/button/index.js.map +1 -1
  10. package/lib/module/components/carousel/index.js +1 -1
  11. package/lib/module/components/carousel/index.js.map +1 -1
  12. package/lib/module/components/chip/index.js +2 -2
  13. package/lib/module/components/chip/index.js.map +1 -1
  14. package/lib/module/components/code-block/index.js.map +1 -1
  15. package/lib/module/components/collapsible/index.js +284 -0
  16. package/lib/module/components/collapsible/index.js.map +1 -0
  17. package/lib/module/components/color-picker/index.js.map +1 -1
  18. package/lib/module/components/fab/index.js +7 -6
  19. package/lib/module/components/fab/index.js.map +1 -1
  20. package/lib/module/components/flow/index.js +67 -4
  21. package/lib/module/components/flow/index.js.map +1 -1
  22. package/lib/module/components/frame/index.js +2 -2
  23. package/lib/module/components/frame/index.js.map +1 -1
  24. package/lib/module/components/grid-item/index.js +2 -2
  25. package/lib/module/components/grid-item/index.js.map +1 -1
  26. package/lib/module/components/heatmap-chart/index.js +1 -0
  27. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  28. package/lib/module/components/hex-chart/index.js +1 -0
  29. package/lib/module/components/hex-chart/index.js.map +1 -1
  30. package/lib/module/components/input-group/index.js +7 -2
  31. package/lib/module/components/input-group/index.js.map +1 -1
  32. package/lib/module/components/item/index.js +2 -2
  33. package/lib/module/components/item/index.js.map +1 -1
  34. package/lib/module/components/line-chart/index.js +1 -0
  35. package/lib/module/components/line-chart/index.js.map +1 -1
  36. package/lib/module/components/live-line-chart/index.js +103 -9
  37. package/lib/module/components/live-line-chart/index.js.map +1 -1
  38. package/lib/module/components/live-line-chart/live-line-accessibility.js +47 -0
  39. package/lib/module/components/live-line-chart/live-line-accessibility.js.map +1 -0
  40. package/lib/module/components/live-line-chart/live-line-lifecycle.js +33 -0
  41. package/lib/module/components/live-line-chart/live-line-lifecycle.js.map +1 -0
  42. package/lib/module/components/marker/index.js +2 -2
  43. package/lib/module/components/marker/index.js.map +1 -1
  44. package/lib/module/components/marquee/index.js +191 -0
  45. package/lib/module/components/marquee/index.js.map +1 -0
  46. package/lib/module/components/marquee/marquee-math.js +39 -0
  47. package/lib/module/components/marquee/marquee-math.js.map +1 -0
  48. package/lib/module/components/menu/index.js +11 -3
  49. package/lib/module/components/menu/index.js.map +1 -1
  50. package/lib/module/components/meter/index.js +38 -20
  51. package/lib/module/components/meter/index.js.map +1 -1
  52. package/lib/module/components/meter/meter-scale.js +62 -8
  53. package/lib/module/components/meter/meter-scale.js.map +1 -1
  54. package/lib/module/components/pagination/index.js +27 -11
  55. package/lib/module/components/pagination/index.js.map +1 -1
  56. package/lib/module/components/panelside/index.js +7 -2
  57. package/lib/module/components/panelside/index.js.map +1 -1
  58. package/lib/module/components/planner/index.js +159 -64
  59. package/lib/module/components/planner/index.js.map +1 -1
  60. package/lib/module/components/planner/planner-announcement.js +30 -0
  61. package/lib/module/components/planner/planner-announcement.js.map +1 -0
  62. package/lib/module/components/planner/planner-entries.js +15 -7
  63. package/lib/module/components/planner/planner-entries.js.map +1 -1
  64. package/lib/module/components/planner/planner-grid-navigation.js +33 -0
  65. package/lib/module/components/planner/planner-grid-navigation.js.map +1 -0
  66. package/lib/module/components/planner/planner-lifecycle.js +45 -0
  67. package/lib/module/components/planner/planner-lifecycle.js.map +1 -0
  68. package/lib/module/components/plot/index.js +105 -42
  69. package/lib/module/components/plot/index.js.map +1 -1
  70. package/lib/module/components/popover/index.js +2 -2
  71. package/lib/module/components/popover/index.js.map +1 -1
  72. package/lib/module/components/post/index.js +4 -4
  73. package/lib/module/components/post/index.js.map +1 -1
  74. package/lib/module/components/search-bar/index.js +331 -0
  75. package/lib/module/components/search-bar/index.js.map +1 -0
  76. package/lib/module/components/table/index.js +3 -4
  77. package/lib/module/components/table/index.js.map +1 -1
  78. package/lib/module/components/timeline/index.js +288 -39
  79. package/lib/module/components/timeline/index.js.map +1 -1
  80. package/lib/module/components/toggle-button/index.js +1 -1
  81. package/lib/module/components/toggle-button/index.js.map +1 -1
  82. package/lib/module/components/tree/index.js +13 -8
  83. package/lib/module/components/tree/index.js.map +1 -1
  84. package/lib/module/hooks/use-keyboard-avoidance.js.map +1 -1
  85. package/lib/module/index.js +3 -0
  86. package/lib/module/index.js.map +1 -1
  87. package/lib/module/primitives/focus-restoration-store.js +52 -0
  88. package/lib/module/primitives/focus-restoration-store.js.map +1 -0
  89. package/lib/module/primitives/portal.js +36 -2
  90. package/lib/module/primitives/portal.js.map +1 -1
  91. package/lib/module/utils/chart.js +50 -7
  92. package/lib/module/utils/chart.js.map +1 -1
  93. package/lib/typescript/src/components/accordion/index.d.ts +6 -2
  94. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  95. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  96. package/lib/typescript/src/components/breadcrumb/index.d.ts.map +1 -1
  97. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  98. package/lib/typescript/src/components/code-block/index.d.ts +3 -3
  99. package/lib/typescript/src/components/code-block/index.d.ts.map +1 -1
  100. package/lib/typescript/src/components/collapsible/index.d.ts +76 -0
  101. package/lib/typescript/src/components/collapsible/index.d.ts.map +1 -0
  102. package/lib/typescript/src/components/color-picker/index.d.ts.map +1 -1
  103. package/lib/typescript/src/components/fab/index.d.ts +4 -2
  104. package/lib/typescript/src/components/fab/index.d.ts.map +1 -1
  105. package/lib/typescript/src/components/flow/index.d.ts +7 -1
  106. package/lib/typescript/src/components/flow/index.d.ts.map +1 -1
  107. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  108. package/lib/typescript/src/components/hex-chart/index.d.ts.map +1 -1
  109. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
  110. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  111. package/lib/typescript/src/components/live-line-chart/index.d.ts +21 -7
  112. package/lib/typescript/src/components/live-line-chart/index.d.ts.map +1 -1
  113. package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts +22 -0
  114. package/lib/typescript/src/components/live-line-chart/live-line-accessibility.d.ts.map +1 -0
  115. package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts +23 -0
  116. package/lib/typescript/src/components/live-line-chart/live-line-lifecycle.d.ts.map +1 -0
  117. package/lib/typescript/src/components/marquee/index.d.ts +73 -0
  118. package/lib/typescript/src/components/marquee/index.d.ts.map +1 -0
  119. package/lib/typescript/src/components/marquee/marquee-math.d.ts +21 -0
  120. package/lib/typescript/src/components/marquee/marquee-math.d.ts.map +1 -0
  121. package/lib/typescript/src/components/menu/index.d.ts +1 -1
  122. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  123. package/lib/typescript/src/components/meter/index.d.ts +7 -4
  124. package/lib/typescript/src/components/meter/index.d.ts.map +1 -1
  125. package/lib/typescript/src/components/meter/meter-scale.d.ts +39 -0
  126. package/lib/typescript/src/components/meter/meter-scale.d.ts.map +1 -1
  127. package/lib/typescript/src/components/pagination/index.d.ts.map +1 -1
  128. package/lib/typescript/src/components/panelside/index.d.ts +1 -1
  129. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  130. package/lib/typescript/src/components/plan/index.d.ts +2 -2
  131. package/lib/typescript/src/components/planner/index.d.ts.map +1 -1
  132. package/lib/typescript/src/components/planner/planner-announcement.d.ts +15 -0
  133. package/lib/typescript/src/components/planner/planner-announcement.d.ts.map +1 -0
  134. package/lib/typescript/src/components/planner/planner-entries.d.ts +1 -1
  135. package/lib/typescript/src/components/planner/planner-entries.d.ts.map +1 -1
  136. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts +4 -0
  137. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts.map +1 -0
  138. package/lib/typescript/src/components/planner/planner-lifecycle.d.ts +20 -0
  139. package/lib/typescript/src/components/planner/planner-lifecycle.d.ts.map +1 -0
  140. package/lib/typescript/src/components/plot/index.d.ts +59 -5
  141. package/lib/typescript/src/components/plot/index.d.ts.map +1 -1
  142. package/lib/typescript/src/components/search-bar/index.d.ts +216 -0
  143. package/lib/typescript/src/components/search-bar/index.d.ts.map +1 -0
  144. package/lib/typescript/src/components/timeline/index.d.ts +35 -2
  145. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  146. package/lib/typescript/src/components/tree/index.d.ts +3 -4
  147. package/lib/typescript/src/components/tree/index.d.ts.map +1 -1
  148. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts +2 -2
  149. package/lib/typescript/src/hooks/use-keyboard-avoidance.d.ts.map +1 -1
  150. package/lib/typescript/src/index.d.ts +4 -1
  151. package/lib/typescript/src/index.d.ts.map +1 -1
  152. package/lib/typescript/src/primitives/focus-restoration-store.d.ts +24 -0
  153. package/lib/typescript/src/primitives/focus-restoration-store.d.ts.map +1 -0
  154. package/lib/typescript/src/primitives/portal.d.ts +4 -0
  155. package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
  156. package/lib/typescript/src/utils/chart.d.ts +28 -3
  157. package/lib/typescript/src/utils/chart.d.ts.map +1 -1
  158. package/package.json +36 -3
  159. package/src/components/accordion/index.tsx +48 -15
  160. package/src/components/area-chart/index.tsx +8 -0
  161. package/src/components/breadcrumb/index.tsx +2 -1
  162. package/src/components/button/index.tsx +14 -6
  163. package/src/components/carousel/index.tsx +1 -1
  164. package/src/components/chip/index.tsx +2 -2
  165. package/src/components/code-block/index.tsx +3 -3
  166. package/src/components/collapsible/index.tsx +287 -0
  167. package/src/components/color-picker/index.tsx +3 -2
  168. package/src/components/fab/index.tsx +21 -11
  169. package/src/components/flow/index.tsx +94 -15
  170. package/src/components/frame/index.tsx +2 -2
  171. package/src/components/grid-item/index.tsx +2 -2
  172. package/src/components/heatmap-chart/index.tsx +7 -1
  173. package/src/components/hex-chart/index.tsx +7 -1
  174. package/src/components/input-group/index.tsx +7 -2
  175. package/src/components/item/index.tsx +2 -2
  176. package/src/components/line-chart/index.tsx +9 -0
  177. package/src/components/live-line-chart/index.tsx +130 -13
  178. package/src/components/live-line-chart/live-line-accessibility.ts +61 -0
  179. package/src/components/live-line-chart/live-line-lifecycle.ts +63 -0
  180. package/src/components/marker/index.tsx +2 -2
  181. package/src/components/marquee/index.tsx +234 -0
  182. package/src/components/marquee/marquee-math.ts +37 -0
  183. package/src/components/menu/index.tsx +11 -3
  184. package/src/components/meter/index.tsx +49 -26
  185. package/src/components/meter/meter-scale.ts +84 -8
  186. package/src/components/pagination/index.tsx +26 -13
  187. package/src/components/panelside/index.tsx +7 -2
  188. package/src/components/plan/index.tsx +2 -2
  189. package/src/components/planner/index.tsx +202 -76
  190. package/src/components/planner/planner-announcement.ts +37 -0
  191. package/src/components/planner/planner-entries.ts +16 -7
  192. package/src/components/planner/planner-grid-navigation.ts +45 -0
  193. package/src/components/planner/planner-lifecycle.ts +76 -0
  194. package/src/components/plot/index.tsx +183 -39
  195. package/src/components/popover/index.tsx +3 -3
  196. package/src/components/post/index.tsx +4 -4
  197. package/src/components/search-bar/index.tsx +436 -0
  198. package/src/components/table/index.tsx +2 -2
  199. package/src/components/timeline/index.tsx +335 -39
  200. package/src/components/toggle-button/index.tsx +1 -1
  201. package/src/components/tree/index.tsx +17 -12
  202. package/src/hooks/use-keyboard-avoidance.ts +7 -2
  203. package/src/index.ts +15 -0
  204. package/src/primitives/focus-restoration-store.ts +70 -0
  205. package/src/primitives/portal.tsx +34 -2
  206. package/src/utils/chart.ts +66 -7
@@ -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',
@@ -136,7 +149,13 @@ const dayVariants = tv({
136
149
  * Square-ish because a day here holds a number, a marker and sometimes an
137
150
  * icon, and a circle wastes the corners it needs for them.
138
151
  */
139
- cell: 'mx-0.5 h-14 flex-1 rounded-xl px-1.5 pt-1.5',
152
+ /*
153
+ * The transparent border is load-bearing: today and the open day both draw
154
+ * one, and a cell that only grows a border when it is picked shifts its
155
+ * contents by a point at the moment you look at it. Every cell reserves the
156
+ * point; the variants below only colour it.
157
+ */
158
+ cell: 'mx-0.5 h-14 flex-1 rounded-xl border border-transparent px-1.5 pt-1.5',
140
159
  number: 'text-xs leading-none',
141
160
  marker: 'absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full',
142
161
  body: 'flex-1 flex-row items-center justify-center gap-0.5 pb-1',
@@ -148,13 +167,22 @@ const dayVariants = tv({
148
167
  false: { cell: 'bg-muted/15', number: 'text-muted-foreground/40' },
149
168
  },
150
169
  /*
151
- * Today is the number, not the tile. A ring on today and a ring on the
152
- * open day are two rings that mean different things and look the same.
170
+ * Today rings the tile; the open day fills it. Two rings would be the
171
+ * problem — two channels are not, and the ring is the one that has to
172
+ * survive being read at a glance across forty-two tiles.
153
173
  */
154
- today: { true: { number: 'text-primary font-semibold' } },
155
- selected: { true: { cell: 'border border-primary/60 bg-primary/10' } },
174
+ today: { true: { cell: 'border-foreground', number: 'font-semibold' } },
175
+ selected: { true: { cell: 'border-primary/60 bg-primary/10' } },
156
176
  disabled: { true: { cell: 'opacity-40' } },
157
177
  },
178
+ compoundVariants: [
179
+ /*
180
+ * Today, and open. Declaration order would otherwise hand the border to
181
+ * `selected` and lose the one mark that says which day it actually is —
182
+ * so the ring is restated over the fill.
183
+ */
184
+ { today: true, selected: true, class: { cell: 'border-foreground bg-primary/10' } },
185
+ ],
158
186
  defaultVariants: { inMonth: true },
159
187
  });
160
188
 
@@ -203,6 +231,7 @@ interface PlannerContextValue {
203
231
  summary: { total: number; categories: PlannerCountedCategory[] };
204
232
  entryLimit: number;
205
233
  weekStartsOn: number;
234
+ grid: Date[][];
206
235
  locale: DateLocale;
207
236
  system: 'gregory' | 'islamic';
208
237
  isInMonth: (date: Date) => boolean;
@@ -315,34 +344,51 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
315
344
  const system = resolveCalendar(calendar, locale);
316
345
  const palette = usePalette();
317
346
  const today = useToday();
318
-
319
- const [internalMonth, setInternalMonth] = useState(() =>
320
- startOfCalendarMonth(defaultMonth ?? new Date(), system, locale)
347
+ const settleMonth = useCallback(
348
+ (date: Date) => startOfCalendarMonth(date, system, locale),
349
+ [system, locale]
321
350
  );
322
- const month = monthProp
323
- ? startOfCalendarMonth(monthProp, system, locale)
324
- : internalMonth;
325
351
 
352
+ const [month, requestMonth] = usePlannerMonthLifecycle({
353
+ month: monthProp,
354
+ defaultMonth,
355
+ settleMonth,
356
+ onMonthChange,
357
+ });
358
+ /*
359
+ * The announcement is registered against the month a press asks for, and
360
+ * spoken only once a commit arrives carrying it. That is what keeps a
361
+ * controlled parent's rejection silent, so it wraps the lifecycle's setter
362
+ * rather than living inside it — the hook owns which month is current, and
363
+ * this owns whether the change was the user's to hear about.
364
+ */
365
+ const expectMonthAnnouncement = usePlannerMonthAnnouncement({
366
+ monthKey: month.getTime(),
367
+ monthLabel: calendarMonthLabel(month, system, locale),
368
+ announce: announceMonth,
369
+ });
326
370
  const setMonth = useCallback(
327
371
  (next: Date) => {
328
- const settled = startOfCalendarMonth(next, system, locale);
329
- if (!monthProp) setInternalMonth(settled);
330
- onMonthChange?.(settled);
372
+ expectMonthAnnouncement(settleMonth(next));
373
+ requestMonth(next);
331
374
  },
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);
341
- },
342
- [selectedProp, onSelectedChange]
375
+ [expectMonthAnnouncement, requestMonth, settleMonth]
343
376
  );
377
+ const [selected, select] = usePlannerSelectionLifecycle({
378
+ selected: selectedProp,
379
+ defaultSelected,
380
+ onSelectedChange,
381
+ });
344
382
 
345
383
  const days = useMemo(() => bucketByDay(entries), [entries]);
384
+ const normalizedWeekStart =
385
+ weekStartsOn === 'auto'
386
+ ? localeWeekStart(locale)
387
+ : normalizeWeekStart(weekStartsOn);
388
+ const grid = useMemo(
389
+ () => monthGrid(month, normalizedWeekStart, system, locale),
390
+ [month, normalizedWeekStart, system, locale]
391
+ );
346
392
 
347
393
  const isInMonth = useCallback(
348
394
  (date: Date) => isSameCalendarMonth(date, month, system, locale),
@@ -354,34 +400,33 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
354
400
  [categories]
355
401
  );
356
402
 
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];
403
+ const categoryColors = useMemo(
404
+ () => {
405
+ const colors = new Map<string, string | undefined>();
406
+ categories.forEach((category, index) => {
407
+ // `findIndex` used to make the first duplicate id authoritative.
408
+ if (colors.has(category.id)) return;
409
+ const slot = (category.colorIndex ?? index + 1) - 1;
410
+ colors.set(
411
+ category.id,
412
+ category.color ??
413
+ palette[((slot % PALETTE_SIZE) + PALETTE_SIZE) % PALETTE_SIZE]
414
+ );
415
+ });
416
+ return colors;
366
417
  },
367
418
  [categories, palette]
368
419
  );
420
+ const colorOf = useCallback(
421
+ (id: string | undefined) => id ? categoryColors.get(id) : undefined,
422
+ [categoryColors]
423
+ );
369
424
 
370
425
  const summary = useMemo(
371
- () => summariseMonth(entries, categories, isInMonth),
372
- [entries, categories, isInMonth]
426
+ () => summariseMonth(days, grid, categories, isInMonth),
427
+ [days, grid, categories, isInMonth]
373
428
  );
374
429
 
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
430
  const context = useMemo<PlannerContextValue>(
386
431
  () => ({
387
432
  month,
@@ -395,10 +440,8 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
395
440
  colorOf,
396
441
  summary,
397
442
  entryLimit,
398
- weekStartsOn:
399
- weekStartsOn === 'auto'
400
- ? localeWeekStart(locale)
401
- : normalizeWeekStart(weekStartsOn),
443
+ weekStartsOn: normalizedWeekStart,
444
+ grid,
402
445
  locale,
403
446
  system,
404
447
  isInMonth,
@@ -406,7 +449,8 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
406
449
  }),
407
450
  [
408
451
  month, setMonth, today, selected, select, days, categories, categoryLabels,
409
- colorOf, summary, entryLimit, weekStartsOn, locale, system, isInMonth, onDayPress,
452
+ colorOf, summary, entryLimit, normalizedWeekStart, grid, locale, system,
453
+ isInMonth, onDayPress,
410
454
  ]
411
455
  );
412
456
 
@@ -600,6 +644,21 @@ export interface PlannerGridProps {
600
644
  renderDay?: PlannerDayRenderer;
601
645
  }
602
646
 
647
+ interface PlannerGridKeyDownEvent {
648
+ nativeEvent: { key?: string };
649
+ preventDefault: () => void;
650
+ }
651
+
652
+ interface PlannerGridNavigationContextValue {
653
+ activeIndex: number;
654
+ indexByDay: ReadonlyMap<number, number>;
655
+ register: (index: number, node: View | null) => void;
656
+ focus: (index: number, event: PlannerGridKeyDownEvent) => void;
657
+ makeActive: (index: number) => void;
658
+ }
659
+
660
+ const PlannerGridNavigationContext = createContext<PlannerGridNavigationContextValue | null>(null);
661
+
603
662
  /**
604
663
  * The weekday row and the six weeks below it.
605
664
  *
@@ -609,40 +668,77 @@ export interface PlannerGridProps {
609
668
  * weekday headings are hidden rather than read out 42 times over.
610
669
  */
611
670
  function PlannerGrid({ className, renderDay }: PlannerGridProps) {
612
- const { month, weekStartsOn, locale, system } = usePlanner('Planner.Grid');
671
+ const { grid: weeks, weekStartsOn, locale, selected, today, isInMonth } =
672
+ usePlanner('Planner.Grid');
613
673
  const { grid, week: weekRow, heading } = plannerVariants();
614
-
615
- const weeks = useMemo(
616
- () => monthGrid(month, weekStartsOn, system, locale),
617
- [month, weekStartsOn, system, locale]
618
- );
619
674
  const headings = useMemo(
620
675
  () => weekdayNames(locale, weekStartsOn),
621
676
  [locale, weekStartsOn]
622
677
  );
678
+ const dates = useMemo(() => weeks.flat(), [weeks]);
679
+ const indexByDay = useMemo(
680
+ () => new Map(dates.map((date, index) => [date.getTime(), index])),
681
+ [dates]
682
+ );
683
+ const initialDay = selected ?? today;
684
+ const [activeDay, setActiveDay] = useState(initialDay.getTime());
685
+ const storedIndex = indexByDay.get(activeDay);
686
+ const preferredIndex =
687
+ (selected ? indexByDay.get(startOfDay(selected).getTime()) : undefined) ??
688
+ indexByDay.get(today.getTime()) ??
689
+ dates.findIndex(isInMonth);
690
+ const activeIndex = storedIndex ?? Math.max(0, preferredIndex);
691
+ const refs = useRef(new Map<number, View>());
692
+ const register = useCallback((index: number, node: View | null) => {
693
+ if (node) refs.current.set(index, node);
694
+ else refs.current.delete(index);
695
+ }, []);
696
+ const makeActive = useCallback(
697
+ (index: number) => {
698
+ const date = dates[index];
699
+ if (date) setActiveDay(date.getTime());
700
+ },
701
+ [dates]
702
+ );
703
+ const focus = useCallback(
704
+ (index: number, event: PlannerGridKeyDownEvent) => {
705
+ const target = plannerGridTarget(event.nativeEvent.key ?? '', index, dates.length);
706
+ if (target === null) return;
707
+ event.preventDefault();
708
+ makeActive(target);
709
+ refs.current.get(target)?.focus();
710
+ },
711
+ [dates.length, makeActive]
712
+ );
713
+ const navigation = useMemo<PlannerGridNavigationContextValue>(
714
+ () => ({ activeIndex, indexByDay, register, focus, makeActive }),
715
+ [activeIndex, indexByDay, register, focus, makeActive]
716
+ );
623
717
 
624
718
  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} />
719
+ <PlannerGridNavigationContext.Provider value={renderDay ? null : navigation}>
720
+ <View className={grid({ className })}>
721
+ <View
722
+ className={weekRow()}
723
+ accessibilityElementsHidden
724
+ importantForAccessibility="no-hide-descendants"
725
+ >
726
+ {headings.map((label) => (
727
+ <Text key={label} size="xs" muted className={heading()}>
728
+ {label.toUpperCase()}
729
+ </Text>
642
730
  ))}
643
731
  </View>
644
- ))}
645
- </View>
732
+
733
+ {weeks.map((week, index) => (
734
+ <View key={index} className={weekRow()}>
735
+ {week.map((date) => (
736
+ <PlannerDay key={date.getTime()} date={date} renderDay={renderDay} />
737
+ ))}
738
+ </View>
739
+ ))}
740
+ </View>
741
+ </PlannerGridNavigationContext.Provider>
646
742
  );
647
743
  }
648
744
  PlannerGrid.displayName = 'Planner.Grid';
@@ -658,6 +754,10 @@ function PlannerDay({ date, renderDay }: PlannerDayProps) {
658
754
  days, today, selected, select, colorOf, categoryLabels,
659
755
  entryLimit, locale, system, isInMonth, onDayPress,
660
756
  } = usePlanner('Planner.Day');
757
+ const navigation = useContext(PlannerGridNavigationContext);
758
+ const registerGridCell = navigation?.register;
759
+ const focusGridCell = navigation?.focus;
760
+ const makeGridCellActive = navigation?.makeActive;
661
761
 
662
762
  const entries = entriesOn(days, date);
663
763
  const inMonth = isInMonth(date);
@@ -677,6 +777,31 @@ function PlannerDay({ date, renderDay }: PlannerDayProps) {
677
777
  onDayPress?.(date, entries);
678
778
  select(date);
679
779
  };
780
+ const gridIndex = navigation?.indexByDay.get(date.getTime());
781
+ const setRef = useCallback(
782
+ (node: View | null) => {
783
+ if (gridIndex !== undefined) registerGridCell?.(gridIndex, node);
784
+ },
785
+ [gridIndex, registerGridCell]
786
+ );
787
+ const onFocus = useCallback(() => {
788
+ if (gridIndex !== undefined) makeGridCellActive?.(gridIndex);
789
+ }, [gridIndex, makeGridCellActive]);
790
+ const onKeyDown = useCallback(
791
+ (event: PlannerGridKeyDownEvent) => {
792
+ if (gridIndex !== undefined) focusGridCell?.(gridIndex, event);
793
+ },
794
+ [gridIndex, focusGridCell]
795
+ );
796
+ const webGridProps =
797
+ Platform.OS === 'web' && gridIndex !== undefined && navigation
798
+ ? {
799
+ ref: setRef,
800
+ tabIndex: navigation.activeIndex === gridIndex ? (0 as const) : (-1 as const),
801
+ onFocus,
802
+ onKeyDown,
803
+ }
804
+ : {};
680
805
 
681
806
  if (renderDay) {
682
807
  return (
@@ -688,6 +813,7 @@ function PlannerDay({ date, renderDay }: PlannerDayProps) {
688
813
 
689
814
  return (
690
815
  <Pressable
816
+ {...(webGridProps as ViewProps)}
691
817
  onPress={press}
692
818
  accessibilityRole="button"
693
819
  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
+ }
@@ -99,9 +99,13 @@ export function dayAccessibilityLabel<T extends PlannerDatedEntry>(
99
99
  if (entries.length === 0) return `${dateLabel}, nothing planned`;
100
100
 
101
101
  const named: string[] = [];
102
+ const seen = new Set<string>();
102
103
  for (const entry of entries) {
103
104
  const label = entry.category ? categoryLabels.get(entry.category) : undefined;
104
- if (label && !named.includes(label)) named.push(label);
105
+ if (label && !seen.has(label)) {
106
+ seen.add(label);
107
+ named.push(label);
108
+ }
105
109
  }
106
110
 
107
111
  const count = `${entries.length} ${entries.length === 1 ? 'entry' : 'entries'}`;
@@ -120,18 +124,23 @@ export function dayAccessibilityLabel<T extends PlannerDatedEntry>(
120
124
  * with the week the month happens to start on.
121
125
  */
122
126
  export function summariseMonth<T extends PlannerDatedEntry>(
123
- entries: readonly T[],
127
+ days: Map<number, T[]>,
128
+ grid: readonly (readonly Date[])[],
124
129
  categories: readonly { id: string; label: string }[],
125
130
  isInMonth: (date: Date) => boolean
126
131
  ): { total: number; categories: PlannerCountedCategory[] } {
127
132
  const counts = new Map<string, number>();
128
133
  let total = 0;
129
134
 
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
+ for (const week of grid) {
136
+ for (const date of week) {
137
+ if (!isInMonth(date)) continue;
138
+ for (const entry of entriesOn(days, date)) {
139
+ total += 1;
140
+ if (entry.category) {
141
+ counts.set(entry.category, (counts.get(entry.category) ?? 0) + 1);
142
+ }
143
+ }
135
144
  }
136
145
  }
137
146
 
@@ -0,0 +1,45 @@
1
+ export type PlannerGridNavigationKey =
2
+ | 'ArrowLeft'
3
+ | 'ArrowRight'
4
+ | 'ArrowUp'
5
+ | 'ArrowDown'
6
+ | 'Home'
7
+ | 'End';
8
+
9
+ /** Returns the next visible cell for a bounded, row-major month grid. */
10
+ export function plannerGridTarget(
11
+ key: string,
12
+ index: number,
13
+ count: number,
14
+ columns = 7
15
+ ): number | null {
16
+ if (!Number.isInteger(index) || index < 0 || index >= count || columns < 1) {
17
+ return null;
18
+ }
19
+
20
+ let target: number;
21
+ switch (key as PlannerGridNavigationKey) {
22
+ case 'ArrowLeft':
23
+ target = index - 1;
24
+ break;
25
+ case 'ArrowRight':
26
+ target = index + 1;
27
+ break;
28
+ case 'ArrowUp':
29
+ target = index - columns;
30
+ break;
31
+ case 'ArrowDown':
32
+ target = index + columns;
33
+ break;
34
+ case 'Home':
35
+ target = index - (index % columns);
36
+ break;
37
+ case 'End':
38
+ target = index + (columns - 1 - (index % columns));
39
+ break;
40
+ default:
41
+ return null;
42
+ }
43
+
44
+ return target >= 0 && target < count ? target : index;
45
+ }
@@ -0,0 +1,76 @@
1
+ import { useCallback, useMemo, useState } from 'react';
2
+
3
+ interface PlannerMonthLifecycleOptions {
4
+ month: Date | undefined;
5
+ defaultMonth: Date | undefined;
6
+ settleMonth: (month: Date) => Date;
7
+ onMonthChange: ((month: Date) => void) | undefined;
8
+ }
9
+
10
+ /** Keeps controlled requests separate from the month a parent has accepted. */
11
+ export function usePlannerMonthLifecycle({
12
+ month: monthProp,
13
+ defaultMonth,
14
+ settleMonth,
15
+ onMonthChange,
16
+ }: PlannerMonthLifecycleOptions): readonly [Date, (month: Date) => void] {
17
+ const [internalMonth, setInternalMonth] = useState(() =>
18
+ settleMonth(defaultMonth ?? new Date())
19
+ );
20
+ const isControlled = monthProp !== undefined;
21
+
22
+ /*
23
+ * Parents commonly create an equivalent Date on every render. Keying this
24
+ * derivation by its instant keeps the month object stable, so an unrelated
25
+ * parent render does not rebuild the grid and its month summary.
26
+ */
27
+ const controlledMonth = useMemo(
28
+ () =>
29
+ monthProp === undefined
30
+ ? undefined
31
+ : settleMonth(monthProp),
32
+ // eslint-disable-next-line react-hooks/exhaustive-deps
33
+ [monthProp?.getTime(), settleMonth]
34
+ );
35
+ const month = controlledMonth ?? internalMonth;
36
+
37
+ const setMonth = useCallback(
38
+ (next: Date) => {
39
+ const settled = settleMonth(next);
40
+ if (!isControlled) setInternalMonth(settled);
41
+ onMonthChange?.(settled);
42
+ },
43
+ [isControlled, onMonthChange, settleMonth]
44
+ );
45
+
46
+ return [month, setMonth] as const;
47
+ }
48
+
49
+ interface PlannerSelectionLifecycleOptions {
50
+ selected: Date | null | undefined;
51
+ defaultSelected: Date | null;
52
+ onSelectedChange: ((date: Date | null) => void) | undefined;
53
+ }
54
+
55
+ /** Commits locally only when selection is uncontrolled, but always reports a request. */
56
+ export function usePlannerSelectionLifecycle({
57
+ selected: selectedProp,
58
+ defaultSelected,
59
+ onSelectedChange,
60
+ }: PlannerSelectionLifecycleOptions): readonly [
61
+ Date | null,
62
+ (date: Date | null) => void,
63
+ ] {
64
+ const [internalSelected, setInternalSelected] = useState<Date | null>(defaultSelected);
65
+ const isControlled = selectedProp !== undefined;
66
+ const selected = isControlled ? selectedProp : internalSelected;
67
+ const select = useCallback(
68
+ (date: Date | null) => {
69
+ if (!isControlled) setInternalSelected(date);
70
+ onSelectedChange?.(date);
71
+ },
72
+ [isControlled, onSelectedChange]
73
+ );
74
+
75
+ return [selected, select] as const;
76
+ }