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);