Skip to content

feat(pickers): cycle assignee, label, and reviewer rows with Tab - #386

Merged
theBGuy merged 6 commits into
masterfrom
feat/picker-tab-row-cycling
Sep 22, 2026
Merged

theBGuy merged 6 commits into
masterfrom
feat/picker-tab-row-cycling

Conversation

@theBGuy

@theBGuy theBGuy commented Sep 21, 2026

Copy link
Copy Markdown
Owner

In the assignees, labels, and reviewers pickers, Tab and Shift+Tab now cycle through the rows, wrapping at both ends, so you can multi-select the whole list one-handed with Tab and Space. Nothing else in those popups is focusable, so Tab has no other job there, and Esc still closes them. The roving-tabindex wiring that five pickers each copied is now one shared hook. The change also removes a stray scrollbar caused by the Checkbox touch target.

Keyboard navigation helper

  • New tabAdvances option on listKeyboardNav in src/lib/list-keyboard-nav.ts:
    • Tab and Shift+Tab move the active row and wrap at both ends. Arrow keys still stop at the ends.
    • Ctrl, Alt, or Meta with Tab keeps its native behavior.
    • An empty list returns before preventDefault, so Tab behaves natively.
    • The Shift on Shift+Tab sets direction only. onActivate receives shift: false for it, so it never extends a range.
  • New useRovingRows hook in the same file:
    • It holds the activeId state and keeps a single tab stop on the active row, falling back to the first row.
    • It returns onRowKeyDown, rowProps(item) (data-row, tabIndex, onFocus) and isActive(item).
    • It replaces the per-component activeId, navIndexById and focusIndex code.
  • Lint allowlist: scripts/check-banned-patterns.mjs allowlists src/lib/list-keyboard-nav.ts for the hand-rolled ctrl/meta check. The rationale: tabAdvances excludes every modified Tab, so it doesn't read a platform modifier.

Pickers

  • Tab cycling enabled: AssigneesPopover (src/features/issues/IssueMetaPickers.tsx), LabelsPopover (src/features/conversations/LabelsPopover.tsx) and ReviewersPopover (src/features/pulls/ReviewersPopover.tsx) use useRovingRows with tabAdvances: true.
  • On-screen hint: while rows exist, those three popups show "Tab cycles the rows; Esc closes." Tab is trapped in both directions there, so the Esc exit has to be visible. LabelsPopover appends the hint to its existing "Changes apply when this closes." footer.
  • Hook without Tab cycling:
    • ProjectsPopover (src/features/conversations/ProjectsPopover.tsx) uses useRovingRows without tabAdvances, because Tab still has to reach its Reconnect and Retry buttons. ProjectRow gains a rowKey prop so its data-row matches the key the hook queries by.
    • MultiSelectRows in src/features/conversations/ProjectFieldsEditor.tsx also skips tabAdvances, because its rows sit among the popup's other field editors.

Scrollbar fix

  • py-2 on three scroll containers: ProjectsPopover.tsx, ProjectFieldsEditor.tsx and src/features/repository/CleanupBranchesDialog.tsx.
    • The Checkbox touch target bleeds 8px vertically (after:-inset-y-2), which created scrollable overflow.
    • Because of that overflow, Windows drew a scrollbar even for a single row. The padding contains the bleed.

Documentation

  • Changelog: changelog.d/changed-projects-picker-parity.md gains a sentence about Tab cycling in the assignees, labels, and reviewers pickers.
  • Other surfaces: this PR does not change README.md, site/, or src/features/help/content.ts.

Summary by CodeRabbit

  • New Features

    • Added Tab and Shift+Tab navigation with wraparound to assignee, label, and reviewer pickers.
    • Added accessible guidance explaining that Tab cycles through rows and Esc closes the picker.
    • Improved keyboard focus management and active-row highlighting across project and other selectable lists.
  • Bug Fixes

    • Prevented unnecessary scrollbars from appearing in the branch cleanup dialog on Windows.

- list-keyboard-nav: add opt-in `tabAdvances`. Tab/Shift+Tab move the
  active row and wrap at both ends, while the arrows still clamp. Modified
  Tab and an empty list stay native, and Shift+Tab never extends a range.
- list-keyboard-nav: add `useRovingRows`, which extracts the
  roving-tabindex state and row props that five pickers each duplicated.
- Assignees, Labels, Reviewers popovers: adopt the hook with Tab cycling,
  since nothing else in those popups can take focus. Each popover shows
  "Tab cycles the rows; Esc closes." so the keyboard exit stays visible.
- Projects popover, project multi-select fields: adopt the hook without
  Tab cycling. Tab still has to reach Reconnect/Retry and the neighbouring
  field editors.
- Projects popover, project fields editor, cleanup-branches dialog: add
  py-2 to the scroll containers. The Checkbox touch target's vertical bleed
  no longer forces a Windows scrollbar on a single row.
- check-banned-patterns: allowlist list-keyboard-nav.ts, whose ctrl/meta
  read rejects every modified Tab and does not derive a platform modifier.
- changelog: extend the projects-picker parity fragment with Tab cycling.
@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Sep 21, 2026

Copy link
Copy Markdown

