@repraze/kernelkit 0.1.0-alpha.3 → 0.1.0-alpha.5
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/README.md +17 -0
- package/dist/components/alert-dialog.d.ts.map +1 -1
- package/dist/components/alert-dialog.js +4 -1
- package/dist/components/alert-dialog.js.map +1 -1
- package/dist/components/alert.js +3 -3
- package/dist/components/alert.js.map +1 -1
- package/dist/components/badge.js +1 -1
- package/dist/components/badge.js.map +1 -1
- package/dist/components/button.js +1 -1
- package/dist/components/button.js.map +1 -1
- package/dist/components/calendar.d.ts +8 -1
- package/dist/components/calendar.d.ts.map +1 -1
- package/dist/components/calendar.js +33 -11
- package/dist/components/calendar.js.map +1 -1
- package/dist/components/callout.js +2 -2
- package/dist/components/callout.js.map +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/combobox.js.map +1 -1
- package/dist/components/date-picker.d.ts +11 -5
- package/dist/components/date-picker.d.ts.map +1 -1
- package/dist/components/date-picker.js +29 -7
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/description-list.d.ts +26 -0
- package/dist/components/description-list.d.ts.map +1 -0
- package/dist/components/description-list.js +59 -0
- package/dist/components/description-list.js.map +1 -0
- package/dist/components/locale.d.ts +19 -0
- package/dist/components/locale.d.ts.map +1 -0
- package/dist/components/locale.js +54 -0
- package/dist/components/locale.js.map +1 -0
- package/dist/components/popover.d.ts +1 -1
- package/dist/components/popover.d.ts.map +1 -1
- package/dist/components/popover.js +2 -2
- package/dist/components/popover.js.map +1 -1
- package/dist/components/progress.d.ts +19 -6
- package/dist/components/progress.d.ts.map +1 -1
- package/dist/components/progress.js +18 -3
- package/dist/components/progress.js.map +1 -1
- package/dist/components/search-dialog.d.ts +6 -1
- package/dist/components/search-dialog.d.ts.map +1 -1
- package/dist/components/search-dialog.js +13 -1
- package/dist/components/search-dialog.js.map +1 -1
- package/dist/components/search-trigger.d.ts +4 -1
- package/dist/components/search-trigger.d.ts.map +1 -1
- package/dist/components/search-trigger.js +5 -2
- package/dist/components/search-trigger.js.map +1 -1
- package/dist/components/shimmer.d.ts +13 -0
- package/dist/components/shimmer.d.ts.map +1 -0
- package/dist/components/shimmer.js +21 -0
- package/dist/components/shimmer.js.map +1 -0
- package/dist/components/spinner.d.ts +28 -6
- package/dist/components/spinner.d.ts.map +1 -1
- package/dist/components/spinner.js +66 -14
- package/dist/components/spinner.js.map +1 -1
- package/dist/components/stepper.d.ts +64 -0
- package/dist/components/stepper.d.ts.map +1 -0
- package/dist/components/stepper.js +167 -0
- package/dist/components/stepper.js.map +1 -0
- package/dist/components/tag-input.d.ts +47 -0
- package/dist/components/tag-input.d.ts.map +1 -0
- package/dist/components/tag-input.js +211 -0
- package/dist/components/tag-input.js.map +1 -0
- package/dist/components/time-input.d.ts +75 -0
- package/dist/components/time-input.d.ts.map +1 -0
- package/dist/components/time-input.js +341 -0
- package/dist/components/time-input.js.map +1 -0
- package/dist/components/time-select.d.ts +49 -0
- package/dist/components/time-select.d.ts.map +1 -0
- package/dist/components/time-select.js +178 -0
- package/dist/components/time-select.js.map +1 -0
- package/dist/components/timeline.d.ts +41 -0
- package/dist/components/timeline.d.ts.map +1 -0
- package/dist/components/timeline.js +83 -0
- package/dist/components/timeline.js.map +1 -0
- package/dist/components/toast.d.ts.map +1 -1
- package/dist/components/toast.js +4 -2
- package/dist/components/toast.js.map +1 -1
- package/dist/components/tree.d.ts +87 -0
- package/dist/components/tree.d.ts.map +1 -0
- package/dist/components/tree.js +516 -0
- package/dist/components/tree.js.map +1 -0
- package/dist/docs/doc-kit.d.ts +72 -0
- package/dist/docs/doc-kit.d.ts.map +1 -0
- package/dist/docs/doc-kit.js +45 -0
- package/dist/docs/doc-kit.js.map +1 -0
- package/dist/docs/doc-page.d.ts +27 -0
- package/dist/docs/doc-page.d.ts.map +1 -0
- package/dist/docs/doc-page.js +33 -0
- package/dist/docs/doc-page.js.map +1 -0
- package/dist/docs/docs-index.d.ts +60 -0
- package/dist/docs/docs-index.d.ts.map +1 -0
- package/dist/docs/docs-index.js +45 -0
- package/dist/docs/docs-index.js.map +1 -0
- package/dist/docs/docs-nav.d.ts +13 -0
- package/dist/docs/docs-nav.d.ts.map +1 -0
- package/dist/docs/docs-nav.js +8 -0
- package/dist/docs/docs-nav.js.map +1 -0
- package/dist/docs/docs-search.d.ts +19 -0
- package/dist/docs/docs-search.d.ts.map +1 -0
- package/dist/docs/docs-search.js +33 -0
- package/dist/docs/docs-search.js.map +1 -0
- package/dist/docs/index.d.ts +7 -0
- package/dist/docs/index.d.ts.map +1 -0
- package/dist/docs/index.js +7 -0
- package/dist/docs/index.js.map +1 -0
- package/dist/docs/review-matrix.d.ts +25 -0
- package/dist/docs/review-matrix.d.ts.map +1 -0
- package/dist/docs/review-matrix.js +31 -0
- package/dist/docs/review-matrix.js.map +1 -0
- package/dist/hooks/use-theme-palette.d.ts +8 -0
- package/dist/hooks/use-theme-palette.d.ts.map +1 -0
- package/dist/hooks/use-theme-palette.js +24 -0
- package/dist/hooks/use-theme-palette.js.map +1 -0
- package/dist/hooks/use-time-format.d.ts +23 -0
- package/dist/hooks/use-time-format.d.ts.map +1 -0
- package/dist/hooks/use-time-format.js +52 -0
- package/dist/hooks/use-time-format.js.map +1 -0
- package/dist/lib/color.d.ts +5 -1
- package/dist/lib/color.d.ts.map +1 -1
- package/dist/lib/color.js +14 -2
- package/dist/lib/color.js.map +1 -1
- package/dist/lib/recipes.d.ts +5 -4
- package/dist/lib/recipes.d.ts.map +1 -1
- package/dist/lib/recipes.js +7 -5
- package/dist/lib/recipes.js.map +1 -1
- package/dist/provider/theme-provider.d.ts +10 -1
- package/dist/provider/theme-provider.d.ts.map +1 -1
- package/dist/provider/theme-provider.js +23 -8
- package/dist/provider/theme-provider.js.map +1 -1
- package/dist/site/brand.d.ts +20 -0
- package/dist/site/brand.d.ts.map +1 -0
- package/dist/site/brand.js +17 -0
- package/dist/site/brand.js.map +1 -0
- package/dist/site/github-icon.d.ts +4 -0
- package/dist/site/github-icon.d.ts.map +1 -0
- package/dist/site/github-icon.js +6 -0
- package/dist/site/github-icon.js.map +1 -0
- package/dist/site/index.d.ts +6 -0
- package/dist/site/index.d.ts.map +1 -0
- package/dist/site/index.js +6 -0
- package/dist/site/index.js.map +1 -0
- package/dist/site/suite-footer.d.ts +24 -0
- package/dist/site/suite-footer.d.ts.map +1 -0
- package/dist/site/suite-footer.js +19 -0
- package/dist/site/suite-footer.js.map +1 -0
- package/dist/site/suite-header.d.ts +40 -0
- package/dist/site/suite-header.d.ts.map +1 -0
- package/dist/site/suite-header.js +19 -0
- package/dist/site/suite-header.js.map +1 -0
- package/dist/site/use-page-title.d.ts +3 -0
- package/dist/site/use-page-title.d.ts.map +1 -0
- package/dist/site/use-page-title.js +8 -0
- package/dist/site/use-page-title.js.map +1 -0
- package/dist/vite/index.d.ts +65 -0
- package/dist/vite/index.d.ts.map +1 -0
- package/dist/vite/index.js +242 -0
- package/dist/vite/index.js.map +1 -0
- package/package.json +39 -3
- package/src/components/alert-dialog.tsx +4 -1
- package/src/components/alert.tsx +3 -3
- package/src/components/badge.tsx +1 -1
- package/src/components/button.tsx +1 -1
- package/src/components/calendar.tsx +48 -4
- package/src/components/callout.tsx +2 -2
- package/src/components/combobox.tsx +1 -1
- package/src/components/date-picker.tsx +43 -9
- package/src/components/description-list.tsx +107 -0
- package/src/components/locale.tsx +67 -0
- package/src/components/popover.tsx +3 -1
- package/src/components/progress.tsx +70 -4
- package/src/components/search-dialog.tsx +14 -1
- package/src/components/search-trigger.tsx +8 -2
- package/src/components/shimmer.tsx +36 -0
- package/src/components/spinner.tsx +126 -23
- package/src/components/stepper.tsx +346 -0
- package/src/components/tag-input.tsx +345 -0
- package/src/components/time-input.tsx +578 -0
- package/src/components/time-select.tsx +291 -0
- package/src/components/timeline.tsx +190 -0
- package/src/components/toast.tsx +4 -4
- package/src/components/tree.tsx +832 -0
- package/src/docs/doc-kit.tsx +176 -0
- package/src/docs/doc-page.tsx +126 -0
- package/src/docs/docs-index.ts +108 -0
- package/src/docs/docs-nav.tsx +47 -0
- package/src/docs/docs-search.tsx +88 -0
- package/src/docs/index.ts +6 -0
- package/src/docs/review-matrix.tsx +67 -0
- package/src/hooks/use-theme-palette.ts +25 -0
- package/src/hooks/use-time-format.ts +72 -0
- package/src/lib/color.ts +19 -2
- package/src/lib/recipes.ts +7 -5
- package/src/provider/theme-provider.tsx +34 -9
- package/src/site/brand.tsx +41 -0
- package/src/site/github-icon.tsx +10 -0
- package/src/site/index.ts +5 -0
- package/src/site/suite-footer.tsx +68 -0
- package/src/site/suite-header.tsx +114 -0
- package/src/site/use-page-title.ts +8 -0
- package/src/styles/kernelkit.css +58 -0
- package/src/styles/site.css +15 -0
- package/src/vite/client.d.ts +11 -0
- package/src/vite/index.ts +298 -0
|
@@ -0,0 +1,578 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {Popover, PopoverContent, PopoverTrigger} from "@repraze/kernelkit/components/popover";
|
|
4
|
+
import {type HourCyclePreference, useTimeFormat} from "@repraze/kernelkit/hooks/use-time-format";
|
|
5
|
+
import {fieldSurface, invalidRing} from "@repraze/kernelkit/lib/recipes";
|
|
6
|
+
import {cva} from "class-variance-authority";
|
|
7
|
+
import {cn} from "cn";
|
|
8
|
+
import {ClockIcon} from "lucide-react";
|
|
9
|
+
import * as React from "react";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Segmented time field (GUIDELINES §4, §6). The container is the field surface; it rings while any
|
|
13
|
+
* segment has focus, like a text input does on click.
|
|
14
|
+
*/
|
|
15
|
+
const timeInputVariants = cva(
|
|
16
|
+
[
|
|
17
|
+
fieldSurface,
|
|
18
|
+
invalidRing,
|
|
19
|
+
"inline-flex w-fit min-w-0 cursor-text items-center text-base md:text-sm",
|
|
20
|
+
"has-[[data-slot=time-input-segment]:focus]:border-ring has-[[data-slot=time-input-segment]:focus]:ring-3 has-[[data-slot=time-input-segment]:focus]:ring-ring/50",
|
|
21
|
+
"data-disabled:cursor-not-allowed data-disabled:bg-field-disabled data-disabled:opacity-50",
|
|
22
|
+
"[&_svg]:pointer-events-none [&_svg]:shrink-0",
|
|
23
|
+
],
|
|
24
|
+
{
|
|
25
|
+
variants: {
|
|
26
|
+
size: {
|
|
27
|
+
// icon inset and icon-to-text gap match a Button with an inline-start icon (DatePicker's
|
|
28
|
+
// trigger), counting the segment's own 2px padding: 8 / 6px default, 6 / 4px sm, 10 / 8px lg
|
|
29
|
+
sm: "h-7 gap-0.5 rounded-md ps-1.5 pe-2 md:text-[0.8rem] [&_svg]:size-3.5",
|
|
30
|
+
default: "h-8 gap-1 ps-2 pe-2.5 [&_svg]:size-4",
|
|
31
|
+
lg: "h-9 gap-1.5 ps-2.5 pe-3 [&_svg]:size-4",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
defaultVariants: {size: "default"},
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
/** One editable part (hours, minutes, seconds, AM/PM). Focus is a solid accent chip, like a native text selection (a tint vanished on the dark field). */
|
|
39
|
+
const timeInputSegmentVariants = cva(
|
|
40
|
+
"rounded-box px-0.5 font-mono tabular-nums outline-none caret-transparent select-none transition-[color,background-color] hover:bg-hover focus:bg-primary focus:text-primary-foreground data-placeholder:text-muted-foreground data-placeholder:focus:text-primary-foreground aria-disabled:pointer-events-none",
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
type Size = "sm" | "default" | "lg";
|
|
44
|
+
type Period = "AM" | "PM";
|
|
45
|
+
type Part = "hour" | "minute" | "second" | "period";
|
|
46
|
+
|
|
47
|
+
interface Segments {
|
|
48
|
+
hour: number | null;
|
|
49
|
+
minute: number | null;
|
|
50
|
+
second: number | null;
|
|
51
|
+
period: Period | null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const EMPTY: Segments = {hour: null, minute: null, second: null, period: null};
|
|
55
|
+
|
|
56
|
+
const pad = (n: number) => String(n).padStart(2, "0");
|
|
57
|
+
|
|
58
|
+
function parse(value: string | null | undefined, cycle: 12 | 24): Segments {
|
|
59
|
+
const m = value ? /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(value) : null;
|
|
60
|
+
if (!m) {
|
|
61
|
+
return EMPTY;
|
|
62
|
+
}
|
|
63
|
+
const h = Math.min(23, Number(m[1]));
|
|
64
|
+
const minute = Math.min(59, Number(m[2]));
|
|
65
|
+
const second = m[3] === undefined ? 0 : Math.min(59, Number(m[3]));
|
|
66
|
+
if (cycle === 24) {
|
|
67
|
+
return {hour: h, minute, second, period: null};
|
|
68
|
+
}
|
|
69
|
+
return {hour: h % 12 || 12, minute, second, period: h < 12 ? "AM" : "PM"};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function serialize(s: Segments, cycle: 12 | 24, seconds: boolean): string | null {
|
|
73
|
+
if (s.hour === null || s.minute === null || (seconds && s.second === null) || (cycle === 12 && !s.period)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const h = cycle === 24 ? s.hour : (s.hour % 12) + (s.period === "PM" ? 12 : 0);
|
|
77
|
+
return `${pad(h)}:${pad(s.minute)}${seconds ? `:${pad(s.second ?? 0)}` : ""}`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
interface TimeInputProps extends Omit<React.ComponentProps<"div">, "defaultValue" | "onChange" | "children" | "color"> {
|
|
81
|
+
/** 24-hour `"HH:mm"` (or `"HH:mm:ss"` with `seconds`); `null` while incomplete. */
|
|
82
|
+
value?: string | null;
|
|
83
|
+
defaultValue?: string | null;
|
|
84
|
+
/** Fires with the full time once every segment is set, `null` when one is cleared. */
|
|
85
|
+
onValueChange?: (value: string | null) => void;
|
|
86
|
+
/** Adds a seconds segment. */
|
|
87
|
+
seconds?: boolean;
|
|
88
|
+
/** `"auto"` follows the user's locale; 12 adds a day-period segment. The value stays 24-hour. */
|
|
89
|
+
hourCycle?: HourCyclePreference;
|
|
90
|
+
/** BCP 47 locale for `"auto"` and the day-period labels; defaults to the browser's. */
|
|
91
|
+
locale?: string;
|
|
92
|
+
/** Minutes per arrow press. */
|
|
93
|
+
step?: number;
|
|
94
|
+
size?: Size;
|
|
95
|
+
disabled?: boolean;
|
|
96
|
+
/** Leading clock icon. */
|
|
97
|
+
icon?: boolean;
|
|
98
|
+
/** The clock icon opens a column picker (hours, minutes...) in a popover: tap and arrow-key friendly. */
|
|
99
|
+
picker?: boolean;
|
|
100
|
+
/** Form field name: a hidden input carries the value. */
|
|
101
|
+
name?: string;
|
|
102
|
+
/** Lands on the first segment, so `<FieldLabel htmlFor>` focuses it. */
|
|
103
|
+
id?: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* A time field split into segments, each a spinbutton: arrows step with wrap, digits fill and move on,
|
|
108
|
+
* Backspace clears, Left / Right (or `:`) move between segments, `a` / `p` set the period.
|
|
109
|
+
*/
|
|
110
|
+
function TimeInput({
|
|
111
|
+
value,
|
|
112
|
+
defaultValue = null,
|
|
113
|
+
onValueChange,
|
|
114
|
+
seconds = false,
|
|
115
|
+
hourCycle: hourCyclePreference = "auto",
|
|
116
|
+
locale,
|
|
117
|
+
step = 1,
|
|
118
|
+
size = "default",
|
|
119
|
+
disabled = false,
|
|
120
|
+
icon = true,
|
|
121
|
+
picker = true,
|
|
122
|
+
name,
|
|
123
|
+
id,
|
|
124
|
+
className,
|
|
125
|
+
...props
|
|
126
|
+
}: TimeInputProps) {
|
|
127
|
+
const [pickerOpen, setPickerOpen] = React.useState(false);
|
|
128
|
+
const fmt = useTimeFormat(hourCyclePreference, locale);
|
|
129
|
+
const hourCycle = fmt.hourCycle;
|
|
130
|
+
const controlled = value !== undefined;
|
|
131
|
+
const [segs, setSegs] = React.useState<Segments>(() => parse(controlled ? value : defaultValue, hourCycle));
|
|
132
|
+
// the locale's cycle can arrive after hydration (SSR renders 24h): carry the time over to the new cycle
|
|
133
|
+
const [segsCycle, setSegsCycle] = React.useState(hourCycle);
|
|
134
|
+
if (segsCycle !== hourCycle) {
|
|
135
|
+
setSegsCycle(hourCycle);
|
|
136
|
+
setSegs(parse(serialize(fill(segs, segsCycle), segsCycle, seconds), hourCycle));
|
|
137
|
+
}
|
|
138
|
+
// controlled: follow outside changes that differ from what the segments already say
|
|
139
|
+
const [seen, setSeen] = React.useState(value);
|
|
140
|
+
if (controlled && value !== seen) {
|
|
141
|
+
setSeen(value);
|
|
142
|
+
if (value !== serialize(segs, hourCycle, seconds)) {
|
|
143
|
+
setSegs(parse(value, hourCycle));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const typed = React.useRef("");
|
|
148
|
+
const refs = React.useRef<Partial<Record<Part, HTMLSpanElement | null>>>({});
|
|
149
|
+
// visual order: the day period leads in locales that write it first ("午前 09:30")
|
|
150
|
+
const parts: Part[] = [
|
|
151
|
+
...(hourCycle === 12 && fmt.periodFirst ? (["period"] as const) : []),
|
|
152
|
+
"hour",
|
|
153
|
+
"minute",
|
|
154
|
+
...(seconds ? (["second"] as const) : []),
|
|
155
|
+
...(hourCycle === 12 && !fmt.periodFirst ? (["period"] as const) : []),
|
|
156
|
+
];
|
|
157
|
+
|
|
158
|
+
const update = (patch: Partial<Segments>) => {
|
|
159
|
+
const next = {...segs, ...patch};
|
|
160
|
+
const before = serialize(segs, hourCycle, seconds);
|
|
161
|
+
const after = serialize(next, hourCycle, seconds);
|
|
162
|
+
setSegs(next);
|
|
163
|
+
if (controlled) {
|
|
164
|
+
setSeen(after);
|
|
165
|
+
}
|
|
166
|
+
if (after !== before) {
|
|
167
|
+
onValueChange?.(after);
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
const move = (from: Part, delta: number) => {
|
|
172
|
+
const target = parts[parts.indexOf(from) + delta];
|
|
173
|
+
if (target) {
|
|
174
|
+
refs.current[target]?.focus();
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const range = (part: Part): [number, number] =>
|
|
179
|
+
part === "hour" ? (hourCycle === 12 ? [1, 12] : [0, 23]) : part === "period" ? [0, 1] : [0, 59];
|
|
180
|
+
|
|
181
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLSpanElement>, part: Part) => {
|
|
182
|
+
if (disabled || e.metaKey || e.ctrlKey || e.altKey) {
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
|
|
186
|
+
const key = e.key;
|
|
187
|
+
const [min, max] = range(part);
|
|
188
|
+
const current = part === "period" ? (segs.period === null ? null : segs.period === "AM" ? 0 : 1) : segs[part];
|
|
189
|
+
const set = (n: number | null) =>
|
|
190
|
+
update(part === "period" ? {period: n === null ? null : n === 0 ? "AM" : "PM"} : {[part]: n});
|
|
191
|
+
|
|
192
|
+
if (key === "ArrowUp" || key === "ArrowDown" || key === "PageUp" || key === "PageDown") {
|
|
193
|
+
e.preventDefault();
|
|
194
|
+
typed.current = "";
|
|
195
|
+
const dir = key === "ArrowUp" || key === "PageUp" ? 1 : -1;
|
|
196
|
+
const by = part === "minute" ? step : 1;
|
|
197
|
+
const span = max - min + 1;
|
|
198
|
+
if (current === null) {
|
|
199
|
+
set(dir > 0 ? min : part === "minute" ? max - (max % step) : max);
|
|
200
|
+
} else {
|
|
201
|
+
// snap to the step grid, then wrap
|
|
202
|
+
const snapped =
|
|
203
|
+
part === "minute" && current % by ? current - (current % by) + (dir > 0 ? 0 : by) : current;
|
|
204
|
+
set(((((snapped - min + dir * by) % span) + span) % span) + min);
|
|
205
|
+
}
|
|
206
|
+
} else if (key === "Home" || key === "End") {
|
|
207
|
+
e.preventDefault();
|
|
208
|
+
set(key === "Home" ? min : max);
|
|
209
|
+
} else if (key === "Backspace" || key === "Delete") {
|
|
210
|
+
e.preventDefault();
|
|
211
|
+
typed.current = "";
|
|
212
|
+
if (current === null && key === "Backspace") {
|
|
213
|
+
move(part, -1);
|
|
214
|
+
} else {
|
|
215
|
+
set(null);
|
|
216
|
+
}
|
|
217
|
+
} else if (key === (rtl ? "ArrowRight" : "ArrowLeft")) {
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
move(part, -1);
|
|
220
|
+
} else if (key === (rtl ? "ArrowLeft" : "ArrowRight") || key === ":" || key === "." || key === " ") {
|
|
221
|
+
e.preventDefault();
|
|
222
|
+
move(part, 1);
|
|
223
|
+
} else if (part === "period" && /^[ap]$/i.test(key)) {
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
set(key.toLowerCase() === "a" ? 0 : 1);
|
|
226
|
+
} else if (part !== "period" && /^\d$/.test(key)) {
|
|
227
|
+
e.preventDefault();
|
|
228
|
+
const buffer = typed.current + key;
|
|
229
|
+
const n = Number(buffer);
|
|
230
|
+
if (buffer.length === 2 || n * 10 > max) {
|
|
231
|
+
// complete: take it if it fits, else start over with this digit
|
|
232
|
+
const final = n >= min && n <= max ? n : Number(key);
|
|
233
|
+
set(final);
|
|
234
|
+
typed.current = "";
|
|
235
|
+
move(part, 1);
|
|
236
|
+
} else {
|
|
237
|
+
// first digit: shown, and kept pending for a second one ("0" in a 1-12 hour shows empty)
|
|
238
|
+
typed.current = buffer;
|
|
239
|
+
set(n < min ? null : n);
|
|
240
|
+
}
|
|
241
|
+
} else if (key === "Tab") {
|
|
242
|
+
typed.current = "";
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
const label: Record<Part, string> = {hour: "Hours", minute: "Minutes", second: "Seconds", period: "AM/PM"};
|
|
247
|
+
|
|
248
|
+
const segment = (part: Part) => {
|
|
249
|
+
const [min, max] = range(part);
|
|
250
|
+
const v = part === "period" ? segs.period : segs[part];
|
|
251
|
+
const text = v === null ? "--" : typeof v === "number" ? fmt.digits(v) : v === "AM" ? fmt.am : fmt.pm;
|
|
252
|
+
return (
|
|
253
|
+
<span
|
|
254
|
+
key={part}
|
|
255
|
+
ref={(el) => {
|
|
256
|
+
refs.current[part] = el;
|
|
257
|
+
}}
|
|
258
|
+
id={part === "hour" ? id : undefined}
|
|
259
|
+
role="spinbutton"
|
|
260
|
+
tabIndex={disabled ? -1 : 0}
|
|
261
|
+
aria-label={label[part]}
|
|
262
|
+
aria-valuemin={min}
|
|
263
|
+
aria-valuemax={max}
|
|
264
|
+
aria-valuenow={v === null ? undefined : part === "period" ? (v === "AM" ? 0 : 1) : (v as number)}
|
|
265
|
+
aria-valuetext={v === null ? "Empty" : String(text)}
|
|
266
|
+
aria-disabled={disabled || undefined}
|
|
267
|
+
inputMode={part === "period" ? "text" : "numeric"}
|
|
268
|
+
data-slot="time-input-segment"
|
|
269
|
+
data-part={part}
|
|
270
|
+
data-placeholder={v === null ? "" : undefined}
|
|
271
|
+
className={timeInputSegmentVariants()}
|
|
272
|
+
onKeyDown={(e) => onKeyDown(e, part)}
|
|
273
|
+
onFocus={() => {
|
|
274
|
+
typed.current = "";
|
|
275
|
+
}}
|
|
276
|
+
>
|
|
277
|
+
{text}
|
|
278
|
+
</span>
|
|
279
|
+
);
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const fieldRef = React.useRef<HTMLDivElement>(null);
|
|
283
|
+
const current = serialize(segs, hourCycle, seconds);
|
|
284
|
+
|
|
285
|
+
return (
|
|
286
|
+
<div
|
|
287
|
+
ref={fieldRef}
|
|
288
|
+
role="group"
|
|
289
|
+
data-slot="time-input"
|
|
290
|
+
data-size={size}
|
|
291
|
+
data-disabled={disabled ? "" : undefined}
|
|
292
|
+
className={cn(timeInputVariants({size}), className)}
|
|
293
|
+
onMouseDown={(e) => {
|
|
294
|
+
// a click on the padding or icon goes to the first empty segment, else the first one
|
|
295
|
+
const target = e.target as HTMLElement;
|
|
296
|
+
if (!disabled && !target.closest("[data-slot=time-input-segment],[data-slot=time-input-picker]")) {
|
|
297
|
+
e.preventDefault();
|
|
298
|
+
const empty = parts.find((p) => (p === "period" ? segs.period : segs[p]) === null);
|
|
299
|
+
refs.current[empty ?? "hour"]?.focus();
|
|
300
|
+
}
|
|
301
|
+
}}
|
|
302
|
+
{...props}
|
|
303
|
+
>
|
|
304
|
+
{icon && picker ? (
|
|
305
|
+
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
|
306
|
+
<PopoverTrigger
|
|
307
|
+
data-slot="time-input-picker"
|
|
308
|
+
disabled={disabled}
|
|
309
|
+
aria-label="Choose time"
|
|
310
|
+
// a bare icon in the same place and size as a plain icon (and DatePicker's), with a
|
|
311
|
+
// 32px hit area; hover and open turn it foreground, keyboard focus rings it
|
|
312
|
+
className="relative flex shrink-0 cursor-pointer items-center justify-center rounded-box text-muted-foreground outline-none transition-colors after:absolute after:-inset-2 hover:text-foreground focus-visible:ring-3 focus-visible:ring-ring/50 disabled:pointer-events-none data-popup-open:text-primary-text"
|
|
313
|
+
>
|
|
314
|
+
<ClockIcon />
|
|
315
|
+
</PopoverTrigger>
|
|
316
|
+
<PopoverContent
|
|
317
|
+
data-slot="time-input-content"
|
|
318
|
+
// anchored to the whole field, not the icon, so it lines up like DatePicker's
|
|
319
|
+
anchor={fieldRef}
|
|
320
|
+
align="start"
|
|
321
|
+
className="w-auto p-1"
|
|
322
|
+
initialFocus={pickerFocusTarget}
|
|
323
|
+
>
|
|
324
|
+
<TimeColumns
|
|
325
|
+
value={serialize(fill(segs, hourCycle), hourCycle, seconds)}
|
|
326
|
+
onValueChange={(next) => {
|
|
327
|
+
const parsed = parse(next, hourCycle);
|
|
328
|
+
update(parsed);
|
|
329
|
+
}}
|
|
330
|
+
onCommit={() => setPickerOpen(false)}
|
|
331
|
+
seconds={seconds}
|
|
332
|
+
hourCycle={hourCycle}
|
|
333
|
+
locale={locale}
|
|
334
|
+
step={step}
|
|
335
|
+
/>
|
|
336
|
+
</PopoverContent>
|
|
337
|
+
</Popover>
|
|
338
|
+
) : (
|
|
339
|
+
icon && <ClockIcon className="text-muted-foreground" />
|
|
340
|
+
)}
|
|
341
|
+
<span data-slot="time-input-segments" className="flex items-center">
|
|
342
|
+
{hourCycle === 12 && fmt.periodFirst && <span className="me-1">{segment("period")}</span>}
|
|
343
|
+
{segment("hour")}
|
|
344
|
+
<span aria-hidden className="text-muted-foreground">
|
|
345
|
+
:
|
|
346
|
+
</span>
|
|
347
|
+
{segment("minute")}
|
|
348
|
+
{seconds && (
|
|
349
|
+
<>
|
|
350
|
+
<span aria-hidden className="text-muted-foreground">
|
|
351
|
+
:
|
|
352
|
+
</span>
|
|
353
|
+
{segment("second")}
|
|
354
|
+
</>
|
|
355
|
+
)}
|
|
356
|
+
{hourCycle === 12 && !fmt.periodFirst && <span className="ms-1">{segment("period")}</span>}
|
|
357
|
+
</span>
|
|
358
|
+
{name && <input type="hidden" name={name} value={current ?? ""} />}
|
|
359
|
+
</div>
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Empty segments take their first value, so the picker always shows a full time. */
|
|
364
|
+
function fill(s: Segments, cycle: 12 | 24): Segments {
|
|
365
|
+
return {
|
|
366
|
+
hour: s.hour ?? (cycle === 12 ? 12 : 0),
|
|
367
|
+
minute: s.minute ?? 0,
|
|
368
|
+
second: s.second ?? 0,
|
|
369
|
+
period: s.period ?? (cycle === 12 ? "AM" : null),
|
|
370
|
+
};
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** The popover opens on the hours column's selected option (found once the popup is in the DOM). */
|
|
374
|
+
function pickerFocusTarget(): HTMLElement | null {
|
|
375
|
+
return document.querySelector<HTMLElement>(
|
|
376
|
+
"[data-slot=time-input-content] [data-slot=time-columns-column] [data-slot=time-columns-option][tabindex='0']",
|
|
377
|
+
);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/** A column of the picker: a listbox of option buttons, scrolled so the selected one sits in the middle. */
|
|
381
|
+
const timeColumnsOptionVariants = cva([
|
|
382
|
+
"flex h-8 w-full shrink-0 snap-center items-center justify-center rounded-md font-mono text-sm tabular-nums outline-none select-none",
|
|
383
|
+
"transition-[color,background-color] duration-100 motion-reduce:transition-none",
|
|
384
|
+
"hover:bg-hover",
|
|
385
|
+
// selected: the vivid fill, like Calendar's selected day
|
|
386
|
+
"aria-selected:bg-primary aria-selected:text-primary-foreground aria-selected:hover:bg-primary/85",
|
|
387
|
+
]);
|
|
388
|
+
|
|
389
|
+
interface TimeColumnsProps extends Omit<React.ComponentProps<"div">, "defaultValue" | "onChange" | "children"> {
|
|
390
|
+
/** 24-hour `"HH:mm"` / `"HH:mm:ss"`; `null` shows no selection until a pick fills the rest with zeros. */
|
|
391
|
+
value: string | null;
|
|
392
|
+
onValueChange: (value: string) => void;
|
|
393
|
+
/** Enter in a column (close a popover). */
|
|
394
|
+
onCommit?: () => void;
|
|
395
|
+
seconds?: boolean;
|
|
396
|
+
/** `"auto"` follows the user's locale. */
|
|
397
|
+
hourCycle?: HourCyclePreference;
|
|
398
|
+
locale?: string;
|
|
399
|
+
/** Minutes between options. */
|
|
400
|
+
step?: number;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Hours | minutes | (seconds) | (AM/PM) as scrollable listboxes. A click or tap picks; ↑↓ / Home / End
|
|
405
|
+
* pick within a column, ← → move between columns, Enter commits. Every pick fires `onValueChange` with a
|
|
406
|
+
* full time. Embed it in a popover (TimeInput does) or inline.
|
|
407
|
+
*/
|
|
408
|
+
function TimeColumns({
|
|
409
|
+
value,
|
|
410
|
+
onValueChange,
|
|
411
|
+
onCommit,
|
|
412
|
+
seconds = false,
|
|
413
|
+
hourCycle: hourCyclePreference = "auto",
|
|
414
|
+
locale,
|
|
415
|
+
step = 1,
|
|
416
|
+
className,
|
|
417
|
+
...props
|
|
418
|
+
}: TimeColumnsProps) {
|
|
419
|
+
const fmt = useTimeFormat(hourCyclePreference, locale);
|
|
420
|
+
const hourCycle = fmt.hourCycle;
|
|
421
|
+
const segs = parse(value, hourCycle);
|
|
422
|
+
const full = fill(segs, hourCycle);
|
|
423
|
+
const empty = value === null;
|
|
424
|
+
|
|
425
|
+
const withValue = (list: number[], v: number | null) =>
|
|
426
|
+
v === null || list.includes(v) ? list : [...list, v].sort((a, b) => a - b);
|
|
427
|
+
const periodColumn = {part: "period" as const, label: "AM/PM", options: ["AM", "PM"] as Period[]};
|
|
428
|
+
const columns: {part: Part; label: string; options: (number | Period)[]}[] = [
|
|
429
|
+
...(hourCycle === 12 && fmt.periodFirst ? [periodColumn] : []),
|
|
430
|
+
{
|
|
431
|
+
part: "hour",
|
|
432
|
+
label: "Hours",
|
|
433
|
+
options:
|
|
434
|
+
hourCycle === 12
|
|
435
|
+
? [12, ...Array.from({length: 11}, (_, i) => i + 1)]
|
|
436
|
+
: Array.from({length: 24}, (_, i) => i),
|
|
437
|
+
},
|
|
438
|
+
{
|
|
439
|
+
part: "minute",
|
|
440
|
+
label: "Minutes",
|
|
441
|
+
options: withValue(
|
|
442
|
+
Array.from({length: Math.ceil(60 / Math.max(1, step))}, (_, i) => i * Math.max(1, step)),
|
|
443
|
+
segs.minute,
|
|
444
|
+
),
|
|
445
|
+
},
|
|
446
|
+
...(seconds
|
|
447
|
+
? [{part: "second" as const, label: "Seconds", options: Array.from({length: 60}, (_, i) => i)}]
|
|
448
|
+
: []),
|
|
449
|
+
...(hourCycle === 12 && !fmt.periodFirst ? [periodColumn] : []),
|
|
450
|
+
];
|
|
451
|
+
|
|
452
|
+
const pick = (part: Part, option: number | Period) => {
|
|
453
|
+
onValueChange(serialize({...full, [part]: option}, hourCycle, seconds)!);
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const root = React.useRef<HTMLDivElement>(null);
|
|
457
|
+
const center = (behavior: ScrollBehavior) => {
|
|
458
|
+
root.current?.querySelectorAll<HTMLElement>("[data-slot=time-columns-column]").forEach((col) => {
|
|
459
|
+
const opt = col.querySelector<HTMLElement>("[tabindex='0']");
|
|
460
|
+
if (opt) {
|
|
461
|
+
col.scrollTo({top: opt.offsetTop - (col.clientHeight - opt.offsetHeight) / 2, behavior});
|
|
462
|
+
}
|
|
463
|
+
});
|
|
464
|
+
};
|
|
465
|
+
// centre the selection on mount, follow it after keyboard picks
|
|
466
|
+
React.useLayoutEffect(() => center("instant"), []);
|
|
467
|
+
|
|
468
|
+
const onKeyDown = (e: React.KeyboardEvent<HTMLButtonElement>, ci: number, oi: number) => {
|
|
469
|
+
const col = columns[ci]!;
|
|
470
|
+
const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
|
|
471
|
+
const go = (i: number) => {
|
|
472
|
+
e.preventDefault();
|
|
473
|
+
const next = Math.max(0, Math.min(col.options.length - 1, i));
|
|
474
|
+
pick(col.part, col.options[next]!);
|
|
475
|
+
requestAnimationFrame(() => {
|
|
476
|
+
const btn = root.current?.querySelectorAll<HTMLElement>("[data-slot=time-columns-column]")[ci]
|
|
477
|
+
?.children[next] as HTMLElement | undefined;
|
|
478
|
+
btn?.focus({preventScroll: true});
|
|
479
|
+
btn?.scrollIntoView({block: "nearest"});
|
|
480
|
+
});
|
|
481
|
+
};
|
|
482
|
+
const toColumn = (delta: number) => {
|
|
483
|
+
e.preventDefault();
|
|
484
|
+
root.current
|
|
485
|
+
?.querySelectorAll<HTMLElement>("[data-slot=time-columns-column]")
|
|
486
|
+
[ci + delta]?.querySelector<HTMLElement>("[tabindex='0']")
|
|
487
|
+
?.focus();
|
|
488
|
+
};
|
|
489
|
+
switch (e.key) {
|
|
490
|
+
case "ArrowDown":
|
|
491
|
+
return go(oi + 1);
|
|
492
|
+
case "ArrowUp":
|
|
493
|
+
return go(oi - 1);
|
|
494
|
+
case "PageDown":
|
|
495
|
+
return go(oi + 5);
|
|
496
|
+
case "PageUp":
|
|
497
|
+
return go(oi - 5);
|
|
498
|
+
case "Home":
|
|
499
|
+
return go(0);
|
|
500
|
+
case "End":
|
|
501
|
+
return go(col.options.length - 1);
|
|
502
|
+
case "ArrowLeft":
|
|
503
|
+
return toColumn(rtl ? 1 : -1);
|
|
504
|
+
case "ArrowRight":
|
|
505
|
+
return toColumn(rtl ? -1 : 1);
|
|
506
|
+
case "Enter":
|
|
507
|
+
e.preventDefault();
|
|
508
|
+
if (empty) {
|
|
509
|
+
pick(col.part, col.options[oi]!);
|
|
510
|
+
}
|
|
511
|
+
onCommit?.();
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
};
|
|
515
|
+
|
|
516
|
+
return (
|
|
517
|
+
<div ref={root} data-slot="time-columns" className={cn("flex gap-1", className)} {...props}>
|
|
518
|
+
{columns.map((col, ci) => {
|
|
519
|
+
const current = full[col.part];
|
|
520
|
+
const active = Math.max(0, col.options.indexOf(current as never));
|
|
521
|
+
return (
|
|
522
|
+
// the ring sits on a wrapper: the column's scroll-fade mask would clip it
|
|
523
|
+
<div
|
|
524
|
+
key={col.part}
|
|
525
|
+
data-slot="time-columns-frame"
|
|
526
|
+
className="rounded-md has-focus-visible:ring-2 has-focus-visible:ring-ring/50"
|
|
527
|
+
>
|
|
528
|
+
<div
|
|
529
|
+
role="listbox"
|
|
530
|
+
aria-label={col.label}
|
|
531
|
+
data-slot="time-columns-column"
|
|
532
|
+
data-part={col.part}
|
|
533
|
+
className={cn(
|
|
534
|
+
// room above and below so the first and last options can sit in the middle
|
|
535
|
+
"no-scrollbar relative flex h-56 w-14 snap-y snap-proximity flex-col gap-0.5 overflow-y-auto overscroll-contain scroll-fade-y py-24",
|
|
536
|
+
"rounded-md",
|
|
537
|
+
)}
|
|
538
|
+
>
|
|
539
|
+
{col.options.map((option, oi) => {
|
|
540
|
+
const selected = !empty && option === current;
|
|
541
|
+
return (
|
|
542
|
+
<button
|
|
543
|
+
key={String(option)}
|
|
544
|
+
type="button"
|
|
545
|
+
role="option"
|
|
546
|
+
aria-selected={selected}
|
|
547
|
+
tabIndex={oi === active ? 0 : -1}
|
|
548
|
+
data-slot="time-columns-option"
|
|
549
|
+
className={timeColumnsOptionVariants()}
|
|
550
|
+
onClick={() => pick(col.part, option)}
|
|
551
|
+
onKeyDown={(e) => onKeyDown(e, ci, oi)}
|
|
552
|
+
>
|
|
553
|
+
{typeof option === "number"
|
|
554
|
+
? fmt.digits(option)
|
|
555
|
+
: option === "AM"
|
|
556
|
+
? fmt.am
|
|
557
|
+
: fmt.pm}
|
|
558
|
+
</button>
|
|
559
|
+
);
|
|
560
|
+
})}
|
|
561
|
+
</div>
|
|
562
|
+
</div>
|
|
563
|
+
);
|
|
564
|
+
})}
|
|
565
|
+
</div>
|
|
566
|
+
);
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
export {
|
|
570
|
+
parse as parseTime,
|
|
571
|
+
serialize as serializeTime,
|
|
572
|
+
TimeColumns,
|
|
573
|
+
timeColumnsOptionVariants,
|
|
574
|
+
TimeInput,
|
|
575
|
+
timeInputSegmentVariants,
|
|
576
|
+
timeInputVariants,
|
|
577
|
+
};
|
|
578
|
+
export type {TimeColumnsProps, TimeInputProps};
|