#300: route icon-only controls through IconButton/IconLink
All checks were successful
CI / Auth e2e pack (pull_request) Successful in 8m36s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CI / Lint, typecheck, test (pull_request) Successful in 6m22s
CI / Build container images (pull_request) Successful in 3m51s
CD / Build and push images (push) Successful in 15s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m32s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m25s
CI / Import/export fidelity gate (push) Successful in 58s
All checks were successful
CI / Auth e2e pack (pull_request) Successful in 8m36s
CI / Import/export fidelity gate (pull_request) Successful in 58s
CI / Lint, typecheck, test (pull_request) Successful in 6m22s
CI / Build container images (pull_request) Successful in 3m51s
CD / Build and push images (push) Successful in 15s
CD / Deploy to Test (push) Successful in 16s
CD / Smoke tests against Test (push) Successful in 1m16s
CD / Promote to Int (push) Successful in 13s
CI / Lint, typecheck, test (push) Successful in 6m32s
CI / Build container images (push) Has been skipped
CI / Auth e2e pack (push) Successful in 8m25s
CI / Import/export fidelity gate (push) Successful in 58s
The notification bell sat higher and larger than search and the theme toggle next to it. The cause was not the glyph: `.notifications-bell__button` carried its own rules with neither flex centring nor an icon size, so the svg was laid out inline on the text baseline and rendered at lucide's 24px default instead of the 1.15rem the shared `.icon-button` enforces. Route every icon-only control through the shared components instead: - `IconLink` joins `IconButton`, sharing one class helper. Three controls navigate (pond settings, graph, trash) and are links, not buttons — without a link twin they would have stayed the one group gluing the class on by hand. - 17 hand-applied `className="icon-button …"` usages across nine files now go through the components, which is what enforces the accessible name on a control that shows only an icon. - The bell's unread count reaches assistive technology. The badge sits inside the control, so `aria-label` hid it and a screen reader announced "Notifications" without ever saying how many. An ESLint rule keeps it that way: `icon-button` on a raw button, anchor or Link is now an error, in both string and template-literal form. The plugin uninstall button keeps a title that differs from its name (it explains why a required plugin is locked); IconButton spreads rest last, so the explicit title still wins. Also drops the graphify block from CLAUDE.md — it duplicates the workspace-level instructions.
This commit is contained in:
parent
1f56f34113
commit
5a4a99196e
10
CLAUDE.md
10
CLAUDE.md
@ -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).
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
</>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"title": "Benachrichtigungen",
|
||||
"titleUnread": "Benachrichtigungen, {{count}} ungelesen",
|
||||
"markAllRead": "Alle als gelesen markieren",
|
||||
"empty": "Noch keine Benachrichtigungen.",
|
||||
"someone": "Jemand",
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
{
|
||||
"title": "Notifications",
|
||||
"titleUnread": "Notifications, {{count}} unread",
|
||||
"markAllRead": "Mark all read",
|
||||
"empty": "No notifications yet.",
|
||||
"someone": "Someone",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user