#300: route icon-only controls through IconButton/IconLink #308
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.
|
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).
|
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user