panelui-native 0.28.1 → 0.32.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 (121) hide show
  1. package/README.md +22 -3
  2. package/lib/module/components/calendar/index.js +450 -132
  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/heatmap-chart/index.js +9 -8
  11. package/lib/module/components/heatmap-chart/index.js.map +1 -1
  12. package/lib/module/components/map/index.js +50 -4
  13. package/lib/module/components/map/index.js.map +1 -1
  14. package/lib/module/components/menu/index.js +714 -0
  15. package/lib/module/components/menu/index.js.map +1 -0
  16. package/lib/module/components/plan/index.js +582 -0
  17. package/lib/module/components/plan/index.js.map +1 -0
  18. package/lib/module/components/popover/index.js +35 -4
  19. package/lib/module/components/popover/index.js.map +1 -1
  20. package/lib/module/components/post/index.js +992 -0
  21. package/lib/module/components/post/index.js.map +1 -0
  22. package/lib/module/components/radio-group/index.js +13 -8
  23. package/lib/module/components/radio-group/index.js.map +1 -1
  24. package/lib/module/components/reasoning/index.js +244 -0
  25. package/lib/module/components/reasoning/index.js.map +1 -0
  26. package/lib/module/components/response/index.js +296 -0
  27. package/lib/module/components/response/index.js.map +1 -0
  28. package/lib/module/components/response/markdown.js +463 -0
  29. package/lib/module/components/response/markdown.js.map +1 -0
  30. package/lib/module/components/select/index.js +97 -15
  31. package/lib/module/components/select/index.js.map +1 -1
  32. package/lib/module/components/sources/index.js +221 -0
  33. package/lib/module/components/sources/index.js.map +1 -0
  34. package/lib/module/components/table/index.js +632 -0
  35. package/lib/module/components/table/index.js.map +1 -0
  36. package/lib/module/components/task/index.js +300 -0
  37. package/lib/module/components/task/index.js.map +1 -0
  38. package/lib/module/components/textarea/index.js +295 -0
  39. package/lib/module/components/textarea/index.js.map +1 -0
  40. package/lib/module/components/tooltip/index.js +179 -23
  41. package/lib/module/components/tooltip/index.js.map +1 -1
  42. package/lib/module/icons/index.js +339 -0
  43. package/lib/module/icons/index.js.map +1 -1
  44. package/lib/module/index.js +12 -1
  45. package/lib/module/index.js.map +1 -1
  46. package/lib/module/primitives/collapse.js +112 -0
  47. package/lib/module/primitives/collapse.js.map +1 -0
  48. package/lib/module/utils/date.js +129 -22
  49. package/lib/module/utils/date.js.map +1 -1
  50. package/lib/typescript/src/components/calendar/index.d.ts +79 -5
  51. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -1
  52. package/lib/typescript/src/components/code-block/highlight.d.ts +39 -0
  53. package/lib/typescript/src/components/code-block/highlight.d.ts.map +1 -0
  54. package/lib/typescript/src/components/code-block/index.d.ts +113 -0
  55. package/lib/typescript/src/components/code-block/index.d.ts.map +1 -0
  56. package/lib/typescript/src/components/date-picker/index.d.ts +7 -1
  57. package/lib/typescript/src/components/date-picker/index.d.ts.map +1 -1
  58. package/lib/typescript/src/components/heatmap-chart/index.d.ts.map +1 -1
  59. package/lib/typescript/src/components/map/index.d.ts +8 -1
  60. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  61. package/lib/typescript/src/components/menu/index.d.ts +261 -0
  62. package/lib/typescript/src/components/menu/index.d.ts.map +1 -0
  63. package/lib/typescript/src/components/plan/index.d.ts +238 -0
  64. package/lib/typescript/src/components/plan/index.d.ts.map +1 -0
  65. package/lib/typescript/src/components/popover/index.d.ts +35 -1
  66. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  67. package/lib/typescript/src/components/post/index.d.ts +358 -0
  68. package/lib/typescript/src/components/post/index.d.ts.map +1 -0
  69. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  70. package/lib/typescript/src/components/reasoning/index.d.ts +97 -0
  71. package/lib/typescript/src/components/reasoning/index.d.ts.map +1 -0
  72. package/lib/typescript/src/components/response/index.d.ts +91 -0
  73. package/lib/typescript/src/components/response/index.d.ts.map +1 -0
  74. package/lib/typescript/src/components/response/markdown.d.ts +88 -0
  75. package/lib/typescript/src/components/response/markdown.d.ts.map +1 -0
  76. package/lib/typescript/src/components/select/index.d.ts +27 -2
  77. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  78. package/lib/typescript/src/components/sources/index.d.ts +85 -0
  79. package/lib/typescript/src/components/sources/index.d.ts.map +1 -0
  80. package/lib/typescript/src/components/table/index.d.ts +274 -0
  81. package/lib/typescript/src/components/table/index.d.ts.map +1 -0
  82. package/lib/typescript/src/components/task/index.d.ts +103 -0
  83. package/lib/typescript/src/components/task/index.d.ts.map +1 -0
  84. package/lib/typescript/src/components/textarea/index.d.ts +175 -0
  85. package/lib/typescript/src/components/textarea/index.d.ts.map +1 -0
  86. package/lib/typescript/src/components/tooltip/index.d.ts +63 -8
  87. package/lib/typescript/src/components/tooltip/index.d.ts.map +1 -1
  88. package/lib/typescript/src/icons/index.d.ts +54 -1
  89. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  90. package/lib/typescript/src/index.d.ts +14 -3
  91. package/lib/typescript/src/index.d.ts.map +1 -1
  92. package/lib/typescript/src/primitives/collapse.d.ts +42 -0
  93. package/lib/typescript/src/primitives/collapse.d.ts.map +1 -0
  94. package/lib/typescript/src/utils/date.d.ts +49 -5
  95. package/lib/typescript/src/utils/date.d.ts.map +1 -1
  96. package/package.json +1 -1
  97. package/src/components/calendar/index.tsx +519 -139
  98. package/src/components/code-block/highlight.ts +278 -0
  99. package/src/components/code-block/index.tsx +341 -0
  100. package/src/components/date-picker/index.tsx +19 -1
  101. package/src/components/heatmap-chart/index.tsx +9 -9
  102. package/src/components/map/index.tsx +53 -4
  103. package/src/components/menu/index.tsx +812 -0
  104. package/src/components/plan/index.tsx +648 -0
  105. package/src/components/popover/index.tsx +69 -2
  106. package/src/components/post/index.tsx +1116 -0
  107. package/src/components/radio-group/index.tsx +11 -2
  108. package/src/components/reasoning/index.tsx +297 -0
  109. package/src/components/response/index.tsx +390 -0
  110. package/src/components/response/markdown.ts +489 -0
  111. package/src/components/select/index.tsx +132 -12
  112. package/src/components/sources/index.tsx +278 -0
  113. package/src/components/table/index.tsx +747 -0
  114. package/src/components/task/index.tsx +329 -0
  115. package/src/components/textarea/index.tsx +381 -0
  116. package/src/components/tooltip/index.tsx +203 -20
  117. package/src/icons/index.tsx +264 -1
  118. package/src/index.ts +131 -0
  119. package/src/primitives/collapse.tsx +128 -0
  120. package/src/utils/date.ts +146 -22
  121. package/theme.css +112 -0
