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 { useChannelPriority } from "../lib/useChannelPriority";
|
||||||
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
|
||||||
import ColumnSortControl from "./ColumnSortControl";
|
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 ActionMenu, { type ActionMenuItem } from "./ActionMenu";
|
||||||
import ChannelStatsPopover from "./ChannelStatsPopover";
|
import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||||
import Pager from "./Pager";
|
import Pager from "./Pager";
|
||||||
@@ -229,6 +230,22 @@ export default function Channels() {
|
|||||||
const noTagOn = tagFilter.includes(NO_TAG_SENTINEL);
|
const noTagOn = tagFilter.includes(NO_TAG_SENTINEL);
|
||||||
const toggleNoTagFilter = () =>
|
const toggleNoTagFilter = () =>
|
||||||
setFilters({ ...filters, tags: noTagOn ? [] : [NO_TAG_SENTINEL] });
|
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.
|
// A focus-channel intent (header "without full history" / tag manager) seeds the search box.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (focusChannelName) setSearch(focusChannelName);
|
if (focusChannelName) setSearch(focusChannelName);
|
||||||
@@ -433,48 +450,31 @@ export default function Channels() {
|
|||||||
{tabs}
|
{tabs}
|
||||||
{view === "subscribed" && (
|
{view === "subscribed" && (
|
||||||
<div className="px-4 pt-2 pb-2 max-w-384 mx-auto">
|
<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
|
{/* One non-wrapping row: the TAGS editor trigger (left) + the tag-filter chip lane (folds
|
||||||
search lives in the shared top SearchBar, tag create/delete in the manager. */}
|
overflow into "+N") + Clear, then the catalog "…" actions pinned right. It never wraps, so the
|
||||||
<div className="flex items-center justify-between gap-4 flex-wrap">
|
"…" menu always stays on the first line. Channel-name search lives in the shared top SearchBar. */}
|
||||||
<div className="flex flex-wrap items-center gap-1.5">
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
{/* The "TAGS" label IS the tags-editor trigger (pencil), replacing the separate
|
{/* 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. */}
|
"Manage tags" button; the tooltip explains what tags are + that it's clickable. */}
|
||||||
<Tooltip hint={t("channels.tags.yourTagsHint")}>
|
<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>
|
|
||||||
))}
|
|
||||||
<button
|
<button
|
||||||
onClick={toggleNoTagFilter}
|
onClick={() => setTagManagerOpen(true)}
|
||||||
aria-pressed={noTagOn}
|
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"
|
||||||
className={filterChipClass}
|
|
||||||
>
|
>
|
||||||
{t("channels.tags.noTag")}
|
<Pencil className="h-3 w-3" />
|
||||||
|
{t("channels.tags.label")}
|
||||||
</button>
|
</button>
|
||||||
{tagFilter.length > 0 && (
|
</Tooltip>
|
||||||
<button
|
<TagFilterChips chips={tagFilterChips} className="flex-1" />
|
||||||
onClick={() => setFilters({ ...filters, tags: [] })}
|
{tagFilter.length > 0 && (
|
||||||
className="text-xs px-2 py-1 text-muted hover:text-accent transition"
|
<button
|
||||||
>
|
onClick={() => setFilters({ ...filters, tags: [] })}
|
||||||
{t("datatable.clear")}
|
className="shrink-0 text-xs px-2 py-1 text-muted hover:text-accent transition"
|
||||||
</button>
|
>
|
||||||
)}
|
{t("datatable.clear")}
|
||||||
</div>
|
</button>
|
||||||
{actionButtons}
|
)}
|
||||||
|
<div className="shrink-0">{actionButtons}</div>
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -52,7 +52,9 @@
|
|||||||
"create": "Create tag",
|
"create": "Create tag",
|
||||||
"createPlaceholder": "New tag…",
|
"createPlaceholder": "New tag…",
|
||||||
"createFailed": "Could not create the 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…",
|
"loading": "Loading channels…",
|
||||||
"empty": "No channels.",
|
"empty": "No channels.",
|
||||||
|
|||||||
@@ -52,7 +52,9 @@
|
|||||||
"create": "Címke létrehozása",
|
"create": "Címke létrehozása",
|
||||||
"createPlaceholder": "Új címke…",
|
"createPlaceholder": "Új címke…",
|
||||||
"createFailed": "A címke létrehozása nem sikerült.",
|
"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…",
|
"loading": "Csatornák betöltése…",
|
||||||
"empty": "Nincsenek csatornák.",
|
"empty": "Nincsenek csatornák.",
|
||||||
|
|||||||
Reference in New Issue
Block a user