diff --git a/eslint.config.mjs b/eslint.config.mjs index 7fc4d7d7..ee0b7770 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -61,6 +61,15 @@ export default [ // (new Set/Map -> assign), which is reactive under $state. SvelteSet/SvelteMap // are only needed for in-place mutation, so this rule is a false positive here. 'svelte/prefer-svelte-reactivity': 'off', + + // eslint-plugin-svelte v3 ships no dedicated a11y-* rules; the Svelte 5 + // compiler emits the a11y_* warnings instead. valid-compile surfaces those + // (plus other compiler warnings, e.g. css_unused_selector) as lint errors, + // so a bare interactive
/ can't be reintroduced without CI + // catching it. Baseline is clean after the #1250 a11y pass. Note: this is a + // regression ratchet — it does NOT police accessible names on icon-only + // buttons or keyboard-operable drag handles, which are covered by tests. + 'svelte/valid-compile': 'error', }, }, // Special rules for main.ts to preserve critical import order diff --git a/src/gui/ChoiceBuilder/FolderList.svelte b/src/gui/ChoiceBuilder/FolderList.svelte index 47c9ebc8..4a6ff4b2 100644 --- a/src/gui/ChoiceBuilder/FolderList.svelte +++ b/src/gui/ChoiceBuilder/FolderList.svelte @@ -1,5 +1,5 @@ -
-
  • {command.name}
  • -
    - + {command.name} +
    + onConfigureAssistant(command)} - onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && onConfigureAssistant(command)} - class="clickable" - > - - - + onDeleteCommand(command.id)} - onkeypress={(e) => (e.key === 'Enter' || e.key === ' ') && onDeleteCommand(command.id)} - class="clickable" - > - - - - - + /> +
    -
    + diff --git a/src/gui/MacroGUIs/Components/ConditionalCommand.svelte b/src/gui/MacroGUIs/Components/ConditionalCommand.svelte index 2e6b39dd..0cb89f5f 100644 --- a/src/gui/MacroGUIs/Components/ConditionalCommand.svelte +++ b/src/gui/MacroGUIs/Components/ConditionalCommand.svelte @@ -1,5 +1,6 @@ -
    -
  • +
  • +
    {summary}
    Then: {thenCount} Else: {elseCount}
    -
  • -
    - +
    + onConfigureCondition(command)} - onkeypress={(e) => - (e.key === "Enter" || e.key === " ") && onConfigureCondition(command)} - aria-label="Edit condition" - > - - - + onEditThenBranch(command)} - onkeypress={(e) => - (e.key === "Enter" || e.key === " ") && onEditThenBranch(command)} - aria-label="Edit then branch" - > - - - + onEditElseBranch(command)} - onkeypress={(e) => - (e.key === "Enter" || e.key === " ") && onEditElseBranch(command)} - aria-label="Edit else branch" - > - - - + onDeleteCommand(command.id)} - onkeypress={(e) => - (e.key === "Enter" || e.key === " ") && onDeleteCommand(command.id)} - aria-label="Delete command" - > - - - - - + /> +
    -
    + diff --git a/src/gui/choiceList/ChoiceList.a11y.test.ts b/src/gui/choiceList/ChoiceList.a11y.test.ts new file mode 100644 index 00000000..cf42e73b --- /dev/null +++ b/src/gui/choiceList/ChoiceList.a11y.test.ts @@ -0,0 +1,194 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render } from "@testing-library/svelte"; + +// ChoiceListItem -> renderChoiceName/contextMenu reach src/main -> obsidian-dataview. +vi.mock("obsidian-dataview", () => ({ getAPI: vi.fn() })); + +import { App, Menu } from "obsidian"; +// Runtime "obsidian" is aliased to the test stub (vitest.config.mts); tsc/svelte-check +// resolve the real obsidian types, which don't know the stub's recording fields. Cast +// through the stub's type to read them in a type-safe way. +import type { Menu as StubMenu } from "../../../tests/obsidian-stub"; +import ChoiceList from "./ChoiceList.svelte"; +import type IChoice from "../../types/choices/IChoice"; +import type { ChoiceListActions } from "./choiceListActions"; + +const ShownMenu = Menu as unknown as typeof StubMenu; + +const normal = (name: string): IChoice => + ({ id: name, name, type: "Template", command: false }) as unknown as IChoice; +const multi = (name: string, children: IChoice[], collapsed = false): IChoice => + ({ + id: name, + name, + type: "Multi", + command: false, + collapsed, + choices: children, + }) as unknown as IChoice; + +function actionsSpy(): ChoiceListActions { + return { + onDeleteChoice: vi.fn(), + onConfigureChoice: vi.fn(), + onToggleCommand: vi.fn(), + onDuplicateChoice: vi.fn(), + onRenameChoice: vi.fn(), + onMoveChoice: vi.fn(), + onReorderChoices: vi.fn(), + }; +} + +const idsOf = (fn: unknown) => + ((fn as { mock: { calls: unknown[][] } }).mock.calls[0][0] as IChoice[]).map((c) => c.id); + +beforeEach(() => { + ShownMenu.lastShown = null; +}); + +describe("ChoiceList keyboard reorder", () => { + it("ArrowDown on a row's drag handle reorders that list and persists", async () => { + const actions = actionsSpy(); + const choices = [normal("Alpha"), normal("Beta"), normal("Gamma")]; + const { getByLabelText } = render(ChoiceList, { + props: { app: new App() as never, roots: choices, choices, actions }, + }); + + await fireEvent.keyDown(getByLabelText("Reorder Alpha"), { key: "ArrowDown" }); + + expect(actions.onReorderChoices).toHaveBeenCalledTimes(1); + expect(idsOf(actions.onReorderChoices)).toEqual(["Beta", "Alpha", "Gamma"]); + }); + + it("clamps at the ends — ArrowUp on the first row is a no-op", async () => { + const actions = actionsSpy(); + const choices = [normal("Alpha"), normal("Beta")]; + const { getByLabelText } = render(ChoiceList, { + props: { app: new App() as never, roots: choices, choices, actions }, + }); + + await fireEvent.keyDown(getByLabelText("Reorder Alpha"), { key: "ArrowUp" }); + expect(actions.onReorderChoices).not.toHaveBeenCalled(); + }); + + it("never reorders a filtered/derived list (forceDragDisabled)", async () => { + const actions = actionsSpy(); + const choices = [normal("Alpha"), normal("Beta")]; + const { getByLabelText } = render(ChoiceList, { + props: { + app: new App() as never, + roots: choices, + choices, + actions, + forceDragDisabled: true, + }, + }); + + await fireEvent.keyDown(getByLabelText("Reorder Alpha"), { key: "ArrowDown" }); + expect(actions.onReorderChoices).not.toHaveBeenCalled(); + // The inert handle must not advertise a keyboard shortcut that does nothing. + expect(getByLabelText("Reorder Alpha").hasAttribute("aria-keyshortcuts")).toBe(false); + }); + + it("reorders a doubly-nested Multi's children WITHOUT corrupting ancestors (depth >= 2)", async () => { + // Regression for the nestedActions bubbling bug: an inner Multi must not push + // the root array into its parent Multi's onReorderChoices override. + const actions = actionsSpy(); + const c1 = normal("c1"); + const c2 = normal("c2"); + const inner = multi("Inner", [c1, c2]); + const outer = multi("Outer", [inner]); + const choices = [outer]; + const { getByLabelText } = render(ChoiceList, { + props: { app: new App() as never, roots: choices, choices, actions }, + }); + + await fireEvent.keyDown(getByLabelText("Reorder c1"), { key: "ArrowDown" }); + + expect(actions.onReorderChoices).toHaveBeenCalledTimes(1); + const rootArg = (actions.onReorderChoices as ReturnType).mock + .calls[0][0] as IChoice[]; + // Root tree intact: exactly [Outer] (not the root array assigned into Outer, + // not Outer containing itself). + expect(rootArg.map((c) => c.id)).toEqual(["Outer"]); + const outerArg = rootArg[0] as unknown as { choices: IChoice[] }; + expect(outerArg.choices.map((c) => c.id)).toEqual(["Inner"]); + // Inner's children are the ones actually reordered. + const innerArg = outerArg.choices[0] as unknown as { choices: IChoice[] }; + expect(innerArg.choices.map((c) => c.id)).toEqual(["c2", "c1"]); + }); +}); + +describe("MultiChoiceListItem collapse toggle", () => { + it("is a native button exposing aria-expanded=true and the nested list when expanded", () => { + const actions = actionsSpy(); + const group = multi("Group", [normal("Child")], false); + const { getByLabelText, queryByLabelText } = render(ChoiceList, { + props: { app: new App() as never, roots: [group], choices: [group], actions }, + }); + + const toggle = getByLabelText("Toggle Group"); + expect(toggle.tagName).toBe("BUTTON"); + expect(toggle.getAttribute("aria-expanded")).toBe("true"); + expect(queryByLabelText("Delete Child")).not.toBeNull(); + }); + + it("exposes aria-expanded=false and hides the nested list when collapsed", () => { + const actions = actionsSpy(); + const group = multi("Group", [normal("Child")], true); + const { getByLabelText, queryByLabelText } = render(ChoiceList, { + props: { app: new App() as never, roots: [group], choices: [group], actions }, + }); + + const toggle = getByLabelText("Toggle Group"); + expect(toggle.getAttribute("aria-expanded")).toBe("false"); + expect(queryByLabelText("Delete Child")).toBeNull(); + }); +}); + +describe("ChoiceList mobile collapse hooks", () => { + // On mobile the per-row action icons are hidden via CSS (.is-mobile + // .qa-row-secondary-action) and reached through the More menu; the More button + // and drag handle stay. Guard that the right buttons carry the collapse class. + it("tags the secondary action buttons but not More / drag handle", () => { + const actions = actionsSpy(); + const choices = [normal("Alpha")]; + const { getByLabelText } = render(ChoiceList, { + props: { app: new App() as never, roots: choices, choices, actions }, + }); + + const secondary = "qa-row-secondary-action"; + for (const label of [ + "Command palette: Alpha", + "Configure Alpha", + "Duplicate Alpha", + "Delete Alpha", + ]) { + expect(getByLabelText(label).classList.contains(secondary)).toBe(true); + } + expect(getByLabelText("More options for Alpha").classList.contains(secondary)).toBe(false); + expect(getByLabelText("Reorder Alpha").classList.contains(secondary)).toBe(false); + }); +}); + +describe("ChoiceList keyboard-accessible context menu", () => { + it("opens the context menu anchored to the More-options button (no mouse needed)", async () => { + const actions = actionsSpy(); + const choices = [normal("Alpha")]; + const { getByLabelText } = render(ChoiceList, { + props: { app: new App() as never, roots: choices, choices, actions }, + }); + + await fireEvent.click(getByLabelText("More options for Alpha")); + + expect(ShownMenu.lastShown).not.toBeNull(); + // Anchored via showAtPosition (keyboard path), not showAtMouseEvent. + expect(ShownMenu.lastShown?.shownAt?.type).toBe("position"); + + // Rename is otherwise menu-only — confirm it is reachable here and wired. + const rename = ShownMenu.lastShown?.items.find((i) => i.title === "Rename"); + expect(rename).toBeDefined(); + rename?.clickHandler?.(); + expect(actions.onRenameChoice).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/gui/choiceList/ChoiceList.svelte b/src/gui/choiceList/ChoiceList.svelte index 1482362c..b0ddb966 100644 --- a/src/gui/choiceList/ChoiceList.svelte +++ b/src/gui/choiceList/ChoiceList.svelte @@ -5,7 +5,7 @@ import MultiChoiceListItem from "./MultiChoiceListItem.svelte"; import { type DndEvent, dndzone } from "svelte-dnd-action"; import { stripShadow } from "../shared/dndReorder"; - import type { App } from "obsidian"; + import { Platform, type App } from "obsidian"; import type { ChoiceListActions } from "./choiceListActions"; let { @@ -14,16 +14,33 @@ app, forceDragDisabled = false, actions, + rootReorder, }: { choices?: IChoice[]; roots?: IChoice[]; app: App; forceDragDisabled?: boolean; actions: ChoiceListActions; + // The TOP-LEVEL onReorderChoices, threaded UNCHANGED through every nesting + // level so a nested Multi persists the whole root tree via the real handler + // instead of an ancestor Multi's override (which would reinterpret the root + // array as its own children — data loss at depth >= 2). Undefined at the top. + rootReorder?: (choices: IChoice[]) => void; } = $props(); + // Resolve once: at the top level there is no incoming rootReorder, so the list's + // own handler IS the top-level handler; nested lists receive it explicitly. + const persistRoots = $derived(rootReorder ?? actions.onReorderChoices); + + const isMobile = Platform.isMobile; + let collapseId = $state(""); - let dragDisabled = $state(true); + // Desktop: drag is armed by grabbing the handle (dragDisabled until then), which + // prevents accidental drags when interacting with a row. Mobile: there is no + // handle — the whole row is draggable by LONG-PRESS (delayTouchStart below), the + // native mobile reorder gesture — so drag stays enabled unless filtering. + let dragArmed = $state(false); + const dragDisabled = $derived(forceDragDisabled || (!isMobile && !dragArmed)); function handleConsider(e: CustomEvent) { if (forceDragDisabled) return; // filtered view: never mutate a derived list @@ -37,21 +54,39 @@ if (forceDragDisabled) return; collapseId = ""; choices = stripShadow(e.detail.items as IChoice[]); - // Always re-disable dragging when the sort finalizes (choiceList behavior; - // intentionally NOT the macro list's POINTER-only gate). - dragDisabled = true; + // Desktop: disarm so a subsequent row interaction doesn't drag (handle must be + // grabbed again). Mobile: dragDisabled ignores dragArmed, so this is a no-op. + dragArmed = false; actions.onReorderChoices(choices); } - let startDrag = (e?: Event) => { + let startDrag = () => { if (forceDragDisabled) return; // do not enable drag while filtering - if (e && typeof e.preventDefault === 'function') e.preventDefault(); - dragDisabled = false; + dragArmed = true; }; + + // Keyboard reorder (ArrowUp/ArrowDown on a row's drag handle). Moves the choice + // one step within THIS list and persists via actions.onReorderChoices — the same + // path pointer drag uses on finalize. Each ChoiceList instance (including the + // nested ones inside a Multi) reorders its own list, so nested reorders bubble + // through MultiChoiceListItem's nestedActions just like a drag does. + function moveChoice(choice: IChoice, direction: -1 | 1) { + if (forceDragDisabled) return; // never persist a filtered/derived list + const list = stripShadow(choices); + const index = list.findIndex((c) => c.id === choice.id); + if (index === -1) return; + const target = index + direction; + if (target < 0 || target >= list.length) return; // clamp at the ends + const next = [...list]; + const [moved] = next.splice(index, 1); + next.splice(target, 0, moved); + choices = next; + actions.onReorderChoices(choices); + }
    moveChoice(choice, -1)} + onMoveDown={forceDragDisabled ? undefined : () => moveChoice(choice, 1)} /> {:else} moveChoice(choice, -1)} + onMoveDown={forceDragDisabled ? undefined : () => moveChoice(choice, 1)} /> {/if} {/each} diff --git a/src/gui/choiceList/ChoiceListItem.svelte b/src/gui/choiceList/ChoiceListItem.svelte index aa30d9b6..3282dca6 100644 --- a/src/gui/choiceList/ChoiceListItem.svelte +++ b/src/gui/choiceList/ChoiceListItem.svelte @@ -2,7 +2,7 @@ import type IChoice from "../../types/choices/IChoice"; import RightButtons from "./ChoiceItemRightButtons.svelte"; import { Component, type App } from "obsidian"; - import { showChoiceContextMenu } from "./contextMenu"; + import { showChoiceContextMenu, showChoiceContextMenuAtElement } from "./contextMenu"; import { renderChoiceName } from "./renderChoiceName"; import type { ChoiceListActions } from "./choiceListActions"; @@ -13,6 +13,8 @@ dragDisabled, startDrag, actions, + onMoveUp, + onMoveDown, }: { choice: IChoice; app: App; @@ -20,6 +22,8 @@ dragDisabled: boolean; startDrag: (e?: Event) => void; actions: ChoiceListActions; + onMoveUp?: () => void; + onMoveDown?: () => void; } = $props(); let showConfigureButton = $state(true); @@ -40,26 +44,29 @@ return () => cmp.unload(); }); + const menuActions = () => ({ + onRename: () => actions.onRenameChoice(choice), + onToggle: () => actions.onToggleCommand(choice), + onConfigure: () => actions.onConfigureChoice(choice), + onDuplicate: () => actions.onDuplicateChoice(choice), + onDelete: () => actions.onDeleteChoice(choice), + onMove: (targetId: string) => actions.onMoveChoice(choice, targetId), + }); + function onContextMenu(evt: MouseEvent) { - showChoiceContextMenu(app, evt, choice, roots, { - onRename: () => actions.onRenameChoice(choice), - onToggle: () => actions.onToggleCommand(choice), - onConfigure: () => actions.onConfigureChoice(choice), - onDuplicate: () => actions.onDuplicateChoice(choice), - onDelete: () => actions.onDeleteChoice(choice), - onMove: (targetId) => actions.onMoveChoice(choice, targetId), - }); + showChoiceContextMenu(app, evt, choice, roots, menuActions()); + } + + function openMenu(anchor: HTMLElement) { + showChoiceContextMenuAtElement(app, anchor, choice, roots, menuActions()); } -
    + + +
    actions.onConfigureChoice(choice)} onToggleCommand={() => actions.onToggleCommand(choice)} onDuplicateChoice={() => actions.onDuplicateChoice(choice)} + onOpenMenu={openMenu} + {onMoveUp} + {onMoveDown} choiceName={choice.name} commandEnabled={choice.command} {showConfigureButton} diff --git a/src/gui/choiceList/MultiChoiceListItem.svelte b/src/gui/choiceList/MultiChoiceListItem.svelte index 2fb1501e..cbaf5779 100644 --- a/src/gui/choiceList/MultiChoiceListItem.svelte +++ b/src/gui/choiceList/MultiChoiceListItem.svelte @@ -6,7 +6,7 @@ import { untrack } from "svelte"; import { Component, type App } from "obsidian"; import type IChoice from "src/types/choices/IChoice"; - import { showChoiceContextMenu } from "./contextMenu"; + import { showChoiceContextMenu, showChoiceContextMenuAtElement } from "./contextMenu"; import { renderChoiceName } from "./renderChoiceName"; import type { ChoiceListActions } from "./choiceListActions"; @@ -18,6 +18,10 @@ startDrag, app, actions, + forceDragDisabled = false, + rootReorder, + onMoveUp, + onMoveDown, }: { choice: IMultiChoice; roots: IChoice[]; @@ -26,6 +30,12 @@ startDrag: (e?: Event) => void; app: App; actions: ChoiceListActions; + forceDragDisabled?: boolean; + // Top-level onReorderChoices (see ChoiceList). Falls back to this list's own + // handler when rendered directly (tests); in the app it is always provided. + rootReorder?: (choices: IChoice[]) => void; + onMoveUp?: () => void; + onMoveDown?: () => void; } = $props(); let showConfigureButton = $state(true); @@ -45,25 +55,34 @@ return () => cmp.unload(); }); + const menuActions = () => ({ + onRename: () => actions.onRenameChoice(choice), + onToggle: () => actions.onToggleCommand(choice), + onConfigure: () => actions.onConfigureChoice(choice), + onDuplicate: () => actions.onDuplicateChoice(choice), + onDelete: () => actions.onDeleteChoice(choice), + onMove: (targetId: string) => actions.onMoveChoice(choice, targetId), + }); + function onContextMenu(evt: MouseEvent) { - showChoiceContextMenu(app, evt, choice, roots, { - onRename: () => actions.onRenameChoice(choice), - onToggle: () => actions.onToggleCommand(choice), - onConfigure: () => actions.onConfigureChoice(choice), - onDuplicate: () => actions.onDuplicateChoice(choice), - onDelete: () => actions.onDeleteChoice(choice), - onMove: (targetId) => actions.onMoveChoice(choice, targetId), - }); + showChoiceContextMenu(app, evt, choice, roots, menuActions()); + } + + function openMenu(anchor: HTMLElement) { + showChoiceContextMenuAtElement(app, anchor, choice, roots, menuActions()); } - // Nested children reordered: write the new order back to this Multi choice, then - // bubble the whole (shallow-cloned) root tree up so the top-level handler persists - // it. Calls the PARENT's onReorderChoices (not nestedActions) — no loop. + // Nested children reordered: write the new order back to this Multi choice (the + // choice object is shared with the root tree, so this mutates in place), then + // persist the whole root tree via the TOP-LEVEL handler. Routing through + // `rootReorder` — NOT `actions.onReorderChoices`, which inside a nested Multi is + // an ancestor's override that would overwrite ITS children with the root array — + // keeps reorder correct at any nesting depth (fixes depth >= 2 data loss). const nestedActions: ChoiceListActions = { ...untrack(() => actions), onReorderChoices: (reordered: IChoice[]) => { choice.choices = reordered; - actions.onReorderChoices([...roots]); + (rootReorder ?? actions.onReorderChoices)([...roots]); }, }; @@ -73,29 +92,27 @@
    -
    -
    + +
    +
    + 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..5f48d567 --- /dev/null +++ b/src/gui/components/DragHandle.test.ts @@ -0,0 +1,98 @@ +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("arms the drag on pointerdown WITHOUT preventing default", () => { + // Regression: preventDefault() on pointerdown suppresses the compatibility + // mousedown event, and svelte-dnd-action starts its drag from mousedown — so + // the handle must arm the drag (flip dragDisabled) but never cancel pointerdown. + const onDragStart = vi.fn(); + const { getByLabelText } = render(DragHandle, { + props: { label: "Reorder Alpha", dragDisabled: true, onDragStart }, + }); + const btn = getByLabelText("Reorder Alpha"); + const ev = new Event("pointerdown", { bubbles: true, cancelable: true }); + btn.dispatchEvent(ev); + expect(onDragStart).toHaveBeenCalledTimes(1); + expect(ev.defaultPrevented).toBe(false); + }); + + 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