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,886 @@
1
+ /**
2
+ * Planner — a month of days, each carrying what falls on it.
3
+ *
4
+ * ```tsx
5
+ * const [month, setMonth] = useState(new Date());
6
+ *
7
+ * <Planner
8
+ * month={month}
9
+ * onMonthChange={setMonth}
10
+ * entries={renewals}
11
+ * categories={[
12
+ * { id: 'monthly', label: 'Monthly' },
13
+ * { id: 'yearly', label: 'Yearly', colorIndex: 4 },
14
+ * ]}
15
+ * >
16
+ * <Planner.Header>
17
+ * <Planner.Title />
18
+ * <Planner.Today />
19
+ * <Planner.Nav />
20
+ * </Planner.Header>
21
+ * <Planner.Grid />
22
+ * <Planner.Legend />
23
+ * <Planner.Details>
24
+ * {(date, entries) => <Text>{entries.length} on {date.toDateString()}</Text>}
25
+ * </Planner.Details>
26
+ * </Planner>
27
+ * ```
28
+ *
29
+ * ## How it differs from Calendar
30
+ *
31
+ * `Calendar` picks a date and answers with one. This shows what is already on
32
+ * the days and answers with the day you asked about — the selection exists to
33
+ * open something, not to be submitted.
34
+ *
35
+ * ## Why it draws its own Frame
36
+ *
37
+ * A month at a glance is a widget: a boundary, a strip along the top carrying
38
+ * the month and the way through it, and a footer that holds still while the
39
+ * middle changes. That is `Frame`, so the root renders one instead of leaving
40
+ * every caller to assemble the same shell. Pass `frame={false}` to drop it,
41
+ * for a planner in a sheet or a card that already draws its own edge.
42
+ *
43
+ * ## Why the grid is always six weeks
44
+ *
45
+ * A month can span five weeks or six. Drawn at its natural height the panel
46
+ * jumps as you page through the year and the days appear to move under your
47
+ * thumb, so the grid is always six rows and the last one is sometimes all
48
+ * next month. `Calendar` fixes its height for the same reason.
49
+ *
50
+ * ## Why a day says more than its date
51
+ *
52
+ * The marker on a day is a coloured dot, and colour is a signal that does not
53
+ * reach everyone looking at it. So the legend prints its label beside every
54
+ * swatch, and a day is spoken as its date, how many entries it carries and
55
+ * which categories they belong to. Neither is decoration: between them they
56
+ * are the whole content of the grid for somebody who cannot see it.
57
+ */
58
+ import {
59
+ Children,
60
+ createContext,
61
+ forwardRef,
62
+ isValidElement,
63
+ useCallback,
64
+ useContext,
65
+ useEffect,
66
+ useMemo,
67
+ useState,
68
+ type ReactElement,
69
+ type ReactNode,
70
+ } from 'react';
71
+ import {
72
+ AccessibilityInfo,
73
+ AppState,
74
+ Pressable,
75
+ View,
76
+ type ViewProps,
77
+ } from 'react-native';
78
+ import { tv } from 'tailwind-variants';
79
+ import { useCSSVariable } from 'uniwind';
80
+ import { ChevronLeftIcon, ChevronRightIcon } from '../../icons';
81
+ import { Text, type TextProps } from '../../primitives/text';
82
+ import { cn } from '../../utils/cn';
83
+ import {
84
+ addCalendarMonths,
85
+ calendarDayNumber,
86
+ calendarLongDate,
87
+ calendarMonthLabel,
88
+ isSameCalendarMonth,
89
+ isSameDay,
90
+ localeWeekStart,
91
+ monthGrid,
92
+ normalizeWeekStart,
93
+ resolveCalendar,
94
+ startOfCalendarMonth,
95
+ startOfDay,
96
+ weekdayNames,
97
+ type CalendarSystem,
98
+ type DateLocale,
99
+ } from '../../utils/date';
100
+ import { Dialog } from '../dialog';
101
+ import { Frame } from '../frame';
102
+ import {
103
+ bucketByDay,
104
+ dayAccessibilityLabel,
105
+ entriesOn,
106
+ summariseMonth,
107
+ visibleEntries,
108
+ type PlannerCountedCategory,
109
+ } from './planner-entries';
110
+
111
+ /** How many of a day's entries a cell draws before it stops and counts. */
112
+ const DEFAULT_ENTRY_LIMIT = 2;
113
+
114
+ /** The palette a category takes its dot from when it does not name a colour. */
115
+ const PALETTE_SIZE = 5;
116
+
117
+ const plannerVariants = tv({
118
+ slots: {
119
+ grid: 'gap-1 px-3 pb-3 pt-1',
120
+ week: 'flex-row',
121
+ heading: 'flex-1 text-center',
122
+ legend: 'flex-row flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3',
123
+ swatch: 'h-2 w-2 rounded-full',
124
+ },
125
+ });
126
+
127
+ const dayVariants = tv({
128
+ slots: {
129
+ /*
130
+ * A fixed height, and a square-ish tile rather than a circle.
131
+ *
132
+ * Fixed because the cell has to be the same size whether the day carries
133
+ * anything or not — sized to its contents, a row with one icon in it is
134
+ * taller than the five around it and the grid stops being a grid.
135
+ *
136
+ * Square-ish because a day here holds a number, a marker and sometimes an
137
+ * icon, and a circle wastes the corners it needs for them.
138
+ */
139
+ cell: 'mx-0.5 h-14 flex-1 rounded-xl px-1.5 pt-1.5',
140
+ number: 'text-xs leading-none',
141
+ marker: 'absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full',
142
+ body: 'flex-1 flex-row items-center justify-center gap-0.5 pb-1',
143
+ },
144
+ variants: {
145
+ /** Inside the month being shown, as opposed to the days either side. */
146
+ inMonth: {
147
+ true: { cell: 'bg-muted/40', number: 'text-foreground' },
148
+ false: { cell: 'bg-muted/15', number: 'text-muted-foreground/40' },
149
+ },
150
+ /*
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.
153
+ */
154
+ today: { true: { number: 'text-primary font-semibold' } },
155
+ selected: { true: { cell: 'border border-primary/60 bg-primary/10' } },
156
+ disabled: { true: { cell: 'opacity-40' } },
157
+ },
158
+ defaultVariants: { inMonth: true },
159
+ });
160
+
161
+ /* ------------------------------------------------------------------ *
162
+ * Data
163
+ * ------------------------------------------------------------------ */
164
+
165
+ /** One thing that falls on a day. */
166
+ export interface PlannerEntry {
167
+ /** Stable across renders; it keys the cell's contents. */
168
+ id: string;
169
+ /** When it falls. The time of day is kept but not read by the grid. */
170
+ date: Date;
171
+ /** Named in the dialog and, through its category, in the day's spoken label. */
172
+ label: string;
173
+ /** Matches a `PlannerCategory` id. Without one the entry is still counted. */
174
+ category?: string;
175
+ /** Drawn in the cell — a brand mark, an avatar, a glyph. */
176
+ icon?: ReactNode;
177
+ }
178
+
179
+ /** A group of entries: the key to a colour, and a line in the legend. */
180
+ export interface PlannerCategory {
181
+ id: string;
182
+ /** Printed beside the swatch, and spoken as part of a day that carries it. */
183
+ label: string;
184
+ /**
185
+ * Which `--color-chart-*` token the dot takes, 1 to 5. Categories without
186
+ * one are numbered in the order they are declared.
187
+ */
188
+ colorIndex?: number;
189
+ /** An explicit colour, for a brand that is not the theme's to choose. */
190
+ color?: string;
191
+ }
192
+
193
+ interface PlannerContextValue {
194
+ month: Date;
195
+ setMonth: (month: Date) => void;
196
+ today: Date;
197
+ selected: Date | null;
198
+ select: (date: Date | null) => void;
199
+ days: Map<number, PlannerEntry[]>;
200
+ categories: PlannerCategory[];
201
+ categoryLabels: Map<string, string>;
202
+ colorOf: (category: string | undefined) => string | undefined;
203
+ summary: { total: number; categories: PlannerCountedCategory[] };
204
+ entryLimit: number;
205
+ weekStartsOn: number;
206
+ locale: DateLocale;
207
+ system: 'gregory' | 'islamic';
208
+ isInMonth: (date: Date) => boolean;
209
+ renderDay?: PlannerDayRenderer;
210
+ onDayPress?: (date: Date, entries: PlannerEntry[]) => void;
211
+ }
212
+
213
+ const PlannerContext = createContext<PlannerContextValue | null>(null);
214
+
215
+ function usePlanner(component: string): PlannerContextValue {
216
+ const context = useContext(PlannerContext);
217
+ if (!context) {
218
+ throw new Error(`${component} must be used within a <Planner>`);
219
+ }
220
+ return context;
221
+ }
222
+
223
+ /** Today, recomputed when the app comes back rather than only at mount. */
224
+ function useToday(): Date {
225
+ const [today, setToday] = useState(() => startOfDay(new Date()));
226
+
227
+ useEffect(() => {
228
+ const refresh = () => {
229
+ const now = startOfDay(new Date());
230
+ // A new object every resume would invalidate every memo below it for a
231
+ // date that has not changed.
232
+ setToday((current) => (current.getTime() === now.getTime() ? current : now));
233
+ };
234
+ const subscription = AppState.addEventListener('change', (state) => {
235
+ if (state === 'active') refresh();
236
+ });
237
+ return () => subscription.remove();
238
+ }, []);
239
+
240
+ return today;
241
+ }
242
+
243
+ /**
244
+ * The five series colours, read once.
245
+ *
246
+ * A fixed number of reads rather than one per category: a hook cannot run in a
247
+ * loop whose length is a prop.
248
+ */
249
+ function usePalette(): (string | undefined)[] {
250
+ const one = useCSSVariable('--color-chart-1');
251
+ const two = useCSSVariable('--color-chart-2');
252
+ const three = useCSSVariable('--color-chart-3');
253
+ const four = useCSSVariable('--color-chart-4');
254
+ const five = useCSSVariable('--color-chart-5');
255
+ return useMemo(
256
+ () => [one, two, three, four, five].map((v) => (typeof v === 'string' ? v : undefined)),
257
+ [one, two, three, four, five]
258
+ );
259
+ }
260
+
261
+ /* ------------------------------------------------------------------ *
262
+ * Root
263
+ * ------------------------------------------------------------------ */
264
+
265
+ export interface PlannerProps extends Omit<ViewProps, 'children'> {
266
+ className?: string;
267
+ /** The month on show. Leave it out for an uncontrolled planner. */
268
+ month?: Date;
269
+ defaultMonth?: Date;
270
+ onMonthChange?: (month: Date) => void;
271
+ /** Everything the planner knows about, in any order and any month. */
272
+ entries?: PlannerEntry[];
273
+ /** The colour key. Declaration order is legend order and palette order. */
274
+ categories?: PlannerCategory[];
275
+ /** The open day. `null` is none. Leave it out for an uncontrolled planner. */
276
+ selected?: Date | null;
277
+ defaultSelected?: Date | null;
278
+ onSelectedChange?: (date: Date | null) => void;
279
+ /** Runs before the selection moves, whether or not `Details` is present. */
280
+ onDayPress?: (date: Date, entries: PlannerEntry[]) => void;
281
+ /** How many entries a cell draws before it counts the rest. Default `2`. */
282
+ entryLimit?: number;
283
+ /** First day of the week, 0 is Sunday. Defaults to the locale's. */
284
+ weekStartsOn?: number | 'auto';
285
+ locale?: DateLocale;
286
+ calendar?: CalendarSystem;
287
+ /** Draw the surrounding `Frame`. Off for a planner in a sheet or a card. */
288
+ frame?: boolean;
289
+ children?: ReactNode;
290
+ }
291
+
292
+ const PlannerRoot = forwardRef<View, PlannerProps>(
293
+ (
294
+ {
295
+ className,
296
+ month: monthProp,
297
+ defaultMonth,
298
+ onMonthChange,
299
+ entries = [],
300
+ categories = [],
301
+ selected: selectedProp,
302
+ defaultSelected = null,
303
+ onSelectedChange,
304
+ onDayPress,
305
+ entryLimit = DEFAULT_ENTRY_LIMIT,
306
+ weekStartsOn = 'auto',
307
+ locale,
308
+ calendar = 'gregory',
309
+ frame = true,
310
+ children,
311
+ ...props
312
+ },
313
+ ref
314
+ ) => {
315
+ const system = resolveCalendar(calendar, locale);
316
+ const palette = usePalette();
317
+ const today = useToday();
318
+
319
+ const [internalMonth, setInternalMonth] = useState(() =>
320
+ startOfCalendarMonth(defaultMonth ?? new Date(), system, locale)
321
+ );
322
+ const month = monthProp
323
+ ? startOfCalendarMonth(monthProp, system, locale)
324
+ : internalMonth;
325
+
326
+ const setMonth = useCallback(
327
+ (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);
341
+ },
342
+ [selectedProp, onSelectedChange]
343
+ );
344
+
345
+ const days = useMemo(() => bucketByDay(entries), [entries]);
346
+
347
+ const isInMonth = useCallback(
348
+ (date: Date) => isSameCalendarMonth(date, month, system, locale),
349
+ [month, system, locale]
350
+ );
351
+
352
+ const categoryLabels = useMemo(
353
+ () => new Map(categories.map((category) => [category.id, category.label])),
354
+ [categories]
355
+ );
356
+
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];
366
+ },
367
+ [categories, palette]
368
+ );
369
+
370
+ const summary = useMemo(
371
+ () => summariseMonth(entries, categories, isInMonth),
372
+ [entries, categories, isInMonth]
373
+ );
374
+
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
+ const context = useMemo<PlannerContextValue>(
386
+ () => ({
387
+ month,
388
+ setMonth,
389
+ today,
390
+ selected,
391
+ select,
392
+ days,
393
+ categories,
394
+ categoryLabels,
395
+ colorOf,
396
+ summary,
397
+ entryLimit,
398
+ weekStartsOn:
399
+ weekStartsOn === 'auto'
400
+ ? localeWeekStart(locale)
401
+ : normalizeWeekStart(weekStartsOn),
402
+ locale,
403
+ system,
404
+ isInMonth,
405
+ onDayPress,
406
+ }),
407
+ [
408
+ month, setMonth, today, selected, select, days, categories, categoryLabels,
409
+ colorOf, summary, entryLimit, weekStartsOn, locale, system, isInMonth, onDayPress,
410
+ ]
411
+ );
412
+
413
+ /*
414
+ * The header belongs in the Frame's strip and everything else in its
415
+ * panel, so the root sorts its children rather than asking the caller to
416
+ * nest them in two places to get one widget.
417
+ */
418
+ const parts = Children.toArray(children);
419
+ const header = parts.find(
420
+ (child) => isValidElement(child) && child.type === PlannerHeader
421
+ );
422
+ const body = parts.filter((child) => child !== header);
423
+
424
+ return (
425
+ <PlannerContext.Provider value={context}>
426
+ {frame ? (
427
+ // Full width by default: the grid is seven equal columns, and a
428
+ // container that sizes to its content collapses them to the width of
429
+ // a two-digit number.
430
+ <Frame ref={ref} className={cn('w-full', className)} {...props}>
431
+ {header}
432
+ <Frame.Panel dividers={false}>{body}</Frame.Panel>
433
+ </Frame>
434
+ ) : (
435
+ <View ref={ref} className={cn('w-full', className)} {...props}>
436
+ {header}
437
+ {body}
438
+ </View>
439
+ )}
440
+ </PlannerContext.Provider>
441
+ );
442
+ }
443
+ );
444
+ PlannerRoot.displayName = 'Planner';
445
+
446
+ /* ------------------------------------------------------------------ *
447
+ * Header strip
448
+ * ------------------------------------------------------------------ */
449
+
450
+ export interface PlannerHeaderProps {
451
+ children?: ReactNode;
452
+ }
453
+
454
+ /**
455
+ * The strip along the top of the frame. Put `Title`, `Today` and `Nav` in it.
456
+ *
457
+ * The strip has two ends rather than an even spread. What the month *is* —
458
+ * its name, and the way back to today — reads from the leading edge; what
459
+ * *moves* it sits at the trailing edge, under the thumb that reaches for it.
460
+ * Spaced evenly across a full-width strip they read as three unrelated
461
+ * controls instead of a label and a pair of buttons.
462
+ */
463
+ function PlannerHeader({ children }: PlannerHeaderProps) {
464
+ const parts = Children.toArray(children);
465
+ const trails = (child: ReactNode) =>
466
+ isValidElement(child) && (child.type === PlannerNav || child.type === PlannerAction);
467
+
468
+ const trailing = parts.filter(trails);
469
+ const lead = parts.filter((child) => !trails(child));
470
+
471
+ return (
472
+ <Frame.Header>
473
+ <View className="min-w-0 flex-1 flex-row items-center gap-2">{lead}</View>
474
+ {trailing.length > 0 ? (
475
+ <View className="shrink-0 flex-row items-center gap-1.5">{trailing}</View>
476
+ ) : null}
477
+ </Frame.Header>
478
+ );
479
+ }
480
+ PlannerHeader.displayName = 'Planner.Header';
481
+
482
+ export interface PlannerTitleProps extends Omit<TextProps, 'children'> {
483
+ /** Replaces the month name, for a title that says something else. */
484
+ children?: ReactNode;
485
+ }
486
+
487
+ /** The month on show, in the calendar system and locale the grid uses. */
488
+ function PlannerTitle({ children, className, ...props }: PlannerTitleProps) {
489
+ const { month, system, locale } = usePlanner('Planner.Title');
490
+ return (
491
+ <Text weight="medium" className={cn('text-base', className)} {...props}>
492
+ {children ?? calendarMonthLabel(month, system, locale)}
493
+ </Text>
494
+ );
495
+ }
496
+ PlannerTitle.displayName = 'Planner.Title';
497
+
498
+ export interface PlannerTodayProps {
499
+ /** Replaces the word on the pill. */
500
+ children?: ReactNode;
501
+ }
502
+
503
+ /** Jumps back to the month today is in, and selects nothing. */
504
+ function PlannerToday({ children = 'Today' }: PlannerTodayProps) {
505
+ const { today, setMonth, month, system, locale } = usePlanner('Planner.Today');
506
+ const alreadyHere = isSameCalendarMonth(today, month, system, locale);
507
+
508
+ return (
509
+ <Pressable
510
+ onPress={() => setMonth(today)}
511
+ disabled={alreadyHere}
512
+ hitSlop={6}
513
+ accessibilityRole="button"
514
+ accessibilityLabel="Go to this month"
515
+ accessibilityState={{ disabled: alreadyHere }}
516
+ className={cn(
517
+ 'rounded-full border border-border px-3 py-1',
518
+ alreadyHere ? 'opacity-40' : 'active:bg-accent'
519
+ )}
520
+ >
521
+ <Text size="xs" muted>
522
+ {children}
523
+ </Text>
524
+ </Pressable>
525
+ );
526
+ }
527
+ PlannerToday.displayName = 'Planner.Today';
528
+
529
+ export interface PlannerNavProps {
530
+ className?: string;
531
+ }
532
+
533
+ /** Back and forward a month. */
534
+ function PlannerNav({ className }: PlannerNavProps) {
535
+ const { month, setMonth, system, locale } = usePlanner('Planner.Nav');
536
+
537
+ return (
538
+ <View className={cn('flex-row items-center gap-1', className)}>
539
+ <PlannerArrow
540
+ direction="previous"
541
+ onPress={() => setMonth(addCalendarMonths(month, -1, system, locale))}
542
+ />
543
+ <PlannerArrow
544
+ direction="next"
545
+ onPress={() => setMonth(addCalendarMonths(month, 1, system, locale))}
546
+ />
547
+ </View>
548
+ );
549
+ }
550
+ PlannerNav.displayName = 'Planner.Nav';
551
+
552
+ function PlannerArrow({
553
+ direction,
554
+ onPress,
555
+ }: {
556
+ direction: 'previous' | 'next';
557
+ onPress: () => void;
558
+ }) {
559
+ return (
560
+ <Pressable
561
+ onPress={onPress}
562
+ hitSlop={8}
563
+ accessibilityRole="button"
564
+ accessibilityLabel={direction === 'next' ? 'Next month' : 'Previous month'}
565
+ className="h-8 w-8 items-center justify-center rounded-lg active:bg-accent"
566
+ >
567
+ {direction === 'next' ? <ChevronRightIcon size={18} /> : <ChevronLeftIcon size={18} />}
568
+ </Pressable>
569
+ );
570
+ }
571
+
572
+ export interface PlannerActionProps {
573
+ children?: ReactNode;
574
+ }
575
+
576
+ /** The trailing end of the header strip, for a button of the caller's. */
577
+ function PlannerAction({ children }: PlannerActionProps) {
578
+ return <Frame.Action>{children}</Frame.Action>;
579
+ }
580
+ PlannerAction.displayName = 'Planner.Action';
581
+
582
+ /* ------------------------------------------------------------------ *
583
+ * Grid
584
+ * ------------------------------------------------------------------ */
585
+
586
+ /** What a custom cell is handed. Everything the default cell draws from. */
587
+ export interface PlannerDayState {
588
+ date: Date;
589
+ entries: PlannerEntry[];
590
+ isToday: boolean;
591
+ isSelected: boolean;
592
+ isInMonth: boolean;
593
+ }
594
+
595
+ export type PlannerDayRenderer = (state: PlannerDayState) => ReactNode;
596
+
597
+ export interface PlannerGridProps {
598
+ className?: string;
599
+ /** Draws a cell yourself. It is handed the day and what falls on it. */
600
+ renderDay?: PlannerDayRenderer;
601
+ }
602
+
603
+ /**
604
+ * The weekday row and the six weeks below it.
605
+ *
606
+ * React Native has no per-cell grid vocabulary — no `gridcell`, no `row` — so
607
+ * a screen reader never hears "row three, column five" and cannot fall back on
608
+ * position for context. Every day therefore carries its own full date, and the
609
+ * weekday headings are hidden rather than read out 42 times over.
610
+ */
611
+ function PlannerGrid({ className, renderDay }: PlannerGridProps) {
612
+ const { month, weekStartsOn, locale, system } = usePlanner('Planner.Grid');
613
+ const { grid, week: weekRow, heading } = plannerVariants();
614
+
615
+ const weeks = useMemo(
616
+ () => monthGrid(month, weekStartsOn, system, locale),
617
+ [month, weekStartsOn, system, locale]
618
+ );
619
+ const headings = useMemo(
620
+ () => weekdayNames(locale, weekStartsOn),
621
+ [locale, weekStartsOn]
622
+ );
623
+
624
+ 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} />
642
+ ))}
643
+ </View>
644
+ ))}
645
+ </View>
646
+ );
647
+ }
648
+ PlannerGrid.displayName = 'Planner.Grid';
649
+
650
+ export interface PlannerDayProps {
651
+ date: Date;
652
+ renderDay?: PlannerDayRenderer;
653
+ }
654
+
655
+ /** One cell. Pressing it selects the day and opens whatever is bound to it. */
656
+ function PlannerDay({ date, renderDay }: PlannerDayProps) {
657
+ const {
658
+ days, today, selected, select, colorOf, categoryLabels,
659
+ entryLimit, locale, system, isInMonth, onDayPress,
660
+ } = usePlanner('Planner.Day');
661
+
662
+ const entries = entriesOn(days, date);
663
+ const inMonth = isInMonth(date);
664
+ const isToday = isSameDay(date, today);
665
+ const isSelected = selected ? isSameDay(date, selected) : false;
666
+ const { shown, overflow } = visibleEntries(entries, entryLimit);
667
+ const drawable = shown.filter((entry) => entry.icon);
668
+ const styles = dayVariants({ inMonth, today: isToday, selected: isSelected });
669
+
670
+ const label = dayAccessibilityLabel(
671
+ calendarLongDate(date, system, locale),
672
+ entries,
673
+ categoryLabels
674
+ );
675
+
676
+ const press = () => {
677
+ onDayPress?.(date, entries);
678
+ select(date);
679
+ };
680
+
681
+ if (renderDay) {
682
+ return (
683
+ <View className="flex-1">
684
+ {renderDay({ date, entries, isToday, isSelected, isInMonth: inMonth })}
685
+ </View>
686
+ );
687
+ }
688
+
689
+ return (
690
+ <Pressable
691
+ onPress={press}
692
+ accessibilityRole="button"
693
+ accessibilityLabel={label}
694
+ accessibilityHint={entries.length > 0 ? 'Opens what is on this day' : undefined}
695
+ accessibilityState={{ selected: isSelected }}
696
+ className={styles.cell()}
697
+ >
698
+ <Text className={styles.number()}>{calendarDayNumber(date, system, locale)}</Text>
699
+
700
+ {entries.length > 0 ? (
701
+ <View className={styles.marker()} style={{ backgroundColor: markerColor(entries, colorOf) }} />
702
+ ) : null}
703
+
704
+ {/*
705
+ Only entries that brought an icon are drawn, and the overflow count
706
+ goes with them. A day whose entries have no icons is already saying
707
+ "something is here" with its marker; adding a bare "+3" under an empty
708
+ row says it twice, in a way that looks like a stray number.
709
+ */}
710
+ {drawable.length > 0 ? (
711
+ <View className={styles.body()}>
712
+ {drawable.map((entry) => (
713
+ <View key={entry.id}>{entry.icon}</View>
714
+ ))}
715
+ {overflow > 0 ? (
716
+ <Text size="xs" muted className="leading-none">
717
+ +{overflow}
718
+ </Text>
719
+ ) : null}
720
+ </View>
721
+ ) : null}
722
+ </Pressable>
723
+ );
724
+ }
725
+ PlannerDay.displayName = 'Planner.Day';
726
+
727
+ /**
728
+ * The dot's colour: the first entry that names a category the planner knows.
729
+ *
730
+ * One dot rather than one per entry. A cell this size fits a row of dots or a
731
+ * day number, and the count is already spoken — so the dot answers "is there
732
+ * anything here, and roughly what kind", which is all it has room to answer.
733
+ */
734
+ function markerColor(
735
+ entries: readonly PlannerEntry[],
736
+ colorOf: (category: string | undefined) => string | undefined
737
+ ): string | undefined {
738
+ for (const entry of entries) {
739
+ const color = colorOf(entry.category);
740
+ if (color) return color;
741
+ }
742
+ return undefined;
743
+ }
744
+
745
+ /* ------------------------------------------------------------------ *
746
+ * Legend and summary
747
+ * ------------------------------------------------------------------ */
748
+
749
+ export interface PlannerLegendProps {
750
+ className?: string;
751
+ /** Print each category's count for the month beside its label. */
752
+ counts?: boolean;
753
+ /** Sits at the trailing end — a total, a currency, whatever the month adds to. */
754
+ children?: ReactNode;
755
+ }
756
+
757
+ /**
758
+ * The key to the dots.
759
+ *
760
+ * It prints the label beside every swatch, because a column of coloured dots
761
+ * with nothing to read them against is a quiz.
762
+ */
763
+ function PlannerLegend({ className, counts = false, children }: PlannerLegendProps) {
764
+ const { summary, colorOf } = usePlanner('Planner.Legend');
765
+ const { legend, swatch } = plannerVariants();
766
+
767
+ return (
768
+ <View className={cn(legend(), 'justify-between border-t border-border', className)}>
769
+ <View className="flex-row flex-wrap items-center gap-x-4 gap-y-2">
770
+ {summary.categories.map((category) => (
771
+ <View key={category.id} className="flex-row items-center gap-1.5">
772
+ <View
773
+ className={swatch()}
774
+ style={{ backgroundColor: colorOf(category.id) }}
775
+ />
776
+ <Text size="xs" muted>
777
+ {counts ? `${category.label} ${category.count}` : category.label}
778
+ </Text>
779
+ </View>
780
+ ))}
781
+ </View>
782
+ {children}
783
+ </View>
784
+ );
785
+ }
786
+ PlannerLegend.displayName = 'Planner.Legend';
787
+
788
+ export interface PlannerSummaryProps extends Omit<TextProps, 'children'> {
789
+ /** Replaces the count, for a total that is money rather than entries. */
790
+ children?: ReactNode;
791
+ }
792
+
793
+ /** What the month adds up to. Counts this month only, never the days either side. */
794
+ function PlannerSummary({ children, ...props }: PlannerSummaryProps) {
795
+ const { summary } = usePlanner('Planner.Summary');
796
+ return (
797
+ <Text size="xs" muted {...props}>
798
+ {children ?? `${summary.total} ${summary.total === 1 ? 'entry' : 'entries'}`}
799
+ </Text>
800
+ );
801
+ }
802
+ PlannerSummary.displayName = 'Planner.Summary';
803
+
804
+ export interface PlannerFooterProps {
805
+ className?: string;
806
+ children?: ReactNode;
807
+ }
808
+
809
+ /** The strip along the bottom, for tools that act on the month. */
810
+ function PlannerFooter({ className, children }: PlannerFooterProps) {
811
+ return (
812
+ <View className={cn('flex-row items-center justify-between gap-3 px-4 py-3', className)}>
813
+ {children}
814
+ </View>
815
+ );
816
+ }
817
+ PlannerFooter.displayName = 'Planner.Footer';
818
+
819
+ /* ------------------------------------------------------------------ *
820
+ * Details
821
+ * ------------------------------------------------------------------ */
822
+
823
+ export interface PlannerDetailsProps {
824
+ className?: string;
825
+ /** Title above the children. Defaults to the day's full date. */
826
+ title?: ReactNode;
827
+ /**
828
+ * The line under the title. Defaults to how many entries the day carries,
829
+ * so the dialog answers "how much of this is there" before it is read.
830
+ * Pass `null` to drop it.
831
+ */
832
+ description?: ReactNode;
833
+ /** Given the open day and what falls on it. */
834
+ children: (date: Date, entries: PlannerEntry[]) => ReactNode;
835
+ }
836
+
837
+ /**
838
+ * A dialog bound to the open day.
839
+ *
840
+ * The planner owns the grid and the binding; what the dialog says is the
841
+ * application's, because the contents of a day are its data and not the
842
+ * component's. Leave it out and `onDayPress` is still called — a planner that
843
+ * pushes a screen instead of opening a dialog wants that and nothing else.
844
+ */
845
+ function PlannerDetails({ className, title, description, children }: PlannerDetailsProps) {
846
+ const { selected, select, days, system, locale } = usePlanner('Planner.Details');
847
+ const entries = selected ? entriesOn(days, selected) : [];
848
+
849
+ const count =
850
+ entries.length === 1 ? '1 entry' : `${entries.length} entries`;
851
+
852
+ return (
853
+ <Dialog open={selected !== null} onOpenChange={(open) => !open && select(null)}>
854
+ <Dialog.Content className={cn('gap-3', className)}>
855
+ <View className="gap-0.5">
856
+ <Dialog.Title>
857
+ {title ?? (selected ? calendarLongDate(selected, system, locale) : '')}
858
+ </Dialog.Title>
859
+ {description === null ? null : (
860
+ <Dialog.Description>
861
+ {description ?? (entries.length === 0 ? 'Nothing planned' : count)}
862
+ </Dialog.Description>
863
+ )}
864
+ </View>
865
+ {selected ? children(selected, entries) : null}
866
+ </Dialog.Content>
867
+ </Dialog>
868
+ );
869
+ }
870
+ PlannerDetails.displayName = 'Planner.Details';
871
+
872
+ export const Planner = Object.assign(PlannerRoot, {
873
+ Header: PlannerHeader,
874
+ Title: PlannerTitle,
875
+ Today: PlannerToday,
876
+ Nav: PlannerNav,
877
+ Action: PlannerAction,
878
+ Grid: PlannerGrid,
879
+ Day: PlannerDay,
880
+ Legend: PlannerLegend,
881
+ Summary: PlannerSummary,
882
+ Footer: PlannerFooter,
883
+ Details: PlannerDetails,
884
+ });
885
+
886
+ export type { PlannerCountedCategory };