panelui-native 0.25.0 → 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 (242) hide show
  1. package/README.md +18 -1
  2. package/lib/module/components/accordion/index.js +8 -8
  3. package/lib/module/components/accordion/index.js.map +1 -1
  4. package/lib/module/components/alert/index.js +2 -2
  5. package/lib/module/components/alert/index.js.map +1 -1
  6. package/lib/module/components/area-chart/index.js +182 -50
  7. package/lib/module/components/area-chart/index.js.map +1 -1
  8. package/lib/module/components/attachment/index.js +2 -1
  9. package/lib/module/components/attachment/index.js.map +1 -1
  10. package/lib/module/components/avatar/index.js +3 -3
  11. package/lib/module/components/avatar/index.js.map +1 -1
  12. package/lib/module/components/badge/index.js +4 -4
  13. package/lib/module/components/badge/index.js.map +1 -1
  14. package/lib/module/components/bar-chart/index.js +197 -56
  15. package/lib/module/components/bar-chart/index.js.map +1 -1
  16. package/lib/module/components/bottom-sheet/index.js +15 -10
  17. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  18. package/lib/module/components/breadcrumb/index.js +6 -6
  19. package/lib/module/components/breadcrumb/index.js.map +1 -1
  20. package/lib/module/components/button/index.js +11 -6
  21. package/lib/module/components/button/index.js.map +1 -1
  22. package/lib/module/components/calendar/index.js +792 -0
  23. package/lib/module/components/calendar/index.js.map +1 -0
  24. package/lib/module/components/carousel/index.js +680 -0
  25. package/lib/module/components/carousel/index.js.map +1 -0
  26. package/lib/module/components/chip/index.js +4 -4
  27. package/lib/module/components/chip/index.js.map +1 -1
  28. package/lib/module/components/code-block/highlight.js +363 -0
  29. package/lib/module/components/code-block/highlight.js.map +1 -0
  30. package/lib/module/components/code-block/index.js +313 -0
  31. package/lib/module/components/code-block/index.js.map +1 -0
  32. package/lib/module/components/date-picker/index.js +165 -0
  33. package/lib/module/components/date-picker/index.js.map +1 -0
  34. package/lib/module/components/dialog/index.js +7 -2
  35. package/lib/module/components/dialog/index.js.map +1 -1
  36. package/lib/module/components/direction/index.js +2 -1
  37. package/lib/module/components/direction/index.js.map +1 -1
  38. package/lib/module/components/empty-state/index.js +2 -2
  39. package/lib/module/components/empty-state/index.js.map +1 -1
  40. package/lib/module/components/field/index.js +5 -5
  41. package/lib/module/components/field/index.js.map +1 -1
  42. package/lib/module/components/flow/index.js +3 -3
  43. package/lib/module/components/flow/index.js.map +1 -1
  44. package/lib/module/components/frame/index.js +5 -5
  45. package/lib/module/components/frame/index.js.map +1 -1
  46. package/lib/module/components/input-group/index.js +3 -2
  47. package/lib/module/components/input-group/index.js.map +1 -1
  48. package/lib/module/components/item/index.js +9 -9
  49. package/lib/module/components/item/index.js.map +1 -1
  50. package/lib/module/components/label/index.js +4 -4
  51. package/lib/module/components/label/index.js.map +1 -1
  52. package/lib/module/components/line-chart/index.js +167 -44
  53. package/lib/module/components/line-chart/index.js.map +1 -1
  54. package/lib/module/components/map/index.js +7 -7
  55. package/lib/module/components/map/index.js.map +1 -1
  56. package/lib/module/components/marker/index.js +3 -3
  57. package/lib/module/components/marker/index.js.map +1 -1
  58. package/lib/module/components/menu/index.js +714 -0
  59. package/lib/module/components/menu/index.js.map +1 -0
  60. package/lib/module/components/message/index.js +7 -7
  61. package/lib/module/components/message/index.js.map +1 -1
  62. package/lib/module/components/message-scroller/index.js +5 -4
  63. package/lib/module/components/message-scroller/index.js.map +1 -1
  64. package/lib/module/components/plan/index.js +296 -0
  65. package/lib/module/components/plan/index.js.map +1 -0
  66. package/lib/module/components/popover/index.js +73 -10
  67. package/lib/module/components/popover/index.js.map +1 -1
  68. package/lib/module/components/radio-group/index.js +15 -10
  69. package/lib/module/components/radio-group/index.js.map +1 -1
  70. package/lib/module/components/reasoning/index.js +244 -0
  71. package/lib/module/components/reasoning/index.js.map +1 -0
  72. package/lib/module/components/section-rail/index.js +4 -4
  73. package/lib/module/components/section-rail/index.js.map +1 -1
  74. package/lib/module/components/select/index.js +10 -4
  75. package/lib/module/components/select/index.js.map +1 -1
  76. package/lib/module/components/sources/index.js +221 -0
  77. package/lib/module/components/sources/index.js.map +1 -0
  78. package/lib/module/components/steps/index.js +4 -4
  79. package/lib/module/components/steps/index.js.map +1 -1
  80. package/lib/module/components/tabs/index.js +7 -7
  81. package/lib/module/components/tabs/index.js.map +1 -1
  82. package/lib/module/components/task/index.js +300 -0
  83. package/lib/module/components/task/index.js.map +1 -0
  84. package/lib/module/components/textarea/index.js +295 -0
  85. package/lib/module/components/textarea/index.js.map +1 -0
  86. package/lib/module/components/timeline/index.js +4 -4
  87. package/lib/module/components/timeline/index.js.map +1 -1
  88. package/lib/module/components/toast/index.js +2 -2
  89. package/lib/module/components/toast/index.js.map +1 -1
  90. package/lib/module/components/toggle-button/index.js +5 -5
  91. package/lib/module/components/toggle-button/index.js.map +1 -1
  92. package/lib/module/components/tooltip/index.js +648 -0
  93. package/lib/module/components/tooltip/index.js.map +1 -0
  94. package/lib/module/components/typography/index.js +2 -2
  95. package/lib/module/components/typography/index.js.map +1 -1
  96. package/lib/module/hooks/index.js +1 -0
  97. package/lib/module/hooks/index.js.map +1 -1
  98. package/lib/module/hooks/use-back-handler.js +37 -0
  99. package/lib/module/hooks/use-back-handler.js.map +1 -0
  100. package/lib/module/icons/index.js +202 -0
  101. package/lib/module/icons/index.js.map +1 -1
  102. package/lib/module/index.js +13 -1
  103. package/lib/module/index.js.map +1 -1
  104. package/lib/module/primitives/collapse.js +112 -0
  105. package/lib/module/primitives/collapse.js.map +1 -0
  106. package/lib/module/primitives/keyboard-avoider.js +2 -1
  107. package/lib/module/primitives/keyboard-avoider.js.map +1 -1
  108. package/lib/module/primitives/text.js +25 -1
  109. package/lib/module/primitives/text.js.map +1 -1
  110. package/lib/module/utils/date.js +386 -0
  111. package/lib/module/utils/date.js.map +1 -0
  112. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  113. package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
  114. package/lib/typescript/src/components/area-chart/index.d.ts +41 -1
  115. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  116. package/lib/typescript/src/components/attachment/index.d.ts.map +1 -1
  117. package/lib/typescript/src/components/bar-chart/index.d.ts +44 -2
  118. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
  119. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  120. package/lib/typescript/src/components/breadcrumb/index.d.ts.map +1 -1
  121. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  122. package/lib/typescript/src/components/calendar/index.d.ts +173 -0
  123. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -0
  124. package/lib/typescript/src/components/carousel/index.d.ts +179 -0
  125. package/lib/typescript/src/components/carousel/index.d.ts.map +1 -0
  126. package/lib/typescript/src/components/chip/index.d.ts.map +1 -1
  127. package/lib/typescript/src/components/code-block/highlight.d.ts +39 -0
  128. package/lib/typescript/src/components/code-block/highlight.d.ts.map +1 -0
  129. package/lib/typescript/src/components/code-block/index.d.ts +113 -0
  130. package/lib/typescript/src/components/code-block/index.d.ts.map +1 -0
  131. package/lib/typescript/src/components/date-picker/index.d.ts +97 -0
  132. package/lib/typescript/src/components/date-picker/index.d.ts.map +1 -0
  133. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  134. package/lib/typescript/src/components/direction/index.d.ts.map +1 -1
  135. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
  136. package/lib/typescript/src/components/field/index.d.ts.map +1 -1
  137. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  138. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
  139. package/lib/typescript/src/components/item/index.d.ts.map +1 -1
  140. package/lib/typescript/src/components/label/index.d.ts.map +1 -1
  141. package/lib/typescript/src/components/line-chart/index.d.ts +41 -2
  142. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  143. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  144. package/lib/typescript/src/components/marker/index.d.ts.map +1 -1
  145. package/lib/typescript/src/components/menu/index.d.ts +261 -0
  146. package/lib/typescript/src/components/menu/index.d.ts.map +1 -0
  147. package/lib/typescript/src/components/message/index.d.ts.map +1 -1
  148. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -1
  149. package/lib/typescript/src/components/plan/index.d.ts +127 -0
  150. package/lib/typescript/src/components/plan/index.d.ts.map +1 -0
  151. package/lib/typescript/src/components/popover/index.d.ts +35 -1
  152. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  153. package/lib/typescript/src/components/radio-group/index.d.ts.map +1 -1
  154. package/lib/typescript/src/components/reasoning/index.d.ts +97 -0
  155. package/lib/typescript/src/components/reasoning/index.d.ts.map +1 -0
  156. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  157. package/lib/typescript/src/components/sources/index.d.ts +85 -0
  158. package/lib/typescript/src/components/sources/index.d.ts.map +1 -0
  159. package/lib/typescript/src/components/steps/index.d.ts.map +1 -1
  160. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  161. package/lib/typescript/src/components/task/index.d.ts +103 -0
  162. package/lib/typescript/src/components/task/index.d.ts.map +1 -0
  163. package/lib/typescript/src/components/textarea/index.d.ts +175 -0
  164. package/lib/typescript/src/components/textarea/index.d.ts.map +1 -0
  165. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  166. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  167. package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -1
  168. package/lib/typescript/src/components/tooltip/index.d.ts +180 -0
  169. package/lib/typescript/src/components/tooltip/index.d.ts.map +1 -0
  170. package/lib/typescript/src/components/typography/index.d.ts.map +1 -1
  171. package/lib/typescript/src/hooks/index.d.ts +1 -0
  172. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  173. package/lib/typescript/src/hooks/use-back-handler.d.ts +19 -0
  174. package/lib/typescript/src/hooks/use-back-handler.d.ts.map +1 -0
  175. package/lib/typescript/src/icons/index.d.ts +17 -0
  176. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  177. package/lib/typescript/src/index.d.ts +16 -4
  178. package/lib/typescript/src/index.d.ts.map +1 -1
  179. package/lib/typescript/src/primitives/collapse.d.ts +42 -0
  180. package/lib/typescript/src/primitives/collapse.d.ts.map +1 -0
  181. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
  182. package/lib/typescript/src/primitives/text.d.ts +20 -0
  183. package/lib/typescript/src/primitives/text.d.ts.map +1 -1
  184. package/lib/typescript/src/utils/date.d.ts +116 -0
  185. package/lib/typescript/src/utils/date.d.ts.map +1 -0
  186. package/package.json +1 -1
  187. package/src/components/accordion/index.tsx +8 -12
  188. package/src/components/alert/index.tsx +2 -2
  189. package/src/components/area-chart/index.tsx +208 -55
  190. package/src/components/attachment/index.tsx +2 -2
  191. package/src/components/avatar/index.tsx +3 -3
  192. package/src/components/badge/index.tsx +6 -6
  193. package/src/components/bar-chart/index.tsx +214 -54
  194. package/src/components/bottom-sheet/index.tsx +17 -16
  195. package/src/components/breadcrumb/index.tsx +6 -8
  196. package/src/components/button/index.tsx +12 -8
  197. package/src/components/calendar/index.tsx +970 -0
  198. package/src/components/carousel/index.tsx +788 -0
  199. package/src/components/chip/index.tsx +4 -6
  200. package/src/components/code-block/highlight.ts +278 -0
  201. package/src/components/code-block/index.tsx +341 -0
  202. package/src/components/date-picker/index.tsx +255 -0
  203. package/src/components/dialog/index.tsx +7 -2
  204. package/src/components/direction/index.tsx +2 -1
  205. package/src/components/empty-state/index.tsx +2 -2
  206. package/src/components/field/index.tsx +3 -5
  207. package/src/components/flow/index.tsx +3 -3
  208. package/src/components/frame/index.tsx +5 -7
  209. package/src/components/input-group/index.tsx +3 -2
  210. package/src/components/item/index.tsx +9 -9
  211. package/src/components/label/index.tsx +2 -2
  212. package/src/components/line-chart/index.tsx +189 -50
  213. package/src/components/map/index.tsx +7 -9
  214. package/src/components/marker/index.tsx +3 -3
  215. package/src/components/menu/index.tsx +812 -0
  216. package/src/components/message/index.tsx +7 -7
  217. package/src/components/message-scroller/index.tsx +5 -4
  218. package/src/components/plan/index.tsx +325 -0
  219. package/src/components/popover/index.tsx +111 -8
  220. package/src/components/radio-group/index.tsx +13 -4
  221. package/src/components/reasoning/index.tsx +297 -0
  222. package/src/components/section-rail/index.tsx +4 -4
  223. package/src/components/select/index.tsx +10 -4
  224. package/src/components/sources/index.tsx +278 -0
  225. package/src/components/steps/index.tsx +4 -4
  226. package/src/components/tabs/index.tsx +7 -9
  227. package/src/components/task/index.tsx +329 -0
  228. package/src/components/textarea/index.tsx +381 -0
  229. package/src/components/timeline/index.tsx +4 -4
  230. package/src/components/toast/index.tsx +2 -2
  231. package/src/components/toggle-button/index.tsx +5 -7
  232. package/src/components/tooltip/index.tsx +793 -0
  233. package/src/components/typography/index.tsx +2 -2
  234. package/src/hooks/index.ts +1 -0
  235. package/src/hooks/use-back-handler.ts +37 -0
  236. package/src/icons/index.tsx +147 -0
  237. package/src/index.ts +116 -0
  238. package/src/primitives/collapse.tsx +128 -0
  239. package/src/primitives/keyboard-avoider.tsx +2 -1
  240. package/src/primitives/text.tsx +28 -1
  241. package/src/utils/date.ts +446 -0
  242. package/theme.css +112 -0
