feat(design): every button on its family, the chip primitive, the lint rule (E28 S3b2)
The second half of the button sweep: the remaining hand-rolled buttons in
feature components, the design lab and the components/ui composites now
stand on Button, IconButton, Pressable, Chip or DragHandle.
- Chip + DismissibleChip (filter / token / tag / suggest / ghost, 24px
floor, 44 on touch) replace five chip geometries; FilterChipGroup
renders Chips; overflow lanes measure a mirror painted by chipClass.
- The Plex control row is IconButton's player rung (36, 20px glyph,
round, overlay; 44 on touch); the YT modal bar is on the sm rung with
pressed cycle toggles and names that survive a folded label.
- New variants: IconButton ghost-accent, danger-soft; Button warn on a
new state-warn utility. The .btn-{tone} recipes and btnCls are gone.
- Fixed on the way: membership rows (collection editor, Plex playlist
add) announce checked/mixed; a one-letter tag chip was 21px wide;
descriptionLinks moved out of lib/ (it renders components).
- local/button-primitive: a raw <button>/<FocusSafeButton> outside the
control-primitive files is a lint error (contract-tested, seen red).
This commit is contained in:
@@ -2,6 +2,8 @@ import { Fragment, useState, type ReactNode } from "react";
|
||||
import { ChevronRight, MoreHorizontal } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||
import { closeThenAct, useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { Pressable } from "./Pressable";
|
||||
|
||||
// A reusable "⋯ more actions" overflow menu: a compact trigger that opens a list of one-shot actions.
|
||||
// Built on the R8 AnchoredPopover platform (arrows/typeahead/Escape/anchor-flip), same as ViewSwitcher
|
||||
@@ -209,19 +211,28 @@ export default function ActionMenu({
|
||||
const pop = useAnchoredPopover({ open, onOpenChange: setOpen, placement: "bottom-end" });
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
className={
|
||||
className ??
|
||||
"glass-card glass-hover flex items-center justify-center rounded-xl p-2 text-sm transition"
|
||||
}
|
||||
>
|
||||
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
|
||||
</button>
|
||||
{/* A caller-shaped trigger (a labelled pill) is a surface; the default "…" is a glyph. */}
|
||||
{className ? (
|
||||
<Pressable
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={ariaLabel}
|
||||
title={ariaLabel}
|
||||
className={className}
|
||||
>
|
||||
{trigger ?? <MoreHorizontal className="h-5 w-5" aria-hidden="true" />}
|
||||
</Pressable>
|
||||
) : (
|
||||
<IconButton
|
||||
variant="glass"
|
||||
size="md"
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
label={ariaLabel}
|
||||
>
|
||||
{trigger ?? <MoreHorizontal aria-hidden="true" />}
|
||||
</IconButton>
|
||||
)}
|
||||
<PopoverSurface popover={pop} ariaLabel={ariaLabel} className={MENU_SURFACE}>
|
||||
<MenuItemList items={items} onClose={() => setOpen(false)} />
|
||||
</PopoverSurface>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { ArrowUp } from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Pressable } from "./Pressable";
|
||||
|
||||
// A floating "back to top" button that fades in once the current page's scroll container is
|
||||
// scrolled past a threshold, and smooth-scrolls it back to the top. Every page scrolls inside a
|
||||
@@ -50,7 +51,8 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
|
||||
// commonly hide floating corner buttons via attribute selectors like [aria-label="Back to top"].
|
||||
// The accessible name comes from a visually-hidden child instead, which those selectors can't match.
|
||||
return createPortal(
|
||||
<button
|
||||
<Pressable
|
||||
align="center"
|
||||
onClick={toTop}
|
||||
className={`fixed bottom-5 right-5 z-chrome inline-flex items-center justify-center w-16 h-16 rounded-full glass-card glass-hover text-fg hover:text-accent transition-all duration-200 ${
|
||||
show ? "opacity-100 translate-y-0" : "opacity-0 translate-y-3 pointer-events-none"
|
||||
@@ -58,7 +60,7 @@ export default function BackToTop({ dep, threshold = 600 }: { dep?: unknown; thr
|
||||
>
|
||||
<ArrowUp className="w-7 h-7" aria-hidden="true" />
|
||||
<span className="sr-only">{t("common.backToTop")}</span>
|
||||
</button>,
|
||||
</Pressable>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,20 +2,21 @@ import type { ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { X } from "lucide-react";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
type BannerTone = "accent" | "warning";
|
||||
|
||||
const TONES: Record<BannerTone, { bar: string; icon: string; action: string }> = {
|
||||
const TONES: Record<BannerTone, { bar: string; icon: string; action: "primary" | "warn" }> = {
|
||||
accent: {
|
||||
bar: "bg-accent/15 border-accent/30",
|
||||
icon: "text-accent",
|
||||
action: "btn-primary",
|
||||
action: "primary",
|
||||
},
|
||||
warning: {
|
||||
bar: "bg-warn/15 border-warn/30",
|
||||
icon: "text-warn",
|
||||
action: "btn-warn",
|
||||
action: "warn",
|
||||
},
|
||||
};
|
||||
|
||||
@@ -54,12 +55,14 @@ export default function Banner({
|
||||
<Icon className={`w-4 h-4 shrink-0 ${c.icon}`} />
|
||||
<span className="min-w-0">{children}</span>
|
||||
{action && (
|
||||
<button
|
||||
<Button
|
||||
variant={c.action}
|
||||
size="sm"
|
||||
onClick={action.onClick}
|
||||
className={`ml-1 px-2.5 py-1 rounded-lg font-semibold transition ${c.action}`}
|
||||
className="ml-1 font-semibold"
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
{onDismiss && (
|
||||
<IconButton
|
||||
@@ -69,7 +72,7 @@ export default function Banner({
|
||||
onClick={onDismiss}
|
||||
className="ml-auto"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
<X />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -17,6 +17,7 @@ import { FocusSafeButton } from "./FocusSafeButton";
|
||||
type ButtonVariant =
|
||||
| "primary" // solid accent — the main affirmative action
|
||||
| "danger" // solid danger — destructive confirm
|
||||
| "warn" // solid warn — the action of a warning bar (reload for a new version)
|
||||
| "outline" // bordered, quiet — secondary action (Full history, Subscribe, Cancel-with-border)
|
||||
| "outline-accent" // bordered + accent-tinted — an affirmative secondary (Activate, Approve)
|
||||
| "outline-danger" // neutral at rest, danger on hover — a row-level Delete
|
||||
@@ -43,6 +44,7 @@ const ACT = "enabled:not-aria-disabled:";
|
||||
const VARIANTS: Record<ButtonVariant, string> = {
|
||||
primary: "font-medium state-filled",
|
||||
danger: "font-medium state-danger",
|
||||
warn: "font-medium state-warn",
|
||||
outline: `font-medium border border-border text-muted state-layer ${ACT}hover:text-fg`,
|
||||
"outline-accent": "font-medium border border-accent/40 text-accent state-layer",
|
||||
// Neutral until you reach for it. NOT `danger-soft`, which is already red at rest — the difference
|
||||
|
||||
@@ -4,6 +4,8 @@ import { useTranslation } from "react-i18next";
|
||||
import clsx from "clsx";
|
||||
import { useScrollEdges } from "../../lib/useScrollEdges";
|
||||
import { useListNav } from "../../lib/listNav";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
// Shared horizontal rail — the one place the app's "row of cards you scroll sideways" lives, replacing
|
||||
// the hand-rolled `flex gap overflow-x-auto` strips (Cast / Related / collections) and the future
|
||||
@@ -95,10 +97,10 @@ export function Carousel({
|
||||
|
||||
// Mouse-only: hidden until the carousel is hovered or the arrow is keyboard-focused; `reveal-hover`
|
||||
// removes it on touch, where the strip scrolls by swipe — so an invisible edge button never eats a tap.
|
||||
// Round, dark translucent: they sit over the pictures (A2).
|
||||
const arrowBtn =
|
||||
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 grid h-9 w-9 place-items-center rounded-full " +
|
||||
"bg-black/55 text-white backdrop-blur-xs border border-white/15 shadow-lg transition " +
|
||||
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100 hover:bg-black/75";
|
||||
"reveal-hover absolute top-1/2 -translate-y-1/2 z-10 backdrop-blur-xs border border-white/15 shadow-lg " +
|
||||
"opacity-0 group-hover/car:opacity-100 focus-visible:opacity-100";
|
||||
|
||||
return (
|
||||
<div className={clsx("group/car relative", className)}>
|
||||
@@ -106,27 +108,25 @@ export function Carousel({
|
||||
<div className="mb-3 flex items-baseline gap-2">
|
||||
<h2 className="text-sm font-semibold">{title}</h2>
|
||||
{onSeeAll && (
|
||||
<button
|
||||
onClick={onSeeAll}
|
||||
className="ml-auto inline-flex items-center gap-0.5 text-xs text-muted transition hover:text-accent"
|
||||
>
|
||||
<Button variant="link-muted" onClick={onSeeAll} className="ml-auto gap-0.5 text-xs">
|
||||
{seeAllLabel ?? t("common.seeAll")}
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="relative">
|
||||
{left && (
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
variant="media"
|
||||
size="md"
|
||||
onClick={() => page(-1)}
|
||||
aria-label={t("common.scrollLeft")}
|
||||
label={t("common.scrollLeft")}
|
||||
className={clsx(arrowBtn, "left-1")}
|
||||
>
|
||||
<ChevronLeft className="h-5 w-5" />
|
||||
</button>
|
||||
<ChevronLeft />
|
||||
</IconButton>
|
||||
)}
|
||||
{/* The px-1/pt-1 against -mx-1/-mt-1 keep the layout and give the keyboard ring room: a
|
||||
scroller clips its overflow, and the ring sits 4px outside the card it surrounds. */}
|
||||
@@ -140,14 +140,15 @@ export function Carousel({
|
||||
{children}
|
||||
</div>
|
||||
{right && (
|
||||
<button
|
||||
type="button"
|
||||
<IconButton
|
||||
variant="media"
|
||||
size="md"
|
||||
onClick={() => page(1)}
|
||||
aria-label={t("common.scrollRight")}
|
||||
label={t("common.scrollRight")}
|
||||
className={clsx(arrowBtn, "right-1")}
|
||||
>
|
||||
<ChevronRight className="h-5 w-5" />
|
||||
</button>
|
||||
<ChevronRight />
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// E28 S3b: the chip family (docs/DESIGN.md "One primitive per button family"). One geometry for a
|
||||
// filter, an applied narrowing, a tag and a suggestion, where five files had five.
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import { Chip, DismissibleChip } from "./Chip";
|
||||
import { chipClass } from "./chipStyles";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
describe("Chip", () => {
|
||||
it("is a pill on the 24px floor that never submits a form, and a toggle announces its state", () => {
|
||||
const onSubmit = vi.fn((e: { preventDefault: () => void }) => e.preventDefault());
|
||||
render(
|
||||
<form onSubmit={onSubmit}>
|
||||
<Chip pressed={false}>2024</Chip>
|
||||
</form>,
|
||||
);
|
||||
const chip = screen.getByRole("button", { name: "2024" });
|
||||
expect(chip.className).toContain("rounded-full");
|
||||
expect(chip.className).toContain("h-6");
|
||||
expect(chip.className).toContain("touch-target");
|
||||
expect(chip.getAttribute("aria-pressed")).toBe("false");
|
||||
fireEvent.click(chip);
|
||||
expect(onSubmit).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("a removing chip is named by the removal and shows the ×", () => {
|
||||
const onClick = vi.fn();
|
||||
render(
|
||||
<Chip variant="token" size="md" removeLabel="Remove Music" onClick={onClick}>
|
||||
Music
|
||||
</Chip>,
|
||||
);
|
||||
const chip = screen.getByRole("button", { name: "Remove Music" });
|
||||
expect(chip.getAttribute("title")).toBe("Remove Music");
|
||||
expect(chip.className).toContain("h-7");
|
||||
expect(chip.querySelector("svg")).not.toBeNull();
|
||||
fireEvent.click(chip);
|
||||
expect(onClick).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("the measuring mirror lays out exactly like the chip it measures", () => {
|
||||
render(
|
||||
<Chip variant="tag" size="xs">
|
||||
tag
|
||||
</Chip>,
|
||||
);
|
||||
const cls = screen.getByRole("button", { name: "tag" }).className;
|
||||
for (const c of chipClass("tag", "xs").split(" ")) expect(cls).toContain(c);
|
||||
});
|
||||
});
|
||||
|
||||
describe("DismissibleChip", () => {
|
||||
it("is two sibling controls at most, never a button in a button", () => {
|
||||
const onAccept = vi.fn();
|
||||
const onDismiss = vi.fn();
|
||||
const { container } = render(
|
||||
<DismissibleChip
|
||||
variant="suggest"
|
||||
onClick={onAccept}
|
||||
bodyLabel="Add the tag Music"
|
||||
onDismiss={onDismiss}
|
||||
dismissLabel="Not now"
|
||||
>
|
||||
Music
|
||||
</DismissibleChip>,
|
||||
);
|
||||
expect(container.querySelector("button button")).toBeNull();
|
||||
fireEvent.click(screen.getByRole("button", { name: "Add the tag Music" }));
|
||||
fireEvent.click(screen.getByRole("button", { name: "Not now" }));
|
||||
expect(onAccept).toHaveBeenCalledOnce();
|
||||
expect(onDismiss).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("with no body action the label is text, and only the × is a control", () => {
|
||||
render(
|
||||
<DismissibleChip onDismiss={() => {}} dismissLabel="Remove Year: 2024">
|
||||
Year: 2024
|
||||
</DismissibleChip>,
|
||||
);
|
||||
expect(screen.getAllByRole("button")).toHaveLength(1);
|
||||
expect(screen.getByText("Year: 2024").closest("button")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,125 @@
|
||||
import { forwardRef } from "react";
|
||||
import type { ButtonHTMLAttributes, ReactNode } from "react";
|
||||
import clsx from "clsx";
|
||||
import { X } from "lucide-react";
|
||||
import { FocusSafeButton } from "./FocusSafeButton";
|
||||
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<HTMLButtonElement> & {
|
||||
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. */
|
||||
removeLabel?: string;
|
||||
};
|
||||
|
||||
/** A chip that is itself the control (a toggle, a removal, an accept, a "+N" trigger). */
|
||||
export const Chip = forwardRef<HTMLButtonElement, ChipProps>(function Chip(
|
||||
{
|
||||
variant = "filter",
|
||||
size = "sm",
|
||||
pressed,
|
||||
icon,
|
||||
removeLabel,
|
||||
type,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<FocusSafeButton
|
||||
ref={ref}
|
||||
type={type ?? "button"}
|
||||
aria-pressed={pressed}
|
||||
aria-label={removeLabel}
|
||||
title={removeLabel}
|
||||
className={clsx(chipClass(variant, size), removeLabel && "group pr-1.5", className)}
|
||||
{...rest}
|
||||
>
|
||||
{icon}
|
||||
{children}
|
||||
{removeLabel && (
|
||||
<X className="size-3 opacity-60 group-hover:opacity-100" aria-hidden="true" />
|
||||
)}
|
||||
</FocusSafeButton>
|
||||
);
|
||||
});
|
||||
|
||||
/** 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<ChipVariant, "filter">;
|
||||
size?: ChipSize;
|
||||
icon?: ReactNode;
|
||||
children: ReactNode;
|
||||
onClick?: () => void;
|
||||
bodyLabel?: string;
|
||||
bodyTitle?: string;
|
||||
onDismiss: () => void;
|
||||
dismissLabel: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const body = (
|
||||
<>
|
||||
{icon}
|
||||
<span className="truncate">{children}</span>
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<span className={clsx(chipClass(variant, size), "pr-0.5 min-w-0", className)}>
|
||||
{onClick ? (
|
||||
<FocusSafeButton
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
aria-label={bodyLabel}
|
||||
title={bodyTitle}
|
||||
className="inline-flex min-w-0 items-center gap-1 self-stretch [&>svg]:size-3 [&>svg]:shrink-0"
|
||||
>
|
||||
{body}
|
||||
</FocusSafeButton>
|
||||
) : (
|
||||
<span className="inline-flex min-w-0 items-center gap-1 [&>svg]:size-3">{body}</span>
|
||||
)}
|
||||
<FocusSafeButton
|
||||
type="button"
|
||||
onClick={onDismiss}
|
||||
aria-label={dismissLabel}
|
||||
title={dismissLabel}
|
||||
className="-my-px grid size-6 shrink-0 place-items-center rounded-full opacity-60 transition hover:opacity-100 hover:bg-accent/20 touch-target"
|
||||
>
|
||||
<X className="size-3" aria-hidden="true" />
|
||||
</FocusSafeButton>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { Search } from "lucide-react";
|
||||
import Modal from "./Modal";
|
||||
import { closeTopOverlayThenRun } from "../../lib/history";
|
||||
import { pageStep, pageTarget } from "../../lib/listPaging";
|
||||
import { Pressable } from "./Pressable";
|
||||
|
||||
// The app's jump-to bar: one text field over every place you can go and every app-wide thing you can
|
||||
// do, opened with Ctrl/Cmd+K. It is the "one-layer operation" half of E10 — reaching a channel three
|
||||
@@ -243,14 +244,13 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
|
||||
{g.label}
|
||||
</div>
|
||||
{g.items.map((item) => (
|
||||
<button
|
||||
<Pressable
|
||||
key={item.id}
|
||||
id={`${listId}-${item.id}`}
|
||||
ref={item.id === activeId ? activeRef : undefined}
|
||||
data-cmd-id={item.id}
|
||||
role="option"
|
||||
aria-selected={item.id === activeId}
|
||||
type="button"
|
||||
// The combobox input owns the tab stop; the arrows move a VIRTUAL selection over
|
||||
// these rows. Leaving them tabbable put DOM focus on a row while
|
||||
// aria-activedescendant named a different one — two competing "current" options —
|
||||
@@ -268,14 +268,14 @@ function CommandPaletteBody({ onClose, groups }: { onClose: () => void; groups:
|
||||
if (e.movementX === 0 && e.movementY === 0) return;
|
||||
setPicked(item.id);
|
||||
}}
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-left text-sm ${
|
||||
className={`flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm ${
|
||||
item.id === activeId ? "bg-card text-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
{item.icon && <span className="shrink-0">{item.icon}</span>}
|
||||
<span className="min-w-0 flex-1 truncate">{item.label}</span>
|
||||
{item.hint && <span className="shrink-0 text-xs text-muted">{item.hint}</span>}
|
||||
</button>
|
||||
</Pressable>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// paint is CSS (`.pill[aria-pressed]`), so we assert the aria-pressed / onChange / testId contract.
|
||||
import { describe, it, expect, vi, afterEach } from "vitest";
|
||||
import { render, screen, fireEvent, cleanup } from "@testing-library/react";
|
||||
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
|
||||
import FilterChipGroup from "./FilterChipGroup";
|
||||
|
||||
const OPTS = [
|
||||
{ value: "all", label: "All", testId: "chip-all" },
|
||||
@@ -56,8 +56,12 @@ describe("FilterChipGroup", () => {
|
||||
expect(screen.getByRole("group", { name: "Sync state" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("exports the canonical chip class for multi-select siblings to reuse", () => {
|
||||
expect(filterChipClass).toContain("rounded-full");
|
||||
expect(filterChipClass).toContain("pill");
|
||||
it("renders its options as Chips: the pill, on the 24px rung", () => {
|
||||
render(<FilterChipGroup options={OPTS} value="all" onChange={vi.fn()} ariaLabel="Sync" />);
|
||||
const chip = screen.getByTestId("chip-all");
|
||||
expect(chip.className).toContain("rounded-full");
|
||||
expect(chip.className).toContain("pill");
|
||||
expect(chip.className).toContain("h-6");
|
||||
expect(chip.getAttribute("type")).toBe("button");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
import { type ReactNode } from "react";
|
||||
import CountBadge from "./CountBadge";
|
||||
|
||||
// Canonical filter-chip geometry — the rounded-full "Filters bar" look used app-wide for a selectable
|
||||
// filter chip. Exported so single-select groups (this component) AND standalone/multi-select filter
|
||||
// chips (e.g. the feed content-type toggles) render identically from ONE source of truth. The active
|
||||
// paint comes entirely from the `.pill[aria-pressed="true"]` recipe (index.css) — carry no
|
||||
// bg/text/border-colour utilities here or a utility class would beat the pressed fill.
|
||||
export const filterChipClass =
|
||||
"pill inline-flex items-center gap-1.5 text-xs px-2.5 py-1 rounded-full border shadow-xs active:translate-y-px";
|
||||
import { Chip } from "./Chip";
|
||||
|
||||
export interface FilterChipOption<T extends string> {
|
||||
value: T;
|
||||
@@ -19,7 +12,7 @@ export interface FilterChipOption<T extends string> {
|
||||
}
|
||||
|
||||
// A single-select row of filter chips (exactly one active), radio-like. For a multi-select chip row,
|
||||
// render plain buttons with `filterChipClass` instead — same look, different (independent) semantics.
|
||||
// render `Chip`s with `pressed` instead — same look, different (independent) semantics.
|
||||
export default function FilterChipGroup<T extends string>({
|
||||
options,
|
||||
value,
|
||||
@@ -39,18 +32,17 @@ export default function FilterChipGroup<T extends string>({
|
||||
{options.map((o) => {
|
||||
const on = value === o.value;
|
||||
return (
|
||||
<button
|
||||
<Chip
|
||||
key={o.value}
|
||||
data-testid={o.testId}
|
||||
onClick={() => onChange(o.value)}
|
||||
title={o.title}
|
||||
aria-pressed={on}
|
||||
className={filterChipClass}
|
||||
pressed={on}
|
||||
icon={o.icon}
|
||||
>
|
||||
{o.icon}
|
||||
<span>{o.label}</span>
|
||||
{o.count != null && o.count > 0 && <CountBadge count={o.count} active={on} />}
|
||||
</button>
|
||||
</Chip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
@@ -11,12 +11,14 @@ import { FocusSafeButton } from "./FocusSafeButton";
|
||||
|
||||
type IconButtonVariant =
|
||||
| "ghost" // the default: quiet glyph, a state layer on hover
|
||||
| "ghost-accent" // an accent glyph: the action it stands for is already in effect (queued, requested)
|
||||
| "primary" // solid accent — the one affirmative action of a compact form (Send, Save view)
|
||||
| "outline" // bordered — an icon action that stands on its own in a form or a header
|
||||
| "outline-accent" // bordered + accent — an affirmative row action (Approve)
|
||||
| "outline-danger" // bordered, danger on hover — a row's destructive action beside bordered siblings
|
||||
| "outline-warn" // bordered + warn — a reversible restriction that is currently ON (Suspended)
|
||||
| "danger" // quiet at rest, danger on hover — a row's delete/remove
|
||||
| "danger-soft" // bordered AND danger at rest — a destructive restriction that is ON (Blocked)
|
||||
| "glass" // glass card — beside the glass Buttons on a Plex page
|
||||
| "media" // round, dark translucent — on a thumbnail or a poster
|
||||
| "overlay"; // white glyph on the always-dark player chrome, whatever the theme
|
||||
@@ -24,17 +26,20 @@ type IconButtonVariant =
|
||||
type IconButtonSize =
|
||||
| "sm" // 28 — rows and dense toolbars
|
||||
| "md" // 32 — on its own
|
||||
| "lg"; // 48 — only the Play in the middle of a picture (C5: a not-started card)
|
||||
| "lg" // 48 — only the Play in the middle of a picture (C5: a not-started card)
|
||||
| "player"; // 36 with a 20px glyph, 44 on touch (A3) — the Plex player's control row (C5)
|
||||
|
||||
const ACT = "enabled:not-aria-disabled:";
|
||||
const VARIANTS: Record<IconButtonVariant, string> = {
|
||||
ghost: `text-muted state-layer ${ACT}hover:text-fg`,
|
||||
"ghost-accent": "text-accent state-layer",
|
||||
primary: "state-filled",
|
||||
outline: `border border-border text-muted state-layer ${ACT}hover:text-fg`,
|
||||
"outline-accent": "border border-accent/40 text-accent state-layer",
|
||||
"outline-danger": `border border-border text-muted state-layer ${ACT}hover:text-danger ${ACT}hover:border-danger/40`,
|
||||
"outline-warn": "border border-warn/40 text-warn state-layer",
|
||||
danger: `text-muted state-layer ${ACT}hover:text-danger`,
|
||||
"danger-soft": `border border-danger/60 text-danger state-layer ${ACT}hover:border-danger`,
|
||||
glass: `glass-card glass-hover ${ACT}hover:text-accent`,
|
||||
// The player chrome and a thumbnail are dark in the light theme too, so these two paint with
|
||||
// white and black, not with the theme's fg (whose state layer would be a dark wash on a dark video).
|
||||
@@ -50,6 +55,7 @@ const SIZES: Record<IconButtonSize, string> = {
|
||||
sm: "size-7 [&>svg]:size-4",
|
||||
md: "size-8 [&>svg]:size-4",
|
||||
lg: "size-12 [&>svg]:size-6",
|
||||
player: "size-9 [&>svg]:size-5",
|
||||
};
|
||||
|
||||
export interface IconButtonProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
|
||||
@@ -94,7 +100,7 @@ export const IconButton = forwardRef<HTMLButtonElement, IconButtonProps>(functio
|
||||
aria-pressed={pressed}
|
||||
title={noTitle ? undefined : label}
|
||||
className={clsx(
|
||||
"inline-grid shrink-0 place-items-center transition touch-target disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
|
||||
"inline-grid shrink-0 place-items-center transition touch-target aria-expanded:text-accent disabled:opacity-50 disabled:cursor-not-allowed aria-disabled:opacity-50 aria-disabled:cursor-not-allowed",
|
||||
VARIANTS[variant],
|
||||
onMedia || variant === "media" ? "rounded-full" : "rounded-lg",
|
||||
pressed !== undefined && PRESSED,
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
import { hasPagerContent, pageCount } from "./pagerContent";
|
||||
import { FocusSafeButton } from "./FocusSafeButton";
|
||||
import SegmentedToggle from "./SegmentedToggle";
|
||||
import { Button } from "./Button";
|
||||
|
||||
// The Prev / page-input / Next + "N per page" cluster, lifted out of DataTable so a non-table view
|
||||
// (the channel manager's card layout) can paginate the same way. Controlled: the caller owns page +
|
||||
@@ -66,14 +66,15 @@ export default function Pager({
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
size="sm"
|
||||
icon={<ChevronLeft />}
|
||||
onClick={() => goToPage(Math.max(0, safePage - 1))}
|
||||
disabled={safePage === 0}
|
||||
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
{t("datatable.pager.prev")}
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
<span className="text-muted flex items-center gap-1.5">
|
||||
{t("datatable.pager.pageLabel")}
|
||||
<input
|
||||
@@ -89,14 +90,15 @@ export default function Pager({
|
||||
/>
|
||||
{t("datatable.pager.ofTotal", { total: totalPages })}
|
||||
</span>
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="glass"
|
||||
size="sm"
|
||||
onClick={() => goToPage(Math.min(totalPages - 1, safePage + 1))}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
className="glass-card glass-hover flex items-center gap-1 px-3 py-1.5 rounded-lg disabled:opacity-40 transition"
|
||||
>
|
||||
{t("datatable.pager.next")}
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</FocusSafeButton>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{total > 0 && (
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { type ReactNode, type CSSProperties } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ChevronDown, Eye, EyeOff, GripVertical, Pencil } from "lucide-react";
|
||||
import { ChevronDown, Eye, EyeOff, Pencil } from "lucide-react";
|
||||
import { CSS } from "@dnd-kit/utilities";
|
||||
import { useSortableItem } from "./sortableA11y";
|
||||
import { DragHandle } from "./DragHandle";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { Pressable } from "./Pressable";
|
||||
|
||||
// One "island" card inside a SidePanel: a titled glass-card, and — in the panel's edit mode — a drag
|
||||
// handle (reorder) and a hide toggle. Generalized from the feed sidebar's WidgetCard so all three
|
||||
@@ -46,7 +49,7 @@ export default function PanelGroup({
|
||||
const titleIsTrigger = !editing && !collapsible && !!onTitleClick;
|
||||
|
||||
const titleCls =
|
||||
"flex-1 min-w-0 flex items-center gap-1 text-left select-none text-sm uppercase tracking-wide";
|
||||
"flex-1 min-w-0 flex items-center gap-1 select-none text-sm uppercase tracking-wide";
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -58,55 +61,53 @@ export default function PanelGroup({
|
||||
>
|
||||
<div className="flex items-center gap-1.5 px-3 py-2">
|
||||
{editing && (
|
||||
<button
|
||||
<DragHandle
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
title={t("sidebar.dragToReorder")}
|
||||
className="-ml-1 cursor-grab active:cursor-grabbing text-muted hover:text-fg touch-none"
|
||||
>
|
||||
<GripVertical className="w-4 h-4" />
|
||||
</button>
|
||||
label={t("sidebar.dragToReorder")}
|
||||
className="-ml-1"
|
||||
/>
|
||||
)}
|
||||
{collapsible && !editing ? (
|
||||
<button
|
||||
<Pressable
|
||||
onClick={onToggleCollapse}
|
||||
aria-expanded={!collapsed}
|
||||
className={`${titleCls} text-muted hover:text-fg transition`}
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
</button>
|
||||
</Pressable>
|
||||
) : titleIsTrigger ? (
|
||||
<button
|
||||
<Pressable
|
||||
onClick={onTitleClick}
|
||||
title={titleActionLabel}
|
||||
className={`${titleCls} group text-muted hover:text-accent transition`}
|
||||
>
|
||||
<span className="truncate">{title}</span>
|
||||
<Pencil className="w-3 h-3 shrink-0 text-muted/60 group-hover:text-accent" />
|
||||
</button>
|
||||
</Pressable>
|
||||
) : (
|
||||
<span className={`${titleCls} text-muted`}>
|
||||
<span className="truncate">{title}</span>
|
||||
</span>
|
||||
)}
|
||||
{editing ? (
|
||||
<button
|
||||
<IconButton
|
||||
onClick={onToggleHidden}
|
||||
title={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
|
||||
className="text-muted hover:text-fg"
|
||||
label={hidden ? t("sidebar.showWidget") : t("sidebar.hideWidget")}
|
||||
>
|
||||
{hidden ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
{hidden ? <EyeOff /> : <Eye />}
|
||||
</IconButton>
|
||||
) : (
|
||||
collapsible && (
|
||||
<button
|
||||
<IconButton
|
||||
onClick={onToggleCollapse}
|
||||
title={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
|
||||
className="text-muted hover:text-fg"
|
||||
aria-expanded={!collapsed}
|
||||
label={collapsed ? t("sidebar.expand") : t("sidebar.collapse")}
|
||||
>
|
||||
<ChevronDown
|
||||
className={`w-4 h-4 transition-transform ${collapsed ? "-rotate-90" : ""}`}
|
||||
/>
|
||||
</button>
|
||||
</IconButton>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -39,7 +39,7 @@ domain-free.
|
||||
| area | components |
|
||||
| ---------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10), `Modal`, `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable`, `DragHandle` (one per button family, docs/DESIGN.md), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| actions & inputs | `Button`, `IconButton`, `Pressable`, `Chip` + `chipStyles`, `DragHandle` (one per button family, docs/DESIGN.md; a raw `<button>` outside this folder is a lint error), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
||||
| data display | `DataTable`, `tableHeader` + `tableAlign`, `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup` |
|
||||
| page & panel structure | `PageShell` + `pageToolbarSlot`, `PageScroller`, `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice` |
|
||||
@@ -51,9 +51,9 @@ are not a target — see [`docs/DESIGN.md`](../../../../docs/DESIGN.md) § Devic
|
||||
change at 1024x768, 820x1180 and a desktop size. That contract lives in these components, not in the
|
||||
~160 call sites, because a rule repeated in 160 places is a rule that is already broken somewhere:
|
||||
|
||||
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES` and `IconButton`'s sizes are the
|
||||
rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32). Pick a rung; do not hand-roll
|
||||
padding on a primitive.
|
||||
- **Geometry is a ladder, not a free choice.** `Button`'s `SIZES`, `IconButton`'s and `Chip`'s sizes
|
||||
are the rungs docs/DESIGN.md decided (B1: 24 / 28 / 32, icons 28 / 32, chips 24 / 28; the C5 media
|
||||
and player rungs). Pick a rung; do not hand-roll padding on a primitive.
|
||||
- **Type, padding and box geometry are rem-derived**, so the Settings text-size slider (`--font-scale`
|
||||
on the root font-size) scales boxes and padding with the label. A `text-[Npx]` font size is an
|
||||
eslint error for exactly this reason. The deliberate exceptions are the layout-engine measurements
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Check, Pencil, Pin, PinOff, RotateCcw } from "lucide-react";
|
||||
import { useScrollFade } from "../../lib/useScrollFade";
|
||||
import { useHoverFocusWithin } from "../../lib/useHoverFocusWithin";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { Pressable } from "./Pressable";
|
||||
|
||||
// The shared floating side panel — one shell for the Feed / Plex / Playlists rails so they read as
|
||||
// one system, and the same peek-open model as the nav rail: unpinned it sits as a small tab docked
|
||||
@@ -72,7 +73,7 @@ export default function SidePanel({
|
||||
nowhere. It only goes transparent: the card's rounded corner cuts away its own pixels, and
|
||||
this square-cornered tab sits right under that arc, so it would otherwise bleed through.
|
||||
`opacity-0` (not `invisible`/`hidden`) keeps it hoverable and focusable. */}
|
||||
<button
|
||||
<Pressable
|
||||
{...peek.hoverHandlers}
|
||||
onClick={onToggleCollapse}
|
||||
aria-label={pinned ? t("sidebar.unpin") : t("sidebar.pin")}
|
||||
@@ -92,7 +93,7 @@ export default function SidePanel({
|
||||
>
|
||||
{title}
|
||||
</span>
|
||||
</button>
|
||||
</Pressable>
|
||||
|
||||
{expanded && (
|
||||
<aside
|
||||
@@ -113,14 +114,14 @@ export default function SidePanel({
|
||||
</IconButton>
|
||||
{iconExpanded ?? <span className="shrink-0 flex text-accent">{icon}</span>}
|
||||
{onTitleClick ? (
|
||||
<button
|
||||
<Pressable
|
||||
onClick={onTitleClick}
|
||||
title={titleActionLabel}
|
||||
aria-label={titleActionLabel}
|
||||
className="flex-1 min-w-0 truncate text-left text-sm font-bold uppercase tracking-[0.12em] transition hover:text-accent"
|
||||
className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em] transition hover:text-accent"
|
||||
>
|
||||
{title}
|
||||
</button>
|
||||
</Pressable>
|
||||
) : (
|
||||
<span className="flex-1 min-w-0 truncate text-sm font-bold uppercase tracking-[0.12em]">
|
||||
{title}
|
||||
@@ -145,15 +146,13 @@ export default function SidePanel({
|
||||
</IconButton>
|
||||
)}
|
||||
{onToggleEditing && (
|
||||
<button
|
||||
<IconButton
|
||||
variant={editing ? "ghost-accent" : "ghost"}
|
||||
onClick={onToggleEditing}
|
||||
title={editing ? t("sidebar.done") : t("sidebar.customize")}
|
||||
className={`shrink-0 p-1.5 rounded-lg transition hover:bg-card ${
|
||||
editing ? "text-accent" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
label={editing ? t("sidebar.done") : t("sidebar.customize")}
|
||||
>
|
||||
{editing ? <Check className="w-4 h-4" /> : <Pencil className="w-4 h-4" />}
|
||||
</button>
|
||||
{editing ? <Check /> : <Pencil />}
|
||||
</IconButton>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -6,6 +6,8 @@ import { createStore } from "../../lib/store";
|
||||
import { useToastDock } from "../../lib/toastDock";
|
||||
import { useOverlayOrder } from "./overlayOrder";
|
||||
import { LEVEL_STYLE } from "./toastLevels";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
// Overlay hosts. The app's own Toaster sits in the content column, and that column is a stacking
|
||||
// context (its `@container` implies layout containment) — so anything portalled to <body> at the
|
||||
@@ -170,24 +172,25 @@ export default function Toaster({
|
||||
)}
|
||||
</div>
|
||||
{toast.action && (
|
||||
<button
|
||||
<Button
|
||||
variant="link"
|
||||
onClick={() => {
|
||||
toast.action!.onClick();
|
||||
dismiss(toast.id);
|
||||
}}
|
||||
className="mt-1 text-accent text-sm font-semibold hover:underline"
|
||||
className="mt-1 text-sm"
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
<IconButton
|
||||
onClick={() => dismiss(toast.id)}
|
||||
className="shrink-0 text-muted hover:text-fg"
|
||||
title={t("notifications.dismiss")}
|
||||
label={t("notifications.dismiss")}
|
||||
className="-m-1.5"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<X />
|
||||
</IconButton>
|
||||
{toast.duration && (
|
||||
// Keyed by `ts`: a coalesced repeat re-arms the dismiss timer, so its bar restarts too.
|
||||
<CountdownBar key={toast.ts} since={toast.ts} duration={toast.duration} bar={bar} />
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Redo2, Undo2 } from "lucide-react";
|
||||
import { dispatch, useKeyScope } from "../../lib/keymap";
|
||||
import { FocusSafeButton } from "./FocusSafeButton";
|
||||
import { IconButton } from "./IconButton";
|
||||
|
||||
// Reusable undo/redo buttons with Ctrl/Cmd+Z (undo) and Ctrl/Cmd+Y or Ctrl/Cmd+Shift+Z
|
||||
// (redo) shortcuts. Pairs with useUndoable but takes plain props, so it works with any
|
||||
@@ -44,28 +44,14 @@ export default function UndoToolbar({
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [canUndo, canRedo, onUndo, onRedo]);
|
||||
|
||||
const btn =
|
||||
"p-1.5 rounded-lg border border-border text-muted enabled:not-aria-disabled:hover:text-fg enabled:not-aria-disabled:hover:border-accent disabled:opacity-30 transition";
|
||||
return (
|
||||
<div className="inline-flex items-center gap-1">
|
||||
<FocusSafeButton
|
||||
onClick={onUndo}
|
||||
disabled={!canUndo}
|
||||
title={t("common.undo")}
|
||||
aria-label={t("common.undo")}
|
||||
className={btn}
|
||||
>
|
||||
<Undo2 className="w-4 h-4" />
|
||||
</FocusSafeButton>
|
||||
<FocusSafeButton
|
||||
onClick={onRedo}
|
||||
disabled={!canRedo}
|
||||
title={t("common.redo")}
|
||||
aria-label={t("common.redo")}
|
||||
className={btn}
|
||||
>
|
||||
<Redo2 className="w-4 h-4" />
|
||||
</FocusSafeButton>
|
||||
<IconButton variant="outline" onClick={onUndo} disabled={!canUndo} label={t("common.undo")}>
|
||||
<Undo2 />
|
||||
</IconButton>
|
||||
<IconButton variant="outline" onClick={onRedo} disabled={!canRedo} label={t("common.redo")}>
|
||||
<Redo2 />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import clsx from "clsx";
|
||||
|
||||
// The chip family's geometry and paint, apart from the component (`Chip.tsx`) so the hidden measuring
|
||||
// mirror of an overflowing chip lane can lay out exactly like the chips it measures.
|
||||
|
||||
export type ChipVariant = "filter" | "token" | "tag" | "suggest" | "ghost";
|
||||
export type ChipSize =
|
||||
| "md" // 28, text-sm — the applied-filter row above the feed
|
||||
| "sm" // 24, text-xs — a filter row
|
||||
| "xs"; // 24, text-2xs, tight — a tag in a table row
|
||||
|
||||
const ACT = "enabled:not-aria-disabled:";
|
||||
const PAINT: Record<ChipVariant, string> = {
|
||||
// `.pill` owns the resting, hover AND pressed colours; a colour utility here would beat the pressed
|
||||
// fill, so the open-popover state is the only one spelled out.
|
||||
filter: "pill border shadow-xs aria-expanded:border-accent aria-expanded:text-accent",
|
||||
token: `chip ${ACT}hover:border-accent ${ACT}hover:bg-accent/20 aria-expanded:border-accent`,
|
||||
// B3: a filled control's hover mixes its own text colour into the fill, never an opacity dim.
|
||||
tag: `chip-solid font-medium ${ACT}hover:bg-[color-mix(in_srgb,var(--accent-fg)_14%,var(--accent))]`,
|
||||
suggest: `border border-dashed border-accent/50 text-accent/90 ${ACT}hover:text-accent ${ACT}hover:border-accent`,
|
||||
ghost: `border border-dashed border-border text-muted ${ACT}hover:text-accent ${ACT}hover:border-accent`,
|
||||
};
|
||||
|
||||
const SIZES: Record<ChipSize, string> = {
|
||||
md: "h-7 px-2.5 text-sm gap-1",
|
||||
sm: "h-6 px-2.5 text-xs gap-1.5",
|
||||
xs: "h-6 px-1.5 text-2xs gap-1",
|
||||
};
|
||||
|
||||
const BASE =
|
||||
"inline-flex min-w-6 shrink-0 items-center justify-center rounded-full whitespace-nowrap transition touch-target disabled:opacity-50 aria-disabled:opacity-50 [&>svg]:shrink-0";
|
||||
|
||||
/** The chip's geometry + paint as a class string: for the hidden measuring mirror of an overflowing
|
||||
* chip lane, which must lay out exactly like the chips it measures but must not be buttons. */
|
||||
export function chipClass(variant: ChipVariant = "filter", size: ChipSize = "sm") {
|
||||
return clsx(BASE, PAINT[variant], SIZES[size]);
|
||||
}
|
||||
@@ -14,9 +14,6 @@ import { FocusSafeButton } from "./FocusSafeButton";
|
||||
export const inputCls =
|
||||
"w-full rounded-lg bg-surface border border-border px-3 py-2 text-sm focus:outline-hidden focus:ring-2 focus:ring-accent/40";
|
||||
|
||||
/** The standard text button style (used verbatim by ShareDialog + VideoEditor). */
|
||||
export const btnCls = "px-3 py-1.5 rounded-lg text-sm font-medium transition disabled:opacity-50";
|
||||
|
||||
/** Pill on/off switch — the bare control; compose it with your own label/row. Pass `label` for
|
||||
* the accessible name (the visible row text) since the control itself has no inner text. */
|
||||
export function Switch({
|
||||
|
||||
@@ -8,6 +8,9 @@ import { type SortState } from "../../lib/columnSort";
|
||||
import { isActive, type Column, type FilterMap } from "../../lib/tableColumns";
|
||||
import { alignClass } from "./tableAlign";
|
||||
import { useStickyTop } from "./stickyTop";
|
||||
import { Button } from "./Button";
|
||||
import { IconButton } from "./IconButton";
|
||||
import { Pressable } from "./Pressable";
|
||||
|
||||
// The column-header machinery — the sortable header row with its filter funnel + popover — lifted
|
||||
// OUT of DataTable so a non-DataTable body can render under the same headers. The column contract
|
||||
@@ -75,17 +78,16 @@ function FilterFunnel<T>({
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
<IconButton
|
||||
variant={filterOn ? "ghost-accent" : "ghost"}
|
||||
ref={pop.refs.setReference}
|
||||
{...pop.getReferenceProps()}
|
||||
aria-label={t("datatable.filter")}
|
||||
// Generous padding: the icon alone was a 14x14 hit target, the smallest in the app.
|
||||
className={`-m-1.5 p-1.5 transition ${
|
||||
filterOn ? "text-accent" : "text-muted/60 hover:text-fg"
|
||||
}`}
|
||||
label={t("datatable.filter")}
|
||||
// The icon alone was a 14x14 hit target, the smallest in the app; the sm rung is 28.
|
||||
className="-my-1.5"
|
||||
>
|
||||
<ListFilter className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<ListFilter className="size-3.5!" />
|
||||
</IconButton>
|
||||
<PopoverSurface
|
||||
popover={pop}
|
||||
ariaLabel={col.header}
|
||||
@@ -167,12 +169,13 @@ function FilterFunnel<T>({
|
||||
</FilterOptionList>
|
||||
)}
|
||||
{filterOn && (
|
||||
<button
|
||||
<Button
|
||||
variant="link-muted"
|
||||
onClick={() => onApply(col.key, f.kind === "multi" ? [] : "")}
|
||||
className="mt-1.5 w-full text-xs text-muted hover:text-accent transition"
|
||||
className="mt-1.5 w-full text-xs"
|
||||
>
|
||||
{t("datatable.clear")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
</PopoverSurface>
|
||||
</>
|
||||
@@ -229,7 +232,7 @@ export function ColumnHead<T>({
|
||||
focusable no-op button, which put unlabeled dead stops in the tab order —
|
||||
two of them per playlist table, whose grip and actions headers are empty. */}
|
||||
{col.sortable ? (
|
||||
<button
|
||||
<Pressable
|
||||
onClick={() => onToggleSort(col.key)}
|
||||
className="inline-flex items-center gap-1 hover:text-fg cursor-pointer"
|
||||
>
|
||||
@@ -241,7 +244,7 @@ export function ColumnHead<T>({
|
||||
) : (
|
||||
<ArrowDown className="w-3 h-3" />
|
||||
))}
|
||||
</button>
|
||||
</Pressable>
|
||||
) : (
|
||||
<span>{col.header}</span>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user