dorfteich/apps/web/src/pages/MovePageDialog.tsx
Claude Fable 5 1eca7c334c #162: Fokus-Management für Dialoge und Such-Palette
Gemeinsamer useModalFocus-Hook: Initialfokus in den Dialog, Tab/Shift-Tab
zyklisch gefangen, Fokus-Rückgabe an den Auslöser (bzw. returnFocusRef,
wenn der öffnende Menüpunkt mit dem Menü unmountet). Dialoge tragen jetzt
aria-labelledby auf ihre Überschrift und tabindex=-1 als Fokus-Fallback.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 13:52:59 +02:00

141 lines
5.0 KiB
TypeScript

import type { PageListItemView, PondView, TreeNode } from '@dorfteich/shared';
import { buildTree, collectSubtreeIds } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
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
* tree. The page's own subtree is disabled (a cycle), the top level is the
* first option. Works in any sort mode — the accessible counterpart to the
* sidebar's drag-onto-a-page reparenting. Depth/cycle refusals from the
* server surface as a translated error inside the dialog.
*/
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],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
});
const pages = useQuery({
queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort],
queryFn: () => apiGet<PageListItemView[]>(`/ponds/${pond.data!.id}/pages`),
enabled: Boolean(pond.data),
});
const list = pages.data ?? [];
const page = list.find((p) => p.id === pageId);
const [target, setTarget] = useState<string | null | undefined>(undefined);
const selected = target === undefined ? (page?.parentId ?? null) : target;
const blocked = collectSubtreeIds(list, pageId);
/** Depth-first options with their level, for the indented picker. */
const options: { page: PageListItemView; depth: number }[] = [];
const walk = (nodes: TreeNode<PageListItemView>[], depth: number): void => {
for (const node of nodes) {
options.push({ page: node, depth });
walk(node.children, depth + 1);
}
};
walk(buildTree(list), 0);
async function move(): Promise<void> {
if (!page || selected === page.parentId) {
onClose();
return;
}
setError(null);
setBusy(true);
try {
// Append at the end of the new sibling group; with no sibling to key
// after, fall back to the pond's last page so the fresh key is unique.
const siblings = list.filter((p) => p.parentId === selected && p.id !== pageId);
const fallback = [...list].reverse().find((p) => p.id !== pageId);
const afterId = siblings.at(-1)?.id ?? fallback?.id ?? null;
await apiPatch(`/pages/${pageId}/position`, { afterId, beforeId: null, parentId: selected });
await queryClient.invalidateQueries({ queryKey: ['pages', pond.data!.id] });
onClose();
} catch (err) {
setError(err);
setBusy(false);
}
}
return (
<div className="modal-overlay">
<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>
<label className="move-dialog__option">
<input
type="radio"
name="move-target"
checked={selected === null}
onChange={() => setTarget(null)}
/>
<span>{t('page.moveRoot')}</span>
</label>
</li>
{options.map(({ page: option, depth }) => (
<li key={option.id} style={{ paddingInlineStart: `${depth * 1}rem` }}>
<label className="move-dialog__option">
<input
type="radio"
name="move-target"
disabled={blocked.has(option.id)}
checked={selected === option.id}
onChange={() => setTarget(option.id)}
/>
<span>{option.title}</span>
</label>
</li>
))}
</ul>
<div className="modal__actions">
<button type="button" className="button" disabled={busy} onClick={() => void move()}>
{t('page.moveAction')}
</button>
<button type="button" className="linklike" onClick={onClose}>
{t('page.moveCancel')}
</button>
</div>
</div>
</div>
);
}