#166: Skip-Link, verstecktes Seiten-h1, Resizer in die Nav-Landmarke
Skip-Link als erster Tab-Stopp springt auf #main; die angemeldete Seitenansicht bekommt ein visually-hidden h1 (der sichtbare Titel ist ein Input, der jetzt auch ein aria-label trägt); der Sidebar-Resizer wandert in die nav-Landmarke (absolut an der Kante positioniert), damit kein Inhalt außerhalb von Landmarken liegt. Zwei e2e-Locator auf das Sidebar-Formular gescoped — das Editor-Titelfeld matcht seit dem neuen Label ebenfalls auf /title|titel/i. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
This commit is contained in:
parent
d4d4282c55
commit
2077d92c09
@ -62,7 +62,7 @@ test('page lifecycle: create via the sidebar, rename, appears in the sidebar', a
|
|||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
await page.goto(`/p/${pond.slug}`);
|
await page.goto(`/p/${pond.slug}`);
|
||||||
await page.getByRole('button', { name: /new page|neue seite/i }).click();
|
await page.getByRole('button', { name: /new page|neue seite/i }).click();
|
||||||
await page.getByLabel(/title|titel/i).fill(title);
|
await page.locator('.sidebar').getByLabel(/title|titel/i).fill(title);
|
||||||
await page.getByRole('button', { name: /create|erstellen/i }).click();
|
await page.getByRole('button', { name: /create|erstellen/i }).click();
|
||||||
await expect(page.locator('.sidebar__page--active')).toHaveText(title);
|
await expect(page.locator('.sidebar__page--active')).toHaveText(title);
|
||||||
|
|
||||||
|
|||||||
@ -110,7 +110,7 @@ test('new-page flow: button opens a title prompt and the editor opens on create'
|
|||||||
await page.goto(`/p/${pond.slug}`);
|
await page.goto(`/p/${pond.slug}`);
|
||||||
|
|
||||||
await page.getByRole('button', { name: /new page|neue seite/i }).click();
|
await page.getByRole('button', { name: /new page|neue seite/i }).click();
|
||||||
await page.getByLabel(/title|titel/i).fill(title);
|
await page.locator('.sidebar').getByLabel(/title|titel/i).fill(title);
|
||||||
await page.getByRole('button', { name: /create|erstellen/i }).click();
|
await page.getByRole('button', { name: /create|erstellen/i }).click();
|
||||||
|
|
||||||
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/.+`));
|
await expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/.+`));
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Outlet } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
|
|
||||||
import { usePersistentState } from '../lib/use-persistent-state';
|
import { usePersistentState } from '../lib/use-persistent-state';
|
||||||
@ -10,6 +11,7 @@ import { clampSidebarWidth, SIDEBAR_DEFAULT_WIDTH_REM, SidebarResizer } from './
|
|||||||
import { TopBar } from './TopBar';
|
import { TopBar } from './TopBar';
|
||||||
|
|
||||||
export function AppLayout(): React.JSX.Element {
|
export function AppLayout(): React.JSX.Element {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = usePersistentState('ui.sidebar.collapsed', false);
|
const [sidebarCollapsed, setSidebarCollapsed] = usePersistentState('ui.sidebar.collapsed', false);
|
||||||
const [sidebarWidth, setSidebarWidth] = usePersistentState(
|
const [sidebarWidth, setSidebarWidth] = usePersistentState(
|
||||||
'ui.sidebar.width',
|
'ui.sidebar.width',
|
||||||
@ -53,6 +55,10 @@ export function AppLayout(): React.JSX.Element {
|
|||||||
<SidebarChromeContext.Provider value={setForcedHidden}>
|
<SidebarChromeContext.Provider value={setForcedHidden}>
|
||||||
<PageActionsSlotContext.Provider value={actionsSlot}>
|
<PageActionsSlotContext.Provider value={actionsSlot}>
|
||||||
<div className="app">
|
<div className="app">
|
||||||
|
{/* First tab stop: jump over topbar + sidebar (#166, WCAG 2.4.1). */}
|
||||||
|
<a className="skip-link" href="#main">
|
||||||
|
{t('layout.skipToContent')}
|
||||||
|
</a>
|
||||||
<TopBar
|
<TopBar
|
||||||
sidebarCollapsed={collapsed}
|
sidebarCollapsed={collapsed}
|
||||||
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
|
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
@ -61,13 +67,20 @@ export function AppLayout(): React.JSX.Element {
|
|||||||
className="app-body"
|
className="app-body"
|
||||||
style={{ '--sidebar-width': `${widthRem}rem` } as React.CSSProperties}
|
style={{ '--sidebar-width': `${widthRem}rem` } as React.CSSProperties}
|
||||||
>
|
>
|
||||||
<Sidebar collapsed={collapsed} />
|
<Sidebar
|
||||||
{!collapsed && <SidebarResizer widthRem={widthRem} onResize={setSidebarWidth} />}
|
collapsed={collapsed}
|
||||||
|
// Inside the nav landmark so no content sits outside landmarks
|
||||||
|
// (#166); hidden with the sidebar as before.
|
||||||
|
resizer={
|
||||||
|
!collapsed && <SidebarResizer widthRem={widthRem} onResize={setSidebarWidth} />
|
||||||
|
}
|
||||||
|
/>
|
||||||
<div className="main-column">
|
<div className="main-column">
|
||||||
{/* tabIndex: the main area is the app's scroll container; on
|
{/* tabIndex: the main area is the app's scroll container; on
|
||||||
pages without focusable content (e.g. legal texts) keyboard
|
pages without focusable content (e.g. legal texts) keyboard
|
||||||
users could otherwise not scroll it (#165, WCAG 2.1.1). */}
|
users could otherwise not scroll it (#165, WCAG 2.1.1).
|
||||||
<main className="main" tabIndex={0}>
|
id: skip-link target (#166). */}
|
||||||
|
<main className="main" id="main" tabIndex={0}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
{/* Outside the scroll container: always visible at the bottom
|
{/* Outside the scroll container: always visible at the bottom
|
||||||
|
|||||||
@ -36,6 +36,8 @@ import { useCurrentPondRoute } from './use-pond-route';
|
|||||||
|
|
||||||
interface SidebarProps {
|
interface SidebarProps {
|
||||||
collapsed: boolean;
|
collapsed: boolean;
|
||||||
|
/** The resize handle, rendered inside the nav landmark (#166). */
|
||||||
|
resizer?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
const SORT_MODES: SidebarSortMode[] = ['alpha', 'created', 'manual'];
|
const SORT_MODES: SidebarSortMode[] = ['alpha', 'created', 'manual'];
|
||||||
@ -50,7 +52,7 @@ const VIEW_MODES: SidebarViewMode[] = ['folders', 'labels'];
|
|||||||
* every user can override it locally. Collapse behavior and the layout
|
* every user can override it locally. Collapse behavior and the layout
|
||||||
* contract (`nav.sidebar`, `aria-hidden`) are unchanged from #4/#25.
|
* contract (`nav.sidebar`, `aria-hidden`) are unchanged from #4/#25.
|
||||||
*/
|
*/
|
||||||
export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
export function Sidebar({ collapsed, resizer }: SidebarProps): React.JSX.Element {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { pondSlug } = useCurrentPondRoute();
|
const { pondSlug } = useCurrentPondRoute();
|
||||||
|
|
||||||
@ -67,6 +69,7 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
|||||||
inert={collapsed}
|
inert={collapsed}
|
||||||
aria-label={t('layout.sidebar.label')}
|
aria-label={t('layout.sidebar.label')}
|
||||||
>
|
>
|
||||||
|
{resizer}
|
||||||
{!pond.data ? (
|
{!pond.data ? (
|
||||||
<p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
|
<p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@ -560,11 +560,15 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
)}
|
)}
|
||||||
<div className="editor-page">
|
<div className="editor-page">
|
||||||
<div className="editor-page__header">
|
<div className="editor-page__header">
|
||||||
|
{/* The visible title is an input; give assistive tech the page
|
||||||
|
heading it expects on an article view (#166). */}
|
||||||
|
<h1 className="visually-hidden">{title || t('title.placeholder')}</h1>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
className="editor-page__title"
|
className="editor-page__title"
|
||||||
value={title}
|
value={title}
|
||||||
placeholder={t('title.placeholder')}
|
placeholder={t('title.placeholder')}
|
||||||
|
aria-label={t('title.label')}
|
||||||
disabled={mode !== 'edit'}
|
disabled={mode !== 'edit'}
|
||||||
onChange={(event) => setTitle(event.target.value)}
|
onChange={(event) => setTitle(event.target.value)}
|
||||||
onBlur={() => void saveTitle()}
|
onBlur={() => void saveTitle()}
|
||||||
|
|||||||
@ -97,6 +97,23 @@ button {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Skip link (#166, WCAG 2.4.1): first tab stop, visible only on focus. */
|
||||||
|
.skip-link {
|
||||||
|
position: absolute;
|
||||||
|
left: -9999px;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1200;
|
||||||
|
padding: var(--space-2) var(--space-4);
|
||||||
|
background: var(--color-accent);
|
||||||
|
color: var(--color-accent-contrast);
|
||||||
|
border-radius: 0 0 var(--radius) 0;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skip-link:focus-visible {
|
||||||
|
left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
/* Narrow viewports (issue #165, WCAG 1.4.10): the topbar wraps onto a
|
/* Narrow viewports (issue #165, WCAG 1.4.10): the topbar wraps onto a
|
||||||
second row instead of pushing the page wider than the viewport; every
|
second row instead of pushing the page wider than the viewport; every
|
||||||
control stays visible and reachable. The search button drops to its
|
control stays visible and reachable. The search button drops to its
|
||||||
@ -133,6 +150,8 @@ button {
|
|||||||
.sidebar {
|
.sidebar {
|
||||||
width: var(--sidebar-width);
|
width: var(--sidebar-width);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
/* Anchor for the absolutely positioned resize handle (#166). */
|
||||||
|
position: relative;
|
||||||
/* Column flex so the trash footer can pin to the bottom (margin-top: auto). */
|
/* Column flex so the trash footer can pin to the bottom (margin-top: auto). */
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@ -152,12 +171,16 @@ button {
|
|||||||
|
|
||||||
/* Drag handle on the sidebar's right edge (issue #99). */
|
/* Drag handle on the sidebar's right edge (issue #99). */
|
||||||
.sidebar-resizer {
|
.sidebar-resizer {
|
||||||
flex-shrink: 0;
|
/* Lives inside the nav landmark (#166); straddles the sidebar edge. */
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
right: -3px;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
margin-left: -3px;
|
|
||||||
cursor: col-resize;
|
cursor: col-resize;
|
||||||
touch-action: none;
|
touch-action: none;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
|
z-index: 5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-resizer:hover,
|
.sidebar-resizer:hover,
|
||||||
|
|||||||
@ -56,7 +56,8 @@
|
|||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"anonymous": "Nicht angemeldet"
|
"anonymous": "Nicht angemeldet"
|
||||||
}
|
},
|
||||||
|
"skipToContent": "Zum Inhalt springen"
|
||||||
},
|
},
|
||||||
"pondHome": {
|
"pondHome": {
|
||||||
"empty": "Dieser Teich hat noch keine Seiten – lege eine über die Seitenleiste an."
|
"empty": "Dieser Teich hat noch keine Seiten – lege eine über die Seitenleiste an."
|
||||||
@ -95,5 +96,6 @@
|
|||||||
},
|
},
|
||||||
"settingsNav": {
|
"settingsNav": {
|
||||||
"label": "Abschnitte"
|
"label": "Abschnitte"
|
||||||
}
|
},
|
||||||
|
"tableActions": "Aktionen"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": {
|
"title": {
|
||||||
"placeholder": "Unbenannte Seite"
|
"placeholder": "Unbenannte Seite",
|
||||||
|
"label": "Seitentitel"
|
||||||
},
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
"edit": "Bearbeiten",
|
"edit": "Bearbeiten",
|
||||||
|
|||||||
@ -56,7 +56,8 @@
|
|||||||
},
|
},
|
||||||
"user": {
|
"user": {
|
||||||
"anonymous": "Not signed in"
|
"anonymous": "Not signed in"
|
||||||
}
|
},
|
||||||
|
"skipToContent": "Skip to content"
|
||||||
},
|
},
|
||||||
"pondHome": {
|
"pondHome": {
|
||||||
"empty": "This pond doesn't have any pages yet — create one from the sidebar."
|
"empty": "This pond doesn't have any pages yet — create one from the sidebar."
|
||||||
@ -95,5 +96,6 @@
|
|||||||
},
|
},
|
||||||
"settingsNav": {
|
"settingsNav": {
|
||||||
"label": "Sections"
|
"label": "Sections"
|
||||||
}
|
},
|
||||||
|
"tableActions": "Actions"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
{
|
{
|
||||||
"title": {
|
"title": {
|
||||||
"placeholder": "Untitled page"
|
"placeholder": "Untitled page",
|
||||||
|
"label": "Page title"
|
||||||
},
|
},
|
||||||
"mode": {
|
"mode": {
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user