import { forwardRef } from "react"; import type { ButtonHTMLAttributes, ReactNode } from "react"; import clsx from "clsx"; import { X } from "lucide-react"; import { FocusSafeButton } from "./FocusSafeButton"; import { handOffFocus } from "../../lib/useFocusKeeper"; import { chipClass, type ChipSize, type ChipVariant } from "./chipStyles"; // The chip family (docs/DESIGN.md "One primitive per button family"): a small rounded-full token that // is a filter, an applied narrowing, a tag, or a suggestion. It was hand-painted in five files with // five geometries; this is the one. `FilterChipGroup` renders a single-select row of these. // // filter — a selectable option (a year, a tag filter, a content type). Toggles with `pressed`: the // `.pill` recipe in index.css paints the solid accent when on. Also the "+N" overflow and // "More…" triggers, which light up while their popover is open (aria-expanded). // token — an applied narrowing, tinted accent (the `.chip` recipe): it exists because it is on. // tag — a user's tag, the solid accent (`.chip-solid`). Dense: it sits in table rows. // suggest — dashed accent: a suggestion not yet taken. // ghost — dashed neutral: the "add a tag" trigger, quiet in every table row until reached for. // // Nothing goes under 24px (B1, WCAG 2.5.8) and a coarse pointer grows every chip to 44 (A3). type ChipProps = ButtonHTMLAttributes & { variant?: ChipVariant; size?: ChipSize; /** A filter chip that toggles: aria-pressed, painted solid when on. */ pressed?: boolean; /** Leading glyph (a lucide element). */ icon?: ReactNode; /** The whole chip removes what it stands for: a trailing × and the removal as its name. A press * that holds the focus hands it to the chip's neighbour before the removal, not to . */ removeLabel?: string; }; /** A chip that is itself the control (a toggle, a removal, an accept, a "+N" trigger). */ export const Chip = forwardRef(function Chip( { variant = "filter", size = "sm", pressed, icon, removeLabel, type, className, children, onClick, ...rest }, ref, ) { return ( { // A removal chip unmounts with what it removes: from the keyboard it holds the focus, which // went to (the active-filter chips of the feed and Plex). Every // removal chip gets it here, once, instead of each caller remembering. if (removeLabel && e.currentTarget === document.activeElement) handOffFocus(e.currentTarget, () => onClick?.(e)); else onClick?.(e); }} {...rest} > {icon} {children} {removeLabel && ); }); /** A chip whose label is not the control but which carries its own trailing × (a captured filter in * a saved view, a suggestion with a dismiss). Two sibling controls at most, never a button in a * button: `onClick` makes the body one (an accept), `onDismiss` the ×. */ export function DismissibleChip({ variant = "token", size = "sm", icon, children, onClick, bodyLabel, bodyTitle, onDismiss, dismissLabel, className, }: { variant?: Exclude; size?: ChipSize; icon?: ReactNode; children: ReactNode; onClick?: () => void; bodyLabel?: string; bodyTitle?: string; onDismiss: () => void; dismissLabel: string; className?: string; }) { const body = ( <> {icon} {children} ); return ( {onClick ? ( {body} ) : ( {body} )} { // From the keyboard the × holds the focus, and the chip unmounts: hand the focus to the // next chip (else the previous one, else what follows the row) first, not to . // A mouse press on it leaves the focus where it was. const chip = e.currentTarget.parentElement; if (chip && e.currentTarget === document.activeElement) handOffFocus(chip, onDismiss); else onDismiss(); }} aria-label={dismissLabel} title={dismissLabel} className="-my-px grid size-6 shrink-0 place-items-center rounded-full transition state-layer touch-target" > ); }