import { type FocusEvent, type KeyboardEvent, type MouseEvent, type ReactElement, useCallback, useEffect, useRef, useState, } from "react"; /** How long a tooltip stays after the pointer leaves its control, so the pointer can cross the gap * onto the tooltip itself (WCAG 2.2 §1.4.13, "hoverable"). */ const HIDE_DELAY_MS = 150; interface Anchor { readonly left: number; readonly top: number; } /** The handlers a control spreads onto itself. Typed loosely enough for a link or a button. */ export interface NavTooltipTriggerProps { onBlur(event: FocusEvent): void; onFocus(event: FocusEvent): void; onKeyDown(event: KeyboardEvent): void; onMouseEnter(event: MouseEvent): void; onMouseLeave(event: MouseEvent): void; } /** * The visible name of an icon-only control in the collapsed sidebar (ADR 0058). * * The control's accessible name is its own text, kept in the DOM and visually hidden, so the * tooltip is a **visual** duplicate of that name: it is `aria-hidden`, and a screen reader hears the * name once. It meets WCAG 2.2 §1.4.13 for content on hover or focus: it appears on keyboard focus as * well as on hover (nothing is hover-only), Escape dismisses it without moving focus, the pointer can * move onto it without it vanishing, and it stays until focus or the pointer leaves. * * Positioned `fixed` beside the control, from the control's own rectangle, so a scrolling sidebar * cannot clip it. Disabled, it renders nothing and changes nothing: an expanded sidebar already * shows every label. * * Deliberately shell-local. ADR 0039 adds an element to `@ekklesia/ui` once two independent * consumers need it; when a module needs a tooltip, this is the shape to promote. */ export function useNavTooltip( label: string, enabled: boolean, ): { readonly triggerProps: NavTooltipTriggerProps; readonly tooltip: ReactElement | null } { const [anchor, setAnchor] = useState(undefined); const hideTimer = useRef | undefined>(undefined); const cancelHide = useCallback(() => { if (hideTimer.current !== undefined) { clearTimeout(hideTimer.current); hideTimer.current = undefined; } }, []); const hide = useCallback(() => { cancelHide(); setAnchor(undefined); }, [cancelHide]); const show = useCallback( (element: HTMLElement) => { cancelHide(); const rect = element.getBoundingClientRect(); setAnchor({ left: rect.right + 8, top: rect.top + rect.height / 2 }); }, [cancelHide], ); useEffect(() => { if (!enabled) { hide(); } }, [enabled, hide]); useEffect(() => cancelHide, [cancelHide]); const triggerProps: NavTooltipTriggerProps = { onBlur: hide, onFocus: (event) => { if (enabled) { show(event.currentTarget); } }, onKeyDown: (event) => { if (event.key === "Escape" && anchor !== undefined) { // Only this tooltip is dismissed; the event goes no further. event.stopPropagation(); hide(); } }, onMouseEnter: (event) => { if (enabled) { show(event.currentTarget); } }, onMouseLeave: () => { cancelHide(); hideTimer.current = setTimeout(hide, HIDE_DELAY_MS); }, }; const tooltip = enabled && anchor !== undefined ? ( ) : null; return { tooltip, triggerProps }; }