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. existing board remains read-only.
- Editable desktop boards support pointer drag handles and keyboard **Move to…** - Editable desktop boards support pointer drag handles and keyboard **Move to…**
controls for Backlog, In Progress, and Done. 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 - Ordinary-note moves persist configured completion/progress tags; native to-do
moves persist native completion plus the configured progress tag. Unrelated moves persist native completion plus the configured progress tag. Unrelated
fields, tags, content, dates, note type, and notebook ownership are preserved. 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 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 `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 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 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, `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 ```bash
npm install npm install
npm test # 295 unit tests npm test # 298 unit tests
npm run dist # builds publish/*.jpl 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`. `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 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…** desktop rendered-note view exposes a pointer handle with a compact disclosure
selector. Destinations are fixed semantic states, never arbitrary patches: 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 | | 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`. - **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`). - **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. - **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 - **Editable kanban:** only `editable: yes` adds a pointer handle and a compact
**Move to…** selector. Pointer movement has a threshold and is scoped to the 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 originating board/notebook group. Pending, success, stale, and failure states
are announced through an atomic live region; focus returns to the moved card are announced through an atomic live region; focus returns to the moved card
or status fallback after canonical rerender. 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] SLICE12 complete — independent project-note and task-to-do filters
- [x] SLICE13 complete — underline removal for kanban and matrix cards - [x] SLICE13 complete — underline removal for kanban and matrix cards
- [x] SLICE14 complete — opt-in kanban drag and drop - [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. 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. inspection, and manual Joplin acceptance were completed on 2026-07-28.
- [x] SLICE14 complete. Phases 17 passed; explicit manual Joplin acceptance was - [x] SLICE14 complete. Phases 17 passed; explicit manual Joplin acceptance was
received on 2026-07-31. Slice 15 remains not started. 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; font-size: 0.85em;
} }
.gtd-kanban-drag-handle { .gtd-kanban-card-controls {
position: absolute; position: absolute;
top: 0.2em; top: 0.2em;
right: 0.25em; 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; width: 1.65em;
height: 1.65em; height: 1.65em;
padding: 0; padding: 0;
@ -416,11 +425,17 @@
line-height: 1; line-height: 1;
} }
.gtd-kanban-move-disclosure {
height: 1.05em;
cursor: pointer;
}
.gtd-kanban-drag-handle:active { .gtd-kanban-drag-handle:active {
cursor: grabbing; cursor: grabbing;
} }
.gtd-kanban-card-editable { .gtd-kanban-card-editable {
min-height: 3.2em;
padding-right: 2.4em; padding-right: 2.4em;
} }
@ -429,24 +444,51 @@
outline-offset: 2px; outline-offset: 2px;
} }
.gtd-kanban-move-select { .gtd-kanban-move-disclosure:focus-visible,
display: block; .gtd-kanban-move-menu-item:focus-visible {
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 {
outline: 2px solid currentColor; outline: 2px solid currentColor;
outline-offset: 2px; 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 { .gtd-kanban-card-dragging {
opacity: 0.55; opacity: 0.55;
outline: 2px solid currentColor; outline: 2px solid currentColor;
@ -462,7 +504,8 @@
background-color: rgba(50, 120, 220, 0.12); 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; pointer-events: none;
opacity: 0.45; opacity: 0.45;
} }

View File

@ -479,6 +479,10 @@
// ------------------------------------------------------------------ // ------------------------------------------------------------------
function renderKanban(el, payload, contentScriptId, renderContext) { function renderKanban(el, payload, contentScriptId, renderContext) {
if (renderContext && renderContext.openMoveMenu) {
renderContext.openMoveMenu.close(false);
renderContext.openMoveMenu = null;
}
el.innerHTML = ""; el.innerHTML = "";
const wrapper = document.createElement("div"); const wrapper = document.createElement("div");
@ -720,6 +724,9 @@
el.setAttribute("tabindex", "0"); el.setAttribute("tabindex", "0");
el.setAttribute("role", "link"); el.setAttribute("role", "link");
el.setAttribute("aria-label", "Open " + card.title); el.setAttribute("aria-label", "Open " + card.title);
const controls = document.createElement("div");
controls.className = "gtd-kanban-card-controls";
const handle = document.createElement("button"); const handle = document.createElement("button");
handle.type = "button"; handle.type = "button";
handle.className = "gtd-kanban-drag-handle"; handle.className = "gtd-kanban-drag-handle";
@ -737,41 +744,18 @@
interaction, interaction,
function () { suppressOpenUntil = Date.now() + 500; } function () { suppressOpenUntil = Date.now() + 500; }
); );
el.appendChild(handle); controls.appendChild(handle);
const disclosure = renderKanbanMoveMenu(
const move = document.createElement("select"); card,
move.className = "gtd-kanban-move-select"; sourceDestination,
move.setAttribute("aria-label", "Move " + card.title + " to"); interaction,
move.disabled = interaction.context.pending; controls
[ );
["", "Move to…"], el.appendChild(controls);
["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);
if (interaction.context.restoreFocusCardId === card.id) { if (interaction.context.restoreFocusCardId === card.id) {
setTimeout(function () { setTimeout(function () {
if (!document.contains(move)) return; if (!document.contains(disclosure)) return;
move.focus(); disclosure.focus();
interaction.context.restoreFocusCardId = null; interaction.context.restoreFocusCardId = null;
}, 0); }, 0);
} }
@ -826,6 +810,113 @@
return el; 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) { function installKanbanPointerDrag(handle, cardEl, card, source, interaction, suppressOpen) {
let state = null; let state = null;
const threshold = 6; const threshold = 6;
@ -907,7 +998,8 @@
context.statusError = false; context.statusError = false;
showKanbanStatus(interaction.root, context.statusMessage, false); showKanbanStatus(interaction.root, context.statusMessage, false);
interaction.root.classList.add("gtd-kanban-pending"); 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; }); .forEach(function (control) { control.disabled = true; });
webviewApi.postMessage(context.contentScriptId, { webviewApi.postMessage(context.contentScriptId, {
type: "moveKanbanCard", type: "moveKanbanCard",
@ -932,8 +1024,10 @@
}).finally(function () { }).finally(function () {
context.pending = false; context.pending = false;
interaction.root.classList.remove("gtd-kanban-pending"); interaction.root.classList.remove("gtd-kanban-pending");
interaction.root.querySelectorAll(".gtd-kanban-move-select, .gtd-kanban-drag-handle") interaction.root.querySelectorAll(".gtd-kanban-move-disclosure, .gtd-kanban-move-menu-item, .gtd-kanban-drag-handle")
.forEach(function (control) { control.disabled = false; }); .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");
});
});