A11y: Struktur/Kontraste/Formulare (#166-#168), Nicht-Text (#169), Verhalten (#170), CI-Schutz (#171) #174

Merged
stwaidele merged 6 commits from a11y-mittel into main 2026-07-21 16:29:26 +02:00
10 changed files with 64 additions and 15 deletions
Showing only changes of commit 2077d92c09 - Show all commits

View File

@ -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);

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.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}/.+`));

View File

@ -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

View File

@ -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>
) : ( ) : (

View File

@ -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()}

View File

@ -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,

View File

@ -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"
} }

View File

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

View File

@ -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"
} }

View File

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