feat(feed): use the Combobox picker for Channel/Your tags/Topic/Language filters

Replaces the hand-rolled channel search list and the flat tag chip walls with the
searchable, ARIA-correct Combobox built for the Plex filter bar (E5), so the YT feed
filters get the same input+listbox+chips UX with facet counts.

- ChannelPicker now wraps Combobox (selectedLabels keeps picks named); drops its bespoke
  input + scroll-fade list.
- Sidebar language/topic/tags: each Combobox owns only its slice of the shared
  filters.tags list (setTagSelection replaces that list's ids, leaves the rest). Topic
  keeps its any/all match toggle, Your tags keeps Manage. TagChip removed.
This commit is contained in:
2026-08-21 17:01:41 +02:00
parent f6192aa6bf
commit ad0cf5827b
2 changed files with 55 additions and 160 deletions
+16 -76
View File
@@ -1,11 +1,8 @@
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";
import Combobox, { type ComboboxOption } from "./Combobox";
// 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:
@@ -22,86 +19,29 @@ export default function ChannelPicker({
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],
});
// The list order isn't alphabetical — it's the user's channel priority — and Combobox preserves the
// options order, so it stays meaningful. selectedLabels keeps a picked channel named even if a later
// narrowing ever drops it from the visible rows.
const options: ComboboxOption[] = all.map((c) => ({ value: c.id, label: c.title ?? c.id }));
const selectedLabels = Object.fromEntries(all.map((c) => [c.id, c.title ?? c.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>
</>
<Combobox
label={t("sidebar.channelSearch")}
placeholder={t("sidebar.channelSearch")}
options={options}
selected={filters.channelIds}
onChange={(channelIds) => setFilters({ ...filters, channelIds })}
loading={channelsQuery.isLoading}
emptyText={t("sidebar.noChannelMatch")}
selectedLabels={selectedLabels}
/>
);
}