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.
|
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.
|
||||||
|
|||||||
@ -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
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`.
|
`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.
|
||||||
|
|||||||
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] 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 8–13 are complete. SLICE14 is the next implementation slice.
|
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.
|
inspection, and manual Joplin acceptance were completed on 2026-07-28.
|
||||||
- [x] SLICE14 complete. Phases 1–7 passed; explicit manual Joplin acceptance was
|
- [x] SLICE14 complete. Phases 1–7 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 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;
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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";
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
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