feat(channels): fixed-width tag cell with "+N" overflow + inline create (R8 S1)

Redesign the channel tag cell (the popover pilot's first real consumer) to fix two
UAT-reported bugs and two UX gaps:

- Jumping editor (bug 1): the "+" edit trigger now sits in a STABLE slot at the end,
  so toggling a tag (which grows the chip row) no longer moves the anchor — the editor
  popover stays put (verified dx/dy = 0).
- Whole-table reflow (bug 2): the cell is width-bounded (max-w inside the cell, since
  table-layout:auto ignores the column width — the documented channel/About trap) and
  single-line, collapsing overflow into a hoverable "+N" pill. Rows no longer wrap and
  reflow (verified uniform row heights).
- The "+" is ALWAYS present (even with zero tags), and its menu can CREATE a tag inline
  (type + Enter → create + attach) — tagging no longer requires the top-of-page dialog.

New shared ChannelTagsControl (used by table + cards; the detail page is Part 2) over the
R8 popover platform: a `menu` editor popover + a `hover` reveal-all popover. Greedy rem-safe
fit via ResizeObserver. `api.createTag` now returns Promise<Tag>.
This commit is contained in:
2026-07-30 23:33:50 +02:00
parent e644b4f7d7
commit cfa8be950d
5 changed files with 294 additions and 95 deletions
+260
View File
@@ -0,0 +1,260 @@
import { useLayoutEffect, useRef, useState } from "react";
import { Check, Plus } from "lucide-react";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
import { useScrollFade } from "../lib/useScrollFade";
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
// 1. Stable edit anchor — the "+" edit trigger sits in a FIXED slot at the end, so toggling tags
// (which grows the chip row) never moves it, and the editor popover doesn't drift.
// 2. Fixed footprint — the chip row lives in a width-bounded, single-line, overflow-hidden box and
// collapses the excess into a "+N" pill (hover reveals the full set). The cell therefore never
// wraps to a 2nd line, so it can't widen its table column and reflow every other row.
// The "+" is ALWAYS present (even with zero tags), and its menu can CREATE a tag inline — tagging is
// no longer gated on the top-of-page "Manage tags" dialog.
interface TagChipProps {
tag: Tag;
onClick?: () => void;
title?: string;
}
function TagChip({ tag, onClick, title }: TagChipProps) {
const cls =
"shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent max-w-[9rem] truncate";
return onClick ? (
<button
type="button"
onClick={onClick}
title={title}
className={`${cls} hover:opacity-80 transition`}
>
{tag.name}
</button>
) : (
<span className={cls}>{tag.name}</span>
);
}
/** 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.) */
function TagEditorMenu({
userTags,
attachedIds,
onToggle,
onCreate,
}: {
userTags: Tag[];
attachedIds: number[];
onToggle: (tagId: number) => void;
onCreate: (name: string) => void;
}) {
const { t } = useTranslation();
const [draft, setDraft] = useState("");
const attached = new Set(attachedIds);
const submit = () => {
const name = draft.trim();
if (!name) return;
onCreate(name);
setDraft("");
};
return (
<div className="flex flex-col">
<div className="max-h-[240px] overflow-y-auto no-scrollbar">
{userTags.map((tg) => {
const on = attached.has(tg.id);
return (
<button
key={tg.id}
type="button"
onClick={() => onToggle(tg.id)}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
on ? "text-fg" : "text-muted hover:bg-card"
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{on && <Check className="w-2.5 h-2.5" />}
</span>
<span className="truncate">{tg.name}</span>
</button>
);
})}
{userTags.length === 0 && (
<p className="text-[11px] text-muted px-2 py-1.5">{t("channels.tags.noneYet")}</p>
)}
</div>
<div className="mt-1 pt-1.5 border-t border-border flex items-center gap-1">
<input
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
submit();
}
}}
maxLength={64}
placeholder={t("channels.tags.createPlaceholder")}
className="flex-1 min-w-0 bg-transparent text-xs px-2 py-1 rounded-md border border-border focus:border-accent outline-none"
/>
<button
type="button"
onClick={submit}
disabled={!draft.trim()}
title={t("channels.tags.create")}
aria-label={t("channels.tags.create")}
className="shrink-0 w-6 h-6 inline-flex items-center justify-center rounded-md border border-border text-muted hover:text-accent hover:border-accent disabled:opacity-40 transition"
>
<Plus className="w-3.5 h-3.5" />
</button>
</div>
</div>
);
}
/** The full tag control for a channel: a width-bounded chip row with "+N" overflow (hover reveals
* all), followed by a stable "+" trigger that opens the shared editor. Fills its container width,
* so a table cell bounds it via the column width and a card bounds it via the card. */
export function ChannelTagsControl({
userTags,
attachedIds,
onToggle,
onCreate,
onChipClick,
}: {
userTags: Tag[];
attachedIds: number[];
onToggle: (tagId: number) => void;
onCreate: (name: string) => void;
/** Manager only: clicking a chip filters the feed by that tag. Omit on the detail page. */
onChipClick?: (tagId: number, name: string) => void;
}) {
const { t } = useTranslation();
const attached = userTags.filter((tg) => attachedIds.includes(tg.id));
// Greedy single-line fit: start showing all, shrink one chip at a time until the row (including
// the "+N" pill it then needs) fits its bounded width. Re-expands and re-fits on width/scale
// change via ResizeObserver — measurement is off the real rem-rendered chips, not a px guess.
const rowRef = useRef<HTMLDivElement | null>(null);
const [maxWidth, setMaxWidth] = useState(0);
const [visible, setVisible] = useState(attached.length);
// Track the bounded width so a font-scale/viewport change re-fits — measured off the real box,
// never a px guess (widths here are rem and grow with the text-size slider).
useLayoutEffect(() => {
const el = rowRef.current;
if (!el || typeof ResizeObserver === "undefined") return;
const ro = new ResizeObserver((entries) => {
const w = entries[0]?.contentRect.width;
if (w != null) setMaxWidth(w);
});
ro.observe(el);
return () => ro.disconnect();
}, []);
// Reset to "show all" whenever the tag set or the available width changes…
useLayoutEffect(() => {
setVisible(attached.length);
}, [attached.length, maxWidth]);
// …then drop one chip at a time until the row (with the "+N" pill it then needs) fits its width.
useLayoutEffect(() => {
const el = rowRef.current;
if (el && el.scrollWidth > el.clientWidth + 1 && visible > 0) setVisible((v) => v - 1);
}, [visible, maxWidth, attached.length]);
const hidden = attached.length - visible;
const [editorOpen, setEditorOpen] = useState(false);
const editPop = useAnchoredPopover({
open: editorOpen,
onOpenChange: setEditorOpen,
mode: "menu",
placement: "bottom-start",
});
const [overflowOpen, setOverflowOpen] = useState(false);
const overflowPop = useAnchoredPopover({
open: overflowOpen,
onOpenChange: setOverflowOpen,
mode: "hover",
placement: "bottom",
});
const fade = useScrollFade();
return (
// max-w caps the cell's MAX-CONTENT so `table-layout:auto` can't ignore the column width and let
// an over-tagged row stretch the whole table (the documented channel/About-column trap). The chip
// row then greedily fits within it and collapses the rest into "+N".
<div className="flex items-center gap-1 w-full max-w-[15rem] min-w-0">
<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="shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-card text-muted border border-border hover:text-fg transition"
{...overflowPop.getReferenceProps()}
>
+{hidden}
</button>
)}
</div>
<button
ref={editPop.refs.setReference}
type="button"
title={t("channels.row.editTags")}
aria-label={t("channels.row.editTags")}
className="shrink-0 w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
{...editPop.getReferenceProps()}
>
<Plus className="w-3 h-3" />
</button>
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
<PopoverSurface
popover={overflowPop}
className="glass-menu z-popover max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
>
{attached.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}
/>
))}
</PopoverSurface>
{/* Click: the shared editor (toggle existing + create inline). */}
<PopoverSurface
popover={editPop}
floatingRef={fade.ref}
style={fade.style}
className="glass-menu z-popover w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
<TagEditorMenu
userTags={userTags}
attachedIds={attachedIds}
onToggle={onToggle}
onCreate={onCreate}
/>
</PopoverSurface>
</div>
);
}
+23 -92
View File
@@ -12,16 +12,14 @@ import {
History,
LayoutGrid,
Pencil,
Plus,
RefreshCw,
Table,
UserMinus,
X,
} from "lucide-react";
import { api, type ManagedChannel, type Tag } from "../lib/api";
import { api, type ManagedChannel } from "../lib/api";
import { notifyYouTubeActionError } from "../lib/youtubeErrors";
import { accountKey, LS } from "../lib/storage";
import { useScrollFade } from "../lib/useScrollFade";
import { formatEta, formatTotalHours, relativeTime } from "../lib/format";
import { subsColumn } from "./channelColumns";
import { notify } from "../lib/notifications";
@@ -29,7 +27,7 @@ import Tooltip from "./Tooltip";
import DataTable, { type Column } from "./DataTable";
import ChannelLayoutGrid from "./ChannelLayoutGrid";
import { LoadingState, StateMessage } from "./QueryState";
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
import { ChannelTagsControl } from "./ChannelTags";
import ViewSwitcher, { type ViewOption } from "./ViewSwitcher";
import ColumnSortControl from "./ColumnSortControl";
import Pager from "./Pager";
@@ -212,6 +210,19 @@ export default function Channels() {
const call = hasTag ? api.detachChannelTag(id, tagId) : api.attachChannelTag(id, tagId);
call.catch(() => qc.invalidateQueries({ queryKey: qk.channels() }));
};
// Create a brand-new personal tag from the row/card editor and attach it to this channel in one
// step (so tagging no longer requires the top-of-page "Manage tags" dialog first). Refresh the tag
// catalog so the new tag appears everywhere; attach optimistically via toggleTag.
const createAndAttachTag = (channelId: string, name: string) => {
api
.createTag({ name })
.then((tag) => {
qc.invalidateQueries({ queryKey: qk.tags() });
toggleTag(channelId, tag.id, false);
})
.catch(() => notify({ level: "error", message: t("channels.tags.createFailed") }));
};
const syncSubs = useMutation({
mutationFn: () => api.syncSubscriptions(),
onSuccess: (r: { subscriptions?: number }) => {
@@ -452,17 +463,21 @@ export default function Channels() {
key: "tags",
header: t("channels.cols.tags"),
cardLabel: false,
// Bounded width so the cell can't widen the column and reflow the whole table; the control
// fills it and collapses overflow into "+N" (see ChannelTagsControl).
width: "15rem",
filter: {
kind: "multi",
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
test: (c, values) => values.some((v) => c.tag_ids.includes(Number(v))),
},
render: (c) => (
<TagsCell
c={c}
<ChannelTagsControl
userTags={userTags}
onToggleTag={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
onTagClick={onFilterByTag}
attachedIds={c.tag_ids}
onToggle={(tagId) => toggleTag(c.id, tagId, c.tag_ids.includes(tagId))}
onCreate={(name) => createAndAttachTag(c.id, name)}
onChipClick={onFilterByTag}
/>
),
},
@@ -1023,90 +1038,6 @@ function SyncCell({ c }: { c: ManagedChannel }) {
);
}
function TagsCell({
c,
userTags,
onToggleTag,
onTagClick,
}: {
c: ManagedChannel;
userTags: Tag[];
onToggleTag: (tagId: number) => void;
onTagClick: (tagId: number, name: string) => void;
}) {
const { t } = useTranslation();
const [open, setOpen] = useState(false);
const fade = useScrollFade();
// R8 anchored-popover platform: positioning (measured flip via flip(), horizontal clamp via shift(),
// scroll-follow via autoUpdate, hide-on-leave-scroller via hide()), body portal, dismiss and
// topmost Escape all come from the shared hook — replacing this cell's former hand-rolled engine.
// The portal is what lets the CARD layout's per-row `transform` (a stacking context) NOT trap the
// menu under the next card. Focus management (roving nav / return-to-trigger) arrives in S2.
const pop = useAnchoredPopover({
open,
onOpenChange: setOpen,
mode: "menu",
placement: "bottom-start",
});
if (userTags.length === 0) return null;
// Show only the tags actually attached to this channel; the “+” opens a per-channel
// picker to attach/detach (kept the convenient "kirakás" without listing every tag
// on every row). Tag create/delete still lives in the top "YOUR TAGS" row.
const attached = userTags.filter((tg) => c.tag_ids.includes(tg.id));
return (
<div className="relative flex flex-wrap items-center gap-1 min-w-[160px]">
{attached.map((tg) => (
<button
key={tg.id}
onClick={() => onTagClick(tg.id, tg.name)}
title={t("channels.row.filterFeedByTag", { name: tg.name })}
className="text-[10px] px-1.5 py-0.5 rounded-full bg-accent text-accent-fg border border-accent hover:opacity-80 transition"
>
{tg.name}
</button>
))}
<button
ref={pop.refs.setReference}
title={t("channels.row.editTags")}
aria-label={t("channels.row.editTags")}
className="w-5 h-5 inline-flex items-center justify-center rounded-full border border-dashed border-border text-muted hover:text-accent hover:border-accent transition"
{...pop.getReferenceProps()}
>
<Plus className="w-3 h-3" />
</button>
<PopoverSurface
popover={pop}
floatingRef={fade.ref}
style={fade.style}
className="glass-menu z-popover w-44 max-h-[264px] overflow-y-auto no-scrollbar rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
>
{userTags.map((tg) => {
const on = c.tag_ids.includes(tg.id);
return (
<button
key={tg.id}
onClick={() => onToggleTag(tg.id)}
className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md transition ${
on ? "text-fg" : "text-muted hover:bg-card"
}`}
>
<span
className={`w-3.5 h-3.5 rounded border flex items-center justify-center shrink-0 ${
on ? "bg-accent border-accent text-accent-fg" : "border-border"
}`}
>
{on && <Check className="w-2.5 h-2.5" />}
</span>
{tg.name}
</button>
);
})}
</PopoverSurface>
</div>
);
}
function ActionsCell({
c,
isAdmin,
+5 -1
View File
@@ -46,7 +46,11 @@
"tags": {
"yourTags": "Your tags",
"manage": "Manage tags",
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)"
"yourTagsHint": "Your personal labels. Attach them to channels below, then filter the feed by tag from the sidebar. (Separate from the automatic language/topic tags.)",
"noneYet": "No tags yet — create one below.",
"create": "Create tag",
"createPlaceholder": "New tag…",
"createFailed": "Could not create the tag."
},
"loading": "Loading channels…",
"empty": "No channels.",
+5 -1
View File
@@ -46,7 +46,11 @@
"tags": {
"yourTags": "Címkéid",
"manage": "Címkék kezelése",
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)"
"yourTagsHint": "Saját személyes címkéid. Csatold őket az alábbi csatornákhoz, majd szűrd a hírfolyamot címke szerint az oldalsávból. (Az automatikus nyelvi/téma címkéktől függetlenül.)",
"noneYet": "Még nincs címke — hozz létre egyet lent.",
"create": "Címke létrehozása",
"createPlaceholder": "Új címke…",
"createFailed": "A címke létrehozása nem sikerült."
},
"loading": "Csatornák betöltése…",
"empty": "Nincsenek csatornák.",
+1 -1
View File
@@ -12,7 +12,7 @@ export interface Tag {
export const tagsApi = {
tags: (): Promise<Tag[]> => req("/api/tags"),
createTag: (t: { name: string; color?: string; category?: string }) =>
createTag: (t: { name: string; color?: string; category?: string }): Promise<Tag> =>
req("/api/tags", { method: "POST", body: JSON.stringify(t) }),
updateTag: (id: number, patch: { name?: string; color?: string }) =>
req(`/api/tags/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),