mirror of
https://github.com/olehomelchenko/astrolabe.git
synced 2026-08-08 02:02:33 +00:00
Popovers: extract shared usePopover hook; SettingsPopoverStore becomes PopoverStore
This commit is contained in:
@@ -25,7 +25,7 @@
|
|||||||
* registry, and is portaled to `<body>` (the panes clip their overflow).
|
* 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 { createPortal } from 'react-dom';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
import {
|
import {
|
||||||
@@ -35,9 +35,9 @@ import {
|
|||||||
} from '@core/chart-export';
|
} from '@core/chart-export';
|
||||||
import { DatasetNotFoundError } from '@core/rendering';
|
import { DatasetNotFoundError } from '@core/rendering';
|
||||||
import { copyText, downloadJson, downloadUrl } from '../infrastructure/file-transfer';
|
import { copyText, downloadJson, downloadUrl } from '../infrastructure/file-transfer';
|
||||||
|
import { usePopover } from '../hooks/usePopover';
|
||||||
import { useDatasetStore } from '../stores/DatasetStore';
|
import { useDatasetStore } from '../stores/DatasetStore';
|
||||||
import { notify } from '../stores/NotificationStore';
|
import { notify } from '../stores/NotificationStore';
|
||||||
import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore';
|
|
||||||
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
import { selectActiveSnippet, selectShownText, useSnippetStore } from '../stores/SnippetStore';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
import { SegmentedControl, type SegmentedOption } from './SegmentedControl';
|
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). */
|
/** Shared registry id (single popover open at a time across the panes). */
|
||||||
const POP_ID = 'chart-export';
|
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 ScaleChoice = '1' | '2' | '3';
|
||||||
type BackgroundChoice = 'theme' | 'white' | 'transparent';
|
type BackgroundChoice = 'theme' | 'white' | 'transparent';
|
||||||
@@ -96,9 +97,11 @@ export interface ChartExportProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function ChartExport({ chartReady, getImageUrl }: ChartExportProps) {
|
export function ChartExport({ chartReady, getImageUrl }: ChartExportProps) {
|
||||||
const open = useSettingsPopoverStore((s) => s.openId === POP_ID);
|
const { open, toggle, close, triggerRef, setPopNode } = usePopover({
|
||||||
const toggle = useSettingsPopoverStore((s) => s.toggle);
|
id: POP_ID,
|
||||||
const close = useSettingsPopoverStore((s) => s.close);
|
align: 'right',
|
||||||
|
initialFocus: INITIAL_FOCUS,
|
||||||
|
});
|
||||||
// Primitive reads only (no fresh objects) so the header doesn't re-render needlessly.
|
// Primitive reads only (no fresh objects) so the header doesn't re-render needlessly.
|
||||||
const name = useSnippetStore((s) => selectActiveSnippet(s)?.name ?? 'chart');
|
const name = useSnippetStore((s) => selectActiveSnippet(s)?.name ?? 'chart');
|
||||||
const shownText = useSnippetStore(selectShownText);
|
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.
|
// Which saved datasets the shown spec references — drives the inline-data option.
|
||||||
const refs = useMemo(() => referencedDatasetNames(shownText), [shownText]);
|
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.
|
/** The spec text to export — inlined for portability when chosen and refs exist.
|
||||||
* Returns null after surfacing an error (a referenced dataset is missing). */
|
* Returns null after surfacing an error (a referenced dataset is missing). */
|
||||||
const buildSpecText = (): string | null => {
|
const buildSpecText = (): string | null => {
|
||||||
@@ -247,7 +191,7 @@ export function ChartExport({ chartReady, getImageUrl }: ChartExportProps) {
|
|||||||
aria-controls={POP_ID}
|
aria-controls={POP_ID}
|
||||||
disabled={!hasSpec}
|
disabled={!hasSpec}
|
||||||
title={hasSpec ? 'Export this chart' : 'Select a snippet to export'}
|
title={hasSpec ? 'Export this chart' : 'Select a snippet to export'}
|
||||||
onClick={() => toggle(POP_ID)}
|
onClick={toggle}
|
||||||
>
|
>
|
||||||
<Icon name="export" className={styles.triggerIcon} />
|
<Icon name="export" className={styles.triggerIcon} />
|
||||||
<span>Export</span>
|
<span>Export</span>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
* primitive as SortControl/SettingsPopover — deliberately NOT an ARIA menu and not
|
* primitive as SortControl/SettingsPopover — deliberately NOT an ARIA menu and not
|
||||||
* a combobox; a short list of buttons needs neither's contract.
|
* a combobox; a short list of buttons needs neither's contract.
|
||||||
*
|
*
|
||||||
* Behaviour (mirrors SortControl): at most one popover is open app-wide
|
* Behaviour (the shared `usePopover` machinery): at most one popover is open
|
||||||
* (`useSettingsPopoverStore`); Esc closes and refocuses the trigger; an outside
|
* app-wide (`PopoverStore`); Esc closes and refocuses the trigger; an outside
|
||||||
* pointer press closes; opening focuses the selected option (or the first);
|
* pointer press closes; opening focuses the selected option (or the first);
|
||||||
* Arrow/Home/End move focus through the options; choosing one fires `onSelect`,
|
* 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
|
* 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).
|
* 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 { createPortal } from 'react-dom';
|
||||||
import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore';
|
import { usePopover } from '../hooks/usePopover';
|
||||||
import styles from './SelectControl.module.css';
|
import styles from './SelectControl.module.css';
|
||||||
|
|
||||||
/** Gap (px) between the trigger and the disclosed panel (matches SortControl). */
|
/** Land on the selected option (falling back to the first). */
|
||||||
const GAP = 6;
|
const INITIAL_FOCUS = ['[aria-current="true"]', 'button'] as const;
|
||||||
|
|
||||||
export interface SelectControlOption<V extends string> {
|
export interface SelectControlOption<V extends string> {
|
||||||
value: V;
|
value: V;
|
||||||
@@ -71,64 +71,15 @@ export function SelectControl<V extends string>({
|
|||||||
heading,
|
heading,
|
||||||
beforeOpen,
|
beforeOpen,
|
||||||
}: SelectControlProps<V>) {
|
}: SelectControlProps<V>) {
|
||||||
const open = useSettingsPopoverStore((s) => s.openId === id);
|
const { open, toggle, closeAndRefocus, triggerRef, popRef, setPopNode } = usePopover({
|
||||||
const toggle = useSettingsPopoverStore((s) => s.toggle);
|
id,
|
||||||
const close = useSettingsPopoverStore((s) => s.close);
|
align: 'left',
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
flip: true,
|
||||||
const popRef = useRef<HTMLDivElement | null>(null);
|
initialFocus: INITIAL_FOCUS,
|
||||||
|
});
|
||||||
|
|
||||||
const current = value !== undefined ? options.find((o) => o.value === value) : undefined;
|
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
|
// 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.
|
// natural Tab order, matching what a native select's popup offers.
|
||||||
const onPopKeyDown = (e: React.KeyboardEvent) => {
|
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
|
// convention) — also keeps focus inside a host modal's trap, since the panel is
|
||||||
// portaled outside it.
|
// portaled outside it.
|
||||||
if (e.key === 'Tab') {
|
if (e.key === 'Tab') {
|
||||||
close();
|
closeAndRefocus();
|
||||||
triggerRef.current?.focus();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const items = Array.from(pop.querySelectorAll<HTMLButtonElement>('button'));
|
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) => {
|
const choose = (v: V) => {
|
||||||
onSelect(v);
|
onSelect(v);
|
||||||
close();
|
closeAndRefocus();
|
||||||
triggerRef.current?.focus();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -192,7 +123,7 @@ export function SelectControl<V extends string>({
|
|||||||
disabled={disabled}
|
disabled={disabled}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (beforeOpen && !beforeOpen()) return;
|
if (beforeOpen && !beforeOpen()) return;
|
||||||
toggle(id);
|
toggle();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{triggerContent ?? (
|
{triggerContent ?? (
|
||||||
|
|||||||
@@ -19,14 +19,14 @@
|
|||||||
* in-flow absolute popover would be cut off. Position re-measures on scroll/resize.
|
* 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 { createPortal } from 'react-dom';
|
||||||
import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore';
|
import { usePopover } from '../hooks/usePopover';
|
||||||
import { Icon } from './Icon';
|
import { Icon } from './Icon';
|
||||||
import styles from './SettingsPopover.module.css';
|
import styles from './SettingsPopover.module.css';
|
||||||
|
|
||||||
/** Gap (px) between the gear and the disclosed panel. */
|
/** Focus the first interactive control on open (any kind — these are forms). */
|
||||||
const GAP = 6;
|
const INITIAL_FOCUS = ['input, button, select, textarea, [tabindex]'] as const;
|
||||||
|
|
||||||
export interface SettingsPopoverProps {
|
export interface SettingsPopoverProps {
|
||||||
/** Stable id, also the popover's element id for `aria-controls` (e.g. 'editor-settings'). */
|
/** Stable id, also the popover's element id for `aria-controls` (e.g. 'editor-settings'). */
|
||||||
@@ -47,77 +47,11 @@ export function SettingsPopover({
|
|||||||
align = 'right',
|
align = 'right',
|
||||||
children,
|
children,
|
||||||
}: SettingsPopoverProps) {
|
}: SettingsPopoverProps) {
|
||||||
const open = useSettingsPopoverStore((s) => s.openId === id);
|
const { open, toggle, triggerRef, setPopNode } = usePopover({
|
||||||
const toggle = useSettingsPopoverStore((s) => s.toggle);
|
id,
|
||||||
const close = useSettingsPopoverStore((s) => s.close);
|
align,
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
initialFocus: INITIAL_FOCUS,
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
@@ -129,7 +63,7 @@ export function SettingsPopover({
|
|||||||
aria-controls={id}
|
aria-controls={id}
|
||||||
aria-label={label}
|
aria-label={label}
|
||||||
title={label}
|
title={label}
|
||||||
onClick={() => toggle(id)}
|
onClick={toggle}
|
||||||
>
|
>
|
||||||
<Icon name="settings" />
|
<Icon name="settings" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -14,24 +14,23 @@
|
|||||||
* Selection model (spec §02): re-selecting the ACTIVE field flips direction;
|
* Selection model (spec §02): re-selecting the ACTIVE field flips direction;
|
||||||
* selecting a DIFFERENT field switches to it and resets to descending — all
|
* selecting a DIFFERENT field switches to it and resets to descending — all
|
||||||
* encapsulated in `SnippetStore.setSort`. Keyboard/focus (APG disclosure): the
|
* 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;
|
* 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
|
* an outside click closes. The panel is portaled to <body> and positioned fixed
|
||||||
* because the panes clip their content.
|
* because the panes clip their content.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
|
||||||
import { createPortal } from 'react-dom';
|
import { createPortal } from 'react-dom';
|
||||||
import type { SortBy } from '@core/snippet-sort';
|
import type { SortBy } from '@core/snippet-sort';
|
||||||
import { useSettingsPopoverStore } from '../stores/SettingsPopoverStore';
|
import { usePopover } from '../hooks/usePopover';
|
||||||
import { useSnippetStore } from '../stores/SnippetStore';
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
import styles from './SortControl.module.css';
|
import styles from './SortControl.module.css';
|
||||||
|
|
||||||
/** Gap (px) between the trigger and the disclosed panel (matches SettingsPopover). */
|
|
||||||
const GAP = 6;
|
|
||||||
|
|
||||||
const ID = 'library-sort';
|
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). */
|
/** Field labels in display order (the order the popover lists them). */
|
||||||
const FIELDS: ReadonlyArray<{ value: SortBy; label: string }> = [
|
const FIELDS: ReadonlyArray<{ value: SortBy; label: string }> = [
|
||||||
{ value: 'modified', label: 'Modified' },
|
{ value: 'modified', label: 'Modified' },
|
||||||
@@ -48,78 +47,14 @@ export function SortControl() {
|
|||||||
const sortBy = useSnippetStore((s) => s.sortBy);
|
const sortBy = useSnippetStore((s) => s.sortBy);
|
||||||
const sortOrder = useSnippetStore((s) => s.sortOrder);
|
const sortOrder = useSnippetStore((s) => s.sortOrder);
|
||||||
const setSort = useSnippetStore((s) => s.setSort);
|
const setSort = useSnippetStore((s) => s.setSort);
|
||||||
const open = useSettingsPopoverStore((s) => s.openId === ID);
|
const { open, toggle, triggerRef, setPopNode } = usePopover({
|
||||||
const toggle = useSettingsPopoverStore((s) => s.toggle);
|
id: ID,
|
||||||
const close = useSettingsPopoverStore((s) => s.close);
|
align: 'left',
|
||||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
initialFocus: INITIAL_FOCUS,
|
||||||
const popRef = useRef<HTMLDivElement | null>(null);
|
});
|
||||||
|
|
||||||
const arrow = sortOrder === 'desc' ? '↓' : '↑';
|
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 (
|
return (
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
<button
|
<button
|
||||||
@@ -136,7 +71,7 @@ export function SortControl() {
|
|||||||
// visible text ("Modified ↓") would otherwise read as a bare field name.
|
// visible text ("Modified ↓") would otherwise read as a bare field name.
|
||||||
aria-label={`Sort by ${labelFor(sortBy)}, ${sortOrder === 'desc' ? 'descending' : 'ascending'}`}
|
aria-label={`Sort by ${labelFor(sortBy)}, ${sortOrder === 'desc' ? 'descending' : 'ascending'}`}
|
||||||
title={`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>
|
{labelFor(sortBy)} <span aria-hidden="true">{arrow}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@
|
|||||||
import { closeModal, toggleDatasets } from '../modals/ModalCoordinator';
|
import { closeModal, toggleDatasets } from '../modals/ModalCoordinator';
|
||||||
import { useAppStore } from '../stores/AppStore';
|
import { useAppStore } from '../stores/AppStore';
|
||||||
import { useSnippetStore } from '../stores/SnippetStore';
|
import { useSnippetStore } from '../stores/SnippetStore';
|
||||||
import { openSettingsPopover } from '../stores/SettingsPopoverStore';
|
import { openPopover } from '../stores/PopoverStore';
|
||||||
import { publishActiveSnippet } from '../services/snippet-actions';
|
import { publishActiveSnippet } from '../services/snippet-actions';
|
||||||
import { isInInteractiveContext } from './focus-utils';
|
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).
|
// the primary one (the arch sketch's openModal('settings') predates that).
|
||||||
if (e.key === ',') {
|
if (e.key === ',') {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
openSettingsPopover('editor-settings');
|
openPopover('editor-settings');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<PopoverState>((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);
|
||||||
@@ -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<SettingsPopoverState>((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);
|
|
||||||
Reference in New Issue
Block a user