// shadcn-styled widgets that plug into the @voltro/ui widget seam. Plain,
// accessible HTML with Tailwind utility classes against the design tokens in
// `./tokens.css`. Drop them in app-wide:
//
// import { shadcnWidgets } from '@voltro/ui-shadcn'
// import '@voltro/ui-shadcn/tokens.css'
// …
//
// They implement the SAME `Widget` contract as @voltro/ui's plain defaults —
// the seam falls back to the plain default for any kind not overridden here.
import { useId, type ReactNode } from 'react'
import type { Widget, WidgetProps, WidgetRegistry } from '@voltro/ui'
const cx = {
field: 'voltro-field flex flex-col gap-1',
label: 'text-sm font-medium text-foreground',
control:
'block w-full rounded-md border border-input bg-background px-3 py-2 text-sm shadow-sm ' +
'focus:outline-none focus:ring-2 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
checkbox: 'h-4 w-4 rounded border-input text-primary focus:ring-2 focus:ring-ring',
error: 'text-sm text-destructive',
}
const asString = (v: unknown): string => (v === undefined || v === null ? '' : String(v))
const describedBy = (id: string, error: string | undefined): string | undefined =>
error !== undefined ? `${id}-error` : undefined
const Shell = (props: {
readonly id: string
readonly label: string
readonly required: boolean
readonly error?: string | undefined
readonly children: ReactNode
}): ReactNode => (
{props.children}
{props.error !== undefined ? (
{props.error}
) : null}
)
const inputWidget = (type: string, toValue: (raw: string) => unknown): Widget => {
const Component: Widget = (props) => {
const id = useId()
return (
props.onChange(toValue(e.target.value))}
disabled={props.disabled}
required={props.required}
aria-invalid={props.error !== undefined}
aria-describedby={describedBy(id, props.error)}
/>
)
}
return Component
}
const TextareaWidget: Widget = (props) => {
const id = useId()
return (
)
}
const CheckboxWidget: Widget = (props) => {
const id = useId()
return (
props.onChange(e.target.checked)}
disabled={props.disabled}
aria-invalid={props.error !== undefined}
aria-describedby={describedBy(id, props.error)}
/>
{props.error !== undefined ? (
{props.error}
) : null}
)
}
const SwitchWidget: Widget = (props) => {
const id = useId()
const on = props.value === true
return (
{props.error !== undefined ? (
{props.error}
) : null}
)
}
const SelectWidget: Widget = (props) => {
const id = useId()
return (
)
}
const RadioWidget: Widget = (props) => {
const id = useId()
return (
)
}
const MultiSelectWidget: Widget = (props) => {
const id = useId()
const selected = Array.isArray(props.value) ? props.value.map(String) : []
return (
)
}
const FileWidget: Widget = (props) => {
const id = useId()
return (
props.onChange(e.target.value)}
disabled={props.disabled}
required={props.required}
aria-invalid={props.error !== undefined}
aria-describedby={describedBy(id, props.error)}
/>
)
}
/** Tailwind-styled widgets keyed by widget kind. Pass to
* ``. Any kind not listed
* here falls back to @voltro/ui's plain accessible default — today that is
* only `hidden` (invisible anyway) and `custom` (the render-prop signal).
* Value contracts mirror the plain defaults exactly: `daterange` and
* `async-select` are styled text inputs until the framework grows richer
* bindings for those kinds (same ceiling as @voltro/ui's defaults). */
export const shadcnWidgets: WidgetRegistry = {
text: inputWidget('text', (s) => s),
number: inputWidget('number', (s) => (s === '' ? undefined : Number(s))),
date: inputWidget('date', (s) => s),
datetime: inputWidget('datetime-local', (s) => s),
daterange: inputWidget('text', (s) => s),
'async-select': inputWidget('text', (s) => s),
textarea: TextareaWidget,
checkbox: CheckboxWidget,
switch: SwitchWidget,
select: SelectWidget,
radio: RadioWidget,
'multi-select': MultiSelectWidget,
file: FileWidget,
}