panelui-native 0.25.0 → 0.28.1

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 (193) hide show
  1. package/lib/module/components/accordion/index.js +8 -8
  2. package/lib/module/components/accordion/index.js.map +1 -1
  3. package/lib/module/components/alert/index.js +2 -2
  4. package/lib/module/components/alert/index.js.map +1 -1
  5. package/lib/module/components/area-chart/index.js +182 -50
  6. package/lib/module/components/area-chart/index.js.map +1 -1
  7. package/lib/module/components/attachment/index.js +2 -1
  8. package/lib/module/components/attachment/index.js.map +1 -1
  9. package/lib/module/components/avatar/index.js +3 -3
  10. package/lib/module/components/avatar/index.js.map +1 -1
  11. package/lib/module/components/badge/index.js +4 -4
  12. package/lib/module/components/badge/index.js.map +1 -1
  13. package/lib/module/components/bar-chart/index.js +197 -56
  14. package/lib/module/components/bar-chart/index.js.map +1 -1
  15. package/lib/module/components/bottom-sheet/index.js +15 -10
  16. package/lib/module/components/bottom-sheet/index.js.map +1 -1
  17. package/lib/module/components/breadcrumb/index.js +6 -6
  18. package/lib/module/components/breadcrumb/index.js.map +1 -1
  19. package/lib/module/components/button/index.js +11 -6
  20. package/lib/module/components/button/index.js.map +1 -1
  21. package/lib/module/components/calendar/index.js +557 -0
  22. package/lib/module/components/calendar/index.js.map +1 -0
  23. package/lib/module/components/carousel/index.js +680 -0
  24. package/lib/module/components/carousel/index.js.map +1 -0
  25. package/lib/module/components/chip/index.js +4 -4
  26. package/lib/module/components/chip/index.js.map +1 -1
  27. package/lib/module/components/date-picker/index.js +158 -0
  28. package/lib/module/components/date-picker/index.js.map +1 -0
  29. package/lib/module/components/dialog/index.js +7 -2
  30. package/lib/module/components/dialog/index.js.map +1 -1
  31. package/lib/module/components/direction/index.js +2 -1
  32. package/lib/module/components/direction/index.js.map +1 -1
  33. package/lib/module/components/empty-state/index.js +2 -2
  34. package/lib/module/components/empty-state/index.js.map +1 -1
  35. package/lib/module/components/field/index.js +5 -5
  36. package/lib/module/components/field/index.js.map +1 -1
  37. package/lib/module/components/flow/index.js +3 -3
  38. package/lib/module/components/flow/index.js.map +1 -1
  39. package/lib/module/components/frame/index.js +5 -5
  40. package/lib/module/components/frame/index.js.map +1 -1
  41. package/lib/module/components/input-group/index.js +3 -2
  42. package/lib/module/components/input-group/index.js.map +1 -1
  43. package/lib/module/components/item/index.js +9 -9
  44. package/lib/module/components/item/index.js.map +1 -1
  45. package/lib/module/components/label/index.js +4 -4
  46. package/lib/module/components/label/index.js.map +1 -1
  47. package/lib/module/components/line-chart/index.js +167 -44
  48. package/lib/module/components/line-chart/index.js.map +1 -1
  49. package/lib/module/components/map/index.js +7 -7
  50. package/lib/module/components/map/index.js.map +1 -1
  51. package/lib/module/components/marker/index.js +3 -3
  52. package/lib/module/components/marker/index.js.map +1 -1
  53. package/lib/module/components/message/index.js +7 -7
  54. package/lib/module/components/message/index.js.map +1 -1
  55. package/lib/module/components/message-scroller/index.js +5 -4
  56. package/lib/module/components/message-scroller/index.js.map +1 -1
  57. package/lib/module/components/popover/index.js +39 -7
  58. package/lib/module/components/popover/index.js.map +1 -1
  59. package/lib/module/components/radio-group/index.js +2 -2
  60. package/lib/module/components/radio-group/index.js.map +1 -1
  61. package/lib/module/components/section-rail/index.js +4 -4
  62. package/lib/module/components/section-rail/index.js.map +1 -1
  63. package/lib/module/components/select/index.js +10 -4
  64. package/lib/module/components/select/index.js.map +1 -1
  65. package/lib/module/components/steps/index.js +4 -4
  66. package/lib/module/components/steps/index.js.map +1 -1
  67. package/lib/module/components/tabs/index.js +7 -7
  68. package/lib/module/components/tabs/index.js.map +1 -1
  69. package/lib/module/components/timeline/index.js +4 -4
  70. package/lib/module/components/timeline/index.js.map +1 -1
  71. package/lib/module/components/toast/index.js +2 -2
  72. package/lib/module/components/toast/index.js.map +1 -1
  73. package/lib/module/components/toggle-button/index.js +5 -5
  74. package/lib/module/components/toggle-button/index.js.map +1 -1
  75. package/lib/module/components/tooltip/index.js +492 -0
  76. package/lib/module/components/tooltip/index.js.map +1 -0
  77. package/lib/module/components/typography/index.js +2 -2
  78. package/lib/module/components/typography/index.js.map +1 -1
  79. package/lib/module/hooks/index.js +1 -0
  80. package/lib/module/hooks/index.js.map +1 -1
  81. package/lib/module/hooks/use-back-handler.js +37 -0
  82. package/lib/module/hooks/use-back-handler.js.map +1 -0
  83. package/lib/module/icons/index.js +63 -0
  84. package/lib/module/icons/index.js.map +1 -1
  85. package/lib/module/index.js +5 -1
  86. package/lib/module/index.js.map +1 -1
  87. package/lib/module/primitives/keyboard-avoider.js +2 -1
  88. package/lib/module/primitives/keyboard-avoider.js.map +1 -1
  89. package/lib/module/primitives/text.js +25 -1
  90. package/lib/module/primitives/text.js.map +1 -1
  91. package/lib/module/utils/date.js +386 -0
  92. package/lib/module/utils/date.js.map +1 -0
  93. package/lib/typescript/src/components/accordion/index.d.ts.map +1 -1
  94. package/lib/typescript/src/components/alert/index.d.ts.map +1 -1
  95. package/lib/typescript/src/components/area-chart/index.d.ts +41 -1
  96. package/lib/typescript/src/components/area-chart/index.d.ts.map +1 -1
  97. package/lib/typescript/src/components/attachment/index.d.ts.map +1 -1
  98. package/lib/typescript/src/components/bar-chart/index.d.ts +44 -2
  99. package/lib/typescript/src/components/bar-chart/index.d.ts.map +1 -1
  100. package/lib/typescript/src/components/bottom-sheet/index.d.ts.map +1 -1
  101. package/lib/typescript/src/components/breadcrumb/index.d.ts.map +1 -1
  102. package/lib/typescript/src/components/button/index.d.ts.map +1 -1
  103. package/lib/typescript/src/components/calendar/index.d.ts +108 -0
  104. package/lib/typescript/src/components/calendar/index.d.ts.map +1 -0
  105. package/lib/typescript/src/components/carousel/index.d.ts +179 -0
  106. package/lib/typescript/src/components/carousel/index.d.ts.map +1 -0
  107. package/lib/typescript/src/components/chip/index.d.ts.map +1 -1
  108. package/lib/typescript/src/components/date-picker/index.d.ts +91 -0
  109. package/lib/typescript/src/components/date-picker/index.d.ts.map +1 -0
  110. package/lib/typescript/src/components/dialog/index.d.ts.map +1 -1
  111. package/lib/typescript/src/components/direction/index.d.ts.map +1 -1
  112. package/lib/typescript/src/components/empty-state/index.d.ts.map +1 -1
  113. package/lib/typescript/src/components/field/index.d.ts.map +1 -1
  114. package/lib/typescript/src/components/frame/index.d.ts.map +1 -1
  115. package/lib/typescript/src/components/input-group/index.d.ts.map +1 -1
  116. package/lib/typescript/src/components/item/index.d.ts.map +1 -1
  117. package/lib/typescript/src/components/label/index.d.ts.map +1 -1
  118. package/lib/typescript/src/components/line-chart/index.d.ts +41 -2
  119. package/lib/typescript/src/components/line-chart/index.d.ts.map +1 -1
  120. package/lib/typescript/src/components/map/index.d.ts.map +1 -1
  121. package/lib/typescript/src/components/marker/index.d.ts.map +1 -1
  122. package/lib/typescript/src/components/message/index.d.ts.map +1 -1
  123. package/lib/typescript/src/components/message-scroller/index.d.ts.map +1 -1
  124. package/lib/typescript/src/components/popover/index.d.ts.map +1 -1
  125. package/lib/typescript/src/components/select/index.d.ts.map +1 -1
  126. package/lib/typescript/src/components/steps/index.d.ts.map +1 -1
  127. package/lib/typescript/src/components/tabs/index.d.ts.map +1 -1
  128. package/lib/typescript/src/components/timeline/index.d.ts.map +1 -1
  129. package/lib/typescript/src/components/toast/index.d.ts.map +1 -1
  130. package/lib/typescript/src/components/toggle-button/index.d.ts.map +1 -1
  131. package/lib/typescript/src/components/tooltip/index.d.ts +125 -0
  132. package/lib/typescript/src/components/tooltip/index.d.ts.map +1 -0
  133. package/lib/typescript/src/components/typography/index.d.ts.map +1 -1
  134. package/lib/typescript/src/hooks/index.d.ts +1 -0
  135. package/lib/typescript/src/hooks/index.d.ts.map +1 -1
  136. package/lib/typescript/src/hooks/use-back-handler.d.ts +19 -0
  137. package/lib/typescript/src/hooks/use-back-handler.d.ts.map +1 -0
  138. package/lib/typescript/src/icons/index.d.ts +3 -0
  139. package/lib/typescript/src/icons/index.d.ts.map +1 -1
  140. package/lib/typescript/src/index.d.ts +8 -4
  141. package/lib/typescript/src/index.d.ts.map +1 -1
  142. package/lib/typescript/src/primitives/keyboard-avoider.d.ts.map +1 -1
  143. package/lib/typescript/src/primitives/text.d.ts +20 -0
  144. package/lib/typescript/src/primitives/text.d.ts.map +1 -1
  145. package/lib/typescript/src/utils/date.d.ts +116 -0
  146. package/lib/typescript/src/utils/date.d.ts.map +1 -0
  147. package/package.json +1 -1
  148. package/src/components/accordion/index.tsx +8 -12
  149. package/src/components/alert/index.tsx +2 -2
  150. package/src/components/area-chart/index.tsx +208 -55
  151. package/src/components/attachment/index.tsx +2 -2
  152. package/src/components/avatar/index.tsx +3 -3
  153. package/src/components/badge/index.tsx +6 -6
  154. package/src/components/bar-chart/index.tsx +214 -54
  155. package/src/components/bottom-sheet/index.tsx +17 -16
  156. package/src/components/breadcrumb/index.tsx +6 -8
  157. package/src/components/button/index.tsx +12 -8
  158. package/src/components/calendar/index.tsx +703 -0
  159. package/src/components/carousel/index.tsx +788 -0
  160. package/src/components/chip/index.tsx +4 -6
  161. package/src/components/date-picker/index.tsx +237 -0
  162. package/src/components/dialog/index.tsx +7 -2
  163. package/src/components/direction/index.tsx +2 -1
  164. package/src/components/empty-state/index.tsx +2 -2
  165. package/src/components/field/index.tsx +3 -5
  166. package/src/components/flow/index.tsx +3 -3
  167. package/src/components/frame/index.tsx +5 -7
  168. package/src/components/input-group/index.tsx +3 -2
  169. package/src/components/item/index.tsx +9 -9
  170. package/src/components/label/index.tsx +2 -2
  171. package/src/components/line-chart/index.tsx +189 -50
  172. package/src/components/map/index.tsx +7 -9
  173. package/src/components/marker/index.tsx +3 -3
  174. package/src/components/message/index.tsx +7 -7
  175. package/src/components/message-scroller/index.tsx +5 -4
  176. package/src/components/popover/index.tsx +43 -7
  177. package/src/components/radio-group/index.tsx +2 -2
  178. package/src/components/section-rail/index.tsx +4 -4
  179. package/src/components/select/index.tsx +10 -4
  180. package/src/components/steps/index.tsx +4 -4
  181. package/src/components/tabs/index.tsx +7 -9
  182. package/src/components/timeline/index.tsx +4 -4
  183. package/src/components/toast/index.tsx +2 -2
  184. package/src/components/toggle-button/index.tsx +5 -7
  185. package/src/components/tooltip/index.tsx +610 -0
  186. package/src/components/typography/index.tsx +2 -2
  187. package/src/hooks/index.ts +1 -0
  188. package/src/hooks/use-back-handler.ts +37 -0
  189. package/src/icons/index.tsx +45 -0
  190. package/src/index.ts +45 -0
  191. package/src/primitives/keyboard-avoider.tsx +2 -1
  192. package/src/primitives/text.tsx +28 -1
  193. package/src/utils/date.ts +446 -0
