@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.
Files changed (203) hide show
  1. package/README.md +17 -0
  2. package/dist/components/alert-dialog.d.ts.map +1 -1
  3. package/dist/components/alert-dialog.js +4 -1
  4. package/dist/components/alert-dialog.js.map +1 -1
  5. package/dist/components/alert.js +3 -3
  6. package/dist/components/alert.js.map +1 -1
  7. package/dist/components/badge.js +1 -1
  8. package/dist/components/badge.js.map +1 -1
  9. package/dist/components/button.js +1 -1
  10. package/dist/components/button.js.map +1 -1
  11. package/dist/components/calendar.d.ts +8 -1
  12. package/dist/components/calendar.d.ts.map +1 -1
  13. package/dist/components/calendar.js +33 -11
  14. package/dist/components/calendar.js.map +1 -1
  15. package/dist/components/callout.js +2 -2
  16. package/dist/components/callout.js.map +1 -1
  17. package/dist/components/combobox.js +1 -1
  18. package/dist/components/combobox.js.map +1 -1
  19. package/dist/components/date-picker.d.ts +11 -5
  20. package/dist/components/date-picker.d.ts.map +1 -1
  21. package/dist/components/date-picker.js +29 -7
  22. package/dist/components/date-picker.js.map +1 -1
  23. package/dist/components/description-list.d.ts +26 -0
  24. package/dist/components/description-list.d.ts.map +1 -0
  25. package/dist/components/description-list.js +59 -0
  26. package/dist/components/description-list.js.map +1 -0
  27. package/dist/components/locale.d.ts +19 -0
  28. package/dist/components/locale.d.ts.map +1 -0
  29. package/dist/components/locale.js +54 -0
  30. package/dist/components/locale.js.map +1 -0
  31. package/dist/components/popover.d.ts +1 -1
  32. package/dist/components/popover.d.ts.map +1 -1
  33. package/dist/components/popover.js +2 -2
  34. package/dist/components/popover.js.map +1 -1
  35. package/dist/components/progress.d.ts +19 -6
  36. package/dist/components/progress.d.ts.map +1 -1
  37. package/dist/components/progress.js +18 -3
  38. package/dist/components/progress.js.map +1 -1
  39. package/dist/components/search-dialog.d.ts +6 -1
  40. package/dist/components/search-dialog.d.ts.map +1 -1
  41. package/dist/components/search-dialog.js +13 -1
  42. package/dist/components/search-dialog.js.map +1 -1
  43. package/dist/components/search-trigger.d.ts +4 -1
  44. package/dist/components/search-trigger.d.ts.map +1 -1
  45. package/dist/components/search-trigger.js +5 -2
  46. package/dist/components/search-trigger.js.map +1 -1
  47. package/dist/components/shimmer.d.ts +13 -0
  48. package/dist/components/shimmer.d.ts.map +1 -0
  49. package/dist/components/shimmer.js +21 -0
  50. package/dist/components/shimmer.js.map +1 -0
  51. package/dist/components/spinner.d.ts +28 -6
  52. package/dist/components/spinner.d.ts.map +1 -1
  53. package/dist/components/spinner.js +66 -14
  54. package/dist/components/spinner.js.map +1 -1
  55. package/dist/components/stepper.d.ts +64 -0
  56. package/dist/components/stepper.d.ts.map +1 -0
  57. package/dist/components/stepper.js +167 -0
  58. package/dist/components/stepper.js.map +1 -0
  59. package/dist/components/tag-input.d.ts +47 -0
  60. package/dist/components/tag-input.d.ts.map +1 -0
  61. package/dist/components/tag-input.js +211 -0
  62. package/dist/components/tag-input.js.map +1 -0
  63. package/dist/components/time-input.d.ts +75 -0
  64. package/dist/components/time-input.d.ts.map +1 -0
  65. package/dist/components/time-input.js +341 -0
  66. package/dist/components/time-input.js.map +1 -0
  67. package/dist/components/time-select.d.ts +49 -0
  68. package/dist/components/time-select.d.ts.map +1 -0
  69. package/dist/components/time-select.js +178 -0
  70. package/dist/components/time-select.js.map +1 -0
  71. package/dist/components/timeline.d.ts +41 -0
  72. package/dist/components/timeline.d.ts.map +1 -0
  73. package/dist/components/timeline.js +83 -0
  74. package/dist/components/timeline.js.map +1 -0
  75. package/dist/components/toast.d.ts.map +1 -1
  76. package/dist/components/toast.js +4 -2
  77. package/dist/components/toast.js.map +1 -1
  78. package/dist/components/tree.d.ts +87 -0
  79. package/dist/components/tree.d.ts.map +1 -0
  80. package/dist/components/tree.js +516 -0
  81. package/dist/components/tree.js.map +1 -0
  82. package/dist/docs/doc-kit.d.ts +72 -0
  83. package/dist/docs/doc-kit.d.ts.map +1 -0
  84. package/dist/docs/doc-kit.js +45 -0
  85. package/dist/docs/doc-kit.js.map +1 -0
  86. package/dist/docs/doc-page.d.ts +27 -0
  87. package/dist/docs/doc-page.d.ts.map +1 -0
  88. package/dist/docs/doc-page.js +33 -0
  89. package/dist/docs/doc-page.js.map +1 -0
  90. package/dist/docs/docs-index.d.ts +60 -0
  91. package/dist/docs/docs-index.d.ts.map +1 -0
  92. package/dist/docs/docs-index.js +45 -0
  93. package/dist/docs/docs-index.js.map +1 -0
  94. package/dist/docs/docs-nav.d.ts +13 -0
  95. package/dist/docs/docs-nav.d.ts.map +1 -0
  96. package/dist/docs/docs-nav.js +8 -0
  97. package/dist/docs/docs-nav.js.map +1 -0
  98. package/dist/docs/docs-search.d.ts +19 -0
  99. package/dist/docs/docs-search.d.ts.map +1 -0
  100. package/dist/docs/docs-search.js +33 -0
  101. package/dist/docs/docs-search.js.map +1 -0
  102. package/dist/docs/index.d.ts +7 -0
  103. package/dist/docs/index.d.ts.map +1 -0
  104. package/dist/docs/index.js +7 -0
  105. package/dist/docs/index.js.map +1 -0
  106. package/dist/docs/review-matrix.d.ts +25 -0
  107. package/dist/docs/review-matrix.d.ts.map +1 -0
  108. package/dist/docs/review-matrix.js +31 -0
  109. package/dist/docs/review-matrix.js.map +1 -0
  110. package/dist/hooks/use-theme-palette.d.ts +8 -0
  111. package/dist/hooks/use-theme-palette.d.ts.map +1 -0
  112. package/dist/hooks/use-theme-palette.js +24 -0
  113. package/dist/hooks/use-theme-palette.js.map +1 -0
  114. package/dist/hooks/use-time-format.d.ts +23 -0
  115. package/dist/hooks/use-time-format.d.ts.map +1 -0
  116. package/dist/hooks/use-time-format.js +52 -0
  117. package/dist/hooks/use-time-format.js.map +1 -0
  118. package/dist/lib/color.d.ts +5 -1
  119. package/dist/lib/color.d.ts.map +1 -1
  120. package/dist/lib/color.js +14 -2
  121. package/dist/lib/color.js.map +1 -1
  122. package/dist/lib/recipes.d.ts +5 -4
  123. package/dist/lib/recipes.d.ts.map +1 -1
  124. package/dist/lib/recipes.js +7 -5
  125. package/dist/lib/recipes.js.map +1 -1
  126. package/dist/provider/theme-provider.d.ts +10 -1
  127. package/dist/provider/theme-provider.d.ts.map +1 -1
  128. package/dist/provider/theme-provider.js +23 -8
  129. package/dist/provider/theme-provider.js.map +1 -1
  130. package/dist/site/brand.d.ts +20 -0
  131. package/dist/site/brand.d.ts.map +1 -0
  132. package/dist/site/brand.js +17 -0
  133. package/dist/site/brand.js.map +1 -0
  134. package/dist/site/github-icon.d.ts +4 -0
  135. package/dist/site/github-icon.d.ts.map +1 -0
  136. package/dist/site/github-icon.js +6 -0
  137. package/dist/site/github-icon.js.map +1 -0
  138. package/dist/site/index.d.ts +6 -0
  139. package/dist/site/index.d.ts.map +1 -0
  140. package/dist/site/index.js +6 -0
  141. package/dist/site/index.js.map +1 -0
  142. package/dist/site/suite-footer.d.ts +24 -0
  143. package/dist/site/suite-footer.d.ts.map +1 -0
  144. package/dist/site/suite-footer.js +19 -0
  145. package/dist/site/suite-footer.js.map +1 -0
  146. package/dist/site/suite-header.d.ts +40 -0
  147. package/dist/site/suite-header.d.ts.map +1 -0
  148. package/dist/site/suite-header.js +19 -0
  149. package/dist/site/suite-header.js.map +1 -0
  150. package/dist/site/use-page-title.d.ts +3 -0
  151. package/dist/site/use-page-title.d.ts.map +1 -0
  152. package/dist/site/use-page-title.js +8 -0
  153. package/dist/site/use-page-title.js.map +1 -0
  154. package/dist/vite/index.d.ts +65 -0
  155. package/dist/vite/index.d.ts.map +1 -0
  156. package/dist/vite/index.js +242 -0
  157. package/dist/vite/index.js.map +1 -0
  158. package/package.json +39 -3
  159. package/src/components/alert-dialog.tsx +4 -1
  160. package/src/components/alert.tsx +3 -3
  161. package/src/components/badge.tsx +1 -1
  162. package/src/components/button.tsx +1 -1
  163. package/src/components/calendar.tsx +48 -4
  164. package/src/components/callout.tsx +2 -2
  165. package/src/components/combobox.tsx +1 -1
  166. package/src/components/date-picker.tsx +43 -9
  167. package/src/components/description-list.tsx +107 -0
  168. package/src/components/locale.tsx +67 -0
  169. package/src/components/popover.tsx +3 -1
  170. package/src/components/progress.tsx +70 -4
  171. package/src/components/search-dialog.tsx +14 -1
  172. package/src/components/search-trigger.tsx +8 -2
  173. package/src/components/shimmer.tsx +36 -0
  174. package/src/components/spinner.tsx +126 -23
  175. package/src/components/stepper.tsx +346 -0
  176. package/src/components/tag-input.tsx +345 -0
  177. package/src/components/time-input.tsx +578 -0
  178. package/src/components/time-select.tsx +291 -0
  179. package/src/components/timeline.tsx +190 -0
  180. package/src/components/toast.tsx +4 -4
  181. package/src/components/tree.tsx +832 -0
  182. package/src/docs/doc-kit.tsx +176 -0
  183. package/src/docs/doc-page.tsx +126 -0
  184. package/src/docs/docs-index.ts +108 -0
  185. package/src/docs/docs-nav.tsx +47 -0
  186. package/src/docs/docs-search.tsx +88 -0
  187. package/src/docs/index.ts +6 -0
  188. package/src/docs/review-matrix.tsx +67 -0
  189. package/src/hooks/use-theme-palette.ts +25 -0
  190. package/src/hooks/use-time-format.ts +72 -0
  191. package/src/lib/color.ts +19 -2
  192. package/src/lib/recipes.ts +7 -5
  193. package/src/provider/theme-provider.tsx +34 -9
  194. package/src/site/brand.tsx +41 -0
  195. package/src/site/github-icon.tsx +10 -0
  196. package/src/site/index.ts +5 -0
  197. package/src/site/suite-footer.tsx +68 -0
  198. package/src/site/suite-header.tsx +114 -0
  199. package/src/site/use-page-title.ts +8 -0
  200. package/src/styles/kernelkit.css +58 -0
  201. package/src/styles/site.css +15 -0
  202. package/src/vite/client.d.ts +11 -0
  203. package/src/vite/index.ts +298 -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};