fix(channels): tag-filter row folds overflow into "+N", keeps "…" pinned

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.
This commit is contained in:
2026-08-25 02:30:28 +02:00
parent 4ee0dab68f
commit 2c843e549b
4 changed files with 174 additions and 42 deletions
+40 -40
View File
@@ -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" && (
<div className="px-4 pt-2 pb-2 max-w-384 mx-auto">
{/* 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. */}
<div className="flex items-center justify-between gap-4 flex-wrap">
<div className="flex flex-wrap items-center gap-1.5">
{/* 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. */}
<Tooltip hint={t("channels.tags.yourTagsHint")}>
<button
onClick={() => setTagManagerOpen(true)}
className="mr-1 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
>
<Pencil className="h-3 w-3" />
{t("channels.tags.label")}
</button>
</Tooltip>
{userTags.map((tg) => (
<button
key={tg.id}
onClick={() => toggleTagFilter(tg.id)}
aria-pressed={tagFilter.includes(String(tg.id))}
className={filterChipClass}
>
{tg.name}
</button>
))}
{/* 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. */}
<div className="flex items-center gap-2 min-w-0">
{/* 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. */}
<Tooltip hint={t("channels.tags.yourTagsHint")}>
<button
onClick={toggleNoTagFilter}
aria-pressed={noTagOn}
className={filterChipClass}
onClick={() => setTagManagerOpen(true)}
className="shrink-0 inline-flex items-center gap-1.5 rounded-lg border border-border px-2 py-1 text-xs uppercase tracking-wide text-muted transition hover:border-accent hover:text-accent"
>
{t("channels.tags.noTag")}
<Pencil className="h-3 w-3" />
{t("channels.tags.label")}
</button>
{tagFilter.length > 0 && (
<button
onClick={() => setFilters({ ...filters, tags: [] })}
className="text-xs px-2 py-1 text-muted hover:text-accent transition"
>
{t("datatable.clear")}
</button>
)}
</div>
{actionButtons}
</Tooltip>
<TagFilterChips chips={tagFilterChips} className="flex-1" />
{tagFilter.length > 0 && (
<button
onClick={() => setFilters({ ...filters, tags: [] })}
className="shrink-0 text-xs px-2 py-1 text-muted hover:text-accent transition"
>
{t("datatable.clear")}
</button>
)}
<div className="shrink-0">{actionButtons}</div>
</div>
</div>
)}
+128
View File
@@ -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 (
<button
type="button"
onClick={chip.onToggle}
aria-pressed={chip.active}
className={`${filterChipClass}${extra ? ` ${extra}` : ""}`}
>
{chip.label}
</button>
);
}
export default function TagFilterChips({
chips,
className,
}: {
chips: FilterChip[];
className?: string;
}) {
const { t } = useTranslation();
const rowRef = useRef<HTMLDivElement | null>(null);
const chipRefs = useRef<Array<HTMLElement | null>>([]);
const pillRef = useRef<HTMLSpanElement | null>(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<number>({
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 (
<div className={`relative flex items-center gap-1.5 min-w-0 ${className ?? ""}`}>
{/* 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. */}
<div
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1.5 whitespace-nowrap"
>
{chips.map((c, i) => (
<span
key={c.key}
ref={(el) => {
chipRefs.current[i] = el;
}}
className="inline-flex"
>
<span className={filterChipClass}>{c.label}</span>
</span>
))}
<span ref={pillRef} className={filterChipClass}>
+{chips.length}
</span>
</div>
<div ref={rowRef} className="flex items-center gap-1.5 min-w-0 flex-1 overflow-hidden">
{chips.slice(0, visible).map((c) => (
<Chip key={c.key} chip={c} extra="shrink-0" />
))}
{hidden > 0 && (
<button
ref={pop.refs.setReference}
type="button"
aria-label={moreLabel}
className={`${filterChipClass} shrink-0 ${open ? "border-accent text-accent" : ""}`}
{...pop.getReferenceProps()}
>
+{hidden}
</button>
)}
</div>
<PopoverSurface
popover={pop}
ariaLabel={moreLabel}
className="glass-menu flex max-w-[22rem] flex-wrap items-center gap-1.5 rounded-xl p-2 shadow-lg animate-[popIn_0.16s_ease]"
>
{chips.slice(visible).map((c) => (
<Chip key={c.key} chip={c} />
))}
</PopoverSurface>
</div>
);
}