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
package/src/wheel.tsx
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
import { usePrefersReducedMotion } from "./hooks";
|
|
4
|
+
import { clamp, cn, mod } from "./utils";
|
|
5
|
+
import { easeOutCubic, flingTarget, nearestCopy, snapIndex } from "./wheel-math";
|
|
6
|
+
|
|
7
|
+
export type WheelOption = { value: number; label: string };
|
|
8
|
+
|
|
9
|
+
export type WheelProps = {
|
|
10
|
+
options: WheelOption[];
|
|
11
|
+
value: number;
|
|
12
|
+
onChange: (value: number) => void;
|
|
13
|
+
/** Hours and minutes wrap around like the iOS clock wheels. */
|
|
14
|
+
loop?: boolean;
|
|
15
|
+
itemHeight?: number;
|
|
16
|
+
visibleRows?: number;
|
|
17
|
+
"aria-label": string;
|
|
18
|
+
className?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Looped wheels render the options this many times and rest in the middle
|
|
22
|
+
// copy, so a fling always has road in both directions.
|
|
23
|
+
const COPIES = 7;
|
|
24
|
+
const TYPEAHEAD_MS = 700;
|
|
25
|
+
|
|
26
|
+
type Drag = {
|
|
27
|
+
startY: number;
|
|
28
|
+
startTop: number;
|
|
29
|
+
lastTop: number;
|
|
30
|
+
lastT: number;
|
|
31
|
+
v: number;
|
|
32
|
+
moved: boolean;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One column of an iOS picker: native scrolling with snap points (so touch
|
|
37
|
+
* and trackpad flings come from the platform), mouse drag with its own
|
|
38
|
+
* deceleration, a click on any row, and the keyboard — arrows step, digits
|
|
39
|
+
* jump. The centre row is the value; the parent draws the highlight bar.
|
|
40
|
+
*/
|
|
41
|
+
export function Wheel({
|
|
42
|
+
options,
|
|
43
|
+
value,
|
|
44
|
+
onChange,
|
|
45
|
+
loop = false,
|
|
46
|
+
itemHeight = 32,
|
|
47
|
+
visibleRows = 5,
|
|
48
|
+
"aria-label": ariaLabel,
|
|
49
|
+
className,
|
|
50
|
+
}: WheelProps) {
|
|
51
|
+
const scroller = React.useRef<HTMLDivElement>(null);
|
|
52
|
+
const reduceMotion = usePrefersReducedMotion();
|
|
53
|
+
const len = options.length;
|
|
54
|
+
const copies = loop ? COPIES : 1;
|
|
55
|
+
const base = loop ? Math.floor(COPIES / 2) * len : 0;
|
|
56
|
+
const maxIndex = len * copies - 1;
|
|
57
|
+
const selected = Math.max(
|
|
58
|
+
0,
|
|
59
|
+
options.findIndex((o) => o.value === value),
|
|
60
|
+
);
|
|
61
|
+
const pad = ((visibleRows - 1) / 2) * itemHeight;
|
|
62
|
+
|
|
63
|
+
// Physical row the wheel rests on; the value is `options[resting % len]`.
|
|
64
|
+
const resting = React.useRef(base + selected);
|
|
65
|
+
const drag = React.useRef<Drag | null>(null);
|
|
66
|
+
const fling = React.useRef(0);
|
|
67
|
+
const settleTimer = React.useRef(0);
|
|
68
|
+
const typeahead = React.useRef({ buffer: "", timer: 0 });
|
|
69
|
+
// A mouse click is resolved on pointer-up (pointer capture would retarget
|
|
70
|
+
// the click event); the click that follows is then ignored. WebKit labels a
|
|
71
|
+
// touch-synthesised click "mouse" too, so the click itself cannot be trusted.
|
|
72
|
+
const clickHandled = React.useRef(false);
|
|
73
|
+
// Timers and animation frames call back after this render; give them the
|
|
74
|
+
// latest handler rather than the one they closed over.
|
|
75
|
+
const onChangeRef = React.useRef(onChange);
|
|
76
|
+
const valueRef = React.useRef(value);
|
|
77
|
+
React.useLayoutEffect(() => {
|
|
78
|
+
onChangeRef.current = onChange;
|
|
79
|
+
valueRef.current = value;
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// Only a different row is a change: settling where the wheel already was
|
|
83
|
+
// (including the first layout) must not write anything.
|
|
84
|
+
const report = (physical: number) => {
|
|
85
|
+
const option = optionAt(physical);
|
|
86
|
+
if (option && option.value !== valueRef.current) onChangeRef.current(option.value);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const optionAt = (physical: number) => options.at(mod(physical, len));
|
|
90
|
+
|
|
91
|
+
// Depth: rows tilt away from the centre, like the drum of a real picker.
|
|
92
|
+
const paint = () => {
|
|
93
|
+
const el = scroller.current;
|
|
94
|
+
if (!el) return;
|
|
95
|
+
const centre = el.scrollTop / itemHeight;
|
|
96
|
+
const rows = el.firstElementChild?.children;
|
|
97
|
+
if (!rows) return;
|
|
98
|
+
const from = Math.max(0, Math.floor(centre) - visibleRows);
|
|
99
|
+
const to = Math.min(rows.length - 1, Math.ceil(centre) + visibleRows);
|
|
100
|
+
const nearest = snapIndex(el.scrollTop, itemHeight);
|
|
101
|
+
for (let i = from; i <= to; i++) {
|
|
102
|
+
const row = rows[i] as HTMLElement;
|
|
103
|
+
const offset = i - centre;
|
|
104
|
+
const angle = clamp(offset * 22, -85, 85);
|
|
105
|
+
row.style.transform = `perspective(600px) rotateX(${-angle}deg)`;
|
|
106
|
+
row.style.opacity = String(clamp(1 - Math.abs(offset) * 0.28, 0.12, 1));
|
|
107
|
+
if (i === nearest) row.dataset.active = "";
|
|
108
|
+
else delete row.dataset.active;
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const scrollToIndex = (physical: number, smooth: boolean) => {
|
|
113
|
+
const el = scroller.current;
|
|
114
|
+
if (!el) return;
|
|
115
|
+
el.scrollTo({
|
|
116
|
+
top: clamp(physical, 0, maxIndex) * itemHeight,
|
|
117
|
+
behavior: smooth && !reduceMotion ? "smooth" : "auto",
|
|
118
|
+
});
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
// Where the wheel stopped becomes the value; looped wheels quietly re-centre.
|
|
122
|
+
const settle = () => {
|
|
123
|
+
const el = scroller.current;
|
|
124
|
+
if (!el || drag.current || fling.current) return;
|
|
125
|
+
let idx = snapIndex(el.scrollTop, itemHeight);
|
|
126
|
+
if (loop) {
|
|
127
|
+
const centred = base + mod(idx, len);
|
|
128
|
+
if (idx !== centred) {
|
|
129
|
+
idx = centred;
|
|
130
|
+
el.scrollTop = idx * itemHeight;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
resting.current = idx;
|
|
134
|
+
report(idx);
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const go = (physical: number, smooth = true) => {
|
|
138
|
+
const target = clamp(physical, 0, maxIndex);
|
|
139
|
+
resting.current = target;
|
|
140
|
+
report(target);
|
|
141
|
+
scrollToIndex(target, smooth);
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
// Initial rest, without animation.
|
|
145
|
+
const mounted = React.useRef(false);
|
|
146
|
+
React.useLayoutEffect(() => {
|
|
147
|
+
if (mounted.current) return;
|
|
148
|
+
mounted.current = true;
|
|
149
|
+
scroller.current?.scrollTo({ top: resting.current * itemHeight });
|
|
150
|
+
paint();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
// The value changed from outside (typed into the field, or set by the app).
|
|
154
|
+
React.useEffect(() => {
|
|
155
|
+
if (drag.current || fling.current) return;
|
|
156
|
+
if (options.at(mod(resting.current, len))?.value === value) return;
|
|
157
|
+
const physical = loop ? nearestCopy(selected, resting.current, len, copies) : selected;
|
|
158
|
+
resting.current = physical;
|
|
159
|
+
scroller.current?.scrollTo({
|
|
160
|
+
top: physical * itemHeight,
|
|
161
|
+
behavior: reduceMotion ? "auto" : "smooth",
|
|
162
|
+
});
|
|
163
|
+
}, [value, options, len, loop, selected, copies, itemHeight, reduceMotion, resting, scroller]);
|
|
164
|
+
|
|
165
|
+
const onScroll = () => {
|
|
166
|
+
paint();
|
|
167
|
+
// `scrollend` is the real signal; the timer covers engines without it.
|
|
168
|
+
window.clearTimeout(settleTimer.current);
|
|
169
|
+
settleTimer.current = window.setTimeout(settle, 120);
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
const onScrollEnd = () => {
|
|
173
|
+
window.clearTimeout(settleTimer.current);
|
|
174
|
+
settle();
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
// Mouse drag: the browser gives touch a fling for free, the mouse needs one.
|
|
178
|
+
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
179
|
+
if (e.pointerType !== "mouse" || e.button !== 0) return;
|
|
180
|
+
const el = e.currentTarget;
|
|
181
|
+
cancelAnimationFrame(fling.current);
|
|
182
|
+
fling.current = 0;
|
|
183
|
+
el.setPointerCapture(e.pointerId);
|
|
184
|
+
el.style.scrollSnapType = "none";
|
|
185
|
+
drag.current = {
|
|
186
|
+
startY: e.clientY,
|
|
187
|
+
startTop: el.scrollTop,
|
|
188
|
+
lastTop: el.scrollTop,
|
|
189
|
+
lastT: e.timeStamp,
|
|
190
|
+
v: 0,
|
|
191
|
+
moved: false,
|
|
192
|
+
};
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
196
|
+
const d = drag.current;
|
|
197
|
+
if (!d) return;
|
|
198
|
+
const el = e.currentTarget;
|
|
199
|
+
const top = clamp(d.startTop - (e.clientY - d.startY), 0, maxIndex * itemHeight);
|
|
200
|
+
const dt = Math.max(1, e.timeStamp - d.lastT);
|
|
201
|
+
d.v = d.v * 0.5 + ((top - d.lastTop) / dt) * 0.5;
|
|
202
|
+
d.lastTop = top;
|
|
203
|
+
d.lastT = e.timeStamp;
|
|
204
|
+
if (Math.abs(e.clientY - d.startY) > 3) d.moved = true;
|
|
205
|
+
el.scrollTop = top;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => {
|
|
209
|
+
const d = drag.current;
|
|
210
|
+
if (!d) return;
|
|
211
|
+
drag.current = null;
|
|
212
|
+
const el = e.currentTarget;
|
|
213
|
+
el.releasePointerCapture(e.pointerId);
|
|
214
|
+
if (!d.moved) {
|
|
215
|
+
// A click: the row under the pointer becomes the value.
|
|
216
|
+
el.style.scrollSnapType = "";
|
|
217
|
+
const y = e.clientY - el.getBoundingClientRect().top;
|
|
218
|
+
go(Math.floor((el.scrollTop + y - pad) / itemHeight));
|
|
219
|
+
clickHandled.current = true;
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
clickHandled.current = true;
|
|
223
|
+
const target = flingTarget(
|
|
224
|
+
el.scrollTop,
|
|
225
|
+
e.timeStamp - d.lastT > 80 ? 0 : d.v,
|
|
226
|
+
itemHeight,
|
|
227
|
+
maxIndex,
|
|
228
|
+
);
|
|
229
|
+
const from = el.scrollTop;
|
|
230
|
+
const to = target * itemHeight;
|
|
231
|
+
const duration = reduceMotion ? 0 : clamp(Math.abs(to - from) * 1.2, 180, 720);
|
|
232
|
+
const start = e.timeStamp;
|
|
233
|
+
const step = (now: number) => {
|
|
234
|
+
const t = duration === 0 ? 1 : Math.min(1, (now - start) / duration);
|
|
235
|
+
el.scrollTop = from + (to - from) * easeOutCubic(t);
|
|
236
|
+
if (t < 1) {
|
|
237
|
+
fling.current = requestAnimationFrame(step);
|
|
238
|
+
} else {
|
|
239
|
+
fling.current = 0;
|
|
240
|
+
el.style.scrollSnapType = "";
|
|
241
|
+
settle();
|
|
242
|
+
}
|
|
243
|
+
};
|
|
244
|
+
fling.current = requestAnimationFrame(step);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
|
|
248
|
+
const r = resting.current;
|
|
249
|
+
const first = loop ? base : 0;
|
|
250
|
+
const last = loop ? base + len - 1 : len - 1;
|
|
251
|
+
switch (e.key) {
|
|
252
|
+
case "ArrowUp":
|
|
253
|
+
go(r - 1);
|
|
254
|
+
break;
|
|
255
|
+
case "ArrowDown":
|
|
256
|
+
go(r + 1);
|
|
257
|
+
break;
|
|
258
|
+
case "PageUp":
|
|
259
|
+
go(r - 5);
|
|
260
|
+
break;
|
|
261
|
+
case "PageDown":
|
|
262
|
+
go(r + 5);
|
|
263
|
+
break;
|
|
264
|
+
case "Home":
|
|
265
|
+
go(first);
|
|
266
|
+
break;
|
|
267
|
+
case "End":
|
|
268
|
+
go(last);
|
|
269
|
+
break;
|
|
270
|
+
default: {
|
|
271
|
+
if (e.key.length !== 1 || e.metaKey || e.ctrlKey || e.altKey) return;
|
|
272
|
+
const ta = typeahead.current;
|
|
273
|
+
window.clearTimeout(ta.timer);
|
|
274
|
+
ta.buffer += e.key.toLowerCase();
|
|
275
|
+
ta.timer = window.setTimeout(() => (ta.buffer = ""), TYPEAHEAD_MS);
|
|
276
|
+
const hit = options.findIndex(
|
|
277
|
+
(o) => String(o.value) === ta.buffer || o.label.toLowerCase().startsWith(ta.buffer),
|
|
278
|
+
);
|
|
279
|
+
if (hit === -1) {
|
|
280
|
+
ta.buffer = e.key.toLowerCase();
|
|
281
|
+
const retry = options.findIndex(
|
|
282
|
+
(o) => String(o.value) === ta.buffer || o.label.toLowerCase().startsWith(ta.buffer),
|
|
283
|
+
);
|
|
284
|
+
if (retry === -1) return;
|
|
285
|
+
go(loop ? nearestCopy(retry, r, len, copies) : retry);
|
|
286
|
+
} else {
|
|
287
|
+
go(loop ? nearestCopy(hit, r, len, copies) : hit);
|
|
288
|
+
}
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const current = options.at(selected);
|
|
296
|
+
|
|
297
|
+
return (
|
|
298
|
+
<div
|
|
299
|
+
ref={scroller}
|
|
300
|
+
role="spinbutton"
|
|
301
|
+
tabIndex={0}
|
|
302
|
+
aria-label={ariaLabel}
|
|
303
|
+
aria-valuenow={current?.value}
|
|
304
|
+
aria-valuetext={current?.label}
|
|
305
|
+
aria-valuemin={options.at(0)?.value}
|
|
306
|
+
aria-valuemax={options.at(-1)?.value}
|
|
307
|
+
data-slot="wheel"
|
|
308
|
+
onScroll={onScroll}
|
|
309
|
+
onScrollEnd={onScrollEnd}
|
|
310
|
+
onPointerDown={onPointerDown}
|
|
311
|
+
onPointerMove={onPointerMove}
|
|
312
|
+
onPointerUp={onPointerUp}
|
|
313
|
+
onPointerCancel={onPointerUp}
|
|
314
|
+
onKeyDown={onKeyDown}
|
|
315
|
+
className={cn(
|
|
316
|
+
"cdp-wheel relative snap-y snap-mandatory overflow-y-scroll overscroll-contain rounded-lg outline-none select-none focus-visible:ring-2 focus-visible:ring-[var(--cdp-tint)]",
|
|
317
|
+
className,
|
|
318
|
+
)}
|
|
319
|
+
style={{ height: visibleRows * itemHeight, scrollbarWidth: "none", touchAction: "pan-y" }}
|
|
320
|
+
>
|
|
321
|
+
<div aria-hidden style={{ paddingBlock: pad }}>
|
|
322
|
+
{Array.from({ length: len * copies }, (_, i) => {
|
|
323
|
+
const option = options.at(i % len);
|
|
324
|
+
return (
|
|
325
|
+
<div
|
|
326
|
+
key={i}
|
|
327
|
+
role="presentation"
|
|
328
|
+
className="flex snap-center items-center justify-center text-[22px] leading-none text-[var(--cdp-secondary)] tabular-nums transition-colors data-active:text-[var(--cdp-label)]"
|
|
329
|
+
style={{ height: itemHeight }}
|
|
330
|
+
onClick={() => {
|
|
331
|
+
if (clickHandled.current) {
|
|
332
|
+
clickHandled.current = false;
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
go(i);
|
|
336
|
+
}}
|
|
337
|
+
>
|
|
338
|
+
{option?.label}
|
|
339
|
+
</div>
|
|
340
|
+
);
|
|
341
|
+
})}
|
|
342
|
+
</div>
|
|
343
|
+
</div>
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** The translucent bar behind the centre row, shared by a group of wheels. */
|
|
348
|
+
export function WheelHighlight({
|
|
349
|
+
itemHeight = 32,
|
|
350
|
+
className,
|
|
351
|
+
}: {
|
|
352
|
+
itemHeight?: number;
|
|
353
|
+
className?: string;
|
|
354
|
+
}) {
|
|
355
|
+
return (
|
|
356
|
+
<div
|
|
357
|
+
aria-hidden
|
|
358
|
+
className={cn(
|
|
359
|
+
"pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 rounded-lg bg-[var(--cdp-fill)]",
|
|
360
|
+
className,
|
|
361
|
+
)}
|
|
362
|
+
style={{ height: itemHeight }}
|
|
363
|
+
/>
|
|
364
|
+
);
|
|
365
|
+
}
|