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
@@ -17,25 +17,35 @@
17
17
  * between months. So the grid is six rows always, and the spare cells hold the
18
18
  * neighbouring months' days.
19
19
  *
20
- * ## Why a range is drawn in three pieces
20
+ * ## How a range is drawn
21
21
  *
22
22
  * On the web the band under a selected range is one background with the ends
23
23
  * rounded by `:first-child` and `:last-child`. There are no pseudo-classes
24
- * here, so each day works out for itself whether it is a start, a middle or an
25
- * end — and, separately, whether it is at the edge of its own row, because a
26
- * range that runs over a weekend has to close off on Saturday and open again
27
- * on Sunday rather than trailing into the gap.
24
+ * here, so each cell draws its own piece of the band as a view *behind* the
25
+ * number, and works out for itself where that piece has to stop.
28
26
  *
29
- * Every cell therefore draws its band as a view *behind* the number, sized and
30
- * rounded from those flags, rather than as a background on the cell itself.
27
+ * Two questions, and they are not the same one:
28
+ *
29
+ * - **How wide.** A day between the two ends fills its cell, so the pieces meet
30
+ * and the band reads as continuous. An end fills half its cell, from the
31
+ * centre outwards towards the middle of the range — the half that is not
32
+ * covered by the disc, so the disc appears to sit *on* the band rather than
33
+ * beside it. That is the join the old three-piece version never made, which
34
+ * is why a range used to read as two discs with a gap and a stripe.
35
+ * - **Where it is rounded.** Only where it genuinely stops: at an end of the
36
+ * range, or at the edge of a row, because a range running over a weekend has
37
+ * to close off on Saturday and open again on Sunday rather than trailing into
38
+ * the gap between the two.
31
39
  */
32
- import { createContext, useCallback, useContext, useMemo, useState } from 'react';
33
- import { Pressable, View } from 'react-native';
40
+ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react';
41
+ import { AppState, Pressable, ScrollView, View } from 'react-native';
34
42
  import { tv } from 'tailwind-variants';
35
- import { ChevronLeftIcon, ChevronRightIcon } from "../../icons/index.js";
43
+ import { useCSSVariable } from 'uniwind';
44
+ import { CheckIcon, ChevronDownIcon, ChevronLeftIcon, ChevronRightIcon } from "../../icons/index.js";
36
45
  import { Text } from "../../primitives/text.js";
37
46
  import { cn } from "../../utils/cn.js";
38
- import { addCalendarMonths, addDays, calendarDayNumber, calendarLongDate, calendarMonthLabel, calendarMonthNames, calendarParts, isAfter, isBefore, isSameCalendarMonth, isSameDay, isWithin, resolveCalendar, startOfCalendarMonth, startOfDay, weekdayNames } from "../../utils/date.js";
47
+ import { Popover } from "../popover/index.js";
48
+ import { addCalendarMonths, calendarDayNumber, calendarLongDate, calendarMonthLabel, calendarMonthNames, calendarParts, clampDate, isAfter, isBefore, isSameCalendarMonth, isSameDay, isWithin, localeWeekStart, monthGrid, normalizeWeekStart, resolveCalendar, startOfCalendarMonth, startOfDay, weekdayNames } from "../../utils/date.js";
39
49
 
40
50
  /** A range under construction: `to` is undefined between the two taps. */
41
51
 
