feat(design): the button families on the decided ladder, first half of the sweep (E28 S3b1)
Button is rebuilt to A1/B1/B3: explicit heights (xs 24 / sm 28 / md 32, lg 40 for a hero CTA), r8 (a pill on media), state layers instead of opacity and bg-card hovers, a `pressed` toggle, `overlay`, `media` and `link-muted` variants. The icon-only sizes move to the new IconButton (28 / 32, 48 only for the Play on a picture; a required label). Pressable (a card, row or tile that is the control) and DragHandle join them. The state-* utilities now stand down on a disabled, busy or pressed control, held by theme.contract.test.ts. Swept onto them: the Button icon-size callers and 16 files (Plex playlist, Plex detail, the YT player modal chrome, Welcome, Plex and video cards with AddToPlaylist and DownloadButton, the video editor, Feed, personal views, Scheduler, Users, the nav rail, Plex detail parts, Settings): 268 raw buttons are 153. Fixed on the way: FocusSafeButton overwrote a caller's aria-disabled (the "unavailable, here is why" state; adminInvites caught it), a season toggle and a Plex settings switch had no name/state, and the video card's overlay follows the C5 card (Resume pill, From the start pill, a round Play).
This commit is contained in:
@@ -16,7 +16,6 @@ import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
GripVertical,
|
||||
ListTree,
|
||||
Pencil,
|
||||
Play,
|
||||
@@ -32,7 +31,9 @@ import { useConfirm } from "./confirmContext";
|
||||
import { Button } from "./ui/Button";
|
||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
import { DragHandle } from "./ui/DragHandle";
|
||||
import { usePlexPlaylistEdits } from "./usePlexPlaylistEdits";
|
||||
import {
|
||||
sameListCollision,
|
||||
@@ -128,21 +129,6 @@ function Poster({ src, className }: { src: string; className: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** `title` names the row it moves: a screen reader heard the English word "reorder" on every
|
||||
* row, with nothing to say which. */
|
||||
function DragHandle({ title, ...props }: { title: string } & Record<string, unknown>) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<button
|
||||
{...props}
|
||||
className="shrink-0 cursor-grab text-muted hover:text-fg active:cursor-grabbing"
|
||||
aria-label={t("plex.playlist.reorderItem", { title })}
|
||||
>
|
||||
<GripVertical className="h-4 w-4" />
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortableItem({
|
||||
@@ -161,13 +147,17 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
|
||||
style={style}
|
||||
className={`flex items-center gap-2.5 rounded-lg ${dense ? "px-1.5 py-1" : "glass-card p-1.5"}`}
|
||||
>
|
||||
<DragHandle title={ep.title} {...attributes} {...listeners} />
|
||||
<DragHandle
|
||||
label={t("plex.playlist.reorderItem", { title: ep.title })}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
<span className="w-5 shrink-0 text-center text-2xs text-muted tabular-nums">
|
||||
{ep.episode_number}
|
||||
</span>
|
||||
<button
|
||||
<Pressable
|
||||
onClick={() => ctx.onPlay(ep.id)}
|
||||
className="group flex min-w-0 flex-1 items-center gap-2.5 text-left"
|
||||
className="group flex min-w-0 flex-1 items-center gap-2.5"
|
||||
>
|
||||
<div
|
||||
className={`relative shrink-0 overflow-hidden rounded-sm border border-border bg-surface ${dense ? "aspect-video w-14" : "aspect-video w-20"}`}
|
||||
@@ -184,15 +174,15 @@ function EpisodeRow({ ep, ctx, dense }: { ep: PlexCard; ctx: Ctx; dense: boolean
|
||||
</div>
|
||||
</div>
|
||||
<div className="min-w-0 truncate text-[0.8125rem]">{ep.title}</div>
|
||||
</button>
|
||||
<FocusSafeButton
|
||||
</Pressable>
|
||||
<IconButton
|
||||
variant="danger"
|
||||
onClick={() => ctx.onRemove([ep.id])}
|
||||
disabled={ctx.busy}
|
||||
className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30"
|
||||
aria-label={t("plex.playlist.removeItem", { title: ep.title })}
|
||||
label={t("plex.playlist.removeItem", { title: ep.title })}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</FocusSafeButton>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -215,10 +205,14 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
|
||||
style={style}
|
||||
className="glass-card flex items-center gap-3 rounded-xl p-2"
|
||||
>
|
||||
<DragHandle title={item.title} {...attributes} {...listeners} />
|
||||
<button
|
||||
<DragHandle
|
||||
label={t("plex.playlist.reorderItem", { title: item.title })}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
<Pressable
|
||||
onClick={() => ctx.onPlay(item.id)}
|
||||
className="group flex min-w-0 flex-1 items-center gap-3 text-left"
|
||||
className="group flex min-w-0 flex-1 items-center gap-3"
|
||||
>
|
||||
<Poster src={item.thumb} className={dense ? "aspect-2/3 w-10" : "aspect-2/3 w-14"} />
|
||||
<div className="min-w-0">
|
||||
@@ -228,15 +222,15 @@ function MovieRow({ item, ctx }: { item: PlexCard; ctx: Ctx }) {
|
||||
<div className="pointer-events-none ml-auto grid h-8 w-8 shrink-0 place-items-center rounded-full bg-accent/0 text-transparent transition group-hover:bg-accent/15 group-hover:text-accent">
|
||||
<Play className="h-4 w-4" fill="currentColor" />
|
||||
</div>
|
||||
</button>
|
||||
<FocusSafeButton
|
||||
</Pressable>
|
||||
<IconButton
|
||||
variant="danger"
|
||||
onClick={() => ctx.onRemove([item.id])}
|
||||
disabled={ctx.busy}
|
||||
className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30"
|
||||
aria-label={t("plex.playlist.removeItem", { title: item.title })}
|
||||
label={t("plex.playlist.removeItem", { title: item.title })}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</FocusSafeButton>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -263,19 +257,22 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
|
||||
<div ref={setNodeRef} style={style} className="glass-card overflow-hidden rounded-xl">
|
||||
{/* Group header — drag moves the whole show; chevron collapses; X removes the whole show. */}
|
||||
<div className="flex items-center gap-2.5 p-2">
|
||||
<DragHandle title={block.title} {...attributes} {...listeners} />
|
||||
<button
|
||||
<DragHandle
|
||||
label={t("plex.playlist.reorderItem", { title: block.title })}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
<IconButton
|
||||
onClick={() => ctx.toggleShow(block.showKey)}
|
||||
className="grid h-6 w-6 shrink-0 place-items-center rounded-sm text-muted hover:text-fg"
|
||||
aria-expanded={open}
|
||||
aria-label={block.title}
|
||||
label={block.title}
|
||||
>
|
||||
{open ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
</button>
|
||||
{open ? <ChevronDown /> : <ChevronRight />}
|
||||
</IconButton>
|
||||
{!dense && (
|
||||
// A second, pointer-only target for the same toggle (its images are decorative): out of
|
||||
// the tab order, so the keyboard does not stop on a button with no name.
|
||||
<button
|
||||
<Pressable
|
||||
onClick={() => ctx.toggleShow(block.showKey)}
|
||||
className="flex shrink-0"
|
||||
tabIndex={-1}
|
||||
@@ -291,22 +288,22 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
|
||||
style={{ marginLeft: i ? -14 : 0 }}
|
||||
/>
|
||||
))}
|
||||
</button>
|
||||
</Pressable>
|
||||
)}
|
||||
<button onClick={() => ctx.toggleShow(block.showKey)} className="min-w-0 flex-1 text-left">
|
||||
<Pressable onClick={() => ctx.toggleShow(block.showKey)} className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold">{block.title}</div>
|
||||
<div className="text-2xs text-muted">
|
||||
{t("plex.playlist.episodes", { count: block.items.length })}
|
||||
</div>
|
||||
</button>
|
||||
<FocusSafeButton
|
||||
</Pressable>
|
||||
<IconButton
|
||||
variant="danger"
|
||||
onClick={() => ctx.onRemove(block.items.map((i) => i.id))}
|
||||
disabled={ctx.busy}
|
||||
title={t("plex.playlist.removeShow")}
|
||||
className="shrink-0 rounded-sm p-1 text-muted hover:text-danger disabled:opacity-30"
|
||||
label={t("plex.playlist.removeShow")}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</FocusSafeButton>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{open && (
|
||||
@@ -322,29 +319,26 @@ function ShowGroup({ block, ctx }: { block: Extract<Block, { kind: "show" }>; ct
|
||||
<div key={s.key} className={dense ? "" : "mt-1.5 first:mt-0.5"}>
|
||||
<div className="flex items-center gap-1.5 py-1 pl-1">
|
||||
{dense && (
|
||||
<button
|
||||
<IconButton
|
||||
onClick={() => ctx.toggleSeason(sKey)}
|
||||
className="grid h-5 w-5 shrink-0 place-items-center rounded-sm text-muted hover:text-fg"
|
||||
aria-expanded={sOpen}
|
||||
label={seasonLabel(s)}
|
||||
>
|
||||
{sOpen ? (
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5" />
|
||||
)}
|
||||
</button>
|
||||
{sOpen ? <ChevronDown /> : <ChevronRight />}
|
||||
</IconButton>
|
||||
)}
|
||||
<span className="text-2xs font-medium uppercase tracking-wide text-muted">
|
||||
{seasonLabel(s)} · {s.items.length}
|
||||
</span>
|
||||
<FocusSafeButton
|
||||
<IconButton
|
||||
variant="danger"
|
||||
onClick={() => ctx.onRemove(s.items.map((i) => i.id))}
|
||||
disabled={ctx.busy}
|
||||
title={t("plex.playlist.removeSeason")}
|
||||
className="ml-1 rounded-sm p-0.5 text-muted hover:text-danger disabled:opacity-30"
|
||||
label={t("plex.playlist.removeSeason")}
|
||||
className="ml-1"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</FocusSafeButton>
|
||||
<X />
|
||||
</IconButton>
|
||||
</div>
|
||||
{sOpen && (
|
||||
<div
|
||||
@@ -556,32 +550,28 @@ export default function PlexPlaylistView({
|
||||
onChange={setLayout}
|
||||
label={t("plex.playlist.layoutLabel")}
|
||||
/>
|
||||
<button
|
||||
<IconButton
|
||||
variant="glass"
|
||||
size="md"
|
||||
onClick={() => {
|
||||
setName(q.data?.title ?? "");
|
||||
setRenaming(true);
|
||||
}}
|
||||
title={t("plex.playlist.rename")}
|
||||
className="glass-card glass-hover rounded-lg p-2 text-sm hover:text-accent"
|
||||
label={t("plex.playlist.rename")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</button>
|
||||
<Pencil />
|
||||
</IconButton>
|
||||
{items.length > 0 && (
|
||||
<button
|
||||
<Button
|
||||
onClick={() => onPlay(order[0]!, order)}
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-accent px-4 py-2 font-medium text-accent-fg hover:opacity-90"
|
||||
icon={<Play className="fill-current" />}
|
||||
>
|
||||
<Play className="h-5 w-5 fill-current" />
|
||||
{t("plex.playlist.playAll")}
|
||||
</button>
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
onClick={del}
|
||||
title={t("plex.playlist.delete")}
|
||||
className="glass-card glass-hover rounded-lg p-2 text-sm hover:text-danger"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
<IconButton variant="glass" size="md" onClick={del} label={t("plex.playlist.delete")}>
|
||||
<Trash2 />
|
||||
</IconButton>
|
||||
</div>
|
||||
|
||||
{q.isError && !q.data ? (
|
||||
|
||||
Reference in New Issue
Block a user