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