From 435b557bdcd18ab1525f0f10c7b12ffd732430ee Mon Sep 17 00:00:00 2001 From: Christian Bager Bach Houmann Date: Sat, 30 May 2026 00:05:56 +0200 Subject: [PATCH 1/7] feat(a11y): keyboard & ARIA pass on the choice and macro GUIs Make the choice and macro editors fully keyboard operable and replace the faux-button / ARIA patterns left after the Svelte 5 migration (#1250). - Replace role="button"+tabindex+onkeypress faux-buttons with native actions.onConfigureChoice(choice)} onToggleCommand={() => actions.onToggleCommand(choice)} onDuplicateChoice={() => actions.onDuplicateChoice(choice)} + onOpenMenu={openMenu} + {onMoveUp} + {onMoveDown} {showConfigureButton} {dragDisabled} choiceName={choice.name} @@ -118,6 +138,8 @@ {app} roots={roots} choices={choice.choices} + {forceDragDisabled} + rootReorder={rootReorder ?? actions.onReorderChoices} actions={nestedActions} /> @@ -142,13 +164,28 @@ transform: rotate(-180deg); } - .clickable:hover { - cursor: pointer; - } - + /* Full-width collapse toggle: reset native diff --git a/src/gui/components/DragHandle.test.ts b/src/gui/components/DragHandle.test.ts new file mode 100644 index 00000000..c8773255 --- /dev/null +++ b/src/gui/components/DragHandle.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render } from "@testing-library/svelte"; + +import DragHandle from "./DragHandle.svelte"; + +describe("DragHandle", () => { + it("is a focusable native button advertising arrow reorder when handlers are provided", () => { + const { getByLabelText } = render(DragHandle, { + props: { + label: "Reorder Alpha", + dragDisabled: true, + onDragStart: () => {}, + onMoveUp: () => {}, + onMoveDown: () => {}, + }, + }); + const btn = getByLabelText("Reorder Alpha"); + expect(btn.tagName).toBe("BUTTON"); + expect(btn.getAttribute("tabindex")).toBe("0"); + expect(btn.getAttribute("aria-keyshortcuts")).toBe("ArrowUp ArrowDown"); + }); + + it("does not advertise or handle arrow reorder when no move handlers are given (e.g. filtered view)", async () => { + const { getByLabelText } = render(DragHandle, { + props: { label: "Reorder Alpha", dragDisabled: true, onDragStart: () => {} }, + }); + const btn = getByLabelText("Reorder Alpha"); + expect(btn.hasAttribute("aria-keyshortcuts")).toBe(false); + // Arrow keys are not intercepted (no preventDefault) when inert. + const down = new KeyboardEvent("keydown", { key: "ArrowDown", cancelable: true, bubbles: true }); + btn.dispatchEvent(down); + expect(down.defaultPrevented).toBe(false); + }); + + it("is taken out of the tab order while an active pointer drag is in progress", () => { + const { getByLabelText } = render(DragHandle, { + props: { label: "Reorder Alpha", dragDisabled: false, onDragStart: () => {} }, + }); + expect(getByLabelText("Reorder Alpha").getAttribute("tabindex")).toBe("-1"); + }); + + it("starts a pointer drag on pointerdown", async () => { + const onDragStart = vi.fn(); + const { getByLabelText } = render(DragHandle, { + props: { label: "Reorder Alpha", dragDisabled: true, onDragStart }, + }); + await fireEvent.pointerDown(getByLabelText("Reorder Alpha")); + expect(onDragStart).toHaveBeenCalledTimes(1); + }); + + it("moves the row with ArrowUp / ArrowDown and prevents page scroll", async () => { + const onMoveUp = vi.fn(); + const onMoveDown = vi.fn(); + const { getByLabelText } = render(DragHandle, { + props: { + label: "Reorder Alpha", + dragDisabled: true, + onDragStart: () => {}, + onMoveUp, + onMoveDown, + }, + }); + const btn = getByLabelText("Reorder Alpha"); + + const down = new KeyboardEvent("keydown", { key: "ArrowDown", cancelable: true, bubbles: true }); + btn.dispatchEvent(down); + expect(onMoveDown).toHaveBeenCalledTimes(1); + expect(down.defaultPrevented).toBe(true); + + const up = new KeyboardEvent("keydown", { key: "ArrowUp", cancelable: true, bubbles: true }); + btn.dispatchEvent(up); + expect(onMoveUp).toHaveBeenCalledTimes(1); + expect(up.defaultPrevented).toBe(true); + }); + + it("ignores other keys", async () => { + const onMoveUp = vi.fn(); + const onMoveDown = vi.fn(); + const { getByLabelText } = render(DragHandle, { + props: { + label: "Reorder Alpha", + dragDisabled: true, + onDragStart: () => {}, + onMoveUp, + onMoveDown, + }, + }); + await fireEvent.keyDown(getByLabelText("Reorder Alpha"), { key: "ArrowLeft" }); + expect(onMoveUp).not.toHaveBeenCalled(); + expect(onMoveDown).not.toHaveBeenCalled(); + }); +}); diff --git a/src/gui/components/IconButton.svelte b/src/gui/components/IconButton.svelte new file mode 100644 index 00000000..118c9307 --- /dev/null +++ b/src/gui/components/IconButton.svelte @@ -0,0 +1,48 @@ + + + diff --git a/src/gui/components/IconButton.test.ts b/src/gui/components/IconButton.test.ts new file mode 100644 index 00000000..8ef223d2 --- /dev/null +++ b/src/gui/components/IconButton.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it, vi } from "vitest"; +import { fireEvent, render } from "@testing-library/svelte"; + +import IconButton from "./IconButton.svelte"; + +describe("IconButton", () => { + it("renders a real