@ -9,6 +9,7 @@ import { FormError } from '../components/forms';
|
|||||||
import { usePondLabels } from '../labels/use-pond-labels';
|
import { usePondLabels } from '../labels/use-pond-labels';
|
||||||
import { apiGet, apiPost } from '../lib/api';
|
import { apiGet, apiPost } from '../lib/api';
|
||||||
import { usePersistentState } from '../lib/use-persistent-state';
|
import { usePersistentState } from '../lib/use-persistent-state';
|
||||||
|
import { useDocumentTitle } from '../lib/use-document-title';
|
||||||
import {
|
import {
|
||||||
ForceGraph,
|
ForceGraph,
|
||||||
GRAPH_SETTINGS_DEFAULTS,
|
GRAPH_SETTINGS_DEFAULTS,
|
||||||
@ -183,6 +184,7 @@ export function PondGraphPage(): React.JSX.Element {
|
|||||||
queryKey: ['pond', pondSlug],
|
queryKey: ['pond', pondSlug],
|
||||||
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
|
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
|
||||||
});
|
});
|
||||||
|
useDocumentTitle(t('title'), pond.data?.name);
|
||||||
const graph = useQuery({
|
const graph = useQuery({
|
||||||
queryKey: ['pond-links', pond.data?.id],
|
queryKey: ['pond-links', pond.data?.id],
|
||||||
queryFn: () => apiGet<PondGraphView>(`/ponds/${pond.data!.id}/links`),
|
queryFn: () => apiGet<PondGraphView>(`/ponds/${pond.data!.id}/links`),
|
||||||
|
|||||||
@ -133,4 +133,13 @@ void i18n
|
|||||||
detection: { order: ['querystring', 'navigator'], lookupQuerystring: 'lng', caches: [] },
|
detection: { order: ['querystring', 'navigator'], lookupQuerystring: 'lng', caches: [] },
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Mirror the active language onto <html lang> (issue #163, WCAG 3.1.1):
|
||||||
|
// index.html ships a static fallback, and screen readers pick their voice
|
||||||
|
// from this attribute. Fires on init and on every later switch (e.g. the
|
||||||
|
// user-profile locale applied in auth-context).
|
||||||
|
i18n.on('languageChanged', (lng) => {
|
||||||
|
document.documentElement.lang = lng;
|
||||||
|
});
|
||||||
|
if (i18n.resolvedLanguage) document.documentElement.lang = i18n.resolvedLanguage;
|
||||||
|
|
||||||
export default i18n;
|
export default i18n;
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import type {
|
|||||||
} from '@dorfteich/shared';
|
} from '@dorfteich/shared';
|
||||||
import { buildTree, labelDepth } from '@dorfteich/shared';
|
import { buildTree, labelDepth } from '@dorfteich/shared';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useRef, useState } from 'react';
|
import { useId, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
@ -17,6 +17,7 @@ import { FormError } from '../components/forms';
|
|||||||
import { labelsKey, usePondLabels } from '../labels/use-pond-labels';
|
import { labelsKey, usePondLabels } from '../labels/use-pond-labels';
|
||||||
import { ApiError, apiGet, apiPost, apiUploadFile } from '../lib/api';
|
import { ApiError, apiGet, apiPost, apiUploadFile } from '../lib/api';
|
||||||
import { useDismissable } from '../lib/use-dismissable';
|
import { useDismissable } from '../lib/use-dismissable';
|
||||||
|
import { useModalFocus } from '../lib/use-modal-focus';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 1000;
|
const POLL_INTERVAL_MS = 1000;
|
||||||
// Vault jobs process many notes and files — budget well past a single
|
// 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 [frontmatter, setFrontmatter] = useState<VaultFrontmatterMode>('strip');
|
||||||
const running = phase.kind === 'running';
|
const running = phase.kind === 'running';
|
||||||
useDismissable(dialogRef, !running, onClose);
|
useDismissable(dialogRef, !running, onClose);
|
||||||
|
useModalFocus(dialogRef);
|
||||||
|
const titleId = useId();
|
||||||
|
|
||||||
const pond = useQuery({
|
const pond = useQuery({
|
||||||
queryKey: ['pond', pondSlug],
|
queryKey: ['pond', pondSlug],
|
||||||
@ -182,8 +185,17 @@ function VaultImportDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay">
|
||||||
<div className="modal vault-import-dialog" role="dialog" aria-modal="true" ref={dialogRef}>
|
<div
|
||||||
<h2 className="modal__title">{t('vault.title')}</h2>
|
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} />
|
<FormError error={error} />
|
||||||
|
|
||||||
{phase.kind === 'done' ? (
|
{phase.kind === 'done' ? (
|
||||||
|
|||||||
22
apps/web/src/lib/use-document-title.ts
Normal file
22
apps/web/src/lib/use-document-title.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
|
||||||
|
const APP_NAME = 'Dorfteich';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route-specific document title (issue #163, WCAG 2.4.2): joins the given
|
||||||
|
* parts with the app name ("Page — Pond — Dorfteich"). Empty/undefined
|
||||||
|
* parts are skipped, so callers can pass still-loading data directly.
|
||||||
|
* Falls back to the bare app name on unmount.
|
||||||
|
*/
|
||||||
|
export function useDocumentTitle(...parts: (string | null | undefined)[]): void {
|
||||||
|
const joined = [...parts.filter(Boolean), APP_NAME].join(' — ');
|
||||||
|
useEffect(() => {
|
||||||
|
document.title = joined;
|
||||||
|
}, [joined]);
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
document.title = APP_NAME;
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
}
|
||||||
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]);
|
||||||
|
}
|
||||||
@ -12,6 +12,7 @@ import { PluginManager } from './PluginManager';
|
|||||||
import { QuotaManager } from './QuotaManager';
|
import { QuotaManager } from './QuotaManager';
|
||||||
import { UserManager } from './UserManager';
|
import { UserManager } from './UserManager';
|
||||||
|
|
||||||
|
import { useDocumentTitle } from '../lib/use-document-title';
|
||||||
interface InstanceSettings {
|
interface InstanceSettings {
|
||||||
'auth.registrationMode': 'open' | 'closed';
|
'auth.registrationMode': 'open' | 'closed';
|
||||||
'instance.name': string;
|
'instance.name': string;
|
||||||
@ -32,6 +33,7 @@ interface InstanceSettings {
|
|||||||
|
|
||||||
export function AdminSettingsPage(): React.JSX.Element {
|
export function AdminSettingsPage(): React.JSX.Element {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
useDocumentTitle(t('settings:admin.title'));
|
||||||
const { t: tQuotas } = useTranslation('quotas');
|
const { t: tQuotas } = useTranslation('quotas');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [error, setError] = useState<unknown>(null);
|
const [error, setError] = useState<unknown>(null);
|
||||||
|
|||||||
@ -16,6 +16,7 @@ import { formatBytes } from '../files/file-format';
|
|||||||
import { apiGet, apiPost } from '../lib/api';
|
import { apiGet, apiPost } from '../lib/api';
|
||||||
import { BackupSection } from './AdminBackupSection';
|
import { BackupSection } from './AdminBackupSection';
|
||||||
|
|
||||||
|
import { useDocumentTitle } from '../lib/use-document-title';
|
||||||
/**
|
/**
|
||||||
* Site-Admin "System" panel (issue #86): maintenance jobs with a manual
|
* Site-Admin "System" panel (issue #86): maintenance jobs with a manual
|
||||||
* trigger, the backup card fed by the sidecar's status.json, the audit-log
|
* trigger, the backup card fed by the sidecar's status.json, the audit-log
|
||||||
@ -24,6 +25,7 @@ import { BackupSection } from './AdminBackupSection';
|
|||||||
*/
|
*/
|
||||||
export function AdminSystemPage(): React.JSX.Element {
|
export function AdminSystemPage(): React.JSX.Element {
|
||||||
const { t } = useTranslation('system');
|
const { t } = useTranslation('system');
|
||||||
|
useDocumentTitle(t('title'));
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1>{t('title')}</h1>
|
<h1>{t('title')}</h1>
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { useRef, useState } from 'react';
|
import { useId, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { FormError } from '../components/forms';
|
import { FormError } from '../components/forms';
|
||||||
import { apiDelete } from '../lib/api';
|
import { apiDelete } from '../lib/api';
|
||||||
import { useDismissable } from '../lib/use-dismissable';
|
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):
|
* The per-case delete decision for a page with subpages (issue #107/#109):
|
||||||
@ -17,18 +18,23 @@ export function DeletePageDialog({
|
|||||||
childCount,
|
childCount,
|
||||||
onDeleted,
|
onDeleted,
|
||||||
onClose,
|
onClose,
|
||||||
|
returnFocusRef,
|
||||||
}: {
|
}: {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
title: string;
|
title: string;
|
||||||
childCount: number;
|
childCount: number;
|
||||||
onDeleted: () => void;
|
onDeleted: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** Where focus goes after closing when the opening control unmounted. */
|
||||||
|
returnFocusRef?: React.RefObject<HTMLElement | null>;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { t } = useTranslation('editor');
|
const { t } = useTranslation('editor');
|
||||||
const [error, setError] = useState<unknown>(null);
|
const [error, setError] = useState<unknown>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
useDismissable(dialogRef, true, onClose);
|
useDismissable(dialogRef, true, onClose);
|
||||||
|
useModalFocus(dialogRef, returnFocusRef);
|
||||||
|
|
||||||
async function remove(mode: 'promote' | 'subtree'): Promise<void> {
|
async function remove(mode: 'promote' | 'subtree'): Promise<void> {
|
||||||
setError(null);
|
setError(null);
|
||||||
@ -44,8 +50,17 @@ export function DeletePageDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay">
|
||||||
<div className="modal delete-page-dialog" role="dialog" aria-modal="true" ref={dialogRef}>
|
<div
|
||||||
<h2 className="modal__title">{t('page.deleteChildrenTitle')}</h2>
|
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} />
|
<FormError error={error} />
|
||||||
<p>{t('page.deleteChildrenHint', { title, count: childCount })}</p>
|
<p>{t('page.deleteChildrenHint', { title, count: childCount })}</p>
|
||||||
<div className="modal__actions modal__actions--stacked">
|
<div className="modal__actions modal__actions--stacked">
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { FONT_CATALOG, fontStack } from '@dorfteich/shared';
|
import { FONT_CATALOG, fontStack } from '@dorfteich/shared';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
|
import { useDocumentTitle } from '../lib/use-document-title';
|
||||||
/**
|
/**
|
||||||
* Font catalog attribution page (issue #66, ADR 0016): lists every self-hosted
|
* Font catalog attribution page (issue #66, ADR 0016): lists every self-hosted
|
||||||
* family with its license, rendered in the font itself. The self-hosting is the
|
* family with its license, rendered in the font itself. The self-hosting is the
|
||||||
@ -8,6 +9,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
*/
|
*/
|
||||||
export function FontCatalogPage(): React.JSX.Element {
|
export function FontCatalogPage(): React.JSX.Element {
|
||||||
const { t } = useTranslation('font');
|
const { t } = useTranslation('font');
|
||||||
|
useDocumentTitle(t('catalog.title'));
|
||||||
return (
|
return (
|
||||||
<div className="font-catalog">
|
<div className="font-catalog">
|
||||||
<h1>{t('catalog.title')}</h1>
|
<h1>{t('catalog.title')}</h1>
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { apiGet, fetchHealth } from '../lib/api';
|
import { apiGet, fetchHealth } from '../lib/api';
|
||||||
|
|
||||||
|
import { useDocumentTitle } from '../lib/use-document-title';
|
||||||
/**
|
/**
|
||||||
* Public landing page (`/`). When a Site Admin has written landing content
|
* Public landing page (`/`). When a Site Admin has written landing content
|
||||||
* (Admin → Settings → Landing page), it renders that — server-sanitized
|
* (Admin → Settings → Landing page), it renders that — server-sanitized
|
||||||
@ -12,6 +13,7 @@ import { apiGet, fetchHealth } from '../lib/api';
|
|||||||
*/
|
*/
|
||||||
export function HomePage(): React.JSX.Element {
|
export function HomePage(): React.JSX.Element {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
useDocumentTitle(t('home.title'));
|
||||||
const home = useQuery({
|
const home = useQuery({
|
||||||
queryKey: ['home-content'],
|
queryKey: ['home-content'],
|
||||||
queryFn: () => apiGet<HomeContentView>('/home/content'),
|
queryFn: () => apiGet<HomeContentView>('/home/content'),
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { useAuth } from '../auth/auth-context';
|
|||||||
import { apiGet } from '../lib/api';
|
import { apiGet } from '../lib/api';
|
||||||
import { NotFoundPage } from './NotFoundPage';
|
import { NotFoundPage } from './NotFoundPage';
|
||||||
|
|
||||||
|
import { useDocumentTitle } from '../lib/use-document-title';
|
||||||
/**
|
/**
|
||||||
* Public legal page (issue #82): imprint or privacy policy, reachable
|
* Public legal page (issue #82): imprint or privacy policy, reachable
|
||||||
* without a session. The HTML comes from the api's sanitizing Markdown
|
* without a session. The HTML comes from the api's sanitizing Markdown
|
||||||
@ -18,6 +19,7 @@ export function LegalPage(): React.JSX.Element {
|
|||||||
const { t } = useTranslation('legal');
|
const { t } = useTranslation('legal');
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const { kind = '' } = useParams<{ kind: string }>();
|
const { kind = '' } = useParams<{ kind: string }>();
|
||||||
|
useDocumentTitle(t(`title.${kind}`));
|
||||||
|
|
||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['legal', kind],
|
queryKey: ['legal', kind],
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
|
|
||||||
import { apiGet } from '../lib/api';
|
import { apiGet } from '../lib/api';
|
||||||
|
|
||||||
|
import { useDocumentTitle } from '../lib/use-document-title';
|
||||||
const POLL_INTERVAL_MS = 3000;
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -15,6 +16,7 @@ const POLL_INTERVAL_MS = 3000;
|
|||||||
*/
|
*/
|
||||||
export function MaintenancePage(): React.JSX.Element {
|
export function MaintenancePage(): React.JSX.Element {
|
||||||
const { t } = useTranslation('system');
|
const { t } = useTranslation('system');
|
||||||
|
useDocumentTitle(t('maintenance.title'));
|
||||||
const [status, setStatus] = useState<RestoreStatusResponse | null>(null);
|
const [status, setStatus] = useState<RestoreStatusResponse | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@ -1,12 +1,13 @@
|
|||||||
import type { PageListItemView, PondView, TreeNode } from '@dorfteich/shared';
|
import type { PageListItemView, PondView, TreeNode } from '@dorfteich/shared';
|
||||||
import { buildTree, collectSubtreeIds } from '@dorfteich/shared';
|
import { buildTree, collectSubtreeIds } from '@dorfteich/shared';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useRef, useState } from 'react';
|
import { useId, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
import { FormError } from '../components/forms';
|
import { FormError } from '../components/forms';
|
||||||
import { apiGet, apiPatch } from '../lib/api';
|
import { apiGet, apiPatch } from '../lib/api';
|
||||||
import { useDismissable } from '../lib/use-dismissable';
|
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
|
* "Move to…" dialog (issue #109): an indented parent picker over the page
|
||||||
@ -19,17 +20,22 @@ export function MovePageDialog({
|
|||||||
pageId,
|
pageId,
|
||||||
pondSlug,
|
pondSlug,
|
||||||
onClose,
|
onClose,
|
||||||
|
returnFocusRef,
|
||||||
}: {
|
}: {
|
||||||
pageId: string;
|
pageId: string;
|
||||||
pondSlug: string;
|
pondSlug: string;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
|
/** Where focus goes after closing when the opening control unmounted. */
|
||||||
|
returnFocusRef?: React.RefObject<HTMLElement | null>;
|
||||||
}): React.JSX.Element {
|
}): React.JSX.Element {
|
||||||
const { t } = useTranslation('editor');
|
const { t } = useTranslation('editor');
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [error, setError] = useState<unknown>(null);
|
const [error, setError] = useState<unknown>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const dialogRef = useRef<HTMLDivElement>(null);
|
const dialogRef = useRef<HTMLDivElement>(null);
|
||||||
|
const titleId = useId();
|
||||||
useDismissable(dialogRef, true, onClose);
|
useDismissable(dialogRef, true, onClose);
|
||||||
|
useModalFocus(dialogRef, returnFocusRef);
|
||||||
|
|
||||||
const pond = useQuery({
|
const pond = useQuery({
|
||||||
queryKey: ['pond', pondSlug],
|
queryKey: ['pond', pondSlug],
|
||||||
@ -81,8 +87,17 @@ export function MovePageDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="modal-overlay">
|
<div className="modal-overlay">
|
||||||
<div className="modal move-dialog" role="dialog" aria-modal="true" ref={dialogRef}>
|
<div
|
||||||
<h2 className="modal__title">{t('page.moveTitle')}</h2>
|
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} />
|
<FormError error={error} />
|
||||||
<ul className="move-dialog__options">
|
<ul className="move-dialog__options">
|
||||||
<li>
|
<li>
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
|
|
||||||
|
import { useDocumentTitle } from '../lib/use-document-title';
|
||||||
export function NotFoundPage(): React.JSX.Element {
|
export function NotFoundPage(): React.JSX.Element {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
useDocumentTitle(t('notFound.title'));
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1>{t('notFound.title')}</h1>
|
<h1>{t('notFound.title')}</h1>
|
||||||
|
|||||||
@ -284,13 +284,19 @@ function PageOverflowMenu({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{moving && (
|
{moving && (
|
||||||
<MovePageDialog pageId={pageId} pondSlug={pondSlug} onClose={() => setMoving(false)} />
|
<MovePageDialog
|
||||||
|
pageId={pageId}
|
||||||
|
pondSlug={pondSlug}
|
||||||
|
onClose={() => setMoving(false)}
|
||||||
|
returnFocusRef={menuRef}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
{deleting && (
|
{deleting && (
|
||||||
<DeletePageDialog
|
<DeletePageDialog
|
||||||
pageId={pageId}
|
pageId={pageId}
|
||||||
title={deleting.title}
|
title={deleting.title}
|
||||||
childCount={deleting.childCount}
|
childCount={deleting.childCount}
|
||||||
|
returnFocusRef={menuRef}
|
||||||
onDeleted={() => {
|
onDeleted={() => {
|
||||||
setDeleting(null);
|
setDeleting(null);
|
||||||
void queryClient.invalidateQueries({ queryKey: ['pages'] });
|
void queryClient.invalidateQueries({ queryKey: ['pages'] });
|
||||||
|
|||||||
@ -41,6 +41,7 @@ import { recallPage, rememberPage } from '../offline/page-cache';
|
|||||||
import { PluginBlockContext } from '../editor/plugin-block-context';
|
import { PluginBlockContext } from '../editor/plugin-block-context';
|
||||||
import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel';
|
import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel';
|
||||||
import { SectionStyleSheets } from '../plugins/SectionStyleSheets';
|
import { SectionStyleSheets } from '../plugins/SectionStyleSheets';
|
||||||
|
import { useDocumentTitle } from '../lib/use-document-title';
|
||||||
import {
|
import {
|
||||||
pluginBlockOptions,
|
pluginBlockOptions,
|
||||||
sectionStyleOptions,
|
sectionStyleOptions,
|
||||||
@ -373,6 +374,7 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
queryFn: () => apiGet<PageStateView>(`/ponds/${pond.data!.id}/pages/${pageSlug}`),
|
queryFn: () => apiGet<PageStateView>(`/ponds/${pond.data!.id}/pages/${pageSlug}`),
|
||||||
enabled: Boolean(pond.data),
|
enabled: Boolean(pond.data),
|
||||||
});
|
});
|
||||||
|
useDocumentTitle(page.data?.title, pond.data?.name);
|
||||||
|
|
||||||
// Word count for the read-mode status line (#134). Derived from the same
|
// Word count for the read-mode status line (#134). Derived from the same
|
||||||
// Markdown export the history panel uses (shared query key), fetched only in
|
// Markdown export the history panel uses (shared query key), fetched only in
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { useNavigate, useParams } from 'react-router-dom';
|
|||||||
import { FormError } from '../components/forms';
|
import { FormError } from '../components/forms';
|
||||||
import { apiGet } from '../lib/api';
|
import { apiGet } from '../lib/api';
|
||||||
|
|
||||||
|
import { useDocumentTitle } from '../lib/use-document-title';
|
||||||
/**
|
/**
|
||||||
* Landing route for a pond without a page open yet (`/p/:pondSlug`, e.g.
|
* Landing route for a pond without a page open yet (`/p/:pondSlug`, e.g.
|
||||||
* from the pond switcher). Redirects to the first page per the pond's sort
|
* from the pond switcher). Redirects to the first page per the pond's sort
|
||||||
@ -22,6 +23,7 @@ export function PondHomePage(): React.JSX.Element {
|
|||||||
queryKey: ['pond', pondSlug],
|
queryKey: ['pond', pondSlug],
|
||||||
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
|
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
|
||||||
});
|
});
|
||||||
|
useDocumentTitle(pond.data?.name);
|
||||||
const pages = useQuery({
|
const pages = useQuery({
|
||||||
queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort],
|
queryKey: ['pages', pond.data?.id, pond.data?.settings.sidebarSort],
|
||||||
queryFn: () => apiGet<PageView[]>(`/ponds/${pond.data!.id}/pages`),
|
queryFn: () => apiGet<PageView[]>(`/ponds/${pond.data!.id}/pages`),
|
||||||
|
|||||||
@ -22,6 +22,7 @@ import { MemberManager } from '../members/MemberManager';
|
|||||||
import { DeletePondSection } from '../ponds/DeletePondSection';
|
import { DeletePondSection } from '../ponds/DeletePondSection';
|
||||||
import { PondPluginSettings } from '../plugins/PondPluginSettings';
|
import { PondPluginSettings } from '../plugins/PondPluginSettings';
|
||||||
|
|
||||||
|
import { useDocumentTitle } from '../lib/use-document-title';
|
||||||
/**
|
/**
|
||||||
* Pond settings (issues #44/#54). Hosts the 'Members' and 'Labels' sections;
|
* Pond settings (issues #44/#54). Hosts the 'Members' and 'Labels' sections;
|
||||||
* future pond-level configuration (fonts, etc.) joins it here. Member
|
* future pond-level configuration (fonts, etc.) joins it here. Member
|
||||||
@ -49,6 +50,7 @@ export function PondSettingsPage(): React.JSX.Element {
|
|||||||
queryKey: ['pond', pondSlug],
|
queryKey: ['pond', pondSlug],
|
||||||
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
|
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
|
||||||
});
|
});
|
||||||
|
useDocumentTitle(tCommon('settings:title'), pond.data?.name);
|
||||||
|
|
||||||
if (pond.error) return <FormError error={pond.error} />;
|
if (pond.error) return <FormError error={pond.error} />;
|
||||||
if (!pond.data) return <></>;
|
if (!pond.data) return <></>;
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useParams } from 'react-router-dom';
|
|||||||
|
|
||||||
import { PublicComments } from '../comments/CommentsSection';
|
import { PublicComments } from '../comments/CommentsSection';
|
||||||
import { ApiError, apiGet } from '../lib/api';
|
import { ApiError, apiGet } from '../lib/api';
|
||||||
|
import { useDocumentTitle } from '../lib/use-document-title';
|
||||||
import { countWords, htmlToText } from '../lib/word-count';
|
import { countWords, htmlToText } from '../lib/word-count';
|
||||||
import { NotFoundPage } from './NotFoundPage';
|
import { NotFoundPage } from './NotFoundPage';
|
||||||
import { PageStatusBar } from './PageStatusBar';
|
import { PageStatusBar } from './PageStatusBar';
|
||||||
@ -34,6 +35,7 @@ export function PublicPageView(): React.JSX.Element {
|
|||||||
enabled: Boolean(pondSlug && pageSlug),
|
enabled: Boolean(pondSlug && pageSlug),
|
||||||
retry: false,
|
retry: false,
|
||||||
});
|
});
|
||||||
|
useDocumentTitle(query.data?.title, query.data?.pondName);
|
||||||
|
|
||||||
// Word count for the status line (#134), derived from the server-rendered
|
// Word count for the status line (#134), derived from the server-rendered
|
||||||
// HTML — no editor bundle needed. Kept before the early returns so the hook
|
// HTML — no editor bundle needed. Kept before the early returns so the hook
|
||||||
|
|||||||
@ -14,6 +14,7 @@ import { ApiTokensSection } from '../api-tokens/ApiTokensSection';
|
|||||||
import { FeedTokensSection } from '../api-tokens/FeedTokensSection';
|
import { FeedTokensSection } from '../api-tokens/FeedTokensSection';
|
||||||
import { WatchesSection } from '../watches/WatchesSection';
|
import { WatchesSection } from '../watches/WatchesSection';
|
||||||
|
|
||||||
|
import { useDocumentTitle } from '../lib/use-document-title';
|
||||||
interface SessionView {
|
interface SessionView {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@ -24,6 +25,7 @@ interface SessionView {
|
|||||||
|
|
||||||
export function SettingsPage(): React.JSX.Element {
|
export function SettingsPage(): React.JSX.Element {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
useDocumentTitle(t('settings:title'));
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<h1>{t('settings:title')}</h1>
|
<h1>{t('settings:title')}</h1>
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { useParams } from 'react-router-dom';
|
|||||||
import { FormError } from '../components/forms';
|
import { FormError } from '../components/forms';
|
||||||
import { apiDelete, apiGet, apiPost } from '../lib/api';
|
import { apiDelete, apiGet, apiPost } from '../lib/api';
|
||||||
|
|
||||||
|
import { useDocumentTitle } from '../lib/use-document-title';
|
||||||
/** Pond trash view (issue #31): list, restore, and permanently delete —
|
/** Pond trash view (issue #31): list, restore, and permanently delete —
|
||||||
* single pages or a checkbox multi-selection at once (#128). */
|
* single pages or a checkbox multi-selection at once (#128). */
|
||||||
export function TrashPage(): React.JSX.Element {
|
export function TrashPage(): React.JSX.Element {
|
||||||
@ -21,6 +22,7 @@ export function TrashPage(): React.JSX.Element {
|
|||||||
queryKey: ['pond', pondSlug],
|
queryKey: ['pond', pondSlug],
|
||||||
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
|
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
|
||||||
});
|
});
|
||||||
|
useDocumentTitle(pond.data ? t('trash.title', { pond: pond.data.name }) : undefined);
|
||||||
const trash = useQuery({
|
const trash = useQuery({
|
||||||
queryKey: ['trash', pond.data?.id],
|
queryKey: ['trash', pond.data?.id],
|
||||||
queryFn: () => apiGet<PageView[]>(`/ponds/${pond.data!.id}/trash`),
|
queryFn: () => apiGet<PageView[]>(`/ponds/${pond.data!.id}/trash`),
|
||||||
|
|||||||
@ -8,8 +8,10 @@ import { Link } from 'react-router-dom';
|
|||||||
import { Field, FormError, FormSuccess } from '../../components/forms';
|
import { Field, FormError, FormSuccess } from '../../components/forms';
|
||||||
import { apiPost } from '../../lib/api';
|
import { apiPost } from '../../lib/api';
|
||||||
|
|
||||||
|
import { useDocumentTitle } from '../../lib/use-document-title';
|
||||||
export function ForgotPasswordPage(): React.JSX.Element {
|
export function ForgotPasswordPage(): React.JSX.Element {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
useDocumentTitle(t('auth:forgot.title'));
|
||||||
const [error, setError] = useState<unknown>(null);
|
const [error, setError] = useState<unknown>(null);
|
||||||
const [sent, setSent] = useState(false);
|
const [sent, setSent] = useState(false);
|
||||||
|
|
||||||
|
|||||||
@ -9,8 +9,10 @@ import { useAuth } from '../../auth/auth-context';
|
|||||||
import { Field, FormError } from '../../components/forms';
|
import { Field, FormError } from '../../components/forms';
|
||||||
import { ApiError, apiPost } from '../../lib/api';
|
import { ApiError, apiPost } from '../../lib/api';
|
||||||
|
|
||||||
|
import { useDocumentTitle } from '../../lib/use-document-title';
|
||||||
export function LoginPage(): React.JSX.Element {
|
export function LoginPage(): React.JSX.Element {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
useDocumentTitle(t('auth:login.title'));
|
||||||
const { refresh } = useAuth();
|
const { refresh } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
|
|||||||
@ -8,8 +8,10 @@ import { Link, useSearchParams } from 'react-router-dom';
|
|||||||
import { Field, FormError } from '../../components/forms';
|
import { Field, FormError } from '../../components/forms';
|
||||||
import { apiPost } from '../../lib/api';
|
import { apiPost } from '../../lib/api';
|
||||||
|
|
||||||
|
import { useDocumentTitle } from '../../lib/use-document-title';
|
||||||
export function ResetPasswordPage(): React.JSX.Element {
|
export function ResetPasswordPage(): React.JSX.Element {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
useDocumentTitle(t('auth:reset.title'));
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const [error, setError] = useState<unknown>(null);
|
const [error, setError] = useState<unknown>(null);
|
||||||
const [done, setDone] = useState(false);
|
const [done, setDone] = useState(false);
|
||||||
|
|||||||
@ -9,8 +9,10 @@ import { Link } from 'react-router-dom';
|
|||||||
import { Field, FormError, applyFieldErrors } from '../../components/forms';
|
import { Field, FormError, applyFieldErrors } from '../../components/forms';
|
||||||
import { apiGet, apiPost } from '../../lib/api';
|
import { apiGet, apiPost } from '../../lib/api';
|
||||||
|
|
||||||
|
import { useDocumentTitle } from '../../lib/use-document-title';
|
||||||
export function SignupPage(): React.JSX.Element {
|
export function SignupPage(): React.JSX.Element {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
useDocumentTitle(t('auth:signup.title'));
|
||||||
const [error, setError] = useState<unknown>(null);
|
const [error, setError] = useState<unknown>(null);
|
||||||
const [registered, setRegistered] = useState<string | null>(null);
|
const [registered, setRegistered] = useState<string | null>(null);
|
||||||
|
|
||||||
|
|||||||
@ -4,10 +4,12 @@ import { Link, useSearchParams } from 'react-router-dom';
|
|||||||
|
|
||||||
import { apiPost } from '../../lib/api';
|
import { apiPost } from '../../lib/api';
|
||||||
|
|
||||||
|
import { useDocumentTitle } from '../../lib/use-document-title';
|
||||||
type VerifyState = 'pending' | 'success' | 'error';
|
type VerifyState = 'pending' | 'success' | 'error';
|
||||||
|
|
||||||
export function VerifyEmailPage(): React.JSX.Element {
|
export function VerifyEmailPage(): React.JSX.Element {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
useDocumentTitle(t('auth:verify.title'));
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const [state, setState] = useState<VerifyState>('pending');
|
const [state, setState] = useState<VerifyState>('pending');
|
||||||
const [email, setEmail] = useState('');
|
const [email, setEmail] = useState('');
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import { LabelChips } from '../labels/LabelChips';
|
|||||||
import { usePondLabels } from '../labels/use-pond-labels';
|
import { usePondLabels } from '../labels/use-pond-labels';
|
||||||
import { useCurrentPondRoute } from '../layout/use-pond-route';
|
import { useCurrentPondRoute } from '../layout/use-pond-route';
|
||||||
import { apiGet } from '../lib/api';
|
import { apiGet } from '../lib/api';
|
||||||
|
import { useModalFocus } from '../lib/use-modal-focus';
|
||||||
import { HighlightedSnippet } from './highlight';
|
import { HighlightedSnippet } from './highlight';
|
||||||
|
|
||||||
const RECENT_KEY = 'dorfteich.recentSearches';
|
const RECENT_KEY = 'dorfteich.recentSearches';
|
||||||
@ -45,6 +46,8 @@ export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.E
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { pondSlug } = useCurrentPondRoute();
|
const { pondSlug } = useCurrentPondRoute();
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const overlayRef = useRef<HTMLDivElement>(null);
|
||||||
|
useModalFocus(overlayRef);
|
||||||
|
|
||||||
const pond = useQuery({
|
const pond = useQuery({
|
||||||
queryKey: ['pond', pondSlug],
|
queryKey: ['pond', pondSlug],
|
||||||
@ -111,7 +114,13 @@ export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.E
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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-backdrop" onClick={onClose} aria-hidden />
|
||||||
<div className="search-palette" onKeyDown={onKeyDown}>
|
<div className="search-palette" onKeyDown={onKeyDown}>
|
||||||
<input
|
<input
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user