Issue close controls: split buttons — one-click Close (completed) with not-planned dropdown segment; Comment and Close likewise; outside-click dismisses the menu #311

Closed
opened 2026-09-10 22:10:10 +00:00 by crueber · 3 comments
Owner

What's requested

Rework the close affordances on an issue into split buttons (primary action + dropdown segment), matching GitHub's pattern:

  1. Close — clicking the button closes immediately (one click, completed by default); a dropdown segment offers "Close as not planned" (the incomplete variant).
  2. Comment and Close — clicking posts the comment and closes as completed by default; a dropdown segment offers "Comment and close as not planned".
  3. Clicking anywhere outside an open dropdown dismisses it (currently only Escape/Tab dismiss — clicking elsewhere leaves it open).

Current state (code evidence)

Everything lives in web/src/components/CommentComposer.jsx:

  • ChooserMenu (:23-110) is the dropdown primitive. When closeChooser is set, both Close and Comment and Close render as pure chooser buttons (:236-262) — you must open the menu and pick a reason to close at all. There is no one-click path.
  • Reasons are exactly two: CLOSE_COMPLETED = "completed" and CLOSE_NOT_PLANNED = "not_planned" (web/src/lib/issue-events.js:29-30), validated client-side (:103) and carried as state_reason server-side (internal/issues/model.go:70). The user's "incomplete" maps to the existing not_planned — no backend or reason-vocabulary change is needed; verify the event-text rendering (issue-events.js:82) reads sensibly for both paths.
  • The menu has keyboard dismissal only: Escape (ChooserMenu onMenuKey) and Tab-out; no document-level outside-click listener. The repo already has the standard pattern to copy — RefPicker/TasksOverlay in web/src/pages/Repo.jsx (document click + !root.contains(e.target) + onCleanup removal), and #255 fixed the clone menu with the same shape.
  • The menu opens upward (bottom-full, :92) beneath the composer — keep that.

