Popovers: extract shared usePopover hook; SettingsPopoverStore becomes PopoverStore

This commit is contained in:
2026-06-12 19:40:35 +03:00
parent 0e225d7d5c
commit 20d593a6a3
8 changed files with 241 additions and 334 deletions
+10 -76
View File
@@ -19,14 +19,14 @@
* in-flow absolute popover would be cut off. Position re-measures on scroll/resize.
*/
import { useCallback, useEffect, useRef, type ReactNode } from 'react';
import { type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore';
import { usePopover } from '../hooks/usePopover';
import { Icon } from './Icon';
import styles from './SettingsPopover.module.css';
/** Gap (px) between the gear and the disclosed panel. */
const GAP = 6;
/** Focus the first interactive control on open (any kind — these are forms). */
const INITIAL_FOCUS = ['input, button, select, textarea, [tabindex]'] as const;
export interface SettingsPopoverProps {
/** Stable id, also the popover's element id for `aria-controls` (e.g. 'editor-settings'). */
@@ -47,77 +47,11 @@ export function SettingsPopover({
align = 'right',
children,
}: SettingsPopoverProps) {
const open = useSettingsPopoverStore((s) => s.openId === id);
const toggle = useSettingsPopoverStore((s) => s.toggle);
const close = useSettingsPopoverStore((s) => s.close);
const triggerRef = useRef<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement | null>(null);
// Position the (fixed) panel imperatively from the gear's rect — no React state,
// so there's no setState-in-effect and no re-render on scroll. The panes clip
// their content, hence the body portal + fixed positioning.
const place = useCallback(() => {
const trigger = triggerRef.current;
const pop = popRef.current;
if (!trigger || !pop) return;
const r = trigger.getBoundingClientRect();
pop.style.top = `${r.bottom + GAP}px`;
if (align === 'left') {
pop.style.left = `${r.left}px`;
pop.style.right = 'auto';
} else {
pop.style.right = `${window.innerWidth - r.right}px`;
pop.style.left = 'auto';
}
}, [align]);
// Re-place while open so the panel tracks the gear if the workspace scrolls/resizes.
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 gear; an outside pointer click closes
// (APG disclosure; non-modal). Capture Esc so it settles here, not a parent.
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]);
// Ref callback: on mount, position the panel before paint and move focus to the
// first control so keyboard users — including those who opened via Cmd/Ctrl+, —
// land inside it. Fires once per open (align is constant per instance).
const setPopNode = useCallback(
(node: HTMLDivElement | null) => {
popRef.current = node;
if (node) {
place();
node.querySelector<HTMLElement>('input, button, select, textarea, [tabindex]')?.focus();
}
},
[place],
);
const { open, toggle, triggerRef, setPopNode } = usePopover({
id,
align,
initialFocus: INITIAL_FOCUS,
});
return (
<div className={styles.wrap}>
@@ -129,7 +63,7 @@ export function SettingsPopover({
aria-controls={id}
aria-label={label}
title={label}
onClick={() => toggle(id)}
onClick={toggle}
>
<Icon name="settings" />
</button>