Shared UI kit
ui.jsx 350 lines
Files · 11
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350import React, { useEffect, useId, useRef, useState } from "react";
const classes = (...values) => values.filter(Boolean).join(" ");
export function Page({ children, className, ...props }) {
return (
<main className={classes("ui-page", className)} {...props}>
{children}
</main>
);
}
export function Header({ title, subtitle, actions, children }) {
return (
<header className="ui-header">
<div>
<h1>{title}</h1>
{subtitle && <p className="ui-muted">{subtitle}</p>}
{children}
</div>
<div className="ui-actions">
{actions}
<ThemeButton />
</div>
</header>
);
}
export function Button({
variant = "secondary",
className,
type = "button",
...props
}) {
return (
<button
type={type}
className={classes("ui-button", `ui-button-${variant}`, className)}
{...props}
/>
);
}
export function Input({
label,
hint,
error,
id,
className,
multiline,
...props
}) {
const Control = multiline ? "textarea" : "input";
return (
<Field
label={label}
hint={hint}
error={error}
id={id}
className={className}
>
<Control {...props} />
</Field>
);
}
export function Field({ label, hint, error, id, className, wide, children }) {
const generated = useId();
const inputId = id || generated;
return (
<div className={classes("ui-field", wide && "ui-span", className)}>
{label && <label htmlFor={inputId}>{label}</label>}
{React.cloneElement(children, {
id: inputId,
"aria-invalid": error ? true : undefined,
"aria-describedby": error || hint ? `${inputId}-hint` : undefined,
})}
{(error || hint) && (
<small
id={`${inputId}-hint`}
className={error ? "ui-error-text" : "ui-muted"}
>
{error || hint}
</small>
)}
</div>
);
}
export function Select({ label, hint, error, id, children, ...props }) {
return (
<Field label={label} hint={hint} error={error} id={id}>
<select {...props}>{children}</select>
</Field>
);
}
export function Row({
title,
subtitle,
leading,
actions,
children,
className,
...props
}) {
return (
<div className={classes("ui-row", className)} {...props}>
{leading}
<div className="ui-row-body">
{title && <div className="ui-row-title">{title}</div>}
{subtitle && <div className="ui-muted">{subtitle}</div>}
{children}
</div>
{actions && <div className="ui-actions">{actions}</div>}
</div>
);
}
export function EmptyState({ title = "Nothing here yet", children, action }) {
return (
<div className="ui-empty">
<h2>{title}</h2>
{children && <p className="ui-muted">{children}</p>}
{action}
</div>
);
}
export function Modal({
title,
onClose,
children,
actions,
sheet = false,
busy = false,
wide = false,
}) {
if (busy) onClose = undefined;
const ref = useRef(null);
const titleId = useId();
const previousFocus = useRef(document.activeElement);
const close = useRef(onClose);
close.current = onClose;
useEffect(() => {
const previous = previousFocus.current;
const oldOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const focusable = () =>
[
...ref.current.querySelectorAll(
'button:not(:disabled), input:not(:disabled), textarea:not(:disabled), select:not(:disabled), a[href], [tabindex="0"]',
),
].filter((el) => el.getClientRects().length);
if (!ref.current.contains(document.activeElement)) {
(focusable()[0] || ref.current).focus();
}
function onKey(event) {
if (event.key === "Escape") {
event.preventDefault();
close.current?.();
}
if (event.key === "Tab") {
const elements = focusable();
const first = elements[0],
last = elements.at(-1);
if (!first) {
event.preventDefault();
ref.current.focus();
} else if (
event.shiftKey &&
(document.activeElement === first ||
document.activeElement === ref.current)
) {
event.preventDefault();
last.focus();
} else if (!event.shiftKey && document.activeElement === last) {
event.preventDefault();
first.focus();
}
}
}
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("keydown", onKey);
document.body.style.overflow = oldOverflow;
if (previous?.isConnected) previous.focus();
};
}, []);
return (
<div
className={classes("ui-overlay", sheet && "ui-overlay-sheet")}
onMouseDown={(event) => {
if (event.target === event.currentTarget) onClose?.();
}}
>
<section
ref={ref}
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className={classes(
"ui-modal",
sheet && "ui-sheet",
wide && "ui-modal-wide",
)}
>
<div className="ui-modal-header">
<h2 id={titleId}>{title}</h2>
<Button
disabled={!onClose}
aria-label="Close dialog"
variant="ghost"
onClick={onClose}
>
×
</Button>
</div>
<div className="ui-modal-body">{children}</div>
{actions && <footer className="ui-modal-footer">{actions}</footer>}
</section>
</div>
);
}
export function Sheet(props) {
return <Modal {...props} sheet />;
}
export function Toast({ message, onDismiss, tone = "success" }) {
useEffect(() => {
if (!message || !onDismiss) return;
const timer = setTimeout(onDismiss, 5000);
return () => clearTimeout(timer);
}, [message, onDismiss]);
return message ? (
<div
className={classes("ui-toast", `ui-toast-${tone}`)}
role={tone === "danger" ? "alert" : "status"}
>
<span>{message}</span>
{onDismiss && (
<Button
variant="ghost"
aria-label="Dismiss message"
onClick={onDismiss}
>
×
</Button>
)}
</div>
) : null;
}
export function useToast() {
const [message, setMessage] = useState("");
return {
toast: message,
notify: setMessage,
dismissToast: React.useCallback(() => setMessage(""), []),
};
}
function ThemeButton() {
const [theme, setTheme] = useState(
() => document.documentElement.dataset.theme,
);
const [systemDark, setSystemDark] = useState(
() => matchMedia("(prefers-color-scheme: dark)").matches,
);
useEffect(() => {
const media = matchMedia("(prefers-color-scheme: dark)");
const update = () => setSystemDark(media.matches);
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
const dark = theme ? theme === "dark" : systemDark;
useEffect(() => {
document.documentElement.dataset.theme = dark ? "dark" : "light";
}, [dark]);
return (
<Button
className="ui-theme-button"
variant="ghost"
aria-label={dark ? "Use light theme" : "Use dark theme"}
title={dark ? "Use light theme" : "Use dark theme"}
onClick={() => {
const next = dark ? "light" : "dark";
document.documentElement.dataset.theme = next;
setTheme(next);
}}
>
<svg
aria-hidden="true"
viewBox="0 0 24 24"
width="18"
height="18"
fill="none"
stroke="currentColor"
strokeWidth="1.6"
>
{dark ? (
<>
<circle cx="12" cy="12" r="4" />
<path d="M12 2v2m0 16v2M2 12h2m16 0h2M5 5l1.5 1.5m11 11L19 19M5 19l1.5-1.5m11-11L19 5" />
</>
) : (
<path d="M20.5 14A8.5 8.5 0 0 1 10 3.5 8.5 8.5 0 1 0 20.5 14Z" />
)}
</svg>
</Button>
);
}
export function Stats({ items }) {
return (
<div className="ui-stats">
{items.map(([value, label]) => (
<div className="ui-stat" key={label}>
<strong>{value}</strong>
<span className="ui-muted">{label}</span>
</div>
))}
</div>
);
}
export function Search({ value, onChange, placeholder }) {
return (
<Input
type="search"
className="ui-search"
aria-label={placeholder}
placeholder={placeholder}
value={value}
onChange={(event) => onChange(event.target.value)}
/>
);
}
export function ErrorNotice({ error, clear, pending }) {
return (
<>
{error && (
<div className="ui-notice ui-notice-danger" role="alert">
<span>{error}</span>
{clear && (
<Button variant="ghost" onClick={clear} aria-label="Dismiss error">
×
</Button>
)}
</div>
)}
{pending && (
<p className="ui-notice" role="status">
{pending}
</p>
)}
</>
);
}