Proposed design

  1. Split the ChooserMenu into a split-button: [primary label][▾] as one visually joined control. Primary click = default action; ▾ segment opens the menu.
    • Close: primary = Close → runClose(CLOSE_COMPLETED); menu item = Close as not planned → runClose(CLOSE_NOT_PLANNED). Menu no longer needs a "Close as completed" item (the button IS that), but keeping it harms nothing — implementer's call, note it in the PR.
    • Comment and Close: primary = posts the current body + runClose(CLOSE_COMPLETED) (via the existing commentAndClose(body, reason) — body may be empty; preserve current semantics where an empty body still closes); menu item = "Comment and close as not planned".
    • Keep the closeChooser=false fallback (plain button) for callers that don't opt into reasons (PR close has no reasons today) — only the chooser path changes.
  2. Outside-click dismissal on the open menu: document click listener closing when the target is outside both the menu and the toggle (the split button's two segments!), removed in onCleanup. Must not close-then-reopen when clicking the ▾ segment itself (segment is inside the boundary).
  3. Accessibility preserved: aria-haspopup, aria-expanded, menu role, arrow-key navigation, Escape-with-focus-return (all exist in ChooserMenu — carry them into the split variant). The primary segment needs aria-label distinguishing it from the menu toggle.
  4. Busy state: both segments disable together during an in-flight close (existing getBusy() — keep single-flight).

Acceptance criteria

  • One click on "Close" closes the issue as completed — no menu.
  • "Close ▾" offers "Close as not planned" which closes with state_reason: not_planned.
  • "Comment and Close" posts the composer body and closes as completed in one click (empty body closes without posting a comment, matching current behavior).
  • "Comment and Close ▾" offers "Comment and close as not planned".
  • An open dropdown closes on any click outside it (including on the composer body and page background), and does NOT toggle-close when clicking its own ▾ segment.
  • Reopen path unchanged (plain button, no reason).
  • Keyboard support intact: menu arrow navigation, Escape closes with focus returned to the toggle, primary segments operable via Enter/Space.
  • Both segments disable while a close is in flight; no double-close.
  • Event timeline renders "closed as completed"/"closed as not planned" correctly for all four paths (close ×2, comment-and-close ×2).
  • Headless test for the split-button state machine (open/close/dismiss/outside-click) per the headless-module convention; visual check light/dark.
## What's requested Rework the close affordances on an issue into **split buttons** (primary action + dropdown segment), matching GitHub's pattern: 1. **Close** — clicking the button closes **immediately** (one click, completed by default); a dropdown segment offers "Close as not planned" (the incomplete variant). 2. **Comment and Close** — clicking posts the comment and closes **as completed** by default; a dropdown segment offers "Comment and close as not planned". 3. Clicking **anywhere outside** an open dropdown dismisses it (currently only Escape/Tab dismiss — clicking elsewhere leaves it open). ## Current state (code evidence) Everything lives in `web/src/components/CommentComposer.jsx`: - `ChooserMenu` (:23-110) is the dropdown primitive. When `closeChooser` is set, **both** Close and Comment and Close render as pure chooser buttons (:236-262) — you must open the menu and pick a reason to close at all. There is no one-click path. - Reasons are exactly two: `CLOSE_COMPLETED = "completed"` and `CLOSE_NOT_PLANNED = "not_planned"` (`web/src/lib/issue-events.js:29-30`), validated client-side (:103) and carried as `state_reason` server-side (`internal/issues/model.go:70`). The user's "incomplete" maps to the existing `not_planned` — **no backend or reason-vocabulary change is needed**; verify the event-text rendering (`issue-events.js:82`) reads sensibly for both paths. - The menu has keyboard dismissal only: Escape (ChooserMenu `onMenuKey`) and Tab-out; **no document-level outside-click listener**. The repo already has the standard pattern to copy — `RefPicker`/`TasksOverlay` in `web/src/pages/Repo.jsx` (document click + `!root.contains(e.target)` + `onCleanup` removal), and #255 fixed the clone menu with the same shape. - The menu opens *upward* (`bottom-full`, :92) beneath the composer — keep that. ## Proposed design 1. **Split the ChooserMenu into a split-button**: `[primary label][▾]` as one visually joined control. Primary click = default action; ▾ segment opens the menu. - Close: primary = `Close` → `runClose(CLOSE_COMPLETED)`; menu item = `Close as not planned` → `runClose(CLOSE_NOT_PLANNED)`. Menu no longer needs a "Close as completed" item (the button IS that), but keeping it harms nothing — implementer's call, note it in the PR. - Comment and Close: primary = posts the current body + `runClose(CLOSE_COMPLETED)` (via the existing `commentAndClose(body, reason)` — body may be empty; preserve current semantics where an empty body still closes); menu item = "Comment and close as not planned". - Keep the `closeChooser=false` fallback (plain button) for callers that don't opt into reasons (PR close has no reasons today) — only the chooser path changes. 2. **Outside-click dismissal** on the open menu: document click listener closing when the target is outside both the menu and the toggle (the split button's two segments!), removed in `onCleanup`. Must not close-then-reopen when clicking the ▾ segment itself (segment is inside the boundary). 3. **Accessibility preserved**: `aria-haspopup`, `aria-expanded`, menu role, arrow-key navigation, Escape-with-focus-return (all exist in `ChooserMenu` — carry them into the split variant). The primary segment needs `aria-label` distinguishing it from the menu toggle. 4. **Busy state**: both segments disable together during an in-flight close (existing `getBusy()` — keep single-flight). ## Acceptance criteria - [ ] One click on "Close" closes the issue as completed — no menu. - [ ] "Close ▾" offers "Close as not planned" which closes with `state_reason: not_planned`. - [ ] "Comment and Close" posts the composer body and closes as completed in one click (empty body closes without posting a comment, matching current behavior). - [ ] "Comment and Close ▾" offers "Comment and close as not planned". - [ ] An open dropdown closes on any click outside it (including on the composer body and page background), and does NOT toggle-close when clicking its own ▾ segment. - [ ] Reopen path unchanged (plain button, no reason). - [ ] Keyboard support intact: menu arrow navigation, Escape closes with focus returned to the toggle, primary segments operable via Enter/Space. - [ ] Both segments disable while a close is in flight; no double-close. - [ ] Event timeline renders "closed as completed"/"closed as not planned" correctly for all four paths (close ×2, comment-and-close ×2). - [ ] Headless test for the split-button state machine (open/close/dismiss/outside-click) per the headless-module convention; visual check light/dark.
crueber added this to the v1 milestone 2026-09-10 22:20:49 +00:00
Author
Owner

PR #315 (fix/issue-311) implements the split-button close controls — ready for review, not merged.

PR #315 (fix/issue-311) implements the split-button close controls — ready for review, not merged.
Author
Owner

PR #315 review (split-button close controls) — verified in scratch worktree, fix pushed as a9a3911.

ACCEPTANCE (all pass, CommentComposer.jsx):

  • One-click Close as completed: onPrimary={() => runClose(CLOSE_COMPLETED)} (:263) — explicit reason sent, not the API default. Comment-and-Close likewise posts body + CLOSE_COMPLETED (:292).
  • Dropdown holds ONLY the not-planned alternate (:260-261, :289-290) — within the issue's latitude ('implementer's call'), noted in 08_ui_sdk.md.
  • Comment-and-Close empty-body semantics preserved: Issue.jsx:146 posts only when non-empty, then closes — empty body just closes.
  • Outside-click dismiss: document click listener + !root.contains(e.target) (:61-65), removed in onCleanup. No toggle fight: ▾ clicks are inside the root boundary so the doc handler ignores them; open-then-click-▾ toggles shut while the doc handler no-ops (getOpen() false).
  • Esc refocuses toggle (close(true), :72/:93), Tab-out dismisses without choosing (:79-82); menu roles + arrow nav intact (:111-112, :122, :131, :73-78).
  • Busy-disable on all three controls: disabled={props.disabled} x3 (:99, :114, :133), fed by getBusy() (:262, :291); runClose/commentAndClose re-guard on getBusy() (:206, :219).
  • closeChooser=false fallback intact (:248-254, :269-283); reopen stays plain button via onClose without chooser.
  • Event text: issueEventText state_changed closed/completed -> 'closed as completed', not_planned -> 'closed as not planned' (issue-events.js:82-84); closePatch throws on missing reason (:102-107).
  • No backend change (diff: 08_ui_sdk.md + CommentComposer.jsx + split-close.test.js only); state_reason vocabulary untouched; no new deps (only added import is onCleanup from solid-js); dark+light menu-item classes unchanged (hover:bg-zinc-100 dark:hover:bg-zinc-800, :132).

