412 lines
10 KiB
TypeScript
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>
|
|
);
|
|
},
|
|
);
|