fix(popover): single hover-follows highlight without stealing input focus (UAT)

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.
This commit is contained in:
2026-08-08 01:38:06 +02:00
parent 22e0d286c0
commit f8e0ab1520
6 changed files with 55 additions and 14 deletions
+1 -1
View File
@@ -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"
>
<span
className={`grid place-items-center w-4 h-4 rounded border ${
+50 -9
View File
@@ -109,6 +109,12 @@ export function useAnchoredPopover(opts: AnchoredPopoverOptions) {
const elementsRef = useRef<Array<HTMLElement | null>>([]);
const labelsRef = useRef<Array<string | null>>([]);
const [activeIndex, setActiveIndex] = useState<number | null>(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<number | null>(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<PopoverListContextValue | null>(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<HTMLButtonElement>) => {
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. */}
<div className={className} style={style}>
<div
className={className}
style={style}
// Pointer left the menu → drop the hover highlight so it returns to the keyboard position.
onMouseLeave={() => setHoverIndex(null)}
>
{children}
</div>
</div>
@@ -411,7 +450,9 @@ function PopoverSurfaceInner({
// Menu/listbox surfaces host the item registry (FloatingList) + active-state context so nested
// <PopoverItem>s can self-register and rove.
const positioned = isNav ? (
<PopoverListContext.Provider value={{ activeIndex, getItemProps }}>
<PopoverListContext.Provider
value={{ activeIndex, highlightIndex, setHoverIndex, getItemProps }}
>
<FloatingList elementsRef={elementsRef} labelsRef={labelsRef}>
{surface}
</FloatingList>
+1 -1
View File
@@ -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"
}`}
>
+1 -1
View File
@@ -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"
>
<span>{l.label}</span>
{l.code === value && <Check className="w-4 h-4 text-accent" />}
+1 -1
View File
@@ -95,7 +95,7 @@ export default function ViewSwitcher<T extends string>({
}}
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"
}`}
>
<Icon className="w-4 h-4 shrink-0" />
+1 -1
View File
@@ -198,7 +198,7 @@ function FilterFunnel<T>({
}
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"
}`}
>
<span