fix: stabilize Toolbox navigation layout
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef, useState, type ChangeEvent } from 'react';
|
||||
import { useRef, useState, type ChangeEvent } from 'react';
|
||||
import {
|
||||
defaultPreferences,
|
||||
parsePreferences,
|
||||
@@ -7,83 +7,19 @@ import {
|
||||
import type { ThemeMode } from '../types';
|
||||
|
||||
interface PreferencesPanelProps {
|
||||
open: boolean;
|
||||
preferences: Preferences;
|
||||
storageAvailable: boolean;
|
||||
onChange: (preferences: Preferences) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function PreferencesPanel({
|
||||
open,
|
||||
preferences,
|
||||
storageAvailable,
|
||||
onChange,
|
||||
onClose,
|
||||
}: PreferencesPanelProps) {
|
||||
const fileInput = useRef<HTMLInputElement>(null);
|
||||
const panel = useRef<HTMLElement>(null);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const returnFocus =
|
||||
document.activeElement instanceof HTMLElement
|
||||
? document.activeElement
|
||||
: null;
|
||||
const dialog = panel.current;
|
||||
const focusableElements = () =>
|
||||
dialog
|
||||
? [...dialog.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)].filter(
|
||||
(element) => !element.hasAttribute('disabled')
|
||||
)
|
||||
: [];
|
||||
|
||||
focusableElements()[0]?.focus();
|
||||
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
|
||||
const elements = focusableElements();
|
||||
if (elements.length === 0) {
|
||||
event.preventDefault();
|
||||
dialog?.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = elements[0]!;
|
||||
const last = elements.at(-1)!;
|
||||
const activeElement = document.activeElement;
|
||||
if (
|
||||
event.shiftKey &&
|
||||
(activeElement === first || !dialog?.contains(activeElement))
|
||||
) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (
|
||||
!event.shiftKey &&
|
||||
(activeElement === last || !dialog?.contains(activeElement))
|
||||
) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
returnFocus?.focus();
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
function setTheme(theme: ThemeMode) {
|
||||
onChange({ ...preferences, theme });
|
||||
}
|
||||
@@ -119,119 +55,90 @@ export function PreferencesPanel({
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="dialog-backdrop"
|
||||
role="presentation"
|
||||
onMouseDown={(event) => event.target === event.currentTarget && onClose()}
|
||||
<section
|
||||
className="preferences-content"
|
||||
aria-labelledby="preferences-title"
|
||||
>
|
||||
<section
|
||||
ref={panel}
|
||||
className="preferences-panel"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="preferences-title"
|
||||
tabIndex={-1}
|
||||
>
|
||||
<header>
|
||||
<div>
|
||||
<p className="eyebrow">On this device</p>
|
||||
<h2 id="preferences-title">Personalize your toolbox</h2>
|
||||
</div>
|
||||
<button
|
||||
className="close-button"
|
||||
type="button"
|
||||
aria-label="Close preferences"
|
||||
onClick={onClose}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div className="preferences-heading">
|
||||
<p className="preferences-kicker">On this device</p>
|
||||
<h2 id="preferences-title">Personalize your toolbox</h2>
|
||||
</div>
|
||||
|
||||
{!storageAvailable && (
|
||||
<p className="inline-warning" role="status">
|
||||
Browser storage is unavailable. Changes will last only until this
|
||||
page closes.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<fieldset className="theme-choice">
|
||||
<legend>Appearance</legend>
|
||||
<div>
|
||||
{(['system', 'light', 'dark'] as const).map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
type="button"
|
||||
aria-pressed={preferences.theme === theme}
|
||||
className={preferences.theme === theme ? 'is-selected' : ''}
|
||||
onClick={() => setTheme(theme)}
|
||||
>
|
||||
{theme.slice(0, 1).toLocaleUpperCase() + theme.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="preference-summary">
|
||||
<div>
|
||||
<strong>{preferences.pinned.length}</strong>
|
||||
<span>Pinned</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{preferences.hidden.length}</strong>
|
||||
<span>Hidden</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{preferences.order.length}</strong>
|
||||
<span>Ordered</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="preferences-copy">
|
||||
The portal stores pins, visibility, order, and appearance in this
|
||||
browser and does not transmit them. Trusted code in another Toolbox
|
||||
app on this site can access same-origin storage. Export a JSON backup
|
||||
to move them to another device.
|
||||
{!storageAvailable && (
|
||||
<p className="inline-warning" role="status">
|
||||
Browser storage is unavailable. Changes will last only until this page
|
||||
closes.
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="preferences-actions">
|
||||
<button type="button" onClick={exportPreferences}>
|
||||
Export JSON
|
||||
</button>
|
||||
<button type="button" onClick={() => fileInput.current?.click()}>
|
||||
Import JSON
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
className="visually-hidden"
|
||||
type="file"
|
||||
tabIndex={-1}
|
||||
accept="application/json,.json"
|
||||
onChange={importPreferences}
|
||||
/>
|
||||
<button
|
||||
className="danger-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({ ...defaultPreferences });
|
||||
setMessage('Preferences reset.');
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
<fieldset className="theme-choice">
|
||||
<legend>Appearance</legend>
|
||||
<div>
|
||||
{(['system', 'light', 'dark'] as const).map((theme) => (
|
||||
<button
|
||||
key={theme}
|
||||
type="button"
|
||||
aria-pressed={preferences.theme === theme}
|
||||
className={preferences.theme === theme ? 'is-selected' : ''}
|
||||
onClick={() => setTheme(theme)}
|
||||
>
|
||||
{theme.slice(0, 1).toLocaleUpperCase() + theme.slice(1)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="action-message" role="status" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
<div className="preference-summary">
|
||||
<div>
|
||||
<strong>{preferences.pinned.length}</strong>
|
||||
<span>Pinned</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{preferences.hidden.length}</strong>
|
||||
<span>Hidden</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{preferences.order.length}</strong>
|
||||
<span>Ordered</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="preferences-copy">
|
||||
The portal stores pins, visibility, order, and appearance in this
|
||||
browser and does not transmit them. Trusted code in another Toolbox app
|
||||
on this site can access same-origin storage. Export a JSON backup to
|
||||
move them to another device.
|
||||
</p>
|
||||
|
||||
<div className="preferences-actions">
|
||||
<button type="button" onClick={exportPreferences}>
|
||||
Export JSON
|
||||
</button>
|
||||
<button type="button" onClick={() => fileInput.current?.click()}>
|
||||
Import JSON
|
||||
</button>
|
||||
<input
|
||||
ref={fileInput}
|
||||
className="visually-hidden"
|
||||
type="file"
|
||||
tabIndex={-1}
|
||||
accept="application/json,.json"
|
||||
onChange={importPreferences}
|
||||
/>
|
||||
<button
|
||||
className="danger-button"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onChange({ ...defaultPreferences });
|
||||
setMessage('Preferences reset.');
|
||||
}}
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
</div>
|
||||
<p className="action-message" role="status" aria-live="polite">
|
||||
{message}
|
||||
</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
'a[href]',
|
||||
'button:not([disabled])',
|
||||
'input:not([disabled]):not([type="hidden"]):not([tabindex="-1"])',
|
||||
'select:not([disabled])',
|
||||
'textarea:not([disabled])',
|
||||
'[tabindex]:not([tabindex="-1"])',
|
||||
].join(',');
|
||||
|
||||
Reference in New Issue
Block a user