@@ -15,44 +15,63 @@
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,
42
+ useEffect,
34
43
  useMemo,
44
+ useRef,
35
45
  useState,
36
- type ReactNode,
37
46
  } from 'react';
38
- import { Pressable, View, type ViewProps } from 'react-native';
47
+ import { AppState, Pressable, ScrollView, View, type ViewProps } from 'react-native';
39
48
  import { tv } from 'tailwind-variants';
40
- import { ChevronLeftIcon, ChevronRightIcon } from '../../icons';
49
+ import { useCSSVariable } from 'uniwind';
50
+ import {
51
+ CheckIcon,
52
+ ChevronDownIcon,
53
+ ChevronLeftIcon,
54
+ ChevronRightIcon,
55
+ } from '../../icons';
41
56
  import { Text } from '../../primitives/text';
42
57
  import { cn } from '../../utils/cn';
58
+ import { Popover } from '../popover';
43
59
  import {
44
60
  addCalendarMonths,
45
- addDays,
46
61
  calendarDayNumber,
47
62
  calendarLongDate,
48
63
  calendarMonthLabel,
49
64
  calendarMonthNames,
50
65
  calendarParts,
66
+ clampDate,
51
67
  isAfter,
52
68
  isBefore,
53
69
  isSameCalendarMonth,
54
70
  isSameDay,
55
71
  isWithin,
72
+ localeWeekStart,
73
+ monthGrid,
74
+ normalizeWeekStart,
56
75
  resolveCalendar,
57
76
  startOfCalendarMonth,
58
77
  startOfDay,
@@ -90,24 +109,56 @@ export type CalendarDisabled =
90
109
  | { from: Date; to: Date }
91
110
  | ((date: Date) => boolean);
92
111
 
112
+ /**
113
+ * The corner radius of a day, and so of the band that runs between days.
114
+ *
115
+ * A number rather than a class because the band's four corners are set
116
+ * independently from the flags below, which no single `rounded-*` can express.
117
+ */
118
+ const DAY_RADIUS = 8;
119
+
120
+ const calendarVariants = tv({
121
+ slots: {
122
+ root: 'gap-3',
123
+ header: 'h-10 flex-row items-center justify-between gap-2',
124
+ nav: 'h-8 w-8 items-center justify-center rounded-full',
125
+ caption: 'flex-1 flex-row items-center justify-center gap-1',
126
+ },
127
+ variants: {
128
+ /** Draw the calendar's own panel, for one standing on the page. */
129
+ bordered: {
130
+ true: { root: 'rounded-3xl border border-border bg-card p-3' },
131
+ },
132
+ },
133
+ defaultVariants: {
134
+ bordered: true,
135
+ },
136
+ });
137
+
93
138
  const dayVariants = tv({
94
139
  slots: {
95
- cell: 'flex-1 items-center justify-center',
140
+ cell: 'h-10 flex-1 items-center justify-center',
96
141
  /** 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: '',
142
+ band: 'absolute inset-y-0.5 bg-accent',
143
+ /**
144
+ * The fill on a selected day, or the ring on today. Square-ish rather than
145
+ * round: a circle has one point of contact with the band beside it, so a
146
+ * range reads as beads on a string. A rounded rectangle meets the band
147
+ * along its whole edge and the two become one shape.
148
+ */
149
+ disc: 'h-9 w-9 items-center justify-center rounded-lg',
150
+ label: 'text-sm',
101
151
  },
102
152
  variants: {
103
153
  selected: { true: { disc: 'bg-primary', label: 'text-primary-foreground' } },
104
154
  today: { true: { disc: 'border border-primary', label: 'text-primary' } },
105
155
  outside: { true: { label: 'text-muted-foreground/40' } },
106
156
  disabled: { true: { label: 'text-muted-foreground/30' } },
107
- inRange: { true: { band: 'bg-accent' } },
157
+ /** A ruled-out day inside a range still sits on it, but faintly. */
158
+ fadedBand: { true: { band: 'opacity-40' } },
108
159
  },
109
160
  compoundVariants: [
110
- // A selected day inside a range keeps the solid disc; the band passes
161
+ // A selected day inside a range keeps the solid fill; the band passes
111
162
  // under it rather than the disc losing to it.
112
163
  { selected: true, today: true, class: { label: 'text-primary-foreground' } },
113
164
  ],
@@ -121,6 +172,9 @@ interface CalendarContextValue {
121
172
  system: 'gregory' | 'islamic';
122
173
  minDate?: Date;
123
174
  maxDate?: Date;
175
+ /** Bounds for the caption's pickers, which are not the selectable bounds. */
176
+ startMonth?: Date;
177
+ endMonth?: Date;
124
178
  captionLayout: CalendarCaptionLayout;
125
179
  }
126
180
 
@@ -134,6 +188,35 @@ function useCalendar(component: string): CalendarContextValue {
134
188
  return context;
135
189
  }
136
190
 
191
+ /**
192
+ * Today, kept current.
193
+ *
194
+ * "Today" is not a constant, and a calendar is exactly the kind of thing left
195
+ * on screen across the boundary that changes it — a picker open in a sheet, an
196
+ * app resumed the next morning. Recomputed when the app comes back to the
197
+ * foreground, which is when a stale answer would otherwise become visible, and
198
+ * held as state so the marker actually moves when it does.
199
+ */
200
+ function useToday(): Date {
201
+ const [today, setToday] = useState(() => startOfDay(new Date()));
202
+
203
+ useEffect(() => {
204
+ const refresh = () => {
205
+ const now = startOfDay(new Date());
206
+ // A new object every resume would invalidate every memo below it for a
207
+ // date that has not changed.
208
+ setToday((current) => (current.getTime() === now.getTime() ? current : now));
209
+ };
210
+
211
+ const subscription = AppState.addEventListener('change', (state) => {
212
+ if (state === 'active') refresh();
213
+ });
214
+ return () => subscription.remove();
215
+ }, []);
216
+
217
+ return today;
218
+ }
219
+
137
220
  /** Whether a rule, a list or a span rules this day out. */
138
221
  function isDisabled(
139
222
  date: Date,
@@ -141,8 +224,14 @@ function isDisabled(
141
224
  minDate: Date | undefined,
142
225
  maxDate: Date | undefined
143
226
  ): boolean {
144
- if (minDate && isBefore(date, minDate)) return true;
145
- if (maxDate && isAfter(date, maxDate)) return true;
227
+ /*
228
+ * Both bounds are compared at local midnight, because they are about days
229
+ * and the caller almost always hands over a moment. `minDate={new Date()}`
230
+ * means "from today", and against a raw timestamp that would rule out every
231
+ * hour of today that has already passed — which is to say, today.
232
+ */
233
+ if (minDate && isBefore(date, startOfDay(minDate))) return true;
234
+ if (maxDate && isAfter(date, startOfDay(maxDate))) return true;
146
235
  if (!disabled) return false;
147
236
  if (typeof disabled === 'function') return disabled(date);
148
237
  if (Array.isArray(disabled)) return disabled.some((day) => isSameDay(day, date));
@@ -177,10 +266,44 @@ export interface CalendarProps<Mode extends CalendarMode = 'single'>
177
266
  minDate?: Date;
178
267
  /** Latest selectable day. */
179
268
  maxDate?: Date;
180
- /** `0` is Sunday. */
181
- weekStartsOn?: number;
269
+ /**
270
+ * Earliest month the caption's pickers offer. Defaults to `minDate`, and to
271
+ * a hundred years back when there is none.
272
+ *
273
+ * Separate from `minDate` because they answer different questions: `minDate`
274
+ * is about which days can be picked, this is about how far the month and year
275
+ * lists reach. A birthday picker wants a century of years on offer and no
276
+ * bound at all on the days inside them.
277
+ */
278
+ startMonth?: Date;
279
+ /** Latest month the caption's pickers offer. Defaults to `maxDate`, else ten years on. */
280
+ endMonth?: Date;
281
+ /**
282
+ * Which day the week's first column is. `0` is Sunday.
283
+ *
284
+ * `auto` asks the locale, which is what most of the world wants — `en-GB` and
285
+ * `fr-FR` start on Monday, and a calendar that says otherwise is wrong in a
286
+ * way its reader notices immediately. It is not the default only because
287
+ * changing an existing calendar's columns out from under it is not something
288
+ * a version bump should do. Out-of-range numbers wrap rather than producing
289
+ * a blank column.
290
+ */
291
+ weekStartsOn?: number | 'auto';
182
292
  /** Draw the neighbouring months' days rather than leaving the cells blank. */
183
293
  showOutsideDays?: boolean;
294
+ /**
295
+ * Let a tap on a neighbouring month's day select it. Off by default: those
296
+ * cells exist to keep the grid six rows tall, and a tap on one is far more
297
+ * often a misfire than an attempt to reach into July from June. Paging to the
298
+ * month is the way to pick a day in it.
299
+ */
300
+ selectOutsideDays?: boolean;
301
+ /**
302
+ * Draw the calendar's own panel — a rounded, bordered card. On by default,
303
+ * for a calendar standing on a page. Turn it off when it is already inside
304
+ * something that draws one, which is what `DatePicker` does.
305
+ */
306
+ bordered?: boolean;
184
307
  /** BCP 47 tag for the month and weekday names. The device's own by default. */
185
308
  locale?: DateLocale;
186
309
  /**
@@ -208,8 +331,12 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
208
331
  disabled,
209
332
  minDate,
210
333
  maxDate,
211
- weekStartsOn = 0,
334
+ startMonth,
335
+ endMonth,
336
+ weekStartsOn: weekStartsOnProp = 0,
212
337
  showOutsideDays = true,
338
+ selectOutsideDays = false,
339
+ bordered = true,
213
340
  locale,
214
341
  calendar = 'gregory',
215
342
  ...props
@@ -217,6 +344,16 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
217
344
  // Settled once here rather than at each call site, so every part of the
218
345
  // grid is certainly reading the same calendar.
219
346
  const system = useMemo(() => resolveCalendar(calendar, locale), [calendar, locale]);
347
+ // Settled once too, and normalised: everything below indexes an array of
348
+ // seven with it, where a negative or out-of-range value is a blank column
349
+ // rather than an error anyone would trace back to here.
350
+ const weekStartsOn = useMemo(
351
+ () =>
352
+ weekStartsOnProp === 'auto'
353
+ ? localeWeekStart(locale)
354
+ : normalizeWeekStart(weekStartsOnProp),
355
+ [weekStartsOnProp, locale]
356
+ );
220
357
  const [internalSelected, setInternalSelected] = useState<CalendarSelection[Mode] | undefined>(
221
358
  defaultSelected
222
359
  );
@@ -243,15 +380,52 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
243
380
  }, []);
244
381
 
245
382
  const [internalMonth, setInternalMonth] = useState(initialMonth);
246
- const month = monthProp ? startOfCalendarMonth(monthProp, system, locale) : internalMonth;
383
+ /*
384
+ * Memoised on the *instant*, not on the prop. A controlled caller almost
385
+ * always passes a fresh `new Date(…)`, and deriving a new month object from
386
+ * it on every render invalidates the grid memo and the context memo below —
387
+ * so a calendar that has not moved rebuilds forty-two cells per keystroke
388
+ * somewhere else on the screen.
389
+ */
390
+ const controlledMonth = useMemo(
391
+ () => (monthProp ? startOfCalendarMonth(monthProp, system, locale) : undefined),
392
+ // eslint-disable-next-line react-hooks/exhaustive-deps
393
+ [monthProp?.getTime(), system, locale]
394
+ );
395
+ const month = controlledMonth ?? internalMonth;
396
+
397
+ /*
398
+ * How far the calendar may be paged, as the first day of the outermost month
399
+ * either way. `startMonth`/`endMonth` win where they are given, because they
400
+ * are the props about *navigation*; `minDate`/`maxDate` are about which days
401
+ * can be picked, and stand in when the navigation bounds are not stated.
402
+ */
403
+ const monthBounds = useMemo(() => {
404
+ const lower = startMonth ?? minDate;
405
+ const upper = endMonth ?? maxDate;
406
+ return {
407
+ from: lower ? startOfCalendarMonth(lower, system, locale) : undefined,
408
+ to: upper ? startOfCalendarMonth(upper, system, locale) : undefined,
409
+ };
410
+ }, [startMonth, minDate, endMonth, maxDate, system, locale]);
247
411
 
248
412
  const setMonth = useCallback(
249
413
  (next: Date) => {
250
- const settled = startOfCalendarMonth(next, system, locale);
414
+ /*
415
+ * Clamped here rather than only at the arrows. The arrows guard
416
+ * themselves, but the caption's dropdowns and a controlled caller both
417
+ * reach this by other routes — and a month list that offers a year is a
418
+ * poor place to discover that the year was out of bounds.
419
+ */
420
+ const settled = clampDate(
421
+ startOfCalendarMonth(next, system, locale),
422
+ monthBounds.from,
423
+ monthBounds.to
424
+ );
251
425
  if (!monthProp) setInternalMonth(settled);
252
426
  onMonthChange?.(settled);
253
427
  },
254
- [monthProp, onMonthChange, system, locale]
428
+ [monthProp, onMonthChange, system, locale, monthBounds]
255
429
  );
256
430
 
257
431
  const commit = useCallback(
@@ -266,6 +440,14 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
266
440
  (date: Date) => {
267
441
  const day = startOfDay(date);
268
442
 
443
+ /*
444
+ * A tap on a neighbouring month's cell — which only happens with
445
+ * `selectOutsideDays` — commits a date the grid is not showing. Page to
446
+ * it, or the selection lands in a greyed-out cell of the month you are
447
+ * still looking at and reads as having missed.
448
+ */
449
+ if (!isSameCalendarMonth(day, month, system, locale)) setMonth(day);
450
+
269
451
  if (mode === 'multiple') {
270
452
  const current = (value as Date[] | undefined) ?? [];
271
453
  const without = current.filter((picked) => !isSameDay(picked, day));
@@ -294,20 +476,33 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
294
476
  const current = value as Date | undefined;
295
477
  commit((isSameDay(current, day) ? undefined : day) as CalendarSelection[Mode]);
296
478
  },
297
- [mode, value, commit]
479
+ [mode, value, commit, month, system, locale, setMonth]
298
480
  );
299
481
 
300
482
  const context = useMemo(
301
- () => ({ month, setMonth, locale, system, minDate, maxDate, captionLayout }),
302
- [month, setMonth, locale, system, minDate, maxDate, captionLayout]
483
+ () => ({
484
+ month,
485
+ setMonth,
486
+ locale,
487
+ system,
488
+ minDate,
489
+ maxDate,
490
+ startMonth,
491
+ endMonth,
492
+ captionLayout,
493
+ }),
494
+ [month, setMonth, locale, system, minDate, maxDate, startMonth, endMonth, captionLayout]
303
495
  );
304
496
 
497
+ const { root } = calendarVariants({ bordered });
498
+ const months = Math.max(1, numberOfMonths);
499
+
305
500
  return (
306
501
  <CalendarContext.Provider value={context}>
307
- <View {...props} className={cn('gap-4', className)}>
308
- {Array.from({ length: Math.max(1, numberOfMonths) }, (_unused, offset) => (
502
+ <View {...props} className={cn(root(), className)}>
503
+ {Array.from({ length: months }, (_unused, offset) => (
309
504
  <View key={offset} className="gap-2">
310
- <CalendarCaption offset={offset} lastOffset={numberOfMonths - 1} />
505
+ <CalendarHeader offset={offset} lastOffset={months - 1} />
311
506
  <CalendarGrid
312
507
  month={addCalendarMonths(month, offset, system, locale)}
313
508
  mode={mode}
@@ -318,6 +513,7 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
318
513
  maxDate={maxDate}
319
514
  weekStartsOn={weekStartsOn}
320
515
  showOutsideDays={showOutsideDays}
516
+ selectOutsideDays={selectOutsideDays}
321
517
  locale={locale}
322
518
  system={system}
323
519
  />
@@ -333,16 +529,29 @@ CalendarRoot.displayName = 'Calendar';
333
529
  /* Caption */
334
530
  /* -------------------------------------------------------------------------- */
335
531
 
532
+ export interface CalendarHeaderProps {
533
+ /** Which month of the run this header sits over. `0` is the first. */
534
+ offset?: number;
535
+ /** The last offset in the run, so the header knows whether it owns the arrow. */
536
+ lastOffset?: number;
537
+ }
538
+
336
539
  /**
337
- * The month name and the arrows, or the month and year pickers.
540
+ * The row over a month: an arrow at each edge and the month's name between
541
+ * them.
338
542
  *
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.
543
+ * The arrows are pinned to the edges of the calendar rather than packed around
544
+ * the label, so the two targets are as far apart as the panel allows and the
545
+ * name sits centred over its own grid. With several months shown they are split
546
+ * across the run — back on the first, forward on the last — because both sets
547
+ * on both headers would page the whole run and give two ways to do one thing.
548
+ * The month that owns neither still lays out a spacer, or its name would slide
549
+ * off centre.
342
550
  */
343
- function CalendarCaption({ offset, lastOffset }: { offset: number; lastOffset: number }) {
551
+ function CalendarHeader({ offset = 0, lastOffset = 0 }: CalendarHeaderProps) {
344
552
  const { month, setMonth, locale, system, minDate, maxDate, captionLayout } =
345
- useCalendar('Calendar.Caption');
553
+ useCalendar('Calendar.Header');
554
+ const { header, caption } = calendarVariants();
346
555
  const shown = addCalendarMonths(month, offset, system, locale);
347
556
 
348
557
  const canGoBack =
@@ -351,7 +560,7 @@ function CalendarCaption({ offset, lastOffset }: { offset: number; lastOffset: n
351
560
  !maxDate || isAfter(startOfCalendarMonth(maxDate, system, locale), shown);
352
561
 
353
562
  return (
354
- <View className="h-9 flex-row items-center justify-between">
563
+ <View className={header()}>
355
564
  {offset === 0 ? (
356
565
  <CalendarNav
357
566
  direction="previous"
@@ -359,14 +568,18 @@ function CalendarCaption({ offset, lastOffset }: { offset: number; lastOffset: n
359
568
  onPress={() => setMonth(addCalendarMonths(month, -1, system, locale))}
360
569
  />
361
570
  ) : (
362
- <View className="h-7 w-7" />
571
+ <View className="h-8 w-8" />
363
572
  )}
364
573
 
365
- {captionLayout === 'dropdown' ? (
366
- <CalendarDropdowns month={shown} offset={offset} />
367
- ) : (
368
- <Text weight="semibold">{calendarMonthLabel(shown, system, locale)}</Text>
369
- )}
574
+ <View className={caption()}>
575
+ {captionLayout === 'dropdown' ? (
576
+ <CalendarDropdowns month={shown} offset={offset} />
577
+ ) : (
578
+ <Text weight="semibold" className="text-base">
579
+ {calendarMonthLabel(shown, system, locale)}
580
+ </Text>
581
+ )}
582
+ </View>
370
583
 
371
584
  {offset === lastOffset ? (
372
585
  <CalendarNav
@@ -375,21 +588,28 @@ function CalendarCaption({ offset, lastOffset }: { offset: number; lastOffset: n
375
588
  onPress={() => setMonth(addCalendarMonths(month, 1, system, locale))}
376
589
  />
377
590
  ) : (
378
- <View className="h-7 w-7" />
591
+ <View className="h-8 w-8" />
379
592
  )}
380
593
  </View>
381
594
  );
382
595
  }
383
596
 
384
- function CalendarNav({
385
- direction,
386
- disabled,
387
- onPress,
388
- }: {
597
+ export interface CalendarNavProps {
389
598
  direction: 'previous' | 'next';
390
- disabled: boolean;
391
- onPress: () => void;
392
- }) {
599
+ disabled?: boolean;
600
+ onPress?: () => void;
601
+ }
602
+
603
+ /**
604
+ * One paging arrow.
605
+ *
606
+ * Borderless: two bordered squares at the ends of a bordered panel read as a
607
+ * row of boxes rather than as controls, and the chevron is legible enough on
608
+ * its own. The target is still 32px with another 8 of slop around it.
609
+ */
610
+ function CalendarNav({ direction, disabled = false, onPress }: CalendarNavProps) {
611
+ const { nav } = calendarVariants();
612
+
393
613
  return (
394
614
  <Pressable
395
615
  disabled={disabled}
@@ -398,26 +618,28 @@ function CalendarNav({
398
618
  accessibilityRole="button"
399
619
  accessibilityLabel={direction === 'next' ? 'Next month' : 'Previous month'}
400
620
  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
- )}
621
+ className={cn(nav(), disabled ? 'opacity-30' : 'active:bg-accent')}
405
622
  >
406
- {direction === 'next' ? <ChevronRightIcon size={16} /> : <ChevronLeftIcon size={16} />}
623
+ {direction === 'next' ? <ChevronRightIcon size={18} /> : <ChevronLeftIcon size={18} />}
407
624
  </Pressable>
408
625
  );
409
626
  }
410
627
 
411
628
  /**
412
- * Month and year as two rows of chips rather than as native pickers.
629
+ * Month and year as chips that open a panel of options.
413
630
  *
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.
631
+ * The panel floats rather than opening in place, and the caption is the reason:
632
+ * it is a fixed-height row, so a list opened inside it has nowhere to go and
633
+ * spills over the grid below. A popover is laid out against the chip and
634
+ * clamped to the screen, which is the behaviour this needs and already has.
635
+ *
636
+ * Native pickers are still the wrong tool — a platform overlay on top of a
637
+ * calendar that is itself already in a popover is two floating layers deep for
638
+ * choosing a number, and it cannot show a Hijri year at all.
417
639
  */
418
640
  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);
641
+ const { setMonth, locale, system, minDate, maxDate, startMonth, endMonth } =
642
+ useCalendar('Calendar.Caption');
421
643
 
422
644
  /*
423
645
  * Everything here counts in the calendar on screen, not in the platform's.
@@ -430,10 +652,46 @@ function CalendarDropdowns({ month, offset }: { month: Date; offset: number }) {
430
652
  [month, system, locale]
431
653
  );
432
654
  const current = calendarParts(month, system, locale);
655
+ /*
656
+ * `startMonth`/`endMonth` bound the lists, falling back to the selectable
657
+ * bounds and then to a century back and a decade on. They are separate props
658
+ * because the two questions are different: a birthday picker wants a hundred
659
+ * years on offer and no bound at all on the days inside them.
660
+ */
661
+ const first = startMonth ?? minDate;
662
+ const last = endMonth ?? maxDate;
663
+ const firstParts = first ? calendarParts(first, system, locale) : null;
664
+ const lastParts = last ? calendarParts(last, system, locale) : null;
433
665
  const bounds = {
434
- from: minDate ? calendarParts(minDate, system, locale).year : current.year - 100,
435
- to: maxDate ? calendarParts(maxDate, system, locale).year : current.year + 10,
666
+ from: firstParts ? firstParts.year : current.year - 100,
667
+ to: lastParts ? lastParts.year : current.year + 10,
436
668
  };
669
+ /*
670
+ * The month list is bounded too, and only the year list used to be — so a
671
+ * calendar starting in March 1925 still offered January and February of 1925,
672
+ * and picking one navigated somewhere the calendar had said it would not go.
673
+ * Only the outermost year of each bound loses months; every year between them
674
+ * has all twelve.
675
+ */
676
+ const monthOptions = months
677
+ .map((label, index) => ({ label, month: index + 1 }))
678
+ .filter(({ month }) => {
679
+ if (firstParts && current.year === firstParts.year && month < firstParts.month) {
680
+ return false;
681
+ }
682
+ if (lastParts && current.year === lastParts.year && month > lastParts.month) {
683
+ return false;
684
+ }
685
+ return true;
686
+ });
687
+ /*
688
+ * Oldest first, the way a year runs. The list is long — a century by default
689
+ * — so it is opened scrolled to the year already in the caption rather than
690
+ * to either end, which is what makes the natural order affordable. Counting
691
+ * backwards was a way of putting the likely years at the top of a list that
692
+ * always opened at the top; it is not needed once the list opens in the right
693
+ * place, and it made every other use of the picker read upside down.
694
+ */
437
695
  const years = Array.from(
438
696
  { length: Math.max(1, bounds.to - bounds.from + 1) },
439
697
  (_unused, index) => bounds.from + index
@@ -446,77 +704,163 @@ function CalendarDropdowns({ month, offset }: { month: Date; offset: number }) {
446
704
  */
447
705
  const choose = (months_: number) => {
448
706
  setMonth(addCalendarMonths(month, months_ - offset, system, locale));
449
- setOpen(null);
450
707
  };
451
708
 
452
709
  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>
710
+ <>
711
+ <CaptionDropdown
712
+ label={months[current.month - 1] ?? String(current.month)}
713
+ accessibilityLabel="Month"
714
+ options={monthOptions.map((option) => option.label)}
715
+ active={monthOptions.findIndex((option) => option.month === current.month)}
716
+ width={MONTH_LIST_WIDTH}
717
+ onSelect={(index) => choose((monthOptions[index]?.month ?? current.month) - current.month)}
718
+ />
719
+ <CaptionDropdown
720
+ label={String(current.year)}
721
+ accessibilityLabel="Year"
722
+ options={years.map(String)}
723
+ active={years.indexOf(current.year)}
724
+ width={YEAR_LIST_WIDTH}
725
+ onSelect={(index) => choose((years[index]! - current.year) * 12)}
726
+ />
727
+ </>
728
+ );
729
+ }
730
+
731
+ /** Panel widths. A month name needs the room; a four-digit year does not. */
732
+ const MONTH_LIST_WIDTH = 176;
733
+ const YEAR_LIST_WIDTH = 132;
734
+
735
+ /**
736
+ * Height of one option row, and the tallest the list is allowed to be.
737
+ *
738
+ * The row height is a constant rather than a measurement because it is what
739
+ * turns "the twenty-ninth option" into a scroll offset, and that has to be
740
+ * known before the list has laid out — the whole point is that it opens in the
741
+ * right place rather than scrolling there afterwards.
742
+ */
743
+ const OPTION_HEIGHT = 40;
744
+ const LIST_MAX_HEIGHT = OPTION_HEIGHT * 6.5;
745
+
746
+ /**
747
+ * One chip and the list of options it opens.
748
+ *
749
+ * A list, not a grid of chips. Twelve months tile into a panel well enough, but
750
+ * the same treatment applied to a century of years is a wall of four-digit
751
+ * numbers with no order the eye can follow — and it opened at whichever end
752
+ * happened to be first. Rows in a column read as a list, take a checkmark, and
753
+ * can be scrolled to a particular one.
754
+ */
755
+ function CaptionDropdown({
756
+ label,
757
+ accessibilityLabel,
758
+ options,
759
+ active,
760
+ width,
761
+ onSelect,
762
+ }: {
763
+ label: string;
764
+ accessibilityLabel: string;
765
+ options: string[];
766
+ /** Index of the option currently in the caption, or -1 for none. */
767
+ active: number;
768
+ width: number;
769
+ onSelect: (index: number) => void;
770
+ }) {
771
+ const [open, setOpen] = useState(false);
772
+ const list = useRef<ScrollView>(null);
773
+ /*
774
+ * The check is given a colour rather than left to its own fallback, which is
775
+ * white — right inside a filled control, invisible on this panel.
776
+ */
777
+ const rawTick = useCSSVariable('--color-foreground');
778
+ const tick = typeof rawTick === 'string' ? rawTick : undefined;
779
+
780
+ /*
781
+ * Opened on the option already in the caption, centred in the panel rather
782
+ * than pinned to an edge so the years either side of it are in view too. A
783
+ * hundred-item list that opens at the top is showing the one year nobody came
784
+ * to pick, and asks for a flick through a century to reach the one they did.
785
+ */
786
+ const scrollToActive = useCallback(() => {
787
+ if (active < 0) return;
788
+ const centred = active * OPTION_HEIGHT - LIST_MAX_HEIGHT / 2 + OPTION_HEIGHT / 2;
789
+ list.current?.scrollTo({ y: Math.max(0, centred), animated: false });
790
+ }, [active]);
466
791
 
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;
792
+ return (
793
+ <Popover open={open} onOpenChange={setOpen}>
794
+ <Popover.Trigger>
795
+ <CaptionChip label={label} expanded={open} accessibilityLabel={accessibilityLabel} />
796
+ </Popover.Trigger>
797
+ <Popover.Content align="center" width={width} className="p-1.5">
798
+ <ScrollView
799
+ ref={list}
800
+ bounces={false}
801
+ showsVerticalScrollIndicator={false}
802
+ style={{ maxHeight: LIST_MAX_HEIGHT }}
803
+ // Fires once the rows exist, which is the first moment there is
804
+ // anything to scroll. Doing it on mount would scroll an empty list.
805
+ onContentSizeChange={scrollToActive}
806
+ >
807
+ {options.map((option, index) => {
808
+ const selected = index === active;
474
809
  return (
475
810
  <Pressable
476
811
  key={option}
477
- onPress={() =>
478
- choose(
479
- open === 'month'
480
- ? index - (current.month - 1)
481
- : (years[index]! - current.year) * 12
482
- )
483
- }
812
+ onPress={() => {
813
+ onSelect(index);
814
+ setOpen(false);
815
+ }}
484
816
  accessibilityRole="button"
485
- accessibilityState={{ selected: active }}
817
+ accessibilityState={{ selected }}
818
+ style={{ height: OPTION_HEIGHT }}
486
819
  className={cn(
487
- 'rounded-md px-2.5 py-1.5',
488
- active ? 'bg-primary' : 'active:bg-accent'
820
+ 'flex-row items-center justify-between gap-2 rounded-xl px-3',
821
+ selected ? 'bg-accent' : 'active:bg-accent'
489
822
  )}
490
823
  >
491
- <Text size="sm" className={active ? 'text-primary-foreground' : undefined}>
824
+ <Text size="sm" weight={selected ? 'semibold' : 'normal'}>
492
825
  {option}
493
826
  </Text>
827
+ {selected ? <CheckIcon size={16} color={tick} /> : null}
494
828
  </Pressable>
495
829
  );
496
830
  })}
497
- </View>
498
- ) : null}
499
- </View>
831
+ </ScrollView>
832
+ </Popover.Content>
833
+ </Popover>
500
834
  );
501
835
  }
502
836
 
503
837
  function CaptionChip({
504
838
  label,
505
839
  expanded,
840
+ accessibilityLabel,
506
841
  onPress,
507
842
  }: {
508
843
  label: string;
509
844
  expanded: boolean;
510
- onPress: () => void;
845
+ accessibilityLabel: string;
846
+ /** Supplied by `Popover.Trigger`, which clones this to open the panel. */
847
+ onPress?: (...args: unknown[]) => void;
511
848
  }) {
512
849
  return (
513
850
  <Pressable
514
851
  onPress={onPress}
515
852
  accessibilityRole="button"
853
+ accessibilityLabel={accessibilityLabel}
516
854
  accessibilityState={{ expanded }}
517
- className="flex-row items-center gap-1 rounded-md px-2 py-1 active:bg-accent"
855
+ className="flex-row items-center gap-1 rounded-lg px-2 py-1.5 active:bg-accent"
518
856
  >
519
857
  <Text weight="semibold">{label}</Text>
858
+ {/*
859
+ The chevron says the chip opens something. Without it a bold word in the
860
+ middle of a header is indistinguishable from the plain caption layout,
861
+ and nobody thinks to press it.
862
+ */}
863
+ <ChevronDownIcon size={14} />
520
864
  </Pressable>
521
865
  );
522
866
  }
@@ -535,6 +879,7 @@ interface GridProps {
535
879
  maxDate?: Date;
536
880
  weekStartsOn: number;
537
881
  showOutsideDays: boolean;
882
+ selectOutsideDays?: boolean;
538
883
  locale: DateLocale;
539
884
  system: 'gregory' | 'islamic';
540
885
  }
@@ -549,6 +894,7 @@ function CalendarGrid({
549
894
  maxDate,
550
895
  weekStartsOn,
551
896
  showOutsideDays,
897
+ selectOutsideDays = false,
552
898
  locale,
553
899
  system,
554
900
  }: GridProps) {
@@ -558,16 +904,12 @@ function CalendarGrid({
558
904
  * boundaries and the day numbers differ — so the walk itself is the same
559
905
  * either way and only where it starts moves.
560
906
  */
561
- const weeks = useMemo(() => {
562
- const first = startOfCalendarMonth(month, system, locale);
563
- const lead = (first.getDay() - weekStartsOn + 7) % 7;
564
- const start = addDays(first, -lead);
565
- return Array.from({ length: 6 }, (_unusedWeek, week) =>
566
- Array.from({ length: 7 }, (_unusedDay, day) => addDays(start, week * 7 + day))
567
- );
568
- }, [month, weekStartsOn, system, locale]);
907
+ const weeks = useMemo(
908
+ () => monthGrid(month, weekStartsOn, system, locale),
909
+ [month, weekStartsOn, system, locale]
910
+ );
569
911
  const headings = useMemo(() => weekdayNames(locale, weekStartsOn), [locale, weekStartsOn]);
570
- const today = startOfDay(new Date());
912
+ const today = useToday();
571
913
 
572
914
  const range = mode === 'range' ? (value as DateRange | undefined) : undefined;
573
915
  const multiple = mode === 'multiple' ? ((value as Date[] | undefined) ?? []) : [];
@@ -595,16 +937,38 @@ function CalendarGrid({
595
937
  return <View key={dayIndex} className="h-10 flex-1" />;
596
938
  }
597
939
 
940
+ const isStart = mode === 'range' && isSameDay(date, range?.from);
941
+ const isEnd = mode === 'range' && isSameDay(date, range?.to);
942
+
598
943
  const selected =
599
944
  mode === 'range'
600
- ? isSameDay(date, range?.from) || isSameDay(date, range?.to)
945
+ ? isStart || isEnd
601
946
  : mode === 'multiple'
602
947
  ? multiple.some((picked) => isSameDay(picked, date))
603
948
  : isSameDay(date, single);
604
949
 
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;
950
+ /*
951
+ * Inclusive of both ends, unlike the old middles-only rule. An end
952
+ * has to carry its half of the band or there is a gap between the
953
+ * disc and the run beside it — which is precisely what made a range
954
+ * read as two discs with a stripe floating between them.
955
+ */
956
+ const inRange = !!range?.to && isWithin(date, range.from, range.to);
957
+
958
+ /*
959
+ * Whether the band carries on past this cell's edge into the one
960
+ * beside it. It stops at an end of the range, and at the edge of a
961
+ * row — a range over a weekend closes on Saturday and opens again
962
+ * on Sunday rather than trailing into the gap between the rows.
963
+ */
964
+ const openStart = inRange && !isStart && dayIndex > 0;
965
+ const openEnd = inRange && !isEnd && dayIndex < 6;
966
+
967
+ // A neighbouring month's day is drawn, not offered. Reaching into
968
+ // July from June is what the arrows are for.
969
+ const ruledOut =
970
+ isDisabled(date, disabled, minDate, maxDate) ||
971
+ (outside && !selectOutsideDays);
608
972
 
609
973
  return (
610
974
  <CalendarDay
@@ -613,12 +977,12 @@ function CalendarGrid({
613
977
  outside={outside}
614
978
  selected={selected}
615
979
  inRange={inRange}
980
+ rangeStart={isStart}
981
+ rangeEnd={isEnd}
616
982
  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}
983
+ disabled={ruledOut}
984
+ openStart={openStart}
985
+ openEnd={openEnd}
622
986
  locale={locale}
623
987
  system={system}
624
988
  onPress={() => onSelect(date)}
@@ -635,10 +999,15 @@ interface DayProps {
635
999
  date: Date;
636
1000
  outside: boolean;
637
1001
  selected: boolean;
1002
+ /** Anywhere inside the range, ends included. */
638
1003
  inRange: boolean;
1004
+ rangeStart?: boolean;
1005
+ rangeEnd?: boolean;
639
1006
  today: boolean;
640
1007
  disabled: boolean;
1008
+ /** The band carries on past this cell's leading edge. */
641
1009
  openStart: boolean;
1010
+ /** The band carries on past this cell's trailing edge. */
642
1011
  openEnd: boolean;
643
1012
  locale: DateLocale;
644
1013
  system: 'gregory' | 'islamic';
@@ -650,6 +1019,8 @@ function CalendarDay({
650
1019
  outside,
651
1020
  selected,
652
1021
  inRange,
1022
+ rangeStart = false,
1023
+ rangeEnd = false,
653
1024
  today,
654
1025
  disabled,
655
1026
  openStart,
@@ -663,9 +1034,28 @@ function CalendarDay({
663
1034
  today: today && !selected,
664
1035
  outside,
665
1036
  disabled,
666
- inRange,
1037
+ fadedBand: disabled && inRange,
667
1038
  });
668
1039
 
1040
+ /*
1041
+ * An end of the range fills half its cell, from the centre outwards towards
1042
+ * the rest of the range; every other day in it fills the whole cell. Half a
1043
+ * cell is what puts the band underneath the disc rather than beside it — the
1044
+ * disc is centred, so a band starting at the centre emerges from behind it
1045
+ * and the two read as one shape. A range of one day gets both halves and so
1046
+ * no band at all, which is right: there is nothing to span.
1047
+ */
1048
+ const bandStyle = inRange
1049
+ ? {
1050
+ left: rangeStart ? ('50%' as const) : 0,
1051
+ right: rangeEnd ? ('50%' as const) : 0,
1052
+ borderTopLeftRadius: openStart ? 0 : DAY_RADIUS,
1053
+ borderBottomLeftRadius: openStart ? 0 : DAY_RADIUS,
1054
+ borderTopRightRadius: openEnd ? 0 : DAY_RADIUS,
1055
+ borderBottomRightRadius: openEnd ? 0 : DAY_RADIUS,
1056
+ }
1057
+ : null;
1058
+
669
1059
  return (
670
1060
  <Pressable
671
1061
  disabled={disabled}
@@ -673,23 +1063,11 @@ function CalendarDay({
673
1063
  accessibilityRole="button"
674
1064
  accessibilityLabel={calendarLongDate(date, system, locale)}
675
1065
  accessibilityState={{ selected, disabled }}
676
- className={cn(styles.cell(), 'h-10')}
1066
+ className={styles.cell()}
677
1067
  >
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())}>
1068
+ {bandStyle ? <View className={styles.band()} style={bandStyle} /> : null}
1069
+ <View className={styles.disc()}>
1070
+ <Text className={styles.label()}>
693
1071
  {calendarDayNumber(date, system, locale)}
694
1072
  </Text>
695
1073
  </View>
@@ -698,6 +1076,8 @@ function CalendarDay({
698
1076
  }
699
1077
 
700
1078
  export const Calendar = Object.assign(CalendarRoot, {
1079
+ Header: CalendarHeader,
1080
+ Nav: CalendarNav,
701
1081
  Grid: CalendarGrid,
702
1082
  Day: CalendarDay,
703
1083
  });