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