Input
registry/pouf/Input.tsx
import { cva } from 'class-variance-authority'
import {
forwardRef,
useId,
type InputHTMLAttributes,
type ReactNode,
type TextareaHTMLAttributes,
} from 'react'
interface FieldProps {
label: string
children: (id: string, describedBy: string | undefined) => ReactNode
hint?: string
error?: string
}
/** Wraps any control with a real <label for>, hint and error text, and wires
* aria-describedby. Screens pass a render fn so the same wrapper serves Input,
* Select and Switch without duplicating the a11y plumbing. */
export function Field({ label, children, hint, error }: FieldProps) {
const id = useId()
const describedBy = error ? `${id}-err` : hint ? `${id}-hint` : undefined
return (
<div className="pouf-field flex flex-col gap-(--s2)">
{/* Labels use ink so their compact uppercase treatment stays emphatic. */}
<label className="pouf-label text-[13px] font-black tracking-[0.6px] uppercase text-ink" htmlFor={id}>
{label}
</label>
{children(id, describedBy)}
{hint && !error && (
<span className="pouf-hint text-[13px] font-bold text-muted" id={`${id}-hint`}>
{hint}
</span>
)}
{/* FIELD-level validation message: a compact one-liner under a control.
* NOT the page alert cushion (ErrorNote) — this one must stay quiet, so
* it keeps the flat little pill it always was. self-start: hug the
* message rather than stretch to fill a flex/grid cell. */}
{error && (
<span
className="pouf-error text-[13px] font-extrabold text-[var(--on-accent)] bg-orange rounded-xl py-(--s2) px-(--s3) [align-self:start] max-w-full"
id={`${id}-err`}
role="alert"
>
{error}
</span>
)}
</div>
)
}
/* One cva for every input voice in the system, exported because the Select and
* Combobox triggers (and Combobox's search input) wear the same field chrome.
* `bare` is the NumberInput-capsule voice: the capsule carries the chrome, so
* the input inside goes transparent and shadowless, and even its disabled fade
* is suppressed (the capsule already fades; double-fading blurs the value).
* Shadow ownership is per-variant because same-property utilities don't
* cascade; a focused invalid input shows the focus ring (pseudo specificity),
* matching the original selector order. */
export const inputClasses = cva(
'pouf-input font-bold text-[15px] text-ink border-none rounded-control w-full placeholder:text-muted',
{
variants: {
bare: {
false: 'bg-bg px-5 pt-[14px] pb-[18px] min-h-[52px] focus:outline-none focus-visible:outline-none disabled:opacity-55 disabled:cursor-not-allowed',
true: 'bg-transparent flex-1 min-w-0 min-h-0 text-center px-0 pt-[6px] pb-[10px] [box-shadow:none] focus:[box-shadow:none] focus:outline-none focus-visible:outline-none disabled:opacity-100 disabled:cursor-not-allowed',
},
invalid: { true: '', false: '' },
/* mono OWNS font-family — a base font-pouf would fight it (same-property
* utilities don't cascade; stylesheet order is not the cascade). */
mono: {
true: "[font-family:ui-monospace,'SF_Mono',Menlo,monospace] [font-variant-numeric:tabular-nums]",
false: 'font-pouf',
},
},
compoundVariants: [
{ bare: false, invalid: false, className: 'cushion-field focus:[box-shadow:var(--pouf-field-focus)]' },
{
bare: false,
invalid: true,
className:
'[box-shadow:var(--pouf-field),inset_0_0_0_3px_var(--orange)] focus:[box-shadow:var(--pouf-field-focus)]',
},
],
defaultVariants: { bare: false, invalid: false, mono: false },
},
)
interface InputProps
extends Omit<
InputHTMLAttributes<HTMLInputElement>,
'children' | 'className' | 'style' | 'value' | 'onChange'
> {
value: string
onChange: (value: string) => void
onBlur?: InputHTMLAttributes<HTMLInputElement>['onBlur']
id?: string
name?: string
describedBy?: string
placeholder?: string
type?: InputHTMLAttributes<HTMLInputElement>['type']
autoComplete?: string
inputMode?: InputHTMLAttributes<HTMLInputElement>['inputMode']
autoCapitalize?: string
spellCheck?: boolean
required?: boolean
mono?: boolean
invalid?: boolean
disabled?: boolean
label?: string
/** Internal: the NumberInput capsule carries the chrome. */
bare?: boolean
}
export const Input = forwardRef<HTMLInputElement, InputProps>(function Input(
{
value,
onChange,
describedBy,
type = 'text',
mono,
invalid,
label,
bare,
...nativeProps
},
ref,
) {
return (
<input
ref={ref}
{...nativeProps}
className={inputClasses({ bare: !!bare, invalid: !!invalid, mono })}
value={value}
onChange={(e) => onChange(e.target.value)}
type={type}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
aria-label={label}
/>
)
})
/* ------------------------------------------------------------------ */
/* Textarea */
/* ------------------------------------------------------------------ */
interface TextareaProps
extends Omit<
TextareaHTMLAttributes<HTMLTextAreaElement>,
'children' | 'className' | 'style' | 'value' | 'onChange'
> {
value: string
onChange: (value: string) => void
onBlur?: TextareaHTMLAttributes<HTMLTextAreaElement>['onBlur']
id?: string
name?: string
describedBy?: string
placeholder?: string
autoComplete?: string
spellCheck?: boolean
required?: boolean
rows?: number
mono?: boolean
invalid?: boolean
disabled?: boolean
label?: string
}
export const Textarea = forwardRef<HTMLTextAreaElement, TextareaProps>(function Textarea(
{
value,
onChange,
describedBy,
rows = 4,
mono,
invalid,
label,
...nativeProps
},
ref,
) {
return (
<textarea
ref={ref}
{...nativeProps}
className={`${inputClasses({ invalid: !!invalid, mono })} pouf-textarea resize-y min-h-[100px]`}
value={value}
onChange={(e) => onChange(e.target.value)}
rows={rows}
aria-invalid={invalid || undefined}
aria-describedby={describedBy}
aria-label={label}
/>
)
})
No selection tags yet — empty means no evidence, not lack of support.
DependenciesBundle weight · light
class-variance-authority