panelui-native 0.28.1 → 0.30.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 (82) hide show
  1. package/README.md +18 -1
  2. package/lib/module/components/calendar/index.js +350 -115
  3. package/lib/module/components/calendar/index.js.map +1 -1
  4. package/lib/module/components/code-block/highlight.js +363 -0
  5. package/lib/module/components/code-block/highlight.js.map +1 -0
  6. package/lib/module/components/code-block/index.js +313 -0
  7. package/lib/module/components/code-block/index.js.map +1 -0
  8. package/lib/module/components/date-picker/index.js +8 -1
  9. package/lib/module/components/date-picker/index.js.map +1 -1
  10. package/lib/module/components/menu/index.js +714 -0
  11. package/lib/module/components/menu/index.js.map +1 -0
  12. package/lib/module/components/plan/index.js +296 -0
  13. package/lib/module/components/plan/index.js.map +1 -0
  14. package/lib/module/components/popover/index.js +35 -4
  15. package/lib/module/components/popover/index.js.map +1 -1
  16. package/lib/module/components/radio-group/index.js +13 -8
  17. package/lib/module/components/radio-group/index.js.map +1 -1
  18. package/lib/module/components/reasoning/index.js +244 -0
  19. package/lib/module/components/reasoning/index.js.map +1 -0
  20. package/lib/module/components/sources/index.js +221 -0
  21. package/lib/module/components/sources/index.js.map +1 -0
  22. package/lib/module/components/task/index.js +300 -0
  23. package/lib/module/components/task/index.js.map +1 -0
  24. package/lib/module/components/textarea/index.js +295 -0
  25. package/lib/module/components/textarea/index.js.map +1 -0
  26. package/lib/module/components/tooltip/index.js +179 -23
  27. package/lib/module/components/tooltip/index.js.map +1 -1
  28. package/lib/module/icons/index.js +139 -0
  29. package/lib/module/icons/index.js.map +1 -1
  30. package/lib/module/index.js +8 -0
  31. package/lib/module/index.js.map +1 -1
  32. package/lib/module/primitives/collapse.js +112 -0
  33. package/lib/module/primitives/collapse.js.map +1 -0
  34. package/lib/typescript/src/components/calendar/index.d.ts +68 -3
  35. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
  36. package/lib/typescript/src/components/code-block/highlight.d.ts +39 -0
  37. package/lib/typescript/src/components/code-block/highlight.d.ts.map +1 -0
  38. package/lib/typescript/src/components/code-block/index.d.ts +113 -0
  39. package/lib/typescript/src/components/code-block/index.d.ts.map +1 -0
  40. package/lib/typescript/src/components/date-picker/index.d.ts +7 -1
  41. package/lib/typescript/src/components/date-picker/index.d.ts.map +1 -1
  42. package/lib/typescript/src/components/menu/index.d.ts +261 -0
  43. package/lib/typescript/src/components/menu/index.d.ts.map +1 -0
  44. package/lib/typescript/src/components/plan/index.d.ts +127 -0
  45. package/lib/typescript/src/components/plan/index.d.ts.map +1 -0
  46. package/lib/typescript/src/components/popover/index.d.ts +35 -1
  47. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  48. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  49. package/lib/typescript/src/components/reasoning/index.d.ts +97 -0
  50. package/lib/typescript/src/components/reasoning/index.d.ts.map +1 -0
  51. package/lib/typescript/src/components/sources/index.d.ts +85 -0
  52. package/lib/typescript/src/components/sources/index.d.ts.map +1 -0
  53. package/lib/typescript/src/components/task/index.d.ts +103 -0
  54. package/lib/typescript/src/components/task/index.d.ts.map +1 -0
  55. package/lib/typescript/src/components/textarea/index.d.ts +175 -0
  56. package/lib/typescript/src/components/textarea/index.d.ts.map +1 -0
  57. package/lib/typescript/src/components/tooltip/index.d.ts +63 -8
  58. package/lib/typescript/src/components/tooltip/index.d.ts.map +1 -1
  59. package/lib/typescript/src/icons/index.d.ts +14 -0
  60. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  61. package/lib/typescript/src/index.d.ts +10 -2
  62. package/lib/typescript/src/index.d.ts.map +1 -1
  63. package/lib/typescript/src/primitives/collapse.d.ts +42 -0
  64. package/lib/typescript/src/primitives/collapse.d.ts.map +1 -0
  65. package/package.json +1 -1
  66. package/src/components/calendar/index.tsx +389 -122
  67. package/src/components/code-block/highlight.ts +278 -0
  68. package/src/components/code-block/index.tsx +341 -0
  69. package/src/components/date-picker/index.tsx +19 -1
  70. package/src/components/menu/index.tsx +812 -0
  71. package/src/components/plan/index.tsx +325 -0
  72. package/src/components/popover/index.tsx +69 -2
  73. package/src/components/radio-group/index.tsx +11 -2
  74. package/src/components/reasoning/index.tsx +297 -0
  75. package/src/components/sources/index.tsx +278 -0
  76. package/src/components/task/index.tsx +329 -0
  77. package/src/components/textarea/index.tsx +381 -0
  78. package/src/components/tooltip/index.tsx +203 -20
  79. package/src/icons/index.tsx +102 -0
  80. package/src/index.ts +71 -0
  81. package/src/primitives/collapse.tsx +128 -0
  82. package/theme.css +112 -0
