Files
clean-architecture-frontend…/src/presentation/design-system/primitives/forms.tsx
T

412 lines
10 KiB
TypeScript

import {
forwardRef,
useEffect,
useId,
useRef,
useState,
} from "react";
import { CloseIcon, SearchIcon } from "../icons/semantic-icons.js";
import { Field, IconButton } from "./core.js";
import type { TextFieldProps } from "./core.js";
import { useLocale } from "../../i18n/index.js";
type FieldCopy = Readonly<{
id?: string;
label: string;
description?: string;
error?: string;
}>;
export type TextAreaProps = Omit<
React.TextareaHTMLAttributes<HTMLTextAreaElement>,
"id"
> &
FieldCopy &
Readonly<{ maxLengthMessage?: (remaining: number) => string }>;
export const TextArea = forwardRef<HTMLTextAreaElement, TextAreaProps>(
function TextArea(
{
id,
label,
description,
error,
required,
className = "",
maxLength,
maxLengthMessage,
value,
defaultValue,
...props
},
ref,
) {
const { message } = useLocale();
const [uncontrolledValue, setUncontrolledValue] = useState(
String(defaultValue ?? ""),
);
const currentValue =
value === undefined ? uncontrolledValue : String(value ?? "");
const remaining =
typeof maxLength === "number" ? maxLength - currentValue.length : null;
return (
<Field
className={className}
description={description}
error={error}
id={id}
label={label}
required={required}
>
{({ controlId, describedBy, invalid }) => (
<>
<textarea
{...props}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
className="ui-field__input ui-field__textarea"
defaultValue={value === undefined ? defaultValue : undefined}
id={controlId}
maxLength={maxLength}
onChange={(event) => {
if (value === undefined) setUncontrolledValue(event.currentTarget.value);
props.onChange?.(event);
}}
ref={ref}
required={required}
value={value}
/>
{remaining !== null ? (
<output className="ui-field__counter">
{maxLengthMessage
? maxLengthMessage(remaining)
: message("form.remaining", { count: remaining })}
</output>
) : null}
</>
)}
</Field>
);
},
);
export type SelectOption = Readonly<{
value: string;
label: string;
disabled?: boolean;
}>;
export type SelectProps = Omit<
React.SelectHTMLAttributes<HTMLSelectElement>,
"id" | "children"
> &
FieldCopy &
Readonly<{
options: readonly SelectOption[];
placeholder?: string;
}>;
export const Select = forwardRef<HTMLSelectElement, SelectProps>(
function Select(
{
id,
label,
description,
error,
options,
placeholder,
required,
className = "",
...props
},
ref,
) {
return (
<Field
className={className}
description={description}
error={error}
id={id}
label={label}
required={required}
>
{({ controlId, describedBy, invalid }) => (
<select
{...props}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
className="ui-field__input ui-field__select"
id={controlId}
ref={ref}
required={required}
>
{placeholder ? (
<option disabled value="">
{placeholder}
</option>
) : null}
{options.map((option) => (
<option
disabled={option.disabled}
key={option.value}
value={option.value}
>
{option.label}
</option>
))}
</select>
)}
</Field>
);
},
);
export type CheckboxProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"type"
> &
Readonly<{
label: string;
description?: string;
error?: string;
indeterminate?: boolean;
}>;
export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
function Checkbox(
{ label, description, error, indeterminate = false, id, ...props },
forwardedRef,
) {
const generatedId = useId();
const controlId = id ?? `checkbox-${generatedId}`;
const inputRef = useRef<HTMLInputElement | null>(null);
const descriptionId = description ? `${controlId}-description` : undefined;
const errorId = error ? `${controlId}-error` : undefined;
useEffect(() => {
if (inputRef.current) inputRef.current.indeterminate = indeterminate;
}, [indeterminate]);
return (
<div className="ui-choice-field">
<label className="ui-choice-field__control" htmlFor={controlId}>
<input
{...props}
aria-describedby={
[descriptionId, errorId].filter(Boolean).join(" ") || undefined
}
aria-invalid={error ? true : undefined}
id={controlId}
ref={(node) => {
inputRef.current = node;
if (typeof forwardedRef === "function") forwardedRef(node);
else if (forwardedRef) forwardedRef.current = node;
}}
type="checkbox"
/>
<span>{label}</span>
</label>
{description ? (
<p className="ui-field__description" id={descriptionId}>
{description}
</p>
) : null}
{error ? (
<p className="ui-field__error" id={errorId}>
{error}
</p>
) : null}
</div>
);
},
);
export type RadioOption = Readonly<{
value: string;
label: string;
description?: string;
disabled?: boolean;
}>;
export type RadioGroupProps = Readonly<{
name: string;
label: string;
options: readonly RadioOption[];
value?: string;
defaultValue?: string;
onChange?(value: string): void;
disabled?: boolean;
error?: string;
}>;
export function RadioGroup({
name,
label,
options,
value,
defaultValue,
onChange,
disabled,
error,
}: RadioGroupProps) {
const [internalValue, setInternalValue] = useState(defaultValue ?? "");
const selected = value ?? internalValue;
const errorId = useId();
const refs = useRef<Array<HTMLInputElement | null>>([]);
function choose(nextValue: string) {
if (value === undefined) setInternalValue(nextValue);
onChange?.(nextValue);
}
return (
<fieldset
aria-describedby={error ? errorId : undefined}
className="ui-radio-group"
disabled={disabled}
>
<legend>{label}</legend>
{options.map((option, index) => (
<label className="ui-choice-field__control" key={option.value}>
<input
checked={selected === option.value}
disabled={option.disabled}
name={name}
onChange={() => choose(option.value)}
onKeyDown={(event) => {
if (!["ArrowDown", "ArrowRight", "ArrowUp", "ArrowLeft"].includes(event.key)) {
return;
}
event.preventDefault();
const direction =
event.key === "ArrowDown" || event.key === "ArrowRight" ? 1 : -1;
let next = index;
do {
next = (next + direction + options.length) % options.length;
} while (options[next]?.disabled && next !== index);
const nextOption = options[next];
if (nextOption && !nextOption.disabled) {
choose(nextOption.value);
refs.current[next]?.focus();
}
}}
ref={(node) => {
refs.current[index] = node;
}}
type="radio"
value={option.value}
/>
<span>
{option.label}
{option.description ? <small>{option.description}</small> : null}
</span>
</label>
))}
{error ? (
<p className="ui-field__error" id={errorId}>
{error}
</p>
) : null}
</fieldset>
);
}
export type SwitchProps = Readonly<{
label: string;
checked: boolean;
onChange(checked: boolean): void;
disabled?: boolean;
description?: string;
}>;
export function Switch({
label,
checked,
onChange,
disabled,
description,
}: SwitchProps) {
const descriptionId = useId();
return (
<div className="ui-switch-field">
<button
aria-checked={checked}
aria-describedby={description ? descriptionId : undefined}
className="ui-switch"
disabled={disabled}
onClick={() => onChange(!checked)}
role="switch"
type="button"
>
<span aria-hidden="true" className="ui-switch__thumb" />
<span>{label}</span>
</button>
{description ? (
<p className="ui-field__description" id={descriptionId}>
{description}
</p>
) : null}
</div>
);
}
export type SearchFieldProps = Omit<TextFieldProps, "type"> &
Readonly<{
clearLabel: string;
onClear(): void;
}>;
export const SearchField = forwardRef<HTMLInputElement, SearchFieldProps>(
function SearchField(
{
clearLabel,
onClear,
value,
className = "",
id,
label,
description,
error,
required,
...inputProps
},
ref,
) {
return (
<div className={`ui-search-field ${className}`.trim()}>
<SearchIcon />
<Field
description={description}
error={error}
id={id}
label={label}
required={required}
>
{({ controlId, describedBy, invalid }) => (
<input
{...inputProps}
aria-describedby={describedBy}
aria-invalid={invalid || undefined}
className="ui-field__input"
id={controlId}
ref={ref}
type="search"
value={value}
/>
)}
</Field>
{String(value ?? "").length > 0 ? (
<IconButton
accessibleName={clearLabel}
onClick={onClear}
variant="ghost"
>
<CloseIcon />
</IconButton>
) : null}
</div>
);
},
);