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