panelui-native 0.80.0 → 0.82.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 (92) hide show
  1. package/lib/module/components/bottom-sheet/index.js +90 -17
  2. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  3. package/lib/module/components/button/index.js +13 -0
  4. package/lib/module/components/button/index.js.map +1 -1
  5. package/lib/module/components/button-group/index.js +3 -0
  6. package/lib/module/components/button-group/index.js.map +1 -1
  7. package/lib/module/components/image-generation/dot-field.js +210 -0
  8. package/lib/module/components/image-generation/dot-field.js.map +1 -0
  9. package/lib/module/components/image-generation/index.js +417 -0
  10. package/lib/module/components/image-generation/index.js.map +1 -0
  11. package/lib/module/components/kpi/index.js +2 -4
  12. package/lib/module/components/kpi/index.js.map +1 -1
  13. package/lib/module/components/menu/index.js +8 -16
  14. package/lib/module/components/menu/index.js.map +1 -1
  15. package/lib/module/components/panelside/index.js +340 -33
  16. package/lib/module/components/panelside/index.js.map +1 -1
  17. package/lib/module/components/planner/index.js +446 -32
  18. package/lib/module/components/planner/index.js.map +1 -1
  19. package/lib/module/components/planner/planner-entries.js +36 -0
  20. package/lib/module/components/planner/planner-entries.js.map +1 -1
  21. package/lib/module/components/planner/planner-grid-navigation.js +34 -2
  22. package/lib/module/components/planner/planner-grid-navigation.js.map +1 -1
  23. package/lib/module/components/planner/planner-weeks.js +95 -0
  24. package/lib/module/components/planner/planner-weeks.js.map +1 -0
  25. package/lib/module/components/select/index.js +127 -23
  26. package/lib/module/components/select/index.js.map +1 -1
  27. package/lib/module/components/slide-button/index.js +610 -0
  28. package/lib/module/components/slide-button/index.js.map +1 -0
  29. package/lib/module/components/slide-button/slide-button-track.js +98 -0
  30. package/lib/module/components/slide-button/slide-button-track.js.map +1 -0
  31. package/lib/module/icons/index.js +553 -1934
  32. package/lib/module/icons/index.js.map +1 -1
  33. package/lib/module/index.js +3 -1
  34. package/lib/module/index.js.map +1 -1
  35. package/lib/module/primitives/portal.js +15 -1
  36. package/lib/module/primitives/portal.js.map +1 -1
  37. package/lib/module/utils/date.js +10 -3
  38. package/lib/module/utils/date.js.map +1 -1
  39. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  40. package/lib/typescript/src/components/button/index.d.ts +12 -0
  41. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/button-group/index.d.ts +9 -0
  43. package/lib/typescript/src/components/button-group/index.d.ts.map +1 -1
  44. package/lib/typescript/src/components/image-generation/dot-field.d.ts +61 -0
  45. package/lib/typescript/src/components/image-generation/dot-field.d.ts.map +1 -0
  46. package/lib/typescript/src/components/image-generation/index.d.ts +126 -0
  47. package/lib/typescript/src/components/image-generation/index.d.ts.map +1 -0
  48. package/lib/typescript/src/components/kpi/index.d.ts.map +1 -1
  49. package/lib/typescript/src/components/menu/index.d.ts.map +1 -1
  50. package/lib/typescript/src/components/panelside/index.d.ts +194 -10
  51. package/lib/typescript/src/components/panelside/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/planner/index.d.ts +65 -4
  53. package/lib/typescript/src/components/planner/index.d.ts.map +1 -1
  54. package/lib/typescript/src/components/planner/planner-entries.d.ts +18 -0
  55. package/lib/typescript/src/components/planner/planner-entries.d.ts.map +1 -1
  56. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts +7 -1
  57. package/lib/typescript/src/components/planner/planner-grid-navigation.d.ts.map +1 -1
  58. package/lib/typescript/src/components/planner/planner-weeks.d.ts +50 -0
  59. package/lib/typescript/src/components/planner/planner-weeks.d.ts.map +1 -0
  60. package/lib/typescript/src/components/select/index.d.ts +75 -2
  61. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  62. package/lib/typescript/src/components/slide-button/index.d.ts +317 -0
  63. package/lib/typescript/src/components/slide-button/index.d.ts.map +1 -0
  64. package/lib/typescript/src/components/slide-button/slide-button-track.d.ts +70 -0
  65. package/lib/typescript/src/components/slide-button/slide-button-track.d.ts.map +1 -0
  66. package/lib/typescript/src/icons/index.d.ts +136 -189
  67. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  68. package/lib/typescript/src/index.d.ts +5 -3
  69. package/lib/typescript/src/index.d.ts.map +1 -1
  70. package/lib/typescript/src/primitives/portal.d.ts.map +1 -1
  71. package/lib/typescript/src/utils/date.d.ts +1 -1
  72. package/lib/typescript/src/utils/date.d.ts.map +1 -1
  73. package/package.json +3 -2
  74. package/src/components/bottom-sheet/index.tsx +97 -17
  75. package/src/components/button/index.tsx +10 -2
  76. package/src/components/button-group/index.tsx +1 -0
  77. package/src/components/image-generation/dot-field.ts +226 -0
  78. package/src/components/image-generation/index.tsx +488 -0
  79. package/src/components/kpi/index.tsx +2 -4
  80. package/src/components/menu/index.tsx +10 -15
  81. package/src/components/panelside/index.tsx +498 -33
  82. package/src/components/planner/index.tsx +516 -38
  83. package/src/components/planner/planner-entries.ts +33 -0
  84. package/src/components/planner/planner-grid-navigation.ts +37 -2
  85. package/src/components/planner/planner-weeks.ts +90 -0
  86. package/src/components/select/index.tsx +158 -31
  87. package/src/components/slide-button/index.tsx +681 -0
  88. package/src/components/slide-button/slide-button-track.ts +100 -0
  89. package/src/icons/index.tsx +338 -1327
  90. package/src/index.ts +30 -0
  91. package/src/primitives/portal.tsx +15 -1
  92. package/src/utils/date.ts +13 -3
