@ -28,7 +28,8 @@ test('user settings show the jump nav and clicking scrolls + activates', async (
|
|||||||
await expect(nav).toBeVisible();
|
await expect(nav).toBeVisible();
|
||||||
const links = nav.locator('.settings-nav__link');
|
const links = nav.locator('.settings-nav__link');
|
||||||
// Profile, password, sessions, watches, API tokens, feed tokens, data export.
|
// 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.
|
// Jump to the last section: it scrolls into view and becomes active.
|
||||||
const last = links.last();
|
const last = links.last();
|
||||||
|
|||||||
@ -18,7 +18,9 @@ interface ToastItem {
|
|||||||
variant: ToastVariant;
|
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>(() => {});
|
const ToastContext = createContext<ShowToast>(() => {});
|
||||||
|
|
||||||
|
|||||||
@ -156,8 +156,18 @@ export function ForceGraph({
|
|||||||
// Settle noticeably faster than d3's default so e2e clicks and the
|
// Settle noticeably faster than d3's default so e2e clicks and the
|
||||||
// reading eye get a resting layout within a couple of seconds.
|
// reading eye get a resting layout within a couple of seconds.
|
||||||
.alphaDecay(0.04)
|
.alphaDecay(0.04)
|
||||||
.alpha(previous.size > 0 ? 0.5 : 1)
|
.alpha(previous.size > 0 ? 0.5 : 1);
|
||||||
.on('tick', applyPositions);
|
// 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;
|
simRef.current = simulation;
|
||||||
simNodesRef.current = new Map(simNodes.map((n) => [n.id, n]));
|
simNodesRef.current = new Map(simNodes.map((n) => [n.id, n]));
|
||||||
return () => {
|
return () => {
|
||||||
|
|||||||
@ -15,6 +15,7 @@ import { usePageActionsSlot } from './page-actions';
|
|||||||
import { PondSwitcher } from './PondSwitcher';
|
import { PondSwitcher } from './PondSwitcher';
|
||||||
import { useCurrentPondRoute } from './use-pond-route';
|
import { useCurrentPondRoute } from './use-pond-route';
|
||||||
|
|
||||||
|
import { singleKeyShortcutsDisabled } from '../lib/single-key-shortcuts';
|
||||||
interface TopBarProps {
|
interface TopBarProps {
|
||||||
sidebarCollapsed: boolean;
|
sidebarCollapsed: boolean;
|
||||||
onToggleSidebar: () => void;
|
onToggleSidebar: () => void;
|
||||||
@ -49,7 +50,12 @@ export function TopBar({ sidebarCollapsed, onToggleSidebar }: TopBarProps): Reac
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return undefined;
|
if (!user) return undefined;
|
||||||
const onKeyDown = (event: KeyboardEvent): void => {
|
const onKeyDown = (event: KeyboardEvent): void => {
|
||||||
if (event.key === '/' && !isTypingTarget(event.target) && !searchOpen) {
|
if (
|
||||||
|
event.key === '/' &&
|
||||||
|
!isTypingTarget(event.target) &&
|
||||||
|
!searchOpen &&
|
||||||
|
!singleKeyShortcutsDisabled()
|
||||||
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSearchOpen(true);
|
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 { hasPageTools, PageToolsPanel } from '../plugins/PageToolsPanel';
|
||||||
import { SectionStyleSheets } from '../plugins/SectionStyleSheets';
|
import { SectionStyleSheets } from '../plugins/SectionStyleSheets';
|
||||||
import { useDocumentTitle } from '../lib/use-document-title';
|
import { useDocumentTitle } from '../lib/use-document-title';
|
||||||
|
import { singleKeyShortcutsDisabled } from '../lib/single-key-shortcuts';
|
||||||
import {
|
import {
|
||||||
pluginBlockOptions,
|
pluginBlockOptions,
|
||||||
sectionStyleOptions,
|
sectionStyleOptions,
|
||||||
@ -461,7 +462,8 @@ export function PageEditorPage(): React.JSX.Element {
|
|||||||
!event.ctrlKey &&
|
!event.ctrlKey &&
|
||||||
!event.metaKey &&
|
!event.metaKey &&
|
||||||
!event.altKey &&
|
!event.altKey &&
|
||||||
!isTypingTarget(event.target)
|
!isTypingTarget(event.target) &&
|
||||||
|
!singleKeyShortcutsDisabled()
|
||||||
) {
|
) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setMode('edit');
|
setMode('edit');
|
||||||
|
|||||||
@ -15,6 +15,8 @@ import { FeedTokensSection } from '../api-tokens/FeedTokensSection';
|
|||||||
import { WatchesSection } from '../watches/WatchesSection';
|
import { WatchesSection } from '../watches/WatchesSection';
|
||||||
|
|
||||||
import { useDocumentTitle } from '../lib/use-document-title';
|
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 {
|
interface SessionView {
|
||||||
id: string;
|
id: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@ -36,12 +38,34 @@ export function SettingsPage(): React.JSX.Element {
|
|||||||
<WatchesSection />
|
<WatchesSection />
|
||||||
<ApiTokensSection />
|
<ApiTokensSection />
|
||||||
<FeedTokensSection />
|
<FeedTokensSection />
|
||||||
|
<InteractionSection />
|
||||||
<DataExportSection />
|
<DataExportSection />
|
||||||
</SettingsLayout>
|
</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 {
|
function DataExportSection(): React.JSX.Element {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const { status, expiresAt, request, download } = useDataExport();
|
const { status, expiresAt, request, download } = useDataExport();
|
||||||
|
|||||||
@ -97,6 +97,17 @@ button {
|
|||||||
flex: 1;
|
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 (#166, WCAG 2.4.1): first tab stop, visible only on focus. */
|
||||||
.skip-link {
|
.skip-link {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@ -56,5 +56,10 @@
|
|||||||
"label": "Startseiten-Inhalt (Markdown)",
|
"label": "Startseiten-Inhalt (Markdown)",
|
||||||
"save": "Startseite speichern",
|
"save": "Startseite speichern",
|
||||||
"saved": "Gespeichert."
|
"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)",
|
"label": "Landing page content (Markdown)",
|
||||||
"save": "Save landing page",
|
"save": "Save landing page",
|
||||||
"saved": "Saved."
|
"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