Repository source: docs/plans/archive/forte_keyboard_navigation_step4_implementation_summary.md
Forte Keyboard Navigation Step 4 Implementation Summary
Stage 1 - Board folder tree: roving tabindex and ARIA scaffolding
Changes:
templates/partials/paned_folder_tree.php: container getsrole="listbox"; each item getsrole="option",aria-selected, and rovingtabindex(0on the selected item,-1on the rest), computed server-side from$selectedTag(consistent with the tag-filter feature's SSR-computed initial state).public/assets/paned_board_reader.js:selectFolder()now also setsaria-selected/tabindexon every folder item in the same loop that already toggles the--selectedclass, so mouse clicks keep the roving-tabindex state in sync automatically - no separate code path to drift out of sync.public/assets/site.css: added.paned-folder-item:focus-visiblewith a high-contrast amber outline, chosen specifically because it stays visible against both the unselected (light) and selected (dark navy) backgrounds - a plain--paned-select-bg-colored outline would have been invisible on an already-selected+focused item.
Verification:
php -l/node --check/ CSS brace-balance: clean.Raw HTML check (
curl): the initially-selected folder item carriesrole="option" aria-selected="true" tabindex="0"in the server response.Headless-browser check: pressing Tab from page load lands on the folder tree after 3 tabs (past the New/Prev/Next toolbar buttons, which is expected/correct), landing on an element with
role="option"; clicking a different tag movestabindex="0"/aria-selected="true"to exactly that one item and off all others.Full test suite re-run: 404 passing, same 4 pre-existing unrelated failures - no regressions.
Notes: none.
Stage 2 - Board folder tree: arrow-key navigation
Changes:
public/assets/paned_board_reader.js: extractedapplyFolderSelection(tag)(the existingselectFolder()call plus the existing conditionalpushState) so both the click handler and a new keydown handler share exactly one "select + sync URL" path. Akeydownlistener on the folder tree handlesArrowUp/ArrowDown, computes the adjacent item by index, callsapplyFolderSelection()on it, and moves real focus to it; movement clamps at the first/last item (no wrap, no Home/End, both explicitly out of scope per Step 2).
Verification:
node --check: no syntax errors.Headless-browser check, starting with "All Threads" focused:
ArrowDown→#general(509 visible rows, matching the known real count),ArrowDown→#like(130 visible),ArrowUp→ back to#general,ArrowUp→ back to "All Threads" (513 visible), one moreArrowUpat the top is a no-op (stays on "All Threads", no error). URL andaria-selectedtracked correctly at every step. Zero console errors.
Notes: none.
Stage 3 - Board thread list: same pattern, second pane
Changes:
templates/partials/paned_board_thread_list.php:.paned-list-bodygetsrole="listbox"; each row getsrole="option",aria-selected="false"(nothing is selected on initial load), and rovingtabindexassigned to the first visible row (since there's no selection yet to anchor it to).public/assets/paned_board_reader.js:selectThread()now also setsaria-selected/tabindexon rows in its existing loop; addedfirstVisibleRow();resetContentPane()now also clearsaria-selected/tabindexon all rows and re-anchors the roving tabindex to the first visible row;selectFolder()now separately tracks whether the row currently holdingtabindex="0"just became hidden by the filter change (independent of whether it was the selected row) and recovers the tabindex to a still-visible row in that case too - the case where nothing was selected yet but the tab-stop row got filtered out. A newkeydownlistener on the thread list handlesArrowUp/ArrowDownscoped to currently-visible rows only, reusingselectThread()as the activation step.public/assets/site.css: added.paned-list-row:focus-visible(same high-contrast outline as the folder tree).
Verification:
php -l/node --check/ CSS brace-balance: clean.Raw HTML check: the first row in the server response carries
role="option" aria-selected="false" tabindex="0".Headless-browser check: focusing the first row and pressing ArrowDown/ArrowDown/ArrowUp moved focus, updated the content-pane preview, and updated
aria-selectedtogether at every step (matching click-driven selection exactly). Then, with a row focused and selected via arrow keys, clicking a different tag folder that hides that exact row correctly movedtabindex="0"to a still-visible row and correctly reset the content pane to its placeholder - the tabindex-recovery edge case Stage 3 was written to cover.Re-ran the full 39-real-tag filter check and the popstate back/forward check: both still pass with zero mismatches after these changes.
Full test suite re-run: 404 passing, same 4 pre-existing unrelated failures - no regressions.
Notes: none.
Stage 4 - Single-thread reply list: same pattern, second file
Changes:
templates/pages/forte.php/templates/partials/paned_list_pane.php: the page now passesrootPostIdthrough so the recursive row-rendering closure can mark the root post's rowrole="option" aria-selected="true" tabindex="0"(matching the content pane's own default-selected post) and every other rowtabindex="-1"; the list body container getsrole="listbox".public/assets/paned_reader.js: extractedvisibleRows()and switched both the existingstepSelection()(used by the Prev/Next toolbar buttons) and the new keydown handler to call it, so the two can't compute "visible" differently, per the Step 3 plan's explicit risk note.selectPost()now also setsaria-selected/tabindexin its existing loop.toggleCollapse()now also detects whether collapsing a branch just hid the row currently holding the roving tabindex and, if so, moves it to the toggled (ancestor) row - a necessary addition beyond the plan's literal wording: without it, a keyboard user who arrowed into a deeply nested reply and then had a mouse-driven collapse hide that row would lose the ability to Tab back into the list at all (no row would havetabindex="0"), a worse regression than anything explicitly listed as a Stage 4 risk.
Verification:
php -l/node --check: clean.Raw HTML check: on a real thread, the root post's row carries
role="option" aria-selected="true" tabindex="0"and its reply carriestabindex="-1", in the server response.Headless-browser check on a real 2-reply thread: ArrowDown from the root moved focus+selection through both replies correctly, with the content pane updating to match at each step. Selecting a reply via keyboard, then real-mouse-clicking the root's collapse toggle (hiding that reply), correctly moved
tabindex="0"to the root row (still visible) rather than leaving it stranded on the now-hidden reply - confirmed viapage.click()(real coordinate-based clicks), after an initial pass with synthetic.click()calls gave a false negative (synthetic clicks don't reliably move real DOM focus the way a real click does, the same lesson learned earlier in this feature's development).Re-ran the full 39-real-tag filter check on the board view: zero mismatches, confirming the shared
.paned-list-rowCSS/behavior changes didn't cross-contaminate the two pages.Full test suite re-run: 404 passing, same 4 pre-existing unrelated failures - no regressions.
Notes: none.
Stage 5 - End-to-end Tab-order and regression pass
Changes (one real defect found and fixed, beyond pure verification):
templates/partials/paned_list_pane.php: the single-thread reply list's "From" column was still rendering a real<a href="/user/...">link via$author($post)for approved authors. That anchor is natively focusable regardless of its ancestor row'stabindex, so it added an uncontrolled extra Tab stop inside what was supposed to be a single, roving-tabindex-controlled option - breaking the "exactly one Tab stop per pane" guarantee for any thread whose author has a profile link (not visible in the small local fixture, which only has non-linked "guest" authors, but confirmed on the live 513-thread instance). Replaced with a plain-text$authorText()closure, mirroring the identical fix already applied to the board view's thread list for an earlier, unrelated request ("remove links from usernames in thread listing") - the same underlying problem, found independently here via the Tab-order walk rather than a user report this time.
Verification:
php -l: clean.Headless-browser Tab-order walk on
/forte: Prev → Next → folder tree (1 stop) → thread list (1 stop) → page exit (correct - the placeholder content pane has nothing focusable when no thread is selected yet, matching Step 2's "no artificial stop for a pane with nothing interactive in it").Headless-browser Tab-order walk on
/threads/root-001/forte: Prev → Next → reply list (1 stop, now truly one stop after the fix above) → content pane's author link → page exit (that thread's one reply has no further links or reply-toggle, since it's a single-reply thread with no nested replies).Pure-keyboard end-to-end walk on the live 513-thread board instance (no mouse at any point): Tab into the folder tree, Tab into the thread list, arrow down until landing on a thread with replies, Tab → correctly reached the content pane's author link, Tab again → correctly reached the "Show N replies" toggle button. This exercises every success criterion from Step 2 in one pass, using only the keyboard.
Re-ran the full 39-real-tag filter check, the popstate back/forward check, and the collapse tabindex-recovery check from Stage 4: all still pass with zero regressions after the Stage 5 fix.
Full test suite re-run: 404 passing, same 4 pre-existing unrelated failures - no regressions.
Notes: none.