From 17f8885fb11fd4f65e850630cbd167fb370b0bf5 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Wed, 26 Aug 2026 02:39:00 +0200 Subject: [PATCH] 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. --- .../ChannelTags.suggest.dom.test.tsx | 27 ++- frontend/src/components/ChannelTags.tsx | 213 +++++++++++++----- frontend/src/components/Channels.tsx | 11 +- 3 files changed, 183 insertions(+), 68 deletions(-) diff --git a/frontend/src/components/ChannelTags.suggest.dom.test.tsx b/frontend/src/components/ChannelTags.suggest.dom.test.tsx index 3730f5fe..cefa69eb 100644 --- a/frontend/src/components/ChannelTags.suggest.dom.test.tsx +++ b/frontend/src/components/ChannelTags.suggest.dom.test.tsx @@ -11,9 +11,13 @@ import { FloatingTree } from "@floating-ui/react"; vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); + // 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[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); }); }); diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index d8521ad4..32feff4e 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -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(null); + const chipRefs = useRef>([]); + const pillRef = useRef(null); + const key = suggestions.map((s) => `${s.tag.id}:${s.tag.name}`).join("|"); + const visible = useChipOverflowFit({ + 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 ( +
+ {/* Hidden measurer — real ghost chips at natural width so the fit hook reads their right edges. */} +
+ {suggestions.map((s, i) => ( + { + chipRefs.current[i] = el; + }} + className="inline-flex" + > + {}} onDismiss={() => {}} /> + + ))} + + +{suggestions.length} + +
+ +
+ {suggestions.slice(0, visible).map((s) => ( + onAccept(s.tag.id)} + onDismiss={() => onDismiss(s.tag.id)} + /> + ))} + {hidden > 0 && ( + + )} +
+ + {/* Hover: the overflowed suggestions, each still one-tap accept / × dismiss. */} + + {suggestions.slice(visible).map((s) => ( + onAccept(s.tag.id)} + onDismiss={() => onDismiss(s.tag.id)} + /> + ))} + +
+ ); +} + /** 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({
- {/* 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. */} -
- {attached.map((tg, i) => ( - { - 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 ? ( + 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. */} +
- - - ))} - - +{attached.length} - -
+ {attached.map((tg, i) => ( + { + chipRefs.current[i] = el; + }} + className="inline-flex" + > + + + ))} + + +{attached.length} + +
-
- {attached.slice(0, visible).map((tg) => ( - onChipClick(tg.id, tg.name) : undefined} - title={onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined} - /> - ))} - {hidden > 0 && ( - - )} - {/* 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) => ( - onAcceptSuggestion?.(s.tag.id)} - onDismiss={() => onDismissSuggestion?.(s.tag.id)} - /> - ))} -
+
+ {attached.slice(0, visible).map((tg) => ( + onChipClick(tg.id, tg.name) : undefined} + title={ + onChipClick ? t("channels.row.filterFeedByTag", { name: tg.name }) : undefined + } + /> + ))} + {hidden > 0 && ( + + )} +
+ + )}