design
tcmts.examples.userinput.design · #owsu7wjm · tsx file
// tcm: namespace userinput.design
// The pieces every page is made of, on React Aria (react-aria-components): buttons, fields, sheets, menus, choices,
// so each works by keyboard, pointer and touch, and says what it is to screen readers. They're unstyled; the
// stylesheet styles them by their state (`data-pressed`, `data-selected`, `data-focus-visible`…), in its few colours
// and squircles (`sq`). None of them knows about records or the network, except `Person` and `HandleField`.
import { useEffect, useState, useSyncExternalStore } from "react";
import type { ReactNode } from "react";
import {
Button as AriaButton, ComboBox, Dialog, FileTrigger, Heading, Input, Label, Link as AriaLink, ListBox, ListBoxItem, Menu as AriaMenu,
MenuItem as AriaMenuItem, MenuTrigger, Modal, ModalOverlay, Popover, Radio, RadioGroup, Tag as AriaTag, TagGroup, TagList, Text, TextArea,
TextField, ToggleButton, ToggleButtonGroup,
} from "react-aria-components";
import type { Key } from "react-aria-components";
import { expect } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { refresh, useData } from "#codebase/userinput/lib/react";
import { ago, getProfile, reindex, searchHandles, stateLabel } from "#codebase/userinput";
import type { Profile, State } from "#codebase/userinput";
/** The icons the app draws: a few strokes each, in the manner of SF Symbols. */
export const ICONS: Record<string, string> = {
up: "M12 5l7 8h-4.5v6h-5v-6H5z",
down: "M12 19l-7-8h4.5V5h5v6H19z",
plus: "M12 5v14M5 12h14",
back: "M15 5l-7 7 7 7",
chevron: "M9 5l7 7-7 7",
bubble: "M4 12a8 7 0 1 1 3.4 5.7L4 19l1.2-3.3A6.7 6.7 0 0 1 4 12z",
bell: "M6 16V11a6 6 0 0 1 12 0v5l2 2H4zM10 20a2 2 0 0 0 4 0",
pin: "M9 4h6l-1 5 3 3H7l3-3zM12 12v8",
lock: "M7 11V8a5 5 0 0 1 10 0v3M5 11h14v9H5z",
hide: "M3 3l18 18M10.6 6.1A9 9 0 0 1 21 12a12 12 0 0 1-2.3 3.1M6.1 6.9A12 12 0 0 0 3 12s3.5 6 9 6a8.7 8.7 0 0 0 3.6-.8",
ban: "M5.6 5.6l12.8 12.8M12 3a9 9 0 1 0 0 18 9 9 0 0 0 0-18z",
gear: "M12 9a3 3 0 1 0 0 6 3 3 0 0 0 0-6zM19 12l2-1-1-3-2 .3-1.3-1.3L17 5l-3-1-1 2h-2l-1-2-3 1 .3 2L6 8.3 4 8 3 11l2 1v0l-2 1 1 3 2-.3 1.3 1.3L7 19l3 1 1-2h2l1 2 3-1-.3-2 1.3-1.3 2 .3 1-3z",
person: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8zM4 20a8 8 0 0 1 16 0",
search: "M11 4a7 7 0 1 0 0 14 7 7 0 0 0 0-14zM20 20l-4-4",
close: "M6 6l12 12M18 6L6 18",
photo: "M4 6h16v12H4zM8 13l3-3 5 5M15 10.5a1.5 1.5 0 1 0 0-.01",
check: "M5 12.5l4.5 4.5L19 7",
more: "M6 12h.01M12 12h.01M18 12h.01",
edit: "M5 19h4L19 9l-4-4L5 15zM13 7l4 4",
status: "M12 4a8 8 0 1 0 0 16 8 8 0 0 0 0-16zM8.5 12.5l2.5 2.5 4.5-5",
space: "M4 5h16v11H8l-4 4z",
signout: "M15 4h4v16h-4M10 8l-4 4 4 4M6 12h10",
};
/** An icon, by name (decorative: what it means is said by its button's label). */
export function Icon({ name, size = 20 }: { name: string; size?: number }) {
return (
<svg className="icon" width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<path d={ICONS[name] ?? ""} />
</svg>
);
}
/** How a button looks: the one thing to do, an alternative, quiet, or destructive. */
export type Variant = "primary" | "secondary" | "plain" | "danger";
/** A button: pressed by pointer, touch or keyboard. `busy` shows it working (and announces it). */
export function Button(props: {
children: ReactNode;
onPress?: () => void;
variant?: Variant;
size?: "small" | "large";
icon?: string;
disabled?: boolean;
busy?: boolean;
type?: "button" | "submit";
label?: string;
}) {
return (
<AriaButton
type={props.type ?? "button"}
className={`btn sq ${props.variant ?? "secondary"} ${props.size ?? ""}`}
onPress={props.onPress}
isDisabled={props.disabled}
isPending={props.busy}
aria-label={props.label}
>
{props.busy ? <Spinner /> : props.icon ? <Icon name={props.icon} /> : null}
{props.children}
</AriaButton>
);
}
/** A button that's just an icon: its label is what screen readers say, and its tooltip. */
export function IconButton({ icon, label, onPress, badge }: { icon: string; label: string; onPress?: () => void; badge?: number }) {
return (
<AriaButton className="btn sq icon-btn" aria-label={badge ? `${label}, ${badge} new` : label} onPress={onPress}>
<Icon name={icon} />
{badge ? <span className="badge" aria-hidden>{badge > 99 ? "99+" : badge}</span> : null}
</AriaButton>
);
}
/** Something happening. */
export const Spinner = () => <span className="spinner" aria-hidden />;
/** A shape where something will be, while it loads (hidden from screen readers, which hear the page is busy). */
export const Skeleton = ({ width = "100%", height = 18 }: { width?: number | string; height?: number }) => <div className="skeleton" aria-hidden style={{ width, height }} />;
/** Rows of skeletons, as a list loading looks. */
export function SkeletonRows({ rows = 4 }: { rows?: number }) {
return (
<div className="list sq" aria-busy aria-label="Loading">
{Array.from({ length: rows }, (_, i) => (
<div className="row" key={i}>
<Skeleton width={52} height={64} />
<div className="row-main">
<Skeleton width="60%" height={22} />
<Skeleton width="90%" />
<Skeleton width="30%" height={14} />
</div>
</div>
))}
</div>
);
}
/** Nothing here (yet): what and why, and what to do about it. */
export function Empty({ icon, title, children }: { icon: string; title: string; children?: ReactNode }) {
return (
<div className="empty">
<Icon name={icon} size={44} />
<div className="headline" style={{ color: "var(--text)" }}>{title}</div>
{children}
</div>
);
}
/** A labelled text field (one line, or several), with a hint under it. */
export function Field(props: {
label: string;
value: string;
onChange: (v: string) => void;
placeholder?: string;
multiline?: boolean;
big?: boolean;
hint?: string;
maxLength?: number;
type?: "text" | "password";
hideLabel?: boolean;
}) {
return (
<TextField className="field" value={props.value} onChange={props.onChange} maxLength={props.maxLength} type={props.type ?? "text"} aria-label={props.hideLabel ? props.label : undefined}>
{props.hideLabel ? null : <Label className="field-label">{props.label}</Label>}
{props.multiline ? <TextArea className="textarea sq" placeholder={props.placeholder} /> : <Input className={`input sq ${props.big ? "big" : ""}`} placeholder={props.placeholder} />}
{props.hint ? <Text slot="description" className="hint">{props.hint}</Text> : null}
</TextField>
);
}
/** One of a few options, side by side (a segmented control): arrow keys move between them. */
export function Segmented<T extends string>({ options, value, onChange, label }: { options: { value: T; label: string }[]; value: T; onChange: (v: T) => void; label: string }) {
return (
<ToggleButtonGroup
className="segmented sq"
aria-label={label}
selectionMode="single"
disallowEmptySelection
selectedKeys={[value]}
onSelectionChange={(keys) => {
const k = [...keys][0];
if (k !== undefined) onChange(String(k) as T);
}}
>
{options.map((o) => <ToggleButton key={o.value} id={o.value} className="segment sq">{o.label}</ToggleButton>)}
</ToggleButtonGroup>
);
}
/** Options to choose from, as chips: one (`single`) or several; or, with `removable`, a list to remove from. */
export function Chips({ label, options, selected = [], onChange, mode = "multiple", removable }: {
label: string;
options: { value: string; label: string }[];
selected?: string[];
onChange?: (selected: string[]) => void;
mode?: "single" | "multiple";
removable?: (value: string) => void;
}) {
return (
<TagGroup
aria-label={label}
selectionMode={removable ? "none" : mode}
selectedKeys={selected}
disallowEmptySelection={mode === "single"}
onSelectionChange={(keys) => onChange?.([...keys].map(String))}
onRemove={removable ? (keys) => [...keys].forEach((k) => removable(String(k))) : undefined}
>
<TagList className="chips">
{options.map((o) => (
<AriaTag key={o.value} id={o.value} className={`chip ${removable ? "removable" : ""}`} textValue={o.label}>
{o.label}
{removable ? <AriaButton slot="remove" className="chip-remove" aria-label={`Remove ${o.label}`}><Icon name="close" size={14} /></AriaButton> : null}
</AriaTag>
))}
</TagList>
</TagGroup>
);
}
/** One of several options, as a radio group shown as chips (a discussion's status, say). */
export function ChoiceChips<T extends string>({ label, options, value, onChange }: { label: string; options: { value: T; label: string }[]; value: T; onChange: (v: T) => void }) {
return (
<RadioGroup className="field" value={value} onChange={(v) => onChange(v as T)}>
<Label className="field-label">{label}</Label>
<div className="chips">{options.map((o) => <Radio key={o.value} value={o.value} className="chip">{o.label}</Radio>)}</div>
</RadioGroup>
);
}
/** A status's colour. */
export const TONES: Record<State, string> = {
open: "var(--open)",
"under-review": "var(--review)",
backlog: "var(--closed)",
planned: "var(--planned)",
"in-progress": "var(--progress)",
implemented: "var(--implemented)",
declined: "var(--declined)",
duplicate: "var(--closed)",
closed: "var(--closed)",
};
/** A discussion's status, as a pill in its colour. */
export const StatusPill = ({ state }: { state: State }) => (
<span className="pill" style={{ ["--tone" as string]: TONES[state] }}>{stateLabel(state)}</span>
);
/** A tag, by its label. */
export const Tag = ({ label }: { label: string }) => <span className="tag">{label}</span>;
/** How long ago, kept up to date, with the full time on hover. */
export function Time({ iso }: { iso: string }) {
const [, tick] = useState(0);
useEffect(() => {
const t = setInterval(() => tick((n) => n + 1), 60_000);
return () => clearInterval(t);
}, []);
return <time dateTime={iso} title={new Date(iso).toLocaleString()}>{ago(iso)}</time>;
}
/** Someone's picture, or their initial. */
export function Avatar({ profile, size = 28 }: { profile?: Profile; size?: number }) {
const initial = (profile?.displayName || profile?.handle || "?").replace(/^did:\w+:/, "")[0]?.toUpperCase();
return profile?.avatar ? (
<img className="avatar" src={profile.avatar} alt="" width={size} height={size} style={{ width: size, height: size }} />
) : (
<span className="avatar" aria-hidden style={{ width: size, height: size, fontSize: size * 0.45 }}>{initial}</span>
);
}
/** Someone's profile, once it's read (once for the page: everywhere they're shown has it at once after that). */
export function useProfile(did: string | undefined): Profile | undefined {
return useData(did ? `profile:${did}` : undefined, () => getProfile(did!)).value;
}
/** Someone: their picture and name, linking to their page. */
export function Person({ did, size = 24, link = true }: { did: string; size?: number; link?: boolean }) {
const p = useProfile(did);
const inner = (
<>
<Avatar profile={p} size={size} />
<span className="person-name">{p?.displayName || p?.handle || "…"}</span>
</>
);
return link ? <Link to={`/profile/${p?.handle ?? did}`} className="person">{inner}</Link> : <span className="person">{inner}</span>;
}
/** The path of a space's board. */
export const spacePath = (uri: string): string => {
const [, , did, , rkey] = uri.split("/");
return `/s/${did}/${rkey}`;
};
/** The path of a discussion. */
export const discussionPath = (uri: string): string => {
const [, , did, , rkey] = uri.split("/");
return `/d/${did}/${rkey}`;
};
/**
* An in-app link (the app's `RouterProvider` follows it without reloading the page). `onReach`: when it's pointed at
* or focused, before it's followed (to read ahead what it opens).
*/
export function Link({ to, children, className, label, onReach }: { to: string; children: ReactNode; className?: string; label?: string; onReach?: () => void }) {
return <AriaLink href={to} className={className} aria-label={label} onHoverStart={onReach} onFocus={onReach}>{children}</AriaLink>;
}
/** Handles as they're typed, to pick from: a combo box of the people whose handles start so. */
export function HandleField({ value, onChange, onPick, label = "Handle" }: { value: string; onChange: (v: string) => void; onPick: (p: Profile) => void; label?: string }) {
const [found, setFound] = useState<Profile[]>([]);
useEffect(() => {
const t = setTimeout(() => void searchHandles(value).then(setFound, () => setFound([])), 150);
return () => clearTimeout(t);
}, [value]);
return (
<ComboBox
className="field"
inputValue={value}
onInputChange={onChange}
items={found}
menuTrigger="input"
allowsCustomValue
allowsEmptyCollection
onSelectionChange={(k: Key | null) => {
const p = found.find((f) => f.did === k);
if (p) onPick(p);
}}
>
<Label className="field-label">{label}</Label>
<Input className="input sq big" placeholder="you.bsky.social" autoCapitalize="none" autoCorrect="off" spellCheck={false} />
<Popover className="popover sq" offset={6}>
<ListBox className="listbox">
{(p: Profile) => (
<ListBoxItem id={p.did} textValue={p.handle} className="menu-item sq">
<Avatar profile={p} size={26} />
<span className="person-name">{p.displayName || p.handle}</span>
<span className="caption">@{p.handle}</span>
</ListBoxItem>
)}
</ListBox>
</Popover>
</ComboBox>
);
}
/** A sheet over the page (a modal dialog): a form, or a choice. Escape or a click outside closes it. */
export function Sheet({ title, onClose, children, actions }: { title: string; onClose: () => void; children: ReactNode; actions?: ReactNode }) {
return (
<ModalOverlay className="scrim" isOpen isDismissable onOpenChange={(open) => !open && onClose()}>
<Modal className="sheet sq">
<Dialog className="dialog" aria-label={title}>
<div className="sheet-head">
<Heading slot="title" className="title">{title}</Heading>
<IconButton icon="close" label="Close" onPress={onClose} />
</div>
{children}
{actions ? <div className="sheet-actions">{actions}</div> : null}
</Dialog>
</Modal>
</ModalOverlay>
);
}
/** A choice in a menu. */
export interface MenuItem {
label: string;
icon: string;
onSelect: () => void;
danger?: boolean;
}
/** A button that opens a menu of choices (arrow keys move through it, typing jumps to one). */
export function Menu({ items, label = "More", face }: { items: MenuItem[]; label?: string; face?: ReactNode }) {
if (!items.length) return null;
return (
<MenuTrigger>
{/* (its face: what the button shows, someone's picture, say; else "more") */}
{face ? <AriaButton className="btn icon-btn face" aria-label={label}>{face}</AriaButton> : <IconButton icon="more" label={label} />}
<Popover className="popover sq" placement="bottom end" offset={6}>
<AriaMenu className="menu" aria-label={label} onAction={(k) => items.find((i) => i.label === k)?.onSelect()}>
{items.map((i) => (
<AriaMenuItem key={i.label} id={i.label} textValue={i.label} className={`menu-item sq ${i.danger ? "danger" : ""}`}>
<Icon name={i.icon} />
{i.label}
</AriaMenuItem>
))}
</AriaMenu>
</Popover>
</MenuTrigger>
);
}
/** A button that opens a file chooser, for images. */
export function ImageChooser({ label, multiple, onChoose }: { label: string; multiple?: boolean; onChoose: (files: File[]) => void }) {
return (
<FileTrigger acceptedFileTypes={["image/png", "image/jpeg", "image/webp", "image/gif"]} allowsMultiple={multiple} onSelect={(list) => list && onChoose([...list])}>
<AriaButton className="btn sq secondary small"><Icon name="photo" />{label}</AriaButton>
</FileTrigger>
);
}
/** The page's title, as the tab shows it (React puts it in the document's head). */
export const Title = ({ text }: { text?: string }) => <title>{text ? `${text} · userinput` : "userinput"}</title>;
/** Images in a grid; each opens full size (in a dialog) when pressed. */
export function ImageGrid({ images }: { images: { thumb: string; full: string; alt: string }[] }) {
const [open, setOpen] = useState<{ full: string; alt: string }>();
if (!images.length) return null;
return (
<>
<div className="images">
{images.map((i) => (
<AriaButton key={i.thumb} className="image-button sq" aria-label={i.alt ? `Open image: ${i.alt}` : "Open image"} onPress={() => setOpen(i)}>
<img className="sq" src={i.thumb} alt={i.alt} loading="lazy" />
</AriaButton>
))}
</div>
{open ? (
<ModalOverlay className="lightbox" isOpen isDismissable onOpenChange={(o) => !o && setOpen(undefined)}>
<Modal>
<Dialog aria-label={open.alt || "Image"} className="dialog">
<img src={open.full} alt={open.alt} />
</Dialog>
</Modal>
</ModalOverlay>
) : null}
</>
);
}
/** The page's next reads again after a write (see {@link refreshSoon}). */
export const refreshes = { timers: [] as ReturnType<typeof setTimeout>[] };
/**
* After a write: read again what the page shows, once the network has it. Your own records are read from your repo,
* which has them at once, and Constellation in a second or so: read again after 1.5 seconds (unless `quick` is
* false: a vote, which the index's counts don't have yet, would seem to undo itself). userinput.app's index reads
* them again when asked: asked after 4 seconds and after 20, as userinput.app does, the page reading again each
* time. Writes close together are read once. What isn't shown is read when it is.
*/
export function refreshSoon(quick = true): void {
for (const t of refreshes.timers) clearTimeout(t);
refreshes.timers = [
...(quick ? [setTimeout(() => void refresh(), 1500)] : []),
...[4000, 20000].map((ms) => setTimeout(() => void reindex().then(() => refresh()), ms)),
];
}
/** A message that comes and goes. */
export interface Toast {
id: number;
text: string;
}
/** The messages showing, shared by the page. */
export const toasts = { current: [] as Toast[], listeners: new Set<() => void>(), next: 1 };
/** Show a message for a few seconds (screen readers say it as it appears). */
export function toast(text: string) {
const t = { id: toasts.next++, text };
toasts.current = [...toasts.current, t];
for (const l of toasts.listeners) l();
setTimeout(() => {
toasts.current = toasts.current.filter((x) => x.id !== t.id);
for (const l of toasts.listeners) l();
}, 3200);
}
/** Where messages show. */
export function Toasts() {
const list = useSyncExternalStore(
(l) => (toasts.listeners.add(l), () => void toasts.listeners.delete(l)),
() => toasts.current,
() => toasts.current,
);
return <output className="toasts" aria-live="polite">{list.map((t) => <div key={t.id} className="toast sq">{t.text}</div>)}</output>;
}
/** Run something that can fail, saying what went wrong if it does; whether it's running. */
export function useAction(): [boolean, (f: () => Promise<unknown>) => Promise<void>] {
const [busy, setBusy] = useState(false);
const run = async (f: () => Promise<unknown>) => {
setBusy(true);
try {
await f();
} catch (e) {
toast((e as Error).message || "Something went wrong");
} finally {
setBusy(false);
}
};
return [busy, run];
}
export const testSegmentedAndChips = () => {
let picked = "";
render(<Segmented label="Sort" options={[{ value: "top", label: "Top" }, { value: "new", label: "New" }]} value="top" onChange={(v) => (picked = v)} />);
expect(screen.getByRole("radio", { name: "Top" }).getAttribute("aria-checked")).toBe("true");
fireEvent.click(screen.getByRole("radio", { name: "New" }));
expect(picked).toBe("new");
};
used by