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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,9 +374,21 @@ 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. */}
|
||||
{/* 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"
|
||||
@@ -299,7 +415,9 @@ export function ChannelTagsControl({
|
||||
key={tg.id}
|
||||
tag={tg}
|
||||
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 && (
|
||||
@@ -312,18 +430,9 @@ export function ChannelTagsControl({
|
||||
+{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>
|
||||
</>
|
||||
)}
|
||||
|
||||
<button
|
||||
ref={editPop.refs.setReference}
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user