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:
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { BookmarkPlus, Check, GripVertical, Play, Save, Star, Trash2, X } from "lucide-react";
|
||||
import { BookmarkPlus, Check, Play, Save, Star, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
@@ -14,13 +14,15 @@ import { CSS } from "@dnd-kit/utilities";
|
||||
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
|
||||
import Modal from "./ui/Modal";
|
||||
import { Button } from "./ui/Button";
|
||||
import { IconButton } from "./ui/IconButton";
|
||||
import { useSavedViews, asFilters } from "./savedViewsContext";
|
||||
import { useActiveFilters } from "../lib/useActiveFilters";
|
||||
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||
import { useUndoable } from "../lib/useUndoable";
|
||||
import UndoToolbar from "./ui/UndoToolbar";
|
||||
import { FocusSafeButton } from "./ui/FocusSafeButton";
|
||||
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
|
||||
import { DragHandle } from "./ui/DragHandle";
|
||||
import { Pressable } from "./ui/Pressable";
|
||||
|
||||
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
|
||||
// reorder) plus a "save the current filters" input; the right column shows the selected view's name
|
||||
@@ -50,22 +52,13 @@ function ViewRow({
|
||||
selected ? "bg-accent/15" : "hover:bg-card"
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
aria-label={t("views.reorder")}
|
||||
className="cursor-grab p-1.5 text-muted hover:text-fg"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
<DragHandle label={t("views.reorder")} {...attributes} {...listeners} />
|
||||
<Pressable
|
||||
onClick={onSelect}
|
||||
className={`flex-1 min-w-0 truncate py-1.5 text-left text-sm ${
|
||||
selected ? "text-accent" : "text-fg"
|
||||
}`}
|
||||
className={`flex-1 min-w-0 truncate py-1.5 text-sm ${selected ? "text-accent" : "text-fg"}`}
|
||||
>
|
||||
{view.name}
|
||||
</button>
|
||||
</Pressable>
|
||||
{view.is_default && <Star className="h-3 w-3 shrink-0 fill-current text-accent" />}
|
||||
{active && <span className="mr-1 h-1.5 w-1.5 shrink-0 rounded-full bg-accent" />}
|
||||
</div>
|
||||
@@ -214,16 +207,15 @@ export default function PersonalViewsModal() {
|
||||
placeholder={t("views.namePlaceholder")}
|
||||
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-2 py-1.5 text-sm focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
iconOnly
|
||||
icon={<BookmarkPlus className="h-4 w-4" />}
|
||||
<IconButton
|
||||
label={t("views.save")}
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={onCreate}
|
||||
disabled={!createName.trim()}
|
||||
className="shrink-0"
|
||||
/>
|
||||
>
|
||||
<BookmarkPlus className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -238,15 +230,15 @@ export default function PersonalViewsModal() {
|
||||
onBlur={() => void onRename()}
|
||||
className="min-w-0 flex-1 rounded-lg border border-border bg-surface px-3 py-2 text-sm font-medium focus:border-accent focus:outline-hidden focus:ring-2 focus:ring-accent/30"
|
||||
/>
|
||||
<FocusSafeButton
|
||||
<IconButton
|
||||
variant="outline"
|
||||
size="md"
|
||||
onClick={() => void onRename()}
|
||||
disabled={!nameChanged}
|
||||
title={t("views.rename")}
|
||||
aria-label={t("views.rename")}
|
||||
className="inline-flex shrink-0 items-center rounded-lg border border-border p-2 text-muted transition enabled:not-aria-disabled:hover:border-accent enabled:not-aria-disabled:hover:text-accent disabled:opacity-30"
|
||||
label={t("views.rename")}
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</FocusSafeButton>
|
||||
<Check />
|
||||
</IconButton>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between gap-2">
|
||||
@@ -294,7 +286,10 @@ export default function PersonalViewsModal() {
|
||||
>
|
||||
{t("views.apply")}
|
||||
</Button>
|
||||
<FocusSafeButton
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
icon={<Save />}
|
||||
onClick={async () => {
|
||||
const saved = await saveCurrent(selected.id);
|
||||
// Re-seed the draft to what we just stored (the live filters) so the chip row
|
||||
@@ -305,36 +300,32 @@ export default function PersonalViewsModal() {
|
||||
title={
|
||||
saveCurrentDisabled ? t("views.saveCurrentUpToDate") : t("views.saveCurrentHint")
|
||||
}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-muted transition enabled:not-aria-disabled:hover:border-accent enabled:not-aria-disabled:hover:text-accent disabled:opacity-40"
|
||||
>
|
||||
<Save className="h-3.5 w-3.5" />
|
||||
{t("views.saveCurrent")}
|
||||
</FocusSafeButton>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
pressed={selected.is_default}
|
||||
onClick={() => void toggleDefault(selected)}
|
||||
title={t("views.default")}
|
||||
className={`inline-flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-sm transition ${
|
||||
selected.is_default
|
||||
? "border-accent text-accent"
|
||||
: "border-border text-muted hover:text-fg"
|
||||
}`}
|
||||
icon={<Star className={selected.is_default ? "fill-current" : ""} />}
|
||||
>
|
||||
<Star className={`h-3.5 w-3.5 ${selected.is_default ? "fill-current" : ""}`} />
|
||||
{t("views.default")}
|
||||
</button>
|
||||
<button
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger-soft"
|
||||
size="sm"
|
||||
icon={<Trash2 />}
|
||||
className="ml-auto"
|
||||
onClick={async () => {
|
||||
const next = views.find((v) => v.id !== selected.id);
|
||||
// Only move the selection if the delete actually happened (remove awaits a confirm
|
||||
// and resolves false on cancel) — otherwise cancelling would still switch the pane.
|
||||
if (await remove(selected)) setSelectedId(next?.id ?? null);
|
||||
}}
|
||||
title={t("views.delete")}
|
||||
className="ml-auto inline-flex items-center gap-1.5 rounded-lg border border-border px-3 py-1.5 text-sm text-danger transition hover:border-danger"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
{t("views.delete")}
|
||||
</button>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user