dorfteich/apps/web/src/layout/Sidebar.tsx
Claude Fable 5 057992faaf #164: ARIA-Semantik — Editorfläche, Autocomplete-Listboxen, Sidebar, Toolbar
Die Editorfläche bekommt einen lokalisierten zugänglichen Namen und ist
im Lesemodus role=document statt eines unbenannten Textfelds (setOptions
im selben Layout-Effekt wie setEditable). Eingeklappte Sidebar zusätzlich
inert (aria-hidden allein ließ fokussierbare Kinder im Tab-Weg). Die
li-Zwischenknoten der Listboxen (Wikilink-/Mention-Autocomplete,
Suchergebnisse) sind role=presentation, damit listbox→option wieder eine
gültige Eltern-Kind-Beziehung ist. Toolbar: Pfeiltasten-Navigation über
die Controls (native Selects behalten ihre Pfeiltasten) und ein
sprechendes Toolbar-Label statt des Absatz-Buttons-Labels.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
2026-07-21 14:04:36 +02:00

700 lines
24 KiB
TypeScript

import type {
LabelView,
PageListItemView,
PondView,
SidebarSortMode,
SidebarViewMode,
TreeNode,
} from '@dorfteich/shared';
import { buildTree, collectSubtreeIds, labelDepth } from '@dorfteich/shared';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import {
ChevronRight,
FilePlus,
FileText,
Folder,
FolderOpen,
Star,
Trash2,
Waypoints,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms';
import { usePageFavorites } from '../favorites/use-favorites';
import { ImportControl } from '../import/ImportControl';
import { LabelChips } from '../labels/LabelChips';
import { usePondLabels } from '../labels/use-pond-labels';
import { apiGet, apiPatch } from '../lib/api';
import { usePersistentState } from '../lib/use-persistent-state';
import { NewPageForm } from './NewPageForm';
import { dropIndex, neighborsForMove } from './reorder';
import { useCurrentPondRoute } from './use-pond-route';
interface SidebarProps {
collapsed: boolean;
}
const SORT_MODES: SidebarSortMode[] = ['alpha', 'created', 'manual'];
const VIEW_MODES: SidebarViewMode[] = ['folders', 'labels'];
/**
* Left sidebar: the current pond's page list, sort mode, active-page
* highlight, "new page" flow (issue #26), and label chips + a
* descendant-inclusive label filter (issue #44). Since issue #108 pages
* render as a collapsible tree ("folders") or grouped under the label tree
* ("labels") — the pond default (`settings.sidebarView`) is owner-set and
* 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 {
const { t } = useTranslation();
const { pondSlug } = useCurrentPondRoute();
const pond = useQuery({
queryKey: ['pond', pondSlug],
queryFn: () => apiGet<PondView>(`/ponds/${pondSlug}`),
enabled: pondSlug !== null,
});
return (
<nav
className={collapsed ? 'sidebar sidebar--collapsed' : 'sidebar'}
aria-hidden={collapsed}
inert={collapsed}
aria-label={t('layout.sidebar.label')}
>
{!pond.data ? (
<p className="sidebar__hint">{t('layout.sidebar.placeholder')}</p>
) : (
// Keyed by pond so the per-pond persistent hooks (view override,
// collapsed nodes) mount with the right localStorage key.
<SidebarContent key={pond.data.id} pond={pond.data} pondSlug={pondSlug!} />
)}
</nav>
);
}
function SidebarContent({
pond,
pondSlug,
}: {
pond: PondView;
pondSlug: string;
}): React.JSX.Element {
const { t } = useTranslation();
const { t: tLabels } = useTranslation('labels');
const { user } = useAuth();
const { pageSlug } = useCurrentPondRoute();
const queryClient = useQueryClient();
const [creating, setCreating] = useState(false);
const [filterIds, setFilterIds] = useState<Set<string>>(new Set());
// The favorites filter (issue #132) — a latching push button, combinable
// with the label filter; both narrow the same list.
const [favoritesOnly, setFavoritesOnly] = useState(false);
const [draggedId, setDraggedId] = useState<string | null>(null);
const [dropIntoId, setDropIntoId] = useState<string | null>(null);
const [moveError, setMoveError] = useState<unknown>(null);
const [announcement, setAnnouncement] = useState('');
// Per-user view override (issue #108); `null` follows the pond default.
const [viewOverride, setViewOverride] = usePersistentState<SidebarViewMode | null>(
`ui.sidebar.view.${pond.id}`,
null,
);
const view: SidebarViewMode = viewOverride ?? pond.settings.sidebarView;
const [collapsedIds, setCollapsedIds] = usePersistentState<string[]>(
`ui.sidebar.collapsedPages.${pond.id}`,
[],
);
const pages = useQuery({
queryKey: ['pages', pond.id, pond.settings.sidebarSort],
queryFn: () => apiGet<PageListItemView[]>(`/ponds/${pond.id}/pages`),
});
const { flat, byId } = usePondLabels(pond.id);
const { ids: favoriteIds } = usePageFavorites(pond.id);
const isOwner = Boolean(user && user.id === pond.ownerId);
// A filter on a label matches pages tagged with that label or any of its
// descendants (permissions/vision: sub-labels belong to their parent).
const expandedFilter = useMemo(() => {
const acc = new Set<string>();
for (const id of filterIds) for (const d of collectSubtreeIds(flat, id)) acc.add(d);
return acc;
}, [filterIds, flat]);
const labelFiltered =
filterIds.size === 0
? pages.data
: pages.data?.filter((p) => p.labelIds.some((id) => expandedFilter.has(id)));
const visiblePages = favoritesOnly
? labelFiltered?.filter((p) => favoriteIds.has(p.id))
: labelFiltered;
// The page tree, built from the list's global sort order (issue #108); a
// filtered list falls back to the flat rendering, so no filter here.
const tree = useMemo(() => buildTree(pages.data ?? []), [pages.data]);
const currentPage = pageSlug ? pages.data?.find((p) => p.slug === pageSlug) : undefined;
function toggleFilter(id: string, on: boolean): void {
setFilterIds((prev) => {
const next = new Set(prev);
if (on) next.add(id);
else next.delete(id);
return next;
});
}
function toggleCollapsed(id: string): void {
setCollapsedIds(
collapsedIds.includes(id) ? collapsedIds.filter((c) => c !== id) : [...collapsedIds, id],
);
}
async function setSortMode(mode: SidebarSortMode): Promise<void> {
await apiPatch(`/ponds/${pond.id}`, { sidebarSort: mode });
await queryClient.invalidateQueries({ queryKey: ['pond', pondSlug] });
}
// Reordering is offered only in manual mode, to the owner, while no label
// filter narrows the list, and in folder view (label view is read-only).
const canReorder =
isOwner && pond.settings.sidebarSort === 'manual' && filterIds.size === 0 && view === 'folders';
/** Move `movedId` to `newIndex` within its sibling group and persist it.
* Fractional keys between two siblings keep the group order intact under
* the pond-wide sequence (issue #106), so no other page is touched. */
async function moveWithinSiblings(
movedId: string,
siblingIds: string[],
newIndex: number,
title: string,
): Promise<void> {
setMoveError(null);
const { afterId, beforeId } = neighborsForMove(siblingIds, movedId, newIndex);
await apiPatch(`/pages/${movedId}/position`, { afterId, beforeId });
await queryClient.invalidateQueries({ queryKey: ['pages', pond.id] });
const position = Math.max(0, Math.min(newIndex, siblingIds.length - 1)) + 1;
setAnnouncement(
t('layout.sidebar.reorder.moved', { title, position, count: siblingIds.length }),
);
}
/** Nest `movedId` under `newParentId` (drop onto a row, issue #109),
* appended at the end of the new sibling group. Cycle/depth refusals from
* the server show as a translated banner under the tree. */
async function reparentTo(movedId: string, newParentId: string): Promise<void> {
setMoveError(null);
const list = pages.data ?? [];
const moved = list.find((p) => p.id === movedId);
const parent = list.find((p) => p.id === newParentId);
if (!moved || moved.parentId === newParentId) return;
try {
// Key after the last sibling of the new group; an empty group falls
// back to the pond's last page so the fresh key is unique.
const siblings = list.filter((p) => p.parentId === newParentId && p.id !== movedId);
const fallback = [...list].reverse().find((p) => p.id !== movedId);
const afterId = siblings.at(-1)?.id ?? fallback?.id ?? null;
await apiPatch(`/pages/${movedId}/position`, {
afterId,
beforeId: null,
parentId: newParentId,
});
await queryClient.invalidateQueries({ queryKey: ['pages', pond.id] });
setAnnouncement(
t('layout.sidebar.reorder.reparented', {
title: moved.title,
parent: parent?.title ?? '',
}),
);
} catch (err) {
setMoveError(err);
}
}
const showFlatFallback = view === 'folders' && (filterIds.size > 0 || favoritesOnly);
// The label view groups the (possibly favorites-narrowed) list; the label
// filter stays a folder-view affordance as before (#108).
const labelViewPages = favoritesOnly
? pages.data?.filter((p) => favoriteIds.has(p.id))
: pages.data;
return (
<>
<div className="sidebar__header">
<h2 className="sidebar__pond-name">{pond.name}</h2>
{isOwner && (
<select
className="sidebar__sort"
aria-label={t('layout.sidebar.sortLabel')}
value={pond.settings.sidebarSort}
onChange={(event) => void setSortMode(event.target.value as SidebarSortMode)}
>
{SORT_MODES.map((mode) => (
<option key={mode} value={mode}>
{t(`layout.sidebar.sortMode.${mode}`)}
</option>
))}
</select>
)}
</div>
<div
className="sidebar__view-toggle"
role="group"
aria-label={t('layout.sidebar.view.toggleLabel')}
>
{VIEW_MODES.map((mode) => (
<button
key={mode}
type="button"
className={`sidebar__view-btn${view === mode ? ' sidebar__view-btn--active' : ''}`}
aria-pressed={view === mode}
onClick={() => setViewOverride(mode)}
>
{t(`layout.sidebar.view.${mode}`)}
</button>
))}
{/* Latching favorites filter (issue #132) — narrows either view. */}
<button
type="button"
className={`sidebar__view-btn sidebar__view-btn--favorites${
favoritesOnly ? ' sidebar__view-btn--active' : ''
}`}
aria-pressed={favoritesOnly}
onClick={() => setFavoritesOnly(!favoritesOnly)}
>
<Star aria-hidden fill={favoritesOnly ? 'currentColor' : 'none'} />
{t('layout.sidebar.favorites.filter')}
</button>
</div>
{view === 'folders' && flat.length > 0 && (
<details className="sidebar__filter">
<summary>
{tLabels('filter.toggle')}
{filterIds.size > 0 && ` (${filterIds.size})`}
</summary>
<ul className="sidebar__filter-list">
{flat.map((label) => (
<li
key={label.id}
style={{ paddingInlineStart: `${(labelDepth(flat, label.id) - 1) * 1}rem` }}
>
<label className="sidebar__filter-option">
<input
type="checkbox"
checked={filterIds.has(label.id)}
onChange={(event) => toggleFilter(label.id, event.target.checked)}
/>
<span
className="label-chip__swatch"
style={{ backgroundColor: label.color }}
aria-hidden
/>
<span>{label.name}</span>
</label>
</li>
))}
</ul>
{filterIds.size > 0 && (
<button
type="button"
className="linklike sidebar__filter-clear"
onClick={() => setFilterIds(new Set())}
>
{tLabels('filter.clear')}
</button>
)}
</details>
)}
{view === 'labels' ? (
labelViewPages && labelViewPages.length > 0 ? (
<LabelGroupedPages
pages={labelViewPages}
labels={flat}
pondSlug={pondSlug}
pageSlug={pageSlug}
/>
) : (
<p className="sidebar__hint">
{favoritesOnly ? t('layout.sidebar.favorites.empty') : t('layout.sidebar.empty')}
</p>
)
) : showFlatFallback ? (
visiblePages && visiblePages.length > 0 ? (
<ul className="sidebar__pages">
{visiblePages.map((p) => (
<li key={p.id} className="sidebar__page-item">
<PageLink page={p} pondSlug={pondSlug} pageSlug={pageSlug} />
<LabelChips labelIds={p.labelIds} byId={byId} />
</li>
))}
</ul>
) : (
<p className="sidebar__hint">
{filterIds.size > 0 ? tLabels('filter.none') : t('layout.sidebar.favorites.empty')}
</p>
)
) : tree.length > 0 ? (
<ul className="sidebar__pages sidebar__pages--tree">
<PageTreeLevel
nodes={tree}
pondSlug={pondSlug}
pageSlug={pageSlug}
byId={byId}
favoriteIds={favoriteIds}
collapsedIds={collapsedIds}
onToggleCollapsed={toggleCollapsed}
canReorder={canReorder}
draggedId={draggedId}
setDraggedId={setDraggedId}
dropIntoId={dropIntoId}
setDropIntoId={setDropIntoId}
onMove={moveWithinSiblings}
onReparent={reparentTo}
/>
</ul>
) : (
<p className="sidebar__hint">{t('layout.sidebar.empty')}</p>
)}
{moveError !== null && <FormError error={moveError} />}
{creating && (
<NewPageForm
pondId={pond.id}
pondSlug={pondSlug}
parentId={currentPage?.id ?? null}
parentTitle={currentPage?.title}
onCreated={() => {
setCreating(false);
void queryClient.invalidateQueries({ queryKey: ['pages', pond.id] });
}}
onCancel={() => setCreating(false)}
/>
)}
{/* Keyboard/drag reordering announcements for screen readers. */}
<p className="visually-hidden sidebar__announce" role="status" aria-live="polite">
{announcement}
</p>
{/* All four actions live as an icon row pinned to the sidebar's
bottom (#124): graph, new page, import, trash — hover hints via
title. The graph is for every member (#112); trash is owner-only. */}
<div className="sidebar__footer">
<Link
to={`/p/${pondSlug}/graph`}
className="icon-button sidebar__graph-link"
title={t('graph:link')}
aria-label={t('graph:link')}
>
<Waypoints aria-hidden />
</Link>
<button
type="button"
className="icon-button sidebar__new-page"
title={t('layout.sidebar.newPageHint')}
aria-label={t('layout.sidebar.newPageHint')}
aria-expanded={creating}
onClick={() => setCreating(!creating)}
>
<FilePlus aria-hidden />
</button>
<ImportControl pondId={pond.id} pondSlug={pondSlug} />
{isOwner && (
<Link
to={`/p/${pondSlug}/trash`}
className="icon-button sidebar__trash-link"
title={t('editor:trash.showLink')}
aria-label={t('editor:trash.showLink')}
>
<Trash2 aria-hidden />
</Link>
)}
</div>
</>
);
}
function PageLink({
page,
pondSlug,
pageSlug,
}: {
page: PageListItemView;
pondSlug: string;
pageSlug: string | null;
}): React.JSX.Element {
return (
<Link
to={`/p/${pondSlug}/${page.slug}`}
className={page.slug === pageSlug ? 'sidebar__page sidebar__page--active' : 'sidebar__page'}
aria-current={page.slug === pageSlug ? 'page' : undefined}
draggable={false}
>
{page.title}
</Link>
);
}
interface PageTreeLevelProps {
nodes: TreeNode<PageListItemView>[];
pondSlug: string;
pageSlug: string | null;
byId: Map<string, LabelView>;
favoriteIds: Set<string>;
collapsedIds: string[];
onToggleCollapsed: (id: string) => void;
canReorder: boolean;
draggedId: string | null;
setDraggedId: (id: string | null) => void;
dropIntoId: string | null;
setDropIntoId: (id: string | null) => void;
onMove: (movedId: string, siblingIds: string[], newIndex: number, title: string) => Promise<void>;
onReparent: (movedId: string, newParentId: string) => Promise<void>;
}
/** Fraction of a row's height (top and bottom) that reads as "drop between";
* the middle band drops INTO the page (reparent, issue #109). */
const EDGE_ZONE = 0.3;
/** One sibling group of the folder view (issue #108), rendered recursively.
* Reordering (buttons and drag-between at the row edges) stays within the
* group; dropping onto a row's middle nests the dragged page under it. */
function PageTreeLevel(props: PageTreeLevelProps): React.JSX.Element {
const { t } = useTranslation();
const {
nodes,
pondSlug,
pageSlug,
byId,
favoriteIds,
collapsedIds,
onToggleCollapsed,
canReorder,
draggedId,
setDraggedId,
dropIntoId,
setDropIntoId,
onMove,
onReparent,
} = props;
const siblingIds = nodes.map((n) => n.id);
function dropZone(event: React.DragEvent<HTMLLIElement>): 'into' | 'between' {
const rect = event.currentTarget.getBoundingClientRect();
const y = event.clientY - rect.top;
return y > rect.height * EDGE_ZONE && y < rect.height * (1 - EDGE_ZONE) ? 'into' : 'between';
}
return (
<>
{nodes.map((node, index) => {
const hasChildren = node.children.length > 0;
const isCollapsed = collapsedIds.includes(node.id);
const itemClasses = [
'sidebar__page-item',
canReorder ? 'sidebar__page-item--draggable' : '',
dropIntoId === node.id ? 'sidebar__page-item--drop-into' : '',
]
.filter(Boolean)
.join(' ');
return (
<li
key={node.id}
className={itemClasses}
draggable={canReorder}
onDragStart={
canReorder
? (event) => {
event.stopPropagation();
setDraggedId(node.id);
event.dataTransfer.effectAllowed = 'move';
}
: undefined
}
onDragEnd={
canReorder
? () => {
setDraggedId(null);
setDropIntoId(null);
}
: undefined
}
onDragOver={
canReorder
? (event) => {
event.preventDefault();
event.stopPropagation();
const into = draggedId && draggedId !== node.id && dropZone(event) === 'into';
setDropIntoId(into ? node.id : null);
}
: undefined
}
onDrop={
canReorder
? (event) => {
event.preventDefault();
event.stopPropagation();
setDropIntoId(null);
if (!draggedId || draggedId === node.id) return;
if (dropZone(event) === 'into') {
// Middle band: nest the dragged page under this one.
void onReparent(draggedId, node.id);
} else {
// Edge bands reorder — inside one sibling group only.
if (!siblingIds.includes(draggedId)) return;
const rect = event.currentTarget.getBoundingClientRect();
const after = event.clientY - rect.top > rect.height / 2;
const target = dropIndex(siblingIds, draggedId, node.id, after);
const title = nodes.find((n) => n.id === draggedId)?.title ?? '';
void onMove(draggedId, siblingIds, target, title);
}
setDraggedId(null);
}
: undefined
}
>
<span className="sidebar__tree-row">
{hasChildren ? (
<button
type="button"
className={
isCollapsed ? 'sidebar__caret sidebar__caret--collapsed' : 'sidebar__caret'
}
aria-expanded={!isCollapsed}
// Deliberately WITHOUT the page title: e2e locators match
// accessible names by substring (#101 convention), and a
// title like "Editor" would collide with the mode toggle.
aria-label={t(
isCollapsed ? 'layout.sidebar.view.expand' : 'layout.sidebar.view.collapseNode',
)}
onClick={() => onToggleCollapsed(node.id)}
>
<ChevronRight aria-hidden />
</button>
) : (
<span className="sidebar__caret sidebar__caret--leaf" aria-hidden />
)}
{/* Favorites carry a golden icon (issue #132). */}
<span
className={
favoriteIds.has(node.id)
? 'sidebar__page-icon sidebar__page-icon--favorite'
: 'sidebar__page-icon'
}
aria-hidden
>
{hasChildren ? isCollapsed ? <Folder /> : <FolderOpen /> : <FileText />}
</span>
<PageLink page={node} pondSlug={pondSlug} pageSlug={pageSlug} />
{canReorder && (
<span className="sidebar__reorder">
<button
type="button"
className="sidebar__reorder-btn"
aria-label={t('layout.sidebar.reorder.up')}
disabled={index === 0}
onClick={() => void onMove(node.id, siblingIds, index - 1, node.title)}
>
</button>
<button
type="button"
className="sidebar__reorder-btn"
aria-label={t('layout.sidebar.reorder.down')}
disabled={index === nodes.length - 1}
onClick={() => void onMove(node.id, siblingIds, index + 1, node.title)}
>
</button>
</span>
)}
<LabelChips labelIds={node.labelIds} byId={byId} />
</span>
{hasChildren && !isCollapsed && (
<ul className="sidebar__pages sidebar__tree-children">
<PageTreeLevel {...props} nodes={node.children} />
</ul>
)}
</li>
);
})}
</>
);
}
/**
* The label view (issue #108): pages grouped under the hierarchical label
* tree, read-only. A page tagged with several labels appears under each;
* untagged pages collect in a trailing "unlabeled" group.
*/
function LabelGroupedPages({
pages,
labels,
pondSlug,
pageSlug,
}: {
pages: PageListItemView[];
labels: LabelView[];
pondSlug: string;
pageSlug: string | null;
}): React.JSX.Element {
const { t } = useTranslation();
const unlabeled = pages.filter((p) => p.labelIds.length === 0);
return (
<div className="sidebar__label-view">
{labels.map((label) => {
const tagged = pages.filter((p) => p.labelIds.includes(label.id));
if (tagged.length === 0) return null;
return (
<section
key={label.id}
className="sidebar__label-group"
style={{ paddingInlineStart: `${(labelDepth(labels, label.id) - 1) * 0.75}rem` }}
>
<h3 className="sidebar__label-heading">
<span
className="label-chip__swatch"
style={{ backgroundColor: label.color }}
aria-hidden
/>
{label.name}
</h3>
<ul className="sidebar__pages">
{tagged.map((p) => (
<li key={p.id} className="sidebar__page-item">
<PageLink page={p} pondSlug={pondSlug} pageSlug={pageSlug} />
</li>
))}
</ul>
</section>
);
})}
{unlabeled.length > 0 && (
<section className="sidebar__label-group sidebar__label-group--unlabeled">
<h3 className="sidebar__label-heading">{t('layout.sidebar.view.unlabeled')}</h3>
<ul className="sidebar__pages">
{unlabeled.map((p) => (
<li key={p.id} className="sidebar__page-item">
<PageLink page={p} pondSlug={pondSlug} pageSlug={pageSlug} />
</li>
))}
</ul>
</section>
)}
</div>
);
}