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

470 lines
11 KiB
TypeScript

import {
forwardRef,
useEffect,
useId,
useImperativeHandle,
useRef,
} from "react";
import { createPortal } from "react-dom";
import { CloseIcon } from "../icons/semantic-icons.js";
import { useLocale } from "../../i18n/index.js";
export type ButtonVariant = "primary" | "secondary" | "danger" | "ghost";
export type ButtonSize = "default" | "compact";
export type ButtonProps = React.ButtonHTMLAttributes<HTMLButtonElement> &
Readonly<{
variant?: ButtonVariant;
size?: ButtonSize;
pending?: boolean;
pendingLabel?: string;
}>;
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
function Button(
{
variant = "primary",
size = "default",
pending = false,
pendingLabel,
className = "",
type = "button",
children,
disabled,
...props
},
ref,
) {
const classes = [
"ui-button",
`ui-button--${variant}`,
size === "compact" ? "ui-button--compact" : "",
pending ? "ui-button--pending" : "",
className,
]
.filter(Boolean)
.join(" ");
return (
<button
{...props}
aria-busy={pending || undefined}
className={classes}
disabled={disabled}
ref={ref}
type={type}
>
{pending ? (
<>
<Spinner decorative />
{pendingLabel ?? children}
</>
) : (
children
)}
</button>
);
},
);
export type LinkButtonProps = React.AnchorHTMLAttributes<HTMLAnchorElement> &
Readonly<{
href: string;
variant?: ButtonVariant;
size?: ButtonSize;
}>;
export const LinkButton = forwardRef<HTMLAnchorElement, LinkButtonProps>(
function LinkButton(
{
href,
variant = "primary",
size = "default",
className = "",
...props
},
ref,
) {
return (
<a
{...props}
className={[
"ui-button",
`ui-button--${variant}`,
size === "compact" ? "ui-button--compact" : "",
className,
]
.filter(Boolean)
.join(" ")}
href={href}
ref={ref}
/>
);
},
);
export type IconButtonProps = Omit<ButtonProps, "aria-label" | "children"> &
Readonly<{
accessibleName: string;
children: React.ReactElement;
}>;
export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(
function IconButton({ accessibleName, className = "", ...props }, ref) {
return (
<Button
{...props}
aria-label={accessibleName}
className={`ui-icon-button ${className}`.trim()}
ref={ref}
/>
);
},
);
type FieldFrameProps = Readonly<{
id?: string;
label: string;
description?: string;
error?: string;
required?: boolean;
className?: string;
children(
contract: Readonly<{
controlId: string;
describedBy: string | undefined;
invalid: boolean;
}>,
): React.ReactNode;
}>;
export function Field({
id,
label,
description,
error,
required,
className = "",
children,
}: FieldFrameProps) {
const generatedId = useId();
const controlId = id ?? `field-${generatedId}`;
const descriptionId = description ? `${controlId}-description` : undefined;
const errorId = error ? `${controlId}-error` : undefined;
const describedBy = [descriptionId, errorId].filter(Boolean).join(" ");
return (
<div className={`ui-field ${className}`.trim()}>
<label className="ui-field__label" htmlFor={controlId}>
{label}
{required ? <span aria-hidden="true"> *</span> : null}
</label>
{description ? (
<p className="ui-field__description" id={descriptionId}>
{description}
</p>
) : null}
{children({
controlId,
describedBy: describedBy || undefined,
invalid: Boolean(error),
})}
{error ? (
<p className="ui-field__error" id={errorId}>
{error}
</p>
) : null}
</div>
);
}
export type TextFieldProps = Omit<
React.InputHTMLAttributes<HTMLInputElement>,
"id"
> &
Readonly<{
id?: string;
label: string;
description?: string;
error?: string;
}>;
export const TextField = forwardRef<HTMLInputElement, TextFieldProps>(
function TextField(
{
id,
label,
description,
error,
className = "",
required,
...inputProps
},
ref,
) {
return (
<Field
className={className}
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}
required={required}
/>
)}
</Field>
);
},
);
export type CardProps = Readonly<{
title: string;
headingLevel?: 2 | 3 | 4;
description?: string;
children?: React.ReactNode;
footer?: React.ReactNode;
className?: string;
}>;
export function Card({
title,
headingLevel = 3,
description,
children,
footer,
className = "",
}: CardProps) {
const titleId = useId();
const Heading = `h${headingLevel}` as "h2" | "h3" | "h4";
return (
<article
aria-labelledby={titleId}
className={`ui-card ${className}`.trim()}
>
<div className="ui-card__header">
<Heading id={titleId}>{title}</Heading>
{description ? <p>{description}</p> : null}
</div>
{children ? <div className="ui-card__content">{children}</div> : null}
{footer ? <footer className="ui-card__footer">{footer}</footer> : null}
</article>
);
}
export type AlertProps = Readonly<{
title: string;
children?: React.ReactNode;
variant?: "info" | "success" | "warning" | "danger";
dismissLabel?: string;
onDismiss?: () => void;
}>;
export function Alert({
title,
children,
variant = "info",
dismissLabel,
onDismiss,
}: AlertProps) {
const { message } = useLocale();
return (
<section
className={`ui-alert ui-alert--${variant}`}
role={variant === "danger" ? "alert" : "status"}
>
<div>
<strong>{title}</strong>
{children ? <div className="ui-alert__content">{children}</div> : null}
</div>
{onDismiss ? (
<IconButton
accessibleName={
dismissLabel ?? message("action.alertCloseNamed", { title })
}
className="ui-alert__dismiss"
onClick={onDismiss}
variant="ghost"
>
<CloseIcon />
</IconButton>
) : null}
</section>
);
}
export type BadgeProps = Readonly<{
children: React.ReactNode;
variant?: "neutral" | "info" | "success" | "warning" | "danger";
}>;
export function Badge({ children, variant = "neutral" }: BadgeProps) {
return <span className={`ui-badge ui-badge--${variant}`}>{children}</span>;
}
export type DialogProps = Readonly<{
open: boolean;
onClose(): void;
title: string;
description?: string;
closeLabel?: string;
children?: React.ReactNode;
actions?: React.ReactNode;
className?: string;
}>;
export const Dialog = forwardRef<HTMLDialogElement, DialogProps>(
function Dialog(
{
open,
onClose,
title,
description,
closeLabel,
children,
actions,
className = "",
},
forwardedRef,
) {
const { message } = useLocale();
const dialogRef = useRef<HTMLDialogElement | null>(null);
const previousFocusRef = useRef<HTMLElement | null>(null);
const focusRestoreGenerationRef = useRef(0);
const titleId = useId();
const descriptionId = useId();
useImperativeHandle(forwardedRef, () => dialogRef.current!, []);
useEffect(() => {
const dialog = dialogRef.current;
if (!dialog) return;
const generation = focusRestoreGenerationRef.current + 1;
focusRestoreGenerationRef.current = generation;
if (open) {
previousFocusRef.current =
document.activeElement instanceof HTMLElement
? document.activeElement
: null;
if (!dialog.open) {
if (typeof dialog.showModal === "function") dialog.showModal();
else dialog.setAttribute("open", "");
}
const firstFocusable = dialog.querySelector<HTMLElement>(
"[autofocus], button, [href], input, select, textarea, [tabindex]:not([tabindex='-1'])",
);
firstFocusable?.focus();
return;
}
if (dialog.open) {
if (typeof dialog.close === "function") dialog.close();
else dialog.removeAttribute("open");
}
const previousFocus = previousFocusRef.current;
previousFocusRef.current = null;
const restoreFocus = () => {
if (
focusRestoreGenerationRef.current === generation &&
previousFocus?.isConnected
) {
previousFocus.focus();
}
};
if (typeof globalThis.requestAnimationFrame === "function") {
const frame = globalThis.requestAnimationFrame(restoreFocus);
return () => globalThis.cancelAnimationFrame(frame);
}
queueMicrotask(restoreFocus);
}, [open]);
return (
<dialog
aria-describedby={description ? descriptionId : undefined}
aria-labelledby={titleId}
className={`ui-dialog ${className}`.trim()}
onCancel={(event) => {
event.preventDefault();
onClose();
}}
onClick={(event) => {
if (event.target === event.currentTarget) onClose();
}}
onKeyDown={(event) => {
if (event.key === "Escape") {
event.preventDefault();
onClose();
}
}}
ref={dialogRef}
>
<div className="ui-dialog__surface">
<header className="ui-dialog__header">
<div>
<h2 id={titleId}>{title}</h2>
{description ? <p id={descriptionId}>{description}</p> : null}
</div>
<IconButton
accessibleName={
closeLabel ?? message("action.closeNamed", { title })
}
className="ui-dialog__close"
onClick={onClose}
variant="ghost"
>
<CloseIcon />
</IconButton>
</header>
{children ? (
<div className="ui-dialog__content">{children}</div>
) : null}
{actions ? (
<footer className="ui-dialog__actions">{actions}</footer>
) : null}
</div>
</dialog>
);
},
);
export type SpinnerProps =
| Readonly<{ decorative: true; label?: never }>
| Readonly<{ decorative?: false; label: string }>;
export function Spinner(props: SpinnerProps) {
const accessibility = props.decorative
? { "aria-hidden": true as const }
: { "aria-label": props.label, role: "status" };
return <span {...accessibility} className="ui-spinner" />;
}
export function VisuallyHidden({
children,
}: Readonly<{ children: React.ReactNode }>) {
return <span className="visually-hidden">{children}</span>;
}
export function Portal({
children,
}: Readonly<{ children: React.ReactNode }>) {
if (typeof document === "undefined") return <>{children}</>;
return createPortal(children, document.body);
}
export function FocusRing({
children,
}: Readonly<{ children: React.ReactNode }>) {
return <span className="ui-focus-ring">{children}</span>;
}