#170: Statusmeldungen, Einzeltasten-Shortcuts, Bewegung
Toast-Standzeit 2,5s auf 6s (WCAG 2.2.1 — für Screenreader-/Zoom-Nutzer kaum erfassbar). Neue Einstellungs-Sektion Bedienung mit dem Schalter Einzeltasten-Kürzel deaktivieren (lokale Geräte-Einstellung); die Handler von e und / prüfen sie beim Tastendruck (WCAG 2.1.4). prefers-reduced-motion: CSS-Transitions kollabieren auf instant, die Graph-Simulation rechnet ihr Layout synchron zu Ende statt zu animieren (WCAG 2.2.2). settings-nav-Spec auf 8 Sektionen nachgeführt. Bewusst KEIN zusätzliches role=status (legal.spec-Locator-Falle). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AGM8jo3hwoV9wsCVGfy8iq
This commit is contained in:
parent
58c19abfdd
commit
31b59f0fb6
@ -28,7 +28,8 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
|
||||
await expect(nav).toBeVisible();
|
||||
const links = nav.locator('.settings-nav__link');
|
||||
// Profile, password, sessions, watches, API tokens, feed tokens, data export.
|
||||
await expect(links).toHaveCount(7);
|
||||
// 8 seit #170 (neue Bedienungs-Sektion).
|
||||
await expect(links).toHaveCount(8);
|
||||
|
||||
// Jump to the last section: it scrolls into view and becomes active.
|
||||
const last = links.last();
|
||||
|
||||
@ -18,7 +18,9 @@ interface ToastItem {
|
||||
variant: ToastVariant;
|
||||
}
|
||||
|
||||
const TOAST_DURATION_MS = 2500;
|
||||
// 6 s statt 2,5 s (issue #170, WCAG 2.2.1): kurzlebige Statusmeldungen
|
||||
// waren für Screenreader-/Vergrößerungs-Nutzer kaum erfassbar.
|
||||
const TOAST_DURATION_MS = 6000;
|
||||
|
||||
const ToastContext = createContext<ShowToast>(() => {});
|
||||
|
||||
|
||||
@ -156,8 +156,18 @@ export function ForceGraph({
|
||||
// Settle noticeably faster than d3's default so e2e clicks and the
|
||||
// reading eye get a resting layout within a couple of seconds.
|
||||
.alphaDecay(0.04)
|
||||
.alpha(previous.size > 0 ? 0.5 : 1)
|
||||
.on('tick', applyPositions);
|
||||
.alpha(previous.size > 0 ? 0.5 : 1);
|
||||
// prefers-reduced-motion (issue #170, WCAG 2.2.2): das Layout wird
|
||||
// synchron zu Ende gerechnet und einmal gemalt statt zu animieren.
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
simulation.stop();
|
||||
simulation.tick(200);
|
||||
// applyPositions läuft nach dem Mount der SVG-Knoten (unten im
|
||||
// Layout-Effekt ohnehin einmal aufgerufen über den ersten Paint).
|
||||
requestAnimationFrame(applyPositions);
|
||||
} else {
|
||||
simulation.on('tick', applyPositions);
|
||||
}
|
||||
simRef.current = simulation;
|
||||
simNodesRef.current = new Map(simNodes.map((n) => [n.id, n]));
|
||||
return () => {
|
||||
|
||||
@ -15,6 +15,7 @@ import { usePageActionsSlot } from './page-actions';
|
||||
import { PondSwitcher } from './PondSwitcher';
|
||||
import { useCurrentPondRoute } from './use-pond-route';
|
||||
|
||||
import { singleKeyShortcutsDisabled } from '../lib/single-key-shortcuts';
|
||||
interface TopBarProps {
|
||||
sidebarCollapsed: boolean;
|
||||
onToggleSidebar: () => void;
|
||||
@ -49,7 +50,12 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
||||
useEffect(() => {
|
||||
if (!user) return undefined;
|
||||
const onKeyDown = (event: KeyboardEvent): void => {
|
||||
if (event.key === '/' && !isTypingTarget(event.target) && !searchOpen) {
|
||||
if (
|
||||
event.key === '/' &&
|
||||
!isTypingTarget(event.target) &&
|
||||
!searchOpen &&
|
||||
!singleKeyShortcutsDisabled()
|
||||
) {
|
||||
event.preventDefault();
|
||||
setSearchOpen(true);
|
||||
}
|
||||
|
||||
13
apps/web/src/lib/single-key-shortcuts.ts
Normal file
13
apps/web/src/lib/single-key-shortcuts.ts
Normal file
@ -0,0 +1,13 @@
|
||||
/** localStorage key for the "disable single-key shortcuts" preference
|
||||
* (issue #170, WCAG 2.1.4): `e` (edit mode) and `/` (search) can misfire
|
||||
* for speech-input users, so they must be switch-offable. */
|
||||
export const SINGLE_KEY_SHORTCUTS_KEY = 'ui.singleKeyShortcuts.disabled';
|
||||
|
||||
/** Read at keypress time so the toggle needs no cross-component state. */
|
||||
export function singleKeyShortcutsDisabled(): boolean {
|
||||
try {
|
||||
return window.localStorage.getItem(SINGLE_KEY_SHORTCUTS_KEY) === 'true';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@ -42,6 +42,7 @@ import { PluginBlockContext } from '../editor/plugin-block-context';
|
||||
import { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel';
|
||||
import { SectionStyleSheets } from '../plugins/SectionStyleSheets';
|
||||
import { useDocumentTitle } from '../lib/use-document-title';
|
||||
import { singleKeyShortcutsDisabled } from '../lib/single-key-shortcuts';
|
||||
import {
|
||||
pluginBlockOptions,
|
||||
sectionStyleOptions,
|
||||
@ -461,7 +462,8 @@ export function PageEditorPage(): React.JSX.Element {
|
||||
!event.ctrlKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
!isTypingTarget(event.target)
|
||||
!isTypingTarget(event.target) &&
|
||||
!singleKeyShortcutsDisabled()
|
||||
) {
|
||||
event.preventDefault();
|
||||
setMode('edit');
|
||||
|
||||
@ -15,6 +15,8 @@ import { FeedTokensSection } from '../api-tokens/FeedTokensSection';
|
||||
import { WatchesSection } from '../watches/WatchesSection';
|
||||
|
||||
import { useDocumentTitle } from '../lib/use-document-title';
|
||||
import { SINGLE_KEY_SHORTCUTS_KEY } from '../lib/single-key-shortcuts';
|
||||
import { usePersistentState } from '../lib/use-persistent-state';
|
||||
interface SessionView {
|
||||
id: string;
|
||||
createdAt: string;
|
||||
@ -36,12 +38,34 @@ export function SettingsPage(): React.JSX.Element {
|
||||
<WatchesSection />
|
||||
<ApiTokensSection />
|
||||
<FeedTokensSection />
|
||||
<InteractionSection />
|
||||
<DataExportSection />
|
||||
</SettingsLayout>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Bedienungs-Einstellungen (issue #170, WCAG 2.1.4): Einzeltasten-Kürzel
|
||||
* abschaltbar machen — lokale Geräte-Einstellung, kein Server-Zustand. */
|
||||
function InteractionSection(): React.JSX.Element {
|
||||
const { t } = useTranslation();
|
||||
const [disabled, setDisabled] = usePersistentState(SINGLE_KEY_SHORTCUTS_KEY, false);
|
||||
return (
|
||||
<section className="settings-section">
|
||||
<h2>{t('settings:interaction.title')}</h2>
|
||||
<label className="settings-checkbox">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={disabled}
|
||||
onChange={(event) => setDisabled(event.target.checked)}
|
||||
/>
|
||||
{t('settings:interaction.disableSingleKey')}
|
||||
</label>
|
||||
<p className="field__hint">{t('settings:interaction.disableSingleKeyHint')}</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DataExportSection(): React.JSX.Element {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { status, expiresAt, request, download } = useDataExport();
|
||||
|
||||
@ -97,6 +97,17 @@ button {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* Reduced motion (#170): collapse the few CSS transitions to instant. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
transition-duration: 0.01ms !important;
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Skip link (#166, WCAG 2.4.1): first tab stop, visible only on focus. */
|
||||
.skip-link {
|
||||
position: absolute;
|
||||
|
||||
@ -56,5 +56,10 @@
|
||||
"label": "Startseiten-Inhalt (Markdown)",
|
||||
"save": "Startseite speichern",
|
||||
"saved": "Gespeichert."
|
||||
},
|
||||
"interaction": {
|
||||
"title": "Bedienung",
|
||||
"disableSingleKey": "Einzeltasten-Kürzel deaktivieren",
|
||||
"disableSingleKeyHint": "Schaltet die Kürzel „e“ (Bearbeiten) und „/“ (Suche) ab — hilfreich bei Spracheingabe. Gilt für dieses Gerät."
|
||||
}
|
||||
}
|
||||
|
||||
@ -56,5 +56,10 @@
|
||||
"label": "Landing page content (Markdown)",
|
||||
"save": "Save landing page",
|
||||
"saved": "Saved."
|
||||
},
|
||||
"interaction": {
|
||||
"title": "Interaction",
|
||||
"disableSingleKey": "Disable single-key shortcuts",
|
||||
"disableSingleKeyHint": "Turns off the “e” (edit) and “/” (search) shortcuts — helpful with speech input. Applies to this device."
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user