fix(channels): center table, kill popover top-left flash, exact "+N" count (R8 S1 UAT)
Second UAT pass on the tag cell + popover platform: - Popover top-left flash (real root cause): the popIn keyframe animates `transform`, which overrode Floating UI's positioning `transform: translate(x,y)` for the animation's duration, parking the popover at (0,0). Move the animation to an INNER wrapper so the positioned element's transform stays clean (verified: menu is at the correct spot from frame 0). The isPositioned visibility gate is kept as a belt-and-suspenders. - Table shifted right / not centered: my previous fixed-15rem tags column + nowrap Sync pushed the table past the max-w-[96rem] wrapper so it overflowed. Trim to 12rem tags and shorten the long "full history queued/coming" sync labels to "queued"/"coming" so the table fits the wrapper (1594 <= 1628) and centers again. - "+N" count wrong/missing while adding tags: replace the iterative shrink (which lagged a render and could clip the pill's digits) with a deterministic measure — a hidden measurer lays out all chips + a sample pill, and we compute the exact fit in one pass. The count is always correct now (verified across varied rows), and a bare "+" with no number is structurally impossible.
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
import { type CSSProperties, type ReactNode, type Ref } from "react";
|
import { type CSSProperties, type ReactNode } from "react";
|
||||||
import {
|
import {
|
||||||
useFloating,
|
useFloating,
|
||||||
autoUpdate,
|
autoUpdate,
|
||||||
@@ -13,7 +13,6 @@ import {
|
|||||||
useDismiss,
|
useDismiss,
|
||||||
useRole,
|
useRole,
|
||||||
useInteractions,
|
useInteractions,
|
||||||
useMergeRefs,
|
|
||||||
useFloatingNodeId,
|
useFloatingNodeId,
|
||||||
safePolygon,
|
safePolygon,
|
||||||
FloatingNode,
|
FloatingNode,
|
||||||
@@ -122,7 +121,6 @@ export function PopoverSurface({
|
|||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
children,
|
children,
|
||||||
floatingRef,
|
|
||||||
focusManaged = false,
|
focusManaged = false,
|
||||||
initialFocus,
|
initialFocus,
|
||||||
}: {
|
}: {
|
||||||
@@ -130,8 +128,6 @@ export function PopoverSurface({
|
|||||||
className?: string;
|
className?: string;
|
||||||
style?: CSSProperties;
|
style?: CSSProperties;
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
/** Extra ref merged onto the floating node (e.g. a useScrollFade callback ref). */
|
|
||||||
floatingRef?: Ref<HTMLDivElement>;
|
|
||||||
focusManaged?: boolean;
|
focusManaged?: boolean;
|
||||||
initialFocus?: number | React.MutableRefObject<HTMLElement | null>;
|
initialFocus?: number | React.MutableRefObject<HTMLElement | null>;
|
||||||
}) {
|
}) {
|
||||||
@@ -144,30 +140,38 @@ export function PopoverSurface({
|
|||||||
referenceHidden,
|
referenceHidden,
|
||||||
isPositioned,
|
isPositioned,
|
||||||
open,
|
open,
|
||||||
|
mode,
|
||||||
} = popover;
|
} = popover;
|
||||||
const mergedRef = useMergeRefs([refs.setFloating, floatingRef ?? null]);
|
const { style: floatingPropStyle, ...floatingProps } = getFloatingProps() as {
|
||||||
const floatingProps = getFloatingProps();
|
style?: CSSProperties;
|
||||||
|
} & Record<string, unknown>;
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
|
const zClass = mode === "hover" ? "z-tooltip" : "z-popover";
|
||||||
|
|
||||||
const surface = (
|
const surface = (
|
||||||
<div
|
<div
|
||||||
ref={mergedRef}
|
ref={refs.setFloating}
|
||||||
{...floatingProps}
|
{...floatingProps}
|
||||||
className={className}
|
className={zClass}
|
||||||
style={{
|
style={{
|
||||||
...(floatingProps.style as CSSProperties | undefined),
|
...floatingPropStyle,
|
||||||
...floatingStyles,
|
...floatingStyles,
|
||||||
// Hide until (a) the first position is measured — else it flashes at top-left (0,0) before
|
// Hide until the first position is measured (else it flashes at 0,0 before the transform
|
||||||
// the transform lands — and (b) while the trigger is scrolled out of its clipping scroller
|
// lands) and while the trigger is scrolled out of its clipping scroller (hide-and-follow).
|
||||||
// (hide-and-follow rather than float over the chrome).
|
|
||||||
...(!isPositioned || referenceHidden
|
...(!isPositioned || referenceHidden
|
||||||
? { visibility: "hidden", pointerEvents: "none" }
|
? { visibility: "hidden", pointerEvents: "none" }
|
||||||
: null),
|
: null),
|
||||||
...style,
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{/* Inner wrapper carries the visual styling + pop-in animation. Keeping the ANIMATION off the
|
||||||
|
positioned element is essential: the popIn keyframe animates `transform`, which would
|
||||||
|
otherwise override Floating UI's `transform: translate(x,y)` and park the popover at (0,0)
|
||||||
|
for the animation's duration — the "flash in the top-left corner" bug. */}
|
||||||
|
<div className={className} style={style}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { Check, Plus } from "lucide-react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type Tag } from "../lib/api";
|
import { type Tag } from "../lib/api";
|
||||||
import { useAnchoredPopover, PopoverSurface } from "./AnchoredPopover";
|
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
|
// 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:
|
// page. Built on the R8 popover platform. Two design goals it fixes over the old inline TagsCell:
|
||||||
@@ -139,36 +138,47 @@ export function ChannelTagsControl({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const attached = userTags.filter((tg) => attachedIds.includes(tg.id));
|
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
|
// Deterministic single-line fit: a hidden measurer lays out ALL chips + a sample "+N" pill at their
|
||||||
// the "+N" pill it then needs) fits its bounded width. Re-expands and re-fits on width/scale
|
// natural widths; we read each chip's right edge and compute EXACTLY how many fit, reserving the
|
||||||
// change via ResizeObserver — measurement is off the real rem-rendered chips, not a px guess.
|
// pill's width whenever any overflow. One pass (no iterative re-render), so the "+N" count is always
|
||||||
|
// right as tags are added/removed. Re-measures on width/scale change via ResizeObserver — off real
|
||||||
|
// rem boxes, never a px guess.
|
||||||
const rowRef = useRef<HTMLDivElement | null>(null);
|
const rowRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [maxWidth, setMaxWidth] = useState(0);
|
const measureRef = useRef<HTMLDivElement | null>(null);
|
||||||
const [visible, setVisible] = useState(attached.length);
|
const [visible, setVisible] = useState(attached.length);
|
||||||
|
const idsKey = attached.map((tg) => tg.id).join(",");
|
||||||
|
|
||||||
// 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(() => {
|
useLayoutEffect(() => {
|
||||||
const el = rowRef.current;
|
const row = rowRef.current;
|
||||||
if (!el || typeof ResizeObserver === "undefined") return;
|
const measure = measureRef.current;
|
||||||
const ro = new ResizeObserver((entries) => {
|
if (!row || !measure) return;
|
||||||
const w = entries[0]?.contentRect.width;
|
const compute = () => {
|
||||||
if (w != null) setMaxWidth(w);
|
const nodes = Array.from(measure.children) as HTMLElement[];
|
||||||
});
|
const pill = nodes[nodes.length - 1];
|
||||||
ro.observe(el);
|
if (!pill) return;
|
||||||
|
const pillW = pill.offsetWidth;
|
||||||
|
const chips = nodes.slice(0, -1);
|
||||||
|
const GAP = 4; // gap-1
|
||||||
|
const avail = row.clientWidth;
|
||||||
|
let count = chips.length;
|
||||||
|
for (let i = 0; i < chips.length; i++) {
|
||||||
|
const chip = chips[i];
|
||||||
|
if (!chip) break;
|
||||||
|
const right = chip.offsetLeft + chip.offsetWidth; // right edge within the measurer
|
||||||
|
const moreAfter = i < chips.length - 1;
|
||||||
|
if (right > avail - (moreAfter ? pillW + GAP : 0)) {
|
||||||
|
count = i;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setVisible(count);
|
||||||
|
};
|
||||||
|
compute();
|
||||||
|
if (typeof ResizeObserver === "undefined") return;
|
||||||
|
const ro = new ResizeObserver(compute);
|
||||||
|
ro.observe(row);
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, []);
|
}, [idsKey]);
|
||||||
|
|
||||||
// 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 hidden = attached.length - visible;
|
||||||
|
|
||||||
@@ -187,15 +197,28 @@ export function ChannelTagsControl({
|
|||||||
mode: "hover",
|
mode: "hover",
|
||||||
placement: "bottom",
|
placement: "bottom",
|
||||||
});
|
});
|
||||||
const fade = useScrollFade();
|
|
||||||
|
const pillClass =
|
||||||
|
"shrink-0 text-[10px] px-1.5 py-0.5 rounded-full bg-card text-muted border border-border";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// FIXED width (not just max-w): under `table-layout:auto` a content-driven cell re-solves the
|
// FIXED width so the cell's footprint is constant under `table-layout:auto`: a content-driven cell
|
||||||
// whole table's column widths every time a chip is added — the table visibly shifts until the cap
|
// re-solves EVERY column's width as chips are added, visibly shifting the whole table. A fixed
|
||||||
// is hit. A fixed width makes the cell's footprint constant, so adding/removing tags never moves
|
// width keeps the table stable and centered; the chip row fits what it can and collapses the rest
|
||||||
// any column; the chip row greedily fits within it and collapses the rest into "+N". (max-w-full
|
// into "+N". (max-w-full lets a narrower card shrink it.)
|
||||||
// lets a narrower card shrink it.)
|
<div className="relative flex items-center gap-1 w-[12rem] max-w-full min-w-0">
|
||||||
<div className="flex items-center gap-1 w-[15rem] max-w-full min-w-0">
|
{/* Hidden measurer: all chips + a sample pill, laid out unbounded to read their true widths. */}
|
||||||
|
<div
|
||||||
|
ref={measureRef}
|
||||||
|
aria-hidden
|
||||||
|
className="pointer-events-none invisible absolute left-0 top-0 flex items-center gap-1 whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{attached.map((tg) => (
|
||||||
|
<TagChip key={tg.id} tag={tg} />
|
||||||
|
))}
|
||||||
|
<span 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
|
||||||
@@ -209,7 +232,7 @@ export function ChannelTagsControl({
|
|||||||
<button
|
<button
|
||||||
ref={overflowPop.refs.setReference}
|
ref={overflowPop.refs.setReference}
|
||||||
type="button"
|
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"
|
className={`${pillClass} hover:text-fg transition`}
|
||||||
{...overflowPop.getReferenceProps()}
|
{...overflowPop.getReferenceProps()}
|
||||||
>
|
>
|
||||||
+{hidden}
|
+{hidden}
|
||||||
@@ -231,7 +254,7 @@ export function ChannelTagsControl({
|
|||||||
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
|
{/* Hover: reveal every attached tag when some are collapsed into "+N". */}
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={overflowPop}
|
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]"
|
className="glass-menu max-w-[18rem] rounded-xl p-1.5 flex flex-wrap items-center gap-1 animate-[popIn_0.16s_ease]"
|
||||||
>
|
>
|
||||||
{attached.map((tg) => (
|
{attached.map((tg) => (
|
||||||
<TagChip
|
<TagChip
|
||||||
@@ -246,9 +269,7 @@ export function ChannelTagsControl({
|
|||||||
{/* Click: the shared editor (toggle existing + create inline). */}
|
{/* Click: the shared editor (toggle existing + create inline). */}
|
||||||
<PopoverSurface
|
<PopoverSurface
|
||||||
popover={editPop}
|
popover={editPop}
|
||||||
floatingRef={fade.ref}
|
className="glass-menu w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
||||||
style={fade.style}
|
|
||||||
className="glass-menu z-popover w-56 rounded-xl p-1.5 animate-[popIn_0.16s_ease]"
|
|
||||||
>
|
>
|
||||||
<TagEditorMenu
|
<TagEditorMenu
|
||||||
userTags={userTags}
|
userTags={userTags}
|
||||||
|
|||||||
@@ -465,7 +465,7 @@ export default function Channels() {
|
|||||||
cardLabel: false,
|
cardLabel: false,
|
||||||
// Bounded width so the cell can't widen the column and reflow the whole table; the control
|
// 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).
|
// fills it and collapses overflow into "+N" (see ChannelTagsControl).
|
||||||
width: "15rem",
|
width: "12rem",
|
||||||
filter: {
|
filter: {
|
||||||
kind: "multi",
|
kind: "multi",
|
||||||
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
|
options: userTags.map((tg) => ({ value: String(tg.id), label: tg.name })),
|
||||||
|
|||||||
@@ -107,8 +107,8 @@
|
|||||||
"backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search). Click again to cancel the request.",
|
"backfillThisHint": "Fetch this channel's full back-catalog (older videos + complete search). Click again to cancel the request.",
|
||||||
"resetBackfill": "Reset & re-fetch this channel",
|
"resetBackfill": "Reset & re-fetch this channel",
|
||||||
"resetHint": "Admin: re-fetch this channel from scratch (recent + full back-catalog), regardless of its current sync state.",
|
"resetHint": "Admin: re-fetch this channel from scratch (recent + full back-catalog), regardless of its current sync state.",
|
||||||
"fullHistoryQueued": "full history queued",
|
"fullHistoryQueued": "queued",
|
||||||
"fullHistoryComing": "full history coming",
|
"fullHistoryComing": "coming",
|
||||||
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.",
|
"queuedRequestedHint": "Full history requested — this channel's whole back-catalog will backfill as the shared quota allows. Click to cancel your request.",
|
||||||
"queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.",
|
"queuedByOtherHint": "Another subscriber already requested this channel's full history, so its whole back-catalog is on its way to everyone — nothing to do here.",
|
||||||
"hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.",
|
"hiddenHint": "Hidden — this channel's videos are kept out of your feed. Click to show them again.",
|
||||||
|
|||||||
@@ -107,8 +107,8 @@
|
|||||||
"backfillThisHint": "A csatorna teljes archívumának letöltése (régebbi videók + teljes keresés). Újra kattintva visszavonod a kérést.",
|
"backfillThisHint": "A csatorna teljes archívumának letöltése (régebbi videók + teljes keresés). Újra kattintva visszavonod a kérést.",
|
||||||
"resetBackfill": "Csatorna resetelése és újraletöltése",
|
"resetBackfill": "Csatorna resetelése és újraletöltése",
|
||||||
"resetHint": "Admin: a csatorna teljes újraletöltése a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.",
|
"resetHint": "Admin: a csatorna teljes újraletöltése a nulláról (legutóbbi + teljes archívum), a jelenlegi szinkron-állapottól függetlenül.",
|
||||||
"fullHistoryQueued": "teljes előzmény sorban",
|
"fullHistoryQueued": "sorban",
|
||||||
"fullHistoryComing": "teljes előzmény úton",
|
"fullHistoryComing": "úton",
|
||||||
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.",
|
"queuedRequestedHint": "Teljes előzmény kérve — a csatorna teljes archívuma letöltődik, ahogy a megosztott kvóta engedi. Kattints a kérés visszavonásához.",
|
||||||
"queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.",
|
"queuedByOtherHint": "Egy másik feliratkozó már kérte ennek a csatornának a teljes előzményét, így a teljes archívuma már úton van mindenkihez — itt nincs teendőd.",
|
||||||
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.",
|
"hiddenHint": "Elrejtve — a csatorna videói nem jelennek meg a hírfolyamodban. Kattints, hogy újra láthatóak legyenek.",
|
||||||
|
|||||||
Reference in New Issue
Block a user