@timeax/form-palette 0.0.1
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/.scaffold-cache.json +537 -0
- package/package.json +42 -0
- package/src/.scaffold-cache.json +544 -0
- package/src/adapters/axios.ts +117 -0
- package/src/adapters/index.ts +91 -0
- package/src/adapters/inertia.ts +187 -0
- package/src/core/adapter-registry.ts +87 -0
- package/src/core/bound/bind-host.ts +14 -0
- package/src/core/bound/observe-bound-field.ts +172 -0
- package/src/core/bound/wait-for-bound-field.ts +57 -0
- package/src/core/context.ts +23 -0
- package/src/core/core-provider.tsx +818 -0
- package/src/core/core-root.tsx +72 -0
- package/src/core/core-shell.tsx +44 -0
- package/src/core/errors/error-strip.tsx +71 -0
- package/src/core/errors/index.ts +2 -0
- package/src/core/errors/map-error-bag.ts +51 -0
- package/src/core/errors/map-zod.ts +39 -0
- package/src/core/hooks/use-button.ts +220 -0
- package/src/core/hooks/use-core-context.ts +20 -0
- package/src/core/hooks/use-core-utility.ts +0 -0
- package/src/core/hooks/use-core.ts +13 -0
- package/src/core/hooks/use-field.ts +497 -0
- package/src/core/hooks/use-optional-field.ts +28 -0
- package/src/core/index.ts +0 -0
- package/src/core/registry/binder-registry.ts +82 -0
- package/src/core/registry/field-registry.ts +187 -0
- package/src/core/test.tsx +17 -0
- package/src/global.d.ts +14 -0
- package/src/index.ts +68 -0
- package/src/input/index.ts +4 -0
- package/src/input/input-field.tsx +854 -0
- package/src/input/input-layout-graph.ts +230 -0
- package/src/input/input-props.ts +190 -0
- package/src/lib/get-global-countries.ts +87 -0
- package/src/lib/utils.ts +6 -0
- package/src/presets/index.ts +0 -0
- package/src/presets/shadcn-preset.ts +0 -0
- package/src/presets/shadcn-variants/checkbox.tsx +849 -0
- package/src/presets/shadcn-variants/chips.tsx +756 -0
- package/src/presets/shadcn-variants/color.tsx +284 -0
- package/src/presets/shadcn-variants/custom.tsx +227 -0
- package/src/presets/shadcn-variants/date.tsx +796 -0
- package/src/presets/shadcn-variants/file.tsx +764 -0
- package/src/presets/shadcn-variants/keyvalue.tsx +556 -0
- package/src/presets/shadcn-variants/multiselect.tsx +1132 -0
- package/src/presets/shadcn-variants/number.tsx +176 -0
- package/src/presets/shadcn-variants/password.tsx +737 -0
- package/src/presets/shadcn-variants/phone.tsx +628 -0
- package/src/presets/shadcn-variants/radio.tsx +578 -0
- package/src/presets/shadcn-variants/select.tsx +956 -0
- package/src/presets/shadcn-variants/slider.tsx +622 -0
- package/src/presets/shadcn-variants/text.tsx +343 -0
- package/src/presets/shadcn-variants/textarea.tsx +66 -0
- package/src/presets/shadcn-variants/toggle.tsx +218 -0
- package/src/presets/shadcn-variants/treeselect.tsx +784 -0
- package/src/presets/ui/badge.tsx +46 -0
- package/src/presets/ui/button.tsx +60 -0
- package/src/presets/ui/calendar.tsx +214 -0
- package/src/presets/ui/checkbox.tsx +115 -0
- package/src/presets/ui/custom.tsx +0 -0
- package/src/presets/ui/dialog.tsx +141 -0
- package/src/presets/ui/field.tsx +246 -0
- package/src/presets/ui/input-mask.tsx +739 -0
- package/src/presets/ui/input-otp.tsx +77 -0
- package/src/presets/ui/input.tsx +1011 -0
- package/src/presets/ui/label.tsx +22 -0
- package/src/presets/ui/number.tsx +1370 -0
- package/src/presets/ui/popover.tsx +46 -0
- package/src/presets/ui/radio-group.tsx +43 -0
- package/src/presets/ui/scroll-area.tsx +56 -0
- package/src/presets/ui/select.tsx +190 -0
- package/src/presets/ui/separator.tsx +28 -0
- package/src/presets/ui/slider.tsx +61 -0
- package/src/presets/ui/switch.tsx +32 -0
- package/src/presets/ui/textarea.tsx +634 -0
- package/src/presets/ui/time-dropdowns.tsx +350 -0
- package/src/schema/adapter.ts +217 -0
- package/src/schema/core.ts +429 -0
- package/src/schema/field-map.ts +0 -0
- package/src/schema/field.ts +224 -0
- package/src/schema/index.ts +0 -0
- package/src/schema/input-field.ts +260 -0
- package/src/schema/presets.ts +0 -0
- package/src/schema/variant.ts +216 -0
- package/src/variants/core/checkbox.tsx +54 -0
- package/src/variants/core/chips.tsx +22 -0
- package/src/variants/core/color.tsx +16 -0
- package/src/variants/core/custom.tsx +18 -0
- package/src/variants/core/date.tsx +25 -0
- package/src/variants/core/file.tsx +9 -0
- package/src/variants/core/keyvalue.tsx +12 -0
- package/src/variants/core/multiselect.tsx +28 -0
- package/src/variants/core/number.tsx +115 -0
- package/src/variants/core/password.tsx +35 -0
- package/src/variants/core/phone.tsx +16 -0
- package/src/variants/core/radio.tsx +38 -0
- package/src/variants/core/select.tsx +15 -0
- package/src/variants/core/slider.tsx +55 -0
- package/src/variants/core/text.tsx +114 -0
- package/src/variants/core/textarea.tsx +22 -0
- package/src/variants/core/toggle.tsx +50 -0
- package/src/variants/core/treeselect.tsx +11 -0
- package/src/variants/helpers/selection-summary.tsx +236 -0
- package/src/variants/index.ts +75 -0
- package/src/variants/registry.ts +38 -0
- package/src/variants/select-shared.ts +0 -0
- package/src/variants/shared.ts +126 -0
- package/tsconfig.json +14 -0
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
// src/input/input-field.tsx
|
|
2
|
+
// noinspection JSUnusedLocalSymbols,SpellCheckingInspection,DuplicatedCode
|
|
3
|
+
|
|
4
|
+
import * as React from "react";
|
|
5
|
+
|
|
6
|
+
import { useField } from "@/core/hooks/use-field";
|
|
7
|
+
import type { InputFieldProps } from "@/input/input-props";
|
|
8
|
+
import type {
|
|
9
|
+
FieldLayoutConfig,
|
|
10
|
+
LayoutResolveContext,
|
|
11
|
+
SlotPlacement,
|
|
12
|
+
ValidateResult,
|
|
13
|
+
} from "@/schema/input-field";
|
|
14
|
+
import type { VariantKey, VariantValueFor } from "@/schema/variant";
|
|
15
|
+
import { getVariant } from "@/variants";
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
Field as UiField,
|
|
19
|
+
FieldContent,
|
|
20
|
+
FieldDescription,
|
|
21
|
+
FieldError,
|
|
22
|
+
FieldGroup,
|
|
23
|
+
FieldLabel,
|
|
24
|
+
FieldTitle,
|
|
25
|
+
} from "@/presets/ui/field";
|
|
26
|
+
import { ChangeDetail } from "@/variants/shared";
|
|
27
|
+
import {
|
|
28
|
+
buildLayoutGraph,
|
|
29
|
+
type HelperSlot,
|
|
30
|
+
} from "@/input/input-layout-graph";
|
|
31
|
+
import { cn } from "@/lib/utils";
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalise a ValidateResult into an array of error messages.
|
|
35
|
+
*/
|
|
36
|
+
function normalizeValidateResult(result: ValidateResult): string[] {
|
|
37
|
+
if (result === undefined || result === null || result === true) return [];
|
|
38
|
+
if (result === false) return ["Invalid value."];
|
|
39
|
+
if (typeof result === "string") return result ? [result] : [];
|
|
40
|
+
if (Array.isArray(result)) return result.filter(Boolean);
|
|
41
|
+
return [];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the layout for this field using:
|
|
46
|
+
* - variant defaults
|
|
47
|
+
* - host overrides
|
|
48
|
+
* - optional variant-level resolveLayout()
|
|
49
|
+
*/
|
|
50
|
+
function resolveLayoutForField(
|
|
51
|
+
defaults: FieldLayoutConfig | undefined,
|
|
52
|
+
overrides: Partial<FieldLayoutConfig>,
|
|
53
|
+
props: unknown,
|
|
54
|
+
variantResolve?: (ctx: LayoutResolveContext) => FieldLayoutConfig
|
|
55
|
+
): FieldLayoutConfig {
|
|
56
|
+
const base: FieldLayoutConfig = defaults ? { ...defaults } : {};
|
|
57
|
+
|
|
58
|
+
if (variantResolve) {
|
|
59
|
+
return variantResolve({
|
|
60
|
+
defaults: base,
|
|
61
|
+
overrides,
|
|
62
|
+
props,
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// Fallback: shallow merge defaults + overrides
|
|
67
|
+
return {
|
|
68
|
+
...base,
|
|
69
|
+
...overrides,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Render a single helper slot using the Shadcn field primitives.
|
|
75
|
+
*/
|
|
76
|
+
function renderHelperSlot(
|
|
77
|
+
root: "label" | "input",
|
|
78
|
+
slot: HelperSlot,
|
|
79
|
+
classes: any
|
|
80
|
+
): React.ReactNode {
|
|
81
|
+
const placement: SlotPlacement = slot.placement;
|
|
82
|
+
|
|
83
|
+
switch (slot.id) {
|
|
84
|
+
case "sublabel":
|
|
85
|
+
return (
|
|
86
|
+
<FieldDescription
|
|
87
|
+
key={`sublabel-${placement}-${root}`}
|
|
88
|
+
className={[
|
|
89
|
+
"text-xs text-muted-foreground",
|
|
90
|
+
classes?.sublabel,
|
|
91
|
+
]
|
|
92
|
+
.filter(Boolean)
|
|
93
|
+
.join(" ")}
|
|
94
|
+
data-slot={`sublabel-${placement}`}
|
|
95
|
+
>
|
|
96
|
+
{slot.content}
|
|
97
|
+
</FieldDescription>
|
|
98
|
+
);
|
|
99
|
+
|
|
100
|
+
case "description":
|
|
101
|
+
return (
|
|
102
|
+
<FieldDescription
|
|
103
|
+
key={`description-${placement}-${root}`}
|
|
104
|
+
className={[
|
|
105
|
+
"text-xs text-muted-foreground",
|
|
106
|
+
classes?.description,
|
|
107
|
+
]
|
|
108
|
+
.filter(Boolean)
|
|
109
|
+
.join(" ")}
|
|
110
|
+
data-slot={`description-${placement}`}
|
|
111
|
+
>
|
|
112
|
+
{slot.content}
|
|
113
|
+
</FieldDescription>
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
case "helpText":
|
|
117
|
+
return (
|
|
118
|
+
<FieldDescription
|
|
119
|
+
key={`helpText-${placement}-${root}`}
|
|
120
|
+
className={[
|
|
121
|
+
"text-xs text-muted-foreground",
|
|
122
|
+
classes?.helpText,
|
|
123
|
+
]
|
|
124
|
+
.filter(Boolean)
|
|
125
|
+
.join(" ")}
|
|
126
|
+
data-slot={`helptext-${placement}`}
|
|
127
|
+
>
|
|
128
|
+
{slot.content}
|
|
129
|
+
</FieldDescription>
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
case "errorText":
|
|
133
|
+
return (
|
|
134
|
+
<FieldError
|
|
135
|
+
key={`error-${placement}-${root}`}
|
|
136
|
+
className={[
|
|
137
|
+
"text-xs text-destructive",
|
|
138
|
+
classes?.error,
|
|
139
|
+
]
|
|
140
|
+
.filter(Boolean)
|
|
141
|
+
.join(" ")}
|
|
142
|
+
data-slot={`error-${placement}`}
|
|
143
|
+
>
|
|
144
|
+
{slot.content}
|
|
145
|
+
</FieldError>
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
case "tags":
|
|
149
|
+
return (
|
|
150
|
+
<div
|
|
151
|
+
key={`tags-${placement}-${root}`}
|
|
152
|
+
className={[
|
|
153
|
+
"flex items-center gap-1",
|
|
154
|
+
classes?.tags,
|
|
155
|
+
]
|
|
156
|
+
.filter(Boolean)
|
|
157
|
+
.join(" ")}
|
|
158
|
+
data-slot={`tags-${placement}`}
|
|
159
|
+
>
|
|
160
|
+
{slot.content}
|
|
161
|
+
</div>
|
|
162
|
+
);
|
|
163
|
+
|
|
164
|
+
default:
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Public InputField component.
|
|
171
|
+
*
|
|
172
|
+
* - Uses `useField` to register a Field and manage value/error/loading.
|
|
173
|
+
* - Delegates rendering to the chosen variant's `Variant` component.
|
|
174
|
+
* - Uses Shadcn's Field primitives for structure.
|
|
175
|
+
* - Lets variants influence layout via defaults + optional resolveLayout().
|
|
176
|
+
* - Uses a layout graph (buildLayoutGraph) + getSlotsFor().render(...) to
|
|
177
|
+
* position helpers (sublabel, description, helpText, error, tags) relative to
|
|
178
|
+
* "label" vs "input" roots without empty wrapper divs.
|
|
179
|
+
*/
|
|
180
|
+
export function InputField<K extends VariantKey = VariantKey>(
|
|
181
|
+
props: InputFieldProps<K>
|
|
182
|
+
) {
|
|
183
|
+
const {
|
|
184
|
+
variant,
|
|
185
|
+
|
|
186
|
+
// Field identity / wiring
|
|
187
|
+
name,
|
|
188
|
+
bind,
|
|
189
|
+
shared,
|
|
190
|
+
groupId,
|
|
191
|
+
alias,
|
|
192
|
+
main,
|
|
193
|
+
ignore,
|
|
194
|
+
required,
|
|
195
|
+
defaultValue,
|
|
196
|
+
|
|
197
|
+
// Chrome
|
|
198
|
+
label,
|
|
199
|
+
sublabel,
|
|
200
|
+
description,
|
|
201
|
+
helpText,
|
|
202
|
+
errorText,
|
|
203
|
+
|
|
204
|
+
// Container + tags
|
|
205
|
+
contain,
|
|
206
|
+
tags,
|
|
207
|
+
tagPlacement,
|
|
208
|
+
|
|
209
|
+
// Layout overrides
|
|
210
|
+
labelPlacement,
|
|
211
|
+
sublabelPlacement,
|
|
212
|
+
descriptionPlacement,
|
|
213
|
+
helpTextPlacement,
|
|
214
|
+
errorTextPlacement,
|
|
215
|
+
inline,
|
|
216
|
+
fullWidth,
|
|
217
|
+
size,
|
|
218
|
+
density,
|
|
219
|
+
|
|
220
|
+
// Validation hook
|
|
221
|
+
onValidate,
|
|
222
|
+
onChange,
|
|
223
|
+
|
|
224
|
+
// Field wrapper props
|
|
225
|
+
className,
|
|
226
|
+
style,
|
|
227
|
+
classes,
|
|
228
|
+
|
|
229
|
+
// Everything else → forwarded to variant
|
|
230
|
+
...rest
|
|
231
|
+
} = props as InputFieldProps & {
|
|
232
|
+
className?: string;
|
|
233
|
+
style?: React.CSSProperties;
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
const module = getVariant(variant);
|
|
237
|
+
|
|
238
|
+
if (!module) {
|
|
239
|
+
if (process.env.NODE_ENV !== "production") {
|
|
240
|
+
// eslint-disable-next-line no-console
|
|
241
|
+
console.warn(
|
|
242
|
+
`[form-palette] InputField: variant "${String(
|
|
243
|
+
variant
|
|
244
|
+
)}" is not registered.`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
type TValue = VariantValueFor<K>;
|
|
251
|
+
|
|
252
|
+
// Compute layout: defaults + host overrides + optional variant resolver
|
|
253
|
+
const layout = React.useMemo(() => {
|
|
254
|
+
const defaultsLayout = module.defaults?.layout;
|
|
255
|
+
const overrides: Partial<FieldLayoutConfig> = {};
|
|
256
|
+
|
|
257
|
+
if (labelPlacement !== undefined) {
|
|
258
|
+
overrides.labelPlacement = labelPlacement;
|
|
259
|
+
}
|
|
260
|
+
if (sublabelPlacement !== undefined) {
|
|
261
|
+
overrides.sublabelPlacement = sublabelPlacement;
|
|
262
|
+
}
|
|
263
|
+
if (descriptionPlacement !== undefined) {
|
|
264
|
+
overrides.descriptionPlacement = descriptionPlacement;
|
|
265
|
+
}
|
|
266
|
+
if (helpTextPlacement !== undefined) {
|
|
267
|
+
overrides.helpTextPlacement = helpTextPlacement;
|
|
268
|
+
}
|
|
269
|
+
if (errorTextPlacement !== undefined) {
|
|
270
|
+
overrides.errorTextPlacement = errorTextPlacement;
|
|
271
|
+
}
|
|
272
|
+
if (tagPlacement !== undefined) {
|
|
273
|
+
overrides.tagPlacement = tagPlacement;
|
|
274
|
+
}
|
|
275
|
+
if (inline !== undefined) {
|
|
276
|
+
overrides.inline = inline;
|
|
277
|
+
}
|
|
278
|
+
if (fullWidth !== undefined) {
|
|
279
|
+
overrides.fullWidth = fullWidth;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return resolveLayoutForField(
|
|
283
|
+
defaultsLayout,
|
|
284
|
+
overrides,
|
|
285
|
+
props,
|
|
286
|
+
module.resolveLayout as any
|
|
287
|
+
);
|
|
288
|
+
}, [
|
|
289
|
+
module,
|
|
290
|
+
labelPlacement,
|
|
291
|
+
sublabelPlacement,
|
|
292
|
+
descriptionPlacement,
|
|
293
|
+
helpTextPlacement,
|
|
294
|
+
errorTextPlacement,
|
|
295
|
+
tagPlacement,
|
|
296
|
+
inline,
|
|
297
|
+
fullWidth,
|
|
298
|
+
props,
|
|
299
|
+
]);
|
|
300
|
+
|
|
301
|
+
const effectiveSize =
|
|
302
|
+
size ?? module.defaults?.layout?.defaultSize ?? undefined;
|
|
303
|
+
const effectiveDensity =
|
|
304
|
+
density ?? module.defaults?.layout?.defaultDensity ?? undefined;
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Validation callback used by the field hook.
|
|
308
|
+
*
|
|
309
|
+
* It combines:
|
|
310
|
+
* - variant-level validation (module.validate)
|
|
311
|
+
* - per-field validation (props.onValidate)
|
|
312
|
+
*/
|
|
313
|
+
const validate = React.useCallback(
|
|
314
|
+
(value: TValue | undefined, _report: boolean): boolean | string => {
|
|
315
|
+
const messages: string[] = [];
|
|
316
|
+
|
|
317
|
+
if (module.validate) {
|
|
318
|
+
const res = module.validate(value, {
|
|
319
|
+
required: !!required,
|
|
320
|
+
props: props as any,
|
|
321
|
+
field: undefined as any,
|
|
322
|
+
form: undefined as any,
|
|
323
|
+
});
|
|
324
|
+
messages.push(...normalizeValidateResult(res));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (onValidate) {
|
|
328
|
+
const res = onValidate(
|
|
329
|
+
value as any,
|
|
330
|
+
undefined as any,
|
|
331
|
+
undefined as any
|
|
332
|
+
);
|
|
333
|
+
messages.push(...normalizeValidateResult(res));
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
if (!messages.length) return true;
|
|
337
|
+
return messages[0] ?? "Invalid value.";
|
|
338
|
+
},
|
|
339
|
+
[module, required, onValidate, props]
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
// Hook into the core: register field, track value/error/loading
|
|
343
|
+
const field = useField<TValue>({
|
|
344
|
+
name,
|
|
345
|
+
bind,
|
|
346
|
+
shared,
|
|
347
|
+
groupId,
|
|
348
|
+
alias,
|
|
349
|
+
main,
|
|
350
|
+
ignore,
|
|
351
|
+
required,
|
|
352
|
+
defaultValue: defaultValue as TValue | undefined,
|
|
353
|
+
validate,
|
|
354
|
+
} as any);
|
|
355
|
+
|
|
356
|
+
const { value, setValue, error, ref, key } = field;
|
|
357
|
+
|
|
358
|
+
const Variant = module.Variant as React.ComponentType<any>;
|
|
359
|
+
const visualError = (errorText ?? error) || "";
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Central change handler for this field.
|
|
363
|
+
*
|
|
364
|
+
* Flow:
|
|
365
|
+
* Variant.onValue(next, detail) →
|
|
366
|
+
* InputField.handleValueChange →
|
|
367
|
+
* props.onChange?.({ value, detail, event, preventDefault }) →
|
|
368
|
+
* (if not prevented) setValue(final)
|
|
369
|
+
*/
|
|
370
|
+
const handleValueChange = React.useCallback(
|
|
371
|
+
(next: TValue | undefined, detail?: ChangeDetail) => {
|
|
372
|
+
let finalValue = next;
|
|
373
|
+
let defaultPrevented = false;
|
|
374
|
+
|
|
375
|
+
if (onChange) {
|
|
376
|
+
const e = {
|
|
377
|
+
value: next,
|
|
378
|
+
preventDefault() {
|
|
379
|
+
defaultPrevented = true;
|
|
380
|
+
},
|
|
381
|
+
get isDefaultPrevented() {
|
|
382
|
+
return defaultPrevented;
|
|
383
|
+
},
|
|
384
|
+
event:
|
|
385
|
+
detail?.nativeEvent as
|
|
386
|
+
| React.SyntheticEvent
|
|
387
|
+
| undefined,
|
|
388
|
+
detail: detail as ChangeDetail,
|
|
389
|
+
};
|
|
390
|
+
|
|
391
|
+
onChange(e);
|
|
392
|
+
|
|
393
|
+
// If the handler returns a value, use it instead of `next`.
|
|
394
|
+
finalValue = e.value;
|
|
395
|
+
if (defaultPrevented) {
|
|
396
|
+
// Host took control and blocked the core update.
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// NOTE: Second argument is an optional "source" tag.
|
|
402
|
+
// If your setValue only accepts one arg, drop `String(variant)`.
|
|
403
|
+
(setValue as any)(finalValue, String(variant));
|
|
404
|
+
},
|
|
405
|
+
[onChange, setValue, variant]
|
|
406
|
+
);
|
|
407
|
+
|
|
408
|
+
const disabledProp = (rest as any).disabled;
|
|
409
|
+
const readOnlyProp = (rest as any).readOnly;
|
|
410
|
+
|
|
411
|
+
// Convenience shorthands for layout
|
|
412
|
+
const lp = layout.labelPlacement;
|
|
413
|
+
const sp = layout.sublabelPlacement;
|
|
414
|
+
const dp = layout.descriptionPlacement;
|
|
415
|
+
const hp = layout.helpTextPlacement;
|
|
416
|
+
const ep = layout.errorTextPlacement;
|
|
417
|
+
const tp = layout.tagPlacement;
|
|
418
|
+
|
|
419
|
+
const isInline = !!layout.inline;
|
|
420
|
+
const isCompactInline = isInline && layout.fullWidth === false;
|
|
421
|
+
|
|
422
|
+
const rootClassName = [
|
|
423
|
+
"gap-1",
|
|
424
|
+
contain && !inline
|
|
425
|
+
? "rounded-xl border border-border bg-background"
|
|
426
|
+
: null,
|
|
427
|
+
classes?.root,
|
|
428
|
+
className,
|
|
429
|
+
]
|
|
430
|
+
.filter(Boolean)
|
|
431
|
+
.join(" ");
|
|
432
|
+
|
|
433
|
+
// Variant-level className merge (host + classes.variant)
|
|
434
|
+
const hostVariantClass =
|
|
435
|
+
(rest as any).className as string | undefined;
|
|
436
|
+
|
|
437
|
+
const mergedVariantClass =
|
|
438
|
+
([
|
|
439
|
+
// In compact inline mode, force the control to size to its content
|
|
440
|
+
isCompactInline ? "inline-flex w-auto" : null,
|
|
441
|
+
hostVariantClass,
|
|
442
|
+
classes?.variant,
|
|
443
|
+
]
|
|
444
|
+
.filter(Boolean)
|
|
445
|
+
.join(" ")) || undefined;
|
|
446
|
+
|
|
447
|
+
// Build tags content cluster (individual pills)
|
|
448
|
+
const tagsContent = React.useMemo(() => {
|
|
449
|
+
const items = (tags ?? []) as any[];
|
|
450
|
+
|
|
451
|
+
if (!items.length) return null;
|
|
452
|
+
|
|
453
|
+
return (
|
|
454
|
+
<>
|
|
455
|
+
{items.map((tag, index) => (
|
|
456
|
+
<span
|
|
457
|
+
key={index}
|
|
458
|
+
className={[
|
|
459
|
+
"inline-flex items-center gap-1 rounded-full px-3 py-1 text-xs font-medium",
|
|
460
|
+
tag.className,
|
|
461
|
+
classes?.tag,
|
|
462
|
+
]
|
|
463
|
+
.filter(Boolean)
|
|
464
|
+
.join(" ")}
|
|
465
|
+
style={{
|
|
466
|
+
color: tag.color,
|
|
467
|
+
backgroundColor: tag.bgColor,
|
|
468
|
+
}}
|
|
469
|
+
>
|
|
470
|
+
{tag.icon && (
|
|
471
|
+
<span className="shrink-0">
|
|
472
|
+
{tag.icon}
|
|
473
|
+
</span>
|
|
474
|
+
)}
|
|
475
|
+
<span>{tag.label}</span>
|
|
476
|
+
</span>
|
|
477
|
+
))}
|
|
478
|
+
</>
|
|
479
|
+
);
|
|
480
|
+
}, [tags, classes?.tag]);
|
|
481
|
+
|
|
482
|
+
// Build helper layout graph for this field
|
|
483
|
+
const graph = React.useMemo(
|
|
484
|
+
() =>
|
|
485
|
+
buildLayoutGraph({
|
|
486
|
+
layout,
|
|
487
|
+
sublabel,
|
|
488
|
+
description,
|
|
489
|
+
helpText,
|
|
490
|
+
errorText: visualError || undefined,
|
|
491
|
+
tags: tagsContent || undefined,
|
|
492
|
+
}),
|
|
493
|
+
[layout, sublabel, description, helpText, visualError, tagsContent]
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
// ─────────────────────────────────────────────────────
|
|
497
|
+
// INLINE LAYOUT
|
|
498
|
+
// ─────────────────────────────────────────────────────
|
|
499
|
+
|
|
500
|
+
// In inline mode, label can effectively be left / right / hidden.
|
|
501
|
+
const inlineLabelSide: "left" | "right" | "hidden" =
|
|
502
|
+
lp === "right" ? "right" : lp === "hidden" ? "hidden" : "left";
|
|
503
|
+
|
|
504
|
+
// Width semantics for inline:
|
|
505
|
+
// - compact inline (fullWidth === false) → input column is content-sized
|
|
506
|
+
// - normal inline → input grows, label minimal
|
|
507
|
+
const inlineInputColClass = [
|
|
508
|
+
isCompactInline ? "flex-none" : "flex-1 min-w-0",
|
|
509
|
+
classes?.inlineInputColumn,
|
|
510
|
+
]
|
|
511
|
+
.filter(Boolean)
|
|
512
|
+
.join(" ");
|
|
513
|
+
|
|
514
|
+
const inlineLabelColClass = [
|
|
515
|
+
isCompactInline ? "flex-1 min-w-0" : "min-w-0",
|
|
516
|
+
classes?.inlineLabelColumn,
|
|
517
|
+
]
|
|
518
|
+
.filter(Boolean)
|
|
519
|
+
.join(" ");
|
|
520
|
+
|
|
521
|
+
const inlineFieldGroupClass = isCompactInline
|
|
522
|
+
? [
|
|
523
|
+
// compact, content-sized group
|
|
524
|
+
"inline-flex w-auto",
|
|
525
|
+
// kill the Shadcn container on this group in compact-inline mode
|
|
526
|
+
"[container-type:normal]",
|
|
527
|
+
"[container-name:none]",
|
|
528
|
+
classes?.group,
|
|
529
|
+
]
|
|
530
|
+
.filter(Boolean)
|
|
531
|
+
.join(" ")
|
|
532
|
+
: classes?.group ?? undefined;
|
|
533
|
+
|
|
534
|
+
const inlineFieldContentClass = isCompactInline
|
|
535
|
+
? ["flex-none w-auto", classes?.content]
|
|
536
|
+
.filter(Boolean)
|
|
537
|
+
.join(" ")
|
|
538
|
+
: ["w-full", classes?.content].filter(Boolean).join(" ");
|
|
539
|
+
|
|
540
|
+
const inlineInputColumn = (
|
|
541
|
+
<div className={inlineInputColClass}>
|
|
542
|
+
{/* Above input (input root) */}
|
|
543
|
+
{graph
|
|
544
|
+
.getSlotsFor("input", "above")
|
|
545
|
+
.render((slots) =>
|
|
546
|
+
slots.map((slot) =>
|
|
547
|
+
renderHelperSlot("input", slot, classes)
|
|
548
|
+
)
|
|
549
|
+
)}
|
|
550
|
+
|
|
551
|
+
<FieldGroup className={inlineFieldGroupClass}>
|
|
552
|
+
<FieldContent className={inlineFieldContentClass}>
|
|
553
|
+
<Variant
|
|
554
|
+
{...(rest as any)}
|
|
555
|
+
id={key}
|
|
556
|
+
ref={ref as any}
|
|
557
|
+
value={value}
|
|
558
|
+
onValue={handleValueChange}
|
|
559
|
+
error={error}
|
|
560
|
+
required={required}
|
|
561
|
+
disabled={disabledProp}
|
|
562
|
+
readOnly={readOnlyProp}
|
|
563
|
+
size={effectiveSize}
|
|
564
|
+
density={effectiveDensity}
|
|
565
|
+
className={mergedVariantClass}
|
|
566
|
+
/>
|
|
567
|
+
</FieldContent>
|
|
568
|
+
</FieldGroup>
|
|
569
|
+
|
|
570
|
+
{/* Below input (input root) */}
|
|
571
|
+
{graph
|
|
572
|
+
.getSlotsFor("input", "below")
|
|
573
|
+
.render((slots) =>
|
|
574
|
+
slots.map((slot) =>
|
|
575
|
+
renderHelperSlot("input", slot, classes)
|
|
576
|
+
)
|
|
577
|
+
)}
|
|
578
|
+
</div>
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
const inlineLabelColumn =
|
|
582
|
+
inlineLabelSide === "hidden" ? null : (
|
|
583
|
+
<div
|
|
584
|
+
className={["flex flex-col gap-0", inlineLabelColClass]
|
|
585
|
+
.filter(Boolean)
|
|
586
|
+
.join(" ")}
|
|
587
|
+
>
|
|
588
|
+
{/* Above label (label root) */}
|
|
589
|
+
{graph
|
|
590
|
+
.getSlotsFor("label", "above")
|
|
591
|
+
.render((slots) =>
|
|
592
|
+
slots.map((slot) =>
|
|
593
|
+
renderHelperSlot("label", slot, classes)
|
|
594
|
+
)
|
|
595
|
+
)}
|
|
596
|
+
|
|
597
|
+
<div
|
|
598
|
+
className={[
|
|
599
|
+
"flex items-baseline justify-between gap-1",
|
|
600
|
+
classes?.labelRow,
|
|
601
|
+
]
|
|
602
|
+
.filter(Boolean)
|
|
603
|
+
.join(" ")}
|
|
604
|
+
data-slot="label-row"
|
|
605
|
+
>
|
|
606
|
+
{/* Left-of-label helpers (label root) */}
|
|
607
|
+
{graph
|
|
608
|
+
.getSlotsFor("label", "left")
|
|
609
|
+
.render((slots) => (
|
|
610
|
+
<div className="flex items-baseline gap-1">
|
|
611
|
+
{slots.map((slot) =>
|
|
612
|
+
renderHelperSlot(
|
|
613
|
+
"label",
|
|
614
|
+
slot,
|
|
615
|
+
classes
|
|
616
|
+
)
|
|
617
|
+
)}
|
|
618
|
+
</div>
|
|
619
|
+
))}
|
|
620
|
+
|
|
621
|
+
{label && (
|
|
622
|
+
<FieldLabel
|
|
623
|
+
htmlFor={key}
|
|
624
|
+
className={[
|
|
625
|
+
"text-sm font-medium text-foreground",
|
|
626
|
+
classes?.label,
|
|
627
|
+
]
|
|
628
|
+
.filter(Boolean)
|
|
629
|
+
.join(" ")}
|
|
630
|
+
>
|
|
631
|
+
<FieldTitle>{label} {required ? <span className={cn("text-destructive", classes?.required)}>*</span> : ''}</FieldTitle>
|
|
632
|
+
</FieldLabel>
|
|
633
|
+
)}
|
|
634
|
+
|
|
635
|
+
{/* Right-of-label helpers (label root) */}
|
|
636
|
+
{graph
|
|
637
|
+
.getSlotsFor("label", "right")
|
|
638
|
+
.render((slots) => (
|
|
639
|
+
<div className="flex items-baseline gap-1">
|
|
640
|
+
{slots.map((slot) =>
|
|
641
|
+
renderHelperSlot(
|
|
642
|
+
"label",
|
|
643
|
+
slot,
|
|
644
|
+
classes
|
|
645
|
+
)
|
|
646
|
+
)}
|
|
647
|
+
</div>
|
|
648
|
+
))}
|
|
649
|
+
</div>
|
|
650
|
+
|
|
651
|
+
{/* Below label (label root) */}
|
|
652
|
+
{graph
|
|
653
|
+
.getSlotsFor("label", "below")
|
|
654
|
+
.render((slots) =>
|
|
655
|
+
slots.map((slot) =>
|
|
656
|
+
renderHelperSlot("label", slot, classes)
|
|
657
|
+
)
|
|
658
|
+
)}
|
|
659
|
+
</div>
|
|
660
|
+
);
|
|
661
|
+
|
|
662
|
+
const inlineRowClassName = [
|
|
663
|
+
"flex items-start gap-2",
|
|
664
|
+
classes?.inlineRow,
|
|
665
|
+
]
|
|
666
|
+
.filter(Boolean)
|
|
667
|
+
.join(" ");
|
|
668
|
+
|
|
669
|
+
// ─────────────────────────────────────────────────────
|
|
670
|
+
// STACKED LAYOUT
|
|
671
|
+
// ─────────────────────────────────────────────────────
|
|
672
|
+
|
|
673
|
+
const stackedGroupClassName = ["mt-1", classes?.group]
|
|
674
|
+
.filter(Boolean)
|
|
675
|
+
.join(" ");
|
|
676
|
+
|
|
677
|
+
const Element = contain ? 'div' : React.Fragment;
|
|
678
|
+
const attrs = (a: 'l' | 'i' = 'l') =>
|
|
679
|
+
contain
|
|
680
|
+
? a === 'l'
|
|
681
|
+
? { className: "p-4 border-b border-input" }
|
|
682
|
+
: { className: "px-4 pt-2 pb-4" }
|
|
683
|
+
: {};
|
|
684
|
+
return (
|
|
685
|
+
<UiField
|
|
686
|
+
className={rootClassName}
|
|
687
|
+
style={style}
|
|
688
|
+
data-variant={String(variant)}
|
|
689
|
+
data-label-placement={lp ?? undefined}
|
|
690
|
+
data-sublabel-placement={sp ?? undefined}
|
|
691
|
+
data-description-placement={dp ?? undefined}
|
|
692
|
+
data-helptext-placement={hp ?? undefined}
|
|
693
|
+
data-errortext-placement={ep ?? undefined}
|
|
694
|
+
data-tag-placement={tp ?? undefined}
|
|
695
|
+
data-inline={isInline ? "true" : "false"}
|
|
696
|
+
data-fullwidth={layout.fullWidth ? "true" : "false"}
|
|
697
|
+
>
|
|
698
|
+
{isInline ? (
|
|
699
|
+
// INLINE MODE: label + control on the same row
|
|
700
|
+
<div
|
|
701
|
+
className={inlineRowClassName}
|
|
702
|
+
data-slot="inline-row"
|
|
703
|
+
>
|
|
704
|
+
{inlineLabelSide === "right" ? (
|
|
705
|
+
<>
|
|
706
|
+
{inlineInputColumn}
|
|
707
|
+
{inlineLabelColumn}
|
|
708
|
+
</>
|
|
709
|
+
) : inlineLabelSide === "hidden" ? (
|
|
710
|
+
<>{inlineInputColumn}</>
|
|
711
|
+
) : (
|
|
712
|
+
<>
|
|
713
|
+
{inlineLabelColumn}
|
|
714
|
+
{inlineInputColumn}
|
|
715
|
+
</>
|
|
716
|
+
)}
|
|
717
|
+
</div>
|
|
718
|
+
) : (
|
|
719
|
+
// STACKED MODE
|
|
720
|
+
<>
|
|
721
|
+
{lp !== "hidden" && (
|
|
722
|
+
<Element {...attrs()}>
|
|
723
|
+
{/* Above label (label root) */}
|
|
724
|
+
{graph
|
|
725
|
+
.getSlotsFor("label", "above")
|
|
726
|
+
.render((slots) =>
|
|
727
|
+
slots.map((slot) =>
|
|
728
|
+
renderHelperSlot(
|
|
729
|
+
"label",
|
|
730
|
+
slot,
|
|
731
|
+
classes
|
|
732
|
+
)
|
|
733
|
+
)
|
|
734
|
+
)}
|
|
735
|
+
|
|
736
|
+
<div
|
|
737
|
+
className={[
|
|
738
|
+
"flex items-baseline justify-between gap-1",
|
|
739
|
+
classes?.labelRow,
|
|
740
|
+
]
|
|
741
|
+
.filter(Boolean)
|
|
742
|
+
.join(" ")}
|
|
743
|
+
data-slot="label-row"
|
|
744
|
+
>
|
|
745
|
+
{/* Left-of-label helpers (label root) */}
|
|
746
|
+
{graph
|
|
747
|
+
.getSlotsFor("label", "left")
|
|
748
|
+
.render((slots) => (
|
|
749
|
+
<div className="flex items-baseline gap-1">
|
|
750
|
+
{slots.map((slot) =>
|
|
751
|
+
renderHelperSlot(
|
|
752
|
+
"label",
|
|
753
|
+
slot,
|
|
754
|
+
classes
|
|
755
|
+
)
|
|
756
|
+
)}
|
|
757
|
+
</div>
|
|
758
|
+
))}
|
|
759
|
+
|
|
760
|
+
{label && (
|
|
761
|
+
<FieldLabel
|
|
762
|
+
htmlFor={key}
|
|
763
|
+
className={[
|
|
764
|
+
"text-sm font-medium text-foreground",
|
|
765
|
+
classes?.label,
|
|
766
|
+
]
|
|
767
|
+
.filter(Boolean)
|
|
768
|
+
.join(" ")}
|
|
769
|
+
>
|
|
770
|
+
<FieldTitle>{label} {required ? <span className={cn("text-destructive", classes?.required)}>*</span> : ''}</FieldTitle>
|
|
771
|
+
</FieldLabel>
|
|
772
|
+
)}
|
|
773
|
+
|
|
774
|
+
{/* Right-of-label helpers (label root) */}
|
|
775
|
+
{graph
|
|
776
|
+
.getSlotsFor("label", "right")
|
|
777
|
+
.render((slots) => (
|
|
778
|
+
<div className="flex items-baseline gap-1">
|
|
779
|
+
{slots.map((slot) =>
|
|
780
|
+
renderHelperSlot(
|
|
781
|
+
"label",
|
|
782
|
+
slot,
|
|
783
|
+
classes
|
|
784
|
+
)
|
|
785
|
+
)}
|
|
786
|
+
</div>
|
|
787
|
+
))}
|
|
788
|
+
</div>
|
|
789
|
+
|
|
790
|
+
{/* Below label (label root) */}
|
|
791
|
+
{graph
|
|
792
|
+
.getSlotsFor("label", "below")
|
|
793
|
+
.render((slots) =>
|
|
794
|
+
slots.map((slot) =>
|
|
795
|
+
renderHelperSlot(
|
|
796
|
+
"label",
|
|
797
|
+
slot,
|
|
798
|
+
classes
|
|
799
|
+
)
|
|
800
|
+
)
|
|
801
|
+
)}
|
|
802
|
+
</Element>
|
|
803
|
+
)}
|
|
804
|
+
|
|
805
|
+
<Element {...attrs('i')}>
|
|
806
|
+
{/* Above input (input root) */}
|
|
807
|
+
{graph
|
|
808
|
+
.getSlotsFor("input", "above")
|
|
809
|
+
.render((slots) =>
|
|
810
|
+
slots.map((slot) =>
|
|
811
|
+
renderHelperSlot(
|
|
812
|
+
"input",
|
|
813
|
+
slot,
|
|
814
|
+
classes
|
|
815
|
+
)
|
|
816
|
+
)
|
|
817
|
+
)}
|
|
818
|
+
|
|
819
|
+
<FieldGroup className={stackedGroupClassName}>
|
|
820
|
+
<FieldContent
|
|
821
|
+
className={["w-full", classes?.content]
|
|
822
|
+
.filter(Boolean)
|
|
823
|
+
.join(" ")}
|
|
824
|
+
>
|
|
825
|
+
<Variant
|
|
826
|
+
{...(rest as any)}
|
|
827
|
+
ref={ref as any}
|
|
828
|
+
value={value}
|
|
829
|
+
onValue={handleValueChange}
|
|
830
|
+
error={error}
|
|
831
|
+
required={required}
|
|
832
|
+
disabled={disabledProp}
|
|
833
|
+
readOnly={readOnlyProp}
|
|
834
|
+
size={effectiveSize}
|
|
835
|
+
density={effectiveDensity}
|
|
836
|
+
className={mergedVariantClass}
|
|
837
|
+
/>
|
|
838
|
+
</FieldContent>
|
|
839
|
+
</FieldGroup>
|
|
840
|
+
|
|
841
|
+
{/* Below input (input root) */}
|
|
842
|
+
{graph
|
|
843
|
+
.getSlotsFor("input", "below")
|
|
844
|
+
.render((slots) =>
|
|
845
|
+
slots.map((slot) =>
|
|
846
|
+
renderHelperSlot("input", slot, classes)
|
|
847
|
+
)
|
|
848
|
+
)}
|
|
849
|
+
</Element>
|
|
850
|
+
</>
|
|
851
|
+
)}
|
|
852
|
+
</UiField>
|
|
853
|
+
);
|
|
854
|
+
}
|