From 20d593a6a31c8181bbb8f8d38e31cf69e3d80c94 Mon Sep 17 00:00:00 2001 From: Oleh Omelchenko Date: Fri, 12 Jun 2026 19:40:35 +0300 Subject: [PATCH] Popovers: extract shared usePopover hook; SettingsPopoverStore becomes PopoverStore --- src/app/components/ChartExport.tsx | 78 ++---------- src/app/components/SelectControl.tsx | 99 +++------------ src/app/components/SettingsPopover.tsx | 86 ++----------- src/app/components/SortControl.tsx | 87 ++----------- src/app/hooks/usePopover.ts | 162 +++++++++++++++++++++++++ src/app/orchestration/EventRouter.ts | 4 +- src/app/stores/PopoverStore.ts | 30 +++++ src/app/stores/SettingsPopoverStore.ts | 29 ----- 8 files changed, 241 insertions(+), 334 deletions(-) create mode 100644 src/app/hooks/usePopover.ts create mode 100644 src/app/stores/PopoverStore.ts delete mode 100644 src/app/stores/SettingsPopoverStore.ts diff --git a/src/app/components/ChartExport.tsx b/src/app/components/ChartExport.tsx index 2578826..a5b148b 100644 --- a/src/app/components/ChartExport.tsx +++ b/src/app/components/ChartExport.tsx @@ -25,7 +25,7 @@ * registry, and is portaled to `` (the panes clip their overflow). */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; import { createPortal } from 'react-dom'; import { useShallow } from 'zustand/react/shallow'; import { @@ -35,9 +35,9 @@ import { } from '@core/chart-export'; import { DatasetNotFoundError } from '@core/rendering'; import { copyText, downloadJson, downloadUrl } from '../infrastructure/file-transfer'; +import { usePopover } from '../hooks/usePopover'; import { useDatasetStore } from '../stores/DatasetStore'; import { notify } from '../stores/NotificationStore'; -import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore'; import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore'; import { Icon } from './Icon'; import { SegmentedControl, type SegmentedOption } from './SegmentedControl'; @@ -46,8 +46,9 @@ import styles from './ChartExport.module.css'; /** Shared registry id (single popover open at a time across the panes). */ const POP_ID = 'chart-export'; -/** Gap (px) between the trigger and the disclosed panel (matches SettingsPopover). */ -const GAP = 6; + +/** Focus the first action button or option radio on open. */ +const INITIAL_FOCUS = ['button, [role="radio"]'] as const; type ScaleChoice = '1' | '2' | '3'; type BackgroundChoice = 'theme' | 'white' | 'transparent'; @@ -96,9 +97,11 @@ export interface ChartExportProps { } export function ChartExport({ chartReady, getImageUrl }: ChartExportProps) { - const open = useSettingsPopoverStore((s) => s.openId === POP_ID); - const toggle = useSettingsPopoverStore((s) => s.toggle); - const close = useSettingsPopoverStore((s) => s.close); + const { open, toggle, close, triggerRef, setPopNode } = usePopover({ + id: POP_ID, + align: 'right', + initialFocus: INITIAL_FOCUS, + }); // Primitive reads only (no fresh objects) so the header doesn't re-render needlessly. const name = useSnippetStore((s) => selectActiveSnippet(s)?.name ?? 'chart'); const shownText = useSnippetStore(selectShownText); @@ -113,65 +116,6 @@ export function ChartExport({ chartReady, getImageUrl }: ChartExportProps) { // Which saved datasets the shown spec references — drives the inline-data option. const refs = useMemo(() => referencedDatasetNames(shownText), [shownText]); - const triggerRef = useRef(null); - const popRef = useRef(null); - - // Position the fixed panel from the trigger's rect (panes clip overflow → body - // portal + fixed). Imperative, like SettingsPopover — no state, no scroll re-render. - 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`; - pop.style.right = `${window.innerWidth - r.right}px`; - pop.style.left = 'auto'; - }, []); - - useEffect(() => { - if (!open) return; - window.addEventListener('resize', place); - window.addEventListener('scroll', place, true); - return () => { - window.removeEventListener('resize', place); - window.removeEventListener('scroll', place, true); - }; - }, [open, place]); - - // Esc closes + restores focus; an outside pointer click closes (APG disclosure). - 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]); - - // On open: place before paint and move focus to the first control. - const setPopNode = useCallback( - (node: HTMLDivElement | null) => { - popRef.current = node; - if (node) { - place(); - node.querySelector('button, [role="radio"]')?.focus(); - } - }, - [place], - ); - /** The spec text to export — inlined for portability when chosen and refs exist. * Returns null after surfacing an error (a referenced dataset is missing). */ const buildSpecText = (): string | null => { @@ -247,7 +191,7 @@ export function ChartExport({ chartReady, getImageUrl }: ChartExportProps) { aria-controls={POP_ID} disabled={!hasSpec} title={hasSpec ? 'Export this chart' : 'Select a snippet to export'} - onClick={() => toggle(POP_ID)} + onClick={toggle} > Export diff --git a/src/app/components/SelectControl.tsx b/src/app/components/SelectControl.tsx index 6acfde2..a17c877 100644 --- a/src/app/components/SelectControl.tsx +++ b/src/app/components/SelectControl.tsx @@ -9,8 +9,8 @@ * primitive as SortControl/SettingsPopover — deliberately NOT an ARIA menu and not * a combobox; a short list of buttons needs neither's contract. * - * Behaviour (mirrors SortControl): at most one popover is open app-wide - * (`useSettingsPopoverStore`); Esc closes and refocuses the trigger; an outside + * Behaviour (the shared `usePopover` machinery): at most one popover is open + * app-wide (`PopoverStore`); Esc closes and refocuses the trigger; an outside * pointer press closes; opening focuses the selected option (or the first); * Arrow/Home/End move focus through the options; choosing one fires `onSelect`, * closes, and refocuses the trigger. The panel is portaled to and positioned @@ -22,13 +22,13 @@ * caller intercept the click entirely (the armed-channel fast path). */ -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 styles from './SelectControl.module.css'; -/** Gap (px) between the trigger and the disclosed panel (matches SortControl). */ -const GAP = 6; +/** Land on the selected option (falling back to the first). */ +const INITIAL_FOCUS = ['[aria-current="true"]', 'button'] as const; export interface SelectControlOption { value: V; @@ -71,64 +71,15 @@ export function SelectControl({ heading, beforeOpen, }: SelectControlProps) { - const open = useSettingsPopoverStore((s) => s.openId === id); - const toggle = useSettingsPopoverStore((s) => s.toggle); - const close = useSettingsPopoverStore((s) => s.close); - const triggerRef = useRef(null); - const popRef = useRef(null); + const { open, toggle, closeAndRefocus, triggerRef, popRef, setPopNode } = usePopover({ + id, + align: 'left', + flip: true, + initialFocus: INITIAL_FOCUS, + }); const current = value !== undefined ? options.find((o) => o.value === value) : undefined; - // Fixed-position from the trigger's rect (no React state → no re-render on - // scroll). Below the trigger by default; above when the viewport below is short. - const place = useCallback(() => { - const trigger = triggerRef.current; - const pop = popRef.current; - if (!trigger || !pop) return; - const r = trigger.getBoundingClientRect(); - 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`; - // 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`; - }, []); - - useEffect(() => { - if (!open) return; - window.addEventListener('resize', place); - window.addEventListener('scroll', place, true); - 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 (the builder modal also listens for Esc). - 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]); - // Arrow/Home/End roving among the option buttons — a convenience on top of the // natural Tab order, matching what a native select's popup offers. const onPopKeyDown = (e: React.KeyboardEvent) => { @@ -138,8 +89,7 @@ export function SelectControl({ // convention) — also keeps focus inside a host modal's trap, since the panel is // portaled outside it. if (e.key === 'Tab') { - close(); - triggerRef.current?.focus(); + closeAndRefocus(); return; } const items = Array.from(pop.querySelectorAll('button')); @@ -155,28 +105,9 @@ export function SelectControl({ } }; - // On mount: position before paint, then land focus on the selected option (or - // the first) so keyboard users arrive inside the popover. - const setPopNode = useCallback( - (node: HTMLDivElement | null) => { - popRef.current = node; - if (node) { - place(); - // Two queries, not one selector list — `querySelector('a, b')` returns the - // first match in document order, which would always be the first button. - const target = - node.querySelector('[aria-current="true"]') ?? - node.querySelector('button'); - target?.focus(); - } - }, - [place], - ); - const choose = (v: V) => { onSelect(v); - close(); - triggerRef.current?.focus(); + closeAndRefocus(); }; return ( @@ -192,7 +123,7 @@ export function SelectControl({ disabled={disabled} onClick={() => { if (beforeOpen && !beforeOpen()) return; - toggle(id); + toggle(); }} > {triggerContent ?? ( diff --git a/src/app/components/SettingsPopover.tsx b/src/app/components/SettingsPopover.tsx index 7fcce72..4bd3122 100644 --- a/src/app/components/SettingsPopover.tsx +++ b/src/app/components/SettingsPopover.tsx @@ -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(null); - const popRef = useRef(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('input, button, select, textarea, [tabindex]')?.focus(); - } - }, - [place], - ); + const { open, toggle, triggerRef, setPopNode } = usePopover({ + id, + align, + initialFocus: INITIAL_FOCUS, + }); return (
@@ -129,7 +63,7 @@ export function SettingsPopover({ aria-controls={id} aria-label={label} title={label} - onClick={() => toggle(id)} + onClick={toggle} > diff --git a/src/app/components/SortControl.tsx b/src/app/components/SortControl.tsx index 013cb80..3e1076e 100644 --- a/src/app/components/SortControl.tsx +++ b/src/app/components/SortControl.tsx @@ -14,24 +14,23 @@ * Selection model (spec §02): re-selecting the ACTIVE field flips direction; * selecting a DIFFERENT field switches to it and resets to descending — all * encapsulated in `SnippetStore.setSort`. Keyboard/focus (APG disclosure): the - * shared `useSettingsPopoverStore` makes at most one popover open at a time; + * shared `usePopover` machinery makes at most one popover open at a time; * Enter/Space toggle the trigger; Esc closes and returns focus to the trigger; * an outside click closes. The panel is portaled to and positioned fixed * because the panes clip their content. */ -import { useCallback, useEffect, useRef } from 'react'; import { createPortal } from 'react-dom'; import type { SortBy } from '@core/snippet-sort'; -import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore'; +import { usePopover } from '../hooks/usePopover'; import { useSnippetStore } from '../stores/SnippetStore'; import styles from './SortControl.module.css'; -/** Gap (px) between the trigger and the disclosed panel (matches SettingsPopover). */ -const GAP = 6; - const ID = 'library-sort'; +/** Land on the active field button (falling back to the first). */ +const INITIAL_FOCUS = ['[aria-checked="true"]', 'button'] as const; + /** Field labels in display order (the order the popover lists them). */ const FIELDS: ReadonlyArray<{ value: SortBy; label: string }> = [ { value: 'modified', label: 'Modified' }, @@ -48,78 +47,14 @@ export function SortControl() { const sortBy = useSnippetStore((s) => s.sortBy); const sortOrder = useSnippetStore((s) => s.sortOrder); const setSort = useSnippetStore((s) => s.setSort); - const open = useSettingsPopoverStore((s) => s.openId === ID); - const toggle = useSettingsPopoverStore((s) => s.toggle); - const close = useSettingsPopoverStore((s) => s.close); - const triggerRef = useRef(null); - const popRef = useRef(null); + const { open, toggle, triggerRef, setPopNode } = usePopover({ + id: ID, + align: 'left', + initialFocus: INITIAL_FOCUS, + }); const arrow = sortOrder === 'desc' ? '↓' : '↑'; - // Position the (fixed) panel from the trigger's rect — no React state, so no - // re-render on scroll. Aligns to the trigger's left edge (the library pane is - // narrow; opening rightward keeps the panel on-screen). - 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`; - pop.style.left = `${r.left}px`; - pop.style.right = 'auto'; - }, []); - - useEffect(() => { - if (!open) return; - window.addEventListener('resize', place); - window.addEventListener('scroll', place, true); - return () => { - window.removeEventListener('resize', place); - window.removeEventListener('scroll', place, true); - }; - }, [open, place]); - - // Esc closes + restores focus to the trigger; 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]); - - // On mount, position before paint and move focus to the active field button so - // keyboard users land inside the popover. - const setPopNode = useCallback( - (node: HTMLDivElement | null) => { - popRef.current = node; - if (node) { - place(); - // Two queries, not one selector list — `querySelector('a, b')` returns the - // first match in document order, which would always be the first button. - const target = - node.querySelector('[aria-checked="true"]') ?? - node.querySelector('button'); - target?.focus(); - } - }, - [place], - ); - return (
diff --git a/src/app/hooks/usePopover.ts b/src/app/hooks/usePopover.ts new file mode 100644 index 0000000..1b0a984 --- /dev/null +++ b/src/app/hooks/usePopover.ts @@ -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 `` 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; + /** The live panel node, for caller-side queries (e.g. roving key nav). */ + popRef: React.RefObject; + /** 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(null); + const popRef = useRef(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(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 }; +} diff --git a/src/app/orchestration/EventRouter.ts b/src/app/orchestration/EventRouter.ts index 9a83953..cf902bb 100644 --- a/src/app/orchestration/EventRouter.ts +++ b/src/app/orchestration/EventRouter.ts @@ -29,7 +29,7 @@ import { closeModal, toggleDatasets } from '../modals/ModalCoordinator'; import { useAppStore } from '../stores/AppStore'; import { useSnippetStore } from '../stores/SnippetStore'; -import { openSettingsPopover } from '../stores/SettingsPopoverStore'; +import { openPopover } from '../stores/PopoverStore'; import { publishActiveSnippet } from '../services/snippet-actions'; import { isInInteractiveContext } from './focus-utils'; @@ -93,7 +93,7 @@ function onKeyDown(e: KeyboardEvent): void { // the primary one (the arch sketch's openModal('settings') predates that). if (e.key === ',') { e.preventDefault(); - openSettingsPopover('editor-settings'); + openPopover('editor-settings'); return; } } diff --git a/src/app/stores/PopoverStore.ts b/src/app/stores/PopoverStore.ts new file mode 100644 index 0000000..b0c6536 --- /dev/null +++ b/src/app/stores/PopoverStore.ts @@ -0,0 +1,30 @@ +/** + * Open-state registry for disclosure popovers (spec §07; arch 10). + * + * A single id names whichever popover is open — settings clusters, the sort + * control, select controls, chart export — so at most one shows at a time, and + * any can be opened imperatively (the Cmd/Ctrl+, shortcut targets the editor + * settings cluster). Kept in its own module so component files export only + * components (fast-refresh friendly). The shared open/place/dismiss behavior + * built on top lives in `hooks/usePopover`. + */ + +import { create } from 'zustand'; + +export interface PopoverState { + /** Id of the single open popover, or null. */ + openId: string | null; + toggle: (id: string) => void; + show: (id: string) => void; + close: () => void; +} + +export const usePopoverStore = create((set) => ({ + openId: null, + toggle: (id) => set((s) => ({ openId: s.openId === id ? null : id })), + show: (id) => set({ openId: id }), + close: () => set({ openId: null }), +})); + +/** Open a popover by id from outside React (e.g. the Cmd/Ctrl+, shortcut). */ +export const openPopover = (id: string): void => usePopoverStore.getState().show(id); diff --git a/src/app/stores/SettingsPopoverStore.ts b/src/app/stores/SettingsPopoverStore.ts deleted file mode 100644 index d0fc544..0000000 --- a/src/app/stores/SettingsPopoverStore.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Open-state for the per-pane settings disclosures (spec §07; arch 10). - * - * A single id names whichever settings popover is open — so at most one shows at a - * time, and any can be opened imperatively (the Cmd/Ctrl+, shortcut targets the - * editor cluster). Kept in its own module so the SettingsPopover component file - * exports only components (fast-refresh friendly). - */ - -import { create } from 'zustand'; - -export interface SettingsPopoverState { - /** Id of the single open popover, or null. */ - openId: string | null; - toggle: (id: string) => void; - show: (id: string) => void; - close: () => void; -} - -export const useSettingsPopoverStore = create((set) => ({ - openId: null, - toggle: (id) => set((s) => ({ openId: s.openId === id ? null : id })), - show: (id) => set({ openId: id }), - close: () => set({ openId: null }), -})); - -/** Open a settings popover by id from outside React (e.g. the Cmd/Ctrl+, shortcut). */ -export const openSettingsPopover = (id: string): void => - useSettingsPopoverStore.getState().show(id);