@k3-universe/react-kit 0.0.13 → 0.0.15

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 (59) hide show
  1. package/dist/index.js +1773 -1739
  2. package/dist/kit/builder/data-table/types.d.ts +1 -1
  3. package/dist/kit/builder/data-table/types.d.ts.map +1 -1
  4. package/dist/kit/builder/form/components/FormBuilder.d.ts +3 -172
  5. package/dist/kit/builder/form/components/FormBuilder.d.ts.map +1 -1
  6. package/dist/kit/builder/form/components/FormBuilderContext.d.ts +18 -0
  7. package/dist/kit/builder/form/components/FormBuilderContext.d.ts.map +1 -0
  8. package/dist/kit/builder/form/components/FormBuilderField.d.ts +8 -8
  9. package/dist/kit/builder/form/components/FormBuilderField.d.ts.map +1 -1
  10. package/dist/kit/builder/form/components/fields/types.d.ts +3 -3
  11. package/dist/kit/builder/form/components/fields/types.d.ts.map +1 -1
  12. package/dist/kit/builder/form/components/sectionNodes.d.ts +17 -0
  13. package/dist/kit/builder/form/components/sectionNodes.d.ts.map +1 -0
  14. package/dist/kit/builder/form/index.d.ts +1 -0
  15. package/dist/kit/builder/form/index.d.ts.map +1 -1
  16. package/dist/kit/builder/form/types.d.ts +176 -0
  17. package/dist/kit/builder/form/types.d.ts.map +1 -0
  18. package/dist/kit/builder/form/utils/common-forms.d.ts +1 -1
  19. package/dist/kit/builder/form/utils/common-forms.d.ts.map +1 -1
  20. package/dist/kit/builder/form/utils/field-factories.d.ts +3 -3
  21. package/dist/kit/builder/form/utils/field-factories.d.ts.map +1 -1
  22. package/dist/kit/builder/form/utils/section-factories.d.ts +4 -4
  23. package/dist/kit/builder/form/utils/section-factories.d.ts.map +1 -1
  24. package/dist/kit/builder/stack-dialog/provider.d.ts.map +1 -1
  25. package/dist/kit/builder/stack-dialog/renderer.d.ts.map +1 -1
  26. package/dist/kit/components/autocomplete/Autocomplete.d.ts +8 -8
  27. package/dist/kit/components/autocomplete/Autocomplete.d.ts.map +1 -1
  28. package/dist/kit/components/autocomplete/types.d.ts +6 -4
  29. package/dist/kit/components/autocomplete/types.d.ts.map +1 -1
  30. package/dist/kit/themes/clean-slate.css +3 -3
  31. package/dist/kit/themes/default.css +4 -4
  32. package/dist/kit/themes/minimal-modern.css +3 -3
  33. package/dist/kit/themes/spotify.css +3 -3
  34. package/package.json +1 -1
  35. package/src/kit/builder/data-table/components/DataTable.tsx +1 -1
  36. package/src/kit/builder/data-table/types.ts +1 -1
  37. package/src/kit/builder/form/components/FormBuilder.tsx +113 -369
  38. package/src/kit/builder/form/components/FormBuilderContext.tsx +45 -0
  39. package/src/kit/builder/form/components/FormBuilderField.tsx +42 -34
  40. package/src/kit/builder/form/components/fields/AutocompleteField.tsx +2 -2
  41. package/src/kit/builder/form/components/fields/types.ts +3 -3
  42. package/src/kit/builder/form/components/sectionNodes.tsx +116 -0
  43. package/src/kit/builder/form/index.ts +1 -0
  44. package/src/kit/builder/form/types.ts +200 -0
  45. package/src/kit/builder/form/utils/common-forms.ts +1 -1
  46. package/src/kit/builder/form/utils/field-factories.ts +5 -5
  47. package/src/kit/builder/form/utils/section-factories.ts +10 -10
  48. package/src/kit/builder/stack-dialog/provider.tsx +2 -1
  49. package/src/kit/builder/stack-dialog/renderer.tsx +6 -7
  50. package/src/kit/components/autocomplete/Autocomplete.tsx +34 -26
  51. package/src/kit/components/autocomplete/types.ts +7 -5
  52. package/src/kit/themes/default.css +1 -1
  53. package/src/shadcn/ui/button.tsx +1 -1
  54. package/src/shadcn/ui/command.tsx +1 -1
  55. package/src/shadcn/ui/input.tsx +1 -1
  56. package/src/shadcn/ui/popover.tsx +1 -1
  57. package/src/shadcn/ui/select.tsx +1 -1
  58. package/src/shadcn/ui/textarea.tsx +1 -1
  59. package/src/stories/kit/builder/Form.MultipleFormBuilder.stories.tsx +335 -0
