Files
siftlode/frontend/src/lib/useHoverFocusWithin.ts
T
peter 9d7a2a75d8 feat(ux): card unification, slim icon nav, filter/plex fixes (7-item batch)
1A ChannelPage "Full history" button no longer wraps (whitespace-nowrap).
1B Extract the manager's press-and-hold priority stepper into a shared
   PriorityStepper and add it to the channel detail header (subscribed, non-demo);
   backend channel_detail now returns the per-subscription `priority`.
2  Unify YouTube + Plex media cards onto one look/behaviour: drop the whole-card
   hover LIFT (VideoCard) — which also fixes the clipped-top bug — and instead the
   inner thumbnail/poster ZOOMS on hover (both); give the flat Plex posters the same
   glass-card 3D depth + accent hover border as the feed cards (shadow-md is a
   transparent no-op here, so it's glass-card that provides the depth); clamp Plex
   card titles to a fixed 2-line height so 1- vs 2-line titles align.
3  Nav rail is now always a slim icon-only column, flush to the left edge; each icon
   reveals a floating name label on hover (Tooltip gains `always` + `openDelay`, and
   a right side). Removed the pin/unpin toggle, the expand-on-hover mode and the
   navCollapsed pref plumbing.
4  Scope the side-panel peek-open HOVER to the tab handle + the card (not the whole
   full-height slot next to the rail), so approaching the nav no longer pops the
   filter open; focus stays container-wide. useHoverFocusWithin gains a hover-out
   grace period + split hover/focus handler groups.
5  Plex art backdrop fills the whole viewport (a fixed inset-0 layer behind the
   transparent app) instead of only the inset <main> box.
6  Plex season binge continues across seasons: the season view's play queue now
   spans the whole show (allKeys), so a season's last episode rolls into the next
   season's first (seasonKeys stays for "add season to playlist").
7  When a Plex video/episode ends with nothing queued next, the player returns to
   the previous page (onEnded → onClose) instead of stranding a black screen.
2026-08-11 02:20:52 +02:00

72 lines
3.0 KiB
TypeScript

import { useCallback, useEffect, useRef, useState, type FocusEvent } from "react";
/**
* "Peek open on hover or keyboard focus" for a floating panel — the shared brain behind the side
* panels, which sit slim until you point at them or tab into them.
*
* Returns `active` plus THREE handler groups so a consumer can scope hover and focus independently:
* - `hoverHandlers` — pointer enter/leave, with a short GRACE period on leave so the pointer can
* cross a gap between two separate peek regions (e.g. the docked tab and the expanded card, which
* are separate absolutely-positioned elements) without the panel collapsing mid-move. Put these on
* ONLY the visible affordances (the tab handle + the card), so approaching empty gutter next to the
* panel does not pop it open.
* - `focusHandlers` — focus/blur, tracked through the REACT tree so a portaled child menu still
* counts as "inside". Put these on the outer container so keyboard focus anywhere within holds it.
* - `handlers` — both groups merged, for the simple single-element case.
*
* The one focus case that misses: a portaled menu unmounts while focused — browsers fire NO blur when
* a focused node is removed, so focus silently falls to the body and would latch `focused` on forever.
* Hence the document-level safety net: when focus has landed nowhere, clear it.
*/
export function useHoverFocusWithin() {
const [hovered, setHovered] = useState(false);
const [focused, setFocused] = useState(false);
const leaveTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const enter = useCallback(() => {
clearTimeout(leaveTimer.current);
setHovered(true);
}, []);
const leaveSoon = useCallback(() => {
clearTimeout(leaveTimer.current);
leaveTimer.current = setTimeout(() => setHovered(false), 120);
}, []);
useEffect(() => () => clearTimeout(leaveTimer.current), []);
useEffect(() => {
let timer: number | undefined;
const clearIfFocusLandedNowhere = () => {
const el = document.activeElement;
if (!el || el === document.body || el === document.documentElement) setFocused(false);
};
// Deferred a tick so activeElement has settled, and rescheduled rather than queued — only the
// latest state matters.
const soon = () => {
window.clearTimeout(timer);
timer = window.setTimeout(clearIfFocusLandedNowhere, 0);
};
document.addEventListener("focusout", soon);
document.addEventListener("click", soon);
return () => {
document.removeEventListener("focusout", soon);
document.removeEventListener("click", soon);
window.clearTimeout(timer);
};
}, []);
const hoverHandlers = { onMouseEnter: enter, onMouseLeave: leaveSoon };
const focusHandlers = {
onFocus: () => setFocused(true),
onBlur: (e: FocusEvent) => {
if (!e.currentTarget.contains(e.relatedTarget as Node)) setFocused(false);
},
};
return {
active: hovered || focused,
hoverHandlers,
focusHandlers,
handlers: { ...hoverHandlers, ...focusHandlers },
};
}