From f8e0ab152021c01905dbd53b95bce56e66b2b0ce Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 8 Aug 2026 01:38:06 +0200 Subject: [PATCH] fix(popover): single hover-follows highlight without stealing input focus (UAT) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second UAT finding on the tag popover: the keyboard-active row stayed put while a fainter CSS :hover chased the pointer — two competing highlights. Root cause: floating-ui's focusItemOnHover is (correctly) OFF for input-hosting menus because setting its activeIndex on hover drags DOM focus onto the item and blurs the create-tag input mid-typing; the only pointer feedback left was the item's own `hover:bg-*`. Fix, applied in the shared PopoverItem so it's general: - Drive the visible highlight off a NEW `hoverIndex` state (highlightIndex = hoverIndex ?? activeIndex), decoupled from floating-ui's activeIndex — hovering sets only hoverIndex, never DOM focus, so one highlight follows the pointer AND the input keeps its caret. Keyboard nav (onNavigate/onMatch) clears hoverIndex so it takes the highlight back; leaving the menu clears it too. - Split PopoverItem's `active` into focusActive (roving tabindex, keyboard) vs visualActive (data-active highlight). - Remove the now-redundant `hover:bg-*` from the 5 consumers so data-active is the sole highlight (it was also a same-specificity tie with data-active). Verified in-browser: hovering moves the single highlight; typing "testtag" in the create field then hovering items keeps focus+caret on the input (caretPos intact); mouse-leave returns the highlight to the keyboard row. Gate: tsc/eslint/prettier/ vitest 79 green. --- frontend/src/components/AddToPlaylist.tsx | 2 +- frontend/src/components/AnchoredPopover.tsx | 59 +++++++++++++++++--- frontend/src/components/ChannelTags.tsx | 2 +- frontend/src/components/LanguageSwitcher.tsx | 2 +- frontend/src/components/ViewSwitcher.tsx | 2 +- frontend/src/components/tableHeader.tsx | 2 +- 6 files changed, 55 insertions(+), 14 deletions(-) diff --git a/frontend/src/components/AddToPlaylist.tsx b/frontend/src/components/AddToPlaylist.tsx index d3335ab6..7e3a548d 100644 --- a/frontend/src/components/AddToPlaylist.tsx +++ b/frontend/src/components/AddToPlaylist.tsx @@ -124,7 +124,7 @@ export default function AddToPlaylist({ // the floating element only). `toggle()` already no-ops while busy, so re-entrancy is // guarded without touching focus; `aria-busy` on the list conveys the pending state. label={playlistName(pl, t)} - className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition hover:bg-card/60" + className="w-full flex items-center gap-2 text-sm px-1.5 py-1.5 rounded-lg text-fg outline-hidden transition" > >([]); const labelsRef = useRef>([]); const [activeIndex, setActiveIndex] = useState(null); + // Pointer-hover highlight, kept SEPARATE from floating-ui's activeIndex on purpose: useListNavigation + // syncs DOM focus to activeIndex, so setting activeIndex on hover would blur a menu's free-text input + // (the create-tag field) mid-typing. hoverIndex drives only the VISUAL highlight; DOM focus never + // follows it. The highlighted row = hoverIndex ?? activeIndex, so there is exactly one highlight that + // tracks the pointer when hovering and the keyboard otherwise. + const [hoverIndex, setHoverIndex] = useState(null); const { refs, floatingStyles, context, middlewareData, isPositioned } = useFloating({ nodeId, @@ -137,7 +143,10 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { // A stale active item would highlight (and steal the next open's initial focus) the second time a // menu opens; clear it whenever the surface closes. useEffect(() => { - if (!opts.open) setActiveIndex(null); + if (!opts.open) { + setActiveIndex(null); + setHoverIndex(null); + } }, [opts.open]); const click = useClick(context, { enabled: !isHover }); @@ -159,7 +168,12 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { enabled: isNav, listRef: elementsRef, activeIndex, - onNavigate: setActiveIndex, + // Keyboard (and pointer, when floating-ui owns it) navigation takes the visual highlight back from + // a stale pointer-hover position. + onNavigate: (i) => { + setActiveIndex(i); + setHoverIndex(null); + }, selectedIndex: opts.selectedIndex ?? null, loop: true, // Focus the first item on open for BOTH click and keyboard (the default 'auto' skips pointer-opens, @@ -174,7 +188,10 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { enabled: isNav && (opts.typeahead ?? true) && !opts.hostsInput, listRef: labelsRef, activeIndex, - onMatch: setActiveIndex, + onMatch: (i) => { + setActiveIndex(i); + setHoverIndex(null); + }, }); const interactions = useInteractions([click, hover, focus, dismiss, role, listNav, typeahead]); @@ -204,6 +221,8 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { elementsRef, labelsRef, activeIndex, + highlightIndex: hoverIndex ?? activeIndex, + setHoverIndex, ...interactions, }; } @@ -212,6 +231,8 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) { // separately owns the elementsRef/labelsRef registration; this context is the active-state channel. interface PopoverListContextValue { activeIndex: number | null; + highlightIndex: number | null; + setHoverIndex: (index: number | null) => void; getItemProps: AnchoredPopover["getItemProps"]; } const PopoverListContext = createContext(null); @@ -259,7 +280,10 @@ export function PopoverItem({ const ctx = useContext(PopoverListContext); const textLabel = label ?? (typeof children === "string" ? children : undefined); const { ref, index } = useListItem({ label: textLabel }); - const active = ctx?.activeIndex === index; + // Roving tabindex / DOM focus follow the KEYBOARD position (activeIndex); the visible highlight + // follows the pointer when hovering, else the keyboard (highlightIndex = hoverIndex ?? activeIndex). + const focusActive = ctx?.activeIndex === index; + const visualActive = ctx?.highlightIndex === index; const ariaProps = role === "menuitemradio" || role === "menuitemcheckbox" @@ -276,8 +300,16 @@ export function PopoverItem({ rest.onClick?.(e); if (!disabled) onSelect?.(); }; + // Move the single visual highlight to the hovered item via hoverIndex — NOT floating-ui's activeIndex, + // which would drag DOM focus onto the item and blur a menu's free-text input (the create-tag field) + // mid-typing. This is what makes one highlight follow the pointer while the input keeps its caret. + // (The guard keeps it a no-op when the pointer hasn't crossed into a new row.) + const mergedMouseMove = (e: React.MouseEvent) => { + rest.onMouseMove?.(e); + if (ctx && !disabled && index != null && ctx.highlightIndex !== index) ctx.setHoverIndex(index); + }; const handlers = ctx - ? ctx.getItemProps({ ...rest, onClick: mergedClick }) + ? ctx.getItemProps({ ...rest, onClick: mergedClick, onMouseMove: mergedMouseMove }) : { ...rest, onClick: mergedClick }; return ( @@ -288,8 +320,8 @@ export function PopoverItem({ {...handlers} // These win over anything in `handlers` — the roving tabindex must not be a caller override, // and disabled/className/data-active are the platform's to set. - tabIndex={active ? 0 : -1} - data-active={active ? "true" : undefined} + tabIndex={focusActive ? 0 : -1} + data-active={visualActive ? "true" : undefined} disabled={disabled} className={`${ACTIVE_HIGHLIGHT}${className ? ` ${className}` : ""}`} > @@ -349,6 +381,8 @@ function PopoverSurfaceInner({ isNav, selectedIndex, activeIndex, + highlightIndex, + setHoverIndex, elementsRef, labelsRef, } = popover; @@ -402,7 +436,12 @@ function PopoverSurfaceInner({ 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. */} -
+
setHoverIndex(null)} + > {children}
@@ -411,7 +450,9 @@ function PopoverSurfaceInner({ // Menu/listbox surfaces host the item registry (FloatingList) + active-state context so nested // s can self-register and rove. const positioned = isNav ? ( - + {surface} diff --git a/frontend/src/components/ChannelTags.tsx b/frontend/src/components/ChannelTags.tsx index 36f8875b..2a7bbd5b 100644 --- a/frontend/src/components/ChannelTags.tsx +++ b/frontend/src/components/ChannelTags.tsx @@ -75,7 +75,7 @@ function TagEditorMenu({ checked={on} onSelect={() => onToggle(tg.id)} label={tg.name} - className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition hover:bg-card ${ + className={`w-full flex items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${ on ? "text-fg" : "text-muted" }`} > diff --git a/frontend/src/components/LanguageSwitcher.tsx b/frontend/src/components/LanguageSwitcher.tsx index 62b9f214..2f29523b 100644 --- a/frontend/src/components/LanguageSwitcher.tsx +++ b/frontend/src/components/LanguageSwitcher.tsx @@ -78,7 +78,7 @@ export default function LanguageSwitcher({ setOpen(false); }} label={l.label} - className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-hidden transition hover:text-fg hover:bg-card" + className="w-full flex items-center justify-between gap-2 text-sm px-2 py-2 rounded-lg text-muted outline-hidden transition hover:text-fg" > {l.label} {l.code === value && } diff --git a/frontend/src/components/ViewSwitcher.tsx b/frontend/src/components/ViewSwitcher.tsx index bb721f54..a3b1c1ea 100644 --- a/frontend/src/components/ViewSwitcher.tsx +++ b/frontend/src/components/ViewSwitcher.tsx @@ -95,7 +95,7 @@ export default function ViewSwitcher({ }} label={o.label} className={`w-full flex items-center gap-2 px-2 py-1.5 rounded-lg text-sm text-left outline-hidden transition ${ - on ? "text-accent" : "text-fg hover:bg-card" + on ? "text-accent" : "text-fg" }`} > diff --git a/frontend/src/components/tableHeader.tsx b/frontend/src/components/tableHeader.tsx index 9db87ad5..39086bbf 100644 --- a/frontend/src/components/tableHeader.tsx +++ b/frontend/src/components/tableHeader.tsx @@ -198,7 +198,7 @@ function FilterFunnel({ } label={o.label} className={`flex w-full items-center gap-2 text-left text-xs px-2 py-1.5 rounded-md outline-hidden transition ${ - on ? "text-fg" : "text-muted hover:bg-card" + on ? "text-fg" : "text-muted" }`} >