@@ -27,7 +27,8 @@ export function StackDialogContextProvider(props: PropsWithChildren) {
27
27
  const handleCloseDialog = useCallback((id?: string) => {
28
28
  setActiveDialogs(prev => {
29
29
  const clone = [...prev];
30
- clone.splice(clone.findIndex(d => d.id === (id || '')));
30
+ if (!id) clone.splice(clone.length - 1, 1);
31
+ else clone.splice(clone.findIndex((d) => (d.id === id)));
31
32
  return clone;
32
33
  });
33
34
  }, [setActiveDialogs]);
@@ -1,5 +1,4 @@
1
- import { DialogContent } from '@radix-ui/react-dialog';
2
- import { Dialog } from '../../../shadcn/ui/dialog';
1
+ import { Dialog, DialogContent } from '../../../shadcn/ui/dialog';
3
2
  import { StackDialogInstance } from './types';
4
3
 
5
4
  export function StackDialogRenderer(props: {
@@ -10,19 +9,19 @@ export function StackDialogRenderer(props: {
10
9
  props.closeDialog(id)
11
10
  }
12
11
 
13
- return props.dialogs.map((dialog) => (
12
+ return props.dialogs.map((dialog, i) => (
14
13
  (
15
14
  <Dialog
16
- open
17
- onOpenChange={(open) => (!open && handleCloseDialog(dialog.id))}
15
+ open={!!props.dialogs[i]}
16
+ onOpenChange={handleCloseDialog(dialog.id)}
18
17
  key={dialog.id}
19
18
  >
20
19
  <DialogContent
21
20
  onEscapeKeyDown={e => {
22
- if (!dialog.closeOnEscapePressed) e.preventDefault();
21
+ if (dialog.closeOnEscapePressed === false) e.preventDefault();
23
22
  }}
24
23
  onInteractOutside={e => {
25
- if (!dialog.closeOnInteractOutside) e.preventDefault();
24
+ if (dialog.closeOnInteractOutside === false) e.preventDefault();
26
25
  }}
27
26
  >
28
27
  {dialog.template}
@@ -32,10 +32,10 @@ import type {
32
32
  } from "./types";
33
33
  import { useDebounce } from "use-debounce";
34
34
 
35
- export type AutocompleteProps = {
35
+ export type AutocompleteProps<T = unknown> = {
36
36
  mode: AutocompleteMode;
37
- options?: AutocompleteOption[];
38
- fetcher?: AutocompleteFetcher;
37
+ options?: AutocompleteOption<T>[];
38
+ fetcher?: AutocompleteFetcher<T>;
39
39
  pageSize?: number;
40
40
  /**
41
41
  * Value can be a single primitive or an array when `multiple` is true
@@ -46,7 +46,8 @@ export type AutocompleteProps = {
46
46
  */
47
47
  onChange?: (
48
48
  value: string | number | null | Array<string | number>,
49
- option: AutocompleteOption | AutocompleteOption[] | null,
49
+ option: AutocompleteOption<T> | AutocompleteOption<T>[] | null,
50
+ raw?: T | T[] | null,
50
51
  ) => void;
51
52
  /** Enable selecting multiple values (shows chips) */
52
53
  multiple?: boolean;
@@ -56,7 +57,7 @@ export type AutocompleteProps = {
56
57
  className?: string;
57
58
  emptyText?: string;
58
59
  renderOption?: (
59
- option: AutocompleteOption,
60
+ option: AutocompleteOption<T>,
60
61
  selected: boolean,
61
62
  ) => React.ReactNode;
62
63
  searchPlaceholder?: string;
@@ -79,21 +80,21 @@ export type AutocompleteProps = {
79
80
  * Optional: seed selected labels for edit pages. These options are only used to populate the
80
81
  * internal label map so labels render correctly when values are prefilled.
81
82
  */
82
- initialSelectedOptions?: AutocompleteOption | AutocompleteOption[] | null;
83
+ initialSelectedOptions?: AutocompleteOption<T> | AutocompleteOption<T>[] | null;
83
84
  /**
84
85
  * Optional: load labels/options for a list of values whose labels are unknown.
85
86
  * Useful for edit pages in server mode when only values are available.
86
87
  */
87
- loadSelected?: (values: Array<string | number>) => Promise<AutocompleteOption[]>;
88
+ loadSelected?: (values: Array<string | number>) => Promise<AutocompleteOption<T>[]>;
88
89
  };
89
90
 
90
91
  const DEFAULT_PAGE_SIZE = 20;
91
92
 
92
93
  const EMPTY_OPTIONS: AutocompleteOption[] = [];
93
94
 
94
- export function Autocomplete({
95
+ export function Autocomplete<T = unknown>({
95
96
  mode,
96
- options = EMPTY_OPTIONS,
97
+ options = EMPTY_OPTIONS as AutocompleteOption<T>[],
97
98
  fetcher,
98
99
  pageSize = DEFAULT_PAGE_SIZE,
99
100
  value: controlledValue,
@@ -113,7 +114,7 @@ export function Autocomplete({
113
114
  clearable = true,
114
115
  initialSelectedOptions,
115
116
  loadSelected,
116
- }: AutocompleteProps) {
117
+ }: AutocompleteProps<T>) {
117
118
  const [open, setOpen] = useState<boolean>(!!defaultOpen);
118
119
  const [search, setSearch] = useState("");
119
120
  const [debouncedSearch] = useDebounce(search, 250);
@@ -135,8 +136,12 @@ export function Autocomplete({
135
136
  // Keep a map of value -> label to ensure we can render chips/labels even if the option
136
137
  // is not present in the current page (especially in server mode or for custom values)
137
138
  const labelMapRef = useRef<Map<string | number, string>>(new Map());
138
- const addToLabelMap = useCallback((opt: AutocompleteOption) => {
139
+ const rawMapRef = useRef<Map<string | number, T>>(new Map());
140
+ const addToLabelMap = useCallback((opt: AutocompleteOption<T>) => {
139
141
  labelMapRef.current.set(opt.value, opt.label);
142
+ if (Object.prototype.hasOwnProperty.call(opt, "raw") && (opt as AutocompleteOption<T>).raw !== undefined) {
143
+ rawMapRef.current.set(opt.value, (opt as AutocompleteOption<T>).raw as T);
144
+ }
140
145
  }, []);
141
146
  // Tick to force re-render when labels hydrate via async
142
147
  const [labelTick, setLabelTick] = useState(0);
@@ -149,7 +154,7 @@ export function Autocomplete({
149
154
  );
150
155
 
151
156
  const handleSelect = useCallback(
152
- (next: AutocompleteOption) => {
157
+ (next: AutocompleteOption<T>) => {
153
158
  addToLabelMap(next);
154
159
  if (isMultiple) {
155
160
  const prevValues = Array.isArray(value) ? value : [];
@@ -159,16 +164,18 @@ export function Autocomplete({
159
164
  : [...prevValues, next.value];
160
165
 
161
166
  if (controlledValue === undefined) setValue(newValues);
162
- const selectedOptions: AutocompleteOption[] = newValues.map((v) => ({
167
+ const selectedOptions: AutocompleteOption<T>[] = newValues.map((v) => ({
163
168
  value: v,
164
169
  label: getLabel(v),
170
+ raw: rawMapRef.current.get(v),
165
171
  }));
166
- onChange?.(newValues, selectedOptions);
172
+ const raws = selectedOptions.map((o) => o.raw as T);
173
+ onChange?.(newValues, selectedOptions, raws);
167
174
  // Keep open for multi-select
168
175
  } else {
169
176
  const newValue = next.value;
170
177
  if (controlledValue === undefined) setValue(newValue);
171
- onChange?.(newValue, next);
178
+ onChange?.(newValue, next, (next as AutocompleteOption<T>).raw as T | undefined ?? null);
172
179
  setOpen(false);
173
180
  }
174
181
  },
@@ -176,7 +183,7 @@ export function Autocomplete({
176
183
  );
177
184
 
178
185
  // Data state (shared for both modes)
179
- const [items, setItems] = useState<AutocompleteOption[]>([]);
186
+ const [items, setItems] = useState<AutocompleteOption<T>[]>([]);
180
187
  const [loading, setLoading] = useState(false);
181
188
  const [hasMore, setHasMore] = useState(false);
182
189
  const [nextCursor, setNextCursor] = useState<
@@ -197,7 +204,7 @@ export function Autocomplete({
197
204
  if (!fetcher) return;
198
205
  setLoading(true);
199
206
  try {
200
- const res: AutocompleteFetchResult = await fetcher({
207
+ const res: AutocompleteFetchResult<T> = await fetcher({
201
208
  search: debouncedSearch,
202
209
  cursor: nextCursor ?? null,
203
210
  page,
@@ -334,24 +341,23 @@ export function Autocomplete({
334
341
  return placeholder;
335
342
  }, [isMultiple, mode, options, selectedOption, value, placeholder, labelTick]);
336
343
 
337
-
338
344
  const selectedValues: Array<string | number> = useMemo(
339
345
  () => (isMultiple && Array.isArray(value) ? value : []),
340
346
  [isMultiple, value],
341
347
  );
342
348
  // biome-ignore lint/correctness/useExhaustiveDependencies: labelTick intentionally triggers recompute when labelMap hydrates
343
- const selectedOptionsMulti: AutocompleteOption[] = useMemo(
344
- () => selectedValues.map((v) => ({ value: v, label: getLabel(v) })),
349
+ const selectedOptionsMulti: AutocompleteOption<T>[] = useMemo(
350
+ () => selectedValues.map((v) => ({ value: v, label: getLabel(v), raw: rawMapRef.current.get(v) })),
345
351
  [getLabel, selectedValues, labelTick],
346
352
  );
347
353
 
348
354
  const handleClear = useCallback(() => {
349
355
  if (isMultiple) {
350
356
  if (controlledValue === undefined) setValue([]);
351
- onChange?.([], []);
357
+ onChange?.([], [], []);
352
358
  } else {
353
359
  if (controlledValue === undefined) setValue(null);
354
- onChange?.(null, null);
360
+ onChange?.(null, null, null);
355
361
  }
356
362
  }, [controlledValue, isMultiple, onChange]);
357
363
 
@@ -360,7 +366,7 @@ export function Autocomplete({
360
366
  (text: string) => {
361
367
  const t = text.trim();
362
368
  if (!t) return;
363
- const created: AutocompleteOption = { value: t, label: t };
369
+ const created: AutocompleteOption<T> = { value: t, label: t };
364
370
  addToLabelMap(created);
365
371
  if (isMultiple) {
366
372
  const prevValues = Array.isArray(value) ? value : [];
@@ -370,12 +376,14 @@ export function Autocomplete({
370
376
  const newOptions = newValues.map((v) => ({
371
377
  value: v,
372
378
  label: getLabel(v),
379
+ raw: rawMapRef.current.get(v),
373
380
  }));
374
- onChange?.(newValues, newOptions);
381
+ const raws = newOptions.map((o) => o.raw as T);
382
+ onChange?.(newValues, newOptions, raws);
375
383
  setSearch("");
376
384
  } else {
377
385
  if (controlledValue === undefined) setValue(created.value);
378
- onChange?.(created.value, created);
386
+ onChange?.(created.value, created, (created.raw as T | undefined) ?? null);
379
387
  setSearch("");
380
388
  setOpen(false);
381
389
  }
@@ -411,7 +419,7 @@ export function Autocomplete({
411
419
  tabIndex={disabled ? -1 : 0}
412
420
  aria-disabled={disabled || undefined}
413
421
  className={cn(
414
- "w-full inline-flex items-center justify-between rounded-md border bg-background px-3 py-2 text-sm shadow-sm transition-colors",
422
+ "w-full inline-flex items-center justify-between rounded-md border border-border bg-background px-3 py-2 text-sm shadow-sm transition-colors",
415
423
  "hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
416
424
  disabled && "opacity-50 pointer-events-none",
417
425
  className,
@@ -1,6 +1,8 @@
1
- export type AutocompleteOption = {
1
+ export type AutocompleteOption<T = unknown> = {
2
2
  value: string | number
3
3
  label: string
4
+ /** Optional original object returned by the fetcher/static source */
5
+ raw?: T
4
6
  }
5
7
 
6
8
  export type AutocompleteFetchParams = {
@@ -10,15 +12,15 @@ export type AutocompleteFetchParams = {
10
12
  pageSize: number
11
13
  }
12
14
 
13
- export type AutocompleteFetchResult = {
14
- items: AutocompleteOption[]
15
+ export type AutocompleteFetchResult<T = unknown> = {
16
+ items: AutocompleteOption<T>[]
15
17
  nextCursor?: string | number | null
16
18
  hasMore: boolean
17
19
  total?: number
18
20
  }
19
21
 
20
- export type AutocompleteFetcher = (
22
+ export type AutocompleteFetcher<T = unknown> = (
21
23
  params: AutocompleteFetchParams,
22
- ) => Promise<AutocompleteFetchResult>
24
+ ) => Promise<AutocompleteFetchResult<T>>
23
25
 
24
26
  export type AutocompleteMode = 'client' | 'server'
@@ -19,7 +19,7 @@
19
19
  --destructive: oklch(0.6368 0.2078 25.3313);
20
20
  --destructive-foreground: oklch(1.0000 0 0);
21
21
  --border: oklch(0.9197 0.0040 286.3202);
22
- --input: oklch(0.9197 0.0040 286.3202);
22
+ --input: oklch(1.0000 0 0);
23
23
  --ring: oklch(0.5234 0.1347 144.1672);
24
24
  --chart-1: oklch(0.5234 0.1347 144.1672);
25
25
  --chart-2: oklch(0.6731 0.1624 144.2083);
@@ -14,7 +14,7 @@ const buttonVariants = cva(
14
14
  destructive:
15
15
  "bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
16
16
  outline:
17
- "border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
17
+ "border border-border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-border dark:hover:bg-input/50",
18
18
  secondary:
19
19
  "bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
20
20
  ghost:
@@ -67,7 +67,7 @@ function CommandInput({
67
67
  return (
68
68
  <div
69
69
  data-slot="command-input-wrapper"
70
- className="flex h-9 items-center gap-2 border-b px-3"
70
+ className="flex h-9 items-center gap-2 border-b border-border px-3"
71
71
  >
72
72
  <SearchIcon className="size-4 shrink-0 opacity-50" />
73
73
  <CommandPrimitive.Input
@@ -8,7 +8,7 @@ function Input({ className, type, ...props }: React.ComponentProps<"input">) {
8
8
  type={type}
9
9
  data-slot="input"
10
10
  className={cn(
11
- "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
11
+ "file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-border flex h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
12
12
  "focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
13
13
  "aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
14
14
  className
@@ -30,7 +30,7 @@ function PopoverContent({
30
30
  align={align}
31
31
  sideOffset={sideOffset}
32
32
  className={cn(
33
- "bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
33
+ "border-border bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
34
34
  className
35
35
  )}
36
36
  {...props}
@@ -37,7 +37,7 @@ function SelectTrigger({
37
37
  data-slot="select-trigger"
38
38
  data-size={size}
39
39
  className={cn(
40
- "border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
40
+ "border-border data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
41
41
  className
42
42
  )}
43
43
  {...props}
@@ -7,7 +7,7 @@ function Textarea({ className, ...props }: React.ComponentProps<"textarea">) {
7
7
  <textarea
8
8
  data-slot="textarea"
9
9
  className={cn(
10
- "border-input placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
10
+ "border-border placeholder:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 flex field-sizing-content min-h-16 w-full rounded-md border bg-transparent px-3 py-2 text-base shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
11
11
  className
12
12
  )}
13
13
  {...props}
@@ -0,0 +1,335 @@
1
+ import { useMemo } from 'react'
2
+ import type { Meta, StoryObj } from '@storybook/react'
3
+ import { useForm } from 'react-hook-form'
4
+ import { FormBuilder } from '../../../index'
5
+ import type { FormBuilderSectionConfig } from '../../../kit/builder/form/types'
6
+
7
+ interface SplitFormValues {
8
+ firstName: string
9
+ lastName: string
10
+ email: string
11
+ accountType: 'individual' | 'business'
12
+ companyName?: string
13
+ address: {
14
+ street: string
15
+ city: string
16
+ zip: string
17
+ }
18
+ marketingOptIn: boolean
19
+ preferredContactMethod: 'email' | 'phone'
20
+ phoneNumber?: string
21
+ newsletterTopics: string[]
22
+ complianceContactEmail?: string
23
+ eventsWebhookUrl?: string
24
+ smsOptIn: boolean
25
+ smsFrequency?: 'daily' | 'weekly' | 'monthly'
26
+ timezone: string
27
+ }
28
+
29
+ const generalInfoSections: FormBuilderSectionConfig<SplitFormValues>[] = [
30
+ {
31
+ title: 'Profile',
32
+ layout: 'grid',
33
+ grid: { cols: 2, gap: 'gap-4' },
34
+ fields: [
35
+ {
36
+ name: 'firstName',
37
+ label: 'First name',
38
+ type: 'text',
39
+ required: true,
40
+ },
41
+ {
42
+ name: 'lastName',
43
+ label: 'Last name',
44
+ type: 'text',
45
+ required: true,
46
+ },
47
+ {
48
+ name: 'email',
49
+ label: 'Email',
50
+ type: 'email',
51
+ required: true,
52
+ gridCols: 2,
53
+ },
54
+ {
55
+ name: 'accountType',
56
+ label: 'Account type',
57
+ type: 'radio',
58
+ options: [
59
+ { label: 'Individual', value: 'individual' },
60
+ { label: 'Business', value: 'business' },
61
+ ],
62
+ defaultValue: 'individual',
63
+ gridCols: 2,
64
+ description: 'Switch to Business to reveal additional company details.',
65
+ },
66
+ {
67
+ name: 'companyName',
68
+ label: 'Company name',
69
+ type: 'text',
70
+ placeholder: 'Acme Inc.',
71
+ dependencies: [
72
+ {
73
+ field: 'accountType',
74
+ condition: (value) => value === 'business',
75
+ action: 'show',
76
+ },
77
+ ],
78
+ gridCols: 2,
79
+ },
80
+ ],
81
+ },
82
+ {
83
+ title: 'Address',
84
+ layout: 'grid',
85
+ grid: { cols: 3, gap: 'gap-4' },
86
+ fields: [
87
+ {
88
+ name: 'address.street',
89
+ label: 'Street',
90
+ type: 'text',
91
+ required: true,
92
+ gridCols: 3,
93
+ },
94
+ {
95
+ name: 'address.city',
96
+ label: 'City',
97
+ type: 'text',
98
+ required: true,
99
+ gridCols: 2,
100
+ },
101
+ {
102
+ name: 'address.zip',
103
+ label: 'ZIP code',
104
+ type: 'text',
105
+ required: true,
106
+ },
107
+ ],
108
+ },
109
+ ]
110
+
111
+ const preferencesSections: FormBuilderSectionConfig<SplitFormValues>[] = [
112
+ {
113
+ title: 'Preferences',
114
+ layout: 'grid',
115
+ grid: { cols: 2, gap: 'gap-4' },
116
+ fields: [
117
+ {
118
+ name: 'marketingOptIn',
119
+ label: 'Marketing emails',
120
+ type: 'switch',
121
+ defaultValue: true,
122
+ description: 'Receive occasional product updates and tips.',
123
+ },
124
+ {
125
+ name: 'newsletterTopics',
126
+ label: 'Topics of interest',
127
+ type: 'select',
128
+ options: [
129
+ { label: 'Product releases', value: 'product' },
130
+ { label: 'Best practices', value: 'best-practices' },
131
+ { label: 'Events', value: 'events' },
132
+ { label: 'Integrations', value: 'integrations' },
133
+ ],
134
+ multiple: true,
135
+ placeholder: 'Choose one or more topics',
136
+ dependencies: [
137
+ {
138
+ field: 'marketingOptIn',
139
+ condition: (value) => Boolean(value),
140
+ action: 'show',
141
+ },
142
+ {
143
+ field: 'marketingOptIn',
144
+ condition: (value) => !value,
145
+ action: 'setValue',
146
+ value: [],
147
+ },
148
+ ],
149
+ },
150
+ {
151
+ name: 'eventsWebhookUrl',
152
+ label: 'Events webhook URL',
153
+ type: 'text',
154
+ placeholder: 'https://example.com/webhooks/events',
155
+ description: 'Provide a webhook to receive notifications about upcoming events.',
156
+ dependencies: [
157
+ {
158
+ field: 'newsletterTopics',
159
+ condition: (value) => Array.isArray(value) && value.includes('events'),
160
+ action: 'show',
161
+ },
162
+ ],
163
+ },
164
+ {
165
+ name: 'preferredContactMethod',
166
+ label: 'Preferred contact method',
167
+ type: 'radio',
168
+ options: [
169
+ { label: 'Email', value: 'email' },
170
+ { label: 'Phone', value: 'phone' },
171
+ ],
172
+ defaultValue: 'email',
173
+ },
174
+ {
175
+ name: 'phoneNumber',
176
+ label: 'Phone number',
177
+ type: 'text',
178
+ placeholder: '(555) 123-4567',
179
+ dependencies: [
180
+ {
181
+ field: 'preferredContactMethod',
182
+ condition: (value) => value === 'phone',
183
+ action: 'show',
184
+ },
185
+ ],
186
+ },
187
+ {
188
+ name: 'smsOptIn',
189
+ label: 'SMS updates',
190
+ type: 'switch',
191
+ defaultValue: false,
192
+ description: 'Get real-time notifications via SMS.',
193
+ dependencies: [
194
+ {
195
+ field: 'preferredContactMethod',
196
+ condition: (value) => value === 'phone',
197
+ action: 'show',
198
+ },
199
+ ],
200
+ },
201
+ {
202
+ name: 'smsFrequency',
203
+ label: 'SMS frequency',
204
+ type: 'select',
205
+ options: [
206
+ { label: 'Daily recap', value: 'daily' },
207
+ { label: 'Weekly summary', value: 'weekly' },
208
+ { label: 'Monthly digest', value: 'monthly' },
209
+ ],
210
+ placeholder: 'Choose how often we should text you',
211
+ dependencies: [
212
+ {
213
+ field: 'smsOptIn',
214
+ condition: (value) => Boolean(value),
215
+ action: 'show',
216
+ },
217
+ ],
218
+ },
219
+ {
220
+ name: 'complianceContactEmail',
221
+ label: 'Compliance contact email',
222
+ type: 'email',
223
+ placeholder: 'compliance@company.com',
224
+ description: 'Required for business accounts to receive compliance updates.',
225
+ dependencies: [
226
+ {
227
+ field: 'accountType',
228
+ condition: (value) => value === 'business',
229
+ action: 'show',
230
+ },
231
+ ],
232
+ gridCols: 2,
233
+ },
234
+ {
235
+ name: 'timezone',
236
+ label: 'Timezone',
237
+ type: 'select',
238
+ options: [
239
+ { label: 'UTC−08:00 Pacific', value: 'America/Los_Angeles' },
240
+ { label: 'UTC−05:00 Eastern', value: 'America/New_York' },
241
+ { label: 'UTC+00:00 London', value: 'Europe/London' },
242
+ { label: 'UTC+07:00 Jakarta', value: 'Asia/Jakarta' },
243
+ ],
244
+ required: true,
245
+ },
246
+ ],
247
+ },
248
+ ]
249
+
250
+ const SplitFormExample = () => {
251
+ const form = useForm<SplitFormValues>({
252
+ defaultValues: {
253
+ firstName: 'Jane',
254
+ lastName: 'Doe',
255
+ email: 'jane.doe@example.com',
256
+ accountType: 'individual',
257
+ companyName: '',
258
+ address: {
259
+ street: '123 Storybook Way',
260
+ city: 'Componentville',
261
+ zip: '90210',
262
+ },
263
+ marketingOptIn: true,
264
+ newsletterTopics: ['product'],
265
+ eventsWebhookUrl: '',
266
+ preferredContactMethod: 'email',
267
+ phoneNumber: '',
268
+ smsOptIn: false,
269
+ smsFrequency: undefined,
270
+ complianceContactEmail: '',
271
+ timezone: 'Asia/Jakarta',
272
+ },
273
+ mode: 'onSubmit',
274
+ })
275
+
276
+ const logSubmit = useMemo(
277
+ () =>
278
+ form.handleSubmit((values) => {
279
+ console.log('Story submit', values)
280
+ }),
281
+ [form],
282
+ )
283
+
284
+ return (
285
+ <div className="space-y-6">
286
+ <FormBuilder
287
+ form={form}
288
+ sections={generalInfoSections}
289
+ onSubmit={async () => {
290
+ /* handled by explicit button */
291
+ }}
292
+ showActions={false}
293
+ />
294
+
295
+ <FormBuilder
296
+ form={form}
297
+ sections={preferencesSections}
298
+ onSubmit={async () => {
299
+ /* handled by explicit button */
300
+ }}
301
+ customActions={(
302
+ <button
303
+ type="button"
304
+ className="px-4 py-2 text-sm font-medium text-white bg-primary rounded-md"
305
+ onClick={logSubmit}
306
+ >
307
+ Save all sections
308
+ </button>
309
+ )}
310
+ />
311
+ </div>
312
+ )
313
+ }
314
+
315
+ const meta: Meta<typeof SplitFormExample> = {
316
+ title: 'Kit/Builder/Form',
317
+ component: SplitFormExample,
318
+ parameters: {
319
+ docs: {
320
+ description: {
321
+ component:
322
+ 'Demonstrates sharing a single `react-hook-form` instance across multiple `FormBuilder` blocks using the new `form` prop.',
323
+ },
324
+ },
325
+ },
326
+ }
327
+
328
+ export default meta
329
+
330
+ type Story = StoryObj<typeof SplitFormExample>
331
+
332
+ export const SharedFormInstance: Story = {
333
+ name: 'Shared form instance across sections',
334
+ render: () => <SplitFormExample />,
335
+ }