#300: route icon-only controls through IconButton/IconLink #308

Merged
opus-5 merged 1 commits from issue-300-icon-buttons into main 2026-08-01 07:14:28 +02:00
17 changed files with 164 additions and 133 deletions

View File

@ -27,13 +27,3 @@ AA) — nicht nachträglich. Kurzfassung; Details und Begründung in
machen — betroffene Specs mit anpassen (scopen), nicht das Label opfern.
Verstöße gelten in Review und Abnahme als Funktionsfehler.
## graphify
This project has a knowledge graph at graphify-out/ with god nodes, community structure, and cross-file relationships.
Rules:
- For codebase questions, first run `graphify query "<question>"` when graphify-out/graph.json exists. Use `graphify path "<A>" "<B>"` for relationships and `graphify explain "<concept>"` for focused concepts. These return a scoped subgraph, usually much smaller than GRAPH_REPORT.md or raw grep output.
- If graphify-out/wiki/index.md exists, use it for broad navigation instead of raw source browsing.
- Read graphify-out/GRAPH_REPORT.md only for broad architecture review or when query/path/explain do not surface enough context.
- After modifying code, run `graphify update .` to keep the graph current (AST-only, no API cost).

View File

@ -16,6 +16,7 @@ import { ApiError, apiGet } from '../lib/api';
import { usePondLabels } from '../labels/use-pond-labels';
import { usePondMembers } from '../members/use-pond-members';
import { useAccessRules, useAccessRuleMutations } from './use-access-rules';
import { IconButton } from '../components/IconButton';
type ScopedType = 'label' | 'page';
@ -238,15 +239,14 @@ export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.El
{error}
</p>
)}
<button
className="icon-button rule-add__submit"
<IconButton
className="rule-add__submit"
type="submit"
disabled={!canSubmit}
aria-label={t('add.submit')}
title={t('add.submit')}
label={t('add.submit')}
>
<Plus aria-hidden />
</button>
</IconButton>
</form>
{rules.length === 0 ? (
@ -260,15 +260,13 @@ export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.El
{group.rules.map((rule) => (
<li key={rule.id} className="rule-item">
<span className="rule-sentence">{ruleSentence(rule, t)}</span>
<button
type="button"
className="icon-button rule-remove"
aria-label={t('remove')}
title={t('remove')}
<IconButton
className="rule-remove"
label={t('remove')}
onClick={() => void mutations.remove(rule.id)}
>
<Trash2 aria-hidden />
</button>
</IconButton>
</li>
))}
</ul>

View File

@ -1,4 +1,14 @@
import type { ButtonHTMLAttributes } from 'react';
import { Link, type LinkProps } from 'react-router-dom';
/** The shared class list behind both controls (issue #300): one place decides
* what an icon-only control looks like, so the box, the icon size, the hover
* and the focus ring cannot drift apart between a button and a link. */
function iconClasses(active: boolean | undefined, className: string | undefined): string {
return ['icon-button', active ? 'icon-button--active' : '', className ?? '']
.filter(Boolean)
.join(' ');
}
interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Localized accessible name; also shown as the hover tooltip. */
@ -15,12 +25,42 @@ export function IconButton({
children,
...rest
}: IconButtonProps): React.JSX.Element {
const classes = ['icon-button', active ? 'icon-button--active' : '', className ?? '']
.filter(Boolean)
.join(' ');
return (
<button type="button" className={classes} aria-label={label} title={label} {...rest}>
<button
type="button"
className={iconClasses(active, className)}
aria-label={label}
title={label}
{...rest}
>
{children}
</button>
);
}
interface IconLinkProps extends LinkProps {
/** Localized accessible name; also shown as the hover tooltip. */
label: string;
active?: boolean;
}
/**
* The navigating twin of {@link IconButton} (issue #300). An icon-only control
* that goes somewhere is a link, not a button but it has to look and focus
* exactly like one, which is why both share {@link iconClasses}. Without it the
* three navigating icons (pond settings, graph, trash) stayed the one group
* that had to glue the class on by hand.
*/
export function IconLink({
label,
active,
className,
children,
...rest
}: IconLinkProps): React.JSX.Element {
return (
<Link className={iconClasses(active, className)} aria-label={label} title={label} {...rest}>
{children}
</Link>
);
}

View File

@ -8,6 +8,7 @@ import { FormError } from '../components/forms';
import { apiDelete, apiGet } from '../lib/api';
import { fileGlyph, formatBytes, mediaUrl } from './file-format';
import { IconButton } from '../components/IconButton';
/**
* Pond-wide file manager (issue #61), Pond-Admin-gated in the api. Lists every
@ -69,15 +70,13 @@ export function PondFileManager({ pondId }: { pondId: string }): React.JSX.Eleme
{formatBytes(item.sizeBytes)} · {item.uploaderName} ·{' '}
{item.pageTitle ?? <span className="attachments-item__orphan">{t('orphan')}</span>}
</span>
<button
type="button"
className="icon-button attachments-item__delete"
aria-label={t('delete')}
title={t('delete')}
<IconButton
className="attachments-item__delete"
label={t('delete')}
onClick={() => void remove(item.id)}
>
<Trash2 aria-hidden />
</button>
</IconButton>
</li>
))}
</ul>

View File

@ -4,6 +4,7 @@ import { useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useImport } from './use-import';
import { IconButton } from '../components/IconButton';
interface ImportControlProps {
pondId: string;
@ -27,15 +28,13 @@ export function ImportControl({ pondId, pondSlug }: ImportControlProps): React.J
return (
<div className="sidebar__import">
<button
type="button"
className="icon-button sidebar__import-action"
title={t('action')}
aria-label={t('action')}
<IconButton
className="sidebar__import-action"
label={t('action')}
onClick={() => inputRef.current?.click()}
>
<Import aria-hidden />
</button>
</IconButton>
<input
ref={inputRef}
type="file"

View File

@ -6,6 +6,7 @@ import { useTranslation } from 'react-i18next';
import { ApiError } from '../lib/api';
import { useLabelMutations, usePondLabels } from './use-pond-labels';
import { IconButton } from '../components/IconButton';
/** Turns an ApiError code into a translated message; other errors are generic. */
function useErrorText(): (error: unknown) => string {
@ -72,15 +73,9 @@ export function LabelManager({ pondId }: { pondId: string }): React.JSX.Element
placeholder={t('settings.newRootPlaceholder')}
aria-label={t('settings.newRootPlaceholder')}
/>
<button
type="submit"
className="icon-button"
disabled={!newRoot.trim()}
aria-label={t('settings.add')}
title={t('settings.add')}
>
<IconButton type="submit" disabled={!newRoot.trim()} label={t('settings.add')}>
<Plus aria-hidden />
</button>
</IconButton>
</form>
{error && (
@ -250,15 +245,13 @@ function LabelNode({
>
{t('settings.addChild')}
</button>
<button
type="button"
className="icon-button label-node__delete"
aria-label={t('settings.delete')}
title={t('settings.delete')}
<IconButton
className="label-node__delete"
label={t('settings.delete')}
onClick={() => void remove()}
>
<Trash2 aria-hidden />
</button>
</IconButton>
</div>
</div>
@ -278,15 +271,9 @@ function LabelNode({
aria-label={t('settings.addChild')}
onChange={(event) => setChildName(event.target.value)}
/>
<button
type="submit"
className="icon-button"
disabled={!childName.trim()}
aria-label={t('settings.add')}
title={t('settings.add')}
>
<IconButton type="submit" disabled={!childName.trim()} label={t('settings.add')}>
<Plus aria-hidden />
</button>
</IconButton>
<button type="button" className="linklike" onClick={() => setAddingChild(false)}>
{t('settings.cancel')}
</button>

View File

@ -9,6 +9,7 @@ import { Link } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { ApiError, apiDelete, apiGet, apiPost } from '../lib/api';
import { useLabelMutations, usePondLabels } from './use-pond-labels';
import { IconButton } from '../components/IconButton';
/**
* Page label picker (issue #44): a searchable, hierarchy-aware multi-select of
@ -161,15 +162,14 @@ export function LabelPicker({
aria-label={t('settings.newRootPlaceholder')}
onChange={(event) => setNewName(event.target.value)}
/>
<button
<IconButton
className="label-picker__create-submit"
type="submit"
className="icon-button label-picker__create-submit"
disabled={!newName.trim()}
aria-label={t('settings.add')}
title={t('settings.add')}
label={t('settings.add')}
>
<Plus aria-hidden />
</button>
</IconButton>
</form>
{createError && (
<p className="label-picker__error" role="alert">

View File

@ -24,6 +24,7 @@ import { Link } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms';
import { IconButton, IconLink } from '../components/IconButton';
import { usePageFavorites } from '../favorites/use-favorites';
import { ImportControl } from '../import/ImportControl';
import { LabelChips } from '../labels/LabelChips';
@ -395,34 +396,30 @@ function SidebarContent({
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
<IconLink
to={`/p/${pondSlug}/graph`}
className="icon-button sidebar__graph-link"
title={t('graph:link')}
aria-label={t('graph:link')}
className="sidebar__graph-link"
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')}
</IconLink>
<IconButton
className="sidebar__new-page"
label={t('layout.sidebar.newPageHint')}
aria-expanded={creating}
onClick={() => setCreating(!creating)}
>
<FilePlus aria-hidden />
</button>
</IconButton>
<ImportControl pondId={pond.id} pondSlug={pondSlug} />
{isOwner && (
<Link
<IconLink
to={`/p/${pondSlug}/trash`}
className="icon-button sidebar__trash-link"
title={t('editor:trash.showLink')}
aria-label={t('editor:trash.showLink')}
className="sidebar__trash-link"
label={t('editor:trash.showLink')}
>
<Trash2 aria-hidden />
</Link>
</IconLink>
)}
</div>
</>

View File

@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/auth-context';
import { IconButton } from '../components/IconButton';
import { IconButton, IconLink } from '../components/IconButton';
import { apiGet } from '../lib/api';
import { isTypingTarget } from '../lib/keyboard';
import { useDismissable } from '../lib/use-dismissable';
@ -94,28 +94,25 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
return (
<header className="topbar">
<button
type="button"
className="icon-button"
<IconButton
onClick={onToggleSidebar}
aria-expanded={!sidebarCollapsed}
aria-label={sidebarCollapsed ? t('layout.sidebar.expand') : t('layout.sidebar.collapse')}
label={sidebarCollapsed ? t('layout.sidebar.expand') : t('layout.sidebar.collapse')}
>
<Menu aria-hidden />
</button>
</IconButton>
<Link to="/" className="topbar__brand">
Dorfteich
</Link>
{user && <PondSwitcher />}
{isPondOwner && pondSlug && (
<Link
<IconLink
to={`/p/${pondSlug}/settings`}
className="icon-button topbar__pond-settings"
aria-label={t('labels:link')}
title={t('labels:link')}
className="topbar__pond-settings"
label={t('labels:link')}
>
<Settings aria-hidden />
</Link>
</IconLink>
)}
<span className="topbar__spacer" />
{/* Page-scoped slots, rendered only for signed-in users: live presence

View File

@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { ApiError } from '../lib/api';
import { useMemberMutations, usePondMembers } from './use-pond-members';
import { IconButton } from '../components/IconButton';
/** Turns an ApiError code into a translated message; other errors are generic. */
function useErrorText(): (error: unknown) => string {
@ -140,15 +141,14 @@ export function MemberManager({ pondId }: { pondId: string }): React.JSX.Element
))}
</select>
</label>
<button
className="icon-button member-add__submit"
<IconButton
className="member-add__submit"
type="submit"
disabled={addBlockedByQuota}
aria-label={t('add.submit')}
title={t('add.submit')}
label={t('add.submit')}
>
<UserPlus aria-hidden />
</button>
</IconButton>
{addBlockedByQuota && (
<p className="member-add__quota-full" role="note">
{t('add.quotaFull', { role: roleLabel(addRole) })}
@ -238,15 +238,9 @@ function MemberRow({
))}
</select>
</label>
<button
type="button"
className="icon-button member-row__remove"
aria-label={t('actions.remove')}
title={t('actions.remove')}
onClick={onRemove}
>
<IconButton className="member-row__remove" label={t('actions.remove')} onClick={onRemove}>
<UserMinus aria-hidden />
</button>
</IconButton>
</span>
) : (
<span className="member-row__role-label">{roleLabel(member.role)}</span>

View File

@ -5,6 +5,7 @@ import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { IconButton } from '../components/IconButton';
import { apiGet, apiPost } from '../lib/api';
import { useDismissable } from '../lib/use-dismissable';
@ -48,18 +49,22 @@ export function NotificationsBell(): React.JSX.Element {
return (
<div className="notifications-bell" ref={bellRef}>
<button
type="button"
className="notifications-bell__button"
<IconButton
// The unread count belongs in the accessible name (issue #300): the
// badge sits inside the control, so aria-label would otherwise hide it
// and a screen reader announced only "Notifications", never how many.
label={unread > 0 ? t('titleUnread', { count: unread }) : t('title')}
aria-haspopup="menu"
aria-expanded={open}
aria-label={t('title')}
title={t('title')}
onClick={() => setOpen((value) => !value)}
>
<Bell aria-hidden />
{unread > 0 && <span className="notifications-bell__badge">{unread}</span>}
</button>
{unread > 0 && (
<span className="notifications-bell__badge" aria-hidden>
{unread}
</span>
)}
</IconButton>
{open && (
<div className="notifications-bell__dropdown" role="menu">
<div className="notifications-bell__header">

View File

@ -8,6 +8,7 @@ import { PLUGIN_INSTANCE_MODES, type PluginInstanceMode, type PluginView } from
import { FormError } from '../components/forms';
import { apiDelete, apiGet, apiPatch, apiUploadFile } from '../lib/api';
import { IconButton } from '../components/IconButton';
/**
* Site Admin plugin administration (issue #72): the installed-plugin list with
@ -190,18 +191,20 @@ export function PluginManager(): React.JSX.Element {
<Link className="plugin-manager__preview" to={`/admin/plugins/${plugin.id}/preview`}>
{t('admin.preview')}
</Link>
<button
type="button"
className="icon-button plugin-manager__uninstall"
<IconButton
className="plugin-manager__uninstall"
disabled={plugin.mode === 'required'}
aria-label={t('admin.uninstall')}
label={t('admin.uninstall')}
// A required plugin explains in the tooltip why it cannot be
// removed, so the title deliberately differs from the name;
// IconButton spreads rest last, which lets it through.
title={
plugin.mode === 'required' ? t('admin.requiredLocked') : t('admin.uninstall')
}
onClick={() => uninstall.mutate(plugin.id)}
>
<Trash2 aria-hidden />
</button>
</IconButton>
</div>
</li>
))}

View File

@ -3811,19 +3811,15 @@ ul[data-type='task_list'] li p:last-of-type {
position: relative;
}
.notifications-bell__button {
background: none;
border: none;
cursor: pointer;
font-size: 1.1rem;
position: relative;
padding: var(--space-1);
}
/* The bell is an `.icon-button` like search and the theme toggle (issue
#300) it used to carry its own rules, which lacked the flex centring
and the icon size, so the glyph sat on the text baseline and rendered at
lucide's 24px default. The badge stays positioned against that button;
`.icon-button` is `position: relative` for exactly this. */
.notifications-bell__badge {
position: absolute;
top: -2px;
right: -4px;
top: -1px;
right: -3px;
background: var(--color-danger);
color: var(--color-danger-contrast);
border-radius: 999px;

View File

@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { apiDelete, apiGet } from '../lib/api';
import { IconButton } from '../components/IconButton';
/**
* The account's watch list (issue #93): everything the user follows, with
@ -42,15 +43,13 @@ export function WatchesSection(): React.JSX.Element {
{watch.name}
</Link>
<span className="watches-list__type">{t(`settings.types.${watch.targetType}`)}</span>
<button
type="button"
className="icon-button watches-list__unwatch"
aria-label={t('settings.unwatch')}
title={t('settings.unwatch')}
<IconButton
className="watches-list__unwatch"
label={t('settings.unwatch')}
onClick={() => void unwatch(watch.targetType, watch.targetId)}
>
<EyeOff aria-hidden />
</button>
</IconButton>
</li>
))}
</ul>

View File

@ -52,6 +52,31 @@ export default tseslint.config(
},
},
},
{
// Icon-only controls go through the shared components (issue #300).
// Gluing `icon-button` onto a raw element copies the looks but skips the
// contract that guarantees an accessible name — that is how the
// notification bell drifted into its own size and focus ring.
files: ['apps/web/src/**/*.tsx'],
ignores: ['apps/web/src/components/IconButton.tsx'],
rules: {
'no-restricted-syntax': [
'error',
{
selector:
'JSXOpeningElement[name.name=/^(button|a|Link)$/] > JSXAttribute[name.name="className"] > Literal[value=/(^|\\s)icon-button(\\s|$)/]',
message:
'Use <IconButton> (or <IconLink> for navigation) from components/IconButton instead of putting the icon-button class on a raw element — the component enforces the accessible name.',
},
{
selector:
'JSXOpeningElement[name.name=/^(button|a|Link)$/] > JSXAttribute[name.name="className"] > JSXExpressionContainer > TemplateLiteral > TemplateElement[value.raw=/(^|\\s)icon-button(\\s|$)/]',
message:
'Use <IconButton> (or <IconLink> for navigation) from components/IconButton instead of putting the icon-button class on a raw element — the component enforces the accessible name.',
},
],
},
},
{
rules: {
// Unused values are usually bugs; underscore-prefix marks intentional ones.

View File

@ -1,5 +1,6 @@
{
"title": "Benachrichtigungen",
"titleUnread": "Benachrichtigungen, {{count}} ungelesen",
"markAllRead": "Alle als gelesen markieren",
"empty": "Noch keine Benachrichtigungen.",
"someone": "Jemand",

View File

@ -1,5 +1,6 @@
{
"title": "Notifications",
"titleUnread": "Notifications, {{count}} unread",
"markAllRead": "Mark all read",
"empty": "No notifications yet.",
"someone": "Someone",