@ -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);
|
||||
|
||||
|
||||
@ -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}/.+`));
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
) : (
|
||||
|
||||
@ -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()}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": {
|
||||
"placeholder": "Unbenannte Seite"
|
||||
"placeholder": "Unbenannte Seite",
|
||||
"label": "Seitentitel"
|
||||
},
|
||||
"mode": {
|
||||
"edit": "Bearbeiten",
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"title": {
|
||||
"placeholder": "Untitled page"
|
||||
"placeholder": "Untitled page",
|
||||
"label": "Page title"
|
||||
},
|
||||
"mode": {
|
||||
"edit": "Edit",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user