mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 10:12:34 +00:00
Popovers: extract shared usePopover hook; SettingsPopoverStore becomes PopoverStore
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
/**
|
||||
* usePopover — the shared machinery behind every disclosure popover
|
||||
* (SettingsPopover, SortControl, SelectControl, ChartExport).
|
||||
*
|
||||
* The widget contract is the WAI-ARIA APG **disclosure** + non-modal popover
|
||||
* (docs/architecture/10): the trigger carries `aria-expanded`/`aria-controls`;
|
||||
* Esc closes and refocuses the trigger; an outside pointer press closes; at most
|
||||
* one popover is open app-wide (the PopoverStore registry). The panel is portaled
|
||||
* to `<body>` by the caller and positioned `fixed` from the trigger's rect here —
|
||||
* the panes clip their overflow, so an in-flow absolute panel would be cut off.
|
||||
*
|
||||
* Positioning is imperative (style writes, no React state), so scroll/resize
|
||||
* tracking never re-renders the component. The caller renders the panel only
|
||||
* while `open` and attaches `setPopNode` as its ref: on mount the panel is placed
|
||||
* before paint and focus moves to the first match of `initialFocus`, so keyboard
|
||||
* users land inside.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { usePopoverStore } from '../stores/PopoverStore';
|
||||
|
||||
/** Gap (px) between the trigger and the disclosed panel. */
|
||||
const GAP = 6;
|
||||
|
||||
export interface PopoverOptions {
|
||||
/** Unique id — the single-open registry key and the panel's DOM id. */
|
||||
id: string;
|
||||
/** Which trigger edge the panel aligns to. `left` also clamps on-screen. */
|
||||
align?: 'left' | 'right';
|
||||
/** Open above the trigger when the viewport below is too short (and above fits). */
|
||||
flip?: boolean;
|
||||
/**
|
||||
* Selectors tried in order for the element to focus on open — separate queries,
|
||||
* not one list, because `querySelector('a, b')` returns the first match in
|
||||
* document order regardless of selector order.
|
||||
*/
|
||||
initialFocus: readonly string[];
|
||||
}
|
||||
|
||||
export interface PopoverHandle {
|
||||
open: boolean;
|
||||
/** Toggle from the trigger's onClick. */
|
||||
toggle: () => void;
|
||||
close: () => void;
|
||||
/** Close and return focus to the trigger (choose-an-option, Tab-out paths). */
|
||||
closeAndRefocus: () => void;
|
||||
triggerRef: React.RefObject<HTMLButtonElement | null>;
|
||||
/** The live panel node, for caller-side queries (e.g. roving key nav). */
|
||||
popRef: React.RefObject<HTMLDivElement | null>;
|
||||
/** Attach as the panel's ref: places before paint, then focuses `initialFocus`. */
|
||||
setPopNode: (node: HTMLDivElement | null) => void;
|
||||
}
|
||||
|
||||
export function usePopover({
|
||||
id,
|
||||
align = 'left',
|
||||
flip = false,
|
||||
initialFocus,
|
||||
}: PopoverOptions): PopoverHandle {
|
||||
const open = usePopoverStore((s) => s.openId === id);
|
||||
const toggleId = usePopoverStore((s) => s.toggle);
|
||||
const close = usePopoverStore((s) => s.close);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const popRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Latest options behind a ref so place/setPopNode stay referentially stable —
|
||||
// an unstable ref callback would re-fire (null, node) on every render,
|
||||
// re-placing and stealing focus while the popover is open.
|
||||
const optsRef = useRef({ align, flip, initialFocus });
|
||||
useEffect(() => {
|
||||
optsRef.current = { align, flip, initialFocus };
|
||||
});
|
||||
|
||||
const place = useCallback(() => {
|
||||
const trigger = triggerRef.current;
|
||||
const pop = popRef.current;
|
||||
if (!trigger || !pop) return;
|
||||
const { align, flip } = optsRef.current;
|
||||
const r = trigger.getBoundingClientRect();
|
||||
if (flip) {
|
||||
const below = window.innerHeight - r.bottom - GAP;
|
||||
const height = pop.offsetHeight;
|
||||
pop.style.top =
|
||||
below < height && r.top > height + GAP
|
||||
? `${r.top - GAP - height}px`
|
||||
: `${r.bottom + GAP}px`;
|
||||
} else {
|
||||
pop.style.top = `${r.bottom + GAP}px`;
|
||||
}
|
||||
if (align === 'left') {
|
||||
// Keep the panel on-screen when the trigger sits near the right edge.
|
||||
const left = Math.min(r.left, window.innerWidth - pop.offsetWidth - GAP);
|
||||
pop.style.left = `${Math.max(GAP, left)}px`;
|
||||
pop.style.right = 'auto';
|
||||
} else {
|
||||
pop.style.right = `${window.innerWidth - r.right}px`;
|
||||
pop.style.left = 'auto';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Re-place while open so the panel tracks the trigger on workspace scroll/resize.
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
window.addEventListener('resize', place);
|
||||
window.addEventListener('scroll', place, true); // capture: catch pane scrolls too
|
||||
return () => {
|
||||
window.removeEventListener('resize', place);
|
||||
window.removeEventListener('scroll', place, true);
|
||||
};
|
||||
}, [open, place]);
|
||||
|
||||
// Esc closes + restores focus to the trigger; an outside pointer press closes
|
||||
// (APG disclosure; non-modal). Esc is captured so it settles here, not on a
|
||||
// parent that also listens (e.g. a host modal).
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
e.stopPropagation();
|
||||
close();
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
const onPointer = (e: PointerEvent) => {
|
||||
const t = e.target as Node;
|
||||
if (!popRef.current?.contains(t) && !triggerRef.current?.contains(t)) close();
|
||||
};
|
||||
document.addEventListener('keydown', onKey, true);
|
||||
document.addEventListener('pointerdown', onPointer, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKey, true);
|
||||
document.removeEventListener('pointerdown', onPointer, true);
|
||||
};
|
||||
}, [open, close]);
|
||||
|
||||
// Panel ref callback: position before paint, then land focus inside. Fires once
|
||||
// per open (the panel mounts with `open`).
|
||||
const setPopNode = useCallback(
|
||||
(node: HTMLDivElement | null) => {
|
||||
popRef.current = node;
|
||||
if (node) {
|
||||
place();
|
||||
for (const selector of optsRef.current.initialFocus) {
|
||||
const target = node.querySelector<HTMLElement>(selector);
|
||||
if (target) {
|
||||
target.focus();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[place],
|
||||
);
|
||||
|
||||
const toggle = useCallback(() => toggleId(id), [toggleId, id]);
|
||||
const closeAndRefocus = useCallback(() => {
|
||||
close();
|
||||
triggerRef.current?.focus();
|
||||
}, [close]);
|
||||
|
||||
return { open, toggle, close, closeAndRefocus, triggerRef, popRef, setPopNode };
|
||||
}
|
||||
Reference in New Issue
Block a user