@@ -72,9 +72,11 @@ import {
72
72
  import {
73
73
  AccessibilityInfo,
74
74
  AppState,
75
+ FlatList,
75
76
  Platform,
76
77
  Pressable,
77
78
  View,
79
+ type ViewToken,
78
80
  type ViewProps,
79
81
  } from 'react-native';
80
82
  import { tv } from 'tailwind-variants';
@@ -111,18 +113,53 @@ import {
111
113
  } from './planner-entries';
112
114
  import { usePlannerMonthAnnouncement } from './planner-announcement';
113
115
  import { plannerGridTarget } from './planner-grid-navigation';
116
+ import { weekAnchor, weekDays, weekIndex, weekRange } from './planner-weeks';
114
117
  import {
115
118
  usePlannerMonthLifecycle,
116
119
  usePlannerSelectionLifecycle,
117
120
  } from './planner-lifecycle';
118
121
  import { millisecondsUntilNextLocalDay } from './planner-today';
119
122
 
120
- /** How many of a day's entries a cell draws before it stops and counts. */
121
- const DEFAULT_ENTRY_LIMIT = 2;
123
+ /**
124
+ * How many of a day's entries a cell draws before it stops and counts.
125
+ *
126
+ * `calendar` gets one more because a named entry is a row, and a cell tall
127
+ * enough to name one is tall enough for three; `tiles` draws a single icon at
128
+ * the size of the tile, so the limit there is the look rather than a number.
129
+ */
130
+ const DEFAULT_ENTRY_LIMIT: Record<PlannerVariant, number> = {
131
+ default: 2,
132
+ tiles: 1,
133
+ calendar: 3,
134
+ };
122
135
 
123
136
  /** The palette a category takes its dot from when it does not name a colour. */
124
137
  const PALETTE_SIZE = 5;
125
138
 
139
+ /**
140
+ * How strongly a `tiles` day takes its category's colour.
141
+ *
142
+ * Low enough that the date in the corner and the icon over it both stay
143
+ * legible, high enough that a brand colour arrives as that brand rather than as
144
+ * a grey tile with a suggestion of one.
145
+ */
146
+ const TILE_TINT_OPACITY = 0.32;
147
+
148
+ /**
149
+ * The height a `tiles` grid reserves, and what it is made of.
150
+ *
151
+ * Six weeks' worth, always — but only the weeks the month actually spans are
152
+ * drawn, and they share the reserved height out between them. A five-week
153
+ * month therefore has slightly taller tiles than a six-week one and no band of
154
+ * empty space under the last row, while the panel itself never changes size as
155
+ * you page through the year. Reserving the height and leaving the sixth row
156
+ * blank, or dropping the row and letting the panel shrink, each fix one of
157
+ * those at the cost of the other.
158
+ */
159
+ const TILE_ROW_HEIGHT = 64;
160
+ const TILE_ROW_GAP = 4;
161
+ const TILE_GRID_HEIGHT = TILE_ROW_HEIGHT * 6 + TILE_ROW_GAP * 5;
162
+
126
163
  /** Stable wrapper keeps the native method's receiver and the hook dependency steady. */
127
164
  const announceMonth = (label: string) => {
128
165
  AccessibilityInfo.announceForAccessibility(label);
@@ -136,6 +173,14 @@ const plannerVariants = tv({
136
173
  legend: 'flex-row flex-wrap items-center gap-x-4 gap-y-2 px-4 py-3',
137
174
  swatch: 'h-2 w-2 rounded-full',
138
175
  },
176
+ variants: {
177
+ /*
178
+ * The weeks share out the height instead of standing at their own. The
179
+ * weekday row keeps its natural height — it is a label, and stretching it
180
+ * only moves the letters away from the column they name.
181
+ */
182
+ fill: { true: { grid: 'flex-1', week: 'flex-1' } },
183
+ },
139
184
  });
140
185
 
141
186
  const dayVariants = tv({
@@ -160,8 +205,27 @@ const dayVariants = tv({
160
205
  number: 'text-xs leading-none',
161
206
  marker: 'absolute right-1.5 top-1.5 h-1.5 w-1.5 rounded-full',
162
207
  body: 'flex-1 flex-row items-center justify-center gap-0.5 pb-1',
208
+ /** The colour a day's entries give it, drawn under everything else. */
209
+ tint: 'absolute inset-0 rounded-xl',
210
+ /*
211
+ * The corners of a tile. These are Views rather than classes on the Text
212
+ * itself: a Text takes its typography from a class but not its position,
213
+ * so a number told to sit in a corner stayed where the layout put it —
214
+ * dead centre, under the icon.
215
+ */
216
+ corner: 'absolute bottom-1 right-1.5',
217
+ counter: 'absolute left-1.5 top-1',
218
+ /** One entry, named, in a cell with the height to name it. */
219
+ chip: 'w-full overflow-hidden rounded-sm bg-muted px-1 py-0.5',
220
+ overflow: 'leading-none',
163
221
  },
164
222
  variants: {
223
+ /*
224
+ * Cells stretch to their container rather than standing at a fixed height.
225
+ * The grid is still six weeks, so what changes is how tall a week is, not
226
+ * how many there are.
227
+ */
228
+ fill: { true: { cell: 'h-auto min-h-0 flex-1' } },
165
229
  /** Inside the month being shown, as opposed to the days either side. */
166
230
  inMonth: {
167
231
  true: { cell: 'bg-muted/40', number: 'text-foreground' },
@@ -175,6 +239,38 @@ const dayVariants = tv({
175
239
  today: { true: { cell: 'border-foreground', number: 'font-semibold' } },
176
240
  selected: { true: { cell: 'border-primary/60 bg-primary/10' } },
177
241
  disabled: { true: { cell: 'opacity-40' } },
242
+ /*
243
+ * What a cell draws. One axis, because these are three answers to the same
244
+ * question and a cell can only give one of them.
245
+ *
246
+ * Last, so a look can undo what the states above it set. `calendar` has no
247
+ * tile to ring or fill, so it has to be able to take the background and the
248
+ * border back off a cell that today or the open day just gave one to.
249
+ */
250
+ variant: {
251
+ default: {},
252
+ /*
253
+ * A tile per day, carrying the one thing on it at the size of a mark
254
+ * rather than a marker. The date moves out of the way into the corner:
255
+ * it is how you find the day you want, not what the cell is showing.
256
+ */
257
+ tiles: {
258
+ cell: 'h-full items-center justify-center px-0 pt-0',
259
+ overflow: 'leading-none',
260
+ },
261
+ /*
262
+ * Named entries stacked down an open cell. No tile, because a tile per
263
+ * day and a block per entry are two boxes saying the same thing and the
264
+ * entries are the ones carrying words — the weeks are ruled off instead,
265
+ * which separates them without enclosing anything.
266
+ */
267
+ calendar: {
268
+ cell: 'mx-0 h-full min-h-0 items-stretch gap-px rounded-none border-transparent bg-transparent px-1 pb-1 pt-1.5',
269
+ number: 'text-center',
270
+ body: 'flex-col items-stretch justify-start gap-px pb-0',
271
+ overflow: 'text-center leading-none',
272
+ },
273
+ },
178
274
  },
179
275
  compoundVariants: [
180
276
  /*
@@ -184,13 +280,23 @@ const dayVariants = tv({
184
280
  */
185
281
  { today: true, selected: true, class: { cell: 'border-foreground bg-primary/10' } },
186
282
  ],
187
- defaultVariants: { inMonth: true },
283
+ defaultVariants: { inMonth: true, variant: 'default' },
188
284
  });
189
285
 
190
286
  /* ------------------------------------------------------------------ *
191
287
  * Data
192
288
  * ------------------------------------------------------------------ */
193
289
 
290
+ /**
291
+ * What a day cell draws.
292
+ *
293
+ * `default` is a number, a marker and small icons. `tiles` gives the day over
294
+ * to one large icon tinted with its category's colour, with the date in the
295
+ * corner. `calendar` names each entry in a block under a centred date, on an
296
+ * open grid ruled off by week.
297
+ */
298
+ export type PlannerVariant = 'default' | 'tiles' | 'calendar';
299
+
194
300
  /** One thing that falls on a day. */
195
301
  export interface PlannerEntry {
196
302
  /** Stable across renders; it keys the cell's contents. */
@@ -203,6 +309,12 @@ export interface PlannerEntry {
203
309
  category?: string;
204
310
  /** Drawn in the cell — a brand mark, an avatar, a glyph. */
205
311
  icon?: ReactNode;
312
+ /**
313
+ * This entry's own colour, for a mark that belongs to the entry rather than
314
+ * to a group of them — a brand. It wins over the category's colour, and
315
+ * under `tiles` it is what tints the day.
316
+ */
317
+ color?: string;
206
318
  }
207
319
 
208
320
  /** A group of entries: the key to a colour, and a line in the legend. */
@@ -222,6 +334,8 @@ export interface PlannerCategory {
222
334
  interface PlannerContextValue {
223
335
  month: Date;
224
336
  setMonth: (month: Date) => void;
337
+ /** Moves the month without announcing it. For scrolling, which is continuous. */
338
+ syncMonth: (month: Date) => void;
225
339
  today: Date;
226
340
  selected: Date | null;
227
341
  select: (date: Date | null) => void;
@@ -231,6 +345,8 @@ interface PlannerContextValue {
231
345
  colorOf: (category: string | undefined) => string | undefined;
232
346
  summary: { total: number; categories: PlannerCountedCategory[] };
233
347
  entryLimit: number;
348
+ variant: PlannerVariant;
349
+ fill: boolean;
234
350
  weekStartsOn: number;
235
351
  grid: Date[][];
236
352
  locale: DateLocale;
@@ -325,7 +441,22 @@ export interface PlannerProps extends Omit<ViewProps, 'children'> {
325
441
  onSelectedChange?: (date: Date | null) => void;
326
442
  /** Runs before the selection moves, whether or not `Details` is present. */
327
443
  onDayPress?: (date: Date, entries: PlannerEntry[]) => void;
328
- /** How many entries a cell draws before it counts the rest. Default `2`. */
444
+ /**
445
+ * What each day draws. `tiles` is one large icon per day, tinted by its
446
+ * category; `calendar` names every entry and needs the height to do it.
447
+ */
448
+ variant?: PlannerVariant;
449
+ /**
450
+ * Stretch the grid to its container instead of standing at its own height.
451
+ * For a planner that owns a screen — the six weeks share out whatever is
452
+ * left after the header, the legend and anything below them.
453
+ */
454
+ fill?: boolean;
455
+ /**
456
+ * How many entries a cell draws before it counts the rest. Default `2`, or
457
+ * `3` under `calendar`, which has the room; `tiles` draws one whatever you
458
+ * pass.
459
+ */
329
460
  entryLimit?: number;
330
461
  /** First day of the week, 0 is Sunday. Defaults to the locale's. */
331
462
  weekStartsOn?: number | 'auto';
@@ -349,7 +480,9 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
349
480
  defaultSelected = null,
350
481
  onSelectedChange,
351
482
  onDayPress,
352
- entryLimit = DEFAULT_ENTRY_LIMIT,
483
+ variant = 'default',
484
+ fill = false,
485
+ entryLimit,
353
486
  weekStartsOn = 'auto',
354
487
  locale,
355
488
  calendar = 'gregory',
@@ -359,6 +492,7 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
359
492
  },
360
493
  ref
361
494
  ) => {
495
+ const resolvedEntryLimit = entryLimit ?? DEFAULT_ENTRY_LIMIT[variant];
362
496
  const system = resolveCalendar(calendar, locale);
363
497
  const palette = usePalette();
364
498
  const today = useToday();
@@ -385,6 +519,18 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
385
519
  monthLabel: calendarMonthLabel(month, system, locale),
386
520
  announce: announceMonth,
387
521
  });
522
+ /*
523
+ * Scrolling changes the month continuously, and announcing each one as it
524
+ * goes would talk over somebody reading the weeks. The announcement belongs
525
+ * to a deliberate move — the arrows, the Today pill — so a scroll reports
526
+ * the change and stays quiet about it.
527
+ */
528
+ const syncMonth = useCallback(
529
+ (next: Date) => {
530
+ requestMonth(next);
531
+ },
532
+ [requestMonth]
533
+ );
388
534
  const setMonth = useCallback(
389
535
  (next: Date) => {
390
536
  expectMonthAnnouncement(settleMonth(next));
@@ -449,6 +595,7 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
449
595
  () => ({
450
596
  month,
451
597
  setMonth,
598
+ syncMonth,
452
599
  today,
453
600
  selected,
454
601
  select,
@@ -457,7 +604,9 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
457
604
  categoryLabels,
458
605
  colorOf,
459
606
  summary,
460
- entryLimit,
607
+ entryLimit: resolvedEntryLimit,
608
+ variant,
609
+ fill,
461
610
  weekStartsOn: normalizedWeekStart,
462
611
  grid,
463
612
  locale,
@@ -466,9 +615,9 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
466
615
  onDayPress,
467
616
  }),
468
617
  [
469
- month, setMonth, today, selected, select, days, categories, categoryLabels,
470
- colorOf, summary, entryLimit, normalizedWeekStart, grid, locale, system,
471
- isInMonth, onDayPress,
618
+ month, setMonth, syncMonth, today, selected, select, days, categories, categoryLabels,
619
+ colorOf, summary, resolvedEntryLimit, variant, fill, normalizedWeekStart,
620
+ grid, locale, system, isInMonth, onDayPress,
472
621
  ]
473
622
  );
474
623
 
@@ -488,13 +637,16 @@ const PlannerRoot = forwardRef<View, PlannerProps>(
488
637
  {frame ? (
489
638
  // Full width by default: the grid is seven equal columns, and a
490
639
  // container that sizes to its content collapses them to the width of
491
- // a two-digit number.
492
- <Frame ref={ref} className={cn('w-full', className)} {...props}>
640
+ // a two-digit number. Under `fill` the panel has to flex too, or the
641
+ // grid inside it stretches against a container that does not.
642
+ <Frame ref={ref} className={cn('w-full', fill && 'flex-1', className)} {...props}>
493
643
  {header}
494
- <Frame.Panel dividers={false}>{body}</Frame.Panel>
644
+ <Frame.Panel dividers={false} className={fill ? 'flex-1' : undefined}>
645
+ {body}
646
+ </Frame.Panel>
495
647
  </Frame>
496
648
  ) : (
497
- <View ref={ref} className={cn('w-full', className)} {...props}>
649
+ <View ref={ref} className={cn('w-full', fill && 'flex-1', className)} {...props}>
498
650
  {header}
499
651
  {body}
500
652
  </View>
@@ -532,9 +684,15 @@ function PlannerHeader({ children }: PlannerHeaderProps) {
532
684
 
533
685
  return (
534
686
  <Frame.Header>
535
- <View className="min-w-0 flex-1 flex-row items-center gap-2">{lead}</View>
687
+ <View className="min-w-0 shrink flex-row items-center gap-2">{lead}</View>
688
+ {/*
689
+ `ml-auto` rather than a flexible lead. The nav belongs against the right
690
+ edge whatever is to its left — one short title, a title and a pill, or
691
+ nothing at all — and pushing it there with a margin says that directly
692
+ instead of leaving it to whether the thing beside it happened to grow.
693
+ */}
536
694
  {trailing.length > 0 ? (
537
- <View className="shrink-0 flex-row items-center gap-1.5">{trailing}</View>
695
+ <View className="ml-auto shrink-0 flex-row items-center gap-1.5">{trailing}</View>
538
696
  ) : null}
539
697
  </Frame.Header>
540
698
  );
@@ -686,18 +844,40 @@ const PlannerGridNavigationContext = createContext<PlannerGridNavigationContextV
686
844
  * weekday headings are hidden rather than read out 42 times over.
687
845
  */
688
846
  function PlannerGrid({ className, renderDay }: PlannerGridProps) {
689
- const { grid: weeks, weekStartsOn, locale, selected, today, isInMonth } =
847
+ const { grid: weeks, weekStartsOn, locale, selected, today, isInMonth, variant, fill } =
690
848
  usePlanner('Planner.Grid');
691
- const { grid, week: weekRow, heading } = plannerVariants();
849
+ const { grid, week: weekRow, heading } = plannerVariants({ fill });
850
+ /*
851
+ * `tiles` leaves the days either side of the month blank, so its columns are
852
+ * told apart by position alone and a single letter is enough to head them.
853
+ */
692
854
  const headings = useMemo(
693
- () => weekdayNames(locale, weekStartsOn),
694
- [locale, weekStartsOn]
855
+ () => weekdayNames(locale, weekStartsOn, variant === 'tiles' ? 'narrow' : 'short'),
856
+ [locale, weekStartsOn, variant]
857
+ );
858
+ /*
859
+ * `tiles` draws nothing for the days either side of the month, so a week made
860
+ * only of those is a band of empty space rather than a week. It is dropped,
861
+ * and the grid keeps its height by letting the weeks that remain stretch.
862
+ */
863
+ const shownWeeks = useMemo(
864
+ () => (variant === 'tiles' ? weeks.filter((week) => week.some(isInMonth)) : weeks),
865
+ [weeks, variant, isInMonth]
695
866
  );
696
- const dates = useMemo(() => weeks.flat(), [weeks]);
867
+ const dates = useMemo(() => shownWeeks.flat(), [shownWeeks]);
697
868
  const indexByDay = useMemo(
698
869
  () => new Map(dates.map((date, index) => [date.getTime(), index])),
699
870
  [dates]
700
871
  );
872
+ /*
873
+ * A blank cell has nothing to focus, so arrow keys step over it rather than
874
+ * landing on a day that is not drawn and losing focus altogether.
875
+ */
876
+ const navigable = useMemo(
877
+ () =>
878
+ variant === 'tiles' ? (index: number) => isInMonth(dates[index] ?? today) : undefined,
879
+ [variant, dates, isInMonth, today]
880
+ );
701
881
  const initialDay = selected ?? today;
702
882
  const [activeDay, setActiveDay] = useState(initialDay.getTime());
703
883
  const storedIndex = indexByDay.get(activeDay);
@@ -720,13 +900,19 @@ function PlannerGrid({ className, renderDay }: PlannerGridProps) {
720
900
  );
721
901
  const focus = useCallback(
722
902
  (index: number, event: PlannerGridKeyDownEvent) => {
723
- const target = plannerGridTarget(event.nativeEvent.key ?? '', index, dates.length);
903
+ const target = plannerGridTarget(
904
+ event.nativeEvent.key ?? '',
905
+ index,
906
+ dates.length,
907
+ 7,
908
+ navigable
909
+ );
724
910
  if (target === null) return;
725
911
  event.preventDefault();
726
912
  makeActive(target);
727
913
  refs.current.get(target)?.focus();
728
914
  },
729
- [dates.length, makeActive]
915
+ [dates.length, makeActive, navigable]
730
916
  );
731
917
  const navigation = useMemo<PlannerGridNavigationContextValue>(
732
918
  () => ({ activeIndex, indexByDay, register, focus, makeActive }),
@@ -735,21 +921,29 @@ function PlannerGrid({ className, renderDay }: PlannerGridProps) {
735
921
 
736
922
  return (
737
923
  <PlannerGridNavigationContext.Provider value={renderDay ? null : navigation}>
738
- <View className={grid({ className })}>
924
+ <View
925
+ className={grid({ className })}
926
+ style={variant === 'tiles' && !fill ? { height: TILE_GRID_HEIGHT } : undefined}
927
+ >
739
928
  <View
740
929
  className={weekRow()}
741
930
  accessibilityElementsHidden
742
931
  importantForAccessibility="no-hide-descendants"
743
932
  >
744
- {headings.map((label) => (
745
- <Text key={label} size="xs" muted className={heading()}>
933
+ {/*
934
+ Keyed by position rather than by the word: narrow names repeat in
935
+ plenty of locales — English has T twice and S twice — and a column
936
+ is identified by where it is, not by the letter over it.
937
+ */}
938
+ {headings.map((label, position) => (
939
+ <Text key={position} size="xs" muted className={heading()}>
746
940
  {label.toUpperCase()}
747
941
  </Text>
748
942
  ))}
749
943
  </View>
750
944
 
751
- {weeks.map((week, index) => (
752
- <View key={index} className={weekRow()}>
945
+ {shownWeeks.map((week, index) => (
946
+ <View key={index} className={cn(weekRow(), variant === 'tiles' && 'flex-1')}>
753
947
  {week.map((date) => (
754
948
  <PlannerDay key={date.getTime()} date={date} renderDay={renderDay} />
755
949
  ))}
@@ -770,7 +964,7 @@ export interface PlannerDayProps {
770
964
  function PlannerDay({ date, renderDay }: PlannerDayProps) {
771
965
  const {
772
966
  days, today, selected, select, colorOf, categoryLabels,
773
- entryLimit, locale, system, isInMonth, onDayPress,
967
+ entryLimit, variant, fill, locale, system, isInMonth, onDayPress,
774
968
  } = usePlanner('Planner.Day');
775
969
  const navigation = useContext(PlannerGridNavigationContext);
776
970
  const registerGridCell = navigation?.register;
@@ -783,7 +977,14 @@ function PlannerDay({ date, renderDay }: PlannerDayProps) {
783
977
  const isSelected = selected ? isSameDay(date, selected) : false;
784
978
  const { shown, overflow } = visibleEntries(entries, entryLimit);
785
979
  const drawable = shown.filter((entry) => entry.icon);
786
- const styles = dayVariants({ inMonth, today: isToday, selected: isSelected });
980
+ const styles = dayVariants({ variant, fill, inMonth, today: isToday, selected: isSelected });
981
+ /*
982
+ * The colour the day's entries give it. The first entry naming a category the
983
+ * planner knows wins, and an uncategorised day gets nothing — the tint is a
984
+ * signal about what is on the day, so a day with no answer stays plain rather
985
+ * than being coloured for the sake of it.
986
+ */
987
+ const tint = markerColor(entries, colorOf);
787
988
 
788
989
  const label = dayAccessibilityLabel(
789
990
  calendarLongDate(date, system, locale),
@@ -791,9 +992,15 @@ function PlannerDay({ date, renderDay }: PlannerDayProps) {
791
992
  categoryLabels
792
993
  );
793
994
 
995
+ /*
996
+ * Pressing the open day again closes it. Without this the mark is a one-way
997
+ * door: a planner with no `Details` bound has nothing to dismiss, so the day
998
+ * you pressed stays ringed until you press a different one and there is no
999
+ * way back to none selected.
1000
+ */
794
1001
  const press = () => {
795
1002
  onDayPress?.(date, entries);
796
- select(date);
1003
+ select(isSelected ? null : date);
797
1004
  };
798
1005
  const gridIndex = navigation?.indexByDay.get(date.getTime());
799
1006
  const setRef = useCallback(
@@ -829,6 +1036,23 @@ function PlannerDay({ date, renderDay }: PlannerDayProps) {
829
1036
  );
830
1037
  }
831
1038
 
1039
+ /*
1040
+ * `tiles` leaves the days either side of the month out altogether. The row
1041
+ * still holds their column, so the grid is six weeks whatever month it is
1042
+ * showing, but there is no tile, no number and nothing to press or to read
1043
+ * out: a blank is the answer, and drawing a faded one only invites the press
1044
+ * it is going to ignore.
1045
+ */
1046
+ if (variant === 'tiles' && !inMonth) {
1047
+ return (
1048
+ <View
1049
+ className={cn('mx-0.5 flex-1', fill ? 'min-h-0' : 'h-16')}
1050
+ accessibilityElementsHidden
1051
+ importantForAccessibility="no-hide-descendants"
1052
+ />
1053
+ );
1054
+ }
1055
+
832
1056
  return (
833
1057
  <Pressable
834
1058
  {...(webGridProps as ViewProps)}
@@ -839,10 +1063,75 @@ function PlannerDay({ date, renderDay }: PlannerDayProps) {
839
1063
  accessibilityState={{ selected: isSelected }}
840
1064
  className={styles.cell()}
841
1065
  >
842
- <Text className={styles.number()}>{calendarDayNumber(date, system, locale)}</Text>
1066
+ {/*
1067
+ The tint sits under the content rather than on the cell itself: the
1068
+ colour arrives as a runtime string from the theme, so it cannot go
1069
+ through a class with an opacity on it, and painting the cell at full
1070
+ strength would take the number down with it.
1071
+ */}
1072
+ {variant === 'tiles' && tint ? (
1073
+ <View
1074
+ pointerEvents="none"
1075
+ className={styles.tint()}
1076
+ style={{ backgroundColor: tint, opacity: TILE_TINT_OPACITY }}
1077
+ />
1078
+ ) : null}
1079
+
1080
+ {variant === 'tiles' ? (
1081
+ <View className={styles.corner()} pointerEvents="none">
1082
+ <Text size="xs" className={styles.number()}>
1083
+ {calendarDayNumber(date, system, locale)}
1084
+ </Text>
1085
+ </View>
1086
+ ) : variant === 'calendar' ? (
1087
+ /*
1088
+ * A disc around the date rather than a ring around the cell. There is
1089
+ * no tile here to ring, and the date is the only thing in the cell that
1090
+ * is always present — so it is the only place a mark can go and be
1091
+ * found in the same spot on every day of the month.
1092
+ */
1093
+ <View
1094
+ className={cn(
1095
+ 'h-6 w-6 items-center justify-center self-center rounded-full',
1096
+ isToday && 'bg-primary'
1097
+ )}
1098
+ >
1099
+ <Text
1100
+ size="xs"
1101
+ className={cn(styles.number(), isToday && 'text-primary-foreground')}
1102
+ >
1103
+ {calendarDayNumber(date, system, locale)}
1104
+ </Text>
1105
+ </View>
1106
+ ) : (
1107
+ <Text className={styles.number()}>{calendarDayNumber(date, system, locale)}</Text>
1108
+ )}
1109
+
1110
+ {variant === 'default' && entries.length > 0 ? (
1111
+ <View className={styles.marker()} style={{ backgroundColor: tint }} />
1112
+ ) : null}
843
1113
 
844
- {entries.length > 0 ? (
845
- <View className={styles.marker()} style={{ backgroundColor: markerColor(entries, colorOf) }} />
1114
+ {variant === 'calendar' ? (
1115
+ <View className={styles.body()}>
1116
+ {shown.map((entry) => (
1117
+ <PlannerChip
1118
+ key={entry.id}
1119
+ entry={entry}
1120
+ color={entry.color ?? colorOf(entry.category)}
1121
+ />
1122
+ ))}
1123
+ {/*
1124
+ Three dots, not a count. The cell has already run out of room for
1125
+ the entries themselves, and "+2 more" spends a whole row of that
1126
+ room on a number nobody acts on — the dots say there is more and
1127
+ leave the row for an entry.
1128
+ */}
1129
+ {overflow > 0 ? (
1130
+ <Text size="xs" muted className={styles.overflow()} numberOfLines={1}>
1131
+ •••
1132
+ </Text>
1133
+ ) : null}
1134
+ </View>
846
1135
  ) : null}
847
1136
 
848
1137
  {/*
@@ -851,13 +1140,21 @@ function PlannerDay({ date, renderDay }: PlannerDayProps) {
851
1140
  "something is here" with its marker; adding a bare "+3" under an empty
852
1141
  row says it twice, in a way that looks like a stray number.
853
1142
  */}
854
- {drawable.length > 0 ? (
1143
+ {variant === 'tiles' && overflow > 0 ? (
1144
+ <View className={styles.counter()} pointerEvents="none">
1145
+ <Text size="xs" muted className={styles.overflow()}>
1146
+ +{overflow}
1147
+ </Text>
1148
+ </View>
1149
+ ) : null}
1150
+
1151
+ {variant !== 'calendar' && drawable.length > 0 ? (
855
1152
  <View className={styles.body()}>
856
1153
  {drawable.map((entry) => (
857
1154
  <View key={entry.id}>{entry.icon}</View>
858
1155
  ))}
859
- {overflow > 0 ? (
860
- <Text size="xs" muted className="leading-none">
1156
+ {variant !== 'tiles' && overflow > 0 ? (
1157
+ <Text size="xs" muted className={styles.overflow()}>
861
1158
  +{overflow}
862
1159
  </Text>
863
1160
  ) : null}
@@ -866,7 +1163,31 @@ function PlannerDay({ date, renderDay }: PlannerDayProps) {
866
1163
  </Pressable>
867
1164
  );
868
1165
  }
869
- PlannerDay.displayName = 'Planner.Day';
1166
+
1167
+ /**
1168
+ * One named entry inside a `calendar` cell.
1169
+ *
1170
+ * A solid block of the entry's colour with light text on it. That is a real
1171
+ * constraint rather than a free choice: the colour is the caller's, so a pale
1172
+ * one will not carry white well — but a wash with the label in the foreground
1173
+ * colour makes every entry the same weight and loses the one thing the colour
1174
+ * was for, which is telling them apart down a column at a glance.
1175
+ */
1176
+ function PlannerChip({ entry, color }: { entry: PlannerEntry; color?: string }) {
1177
+ const { chip } = dayVariants();
1178
+ return (
1179
+ <View className={chip()} style={color ? { backgroundColor: color } : undefined}>
1180
+ <Text
1181
+ size="xs"
1182
+ numberOfLines={1}
1183
+ className={cn('leading-none', !color && 'text-foreground')}
1184
+ style={color ? { color: '#ffffff' } : undefined}
1185
+ >
1186
+ {entry.label}
1187
+ </Text>
1188
+ </View>
1189
+ );
1190
+ }
870
1191
 
871
1192
  /**
872
1193
  * The dot's colour: the first entry that names a category the planner knows.
@@ -880,7 +1201,9 @@ function markerColor(
880
1201
  colorOf: (category: string | undefined) => string | undefined
881
1202
  ): string | undefined {
882
1203
  for (const entry of entries) {
883
- const color = colorOf(entry.category);
1204
+ // The entry's own colour first: a brand belongs to the thing, not to the
1205
+ // group it was filed under.
1206
+ const color = entry.color ?? colorOf(entry.category);
884
1207
  if (color) return color;
885
1208
  }
886
1209
  return undefined;
@@ -1013,6 +1336,160 @@ function PlannerDetails({ className, title, description, children }: PlannerDeta
1013
1336
  }
1014
1337
  PlannerDetails.displayName = 'Planner.Details';
1015
1338
 
1339
+ /* ------------------------------------------------------------------ *
1340
+ * Scroller
1341
+ * ------------------------------------------------------------------ */
1342
+
1343
+ /** How many weeks either side of the opening month a scroller reaches by default. */
1344
+ const DEFAULT_SCROLLER_WEEKS = 53;
1345
+
1346
+ /** The height of one week row, and enough of it for a date and three entries. */
1347
+ const DEFAULT_WEEK_HEIGHT = 96;
1348
+
1349
+ export interface PlannerScrollerProps {
1350
+ className?: string;
1351
+ /**
1352
+ * How many weeks either side of the opening month can be reached. Default
1353
+ * `53`, about a year each way.
1354
+ */
1355
+ weeks?: number;
1356
+ /** The height of one week row. Default `96`. */
1357
+ rowHeight?: number;
1358
+ /** Draws a cell yourself. It is handed the day and what falls on it. */
1359
+ renderDay?: PlannerDayRenderer;
1360
+ }
1361
+
1362
+ /**
1363
+ * The weeks of the year, scrolled through rather than paged.
1364
+ *
1365
+ * A month grid answers "what does this month look like"; a scroller answers
1366
+ * "what is coming", which does not stop at the end of a month. The week
1367
+ * straddling the boundary is drawn once, in one piece, instead of appearing cut
1368
+ * in half at the bottom of one page and again at the top of the next.
1369
+ *
1370
+ * The range is bounded rather than endless. A scroller has to know its own
1371
+ * height to place a scrollbar and to jump to a month without rendering its way
1372
+ * there, and neither is possible over a list with no end.
1373
+ *
1374
+ * The month in the header follows the scroll: whichever month holds most of the
1375
+ * first week on screen is the one named, and the days either side of it grey
1376
+ * out. `Planner.Nav` and `Planner.Today` still work — they scroll the list
1377
+ * rather than replacing what is in it.
1378
+ */
1379
+ function PlannerScroller({
1380
+ className,
1381
+ weeks: span = DEFAULT_SCROLLER_WEEKS,
1382
+ rowHeight = DEFAULT_WEEK_HEIGHT,
1383
+ renderDay,
1384
+ }: PlannerScrollerProps) {
1385
+ const { month, syncMonth, weekStartsOn, locale, variant, system } =
1386
+ usePlanner('Planner.Scroller');
1387
+ const { week: weekRow, heading } = plannerVariants();
1388
+ const list = useRef<FlatList<Date>>(null);
1389
+
1390
+ /*
1391
+ * The range is anchored where the planner opened and never rebuilt. Rebuilding
1392
+ * it around the current month would move every row's index under the scroll
1393
+ * position each time the month changed, which is the one thing a list being
1394
+ * scrolled must not do.
1395
+ */
1396
+ const [anchor] = useState(() => month);
1397
+ const range = useMemo(
1398
+ () => weekRange(anchor, weekStartsOn, span, span),
1399
+ [anchor, weekStartsOn, span]
1400
+ );
1401
+
1402
+ const headings = useMemo(
1403
+ () => weekdayNames(locale, weekStartsOn, 'narrow'),
1404
+ [locale, weekStartsOn]
1405
+ );
1406
+
1407
+ /*
1408
+ * Which month the scroll is showing, reported up but not acted on here. The
1409
+ * ref is what stops a scroll that stays inside one month from setting it over
1410
+ * and over, and what stops the scroll this component performs in answer to a
1411
+ * month change from reporting that same month straight back.
1412
+ */
1413
+ const reported = useRef(month.getTime());
1414
+ const onViewable = useRef(({ viewableItems }: { viewableItems: ViewToken[] }) => {
1415
+ const first = viewableItems[0]?.item as Date | undefined;
1416
+ if (!first) return;
1417
+ const next = startOfCalendarMonth(weekAnchor(first), system, locale);
1418
+ if (next.getTime() === reported.current) return;
1419
+ reported.current = next.getTime();
1420
+ syncMonth(next);
1421
+ });
1422
+
1423
+ /*
1424
+ * A month arriving from outside — the arrows, the Today pill, a controlled
1425
+ * prop — scrolls the list to it. A month the scroll itself just reported is
1426
+ * already there, and scrolling to it again would fight the finger.
1427
+ */
1428
+ useEffect(() => {
1429
+ if (month.getTime() === reported.current) return;
1430
+ reported.current = month.getTime();
1431
+ const index = weekIndex(range, month, weekStartsOn);
1432
+ if (index >= 0) list.current?.scrollToIndex({ index, animated: true });
1433
+ }, [month, range, weekStartsOn]);
1434
+
1435
+ const initialIndex = Math.max(0, weekIndex(range, month, weekStartsOn));
1436
+
1437
+ return (
1438
+ <View className={cn('flex-1', className)}>
1439
+ <View
1440
+ className={cn(weekRow(), 'px-1 pb-1')}
1441
+ accessibilityElementsHidden
1442
+ importantForAccessibility="no-hide-descendants"
1443
+ >
1444
+ {headings.map((label, position) => (
1445
+ <Text key={position} size="xs" muted className={heading()}>
1446
+ {label.toUpperCase()}
1447
+ </Text>
1448
+ ))}
1449
+ </View>
1450
+
1451
+ <FlatList
1452
+ ref={list}
1453
+ data={range}
1454
+ keyExtractor={(weekStart) => String(weekStart.getTime())}
1455
+ initialScrollIndex={initialIndex}
1456
+ // Every row is the same height, so the list can place any of them
1457
+ // without measuring — which is what makes jumping to a month cheap.
1458
+ getItemLayout={(_unused, index) => ({
1459
+ length: rowHeight,
1460
+ offset: rowHeight * index,
1461
+ index,
1462
+ })}
1463
+ onViewableItemsChanged={onViewable.current}
1464
+ viewabilityConfig={VIEWABILITY}
1465
+ showsVerticalScrollIndicator={false}
1466
+ renderItem={({ item }) => (
1467
+ <View
1468
+ className={cn(
1469
+ weekRow(),
1470
+ 'border-t border-border px-1',
1471
+ variant === 'calendar' && 'items-stretch'
1472
+ )}
1473
+ style={{ height: rowHeight }}
1474
+ >
1475
+ {weekDays(item).map((date) => (
1476
+ <PlannerDay key={date.getTime()} date={date} renderDay={renderDay} />
1477
+ ))}
1478
+ </View>
1479
+ )}
1480
+ />
1481
+ </View>
1482
+ );
1483
+ }
1484
+ PlannerScroller.displayName = 'Planner.Scroller';
1485
+
1486
+ /*
1487
+ * The first row more than half on screen is the one that names the month. A
1488
+ * lower threshold hands the month over while the week is still mostly below the
1489
+ * fold, which reads as the header changing early.
1490
+ */
1491
+ const VIEWABILITY = { itemVisiblePercentThreshold: 60 };
1492
+
1016
1493
  export const Planner = Object.assign(PlannerRoot, {
1017
1494
  Header: PlannerHeader,
1018
1495
  Title: PlannerTitle,
@@ -1020,6 +1497,7 @@ export const Planner = Object.assign(PlannerRoot, {
1020
1497
  Nav: PlannerNav,
1021
1498
  Action: PlannerAction,
1022
1499
  Grid: PlannerGrid,
1500
+ Scroller: PlannerScroller,
1023
1501
  Day: PlannerDay,
1024
1502
  Legend: PlannerLegend,
1025
1503
  Summary: PlannerSummary,