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
+11 -67
View File
@@ -25,7 +25,7 @@
* registry, and is portaled to `<body>` (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<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement | null>(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<HTMLElement>('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}
>
<Icon name="export" className={styles.triggerIcon} />
<span>Export</span>
+15 -84
View File
@@ -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 <body> 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<V extends string> {
value: V;
@@ -71,64 +71,15 @@ export function SelectControl<V extends string>({
heading,
beforeOpen,
}: SelectControlProps<V>) {
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);
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<V extends string>({
// 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<HTMLButtonElement>('button'));
@@ -155,28 +105,9 @@ export function SelectControl<V extends string>({
}
};
// 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<HTMLElement>('[aria-current="true"]') ??
node.querySelector<HTMLElement>('button');
target?.focus();
}
},
[place],
);
const choose = (v: V) => {
onSelect(v);
close();
triggerRef.current?.focus();
closeAndRefocus();
};
return (
@@ -192,7 +123,7 @@ export function SelectControl<V extends string>({
disabled={disabled}
onClick={() => {
if (beforeOpen && !beforeOpen()) return;
toggle(id);
toggle();
}}
>
{triggerContent ?? (
+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>
+11 -76
View File
@@ -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 <body> 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<HTMLButtonElement>(null);
const popRef = useRef<HTMLDivElement | null>(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<HTMLElement>('[aria-checked="true"]') ??
node.querySelector<HTMLElement>('button');
target?.focus();
}
},
[place],
);
return (
<div className={styles.wrap}>
<button
@@ -136,7 +71,7 @@ export function SortControl() {
// visible text ("Modified ↓") would otherwise read as a bare field name.
aria-label={`Sort by ${labelFor(sortBy)}, ${sortOrder === 'desc' ? 'descending' : 'ascending'}`}
title={`Sort by ${labelFor(sortBy)}, ${sortOrder === 'desc' ? 'descending' : 'ascending'}`}
onClick={() => toggle(ID)}
onClick={toggle}
>
{labelFor(sortBy)} <span aria-hidden="true">{arrow}</span>
</button>