@ -9,7 +9,7 @@ import type {
|
||||
} from '@dorfteich/shared';
|
||||
import { buildTree, labelDepth } from '@dorfteich/shared';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useId, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
@ -17,6 +17,7 @@ import { FormError } from '../components/forms';
|
||||
import { labelsKey, usePondLabels } from '../labels/use-pond-labels';
|
||||
import { ApiError, apiGet, apiPost, apiUploadFile } from '../lib/api';
|
||||
import { useDismissable } from '../lib/use-dismissable';
|
||||
import { useModalFocus } from '../lib/use-modal-focus';
|
||||
|
||||
const POLL_INTERVAL_MS = 1000;
|
||||
// Vault jobs process many notes and files — budget well past a single
|
||||
@ -83,6 +84,8 @@ function VaultImportDialog({
|
||||
const [frontmatter, setFrontmatter] = useState<VaultFrontmatterMode>('strip');
|
||||
const running = phase.kind === 'running';
|
||||
useDismissable(dialogRef, !running, onClose);
|
||||
useModalFocus(dialogRef);
|
||||
const titleId = useId();
|
||||
|
||||
const pond = useQuery({
|
||||
queryKey: ['pond', pondSlug],
|
||||
@ -182,8 +185,17 @@ function VaultImportDialog({
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal vault-import-dialog" role="dialog" aria-modal="true" ref={dialogRef}>
|
||||
<h2 className="modal__title">{t('vault.title')}</h2>
|
||||
<div
|
||||
className="modal vault-import-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
ref={dialogRef}
|
||||
>
|
||||
<h2 className="modal__title" id={titleId}>
|
||||
{t('vault.title')}
|
||||
</h2>
|
||||
<FormError error={error} />
|
||||
|
||||
{phase.kind === 'done' ? (
|
||||
|
||||
69
apps/web/src/lib/use-modal-focus.ts
Normal file
69
apps/web/src/lib/use-modal-focus.ts
Normal file
@ -0,0 +1,69 @@
|
||||
import { useEffect, type RefObject } from 'react';
|
||||
|
||||
const FOCUSABLE =
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), ' +
|
||||
'textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
|
||||
|
||||
function visibleFocusables(container: HTMLElement): HTMLElement[] {
|
||||
return [...container.querySelectorAll<HTMLElement>(FOCUSABLE)].filter(
|
||||
(el) => el.getClientRects().length > 0,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Focus management for modal dialogs (issue #162, WCAG 2.4.3): moves focus
|
||||
* into the dialog on mount (unless an autofocused child already holds it),
|
||||
* keeps Tab/Shift+Tab cycling inside, and returns focus on unmount — to
|
||||
* `restoreRef` if given (an element, or a container whose first focusable
|
||||
* child is used; needed when the opening control unmounts with its menu),
|
||||
* otherwise to the element focused when the dialog appeared.
|
||||
*/
|
||||
export function useModalFocus(
|
||||
ref: RefObject<HTMLElement | null>,
|
||||
restoreRef?: RefObject<HTMLElement | null>,
|
||||
): void {
|
||||
useEffect(() => {
|
||||
const dialog = ref.current;
|
||||
if (!dialog) return undefined;
|
||||
const opener = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
|
||||
if (!dialog.contains(document.activeElement)) {
|
||||
(visibleFocusables(dialog)[0] ?? dialog).focus();
|
||||
}
|
||||
|
||||
function onKeyDown(event: KeyboardEvent): void {
|
||||
if (event.key !== 'Tab') return;
|
||||
const focusables = visibleFocusables(dialog!);
|
||||
if (focusables.length === 0) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
const first = focusables[0]!;
|
||||
const last = focusables[focusables.length - 1]!;
|
||||
const active = document.activeElement;
|
||||
const inside = dialog!.contains(active);
|
||||
if (event.shiftKey && (active === first || !inside)) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && (active === last || !inside)) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', onKeyDown, true);
|
||||
return () => {
|
||||
document.removeEventListener('keydown', onKeyDown, true);
|
||||
const target = restoreRef?.current;
|
||||
const restore =
|
||||
target && target.isConnected
|
||||
? target.matches(FOCUSABLE)
|
||||
? target
|
||||
: visibleFocusables(target)[0]
|
||||
: opener && opener.isConnected
|
||||
? opener
|
||||
: undefined;
|
||||
restore?.focus();
|
||||
};
|
||||
}, [ref, restoreRef]);
|
||||
}
|
||||
@ -1,9 +1,10 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useId, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FormError } from '../components/forms';
|
||||
import { apiDelete } from '../lib/api';
|
||||
import { useDismissable } from '../lib/use-dismissable';
|
||||
import { useModalFocus } from '../lib/use-modal-focus';
|
||||
|
||||
/**
|
||||
* The per-case delete decision for a page with subpages (issue #107/#109):
|
||||
@ -17,18 +18,23 @@ export function DeletePageDialog({
|
||||
childCount,
|
||||
onDeleted,
|
||||
onClose,
|
||||
returnFocusRef,
|
||||
}: {
|
||||
pageId: string;
|
||||
title: string;
|
||||
childCount: number;
|
||||
onDeleted: () => void;
|
||||
onClose: () => void;
|
||||
/** Where focus goes after closing when the opening control unmounted. */
|
||||
returnFocusRef?: React.RefObject<HTMLElement | null>;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('editor');
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
useDismissable(dialogRef, true, onClose);
|
||||
useModalFocus(dialogRef, returnFocusRef);
|
||||
|
||||
async function remove(mode: 'promote' | 'subtree'): Promise<void> {
|
||||
setError(null);
|
||||
@ -44,8 +50,17 @@ export function DeletePageDialog({
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal delete-page-dialog" role="dialog" aria-modal="true" ref={dialogRef}>
|
||||
<h2 className="modal__title">{t('page.deleteChildrenTitle')}</h2>
|
||||
<div
|
||||
className="modal delete-page-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
ref={dialogRef}
|
||||
>
|
||||
<h2 className="modal__title" id={titleId}>
|
||||
{t('page.deleteChildrenTitle')}
|
||||
</h2>
|
||||
<FormError error={error} />
|
||||
<p>{t('page.deleteChildrenHint', { title, count: childCount })}</p>
|
||||
<div className="modal__actions modal__actions--stacked">
|
||||
|
||||
@ -1,12 +1,13 @@
|
||||
import type { PageListItemView, PondView, TreeNode } from '@dorfteich/shared';
|
||||
import { buildTree, collectSubtreeIds } from '@dorfteich/shared';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useId, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { FormError } from '../components/forms';
|
||||
import { apiGet, apiPatch } from '../lib/api';
|
||||
import { useDismissable } from '../lib/use-dismissable';
|
||||
import { useModalFocus } from '../lib/use-modal-focus';
|
||||
|
||||
/**
|
||||
* "Move to…" dialog (issue #109): an indented parent picker over the page
|
||||
@ -19,17 +20,22 @@ export function MovePageDialog({
|
||||
pageId,
|
||||
pondSlug,
|
||||
onClose,
|
||||
returnFocusRef,
|
||||
}: {
|
||||
pageId: string;
|
||||
pondSlug: string;
|
||||
onClose: () => void;
|
||||
/** Where focus goes after closing when the opening control unmounted. */
|
||||
returnFocusRef?: React.RefObject<HTMLElement | null>;
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('editor');
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState<unknown>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
const titleId = useId();
|
||||
useDismissable(dialogRef, true, onClose);
|
||||
useModalFocus(dialogRef, returnFocusRef);
|
||||
|
||||
const pond = useQuery({
|
||||
queryKey: ['pond', pondSlug],
|
||||
@ -81,8 +87,17 @@ export function MovePageDialog({
|
||||
|
||||
return (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal move-dialog" role="dialog" aria-modal="true" ref={dialogRef}>
|
||||
<h2 className="modal__title">{t('page.moveTitle')}</h2>
|
||||
<div
|
||||
className="modal move-dialog"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby={titleId}
|
||||
tabIndex={-1}
|
||||
ref={dialogRef}
|
||||
>
|
||||
<h2 className="modal__title" id={titleId}>
|
||||
{t('page.moveTitle')}
|
||||
</h2>
|
||||
<FormError error={error} />
|
||||
<ul className="move-dialog__options">
|
||||
<li>
|
||||
|
||||
@ -284,13 +284,19 @@ function PageOverflowMenu({
|
||||
</div>
|
||||
)}
|
||||
{moving && (
|
||||
<MovePageDialog pageId={pageId} pondSlug={pondSlug} onClose={() => setMoving(false)} />
|
||||
<MovePageDialog
|
||||
pageId={pageId}
|
||||
pondSlug={pondSlug}
|
||||
onClose={() => setMoving(false)}
|
||||
returnFocusRef={menuRef}
|
||||
/>
|
||||
)}
|
||||
{deleting && (
|
||||
<DeletePageDialog
|
||||
pageId={pageId}
|
||||
title={deleting.title}
|
||||
childCount={deleting.childCount}
|
||||
returnFocusRef={menuRef}
|
||||
onDeleted={() => {
|
||||
setDeleting(null);
|
||||
void queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||
|
||||
@ -8,6 +8,7 @@ import { LabelChips } from '../labels/LabelChips';
|
||||
import { usePondLabels } from '../labels/use-pond-labels';
|
||||
import { useCurrentPondRoute } from '../layout/use-pond-route';
|
||||
import { apiGet } from '../lib/api';
|
||||
import { useModalFocus } from '../lib/use-modal-focus';
|
||||
import { HighlightedSnippet } from './highlight';
|
||||
|
||||
const RECENT_KEY = 'dorfteich.recentSearches';
|
||||
@ -45,6 +46,8 @@ export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.E
|
||||
const navigate = useNavigate();
|
||||
const { pondSlug } = useCurrentPondRoute();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
useModalFocus(overlayRef);
|
||||
|
||||
const pond = useQuery({
|
||||
queryKey: ['pond', pondSlug],
|
||||
@ -111,7 +114,13 @@ export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.E
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="search-overlay" role="dialog" aria-modal="true" aria-label={t('title')}>
|
||||
<div
|
||||
className="search-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('title')}
|
||||
ref={overlayRef}
|
||||
>
|
||||
<div className="search-backdrop" onClick={onClose} aria-hidden />
|
||||
<div className="search-palette" onKeyDown={onKeyDown}>
|
||||
<input
|
||||
|
||||
Loading…
Reference in New Issue
Block a user