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();
  // Capture the opener before React commits any child autoFocus input.
  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(""), []),
  };
}

// Follow the system until the user explicitly chooses an appearance for this window.
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>
      )}
    </>
  );
}