@@ -15,31 +15,46 @@
15
15
  * between months. So the grid is six rows always, and the spare cells hold the
16
16
  * neighbouring months' days.
17
17
  *
18
- * ## Why a range is drawn in three pieces
18
+ * ## How a range is drawn
19
19
  *
20
20
  * On the web the band under a selected range is one background with the ends
21
21
  * rounded by `:first-child` and `:last-child`. There are no pseudo-classes
22
- * here, so each day works out for itself whether it is a start, a middle or an
23
- * end — and, separately, whether it is at the edge of its own row, because a
24
- * range that runs over a weekend has to close off on Saturday and open again
25
- * on Sunday rather than trailing into the gap.
22
+ * here, so each cell draws its own piece of the band as a view *behind* the
23
+ * number, and works out for itself where that piece has to stop.
26
24
  *
27
- * Every cell therefore draws its band as a view *behind* the number, sized and
28
- * rounded from those flags, rather than as a background on the cell itself.
25
+ * Two questions, and they are not the same one:
26
+ *
27
+ * - **How wide.** A day between the two ends fills its cell, so the pieces meet
28
+ * and the band reads as continuous. An end fills half its cell, from the
29
+ * centre outwards towards the middle of the range — the half that is not
30
+ * covered by the disc, so the disc appears to sit *on* the band rather than
31
+ * beside it. That is the join the old three-piece version never made, which
32
+ * is why a range used to read as two discs with a gap and a stripe.
33
+ * - **Where it is rounded.** Only where it genuinely stops: at an end of the
34
+ * range, or at the edge of a row, because a range running over a weekend has
35
+ * to close off on Saturday and open again on Sunday rather than trailing into
36
+ * the gap between the two.
29
37
  */
30
38
  import {
31
39
  createContext,
32
40
  useCallback,
33
41
  useContext,
34
42
  useMemo,
43
+ useRef,
35
44
  useState,
36
- type ReactNode,
37
45
  } from 'react';
38
- import { Pressable, View, type ViewProps } from 'react-native';
46
+ import { Pressable, ScrollView, View, type ViewProps } from 'react-native';
39
47
  import { tv } from 'tailwind-variants';
40
- import { ChevronLeftIcon, ChevronRightIcon } from '../../icons';
48
+ import { useCSSVariable } from 'uniwind';
49
+ import {
50
+ CheckIcon,
51
+ ChevronDownIcon,
52
+ ChevronLeftIcon,
53
+ ChevronRightIcon,
54
+ } from '../../icons';
41
55
  import { Text } from '../../primitives/text';
42
56
  import { cn } from '../../utils/cn';