@@ -0,0 +1,703 @@
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
+ * ## Why a range is drawn in three pieces
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 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.
26
+ *
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.
29
+ */
30
+ import {
31
+ createContext,
32
+ useCallback,
33
+ useContext,
34
+ useMemo,
35
+ useState,
36
+ type ReactNode,
37
+ } from 'react';
38
+ import { Pressable, View, type ViewProps } from 'react-native';
39
+ import { tv } from 'tailwind-variants';
40
+ import { ChevronLeftIcon, ChevronRightIcon } from '../../icons';
41
+ import { Text } from '../../primitives/text';
42
+ import { cn } from '../../utils/cn';
43
+ import {
44
+ addCalendarMonths,
45
+ addDays,
46
+ calendarDayNumber,
47
+ calendarLongDate,
48
+ calendarMonthLabel,
49
+ calendarMonthNames,
50
+ calendarParts,
51
+ isAfter,
52
+ isBefore,
53
+ isSameCalendarMonth,
54
+ isSameDay,
55
+ isWithin,
56
+ resolveCalendar,
57
+ startOfCalendarMonth,
58
+ startOfDay,
59
+ weekdayNames,
60
+ type CalendarSystem,
61
+ type DateLocale,
62
+ } from '../../utils/date';
63
+
64
+ export type CalendarMode = 'single' | 'multiple' | 'range';
65
+ export type CalendarCaptionLayout = 'label' | 'dropdown';
66
+
67
+ /** A range under construction: `to` is undefined between the two taps. */
68
+ export interface DateRange {
69
+ from: Date;
70
+ to?: Date;
71
+ }
72
+
73
+ /**
74
+ * What a `mode` selects. Written as a map so the root, the parts and the
75
+ * caller all read the same association rather than each casting their own way.
76
+ */
77
+ export interface CalendarSelection {
78
+ single: Date | undefined;
79
+ multiple: Date[];
80
+ range: DateRange | undefined;
81
+ }
82
+
83
+ /**
84
+ * Which days cannot be picked. A list, a span, or a rule — a rule because
85
+ * "no weekends" and "not in the past" are the two most common cases and
86
+ * neither can be written as a list that stays right tomorrow.
87
+ */
88
+ export type CalendarDisabled =
89
+ | Date[]
90
+ | { from: Date; to: Date }
91
+ | ((date: Date) => boolean);
92
+
93
+ const dayVariants = tv({
94
+ slots: {
95
+ cell: 'flex-1 items-center justify-center',
96
+ /** 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: '',
101
+ },
102
+ variants: {
103
+ selected: { true: { disc: 'bg-primary', label: 'text-primary-foreground' } },
104
+ today: { true: { disc: 'border border-primary', label: 'text-primary' } },
105
+ outside: { true: { label: 'text-muted-foreground/40' } },
106
+ disabled: { true: { label: 'text-muted-foreground/30' } },
107
+ inRange: { true: { band: 'bg-accent' } },
108
+ },
109
+ compoundVariants: [
110
+ // A selected day inside a range keeps the solid disc; the band passes
111
+ // under it rather than the disc losing to it.
112
+ { selected: true, today: true, class: { label: 'text-primary-foreground' } },
113
+ ],
114
+ });
115
+
116
+ interface CalendarContextValue {
117
+ month: Date;
118
+ setMonth: (month: Date) => void;
119
+ locale: DateLocale;
120
+ /** Already resolved — `auto` is settled once, at the root. */
121
+ system: 'gregory' | 'islamic';
122
+ minDate?: Date;
123
+ maxDate?: Date;
124
+ captionLayout: CalendarCaptionLayout;
125
+ }
126
+
127
+ const CalendarContext = createContext<CalendarContextValue | null>(null);
128
+
129
+ function useCalendar(component: string): CalendarContextValue {
130
+ const context = useContext(CalendarContext);
131
+ if (!context) {
132
+ throw new Error(`${component} must be used within a <Calendar>`);
133
+ }
134
+ return context;
135
+ }
136
+
137
+ /** Whether a rule, a list or a span rules this day out. */
138
+ function isDisabled(
139
+ date: Date,
140
+ disabled: CalendarDisabled | undefined,
141
+ minDate: Date | undefined,
142
+ maxDate: Date | undefined
143
+ ): boolean {
144
+ if (minDate && isBefore(date, minDate)) return true;
145
+ if (maxDate && isAfter(date, maxDate)) return true;
146
+ if (!disabled) return false;
147
+ if (typeof disabled === 'function') return disabled(date);
148
+ if (Array.isArray(disabled)) return disabled.some((day) => isSameDay(day, date));
149
+ return isWithin(date, disabled.from, disabled.to);
150
+ }
151
+
152
+ export interface CalendarProps<Mode extends CalendarMode = 'single'>
153
+ extends Omit<ViewProps, 'children'> {
154
+ className?: string;
155
+ /** One day, several, or a span with two ends. */
156
+ mode?: Mode;
157
+ /** Controlled selection. Its shape follows `mode`. */
158
+ selected?: CalendarSelection[Mode];
159
+ /** Starting selection when uncontrolled. */
160
+ defaultSelected?: CalendarSelection[Mode];
161
+ onSelect?: (selected: CalendarSelection[Mode]) => void;
162
+ /** Controlled visible month. Any day inside it will do. */
163
+ month?: Date;
164
+ /** Month shown first when uncontrolled. Defaults to the selection, or today. */
165
+ defaultMonth?: Date;
166
+ onMonthChange?: (month: Date) => void;
167
+ /** Months side by side. Two is what picking a range across a boundary wants. */
168
+ numberOfMonths?: number;
169
+ /**
170
+ * `dropdown` turns the caption into month and year pickers, which is the
171
+ * difference between choosing a birthday in four taps and in four hundred.
172
+ */
173
+ captionLayout?: CalendarCaptionLayout;
174
+ /** Days that cannot be picked: a list, a span, or a rule. */
175
+ disabled?: CalendarDisabled;
176
+ /** Earliest selectable day. Also stops the caption paging back past it. */
177
+ minDate?: Date;
178
+ /** Latest selectable day. */
179
+ maxDate?: Date;
180
+ /** `0` is Sunday. */
181
+ weekStartsOn?: number;
182
+ /** Draw the neighbouring months' days rather than leaving the cells blank. */
183
+ showOutsideDays?: boolean;
184
+ /** BCP 47 tag for the month and weekday names. The device's own by default. */
185
+ locale?: DateLocale;
186
+ /**
187
+ * Which calendar the months and the day numbers are counted in.
188
+ *
189
+ * `gregory` by default rather than `auto`: a grid whose month boundaries move
190
+ * with the device's language is a surprise, and it is the caller — not the
191
+ * locale — who knows whether the dates being picked are Hijri ones. `auto`
192
+ * follows the locale for the cases where they are the same question.
193
+ */
194
+ calendar?: CalendarSystem;
195
+ }
196
+
197
+ function CalendarRoot<Mode extends CalendarMode = 'single'>({
198
+ className,
199
+ mode = 'single' as Mode,
200
+ selected,
201
+ defaultSelected,
202
+ onSelect,
203
+ month: monthProp,
204
+ defaultMonth,
205
+ onMonthChange,
206
+ numberOfMonths = 1,
207
+ captionLayout = 'label',
208
+ disabled,
209
+ minDate,
210
+ maxDate,
211
+ weekStartsOn = 0,
212
+ showOutsideDays = true,
213
+ locale,
214
+ calendar = 'gregory',
215
+ ...props
216
+ }: CalendarProps<Mode>) {
217
+ // Settled once here rather than at each call site, so every part of the
218
+ // grid is certainly reading the same calendar.
219
+ const system = useMemo(() => resolveCalendar(calendar, locale), [calendar, locale]);
220
+ const [internalSelected, setInternalSelected] = useState<CalendarSelection[Mode] | undefined>(
221
+ defaultSelected
222
+ );
223
+ const isControlled = selected !== undefined;
224
+ const value = (isControlled ? selected : internalSelected) as CalendarSelection[Mode];
225
+
226
+ /**
227
+ * The month to open on: whatever is selected, so a picker reopened on a date
228
+ * chosen last year does not land on today and make it look unselected.
229
+ */
230
+ const initialMonth = useMemo(() => {
231
+ if (defaultMonth) return startOfCalendarMonth(defaultMonth, system, locale);
232
+ const first =
233
+ value instanceof Date
234
+ ? value
235
+ : Array.isArray(value)
236
+ ? value[0]
237
+ : value && typeof value === 'object' && 'from' in value
238
+ ? (value as DateRange).from
239
+ : undefined;
240
+ return startOfCalendarMonth(first ?? new Date(), system, locale);
241
+ // Only the first render decides this; after that the month is its own state.
242
+ // eslint-disable-next-line react-hooks/exhaustive-deps
243
+ }, []);
244
+
245
+ const [internalMonth, setInternalMonth] = useState(initialMonth);
246
+ const month = monthProp ? startOfCalendarMonth(monthProp, system, locale) : internalMonth;
247
+
248
+ const setMonth = useCallback(
249
+ (next: Date) => {
250
+ const settled = startOfCalendarMonth(next, system, locale);
251
+ if (!monthProp) setInternalMonth(settled);
252
+ onMonthChange?.(settled);
253
+ },
254
+ [monthProp, onMonthChange, system, locale]
255
+ );
256
+
257
+ const commit = useCallback(
258
+ (next: CalendarSelection[Mode]) => {
259
+ if (!isControlled) setInternalSelected(next);
260
+ onSelect?.(next);
261
+ },
262
+ [isControlled, onSelect]
263
+ );
264
+
265
+ const select = useCallback(
266
+ (date: Date) => {
267
+ const day = startOfDay(date);
268
+
269
+ if (mode === 'multiple') {
270
+ const current = (value as Date[] | undefined) ?? [];
271
+ const without = current.filter((picked) => !isSameDay(picked, day));
272
+ const next = without.length === current.length ? [...current, day] : without;
273
+ commit(next as CalendarSelection[Mode]);
274
+ return;
275
+ }
276
+
277
+ if (mode === 'range') {
278
+ const current = value as DateRange | undefined;
279
+ /*
280
+ * Three states, not two: no range, half a range, a whole range. A tap
281
+ * with half a range open closes it — unless it landed before the open
282
+ * end, in which case it becomes the new start. Anything else would ask
283
+ * someone who picked the wrong month first to undo before retrying.
284
+ */
285
+ if (!current || current.to || isBefore(day, current.from)) {
286
+ commit({ from: day } as CalendarSelection[Mode]);
287
+ } else {
288
+ commit({ from: current.from, to: day } as CalendarSelection[Mode]);
289
+ }
290
+ return;
291
+ }
292
+
293
+ // Single. Tapping the selected day again clears it.
294
+ const current = value as Date | undefined;
295
+ commit((isSameDay(current, day) ? undefined : day) as CalendarSelection[Mode]);
296
+ },
297
+ [mode, value, commit]
298
+ );
299
+
300
+ const context = useMemo(
301
+ () => ({ month, setMonth, locale, system, minDate, maxDate, captionLayout }),
302
+ [month, setMonth, locale, system, minDate, maxDate, captionLayout]
303
+ );
304
+
305
+ return (
306
+ <CalendarContext.Provider value={context}>
307
+ <View {...props} className={cn('gap-4', className)}>
308
+ {Array.from({ length: Math.max(1, numberOfMonths) }, (_unused, offset) => (
309
+ <View key={offset} className="gap-2">
310
+ <CalendarCaption offset={offset} lastOffset={numberOfMonths - 1} />
311
+ <CalendarGrid
312
+ month={addCalendarMonths(month, offset, system, locale)}
313
+ mode={mode}
314
+ value={value}
315
+ onSelect={select}
316
+ disabled={disabled}
317
+ minDate={minDate}
318
+ maxDate={maxDate}
319
+ weekStartsOn={weekStartsOn}
320
+ showOutsideDays={showOutsideDays}
321
+ locale={locale}
322
+ system={system}
323
+ />
324
+ </View>
325
+ ))}
326
+ </View>
327
+ </CalendarContext.Provider>
328
+ );
329
+ }
330
+ CalendarRoot.displayName = 'Calendar';
331
+
332
+ /* -------------------------------------------------------------------------- */
333
+ /* Caption */
334
+ /* -------------------------------------------------------------------------- */
335
+
336
+ /**
337
+ * The month name and the arrows, or the month and year pickers.
338
+ *
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.
342
+ */
343
+ function CalendarCaption({ offset, lastOffset }: { offset: number; lastOffset: number }) {
344
+ const { month, setMonth, locale, system, minDate, maxDate, captionLayout } =
345
+ useCalendar('Calendar.Caption');
346
+ const shown = addCalendarMonths(month, offset, system, locale);
347
+
348
+ const canGoBack =
349
+ !minDate || isBefore(startOfCalendarMonth(minDate, system, locale), month);
350
+ const canGoForward =
351
+ !maxDate || isAfter(startOfCalendarMonth(maxDate, system, locale), shown);
352
+
353
+ return (
354
+ <View className="h-9 flex-row items-center justify-between">
355
+ {offset === 0 ? (
356
+ <CalendarNav
357
+ direction="previous"
358
+ disabled={!canGoBack}
359
+ onPress={() => setMonth(addCalendarMonths(month, -1, system, locale))}
360
+ />
361
+ ) : (
362
+ <View className="h-7 w-7" />
363
+ )}
364
+
365
+ {captionLayout === 'dropdown' ? (
366
+ <CalendarDropdowns month={shown} offset={offset} />
367
+ ) : (
368
+ <Text weight="semibold">{calendarMonthLabel(shown, system, locale)}</Text>
369
+ )}
370
+
371
+ {offset === lastOffset ? (
372
+ <CalendarNav
373
+ direction="next"
374
+ disabled={!canGoForward}
375
+ onPress={() => setMonth(addCalendarMonths(month, 1, system, locale))}
376
+ />
377
+ ) : (
378
+ <View className="h-7 w-7" />
379
+ )}
380
+ </View>
381
+ );
382
+ }
383
+
384
+ function CalendarNav({
385
+ direction,
386
+ disabled,
387
+ onPress,
388
+ }: {
389
+ direction: 'previous' | 'next';
390
+ disabled: boolean;
391
+ onPress: () => void;
392
+ }) {
393
+ return (
394
+ <Pressable
395
+ disabled={disabled}
396
+ onPress={onPress}
397
+ hitSlop={8}
398
+ accessibilityRole="button"
399
+ accessibilityLabel={direction === 'next' ? 'Next month' : 'Previous month'}
400
+ 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
+ )}
405
+ >
406
+ {direction === 'next' ? <ChevronRightIcon size={16} /> : <ChevronLeftIcon size={16} />}
407
+ </Pressable>
408
+ );
409
+ }
410
+
411
+ /**
412
+ * Month and year as two rows of chips rather than as native pickers.
413
+ *
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.
417
+ */
418
+ 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);
421
+
422
+ /*
423
+ * Everything here counts in the calendar on screen, not in the platform's.
424
+ * The names come from a real year of that calendar rather than from twelve
425
+ * Gregorian firsts, and the year list is that calendar's years — a Hijri
426
+ * caption offering Gregorian years is choosing from the wrong set.
427
+ */
428
+ const months = useMemo(
429
+ () => calendarMonthNames(month, system, locale, 'short'),
430
+ [month, system, locale]
431
+ );
432
+ const current = calendarParts(month, system, locale);
433
+ const bounds = {
434
+ from: minDate ? calendarParts(minDate, system, locale).year : current.year - 100,
435
+ to: maxDate ? calendarParts(maxDate, system, locale).year : current.year + 10,
436
+ };
437
+ const years = Array.from(
438
+ { length: Math.max(1, bounds.to - bounds.from + 1) },
439
+ (_unused, index) => bounds.from + index
440
+ );
441
+
442
+ /*
443
+ * Navigated by months rather than by building a date, because there is no
444
+ * `new Date(hijriYear, hijriMonth)`. A Hijri year is always twelve months,
445
+ * so a year jump is twelve of them.
446
+ */
447
+ const choose = (months_: number) => {
448
+ setMonth(addCalendarMonths(month, months_ - offset, system, locale));
449
+ setOpen(null);
450
+ };
451
+
452
+ 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>
466
+
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;
474
+ return (
475
+ <Pressable
476
+ key={option}
477
+ onPress={() =>
478
+ choose(
479
+ open === 'month'
480
+ ? index - (current.month - 1)
481
+ : (years[index]! - current.year) * 12
482
+ )
483
+ }
484
+ accessibilityRole="button"
485
+ accessibilityState={{ selected: active }}
486
+ className={cn(
487
+ 'rounded-md px-2.5 py-1.5',
488
+ active ? 'bg-primary' : 'active:bg-accent'
489
+ )}
490
+ >
491
+ <Text size="sm" className={active ? 'text-primary-foreground' : undefined}>
492
+ {option}
493
+ </Text>
494
+ </Pressable>
495
+ );
496
+ })}
497
+ </View>
498
+ ) : null}
499
+ </View>
500
+ );
501
+ }
502
+
503
+ function CaptionChip({
504
+ label,
505
+ expanded,
506
+ onPress,
507
+ }: {
508
+ label: string;
509
+ expanded: boolean;
510
+ onPress: () => void;
511
+ }) {
512
+ return (
513
+ <Pressable
514
+ onPress={onPress}
515
+ accessibilityRole="button"
516
+ accessibilityState={{ expanded }}
517
+ className="flex-row items-center gap-1 rounded-md px-2 py-1 active:bg-accent"
518
+ >
519
+ <Text weight="semibold">{label}</Text>
520
+ </Pressable>
521
+ );
522
+ }
523
+
524
+ /* -------------------------------------------------------------------------- */
525
+ /* Grid */
526
+ /* -------------------------------------------------------------------------- */
527
+
528
+ interface GridProps {
529
+ month: Date;
530
+ mode: CalendarMode;
531
+ value: unknown;
532
+ onSelect: (date: Date) => void;
533
+ disabled?: CalendarDisabled;
534
+ minDate?: Date;
535
+ maxDate?: Date;
536
+ weekStartsOn: number;
537
+ showOutsideDays: boolean;
538
+ locale: DateLocale;
539
+ system: 'gregory' | 'islamic';
540
+ }
541
+
542
+ function CalendarGrid({
543
+ month,
544
+ mode,
545
+ value,
546
+ onSelect,
547
+ disabled,
548
+ minDate,
549
+ maxDate,
550
+ weekStartsOn,
551
+ showOutsideDays,
552
+ locale,
553
+ system,
554
+ }: GridProps) {
555
+ /*
556
+ * Six rows of consecutive days, anchored on the first of *this* calendar's
557
+ * month. Weekdays are shared between the two calendars — only the month
558
+ * boundaries and the day numbers differ — so the walk itself is the same
559
+ * either way and only where it starts moves.
560
+ */
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]);
569
+ const headings = useMemo(() => weekdayNames(locale, weekStartsOn), [locale, weekStartsOn]);
570
+ const today = startOfDay(new Date());
571
+
572
+ const range = mode === 'range' ? (value as DateRange | undefined) : undefined;
573
+ const multiple = mode === 'multiple' ? ((value as Date[] | undefined) ?? []) : [];
574
+ const single = mode === 'single' ? (value as Date | undefined) : undefined;
575
+
576
+ return (
577
+ // React Native has no `grid` role, so the month announces itself as a list
578
+ // of days and each day carries its own full date as its label.
579
+ <View accessibilityRole="list" className="gap-1">
580
+ <View className="flex-row">
581
+ {headings.map((heading) => (
582
+ <Text key={heading} size="xs" muted className="flex-1 text-center">
583
+ {heading}
584
+ </Text>
585
+ ))}
586
+ </View>
587
+
588
+ {weeks.map((week, weekIndex) => (
589
+ <View key={weekIndex} className="flex-row">
590
+ {week.map((date, dayIndex) => {
591
+ const outside = !isSameCalendarMonth(date, month, system, locale);
592
+ if (outside && !showOutsideDays) {
593
+ // A spacer, not nothing: the row has to stay seven wide or the
594
+ // columns stop lining up with their headings.
595
+ return <View key={dayIndex} className="h-10 flex-1" />;
596
+ }
597
+
598
+ const selected =
599
+ mode === 'range'
600
+ ? isSameDay(date, range?.from) || isSameDay(date, range?.to)
601
+ : mode === 'multiple'
602
+ ? multiple.some((picked) => isSameDay(picked, date))
603
+ : isSameDay(date, single);
604
+
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;
608
+
609
+ return (
610
+ <CalendarDay
611
+ key={dayIndex}
612
+ date={date}
613
+ outside={outside}
614
+ selected={selected}
615
+ inRange={inRange}
616
+ 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}
622
+ locale={locale}
623
+ system={system}
624
+ onPress={() => onSelect(date)}
625
+ />
626
+ );
627
+ })}
628
+ </View>
629
+ ))}
630
+ </View>
631
+ );
632
+ }
633
+
634
+ interface DayProps {
635
+ date: Date;
636
+ outside: boolean;
637
+ selected: boolean;
638
+ inRange: boolean;
639
+ today: boolean;
640
+ disabled: boolean;
641
+ openStart: boolean;
642
+ openEnd: boolean;
643
+ locale: DateLocale;
644
+ system: 'gregory' | 'islamic';
645
+ onPress: () => void;
646
+ }
647
+
648
+ function CalendarDay({
649
+ date,
650
+ outside,
651
+ selected,
652
+ inRange,
653
+ today,
654
+ disabled,
655
+ openStart,
656
+ openEnd,
657
+ locale,
658
+ system,
659
+ onPress,
660
+ }: DayProps) {
661
+ const styles = dayVariants({
662
+ selected,
663
+ today: today && !selected,
664
+ outside,
665
+ disabled,
666
+ inRange,
667
+ });
668
+
669
+ return (
670
+ <Pressable
671
+ disabled={disabled}
672
+ onPress={onPress}
673
+ accessibilityRole="button"
674
+ accessibilityLabel={calendarLongDate(date, system, locale)}
675
+ accessibilityState={{ selected, disabled }}
676
+ className={cn(styles.cell(), 'h-10')}
677
+ >
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())}>
693
+ {calendarDayNumber(date, system, locale)}
694
+ </Text>
695
+ </View>
696
+ </Pressable>
697
+ );
698
+ }
699
+
700
+ export const Calendar = Object.assign(CalendarRoot, {
701
+ Grid: CalendarGrid,
702
+ Day: CalendarDay,
703
+ });