feat: Politur – Demodaten, Kürzel, Fehlerseiten, Kontraste
- Demo-Seed mit realistischem deutschem Haushalt (make seed-demo): drei Konten, zehn Firmen samt Logos, zwölf Posten, bestätigte Historie, Buchungen, Budgets und Sparziele; alle Termine relativ zum heutigen Monat - Tastaturkürzel n / r / / / ? samt Übersicht; greifen nur außerhalb von Eingabefeldern - Fehlergrenze für Renderfehler und eigene 404-Seite - Farbtoken beider Modi gegen WCAG geprüft und nachgezogen: Hilfstext lag hell bei 2,56:1, Weiß auf dem Primärknopf bei 3,30:1 – jetzt überall >= 4,5:1 - Abo-Übersicht heißt „Laufende Kosten“, weil sie auch Miete und Sparplan enthält - build.yml fällt auf GITEA_TOKEN zurück, wenn REGISTRY_TOKEN fehlt, und erklärt im Fehlerfall, wie das Secret anzulegen ist - README mit Screenshot-Abschnitt, Kürzeln und Barrierefreiheit Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014e7t8UpmoVNMtWivY5LiSH
This commit is contained in:
co-authored by
Claude Opus 5
parent
bdf4097bd7
commit
d63cc0af65
@@ -0,0 +1,31 @@
|
||||
/** Bindeglied zwischen den globalen Kürzeln und den einzelnen Seiten. */
|
||||
|
||||
import { type RefObject, useEffect } from "react";
|
||||
|
||||
import { type PendingAction, useUiStore } from "@/store/ui";
|
||||
|
||||
/**
|
||||
* Ruft `run` auf, sobald das Layout die passende Absicht hinterlegt hat –
|
||||
* etwa nach `n` auf einer anderen Seite.
|
||||
*/
|
||||
export function usePendingAction(action: PendingAction, run: () => void): void {
|
||||
const pending = useUiStore((zustand) => zustand.pendingAction);
|
||||
const consume = useUiStore((zustand) => zustand.consumeAction);
|
||||
|
||||
useEffect(() => {
|
||||
if (pending === action && consume(action)) run();
|
||||
// `run` ändert sich bei jedem Rendern; die Absicht ist der Auslöser.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [pending, action]);
|
||||
}
|
||||
|
||||
/** Fokussiert das Suchfeld der Seite, wenn `/` gedrückt wurde. */
|
||||
export function useSearchFocus(ziel: RefObject<HTMLInputElement | null>): void {
|
||||
const anfrage = useUiStore((zustand) => zustand.searchFocusRequest);
|
||||
|
||||
useEffect(() => {
|
||||
if (anfrage === 0) return;
|
||||
ziel.current?.focus();
|
||||
ziel.current?.select();
|
||||
}, [anfrage, ziel]);
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/** Tests der Tastaturkürzel. */
|
||||
|
||||
import { render, screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useShortcuts, type Shortcut } from "@/hooks/useShortcuts";
|
||||
|
||||
function Huelle({ shortcuts, enabled = true }: { shortcuts: Shortcut[]; enabled?: boolean }) {
|
||||
useShortcuts(shortcuts, enabled);
|
||||
return (
|
||||
<div>
|
||||
<input aria-label="Eingabefeld" />
|
||||
<textarea aria-label="Mehrzeilig" />
|
||||
<button type="button">Knopf</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
describe("useShortcuts", () => {
|
||||
it("löst bei der passenden Taste aus", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
const neueBuchung = vi.fn();
|
||||
render(<Huelle shortcuts={[{ key: "n", description: "Neu", run: neueBuchung }]} />);
|
||||
|
||||
await nutzer.keyboard("n");
|
||||
|
||||
expect(neueBuchung).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("greift nicht, während ein Eingabefeld den Fokus hat", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
const ausloeser = vi.fn();
|
||||
render(<Huelle shortcuts={[{ key: "n", description: "Neu", run: ausloeser }]} />);
|
||||
|
||||
await nutzer.click(screen.getByLabelText("Eingabefeld"));
|
||||
await nutzer.keyboard("nnn");
|
||||
|
||||
expect(ausloeser).not.toHaveBeenCalled();
|
||||
expect(screen.getByLabelText("Eingabefeld")).toHaveValue("nnn");
|
||||
});
|
||||
|
||||
it("greift auch in einem mehrzeiligen Feld nicht", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
const ausloeser = vi.fn();
|
||||
render(<Huelle shortcuts={[{ key: "r", description: "Neu", run: ausloeser }]} />);
|
||||
|
||||
await nutzer.click(screen.getByLabelText("Mehrzeilig"));
|
||||
await nutzer.keyboard("r");
|
||||
|
||||
expect(ausloeser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignoriert Tastenkombinationen mit Zusatztaste", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
const ausloeser = vi.fn();
|
||||
render(<Huelle shortcuts={[{ key: "n", description: "Neu", run: ausloeser }]} />);
|
||||
|
||||
// Strg+N ist das Browserkürzel für ein neues Fenster.
|
||||
await nutzer.keyboard("{Control>}n{/Control}");
|
||||
|
||||
expect(ausloeser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("versteht auch Sonderzeichen wie den Schrägstrich", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
const suche = vi.fn();
|
||||
render(<Huelle shortcuts={[{ key: "/", description: "Suche", run: suche }]} />);
|
||||
|
||||
await nutzer.keyboard("/");
|
||||
|
||||
expect(suche).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("lässt sich abschalten", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
const ausloeser = vi.fn();
|
||||
render(
|
||||
<Huelle enabled={false} shortcuts={[{ key: "n", description: "Neu", run: ausloeser }]} />,
|
||||
);
|
||||
|
||||
await nutzer.keyboard("n");
|
||||
|
||||
expect(ausloeser).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("räumt den Zuhörer beim Abbau wieder ab", async () => {
|
||||
const nutzer = userEvent.setup();
|
||||
const ausloeser = vi.fn();
|
||||
const { unmount } = render(
|
||||
<Huelle shortcuts={[{ key: "n", description: "Neu", run: ausloeser }]} />,
|
||||
);
|
||||
|
||||
unmount();
|
||||
await nutzer.keyboard("n");
|
||||
|
||||
expect(ausloeser).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Tastaturkürzel der Anwendung.
|
||||
*
|
||||
* Kürzel greifen nur, wenn gerade kein Eingabefeld den Fokus hat und keine
|
||||
* Zusatztaste gedrückt ist – sonst käme man beim Tippen nicht weit.
|
||||
*/
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
export interface Shortcut {
|
||||
/** Taste in Kleinschreibung, etwa "n" oder "/". */
|
||||
key: string;
|
||||
description: string;
|
||||
run: () => void;
|
||||
}
|
||||
|
||||
function schreibtGerade(ziel: EventTarget | null): boolean {
|
||||
if (!(ziel instanceof HTMLElement)) return false;
|
||||
if (ziel.isContentEditable) return true;
|
||||
return ["INPUT", "TEXTAREA", "SELECT"].includes(ziel.tagName);
|
||||
}
|
||||
|
||||
export function useShortcuts(shortcuts: Shortcut[], enabled = true): void {
|
||||
useEffect(() => {
|
||||
if (!enabled || shortcuts.length === 0) return;
|
||||
|
||||
function behandeln(ereignis: KeyboardEvent) {
|
||||
if (ereignis.metaKey || ereignis.ctrlKey || ereignis.altKey) return;
|
||||
if (schreibtGerade(ereignis.target)) return;
|
||||
|
||||
const treffer = shortcuts.find(
|
||||
(kuerzel) => kuerzel.key === ereignis.key.toLowerCase(),
|
||||
);
|
||||
if (!treffer) return;
|
||||
|
||||
ereignis.preventDefault();
|
||||
treffer.run();
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", behandeln);
|
||||
return () => window.removeEventListener("keydown", behandeln);
|
||||
}, [shortcuts, enabled]);
|
||||
}
|
||||
|
||||
/** Beschreibungen für die Übersicht der Kürzel. */
|
||||
export const GLOBAL_SHORTCUTS: { key: string; description: string }[] = [
|
||||
{ key: "n", description: "Neue Buchung" },
|
||||
{ key: "r", description: "Neuer wiederkehrender Posten" },
|
||||
{ key: "/", description: "Suche" },
|
||||
{ key: "?", description: "Diese Übersicht" },
|
||||
];
|
||||
Reference in New Issue
Block a user