470 lines
11 KiB
TypeScript
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>;
|
|
}
|