@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,578 @@
|
|
|
1
|
+
// src/presets/shadcn-variants/radio.tsx
|
|
2
|
+
|
|
3
|
+
import * as React from "react";
|
|
4
|
+
import type { VariantBaseProps, ChangeDetail } from "@/variants/shared";
|
|
5
|
+
import { cn } from "@/lib/utils";
|
|
6
|
+
|
|
7
|
+
// Adjust path if your radio group lives elsewhere
|
|
8
|
+
import {
|
|
9
|
+
RadioGroup,
|
|
10
|
+
RadioGroupItem,
|
|
11
|
+
} from "@/presets/ui/radio-group";
|
|
12
|
+
|
|
13
|
+
// ─────────────────────────────────────────────
|
|
14
|
+
// Types
|
|
15
|
+
// ─────────────────────────────────────────────
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Visual size of the radio UI.
|
|
19
|
+
*/
|
|
20
|
+
export type RadioSize = "sm" | "md" | "lg";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Vertical density of each radio row.
|
|
24
|
+
*
|
|
25
|
+
* Names aligned with your FieldDensity, but local to this variant.
|
|
26
|
+
*/
|
|
27
|
+
export type RadioDensity = "compact" | "comfortable" | "loose";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Layout mode for the group.
|
|
31
|
+
*
|
|
32
|
+
* - "list" → stacked rows
|
|
33
|
+
* - "grid" → CSS grid with `columns`
|
|
34
|
+
*/
|
|
35
|
+
export type RadioLayoutMode = "list" | "grid";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Base radio item shape.
|
|
39
|
+
*/
|
|
40
|
+
export interface RadioItem<TValue> {
|
|
41
|
+
value: TValue;
|
|
42
|
+
label: React.ReactNode;
|
|
43
|
+
description?: React.ReactNode;
|
|
44
|
+
disabled?: boolean;
|
|
45
|
+
key?: React.Key;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Mapping functions used when TItem is not `RadioItem<TValue>`.
|
|
50
|
+
*/
|
|
51
|
+
export interface RadioMappers<TItem, TValue> {
|
|
52
|
+
getValue: (item: TItem, index: number) => TValue;
|
|
53
|
+
getLabel: (item: TItem, index: number) => React.ReactNode;
|
|
54
|
+
getDescription?: (item: TItem, index: number) => React.ReactNode;
|
|
55
|
+
isDisabled?: (item: TItem, index: number) => boolean;
|
|
56
|
+
getKey?: (item: TItem, index: number) => React.Key;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Context passed to a custom renderOption callback.
|
|
61
|
+
*/
|
|
62
|
+
export interface RadioRenderOptionContext<TValue> {
|
|
63
|
+
item: RadioItem<TValue>;
|
|
64
|
+
index: number;
|
|
65
|
+
selected: boolean;
|
|
66
|
+
disabled: boolean;
|
|
67
|
+
size: RadioSize;
|
|
68
|
+
density: RadioDensity;
|
|
69
|
+
/**
|
|
70
|
+
* DOM id of this option (tied to the underlying RadioGroupItem).
|
|
71
|
+
*/
|
|
72
|
+
optionId?: string;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Prebuilt radio control for convenience.
|
|
76
|
+
* You can ignore this and render your own if you want.
|
|
77
|
+
*/
|
|
78
|
+
radio: React.ReactNode;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* UI-specific radio props (independent of VariantBaseProps).
|
|
83
|
+
*/
|
|
84
|
+
export interface ShadcnRadioUiProps<TItem, TValue> {
|
|
85
|
+
/**
|
|
86
|
+
* Items to render as choices.
|
|
87
|
+
*
|
|
88
|
+
* Can be:
|
|
89
|
+
* - `RadioItem<TValue>[]`, or
|
|
90
|
+
* - any custom TItem[] when used with mapping functions
|
|
91
|
+
* or optionValue/optionLabel keys.
|
|
92
|
+
*/
|
|
93
|
+
items: readonly TItem[];
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Mapping functions for TItem → value/label/etc.
|
|
97
|
+
*
|
|
98
|
+
* Takes precedence over optionValue/optionLabel if provided.
|
|
99
|
+
*/
|
|
100
|
+
mappers?: RadioMappers<TItem, TValue>;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Property name on TItem that holds the **value**.
|
|
104
|
+
*
|
|
105
|
+
* Example:
|
|
106
|
+
* items = [{ id: "free", title: "Free" }]
|
|
107
|
+
* optionValue = "id"
|
|
108
|
+
*/
|
|
109
|
+
optionValue?: keyof TItem;
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Property name on TItem that holds the **label**.
|
|
113
|
+
*
|
|
114
|
+
* Example:
|
|
115
|
+
* items = [{ id: "free", title: "Free" }]
|
|
116
|
+
* optionLabel = "title"
|
|
117
|
+
*/
|
|
118
|
+
optionLabel?: keyof TItem;
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Optional custom renderer for each option.
|
|
122
|
+
*
|
|
123
|
+
* If provided, the default label/description layout is skipped and
|
|
124
|
+
* this function is responsible for rendering the row.
|
|
125
|
+
*/
|
|
126
|
+
renderOption?: (
|
|
127
|
+
ctx: RadioRenderOptionContext<TValue>
|
|
128
|
+
) => React.ReactNode;
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Layout mode for the group.
|
|
132
|
+
* Default: "list".
|
|
133
|
+
*/
|
|
134
|
+
layout?: RadioLayoutMode;
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Number of columns in grid mode.
|
|
138
|
+
* Default: 2.
|
|
139
|
+
*/
|
|
140
|
+
columns?: number;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Gap between items (list rows or grid cells) in px.
|
|
144
|
+
* If omitted, Tailwind gaps/classes can handle spacing.
|
|
145
|
+
*/
|
|
146
|
+
itemGapPx?: number;
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Visual size of the radios.
|
|
150
|
+
* Default: "md".
|
|
151
|
+
*/
|
|
152
|
+
size?: RadioSize;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Vertical density (padding) of each row.
|
|
156
|
+
* Default: "comfortable".
|
|
157
|
+
*/
|
|
158
|
+
density?: RadioDensity;
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* ARIA overrides for the group.
|
|
162
|
+
*/
|
|
163
|
+
"aria-label"?: string;
|
|
164
|
+
"aria-labelledby"?: string;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Wrapper class for the whole radio group.
|
|
168
|
+
*/
|
|
169
|
+
groupClassName?: string;
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Extra classes for each radio option row.
|
|
173
|
+
*/
|
|
174
|
+
optionClassName?: string;
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Extra classes for the option label node.
|
|
178
|
+
*/
|
|
179
|
+
labelClassName?: string;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Extra classes for the description text under the label.
|
|
183
|
+
*/
|
|
184
|
+
descriptionClassName?: string;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Full props for the Shadcn-based radio variant.
|
|
189
|
+
*/
|
|
190
|
+
export type ShadcnRadioVariantProps<
|
|
191
|
+
TValue,
|
|
192
|
+
TItem = RadioItem<TValue>
|
|
193
|
+
> = ShadcnRadioUiProps<TItem, TValue> &
|
|
194
|
+
Pick<
|
|
195
|
+
VariantBaseProps<TValue | undefined>,
|
|
196
|
+
"value" | "onValue" | "error" | "disabled" | "required"
|
|
197
|
+
> &
|
|
198
|
+
Pick<
|
|
199
|
+
React.ComponentProps<typeof RadioGroup>,
|
|
200
|
+
// we want to allow name + data-* etc through
|
|
201
|
+
"name"
|
|
202
|
+
> & {
|
|
203
|
+
id?: string;
|
|
204
|
+
className?: string; // alias for groupClassName
|
|
205
|
+
"aria-describedby"?: string;
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Convenience type for the concrete React component.
|
|
210
|
+
*/
|
|
211
|
+
export interface ShadcnRadioVariantComponent<
|
|
212
|
+
TValue,
|
|
213
|
+
TItem = RadioItem<TValue>
|
|
214
|
+
> extends React.ForwardRefExoticComponent<
|
|
215
|
+
ShadcnRadioVariantProps<TValue, TItem> &
|
|
216
|
+
React.RefAttributes<HTMLDivElement>
|
|
217
|
+
> { }
|
|
218
|
+
|
|
219
|
+
// ─────────────────────────────────────────────
|
|
220
|
+
// Helpers
|
|
221
|
+
// ─────────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
function paddingForDensity(density: RadioDensity): string {
|
|
224
|
+
switch (density) {
|
|
225
|
+
case "compact":
|
|
226
|
+
return "py-1.5";
|
|
227
|
+
case "loose":
|
|
228
|
+
return "py-3";
|
|
229
|
+
case "comfortable":
|
|
230
|
+
default:
|
|
231
|
+
return "py-1";
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function labelTextSize(size: RadioSize): string {
|
|
236
|
+
switch (size) {
|
|
237
|
+
case "sm":
|
|
238
|
+
return "text-xs";
|
|
239
|
+
case "lg":
|
|
240
|
+
return "text-base";
|
|
241
|
+
case "md":
|
|
242
|
+
default:
|
|
243
|
+
return "text-sm";
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function descriptionTextSize(size: RadioSize): string {
|
|
248
|
+
switch (size) {
|
|
249
|
+
case "sm":
|
|
250
|
+
return "text-[0.7rem]";
|
|
251
|
+
case "lg":
|
|
252
|
+
return "text-sm";
|
|
253
|
+
case "md":
|
|
254
|
+
default:
|
|
255
|
+
return "text-xs";
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Normalise TItem[] into RadioItem<TValue>[] using one of:
|
|
261
|
+
* - explicit mappers
|
|
262
|
+
* - optionValue/optionLabel keys
|
|
263
|
+
* - native RadioItem fields
|
|
264
|
+
*/
|
|
265
|
+
function normalizeItems<TItem, TValue>(
|
|
266
|
+
items: readonly TItem[],
|
|
267
|
+
mappers?: RadioMappers<TItem, TValue>,
|
|
268
|
+
optionValueKey?: keyof TItem,
|
|
269
|
+
optionLabelKey?: keyof TItem
|
|
270
|
+
): RadioItem<TValue>[] {
|
|
271
|
+
// 1) Full mappers win – most explicit
|
|
272
|
+
if (mappers) {
|
|
273
|
+
return items.map((item, index) => ({
|
|
274
|
+
value: mappers.getValue(item, index),
|
|
275
|
+
label: mappers.getLabel(item, index),
|
|
276
|
+
description: mappers.getDescription
|
|
277
|
+
? mappers.getDescription(item, index)
|
|
278
|
+
: undefined,
|
|
279
|
+
disabled: mappers.isDisabled
|
|
280
|
+
? mappers.isDisabled(item, index)
|
|
281
|
+
: false,
|
|
282
|
+
key: mappers.getKey ? mappers.getKey(item, index) : index,
|
|
283
|
+
}));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// 2) optionValue / optionLabel keys
|
|
287
|
+
if (optionValueKey || optionLabelKey) {
|
|
288
|
+
return items.map((item, index) => {
|
|
289
|
+
const anyItem = item as any;
|
|
290
|
+
|
|
291
|
+
const rawValue =
|
|
292
|
+
optionValueKey != null
|
|
293
|
+
? anyItem[optionValueKey as string]
|
|
294
|
+
: anyItem.value;
|
|
295
|
+
|
|
296
|
+
const value = rawValue as TValue;
|
|
297
|
+
|
|
298
|
+
const rawLabel =
|
|
299
|
+
optionLabelKey != null
|
|
300
|
+
? anyItem[optionLabelKey as string]
|
|
301
|
+
: anyItem.label ?? String(rawValue ?? index);
|
|
302
|
+
|
|
303
|
+
const description = anyItem.description;
|
|
304
|
+
const disabled = !!anyItem.disabled;
|
|
305
|
+
const key: React.Key = anyItem.key ?? index;
|
|
306
|
+
|
|
307
|
+
return {
|
|
308
|
+
value,
|
|
309
|
+
label: rawLabel,
|
|
310
|
+
description,
|
|
311
|
+
disabled,
|
|
312
|
+
key,
|
|
313
|
+
};
|
|
314
|
+
});
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// 3) Fallback: assume TItem already matches RadioItem<TValue>
|
|
318
|
+
return items as unknown as RadioItem<TValue>[];
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* Shallow-ish equality for values.
|
|
323
|
+
*/
|
|
324
|
+
function isEqualValue(a: unknown, b: unknown): boolean {
|
|
325
|
+
return Object.is(a, b);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ─────────────────────────────────────────────
|
|
329
|
+
// Component
|
|
330
|
+
// ─────────────────────────────────────────────
|
|
331
|
+
|
|
332
|
+
const InnerShadcnRadioVariant = <
|
|
333
|
+
TValue,
|
|
334
|
+
TItem = RadioItem<TValue>
|
|
335
|
+
>(
|
|
336
|
+
props: ShadcnRadioVariantProps<TValue, TItem>,
|
|
337
|
+
ref: React.Ref<HTMLDivElement>
|
|
338
|
+
) => {
|
|
339
|
+
const {
|
|
340
|
+
// variant base
|
|
341
|
+
value,
|
|
342
|
+
onValue,
|
|
343
|
+
error,
|
|
344
|
+
disabled,
|
|
345
|
+
required,
|
|
346
|
+
|
|
347
|
+
// radio UI
|
|
348
|
+
items,
|
|
349
|
+
mappers,
|
|
350
|
+
optionValue,
|
|
351
|
+
optionLabel,
|
|
352
|
+
renderOption,
|
|
353
|
+
layout = "list",
|
|
354
|
+
columns = 2,
|
|
355
|
+
itemGapPx,
|
|
356
|
+
size = "md",
|
|
357
|
+
density = "comfortable",
|
|
358
|
+
"aria-label": ariaLabel,
|
|
359
|
+
"aria-labelledby": ariaLabelledBy,
|
|
360
|
+
"aria-describedby": ariaDescribedBy,
|
|
361
|
+
name,
|
|
362
|
+
|
|
363
|
+
groupClassName,
|
|
364
|
+
optionClassName,
|
|
365
|
+
labelClassName,
|
|
366
|
+
descriptionClassName,
|
|
367
|
+
|
|
368
|
+
className, // alias for groupClassName
|
|
369
|
+
id,
|
|
370
|
+
|
|
371
|
+
// passthrough to RadioGroup
|
|
372
|
+
...restGroupProps
|
|
373
|
+
} = props;
|
|
374
|
+
|
|
375
|
+
const hasError = !!error;
|
|
376
|
+
|
|
377
|
+
const normalized = React.useMemo(
|
|
378
|
+
() =>
|
|
379
|
+
normalizeItems<TItem, TValue>(
|
|
380
|
+
items,
|
|
381
|
+
mappers,
|
|
382
|
+
optionValue,
|
|
383
|
+
optionLabel
|
|
384
|
+
),
|
|
385
|
+
[items, mappers, optionValue, optionLabel]
|
|
386
|
+
);
|
|
387
|
+
|
|
388
|
+
// Map TValue → string for RadioGroup
|
|
389
|
+
const selectedString = React.useMemo(() => {
|
|
390
|
+
if (value === undefined) return undefined;
|
|
391
|
+
const found = normalized.find((item) =>
|
|
392
|
+
isEqualValue(item.value, value)
|
|
393
|
+
);
|
|
394
|
+
return found ? String(found.value) : undefined;
|
|
395
|
+
}, [normalized, value]);
|
|
396
|
+
|
|
397
|
+
const handleSelect = React.useCallback(
|
|
398
|
+
(next: TValue) => {
|
|
399
|
+
if (!onValue || disabled) return;
|
|
400
|
+
|
|
401
|
+
const detail: ChangeDetail = {
|
|
402
|
+
source: "variant",
|
|
403
|
+
raw: next,
|
|
404
|
+
nativeEvent: undefined,
|
|
405
|
+
meta: undefined,
|
|
406
|
+
};
|
|
407
|
+
|
|
408
|
+
onValue(next, detail);
|
|
409
|
+
},
|
|
410
|
+
[onValue, disabled]
|
|
411
|
+
);
|
|
412
|
+
|
|
413
|
+
const handleRadioChange = React.useCallback(
|
|
414
|
+
(raw: string) => {
|
|
415
|
+
const found = normalized.find(
|
|
416
|
+
(item) => String(item.value) === raw
|
|
417
|
+
);
|
|
418
|
+
if (!found) return;
|
|
419
|
+
handleSelect(found.value);
|
|
420
|
+
},
|
|
421
|
+
[normalized, handleSelect]
|
|
422
|
+
);
|
|
423
|
+
|
|
424
|
+
const groupStyle: React.CSSProperties | undefined = React.useMemo(() => {
|
|
425
|
+
if (!itemGapPx) {
|
|
426
|
+
if (layout === "grid") {
|
|
427
|
+
return {
|
|
428
|
+
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
|
429
|
+
};
|
|
430
|
+
}
|
|
431
|
+
return undefined;
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (layout === "list") {
|
|
435
|
+
return { rowGap: itemGapPx };
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
gridTemplateColumns: `repeat(${columns}, minmax(0, 1fr))`,
|
|
440
|
+
gap: itemGapPx,
|
|
441
|
+
};
|
|
442
|
+
}, [layout, columns, itemGapPx]);
|
|
443
|
+
|
|
444
|
+
const groupClasses = cn(
|
|
445
|
+
layout === "grid" ? "grid" : "flex flex-col",
|
|
446
|
+
groupClassName ?? className
|
|
447
|
+
);
|
|
448
|
+
|
|
449
|
+
const baseOptionClass = cn(
|
|
450
|
+
// layout container for each option row
|
|
451
|
+
"relative flex items-start",
|
|
452
|
+
// keep disabled styles
|
|
453
|
+
"data-[disabled=true]:opacity-60 data-[disabled=true]:cursor-not-allowed",
|
|
454
|
+
// vertical padding from density
|
|
455
|
+
paddingForDensity(density),
|
|
456
|
+
optionClassName
|
|
457
|
+
);
|
|
458
|
+
|
|
459
|
+
const labelClassesBase = cn(
|
|
460
|
+
"font-medium text-foreground",
|
|
461
|
+
labelTextSize(size),
|
|
462
|
+
labelClassName
|
|
463
|
+
);
|
|
464
|
+
|
|
465
|
+
const descriptionClassesBase = cn(
|
|
466
|
+
"mt-0.5 text-muted-foreground",
|
|
467
|
+
descriptionTextSize(size),
|
|
468
|
+
descriptionClassName
|
|
469
|
+
);
|
|
470
|
+
|
|
471
|
+
return (
|
|
472
|
+
<RadioGroup
|
|
473
|
+
ref={ref}
|
|
474
|
+
id={id}
|
|
475
|
+
name={name}
|
|
476
|
+
value={selectedString}
|
|
477
|
+
onValueChange={handleRadioChange}
|
|
478
|
+
disabled={disabled}
|
|
479
|
+
aria-label={ariaLabel}
|
|
480
|
+
aria-labelledby={ariaLabelledBy}
|
|
481
|
+
aria-describedby={ariaDescribedBy}
|
|
482
|
+
aria-invalid={hasError || undefined}
|
|
483
|
+
aria-required={required || undefined}
|
|
484
|
+
className={groupClasses}
|
|
485
|
+
style={groupStyle}
|
|
486
|
+
data-slot="radio-group"
|
|
487
|
+
{...restGroupProps}
|
|
488
|
+
>
|
|
489
|
+
{normalized.map((item, index) => {
|
|
490
|
+
const itemString = String(item.value);
|
|
491
|
+
const selected = selectedString === itemString;
|
|
492
|
+
const optionDisabled = !!disabled || !!item.disabled;
|
|
493
|
+
const optionKey = item.key ?? index;
|
|
494
|
+
const optionId = id ? `${id}-option-${optionKey}` : undefined;
|
|
495
|
+
|
|
496
|
+
const radioNode = (
|
|
497
|
+
<RadioGroupItem
|
|
498
|
+
id={optionId}
|
|
499
|
+
value={itemString}
|
|
500
|
+
disabled={optionDisabled}
|
|
501
|
+
className="mt-1"
|
|
502
|
+
/>
|
|
503
|
+
);
|
|
504
|
+
|
|
505
|
+
// Custom renderer path
|
|
506
|
+
if (renderOption) {
|
|
507
|
+
return (
|
|
508
|
+
<div
|
|
509
|
+
key={optionKey}
|
|
510
|
+
data-slot="radio-option"
|
|
511
|
+
data-checked={selected ? "true" : "false"}
|
|
512
|
+
data-disabled={optionDisabled ? "true" : "false"}
|
|
513
|
+
className={baseOptionClass}
|
|
514
|
+
>
|
|
515
|
+
{renderOption({
|
|
516
|
+
item,
|
|
517
|
+
index,
|
|
518
|
+
selected,
|
|
519
|
+
disabled: optionDisabled,
|
|
520
|
+
size,
|
|
521
|
+
density,
|
|
522
|
+
optionId,
|
|
523
|
+
radio: radioNode,
|
|
524
|
+
})}
|
|
525
|
+
</div>
|
|
526
|
+
);
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Default rendering
|
|
530
|
+
return (
|
|
531
|
+
<div
|
|
532
|
+
key={optionKey}
|
|
533
|
+
data-slot="radio-option"
|
|
534
|
+
data-checked={selected ? "true" : "false"}
|
|
535
|
+
data-disabled={optionDisabled ? "true" : "false"}
|
|
536
|
+
className={baseOptionClass}
|
|
537
|
+
>
|
|
538
|
+
<label
|
|
539
|
+
htmlFor={optionId}
|
|
540
|
+
className="flex cursor-pointer items-start gap-3 w-full"
|
|
541
|
+
>
|
|
542
|
+
{radioNode}
|
|
543
|
+
|
|
544
|
+
<div className="flex flex-col min-w-0">
|
|
545
|
+
<span className={labelClassesBase}>
|
|
546
|
+
{item.label}
|
|
547
|
+
</span>
|
|
548
|
+
{item.description != null && (
|
|
549
|
+
<span className={descriptionClassesBase}>
|
|
550
|
+
{item.description}
|
|
551
|
+
</span>
|
|
552
|
+
)}
|
|
553
|
+
</div>
|
|
554
|
+
</label>
|
|
555
|
+
</div>
|
|
556
|
+
);
|
|
557
|
+
})}
|
|
558
|
+
</RadioGroup>
|
|
559
|
+
);
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Concrete Shadcn radio variant component.
|
|
564
|
+
*
|
|
565
|
+
* Cast to a generic-friendly type so TS can still infer TValue/TItem.
|
|
566
|
+
*/
|
|
567
|
+
export const ShadcnRadioVariant =
|
|
568
|
+
React.forwardRef(InnerShadcnRadioVariant) as unknown as <
|
|
569
|
+
TValue,
|
|
570
|
+
TItem = RadioItem<TValue>
|
|
571
|
+
>(
|
|
572
|
+
props: ShadcnRadioVariantProps<TValue, TItem> & {
|
|
573
|
+
ref?: React.Ref<HTMLDivElement>;
|
|
574
|
+
}
|
|
575
|
+
) => React.ReactElement | null;
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
export default ShadcnRadioVariant;
|