panelui-native 0.28.1 → 0.30.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -1
- package/lib/module/components/calendar/index.js +350 -115
- 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/menu/index.js +714 -0
- package/lib/module/components/menu/index.js.map +1 -0
- package/lib/module/components/plan/index.js +296 -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/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/sources/index.js +221 -0
- package/lib/module/components/sources/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 +139 -0
- package/lib/module/icons/index.js.map +1 -1
- package/lib/module/index.js +8 -0
- 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/typescript/src/components/calendar/index.d.ts +68 -3
- 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/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 +127 -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/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/sources/index.d.ts +85 -0
- package/lib/typescript/src/components/sources/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 +14 -0
- package/lib/typescript/src/icons/index.d.ts.map +1 -1
- package/lib/typescript/src/index.d.ts +10 -2
- 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/package.json +1 -1
- package/src/components/calendar/index.tsx +389 -122
- 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/menu/index.tsx +812 -0
- package/src/components/plan/index.tsx +325 -0
- package/src/components/popover/index.tsx +69 -2
- package/src/components/radio-group/index.tsx +11 -2
- package/src/components/reasoning/index.tsx +297 -0
- package/src/components/sources/index.tsx +278 -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 +102 -0
- package/src/index.ts +71 -0
- package/src/primitives/collapse.tsx +128 -0
- package/theme.css +112 -0
|
@@ -15,31 +15,46 @@
|
|
|
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,
|
|
34
42
|
useMemo,
|
|
43
|
+
useRef,
|
|
35
44
|
useState,
|
|
36
|
-
type ReactNode,
|
|
37
45
|
} from 'react';
|
|
38
|
-
import { Pressable, View, type ViewProps } from 'react-native';
|
|
46
|
+
import { Pressable, ScrollView, View, type ViewProps } from 'react-native';
|
|
39
47
|
import { tv } from 'tailwind-variants';
|
|
40
|
-
import {
|
|
48
|
+
import { useCSSVariable } from 'uniwind';
|
|
49
|
+
import {
|
|
50
|
+
CheckIcon,
|
|
51
|
+
ChevronDownIcon,
|
|
52
|
+
ChevronLeftIcon,
|
|
53
|
+
ChevronRightIcon,
|
|
54
|
+
} from '../../icons';
|
|
41
55
|
import { Text } from '../../primitives/text';
|
|
42
56
|
import { cn } from '../../utils/cn';
|
|
57
|
+
import { Popover } from '../popover';
|
|
43
58
|
import {
|
|
44
59
|
addCalendarMonths,
|
|
45
60
|
addDays,
|
|
@@ -90,24 +105,56 @@ export type CalendarDisabled =
|
|
|
90
105
|
| { from: Date; to: Date }
|
|
91
106
|
| ((date: Date) => boolean);
|
|
92
107
|
|
|
108
|
+
/**
|
|
109
|
+
* The corner radius of a day, and so of the band that runs between days.
|
|
110
|
+
*
|
|
111
|
+
* A number rather than a class because the band's four corners are set
|
|
112
|
+
* independently from the flags below, which no single `rounded-*` can express.
|
|
113
|
+
*/
|
|
114
|
+
const DAY_RADIUS = 8;
|
|
115
|
+
|
|
116
|
+
const calendarVariants = tv({
|
|
117
|
+
slots: {
|
|
118
|
+
root: 'gap-3',
|
|
119
|
+
header: 'h-10 flex-row items-center justify-between gap-2',
|
|
120
|
+
nav: 'h-8 w-8 items-center justify-center rounded-full',
|
|
121
|
+
caption: 'flex-1 flex-row items-center justify-center gap-1',
|
|
122
|
+
},
|
|
123
|
+
variants: {
|
|
124
|
+
/** Draw the calendar's own panel, for one standing on the page. */
|
|
125
|
+
bordered: {
|
|
126
|
+
true: { root: 'rounded-3xl border border-border bg-card p-3' },
|
|
127
|
+
},
|
|
128
|
+
},
|
|
129
|
+
defaultVariants: {
|
|
130
|
+
bordered: true,
|
|
131
|
+
},
|
|
132
|
+
});
|
|
133
|
+
|
|
93
134
|
const dayVariants = tv({
|
|
94
135
|
slots: {
|
|
95
|
-
cell: 'flex-1 items-center justify-center',
|
|
136
|
+
cell: 'h-10 flex-1 items-center justify-center',
|
|
96
137
|
/** The range band, drawn behind the number so it can run cell to cell. */
|
|
97
|
-
band: 'absolute inset-y-
|
|
98
|
-
/**
|
|
99
|
-
|
|
100
|
-
|
|
138
|
+
band: 'absolute inset-y-0.5 bg-accent',
|
|
139
|
+
/**
|
|
140
|
+
* The fill on a selected day, or the ring on today. Square-ish rather than
|
|
141
|
+
* round: a circle has one point of contact with the band beside it, so a
|
|
142
|
+
* range reads as beads on a string. A rounded rectangle meets the band
|
|
143
|
+
* along its whole edge and the two become one shape.
|
|
144
|
+
*/
|
|
145
|
+
disc: 'h-9 w-9 items-center justify-center rounded-lg',
|
|
146
|
+
label: 'text-sm',
|
|
101
147
|
},
|
|
102
148
|
variants: {
|
|
103
149
|
selected: { true: { disc: 'bg-primary', label: 'text-primary-foreground' } },
|
|
104
150
|
today: { true: { disc: 'border border-primary', label: 'text-primary' } },
|
|
105
151
|
outside: { true: { label: 'text-muted-foreground/40' } },
|
|
106
152
|
disabled: { true: { label: 'text-muted-foreground/30' } },
|
|
107
|
-
|
|
153
|
+
/** A ruled-out day inside a range still sits on it, but faintly. */
|
|
154
|
+
fadedBand: { true: { band: 'opacity-40' } },
|
|
108
155
|
},
|
|
109
156
|
compoundVariants: [
|
|
110
|
-
// A selected day inside a range keeps the solid
|
|
157
|
+
// A selected day inside a range keeps the solid fill; the band passes
|
|
111
158
|
// under it rather than the disc losing to it.
|
|
112
159
|
{ selected: true, today: true, class: { label: 'text-primary-foreground' } },
|
|
113
160
|
],
|
|
@@ -121,6 +168,9 @@ interface CalendarContextValue {
|
|
|
121
168
|
system: 'gregory' | 'islamic';
|
|
122
169
|
minDate?: Date;
|
|
123
170
|
maxDate?: Date;
|
|
171
|
+
/** Bounds for the caption's pickers, which are not the selectable bounds. */
|
|
172
|
+
startMonth?: Date;
|
|
173
|
+
endMonth?: Date;
|
|
124
174
|
captionLayout: CalendarCaptionLayout;
|
|
125
175
|
}
|
|
126
176
|
|
|
@@ -141,8 +191,14 @@ function isDisabled(
|
|
|
141
191
|
minDate: Date | undefined,
|
|
142
192
|
maxDate: Date | undefined
|
|
143
193
|
): boolean {
|
|
144
|
-
|
|
145
|
-
|
|
194
|
+
/*
|
|
195
|
+
* Both bounds are compared at local midnight, because they are about days
|
|
196
|
+
* and the caller almost always hands over a moment. `minDate={new Date()}`
|
|
197
|
+
* means "from today", and against a raw timestamp that would rule out every
|
|
198
|
+
* hour of today that has already passed — which is to say, today.
|
|
199
|
+
*/
|
|
200
|
+
if (minDate && isBefore(date, startOfDay(minDate))) return true;
|
|
201
|
+
if (maxDate && isAfter(date, startOfDay(maxDate))) return true;
|
|
146
202
|
if (!disabled) return false;
|
|
147
203
|
if (typeof disabled === 'function') return disabled(date);
|
|
148
204
|
if (Array.isArray(disabled)) return disabled.some((day) => isSameDay(day, date));
|
|
@@ -177,10 +233,35 @@ export interface CalendarProps<Mode extends CalendarMode = 'single'>
|
|
|
177
233
|
minDate?: Date;
|
|
178
234
|
/** Latest selectable day. */
|
|
179
235
|
maxDate?: Date;
|
|
236
|
+
/**
|
|
237
|
+
* Earliest month the caption's pickers offer. Defaults to `minDate`, and to
|
|
238
|
+
* a hundred years back when there is none.
|
|
239
|
+
*
|
|
240
|
+
* Separate from `minDate` because they answer different questions: `minDate`
|
|
241
|
+
* is about which days can be picked, this is about how far the month and year
|
|
242
|
+
* lists reach. A birthday picker wants a century of years on offer and no
|
|
243
|
+
* bound at all on the days inside them.
|
|
244
|
+
*/
|
|
245
|
+
startMonth?: Date;
|
|
246
|
+
/** Latest month the caption's pickers offer. Defaults to `maxDate`, else ten years on. */
|
|
247
|
+
endMonth?: Date;
|
|
180
248
|
/** `0` is Sunday. */
|
|
181
249
|
weekStartsOn?: number;
|
|
182
250
|
/** Draw the neighbouring months' days rather than leaving the cells blank. */
|
|
183
251
|
showOutsideDays?: boolean;
|
|
252
|
+
/**
|
|
253
|
+
* Let a tap on a neighbouring month's day select it. Off by default: those
|
|
254
|
+
* cells exist to keep the grid six rows tall, and a tap on one is far more
|
|
255
|
+
* often a misfire than an attempt to reach into July from June. Paging to the
|
|
256
|
+
* month is the way to pick a day in it.
|
|
257
|
+
*/
|
|
258
|
+
selectOutsideDays?: boolean;
|
|
259
|
+
/**
|
|
260
|
+
* Draw the calendar's own panel — a rounded, bordered card. On by default,
|
|
261
|
+
* for a calendar standing on a page. Turn it off when it is already inside
|
|
262
|
+
* something that draws one, which is what `DatePicker` does.
|
|
263
|
+
*/
|
|
264
|
+
bordered?: boolean;
|
|
184
265
|
/** BCP 47 tag for the month and weekday names. The device's own by default. */
|
|
185
266
|
locale?: DateLocale;
|
|
186
267
|
/**
|
|
@@ -208,8 +289,12 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
|
|
|
208
289
|
disabled,
|
|
209
290
|
minDate,
|
|
210
291
|
maxDate,
|
|
292
|
+
startMonth,
|
|
293
|
+
endMonth,
|
|
211
294
|
weekStartsOn = 0,
|
|
212
295
|
showOutsideDays = true,
|
|
296
|
+
selectOutsideDays = false,
|
|
297
|
+
bordered = true,
|
|
213
298
|
locale,
|
|
214
299
|
calendar = 'gregory',
|
|
215
300
|
...props
|
|
@@ -298,16 +383,29 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
|
|
|
298
383
|
);
|
|
299
384
|
|
|
300
385
|
const context = useMemo(
|
|
301
|
-
() => ({
|
|
302
|
-
|
|
386
|
+
() => ({
|
|
387
|
+
month,
|
|
388
|
+
setMonth,
|
|
389
|
+
locale,
|
|
390
|
+
system,
|
|
391
|
+
minDate,
|
|
392
|
+
maxDate,
|
|
393
|
+
startMonth,
|
|
394
|
+
endMonth,
|
|
395
|
+
captionLayout,
|
|
396
|
+
}),
|
|
397
|
+
[month, setMonth, locale, system, minDate, maxDate, startMonth, endMonth, captionLayout]
|
|
303
398
|
);
|
|
304
399
|
|
|
400
|
+
const { root } = calendarVariants({ bordered });
|
|
401
|
+
const months = Math.max(1, numberOfMonths);
|
|
402
|
+
|
|
305
403
|
return (
|
|
306
404
|
<CalendarContext.Provider value={context}>
|
|
307
|
-
<View {...props} className={cn(
|
|
308
|
-
{Array.from({ length:
|
|
405
|
+
<View {...props} className={cn(root(), className)}>
|
|
406
|
+
{Array.from({ length: months }, (_unused, offset) => (
|
|
309
407
|
<View key={offset} className="gap-2">
|
|
310
|
-
<
|
|
408
|
+
<CalendarHeader offset={offset} lastOffset={months - 1} />
|
|
311
409
|
<CalendarGrid
|
|
312
410
|
month={addCalendarMonths(month, offset, system, locale)}
|
|
313
411
|
mode={mode}
|
|
@@ -318,6 +416,7 @@ function CalendarRoot<Mode extends CalendarMode = 'single'>({
|
|
|
318
416
|
maxDate={maxDate}
|
|
319
417
|
weekStartsOn={weekStartsOn}
|
|
320
418
|
showOutsideDays={showOutsideDays}
|
|
419
|
+
selectOutsideDays={selectOutsideDays}
|
|
321
420
|
locale={locale}
|
|
322
421
|
system={system}
|
|
323
422
|
/>
|
|
@@ -333,16 +432,29 @@ CalendarRoot.displayName = 'Calendar';
|
|
|
333
432
|
/* Caption */
|
|
334
433
|
/* -------------------------------------------------------------------------- */
|
|
335
434
|
|
|
435
|
+
export interface CalendarHeaderProps {
|
|
436
|
+
/** Which month of the run this header sits over. `0` is the first. */
|
|
437
|
+
offset?: number;
|
|
438
|
+
/** The last offset in the run, so the header knows whether it owns the arrow. */
|
|
439
|
+
lastOffset?: number;
|
|
440
|
+
}
|
|
441
|
+
|
|
336
442
|
/**
|
|
337
|
-
* The month
|
|
443
|
+
* The row over a month: an arrow at each edge and the month's name between
|
|
444
|
+
* them.
|
|
338
445
|
*
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
*
|
|
446
|
+
* The arrows are pinned to the edges of the calendar rather than packed around
|
|
447
|
+
* the label, so the two targets are as far apart as the panel allows and the
|
|
448
|
+
* name sits centred over its own grid. With several months shown they are split
|
|
449
|
+
* across the run — back on the first, forward on the last — because both sets
|
|
450
|
+
* on both headers would page the whole run and give two ways to do one thing.
|
|
451
|
+
* The month that owns neither still lays out a spacer, or its name would slide
|
|
452
|
+
* off centre.
|
|
342
453
|
*/
|
|
343
|
-
function
|
|
454
|
+
function CalendarHeader({ offset = 0, lastOffset = 0 }: CalendarHeaderProps) {
|
|
344
455
|
const { month, setMonth, locale, system, minDate, maxDate, captionLayout } =
|
|
345
|
-
useCalendar('Calendar.
|
|
456
|
+
useCalendar('Calendar.Header');
|
|
457
|
+
const { header, caption } = calendarVariants();
|
|
346
458
|
const shown = addCalendarMonths(month, offset, system, locale);
|
|
347
459
|
|
|
348
460
|
const canGoBack =
|
|
@@ -351,7 +463,7 @@ function CalendarCaption({ offset, lastOffset }: { offset: number; lastOffset: n
|
|
|
351
463
|
!maxDate || isAfter(startOfCalendarMonth(maxDate, system, locale), shown);
|
|
352
464
|
|
|
353
465
|
return (
|
|
354
|
-
<View className=
|
|
466
|
+
<View className={header()}>
|
|
355
467
|
{offset === 0 ? (
|
|
356
468
|
<CalendarNav
|
|
357
469
|
direction="previous"
|
|
@@ -359,14 +471,18 @@ function CalendarCaption({ offset, lastOffset }: { offset: number; lastOffset: n
|
|
|
359
471
|
onPress={() => setMonth(addCalendarMonths(month, -1, system, locale))}
|
|
360
472
|
/>
|
|
361
473
|
) : (
|
|
362
|
-
<View className="h-
|
|
474
|
+
<View className="h-8 w-8" />
|
|
363
475
|
)}
|
|
364
476
|
|
|
365
|
-
{
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
477
|
+
<View className={caption()}>
|
|
478
|
+
{captionLayout === 'dropdown' ? (
|
|
479
|
+
<CalendarDropdowns month={shown} offset={offset} />
|
|
480
|
+
) : (
|
|
481
|
+
<Text weight="semibold" className="text-base">
|
|
482
|
+
{calendarMonthLabel(shown, system, locale)}
|
|
483
|
+
</Text>
|
|
484
|
+
)}
|
|
485
|
+
</View>
|
|
370
486
|
|
|
371
487
|
{offset === lastOffset ? (
|
|
372
488
|
<CalendarNav
|
|
@@ -375,21 +491,28 @@ function CalendarCaption({ offset, lastOffset }: { offset: number; lastOffset: n
|
|
|
375
491
|
onPress={() => setMonth(addCalendarMonths(month, 1, system, locale))}
|
|
376
492
|
/>
|
|
377
493
|
) : (
|
|
378
|
-
<View className="h-
|
|
494
|
+
<View className="h-8 w-8" />
|
|
379
495
|
)}
|
|
380
496
|
</View>
|
|
381
497
|
);
|
|
382
498
|
}
|
|
383
499
|
|
|
384
|
-
|
|
385
|
-
direction,
|
|
386
|
-
disabled,
|
|
387
|
-
onPress,
|
|
388
|
-
}: {
|
|
500
|
+
export interface CalendarNavProps {
|
|
389
501
|
direction: 'previous' | 'next';
|
|
390
|
-
disabled
|
|
391
|
-
onPress
|
|
392
|
-
}
|
|
502
|
+
disabled?: boolean;
|
|
503
|
+
onPress?: () => void;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* One paging arrow.
|
|
508
|
+
*
|
|
509
|
+
* Borderless: two bordered squares at the ends of a bordered panel read as a
|
|
510
|
+
* row of boxes rather than as controls, and the chevron is legible enough on
|
|
511
|
+
* its own. The target is still 32px with another 8 of slop around it.
|
|
512
|
+
*/
|
|
513
|
+
function CalendarNav({ direction, disabled = false, onPress }: CalendarNavProps) {
|
|
514
|
+
const { nav } = calendarVariants();
|
|
515
|
+
|
|
393
516
|
return (
|
|
394
517
|
<Pressable
|
|
395
518
|
disabled={disabled}
|
|
@@ -398,26 +521,28 @@ function CalendarNav({
|
|
|
398
521
|
accessibilityRole="button"
|
|
399
522
|
accessibilityLabel={direction === 'next' ? 'Next month' : 'Previous month'}
|
|
400
523
|
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
|
-
)}
|
|
524
|
+
className={cn(nav(), disabled ? 'opacity-30' : 'active:bg-accent')}
|
|
405
525
|
>
|
|
406
|
-
{direction === 'next' ? <ChevronRightIcon size={
|
|
526
|
+
{direction === 'next' ? <ChevronRightIcon size={18} /> : <ChevronLeftIcon size={18} />}
|
|
407
527
|
</Pressable>
|
|
408
528
|
);
|
|
409
529
|
}
|
|
410
530
|
|
|
411
531
|
/**
|
|
412
|
-
* Month and year as
|
|
532
|
+
* Month and year as chips that open a panel of options.
|
|
533
|
+
*
|
|
534
|
+
* The panel floats rather than opening in place, and the caption is the reason:
|
|
535
|
+
* it is a fixed-height row, so a list opened inside it has nowhere to go and
|
|
536
|
+
* spills over the grid below. A popover is laid out against the chip and
|
|
537
|
+
* clamped to the screen, which is the behaviour this needs and already has.
|
|
413
538
|
*
|
|
414
|
-
*
|
|
415
|
-
* itself already
|
|
416
|
-
*
|
|
539
|
+
* Native pickers are still the wrong tool — a platform overlay on top of a
|
|
540
|
+
* calendar that is itself already in a popover is two floating layers deep for
|
|
541
|
+
* choosing a number, and it cannot show a Hijri year at all.
|
|
417
542
|
*/
|
|
418
543
|
function CalendarDropdowns({ month, offset }: { month: Date; offset: number }) {
|
|
419
|
-
const { setMonth, locale, system, minDate, maxDate } =
|
|
420
|
-
|
|
544
|
+
const { setMonth, locale, system, minDate, maxDate, startMonth, endMonth } =
|
|
545
|
+
useCalendar('Calendar.Caption');
|
|
421
546
|
|
|
422
547
|
/*
|
|
423
548
|
* Everything here counts in the calendar on screen, not in the platform's.
|
|
@@ -430,10 +555,26 @@ function CalendarDropdowns({ month, offset }: { month: Date; offset: number }) {
|
|
|
430
555
|
[month, system, locale]
|
|
431
556
|
);
|
|
432
557
|
const current = calendarParts(month, system, locale);
|
|
558
|
+
/*
|
|
559
|
+
* `startMonth`/`endMonth` bound the lists, falling back to the selectable
|
|
560
|
+
* bounds and then to a century back and a decade on. They are separate props
|
|
561
|
+
* because the two questions are different: a birthday picker wants a hundred
|
|
562
|
+
* years on offer and no bound at all on the days inside them.
|
|
563
|
+
*/
|
|
564
|
+
const first = startMonth ?? minDate;
|
|
565
|
+
const last = endMonth ?? maxDate;
|
|
433
566
|
const bounds = {
|
|
434
|
-
from:
|
|
435
|
-
to:
|
|
567
|
+
from: first ? calendarParts(first, system, locale).year : current.year - 100,
|
|
568
|
+
to: last ? calendarParts(last, system, locale).year : current.year + 10,
|
|
436
569
|
};
|
|
570
|
+
/*
|
|
571
|
+
* Oldest first, the way a year runs. The list is long — a century by default
|
|
572
|
+
* — so it is opened scrolled to the year already in the caption rather than
|
|
573
|
+
* to either end, which is what makes the natural order affordable. Counting
|
|
574
|
+
* backwards was a way of putting the likely years at the top of a list that
|
|
575
|
+
* always opened at the top; it is not needed once the list opens in the right
|
|
576
|
+
* place, and it made every other use of the picker read upside down.
|
|
577
|
+
*/
|
|
437
578
|
const years = Array.from(
|
|
438
579
|
{ length: Math.max(1, bounds.to - bounds.from + 1) },
|
|
439
580
|
(_unused, index) => bounds.from + index
|
|
@@ -446,77 +587,163 @@ function CalendarDropdowns({ month, offset }: { month: Date; offset: number }) {
|
|
|
446
587
|
*/
|
|
447
588
|
const choose = (months_: number) => {
|
|
448
589
|
setMonth(addCalendarMonths(month, months_ - offset, system, locale));
|
|
449
|
-
setOpen(null);
|
|
450
590
|
};
|
|
451
591
|
|
|
452
592
|
return (
|
|
453
|
-
|
|
454
|
-
<
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
593
|
+
<>
|
|
594
|
+
<CaptionDropdown
|
|
595
|
+
label={months[current.month - 1] ?? String(current.month)}
|
|
596
|
+
accessibilityLabel="Month"
|
|
597
|
+
options={months}
|
|
598
|
+
active={current.month - 1}
|
|
599
|
+
width={MONTH_LIST_WIDTH}
|
|
600
|
+
onSelect={(index) => choose(index - (current.month - 1))}
|
|
601
|
+
/>
|
|
602
|
+
<CaptionDropdown
|
|
603
|
+
label={String(current.year)}
|
|
604
|
+
accessibilityLabel="Year"
|
|
605
|
+
options={years.map(String)}
|
|
606
|
+
active={years.indexOf(current.year)}
|
|
607
|
+
width={YEAR_LIST_WIDTH}
|
|
608
|
+
onSelect={(index) => choose((years[index]! - current.year) * 12)}
|
|
609
|
+
/>
|
|
610
|
+
</>
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
/** Panel widths. A month name needs the room; a four-digit year does not. */
|
|
615
|
+
const MONTH_LIST_WIDTH = 176;
|
|
616
|
+
const YEAR_LIST_WIDTH = 132;
|
|
617
|
+
|
|
618
|
+
/**
|
|
619
|
+
* Height of one option row, and the tallest the list is allowed to be.
|
|
620
|
+
*
|
|
621
|
+
* The row height is a constant rather than a measurement because it is what
|
|
622
|
+
* turns "the twenty-ninth option" into a scroll offset, and that has to be
|
|
623
|
+
* known before the list has laid out — the whole point is that it opens in the
|
|
624
|
+
* right place rather than scrolling there afterwards.
|
|
625
|
+
*/
|
|
626
|
+
const OPTION_HEIGHT = 40;
|
|
627
|
+
const LIST_MAX_HEIGHT = OPTION_HEIGHT * 6.5;
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* One chip and the list of options it opens.
|
|
631
|
+
*
|
|
632
|
+
* A list, not a grid of chips. Twelve months tile into a panel well enough, but
|
|
633
|
+
* the same treatment applied to a century of years is a wall of four-digit
|
|
634
|
+
* numbers with no order the eye can follow — and it opened at whichever end
|
|
635
|
+
* happened to be first. Rows in a column read as a list, take a checkmark, and
|
|
636
|
+
* can be scrolled to a particular one.
|
|
637
|
+
*/
|
|
638
|
+
function CaptionDropdown({
|
|
639
|
+
label,
|
|
640
|
+
accessibilityLabel,
|
|
641
|
+
options,
|
|
642
|
+
active,
|
|
643
|
+
width,
|
|
644
|
+
onSelect,
|
|
645
|
+
}: {
|
|
646
|
+
label: string;
|
|
647
|
+
accessibilityLabel: string;
|
|
648
|
+
options: string[];
|
|
649
|
+
/** Index of the option currently in the caption, or -1 for none. */
|
|
650
|
+
active: number;
|
|
651
|
+
width: number;
|
|
652
|
+
onSelect: (index: number) => void;
|
|
653
|
+
}) {
|
|
654
|
+
const [open, setOpen] = useState(false);
|
|
655
|
+
const list = useRef<ScrollView>(null);
|
|
656
|
+
/*
|
|
657
|
+
* The check is given a colour rather than left to its own fallback, which is
|
|
658
|
+
* white — right inside a filled control, invisible on this panel.
|
|
659
|
+
*/
|
|
660
|
+
const rawTick = useCSSVariable('--color-foreground');
|
|
661
|
+
const tick = typeof rawTick === 'string' ? rawTick : undefined;
|
|
662
|
+
|
|
663
|
+
/*
|
|
664
|
+
* Opened on the option already in the caption, centred in the panel rather
|
|
665
|
+
* than pinned to an edge so the years either side of it are in view too. A
|
|
666
|
+
* hundred-item list that opens at the top is showing the one year nobody came
|
|
667
|
+
* to pick, and asks for a flick through a century to reach the one they did.
|
|
668
|
+
*/
|
|
669
|
+
const scrollToActive = useCallback(() => {
|
|
670
|
+
if (active < 0) return;
|
|
671
|
+
const centred = active * OPTION_HEIGHT - LIST_MAX_HEIGHT / 2 + OPTION_HEIGHT / 2;
|
|
672
|
+
list.current?.scrollTo({ y: Math.max(0, centred), animated: false });
|
|
673
|
+
}, [active]);
|
|
466
674
|
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
675
|
+
return (
|
|
676
|
+
<Popover open={open} onOpenChange={setOpen}>
|
|
677
|
+
<Popover.Trigger>
|
|
678
|
+
<CaptionChip label={label} expanded={open} accessibilityLabel={accessibilityLabel} />
|
|
679
|
+
</Popover.Trigger>
|
|
680
|
+
<Popover.Content align="center" width={width} className="p-1.5">
|
|
681
|
+
<ScrollView
|
|
682
|
+
ref={list}
|
|
683
|
+
bounces={false}
|
|
684
|
+
showsVerticalScrollIndicator={false}
|
|
685
|
+
style={{ maxHeight: LIST_MAX_HEIGHT }}
|
|
686
|
+
// Fires once the rows exist, which is the first moment there is
|
|
687
|
+
// anything to scroll. Doing it on mount would scroll an empty list.
|
|
688
|
+
onContentSizeChange={scrollToActive}
|
|
689
|
+
>
|
|
690
|
+
{options.map((option, index) => {
|
|
691
|
+
const selected = index === active;
|
|
474
692
|
return (
|
|
475
693
|
<Pressable
|
|
476
694
|
key={option}
|
|
477
|
-
onPress={() =>
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
: (years[index]! - current.year) * 12
|
|
482
|
-
)
|
|
483
|
-
}
|
|
695
|
+
onPress={() => {
|
|
696
|
+
onSelect(index);
|
|
697
|
+
setOpen(false);
|
|
698
|
+
}}
|
|
484
699
|
accessibilityRole="button"
|
|
485
|
-
accessibilityState={{ selected
|
|
700
|
+
accessibilityState={{ selected }}
|
|
701
|
+
style={{ height: OPTION_HEIGHT }}
|
|
486
702
|
className={cn(
|
|
487
|
-
'
|
|
488
|
-
|
|
703
|
+
'flex-row items-center justify-between gap-2 rounded-xl px-3',
|
|
704
|
+
selected ? 'bg-accent' : 'active:bg-accent'
|
|
489
705
|
)}
|
|
490
706
|
>
|
|
491
|
-
<Text size="sm"
|
|
707
|
+
<Text size="sm" weight={selected ? 'semibold' : 'normal'}>
|
|
492
708
|
{option}
|
|
493
709
|
</Text>
|
|
710
|
+
{selected ? <CheckIcon size={16} color={tick} /> : null}
|
|
494
711
|
</Pressable>
|
|
495
712
|
);
|
|
496
713
|
})}
|
|
497
|
-
</
|
|
498
|
-
|
|
499
|
-
</
|
|
714
|
+
</ScrollView>
|
|
715
|
+
</Popover.Content>
|
|
716
|
+
</Popover>
|
|
500
717
|
);
|
|
501
718
|
}
|
|
502
719
|
|
|
503
720
|
function CaptionChip({
|
|
504
721
|
label,
|
|
505
722
|
expanded,
|
|
723
|
+
accessibilityLabel,
|
|
506
724
|
onPress,
|
|
507
725
|
}: {
|
|
508
726
|
label: string;
|
|
509
727
|
expanded: boolean;
|
|
510
|
-
|
|
728
|
+
accessibilityLabel: string;
|
|
729
|
+
/** Supplied by `Popover.Trigger`, which clones this to open the panel. */
|
|
730
|
+
onPress?: (...args: unknown[]) => void;
|
|
511
731
|
}) {
|
|
512
732
|
return (
|
|
513
733
|
<Pressable
|
|
514
734
|
onPress={onPress}
|
|
515
735
|
accessibilityRole="button"
|
|
736
|
+
accessibilityLabel={accessibilityLabel}
|
|
516
737
|
accessibilityState={{ expanded }}
|
|
517
|
-
className="flex-row items-center gap-1 rounded-
|
|
738
|
+
className="flex-row items-center gap-1 rounded-lg px-2 py-1.5 active:bg-accent"
|
|
518
739
|
>
|
|
519
740
|
<Text weight="semibold">{label}</Text>
|
|
741
|
+
{/*
|
|
742
|
+
The chevron says the chip opens something. Without it a bold word in the
|
|
743
|
+
middle of a header is indistinguishable from the plain caption layout,
|
|
744
|
+
and nobody thinks to press it.
|
|
745
|
+
*/}
|
|
746
|
+
<ChevronDownIcon size={14} />
|
|
520
747
|
</Pressable>
|
|
521
748
|
);
|
|
522
749
|
}
|
|
@@ -535,6 +762,7 @@ interface GridProps {
|
|
|
535
762
|
maxDate?: Date;
|
|
536
763
|
weekStartsOn: number;
|
|
537
764
|
showOutsideDays: boolean;
|
|
765
|
+
selectOutsideDays?: boolean;
|
|
538
766
|
locale: DateLocale;
|
|
539
767
|
system: 'gregory' | 'islamic';
|
|
540
768
|
}
|
|
@@ -549,6 +777,7 @@ function CalendarGrid({
|
|
|
549
777
|
maxDate,
|
|
550
778
|
weekStartsOn,
|
|
551
779
|
showOutsideDays,
|
|
780
|
+
selectOutsideDays = false,
|
|
552
781
|
locale,
|
|
553
782
|
system,
|
|
554
783
|
}: GridProps) {
|
|
@@ -595,16 +824,38 @@ function CalendarGrid({
|
|
|
595
824
|
return <View key={dayIndex} className="h-10 flex-1" />;
|
|
596
825
|
}
|
|
597
826
|
|
|
827
|
+
const isStart = mode === 'range' && isSameDay(date, range?.from);
|
|
828
|
+
const isEnd = mode === 'range' && isSameDay(date, range?.to);
|
|
829
|
+
|
|
598
830
|
const selected =
|
|
599
831
|
mode === 'range'
|
|
600
|
-
?
|
|
832
|
+
? isStart || isEnd
|
|
601
833
|
: mode === 'multiple'
|
|
602
834
|
? multiple.some((picked) => isSameDay(picked, date))
|
|
603
835
|
: isSameDay(date, single);
|
|
604
836
|
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
837
|
+
/*
|
|
838
|
+
* Inclusive of both ends, unlike the old middles-only rule. An end
|
|
839
|
+
* has to carry its half of the band or there is a gap between the
|
|
840
|
+
* disc and the run beside it — which is precisely what made a range
|
|
841
|
+
* read as two discs with a stripe floating between them.
|
|
842
|
+
*/
|
|
843
|
+
const inRange = !!range?.to && isWithin(date, range.from, range.to);
|
|
844
|
+
|
|
845
|
+
/*
|
|
846
|
+
* Whether the band carries on past this cell's edge into the one
|
|
847
|
+
* beside it. It stops at an end of the range, and at the edge of a
|
|
848
|
+
* row — a range over a weekend closes on Saturday and opens again
|
|
849
|
+
* on Sunday rather than trailing into the gap between the rows.
|
|
850
|
+
*/
|
|
851
|
+
const openStart = inRange && !isStart && dayIndex > 0;
|
|
852
|
+
const openEnd = inRange && !isEnd && dayIndex < 6;
|
|
853
|
+
|
|
854
|
+
// A neighbouring month's day is drawn, not offered. Reaching into
|
|
855
|
+
// July from June is what the arrows are for.
|
|
856
|
+
const ruledOut =
|
|
857
|
+
isDisabled(date, disabled, minDate, maxDate) ||
|
|
858
|
+
(outside && !selectOutsideDays);
|
|
608
859
|
|
|
609
860
|
return (
|
|
610
861
|
<CalendarDay
|
|
@@ -613,12 +864,12 @@ function CalendarGrid({
|
|
|
613
864
|
outside={outside}
|
|
614
865
|
selected={selected}
|
|
615
866
|
inRange={inRange}
|
|
867
|
+
rangeStart={isStart}
|
|
868
|
+
rangeEnd={isEnd}
|
|
616
869
|
today={isSameDay(date, today)}
|
|
617
|
-
disabled={
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
openStart={inRange && dayIndex > 0}
|
|
621
|
-
openEnd={inRange && dayIndex < 6}
|
|
870
|
+
disabled={ruledOut}
|
|
871
|
+
openStart={openStart}
|
|
872
|
+
openEnd={openEnd}
|
|
622
873
|
locale={locale}
|
|
623
874
|
system={system}
|
|
624
875
|
onPress={() => onSelect(date)}
|
|
@@ -635,10 +886,15 @@ interface DayProps {
|
|
|
635
886
|
date: Date;
|
|
636
887
|
outside: boolean;
|
|
637
888
|
selected: boolean;
|
|
889
|
+
/** Anywhere inside the range, ends included. */
|
|
638
890
|
inRange: boolean;
|
|
891
|
+
rangeStart?: boolean;
|
|
892
|
+
rangeEnd?: boolean;
|
|
639
893
|
today: boolean;
|
|
640
894
|
disabled: boolean;
|
|
895
|
+
/** The band carries on past this cell's leading edge. */
|
|
641
896
|
openStart: boolean;
|
|
897
|
+
/** The band carries on past this cell's trailing edge. */
|
|
642
898
|
openEnd: boolean;
|
|
643
899
|
locale: DateLocale;
|
|
644
900
|
system: 'gregory' | 'islamic';
|
|
@@ -650,6 +906,8 @@ function CalendarDay({
|
|
|
650
906
|
outside,
|
|
651
907
|
selected,
|
|
652
908
|
inRange,
|
|
909
|
+
rangeStart = false,
|
|
910
|
+
rangeEnd = false,
|
|
653
911
|
today,
|
|
654
912
|
disabled,
|
|
655
913
|
openStart,
|
|
@@ -663,9 +921,28 @@ function CalendarDay({
|
|
|
663
921
|
today: today && !selected,
|
|
664
922
|
outside,
|
|
665
923
|
disabled,
|
|
666
|
-
inRange,
|
|
924
|
+
fadedBand: disabled && inRange,
|
|
667
925
|
});
|
|
668
926
|
|
|
927
|
+
/*
|
|
928
|
+
* An end of the range fills half its cell, from the centre outwards towards
|
|
929
|
+
* the rest of the range; every other day in it fills the whole cell. Half a
|
|
930
|
+
* cell is what puts the band underneath the disc rather than beside it — the
|
|
931
|
+
* disc is centred, so a band starting at the centre emerges from behind it
|
|
932
|
+
* and the two read as one shape. A range of one day gets both halves and so
|
|
933
|
+
* no band at all, which is right: there is nothing to span.
|
|
934
|
+
*/
|
|
935
|
+
const bandStyle = inRange
|
|
936
|
+
? {
|
|
937
|
+
left: rangeStart ? ('50%' as const) : 0,
|
|
938
|
+
right: rangeEnd ? ('50%' as const) : 0,
|
|
939
|
+
borderTopLeftRadius: openStart ? 0 : DAY_RADIUS,
|
|
940
|
+
borderBottomLeftRadius: openStart ? 0 : DAY_RADIUS,
|
|
941
|
+
borderTopRightRadius: openEnd ? 0 : DAY_RADIUS,
|
|
942
|
+
borderBottomRightRadius: openEnd ? 0 : DAY_RADIUS,
|
|
943
|
+
}
|
|
944
|
+
: null;
|
|
945
|
+
|
|
669
946
|
return (
|
|
670
947
|
<Pressable
|
|
671
948
|
disabled={disabled}
|
|
@@ -673,23 +950,11 @@ function CalendarDay({
|
|
|
673
950
|
accessibilityRole="button"
|
|
674
951
|
accessibilityLabel={calendarLongDate(date, system, locale)}
|
|
675
952
|
accessibilityState={{ selected, disabled }}
|
|
676
|
-
className={
|
|
953
|
+
className={styles.cell()}
|
|
677
954
|
>
|
|
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())}>
|
|
955
|
+
{bandStyle ? <View className={styles.band()} style={bandStyle} /> : null}
|
|
956
|
+
<View className={styles.disc()}>
|
|
957
|
+
<Text className={styles.label()}>
|
|
693
958
|
{calendarDayNumber(date, system, locale)}
|
|
694
959
|
</Text>
|
|
695
960
|
</View>
|
|
@@ -698,6 +963,8 @@ function CalendarDay({
|
|
|
698
963
|
}
|
|
699
964
|
|
|
700
965
|
export const Calendar = Object.assign(CalendarRoot, {
|
|
966
|
+
Header: CalendarHeader,
|
|
967
|
+
Nav: CalendarNav,
|
|
701
968
|
Grid: CalendarGrid,
|
|
702
969
|
Day: CalendarDay,
|
|
703
970
|
});
|