diff --git a/.changeset/thick-hornets-act.md b/.changeset/thick-hornets-act.md new file mode 100644 index 0000000..e167e9f --- /dev/null +++ b/.changeset/thick-hornets-act.md @@ -0,0 +1,10 @@ +--- +"@buildnbuzz/buzzform": patch +--- + +**Fixes** +- Defaulted boolean schema values for `checkbox` and `switch` to `false` when values are missing, preventing submit-time `Invalid value` errors for untouched fields. +- Kept required boolean behavior intact by still requiring `true` when `required: true` is set. + +**Types** +- Expanded `ui.columns` typing for `checkbox-group` and `radio` to `number | string | undefined`, enabling the builder's `Auto` (`""`) option for natural horizontal flow layouts. diff --git a/apps/web/app/(builder)/lib/properties/checkbox-group.ts b/apps/web/app/(builder)/lib/properties/checkbox-group.ts index fe53eea..c5c3990 100644 --- a/apps/web/app/(builder)/lib/properties/checkbox-group.ts +++ b/apps/web/app/(builder)/lib/properties/checkbox-group.ts @@ -202,9 +202,10 @@ export const checkboxGroupFieldProperties: Field[] = [ type: "select", name: "ui.columns", label: "Columns", - description: "Grid columns (responsive, 1 on mobile)", + description: "Grid columns. Choose 'Auto' for natural flow.", condition: shouldShowColumns, options: [ + { label: "Auto", value: "" }, { label: "1 Column", value: 1 }, { label: "2 Columns", value: 2 }, { label: "3 Columns", value: 3 }, diff --git a/apps/web/app/(builder)/lib/properties/radio.ts b/apps/web/app/(builder)/lib/properties/radio.ts index 41c1d18..bed4595 100644 --- a/apps/web/app/(builder)/lib/properties/radio.ts +++ b/apps/web/app/(builder)/lib/properties/radio.ts @@ -191,9 +191,10 @@ export const radioFieldProperties: Field[] = [ type: "select", name: "ui.columns", label: "Columns", - description: "Grid columns (responsive, 1 on mobile)", + description: "Grid columns. Choose 'Auto' for natural flow.", condition: shouldShowColumns, options: [ + { label: "Auto", value: "" }, { label: "1 Column", value: 1 }, { label: "2 Columns", value: 2 }, { label: "3 Columns", value: 3 }, diff --git a/apps/web/content/docs/fields/data/checkbox-group.mdx b/apps/web/content/docs/fields/data/checkbox-group.mdx index 8e23a3f..9cebf14 100644 --- a/apps/web/content/docs/fields/data/checkbox-group.mdx +++ b/apps/web/content/docs/fields/data/checkbox-group.mdx @@ -54,6 +54,13 @@ Selected values are submitted as a single array: | ------------------ | ---------------------------- | ------------ | ------------------------------------ | | `ui.variant` | `"default" \| "card"` | `"default"` | Option presentation style | | `ui.direction` | `"vertical" \| "horizontal"` | `"vertical"` | Layout direction for default variant | -| `ui.columns` | `1 \| 2 \| 3 \| 4` | - | Responsive grid columns (card variant or horizontal default layout) | +| `ui.columns` | `number \| ""` | - | Grid columns. Use `""` (`Auto`) for natural horizontal flow instead of a fixed grid | | `ui.card.size` | `"sm" \| "md" \| "lg"` | `"md"` | Card size preset | | `ui.card.bordered` | `boolean` | `true` | Show card borders | + +### Auto Columns + +Use `ui.columns: ""` to enable `Auto` layout behavior. + +- In `default` + `horizontal` layout, options flow naturally in a wrapping row. +- In card layouts, set a number when you want a fixed responsive grid. diff --git a/apps/web/content/docs/fields/data/radio.mdx b/apps/web/content/docs/fields/data/radio.mdx index a6aecb3..4a5d950 100644 --- a/apps/web/content/docs/fields/data/radio.mdx +++ b/apps/web/content/docs/fields/data/radio.mdx @@ -55,10 +55,17 @@ export function RadioFieldDemo() { | ------------------ | ---------------------------- | ------------ | ---------------- | | `ui.variant` | `'default' \| 'card'` | `'default'` | Visual style | | `ui.direction` | `'vertical' \| 'horizontal'` | `'vertical'` | Layout direction | -| `ui.columns` | `1 \| 2 \| 3 \| 4` | - | Grid columns (card variant or horizontal default layout) | +| `ui.columns` | `number \| ""` | - | Grid columns. Use `""` (`Auto`) for natural horizontal flow instead of a fixed grid | | `ui.card.size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Card size | | `ui.card.bordered` | `boolean` | `true` | Show card border | +## Auto Columns + +Use `ui.columns: ""` to enable `Auto` layout behavior. + +- In `default` + `horizontal` layout, options flow naturally in a wrapping row. +- In card layouts, set a number when you want a fixed responsive grid. + ## Variants ### Default Radio Buttons diff --git a/apps/web/public/r/checkbox-group.json b/apps/web/public/r/checkbox-group.json index 7ee9760..3b94ac6 100644 --- a/apps/web/public/r/checkbox-group.json +++ b/apps/web/public/r/checkbox-group.json @@ -14,7 +14,7 @@ "files": [ { "path": "registry/base/fields/checkbox-group.tsx", - "content": "\"use client\";\r\n\r\nimport { useEffect, useRef, useState } from \"react\";\r\nimport type {\r\n CheckboxGroupField as CheckboxGroupFieldType,\r\n SelectOption,\r\n ValidationContext,\r\n FormAdapter,\r\n} from \"@buildnbuzz/buzzform\";\r\nimport {\r\n normalizeSelectOption,\r\n getSelectOptionValue,\r\n getSelectOptionLabel,\r\n isSelectOptionDisabled,\r\n getFieldWidthStyle,\r\n getNestedValue,\r\n} from \"@buildnbuzz/buzzform\";\r\nimport { Checkbox } from \"@/components/ui/checkbox\";\r\nimport {\n Field,\n FieldDescription,\n FieldError,\n FieldLabel,\n} from \"@/components/ui/field\";\nimport { cn } from \"@/lib/utils\";\n\ntype OptionValue = string | number | boolean;\ntype OptionGroupVariant = \"default\" | \"card\";\ntype OptionGroupDirection = \"vertical\" | \"horizontal\";\ntype OptionGroupColumns = 1 | 2 | 3 | 4;\n\r\nexport interface CheckboxGroupFieldProps {\n field: CheckboxGroupFieldType;\r\n path: string;\r\n form: FormAdapter;\r\n autoFocus?: boolean;\r\n formValues: Record;\r\n siblingData: Record;\r\n // Computed props\r\n fieldId: string;\r\n label: React.ReactNode | null;\r\n isDisabled: boolean;\r\n isReadOnly: boolean;\r\n error?: string;\n}\n\nfunction getGridColumnsClass(columns: OptionGroupColumns | undefined) {\n if (columns === 2) return \"sm:grid-cols-2\";\n if (columns === 3) return \"sm:grid-cols-2 md:grid-cols-3\";\n if (columns === 4) return \"sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4\";\n return undefined;\n}\n\nfunction getOptionGroupLayoutClassName({\n variant = \"default\",\n direction = \"vertical\",\n columns,\n}: {\n variant?: OptionGroupVariant;\n direction?: OptionGroupDirection;\n columns?: OptionGroupColumns;\n}) {\n const usesGridColumns = variant === \"card\" || direction === \"horizontal\";\n const effectiveColumns = usesGridColumns ? columns : undefined;\n\n if (!effectiveColumns || effectiveColumns === 1) {\n if (variant === \"default\" && direction === \"horizontal\") {\n return \"flex flex-wrap gap-x-4 gap-y-2\";\n }\n return \"flex flex-col gap-2\";\n }\n\n return cn(\"grid gap-2\", getGridColumnsClass(effectiveColumns));\n}\n\r\nfunction valueToString(value: OptionValue): string {\r\n if (typeof value === \"boolean\") return value ? \"true\" : \"false\";\r\n return String(value);\r\n}\r\n\r\nfunction stringToValue(\r\n str: string,\r\n options: SelectOption[],\r\n): OptionValue | undefined {\r\n const option = options.find((opt) => valueToString(opt.value) === str);\r\n return option?.value;\r\n}\r\n\r\nfunction isAsyncOptions(\r\n options: CheckboxGroupFieldType[\"options\"],\r\n): options is (context: ValidationContext) => Promise {\r\n return typeof options === \"function\";\r\n}\r\n\r\nfunction useAsyncOptions(\r\n options: CheckboxGroupFieldType[\"options\"],\r\n dependencies: string[] | undefined,\r\n formValues: Record,\r\n siblingData: Record,\r\n path: string,\r\n): {\r\n resolvedOptions: SelectOption[];\r\n isLoading: boolean;\r\n} {\r\n const isAsync = isAsyncOptions(options);\r\n const cacheRef = useRef>(new Map());\r\n\r\n const [resolvedOptions, setResolvedOptions] = useState(\r\n !isAsync\r\n ? Array.isArray(options)\r\n ? options.map(normalizeSelectOption)\r\n : []\r\n : [],\r\n );\r\n const [isLoading, setIsLoading] = useState(isAsync);\r\n\r\n const dependencyKey = (() => {\r\n if (!dependencies || dependencies.length === 0) return \"static\";\r\n const values = dependencies.map((dep) => getNestedValue(formValues, dep));\r\n return JSON.stringify(values);\r\n })();\r\n\r\n useEffect(() => {\r\n if (!isAsync) {\r\n if (Array.isArray(options)) {\r\n setResolvedOptions(options.map(normalizeSelectOption));\r\n }\r\n setIsLoading(false);\r\n return;\r\n }\r\n\r\n if (cacheRef.current.has(dependencyKey)) {\r\n setResolvedOptions(cacheRef.current.get(dependencyKey)!);\r\n setIsLoading(false);\r\n return;\r\n }\r\n\r\n let isMounted = true;\r\n\r\n async function fetchOptions() {\r\n setIsLoading(true);\r\n\r\n try {\r\n const context: ValidationContext = {\r\n data: formValues,\r\n siblingData,\r\n path: path.split(\".\"),\r\n };\r\n\r\n const result = await (\r\n options as (context: ValidationContext) => Promise\r\n )(context);\r\n\r\n const normalized = result.map(normalizeSelectOption);\r\n\r\n if (isMounted) {\r\n cacheRef.current.set(dependencyKey, normalized);\r\n setResolvedOptions(normalized);\r\n }\r\n } catch (err) {\r\n console.error(\"Failed to fetch checkbox-group options:\", err);\r\n if (isMounted) {\r\n setResolvedOptions([]);\r\n }\r\n } finally {\r\n if (isMounted) {\r\n setIsLoading(false);\r\n }\r\n }\r\n }\r\n\r\n fetchOptions();\r\n\r\n return () => {\r\n isMounted = false;\r\n };\r\n }, [isAsync, options, dependencyKey, formValues, siblingData, path]);\r\n\r\n return { resolvedOptions, isLoading };\r\n}\r\n\r\nconst cardSizeClasses = {\r\n sm: \"p-2.5 sm:p-3\",\r\n md: \"p-3 sm:p-4\",\r\n lg: \"p-4 sm:p-5\",\r\n} as const;\r\n\r\nexport function CheckboxGroupField({\r\n field,\r\n path,\r\n form,\r\n autoFocus,\r\n formValues,\r\n siblingData,\r\n fieldId,\r\n label,\r\n isDisabled,\r\n isReadOnly,\r\n error,\r\n}: CheckboxGroupFieldProps) {\r\n const rawValue = form.watch(path);\r\n const hasError = !!error;\r\n\r\n const selectedValues = Array.isArray(rawValue)\r\n ? (rawValue as OptionValue[]).map(valueToString)\r\n : [];\r\n\r\n const { resolvedOptions, isLoading } = useAsyncOptions(\r\n field.options,\r\n field.dependencies,\r\n formValues,\r\n siblingData,\r\n path,\r\n );\r\n\r\n const variant = field.ui?.variant ?? \"default\";\n const direction = field.ui?.direction ?? \"vertical\";\n const columns = field.ui?.columns;\n const cardSize = field.ui?.card?.size ?? \"md\";\n const cardBordered = field.ui?.card?.bordered ?? true;\n const maxSelected = field.maxSelected;\n\n const isCardVariant = variant === \"card\";\n const layoutClasses = getOptionGroupLayoutClassName({\n variant,\n direction,\n columns,\n });\n\r\n const handleToggle = (stringValue: string, checked: boolean) => {\r\n if (isReadOnly) return;\r\n\r\n const hasMaxLimit = typeof maxSelected === \"number\" && maxSelected >= 0;\r\n if (checked && hasMaxLimit && selectedValues.length >= maxSelected) {\r\n return;\r\n }\r\n\r\n const currentSet = new Set(selectedValues);\r\n if (checked) {\r\n currentSet.add(stringValue);\r\n } else {\r\n currentSet.delete(stringValue);\r\n }\r\n\r\n const nextValues = Array.from(currentSet)\r\n .map((value) => stringToValue(value, resolvedOptions))\r\n .filter((value): value is OptionValue => value !== undefined);\r\n\r\n form.setValue(path, nextValues, {\r\n shouldDirty: true,\r\n shouldValidate: true,\r\n });\r\n };\r\n\r\n return (\r\n \r\n {label && (\r\n \r\n {field.required && *}\r\n {label}\r\n \r\n )}\r\n\r\n {field.description && (\r\n \r\n {field.description}\r\n \r\n )}\r\n\r\n \n {!isLoading &&\r\n resolvedOptions.map((opt, i) => {\r\n const value = getSelectOptionValue(opt);\r\n const optionLabel = getSelectOptionLabel(opt);\r\n const normalized = normalizeSelectOption(opt);\r\n const checked = selectedValues.includes(value);\r\n const hasMaxLimit =\r\n typeof maxSelected === \"number\" && maxSelected >= 0;\r\n const isAtMaxSelection =\r\n hasMaxLimit && selectedValues.length >= maxSelected;\r\n const optDisabled =\r\n isSelectOptionDisabled(opt) ||\r\n isDisabled ||\r\n (isAtMaxSelection && !checked);\r\n const id = `${fieldId}-${i}`;\r\n\r\n if (isCardVariant) {\r\n const showDescription =\r\n normalized.description && cardSize !== \"sm\";\r\n const optionKey = `${value}-${i}`;\r\n\r\n return (\r\n \r\n \r\n handleToggle(value, next === true)\r\n }\r\n disabled={optDisabled}\r\n className=\"shrink-0 mt-0.5\"\r\n autoFocus={autoFocus && i === 0}\r\n />\r\n
\r\n
\r\n {normalized.icon && (\r\n \r\n {normalized.icon}\r\n \r\n )}\r\n \r\n {optionLabel}\r\n \r\n
\r\n {showDescription && (\r\n \r\n {normalized.description}\r\n \r\n )}\r\n
\r\n \r\n );\r\n }\r\n\r\n return (\r\n \r\n handleToggle(value, next === true)}\r\n disabled={optDisabled}\r\n autoFocus={autoFocus && i === 0}\r\n />\r\n \r\n {normalized.icon && (\r\n \r\n {normalized.icon}\r\n \r\n )}\r\n {optionLabel}\r\n \r\n \r\n );\r\n })}\r\n \r\n\r\n {error && {error}}\r\n \r\n );\r\n}\r\n\r\nexport function CheckboxGroupFieldSkeleton({\r\n field,\r\n}: {\r\n field: CheckboxGroupFieldType;\r\n}) {\r\n const label = field.label !== false ? (field.label ?? field.name) : null;\n const variant = field.ui?.variant ?? \"default\";\n const direction = field.ui?.direction ?? \"vertical\";\n const columns = field.ui?.columns;\n const cardSize = field.ui?.card?.size ?? \"md\";\n const cardBordered = field.ui?.card?.bordered ?? true;\n\n const isCardVariant = variant === \"card\";\n const optionCount = Math.min(\r\n Array.isArray(field.options) ? field.options.length : 3,\r\n 6,\r\n );\r\n\r\n const layoutClasses = getOptionGroupLayoutClassName({\n variant,\n direction,\n columns,\n });\n\r\n return (\r\n
\r\n {label &&
}\r\n
\n {Array.from({ length: optionCount }).map((_, i) =>\r\n isCardVariant ? (\r\n \r\n
\r\n
\r\n ) : (\r\n
\r\n
\r\n
\r\n
\r\n ),\r\n )}\r\n
\r\n {field.description && (\r\n
\r\n )}\r\n
\r\n );\r\n}\r\n", + "content": "\"use client\";\r\n\r\nimport { useEffect, useRef, useState } from \"react\";\r\nimport type {\r\n CheckboxGroupField as CheckboxGroupFieldType,\r\n SelectOption,\r\n ValidationContext,\r\n FormAdapter,\r\n} from \"@buildnbuzz/buzzform\";\r\nimport {\r\n normalizeSelectOption,\r\n getSelectOptionValue,\r\n getSelectOptionLabel,\r\n isSelectOptionDisabled,\r\n getFieldWidthStyle,\r\n getNestedValue,\r\n} from \"@buildnbuzz/buzzform\";\r\nimport { Checkbox } from \"@/components/ui/checkbox\";\r\nimport {\r\n Field,\r\n FieldDescription,\r\n FieldError,\r\n FieldLabel,\r\n} from \"@/components/ui/field\";\r\nimport { cn } from \"@/lib/utils\";\r\n\r\ntype OptionValue = string | number | boolean;\r\ntype OptionGroupVariant = \"default\" | \"card\";\r\ntype OptionGroupDirection = \"vertical\" | \"horizontal\";\r\ntype OptionGroupColumns = 1 | 2 | 3 | 4 | string | number | undefined;\r\n\r\nexport interface CheckboxGroupFieldProps {\r\n field: CheckboxGroupFieldType;\r\n path: string;\r\n form: FormAdapter;\r\n autoFocus?: boolean;\r\n formValues: Record;\r\n siblingData: Record;\r\n // Computed props\r\n fieldId: string;\r\n label: React.ReactNode | null;\r\n isDisabled: boolean;\r\n isReadOnly: boolean;\r\n error?: string;\r\n}\r\n\r\nfunction getGridColumnsClass(columns: OptionGroupColumns | undefined) {\r\n if (columns === 2) return \"sm:grid-cols-2\";\r\n if (columns === 3) return \"sm:grid-cols-2 md:grid-cols-3\";\r\n if (columns === 4) return \"sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4\";\r\n return undefined;\r\n}\r\n\r\nfunction getOptionGroupLayoutClassName({\r\n variant = \"default\",\r\n direction = \"vertical\",\r\n columns,\r\n}: {\r\n variant?: OptionGroupVariant;\r\n direction?: OptionGroupDirection;\r\n columns?: OptionGroupColumns;\r\n}) {\r\n const isCard = variant === \"card\";\r\n const isHorizontal = direction === \"horizontal\";\r\n\r\n // If horizontal and NO columns chosen, use fluid flex-row layout\r\n if (isHorizontal && !columns) {\r\n return \"!flex flex-row flex-wrap gap-x-4 gap-y-2\";\r\n }\r\n\r\n const effectiveColumns =\r\n isCard || isHorizontal ? (columns ?? (isCard ? 2 : undefined)) : undefined;\r\n\r\n if (!effectiveColumns || effectiveColumns === 1) {\r\n return \"flex flex-col gap-2\";\r\n }\r\n\r\n return cn(\"grid gap-2\", getGridColumnsClass(effectiveColumns));\r\n}\r\n\r\nfunction valueToString(value: OptionValue): string {\r\n if (typeof value === \"boolean\") return value ? \"true\" : \"false\";\r\n return String(value);\r\n}\r\n\r\nfunction stringToValue(\r\n str: string,\r\n options: SelectOption[],\r\n): OptionValue | undefined {\r\n const option = options.find((opt) => valueToString(opt.value) === str);\r\n return option?.value;\r\n}\r\n\r\nfunction isAsyncOptions(\r\n options: CheckboxGroupFieldType[\"options\"],\r\n): options is (context: ValidationContext) => Promise {\r\n return typeof options === \"function\";\r\n}\r\n\r\nfunction useAsyncOptions(\r\n options: CheckboxGroupFieldType[\"options\"],\r\n dependencies: string[] | undefined,\r\n formValues: Record,\r\n siblingData: Record,\r\n path: string,\r\n): {\r\n resolvedOptions: SelectOption[];\r\n isLoading: boolean;\r\n} {\r\n const isAsync = isAsyncOptions(options);\r\n const cacheRef = useRef>(new Map());\r\n\r\n const [resolvedOptions, setResolvedOptions] = useState(\r\n !isAsync\r\n ? Array.isArray(options)\r\n ? options.map(normalizeSelectOption)\r\n : []\r\n : [],\r\n );\r\n const [isLoading, setIsLoading] = useState(isAsync);\r\n\r\n const dependencyKey = (() => {\r\n if (!dependencies || dependencies.length === 0) return \"static\";\r\n const values = dependencies.map((dep) => getNestedValue(formValues, dep));\r\n return JSON.stringify(values);\r\n })();\r\n\r\n useEffect(() => {\r\n if (!isAsync) {\r\n if (Array.isArray(options)) {\r\n setResolvedOptions(options.map(normalizeSelectOption));\r\n }\r\n setIsLoading(false);\r\n return;\r\n }\r\n\r\n if (cacheRef.current.has(dependencyKey)) {\r\n setResolvedOptions(cacheRef.current.get(dependencyKey)!);\r\n setIsLoading(false);\r\n return;\r\n }\r\n\r\n let isMounted = true;\r\n\r\n async function fetchOptions() {\r\n setIsLoading(true);\r\n\r\n try {\r\n const context: ValidationContext = {\r\n data: formValues,\r\n siblingData,\r\n path: path.split(\".\"),\r\n };\r\n\r\n const result = await (\r\n options as (context: ValidationContext) => Promise\r\n )(context);\r\n\r\n const normalized = result.map(normalizeSelectOption);\r\n\r\n if (isMounted) {\r\n cacheRef.current.set(dependencyKey, normalized);\r\n setResolvedOptions(normalized);\r\n }\r\n } catch (err) {\r\n console.error(\"Failed to fetch checkbox-group options:\", err);\r\n if (isMounted) {\r\n setResolvedOptions([]);\r\n }\r\n } finally {\r\n if (isMounted) {\r\n setIsLoading(false);\r\n }\r\n }\r\n }\r\n\r\n fetchOptions();\r\n\r\n return () => {\r\n isMounted = false;\r\n };\r\n }, [isAsync, options, dependencyKey, formValues, siblingData, path]);\r\n\r\n return { resolvedOptions, isLoading };\r\n}\r\n\r\nconst cardSizeClasses = {\r\n sm: \"p-2.5 sm:p-3\",\r\n md: \"p-3 sm:p-4\",\r\n lg: \"p-4 sm:p-5\",\r\n} as const;\r\n\r\nexport function CheckboxGroupField({\r\n field,\r\n path,\r\n form,\r\n autoFocus,\r\n formValues,\r\n siblingData,\r\n fieldId,\r\n label,\r\n isDisabled,\r\n isReadOnly,\r\n error,\r\n}: CheckboxGroupFieldProps) {\r\n const rawValue = form.watch(path);\r\n const hasError = !!error;\r\n\r\n const selectedValues = Array.isArray(rawValue)\r\n ? (rawValue as OptionValue[]).map(valueToString)\r\n : [];\r\n\r\n const { resolvedOptions, isLoading } = useAsyncOptions(\r\n field.options,\r\n field.dependencies,\r\n formValues,\r\n siblingData,\r\n path,\r\n );\r\n\r\n const variant = field.ui?.variant ?? \"default\";\r\n const direction = field.ui?.direction ?? \"vertical\";\r\n const columns = field.ui?.columns;\r\n const cardSize = field.ui?.card?.size ?? \"md\";\r\n const cardBordered = field.ui?.card?.bordered ?? true;\r\n const maxSelected = field.maxSelected;\r\n\r\n const isCardVariant = variant === \"card\";\r\n const isHorizontal = direction === \"horizontal\";\r\n const layoutClasses = getOptionGroupLayoutClassName({\r\n variant,\r\n direction,\r\n columns,\r\n });\r\n\r\n const handleToggle = (stringValue: string, checked: boolean) => {\r\n if (isReadOnly) return;\r\n\r\n const hasMaxLimit = typeof maxSelected === \"number\" && maxSelected >= 0;\r\n if (checked && hasMaxLimit && selectedValues.length >= maxSelected) {\r\n return;\r\n }\r\n\r\n const currentSet = new Set(selectedValues);\r\n if (checked) {\r\n currentSet.add(stringValue);\r\n } else {\r\n currentSet.delete(stringValue);\r\n }\r\n\r\n const nextValues = Array.from(currentSet)\r\n .map((value) => stringToValue(value, resolvedOptions))\r\n .filter((value): value is OptionValue => value !== undefined);\r\n\r\n form.setValue(path, nextValues, {\r\n shouldDirty: true,\r\n shouldValidate: true,\r\n });\r\n };\r\n\r\n return (\r\n \r\n {label && (\r\n \r\n {field.required && *}\r\n {label}\r\n \r\n )}\r\n\r\n {field.description && (\r\n \r\n {field.description}\r\n \r\n )}\r\n\r\n \r\n {!isLoading &&\r\n resolvedOptions.map((opt, i) => {\r\n const value = getSelectOptionValue(opt);\r\n const optionLabel = getSelectOptionLabel(opt);\r\n const normalized = normalizeSelectOption(opt);\r\n const checked = selectedValues.includes(value);\r\n const hasMaxLimit =\r\n typeof maxSelected === \"number\" && maxSelected >= 0;\r\n const isAtMaxSelection =\r\n hasMaxLimit && selectedValues.length >= maxSelected;\r\n const optDisabled =\r\n isSelectOptionDisabled(opt) ||\r\n isDisabled ||\r\n (isAtMaxSelection && !checked);\r\n const id = `${fieldId}-${i}`;\r\n\r\n if (isCardVariant) {\r\n const showDescription =\r\n normalized.description && cardSize !== \"sm\";\r\n const optionKey = `${value}-${i}`;\r\n\r\n return (\r\n \r\n \r\n handleToggle(value, next === true)\r\n }\r\n disabled={optDisabled}\r\n className=\"shrink-0 mt-0.5\"\r\n autoFocus={autoFocus && i === 0}\r\n />\r\n
\r\n
\r\n {normalized.icon && (\r\n \r\n {normalized.icon}\r\n \r\n )}\r\n \r\n {optionLabel}\r\n \r\n
\r\n {showDescription && (\r\n \r\n {normalized.description}\r\n \r\n )}\r\n
\r\n \r\n );\r\n }\r\n\r\n return (\r\n \r\n handleToggle(value, next === true)}\r\n disabled={optDisabled}\r\n autoFocus={autoFocus && i === 0}\r\n />\r\n \r\n {normalized.icon && (\r\n \r\n {normalized.icon}\r\n \r\n )}\r\n {optionLabel}\r\n \r\n \r\n );\r\n })}\r\n
\r\n\r\n {error && {error}}\r\n
\r\n );\r\n}\r\n\r\nexport function CheckboxGroupFieldSkeleton({\r\n field,\r\n}: {\r\n field: CheckboxGroupFieldType;\r\n}) {\r\n const label = field.label !== false ? (field.label ?? field.name) : null;\r\n const variant = field.ui?.variant ?? \"default\";\r\n const direction = field.ui?.direction ?? \"vertical\";\r\n const columns = field.ui?.columns;\r\n const cardSize = field.ui?.card?.size ?? \"md\";\r\n const cardBordered = field.ui?.card?.bordered ?? true;\r\n\r\n const isCardVariant = variant === \"card\";\r\n const optionCount = Math.min(\r\n Array.isArray(field.options) ? field.options.length : 3,\r\n 6,\r\n );\r\n\r\n const layoutClasses = getOptionGroupLayoutClassName({\r\n variant,\r\n direction,\r\n columns,\r\n });\r\n\r\n return (\r\n
\r\n {label &&
}\r\n
\r\n {Array.from({ length: optionCount }).map((_, i) =>\r\n isCardVariant ? (\r\n \r\n
\r\n
\r\n ) : (\r\n
\r\n
\r\n
\r\n
\r\n ),\r\n )}\r\n
\r\n {field.description && (\r\n
\r\n )}\r\n
\r\n );\r\n}\r\n", "type": "registry:component", "target": "components/buzzform/fields/checkbox-group.tsx" } diff --git a/apps/web/public/r/radio.json b/apps/web/public/r/radio.json index f03ffcd..b812ec3 100644 --- a/apps/web/public/r/radio.json +++ b/apps/web/public/r/radio.json @@ -14,7 +14,7 @@ "files": [ { "path": "registry/base/fields/radio.tsx", - "content": "\"use client\";\r\n\r\nimport type {\r\n RadioField as RadioFieldType,\r\n FormAdapter,\r\n} from \"@buildnbuzz/buzzform\";\r\nimport {\r\n getSelectOptionValue,\r\n getSelectOptionLabel,\r\n isSelectOptionDisabled,\r\n normalizeSelectOption,\r\n getFieldWidthStyle,\r\n} from \"@buildnbuzz/buzzform\";\r\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\";\r\nimport {\n Field,\n FieldDescription,\n FieldError,\n FieldLabel,\n} from \"@/components/ui/field\";\nimport { cn } from \"@/lib/utils\";\n\nexport interface RadioFieldProps {\n field: RadioFieldType;\r\n path: string;\r\n form: FormAdapter;\r\n autoFocus?: boolean;\r\n // Computed props\r\n fieldId: string;\r\n label: React.ReactNode | null;\r\n isDisabled: boolean;\r\n isReadOnly: boolean;\r\n error?: string;\n}\n\ntype OptionGroupVariant = \"default\" | \"card\";\ntype OptionGroupDirection = \"vertical\" | \"horizontal\";\ntype OptionGroupColumns = 1 | 2 | 3 | 4;\n\nfunction getGridColumnsClass(columns: OptionGroupColumns | undefined) {\n if (columns === 2) return \"sm:grid-cols-2\";\n if (columns === 3) return \"sm:grid-cols-2 md:grid-cols-3\";\n if (columns === 4) return \"sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4\";\n return undefined;\n}\n\nfunction getOptionGroupLayoutClassName({\n variant = \"default\",\n direction = \"vertical\",\n columns,\n}: {\n variant?: OptionGroupVariant;\n direction?: OptionGroupDirection;\n columns?: OptionGroupColumns;\n}) {\n const usesGridColumns = variant === \"card\" || direction === \"horizontal\";\n const effectiveColumns = usesGridColumns ? columns : undefined;\n\n if (!effectiveColumns || effectiveColumns === 1) {\n if (variant === \"default\" && direction === \"horizontal\") {\n return \"flex flex-wrap gap-x-4 gap-y-2\";\n }\n return \"flex flex-col gap-2\";\n }\n\n return cn(\"grid gap-2\", getGridColumnsClass(effectiveColumns));\n}\n\r\n/** Card size classes */\r\nconst cardSizeClasses = {\r\n sm: \"p-2.5 sm:p-3\",\r\n md: \"p-3 sm:p-4\",\r\n lg: \"p-4 sm:p-5\",\r\n} as const;\r\n\r\nexport function RadioField({\r\n field,\r\n path,\r\n form,\r\n autoFocus,\r\n fieldId,\r\n label,\r\n isDisabled,\r\n isReadOnly,\r\n error,\r\n}: RadioFieldProps) {\r\n const value = form.watch(path) ?? \"\";\r\n const hasError = !!error;\r\n\r\n // UI options with defaults\r\n const variant = field.ui?.variant ?? \"default\";\n const direction = field.ui?.direction ?? \"vertical\";\n const columns = field.ui?.columns;\n const cardSize = field.ui?.card?.size ?? \"md\";\n const cardBordered = field.ui?.card?.bordered ?? true;\n\r\n const isCardVariant = variant === \"card\";\r\n\r\n // Get options (only static for now, async would need useEffect)\r\n const options = Array.isArray(field.options) ? field.options : [];\r\n\r\n const handleChange = (val: unknown) => {\r\n if (isReadOnly) return;\r\n if (typeof val === \"string\") {\r\n form.setValue(path, val, {\r\n shouldDirty: true,\r\n });\r\n }\r\n };\r\n\r\n const layoutClasses = getOptionGroupLayoutClassName({\n variant,\n direction,\n columns,\n });\n\r\n return (\r\n \r\n {/* Label */}\r\n {label && (\r\n \r\n {field.required && *}\r\n {label}\r\n \r\n )}\r\n\r\n {/* Description */}\r\n {field.description && (\r\n \r\n {field.description}\r\n \r\n )}\r\n\r\n {/* Radio Group */}\r\n handleChange(val)}\n disabled={isDisabled}\n className={layoutClasses}\n aria-describedby={\n field.description ? `${fieldId}-description` : undefined\n }\n aria-readonly={isReadOnly}\n >\r\n {options.map((opt, i) => {\r\n const normalized = normalizeSelectOption(opt);\r\n const val = getSelectOptionValue(opt);\r\n const optLabel = getSelectOptionLabel(opt);\r\n const optDesc = normalized.description;\r\n const optIcon = normalized.icon;\r\n const optDisabled = isSelectOptionDisabled(opt) || isDisabled;\r\n const isSelected = value === val;\r\n const id = `${fieldId}-${i}`;\r\n\r\n // Card variant\r\n if (isCardVariant) {\r\n const showDescription = optDesc && cardSize !== \"sm\";\r\n\r\n return (\r\n \r\n \r\n
\r\n
\r\n {optIcon && (\r\n \r\n {optIcon}\r\n \r\n )}\r\n \r\n {optLabel}\r\n \r\n
\r\n {showDescription && (\r\n \r\n {optDesc}\r\n \r\n )}\r\n
\r\n \r\n );\r\n }\r\n\r\n // Default variant\r\n return (\r\n \r\n \r\n \r\n {optIcon && (\r\n \r\n {optIcon}\r\n \r\n )}\r\n {optLabel}\r\n \r\n \r\n );\r\n })}\r\n \r\n\r\n {/* Error */}\r\n {error && {error}}\r\n
\r\n );\r\n}\r\n\r\nexport function RadioFieldSkeleton({ field }: { field: RadioFieldType }) {\n const label = field.label !== false ? (field.label ?? field.name) : null;\n const variant = field.ui?.variant ?? \"default\";\n const direction = field.ui?.direction ?? \"vertical\";\n const columns = field.ui?.columns;\n const cardSize = field.ui?.card?.size ?? \"md\";\n const cardBordered = field.ui?.card?.bordered ?? true;\n\r\n const isCardVariant = variant === \"card\";\r\n const optionCount = Math.min(\r\n Array.isArray(field.options) ? field.options.length : 3,\r\n 6,\r\n );\r\n\r\n const layoutClasses = getOptionGroupLayoutClassName({\n variant,\n direction,\n columns,\n });\n\r\n // Card height based on size\r\n const cardHeights = {\r\n sm: \"h-12\",\r\n md: \"h-16\",\r\n lg: \"h-20\",\r\n };\r\n\r\n return (\r\n \r\n {/* Label skeleton */}\r\n {label &&
}\r\n\r\n {/* Description skeleton */}\r\n {field.description && (\r\n
\r\n )}\r\n\r\n {/* Options skeleton */}\r\n
\n {Array.from({ length: optionCount }).map((_, i) => {\r\n if (isCardVariant) {\r\n return (\r\n \r\n
\r\n
\r\n
\r\n {cardSize !== \"sm\" && (\r\n
\r\n )}\r\n
\r\n
\r\n );\r\n }\r\n\r\n // Default variant skeleton\r\n return (\r\n
\r\n
\r\n
\r\n
\r\n );\r\n })}\r\n
\r\n
\r\n );\r\n}\r\n", + "content": "\"use client\";\r\n\r\nimport type {\r\n RadioField as RadioFieldType,\r\n FormAdapter,\r\n} from \"@buildnbuzz/buzzform\";\r\nimport {\r\n getSelectOptionValue,\r\n getSelectOptionLabel,\r\n isSelectOptionDisabled,\r\n normalizeSelectOption,\r\n getFieldWidthStyle,\r\n} from \"@buildnbuzz/buzzform\";\r\nimport { RadioGroup, RadioGroupItem } from \"@/components/ui/radio-group\";\r\nimport {\r\n Field,\r\n FieldDescription,\r\n FieldError,\r\n FieldLabel,\r\n} from \"@/components/ui/field\";\r\nimport { cn } from \"@/lib/utils\";\r\n\r\nexport interface RadioFieldProps {\r\n field: RadioFieldType;\r\n path: string;\r\n form: FormAdapter;\r\n autoFocus?: boolean;\r\n // Computed props\r\n fieldId: string;\r\n label: React.ReactNode | null;\r\n isDisabled: boolean;\r\n isReadOnly: boolean;\r\n error?: string;\r\n}\r\n\r\ntype OptionGroupVariant = \"default\" | \"card\";\r\ntype OptionGroupDirection = \"vertical\" | \"horizontal\";\r\ntype OptionGroupColumns = 1 | 2 | 3 | 4 | string | number | undefined;\r\n\r\nfunction getGridColumnsClass(columns: OptionGroupColumns | undefined) {\r\n if (columns === 2) return \"sm:grid-cols-2\";\r\n if (columns === 3) return \"sm:grid-cols-2 md:grid-cols-3\";\r\n if (columns === 4) return \"sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4\";\r\n return undefined;\r\n}\r\n\r\nfunction getOptionGroupLayoutClassName({\r\n variant = \"default\",\r\n direction = \"vertical\",\r\n columns,\r\n}: {\r\n variant?: OptionGroupVariant;\r\n direction?: OptionGroupDirection;\r\n columns?: OptionGroupColumns;\r\n}) {\r\n const isCard = variant === \"card\";\r\n const isHorizontal = direction === \"horizontal\";\r\n\r\n // If horizontal and NO columns chosen, use fluid flex-row layout\r\n if (isHorizontal && !columns) {\r\n return \"!flex flex-row flex-wrap gap-x-4 gap-y-2\";\r\n }\r\n\r\n const effectiveColumns =\r\n isCard || isHorizontal ? (columns ?? (isCard ? 2 : undefined)) : undefined;\r\n\r\n if (!effectiveColumns || effectiveColumns === 1) {\r\n return \"flex flex-col gap-2\";\r\n }\r\n\r\n return cn(\"grid gap-2\", getGridColumnsClass(effectiveColumns));\r\n}\r\n\r\n/** Card size classes */\r\nconst cardSizeClasses = {\r\n sm: \"p-2.5 sm:p-3\",\r\n md: \"p-3 sm:p-4\",\r\n lg: \"p-4 sm:p-5\",\r\n} as const;\r\n\r\nexport function RadioField({\r\n field,\r\n path,\r\n form,\r\n autoFocus,\r\n fieldId,\r\n label,\r\n isDisabled,\r\n isReadOnly,\r\n error,\r\n}: RadioFieldProps) {\r\n const value = form.watch(path) ?? \"\";\r\n const hasError = !!error;\r\n\r\n // UI options with defaults\r\n const variant = field.ui?.variant ?? \"default\";\r\n const direction = field.ui?.direction ?? \"vertical\";\r\n const columns = field.ui?.columns;\r\n const cardSize = field.ui?.card?.size ?? \"md\";\r\n const cardBordered = field.ui?.card?.bordered ?? true;\r\n\r\n const isCardVariant = variant === \"card\";\r\n const isHorizontal = direction === \"horizontal\";\r\n\r\n // Get options (only static for now, async would need useEffect)\r\n const options = Array.isArray(field.options) ? field.options : [];\r\n\r\n const handleChange = (val: unknown) => {\r\n if (isReadOnly) return;\r\n if (typeof val === \"string\") {\r\n form.setValue(path, val, {\r\n shouldDirty: true,\r\n });\r\n }\r\n };\r\n\r\n const layoutClasses = getOptionGroupLayoutClassName({\r\n variant,\r\n direction,\r\n columns,\r\n });\r\n\r\n return (\r\n \r\n {/* Label */}\r\n {label && (\r\n \r\n {field.required && *}\r\n {label}\r\n \r\n )}\r\n\r\n {/* Description */}\r\n {field.description && (\r\n \r\n {field.description}\r\n \r\n )}\r\n\r\n {/* Radio Group */}\r\n handleChange(val)}\r\n disabled={isDisabled}\r\n className={layoutClasses}\r\n aria-describedby={\r\n field.description ? `${fieldId}-description` : undefined\r\n }\r\n aria-readonly={isReadOnly}\r\n >\r\n {options.map((opt, i) => {\r\n const normalized = normalizeSelectOption(opt);\r\n const val = getSelectOptionValue(opt);\r\n const optLabel = getSelectOptionLabel(opt);\r\n const optDesc = normalized.description;\r\n const optIcon = normalized.icon;\r\n const optDisabled = isSelectOptionDisabled(opt) || isDisabled;\r\n const isSelected = value === val;\r\n const id = `${fieldId}-${i}`;\r\n\r\n // Card variant\r\n if (isCardVariant) {\r\n const showDescription = optDesc && cardSize !== \"sm\";\r\n\r\n return (\r\n \r\n \r\n
\r\n
\r\n {optIcon && (\r\n \r\n {optIcon}\r\n \r\n )}\r\n \r\n {optLabel}\r\n \r\n
\r\n {showDescription && (\r\n \r\n {optDesc}\r\n \r\n )}\r\n
\r\n \r\n );\r\n }\r\n\r\n // Default variant\r\n return (\r\n \r\n \r\n \r\n {optIcon && (\r\n \r\n {optIcon}\r\n \r\n )}\r\n {optLabel}\r\n \r\n \r\n );\r\n })}\r\n \r\n\r\n {/* Error */}\r\n {error && {error}}\r\n
\r\n );\r\n}\r\n\r\nexport function RadioFieldSkeleton({ field }: { field: RadioFieldType }) {\r\n const label = field.label !== false ? (field.label ?? field.name) : null;\r\n const variant = field.ui?.variant ?? \"default\";\r\n const direction = field.ui?.direction ?? \"vertical\";\r\n const columns = field.ui?.columns;\r\n const cardSize = field.ui?.card?.size ?? \"md\";\r\n const cardBordered = field.ui?.card?.bordered ?? true;\r\n\r\n const isCardVariant = variant === \"card\";\r\n const optionCount = Math.min(\r\n Array.isArray(field.options) ? field.options.length : 3,\r\n 6,\r\n );\r\n\r\n const layoutClasses = getOptionGroupLayoutClassName({\r\n variant,\r\n direction,\r\n columns,\r\n });\r\n\r\n // Card height based on size\r\n const cardHeights = {\r\n sm: \"h-12\",\r\n md: \"h-16\",\r\n lg: \"h-20\",\r\n };\r\n\r\n return (\r\n \r\n {/* Label skeleton */}\r\n {label &&
}\r\n\r\n {/* Description skeleton */}\r\n {field.description && (\r\n
\r\n )}\r\n\r\n {/* Options skeleton */}\r\n
\r\n {Array.from({ length: optionCount }).map((_, i) => {\r\n if (isCardVariant) {\r\n return (\r\n \r\n
\r\n
\r\n
\r\n {cardSize !== \"sm\" && (\r\n
\r\n )}\r\n
\r\n
\r\n );\r\n }\r\n\r\n // Default variant skeleton\r\n return (\r\n
\r\n
\r\n
\r\n
\r\n );\r\n })}\r\n
\r\n
\r\n );\r\n}\r\n", "type": "registry:component", "target": "components/buzzform/fields/radio.tsx" } diff --git a/apps/web/registry/base/fields/checkbox-group.tsx b/apps/web/registry/base/fields/checkbox-group.tsx index 54f9b74..b6d3da7 100644 --- a/apps/web/registry/base/fields/checkbox-group.tsx +++ b/apps/web/registry/base/fields/checkbox-group.tsx @@ -27,7 +27,7 @@ import { cn } from "@/lib/utils"; type OptionValue = string | number | boolean; type OptionGroupVariant = "default" | "card"; type OptionGroupDirection = "vertical" | "horizontal"; -type OptionGroupColumns = 1 | 2 | 3 | 4; +type OptionGroupColumns = 1 | 2 | 3 | 4 | string | number | undefined; export interface CheckboxGroupFieldProps { field: CheckboxGroupFieldType; @@ -60,11 +60,16 @@ function getOptionGroupLayoutClassName({ direction?: OptionGroupDirection; columns?: OptionGroupColumns; }) { - const usesGridColumns = variant === "card" || direction === "horizontal"; - // Default to 2 columns for horizontal direction so users see immediate feedback - const effectiveColumns = usesGridColumns - ? (columns ?? (direction === "horizontal" ? 2 : undefined)) - : undefined; + const isCard = variant === "card"; + const isHorizontal = direction === "horizontal"; + + // If horizontal and NO columns chosen, use fluid flex-row layout + if (isHorizontal && !columns) { + return "!flex flex-row flex-wrap gap-x-4 gap-y-2"; + } + + const effectiveColumns = + isCard || isHorizontal ? (columns ?? (isCard ? 2 : undefined)) : undefined; if (!effectiveColumns || effectiveColumns === 1) { return "flex flex-col gap-2"; @@ -221,6 +226,7 @@ export function CheckboxGroupField({ const maxSelected = field.maxSelected; const isCardVariant = variant === "card"; + const isHorizontal = direction === "horizontal"; const layoutClasses = getOptionGroupLayoutClassName({ variant, direction, @@ -370,6 +376,7 @@ export function CheckboxGroupField({ orientation="horizontal" className={cn( "items-center gap-2.5 space-y-0", + isHorizontal && !columns && "w-auto", optDisabled && "opacity-50 cursor-not-allowed", )} > diff --git a/apps/web/registry/base/fields/radio.tsx b/apps/web/registry/base/fields/radio.tsx index 2e87f39..1bcb291 100644 --- a/apps/web/registry/base/fields/radio.tsx +++ b/apps/web/registry/base/fields/radio.tsx @@ -35,7 +35,7 @@ export interface RadioFieldProps { type OptionGroupVariant = "default" | "card"; type OptionGroupDirection = "vertical" | "horizontal"; -type OptionGroupColumns = 1 | 2 | 3 | 4; +type OptionGroupColumns = 1 | 2 | 3 | 4 | string | number | undefined; function getGridColumnsClass(columns: OptionGroupColumns | undefined) { if (columns === 2) return "sm:grid-cols-2"; @@ -53,11 +53,16 @@ function getOptionGroupLayoutClassName({ direction?: OptionGroupDirection; columns?: OptionGroupColumns; }) { - const usesGridColumns = variant === "card" || direction === "horizontal"; - // Default to 2 columns for horizontal direction so users see immediate feedback - const effectiveColumns = usesGridColumns - ? (columns ?? (direction === "horizontal" ? 2 : undefined)) - : undefined; + const isCard = variant === "card"; + const isHorizontal = direction === "horizontal"; + + // If horizontal and NO columns chosen, use fluid flex-row layout + if (isHorizontal && !columns) { + return "!flex flex-row flex-wrap gap-x-4 gap-y-2"; + } + + const effectiveColumns = + isCard || isHorizontal ? (columns ?? (isCard ? 2 : undefined)) : undefined; if (!effectiveColumns || effectiveColumns === 1) { return "flex flex-col gap-2"; @@ -95,6 +100,7 @@ export function RadioField({ const cardBordered = field.ui?.card?.bordered ?? true; const isCardVariant = variant === "card"; + const isHorizontal = direction === "horizontal"; // Get options (only static for now, async would need useEffect) const options = Array.isArray(field.options) ? field.options : []; @@ -163,7 +169,7 @@ export function RadioField({ return (