@ -121,7 +121,7 @@ export function MentionAutocomplete({ editor }: { editor: Editor }): React.JSX.E
|
|||||||
style={{ position: 'fixed', left: state.coords.left, top: state.coords.bottom + 4 }}
|
style={{ position: 'fixed', left: state.coords.left, top: state.coords.bottom + 4 }}
|
||||||
>
|
>
|
||||||
{suggestions.map((user, index) => (
|
{suggestions.map((user, index) => (
|
||||||
<li key={user.id}>
|
<li key={user.id} role="presentation">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
role="option"
|
||||||
|
|||||||
@ -80,6 +80,22 @@ function ImageInsertButton({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Arrow-key navigation between toolbar controls (#164, role="toolbar"
|
||||||
|
* convention). Native selects keep their own arrow-key handling. */
|
||||||
|
function onToolbarArrowKey(event: React.KeyboardEvent<HTMLDivElement>): void {
|
||||||
|
if (event.key !== 'ArrowRight' && event.key !== 'ArrowLeft') return;
|
||||||
|
const target = event.target as HTMLElement;
|
||||||
|
if (target.tagName === 'SELECT' || target.tagName === 'INPUT') return;
|
||||||
|
const controls = [
|
||||||
|
...event.currentTarget.querySelectorAll<HTMLElement>('button:not([disabled]), select'),
|
||||||
|
];
|
||||||
|
const index = controls.indexOf(target);
|
||||||
|
if (index === -1) return;
|
||||||
|
event.preventDefault();
|
||||||
|
const step = event.key === 'ArrowRight' ? 1 : -1;
|
||||||
|
controls[(index + step + controls.length) % controls.length]?.focus();
|
||||||
|
}
|
||||||
|
|
||||||
/** Keyboard-accessible toolbar for the page editor (issue #25). Table row/
|
/** Keyboard-accessible toolbar for the page editor (issue #25). Table row/
|
||||||
* column controls stay visible but disabled outside a table, so the
|
* column controls stay visible but disabled outside a table, so the
|
||||||
* toolbar's layout and tab order never shift while typing. */
|
* toolbar's layout and tab order never shift while typing. */
|
||||||
@ -118,7 +134,12 @@ export function Toolbar({
|
|||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="editor-toolbar" role="toolbar" aria-label={t('toolbar.paragraph')}>
|
<div
|
||||||
|
className="editor-toolbar"
|
||||||
|
role="toolbar"
|
||||||
|
aria-label={t('toolbar.label')}
|
||||||
|
onKeyDown={onToolbarArrowKey}
|
||||||
|
>
|
||||||
<div className="editor-toolbar__group">
|
<div className="editor-toolbar__group">
|
||||||
<ToolbarButton
|
<ToolbarButton
|
||||||
label={t('toolbar.paragraph')}
|
label={t('toolbar.paragraph')}
|
||||||
|
|||||||
@ -158,7 +158,7 @@ export function WikilinkAutocomplete({ editor }: { editor: Editor }): React.JSX.
|
|||||||
style={{ position: 'fixed', left: state.coords.left, top: state.coords.bottom + 4 }}
|
style={{ position: 'fixed', left: state.coords.left, top: state.coords.bottom + 4 }}
|
||||||
>
|
>
|
||||||
{suggestions.map((item, index) => (
|
{suggestions.map((item, index) => (
|
||||||
<li key={`${item.kind}:${item.slug}`}>
|
<li key={`${item.kind}:${item.slug}`} role="presentation">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
role="option"
|
||||||
|
|||||||
@ -64,7 +64,10 @@ export function AppLayout(): React.JSX.Element {
|
|||||||
<Sidebar collapsed={collapsed} />
|
<Sidebar collapsed={collapsed} />
|
||||||
{!collapsed && <SidebarResizer widthRem={widthRem} onResize={setSidebarWidth} />}
|
{!collapsed && <SidebarResizer widthRem={widthRem} onResize={setSidebarWidth} />}
|
||||||
<div className="main-column">
|
<div className="main-column">
|
||||||
<main className="main">
|
{/* 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}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
{/* Outside the scroll container: always visible at the bottom
|
{/* Outside the scroll container: always visible at the bottom
|
||||||
|
|||||||
@ -64,6 +64,7 @@ export function Sidebar({ collapsed }: SidebarProps): React.JSX.Element {
|
|||||||
<nav
|
<nav
|
||||||
className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'}
|
className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'}
|
||||||
aria-hidden={collapsed}
|
aria-hidden={collapsed}
|
||||||
|
inert={collapsed}
|
||||||
aria-label={t('layout.sidebar.label')}
|
aria-label={t('layout.sidebar.label')}
|
||||||
>
|
>
|
||||||
{!pond.data ? (
|
{!pond.data ? (
|
||||||
|
|||||||
@ -96,7 +96,8 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
|||||||
onClick={() => setSearchOpen(true)}
|
onClick={() => setSearchOpen(true)}
|
||||||
aria-label={t('search:open')}
|
aria-label={t('search:open')}
|
||||||
>
|
>
|
||||||
<Search aria-hidden /> {t('search:open')}
|
<Search aria-hidden />
|
||||||
|
<span className="topbar__search-text">{t('search:open')}</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{searchOpen && user && <SearchPalette onClose={() => setSearchOpen(false)} />}
|
{searchOpen && user && <SearchPalette onClose={() => setSearchOpen(false)} />}
|
||||||
|
|||||||
@ -209,7 +209,19 @@ function PageEditor({
|
|||||||
|
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
editor?.setEditable(canEdit);
|
editor?.setEditable(canEdit);
|
||||||
}, [editor, canEdit]);
|
// TipTap rendert die Fläche als role="textbox" — ohne Namen, und im
|
||||||
|
// Lesemodus ist "Textfeld" schlicht falsch (#164, WCAG 4.1.2). Im
|
||||||
|
// Lesemodus wird sie zum benannten Dokument-Bereich.
|
||||||
|
editor?.setOptions({
|
||||||
|
editorProps: {
|
||||||
|
attributes: {
|
||||||
|
role: canEdit ? 'textbox' : 'document',
|
||||||
|
'aria-label': t('contentLabel'),
|
||||||
|
...(canEdit ? { 'aria-multiline': 'true' } : {}),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}, [editor, canEdit, t]);
|
||||||
|
|
||||||
// Entering edit mode drops the caret straight into the content, so writing
|
// Entering edit mode drops the caret straight into the content, so writing
|
||||||
// starts without an extra — and on empty pages pixel-precise — click. Skip
|
// starts without an extra — and on empty pages pixel-precise — click. Skip
|
||||||
|
|||||||
@ -208,7 +208,7 @@ export function SearchPalette({ onClose }: { onClose: () => void }): React.JSX.E
|
|||||||
) : (
|
) : (
|
||||||
<ul className="search-results" role="listbox" aria-label={t('resultsLabel')}>
|
<ul className="search-results" role="listbox" aria-label={t('resultsLabel')}>
|
||||||
{hits.map((hit, index) => (
|
{hits.map((hit, index) => (
|
||||||
<li key={hit.pageId}>
|
<li key={hit.pageId} role="presentation">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
role="option"
|
role="option"
|
||||||
|
|||||||
@ -53,7 +53,9 @@ button {
|
|||||||
/* Layout skeleton */
|
/* Layout skeleton */
|
||||||
.app {
|
.app {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-rows: var(--topbar-height) 1fr;
|
/* minmax: the topbar row may grow when its controls wrap on narrow
|
||||||
|
viewports (issue #165, WCAG 1.4.10). */
|
||||||
|
grid-template-rows: minmax(var(--topbar-height), auto) 1fr;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -70,6 +72,10 @@ button {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-4);
|
gap: var(--space-4);
|
||||||
padding: 0 var(--space-4);
|
padding: 0 var(--space-4);
|
||||||
|
/* Same #100 pattern as .app-body: without this the topbar's min-content
|
||||||
|
width sizes the shared grid column and forces page-level horizontal
|
||||||
|
scrolling on narrow viewports (issue #165, WCAG 1.4.10). */
|
||||||
|
min-width: 0;
|
||||||
border-bottom: 1px solid var(--color-border);
|
border-bottom: 1px solid var(--color-border);
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
/* Own stacking context above the scrolling `.main` content: the nav comes
|
/* Own stacking context above the scrolling `.main` content: the nav comes
|
||||||
@ -91,6 +97,39 @@ button {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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
|
||||||
|
icon (the aria-label carries the name). The expanded sidebar becomes
|
||||||
|
an overlay above the content — 256 px beside a 320 px viewport would
|
||||||
|
leave the content a sliver and force page-level horizontal scrolling. */
|
||||||
|
@media (max-width: 40rem) {
|
||||||
|
.topbar {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
row-gap: 0;
|
||||||
|
padding: var(--space-1) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topbar__search-text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-body {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar:not(.sidebar--collapsed) {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
|
z-index: 25;
|
||||||
|
box-shadow: 2px 0 8px rgb(0 0 0 / 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-resizer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
width: var(--sidebar-width);
|
width: var(--sidebar-width);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
@ -715,6 +754,9 @@ button {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--space-2);
|
gap: var(--space-2);
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
/* Wrap instead of widening the page on narrow viewports (#165). */
|
||||||
|
flex-wrap: wrap;
|
||||||
|
min-width: 0;
|
||||||
padding: var(--space-2) var(--space-8);
|
padding: var(--space-2) var(--space-8);
|
||||||
border-top: 1px solid var(--color-border);
|
border-top: 1px solid var(--color-border);
|
||||||
background: var(--color-bg);
|
background: var(--color-bg);
|
||||||
@ -958,6 +1000,9 @@ button {
|
|||||||
|
|
||||||
.editor-page__title {
|
.editor-page__title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
/* An input's intrinsic size-attribute width must not set the page's
|
||||||
|
minimum width on narrow viewports (#165). */
|
||||||
|
min-width: 0;
|
||||||
border: none;
|
border: none;
|
||||||
background: none;
|
background: none;
|
||||||
font-family: var(--font-heading);
|
font-family: var(--font-heading);
|
||||||
@ -3784,6 +3829,9 @@ ul[data-type='task_list'] li p:last-of-type {
|
|||||||
@media (max-width: 60rem) {
|
@media (max-width: 60rem) {
|
||||||
.settings-layout {
|
.settings-layout {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
/* Override the row layout's flex-start: stacked, the content column
|
||||||
|
must stretch to the viewport instead of its widest control (#165). */
|
||||||
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
|
|
||||||
.settings-nav {
|
.settings-nav {
|
||||||
|
|||||||
@ -38,6 +38,7 @@
|
|||||||
"discardConfirm": "Die auf diesem Gerät gespeicherten Änderungen verwerfen? Das kann nicht rückgängig gemacht werden."
|
"discardConfirm": "Die auf diesem Gerät gespeicherten Änderungen verwerfen? Das kann nicht rückgängig gemacht werden."
|
||||||
},
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
|
"label": "Formatierung",
|
||||||
"paragraph": "Absatz",
|
"paragraph": "Absatz",
|
||||||
"heading1": "Überschrift 1",
|
"heading1": "Überschrift 1",
|
||||||
"heading2": "Überschrift 2",
|
"heading2": "Überschrift 2",
|
||||||
@ -189,5 +190,6 @@
|
|||||||
"dateMarker": {
|
"dateMarker": {
|
||||||
"due": "Zieldatum",
|
"due": "Zieldatum",
|
||||||
"start": "Startdatum"
|
"start": "Startdatum"
|
||||||
}
|
},
|
||||||
|
"contentLabel": "Seiteninhalt"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -38,6 +38,7 @@
|
|||||||
"discardConfirm": "Discard the changes saved on this device? This cannot be undone."
|
"discardConfirm": "Discard the changes saved on this device? This cannot be undone."
|
||||||
},
|
},
|
||||||
"toolbar": {
|
"toolbar": {
|
||||||
|
"label": "Formatting",
|
||||||
"paragraph": "Paragraph",
|
"paragraph": "Paragraph",
|
||||||
"heading1": "Heading 1",
|
"heading1": "Heading 1",
|
||||||
"heading2": "Heading 2",
|
"heading2": "Heading 2",
|
||||||
@ -189,5 +190,6 @@
|
|||||||
"dateMarker": {
|
"dateMarker": {
|
||||||
"due": "Due date",
|
"due": "Due date",
|
||||||
"start": "Start date"
|
"start": "Start date"
|
||||||
}
|
},
|
||||||
|
"contentLabel": "Page content"
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user