#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
Showing only changes of commit 5a4a99196e - Show all commits

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. machen — betroffene Specs mit anpassen (scopen), nicht das Label opfern.
Verstöße gelten in Review und Abnahme als Funktionsfehler. 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 { usePondLabels } from '../labels/use-pond-labels';
import { usePondMembers } from '../members/use-pond-members'; import { usePondMembers } from '../members/use-pond-members';
import { useAccessRules, useAccessRuleMutations } from './use-access-rules'; import { useAccessRules, useAccessRuleMutations } from './use-access-rules';
import { IconButton } from '../components/IconButton';
type ScopedType = 'label' | 'page'; type ScopedType = 'label' | 'page';
@ -238,15 +239,14 @@ export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.El
{error} {error}
</p> </p>
)} )}
<button <IconButton
className="icon-button rule-add__submit" className="rule-add__submit"
type="submit" type="submit"
disabled={!canSubmit} disabled={!canSubmit}
aria-label={t('add.submit')} label={t('add.submit')}
title={t('add.submit')}
> >
<Plus aria-hidden /> <Plus aria-hidden />
</button> </IconButton>
</form> </form>
{rules.length === 0 ? ( {rules.length === 0 ? (
@ -260,15 +260,13 @@ export function AccessRulesManager({ pondId }: { pondId: string }): React.JSX.El
{group.rules.map((rule) => ( {group.rules.map((rule) => (
<li key={rule.id} className="rule-item"> <li key={rule.id} className="rule-item">
<span className="rule-sentence">{ruleSentence(rule, t)}</span> <span className="rule-sentence">{ruleSentence(rule, t)}</span>
<button <IconButton
type="button" className="rule-remove"
className="icon-button rule-remove" label={t('remove')}
aria-label={t('remove')}
title={t('remove')}
onClick={() => void mutations.remove(rule.id)} onClick={() => void mutations.remove(rule.id)}
> >
<Trash2 aria-hidden /> <Trash2 aria-hidden />
</button> </IconButton>
</li> </li>
))} ))}
</ul> </ul>

View File

@ -1,4 +1,14 @@
import type { ButtonHTMLAttributes } from 'react'; 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> { interface IconButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
/** Localized accessible name; also shown as the hover tooltip. */ /** Localized accessible name; also shown as the hover tooltip. */
@ -15,12 +25,42 @@ export function IconButton({
children, children,
...rest ...rest
}: IconButtonProps): React.JSX.Element { }: IconButtonProps): React.JSX.Element {
const classes = ['icon-button', active ? 'icon-button--active' : '', className ?? '']
.filter(Boolean)
.join(' ');
return ( 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} {children}
</button> </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 { apiDelete, apiGet } from '../lib/api';
import { fileGlyph, formatBytes, mediaUrl } from './file-format'; 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 * 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} ·{' '} {formatBytes(item.sizeBytes)} · {item.uploaderName} ·{' '}
{item.pageTitle ?? <span className="attachments-item__orphan">{t('orphan')}</span>} {item.pageTitle ?? <span className="attachments-item__orphan">{t('orphan')}</span>}
</span> </span>
<button <IconButton
type="button" className="attachments-item__delete"
className="icon-button attachments-item__delete" label={t('delete')}
aria-label={t('delete')}
title={t('delete')}
onClick={() => void remove(item.id)} onClick={() => void remove(item.id)}
> >
<Trash2 aria-hidden /> <Trash2 aria-hidden />
</button> </IconButton>
</li> </li>
))} ))}
</ul> </ul>

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import { Link } from 'react-router-dom';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { ApiError, apiDelete, apiGet, apiPost } from '../lib/api'; import { ApiError, apiDelete, apiGet, apiPost } from '../lib/api';
import { useLabelMutations, usePondLabels } from './use-pond-labels'; import { useLabelMutations, usePondLabels } from './use-pond-labels';
import { IconButton } from '../components/IconButton';
/** /**
* Page label picker (issue #44): a searchable, hierarchy-aware multi-select of * Page label picker (issue #44): a searchable, hierarchy-aware multi-select of
@ -161,15 +162,14 @@ export function LabelPicker({
aria-label={t('settings.newRootPlaceholder')} aria-label={t('settings.newRootPlaceholder')}
onChange={(event) => setNewName(event.target.value)} onChange={(event) => setNewName(event.target.value)}
/> />
<button <IconButton
className="label-picker__create-submit"
type="submit" type="submit"
className="icon-button label-picker__create-submit"
disabled={!newName.trim()} disabled={!newName.trim()}
aria-label={t('settings.add')} label={t('settings.add')}
title={t('settings.add')}
> >
<Plus aria-hidden /> <Plus aria-hidden />
</button> </IconButton>
</form> </form>
{createError && ( {createError && (
<p className="label-picker__error" role="alert"> <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 { useAuth } from '../auth/auth-context';
import { FormError } from '../components/forms'; import { FormError } from '../components/forms';
import { IconButton, IconLink } from '../components/IconButton';
import { usePageFavorites } from '../favorites/use-favorites'; import { usePageFavorites } from '../favorites/use-favorites';
import { ImportControl } from '../import/ImportControl'; import { ImportControl } from '../import/ImportControl';
import { LabelChips } from '../labels/LabelChips'; import { LabelChips } from '../labels/LabelChips';
@ -395,34 +396,30 @@ function SidebarContent({
bottom (#124): graph, new page, import, trash hover hints via bottom (#124): graph, new page, import, trash hover hints via
title. The graph is for every member (#112); trash is owner-only. */} title. The graph is for every member (#112); trash is owner-only. */}
<div className="sidebar__footer"> <div className="sidebar__footer">
<Link <IconLink
to={`/p/${pondSlug}/graph`} to={`/p/${pondSlug}/graph`}
className="icon-button sidebar__graph-link" className="sidebar__graph-link"
title={t('graph:link')} label={t('graph:link')}
aria-label={t('graph:link')}
> >
<Waypoints aria-hidden /> <Waypoints aria-hidden />
</Link> </IconLink>
<button <IconButton
type="button" className="sidebar__new-page"
className="icon-button sidebar__new-page" label={t('layout.sidebar.newPageHint')}
title={t('layout.sidebar.newPageHint')}
aria-label={t('layout.sidebar.newPageHint')}
aria-expanded={creating} aria-expanded={creating}
onClick={() => setCreating(!creating)} onClick={() => setCreating(!creating)}
> >
<FilePlus aria-hidden /> <FilePlus aria-hidden />
</button> </IconButton>
<ImportControl pondId={pond.id} pondSlug={pondSlug} /> <ImportControl pondId={pond.id} pondSlug={pondSlug} />
{isOwner && ( {isOwner && (
<Link <IconLink
to={`/p/${pondSlug}/trash`} to={`/p/${pondSlug}/trash`}
className="icon-button sidebar__trash-link" className="sidebar__trash-link"
title={t('editor:trash.showLink')} label={t('editor:trash.showLink')}
aria-label={t('editor:trash.showLink')}
> >
<Trash2 aria-hidden /> <Trash2 aria-hidden />
</Link> </IconLink>
)} )}
</div> </div>
</> </>

View File

@ -6,7 +6,7 @@ import { useTranslation } from 'react-i18next';
import { Link, useNavigate } from 'react-router-dom'; import { Link, useNavigate } from 'react-router-dom';
import { useAuth } from '../auth/auth-context'; import { useAuth } from '../auth/auth-context';
import { IconButton } from '../components/IconButton'; import { IconButton, IconLink } from '../components/IconButton';
import { apiGet } from '../lib/api'; import { apiGet } from '../lib/api';
import { isTypingTarget } from '../lib/keyboard'; import { isTypingTarget } from '../lib/keyboard';
import { useDismissable } from '../lib/use-dismissable'; import { useDismissable } from '../lib/use-dismissable';
@ -94,28 +94,25 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
return ( return (
<header className="topbar"> <header className="topbar">
<button <IconButton
type="button"
className="icon-button"
onClick={onToggleSidebar} onClick={onToggleSidebar}
aria-expanded={!sidebarCollapsed} 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 /> <Menu aria-hidden />
</button> </IconButton>
<Link to="/" className="topbar__brand"> <Link to="/" className="topbar__brand">
Dorfteich Dorfteich
</Link> </Link>
{user && <PondSwitcher />} {user && <PondSwitcher />}
{isPondOwner && pondSlug && ( {isPondOwner && pondSlug && (
<Link <IconLink
to={`/p/${pondSlug}/settings`} to={`/p/${pondSlug}/settings`}
className="icon-button topbar__pond-settings" className="topbar__pond-settings"
aria-label={t('labels:link')} label={t('labels:link')}
title={t('labels:link')}
> >
<Settings aria-hidden /> <Settings aria-hidden />
</Link> </IconLink>
)} )}
<span className="topbar__spacer" /> <span className="topbar__spacer" />
{/* Page-scoped slots, rendered only for signed-in users: live presence {/* 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 { ApiError } from '../lib/api';
import { useMemberMutations, usePondMembers } from './use-pond-members'; import { useMemberMutations, usePondMembers } from './use-pond-members';
import { IconButton } from '../components/IconButton';
/** Turns an ApiError code into a translated message; other errors are generic. */ /** Turns an ApiError code into a translated message; other errors are generic. */
function useErrorText(): (error: unknown) => string { function useErrorText(): (error: unknown) => string {
@ -140,15 +141,14 @@ export function MemberManager({ pondId }: { pondId: string }): React.JSX.Element
))} ))}
</select> </select>
</label> </label>
<button <IconButton
className="icon-button member-add__submit" className="member-add__submit"
type="submit" type="submit"
disabled={addBlockedByQuota} disabled={addBlockedByQuota}
aria-label={t('add.submit')} label={t('add.submit')}
title={t('add.submit')}
> >
<UserPlus aria-hidden /> <UserPlus aria-hidden />
</button> </IconButton>
{addBlockedByQuota && ( {addBlockedByQuota && (
<p className="member-add__quota-full" role="note"> <p className="member-add__quota-full" role="note">
{t('add.quotaFull', { role: roleLabel(addRole) })} {t('add.quotaFull', { role: roleLabel(addRole) })}
@ -238,15 +238,9 @@ function MemberRow({
))} ))}
</select> </select>
</label> </label>
<button <IconButton className="member-row__remove" label={t('actions.remove')} onClick={onRemove}>
type="button"
className="icon-button member-row__remove"
aria-label={t('actions.remove')}
title={t('actions.remove')}
onClick={onRemove}
>
<UserMinus aria-hidden /> <UserMinus aria-hidden />
</button> </IconButton>
</span> </span>
) : ( ) : (
<span className="member-row__role-label">{roleLabel(member.role)}</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 { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { IconButton } from '../components/IconButton';
import { apiGet, apiPost } from '../lib/api'; import { apiGet, apiPost } from '../lib/api';
import { useDismissable } from '../lib/use-dismissable'; import { useDismissable } from '../lib/use-dismissable';
@ -48,18 +49,22 @@ export function NotificationsBell(): React.JSX.Element {
return ( return (
<div className="notifications-bell" ref={bellRef}> <div className="notifications-bell" ref={bellRef}>
<button <IconButton
type="button" // The unread count belongs in the accessible name (issue #300): the
className="notifications-bell__button" // 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-haspopup="menu"
aria-expanded={open} aria-expanded={open}
aria-label={t('title')}
title={t('title')}
onClick={() => setOpen((value) => !value)} onClick={() => setOpen((value) => !value)}
> >
<Bell aria-hidden /> <Bell aria-hidden />
{unread > 0 && <span className="notifications-bell__badge">{unread}</span>} {unread > 0 && (
</button> <span className="notifications-bell__badge" aria-hidden>
{unread}
</span>
)}
</IconButton>
{open && ( {open && (
<div className="notifications-bell__dropdown" role="menu"> <div className="notifications-bell__dropdown" role="menu">
<div className="notifications-bell__header"> <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 { FormError } from '../components/forms';
import { apiDelete, apiGet, apiPatch, apiUploadFile } from '../lib/api'; import { apiDelete, apiGet, apiPatch, apiUploadFile } from '../lib/api';
import { IconButton } from '../components/IconButton';
/** /**
* Site Admin plugin administration (issue #72): the installed-plugin list with * 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`}> <Link className="plugin-manager__preview" to={`/admin/plugins/${plugin.id}/preview`}>
{t('admin.preview')} {t('admin.preview')}
</Link> </Link>
<button <IconButton
type="button" className="plugin-manager__uninstall"
className="icon-button plugin-manager__uninstall"
disabled={plugin.mode === 'required'} 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={ title={
plugin.mode === 'required' ? t('admin.requiredLocked') : t('admin.uninstall') plugin.mode === 'required' ? t('admin.requiredLocked') : t('admin.uninstall')
} }
onClick={() => uninstall.mutate(plugin.id)} onClick={() => uninstall.mutate(plugin.id)}
> >
<Trash2 aria-hidden /> <Trash2 aria-hidden />
</button> </IconButton>
</div> </div>
</li> </li>
))} ))}

View File

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

View File

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

View File

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

View File

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