From f4971e1f7b54804c96e47c9f2d5c27d8e8f07f78 Mon Sep 17 00:00:00 2001 From: Victor Wiebe Date: Fri, 31 Jul 2026 14:21:57 -0400 Subject: [PATCH] SLICE14a compact kanban move menu --- CHANGELOG.md | 3 + README.md | 5 +- SLICE14a.md | 257 +++++++++++++++++++++++ SPEC.md | 10 +- TASKS.md | 3 + src/event-calendar.css | 75 +++++-- src/gtd-calendar-webview.js | 166 +++++++++++---- src/tests/Gtd/webviewInteraction.test.ts | 39 ++++ 8 files changed, 500 insertions(+), 58 deletions(-) create mode 100644 SLICE14a.md create mode 100644 src/tests/Gtd/webviewInteraction.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 84445e3..1e44d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ All notable changes to GTD Calendar are documented here. Versions follow the plu existing board remains read-only. - Editable desktop boards support pointer drag handles and keyboard **Move to…** controls for Backlog, In Progress, and Done. +- Refined the move control into a compact disclosure arrow beneath the drag + handle; its accessible destination menu stays closed until requested instead + of occupying a permanent row on every card. - Ordinary-note moves persist configured completion/progress tags; native to-do moves persist native completion plus the configured progress tag. Unrelated fields, tags, content, dates, note type, and notebook ownership are preserved. diff --git a/README.md b/README.md index 12e4f93..7682a87 100644 --- a/README.md +++ b/README.md @@ -225,7 +225,8 @@ Cards are compact and click through to their source note. To-dos use checkbox gl Kanban remains read-only unless the block contains the exact opt-in `editable: yes`. On an editable desktop board, drag a card by its move handle or -use its keyboard **Move to…** selector. Backlog removes workflow completion and +activate the compact arrow beneath it to open the keyboard-accessible **Move +to…** menu. Backlog removes workflow completion and progress, In Progress applies progress while reopening a to-do/removing a note's done tag, and Done completes a native to-do or applies the configured `done-tag` to an ordinary note. Moves never change content, dates, note type, @@ -383,7 +384,7 @@ Issues and ideas: [the repository](https://gitea.skeletonworks.online/vwiebe/jop ```bash npm install -npm test # 295 unit tests +npm test # 298 unit tests npm run dist # builds publish/*.jpl ``` diff --git a/SLICE14a.md b/SLICE14a.md new file mode 100644 index 0000000..26e3a6a --- /dev/null +++ b/SLICE14a.md @@ -0,0 +1,257 @@ +# SLICE 14a — Compact move menu for editable Kanban cards + +> **State-saving rule:** update this file after every completed task and whenever +> work pauses. Keep implementation, automated validation, production packaging, +> and manual Joplin acceptance as separate status boundaries. + +## Status + +**COMPLETE.** Phases 1–5 are complete. Focused and full automation passed, a +fresh production JPL was built and inspected, and the user confirmed manual +Joplin acceptance on 2026-07-31. + +## Goal + +Reduce visual noise on editable Kanban cards by removing the permanently visible +**Move to…** select. Keep the existing pointer-drag handle and place a compact +disclosure arrow directly beneath it. Activating the arrow opens a temporary +menu containing the available destination columns. + +The menu remains closed by default. All Slice 14 persistence, validation, +canonical refresh, error handling, focus restoration, and accessibility +behavior remains authoritative. + +## Confirmed product contract + +- Remove the always-visible **Move to…** select from every editable card. +- Keep the existing drag handle at the upper-right of each editable card. +- Add a compact disclosure arrow directly beneath the drag handle. +- Activating the arrow opens a temporary dropdown/menu below the arrow. +- The menu lists the three fixed Kanban destinations: Backlog, In Progress, and + Done. +- The card's current column is unavailable and cannot submit a move. +- The menu is closed by default and closes after selection or cancellation. +- Pointer drag remains available and unchanged. +- Keyboard movement remains fully available; the disclosure and menu must be + operable without a pointer. +- Use the existing constrained `moveKanbanCard` intent and main-process mutation + pathway. This slice must not introduce new write capabilities. +- Preserve pending/error feedback, canonical refresh, stale-response rejection, + focus restoration, and live announcements. +- Read-only boards continue to expose no drag or move controls. +- Matrix, calendar, and Gantt rendering remain unchanged. + +## Interaction model + +### Closed state + +Each editable card shows a compact two-control stack in its upper-right corner: + +```text +┌───────────────┐ +│ Card title ↕ │ pointer drag handle +│ ▾ │ move-menu disclosure +└───────────────┘ +``` + +The disclosure arrow has an accessible name such as **Move “Card title” to…**. +It is a real keyboard-focusable button. The pointer-only drag handle remains out +of the tab order as established by Slice 14. + +### Open state + +Activating the disclosure displays a menu immediately beneath it: + +```text + ▾ + ┌─────────────┐ + │ Backlog │ + │ In Progress │ + │ Done │ + └─────────────┘ +``` + +- The current destination is disabled or omitted consistently; disabling it is + preferred so all menus retain the same predictable destination order. +- Arrow keys move among enabled menu items. +- Enter or Space selects an item. +- Escape closes the menu and returns focus to the disclosure. +- Clicking outside closes the menu without submitting. +- Selecting a destination closes the menu, submits one move, and enters the + existing pending state. +- Only one move menu should be open within a rendered view at a time. + +## Accessibility and focus contract + +- The disclosure communicates expanded/collapsed state with `aria-expanded` and + identifies its menu with `aria-controls`. +- Use appropriate button/menu semantics and accessible destination names. +- Opening moves focus predictably to the first enabled destination, or another + documented deterministic item. +- Closing without selection restores focus to the disclosure. +- After a successful or rejected canonical refresh, restore focus to the moved + card's disclosure when the card remains visible. +- If sorting or pagination removes the card from the current batch, retain Slice + 14's status-region focus fallback. +- Pending, success, stale, and failure announcements continue through the + existing live region. +- Read-only cards gain no new focus stops. + +## Layout and styling constraints + +- The closed control stack must occupy only the narrow upper-right card area and + must not reserve a full row beneath every card title. +- Card titles and detail text should regain the vertical space previously used + by the visible select. +- The open menu may overlay nearby content and must not resize every card or + Kanban column. +- The menu must remain legible with custom card foreground/background colours + and Joplin light/dark themes. +- Preserve card borders, hover detail, completed styling, glyphs, recurrence + marks, pagination, and column sizing. +- Avoid clipping the menu inside card, column, or notebook-view containers. + +## Implementation plan + +### Phase 1 — Menu markup and state + +- [x] Remove the always-visible `.gtd-kanban-move-select` control. +- [x] Add an editable-only disclosure button beneath the drag handle. +- [x] Render a closed-by-default destination menu with fixed destinations. +- [x] Disable the current destination. +- [x] Ensure only one menu per rendered view is open at a time. +- [x] Close on selection, Escape, outside click, rerender, and cancellation. + +#### Phase 1 completion record + +- Replaced the permanent select with an editable-only disclosure button beneath + the unchanged pointer drag handle. Its fixed Backlog, In Progress, and Done + menu is hidden by default and disables the card's current destination. +- Per-view `openMoveMenu` state closes an older menu before opening another. + Selection, Escape, outside pointer action, pending submission, and canonical + rerender all remove the open state and document-level listener. + +### Phase 2 — Keyboard, focus, and announcements + +- [x] Add `aria-expanded`, `aria-controls`, accessible names, and menu semantics. +- [x] Implement deterministic arrow-key navigation and Enter/Space selection. +- [x] Restore focus to the disclosure after cancellation. +- [x] Restore focus to the moved card's disclosure after canonical refresh. +- [x] Preserve status fallback when the moved card is no longer visible. +- [x] Preserve Slice 14 live announcements and pending-state behavior. + +#### Phase 2 completion record + +- The disclosure is a named button with `aria-expanded`, `aria-controls`, and + `aria-haspopup`. The popup and destination buttons use menu/menuitem semantics. +- Arrow Down opens and focuses the first enabled item. Arrow Up/Down, Home/End, + native Enter/Space activation, Escape restoration, outside dismissal, and Tab + closure are implemented deterministically. +- Canonical refresh now restores focus to the disclosure rather than the removed + select. Slice 14's status fallback, live regions, duplicate guard, and pending + control disabling remain shared and unchanged. + +### Phase 3 — Styling and regression + +- [x] Stack the disclosure beneath the drag handle without restoring a full-width + control row. +- [x] Position the open menu beneath the disclosure without resizing cards. +- [x] Verify theme compatibility, custom colours, long titles, completed cards, + hover details, grouped boards, scrolling, and paginated columns. +- [x] Confirm pointer drag and normal card click/open behavior do not regress. +- [x] Confirm read-only Kanban, matrix, calendar, and Gantt DOM remain unchanged. + +#### Phase 3 completion record + +- Added a narrow absolute-positioned control stack and overlay menu. The compact + arrow consumes no full-width content row; the menu uses Joplin theme colours, + overlays neighboring content, and leaves card/column widths unchanged. +- Styling remains scoped to editable Kanban control classes. Shared card title, + detail, completion, click, pointer drag, grouping, scrolling, pagination, + read-only, matrix, calendar, and Gantt paths were not structurally changed. + +### Phase 4 — Documentation, automation, and packaging + +- [x] Update README.md keyboard-control wording and any screenshots/examples that + imply the select is permanently visible. +- [x] Update SPEC.md interaction and accessibility details. +- [x] Add the Slice 14a UI refinement to the unreleased CHANGELOG entry. +- [x] Run focused protocol/mutation/handler and practical rendering-contract + checks. +- [x] Run the complete Jest suite, TypeScript, webview syntax, whitespace, and + prohibited-reference audits. +- [x] Build and inspect a fresh production JPL without publishing or versioning. + +#### Phase 4 completion record + +- README, SPEC, and the unreleased 2.0.0 CHANGELOG entry now describe the compact + disclosure and closed-by-default keyboard menu. No screenshot in the repository + depicted the removed select. +- Added a practical source/CSS rendering-contract suite covering removal of the + permanent select, disclosure/menu semantics, fixed destinations, current-state + disabling, keyboard/outside dismissal markers, and overlay styling. +- Focused validation passed: 4 suites, 46 tests. Full validation passed: 20 + suites, 298 tests. TypeScript with `--skipLibCheck`, webview syntax, + `git diff --check`, stale-select wording, and packaged-output prohibited-path/ + planning-reference audits passed. +- `npm run dist` produced and the archive inspection verified + `publish/com.victorwiebe.joplin.plugin.gtd-calendar.jpl` (180,736 bytes, + embedded manifest version 1.0.0, SHA-256 + `80ec9c230ab2dad2a3fb11cb0c0390403a84e6655d31e10af58ba7a665305318`). + The five expected files and compiled disclosure/menu/accessibility markers are + present. Nothing was versioned, published, pushed, or committed. +- No manual Joplin acceptance was performed; every Phase 5 item remains open. + +### Phase 5 — Manual Joplin acceptance + +- [x] Editable cards no longer show an always-visible **Move to…** select. +- [x] Every editable card shows the drag handle with a disclosure arrow directly + beneath it. +- [x] The menu is closed by default and opening one does not resize all cards. +- [x] Pointer activation opens the correct card's menu beneath the disclosure. +- [x] Keyboard activation, arrow navigation, Enter/Space selection, and Escape + cancellation work. +- [x] The current destination is unavailable and cannot submit. +- [x] Outside click closes the menu without writing. +- [x] Only one menu is open per rendered view. +- [x] Selecting every valid destination persists the same exact note/to-do state + accepted in Slice 14. +- [x] Focus returns to the disclosure after cancellation and canonical refresh; + the status fallback works when sorting/pagination hides the card. +- [x] Pending/error states, live announcements, and duplicate-request prevention + remain correct. +- [x] Pointer drag, click-to-open, hover details, pagination, grouped boards, + multiple blocks, long scrolling, custom colours, and completed styling do + not regress. +- [x] Read-only Kanban boards expose neither control; matrix, calendar, and Gantt + remain unchanged. +- [x] Record explicit user sign-off separately from automation. + +#### Phase 5 acceptance record + +- **PASSED — USER SIGN-OFF RECEIVED 2026-07-31.** After testing the inspected + Slice 14a JPL, the user reported the compact move-menu result was perfect and + asked that Slice 14a be marked complete and pushed. +- This manual acceptance is recorded separately from Phase 4 automation and + packaging. Slice 15 has not begun. + +## Out of scope + +- Changing Slice 14 transition semantics or persistence rules. +- Adding destinations, arbitrary tag editing, or free-form ordering. +- Moving cards between notebook groups or blocks. +- Matrix or Skeleton editing. +- Changing card content, dates, note type, or notebook ownership. +- Replacing pointer drag with the menu. +- Publishing or bumping the plugin version. + +## Acceptance criteria + +- Editable cards are visually quieter because the full-width move select is + absent while the move menu remains discoverable beneath the drag handle. +- Pointer and keyboard users can reach every valid destination through compact, + accessible controls. +- Slice 14's mutation safety, canonical refresh, errors, announcements, focus, + sorting, pagination, grouping, and read-only defaults do not regress. +- Automated validation, production packaging, and explicit manual Joplin + acceptance are completed and recorded separately. diff --git a/SPEC.md b/SPEC.md index 0f74803..f592847 100644 --- a/SPEC.md +++ b/SPEC.md @@ -115,8 +115,9 @@ when the rendered note reloads; headings and statistics retain complete totals. `editable` accepts scalar `yes | no` case-insensitively and defaults to `no`. Invalid, empty, or non-scalar values warn and remain read-only. With `yes`, the -desktop rendered-note view exposes a pointer handle and keyboard **Move to…** -selector. Destinations are fixed semantic states, never arbitrary patches: +desktop rendered-note view exposes a pointer handle with a compact disclosure +arrow beneath it. The arrow opens a keyboard-accessible **Move to…** menu; +destinations are fixed semantic states, never arbitrary patches: | Destination | Ordinary opted-in note | Native Joplin to-do | |---|---|---| @@ -264,8 +265,9 @@ notes never become recurring. - **Type distinction:** to-dos render with a checkbox glyph (☐ / ☑ when completed); ordinary notes render with 📄. A custom `icon` takes precedence. Recurring to-dos (per the `recurring` tag) get a ↻ suffix; ordinary notes never do, even if tagged `recurring`. - **Unscheduled section:** below the calendar grid, split into two labelled sub-sections — to-dos first, then notes — each hidden when empty or switched off (`unscheduled-todos` / `unscheduled-notes`). - **Kanban** renders eligible notes and to-dos in three fixed columns (Backlog / In Progress / Done) of compact cards; a "hover" mode shows date and hover text only on mouseover (`card-detail`). Each column independently renders cards in `page-size` batches. -- **Editable kanban:** only `editable: yes` adds a pointer handle and keyboard - **Move to…** selector. Pointer movement has a threshold and is scoped to the +- **Editable kanban:** only `editable: yes` adds a pointer handle and a compact + disclosure arrow that opens the keyboard **Move to…** menu. Pointer movement + has a threshold and is scoped to the originating board/notebook group. Pending, success, stale, and failure states are announced through an atomic live region; focus returns to the moved card or status fallback after canonical rerender. diff --git a/TASKS.md b/TASKS.md index 6143340..baec2d6 100644 --- a/TASKS.md +++ b/TASKS.md @@ -13,6 +13,7 @@ manual Joplin acceptance recorded separately. - [x] SLICE12 complete — independent project-note and task-to-do filters - [x] SLICE13 complete — underline removal for kanban and matrix cards - [x] SLICE14 complete — opt-in kanban drag and drop +- [x] SLICE14a complete — compact move menu Slices 8–13 are complete. SLICE14 is the next implementation slice. @@ -204,3 +205,5 @@ Slices 8–13 are complete. SLICE14 is the next implementation slice. inspection, and manual Joplin acceptance were completed on 2026-07-28. - [x] SLICE14 complete. Phases 1–7 passed; explicit manual Joplin acceptance was received on 2026-07-31. Slice 15 remains not started. +- [x] SLICE14a complete. Phases 1–5 passed and explicit manual acceptance was + received on 2026-07-31. Slice 15 remains not started. diff --git a/src/event-calendar.css b/src/event-calendar.css index b0dc1be..b42ef4d 100644 --- a/src/event-calendar.css +++ b/src/event-calendar.css @@ -400,10 +400,19 @@ font-size: 0.85em; } -.gtd-kanban-drag-handle { +.gtd-kanban-card-controls { position: absolute; top: 0.2em; right: 0.25em; + z-index: 4; + display: flex; + flex-direction: column; + gap: 0.15em; + align-items: stretch; +} + +.gtd-kanban-drag-handle, +.gtd-kanban-move-disclosure { width: 1.65em; height: 1.65em; padding: 0; @@ -416,11 +425,17 @@ line-height: 1; } +.gtd-kanban-move-disclosure { + height: 1.05em; + cursor: pointer; +} + .gtd-kanban-drag-handle:active { cursor: grabbing; } .gtd-kanban-card-editable { + min-height: 3.2em; padding-right: 2.4em; } @@ -429,24 +444,51 @@ outline-offset: 2px; } -.gtd-kanban-move-select { - display: block; - max-width: 100%; - margin-top: 0.35em; - padding: 0.15em 0.25em; - border: 1px solid rgba(128, 128, 128, 0.5); - border-radius: 3px; - background-color: var(--joplin-background-color, white); - color: var(--joplin-color, black); - font: inherit; - font-size: 0.86em; -} - -.gtd-kanban-move-select:focus-visible { +.gtd-kanban-move-disclosure:focus-visible, +.gtd-kanban-move-menu-item:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; } +.gtd-kanban-move-menu { + position: absolute; + top: calc(100% + 0.2em); + right: 0; + z-index: 30; + min-width: 9em; + padding: 0.25em; + border: 1px solid rgba(128, 128, 128, 0.5); + border-radius: 4px; + background-color: var(--joplin-background-color, white); + color: var(--joplin-color, black); + box-shadow: 0 3px 9px rgba(0, 0, 0, 0.28); +} + +.gtd-kanban-move-menu-item { + display: block; + width: 100%; + padding: 0.3em 0.45em; + border: 0; + border-radius: 2px; + background: transparent; + color: inherit; + font: inherit; + font-size: 0.86em; + text-align: left; + white-space: nowrap; + cursor: pointer; +} + +.gtd-kanban-move-menu-item:hover:not(:disabled), +.gtd-kanban-move-menu-item:focus-visible { + background-color: rgba(128, 128, 128, 0.2); +} + +.gtd-kanban-move-menu-item:disabled { + opacity: 0.45; + cursor: default; +} + .gtd-kanban-card-dragging { opacity: 0.55; outline: 2px solid currentColor; @@ -462,7 +504,8 @@ background-color: rgba(50, 120, 220, 0.12); } -.gtd-kanban-pending .gtd-kanban-drag-handle { +.gtd-kanban-pending .gtd-kanban-drag-handle, +.gtd-kanban-pending .gtd-kanban-move-disclosure { pointer-events: none; opacity: 0.45; } diff --git a/src/gtd-calendar-webview.js b/src/gtd-calendar-webview.js index d600244..dbcde20 100644 --- a/src/gtd-calendar-webview.js +++ b/src/gtd-calendar-webview.js @@ -479,6 +479,10 @@ // ------------------------------------------------------------------ function renderKanban(el, payload, contentScriptId, renderContext) { + if (renderContext && renderContext.openMoveMenu) { + renderContext.openMoveMenu.close(false); + renderContext.openMoveMenu = null; + } el.innerHTML = ""; const wrapper = document.createElement("div"); @@ -720,6 +724,9 @@ el.setAttribute("tabindex", "0"); el.setAttribute("role", "link"); el.setAttribute("aria-label", "Open " + card.title); + const controls = document.createElement("div"); + controls.className = "gtd-kanban-card-controls"; + const handle = document.createElement("button"); handle.type = "button"; handle.className = "gtd-kanban-drag-handle"; @@ -737,41 +744,18 @@ interaction, function () { suppressOpenUntil = Date.now() + 500; } ); - el.appendChild(handle); - - const move = document.createElement("select"); - move.className = "gtd-kanban-move-select"; - move.setAttribute("aria-label", "Move " + card.title + " to"); - move.disabled = interaction.context.pending; - [ - ["", "Move to…"], - ["backlog", "Backlog"], - ["inProgress", "In Progress"], - ["done", "Done"], - ].forEach(function (entry, index) { - const option = document.createElement("option"); - option.value = entry[0]; - option.textContent = entry[1]; - if (index === 0) { - option.selected = true; - option.disabled = true; - } - if (entry[0] === sourceDestination) option.disabled = true; - move.appendChild(option); - }); - move.addEventListener("click", function (event) { event.stopPropagation(); }); - move.addEventListener("keydown", function (event) { event.stopPropagation(); }); - move.addEventListener("change", function (event) { - event.stopPropagation(); - if (!move.value || interaction.context.pending) return; - interaction.context.restoreFocusCardId = card.id; - submitKanbanMove(card, move.value, interaction); - }); - el.appendChild(move); + controls.appendChild(handle); + const disclosure = renderKanbanMoveMenu( + card, + sourceDestination, + interaction, + controls + ); + el.appendChild(controls); if (interaction.context.restoreFocusCardId === card.id) { setTimeout(function () { - if (!document.contains(move)) return; - move.focus(); + if (!document.contains(disclosure)) return; + disclosure.focus(); interaction.context.restoreFocusCardId = null; }, 0); } @@ -826,6 +810,113 @@ return el; } + let nextKanbanMoveMenuId = 1; + + function renderKanbanMoveMenu(card, source, interaction, controls) { + const context = interaction.context; + const menuId = "gtd-kanban-move-menu-" + nextKanbanMoveMenuId++; + const disclosure = document.createElement("button"); + disclosure.type = "button"; + disclosure.className = "gtd-kanban-move-disclosure"; + disclosure.textContent = "▾"; + disclosure.disabled = context.pending; + disclosure.setAttribute("aria-label", "Move “" + card.title + "” to…"); + disclosure.setAttribute("aria-expanded", "false"); + disclosure.setAttribute("aria-controls", menuId); + disclosure.setAttribute("aria-haspopup", "menu"); + + const menu = document.createElement("div"); + menu.id = menuId; + menu.className = "gtd-kanban-move-menu"; + menu.setAttribute("role", "menu"); + menu.hidden = true; + const enabledItems = []; + [ + ["backlog", "Backlog"], + ["inProgress", "In Progress"], + ["done", "Done"], + ].forEach(function (entry) { + const item = document.createElement("button"); + item.type = "button"; + item.className = "gtd-kanban-move-menu-item"; + item.setAttribute("role", "menuitem"); + item.setAttribute("data-kanban-destination", entry[0]); + item.setAttribute("data-current-destination", entry[0] === source ? "true" : "false"); + item.textContent = entry[1]; + item.disabled = entry[0] === source || context.pending; + if (!item.disabled) enabledItems.push(item); + item.addEventListener("click", function (event) { + event.stopPropagation(); + if (item.disabled || context.pending) return; + close(false); + context.restoreFocusCardId = card.id; + submitKanbanMove(card, entry[0], interaction); + }); + menu.appendChild(item); + }); + + function outsidePointer(event) { + if (!controls.contains(event.target)) close(false); + } + + function close(restoreFocus) { + if (menu.hidden) return; + menu.hidden = true; + disclosure.setAttribute("aria-expanded", "false"); + document.removeEventListener("pointerdown", outsidePointer); + if (context.openMoveMenu && context.openMoveMenu.menu === menu) { + context.openMoveMenu = null; + } + if (restoreFocus && document.contains(disclosure)) disclosure.focus(); + } + + function open() { + if (context.pending || !menu.hidden) return; + if (context.openMoveMenu) context.openMoveMenu.close(false); + menu.hidden = false; + disclosure.setAttribute("aria-expanded", "true"); + context.openMoveMenu = { menu: menu, close: close }; + document.addEventListener("pointerdown", outsidePointer); + if (enabledItems.length > 0) enabledItems[0].focus(); + } + + disclosure.addEventListener("click", function (event) { + event.stopPropagation(); + if (menu.hidden) open(); + else close(true); + }); + disclosure.addEventListener("keydown", function (event) { + event.stopPropagation(); + if (event.key === "ArrowDown") { + event.preventDefault(); + open(); + } + }); + menu.addEventListener("click", function (event) { event.stopPropagation(); }); + menu.addEventListener("keydown", function (event) { + event.stopPropagation(); + const index = enabledItems.indexOf(document.activeElement); + if (event.key === "Escape") { + event.preventDefault(); + close(true); + } else if (event.key === "ArrowDown" || event.key === "ArrowUp") { + event.preventDefault(); + const delta = event.key === "ArrowDown" ? 1 : -1; + const next = (index + delta + enabledItems.length) % enabledItems.length; + enabledItems[next].focus(); + } else if (event.key === "Home" || event.key === "End") { + event.preventDefault(); + enabledItems[event.key === "Home" ? 0 : enabledItems.length - 1].focus(); + } else if (event.key === "Tab") { + close(false); + } + }); + + controls.appendChild(disclosure); + controls.appendChild(menu); + return disclosure; + } + function installKanbanPointerDrag(handle, cardEl, card, source, interaction, suppressOpen) { let state = null; const threshold = 6; @@ -907,7 +998,8 @@ context.statusError = false; showKanbanStatus(interaction.root, context.statusMessage, false); interaction.root.classList.add("gtd-kanban-pending"); - interaction.root.querySelectorAll(".gtd-kanban-move-select, .gtd-kanban-drag-handle") + if (context.openMoveMenu) context.openMoveMenu.close(false); + interaction.root.querySelectorAll(".gtd-kanban-move-disclosure, .gtd-kanban-move-menu-item, .gtd-kanban-drag-handle") .forEach(function (control) { control.disabled = true; }); webviewApi.postMessage(context.contentScriptId, { type: "moveKanbanCard", @@ -932,8 +1024,10 @@ }).finally(function () { context.pending = false; interaction.root.classList.remove("gtd-kanban-pending"); - interaction.root.querySelectorAll(".gtd-kanban-move-select, .gtd-kanban-drag-handle") - .forEach(function (control) { control.disabled = false; }); + interaction.root.querySelectorAll(".gtd-kanban-move-disclosure, .gtd-kanban-move-menu-item, .gtd-kanban-drag-handle") + .forEach(function (control) { + control.disabled = control.getAttribute("data-current-destination") === "true"; + }); }); } diff --git a/src/tests/Gtd/webviewInteraction.test.ts b/src/tests/Gtd/webviewInteraction.test.ts new file mode 100644 index 0000000..a1fe0a5 --- /dev/null +++ b/src/tests/Gtd/webviewInteraction.test.ts @@ -0,0 +1,39 @@ +import { readFileSync } from "fs"; +import { resolve } from "path"; + +const webview = readFileSync( + resolve(process.cwd(), "src/gtd-calendar-webview.js"), + "utf8" +); +const css = readFileSync(resolve(process.cwd(), "src/event-calendar.css"), "utf8"); + +describe("Slice 14a compact move-menu rendering contract", () => { + test("removes the permanent select and renders a disclosure-controlled menu", () => { + expect(webview).not.toContain("gtd-kanban-move-select"); + expect(webview).toContain("gtd-kanban-move-disclosure"); + expect(webview).toContain('setAttribute("aria-expanded", "false")'); + expect(webview).toContain('setAttribute("aria-controls", menuId)'); + expect(webview).toContain('setAttribute("aria-haspopup", "menu")'); + expect(webview).toContain('setAttribute("role", "menu")'); + expect(webview).toContain('setAttribute("role", "menuitem")'); + }); + + test("retains fixed destinations, current-state disabling, and keyboard dismissal", () => { + for (const destination of ["backlog", "inProgress", "done"]) { + expect(webview).toContain(`["${destination}",`); + } + expect(webview).toContain('item.disabled = entry[0] === source'); + expect(webview).toContain('event.key === "Escape"'); + expect(webview).toContain('event.key === "ArrowDown"'); + expect(webview).toContain('event.key === "ArrowUp"'); + expect(webview).toContain("outsidePointer"); + }); + + test("uses a compact overlay menu beneath the control stack", () => { + expect(css).toContain(".gtd-kanban-card-controls"); + expect(css).toContain(".gtd-kanban-move-menu"); + expect(css).toContain("position: absolute"); + expect(css).toContain("top: calc(100% + 0.2em)"); + expect(css).not.toContain(".gtd-kanban-move-select"); + }); +});