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) => {
|
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);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user