SLICE14a compact kanban move menu

This commit is contained in:
Victor Wiebe 2026-07-31 14:21:57 -04:00
parent c89b90839f
commit f4971e1f7b
8 changed files with 500 additions and 58 deletions

View File

@ -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.

View File

@ -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
```

257
SLICE14a.md Normal file
View File

@ -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 15 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.

10
SPEC.md
View File

@ -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.

View File

@ -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 813 are complete. SLICE14 is the next implementation slice.
@ -204,3 +205,5 @@ Slices 813 are complete. SLICE14 is the next implementation slice.
inspection, and manual Joplin acceptance were completed on 2026-07-28.
- [x] SLICE14 complete. Phases 17 passed; explicit manual Joplin acceptance was
received on 2026-07-31. Slice 15 remains not started.
- [x] SLICE14a complete. Phases 15 passed and explicit manual acceptance was
received on 2026-07-31. Slice 15 remains not started.

View File

@ -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;
}

View File

@ -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";
});
});
}

View File

@ -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");
});
});