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,26 @@
1
+ /**
2
+ * Typing into a two-digit clock segment. The person types digits; the segment
3
+ * shows what has been typed, commits as soon as the digits name a value, and
4
+ * says when it is done so focus can move on (the second digit arrived, or no
5
+ * further digit could still fit under `max`).
6
+ */
7
+ export type DigitStep = { buffer: string; value: number | null; done: boolean };
8
+
9
+ export function typeDigit(buffer: string, digit: string, min: number, max: number): DigitStep {
10
+ const next = buffer.length >= 2 ? digit : buffer + digit;
11
+ const n = Number(next);
12
+ if (n > max) {
13
+ // The pair cannot be a value: start over with just this digit.
14
+ const d = Number(digit);
15
+ return d > max || d < min
16
+ ? { buffer: "", value: null, done: false }
17
+ : { buffer: digit, value: d, done: d * 10 > max };
18
+ }
19
+ return { buffer: next, value: n >= min ? n : null, done: next.length === 2 || n * 10 > max };
20
+ }
21
+
22
+ /** Step within [min, max], wrapping at both ends. */
23
+ export function stepValue(value: number, by: number, min: number, max: number): number {
24
+ const span = max - min + 1;
25
+ return min + ((((value - min + by) % span) + span) % span);
26
+ }
package/src/styles.css ADDED
@@ -0,0 +1,91 @@
1
+ /*
2
+ * Tokens follow the iOS system palette: tint, the translucent fills, and
3
+ * the label hierarchy. Override `--cdp-tint` to re-tint; add `.dark` (or
4
+ * `data-theme="dark"`) on an ancestor for dark mode.
5
+ */
6
+ :root {
7
+ --cdp-tint: #007aff;
8
+ --cdp-tint-fill: color-mix(in srgb, var(--cdp-tint) 14%, transparent);
9
+ --cdp-on-tint: #fff;
10
+ --cdp-fill: rgba(120, 120, 128, 0.12);
11
+ --cdp-fill-hover: rgba(120, 120, 128, 0.2);
12
+ --cdp-label: #000;
13
+ --cdp-secondary: rgba(60, 60, 67, 0.6);
14
+ --cdp-tertiary: rgba(60, 60, 67, 0.3);
15
+ --cdp-bg: #fff;
16
+ --cdp-thumb: #fff;
17
+ --cdp-shadow:
18
+ 0 0 0 0.5px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(0, 0, 0, 0.06), 0 16px 48px rgba(0, 0, 0, 0.18);
19
+ }
20
+
21
+ .dark,
22
+ [data-theme="dark"] {
23
+ --cdp-tint: #0a84ff;
24
+ --cdp-fill: rgba(120, 120, 128, 0.24);
25
+ --cdp-fill-hover: rgba(120, 120, 128, 0.32);
26
+ --cdp-label: #fff;
27
+ --cdp-secondary: rgba(235, 235, 245, 0.6);
28
+ --cdp-tertiary: rgba(235, 235, 245, 0.3);
29
+ --cdp-bg: #1c1c1e;
30
+ --cdp-thumb: #636366;
31
+ --cdp-shadow:
32
+ 0 0 0 0.5px rgba(255, 255, 255, 0.12), 0 4px 12px rgba(0, 0, 0, 0.3),
33
+ 0 16px 48px rgba(0, 0, 0, 0.6);
34
+ }
35
+
36
+ .cdp {
37
+ font-family:
38
+ -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display", "Helvetica Neue", system-ui,
39
+ sans-serif;
40
+ -webkit-font-smoothing: antialiased;
41
+ -webkit-tap-highlight-color: transparent;
42
+ color: var(--cdp-label);
43
+ }
44
+
45
+ .cdp-wheel::-webkit-scrollbar {
46
+ display: none;
47
+ }
48
+
49
+ .cdp-wheel-mask {
50
+ mask-image: linear-gradient(to bottom, transparent, black 22%, black 78%, transparent);
51
+ }
52
+
53
+ @keyframes cdp-pop-in {
54
+ from {
55
+ opacity: 0;
56
+ transform: scale(0.86);
57
+ }
58
+ to {
59
+ opacity: 1;
60
+ transform: scale(1);
61
+ }
62
+ }
63
+
64
+ @keyframes cdp-pop-out {
65
+ to {
66
+ opacity: 0;
67
+ transform: scale(0.96);
68
+ }
69
+ }
70
+
71
+ @keyframes cdp-slide-from-right {
72
+ from {
73
+ opacity: 0;
74
+ transform: translateX(28px);
75
+ }
76
+ }
77
+
78
+ @keyframes cdp-slide-from-left {
79
+ from {
80
+ opacity: 0;
81
+ transform: translateX(-28px);
82
+ }
83
+ }
84
+
85
+ @media (prefers-reduced-motion: reduce) {
86
+ .cdp *,
87
+ .cdp {
88
+ animation-duration: 0.01ms !important;
89
+ transition-duration: 0.01ms !important;
90
+ }
91
+ }
@@ -0,0 +1,262 @@
1
+ import * as React from "react";
2
+
3
+ import { withTime } from "./calendar";
4
+ import { SegmentedControl } from "./segmented-control";
5
+ import { stepValue, typeDigit } from "./segments";
6
+ import { dayPeriodLabels, hourCycleFor, to12, to24, type HourCycle } from "./time";
7
+ import { cn, pad2 } from "./utils";
8
+ import { Wheel, WheelHighlight } from "./wheel";
9
+
10
+ export type TimePanelLabels = {
11
+ time: string;
12
+ hour: string;
13
+ minute: string;
14
+ dayPeriod: string;
15
+ };
16
+
17
+ export const TIME_LABELS: TimePanelLabels = {
18
+ time: "Time",
19
+ hour: "Hour",
20
+ minute: "Minute",
21
+ dayPeriod: "Day period",
22
+ };
23
+
24
+ export type TimePanelProps = {
25
+ value: Date;
26
+ onChange: (date: Date) => void;
27
+ locale?: string;
28
+ hourCycle?: HourCycle;
29
+ /** Accessible names; visible text comes from `Intl`. */
30
+ labels?: Partial<TimePanelLabels>;
31
+ /** Minute wheel granularity, like `UIDatePicker.minuteInterval`. */
32
+ minuteInterval?: number;
33
+ /** Hide the wheels and keep only the typed field. */
34
+ wheels?: boolean;
35
+ className?: string;
36
+ };
37
+
38
+ type Segment = "hour" | "minute";
39
+
40
+ /**
41
+ * Time entry the way iOS 14+ does it, both halves at once: a field of
42
+ * segments that takes digits from a keyboard or keypad and steps with the
43
+ * arrows, and the hour / minute / period wheels underneath. Either side
44
+ * moves the other — type "930" and the wheels spin there; fling a wheel and
45
+ * the digits follow.
46
+ */
47
+ export function TimePanel({
48
+ value,
49
+ onChange,
50
+ locale = navigator.language,
51
+ hourCycle: hourCycleProp,
52
+ minuteInterval = 1,
53
+ wheels = true,
54
+ labels: labelsProp,
55
+ className,
56
+ }: TimePanelProps) {
57
+ const labels = { ...TIME_LABELS, ...labelsProp };
58
+ const hourCycle = hourCycleProp ?? hourCycleFor(locale);
59
+ const twelve = hourCycle === "h12";
60
+ const hours24 = value.getHours();
61
+ const minutes = value.getMinutes();
62
+ const { hour: hour12, pm } = to12(hours24);
63
+ const hourShown = twelve ? hour12 : hours24;
64
+ const hourMin = twelve ? 1 : 0;
65
+ const hourMax = twelve ? 12 : 23;
66
+ const periods = dayPeriodLabels(locale);
67
+
68
+ const [typing, setTyping] = React.useState<{ segment: Segment; buffer: string } | null>(null);
69
+ const hourRef = React.useRef<HTMLInputElement>(null);
70
+ const minuteRef = React.useRef<HTMLInputElement>(null);
71
+
72
+ const setHour = (shown: number) =>
73
+ onChange(withTime(value, twelve ? to24(shown, pm) : shown, minutes));
74
+ const setMinute = (m: number) => onChange(withTime(value, hours24, m));
75
+ const setPm = (next: boolean) => onChange(withTime(value, to24(hour12, next), minutes));
76
+
77
+ const focusSegment = (segment: Segment) => {
78
+ const el = segment === "hour" ? hourRef.current : minuteRef.current;
79
+ el?.focus();
80
+ el?.select();
81
+ };
82
+
83
+ const typeInto = (segment: Segment, digit: string) => {
84
+ const buffer = typing?.segment === segment ? typing.buffer : "";
85
+ const step =
86
+ segment === "hour"
87
+ ? typeDigit(buffer, digit, hourMin, hourMax)
88
+ : typeDigit(buffer, digit, 0, 59);
89
+ if (step.value !== null) (segment === "hour" ? setHour : setMinute)(step.value);
90
+ if (step.done) {
91
+ setTyping(null);
92
+ if (segment === "hour") focusSegment("minute");
93
+ } else {
94
+ setTyping({ segment, buffer: step.buffer });
95
+ }
96
+ };
97
+
98
+ const onSegmentKeyDown = (segment: Segment) => (e: React.KeyboardEvent<HTMLInputElement>) => {
99
+ const key = e.key;
100
+ if (/^\d$/.test(key)) {
101
+ e.preventDefault();
102
+ typeInto(segment, key);
103
+ return;
104
+ }
105
+ const by = key === "ArrowUp" ? 1 : key === "ArrowDown" ? -1 : 0;
106
+ if (by) {
107
+ e.preventDefault();
108
+ setTyping(null);
109
+ if (segment === "hour") setHour(stepValue(hourShown, by, hourMin, hourMax));
110
+ else setMinute(stepValue(minutes, by * minuteInterval, 0, 59));
111
+ return;
112
+ }
113
+ if (key === "ArrowLeft" && segment === "minute") {
114
+ e.preventDefault();
115
+ focusSegment("hour");
116
+ } else if (key === "ArrowRight" && segment === "hour") {
117
+ e.preventDefault();
118
+ focusSegment("minute");
119
+ } else if (key === "Backspace" || key === "Delete") {
120
+ e.preventDefault();
121
+ setTyping({ segment, buffer: "" });
122
+ } else if (twelve && (key === "a" || key === "A" || key === "p" || key === "P")) {
123
+ e.preventDefault();
124
+ setPm(key === "p" || key === "P");
125
+ } else if (key === "Enter") {
126
+ e.currentTarget.blur();
127
+ } else if (key.length === 1 && !e.metaKey && !e.ctrlKey) {
128
+ e.preventDefault();
129
+ }
130
+ };
131
+
132
+ // Mobile keypads and IMEs insert text without a key name; `beforeinput`
133
+ // carries the digit either way.
134
+ const onBeforeInput = (segment: Segment) => (e: React.FormEvent<HTMLInputElement>) => {
135
+ e.preventDefault();
136
+ const data = (e.nativeEvent as InputEvent).data ?? "";
137
+ for (const ch of data) if (/\d/.test(ch)) typeInto(segment, ch);
138
+ };
139
+
140
+ // An engine that skipped `beforeinput` still changes the input: whatever
141
+ // was added to the shown text is what was typed.
142
+ const onInput = (segment: Segment) => (e: React.ChangeEvent<HTMLInputElement>) => {
143
+ const added = e.target.value.replace(shownText(segment), "");
144
+ for (const ch of added) if (/\d/.test(ch)) typeInto(segment, ch);
145
+ };
146
+
147
+ const shownText = (segment: Segment) => {
148
+ if (typing?.segment === segment) return typing.buffer;
149
+ if (segment === "hour") return twelve ? String(hourShown) : pad2(hourShown);
150
+ return pad2(minutes);
151
+ };
152
+
153
+ const segmentClass =
154
+ "box-content h-10 rounded-md bg-transparent px-1 text-center text-[32px] leading-none font-light tabular-nums text-[var(--cdp-label)] caret-transparent outline-none selection:bg-transparent focus:bg-[var(--cdp-tint-fill)] focus:text-[var(--cdp-tint)]";
155
+
156
+ const hourOptions = Array.from({ length: hourMax - hourMin + 1 }, (_, i) => {
157
+ const h = hourMin + i;
158
+ return { value: h, label: twelve ? String(h) : pad2(h) };
159
+ });
160
+ const minuteOptions = Array.from({ length: Math.ceil(60 / minuteInterval) }, (_, i) => ({
161
+ value: i * minuteInterval,
162
+ label: pad2(i * minuteInterval),
163
+ }));
164
+
165
+ return (
166
+ <div
167
+ className={cn("cdp flex w-[280px] flex-col gap-2 p-3 select-none", className)}
168
+ data-slot="time"
169
+ >
170
+ <div className="flex items-center justify-between gap-3">
171
+ <div
172
+ role="group"
173
+ aria-label={labels.time}
174
+ className="flex h-11 items-center rounded-lg bg-[var(--cdp-fill)] px-1.5"
175
+ data-slot="time-field"
176
+ >
177
+ <input
178
+ ref={hourRef}
179
+ aria-label={labels.hour}
180
+ inputMode="numeric"
181
+ autoComplete="off"
182
+ value={shownText("hour")}
183
+ onChange={onInput("hour")}
184
+ onBeforeInput={onBeforeInput("hour")}
185
+ onKeyDown={onSegmentKeyDown("hour")}
186
+ onFocus={(e) => e.currentTarget.select()}
187
+ onBlur={() => setTyping(null)}
188
+ className={segmentClass}
189
+ style={{ width: `${Math.max(1, shownText("hour").length || 1)}ch` }}
190
+ data-segment="hour"
191
+ />
192
+ <span
193
+ className="-mx-0.5 text-[32px] leading-none font-light text-[var(--cdp-label)]"
194
+ aria-hidden
195
+ >
196
+ :
197
+ </span>
198
+ <input
199
+ ref={minuteRef}
200
+ aria-label={labels.minute}
201
+ inputMode="numeric"
202
+ autoComplete="off"
203
+ value={shownText("minute")}
204
+ onChange={onInput("minute")}
205
+ onBeforeInput={onBeforeInput("minute")}
206
+ onKeyDown={onSegmentKeyDown("minute")}
207
+ onFocus={(e) => e.currentTarget.select()}
208
+ onBlur={() => setTyping(null)}
209
+ className={segmentClass}
210
+ style={{ width: "2ch" }}
211
+ data-segment="minute"
212
+ />
213
+ </div>
214
+ {twelve && (
215
+ <SegmentedControl
216
+ aria-label={labels.dayPeriod}
217
+ options={[
218
+ { value: false, label: periods.am },
219
+ { value: true, label: periods.pm },
220
+ ]}
221
+ value={pm}
222
+ onChange={setPm}
223
+ />
224
+ )}
225
+ </div>
226
+
227
+ {wheels && (
228
+ <div className="cdp-wheel-mask relative flex justify-center" data-slot="time-wheels">
229
+ <WheelHighlight />
230
+ <Wheel
231
+ aria-label={labels.hour}
232
+ options={hourOptions}
233
+ value={hourShown}
234
+ loop
235
+ onChange={setHour}
236
+ className="w-16"
237
+ />
238
+ <Wheel
239
+ aria-label={labels.minute}
240
+ options={minuteOptions}
241
+ value={minutes - (minutes % minuteInterval)}
242
+ loop
243
+ onChange={setMinute}
244
+ className="w-16"
245
+ />
246
+ {twelve && (
247
+ <Wheel
248
+ aria-label={labels.dayPeriod}
249
+ options={[
250
+ { value: 0, label: periods.am },
251
+ { value: 1, label: periods.pm },
252
+ ]}
253
+ value={pm ? 1 : 0}
254
+ onChange={(v) => setPm(v === 1)}
255
+ className="w-16"
256
+ />
257
+ )}
258
+ </div>
259
+ )}
260
+ </div>
261
+ );
262
+ }
package/src/time.ts ADDED
@@ -0,0 +1,71 @@
1
+ /**
2
+ * Clock helpers on top of `Intl`, so 12/24-hour, day-period labels, and month
3
+ * and weekday names all follow the locale instead of a hard-coded table.
4
+ */
5
+ export type HourCycle = "h12" | "h23";
6
+
7
+ export function hourCycleFor(locale: string): HourCycle {
8
+ const cycle = new Intl.DateTimeFormat(locale, { hour: "numeric" }).resolvedOptions().hourCycle;
9
+ return cycle === "h11" || cycle === "h12" ? "h12" : "h23";
10
+ }
11
+
12
+ export function to12(hour24: number): { hour: number; pm: boolean } {
13
+ return { hour: hour24 % 12 === 0 ? 12 : hour24 % 12, pm: hour24 >= 12 };
14
+ }
15
+
16
+ export function to24(hour12: number, pm: boolean): number {
17
+ return (hour12 % 12) + (pm ? 12 : 0);
18
+ }
19
+
20
+ export function dayPeriodLabels(locale: string): { am: string; pm: string } {
21
+ const f = new Intl.DateTimeFormat(locale, { hour: "numeric", hourCycle: "h12" });
22
+ const label = (hour: number) =>
23
+ f.formatToParts(new Date(2000, 0, 1, hour)).find((p) => p.type === "dayPeriod")?.value ??
24
+ (hour < 12 ? "AM" : "PM");
25
+ return { am: label(1), pm: label(13) };
26
+ }
27
+
28
+ export function monthNames(locale: string, style: "long" | "short" = "long"): string[] {
29
+ const f = new Intl.DateTimeFormat(locale, { month: style });
30
+ return Array.from({ length: 12 }, (_, m) => f.format(new Date(2000, m, 1)));
31
+ }
32
+
33
+ /** Seven names starting on `weekStart` (0 = Sunday). */
34
+ export function weekdayNames(
35
+ locale: string,
36
+ weekStart: number,
37
+ style: "narrow" | "short" = "narrow",
38
+ ): string[] {
39
+ const f = new Intl.DateTimeFormat(locale, { weekday: style });
40
+ // 2023-01-01 is a Sunday.
41
+ return Array.from({ length: 7 }, (_, i) =>
42
+ f.format(new Date(2023, 0, 1 + ((weekStart + i) % 7))),
43
+ );
44
+ }
45
+
46
+ /** The locale's medium date, as the iOS pill shows it: "Sep 5, 2026", "05.09.2026", "2026年9月5日". */
47
+ export function formatDay(date: Date, locale: string): string {
48
+ return new Intl.DateTimeFormat(locale, { dateStyle: "medium" }).format(date);
49
+ }
50
+
51
+ /** "6:30 PM" on a 12-hour clock, "06:30" on a 24-hour one. */
52
+ export function formatClock(date: Date, locale: string, hourCycle: HourCycle): string {
53
+ return new Intl.DateTimeFormat(locale, {
54
+ hour: hourCycle === "h12" ? "numeric" : "2-digit",
55
+ minute: "2-digit",
56
+ hourCycle,
57
+ }).format(date);
58
+ }
59
+
60
+ export function formatMonthYear(date: Date, locale: string): string {
61
+ return new Intl.DateTimeFormat(locale, { month: "long", year: "numeric" }).format(date);
62
+ }
63
+
64
+ export function formatFullDate(date: Date, locale: string): string {
65
+ return new Intl.DateTimeFormat(locale, {
66
+ weekday: "long",
67
+ year: "numeric",
68
+ month: "long",
69
+ day: "numeric",
70
+ }).format(date);
71
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,19 @@
1
+ import { clsx, type ClassValue } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ export function cn(...inputs: ClassValue[]) {
5
+ return twMerge(clsx(inputs));
6
+ }
7
+
8
+ export function clamp(n: number, min: number, max: number) {
9
+ return Math.min(max, Math.max(min, n));
10
+ }
11
+
12
+ /** Wrap `n` into [0, len). */
13
+ export function mod(n: number, len: number) {
14
+ return ((n % len) + len) % len;
15
+ }
16
+
17
+ export function pad2(n: number) {
18
+ return n < 10 ? `0${n}` : String(n);
19
+ }
@@ -0,0 +1,35 @@
1
+ import { clamp } from "./utils";
2
+
3
+ /** The row a scroll offset rests on. */
4
+ export function snapIndex(scrollTop: number, itemHeight: number): number {
5
+ return Math.round(scrollTop / itemHeight);
6
+ }
7
+
8
+ /**
9
+ * Where a released drag comes to rest. `velocity` is d(scrollTop)/dt in
10
+ * px/ms; the projection mirrors UIScrollView's normal deceleration, then
11
+ * lands on a row.
12
+ */
13
+ export function flingTarget(
14
+ scrollTop: number,
15
+ velocity: number,
16
+ itemHeight: number,
17
+ maxIndex: number,
18
+ ): number {
19
+ const projected = scrollTop + velocity * 380;
20
+ return clamp(snapIndex(projected, itemHeight), 0, maxIndex);
21
+ }
22
+
23
+ /** Of the copies of `logical` in a looped wheel, the one nearest `from`. */
24
+ export function nearestCopy(logical: number, from: number, len: number, copies: number): number {
25
+ let best = logical;
26
+ for (let c = 0; c < copies; c++) {
27
+ const candidate = c * len + logical;
28
+ if (Math.abs(candidate - from) < Math.abs(best - from)) best = candidate;
29
+ }
30
+ return best;
31
+ }
32
+
33
+ export function easeOutCubic(t: number): number {
34
+ return 1 - (1 - t) ** 3;
35
+ }