feat: add design system platform
This commit is contained in:
@@ -0,0 +1,409 @@
|
||||
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";
|
||||
|
||||
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 [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)
|
||||
: `${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>
|
||||
);
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user