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.
@@ -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
+ }
@@ -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
+ }