From 2c843e549b1991e34dd0fb2e8ceacc87f5075a40 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Tue, 25 Aug 2026 02:30:28 +0200 Subject: [PATCH] =?UTF-8?q?fix(channels):=20tag-filter=20row=20folds=20ove?= =?UTF-8?q?rflow=20into=20"+N",=20keeps=20"=E2=80=A6"=20pinned?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The channel manager's tag-filter row wrapped the trailing "…" catalog-actions menu onto a second line once the chips + Clear outgrew the width. Rework it as one non-wrapping row: the TAGS editor trigger (left), a single-line chip lane that collapses overflow into a "+N" pill whose popover holds the rest (still toggleable), then Clear and the "…" menu pinned right — so "…" always stays on the first line. New TagFilterChips reuses the shared measure core (useChipOverflowFit + fitChipsWithPill), the same compression the feed's applied-filter row uses. --- frontend/src/components/Channels.tsx | 80 ++++++------- frontend/src/components/TagFilterChips.tsx | 128 +++++++++++++++++++++ frontend/src/i18n/locales/en/channels.json | 4 +- frontend/src/i18n/locales/hu/channels.json | 4 +- 4 files changed, 174 insertions(+), 42 deletions(-) create mode 100644 frontend/src/components/TagFilterChips.tsx diff --git a/frontend/src/components/Channels.tsx b/frontend/src/components/Channels.tsx index 46388acf..c8e7a907 100644 --- a/frontend/src/components/Channels.tsx +++ b/frontend/src/components/Channels.tsx @@ -18,7 +18,8 @@ import { useChannelTagActions } from "../lib/useChannelTagActions"; import { useChannelPriority } from "../lib/useChannelPriority"; import ViewSwitcher, { type ViewOption } from "./ViewSwitcher"; import ColumnSortControl from "./ColumnSortControl"; -import FilterChipGroup, { filterChipClass } from "./FilterChipGroup"; +import FilterChipGroup from "./FilterChipGroup"; +import TagFilterChips, { type FilterChip } from "./TagFilterChips"; import ActionMenu, { type ActionMenuItem } from "./ActionMenu"; import ChannelStatsPopover from "./ChannelStatsPopover"; import Pager from "./Pager"; @@ -229,6 +230,22 @@ export default function Channels() { const noTagOn = tagFilter.includes(NO_TAG_SENTINEL); const toggleNoTagFilter = () => setFilters({ ...filters, tags: noTagOn ? [] : [NO_TAG_SENTINEL] }); + // The tag-filter chips (user tags + the "No tag" sentinel), fed to TagFilterChips which folds any + // overflow into a "+N" pill so the row stays one line and the trailing "…" actions can't wrap. + const tagFilterChips: FilterChip[] = [ + ...userTags.map((tg) => ({ + key: `t${tg.id}`, + label: tg.name, + active: tagFilter.includes(String(tg.id)), + onToggle: () => toggleTagFilter(tg.id), + })), + { + key: NO_TAG_SENTINEL, + label: t("channels.tags.noTag"), + active: noTagOn, + onToggle: toggleNoTagFilter, + }, + ]; // A focus-channel intent (header "without full history" / tag manager) seeds the search box. useEffect(() => { if (focusChannelName) setSearch(focusChannelName); @@ -433,48 +450,31 @@ export default function Channels() { {tabs} {view === "subscribed" && (
- {/* Your tags (left) share one row with the two catalog-wide actions (right); the channel-name - search lives in the shared top SearchBar, tag create/delete in the manager. */} -
-
- {/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate - "Manage tags" button; the tooltip explains what tags are + that it's clickable. */} - - - - {userTags.map((tg) => ( - - ))} + {/* One non-wrapping row: the TAGS editor trigger (left) + the tag-filter chip lane (folds + overflow into "+N") + Clear, then the catalog "…" actions pinned right. It never wraps, so the + "…" menu always stays on the first line. Channel-name search lives in the shared top SearchBar. */} +
+ {/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate + "Manage tags" button; the tooltip explains what tags are + that it's clickable. */} + - {tagFilter.length > 0 && ( - - )} -
- {actionButtons} + + + {tagFilter.length > 0 && ( + + )} +
{actionButtons}
)} diff --git a/frontend/src/components/TagFilterChips.tsx b/frontend/src/components/TagFilterChips.tsx new file mode 100644 index 00000000..e2efd945 --- /dev/null +++ b/frontend/src/components/TagFilterChips.tsx @@ -0,0 +1,128 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { fitChipsWithPill } from "../lib/chipOverflow"; +import { useChipOverflowFit } from "../lib/useChipOverflowFit"; +import { filterChipClass } from "./FilterChipGroup"; +import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover"; + +// A single-line row of toggle filter chips that collapses its overflow into a trailing "+N" pill whose +// popover holds the rest (still toggleable) — the same compression the feed's applied-filter row uses +// (ActiveFilterChips), sharing the same measure core (useChipOverflowFit + fitChipsWithPill). Used by +// the channel manager's tag-filter row so it never wraps to a second line and the trailing "…" actions +// stay pinned on the first line. Any fixed leading (the TAGS editor trigger) / trailing (Clear, the "…" +// menu) chrome lives OUTSIDE this lane in the caller, so only the chips themselves fold into "+N". + +export interface FilterChip { + key: string; + label: string; + active: boolean; + onToggle: () => void; +} + +function Chip({ chip, extra }: { chip: FilterChip; extra?: string }) { + return ( + + ); +} + +export default function TagFilterChips({ + chips, + className, +}: { + chips: FilterChip[]; + className?: string; +}) { + const { t } = useTranslation(); + const rowRef = useRef(null); + const chipRefs = useRef>([]); + const pillRef = useRef(null); + // Re-fit when the set, the labels, OR the pressed state change (the pressed paint can change a chip's + // width via the `.pill` recipe, so an aria-pressed flip must re-measure too). + const key = chips.map((c) => `${c.key}:${c.label}:${c.active ? 1 : 0}`).join("|"); + + const visible = useChipOverflowFit({ + rowRef, + chipsRef: chipRefs, + pillRef, + deps: key, + initial: chips.length, + decide: (g) => + fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }), + }); + + const hidden = chips.length - visible; + const [open, setOpen] = useState(false); + const pop = useAnchoredPopover({ + open, + onOpenChange: setOpen, + mode: "menu", + placement: "bottom-end", + }); + // If nothing overflows anymore, the "+N" trigger unmounts — force the popover closed so it can't + // linger against a gone reference. + useEffect(() => { + if (hidden === 0 && open) setOpen(false); + }, [hidden, open]); + + const moreLabel = t("channels.tags.moreCount", { count: hidden }); + + return ( +
+ {/* Hidden measurer: all chips + a sample "+N" pill at natural widths, so the shared fit core can + read each chip's right edge, the rem gap and the pill width off real boxes. */} +
+ {chips.map((c, i) => ( + { + chipRefs.current[i] = el; + }} + className="inline-flex" + > + {c.label} + + ))} + + +{chips.length} + +
+ +
+ {chips.slice(0, visible).map((c) => ( + + ))} + {hidden > 0 && ( + + )} +
+ + + {chips.slice(visible).map((c) => ( + + ))} + +
+ ); +} diff --git a/frontend/src/i18n/locales/en/channels.json b/frontend/src/i18n/locales/en/channels.json index f3d76b7b..9cebcbec 100644 --- a/frontend/src/i18n/locales/en/channels.json +++ b/frontend/src/i18n/locales/en/channels.json @@ -52,7 +52,9 @@ "create": "Create tag", "createPlaceholder": "New tag…", "createFailed": "Could not create the tag.", - "noTag": "No tag" + "noTag": "No tag", + "moreCount_one": "{{count}} more tag", + "moreCount_other": "{{count}} more tags" }, "loading": "Loading channels…", "empty": "No channels.", diff --git a/frontend/src/i18n/locales/hu/channels.json b/frontend/src/i18n/locales/hu/channels.json index 78accf16..6b1189e0 100644 --- a/frontend/src/i18n/locales/hu/channels.json +++ b/frontend/src/i18n/locales/hu/channels.json @@ -52,7 +52,9 @@ "create": "Címke létrehozása", "createPlaceholder": "Új címke…", "createFailed": "A címke létrehozása nem sikerült.", - "noTag": "Nincs címke" + "noTag": "Nincs címke", + "moreCount_one": "még {{count}} címke", + "moreCount_other": "még {{count}} címke" }, "loading": "Csatornák betöltése…", "empty": "Nincsenek csatornák.",