ONE FINDING (fixed, pushed a9a3911): 08_ui_sdk.md claimed an 'incidental fix' where the Comment-and-Close chooser passed props.disabled (always undefined) instead of getBusy() — inaccurate: main already passed disabled={getBusy()} to BOTH chooser menus. Doc now states the kept behavior (all three split controls disable from getBusy(), as the choosers did). Code was already correct; no behavior change.

VERIFY: full node suite in scratch 595/595 pass (588 existing + 7 new split-close); vite build clean in 2s (chunk-size warning is pre-existing). No browser (per instructions; change is headless-testable + reasoned). Main worktree untouched.

MERGE RECOMMENDATION: ready to merge.

PR #315 review (split-button close controls) — verified in scratch worktree, fix pushed as a9a3911. ACCEPTANCE (all pass, CommentComposer.jsx): - One-click Close as completed: onPrimary={() => runClose(CLOSE_COMPLETED)} (:263) — explicit reason sent, not the API default. Comment-and-Close likewise posts body + CLOSE_COMPLETED (:292). - Dropdown holds ONLY the not-planned alternate (:260-261, :289-290) — within the issue's latitude ('implementer's call'), noted in 08_ui_sdk.md. - Comment-and-Close empty-body semantics preserved: Issue.jsx:146 posts only when non-empty, then closes — empty body just closes. - Outside-click dismiss: document click listener + !root.contains(e.target) (:61-65), removed in onCleanup. No toggle fight: ▾ clicks are inside the root boundary so the doc handler ignores them; open-then-click-▾ toggles shut while the doc handler no-ops (getOpen() false). - Esc refocuses toggle (close(true), :72/:93), Tab-out dismisses without choosing (:79-82); menu roles + arrow nav intact (:111-112, :122, :131, :73-78). - Busy-disable on all three controls: disabled={props.disabled} x3 (:99, :114, :133), fed by getBusy() (:262, :291); runClose/commentAndClose re-guard on getBusy() (:206, :219). - closeChooser=false fallback intact (:248-254, :269-283); reopen stays plain button via onClose without chooser. - Event text: issueEventText state_changed closed/completed -> 'closed as completed', not_planned -> 'closed as not planned' (issue-events.js:82-84); closePatch throws on missing reason (:102-107). - No backend change (diff: 08_ui_sdk.md + CommentComposer.jsx + split-close.test.js only); state_reason vocabulary untouched; no new deps (only added import is onCleanup from solid-js); dark+light menu-item classes unchanged (hover:bg-zinc-100 dark:hover:bg-zinc-800, :132). ONE FINDING (fixed, pushed a9a3911): 08_ui_sdk.md claimed an 'incidental fix' where the Comment-and-Close chooser passed props.disabled (always undefined) instead of getBusy() — inaccurate: main already passed disabled={getBusy()} to BOTH chooser menus. Doc now states the kept behavior (all three split controls disable from getBusy(), as the choosers did). Code was already correct; no behavior change. VERIFY: full node suite in scratch 595/595 pass (588 existing + 7 new split-close); vite build clean in 2s (chunk-size warning is pre-existing). No browser (per instructions; change is headless-testable + reasoned). Main worktree untouched. MERGE RECOMMENDATION: ready to merge.
Author
Owner

Fixed by PR #315 incl. review doc fix (one-click completed + not-planned segments + outside-click; 595/595), merged. Closing.

Fixed by PR #315 incl. review doc fix (one-click completed + not-planned segments + outside-click; 595/595), merged. Closing.
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
crueber/walhub#311
No description provided.