324 lines
7.7 KiB
TypeScript
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>
|
|
);
|
|
}
|