@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.
- package/dist/index.js +1773 -1739
- package/dist/kit/builder/data-table/types.d.ts +1 -1
- package/dist/kit/builder/data-table/types.d.ts.map +1 -1
- package/dist/kit/builder/form/components/FormBuilder.d.ts +3 -172
- package/dist/kit/builder/form/components/FormBuilder.d.ts.map +1 -1
- package/dist/kit/builder/form/components/FormBuilderContext.d.ts +18 -0
- package/dist/kit/builder/form/components/FormBuilderContext.d.ts.map +1 -0
- package/dist/kit/builder/form/components/FormBuilderField.d.ts +8 -8
- package/dist/kit/builder/form/components/FormBuilderField.d.ts.map +1 -1
- package/dist/kit/builder/form/components/fields/types.d.ts +3 -3
- package/dist/kit/builder/form/components/fields/types.d.ts.map +1 -1
- package/dist/kit/builder/form/components/sectionNodes.d.ts +17 -0
- package/dist/kit/builder/form/components/sectionNodes.d.ts.map +1 -0
- package/dist/kit/builder/form/index.d.ts +1 -0
- package/dist/kit/builder/form/index.d.ts.map +1 -1
- package/dist/kit/builder/form/types.d.ts +176 -0
- package/dist/kit/builder/form/types.d.ts.map +1 -0
- package/dist/kit/builder/form/utils/common-forms.d.ts +1 -1
- package/dist/kit/builder/form/utils/common-forms.d.ts.map +1 -1
- package/dist/kit/builder/form/utils/field-factories.d.ts +3 -3
- package/dist/kit/builder/form/utils/field-factories.d.ts.map +1 -1
- package/dist/kit/builder/form/utils/section-factories.d.ts +4 -4
- package/dist/kit/builder/form/utils/section-factories.d.ts.map +1 -1
- package/dist/kit/builder/stack-dialog/provider.d.ts.map +1 -1
- package/dist/kit/builder/stack-dialog/renderer.d.ts.map +1 -1
- package/dist/kit/components/autocomplete/Autocomplete.d.ts +8 -8
- package/dist/kit/components/autocomplete/Autocomplete.d.ts.map +1 -1
- package/dist/kit/components/autocomplete/types.d.ts +6 -4
- package/dist/kit/components/autocomplete/types.d.ts.map +1 -1
- package/dist/kit/themes/clean-slate.css +3 -3
- package/dist/kit/themes/default.css +4 -4
- package/dist/kit/themes/minimal-modern.css +3 -3
- package/dist/kit/themes/spotify.css +3 -3
- package/package.json +1 -1
- package/src/kit/builder/data-table/components/DataTable.tsx +1 -1
- package/src/kit/builder/data-table/types.ts +1 -1
- package/src/kit/builder/form/components/FormBuilder.tsx +113 -369
- package/src/kit/builder/form/components/FormBuilderContext.tsx +45 -0
- package/src/kit/builder/form/components/FormBuilderField.tsx +42 -34
- package/src/kit/builder/form/components/fields/AutocompleteField.tsx +2 -2
- package/src/kit/builder/form/components/fields/types.ts +3 -3
- package/src/kit/builder/form/components/sectionNodes.tsx +116 -0
- package/src/kit/builder/form/index.ts +1 -0
- package/src/kit/builder/form/types.ts +200 -0
- package/src/kit/builder/form/utils/common-forms.ts +1 -1
- package/src/kit/builder/form/utils/field-factories.ts +5 -5
- package/src/kit/builder/form/utils/section-factories.ts +10 -10
- package/src/kit/builder/stack-dialog/provider.tsx +2 -1
- package/src/kit/builder/stack-dialog/renderer.tsx +6 -7
- package/src/kit/components/autocomplete/Autocomplete.tsx +34 -26
- package/src/kit/components/autocomplete/types.ts +7 -5
- package/src/kit/themes/default.css +1 -1
- package/src/shadcn/ui/button.tsx +1 -1
- package/src/shadcn/ui/command.tsx +1 -1
- package/src/shadcn/ui/input.tsx +1 -1
- package/src/shadcn/ui/popover.tsx +1 -1
- package/src/shadcn/ui/select.tsx +1 -1
- package/src/shadcn/ui/textarea.tsx +1 -1
- 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.
|
|
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 '
|
|
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={
|
|
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 (
|
|
21
|
+
if (dialog.closeOnEscapePressed === false) e.preventDefault();
|
|
23
22
|
}}
|
|
24
23
|
onInteractOutside={e => {
|
|
25
|
-
if (
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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);
|
package/src/shadcn/ui/button.tsx
CHANGED
|
@@ -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-
|
|
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
|
package/src/shadcn/ui/input.tsx
CHANGED
|
@@ -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-
|
|
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}
|
package/src/shadcn/ui/select.tsx
CHANGED
|
@@ -37,7 +37,7 @@ function SelectTrigger({
|
|
|
37
37
|
data-slot="select-trigger"
|
|
38
38
|
data-size={size}
|
|
39
39
|
className={cn(
|
|
40
|
-
"border-
|
|
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-
|
|
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
|
+
}
|