SLICE14a compact kanban move menu
This commit is contained in:
parent
c89b90839f
commit
f4971e1f7b
@ -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.
|
||||
|
||||
@ -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
257
SLICE14a.md
Normal 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 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.
|
||||
10
SPEC.md
10
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.
|
||||
|
||||
3
TASKS.md
3
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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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";
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
39
src/tests/Gtd/webviewInteraction.test.ts
Normal file
39
src/tests/Gtd/webviewInteraction.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user