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) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
// Interpolate the `name` option so per-tag accept labels are distinguishable ("…acceptSuggestion:docu").
return { return {
...actual, ...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", () => { describe("ChannelTagsControl suggested ghost chips", () => {
it("renders a ghost chip per suggestion and accepts on click", () => { it("renders a ghost chip per suggestion and accepts on click", () => {
const { onAccept } = renderControl(); const { onAccept } = renderControl();
// Both suggested tag names show (attached row is empty on an untagged channel). // Role query excludes the aria-hidden fit-measurer copy; the name carries the interpolated tag.
fireEvent.click(screen.getByText("docu")); fireEvent.click(screen.getByRole("button", { name: "channels.tags.acceptSuggestion:docu" }));
expect(onAccept).toHaveBeenCalledWith(docu.id); expect(onAccept).toHaveBeenCalledWith(docu.id);
}); });
@@ -82,19 +86,20 @@ describe("ChannelTagsControl suggested ghost chips", () => {
const dismissButtons = screen.getAllByRole("button", { const dismissButtons = screen.getAllByRole("button", {
name: "channels.tags.dismissSuggestion", 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]!); fireEvent.click(dismissButtons[1]!);
expect(onDismiss).toHaveBeenCalledWith(history.id); expect(onDismiss).toHaveBeenCalledWith(history.id);
expect(onAccept).not.toHaveBeenCalled(); 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] }); renderControl({ attachedIds: [docu.id] });
// docu is attached → its ghost chip is suppressed (only history's × remains). Counting the // A tagged channel is not "untagged" → the ghost lane is not rendered at all (the server excludes
// dismiss buttons avoids colliding with docu's now-present *attached* chip text. // tagged channels from suggestions; the control mirrors that with its attached-XOR-ghost branch).
const dismissButtons = screen.getAllByRole("button", { expect(
name: "channels.tags.dismissSuggestion", screen.queryAllByRole("button", { name: "channels.tags.dismissSuggestion" }),
}); ).toHaveLength(0);
expect(dismissButtons).toHaveLength(1);
}); });
}); });
+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 /** 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 * "create + attach" input. Rendered inside an anchored `menu` popover. (Exported for the channel
* detail page in Part 2; internal for now.) */ * detail page in Part 2; internal for now.) */
@@ -270,60 +374,65 @@ export function ChannelTagsControl({
<div <div
className={`relative flex items-center gap-1 min-w-0 ${bounded ? "w-48 max-w-full" : "w-full"}`} 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 {/* Untagged + has suggestions → the ghost-chip lane (its own overflow "+N"); otherwise the
chip is wrapped in a ref span (index-aligned) so the shared fit hook can read its right edge; attached-chip lane. The two are mutually exclusive: suggestions are computed for untagged
the pill is the sample the hook reserves room for. */} channels only, and accepting one removes the channel from the suggestion set — so a channel
<div never shows both, and each lane gets the full cell width instead of clipping the other. */}
aria-hidden {attached.length === 0 && shownSuggestions.length > 0 ? (
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap" <SuggestedChipsLane
> suggestions={shownSuggestions}
{attached.map((tg, i) => ( onAccept={(tagId) => onAcceptSuggestion?.(tagId)}
<span onDismiss={(tagId) => onDismissSuggestion?.(tagId)}
key={tg.id} />
ref={(el) => { ) : (
chipRefs.current[i] = el; <>
}} {/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths.
className="inline-flex" 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} /> {attached.map((tg, i) => (
</span> <span
))} key={tg.id}
<span ref={pillRef} className={pillClass}> ref={(el) => {
+{attached.length} chipRefs.current[i] = el;
</span> }}
</div> 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"> <div ref={rowRef} className="flex items-center gap-1 min-w-0 flex-1 overflow-hidden">
{attached.slice(0, visible).map((tg) => ( {attached.slice(0, visible).map((tg) => (
<TagChip <TagChip
key={tg.id} key={tg.id}
tag={tg} tag={tg}
onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined} onClick={onChipClick ? () => onChipClick(tg.id, tg.name) : undefined}
title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined} title={
/> onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined
))} }
{hidden > 0 && ( />
<button ))}
ref={overflowPop.refs.setReference} {hidden > 0 && (
type="button" <button
className={`${pillClass} hover:text-fg transition`} ref={overflowPop.refs.setReference}
{...overflowPop.getReferenceProps()} type="button"
> className={`${pillClass} hover:text-fg transition`}
+{hidden} {...overflowPop.getReferenceProps()}
</button> >
)} +{hidden}
{/* Suggested ghost chips (E8·S4) ride in the same overflow-hidden row. They only appear on </button>
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. */} </div>
{shownSuggestions.map((s) => ( </>
<SuggestedChip )}
key={s.tag.id}
suggestion={s}
onAccept={() => onAcceptSuggestion?.(s.tag.id)}
onDismiss={() => onDismissSuggestion?.(s.tag.id)}
/>
))}
</div>
<button <button
ref={editPop.refs.setReference} ref={editPop.refs.setReference}
+6 -5
View File
@@ -205,14 +205,15 @@ export default function Channels() {
const dismissSuggestion = (cid: string, tagId: number) => const dismissSuggestion = (cid: string, tagId: number) =>
setDismissed((prev) => new Set(prev).add(sKey(cid, tagId))); setDismissed((prev) => new Set(prev).add(sKey(cid, tagId)));
const acceptSuggestion = (cid: string, tagId: number) => { const acceptSuggestion = (cid: string, tagId: number) => {
// Optimistically drop it from the suggestions cache so the ghost chip vanishes at once; toggleTag // Optimistically drop the WHOLE channel from the suggestions cache: attaching any tag makes it
// attaches the real tag (its onSuccess re-invalidates suggestions + the channel detail). // "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) => { qc.setQueryData<TagSuggestions>(qk.tagSuggestions(), (old) => {
if (!old?.[cid]) return old; if (!old?.[cid]) return old;
const rest = old[cid].filter((s) => s.tag_id !== tagId);
const next = { ...old }; const next = { ...old };
if (rest.length) next[cid] = rest; delete next[cid];
else delete next[cid];
return next; return next;
}); });
toggleTag(cid, tagId, false); toggleTag(cid, tagId, false);