@repraze/kernelkit 0.1.0-alpha.4 → 0.1.0-alpha.6
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/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/pager.d.ts +1 -1
- package/dist/components/pager.d.ts.map +1 -1
- package/dist/components/pager.js +4 -4
- package/dist/components/pager.js.map +1 -1
- 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-trigger.d.ts +14 -4
- package/dist/components/search-trigger.d.ts.map +1 -1
- package/dist/components/search-trigger.js +25 -4
- 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/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 +11 -6
- package/dist/lib/recipes.d.ts.map +1 -1
- package/dist/lib/recipes.js +13 -7
- package/dist/lib/recipes.js.map +1 -1
- package/dist/site/suite-header.d.ts +4 -1
- package/dist/site/suite-header.d.ts.map +1 -1
- package/dist/site/suite-header.js +7 -2
- package/dist/site/suite-header.js.map +1 -1
- package/package.json +1 -1
- 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/pager.tsx +8 -8
- package/src/components/popover.tsx +3 -1
- package/src/components/progress.tsx +70 -4
- package/src/components/search-trigger.tsx +40 -7
- 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/hooks/use-time-format.ts +72 -0
- package/src/lib/color.ts +19 -2
- package/src/lib/recipes.ts +13 -7
- package/src/site/suite-header.tsx +25 -4
- package/src/styles/kernelkit.css +56 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {mergeProps} from "@base-ui/react/merge-props";
|
|
4
|
+
import {useRender} from "@base-ui/react/use-render";
|
|
5
|
+
import {Spinner} from "@repraze/kernelkit/components/spinner";
|
|
6
|
+
import {focusRing} from "@repraze/kernelkit/lib/recipes";
|
|
7
|
+
import {cva} from "class-variance-authority";
|
|
8
|
+
import {cn} from "cn";
|
|
9
|
+
import {CheckIcon, SkipForwardIcon, XIcon} from "lucide-react";
|
|
10
|
+
import * as React from "react";
|
|
11
|
+
|
|
12
|
+
type StepperOrientation = "horizontal" | "vertical";
|
|
13
|
+
/** Where a step stands against the current one. */
|
|
14
|
+
type StepState = "completed" | "current" | "upcoming";
|
|
15
|
+
|
|
16
|
+
interface StepperContextValue {
|
|
17
|
+
value: number;
|
|
18
|
+
setValue: (step: number) => void;
|
|
19
|
+
orientation: StepperOrientation;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const StepperContext = React.createContext<StepperContextValue | null>(null);
|
|
23
|
+
|
|
24
|
+
interface StepperItemContextValue {
|
|
25
|
+
step: number;
|
|
26
|
+
state: StepState;
|
|
27
|
+
error: boolean;
|
|
28
|
+
loading: boolean;
|
|
29
|
+
disabled: boolean;
|
|
30
|
+
skipped: boolean;
|
|
31
|
+
/** `completed` was passed explicitly (a selected step keeps its check). */
|
|
32
|
+
done: boolean;
|
|
33
|
+
orientation: StepperOrientation;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const StepperItemContext = React.createContext<StepperItemContextValue | null>(null);
|
|
37
|
+
|
|
38
|
+
function useStepper() {
|
|
39
|
+
const ctx = React.useContext(StepperContext);
|
|
40
|
+
if (!ctx) {
|
|
41
|
+
throw new Error("Stepper parts must be used inside <Stepper>.");
|
|
42
|
+
}
|
|
43
|
+
return ctx;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function useStepperItem() {
|
|
47
|
+
const ctx = React.useContext(StepperItemContext);
|
|
48
|
+
if (!ctx) {
|
|
49
|
+
throw new Error("Stepper item parts must be used inside <StepperItem>.");
|
|
50
|
+
}
|
|
51
|
+
return ctx;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Data attributes every part carries, so each styles itself from its own state (§4). */
|
|
55
|
+
function itemState(item: StepperItemContextValue) {
|
|
56
|
+
return {
|
|
57
|
+
"data-state": item.state,
|
|
58
|
+
"data-orientation": item.orientation,
|
|
59
|
+
"data-error": item.error ? "" : undefined,
|
|
60
|
+
"data-loading": item.loading ? "" : undefined,
|
|
61
|
+
"data-disabled": item.disabled ? "" : undefined,
|
|
62
|
+
"data-skipped": item.skipped ? "" : undefined,
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const stepperVariants = cva("flex", {
|
|
67
|
+
variants: {
|
|
68
|
+
orientation: {
|
|
69
|
+
horizontal: "w-full flex-row items-center gap-2",
|
|
70
|
+
vertical: "flex-col",
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
defaultVariants: {orientation: "horizontal"},
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
interface StepperProps extends Omit<React.ComponentProps<"ol">, "defaultValue" | "onChange"> {
|
|
77
|
+
/** The current step (matches a `StepperItem`'s `step`). */
|
|
78
|
+
value?: number;
|
|
79
|
+
defaultValue?: number;
|
|
80
|
+
onValueChange?: (step: number) => void;
|
|
81
|
+
orientation?: StepperOrientation;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* A multi-step flow: numbered indicators joined by separators. Steps before the current one count as
|
|
86
|
+
* completed unless an item says otherwise. Arrow keys move focus between `StepperTrigger`s.
|
|
87
|
+
*/
|
|
88
|
+
function Stepper({
|
|
89
|
+
value,
|
|
90
|
+
defaultValue = 1,
|
|
91
|
+
onValueChange,
|
|
92
|
+
orientation = "horizontal",
|
|
93
|
+
className,
|
|
94
|
+
onKeyDown,
|
|
95
|
+
...props
|
|
96
|
+
}: StepperProps) {
|
|
97
|
+
const [inner, setInner] = React.useState(defaultValue);
|
|
98
|
+
const controlled = value !== undefined;
|
|
99
|
+
const current = controlled ? value : inner;
|
|
100
|
+
const setValue = React.useCallback(
|
|
101
|
+
(step: number) => {
|
|
102
|
+
if (!controlled) {
|
|
103
|
+
setInner(step);
|
|
104
|
+
}
|
|
105
|
+
onValueChange?.(step);
|
|
106
|
+
},
|
|
107
|
+
[controlled, onValueChange],
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
const ctx = React.useMemo(() => ({value: current, setValue, orientation}), [current, setValue, orientation]);
|
|
111
|
+
|
|
112
|
+
const handleKeyDown = (e: React.KeyboardEvent<HTMLOListElement>) => {
|
|
113
|
+
onKeyDown?.(e);
|
|
114
|
+
const prev = orientation === "vertical" ? "ArrowUp" : "ArrowLeft";
|
|
115
|
+
const next = orientation === "vertical" ? "ArrowDown" : "ArrowRight";
|
|
116
|
+
if (e.defaultPrevented || ![prev, next, "Home", "End"].includes(e.key)) {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
const triggers = Array.from(
|
|
120
|
+
e.currentTarget.querySelectorAll<HTMLElement>("[data-slot=stepper-trigger]:not(:disabled)"),
|
|
121
|
+
).filter((el) => el.closest("[data-slot=stepper]") === e.currentTarget);
|
|
122
|
+
const index = triggers.indexOf(document.activeElement as HTMLElement);
|
|
123
|
+
if (index === -1) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
e.preventDefault();
|
|
127
|
+
const target =
|
|
128
|
+
e.key === "Home"
|
|
129
|
+
? 0
|
|
130
|
+
: e.key === "End"
|
|
131
|
+
? triggers.length - 1
|
|
132
|
+
: (index + (e.key === next ? 1 : -1) + triggers.length) % triggers.length;
|
|
133
|
+
triggers[target]?.focus();
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
return (
|
|
137
|
+
<StepperContext.Provider value={ctx}>
|
|
138
|
+
<ol
|
|
139
|
+
data-slot="stepper"
|
|
140
|
+
data-orientation={orientation}
|
|
141
|
+
aria-orientation={orientation}
|
|
142
|
+
className={cn(stepperVariants({orientation}), className)}
|
|
143
|
+
onKeyDown={handleKeyDown}
|
|
144
|
+
{...props}
|
|
145
|
+
/>
|
|
146
|
+
</StepperContext.Provider>
|
|
147
|
+
);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const stepperItemVariants = cva("group/stepper-item relative flex", {
|
|
151
|
+
variants: {
|
|
152
|
+
orientation: {
|
|
153
|
+
// every step but the last grows, so the separators share the free width
|
|
154
|
+
horizontal: "flex-1 items-center gap-2 last:flex-none",
|
|
155
|
+
// the separator hangs under the indicator; the padding is its length
|
|
156
|
+
vertical: "items-start gap-3 pb-6 last:pb-0",
|
|
157
|
+
},
|
|
158
|
+
},
|
|
159
|
+
defaultVariants: {orientation: "horizontal"},
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
interface StepperItemProps extends React.ComponentProps<"li"> {
|
|
163
|
+
/** This step's number: shown in the indicator and compared with the Stepper's `value`. */
|
|
164
|
+
step: number;
|
|
165
|
+
/** Force the completed look (default: any step before the current one). */
|
|
166
|
+
completed?: boolean;
|
|
167
|
+
disabled?: boolean;
|
|
168
|
+
/** Show the error look: danger indicator with an X. */
|
|
169
|
+
error?: boolean;
|
|
170
|
+
/** Show a spinner in the indicator. */
|
|
171
|
+
loading?: boolean;
|
|
172
|
+
/** Never ran (a CI step after a failure): dashed muted indicator with a skip glyph. Still clickable. */
|
|
173
|
+
skipped?: boolean;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function StepperItem({
|
|
177
|
+
step,
|
|
178
|
+
completed,
|
|
179
|
+
disabled = false,
|
|
180
|
+
error = false,
|
|
181
|
+
loading = false,
|
|
182
|
+
skipped = false,
|
|
183
|
+
className,
|
|
184
|
+
...props
|
|
185
|
+
}: StepperItemProps) {
|
|
186
|
+
const {value, orientation} = useStepper();
|
|
187
|
+
const state: StepState =
|
|
188
|
+
step === value ? "current" : skipped ? "upcoming" : (completed ?? step < value) ? "completed" : "upcoming";
|
|
189
|
+
const done = completed === true;
|
|
190
|
+
const item = React.useMemo(
|
|
191
|
+
() => ({step, state, error, loading, disabled, skipped, done, orientation}),
|
|
192
|
+
[step, state, error, loading, disabled, skipped, done, orientation],
|
|
193
|
+
);
|
|
194
|
+
// reached = the flow got here (completed, current or failed): the line leading into it fills
|
|
195
|
+
const reached = !skipped && (state !== "upcoming" || error || done);
|
|
196
|
+
return (
|
|
197
|
+
<StepperItemContext.Provider value={item}>
|
|
198
|
+
<li
|
|
199
|
+
data-slot="stepper-item"
|
|
200
|
+
data-reached={reached ? "" : undefined}
|
|
201
|
+
{...itemState(item)}
|
|
202
|
+
className={cn(stepperItemVariants({orientation}), className)}
|
|
203
|
+
{...props}
|
|
204
|
+
/>
|
|
205
|
+
</StepperItemContext.Provider>
|
|
206
|
+
);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Makes a step clickable: a button that sets the Stepper's value. Wraps the indicator and the text. */
|
|
210
|
+
function StepperTrigger({className, render, onClick, ...props}: useRender.ComponentProps<"button">) {
|
|
211
|
+
const {setValue} = useStepper();
|
|
212
|
+
const item = useStepperItem();
|
|
213
|
+
return useRender({
|
|
214
|
+
defaultTagName: "button",
|
|
215
|
+
props: mergeProps<"button">(
|
|
216
|
+
{
|
|
217
|
+
type: "button",
|
|
218
|
+
disabled: item.disabled,
|
|
219
|
+
"aria-current": item.state === "current" ? "step" : undefined,
|
|
220
|
+
className: cn(
|
|
221
|
+
"-m-1 flex min-w-0 items-center gap-2 rounded-md p-1 text-start transition-[color,background-color,box-shadow] duration-100 hover:bg-hover disabled:pointer-events-none disabled:opacity-50 motion-reduce:transition-none",
|
|
222
|
+
"data-[orientation=vertical]:items-start data-[orientation=vertical]:gap-3",
|
|
223
|
+
focusRing,
|
|
224
|
+
className,
|
|
225
|
+
),
|
|
226
|
+
onClick: (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
227
|
+
onClick?.(e);
|
|
228
|
+
if (!e.defaultPrevented) {
|
|
229
|
+
setValue(item.step);
|
|
230
|
+
}
|
|
231
|
+
},
|
|
232
|
+
...itemState(item),
|
|
233
|
+
} as React.ComponentProps<"button">,
|
|
234
|
+
props,
|
|
235
|
+
),
|
|
236
|
+
render,
|
|
237
|
+
state: {slot: "stepper-trigger"},
|
|
238
|
+
});
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const stepperIndicatorVariants = cva([
|
|
242
|
+
"relative inline-flex size-6 shrink-0 items-center justify-center rounded-pill border font-alt text-xs font-medium tabular-nums transition-[color,background-color,border-color] duration-100 motion-reduce:transition-none [&_svg:not([class*='size-'])]:size-3.5",
|
|
243
|
+
// upcoming: a plain outlined number
|
|
244
|
+
"border-input bg-background text-muted-foreground",
|
|
245
|
+
// current: the solid accent fill
|
|
246
|
+
"data-[state=current]:border-primary data-[state=current]:bg-primary data-[state=current]:text-primary-foreground",
|
|
247
|
+
// completed: accent text tier, a check
|
|
248
|
+
"data-[state=completed]:border-primary-text data-[state=completed]:bg-primary-tint data-[state=completed]:text-primary-text",
|
|
249
|
+
// skipped: never ran, a dashed muted ring
|
|
250
|
+
"data-skipped:border-dashed data-skipped:border-input data-skipped:bg-background data-skipped:text-muted-foreground",
|
|
251
|
+
"data-[state=current]:data-skipped:border-foreground data-[state=current]:data-skipped:text-foreground",
|
|
252
|
+
// error outranks the step state; the selected failed step is the solid danger fill
|
|
253
|
+
"data-error:border-danger data-error:bg-danger-tint data-error:text-danger",
|
|
254
|
+
"data-[state=current]:data-error:border-(--tc-red-fill) data-[state=current]:data-error:bg-(--tc-red-fill) data-[state=current]:data-error:text-(--tc-red-on-fill)",
|
|
255
|
+
]);
|
|
256
|
+
|
|
257
|
+
/** The step's badge: its number, a check once completed, an X on error, a skip glyph, a spinner while loading. */
|
|
258
|
+
function StepperIndicator({className, children, ...props}: React.ComponentProps<"span">) {
|
|
259
|
+
const item = useStepperItem();
|
|
260
|
+
const content = item.loading ? (
|
|
261
|
+
<Spinner size="sm" aria-label="In progress" />
|
|
262
|
+
) : item.error ? (
|
|
263
|
+
<XIcon aria-label="Error" />
|
|
264
|
+
) : item.skipped ? (
|
|
265
|
+
<SkipForwardIcon aria-label="Skipped" className="size-3" />
|
|
266
|
+
) : item.state === "completed" || item.done ? (
|
|
267
|
+
<CheckIcon aria-label="Completed" />
|
|
268
|
+
) : (
|
|
269
|
+
(children ?? item.step)
|
|
270
|
+
);
|
|
271
|
+
return (
|
|
272
|
+
<span
|
|
273
|
+
data-slot="stepper-indicator"
|
|
274
|
+
{...itemState(item)}
|
|
275
|
+
className={cn(stepperIndicatorVariants(), className)}
|
|
276
|
+
{...props}
|
|
277
|
+
>
|
|
278
|
+
{content}
|
|
279
|
+
</span>
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function StepperTitle({className, ...props}: React.ComponentProps<"span">) {
|
|
284
|
+
const item = useStepperItem();
|
|
285
|
+
return (
|
|
286
|
+
<span
|
|
287
|
+
data-slot="stepper-title"
|
|
288
|
+
{...itemState(item)}
|
|
289
|
+
className={cn(
|
|
290
|
+
"block truncate text-sm font-medium text-foreground not-data-error:data-[state=upcoming]:text-muted-foreground not-data-error:not-data-[state=current]:data-skipped:text-muted-foreground data-error:text-danger data-[orientation=vertical]:leading-6",
|
|
291
|
+
className,
|
|
292
|
+
)}
|
|
293
|
+
{...props}
|
|
294
|
+
/>
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function StepperDescription({className, ...props}: React.ComponentProps<"span">) {
|
|
299
|
+
const item = useStepperItem();
|
|
300
|
+
return (
|
|
301
|
+
<span
|
|
302
|
+
data-slot="stepper-description"
|
|
303
|
+
{...itemState(item)}
|
|
304
|
+
className={cn("block text-xs text-muted-foreground", className)}
|
|
305
|
+
{...props}
|
|
306
|
+
/>
|
|
307
|
+
);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* The line to the next step. Fills with the accent once the step it leads into has been reached
|
|
312
|
+
* (completed, current or failed); lines into skipped or upcoming steps stay plain. Leave it out of the last item.
|
|
313
|
+
*/
|
|
314
|
+
function StepperSeparator({className, ...props}: React.ComponentProps<"span">) {
|
|
315
|
+
const item = useStepperItem();
|
|
316
|
+
return (
|
|
317
|
+
<span
|
|
318
|
+
data-slot="stepper-separator"
|
|
319
|
+
aria-hidden="true"
|
|
320
|
+
{...itemState(item)}
|
|
321
|
+
className={cn(
|
|
322
|
+
"shrink-0 rounded-pill bg-border transition-colors duration-100 motion-reduce:transition-none [li:has(+[data-reached])>&]:bg-primary-text",
|
|
323
|
+
"data-[orientation=horizontal]:h-0.5 data-[orientation=horizontal]:min-w-4 data-[orientation=horizontal]:flex-1",
|
|
324
|
+
// under the 24px indicator, centred on it, with a 4px gap at both ends
|
|
325
|
+
"data-[orientation=vertical]:absolute data-[orientation=vertical]:start-[calc(0.75rem-1px)] data-[orientation=vertical]:top-7 data-[orientation=vertical]:bottom-1 data-[orientation=vertical]:w-0.5",
|
|
326
|
+
className,
|
|
327
|
+
)}
|
|
328
|
+
{...props}
|
|
329
|
+
/>
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export {
|
|
334
|
+
Stepper,
|
|
335
|
+
StepperDescription,
|
|
336
|
+
StepperIndicator,
|
|
337
|
+
stepperIndicatorVariants,
|
|
338
|
+
StepperItem,
|
|
339
|
+
stepperItemVariants,
|
|
340
|
+
StepperSeparator,
|
|
341
|
+
StepperTitle,
|
|
342
|
+
StepperTrigger,
|
|
343
|
+
stepperVariants,
|
|
344
|
+
useStepperItem,
|
|
345
|
+
};
|
|
346
|
+
export type {StepperItemProps, StepperOrientation, StepperProps, StepState};
|
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {fieldSurface} from "@repraze/kernelkit/lib/recipes";
|
|
4
|
+
import {cva} from "class-variance-authority";
|
|
5
|
+
import {cn} from "cn";
|
|
6
|
+
import {XIcon} from "lucide-react";
|
|
7
|
+
import * as React from "react";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Field that holds chips and a text input, the same surface as `ComboboxChips` (GUIDELINES §4, §6):
|
|
11
|
+
* focus rings the container when the inner input or a chip has keyboard focus.
|
|
12
|
+
*/
|
|
13
|
+
const tagInputVariants = cva(
|
|
14
|
+
[
|
|
15
|
+
fieldSurface,
|
|
16
|
+
"flex w-full min-w-0 cursor-text flex-wrap items-center gap-1 bg-clip-padding text-base md:text-sm",
|
|
17
|
+
"has-[:focus-visible]:border-ring has-[:focus-visible]:ring-3 has-[:focus-visible]:ring-ring/50",
|
|
18
|
+
"aria-invalid:border-danger aria-invalid:ring-3 aria-invalid:ring-danger/20 dark:aria-invalid:ring-danger/40",
|
|
19
|
+
"has-[input[aria-invalid=true]]:border-danger has-[input[aria-invalid=true]]:ring-3 has-[input[aria-invalid=true]]:ring-danger/20 dark:has-[input[aria-invalid=true]]:ring-danger/40",
|
|
20
|
+
"data-disabled:cursor-not-allowed data-disabled:bg-field-disabled data-disabled:opacity-50",
|
|
21
|
+
],
|
|
22
|
+
{
|
|
23
|
+
variants: {
|
|
24
|
+
// Heights match Input (§4); a chip row adds 4px per line when it wraps.
|
|
25
|
+
size: {
|
|
26
|
+
sm: "min-h-7 rounded-md px-2 py-0.5 has-data-[slot=tag-input-chip]:px-0.5 md:text-[0.8rem]",
|
|
27
|
+
default: "min-h-8 px-2.5 py-1 has-data-[slot=tag-input-chip]:px-1",
|
|
28
|
+
lg: "min-h-9 px-3 py-1.5 has-data-[slot=tag-input-chip]:px-1.5",
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
defaultVariants: {size: "default"},
|
|
32
|
+
},
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/** One tag: the `ComboboxChip` look. Keyboard focus (arrows, Backspace on an empty input) rings it. */
|
|
36
|
+
const tagInputChipVariants = cva(
|
|
37
|
+
"flex h-5.5 w-fit min-w-0 items-center gap-1 rounded-md border border-border bg-foreground/10 px-1.5 text-xs font-medium whitespace-nowrap text-foreground hc:bg-transparent outline-none transition-[border-color,box-shadow] focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50 has-data-[slot=tag-input-chip-remove]:pe-0",
|
|
38
|
+
);
|
|
39
|
+
|
|
40
|
+
type Size = "sm" | "default" | "lg";
|
|
41
|
+
|
|
42
|
+
function useControllable<T>(value: T | undefined, defaultValue: T, onChange?: (value: T) => void) {
|
|
43
|
+
const [inner, setInner] = React.useState(defaultValue);
|
|
44
|
+
const controlled = value !== undefined;
|
|
45
|
+
const current = controlled ? value : inner;
|
|
46
|
+
const set = React.useCallback(
|
|
47
|
+
(next: T) => {
|
|
48
|
+
if (!controlled) {
|
|
49
|
+
setInner(next);
|
|
50
|
+
}
|
|
51
|
+
onChange?.(next);
|
|
52
|
+
},
|
|
53
|
+
[controlled, onChange],
|
|
54
|
+
);
|
|
55
|
+
return [current, set] as const;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface TagInputProps extends Omit<
|
|
59
|
+
React.ComponentProps<"input">,
|
|
60
|
+
"value" | "defaultValue" | "size" | "onChange" | "type" | "max"
|
|
61
|
+
> {
|
|
62
|
+
value?: string[];
|
|
63
|
+
defaultValue?: string[];
|
|
64
|
+
onValueChange?: (tags: string[]) => void;
|
|
65
|
+
/** Keys that commit the typed text as a tag. */
|
|
66
|
+
separators?: string[];
|
|
67
|
+
/** Splits pasted text into several tags. */
|
|
68
|
+
delimiter?: string | RegExp;
|
|
69
|
+
/** Most tags allowed; extra ones are ignored. */
|
|
70
|
+
max?: number;
|
|
71
|
+
allowDuplicates?: boolean;
|
|
72
|
+
/** `false` rejects silently, a string rejects with that message (`onValidationError`). */
|
|
73
|
+
validate?: (tag: string) => boolean | string;
|
|
74
|
+
/** Called with the rejection message, then `null` once the text changes or a tag is added. */
|
|
75
|
+
onValidationError?: (message: string | null) => void;
|
|
76
|
+
/** Commit the pending text when the input loses focus. */
|
|
77
|
+
addOnBlur?: boolean;
|
|
78
|
+
size?: Size;
|
|
79
|
+
/** Classes for the inner `<input>` (`className` goes on the field). */
|
|
80
|
+
inputClassName?: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A field of free-form tags. Enter or comma adds the typed text, a paste splits on commas, newlines and
|
|
85
|
+
* tabs. Backspace in an empty input focuses the last tag, a second press removes it; arrows move between
|
|
86
|
+
* tags, Delete / Backspace removes the focused one.
|
|
87
|
+
*/
|
|
88
|
+
function TagInput({
|
|
89
|
+
value,
|
|
90
|
+
defaultValue = [],
|
|
91
|
+
onValueChange,
|
|
92
|
+
separators = ["Enter", ","],
|
|
93
|
+
delimiter = /[,\n\t]/,
|
|
94
|
+
max,
|
|
95
|
+
allowDuplicates = false,
|
|
96
|
+
validate,
|
|
97
|
+
onValidationError,
|
|
98
|
+
addOnBlur = false,
|
|
99
|
+
size = "default",
|
|
100
|
+
disabled,
|
|
101
|
+
name,
|
|
102
|
+
placeholder,
|
|
103
|
+
className,
|
|
104
|
+
inputClassName,
|
|
105
|
+
onKeyDown,
|
|
106
|
+
onPaste,
|
|
107
|
+
onBlur,
|
|
108
|
+
ref,
|
|
109
|
+
"aria-invalid": ariaInvalid,
|
|
110
|
+
...inputProps
|
|
111
|
+
}: TagInputProps) {
|
|
112
|
+
const [tags, setTags] = useControllable(value, defaultValue, onValueChange);
|
|
113
|
+
const [text, setText] = React.useState("");
|
|
114
|
+
const [error, setError] = React.useState<string | null>(null);
|
|
115
|
+
const inputRef = React.useRef<HTMLInputElement>(null);
|
|
116
|
+
const chipRefs = React.useRef<(HTMLElement | null)[]>([]);
|
|
117
|
+
const full = max !== undefined && tags.length >= max;
|
|
118
|
+
|
|
119
|
+
React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement);
|
|
120
|
+
|
|
121
|
+
const report = (message: string | null) => {
|
|
122
|
+
setError(message);
|
|
123
|
+
onValidationError?.(message);
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/** Adds what passes; returns the parts that did not (kept in the input). */
|
|
127
|
+
const add = (candidates: string[]) => {
|
|
128
|
+
const next = [...tags];
|
|
129
|
+
const rejected: string[] = [];
|
|
130
|
+
let message: string | null = null;
|
|
131
|
+
for (const raw of candidates) {
|
|
132
|
+
const tag = raw.trim();
|
|
133
|
+
if (!tag) {
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
if (max !== undefined && next.length >= max) {
|
|
137
|
+
rejected.push(tag);
|
|
138
|
+
message ??= `At most ${max} tags.`;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (!allowDuplicates && next.includes(tag)) {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const verdict = validate?.(tag) ?? true;
|
|
145
|
+
if (verdict !== true) {
|
|
146
|
+
rejected.push(tag);
|
|
147
|
+
if (typeof verdict === "string") {
|
|
148
|
+
message ??= verdict;
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
next.push(tag);
|
|
153
|
+
}
|
|
154
|
+
if (next.length !== tags.length) {
|
|
155
|
+
setTags(next);
|
|
156
|
+
}
|
|
157
|
+
report(message);
|
|
158
|
+
return rejected;
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
const commit = () => {
|
|
162
|
+
if (!text.trim()) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
const rejected = add([text]);
|
|
166
|
+
setText(rejected.join(", "));
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const remove = (index: number, focusAfter: "prev" | "next" | "input" = "input") => {
|
|
170
|
+
setTags(tags.filter((_, i) => i !== index));
|
|
171
|
+
report(null);
|
|
172
|
+
const target = focusAfter === "prev" ? index - 1 : index;
|
|
173
|
+
requestAnimationFrame(() => {
|
|
174
|
+
const chip = focusAfter !== "input" && target >= 0 ? chipRefs.current[target] : null;
|
|
175
|
+
// the list shrank by one: `index` now points at what was the next chip
|
|
176
|
+
if (chip && target < tags.length - 1) {
|
|
177
|
+
chip.focus();
|
|
178
|
+
} else {
|
|
179
|
+
inputRef.current?.focus();
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
const onInputKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
185
|
+
onKeyDown?.(e);
|
|
186
|
+
if (e.defaultPrevented || e.nativeEvent.isComposing) {
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
const el = e.currentTarget;
|
|
190
|
+
if (separators.includes(e.key)) {
|
|
191
|
+
if (text.trim() || e.key !== "Enter") {
|
|
192
|
+
e.preventDefault();
|
|
193
|
+
}
|
|
194
|
+
commit();
|
|
195
|
+
} else if (e.key === "Backspace" && el.selectionStart === 0 && el.selectionEnd === 0 && tags.length) {
|
|
196
|
+
e.preventDefault();
|
|
197
|
+
chipRefs.current[tags.length - 1]?.focus();
|
|
198
|
+
} else if (e.key === "ArrowLeft" && el.selectionStart === 0 && el.selectionEnd === 0 && tags.length) {
|
|
199
|
+
e.preventDefault();
|
|
200
|
+
chipRefs.current[tags.length - 1]?.focus();
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
const onChipKeyDown = (e: React.KeyboardEvent<HTMLElement>, index: number) => {
|
|
205
|
+
const rtl = getComputedStyle(e.currentTarget).direction === "rtl";
|
|
206
|
+
const prev = rtl ? "ArrowRight" : "ArrowLeft";
|
|
207
|
+
const next = rtl ? "ArrowLeft" : "ArrowRight";
|
|
208
|
+
if (e.key === prev) {
|
|
209
|
+
e.preventDefault();
|
|
210
|
+
chipRefs.current[Math.max(0, index - 1)]?.focus();
|
|
211
|
+
} else if (e.key === next) {
|
|
212
|
+
e.preventDefault();
|
|
213
|
+
(chipRefs.current[index + 1] ?? inputRef.current)?.focus();
|
|
214
|
+
} else if (e.key === "Home") {
|
|
215
|
+
e.preventDefault();
|
|
216
|
+
chipRefs.current[0]?.focus();
|
|
217
|
+
} else if (e.key === "End" || e.key === "Escape") {
|
|
218
|
+
e.preventDefault();
|
|
219
|
+
inputRef.current?.focus();
|
|
220
|
+
} else if (e.key === "Backspace") {
|
|
221
|
+
e.preventDefault();
|
|
222
|
+
remove(index, index > 0 ? "prev" : "next");
|
|
223
|
+
} else if (e.key === "Delete") {
|
|
224
|
+
e.preventDefault();
|
|
225
|
+
remove(index, "next");
|
|
226
|
+
} else if (e.key.length === 1 && !e.metaKey && !e.ctrlKey && !e.altKey) {
|
|
227
|
+
// typing on a chip goes back to the input
|
|
228
|
+
inputRef.current?.focus();
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
|
|
232
|
+
const onInputPaste = (e: React.ClipboardEvent<HTMLInputElement>) => {
|
|
233
|
+
onPaste?.(e);
|
|
234
|
+
if (e.defaultPrevented) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
const pasted = e.clipboardData.getData("text");
|
|
238
|
+
const parts = pasted.split(delimiter);
|
|
239
|
+
if (parts.length < 2) {
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
e.preventDefault();
|
|
243
|
+
const rejected = add([text + parts[0], ...parts.slice(1)]);
|
|
244
|
+
setText(rejected.join(", "));
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
const invalid = ariaInvalid === true || ariaInvalid === "true" || error !== null;
|
|
248
|
+
|
|
249
|
+
return (
|
|
250
|
+
<div
|
|
251
|
+
data-slot="tag-input"
|
|
252
|
+
data-size={size}
|
|
253
|
+
data-disabled={disabled ? "" : undefined}
|
|
254
|
+
data-full={full ? "" : undefined}
|
|
255
|
+
className={cn(tagInputVariants({size}), className)}
|
|
256
|
+
onMouseDown={(e) => {
|
|
257
|
+
// clicks on the field's padding focus the input, like a native field
|
|
258
|
+
if (e.target === e.currentTarget && !disabled) {
|
|
259
|
+
e.preventDefault();
|
|
260
|
+
inputRef.current?.focus();
|
|
261
|
+
}
|
|
262
|
+
}}
|
|
263
|
+
>
|
|
264
|
+
{tags.length > 0 && (
|
|
265
|
+
<ul data-slot="tag-input-list" aria-label="Tags" className="contents">
|
|
266
|
+
{tags.map((tag, i) => (
|
|
267
|
+
<TagInputChip
|
|
268
|
+
key={`${tag}-${i}`}
|
|
269
|
+
ref={(el) => {
|
|
270
|
+
chipRefs.current[i] = el;
|
|
271
|
+
}}
|
|
272
|
+
tabIndex={-1}
|
|
273
|
+
onKeyDown={(e) => onChipKeyDown(e, i)}
|
|
274
|
+
onRemove={disabled ? undefined : () => remove(i)}
|
|
275
|
+
>
|
|
276
|
+
{tag}
|
|
277
|
+
</TagInputChip>
|
|
278
|
+
))}
|
|
279
|
+
</ul>
|
|
280
|
+
)}
|
|
281
|
+
<input
|
|
282
|
+
ref={inputRef}
|
|
283
|
+
data-slot="tag-input-input"
|
|
284
|
+
type="text"
|
|
285
|
+
disabled={disabled}
|
|
286
|
+
aria-invalid={invalid || undefined}
|
|
287
|
+
enterKeyHint="enter"
|
|
288
|
+
autoComplete="off"
|
|
289
|
+
placeholder={tags.length ? undefined : placeholder}
|
|
290
|
+
value={text}
|
|
291
|
+
onChange={(e) => {
|
|
292
|
+
setText(e.target.value);
|
|
293
|
+
if (error) {
|
|
294
|
+
report(null);
|
|
295
|
+
}
|
|
296
|
+
}}
|
|
297
|
+
onKeyDown={onInputKeyDown}
|
|
298
|
+
onPaste={onInputPaste}
|
|
299
|
+
onBlur={(e) => {
|
|
300
|
+
onBlur?.(e);
|
|
301
|
+
if (addOnBlur) {
|
|
302
|
+
commit();
|
|
303
|
+
}
|
|
304
|
+
}}
|
|
305
|
+
className={cn(
|
|
306
|
+
"h-5.5 min-w-16 flex-1 bg-transparent outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed",
|
|
307
|
+
tags.length > 0 && "ps-1",
|
|
308
|
+
inputClassName,
|
|
309
|
+
)}
|
|
310
|
+
{...inputProps}
|
|
311
|
+
/>
|
|
312
|
+
{name && tags.map((tag, i) => <input key={i} type="hidden" name={name} value={tag} />)}
|
|
313
|
+
</div>
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
interface TagInputChipProps extends React.ComponentProps<"li"> {
|
|
318
|
+
/** Shows the × button. */
|
|
319
|
+
onRemove?: () => void;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** A tag chip. Usable on its own for read-only tag lists. */
|
|
323
|
+
function TagInputChip({className, children, onRemove, ...props}: TagInputChipProps) {
|
|
324
|
+
return (
|
|
325
|
+
<li data-slot="tag-input-chip" className={cn(tagInputChipVariants(), className)} {...props}>
|
|
326
|
+
<span className="truncate">{children}</span>
|
|
327
|
+
{onRemove && (
|
|
328
|
+
<button
|
|
329
|
+
type="button"
|
|
330
|
+
data-slot="tag-input-chip-remove"
|
|
331
|
+
tabIndex={-1}
|
|
332
|
+
aria-label={typeof children === "string" ? `Remove ${children}` : "Remove"}
|
|
333
|
+
onMouseDown={(e) => e.preventDefault()}
|
|
334
|
+
onClick={onRemove}
|
|
335
|
+
className="-ms-0.5 flex size-5 shrink-0 items-center justify-center rounded-box text-muted-foreground transition-[color,background-color] hover:bg-hover hover:text-foreground [&_svg]:size-3"
|
|
336
|
+
>
|
|
337
|
+
<XIcon className="pointer-events-none" />
|
|
338
|
+
</button>
|
|
339
|
+
)}
|
|
340
|
+
</li>
|
|
341
|
+
);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
export {TagInput, TagInputChip, tagInputChipVariants, tagInputVariants};
|
|
345
|
+
export type {TagInputChipProps, TagInputProps};
|