@@ -0,0 +1,970 @@
1
+ /**
2
+ * Calendar — a month of days, for picking one, several, or a range.
3
+ *
4
+ * ```tsx
5
+ * const [day, setDay] = useState<Date>();
6
+ *
7
+ * <Calendar mode="single" selected={day} onSelect={setDay} />
8
+ * ```
9
+ *
10
+ * ## The grid is always six weeks
11
+ *
12
+ * A month needs five rows or six depending on what day it starts on. Drawing
13
+ * only the rows a month uses makes the calendar change height as it is paged
14
+ * through — everything below it jumps, and the days themselves appear to shift
15
+ * between months. So the grid is six rows always, and the spare cells hold the
16
+ * neighbouring months' days.
17
+ *
18
+ * ## How a range is drawn
19
+ *
20
+ * On the web the band under a selected range is one background with the ends
21
+ * rounded by `:first-child` and `:last-child`. There are no pseudo-classes
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.
24
+ *
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.
37
+ */
38
+ import {
39
+ createContext,
40
+ useCallback,
41
+ useContext,
42
+ useMemo,
43
+ useRef,
44
+ useState,
45
+ } from 'react';
46
+ import { Pressable, ScrollView, View, type ViewProps } from 'react-native';
47
+ import { tv } from 'tailwind-variants';
48
+ import { useCSSVariable } from 'uniwind';
49
+ import {
50
+ CheckIcon,
51
+ ChevronDownIcon,
52
+ ChevronLeftIcon,
53
+ ChevronRightIcon,
54
+ } from '../../icons';
55
+ import { Text } from '../../primitives/text';
56
+ import { cn } from '../../utils/cn';
57
+ import { Popover } from '../popover';
58
+ import {
59
+ addCalendarMonths,
60
+ addDays,
61
+ calendarDayNumber,
62
+ calendarLongDate,
63
+ calendarMonthLabel,
64
+ calendarMonthNames,
65
+ calendarParts,
66
+ isAfter,
67
+ isBefore,
68
+ isSameCalendarMonth,
69
+ isSameDay,
70
+ isWithin,
71
+ resolveCalendar,
72
+ startOfCalendarMonth,
73
+ startOfDay,
74
+ weekdayNames,
75
+ type CalendarSystem,
76
+ type DateLocale,
77
+ } from '../../utils/date';
78
+
79
+ export type CalendarMode = 'single' | 'multiple' | 'range';
80
+ export type CalendarCaptionLayout = 'label' | 'dropdown';
81
+
82
+ /** A range under construction: `to` is undefined between the two taps. */
83
+ export interface DateRange {
84
+ from: Date;
85
+ to?: Date;
86
+ }
87
+
88
+ /**
89
+ * What a `mode` selects. Written as a map so the root, the parts and the
90
+ * caller all read the same association rather than each casting their own way.
91
+ */
92
+ export interface CalendarSelection {
93
+ single: Date | undefined;
94
+ multiple: Date[];
95
+ range: DateRange | undefined;
96
+ }
97
+
98
+ /**
99
+ * Which days cannot be picked. A list, a span, or a rule — a rule because
100
+ * "no weekends" and "not in the past" are the two most common cases and
101
+ * neither can be written as a list that stays right tomorrow.
102
+ */
103
+ export type CalendarDisabled =
104
+ | Date[]
105
+ | { from: Date; to: Date }
106
+ | ((date: Date) => boolean);
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
+
134
+ const dayVariants = tv({
135
+ slots: {
136
+ cell: 'h-10 flex-1 items-center justify-center',
137
+ /** The range band, drawn behind the number so it can run cell to cell. */
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',
147
+ },
148
+ variants: {
149
+ selected: { true: { disc: 'bg-primary', label: 'text-primary-foreground' } },
150
+ today: { true: { disc: 'border border-primary', label: 'text-primary' } },
151
+ outside: { true: { label: 'text-muted-foreground/40' } },
152
+ disabled: { true: { label: 'text-muted-foreground/30' } },
153
+ /** A ruled-out day inside a range still sits on it, but faintly. */
154
+ fadedBand: { true: { band: 'opacity-40' } },
155
+ },
156
+ compoundVariants: [
157
+ // A selected day inside a range keeps the solid fill; the band passes
158
+ // under it rather than the disc losing to it.
159
+ { selected: true, today: true, class: { label: 'text-primary-foreground' } },
160
+ ],
161
+ });
162
+
163
+ interface CalendarContextValue {
164
+ month: Date;
165
+ setMonth: (month: Date) => void;
166
+ locale: DateLocale;
167
+ /** Already resolved — `auto` is settled once, at the root. */
168
+ system: 'gregory' | 'islamic';
169
+ minDate?: Date;
170
+ maxDate?: Date;
171
+ /** Bounds for the caption's pickers, which are not the selectable bounds. */
172
+ startMonth?: Date;
173
+ endMonth?: Date;
174
+ captionLayout: CalendarCaptionLayout;
175
+ }
176
+
177
+ const CalendarContext = createContext<CalendarContextValue | null>(null);
178
+
179
+ function useCalendar(component: string): CalendarContextValue {
180
+ const context = useContext(CalendarContext);
181
+ if (!context) {
182
+ throw new Error(`${component} must be used within a <Calendar>`);
183
+ }
184
+ return context;
185
+ }
186
+
187
+ /** Whether a rule, a list or a span rules this day out. */
188
+ function isDisabled(
189
+ date: Date,
190
+ disabled: CalendarDisabled | undefined,
191
+ minDate: Date | undefined,
192
+ maxDate: Date | undefined
193
+ ): boolean {
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;
202
+ if (!disabled) return false;
203
+ if (typeof disabled === 'function') return disabled(date);
204
+ if (Array.isArray(disabled)) return disabled.some((day) => isSameDay(day, date));
205
+ return isWithin(date, disabled.from, disabled.to);
206
+ }
207
+
208
+ export interface CalendarProps<Mode extends CalendarMode = 'single'>
209
+ extends Omit<ViewProps, 'children'> {
210
+ className?: string;
211
+ /** One day, several, or a span with two ends. */
212
+ mode?: Mode;
213
+ /** Controlled selection. Its shape follows `mode`. */
214
+ selected?: CalendarSelection[Mode];
215
+ /** Starting selection when uncontrolled. */
216
+ defaultSelected?: CalendarSelection[Mode];
217
+ onSelect?: (selected: CalendarSelection[Mode]) => void;
218
+ /** Controlled visible month. Any day inside it will do. */
219
+ month?: Date;
220
+ /** Month shown first when uncontrolled. Defaults to the selection, or today. */
221
+ defaultMonth?: Date;
222
+ onMonthChange?: (month: Date) => void;
223
+ /** Months side by side. Two is what picking a range across a boundary wants. */
224
+ numberOfMonths?: number;
225
+ /**
226
+ * `dropdown` turns the caption into month and year pickers, which is the
227
+ * difference between choosing a birthday in four taps and in four hundred.
228
+ */
229
+ captionLayout?: CalendarCaptionLayout;
230
+ /** Days that cannot be picked: a list, a span, or a rule. */
231
+ disabled?: CalendarDisabled;
232
+ /** Earliest selectable day. Also stops the caption paging back past it. */
233
+ minDate?: Date;
234
+ /** Latest selectable day. */
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;
248
+ /** `0` is Sunday. */
249
+ weekStartsOn?: number;
250
+ /** Draw the neighbouring months' days rather than leaving the cells blank. */
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;
265
+ /** BCP 47 tag for the month and weekday names. The device's own by default. */
266
+ locale?: DateLocale;
267
+ /**
268
+ * Which calendar the months and the day numbers are counted in.
269
+ *
270
+ * `gregory` by default rather than `auto`: a grid whose month boundaries move
271
+ * with the device's language is a surprise, and it is the caller — not the
272
+ * locale — who knows whether the dates being picked are Hijri ones. `auto`
273
+ * follows the locale for the cases where they are the same question.
274
+ */
275
+ calendar?: CalendarSystem;
276
+ }
277
+
278
+ function CalendarRoot<Mode extends CalendarMode = 'single'>({
279
+ className,
280
+ mode = 'single' as Mode,
281
+ selected,
282
+ defaultSelected,
283
+ onSelect,
284
+ month: monthProp,
285
+ defaultMonth,
286
+ onMonthChange,
287
+ numberOfMonths = 1,
288
+ captionLayout = 'label',
289
+ disabled,
290
+ minDate,
291
+ maxDate,
292
+ startMonth,
293
+ endMonth,
294
+ weekStartsOn = 0,
295
+ showOutsideDays = true,
296
+ selectOutsideDays = false,
297
+ bordered = true,
298
+ locale,
299
+ calendar = 'gregory',
300
+ ...props
301
+ }: CalendarProps<Mode>) {
302
+ // Settled once here rather than at each call site, so every part of the
303
+ // grid is certainly reading the same calendar.
304
+ const system = useMemo(() => resolveCalendar(calendar, locale), [calendar, locale]);
305
+ const [internalSelected, setInternalSelected] = useState<CalendarSelection[Mode] | undefined>(
306
+ defaultSelected
307
+ );
308
+ const isControlled = selected !== undefined;
309
+ const value = (isControlled ? selected : internalSelected) as CalendarSelection[Mode];
310
+
311
+ /**
312
+ * The month to open on: whatever is selected, so a picker reopened on a date
313
+ * chosen last year does not land on today and make it look unselected.
314
+ */
315
+ const initialMonth = useMemo(() => {
316
+ if (defaultMonth) return startOfCalendarMonth(defaultMonth, system, locale);
317
+ const first =
318
+ value instanceof Date
319
+ ? value
320
+ : Array.isArray(value)
321
+ ? value[0]
322
+ : value && typeof value === 'object' && 'from' in value
323
+ ? (value as DateRange).from
324
+ : undefined;
325
+ return startOfCalendarMonth(first ?? new Date(), system, locale);
326
+ // Only the first render decides this; after that the month is its own state.
327
+ // eslint-disable-next-line react-hooks/exhaustive-deps
328
+ }, []);
329
+
330
+ const [internalMonth, setInternalMonth] = useState(initialMonth);
331
+ const month = monthProp ? startOfCalendarMonth(monthProp, system, locale) : internalMonth;
332
+
333
+ const setMonth = useCallback(
334
+ (next: Date) => {
335
+ const settled = startOfCalendarMonth(next, system, locale);
336
+ if (!monthProp) setInternalMonth(settled);
337
+ onMonthChange?.(settled);
338
+ },
339
+ [monthProp, onMonthChange, system, locale]
340
+ );
341
+
342
+ const commit = useCallback(
343
+ (next: CalendarSelection[Mode]) => {
344
+ if (!isControlled) setInternalSelected(next);
345
+ onSelect?.(next);
346
+ },
347
+ [isControlled, onSelect]
348
+ );
349
+
350
+ const select = useCallback(
351
+ (date: Date) => {
352
+ const day = startOfDay(date);
353
+
354
+ if (mode === 'multiple') {
355
+ const current = (value as Date[] | undefined) ?? [];
356
+ const without = current.filter((picked) => !isSameDay(picked, day));
357
+ const next = without.length === current.length ? [...current, day] : without;
358
+ commit(next as CalendarSelection[Mode]);
359
+ return;
360
+ }
361
+
362
+ if (mode === 'range') {
363
+ const current = value as DateRange | undefined;
364
+ /*
365
+ * Three states, not two: no range, half a range, a whole range. A tap
366
+ * with half a range open closes it — unless it landed before the open
367
+ * end, in which case it becomes the new start. Anything else would ask
368
+ * someone who picked the wrong month first to undo before retrying.
369
+ */
370
+ if (!current || current.to || isBefore(day, current.from)) {
371
+ commit({ from: day } as CalendarSelection[Mode]);
372
+ } else {
373
+ commit({ from: current.from, to: day } as CalendarSelection[Mode]);
374
+ }
375
+ return;
376
+ }
377
+
378
+ // Single. Tapping the selected day again clears it.
379
+ const current = value as Date | undefined;
380
+ commit((isSameDay(current, day) ? undefined : day) as CalendarSelection[Mode]);
381
+ },
382
+ [mode, value, commit]
383
+ );
384
+
385
+ const context = useMemo(
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]
398
+ );
399
+
400
+ const { root } = calendarVariants({ bordered });
401
+ const months = Math.max(1, numberOfMonths);
402
+
403
+ return (
404
+ <CalendarContext.Provider value={context}>
405
+ <View {...props} className={cn(root(), className)}>
406
+ {Array.from({ length: months }, (_unused, offset) => (
407
+ <View key={offset} className="gap-2">
408
+ <CalendarHeader offset={offset} lastOffset={months - 1} />
409
+ <CalendarGrid
410
+ month={addCalendarMonths(month, offset, system, locale)}
411
+ mode={mode}
412
+ value={value}
413
+ onSelect={select}
414
+ disabled={disabled}
415
+ minDate={minDate}
416
+ maxDate={maxDate}
417
+ weekStartsOn={weekStartsOn}
418
+ showOutsideDays={showOutsideDays}
419
+ selectOutsideDays={selectOutsideDays}
420
+ locale={locale}
421
+ system={system}
422
+ />
423
+ </View>
424
+ ))}
425
+ </View>
426
+ </CalendarContext.Provider>
427
+ );
428
+ }
429
+ CalendarRoot.displayName = 'Calendar';
430
+
431
+ /* -------------------------------------------------------------------------- */
432
+ /* Caption */
433
+ /* -------------------------------------------------------------------------- */
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
+
442
+ /**
443
+ * The row over a month: an arrow at each edge and the month's name between
444
+ * them.
445
+ *
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.
453
+ */
454
+ function CalendarHeader({ offset = 0, lastOffset = 0 }: CalendarHeaderProps) {
455
+ const { month, setMonth, locale, system, minDate, maxDate, captionLayout } =
456
+ useCalendar('Calendar.Header');
457
+ const { header, caption } = calendarVariants();
458
+ const shown = addCalendarMonths(month, offset, system, locale);
459
+
460
+ const canGoBack =
461
+ !minDate || isBefore(startOfCalendarMonth(minDate, system, locale), month);
462
+ const canGoForward =
463
+ !maxDate || isAfter(startOfCalendarMonth(maxDate, system, locale), shown);
464
+
465
+ return (
466
+ <View className={header()}>
467
+ {offset === 0 ? (
468
+ <CalendarNav
469
+ direction="previous"
470
+ disabled={!canGoBack}
471
+ onPress={() => setMonth(addCalendarMonths(month, -1, system, locale))}
472
+ />
473
+ ) : (
474
+ <View className="h-8 w-8" />
475
+ )}
476
+
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>
486
+
487
+ {offset === lastOffset ? (
488
+ <CalendarNav
489
+ direction="next"
490
+ disabled={!canGoForward}
491
+ onPress={() => setMonth(addCalendarMonths(month, 1, system, locale))}
492
+ />
493
+ ) : (
494
+ <View className="h-8 w-8" />
495
+ )}
496
+ </View>
497
+ );
498
+ }
499
+
500
+ export interface CalendarNavProps {
501
+ direction: 'previous' | 'next';
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
+
516
+ return (
517
+ <Pressable
518
+ disabled={disabled}
519
+ onPress={onPress}
520
+ hitSlop={8}
521
+ accessibilityRole="button"
522
+ accessibilityLabel={direction === 'next' ? 'Next month' : 'Previous month'}
523
+ accessibilityState={{ disabled }}
524
+ className={cn(nav(), disabled ? 'opacity-30' : 'active:bg-accent')}
525
+ >
526
+ {direction === 'next' ? <ChevronRightIcon size={18} /> : <ChevronLeftIcon size={18} />}
527
+ </Pressable>
528
+ );
529
+ }
530
+
531
+ /**
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.
538
+ *
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.
542
+ */
543
+ function CalendarDropdowns({ month, offset }: { month: Date; offset: number }) {
544
+ const { setMonth, locale, system, minDate, maxDate, startMonth, endMonth } =
545
+ useCalendar('Calendar.Caption');
546
+
547
+ /*
548
+ * Everything here counts in the calendar on screen, not in the platform's.
549
+ * The names come from a real year of that calendar rather than from twelve
550
+ * Gregorian firsts, and the year list is that calendar's years — a Hijri
551
+ * caption offering Gregorian years is choosing from the wrong set.
552
+ */
553
+ const months = useMemo(
554
+ () => calendarMonthNames(month, system, locale, 'short'),
555
+ [month, system, locale]
556
+ );
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;
566
+ const bounds = {
567
+ from: first ? calendarParts(first, system, locale).year : current.year - 100,
568
+ to: last ? calendarParts(last, system, locale).year : current.year + 10,
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
+ */
578
+ const years = Array.from(
579
+ { length: Math.max(1, bounds.to - bounds.from + 1) },
580
+ (_unused, index) => bounds.from + index
581
+ );
582
+
583
+ /*
584
+ * Navigated by months rather than by building a date, because there is no
585
+ * `new Date(hijriYear, hijriMonth)`. A Hijri year is always twelve months,
586
+ * so a year jump is twelve of them.
587
+ */
588
+ const choose = (months_: number) => {
589
+ setMonth(addCalendarMonths(month, months_ - offset, system, locale));
590
+ };
591
+
592
+ return (
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]);
674
+
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;
692
+ return (
693
+ <Pressable
694
+ key={option}
695
+ onPress={() => {
696
+ onSelect(index);
697
+ setOpen(false);
698
+ }}
699
+ accessibilityRole="button"
700
+ accessibilityState={{ selected }}
701
+ style={{ height: OPTION_HEIGHT }}
702
+ className={cn(
703
+ 'flex-row items-center justify-between gap-2 rounded-xl px-3',
704
+ selected ? 'bg-accent' : 'active:bg-accent'
705
+ )}
706
+ >
707
+ <Text size="sm" weight={selected ? 'semibold' : 'normal'}>
708
+ {option}
709
+ </Text>
710
+ {selected ? <CheckIcon size={16} color={tick} /> : null}
711
+ </Pressable>
712
+ );
713
+ })}
714
+ </ScrollView>
715
+ </Popover.Content>
716
+ </Popover>
717
+ );
718
+ }
719
+
720
+ function CaptionChip({
721
+ label,
722
+ expanded,
723
+ accessibilityLabel,
724
+ onPress,
725
+ }: {
726
+ label: string;
727
+ expanded: boolean;
728
+ accessibilityLabel: string;
729
+ /** Supplied by `Popover.Trigger`, which clones this to open the panel. */
730
+ onPress?: (...args: unknown[]) => void;
731
+ }) {
732
+ return (
733
+ <Pressable
734
+ onPress={onPress}
735
+ accessibilityRole="button"
736
+ accessibilityLabel={accessibilityLabel}
737
+ accessibilityState={{ expanded }}
738
+ className="flex-row items-center gap-1 rounded-lg px-2 py-1.5 active:bg-accent"
739
+ >
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} />
747
+ </Pressable>
748
+ );
749
+ }
750
+
751
+ /* -------------------------------------------------------------------------- */
752
+ /* Grid */
753
+ /* -------------------------------------------------------------------------- */
754
+
755
+ interface GridProps {
756
+ month: Date;
757
+ mode: CalendarMode;
758
+ value: unknown;
759
+ onSelect: (date: Date) => void;
760
+ disabled?: CalendarDisabled;
761
+ minDate?: Date;
762
+ maxDate?: Date;
763
+ weekStartsOn: number;
764
+ showOutsideDays: boolean;
765
+ selectOutsideDays?: boolean;
766
+ locale: DateLocale;
767
+ system: 'gregory' | 'islamic';
768
+ }
769
+
770
+ function CalendarGrid({
771
+ month,
772
+ mode,
773
+ value,
774
+ onSelect,
775
+ disabled,
776
+ minDate,
777
+ maxDate,
778
+ weekStartsOn,
779
+ showOutsideDays,
780
+ selectOutsideDays = false,
781
+ locale,
782
+ system,
783
+ }: GridProps) {
784
+ /*
785
+ * Six rows of consecutive days, anchored on the first of *this* calendar's
786
+ * month. Weekdays are shared between the two calendars — only the month
787
+ * boundaries and the day numbers differ — so the walk itself is the same
788
+ * either way and only where it starts moves.
789
+ */
790
+ const weeks = useMemo(() => {
791
+ const first = startOfCalendarMonth(month, system, locale);
792
+ const lead = (first.getDay() - weekStartsOn + 7) % 7;
793
+ const start = addDays(first, -lead);
794
+ return Array.from({ length: 6 }, (_unusedWeek, week) =>
795
+ Array.from({ length: 7 }, (_unusedDay, day) => addDays(start, week * 7 + day))
796
+ );
797
+ }, [month, weekStartsOn, system, locale]);
798
+ const headings = useMemo(() => weekdayNames(locale, weekStartsOn), [locale, weekStartsOn]);
799
+ const today = startOfDay(new Date());
800
+
801
+ const range = mode === 'range' ? (value as DateRange | undefined) : undefined;
802
+ const multiple = mode === 'multiple' ? ((value as Date[] | undefined) ?? []) : [];
803
+ const single = mode === 'single' ? (value as Date | undefined) : undefined;
804
+
805
+ return (
806
+ // React Native has no `grid` role, so the month announces itself as a list
807
+ // of days and each day carries its own full date as its label.
808
+ <View accessibilityRole="list" className="gap-1">
809
+ <View className="flex-row">
810
+ {headings.map((heading) => (
811
+ <Text key={heading} size="xs" muted className="flex-1 text-center">
812
+ {heading}
813
+ </Text>
814
+ ))}
815
+ </View>
816
+
817
+ {weeks.map((week, weekIndex) => (
818
+ <View key={weekIndex} className="flex-row">
819
+ {week.map((date, dayIndex) => {
820
+ const outside = !isSameCalendarMonth(date, month, system, locale);
821
+ if (outside && !showOutsideDays) {
822
+ // A spacer, not nothing: the row has to stay seven wide or the
823
+ // columns stop lining up with their headings.
824
+ return <View key={dayIndex} className="h-10 flex-1" />;
825
+ }
826
+
827
+ const isStart = mode === 'range' && isSameDay(date, range?.from);
828
+ const isEnd = mode === 'range' && isSameDay(date, range?.to);
829
+
830
+ const selected =
831
+ mode === 'range'
832
+ ? isStart || isEnd
833
+ : mode === 'multiple'
834
+ ? multiple.some((picked) => isSameDay(picked, date))
835
+ : isSameDay(date, single);
836
+
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);
859
+
860
+ return (
861
+ <CalendarDay
862
+ key={dayIndex}
863
+ date={date}
864
+ outside={outside}
865
+ selected={selected}
866
+ inRange={inRange}
867
+ rangeStart={isStart}
868
+ rangeEnd={isEnd}
869
+ today={isSameDay(date, today)}
870
+ disabled={ruledOut}
871
+ openStart={openStart}
872
+ openEnd={openEnd}
873
+ locale={locale}
874
+ system={system}
875
+ onPress={() => onSelect(date)}
876
+ />
877
+ );
878
+ })}
879
+ </View>
880
+ ))}
881
+ </View>
882
+ );
883
+ }
884
+
885
+ interface DayProps {
886
+ date: Date;
887
+ outside: boolean;
888
+ selected: boolean;
889
+ /** Anywhere inside the range, ends included. */
890
+ inRange: boolean;
891
+ rangeStart?: boolean;
892
+ rangeEnd?: boolean;
893
+ today: boolean;
894
+ disabled: boolean;
895
+ /** The band carries on past this cell's leading edge. */
896
+ openStart: boolean;
897
+ /** The band carries on past this cell's trailing edge. */
898
+ openEnd: boolean;
899
+ locale: DateLocale;
900
+ system: 'gregory' | 'islamic';
901
+ onPress: () => void;
902
+ }
903
+
904
+ function CalendarDay({
905
+ date,
906
+ outside,
907
+ selected,
908
+ inRange,
909
+ rangeStart = false,
910
+ rangeEnd = false,
911
+ today,
912
+ disabled,
913
+ openStart,
914
+ openEnd,
915
+ locale,
916
+ system,
917
+ onPress,
918
+ }: DayProps) {
919
+ const styles = dayVariants({
920
+ selected,
921
+ today: today && !selected,
922
+ outside,
923
+ disabled,
924
+ fadedBand: disabled && inRange,
925
+ });
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
+
946
+ return (
947
+ <Pressable
948
+ disabled={disabled}
949
+ onPress={onPress}
950
+ accessibilityRole="button"
951
+ accessibilityLabel={calendarLongDate(date, system, locale)}
952
+ accessibilityState={{ selected, disabled }}
953
+ className={styles.cell()}
954
+ >
955
+ {bandStyle ? <View className={styles.band()} style={bandStyle} /> : null}
956
+ <View className={styles.disc()}>
957
+ <Text className={styles.label()}>
958
+ {calendarDayNumber(date, system, locale)}
959
+ </Text>
960
+ </View>
961
+ </Pressable>
962
+ );
963
+ }
964
+
965
+ export const Calendar = Object.assign(CalendarRoot, {
966
+ Header: CalendarHeader,
967
+ Nav: CalendarNav,
968
+ Grid: CalendarGrid,
969
+ Day: CalendarDay,
970
+ });