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
141 lines
5.0 KiB
TypeScript
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>
|
|
);
|
|
}
|