refactor(ui): R10 S4 — unify selected-paint into .pill/.is-current/.chip recipes
The ~14 hand-rolled "this is the selected/current/active one" idioms each repeated the same `sel ? "bg-accent text-accent-fg border-accent" : "…"` inline ternary, which had drifted. Three attribute-driven recipes in @layer components now own that paint, keyed on the ARIA the control already carries: .pill (aria-pressed segments/toggles), .is-current (aria-current nav/list items), and .chip / .chip-solid (tag & filter tokens). The JSX just toggles the ARIA; the CSS paints, so one control's selected look can't diverge from the next's. No focus-visible in the recipes — the @layer base rule already rings every button/a. Migrated 15 sites (Feed show/content, Channels tag filter, Sidebar or-and + scope, PlexSidebar match, Tabs, SettingsPanel tab-rail, PlexPlaylistView layout [soft], NavSidebar nav + settings, ActiveFilterChips, PlexSidebar RemovableChip, ChannelTags, ChannelPicker). Deliberately left: PlexSidebar facet Chip and PlexPlaylistView already covered; SavedViews row (paint split across two elements), the scheme swatch (border-only on a coloured swatch), and ChannelPicker's option rows (text-only selection) — noted for a follow-up.
This commit is contained in:
@@ -32,7 +32,7 @@ export default function ActiveFilterChips({
|
||||
onClick={f.remove}
|
||||
title={t("feed.chips.remove", { name: f.label })}
|
||||
aria-label={t("feed.chips.remove", { name: f.label })}
|
||||
className="group inline-flex items-center gap-1 pl-2.5 pr-1.5 py-1 rounded-full border border-accent/40 bg-accent/10 text-accent text-xs hover:border-accent hover:bg-accent/20 transition"
|
||||
className="chip group pl-2.5 pr-1.5 py-1 text-xs hover:border-accent hover:bg-accent/20 transition"
|
||||
>
|
||||
<span className="max-w-56 truncate">{f.label}</span>
|
||||
<X className="w-3 h-3 shrink-0 opacity-60 group-hover:opacity-100" />
|
||||
|
||||
@@ -52,7 +52,7 @@ export default function ChannelPicker({
|
||||
key={id}
|
||||
onClick={() => toggle(id)}
|
||||
title={t("sidebar.channelRemove", { name: nameOf(id) })}
|
||||
className="inline-flex items-center gap-1 max-w-full pl-2.5 pr-1.5 py-1 rounded-full bg-accent text-accent-fg text-xs shadow-xs hover:opacity-90 transition"
|
||||
className="chip-solid inline-flex items-center gap-1 max-w-full pl-2.5 pr-1.5 py-1 rounded-full text-xs shadow-xs hover:opacity-90 transition"
|
||||
>
|
||||
<span className="truncate">{nameOf(id)}</span>
|
||||
<X className="w-3 h-3 shrink-0" />
|
||||
|
||||
@@ -22,8 +22,7 @@ interface TagChipProps {
|
||||
}
|
||||
|
||||
function TagChip({ tag, onClick, title }: TagChipProps) {
|
||||
const cls =
|
||||
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent max-w-36 truncate";
|
||||
const cls = "chip-solid shrink-0 text-3xs px-1.5 py-0.5 rounded-full border max-w-36 truncate";
|
||||
return onClick ? (
|
||||
<button
|
||||
type="button"
|
||||
|
||||
@@ -667,11 +667,7 @@ export default function Channels() {
|
||||
setTagFilter((f) => (active ? f.filter((x) => x !== tg.id) : [...f, tg.id]))
|
||||
}
|
||||
aria-pressed={active}
|
||||
className={`text-xs px-2 py-1 rounded-full border transition ${
|
||||
active
|
||||
? "bg-accent text-accent-fg border-accent"
|
||||
: "bg-card border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
className="pill text-xs px-2 py-1 rounded-full border"
|
||||
>
|
||||
{tg.name}
|
||||
</button>
|
||||
|
||||
@@ -557,11 +557,7 @@ export default function Feed({
|
||||
data-testid={`feed-show-${id}`}
|
||||
onClick={() => setFilters({ ...filters, show: id })}
|
||||
aria-pressed={filters.show === id}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border transition ${
|
||||
filters.show === id
|
||||
? "bg-accent text-accent-fg border-accent"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
className="pill text-xs px-3 py-1.5 rounded-full border"
|
||||
>
|
||||
{t("sidebar.show." + id)}
|
||||
</button>
|
||||
@@ -575,11 +571,7 @@ export default function Feed({
|
||||
data-testid={`feed-content-${c.key}`}
|
||||
onClick={() => setFilters({ ...filters, [c.key]: !on })}
|
||||
aria-pressed={on}
|
||||
className={`text-xs px-3 py-1.5 rounded-full border transition ${
|
||||
on
|
||||
? "bg-accent text-accent-fg border-accent"
|
||||
: "border-border text-muted hover:text-fg hover:border-accent"
|
||||
}`}
|
||||
className="pill text-xs px-3 py-1.5 rounded-full border"
|
||||
>
|
||||
{t(c.label)}
|
||||
</button>
|
||||
|
||||
@@ -193,9 +193,7 @@ export default function NavSidebar({
|
||||
onClick={() => setPage(p)}
|
||||
title={slim ? label : undefined}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className={`${rowBase} ${slim ? "justify-center px-0" : ""} relative ${
|
||||
active ? "bg-accent text-accent-fg" : "text-muted hover:text-fg hover:bg-card"
|
||||
}`}
|
||||
className={`${rowBase} ${slim ? "justify-center px-0" : ""} relative is-current`}
|
||||
>
|
||||
<span className="relative shrink-0">
|
||||
<Icon className="w-4.5 h-4.5" />
|
||||
@@ -308,11 +306,7 @@ export default function NavSidebar({
|
||||
onClick={() => setPage("settings")}
|
||||
title={slim ? t("header.account.settings") : undefined}
|
||||
aria-current={page === "settings" ? "page" : undefined}
|
||||
className={`${rowBase} ${slim ? "justify-center px-0" : ""} ${
|
||||
page === "settings"
|
||||
? "bg-accent text-accent-fg"
|
||||
: "text-muted hover:text-fg hover:bg-card"
|
||||
}`}
|
||||
className={`${rowBase} ${slim ? "justify-center px-0" : ""} is-current`}
|
||||
>
|
||||
<Settings className="w-4.5 h-4.5 shrink-0" />
|
||||
{!slim && <span className="truncate">{t("header.account.settings")}</span>}
|
||||
|
||||
@@ -505,9 +505,8 @@ export default function PlexPlaylistView({
|
||||
title={label}
|
||||
aria-label={label}
|
||||
aria-pressed={layout === v}
|
||||
className={`rounded-lg p-2 text-sm transition ${
|
||||
layout === v ? "bg-accent/15 text-accent" : "glass-card glass-hover text-muted"
|
||||
}`}
|
||||
data-soft="true"
|
||||
className={`pill rounded-lg p-2 text-sm ${layout === v ? "" : "glass-card glass-hover text-muted"}`}
|
||||
>
|
||||
{icon}
|
||||
</button>
|
||||
|
||||
@@ -59,9 +59,7 @@ function MatchToggle({
|
||||
key={m}
|
||||
onClick={() => onChange(m)}
|
||||
aria-pressed={mode === m}
|
||||
className={`rounded-full px-2 py-0.5 transition ${
|
||||
mode === m ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
className="pill rounded-full px-2 py-0.5"
|
||||
>
|
||||
{t(`plex.filter.match.${m}`)}
|
||||
</button>
|
||||
@@ -518,7 +516,7 @@ function Chip({
|
||||
|
||||
function RemovableChip({ label, onRemove }: { label: string; onRemove: () => void }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-accent/15 text-accent px-2 py-1 text-xs">
|
||||
<span className="chip px-2 py-1 text-xs">
|
||||
<span className="truncate max-w-36">{label}</span>
|
||||
<button onClick={onRemove} className="hover:opacity-70" aria-label="remove">
|
||||
<X className="w-3 h-3" />
|
||||
|
||||
@@ -69,10 +69,9 @@ export default function SettingsPanel() {
|
||||
key={tabItem.id}
|
||||
data-testid={`settings-tab-${tabItem.id}`}
|
||||
onClick={() => setTab(tabItem.id)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${
|
||||
active
|
||||
? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
|
||||
: "text-muted hover:text-fg hover:bg-card/60"
|
||||
aria-pressed={active}
|
||||
className={`pill flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left ${
|
||||
active ? "font-medium shadow-md shadow-accent/20" : "hover:bg-card/60"
|
||||
}`}
|
||||
>
|
||||
<tabItem.icon className="w-4 h-4 shrink-0" />
|
||||
|
||||
@@ -281,11 +281,7 @@ export default function Sidebar({
|
||||
key={m}
|
||||
onClick={() => setFilters({ ...filters, tagMode: m })}
|
||||
aria-pressed={filters.tagMode === m}
|
||||
className={`px-2 py-0.5 rounded-full transition ${
|
||||
filters.tagMode === m
|
||||
? "bg-accent text-accent-fg"
|
||||
: "text-muted hover:text-fg"
|
||||
}`}
|
||||
className="pill px-2 py-0.5 rounded-full"
|
||||
>
|
||||
{m === "or" ? t("sidebar.any") : t("sidebar.all")}
|
||||
</button>
|
||||
@@ -392,9 +388,7 @@ export default function Sidebar({
|
||||
onClick={() => setFilters({ ...filters, scope: s })}
|
||||
title={t("header.scope." + s + "Tip")}
|
||||
aria-pressed={filters.scope === s}
|
||||
className={`flex-1 inline-flex items-center justify-center gap-1 px-2.5 py-1 rounded-full transition ${
|
||||
filters.scope === s ? "bg-accent text-accent-fg" : "text-muted hover:text-fg"
|
||||
}`}
|
||||
className="pill flex-1 inline-flex items-center justify-center gap-1 px-2.5 py-1 rounded-full"
|
||||
>
|
||||
{s === "my" ? <User className="w-3.5 h-3.5" /> : <Library className="w-3.5 h-3.5" />}
|
||||
<span>{t("header.scope." + s)}</span>
|
||||
|
||||
@@ -31,10 +31,9 @@ export default function Tabs({
|
||||
<button
|
||||
key={tb.id}
|
||||
onClick={() => onChange(tb.id)}
|
||||
className={`inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm transition ${
|
||||
on
|
||||
? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
|
||||
: "text-muted hover:text-fg hover:bg-card/60"
|
||||
aria-pressed={on}
|
||||
className={`pill inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm ${
|
||||
on ? "font-medium shadow-md shadow-accent/20" : "hover:bg-card/60"
|
||||
}`}
|
||||
>
|
||||
{tb.label}
|
||||
|
||||
@@ -418,6 +418,70 @@ body {
|
||||
outline: 2px solid color-mix(in srgb, var(--accent) 60%, transparent);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
/* ===== R10 S4 · Selection-paint recipes =====
|
||||
The ~14 hand-rolled "this is the selected / current / active one" idioms all painted the SAME
|
||||
bg-accent fill via an inline ternary (`sel ? "bg-accent text-accent-fg border-accent" : "…"`),
|
||||
which drifted over time. These recipes OWN that paint, keyed on the semantic ARIA the control
|
||||
already carries — the JSX just toggles `aria-pressed` (a segment/toggle) or `aria-current` (the
|
||||
single current item) and the CSS paints, so one control's selected look can never diverge from
|
||||
the next's. Geometry (padding / radius / border-width / font) stays a per-site utility since it
|
||||
legitimately varies. NO focus-visible here on purpose: the `@layer base` rule above already rings
|
||||
every button/a/[role]; re-adding it would duplicate that (an R9 review finding). Because these sit
|
||||
in @layer components, a site's own utility still wins — so a MIGRATED site must DROP its bg/text/
|
||||
border-color utilities (else a utility-layer `bg-*` beats the recipe's pressed fill). Add
|
||||
data-soft="true" for the subtle tint some surfaces use instead of the solid fill. */
|
||||
.pill,
|
||||
.is-current {
|
||||
color: var(--muted);
|
||||
border-color: var(--border); /* inert unless the site also adds a border-width utility */
|
||||
transition:
|
||||
color 0.15s ease,
|
||||
background-color 0.15s ease,
|
||||
border-color 0.15s ease;
|
||||
}
|
||||
.pill:hover,
|
||||
.is-current:hover {
|
||||
color: var(--fg);
|
||||
}
|
||||
.pill:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
}
|
||||
.is-current:hover {
|
||||
background-color: var(--card);
|
||||
}
|
||||
/* selected / current — SOLID accent fill (the dominant idiom) */
|
||||
.pill[aria-pressed="true"],
|
||||
.is-current[aria-current] {
|
||||
background-color: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
/* selected — SOFT tint variant (the plex layout toggle keeps a glass-card resting look, so its
|
||||
"on" state is a subtle accent tint rather than the solid fill). */
|
||||
.pill[aria-pressed="true"][data-soft="true"] {
|
||||
background-color: color-mix(in srgb, var(--accent) 14%, transparent);
|
||||
color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
}
|
||||
|
||||
/* .chip — an applied tag / filter token (rounded, tinted). The tint is the resting look (a filter
|
||||
is "on" whenever the chip exists); removable variants add their own × button as a child.
|
||||
.chip-solid = the strong-fill token (an applied tag colour, a picked channel). */
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
|
||||
background-color: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
color: var(--accent);
|
||||
}
|
||||
.chip-solid {
|
||||
background-color: var(--accent);
|
||||
color: var(--accent-fg);
|
||||
border-color: var(--accent);
|
||||
}
|
||||
}
|
||||
.glass-hover:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, transparent);
|
||||
|
||||
Reference in New Issue
Block a user