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.
108 lines
4.2 KiB
TypeScript
108 lines
4.2 KiB
TypeScript
import { useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { X } from "lucide-react";
|
|
import { api, type FeedFilters } from "../lib/api";
|
|
import { useScrollFade } from "../lib/useScrollFade";
|
|
import { inputCls } from "./ui/form";
|
|
|
|
// Pick channels to narrow the feed to — several OR together, so every other filter then applies
|
|
// across all of them. Until this existed the filter was settable only via a shared "?channel=" link:
|
|
// the panel had a remove button for something nothing could apply.
|
|
//
|
|
// It owns its channel query rather than letting the Sidebar hold it: the Sidebar is mounted for the
|
|
// whole feed page, but SidePanel only renders its children once the panel is open, so keeping the
|
|
// fetch here means a subscription list of a few hundred loads when the picker is actually looked at.
|
|
export default function ChannelPicker({
|
|
filters,
|
|
setFilters,
|
|
}: {
|
|
filters: FeedFilters;
|
|
setFilters: (f: FeedFilters) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [query, setQuery] = useState("");
|
|
// Same edge-fade affordance the nav rail and the panel body use to hint at more content.
|
|
const fade = useScrollFade();
|
|
const channelsQuery = useQuery({
|
|
queryKey: qk.channels(),
|
|
queryFn: api.channels,
|
|
staleTime: 5 * 60_000,
|
|
});
|
|
|
|
const q = query.trim().toLowerCase();
|
|
const all = channelsQuery.data ?? [];
|
|
const matches = q ? all.filter((c) => (c.title ?? c.id).toLowerCase().includes(q)) : all;
|
|
const picked = filters.channelIds;
|
|
const nameOf = (id: string) => all.find((c) => c.id === id)?.title ?? id;
|
|
const toggle = (id: string) =>
|
|
setFilters({
|
|
...filters,
|
|
channelIds: picked.includes(id) ? picked.filter((x) => x !== id) : [...picked, id],
|
|
});
|
|
|
|
return (
|
|
<>
|
|
{/* The picks sit above the list so they stay visible once the list scrolls away. */}
|
|
{picked.length > 0 && (
|
|
<div className="flex flex-wrap gap-1.5 pb-2">
|
|
{picked.map((id) => (
|
|
<button
|
|
key={id}
|
|
onClick={() => toggle(id)}
|
|
title={t("sidebar.channelRemove", { name: nameOf(id) })}
|
|
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" />
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
<input
|
|
value={query}
|
|
onChange={(e) => setQuery(e.target.value)}
|
|
placeholder={t("sidebar.channelSearch")}
|
|
aria-label={t("sidebar.channelSearch")}
|
|
className={inputCls}
|
|
/>
|
|
{/* The order isn't arbitrary and isn't alphabetical — say so, or the top of the list looks
|
|
random to anyone who hasn't set a priority. Only while unfiltered: a search reorders nothing
|
|
but does drop the rows that explain it. */}
|
|
{!q && all.length > 0 && (
|
|
<div className="px-2 pt-1.5 text-2xs text-muted">{t("sidebar.channelOrder")}</div>
|
|
)}
|
|
<div
|
|
ref={fade.ref}
|
|
style={fade.style}
|
|
className="mt-1.5 max-h-44 overflow-y-auto no-scrollbar flex flex-col gap-0.5"
|
|
>
|
|
{matches.length === 0 ? (
|
|
<div className="px-2 py-1.5 text-xs text-muted">
|
|
{channelsQuery.isLoading ? t("sidebar.loading") : t("sidebar.noChannelMatch")}
|
|
</div>
|
|
) : (
|
|
matches.map((c) => {
|
|
const on = picked.includes(c.id);
|
|
return (
|
|
<button
|
|
key={c.id}
|
|
onClick={() => toggle(c.id)}
|
|
aria-pressed={on}
|
|
// shrink-0 is load-bearing: these are flex children in a max-height box, so without
|
|
// them they squash to ~12px each and clip their own text instead of the box scrolling.
|
|
className={`w-full shrink-0 text-left text-sm px-2 py-1.5 rounded-lg truncate transition ${
|
|
on ? "text-accent font-medium" : "text-muted hover:text-fg hover:bg-card"
|
|
}`}
|
|
>
|
|
{c.title ?? c.id}
|
|
</button>
|
|
);
|
|
})
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|