fix(channels): collapse overflowing tag suggestions into a "+N" pill

With 2+ suggestions the narrow tags cell clipped the second ghost chip
mid-word right next to the edit "+", reading as the icon covering it. Route
the ghost chips through the same fit machinery the attached chips use: a
dedicated SuggestedChipsLane fits what the cell holds and collapses the rest
into a "+N" pill whose hover popover still one-tap accepts / dismisses each.

The lane is attached-XOR-ghost (suggestions are untagged-only, and accepting
one now drops the whole channel from the suggestion cache), so each lane owns
the full cell width instead of clipping the other.
This commit is contained in:
2026-08-26 02:39:00 +02:00
parent 549aae67cc
commit 17f8885fb1
3 changed files with 183 additions and 68 deletions
@@ -11,9 +11,13 @@ import { FloatingTree } from "@floating-ui/react";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
// Interpolate the `name` option so per-tag accept labels are distinguishable ("…acceptSuggestion:docu").
return {
...actual,
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
useTranslation: () => ({
t: (key: string, opts?: { name?: string }) => (opts?.name ? `${key}:${opts.name}` : key),
i18n: { language: "en" },
}),
};
});
@@ -72,8 +76,8 @@ function renderControl(props: Partial<Parameters<typeof ChannelTagsControl>[0]>
describe("ChannelTagsControl suggested ghost chips", () => {
it("renders a ghost chip per suggestion and accepts on click", () => {
const { onAccept } = renderControl();
// Both suggested tag names show (attached row is empty on an untagged channel).
fireEvent.click(screen.getByText("docu"));
// Role query excludes the aria-hidden fit-measurer copy; the name carries the interpolated tag.
fireEvent.click(screen.getByRole("button", { name: "channels.tags.acceptSuggestion:docu" }));
expect(onAccept).toHaveBeenCalledWith(docu.id);
});
@@ -82,19 +86,20 @@ describe("ChannelTagsControl suggested ghost chips", () => {
const dismissButtons = screen.getAllByRole("button", {
name: "channels.tags.dismissSuggestion",
});
// Order matches the suggestions array — the second × is the "history" suggestion.
// Two visible ghost chips (the measurer's copies are aria-hidden → excluded); order matches the
// suggestions array, so the second × is the "history" suggestion.
expect(dismissButtons).toHaveLength(2);
fireEvent.click(dismissButtons[1]!);
expect(onDismiss).toHaveBeenCalledWith(history.id);
expect(onAccept).not.toHaveBeenCalled();
});
it("never shows a suggestion whose tag the channel already carries", () => {
it("shows the attached lane (no ghost chips) once the channel carries a user tag", () => {
renderControl({ attachedIds: [docu.id] });
// docu is attached → its ghost chip is suppressed (only history's × remains). Counting the
// dismiss buttons avoids colliding with docu's now-present *attached* chip text.
const dismissButtons = screen.getAllByRole("button", {
name: "channels.tags.dismissSuggestion",
});
expect(dismissButtons).toHaveLength(1);
// A tagged channel is not "untagged" → the ghost lane is not rendered at all (the server excludes
// tagged channels from suggestions; the control mirrors that with its attached-XOR-ghost branch).
expect(
screen.queryAllByRole("button", { name: "channels.tags.dismissSuggestion" }),
).toHaveLength(0);
});
});
+161 -52
View File
@@ -85,6 +85,110 @@ function SuggestedChip({
);
}
const GHOST_PILL =
"shrink-0 text-3xs px-1.5 py-0.5 rounded-full border border-dashed border-accent/50 text-accent/90";
/** A single-line lane of suggested ghost chips that collapses its overflow into a "+N" pill (hover
* reveals the rest, still accept/dismiss-able), reusing the SAME fit machinery as the attached-chip
* lane. Rendered in the untagged-channel case only, so it owns the full tags-cell width — this is what
* stops a 2nd suggestion from being clipped mid-word right next to the edit "+". */
function SuggestedChipsLane({
suggestions,
onAccept,
onDismiss,
}: {
suggestions: SuggestedTag[];
onAccept: (tagId: number) => void;
onDismiss: (tagId: number) => void;
}) {
const rowRef = useRef<HTMLDivElement | null>(null);
const chipRefs = useRef<Array<HTMLElement | null>>([]);
const pillRef = useRef<HTMLSpanElement | null>(null);
const key = suggestions.map((s) => `${s.tag.id}:${s.tag.name}`).join("|");
const visible = useChipOverflowFit<number>({
rowRef,
chipsRef: chipRefs,
pillRef,
deps: key,
initial: suggestions.length,
decide: (g) =>
fitChipsWithPill({ chipRights: g.chipRights, pillW: g.pillW, avail: g.avail, gap: g.gap }),
});
const hidden = suggestions.length - visible;
const [overflowOpen, setOverflowOpen] = useState(false);
const overflowPop = useAnchoredPopover({
open: overflowOpen,
onOpenChange: setOverflowOpen,
mode: "hover",
placement: "bottom",
});
useEffect(() => {
if (hidden === 0 && overflowOpen) setOverflowOpen(false);
}, [hidden, overflowOpen]);
return (
<div className="relative flex items-center gap-1 min-w-0 flex-1">
{/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */}
<div
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
>
{suggestions.map((s, i) => (
<span
key={s.tag.id}
ref={(el) => {
chipRefs.current[i] = el;
}}
className="inline-flex"
>
<SuggestedChip suggestion={s} onAccept={() => {}} onDismiss={() => {}} />
</span>
))}
<span ref={pillRef} className={GHOST_PILL}>
+{suggestions.length}
</span>
</div>
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
{suggestions.slice(0, visible).map((s) => (
<SuggestedChip
key={s.tag.id}
suggestion={s}
onAccept={() => onAccept(s.tag.id)}
onDismiss={() => onDismiss(s.tag.id)}
/>
))}
{hidden > 0 && (
<button
ref={overflowPop.refs.setReference}
type="button"
className={`${GHOST_PILL} hover:text-accent transition`}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
)}
</div>
{/* Hover: the overflowed suggestions, each still one-tap accept / × dismiss. */}
<PopoverSurface
popover={overflowPop}
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
>
{suggestions.slice(visible).map((s) => (
<SuggestedChip
key={s.tag.id}
suggestion={s}
onAccept={() => onAccept(s.tag.id)}
onDismiss={() => onDismiss(s.tag.id)}
/>
))}
</PopoverSurface>
</div>
);
}
/** The editor body: toggle the user's existing tags on/off for this channel, plus an inline
* "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel
* detail page in Part 2; internal for now.) */
@@ -270,60 +374,65 @@ export function ChannelTagsControl({
<div
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`}
>
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. Each
chip is wrapped in a ref span (index-aligned) so the shared fit hook can read its right edge;
the pill is the sample the hook reserves room for. */}
<div
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
>
{attached.map((tg, i) => (
<span
key={tg.id}
ref={(el) => {
chipRefs.current[i] = el;
}}
className="inline-flex"
{/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the
attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged
channels only, and accepting one removes the channel from the suggestion set — so a channel
never shows both, and each lane gets the full cell width instead of clipping the other. */}
{attached.length === 0 && shownSuggestions.length > 0 ? (
<SuggestedChipsLane
suggestions={shownSuggestions}
onAccept={(tagId) => onAcceptSuggestion?.(tagId)}
onDismiss={(tagId) => onDismissSuggestion?.(tagId)}
/>
) : (
<>
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths.
Each chip is wrapped in a ref span (index-aligned) so the shared fit hook can read its
right edge; the pill is the sample the hook reserves room for. */}
<div
aria-hidden
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
>
<TagChip tag={tg} />
</span>
))}
<span ref={pillRef} className={pillClass}>
+{attached.length}
</span>
</div>
{attached.map((tg, i) => (
<span
key={tg.id}
ref={(el) => {
chipRefs.current[i] = el;
}}
className="inline-flex"
>
<TagChip tag={tg} />
</span>
))}
<span ref={pillRef} className={pillClass}>
+{attached.length}
</span>
</div>
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
{attached.slice(0, visible).map((tg) => (
<TagChip
key={tg.id}
tag={tg}
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined}
/>
))}
{hidden > 0 && (
<button
ref={overflowPop.refs.setReference}
type="button"
className={`${pillClass} hover:text-fg transition`}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
)}
{/* Suggested ghost chips (E8·S4) ride in the same overflow-hidden row. They only appear on
untagged channels (the attached slice above is empty then), so they get the full width and
can't fight the fit measurer. Extras past the row clip like any overflow. */}
{shownSuggestions.map((s) => (
<SuggestedChip
key={s.tag.id}
suggestion={s}
onAccept={() => onAcceptSuggestion?.(s.tag.id)}
onDismiss={() => onDismissSuggestion?.(s.tag.id)}
/>
))}
</div>
<div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
{attached.slice(0, visible).map((tg) => (
<TagChip
key={tg.id}
tag={tg}
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
title={
onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined
}
/>
))}
{hidden > 0 && (
<button
ref={overflowPop.refs.setReference}
type="button"
className={`${pillClass} hover:text-fg transition`}
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
)}
</div>
</>
)}
<button
ref={editPop.refs.setReference}
+6 -5
View File
@@ -205,14 +205,15 @@ export default function Channels() {
const dismissSuggestion = (cid: string, tagId: number) =>
setDismissed((prev) => new Set(prev).add(sKey(cid, tagId)));
const acceptSuggestion = (cid: string, tagId: number) => {
// Optimistically drop it from the suggestions cache so the ghost chip vanishes at once; toggleTag
// attaches the real tag (its onSuccess re-invalidates suggestions + the channel detail).
// Optimistically drop the WHOLE channel from the suggestions cache: attaching any tag makes it
// "tagged", which the server excludes from suggestions entirely — so its other ghost chips would
// vanish on the next refetch anyway. Removing them now keeps the ghost lane and the attached lane
// mutually exclusive (no transient of both showing). toggleTag attaches the real tag; its onSuccess
// re-invalidates suggestions + the channel detail.
qc.setQueryData<TagSuggestions>(qk.tagSuggestions(), (old) => {
if (!old?.[cid]) return old;
const rest = old[cid].filter((s) => s.tag_id !== tagId);
const next = { ...old };
if (rest.length) next[cid] = rest;
else delete next[cid];
delete next[cid];
return next;
});
toggleTag(cid, tagId, false);