cupertino-datetime-picker 0.1.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/LICENSE +21 -0
- package/README.md +114 -0
- package/dist/calendar-panel.d.ts +27 -0
- package/dist/calendar.d.ts +33 -0
- package/dist/date-time-picker.d.ts +31 -0
- package/dist/hooks.d.ts +3 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +832 -0
- package/dist/index.js.map +1 -0
- package/dist/segmented-control.d.ts +17 -0
- package/dist/segments.d.ts +14 -0
- package/dist/time-panel.d.ts +30 -0
- package/dist/time.d.ts +24 -0
- package/dist/utils.d.ts +6 -0
- package/dist/wheel-math.d.ts +11 -0
- package/dist/wheel.d.ts +28 -0
- package/package.json +90 -0
- package/registry.json +76 -0
- package/src/calendar-panel.tsx +321 -0
- package/src/calendar.ts +118 -0
- package/src/date-time-picker.tsx +138 -0
- package/src/hooks.ts +28 -0
- package/src/index.ts +18 -0
- package/src/segmented-control.tsx +72 -0
- package/src/segments.ts +26 -0
- package/src/styles.css +91 -0
- package/src/time-panel.tsx +262 -0
- package/src/time.ts +71 -0
- package/src/utils.ts +19 -0
- package/src/wheel-math.ts +35 -0
- package/src/wheel.tsx +365 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
addDays,
|
|
5
|
+
addMonths,
|
|
6
|
+
isDayDisabled,
|
|
7
|
+
isSameDay,
|
|
8
|
+
monthDiff,
|
|
9
|
+
monthGrid,
|
|
10
|
+
shiftMonth,
|
|
11
|
+
startOfDay,
|
|
12
|
+
weekStartFor,
|
|
13
|
+
withDay,
|
|
14
|
+
yearMonthOf,
|
|
15
|
+
type YearMonth,
|
|
16
|
+
} from "./calendar";
|
|
17
|
+
import { formatFullDate, formatMonthYear, monthNames, weekdayNames } from "./time";
|
|
18
|
+
import { cn } from "./utils";
|
|
19
|
+
import { Wheel, WheelHighlight } from "./wheel";
|
|
20
|
+
|
|
21
|
+
export type CalendarPanelLabels = {
|
|
22
|
+
previousMonth: string;
|
|
23
|
+
nextMonth: string;
|
|
24
|
+
month: string;
|
|
25
|
+
year: string;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export const CALENDAR_LABELS: CalendarPanelLabels = {
|
|
29
|
+
previousMonth: "Previous month",
|
|
30
|
+
nextMonth: "Next month",
|
|
31
|
+
month: "Month",
|
|
32
|
+
year: "Year",
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type CalendarPanelProps = {
|
|
36
|
+
value: Date | null;
|
|
37
|
+
onChange: (date: Date) => void;
|
|
38
|
+
locale?: string;
|
|
39
|
+
min?: Date;
|
|
40
|
+
max?: Date;
|
|
41
|
+
/** Accessible names; visible text comes from `Intl`. */
|
|
42
|
+
labels?: Partial<CalendarPanelLabels>;
|
|
43
|
+
/** Injected for tests and stories. */
|
|
44
|
+
today?: Date;
|
|
45
|
+
className?: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const YEAR_SPAN = 100;
|
|
49
|
+
const SWIPE_PX = 40;
|
|
50
|
+
|
|
51
|
+
function Chevron({ className }: { className?: string }) {
|
|
52
|
+
return (
|
|
53
|
+
<svg viewBox="0 0 20 20" className={cn("size-5", className)} aria-hidden fill="none">
|
|
54
|
+
<path
|
|
55
|
+
d="M7.5 4.5 13 10l-5.5 5.5"
|
|
56
|
+
stroke="currentColor"
|
|
57
|
+
strokeWidth="2.25"
|
|
58
|
+
strokeLinecap="round"
|
|
59
|
+
strokeLinejoin="round"
|
|
60
|
+
/>
|
|
61
|
+
</svg>
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The iOS inline calendar: month title that opens month/year wheels, next
|
|
67
|
+
* and previous month, a grid of circles. Months slide, the wheels cross-fade
|
|
68
|
+
* in over the grid, a horizontal swipe changes month, and the keyboard walks
|
|
69
|
+
* the grid (arrows, Home/End, PageUp/PageDown, Enter).
|
|
70
|
+
*/
|
|
71
|
+
export function CalendarPanel({
|
|
72
|
+
value,
|
|
73
|
+
onChange,
|
|
74
|
+
locale = navigator.language,
|
|
75
|
+
min,
|
|
76
|
+
max,
|
|
77
|
+
labels: labelsProp,
|
|
78
|
+
today: todayProp,
|
|
79
|
+
className,
|
|
80
|
+
}: CalendarPanelProps) {
|
|
81
|
+
const labels = { ...CALENDAR_LABELS, ...labelsProp };
|
|
82
|
+
const today = todayProp ?? startOfDay(new Date());
|
|
83
|
+
const weekStart = weekStartFor(locale);
|
|
84
|
+
const [view, setView] = React.useState<YearMonth>(() => yearMonthOf(value ?? today));
|
|
85
|
+
const [dir, setDir] = React.useState<-1 | 0 | 1>(0);
|
|
86
|
+
const [picking, setPicking] = React.useState(false);
|
|
87
|
+
const [focusDate, setFocusDate] = React.useState<Date>(value ?? today);
|
|
88
|
+
const wantFocus = React.useRef(false);
|
|
89
|
+
const grid = React.useRef<HTMLDivElement>(null);
|
|
90
|
+
const swipe = React.useRef<{ x: number; y: number } | null>(null);
|
|
91
|
+
|
|
92
|
+
// A value set from outside moves the view to its month.
|
|
93
|
+
const [seen, setSeen] = React.useState(value);
|
|
94
|
+
if (value !== seen) {
|
|
95
|
+
setSeen(value);
|
|
96
|
+
if (value && monthDiff(view, yearMonthOf(value)) !== 0) {
|
|
97
|
+
setDir(0);
|
|
98
|
+
setView(yearMonthOf(value));
|
|
99
|
+
setFocusDate(value);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const show = (next: YearMonth, direction: -1 | 0 | 1) => {
|
|
104
|
+
setDir(direction);
|
|
105
|
+
setView(next);
|
|
106
|
+
};
|
|
107
|
+
const shift = (by: number) => show(shiftMonth(view, by), by > 0 ? 1 : -1);
|
|
108
|
+
|
|
109
|
+
const canShift = (by: number) => {
|
|
110
|
+
const target = shiftMonth(view, by);
|
|
111
|
+
if (by < 0 && min && monthDiff(yearMonthOf(min), target) < 0) return false;
|
|
112
|
+
if (by > 0 && max && monthDiff(target, yearMonthOf(max)) < 0) return false;
|
|
113
|
+
return true;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
const pick = (date: Date) => {
|
|
117
|
+
setFocusDate(date);
|
|
118
|
+
onChange(withDay(value ?? today, date.getFullYear(), date.getMonth(), date.getDate()));
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const moveFocus = (next: Date) => {
|
|
122
|
+
if (isDayDisabled(next, min, max)) return;
|
|
123
|
+
wantFocus.current = true;
|
|
124
|
+
setFocusDate(next);
|
|
125
|
+
const diff = monthDiff(view, yearMonthOf(next));
|
|
126
|
+
if (diff !== 0) show(yearMonthOf(next), diff > 0 ? 1 : -1);
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
React.useEffect(() => {
|
|
130
|
+
if (!wantFocus.current) return;
|
|
131
|
+
wantFocus.current = false;
|
|
132
|
+
grid.current
|
|
133
|
+
?.querySelector<HTMLButtonElement>(`[data-date="${startOfDay(focusDate).getTime()}"]`)
|
|
134
|
+
?.focus();
|
|
135
|
+
}, [focusDate]);
|
|
136
|
+
|
|
137
|
+
const onGridKeyDown = (e: React.KeyboardEvent) => {
|
|
138
|
+
const f = focusDate;
|
|
139
|
+
const jump = new Map<string, () => Date>([
|
|
140
|
+
["ArrowLeft", () => addDays(f, -1)],
|
|
141
|
+
["ArrowRight", () => addDays(f, 1)],
|
|
142
|
+
["ArrowUp", () => addDays(f, -7)],
|
|
143
|
+
["ArrowDown", () => addDays(f, 7)],
|
|
144
|
+
["Home", () => addDays(f, -((f.getDay() - weekStart + 7) % 7))],
|
|
145
|
+
["End", () => addDays(f, 6 - ((f.getDay() - weekStart + 7) % 7))],
|
|
146
|
+
["PageUp", () => addMonths(f, e.shiftKey ? -12 : -1)],
|
|
147
|
+
["PageDown", () => addMonths(f, e.shiftKey ? 12 : 1)],
|
|
148
|
+
]);
|
|
149
|
+
const to = jump.get(e.key);
|
|
150
|
+
if (!to) return;
|
|
151
|
+
e.preventDefault();
|
|
152
|
+
moveFocus(to());
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const onPointerDown = (e: React.PointerEvent) => {
|
|
156
|
+
if (e.pointerType === "mouse") return;
|
|
157
|
+
swipe.current = { x: e.clientX, y: e.clientY };
|
|
158
|
+
};
|
|
159
|
+
const onPointerUp = (e: React.PointerEvent) => {
|
|
160
|
+
const s = swipe.current;
|
|
161
|
+
swipe.current = null;
|
|
162
|
+
if (!s) return;
|
|
163
|
+
const dx = e.clientX - s.x;
|
|
164
|
+
if (Math.abs(dx) < SWIPE_PX || Math.abs(dx) < Math.abs(e.clientY - s.y)) return;
|
|
165
|
+
const by = dx < 0 ? 1 : -1;
|
|
166
|
+
if (canShift(by)) shift(by);
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const rows = monthGrid(view.year, view.month, weekStart);
|
|
170
|
+
const viewDate = new Date(view.year, view.month, 1);
|
|
171
|
+
const months = monthNames(locale).map((label, month) => ({ value: month, label }));
|
|
172
|
+
const years = Array.from({ length: YEAR_SPAN * 2 + 1 }, (_, i) => {
|
|
173
|
+
const year = today.getFullYear() - YEAR_SPAN + i;
|
|
174
|
+
return { value: year, label: String(year) };
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
return (
|
|
178
|
+
<div className={cn("cdp flex w-[312px] flex-col select-none", className)} data-slot="calendar">
|
|
179
|
+
<div className="flex h-11 items-center justify-between pr-1 pl-2.5">
|
|
180
|
+
<button
|
|
181
|
+
type="button"
|
|
182
|
+
aria-expanded={picking}
|
|
183
|
+
onClick={() => setPicking((p) => !p)}
|
|
184
|
+
className="flex h-9 items-center gap-1 rounded-lg px-1.5 text-[17px] font-semibold tracking-[-0.01em] text-[var(--cdp-label)] transition-opacity outline-none active:opacity-50 focus-visible:ring-2 focus-visible:ring-[var(--cdp-tint)]"
|
|
185
|
+
>
|
|
186
|
+
<span>{formatMonthYear(viewDate, locale)}</span>
|
|
187
|
+
<Chevron
|
|
188
|
+
className={cn(
|
|
189
|
+
"size-4 text-[var(--cdp-tint)] transition-transform duration-200",
|
|
190
|
+
picking && "rotate-90",
|
|
191
|
+
)}
|
|
192
|
+
/>
|
|
193
|
+
</button>
|
|
194
|
+
<div
|
|
195
|
+
className={cn(
|
|
196
|
+
"flex items-center transition-opacity duration-150",
|
|
197
|
+
picking && "pointer-events-none opacity-0",
|
|
198
|
+
)}
|
|
199
|
+
>
|
|
200
|
+
<button
|
|
201
|
+
type="button"
|
|
202
|
+
aria-label={labels.previousMonth}
|
|
203
|
+
disabled={!canShift(-1)}
|
|
204
|
+
onClick={() => shift(-1)}
|
|
205
|
+
className="flex size-11 items-center justify-center rounded-full text-[var(--cdp-tint)] transition-opacity outline-none active:opacity-40 disabled:opacity-30 focus-visible:ring-2 focus-visible:ring-[var(--cdp-tint)]"
|
|
206
|
+
>
|
|
207
|
+
<Chevron className="rotate-180" />
|
|
208
|
+
</button>
|
|
209
|
+
<button
|
|
210
|
+
type="button"
|
|
211
|
+
aria-label={labels.nextMonth}
|
|
212
|
+
disabled={!canShift(1)}
|
|
213
|
+
onClick={() => shift(1)}
|
|
214
|
+
className="flex size-11 items-center justify-center rounded-full text-[var(--cdp-tint)] transition-opacity outline-none active:opacity-40 disabled:opacity-30 focus-visible:ring-2 focus-visible:ring-[var(--cdp-tint)]"
|
|
215
|
+
>
|
|
216
|
+
<Chevron />
|
|
217
|
+
</button>
|
|
218
|
+
</div>
|
|
219
|
+
</div>
|
|
220
|
+
|
|
221
|
+
<div className="relative grid px-1.5 pb-1.5">
|
|
222
|
+
{/* Day grid and the month/year wheels share the cell; one fades out as the other fades in. */}
|
|
223
|
+
<div
|
|
224
|
+
className={cn(
|
|
225
|
+
"col-start-1 row-start-1 transition-[opacity,transform] duration-200",
|
|
226
|
+
picking && "pointer-events-none scale-95 opacity-0",
|
|
227
|
+
)}
|
|
228
|
+
aria-hidden={picking}
|
|
229
|
+
>
|
|
230
|
+
<div className="grid grid-cols-7" aria-hidden>
|
|
231
|
+
{weekdayNames(locale, weekStart).map((name, i) => (
|
|
232
|
+
<div
|
|
233
|
+
key={i}
|
|
234
|
+
className="flex h-8 items-center justify-center text-[13px] font-semibold text-[var(--cdp-tertiary)]"
|
|
235
|
+
>
|
|
236
|
+
{name}
|
|
237
|
+
</div>
|
|
238
|
+
))}
|
|
239
|
+
</div>
|
|
240
|
+
<div
|
|
241
|
+
ref={grid}
|
|
242
|
+
role="grid"
|
|
243
|
+
tabIndex={-1}
|
|
244
|
+
aria-label={formatMonthYear(viewDate, locale)}
|
|
245
|
+
key={`${view.year}-${view.month}`}
|
|
246
|
+
data-dir={dir}
|
|
247
|
+
onKeyDown={onGridKeyDown}
|
|
248
|
+
onPointerDown={onPointerDown}
|
|
249
|
+
onPointerUp={onPointerUp}
|
|
250
|
+
className="grid grid-cols-7 gap-y-0.5 data-[dir=-1]:animate-[cdp-slide-from-left_240ms_cubic-bezier(0.2,0.9,0.3,1)] data-[dir=1]:animate-[cdp-slide-from-right_240ms_cubic-bezier(0.2,0.9,0.3,1)]"
|
|
251
|
+
style={{ touchAction: "pan-y" }}
|
|
252
|
+
>
|
|
253
|
+
{rows.flat().map(({ date, inMonth }, i) => {
|
|
254
|
+
if (!inMonth) return <div key={i} role="gridcell" aria-hidden />;
|
|
255
|
+
const selected = isSameDay(date, value);
|
|
256
|
+
const isToday = isSameDay(date, today);
|
|
257
|
+
const disabled = isDayDisabled(date, min, max);
|
|
258
|
+
return (
|
|
259
|
+
<button
|
|
260
|
+
key={i}
|
|
261
|
+
type="button"
|
|
262
|
+
role="gridcell"
|
|
263
|
+
aria-selected={selected}
|
|
264
|
+
aria-label={formatFullDate(date, locale)}
|
|
265
|
+
aria-current={isToday ? "date" : undefined}
|
|
266
|
+
data-date={startOfDay(date).getTime()}
|
|
267
|
+
data-today={isToday || undefined}
|
|
268
|
+
data-selected={selected || undefined}
|
|
269
|
+
tabIndex={isSameDay(date, focusDate) ? 0 : -1}
|
|
270
|
+
disabled={disabled}
|
|
271
|
+
onClick={() => pick(date)}
|
|
272
|
+
onFocus={() => setFocusDate(date)}
|
|
273
|
+
className={cn(
|
|
274
|
+
"mx-auto flex size-10 items-center justify-center rounded-full text-[20px] leading-none tabular-nums transition-transform duration-150 outline-none active:scale-90 focus-visible:ring-2 focus-visible:ring-[var(--cdp-tint)] focus-visible:ring-offset-2 focus-visible:ring-offset-[var(--cdp-bg)] disabled:opacity-30",
|
|
275
|
+
selected
|
|
276
|
+
? isToday
|
|
277
|
+
? "bg-[var(--cdp-tint)] font-semibold text-[var(--cdp-on-tint)]"
|
|
278
|
+
: "bg-[var(--cdp-tint-fill)] font-semibold text-[var(--cdp-tint)]"
|
|
279
|
+
: isToday
|
|
280
|
+
? "text-[var(--cdp-tint)] hover:bg-[var(--cdp-fill)]"
|
|
281
|
+
: "text-[var(--cdp-label)] hover:bg-[var(--cdp-fill)]",
|
|
282
|
+
)}
|
|
283
|
+
>
|
|
284
|
+
{date.getDate()}
|
|
285
|
+
</button>
|
|
286
|
+
);
|
|
287
|
+
})}
|
|
288
|
+
</div>
|
|
289
|
+
</div>
|
|
290
|
+
|
|
291
|
+
<div
|
|
292
|
+
className={cn(
|
|
293
|
+
"cdp-wheel-mask relative col-start-1 row-start-1 flex items-center justify-center gap-2 px-2 transition-[opacity,transform] duration-200",
|
|
294
|
+
!picking && "pointer-events-none scale-95 opacity-0",
|
|
295
|
+
)}
|
|
296
|
+
aria-hidden={!picking}
|
|
297
|
+
data-slot="month-year"
|
|
298
|
+
>
|
|
299
|
+
<WheelHighlight className="inset-x-2" />
|
|
300
|
+
<Wheel
|
|
301
|
+
aria-label={labels.month}
|
|
302
|
+
options={months}
|
|
303
|
+
value={view.month}
|
|
304
|
+
loop
|
|
305
|
+
visibleRows={7}
|
|
306
|
+
onChange={(month) => show({ year: view.year, month }, 0)}
|
|
307
|
+
className="w-40"
|
|
308
|
+
/>
|
|
309
|
+
<Wheel
|
|
310
|
+
aria-label={labels.year}
|
|
311
|
+
options={years}
|
|
312
|
+
value={view.year}
|
|
313
|
+
visibleRows={7}
|
|
314
|
+
onChange={(year) => show({ year, month: view.month }, 0)}
|
|
315
|
+
className="w-24"
|
|
316
|
+
/>
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
</div>
|
|
320
|
+
);
|
|
321
|
+
}
|
package/src/calendar.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calendar arithmetic on local `Date`s, with no library. A picker only ever
|
|
3
|
+
* needs the month grid, day-level comparisons, and "same clock, other day".
|
|
4
|
+
*/
|
|
5
|
+
export type YearMonth = { year: number; month: number };
|
|
6
|
+
|
|
7
|
+
export function startOfDay(date: Date): Date {
|
|
8
|
+
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function isSameDay(a: Date | null | undefined, b: Date | null | undefined): boolean {
|
|
12
|
+
return (
|
|
13
|
+
!!a &&
|
|
14
|
+
!!b &&
|
|
15
|
+
a.getFullYear() === b.getFullYear() &&
|
|
16
|
+
a.getMonth() === b.getMonth() &&
|
|
17
|
+
a.getDate() === b.getDate()
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function daysInMonth(year: number, month: number): number {
|
|
22
|
+
return new Date(year, month + 1, 0).getDate();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function yearMonthOf(date: Date): YearMonth {
|
|
26
|
+
return { year: date.getFullYear(), month: date.getMonth() };
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Months between `a` and `b`; negative when `b` is earlier. */
|
|
30
|
+
export function monthDiff(a: YearMonth, b: YearMonth): number {
|
|
31
|
+
return (b.year - a.year) * 12 + (b.month - a.month);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function shiftMonth(ym: YearMonth, by: number): YearMonth {
|
|
35
|
+
const total = ym.month + by;
|
|
36
|
+
return { year: ym.year + Math.floor(total / 12), month: ((total % 12) + 12) % 12 };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** The same clock on another calendar day; the day is clamped into the month. */
|
|
40
|
+
export function withDay(base: Date, year: number, month: number, day: number): Date {
|
|
41
|
+
const next = new Date(base);
|
|
42
|
+
next.setFullYear(year, month, Math.min(day, daysInMonth(year, month)));
|
|
43
|
+
return next;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function withTime(base: Date, hours: number, minutes: number): Date {
|
|
47
|
+
const next = new Date(base);
|
|
48
|
+
next.setHours(hours, minutes, 0, 0);
|
|
49
|
+
return next;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function addDays(date: Date, days: number): Date {
|
|
53
|
+
const next = new Date(date);
|
|
54
|
+
next.setDate(next.getDate() + days);
|
|
55
|
+
return next;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function addMonths(date: Date, months: number): Date {
|
|
59
|
+
const ym = shiftMonth(yearMonthOf(date), months);
|
|
60
|
+
return withDay(date, ym.year, ym.month, date.getDate());
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Whether the whole day lies outside [min, max]. */
|
|
64
|
+
export function isDayDisabled(date: Date, min?: Date, max?: Date): boolean {
|
|
65
|
+
const day = startOfDay(date).getTime();
|
|
66
|
+
if (min && day < startOfDay(min).getTime()) return true;
|
|
67
|
+
if (max && day > startOfDay(max).getTime()) return true;
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export type DayCell = { date: Date; inMonth: boolean };
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Rows of seven for one month, starting on `weekStart` (0 = Sunday). Only as
|
|
75
|
+
* many rows as the month needs, like the iOS inline calendar.
|
|
76
|
+
*/
|
|
77
|
+
export function monthGrid(year: number, month: number, weekStart: number): DayCell[][] {
|
|
78
|
+
const lead = (new Date(year, month, 1).getDay() - weekStart + 7) % 7;
|
|
79
|
+
const rows = Math.ceil((lead + daysInMonth(year, month)) / 7);
|
|
80
|
+
return Array.from({ length: rows }, (_row, r) =>
|
|
81
|
+
Array.from({ length: 7 }, (_cell, c) => {
|
|
82
|
+
const date = new Date(year, month, r * 7 + c - lead + 1);
|
|
83
|
+
return { date, inMonth: date.getMonth() === month };
|
|
84
|
+
}),
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** First day of the week for a locale, 0 = Sunday … 6 = Saturday. */
|
|
89
|
+
export function weekStartFor(locale: string): number {
|
|
90
|
+
const firstDay = readFirstDay(locale);
|
|
91
|
+
if (firstDay !== undefined) return firstDay % 7;
|
|
92
|
+
return /^(en-(US|CA|AU)|ja|ko|zh-(TW|HK)|he|pt-BR|es-(MX|US))\b/i.test(locale) ? 0 : 1;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* `Intl.Locale#getWeekInfo` (or the older `weekInfo` getter) where the engine
|
|
97
|
+
* has it, read without assuming either exists in the type library.
|
|
98
|
+
*/
|
|
99
|
+
function readFirstDay(locale: string): number | undefined {
|
|
100
|
+
let l: object;
|
|
101
|
+
try {
|
|
102
|
+
l = new Intl.Locale(locale);
|
|
103
|
+
} catch {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
const info: unknown =
|
|
107
|
+
"getWeekInfo" in l && typeof l.getWeekInfo === "function"
|
|
108
|
+
? l.getWeekInfo()
|
|
109
|
+
: "weekInfo" in l
|
|
110
|
+
? l.weekInfo
|
|
111
|
+
: undefined;
|
|
112
|
+
return typeof info === "object" &&
|
|
113
|
+
info !== null &&
|
|
114
|
+
"firstDay" in info &&
|
|
115
|
+
typeof info.firstDay === "number"
|
|
116
|
+
? info.firstDay
|
|
117
|
+
: undefined;
|
|
118
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { Popover } from "@base-ui/react/popover";
|
|
2
|
+
|
|
3
|
+
import { CalendarPanel } from "./calendar-panel";
|
|
4
|
+
import { useControlled } from "./hooks";
|
|
5
|
+
import { formatClock, formatDay, hourCycleFor, type HourCycle } from "./time";
|
|
6
|
+
import { TimePanel } from "./time-panel";
|
|
7
|
+
import { cn } from "./utils";
|
|
8
|
+
|
|
9
|
+
export type DateTimePickerProps = {
|
|
10
|
+
value?: Date | null;
|
|
11
|
+
defaultValue?: Date | null;
|
|
12
|
+
onChange?: (date: Date | null) => void;
|
|
13
|
+
/** Which parts are editable — `UIDatePicker.Mode`. */
|
|
14
|
+
mode?: "date" | "time" | "dateTime";
|
|
15
|
+
/** Pills that open popovers (`.compact`), or the panels laid out in place (`.inline`). */
|
|
16
|
+
display?: "compact" | "inline";
|
|
17
|
+
locale?: string;
|
|
18
|
+
hourCycle?: HourCycle;
|
|
19
|
+
minuteInterval?: number;
|
|
20
|
+
min?: Date;
|
|
21
|
+
max?: Date;
|
|
22
|
+
disabled?: boolean;
|
|
23
|
+
className?: string;
|
|
24
|
+
/** Placeholder labels when there is no value. */
|
|
25
|
+
labels?: { date?: string; time?: string };
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/** The compact pill and its popover, for composing your own rows. */
|
|
29
|
+
export const pillClass =
|
|
30
|
+
"inline-flex h-[34px] items-center rounded-lg bg-[var(--cdp-fill)] px-3 text-[17px] leading-none text-[var(--cdp-label)] transition-[background-color,color] outline-none hover:bg-[var(--cdp-fill-hover)] focus-visible:ring-2 focus-visible:ring-[var(--cdp-tint)] active:opacity-60 disabled:opacity-40 data-popup-open:text-[var(--cdp-tint)]";
|
|
31
|
+
|
|
32
|
+
export const popupClass =
|
|
33
|
+
"cdp origin-(--transform-origin) rounded-[13px] bg-[var(--cdp-bg)] shadow-[var(--cdp-shadow)] outline-none data-open:animate-[cdp-pop-in_260ms_cubic-bezier(0.18,0.9,0.32,1.15)] data-closed:animate-[cdp-pop-out_140ms_ease-in]";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The iOS 14+ date picker for the web. Compact display shows the date and
|
|
37
|
+
* time as pills; each opens a popover — the calendar or the time panel —
|
|
38
|
+
* anchored to it. Inline display lays the panels out in place.
|
|
39
|
+
*/
|
|
40
|
+
export function DateTimePicker({
|
|
41
|
+
value: valueProp,
|
|
42
|
+
defaultValue = null,
|
|
43
|
+
onChange,
|
|
44
|
+
mode = "dateTime",
|
|
45
|
+
display = "compact",
|
|
46
|
+
locale = navigator.language,
|
|
47
|
+
hourCycle: hourCycleProp,
|
|
48
|
+
minuteInterval = 1,
|
|
49
|
+
min,
|
|
50
|
+
max,
|
|
51
|
+
disabled = false,
|
|
52
|
+
className,
|
|
53
|
+
labels,
|
|
54
|
+
}: DateTimePickerProps) {
|
|
55
|
+
const [value, setValue] = useControlled(valueProp, defaultValue, onChange);
|
|
56
|
+
const hourCycle = hourCycleProp ?? hourCycleFor(locale);
|
|
57
|
+
const showDate = mode !== "time";
|
|
58
|
+
const showTime = mode !== "date";
|
|
59
|
+
// Panels always have something to edit; a picked day or time starts from now.
|
|
60
|
+
const draft = value ?? new Date();
|
|
61
|
+
|
|
62
|
+
const calendar = (
|
|
63
|
+
<CalendarPanel value={value} onChange={setValue} locale={locale} min={min} max={max} />
|
|
64
|
+
);
|
|
65
|
+
const time = (
|
|
66
|
+
<TimePanel
|
|
67
|
+
value={draft}
|
|
68
|
+
onChange={setValue}
|
|
69
|
+
locale={locale}
|
|
70
|
+
hourCycle={hourCycle}
|
|
71
|
+
minuteInterval={minuteInterval}
|
|
72
|
+
/>
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
const timePopover = (
|
|
76
|
+
<Popover.Root>
|
|
77
|
+
<Popover.Trigger className={pillClass} disabled={disabled} data-slot="time-trigger">
|
|
78
|
+
{value ? formatClock(value, locale, hourCycle) : (labels?.time ?? "Time")}
|
|
79
|
+
</Popover.Trigger>
|
|
80
|
+
<Popover.Portal>
|
|
81
|
+
<Popover.Positioner sideOffset={8} align="center" className="isolate z-50">
|
|
82
|
+
<Popover.Popup className={popupClass} data-slot="time-popup">
|
|
83
|
+
{time}
|
|
84
|
+
</Popover.Popup>
|
|
85
|
+
</Popover.Positioner>
|
|
86
|
+
</Popover.Portal>
|
|
87
|
+
</Popover.Root>
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
if (display === "inline") {
|
|
91
|
+
return (
|
|
92
|
+
<div
|
|
93
|
+
className={cn(
|
|
94
|
+
"cdp inline-flex flex-col items-stretch rounded-[13px] bg-[var(--cdp-bg)]",
|
|
95
|
+
className,
|
|
96
|
+
)}
|
|
97
|
+
data-slot="date-time-picker"
|
|
98
|
+
data-display="inline"
|
|
99
|
+
>
|
|
100
|
+
{showDate && calendar}
|
|
101
|
+
{/* As on iOS: the time sits on a "Time" row under the calendar and opens the wheels. */}
|
|
102
|
+
{showTime &&
|
|
103
|
+
(showDate ? (
|
|
104
|
+
<div className="mx-3 flex h-[52px] items-center justify-between border-t border-[var(--cdp-fill)]">
|
|
105
|
+
<span className="text-[17px] text-[var(--cdp-label)]">{labels?.time ?? "Time"}</span>
|
|
106
|
+
{timePopover}
|
|
107
|
+
</div>
|
|
108
|
+
) : (
|
|
109
|
+
time
|
|
110
|
+
))}
|
|
111
|
+
</div>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return (
|
|
116
|
+
<div
|
|
117
|
+
className={cn("cdp inline-flex items-center gap-2", className)}
|
|
118
|
+
data-slot="date-time-picker"
|
|
119
|
+
data-display="compact"
|
|
120
|
+
>
|
|
121
|
+
{showDate && (
|
|
122
|
+
<Popover.Root>
|
|
123
|
+
<Popover.Trigger className={pillClass} disabled={disabled} data-slot="date-trigger">
|
|
124
|
+
{value ? formatDay(value, locale) : (labels?.date ?? "Date")}
|
|
125
|
+
</Popover.Trigger>
|
|
126
|
+
<Popover.Portal>
|
|
127
|
+
<Popover.Positioner sideOffset={8} align="center" className="isolate z-50">
|
|
128
|
+
<Popover.Popup className={popupClass} data-slot="date-popup">
|
|
129
|
+
{calendar}
|
|
130
|
+
</Popover.Popup>
|
|
131
|
+
</Popover.Positioner>
|
|
132
|
+
</Popover.Portal>
|
|
133
|
+
</Popover.Root>
|
|
134
|
+
)}
|
|
135
|
+
{showTime && timePopover}
|
|
136
|
+
</div>
|
|
137
|
+
);
|
|
138
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
/** Controlled when `value` is given, otherwise owned here. */
|
|
4
|
+
export function useControlled<T>(
|
|
5
|
+
value: T | undefined,
|
|
6
|
+
defaultValue: T,
|
|
7
|
+
onChange: ((next: T) => void) | undefined,
|
|
8
|
+
): [T, (next: T) => void] {
|
|
9
|
+
const [inner, setInner] = React.useState(defaultValue);
|
|
10
|
+
const current = value === undefined ? inner : value;
|
|
11
|
+
const set = (next: T) => {
|
|
12
|
+
if (value === undefined) setInner(next);
|
|
13
|
+
onChange?.(next);
|
|
14
|
+
};
|
|
15
|
+
return [current, set];
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function usePrefersReducedMotion(): boolean {
|
|
19
|
+
return React.useSyncExternalStore(
|
|
20
|
+
(notify) => {
|
|
21
|
+
const query = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
22
|
+
query.addEventListener("change", notify);
|
|
23
|
+
return () => query.removeEventListener("change", notify);
|
|
24
|
+
},
|
|
25
|
+
() => window.matchMedia("(prefers-reduced-motion: reduce)").matches,
|
|
26
|
+
() => false,
|
|
27
|
+
);
|
|
28
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export {
|
|
2
|
+
DateTimePicker,
|
|
3
|
+
pillClass,
|
|
4
|
+
popupClass,
|
|
5
|
+
type DateTimePickerProps,
|
|
6
|
+
} from "./date-time-picker";
|
|
7
|
+
export {
|
|
8
|
+
CalendarPanel,
|
|
9
|
+
CALENDAR_LABELS,
|
|
10
|
+
type CalendarPanelLabels,
|
|
11
|
+
type CalendarPanelProps,
|
|
12
|
+
} from "./calendar-panel";
|
|
13
|
+
export { TimePanel, TIME_LABELS, type TimePanelLabels, type TimePanelProps } from "./time-panel";
|
|
14
|
+
export { Wheel, WheelHighlight, type WheelOption, type WheelProps } from "./wheel";
|
|
15
|
+
export { SegmentedControl, type SegmentedOption } from "./segmented-control";
|
|
16
|
+
export * from "./calendar";
|
|
17
|
+
export * from "./time";
|
|
18
|
+
export { typeDigit, stepValue, type DigitStep } from "./segments";
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { cn } from "./utils";
|
|
4
|
+
|
|
5
|
+
export type SegmentedOption<T> = { value: T; label: string };
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The iOS segmented control: a translucent track with a white thumb that
|
|
9
|
+
* slides to the chosen segment. Underneath it is a native radio group, so
|
|
10
|
+
* arrow keys, focus and form semantics come from the browser.
|
|
11
|
+
*/
|
|
12
|
+
export function SegmentedControl<T extends string | number | boolean>({
|
|
13
|
+
options,
|
|
14
|
+
value,
|
|
15
|
+
onChange,
|
|
16
|
+
"aria-label": ariaLabel,
|
|
17
|
+
className,
|
|
18
|
+
}: {
|
|
19
|
+
options: SegmentedOption<T>[];
|
|
20
|
+
value: T;
|
|
21
|
+
onChange: (value: T) => void;
|
|
22
|
+
"aria-label": string;
|
|
23
|
+
className?: string;
|
|
24
|
+
}) {
|
|
25
|
+
const name = React.useId();
|
|
26
|
+
const index = Math.max(
|
|
27
|
+
0,
|
|
28
|
+
options.findIndex((o) => o.value === value),
|
|
29
|
+
);
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
role="radiogroup"
|
|
33
|
+
aria-label={ariaLabel}
|
|
34
|
+
data-slot="segmented-control"
|
|
35
|
+
className={cn(
|
|
36
|
+
"relative inline-grid h-8 auto-cols-fr grid-flow-col rounded-[9px] bg-[var(--cdp-fill)] p-0.5",
|
|
37
|
+
className,
|
|
38
|
+
)}
|
|
39
|
+
>
|
|
40
|
+
<div
|
|
41
|
+
aria-hidden
|
|
42
|
+
className="pointer-events-none absolute inset-y-0.5 left-0.5 rounded-[7px] bg-[var(--cdp-thumb)] shadow-[0_3px_8px_rgba(0,0,0,0.12),0_3px_1px_rgba(0,0,0,0.04)] transition-transform duration-200 ease-[cubic-bezier(0.2,0,0,1)]"
|
|
43
|
+
style={{
|
|
44
|
+
width: `calc((100% - 4px) / ${options.length})`,
|
|
45
|
+
transform: `translateX(${index * 100}%)`,
|
|
46
|
+
}}
|
|
47
|
+
/>
|
|
48
|
+
{options.map((option) => {
|
|
49
|
+
const checked = option.value === value;
|
|
50
|
+
return (
|
|
51
|
+
<label
|
|
52
|
+
key={String(option.value)}
|
|
53
|
+
className={cn(
|
|
54
|
+
"relative z-10 flex min-w-12 cursor-pointer items-center justify-center rounded-[7px] px-3 text-[13px] font-semibold transition-colors select-none has-focus-visible:ring-2 has-focus-visible:ring-[var(--cdp-tint)]",
|
|
55
|
+
checked ? "text-[var(--cdp-label)]" : "text-[var(--cdp-label)]/80 active:opacity-60",
|
|
56
|
+
)}
|
|
57
|
+
>
|
|
58
|
+
<input
|
|
59
|
+
type="radio"
|
|
60
|
+
name={name}
|
|
61
|
+
checked={checked}
|
|
62
|
+
onChange={() => onChange(option.value)}
|
|
63
|
+
// Covers the label so the radio itself is the hit target.
|
|
64
|
+
className="absolute inset-0 cursor-pointer appearance-none opacity-0"
|
|
65
|
+
/>
|
|
66
|
+
{option.label}
|
|
67
|
+
</label>
|
|
68
|
+
);
|
|
69
|
+
})}
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|