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
+39 -84
View File
@@ -15,7 +15,8 @@ import { type PanelItem } from "./PanelGroups";
import { type MediaSourceKind } from "../lib/mediaSource";
import ChannelPicker from "./ChannelPicker";
import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup, { filterChipClass } from "./FilterChipGroup";
import FilterChipGroup from "./FilterChipGroup";
import Combobox, { type ComboboxOption } from "./Combobox";
import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider";
import { useChannelsActions } from "./ChannelsProvider";
import { useMe } from "../lib/useMe";
@@ -29,39 +30,6 @@ const DATE_PRESETS: { days: number; key: string }[] = [
{ days: 365, key: "1year" },
];
function TagChip({
tag,
count,
active,
onClick,
}: {
tag: Tag;
count: number;
active: boolean;
onClick: () => void;
}) {
const { t } = useTranslation();
return (
<button
data-testid="filter-tag-chip"
data-tag={tag.name}
onClick={onClick}
title={t("sidebar.channelCount", { count })}
aria-pressed={active}
className={filterChipClass}
>
<span>{tag.name}</span>
<span
className={`tabular-nums text-3xs leading-none px-1 py-0.5 rounded-full ${
active ? "bg-accent-fg/20 text-accent-fg" : "bg-muted/15 text-muted"
}`}
>
{count}
</span>
</button>
);
}
export default function Sidebar({
layout,
setLayout,
@@ -113,11 +81,20 @@ export default function Sidebar({
// the panel layout key. `FilterRail` owns the customize `editing` state now.
const source: MediaSourceKind = "feed";
function toggleTag(id: number) {
const has = filters.tags.includes(id);
// The language/topic/tags sections all write into the one `filters.tags` list, so each combobox owns
// only its own slice: its selection replaces the ids that belong to its list, leaving the others.
const tagOption = (tg: Tag): ComboboxOption => ({
value: String(tg.id),
label: tg.name,
count: chipCount(tg),
});
const selectedIn = (list: Tag[]): string[] =>
filters.tags.filter((id) => list.some((tg) => tg.id === id)).map(String);
function setTagSelection(list: Tag[], next: string[]) {
const inList = new Set(list.map((tg) => tg.id));
setFilters({
...filters,
tags: has ? filters.tags.filter((t) => t !== id) : [...filters.tags, id],
tags: [...filters.tags.filter((id) => !inList.has(id)), ...next.map(Number)],
});
}
@@ -216,28 +193,19 @@ export default function Sidebar({
);
}
case "language": {
const chips = visibleChips(languages);
return (
<div className="flex flex-wrap gap-1.5">
{chips.map((tg) => (
<TagChip
key={tg.id}
tag={tg}
count={chipCount(tg)}
active={filters.tags.includes(tg.id)}
onClick={() => toggleTag(tg.id)}
/>
))}
{facetsReady && chips.length === 0 && (
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
)}
</div>
<Combobox
label={t("sidebar.widget.language")}
options={visibleChips(languages).map(tagOption)}
selected={selectedIn(languages)}
onChange={(next) => setTagSelection(languages, next)}
emptyText={t("sidebar.noMatchingTags")}
/>
);
}
case "topic": {
const chips = visibleChips(topics);
return (
<>
<div className="flex flex-col gap-2">
<SegmentedToggle
label={t("sidebar.match")}
title={t("sidebar.tagModeTooltip")}
@@ -249,43 +217,30 @@ export default function Sidebar({
value={filters.tagMode}
onChange={(tagMode) => setFilters({ ...filters, tagMode })}
/>
<div className="flex flex-wrap gap-1.5">
{chips.map((tg) => (
<TagChip
key={tg.id}
tag={tg}
count={chipCount(tg)}
active={filters.tags.includes(tg.id)}
onClick={() => toggleTag(tg.id)}
/>
))}
{facetsReady && chips.length === 0 && (
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
)}
</div>
</>
<Combobox
label={t("sidebar.widget.topic")}
options={visibleChips(topics).map(tagOption)}
selected={selectedIn(topics)}
onChange={(next) => setTagSelection(topics, next)}
emptyText={t("sidebar.noMatchingTags")}
/>
</div>
);
}
case "tags": {
const tagChips = visibleChips(userTags);
return (
<div className="flex flex-wrap items-center gap-1.5">
{tagChips.map((tg) => (
<TagChip
key={tg.id}
tag={tg}
count={chipCount(tg)}
active={filters.tags.includes(tg.id)}
onClick={() => toggleTag(tg.id)}
/>
))}
{facetsReady && tagChips.length === 0 && (
<span className="text-2xs text-muted">{t("sidebar.noMatchingTags")}</span>
)}
<div className="flex flex-col gap-2">
<Combobox
label={t("sidebar.widget.tags")}
options={visibleChips(userTags).map(tagOption)}
selected={selectedIn(userTags)}
onChange={(next) => setTagSelection(userTags, next)}
emptyText={t("sidebar.noMatchingTags")}
/>
<button
onClick={() => setTagManagerOpen(true)}
title={t("sidebar.manageTags")}
className="inline-flex items-center gap-1 text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
className="inline-flex items-center gap-1 self-start text-xs px-2 py-1 rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
>
<Pencil className="w-3 h-3" />
{t("sidebar.manageTags")}