57
+ import { Popover } from '../popover';
43
58
  import {
44
59
  addCalendarMonths,
45
60
  addDays,
@@ -90,24 +105,56 @@ export type CalendarDisabled =
90
105
  | { from: Date; to: Date }
91
106
  | ((date: Date) => boolean);
92
107
 
108
+ /**
109
+ * The corner radius of a day, and so of the band that runs between days.
110
+ *
111
+ * A number rather than a class because the band's four corners are set
112
+ * independently from the flags below, which no single `rounded-*` can express.
113
+ */
114
+ const DAY_RADIUS = 8;
115
+
116
+ const calendarVariants = tv({
117
+ slots: {
118
+ root: 'gap-3',
119
+ header: 'h-10 flex-row items-center justify-between gap-2',
120
+ nav: 'h-8 w-8 items-center justify-center rounded-full',
121
+ caption: 'flex-1 flex-row items-center justify-center gap-1',
122
+ },
123
+ variants: {
124
+ /** Draw the calendar's own panel, for one standing on the page. */
125
+ bordered: {
126
+ true: { root: 'rounded-3xl border border-border bg-card p-3' },
127
+ },
128
+ },
129
+ defaultVariants: {
130
+ bordered: true,
131
+ },
132
+ });
133
+
93
134
  const dayVariants = tv({
94
135
  slots: {
95
- cell: 'flex-1 items-center justify-center',
136
+ cell: 'h-10 flex-1 items-center justify-center',
96
137
  /** The range band, drawn behind the number so it can run cell to cell. */
97
- band: 'absolute inset-y-1',
98
- /** The circle on a selected day, or the ring on today. */
99
- disc: 'h-9 w-9 items-center justify-center rounded-full',
100
- label: '',
138
+ band: 'absolute inset-y-0.5 bg-accent',
139
+ /**
140
+ * The fill on a selected day, or the ring on today. Square-ish rather than
141
+ * round: a circle has one point of contact with the band beside it, so a
142
+ * range reads as beads on a string. A rounded rectangle meets the band
143
+ * along its whole edge and the two become one shape.
144
+ */
145
+ disc: 'h-9 w-9 items-center justify-center rounded-lg',
146
+ label: 'text-sm',
101
147
  },
102
148
  variants: {
103
149
  selected: { true: { disc: 'bg-primary', label: 'text-primary-foreground' } },
104
150
  today: { true: { disc: 'border border-primary', label: 'text-primary' } },
105
151
  outside: { true: { label: 'text-muted-foreground/40' } },
106
152
  disabled: { true: { label: 'text-muted-foreground/30' } },
107
- inRange: { true: { band: 'bg-accent' } },
153
+ /** A ruled-out day inside a range still sits on it, but faintly. */
154
+ fadedBand: { true: { band: 'opacity-40' } },
108
155
  },
109
156
  compoundVariants: [
110
- // A selected day inside a range keeps the solid disc; the band passes
157
+ // A selected day inside a range keeps the solid fill; the band passes
111
158
  // under it rather than the disc losing to it.
112
159
  { selected: true, today: true, class: { label: 'text-primary-foreground' } },
113
160
  ],
@@ -121,6 +168,9 @@ interface CalendarContextValue {
121
168
  system: 'gregory' | 'islamic';
122
169
  minDate?: Date;
123
170
  maxDate?: Date;
171
+ /** Bounds for the caption's pickers, which are not the selectable bounds. */
172
+ startMonth?: Date;
173
+ endMonth?: Date;
124
174
  captionLayout: CalendarCaptionLayout;
125
175
  }
126
176
 
@@ -141,8 +191,14 @@ function isDisabled(
141
191
  minDate: Date | undefined,
142
192
  maxDate: Date | undefined
143
193
  ): boolean {
144
- if (minDate && isBefore(date, minDate)) return true;
145
- if (maxDate && isAfter(date, maxDate)) return true;
194
+ /*
195
+ * Both bounds are compared at local midnight, because they are about days
196
+ * and the caller almost always hands over a moment. `minDate={new Date()}`
197
+ * means "from today", and against a raw timestamp that would rule out every
198
+ * hour of today that has already passed — which is to say, today.
199
+ */
200
+ if (minDate && isBefore(date, startOfDay(minDate))) return true;
201
+ if (maxDate && isAfter(date, startOfDay(maxDate))) return true;
146
202
  if (!disabled) return false;
147
203
  if (typeof disabled === 'function') return disabled(date);
148
204
  if (Array.isArray(disabled)) return disabled.some((day) => isSameDay(day, date));
@@ -177,10 +233,35 @@ export interface CalendarProps<Mode extends CalendarMode = 'single'>
177
233
  minDate?: Date;
178
234
  /** Latest selectable day. */
179
235
  maxDate?: Date;
236
+ /**
237
+ * Earliest month the caption's pickers offer. Defaults to `minDate`, and to
238
+ * a hundred years back when there is none.
239
+ *
240
+ * Separate from `minDate` because they answer different questions: `minDate`
241
+ * is about which days can be picked, this is about how far the month and year
242
+ * lists reach. A birthday picker wants a century of years on offer and no
243
+ * bound at all on the days inside them.
244
+ */
245
+ startMonth?: Date;
246
+ /** Latest month the caption's pickers offer. Defaults to `maxDate`, else ten years on. */
247
+ endMonth?: Date;
180
248
  /** `0` is Sunday. */
181
249
  weekStartsOn?: number;
182
250
  /** Draw the neighbouring months' days rather than leaving the cells blank. */
183
251
  showOutsideDays?: boolean;
252
+ /**
253
+ * Let a tap on a neighbouring month's day select it. Off by default: those
254
+ * cells exist to keep the grid six rows tall, and a tap on one is far more
255
+ * often a misfire than an attempt to reach into July from June. Paging to the
256
+ * month is the way to pick a day in it.
257
+ */
258
+ selectOutsideDays?: boolean;
259
+ /**
260
+ * Draw the calendar's own panel — a rounded, bordered card. On by default,
261
+ * for a calendar standing on a page. Turn it off when it is already inside
262
+ * something that draws one, which is what `DatePicker` does.
263
+ */
264
+ bordered?: boolean;
184
265
  /** BCP 47 tag for the month and weekday names. The device's own by default. */
185
266
  locale?: DateLocale;
186
267
  /**
@@ -208,8 +289,12 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
208
289
  disabled,
209
290
  minDate,
210
291
  maxDate,
292
+ startMonth,
293
+ endMonth,
211
294
  weekStartsOn = 0,
212
295
  showOutsideDays = true,
296
+ selectOutsideDays = false,
297
+ bordered = true,
213
298
  locale,
214
299
  calendar = 'gregory',
215
300
  ...props
@@ -298,16 +383,29 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
298
383
  );
299
384
 
300
385
  const context = useMemo(
301
- () => ({ month, setMonth, locale, system, minDate, maxDate, captionLayout }),
302
- [month, setMonth, locale, system, minDate, maxDate, captionLayout]
386
+ () => ({
387
+ month,
388
+ setMonth,
389
+ locale,
390
+ system,
391
+ minDate,
392
+ maxDate,
393
+ startMonth,
394
+ endMonth,
395
+ captionLayout,
396
+ }),
397
+ [month, setMonth, locale, system, minDate, maxDate, startMonth, endMonth, captionLayout]
303
398
  );
304
399
 
400
+ const { root } = calendarVariants({ bordered });
401
+ const months = Math.max(1, numberOfMonths);
402
+
305
403
  return (
306
404
  <CalendarContext.Provider value={context}>
307
- <View {...props} className={cn('gap-4', className)}>
308
- {Array.from({ length: Math.max(1, numberOfMonths) }, (_unused, offset) => (
405
+ <View {...props} className={cn(root(), className)}>
406
+ {Array.from({ length: months }, (_unused, offset) => (
309
407
  <View key={offset} className="gap-2">
310
- <CalendarCaption offset={offset} lastOffset={numberOfMonths - 1} />
408
+ <CalendarHeader offset={offset} lastOffset={months - 1} />
311
409
  <CalendarGrid
312
410
  month={addCalendarMonths(month, offset, system, locale)}
313
411
  mode={mode}
@@ -318,6 +416,7 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
318
416
  maxDate={maxDate}
319
417
  weekStartsOn={weekStartsOn}
320
418
  showOutsideDays={showOutsideDays}
419
+ selectOutsideDays={selectOutsideDays}
321
420
  locale={locale}
322
421
  system={system}
323
422
  />
@@ -333,16 +432,29 @@ CalendarRoot.displayName = 'Calendar';
333
432
  /* Caption */
334
433
  /* -------------------------------------------------------------------------- */
335
434
 
435
+ export interface CalendarHeaderProps {
436
+ /** Which month of the run this header sits over. `0` is the first. */
437
+ offset?: number;
438
+ /** The last offset in the run, so the header knows whether it owns the arrow. */
439
+ lastOffset?: number;
440
+ }
441
+
336
442
  /**
337
- * The month name and the arrows, or the month and year pickers.
443
+ * The row over a month: an arrow at each edge and the month's name between
444
+ * them.
338
445
  *
339
- * With several months shown the arrows are split across the row — back on the
340
- * first, forward on the last — because both sets on both captions would page
341
- * the whole run and give two ways to do one thing.
446
+ * The arrows are pinned to the edges of the calendar rather than packed around
447
+ * the label, so the two targets are as far apart as the panel allows and the
448
+ * name sits centred over its own grid. With several months shown they are split
449
+ * across the run — back on the first, forward on the last — because both sets
450
+ * on both headers would page the whole run and give two ways to do one thing.
451
+ * The month that owns neither still lays out a spacer, or its name would slide
452
+ * off centre.
342
453
  */
343
- function CalendarCaption({ offset, lastOffset }: { offset: number; lastOffset: number }) {
454
+ function CalendarHeader({ offset = 0, lastOffset = 0 }: CalendarHeaderProps) {
344
455
  const { month, setMonth, locale, system, minDate, maxDate, captionLayout } =
345
- useCalendar('Calendar.Caption');
456
+ useCalendar('Calendar.Header');
457
+ const { header, caption } = calendarVariants();
346
458
  const shown = addCalendarMonths(month, offset, system, locale);
347
459
 
348
460
  const canGoBack =
@@ -351,7 +463,7 @@ function CalendarCaption({ offset, lastOffset }: { offset: number; lastOffset: n
351
463
  !maxDate || isAfter(startOfCalendarMonth(maxDate, system, locale), shown);
352
464
 
353
465
  return (
354
- <View className="h-9 flex-row items-center justify-between">
466
+ <View className={header()}>
355
467
  {offset === 0 ? (
356
468
  <CalendarNav
357
469
  direction="previous"
@@ -359,14 +471,18 @@ function CalendarCaption({ offset, lastOffset }: { offset: number; lastOffset: n
359
471
  onPress={() => setMonth(addCalendarMonths(month, -1, system, locale))}
360
472
  />
361
473
  ) : (
362
- <View className="h-7 w-7" />
474
+ <View className="h-8 w-8" />
363
475
  )}
364
476
 
365
- {captionLayout === 'dropdown' ? (
366
- <CalendarDropdowns month={shown} offset={offset} />
367
- ) : (
368
- <Text weight="semibold">{calendarMonthLabel(shown, system, locale)}</Text>
369
- )}
477
+ <View className={caption()}>
478
+ {captionLayout === 'dropdown' ? (
479
+ <CalendarDropdowns month={shown} offset={offset} />
480
+ ) : (
481
+ <Text weight="semibold" className="text-base">
482
+ {calendarMonthLabel(shown, system, locale)}
483
+ </Text>
484
+ )}
485
+ </View>
370
486
 
371
487
  {offset === lastOffset ? (
372
488
  <CalendarNav
@@ -375,21 +491,28 @@ function CalendarCaption({ offset, lastOffset }: { offset: number; lastOffset: n
375
491
  onPress={() => setMonth(addCalendarMonths(month, 1, system, locale))}
376
492
  />
377
493
  ) : (
378
- <View className="h-7 w-7" />
494
+ <View className="h-8 w-8" />
379
495
  )}
380
496
  </View>
381
497
  );
382
498
  }
383
499
 
384
- function CalendarNav({
385
- direction,
386
- disabled,
387
- onPress,
388
- }: {
500
+ export interface CalendarNavProps {
389
501
  direction: 'previous' | 'next';
390
- disabled: boolean;
391
- onPress: () => void;
392
- }) {
502
+ disabled?: boolean;
503
+ onPress?: () => void;
504
+ }
505
+
506
+ /**
507
+ * One paging arrow.
508
+ *
509
+ * Borderless: two bordered squares at the ends of a bordered panel read as a
510
+ * row of boxes rather than as controls, and the chevron is legible enough on
511
+ * its own. The target is still 32px with another 8 of slop around it.
512
+ */
513
+ function CalendarNav({ direction, disabled = false, onPress }: CalendarNavProps) {
514
+ const { nav } = calendarVariants();
515
+
393
516
  return (
394
517
  <Pressable
395
518
  disabled={disabled}
@@ -398,26 +521,28 @@ function CalendarNav({
398
521
  accessibilityRole="button"
399
522
  accessibilityLabel={direction === 'next' ? 'Next month' : 'Previous month'}
400
523
  accessibilityState={{ disabled }}
401
- className={cn(
402
- 'h-7 w-7 items-center justify-center rounded-md border border-border',
403
- disabled ? 'opacity-40' : 'active:bg-accent'
404
- )}
524
+ className={cn(nav(), disabled ? 'opacity-30' : 'active:bg-accent')}
405
525
  >
406
- {direction === 'next' ? <ChevronRightIcon size={16} /> : <ChevronLeftIcon size={16} />}
526
+ {direction === 'next' ? <ChevronRightIcon size={18} /> : <ChevronLeftIcon size={18} />}
407
527
  </Pressable>
408
528
  );
409
529
  }
410
530
 
411
531
  /**
412
- * Month and year as two rows of chips rather than as native pickers.
532
+ * Month and year as chips that open a panel of options.
533
+ *
534
+ * The panel floats rather than opening in place, and the caption is the reason:
535
+ * it is a fixed-height row, so a list opened inside it has nowhere to go and
536
+ * spills over the grid below. A popover is laid out against the chip and
537
+ * clamped to the screen, which is the behaviour this needs and already has.
413
538
  *
414
- * A picker is a platform overlay, and putting one on top of a calendar that is
415
- * itself already inside a popover is two floating layers deep for choosing a
416
- * number. Chips stay on the surface the choice is being made on.
539
+ * Native pickers are still the wrong tool — a platform overlay on top of a
540
+ * calendar that is itself already in a popover is two floating layers deep for
541
+ * choosing a number, and it cannot show a Hijri year at all.
417
542
  */
418
543
  function CalendarDropdowns({ month, offset }: { month: Date; offset: number }) {
419
- const { setMonth, locale, system, minDate, maxDate } = useCalendar('Calendar.Caption');
420
- const [open, setOpen] = useState<'month' | 'year' | null>(null);
544
+ const { setMonth, locale, system, minDate, maxDate, startMonth, endMonth } =
545
+ useCalendar('Calendar.Caption');
421
546
 
422
547
  /*
423
548
  * Everything here counts in the calendar on screen, not in the platform's.
@@ -430,10 +555,26 @@ function CalendarDropdowns({ month, offset }: { month: Date; offset: number }) {
430
555
  [month, system, locale]
431
556
  );
432
557
  const current = calendarParts(month, system, locale);
558
+ /*
559
+ * `startMonth`/`endMonth` bound the lists, falling back to the selectable
560
+ * bounds and then to a century back and a decade on. They are separate props
561
+ * because the two questions are different: a birthday picker wants a hundred
562
+ * years on offer and no bound at all on the days inside them.
563
+ */
564
+ const first = startMonth ?? minDate;
565
+ const last = endMonth ?? maxDate;
433
566
  const bounds = {
434
- from: minDate ? calendarParts(minDate, system, locale).year : current.year - 100,
435
- to: maxDate ? calendarParts(maxDate, system, locale).year : current.year + 10,
567
+ from: first ? calendarParts(first, system, locale).year : current.year - 100,
568
+ to: last ? calendarParts(last, system, locale).year : current.year + 10,
436
569
  };
570
+ /*
571
+ * Oldest first, the way a year runs. The list is long — a century by default
572
+ * — so it is opened scrolled to the year already in the caption rather than
573
+ * to either end, which is what makes the natural order affordable. Counting
574
+ * backwards was a way of putting the likely years at the top of a list that
575
+ * always opened at the top; it is not needed once the list opens in the right
576
+ * place, and it made every other use of the picker read upside down.
577
+ */
437
578
  const years = Array.from(
438
579
  { length: Math.max(1, bounds.to - bounds.from + 1) },
439
580
  (_unused, index) => bounds.from + index
@@ -446,77 +587,163 @@ function CalendarDropdowns({ month, offset }: { month: Date; offset: number }) {
446
587
  */
447
588
  const choose = (months_: number) => {
448
589
  setMonth(addCalendarMonths(month, months_ - offset, system, locale));
449
- setOpen(null);
450
590
  };
451
591
 
452
592
  return (
453
- <View className="flex-1 items-center">
454
- <View className="flex-row items-center gap-1">
455
- <CaptionChip
456
- label={months[current.month - 1] ?? String(current.month)}
457
- expanded={open === 'month'}
458
- onPress={() => setOpen(open === 'month' ? null : 'month')}
459
- />
460
- <CaptionChip
461
- label={String(current.year)}
462
- expanded={open === 'year'}
463
- onPress={() => setOpen(open === 'year' ? null : 'year')}
464
- />
465
- </View>
593
+ <>
594
+ <CaptionDropdown
595
+ label={months[current.month - 1] ?? String(current.month)}
596
+ accessibilityLabel="Month"
597
+ options={months}
598
+ active={current.month - 1}
599
+ width={MONTH_LIST_WIDTH}
600
+ onSelect={(index) => choose(index - (current.month - 1))}
601
+ />
602
+ <CaptionDropdown
603
+ label={String(current.year)}
604
+ accessibilityLabel="Year"
605
+ options={years.map(String)}
606
+ active={years.indexOf(current.year)}
607
+ width={YEAR_LIST_WIDTH}
608
+ onSelect={(index) => choose((years[index]! - current.year) * 12)}
609
+ />
610
+ </>
611
+ );
612
+ }
613
+
614
+ /** Panel widths. A month name needs the room; a four-digit year does not. */
615
+ const MONTH_LIST_WIDTH = 176;
616
+ const YEAR_LIST_WIDTH = 132;
617
+
618
+ /**
619
+ * Height of one option row, and the tallest the list is allowed to be.
620
+ *
621
+ * The row height is a constant rather than a measurement because it is what
622
+ * turns "the twenty-ninth option" into a scroll offset, and that has to be
623
+ * known before the list has laid out — the whole point is that it opens in the
624
+ * right place rather than scrolling there afterwards.
625
+ */
626
+ const OPTION_HEIGHT = 40;
627
+ const LIST_MAX_HEIGHT = OPTION_HEIGHT * 6.5;
628
+
629
+ /**
630
+ * One chip and the list of options it opens.
631
+ *
632
+ * A list, not a grid of chips. Twelve months tile into a panel well enough, but
633
+ * the same treatment applied to a century of years is a wall of four-digit
634
+ * numbers with no order the eye can follow — and it opened at whichever end
635
+ * happened to be first. Rows in a column read as a list, take a checkmark, and
636
+ * can be scrolled to a particular one.
637
+ */
638
+ function CaptionDropdown({
639
+ label,
640
+ accessibilityLabel,
641
+ options,
642
+ active,
643
+ width,
644
+ onSelect,
645
+ }: {
646
+ label: string;
647
+ accessibilityLabel: string;
648
+ options: string[];
649
+ /** Index of the option currently in the caption, or -1 for none. */
650
+ active: number;
651
+ width: number;
652
+ onSelect: (index: number) => void;
653
+ }) {
654
+ const [open, setOpen] = useState(false);
655
+ const list = useRef<ScrollView>(null);
656
+ /*
657
+ * The check is given a colour rather than left to its own fallback, which is
658
+ * white — right inside a filled control, invisible on this panel.
659
+ */
660
+ const rawTick = useCSSVariable('--color-foreground');
661
+ const tick = typeof rawTick === 'string' ? rawTick : undefined;
662
+
663
+ /*
664
+ * Opened on the option already in the caption, centred in the panel rather
665
+ * than pinned to an edge so the years either side of it are in view too. A
666
+ * hundred-item list that opens at the top is showing the one year nobody came
667
+ * to pick, and asks for a flick through a century to reach the one they did.
668
+ */
669
+ const scrollToActive = useCallback(() => {
670
+ if (active < 0) return;
671
+ const centred = active * OPTION_HEIGHT - LIST_MAX_HEIGHT / 2 + OPTION_HEIGHT / 2;
672
+ list.current?.scrollTo({ y: Math.max(0, centred), animated: false });
673
+ }, [active]);
466
674
 
467
- {open ? (
468
- <View className="mt-2 max-h-40 w-full flex-row flex-wrap justify-center gap-1 rounded-lg border border-border bg-popover p-2">
469
- {(open === 'month' ? months : years.map(String)).map((option, index) => {
470
- const active =
471
- open === 'month'
472
- ? index === current.month - 1
473
- : years[index] === current.year;
675
+ return (
676
+ <Popover open={open} onOpenChange={setOpen}>
677
+ <Popover.Trigger>
678
+ <CaptionChip label={label} expanded={open} accessibilityLabel={accessibilityLabel} />
679
+ </Popover.Trigger>
680
+ <Popover.Content align="center" width={width} className="p-1.5">
681
+ <ScrollView
682
+ ref={list}
683
+ bounces={false}
684
+ showsVerticalScrollIndicator={false}
685
+ style={{ maxHeight: LIST_MAX_HEIGHT }}
686
+ // Fires once the rows exist, which is the first moment there is
687
+ // anything to scroll. Doing it on mount would scroll an empty list.
688
+ onContentSizeChange={scrollToActive}
689
+ >
690
+ {options.map((option, index) => {
691
+ const selected = index === active;
474
692
  return (
475
693
  <Pressable
476
694
  key={option}
477
- onPress={() =>
478
- choose(
479
- open === 'month'
480
- ? index - (current.month - 1)
481
- : (years[index]! - current.year) * 12
482
- )
483
- }
695
+ onPress={() => {
696
+ onSelect(index);
697
+ setOpen(false);
698
+ }}
484
699
  accessibilityRole="button"
485
- accessibilityState={{ selected: active }}
700
+ accessibilityState={{ selected }}
701
+ style={{ height: OPTION_HEIGHT }}
486
702
  className={cn(
487
- 'rounded-md px-2.5 py-1.5',
488
- active ? 'bg-primary' : 'active:bg-accent'
703
+ 'flex-row items-center justify-between gap-2 rounded-xl px-3',
704
+ selected ? 'bg-accent' : 'active:bg-accent'
489
705
  )}
490
706
  >
491
- <Text size="sm" className={active ? 'text-primary-foreground' : undefined}>
707
+ <Text size="sm" weight={selected ? 'semibold' : 'normal'}>
492
708
  {option}
493
709
  </Text>
710
+ {selected ? <CheckIcon size={16} color={tick} /> : null}
494
711
  </Pressable>
495
712
  );
496
713
  })}
497
- </View>
498
- ) : null}
499
- </View>
714
+ </ScrollView>
715
+ </Popover.Content>
716
+ </Popover>
500
717
  );
501
718
  }
502
719
 
503
720
  function CaptionChip({
504
721
  label,
505
722
  expanded,
723
+ accessibilityLabel,
506
724
  onPress,
507
725
  }: {
508
726
  label: string;
509
727
  expanded: boolean;
510
- onPress: () => void;
728
+ accessibilityLabel: string;
729
+ /** Supplied by `Popover.Trigger`, which clones this to open the panel. */
730
+ onPress?: (...args: unknown[]) => void;
511
731
  }) {
512
732
  return (
513
733
  <Pressable
514
734
  onPress={onPress}
515
735
  accessibilityRole="button"
736
+ accessibilityLabel={accessibilityLabel}
516
737
  accessibilityState={{ expanded }}
517
- className="flex-row items-center gap-1 rounded-md px-2 py-1 active:bg-accent"
738
+ className="flex-row items-center gap-1 rounded-lg px-2 py-1.5 active:bg-accent"
518
739
  >
519
740
  <Text weight="semibold">{label}</Text>
741
+ {/*
742
+ The chevron says the chip opens something. Without it a bold word in the
743
+ middle of a header is indistinguishable from the plain caption layout,
744
+ and nobody thinks to press it.
745
+ */}
746
+ <ChevronDownIcon size={14} />
520
747
  </Pressable>
521
748
  );
522
749
  }
@@ -535,6 +762,7 @@ interface GridProps {
535
762
  maxDate?: Date;
536
763
  weekStartsOn: number;
537
764
  showOutsideDays: boolean;
765
+ selectOutsideDays?: boolean;
538
766
  locale: DateLocale;
539
767
  system: 'gregory' | 'islamic';
540
768
  }
@@ -549,6 +777,7 @@ function CalendarGrid({
549
777
  maxDate,
550
778
  weekStartsOn,
551
779
  showOutsideDays,
780
+ selectOutsideDays = false,
552
781
  locale,
553
782
  system,
554
783
  }: GridProps) {
@@ -595,16 +824,38 @@ function CalendarGrid({
595
824
  return <View key={dayIndex} className="h-10 flex-1" />;
596
825
  }
597
826
 
827
+ const isStart = mode === 'range' && isSameDay(date, range?.from);
828
+ const isEnd = mode === 'range' && isSameDay(date, range?.to);
829
+
598
830
  const selected =
599
831
  mode === 'range'
600
- ? isSameDay(date, range?.from) || isSameDay(date, range?.to)
832
+ ? isStart || isEnd
601
833
  : mode === 'multiple'
602
834
  ? multiple.some((picked) => isSameDay(picked, date))
603
835
  : isSameDay(date, single);
604
836
 
605
- // Between the ends, but not an end itself — the band, not the disc.
606
- const inRange =
607
- !!range?.to && isWithin(date, range.from, range.to) && !selected;
837
+ /*
838
+ * Inclusive of both ends, unlike the old middles-only rule. An end
839
+ * has to carry its half of the band or there is a gap between the
840
+ * disc and the run beside it — which is precisely what made a range
841
+ * read as two discs with a stripe floating between them.
842
+ */
843
+ const inRange = !!range?.to && isWithin(date, range.from, range.to);
844
+
845
+ /*
846
+ * Whether the band carries on past this cell's edge into the one
847
+ * beside it. It stops at an end of the range, and at the edge of a
848
+ * row — a range over a weekend closes on Saturday and opens again
849
+ * on Sunday rather than trailing into the gap between the rows.
850
+ */
851
+ const openStart = inRange && !isStart && dayIndex > 0;
852
+ const openEnd = inRange && !isEnd && dayIndex < 6;
853
+
854
+ // A neighbouring month's day is drawn, not offered. Reaching into
855
+ // July from June is what the arrows are for.
856
+ const ruledOut =
857
+ isDisabled(date, disabled, minDate, maxDate) ||
858
+ (outside && !selectOutsideDays);
608
859
 
609
860
  return (
610
861
  <CalendarDay
@@ -613,12 +864,12 @@ function CalendarGrid({
613
864
  outside={outside}
614
865
  selected={selected}
615
866
  inRange={inRange}
867
+ rangeStart={isStart}
868
+ rangeEnd={isEnd}
616
869
  today={isSameDay(date, today)}
617
- disabled={isDisabled(date, disabled, minDate, maxDate)}
618
- // The band closes off at the edges of a row rather than
619
- // trailing into the gap between weeks.
620
- openStart={inRange && dayIndex > 0}
621
- openEnd={inRange && dayIndex < 6}
870
+ disabled={ruledOut}
871
+ openStart={openStart}
872
+ openEnd={openEnd}
622
873
  locale={locale}
623
874
  system={system}
624
875
  onPress={() => onSelect(date)}
@@ -635,10 +886,15 @@ interface DayProps {
635
886
  date: Date;
636
887
  outside: boolean;
637
888
  selected: boolean;
889
+ /** Anywhere inside the range, ends included. */
638
890
  inRange: boolean;
891
+ rangeStart?: boolean;
892
+ rangeEnd?: boolean;
639
893
  today: boolean;
640
894
  disabled: boolean;
895
+ /** The band carries on past this cell's leading edge. */
641
896
  openStart: boolean;
897
+ /** The band carries on past this cell's trailing edge. */
642
898
  openEnd: boolean;
643
899
  locale: DateLocale;
644
900
  system: 'gregory' | 'islamic';
@@ -650,6 +906,8 @@ function CalendarDay({
650
906
  outside,
651
907
  selected,
652
908
  inRange,
909
+ rangeStart = false,
910
+ rangeEnd = false,
653
911
  today,
654
912
  disabled,
655
913
  openStart,
@@ -663,9 +921,28 @@ function CalendarDay({
663
921
  today: today && !selected,
664
922
  outside,
665
923
  disabled,
666
- inRange,
924
+ fadedBand: disabled && inRange,
667
925
  });
668
926
 
927
+ /*
928
+ * An end of the range fills half its cell, from the centre outwards towards
929
+ * the rest of the range; every other day in it fills the whole cell. Half a
930
+ * cell is what puts the band underneath the disc rather than beside it — the
931
+ * disc is centred, so a band starting at the centre emerges from behind it
932
+ * and the two read as one shape. A range of one day gets both halves and so
933
+ * no band at all, which is right: there is nothing to span.
934
+ */
935
+ const bandStyle = inRange
936
+ ? {
937
+ left: rangeStart ? ('50%' as const) : 0,
938
+ right: rangeEnd ? ('50%' as const) : 0,
939
+ borderTopLeftRadius: openStart ? 0 : DAY_RADIUS,
940
+ borderBottomLeftRadius: openStart ? 0 : DAY_RADIUS,
941
+ borderTopRightRadius: openEnd ? 0 : DAY_RADIUS,
942
+ borderBottomRightRadius: openEnd ? 0 : DAY_RADIUS,
943
+ }
944
+ : null;
945
+
669
946
  return (
670
947
  <Pressable
671
948
  disabled={disabled}
@@ -673,23 +950,11 @@ function CalendarDay({
673
950
  accessibilityRole="button"
674
951
  accessibilityLabel={calendarLongDate(date, system, locale)}
675
952
  accessibilityState={{ selected, disabled }}
676
- className={cn(styles.cell(), 'h-10')}
953
+ className={styles.cell()}
677
954
  >
678
- {inRange ? (
679
- <View
680
- className={cn(styles.band())}
681
- style={{
682
- left: openStart ? 0 : '10%',
683
- right: openEnd ? 0 : '10%',
684
- borderTopLeftRadius: openStart ? 0 : 999,
685
- borderBottomLeftRadius: openStart ? 0 : 999,
686
- borderTopRightRadius: openEnd ? 0 : 999,
687
- borderBottomRightRadius: openEnd ? 0 : 999,
688
- }}
689
- />
690
- ) : null}
691
- <View className={cn(styles.disc())}>
692
- <Text size="sm" className={cn(styles.label())}>
955
+ {bandStyle ? <View className={styles.band()} style={bandStyle} /> : null}
956
+ <View className={styles.disc()}>
957
+ <Text className={styles.label()}>
693
958
  {calendarDayNumber(date, system, locale)}
694
959
  </Text>
695
960
  </View>
@@ -698,6 +963,8 @@ function CalendarDay({
698
963
  }
699
964
 
700
965
  export const Calendar = Object.assign(CalendarRoot, {
966
+ Header: CalendarHeader,
967
+ Nav: CalendarNav,
701
968
  Grid: CalendarGrid,
702
969
  Day: CalendarDay,
703
970
  });