Deploying gitdesktop with  Cloudflare Pages  Cloudflare Pages

Latest commit: 83a6680
Status: ✅  Deploy successful!
Preview URL: https://02b37dc8.gitdesktop.pages.dev
Branch Preview URL: https://feat-picker-tab-row-cycling.gitdesktop.pages.dev

View logs

@theBGuy theBGuy added the enhancement New feature or request label Sep 21, 2026
@theBGuy theBGuy self-assigned this Sep 21, 2026
@theBGuy

theBGuy commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

Context for reviewers — deliberate calls and their evidence, from the session that built this. Items are numbered; each is a single claim.

  1. What this does: restores one-handed Tab-through in the assignee, reviewer, and label pickers (Tab = next row, Shift+Tab = previous, wrapping at the ends), extracts the roving-tab-stop block previously copied across five pickers into one useRovingRows hook (the refactor deferred on the feat(projects,pulls): edit projects on every PR and from the palette #382 record), and pads three checkbox-hosting scroller rails so the checkbox's touch-target pseudo can't mint a phantom scrollbar.
  2. Tab wraps while arrows clamp — deliberate. Inside a popup, cycling is what makes one-handed multi-select work; the arrows keep their shipped clamp. Verified live via CDP keyboard walk on all three popups (wrap forward at last row, wrap back at first, arrow clamp at the end).
  3. Tab-advance opts in only where the popup contains no other focusable element (Assignees, Reviewers, Labels). ProjectsPopover keeps native Tab (its popup holds Reconnect/Retry buttons Tab must reach — verified live: Tab exits its rows to the next control), and the project-fields multi-select keeps native Tab (it renders among other field editors).
  4. Modified Tab stays native. Ctrl/Alt/Meta+Tab are excluded before any row handling — verified live: Ctrl+Tab with the popover open reached the app-level chord and moved no row focus. The hand-rolled-mod-key guard allowlists list-keyboard-nav.ts with rationale: this exclusion is symmetric across all platforms, so an isMac derivation would be wrong here.
  5. Esc in a nested host closes only the popover. Verified live in the Create PR dialog: Esc closed the assignees popover, the dialog and its draft survived, focus returned to the trigger; a second Esc closes the dialog.
  6. Each opt-in popup carries a footer advisory — "Tab cycles the rows; Esc closes." — the on-surface advisory for the consumed Tab exit (WCAG 2.1.2), gated on rows existing: with zero rows Tab stays native, so the advisory would be a false claim there.
  7. onActivate reports shift: false on Tab moves (Shift is the direction there, never range-extend). The only two call sites consuming that parameter (HistoryPanel.tsx:472, RepositoryFilesDialog.tsx:387) don't set tabAdvances, so their behavior is expression-identical.
  8. CleanupBranchesDialog is deliberately not folded onto the hook. It spells the same roving pattern inline (:489-494, :800-801) and its checkboxes carry disabled={running}, so adoption isn't a mechanical swap — recorded as its own follow-up; the dedup acceptance for this PR is scoped to the five feat(projects,pulls): edit projects on every PR and from the palette #382 copies.
  9. The scroller py-2 is a measured fix, not styling drift. The vendored Checkbox's touch-target pseudo (after:-inset-y-2, computed −8px) contributes layout overflow inside overflow-y-auto rails; measured pre-fix on the Projects rail: clientHeight 28 vs scrollHeight 29 with a single row, which Windows renders as a full arrows-and-track scrollbar. Post-fix live re-measure: 44/44, no overflow. BoardAddDialogs' scrollers are exempt — zero Checkbox occurrences in that file (button rows).
  10. Changelog: no new fragment, deliberately. The Tab behavior extends feat(projects,pulls): edit projects on every PR and from the palette #382's pending changed-projects-picker-parity bullet (that picker behavior is unreleased), and the scrollbar fix is dev-only for the same reason. README, site, and in-app guide are deliberately untouched: none of the three documents per-picker key behavior (grepped this session).
  11. Disclosure: the project-fields multi-select rows were not live-driven (no fixture exposes project fields); they're covered by static review and share the exact wiring of the live-driven ProjectsPopover, without tabAdvances. Single-row wrap (degenerate case) was live-verified on the Reviewers picker — Tab stays put, no crash.

Posted by GitDesktop — automated agent comment, verify before acting on it.

@theBGuy
theBGuy marked this pull request as ready for review September 21, 2026 23:37
Copilot AI lite review requested due to automatic review settings September 21, 2026 23:37
@coderabbitai

coderabbitai Bot commented Sep 21, 2026

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Warning

Review limit reached

Next included review available in 3 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used all 2 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: df81261f-45df-4ddb-b0a0-704f89c42af2

📥 Commits

Reviewing files that changed from the base of the PR and between b450343 and 83a6680.

📒 Files selected for processing (3)
  • .github/workflows/frontend.yml
  • .github/workflows/quality.yml
  • scripts/list-keyboard-nav.test.mjs

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Advanced

Run ID: 6ec5218a-8893-4736-8406-4b212efe4efb

📥 Commits

Reviewing files that changed from the base of the PR and between 07b9de2 and b450343.

📒 Files selected for processing (3)
  • .github/workflows/frontend.yml
  • .github/workflows/quality.yml
  • scripts/list-keyboard-nav.test.mjs

Included review availability: Your plan provides up to 2 included reviews per hour; 0 remain after this review.

📜 Recent review details
⏰ Context from checks skipped due to timeout. (1)
  • GitHub Check: Cloudflare Pages
🔇 Additional comments (3)
scripts/list-keyboard-nav.test.mjs (1)

10-16: LGTM!

Also applies to: 18-18, 21-21, 26-26, 28-28, 32-66, 68-129

.github/workflows/frontend.yml (1)

71-76: LGTM!

.github/workflows/quality.yml (1)

75-79: LGTM!


📝 Walkthrough

Walkthrough

The shared keyboard navigation utility now supports optional Tab cycling and roving row focus. Conversation, issue, and pull request pickers use the shared hook. Tests cover Tab behavior, and supporting changes contain checkbox touch-target overflow.

Changes

Roving row navigation

Layer / File(s) Summary
Navigation core
src/lib/list-keyboard-nav.ts
listKeyboardNav adds wrapped Tab and Shift+Tab navigation. useRovingRows manages the active row, tab stop, focus properties, and active styling.
Picker adoption
src/features/conversations/LabelsPopover.tsx, src/features/conversations/ProjectFieldsEditor.tsx, src/features/conversations/ProjectsPopover.tsx, src/features/issues/IssueMetaPickers.tsx, src/features/pulls/ReviewersPopover.tsx
Picker lists replace local navigation state with useRovingRows. Assignee, reviewer, and label lists enable Tab cycling. Project lists keep Tab available for other focusable controls. Picker hints use Popover.Description where added or updated.
Validation and supporting changes
scripts/list-keyboard-nav.test.mjs, .github/workflows/frontend.yml, .github/workflows/quality.yml, scripts/check-banned-patterns.mjs, changelog.d/changed-projects-picker-parity.md, src/features/repository/CleanupBranchesDialog.tsx, src/features/conversations/ProjectFieldsEditor.tsx, src/features/conversations/ProjectsPopover.tsx
Tests cover Tab wrapping, modifier handling, empty lists, disabled Tab advancement, and arrow clamping. Workflow steps run the keyboard-navigation suite. Supporting changes document the modifier-key exception, revise the changelog, and contain checkbox touch-target overflow.

Priority: ⬇️ Low

Estimated code review effort: 3 (Moderate) | ~25 minutes

Change: Feature

Sequence Diagram(s)

sequenceDiagram
  participant Picker
  participant Checkbox
  participant useRovingRows
  participant listKeyboardNav
  Picker->>Checkbox: render row properties
  Checkbox->>useRovingRows: send arrow or Tab keydown
  useRovingRows->>listKeyboardNav: calculate next row
  listKeyboardNav-->>useRovingRows: return wrapped or clamped row
  useRovingRows-->>Checkbox: update active row and tab stop
Loading

Merge Risk: ⚪ Minimal · up to b4503

The PR adds wrapped Tab navigation and shared picker row focus behavior while preserving native navigation where needed; current evidence supports merging with no remaining merge-blocking risk.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 9 files. (2 skipped: … Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: adding Tab-based row cycling to the assignee, label, and reviewer pickers.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Full details: Docstring Coverage

Explanation

Docstring coverage is 75.00% which is insufficient. The required threshold is 80.00%. Docstring coverage is scoped to functions touched by this diff. Analyzed 16 functions across 9 files. (2 skipped: 2 unsupported.)

✨ Finishing Touches 💡 2
📝 Generate docstrings 💡
  • Commit to this branch
  • Create a new PR
⚔️ Resolve merge conflicts 💡
  • Resolve merge conflict in branch feat/picker-tab-row-cycling
🧪 Generate unit tests (beta)
  • Commit to this branch
  • Create a new PR

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@theBGuy

theBGuy commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

🤖 GitDesktop AI review · opus · automated


The refactor is sound. The five copied roving-tab-stop blocks now come from one useRovingRows hook. With tabAdvances off, the shared listKeyboardNav handler behaves exactly as before, so the other ~50 callers are unaffected. The Tab-cycling opt-in is limited to the three popups that have no other focusable element, which I confirmed in each file. Nothing blocks the merge. Four findings below.

Recorded decisions I'm not re-flagging (all noted in the description):

  • Tab wraps while the arrows clamp.
  • ProjectsPopover and the project-field rows don't get tabAdvances.
  • CleanupBranchesDialog stays inline for now.
  • README, site and in-app guide are unchanged. I checked src/features/help/content.ts and it has no per-picker key text.

Accessibility

  • should-fix — The "Tab cycles the rows; Esc closes." footer is only visual, so screen-reader users never hear it:

    • src/features/issues/IssueMetaPickers.tsx (the AssigneesPopover footer, ~L210–214)
    • src/features/pulls/ReviewersPopover.tsx (~L212–216)
    • src/features/conversations/LabelsPopover.tsx (~L179–183)

    The description names this footer as the WCAG 2.1.2 advisory for the Tab behavior. But each footer is a plain <p> that isn't linked to the popup. A screen-reader user who opens the popup lands on a checkbox and hears only "checkbox, not checked, alice". They are never told that Tab now stays inside the popup or that Esc is the way out. They would only find the text by switching to browse mode.

    Fix: render each footer as the popup's description, keeping the existing loaded.length > 0 / rows.length > 0 gate:

    <Popover.Description render={<p />} className="mt-1 border-t px-1 pt-1.5 text-[11px] text-muted-foreground">
      Tab cycles the rows; Esc closes.
    </Popover.Description>

    Base UI then adds aria-describedby to the role="dialog" popup, and screen readers announce it when focus enters. In Labels, the whole footer sentence becomes the description. Also extend the tabAdvances doc in src/lib/list-keyboard-nav.ts from "only while its Esc exit is on screen" to "…on screen as the popup's Popover.Description", so the next popup that opts in does the same.

Tests

  • should-fixsrc/lib/list-keyboard-nav.ts (listKeyboardNav, the tab branch): the new mode has no test, even though it lives in a handler about 50 call sites share. The repo already has a runner that can test it: node --test "scripts/*.test.mjs" runs in pnpm checks and in quality.yml, and it imports .ts directly.

    Fix: add scripts/list-keyboard-nav.test.mjs that imports listKeyboardNav from ../src/lib/list-keyboard-nav.ts. Build it with items: ["a","b","c"], a recording onActivate, and no rowKey. Without rowKey and ignoreTextEntry, CSS.escape and HTMLElement are never reached, so no DOM is needed. Call it with a stub event { key, shiftKey, ctrlKey, altKey, metaKey, preventDefault() }. Assert that:

    • Tab at index 2 goes to 0, and Shift+Tab at 0 goes to 2.
    • With nothing active (index −1), Tab goes to 0 and Shift+Tab goes to 2.
    • A Tab move reports shift === false.
    • Ctrl+Tab, Alt+Tab, Meta+Tab, and any Tab on an empty list call neither preventDefault nor onActivate.
    • With tabAdvances off, Tab is ignored and ArrowDown/ArrowUp still stop at the ends.

Layout

  • nitsrc/features/conversations/ProjectsPopover.tsx, the row rail (~L404–407): the new py-2 also applies when rows is empty, because this div always renders outside the classicMissing branch. That leaves a 16px blank band under "Loading projects…", under "No open projects in this repository or its owner.", and under the Retry block. Fix: render the rail only when there are rows, as {rows.length > 0 && (<div …>)}, and move the py-2 comment with it. The key handler has nothing to do with zero rows anyway.

Copy

  • nitchangelog.d/changed-projects-picker-parity.md L4–7: the sentence now chains "…with the arrow keys, and Tab cycles those rows…; and the Projects picker says…", which is hard to follow. Fix: "…the assignees, labels, and reviewers pickers walk their rows with the arrow keys and cycle them with Tab, so you can work the whole list one-handed. The Projects picker also says when an item sits on more boards than GitHub handed back."

Posted by GitDesktop — AI output, verify before acting on it.

@theBGuy

theBGuy commented Sep 21, 2026

Copy link
Copy Markdown
Owner Author

🤖 GitDesktop AI security audit · opus · automated


I found no security issues in this PR. It changes client-side keyboard focus handling and CSS classes only, and none of the changes pass untrusted input into anything that could run it.


Posted by GitDesktop — AI output, verify before acting on it.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟢 Approval recommended

The keyboard navigation behavior and shared hook refactor are consistent with the PR description, and the scrollbar fix is scoped and well-justified without introducing observable regressions in the reviewed call sites.

Review effort: Lite
Findings: None

What changed in this PR

Adds Tab/Shift+Tab cycling for the assignees, labels, and reviewers picker rows (wrapping at both ends) to enable one-handed Tab+Space multi-select, and consolidates the previously duplicated roving-tabindex logic into a shared hook in src/lib/list-keyboard-nav.ts. Also addresses a Windows-only scrollbar artifact caused by the Checkbox touch target bleeding outside scroll containers.

Changes:

  • Extend listKeyboardNav with an opt-in tabAdvances mode for Tab/Shift+Tab row navigation (wrapping) while preserving modified-Tab native behavior.
  • Introduce useRovingRows hook and migrate relevant pickers to it (Tab-cycling enabled where Tab has no other job).
  • Fix stray scrollbars by adding py-2 padding to affected scroll containers; update banned-patterns allowlist and changelog entry accordingly.
File Description
src/​lib/​list-keyboard-nav.ts Adds tabAdvances to listKeyboardNav and introduces useRovingRows to share roving-tabindex + key handling.
src/​features/​pulls/​ReviewersPopover.tsx Switches to useRovingRows with Tab cycling and adds an on-screen keyboard hint.
src/​features/​issues/​IssueMetaPickers.tsx Updates AssigneesPopover to use useRovingRows with Tab cycling and adds the keyboard hint.
src/​features/​conversations/​LabelsPopover.tsx Updates to useRovingRows with Tab cycling and appends the keyboard hint to the footer.
src/​features/​conversations/​ProjectsPopover.tsx Uses useRovingRows (without Tab cycling) and adds py-2 to contain Checkbox bleed; adjusts ProjectRow to accept rowKey.
src/​features/​conversations/​ProjectFieldsEditor.tsx Uses useRovingRows for multi-select rows (without Tab cycling) and adds py-2 to contain Checkbox bleed.
src/​features/​repository/​CleanupBranchesDialog.tsx Adds py-2 to the scroll container to prevent overflow-induced scrollbars.
scripts/​check-banned-patterns.mjs Allowlists src/lib/list-keyboard-nav.ts for the ctrl/meta Tab-modifier check with rationale.
changelog.d/​changed-projects-picker-parity.md Documents Tab cycling in the assignees/labels/reviewers pickers.

💡 Configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

@theBGuy

theBGuy commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

Round 1 dispositions — all four AI-review findings accepted; batch applied, lands in the next push.

  1. SR-announced advisory — fixed. All three footers are now the popup's Popover.Description (render={<p />}, same text, same rows-exist gate), so Base UI wires aria-describedby on the role="dialog" popup; the tabAdvances doc now names the Description as part of the opt-in contract. Verified live on the built app: the popup's aria-describedby resolves to "Tab cycles the rows; Esc closes."
  2. Tab-branch tests — fixed, with one mechanism correction. scripts/list-keyboard-nav.test.mjs covers the prescribed assertion set plus two additions (plain Tab 0→1 and Shift+Tab 2→1 — without them an always-wrapping implementation would have passed). Correction: a static import of list-keyboard-nav.ts would redden CI — the guards job runs these self-tests installless by design (quality.yml's own comment) and the module value-imports useState. The test therefore uses the repo's optional-import precedent (dynamic import, catch narrowed to ERR_MODULE_NOT_FOUND so real breakage still fails loudly); it runs everywhere deps exist — locally it's 8/8, and pnpm run checks now counts 447 self-tests, up from 439.
  3. Empty-rows blank band — fixed. The Projects rail is gated on rows.length > 0 with the py-2 rationale moved inside. Class-checked the other two padded rails by reading, not assumption: the project-fields popup can't open with zero boards (showEditor/unsettledReason coupling in ProjectFieldValues), and the cleanup-branches rail is already the non-empty ternary arm. Fair catch on our own scrollbar fold-in's collateral.
  4. Fragment flow — fixed. Recast per the suggested copy (each clause re-verified against shipped behavior); the preceding clause got an "alike, and" splice so the new sentence break reads.

Security audit: clean bill, acknowledged — nothing to triage.

Round-close gates on the batch: tsc clean, biome CI-form clean (one formatting red on the new test file caught and fixed by the gate itself), changelog check 44 valid, guards + self-tests 447/447, second-provider shadow dry (two consecutive clean passes on the applied batch). Note for the push: scripts/list-keyboard-nav.test.mjs is a new file.


Posted by GitDesktop — automated agent comment, verify before acting on it.

- Assignees, labels, reviewers: the "Tab cycles the rows; Esc closes."
  footer now renders as `Popover.Description`, which wires it as the
  popup's `aria-describedby`. Tab is trapped in both directions while
  rows exist, so screen readers need to hear the Esc exit, not only see it.
- Projects picker: the scroll rail renders only when there are rows. Its
  `py-2` padding (which contains the checkbox touch-target bleed) no
  longer paints a blank band under the loading and empty notices.
- `scripts/list-keyboard-nav.test.mjs`: new tests for the `tabAdvances`
  branch:
  - Tab and Shift+Tab wrap at both ends.
  - With no active row, Tab enters at the first row and Shift+Tab at
    the last.
  - Shift+Tab reports `shift: false`, since Shift only sets direction.
  - Ctrl/Alt/Meta+Tab and an empty list stay native.
  - The arrow keys still clamp at the ends.

  The module is imported dynamically. The suite skips only on
  `ERR_MODULE_NOT_FOUND`, because the `guards` job runs without
  installing react.
- `list-keyboard-nav.ts`: the `tabAdvances` doc now names the
  `Popover.Description` contract.
- Changelog fragment reworded into two sentences.
@theBGuy

theBGuy commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

🤖 GitDesktop AI review · opus · automated


The earlier findings are fixed, apart from one gap in the new test. The Tab-branch suite this round adds never runs in CI, and it can skip silently anywhere. Nothing blocks the merge.

Recorded decisions I'm still not re-flagging: Tab wraps while the arrows clamp; Projects and project-field rows keep native Tab; CleanupBranchesDialog stays inline; and README, site and in-app guide stay unchanged.

Tests

  • should-fix: scripts/list-keyboard-nav.test.mjs, the dynamic import at L16–23. The suite added to pin the Tab branch has two gaps:

    1. CI never runs it. The only CI run of scripts/*.test.mjs is the installless guards job (quality.yml L80–81). There, importing react throws ERR_MODULE_NOT_FOUND, so the whole describe is skipped, and node --test treats a skipped test as a pass. The existing optional-import test, the marked render oracle, has a second run with dependencies installed: frontend.yml L69–70 runs node --test "scripts/comment-refs.test.mjs". This suite has no such step, so its eight assertions only ever run locally through pnpm checks.
    2. The catch is wider than the header claims. The comment says the suite "skips on exactly that failure", meaning react missing. But a missing relative module also throws ERR_MODULE_NOT_FOUND. Here is how that happens: someone runs git mv src/lib/list-keyboard-nav.ts src/lib/keyboard/list-nav.ts. tsc -b makes them update every @/lib/list-keyboard-nav import under src/. But scripts/ isn't in any tsconfig include (only src and vite.config.ts are), so this test's ../src/lib/list-keyboard-nav.ts path goes stale without any error. From then on the suite reports "react is not installed" and passes everywhere, even in an installed CI step.

    Fix:

    1. Add import { existsSync } from "node:fs";. It's a node: import, so the installless constraint still holds.
    2. Hoist const MODULE = new URL("../src/lib/list-keyboard-nav.ts", import.meta.url); and import it with await import(MODULE.href).
    3. Change the catch guard to if (err?.code !== "ERR_MODULE_NOT_FOUND" || !existsSync(MODULE)) throw err;. A moved module then fails loudly, and the header's "exactly that failure" becomes true.
    4. In frontend.yml, right after the reference-detector step, add:
            # The keyboard-nav suite reaches react through the module under test and
            # skips in the no-install guards job — this installed job is its one
            # enforced run.
            - name: Keyboard-nav suite (react import enforced)
              run: node --test "scripts/list-keyboard-nav.test.mjs"
    5. Sync the two comments that describe the enforced run. In quality.yml L74–77, change "optional imports like the marked render oracle skip when absent … the frontend job is the oracle's enforced, installed run" so it names both suites, e.g. "optional imports (the marked render oracle, the keyboard-nav suite's react) skip when absent … the frontend job is their enforced, installed run". In the test's second header paragraph, add a clause saying the frontend job's installed step is the suite's enforced run.

Resolved since last review

  • The screen-reader gap on the footer is fixed. All three footers (AssigneesPopover, ReviewersPopover, LabelsPopover) are now Popover.Description, still behind the rows-exist check. The tabAdvances doc in src/lib/list-keyboard-nav.ts now names the Description as part of the opt-in contract.
  • The Tab-branch tests exist now. scripts/list-keyboard-nav.test.mjs covers wrapping, the nothing-active entry point, shift === false, modified Tab, the empty list, the off switch and the arrow clamp. Its CI gap is the finding above.
  • The blank band under the Projects rail is gone. ProjectsPopover renders the rail only when rows.length > 0, and the py-2 explanation moved inside the gate.
  • The changelog sentence is fixed. changelog.d/changed-projects-picker-parity.md now reads cleanly, and the new sentence break doesn't leave a dangling clause.

Verdict: no blocking issues — remaining items are non-blocking; merge when ready


Posted by GitDesktop — AI output, verify before acting on it.

@theBGuy

theBGuy commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

Round 2 disposition — the one finding accepted as prescribed, fixed; lands in the next push.

Test CI-enforcement + skip bound — fixed. The suite's dynamic import now resolves a hoisted MODULE URL and the catch rethrows unless ERR_MODULE_NOT_FOUND and the module file still exists — so a moved/renamed module fails loudly instead of skipping forever as "react missing". frontend.yml gains the suite's enforced installed step (mirroring the render oracle's, inside the required build check), and quality.yml's installless comment now names both suites.

Both guard arms were negative-controlled from scratch copies before accepting: module-moved with react present → the runner fails (1 fail, exit 1; the pre-hardening guard would have skipped it silently), and module-present with a dependency unresolvable → clean skip. Gates on the batch: node --test 8/8, checks 447/447 self-tests, tsc clean, biome CI-form clean (it caught one import-order defect on the test copy; biome's own sort applied), second-provider shadow dry again. The two workflow edits are a pure 6-line step addition and a comment-only sync — careful-diffed against the adjacent working step, since no local YAML runner exists; the added step's first real execution will be this push's CI.


Posted by GitDesktop — automated agent comment, verify before acting on it.

The keyboard-nav suite skips when react can't be resolved, and the
installless guards job never resolves it. Until now nothing ever ran the
suite for real.

- frontend.yml: run scripts/list-keyboard-nav.test.mjs in the installed
  job, the suite's only enforced run
- list-keyboard-nav.test.mjs: skip only when ERR_MODULE_NOT_FOUND occurs
  and the module under test still exists on disk. A moved or renamed
  module now fails loudly instead of skipping forever as "react missing".
- quality.yml: name the keyboard-nav suite alongside the marked render
  oracle in the guard self-tests comment
@theBGuy

theBGuy commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

🤖 GitDesktop AI review · opus · automated


The previous round's test fix is in place: the Tab-branch suite now has an installed CI run, and a moved module no longer turns into a silent skip. The fix is correct. Two small comment nits are left, and nothing blocks the merge.

Readability

  • nit: scripts/list-keyboard-nav.test.mjs, header L14–16. "Nothing type-checks this path, so the frontend job's installed step is this suite's one enforced run" joins two unrelated facts with "so". The missing type-check is why the existsSync bound exists. The installless guards skip is why the frontend job is the enforced run. The paragraph is also 10 lines, above the repo's ~6-line budget for comments. Suggested replacement for L6–16:
    // Stdlib-only, so the installless `guards` job runs it — but the module under
    // test value-imports react, so the import is dynamic and skips only when a
    // specifier is unresolved (ERR_MODULE_NOT_FOUND, measured on node 24) AND the
    // module is still on disk: nothing type-checks this path, so a moved module
    // must fail loudly. The frontend job's installed step is the enforced run.
  • nit: .github/workflows/frontend.yml, L22. The push-path comment on scripts/** still says only "the reference-detector suite step reads these", but the new step at L75–76 reads from scripts/ too. Change it to # the reference-detector and keyboard-nav suite steps read these.

Resolved since last review

  • The suite is now enforced in CI. frontend.yml L72–76 adds node --test "scripts/list-keyboard-nav.test.mjs" to the build job. That job runs on node 24 after pnpm install --frozen-lockfile and has no paths filter on PRs, so the react import actually resolves there.
  • The skip is now bounded. The import resolves a hoisted MODULE URL. The catch rethrows unless the error is ERR_MODULE_NOT_FOUND and existsSync(MODULE) still holds; fs.existsSync accepts a file: URL object, so the check is valid as written. A moved or renamed module now fails loudly.
  • The comments that describe the enforced run are in sync. The quality.yml L74–79 comment names both optional-import suites and the frontend job as their installed run.

Verdict: no blocking issues — remaining items are non-blocking; merge when ready


Posted by GitDesktop — AI output, verify before acting on it.

@theBGuy

theBGuy commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

Round 3 dispositions — both nits fixed; this is the closing batch and it rides the final push.

  1. Test header — fixed. Condensed to the suggested five lines with the causal pairing corrected (the missing type-check motivates the existsSync bound; the installless guards job motivates the enforced frontend run). Each clause re-verified against the guard before adopting the wording.
  2. frontend.yml push-path comment — fixed. The scripts/** line now names both suite steps that read it.

Gates on the batch: suite 8/8 (count read from the runner output), biome CI-form clean on the test file, NUL/newline sweep clean; the workflow delta is comment-only. Second-provider shadow deliberately skipped for this batch — comment-only deltas keep the recorded waiver.

With this push the board is: three consecutive merge-when-ready verdicts, Copilot approval with no findings, CodeRabbit no actionable comments, security audit clean, and CI green with the new suite step's first enforced run landing on this head. Recommending merge once that run is green.


Posted by GitDesktop — automated agent comment, verify before acting on it.

The frontend workflow's `scripts/**` path-filter comment named only the
reference-detector step. It now also names the keyboard-nav suite step,
which reads the same directory.

The list-keyboard-nav test header is cut down to its constraints:

- The suite is stdlib-only, so the installless `guards` job can run it.
- The import is dynamic because the module under test value-imports react.
- The skip fires only when the specifier is unresolved AND the module is
  still on disk, so a moved module fails loudly.
- The frontend job's installed step is the enforced run.

Comment-only change with no behavior change.
@theBGuy

theBGuy commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

🤖 GitDesktop AI review · opus · automated


This round only changes comments. Both nits from the last review are fixed, and neither fix introduces new problems. Nothing blocks the merge.

Resolved since last review

  • Test header comment (scripts/list-keyboard-nav.test.mjs, lines 6–10): now five lines, with each reason tied to the right fact. The missing type-check explains the existsSync bound, and the installless guards job explains why the frontend job is the enforced run. It matches the code: the catch on line 22 rethrows unless the error is ERR_MODULE_NOT_FOUND and existsSync(MODULE) holds. It still implies, correctly, that every other import error fails loudly.
  • Push-path comment (.github/workflows/frontend.yml, line 22): the scripts/** comment now names both suite steps that read it, matching the step at lines 72–76.

I also checked the hook's call sites against its "call it before any early return" rule. AssigneesPopover (IssueMetaPickers.tsx:144) and ProjectsPopover (ProjectsPopover.tsx:210) both call useRovingRows before any early return. The only returns above those lines are inside nested functions and IIFEs. There is nothing further to raise.

Verdict: no blocking issues — remaining items are non-blocking; merge when ready


Posted by GitDesktop — AI output, verify before acting on it.

@theBGuy

theBGuy commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

🤖 GitDesktop AI review · opus · automated


This round rebases the PR onto master and moves the keyboard-nav suite into master's combined "Node suites needing node_modules (enforced)" step. The picker and hook code hasn't changed and is still sound. One problem came in with the rebase: the new suite doesn't honor that step's GD_EXPECT_DEPS contract, so the "enforced" run can still skip it silently. Nothing blocks the merge.

CI enforcement

  • should-fixscripts/list-keyboard-nav.test.mjs, the import catch (lines 21–23) and header line 10; the claim it breaks is in .github/workflows/frontend.yml, lines 66–75.

    • The problem: the step now sets GD_EXPECT_DEPS: "1", and its comment says that setting "turns an unresolvable import there into a failure rather than a silent skip" for all three suites, keyboard-nav included by name. The two sibling suites honor it: comment-refs.test.mjs:1793 and pr-create-lane.test.mjs:34 both do if (process.env.GD_EXPECT_DEPS) throw e;. This file never reads the variable. It still skips on ERR_MODULE_NOT_FOUND whenever the module file exists.
    • The case that triggers it: someone adds a runtime import to src/lib/list-keyboard-nav.ts that the bundler accepts but Node's type-stripping loader can't resolve, such as an extensionless ./foo import. pnpm build passes. The import throws ERR_MODULE_NOT_FOUND and existsSync(MODULE) is still true, so skip is set. describe(..., { skip }) then passes green in the guards job and in the frontend job. The suite is dead everywhere, which is exactly the silent fail-open the variable exists to stop. The header's own reasoning ("nothing type-checks this path, so a moved module must fail loudly") covers the module's imports too, not just the module's path.
    • Fix: make the variable an unconditional rethrow, keeping the current bound for the installless job:
      } catch (err) {
        if (
          process.env.GD_EXPECT_DEPS ||
          err?.code !== "ERR_MODULE_NOT_FOUND" ||
          !existsSync(MODULE)
        )
          throw err;
        skip = "react is not installed — the guards job runs with no install step";
      }
      Then change header line 10 so it states the contract, e.g. "The frontend job's installed step is the enforced run: it sets GD_EXPECT_DEPS, which fails any unresolved import there." With that change, frontend.yml's comment becomes true as written and needs no edit.
  • nit.github/workflows/quality.yml, lines 74–77. This PR's rewrite lists the suites that skip in the installless job as "the marked render oracle, the keyboard-nav suite's react". After the rebase, pr-create-lane.test.mjs skips there too (zustand plus @tanstack/react-query). Either add it, matching the three-suite list in frontend.yml lines 67–69, or reword to "optional imports (see frontend.yml's enforced Node-suites step) skip when absent" so this comment doesn't have to track the list.

Verdict: no blocking issues — remaining items are non-blocking; merge when ready


Posted by GitDesktop — AI output, verify before acting on it.

@theBGuy

theBGuy commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

Round 5 dispositions — both accepted, fixed; ready for the push.

  1. GD_EXPECT_DEPS honored — fixed. The catch is now the combined condition (env set, wrong error code, or module missing → rethrow), and the header states the contract. Credit where due: this arm was authored during the conflict resolution but sat unstaged in the resolve worktree, so the merge commit carried the three-suite contract without it — the review caught the gap on the pushed head, and its extensionless-import trigger is a sharper rationale than the original. Verified with the full four-cell battery: deps present → 8/8 with and without the env; dep broken + env → throws ERR_MODULE_NOT_FOUND, exit 1; dep broken, no env → clean skip (the installless guards job keeps working).
  2. quality.yml roster — fixed with the upgrade. Took the pointer form ("see frontend.yml's enforced Node-suites step") rather than re-listing suites — the list went stale within one rebase, so the comment now names the authority instead of tracking the roster.

Gates: biome CI-form clean on the test file, NUL/newline sweep clean, scratch controls deleted. Two-file batch (scripts/list-keyboard-nav.test.mjs, .github/workflows/quality.yml), both tracked.


Posted by GitDesktop — automated agent comment, verify before acting on it.

The keyboard-nav suite imports its module dynamically and skips when react
is unresolved, so the installless guards job can still run it. In the
installed frontend run, that same skip would let a missing dependency pass
silently. With GD_EXPECT_DEPS set, any import failure now throws, so the
enforced run cannot fail open.

The quality.yml comment no longer keeps its own list of optional imports.
It points to frontend.yml's Node-suites step as the roster instead, so the
two lists cannot drift apart.
@theBGuy

theBGuy commented Sep 22, 2026

Copy link
Copy Markdown
Owner Author

🤖 GitDesktop AI review · opus · automated


This round addresses both findings from the last review. It changes only the keyboard-nav test's import guard and header, and one quality.yml comment; the picker, hook, and workflow-step code is unchanged and still sound. Nothing blocks the merge.

Resolved since last review

  • GD_EXPECT_DEPS now honored in scripts/list-keyboard-nav.test.mjs.
    • The catch at lines 23–28 now rethrows when the variable is set, or the error code is wrong, or the module file is missing.
    • The header at lines 10–11 now states that contract.
    • This matches how comment-refs.test.mjs:1793 and pr-create-lane.test.mjs:34 handle the variable. The comment at frontend.yml lines 66–71 is now accurate for all three suites.
    • The installless guards job doesn't set the variable, so it still skips the suite cleanly.
  • quality.yml roster comment (lines 74–77). It now points to "frontend.yml's enforced Node-suites step" instead of copying the suite list. That step exists under that name (frontend.yml:72), so the comment can't go stale again as suites are added.

Neither fix caused collateral damage:

  • frontend.yml's scripts/** path comment ("the Node-suites step reads these") still names the right step.
  • Only the Assignees, Reviewers, and Labels pickers set tabAdvances, and no other listKeyboardNav caller changes behavior.
  • The in-app guide doesn't document per-picker keys, so the author's documentation decision (the author's notes, item 10) holds.

There's nothing further to raise.

Verdict: no blocking issues — remaining items are non-blocking; merge when ready


Posted by GitDesktop — AI output, verify before acting on it.

@theBGuy
theBGuy merged commit 71fb5dc into master Sep 22, 2026
6 checks passed
@theBGuy
theBGuy deleted the feat/picker-tab-row-cycling branch September 22, 2026 14:27
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants