Files
ersatztv/web/src/components/forms.tsx
T

324 lines
7.7 KiB
TypeScript

import type { ChangeEvent, CSSProperties, FocusEvent, MouseEvent, ReactNode } from 'react';
import { Check, ChevronDown, Minus } from 'lucide-react';
import { Spinner } from './feedback';
type ControlSize = 'sm' | 'md';
function classNames(...values: Array<string | false | null | undefined>): string {
return values.filter(Boolean).join(' ');
}
export interface ButtonProps {
children?: ReactNode;
variant?: 'primary' | 'secondary' | 'ghost' | 'danger';
size?: ControlSize;
startIcon?: ReactNode;
endIcon?: ReactNode;
disabled?: boolean;
loading?: boolean;
fullWidth?: boolean;
type?: 'button' | 'submit' | 'reset';
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
style?: CSSProperties;
title?: string;
}
export function Button({
children,
variant = 'primary',
size = 'md',
startIcon,
endIcon,
disabled = false,
loading = false,
fullWidth = false,
type = 'button',
onClick,
style,
title
}: ButtonProps) {
return (
<button
type={type}
disabled={disabled || loading}
onClick={onClick}
title={title}
className={classNames(
'ctv-button',
`ctv-button-${variant}`,
size === 'sm' && 'ctv-button-sm',
fullWidth && 'ctv-button-full'
)}
style={style}
>
{loading ? <Spinner size={14} stroke={3} /> : startIcon}
<span>{children}</span>
{!loading && endIcon}
</button>
);
}
export interface IconButtonProps {
children?: ReactNode;
size?: ControlSize;
variant?: 'ghost' | 'solid';
active?: boolean;
disabled?: boolean;
title?: string;
onClick?: (e: MouseEvent<HTMLButtonElement>) => void;
style?: CSSProperties;
}
export function IconButton({
children,
size = 'md',
variant = 'ghost',
active = false,
disabled = false,
title,
onClick,
style
}: IconButtonProps) {
return (
<button
type="button"
title={title}
aria-label={title}
disabled={disabled}
onClick={onClick}
className={classNames(
'ctv-icon-button',
size === 'sm' && 'ctv-icon-button-sm',
variant === 'solid' && 'ctv-icon-button-solid',
active && 'ctv-icon-button-active'
)}
style={style}
>
{children}
</button>
);
}
export interface InputProps {
label?: string;
value?: string;
onChange?: (e: ChangeEvent<HTMLInputElement>) => void;
placeholder?: string;
leadingIcon?: ReactNode;
trailing?: ReactNode;
error?: string | null;
disabled?: boolean;
size?: ControlSize;
type?: string;
fullWidth?: boolean;
style?: CSSProperties;
// Native <input> passthroughs — added for numeric fields (e.g. the multi-collection weight input,
// #404): min/max bound the stepper, inputMode hints the mobile keyboard, onBlur normalizes on commit.
min?: number;
max?: number;
inputMode?: 'numeric' | 'decimal' | 'text';
onBlur?: (e: FocusEvent<HTMLInputElement>) => void;
ariaLabel?: string;
}
export function Input({
label,
value,
onChange,
placeholder,
leadingIcon,
trailing,
error = null,
disabled = false,
size = 'md',
type = 'text',
fullWidth = true,
style,
min,
max,
inputMode,
onBlur,
ariaLabel
}: InputProps) {
return (
<label className={classNames('ctv-field', fullWidth && 'ctv-field-full')}>
{label && <span className="ctv-field-label">{label}</span>}
<span
className={classNames(
'ctv-field-frame',
size === 'sm' && 'ctv-field-frame-sm',
error && 'ctv-field-frame-error',
disabled && 'ctv-field-frame-disabled'
)}
style={style}
>
{leadingIcon && <span className="ctv-field-icon">{leadingIcon}</span>}
<input
className="ctv-input"
type={type}
value={value}
onChange={onChange}
onBlur={onBlur}
placeholder={placeholder}
disabled={disabled}
min={min}
max={max}
inputMode={inputMode}
aria-label={ariaLabel}
/>
{trailing && <span className="ctv-field-trailing">{trailing}</span>}
</span>
{error && <span className="ctv-field-error">{error}</span>}
</label>
);
}
export interface SelectOption {
value: string;
label: string;
}
export interface SelectProps {
label?: string;
value?: string;
onChange?: (e: ChangeEvent<HTMLSelectElement>) => void;
options?: Array<string | SelectOption>;
disabled?: boolean;
size?: ControlSize;
fullWidth?: boolean;
style?: CSSProperties;
}
export function Select({
label,
value,
onChange,
options = [],
disabled = false,
size = 'md',
fullWidth = true,
style
}: SelectProps) {
const normalizedOptions = options.map((option) =>
typeof option === 'string' ? { value: option, label: option } : option
);
return (
<label className={classNames('ctv-field', fullWidth && 'ctv-field-full')}>
{label && <span className="ctv-field-label">{label}</span>}
<span
className={classNames(
'ctv-field-frame',
'ctv-select-frame',
size === 'sm' && 'ctv-field-frame-sm',
disabled && 'ctv-field-frame-disabled'
)}
style={style}
>
<select className="ctv-select" value={value} onChange={onChange} disabled={disabled}>
{normalizedOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<ChevronDown className="ctv-select-chevron" aria-hidden="true" size={14} />
</span>
</label>
);
}
export interface SwitchProps {
checked?: boolean;
onChange?: (next: boolean) => void;
label?: string;
// When true, `label` still becomes the accessible name (aria-label) but is not rendered as
// visible text — for callers that already show the same caption elsewhere (e.g. a settings
// Row label) and would otherwise duplicate it.
hideLabel?: boolean;
disabled?: boolean;
size?: ControlSize;
style?: CSSProperties;
}
export function Switch({
checked = false,
onChange,
label,
hideLabel = false,
disabled = false,
size = 'md',
style
}: SwitchProps) {
return (
<span
className={classNames(
'ctv-switch',
size === 'sm' && 'ctv-switch-sm',
disabled && 'ctv-switch-disabled'
)}
style={style}
>
<button
type="button"
role="switch"
aria-label={label}
aria-checked={checked}
aria-disabled={disabled}
className="ctv-switch-control"
onClick={() => {
if (!disabled) {
onChange?.(!checked);
}
}}
>
<span className="ctv-switch-knob" />
</button>
{label && !hideLabel && <span>{label}</span>}
</span>
);
}
export interface CheckboxProps {
checked?: boolean;
onChange?: (next: boolean) => void;
label?: string;
indeterminate?: boolean;
disabled?: boolean;
style?: CSSProperties;
}
export function Checkbox({
checked = false,
onChange,
label,
indeterminate = false,
disabled = false,
style
}: CheckboxProps) {
return (
<span className={classNames('ctv-checkbox', disabled && 'ctv-checkbox-disabled')} style={style}>
<button
type="button"
role="checkbox"
aria-label={label}
aria-checked={indeterminate ? 'mixed' : checked}
aria-disabled={disabled}
className="ctv-checkbox-control"
onClick={() => {
if (!disabled) {
onChange?.(!checked);
}
}}
>
{indeterminate ? (
<Minus aria-hidden="true" size={11} strokeWidth={3} />
) : checked ? (
<Check aria-hidden="true" size={12} strokeWidth={3} />
) : null}
</button>
{label && <span>{label}</span>}
</span>
);
}