#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:
Claude Fable 5 2026-07-21 14:26:54 +02:00
parent d4d4282c55
commit 2077d92c09
10 changed files with 64 additions and 15 deletions

View File

@ -62,7 +62,7 @@ test('page lifecycle: create via the sidebar, rename, appears in the sidebar', a
const page = await context.newPage();
await page.goto(`/p/${pond.slug}`);
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 expect(page.locator('.sidebar__page--active')).toHaveText(title);

View File

@ -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.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 expect(page).toHaveURL(new RegExp(`/p/${pond.slug}/.+`));

View File

@ -1,4 +1,5 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Outlet } from 'react-router-dom';
import { usePersistentState } from '../lib/use-persistent-state';
@ -10,6 +11,7 @@ import { clampSidebarWidth, SIDEBAR_DEFAULT_WIDTH_REM, SidebarResizer } from './
import { TopBar } from './TopBar';
export function AppLayout(): React.JSX.Element {
const { t } = useTranslation();
const [sidebarCollapsed, setSidebarCollapsed] = usePersistentState('ui.sidebar.collapsed', false);
const [sidebarWidth, setSidebarWidth] = usePersistentState(
'ui.sidebar.width',
@ -53,6 +55,10 @@ export function AppLayout(): React.JSX.Element {
<SidebarChromeContext.Provider value={setForcedHidden}>
<PageActionsSlotContext.Provider value={actionsSlot}>
<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
sidebarCollapsed={collapsed}
onToggleSidebar={() => setSidebarCollapsed(!sidebarCollapsed)}
@ -61,13 +67,20 @@ export function AppLayout(): React.JSX.Element {
className="app-body"
style={{ '--sidebar-width': `${widthRem}rem` } as React.CSSProperties}
>
<Sidebar collapsed={collapsed} />
{!collapsed && <SidebarResizer widthRem={widthRem} onResize={setSidebarWidth} />}
<Sidebar
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">
{/* tabIndex: the main area is the app's scroll container; on
pages without focusable content (e.g. legal texts) keyboard
users could otherwise not scroll it (#165, WCAG 2.1.1). */}
<main className="main" tabIndex={0}>
users could otherwise not scroll it (#165, WCAG 2.1.1).
id: skip-link target (#166). */}
<main className="main" id="main" tabIndex={0}>
<Outlet />
</main>
{/* Outside the scroll container: always visible at the bottom

View File

@ -36,6 +36,8 @@ import { useCurrentPondRoute } from './use-pond-route';
interface SidebarProps {
collapsed: boolean;
/** The resize handle, rendered inside the nav landmark (#166). */
resizer?: React.ReactNode;
}
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
* 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 { pondSlug } = useCurrentPondRoute();
@ -67,6 +69,7 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
inert={collapsed}
aria-label={t('layout.sidebar.label')}
>
{resizer}
{!pond.data ? (
<p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
) : (

View File

@ -560,11 +560,15 @@ export function PageEditorPage(): React.JSX.Element {
)}
<div className="editor-page">
<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
type="text"
className="editor-page__title"
value={title}
placeholder={t('title.placeholder')}
aria-label={t('title.label')}
disabled={mode !== 'edit'}
onChange={(event) => setTitle(event.target.value)}
onBlur={() => void saveTitle()}

View File

@ -97,6 +97,23 @@ button {
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
second row instead of pushing the page wider than the viewport; every
control stays visible and reachable. The search button drops to its
@ -133,6 +150,8 @@ button {
.sidebar {
width: var(--sidebar-width);
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). */
display: flex;
flex-direction: column;
@ -152,12 +171,16 @@ button {
/* Drag handle on the sidebar's right edge (issue #99). */
.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;
margin-left: -3px;
cursor: col-resize;
touch-action: none;
background: transparent;
z-index: 5;
}
.sidebar-resizer:hover,

View File

@ -56,7 +56,8 @@
},
"user": {
"anonymous": "Nicht angemeldet"
}
},
"skipToContent": "Zum Inhalt springen"
},
"pondHome": {
"empty": "Dieser Teich hat noch keine Seiten lege eine über die Seitenleiste an."
@ -95,5 +96,6 @@
},
"settingsNav": {
"label": "Abschnitte"
}
},
"tableActions": "Aktionen"
}

View File

@ -1,6 +1,7 @@
{
"title": {
"placeholder": "Unbenannte Seite"
"placeholder": "Unbenannte Seite",
"label": "Seitentitel"
},
"mode": {
"edit": "Bearbeiten",

View File

@ -56,7 +56,8 @@
},
"user": {
"anonymous": "Not signed in"
}
},
"skipToContent": "Skip to content"
},
"pondHome": {
"empty": "This pond doesn't have any pages yet — create one from the sidebar."
@ -95,5 +96,6 @@
},
"settingsNav": {
"label": "Sections"
}
},
"tableActions": "Actions"
}

View File

@ -1,6 +1,7 @@
{
"title": {
"placeholder": "Untitled page"
"placeholder": "Untitled page",
"label": "Page title"
},
"mode": {
"edit": "Edit",