@@ -49,15 +59,46 @@ import { addCalendarMonths, addDays, calendarDayNumber, calendarLongDate, calend
49
59
  * "no weekends" and "not in the past" are the two most common cases and
50
60
  * neither can be written as a list that stays right tomorrow.
51
61
  */
52
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
62
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
63
+ /**
64
+ * The corner radius of a day, and so of the band that runs between days.
65
+ *
66
+ * A number rather than a class because the band's four corners are set
67
+ * independently from the flags below, which no single `rounded-*` can express.
68
+ */
69
+ const DAY_RADIUS = 8;
70
+ const calendarVariants = tv({
71
+ slots: {
72
+ root: 'gap-3',
73
+ header: 'h-10 flex-row items-center justify-between gap-2',
74
+ nav: 'h-8 w-8 items-center justify-center rounded-full',
75
+ caption: 'flex-1 flex-row items-center justify-center gap-1'
76
+ },
77
+ variants: {
78
+ /** Draw the calendar's own panel, for one standing on the page. */
79
+ bordered: {
80
+ true: {
81
+ root: 'rounded-3xl border border-border bg-card p-3'
82
+ }
83
+ }
84
+ },
85
+ defaultVariants: {
86
+ bordered: true
87
+ }
88
+ });
53
89
  const dayVariants = tv({
54
90
  slots: {
55
- cell: 'flex-1 items-center justify-center',
91
+ cell: 'h-10 flex-1 items-center justify-center',
56
92
  /** The range band, drawn behind the number so it can run cell to cell. */
57
- band: 'absolute inset-y-1',
58
- /** The circle on a selected day, or the ring on today. */
59
- disc: 'h-9 w-9 items-center justify-center rounded-full',
60
- label: ''
93
+ band: 'absolute inset-y-0.5 bg-accent',
94
+ /**
95
+ * The fill on a selected day, or the ring on today. Square-ish rather than
96
+ * round: a circle has one point of contact with the band beside it, so a
97
+ * range reads as beads on a string. A rounded rectangle meets the band
98
+ * along its whole edge and the two become one shape.
99
+ */
100
+ disc: 'h-9 w-9 items-center justify-center rounded-lg',
101
+ label: 'text-sm'
61
102
  },
62
103
  variants: {
63
104
  selected: {
@@ -82,14 +123,15 @@ const dayVariants = tv({
82
123
  label: 'text-muted-foreground/30'
83
124
  }
84
125
  },
85
- inRange: {
126
+ /** A ruled-out day inside a range still sits on it, but faintly. */
127
+ fadedBand: {
86
128
  true: {
87
- band: 'bg-accent'
129
+ band: 'opacity-40'
88
130
  }
89
131
  }
90
132
  },
91
133
  compoundVariants: [
92
- // A selected day inside a range keeps the solid disc; the band passes
134
+ // A selected day inside a range keeps the solid fill; the band passes
93
135
  // under it rather than the disc losing to it.
94
136
  {
95
137
  selected: true,
@@ -108,10 +150,42 @@ function useCalendar(component) {
108
150
  return context;
109
151
  }
110
152
 
153
+ /**
154
+ * Today, kept current.
155
+ *
156
+ * "Today" is not a constant, and a calendar is exactly the kind of thing left
157
+ * on screen across the boundary that changes it — a picker open in a sheet, an
158
+ * app resumed the next morning. Recomputed when the app comes back to the
159
+ * foreground, which is when a stale answer would otherwise become visible, and
160
+ * held as state so the marker actually moves when it does.
161
+ */
162
+ function useToday() {
163
+ const [today, setToday] = useState(() => startOfDay(new Date()));
164
+ useEffect(() => {
165
+ const refresh = () => {
166
+ const now = startOfDay(new Date());
167
+ // A new object every resume would invalidate every memo below it for a
168
+ // date that has not changed.
169
+ setToday(current => current.getTime() === now.getTime() ? current : now);
170
+ };
171
+ const subscription = AppState.addEventListener('change', state => {
172
+ if (state === 'active') refresh();
173
+ });
174
+ return () => subscription.remove();
175
+ }, []);
176
+ return today;
177
+ }
178
+
111
179
  /** Whether a rule, a list or a span rules this day out. */
112
180
  function isDisabled(date, disabled, minDate, maxDate) {
113
- if (minDate && isBefore(date, minDate)) return true;
114
- if (maxDate && isAfter(date, maxDate)) return true;
181
+ /*
182
+ * Both bounds are compared at local midnight, because they are about days
183
+ * and the caller almost always hands over a moment. `minDate={new Date()}`
184
+ * means "from today", and against a raw timestamp that would rule out every
185
+ * hour of today that has already passed — which is to say, today.
186
+ */
187
+ if (minDate && isBefore(date, startOfDay(minDate))) return true;
188
+ if (maxDate && isAfter(date, startOfDay(maxDate))) return true;
115
189
  if (!disabled) return false;
116
190
  if (typeof disabled === 'function') return disabled(date);
117
191
  if (Array.isArray(disabled)) return disabled.some(day => isSameDay(day, date));
@@ -131,8 +205,12 @@ function CalendarRoot({
131
205
  disabled,
132
206
  minDate,
133
207
  maxDate,
134
- weekStartsOn = 0,
208
+ startMonth,
209
+ endMonth,
210
+ weekStartsOn: weekStartsOnProp = 0,
135
211
  showOutsideDays = true,
212
+ selectOutsideDays = false,
213
+ bordered = true,
136
214
  locale,
137
215
  calendar = 'gregory',
138
216
  ...props
@@ -140,6 +218,10 @@ function CalendarRoot({
140
218
  // Settled once here rather than at each call site, so every part of the
141
219
  // grid is certainly reading the same calendar.
142
220
  const system = useMemo(() => resolveCalendar(calendar, locale), [calendar, locale]);
221
+ // Settled once too, and normalised: everything below indexes an array of
222
+ // seven with it, where a negative or out-of-range value is a blank column
223
+ // rather than an error anyone would trace back to here.
224
+ const weekStartsOn = useMemo(() => weekStartsOnProp === 'auto' ? localeWeekStart(locale) : normalizeWeekStart(weekStartsOnProp), [weekStartsOnProp, locale]);
143
225
  const [internalSelected, setInternalSelected] = useState(defaultSelected);
144
226
  const isControlled = selected !== undefined;
145
227
  const value = isControlled ? selected : internalSelected;
@@ -156,18 +238,57 @@ function CalendarRoot({
156
238
  // eslint-disable-next-line react-hooks/exhaustive-deps
157
239
  }, []);
158
240
  const [internalMonth, setInternalMonth] = useState(initialMonth);
159
- const month = monthProp ? startOfCalendarMonth(monthProp, system, locale) : internalMonth;
241
+ /*
242
+ * Memoised on the *instant*, not on the prop. A controlled caller almost
243
+ * always passes a fresh `new Date(…)`, and deriving a new month object from
244
+ * it on every render invalidates the grid memo and the context memo below —
245
+ * so a calendar that has not moved rebuilds forty-two cells per keystroke
246
+ * somewhere else on the screen.
247
+ */
248
+ const controlledMonth = useMemo(() => monthProp ? startOfCalendarMonth(monthProp, system, locale) : undefined,
249
+ // eslint-disable-next-line react-hooks/exhaustive-deps
250
+ [monthProp?.getTime(), system, locale]);
251
+ const month = controlledMonth ?? internalMonth;
252
+
253
+ /*
254
+ * How far the calendar may be paged, as the first day of the outermost month
255
+ * either way. `startMonth`/`endMonth` win where they are given, because they
256
+ * are the props about *navigation*; `minDate`/`maxDate` are about which days
257
+ * can be picked, and stand in when the navigation bounds are not stated.
258
+ */
259
+ const monthBounds = useMemo(() => {
260
+ const lower = startMonth ?? minDate;
261
+ const upper = endMonth ?? maxDate;
262
+ return {
263
+ from: lower ? startOfCalendarMonth(lower, system, locale) : undefined,
264
+ to: upper ? startOfCalendarMonth(upper, system, locale) : undefined
265
+ };
266
+ }, [startMonth, minDate, endMonth, maxDate, system, locale]);
160
267
  const setMonth = useCallback(next => {
161
- const settled = startOfCalendarMonth(next, system, locale);
268
+ /*
269
+ * Clamped here rather than only at the arrows. The arrows guard
270
+ * themselves, but the caption's dropdowns and a controlled caller both
271
+ * reach this by other routes — and a month list that offers a year is a
272
+ * poor place to discover that the year was out of bounds.
273
+ */
274
+ const settled = clampDate(startOfCalendarMonth(next, system, locale), monthBounds.from, monthBounds.to);
162
275
  if (!monthProp) setInternalMonth(settled);
163
276
  onMonthChange?.(settled);
164
- }, [monthProp, onMonthChange, system, locale]);
277
+ }, [monthProp, onMonthChange, system, locale, monthBounds]);
165
278
  const commit = useCallback(next => {
166
279
  if (!isControlled) setInternalSelected(next);
167
280
  onSelect?.(next);
168
281
  }, [isControlled, onSelect]);
169
282
  const select = useCallback(date => {
170
283
  const day = startOfDay(date);
284
+
285
+ /*
286
+ * A tap on a neighbouring month's cell — which only happens with
287
+ * `selectOutsideDays` — commits a date the grid is not showing. Page to
288
+ * it, or the selection lands in a greyed-out cell of the month you are
289
+ * still looking at and reads as having missed.
290
+ */
291
+ if (!isSameCalendarMonth(day, month, system, locale)) setMonth(day);
171
292
  if (mode === 'multiple') {
172
293
  const current = value ?? [];
173
294
  const without = current.filter(picked => !isSameDay(picked, day));
@@ -199,7 +320,7 @@ function CalendarRoot({
199
320
  // Single. Tapping the selected day again clears it.
200
321
  const current = value;
201
322
  commit(isSameDay(current, day) ? undefined : day);
202
- }, [mode, value, commit]);
323
+ }, [mode, value, commit, month, system, locale, setMonth]);
203
324
  const context = useMemo(() => ({
204
325
  month,
205
326
  setMonth,
@@ -207,20 +328,28 @@ function CalendarRoot({
207
328
  system,
208
329
  minDate,
209
330
  maxDate,
331
+ startMonth,
332
+ endMonth,
210
333
  captionLayout
211
- }), [month, setMonth, locale, system, minDate, maxDate, captionLayout]);
334
+ }), [month, setMonth, locale, system, minDate, maxDate, startMonth, endMonth, captionLayout]);
335
+ const {
336
+ root
337
+ } = calendarVariants({
338
+ bordered
339
+ });
340
+ const months = Math.max(1, numberOfMonths);
212
341
  return /*#__PURE__*/_jsx(CalendarContext.Provider, {
213
342
  value: context,
214
343
  children: /*#__PURE__*/_jsx(View, {
215
344
  ...props,
216
- className: cn('gap-4', className),
345
+ className: cn(root(), className),
217
346
  children: Array.from({
218
- length: Math.max(1, numberOfMonths)
347
+ length: months
219
348
  }, (_unused, offset) => /*#__PURE__*/_jsxs(View, {
220
349
  className: "gap-2",
221
- children: [/*#__PURE__*/_jsx(CalendarCaption, {
350
+ children: [/*#__PURE__*/_jsx(CalendarHeader, {
222
351
  offset: offset,
223
- lastOffset: numberOfMonths - 1
352
+ lastOffset: months - 1
224
353
  }), /*#__PURE__*/_jsx(CalendarGrid, {
225
354
  month: addCalendarMonths(month, offset, system, locale),
226
355
  mode: mode,
@@ -231,6 +360,7 @@ function CalendarRoot({
231
360
  maxDate: maxDate,
232
361
  weekStartsOn: weekStartsOn,
233
362
  showOutsideDays: showOutsideDays,
363
+ selectOutsideDays: selectOutsideDays,
234
364
  locale: locale,
235
365
  system: system
236
366
  })]
@@ -245,15 +375,20 @@ CalendarRoot.displayName = 'Calendar';
245
375
  /* -------------------------------------------------------------------------- */
246
376
 
247
377
  /**
248
- * The month name and the arrows, or the month and year pickers.
378
+ * The row over a month: an arrow at each edge and the month's name between
379
+ * them.
249
380
  *
250
- * With several months shown the arrows are split across the row — back on the
251
- * first, forward on the last — because both sets on both captions would page
252
- * the whole run and give two ways to do one thing.
381
+ * The arrows are pinned to the edges of the calendar rather than packed around
382
+ * the label, so the two targets are as far apart as the panel allows and the
383
+ * name sits centred over its own grid. With several months shown they are split
384
+ * across the run — back on the first, forward on the last — because both sets
385
+ * on both headers would page the whole run and give two ways to do one thing.
386
+ * The month that owns neither still lays out a spacer, or its name would slide
387
+ * off centre.
253
388
  */
254
- function CalendarCaption({
255
- offset,
256
- lastOffset
389
+ function CalendarHeader({
390
+ offset = 0,
391
+ lastOffset = 0
257
392
  }) {
258
393
  const {
259
394
  month,
@@ -263,38 +398,56 @@ function CalendarCaption({
263
398
  minDate,
264
399
  maxDate,
265
400
  captionLayout
266
- } = useCalendar('Calendar.Caption');
401
+ } = useCalendar('Calendar.Header');
402
+ const {
403
+ header,
404
+ caption
405
+ } = calendarVariants();
267
406
  const shown = addCalendarMonths(month, offset, system, locale);
268
407
  const canGoBack = !minDate || isBefore(startOfCalendarMonth(minDate, system, locale), month);
269
408
  const canGoForward = !maxDate || isAfter(startOfCalendarMonth(maxDate, system, locale), shown);
270
409
  return /*#__PURE__*/_jsxs(View, {
271
- className: "h-9 flex-row items-center justify-between",
410
+ className: header(),
272
411
  children: [offset === 0 ? /*#__PURE__*/_jsx(CalendarNav, {
273
412
  direction: "previous",
274
413
  disabled: !canGoBack,
275
414
  onPress: () => setMonth(addCalendarMonths(month, -1, system, locale))
276
415
  }) : /*#__PURE__*/_jsx(View, {
277
- className: "h-7 w-7"
278
- }), captionLayout === 'dropdown' ? /*#__PURE__*/_jsx(CalendarDropdowns, {
279
- month: shown,
280
- offset: offset
281
- }) : /*#__PURE__*/_jsx(Text, {
282
- weight: "semibold",
283
- children: calendarMonthLabel(shown, system, locale)
416
+ className: "h-8 w-8"
417
+ }), /*#__PURE__*/_jsx(View, {
418
+ className: caption(),
419
+ children: captionLayout === 'dropdown' ? /*#__PURE__*/_jsx(CalendarDropdowns, {
420
+ month: shown,
421
+ offset: offset
422
+ }) : /*#__PURE__*/_jsx(Text, {
423
+ weight: "semibold",
424
+ className: "text-base",
425
+ children: calendarMonthLabel(shown, system, locale)
426
+ })
284
427
  }), offset === lastOffset ? /*#__PURE__*/_jsx(CalendarNav, {
285
428
  direction: "next",
286
429
  disabled: !canGoForward,
287
430
  onPress: () => setMonth(addCalendarMonths(month, 1, system, locale))
288
431
  }) : /*#__PURE__*/_jsx(View, {
289
- className: "h-7 w-7"
432
+ className: "h-8 w-8"
290
433
  })]
291
434
  });
292
435
  }
436
+ /**
437
+ * One paging arrow.
438
+ *
439
+ * Borderless: two bordered squares at the ends of a bordered panel read as a
440
+ * row of boxes rather than as controls, and the chevron is legible enough on
441
+ * its own. The target is still 32px with another 8 of slop around it.
442
+ */
293
443
  function CalendarNav({
294
444
  direction,
295
- disabled,
445
+ disabled = false,
296
446
  onPress
297
447
  }) {
448
+ const {
449
+ nav
450
+ } = calendarVariants();
298
451
  return /*#__PURE__*/_jsx(Pressable, {
299
452
  disabled: disabled,
300
453
  onPress: onPress,
@@ -304,21 +457,26 @@ function CalendarNav({
304
457
  accessibilityState: {
305
458
  disabled
306
459
  },
307
- className: cn('h-7 w-7 items-center justify-center rounded-md border border-border', disabled ? 'opacity-40' : 'active:bg-accent'),
460
+ className: cn(nav(), disabled ? 'opacity-30' : 'active:bg-accent'),
308
461
  children: direction === 'next' ? /*#__PURE__*/_jsx(ChevronRightIcon, {
309
- size: 16
462
+ size: 18
310
463
  }) : /*#__PURE__*/_jsx(ChevronLeftIcon, {
311
- size: 16
464
+ size: 18
312
465
  })
313
466
  });
314
467
  }
315
468
 
316
469
  /**
317
- * Month and year as two rows of chips rather than as native pickers.
470
+ * Month and year as chips that open a panel of options.
471
+ *
472
+ * The panel floats rather than opening in place, and the caption is the reason:
473
+ * it is a fixed-height row, so a list opened inside it has nowhere to go and
474
+ * spills over the grid below. A popover is laid out against the chip and
475
+ * clamped to the screen, which is the behaviour this needs and already has.
318
476
  *
319
- * A picker is a platform overlay, and putting one on top of a calendar that is
320
- * itself already inside a popover is two floating layers deep for choosing a
321
- * number. Chips stay on the surface the choice is being made on.
477
+ * Native pickers are still the wrong tool — a platform overlay on top of a
478
+ * calendar that is itself already in a popover is two floating layers deep for
479
+ * choosing a number, and it cannot show a Hijri year at all.
322
480
  */
323
481
  function CalendarDropdowns({
324
482
  month,
@@ -329,9 +487,10 @@ function CalendarDropdowns({
329
487
  locale,
330
488
  system,
331
489
  minDate,
332
- maxDate
490
+ maxDate,
491
+ startMonth,
492
+ endMonth
333
493
  } = useCalendar('Calendar.Caption');
334
- const [open, setOpen] = useState(null);
335
494
 
336
495
  /*
337
496
  * Everything here counts in the calendar on screen, not in the platform's.
@@ -341,10 +500,49 @@ function CalendarDropdowns({
341
500
  */
342
501
  const months = useMemo(() => calendarMonthNames(month, system, locale, 'short'), [month, system, locale]);
343
502
  const current = calendarParts(month, system, locale);
503
+ /*
504
+ * `startMonth`/`endMonth` bound the lists, falling back to the selectable
505
+ * bounds and then to a century back and a decade on. They are separate props
506
+ * because the two questions are different: a birthday picker wants a hundred
507
+ * years on offer and no bound at all on the days inside them.
508
+ */
509
+ const first = startMonth ?? minDate;
510
+ const last = endMonth ?? maxDate;
511
+ const firstParts = first ? calendarParts(first, system, locale) : null;
512
+ const lastParts = last ? calendarParts(last, system, locale) : null;
344
513
  const bounds = {
345
- from: minDate ? calendarParts(minDate, system, locale).year : current.year - 100,
346
- to: maxDate ? calendarParts(maxDate, system, locale).year : current.year + 10
514
+ from: firstParts ? firstParts.year : current.year - 100,
515
+ to: lastParts ? lastParts.year : current.year + 10
347
516
  };
517
+ /*
518
+ * The month list is bounded too, and only the year list used to be — so a
519
+ * calendar starting in March 1925 still offered January and February of 1925,
520
+ * and picking one navigated somewhere the calendar had said it would not go.
521
+ * Only the outermost year of each bound loses months; every year between them
522
+ * has all twelve.
523
+ */
524
+ const monthOptions = months.map((label, index) => ({
525
+ label,
526
+ month: index + 1
527
+ })).filter(({
528
+ month
529
+ }) => {
530
+ if (firstParts && current.year === firstParts.year && month < firstParts.month) {
531
+ return false;
532
+ }
533
+ if (lastParts && current.year === lastParts.year && month > lastParts.month) {
534
+ return false;
535
+ }
536
+ return true;
537
+ });
538
+ /*
539
+ * Oldest first, the way a year runs. The list is long — a century by default
540
+ * — so it is opened scrolled to the year already in the caption rather than
541
+ * to either end, which is what makes the natural order affordable. Counting
542
+ * backwards was a way of putting the likely years at the top of a list that
543
+ * always opened at the top; it is not needed once the list opens in the right
544
+ * place, and it made every other use of the picker read upside down.
545
+ */
348
546
  const years = Array.from({
349
547
  length: Math.max(1, bounds.to - bounds.from + 1)
350
548
  }, (_unused, index) => bounds.from + index);
@@ -356,58 +554,154 @@ function CalendarDropdowns({
356
554
  */
357
555
  const choose = months_ => {
358
556
  setMonth(addCalendarMonths(month, months_ - offset, system, locale));
359
- setOpen(null);
360
557
  };
361
- return /*#__PURE__*/_jsxs(View, {
362
- className: "flex-1 items-center",
363
- children: [/*#__PURE__*/_jsxs(View, {
364
- className: "flex-row items-center gap-1",
365
- children: [/*#__PURE__*/_jsx(CaptionChip, {
366
- label: months[current.month - 1] ?? String(current.month),
367
- expanded: open === 'month',
368
- onPress: () => setOpen(open === 'month' ? null : 'month')
369
- }), /*#__PURE__*/_jsx(CaptionChip, {
370
- label: String(current.year),
371
- expanded: open === 'year',
372
- onPress: () => setOpen(open === 'year' ? null : 'year')
373
- })]
374
- }), open ? /*#__PURE__*/_jsx(View, {
375
- 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",
376
- children: (open === 'month' ? months : years.map(String)).map((option, index) => {
377
- const active = open === 'month' ? index === current.month - 1 : years[index] === current.year;
378
- return /*#__PURE__*/_jsx(Pressable, {
379
- onPress: () => choose(open === 'month' ? index - (current.month - 1) : (years[index] - current.year) * 12),
380
- accessibilityRole: "button",
381
- accessibilityState: {
382
- selected: active
383
- },
384
- className: cn('rounded-md px-2.5 py-1.5', active ? 'bg-primary' : 'active:bg-accent'),
385
- children: /*#__PURE__*/_jsx(Text, {
386
- size: "sm",
387
- className: active ? 'text-primary-foreground' : undefined,
388
- children: option
389
- })
390
- }, option);
558
+ return /*#__PURE__*/_jsxs(_Fragment, {
559
+ children: [/*#__PURE__*/_jsx(CaptionDropdown, {
560
+ label: months[current.month - 1] ?? String(current.month),
561
+ accessibilityLabel: "Month",
562
+ options: monthOptions.map(option => option.label),
563
+ active: monthOptions.findIndex(option => option.month === current.month),
564
+ width: MONTH_LIST_WIDTH,
565
+ onSelect: index => choose((monthOptions[index]?.month ?? current.month) - current.month)
566
+ }), /*#__PURE__*/_jsx(CaptionDropdown, {
567
+ label: String(current.year),
568
+ accessibilityLabel: "Year",
569
+ options: years.map(String),
570
+ active: years.indexOf(current.year),
571
+ width: YEAR_LIST_WIDTH,
572
+ onSelect: index => choose((years[index] - current.year) * 12)
573
+ })]
574
+ });
575
+ }
576
+
577
+ /** Panel widths. A month name needs the room; a four-digit year does not. */
578
+ const MONTH_LIST_WIDTH = 176;
579
+ const YEAR_LIST_WIDTH = 132;
580
+
581
+ /**
582
+ * Height of one option row, and the tallest the list is allowed to be.
583
+ *
584
+ * The row height is a constant rather than a measurement because it is what
585
+ * turns "the twenty-ninth option" into a scroll offset, and that has to be
586
+ * known before the list has laid out — the whole point is that it opens in the
587
+ * right place rather than scrolling there afterwards.
588
+ */
589
+ const OPTION_HEIGHT = 40;
590
+ const LIST_MAX_HEIGHT = OPTION_HEIGHT * 6.5;
591
+
592
+ /**
593
+ * One chip and the list of options it opens.
594
+ *
595
+ * A list, not a grid of chips. Twelve months tile into a panel well enough, but
596
+ * the same treatment applied to a century of years is a wall of four-digit
597
+ * numbers with no order the eye can follow — and it opened at whichever end
598
+ * happened to be first. Rows in a column read as a list, take a checkmark, and
599
+ * can be scrolled to a particular one.
600
+ */
601
+ function CaptionDropdown({
602
+ label,
603
+ accessibilityLabel,
604
+ options,
605
+ active,
606
+ width,
607
+ onSelect
608
+ }) {
609
+ const [open, setOpen] = useState(false);
610
+ const list = useRef(null);
611
+ /*
612
+ * The check is given a colour rather than left to its own fallback, which is
613
+ * white — right inside a filled control, invisible on this panel.
614
+ */
615
+ const rawTick = useCSSVariable('--color-foreground');
616
+ const tick = typeof rawTick === 'string' ? rawTick : undefined;
617
+
618
+ /*
619
+ * Opened on the option already in the caption, centred in the panel rather
620
+ * than pinned to an edge so the years either side of it are in view too. A
621
+ * hundred-item list that opens at the top is showing the one year nobody came
622
+ * to pick, and asks for a flick through a century to reach the one they did.
623
+ */
624
+ const scrollToActive = useCallback(() => {
625
+ if (active < 0) return;
626
+ const centred = active * OPTION_HEIGHT - LIST_MAX_HEIGHT / 2 + OPTION_HEIGHT / 2;
627
+ list.current?.scrollTo({
628
+ y: Math.max(0, centred),
629
+ animated: false
630
+ });
631
+ }, [active]);
632
+ return /*#__PURE__*/_jsxs(Popover, {
633
+ open: open,
634
+ onOpenChange: setOpen,
635
+ children: [/*#__PURE__*/_jsx(Popover.Trigger, {
636
+ children: /*#__PURE__*/_jsx(CaptionChip, {
637
+ label: label,
638
+ expanded: open,
639
+ accessibilityLabel: accessibilityLabel
391
640
  })
392
- }) : null]
641
+ }), /*#__PURE__*/_jsx(Popover.Content, {
642
+ align: "center",
643
+ width: width,
644
+ className: "p-1.5",
645
+ children: /*#__PURE__*/_jsx(ScrollView, {
646
+ ref: list,
647
+ bounces: false,
648
+ showsVerticalScrollIndicator: false,
649
+ style: {
650
+ maxHeight: LIST_MAX_HEIGHT
651
+ }
652
+ // Fires once the rows exist, which is the first moment there is
653
+ // anything to scroll. Doing it on mount would scroll an empty list.
654
+ ,
655
+ onContentSizeChange: scrollToActive,
656
+ children: options.map((option, index) => {
657
+ const selected = index === active;
658
+ return /*#__PURE__*/_jsxs(Pressable, {
659
+ onPress: () => {
660
+ onSelect(index);
661
+ setOpen(false);
662
+ },
663
+ accessibilityRole: "button",
664
+ accessibilityState: {
665
+ selected
666
+ },
667
+ style: {
668
+ height: OPTION_HEIGHT
669
+ },
670
+ className: cn('flex-row items-center justify-between gap-2 rounded-xl px-3', selected ? 'bg-accent' : 'active:bg-accent'),
671
+ children: [/*#__PURE__*/_jsx(Text, {
672
+ size: "sm",
673
+ weight: selected ? 'semibold' : 'normal',
674
+ children: option
675
+ }), selected ? /*#__PURE__*/_jsx(CheckIcon, {
676
+ size: 16,
677
+ color: tick
678
+ }) : null]
679
+ }, option);
680
+ })
681
+ })
682
+ })]
393
683
  });
394
684
  }
395
685
  function CaptionChip({
396
686
  label,
397
687
  expanded,
688
+ accessibilityLabel,
398
689
  onPress
399
690
  }) {
400
- return /*#__PURE__*/_jsx(Pressable, {
691
+ return /*#__PURE__*/_jsxs(Pressable, {
401
692
  onPress: onPress,
402
693
  accessibilityRole: "button",
694
+ accessibilityLabel: accessibilityLabel,
403
695
  accessibilityState: {
404
696
  expanded
405
697
  },
406
- className: "flex-row items-center gap-1 rounded-md px-2 py-1 active:bg-accent",
407
- children: /*#__PURE__*/_jsx(Text, {
698
+ className: "flex-row items-center gap-1 rounded-lg px-2 py-1.5 active:bg-accent",
699
+ children: [/*#__PURE__*/_jsx(Text, {
408
700
  weight: "semibold",
409
701
  children: label
410
- })
702
+ }), /*#__PURE__*/_jsx(ChevronDownIcon, {
703
+ size: 14
704
+ })]
411
705
  });
412
706
  }
413
707
 
@@ -425,6 +719,7 @@ function CalendarGrid({
425
719
  maxDate,
426
720
  weekStartsOn,
427
721
  showOutsideDays,
722
+ selectOutsideDays = false,
428
723
  locale,
429
724
  system
430
725
  }) {
@@ -434,18 +729,9 @@ function CalendarGrid({
434
729
  * boundaries and the day numbers differ — so the walk itself is the same
435
730
  * either way and only where it starts moves.
436
731
  */
437
- const weeks = useMemo(() => {
438
- const first = startOfCalendarMonth(month, system, locale);
439
- const lead = (first.getDay() - weekStartsOn + 7) % 7;
440
- const start = addDays(first, -lead);
441
- return Array.from({
442
- length: 6
443
- }, (_unusedWeek, week) => Array.from({
444
- length: 7
445
- }, (_unusedDay, day) => addDays(start, week * 7 + day)));
446
- }, [month, weekStartsOn, system, locale]);
732
+ const weeks = useMemo(() => monthGrid(month, weekStartsOn, system, locale), [month, weekStartsOn, system, locale]);
447
733
  const headings = useMemo(() => weekdayNames(locale, weekStartsOn), [locale, weekStartsOn]);
448
- const today = startOfDay(new Date());
734
+ const today = useToday();
449
735
  const range = mode === 'range' ? value : undefined;
450
736
  const multiple = mode === 'multiple' ? value ?? [] : [];
451
737
  const single = mode === 'single' ? value : undefined;
@@ -475,22 +761,41 @@ function CalendarGrid({
475
761
  className: "h-10 flex-1"
476
762
  }, dayIndex);
477
763
  }
478
- const selected = mode === 'range' ? isSameDay(date, range?.from) || isSameDay(date, range?.to) : mode === 'multiple' ? multiple.some(picked => isSameDay(picked, date)) : isSameDay(date, single);
764
+ const isStart = mode === 'range' && isSameDay(date, range?.from);
765
+ const isEnd = mode === 'range' && isSameDay(date, range?.to);
766
+ const selected = mode === 'range' ? isStart || isEnd : mode === 'multiple' ? multiple.some(picked => isSameDay(picked, date)) : isSameDay(date, single);
767
+
768
+ /*
769
+ * Inclusive of both ends, unlike the old middles-only rule. An end
770
+ * has to carry its half of the band or there is a gap between the
771
+ * disc and the run beside it — which is precisely what made a range
772
+ * read as two discs with a stripe floating between them.
773
+ */
774
+ const inRange = !!range?.to && isWithin(date, range.from, range.to);
479
775
 
480
- // Between the ends, but not an end itself — the band, not the disc.
481
- const inRange = !!range?.to && isWithin(date, range.from, range.to) && !selected;
776
+ /*
777
+ * Whether the band carries on past this cell's edge into the one
778
+ * beside it. It stops at an end of the range, and at the edge of a
779
+ * row — a range over a weekend closes on Saturday and opens again
780
+ * on Sunday rather than trailing into the gap between the rows.
781
+ */
782
+ const openStart = inRange && !isStart && dayIndex > 0;
783
+ const openEnd = inRange && !isEnd && dayIndex < 6;
784
+
785
+ // A neighbouring month's day is drawn, not offered. Reaching into
786
+ // July from June is what the arrows are for.
787
+ const ruledOut = isDisabled(date, disabled, minDate, maxDate) || outside && !selectOutsideDays;
482
788
  return /*#__PURE__*/_jsx(CalendarDay, {
483
789
  date: date,
484
790
  outside: outside,
485
791
  selected: selected,
486
792
  inRange: inRange,
793
+ rangeStart: isStart,
794
+ rangeEnd: isEnd,
487
795
  today: isSameDay(date, today),
488
- disabled: isDisabled(date, disabled, minDate, maxDate)
489
- // The band closes off at the edges of a row rather than
490
- // trailing into the gap between weeks.
491
- ,
492
- openStart: inRange && dayIndex > 0,
493
- openEnd: inRange && dayIndex < 6,
796
+ disabled: ruledOut,
797
+ openStart: openStart,
798
+ openEnd: openEnd,
494
799
  locale: locale,
495
800
  system: system,
496
801
  onPress: () => onSelect(date)
@@ -505,6 +810,8 @@ function CalendarDay({
505
810
  outside,
506
811
  selected,
507
812
  inRange,
813
+ rangeStart = false,
814
+ rangeEnd = false,
508
815
  today,
509
816
  disabled,
510
817
  openStart,
@@ -518,8 +825,25 @@ function CalendarDay({
518
825
  today: today && !selected,
519
826
  outside,
520
827
  disabled,
521
- inRange
828
+ fadedBand: disabled && inRange
522
829
  });
830
+
831
+ /*
832
+ * An end of the range fills half its cell, from the centre outwards towards
833
+ * the rest of the range; every other day in it fills the whole cell. Half a
834
+ * cell is what puts the band underneath the disc rather than beside it — the
835
+ * disc is centred, so a band starting at the centre emerges from behind it
836
+ * and the two read as one shape. A range of one day gets both halves and so
837
+ * no band at all, which is right: there is nothing to span.
838
+ */
839
+ const bandStyle = inRange ? {
840
+ left: rangeStart ? '50%' : 0,
841
+ right: rangeEnd ? '50%' : 0,
842
+ borderTopLeftRadius: openStart ? 0 : DAY_RADIUS,
843
+ borderBottomLeftRadius: openStart ? 0 : DAY_RADIUS,
844
+ borderTopRightRadius: openEnd ? 0 : DAY_RADIUS,
845
+ borderBottomRightRadius: openEnd ? 0 : DAY_RADIUS
846
+ } : null;
523
847
  return /*#__PURE__*/_jsxs(Pressable, {
524
848
  disabled: disabled,
525
849
  onPress: onPress,
@@ -529,28 +853,22 @@ function CalendarDay({
529
853
  selected,
530
854
  disabled
531
855
  },
532
- className: cn(styles.cell(), 'h-10'),
533
- children: [inRange ? /*#__PURE__*/_jsx(View, {
534
- className: cn(styles.band()),
535
- style: {
536
- left: openStart ? 0 : '10%',
537
- right: openEnd ? 0 : '10%',
538
- borderTopLeftRadius: openStart ? 0 : 999,
539
- borderBottomLeftRadius: openStart ? 0 : 999,
540
- borderTopRightRadius: openEnd ? 0 : 999,
541
- borderBottomRightRadius: openEnd ? 0 : 999
542
- }
856
+ className: styles.cell(),
857
+ children: [bandStyle ? /*#__PURE__*/_jsx(View, {
858
+ className: styles.band(),
859
+ style: bandStyle
543
860
  }) : null, /*#__PURE__*/_jsx(View, {
544
- className: cn(styles.disc()),
861
+ className: styles.disc(),
545
862
  children: /*#__PURE__*/_jsx(Text, {
546
- size: "sm",
547
- className: cn(styles.label()),
863
+ className: styles.label(),
548
864
  children: calendarDayNumber(date, system, locale)
549
865
  })
550
866
  })]
551
867
  });
552
868
  }
553
869
  export const Calendar = Object.assign(CalendarRoot, {
870
+ Header: CalendarHeader,
871
+ Nav: CalendarNav,
554
872
  Grid: CalendarGrid,
555
873
  Day: CalendarDay
556
874
  });