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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user