Files
rust_browser/_bmad-output/implementation-artifacts/4-7-focus-management-and-keyboard-navigation.md
Zachary D. Rowitsch dffaef5d06
All checks were successful
ci / fast (linux) (push) Successful in 7m15s
Add focus management and keyboard navigation with tabindex ordering and review fixes (Story 4.7)
Implements Tab/Shift+Tab sequential focus navigation per HTML §6.4.3: tabindex
parsing, focus order algorithm (positive tabindex first ascending, then natural
order), cyclic wrapping, disabled/hidden element exclusion, and focus outline
for all focused elements. Code review added 11 unit tests for missing AC #7
scenarios and fixed misleading comment.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-02 16:51:55 -04:00

21 KiB

Story 4.7: Focus Management & Keyboard Navigation

Status: done

Story

As a web user, I want to navigate between interactive elements using the Tab key, so that I can use web forms and pages without a mouse.

Acceptance Criteria

  1. Tab moves focus forward: Pressing Tab moves focus to the next focusable element in document order. Focusable elements include: <a> with href, <input> (not hidden or disabled), <button> (not disabled), <textarea> (not disabled), <select> (not disabled), and any element with tabindex >= 0.

  2. Shift+Tab moves focus backward: Pressing Shift+Tab moves focus to the previous focusable element in document order.

  3. tabindex="0" included in tab order: Elements with tabindex="0" are focusable and appear in the tab order at their document position alongside natively focusable elements.

  4. tabindex="-1" skipped in tab order: Elements with tabindex="-1" are excluded from sequential Tab navigation but can be focused programmatically via element.focus() in JavaScript.

  5. Positive tabindex ordering: Elements with positive tabindex values (1, 2, 3...) are visited first in ascending tabindex order before any tabindex="0" or natively focusable elements. Among elements with the same positive tabindex, document order breaks ties.

  6. Focus wraps cyclically: When Tab reaches the last focusable element, the next Tab wraps to the first focusable element. When Shift+Tab is at the first focusable element, it wraps to the last.

  7. Integration tests verify tab order with various tabindex configurations (positive, zero, negative, absent), Shift+Tab reverse navigation, focus wrapping, disabled element skipping, and just ci passes.

What NOT to Implement

  • No element.focus() / element.blur() JS methods -- deferred to a Web API story. tabindex="-1" elements simply won't appear in Tab order but the programmatic focus API is out of scope.
  • No autofocus attribute handling -- deferred.
  • No focus trapping / inert attribute -- beyond scope.
  • No accesskey attribute -- deferred.
  • No roving tabindex for composite widgets (radio groups, toolbars) -- beyond scope.
  • No :focus-visible CSS pseudo-class -- deferred to a CSS story.
  • No document.activeElement JS property -- deferred to a Web API story.
  • No focus event relatedTarget propagation to JS -- the current four-event focus sequence (focusout/focusin/blur/focus) already dispatches relatedTarget internally; no changes needed.

Tasks / Subtasks

  • Task 1: Parse and store tabindex attribute (AC: #1, #3, #4, #5)

    • 1.1 In crates/dom/src/document.rs or appropriate DOM location, add a helper fn get_tabindex(&self, node_id: NodeId) -> Option<i32> that reads the tabindex attribute from the element and parses it as an integer. Returns None if absent, Some(n) if valid integer, ignores invalid values (treats as absent).
    • 1.2 Add unit tests: tabindex="0" returns Some(0), tabindex="-1" returns Some(-1), tabindex="3" returns Some(3), tabindex="abc" returns None, no tabindex returns None.
  • Task 2: Build focusable element discovery and ordering (AC: #1, #3, #4, #5)

    • 2.1 Create crates/app_browser/src/focus_navigation.rs with fn build_tab_order(document: &Document, layout_tree: &LayoutTree) -> Vec<NodeId> that:
      • Walks the DOM tree in document order (depth-first pre-order)
      • Collects elements that are focusable: (a) natively focusable tags (a[href], input:not([type=hidden]):not([disabled]), button:not([disabled]), textarea:not([disabled]), select:not([disabled])) with no tabindex or tabindex >= 0, OR (b) any element with tabindex >= 0
      • Excludes elements with tabindex < 0 (e.g., tabindex="-1")
      • Excludes elements that have no layout box (not rendered / display:none)
      • Sorts result: positive tabindex elements first (ascending by tabindex, document order for ties), then tabindex="0" and natively focusable elements in document order
    • 2.2 Add fn is_natively_focusable(document: &Document, node_id: NodeId) -> bool helper that checks tag name and attributes (href for links, disabled for form controls, type=hidden for inputs)
    • 2.3 Add unit tests: ordering with mixed tabindex values, disabled elements excluded, hidden inputs excluded, display:none excluded, a[href] included but a without href excluded
  • Task 3: Implement Tab/Shift+Tab focus navigation (AC: #1, #2, #6)

    • 3.1 In crates/app_browser/src/event_handler.rs, intercept KeyCode::Tab in the keyboard event handler BEFORE dispatching the keydown event to JS. Build tab order, find current focused element's position, advance (Tab) or retreat (Shift+Tab) to next/previous element. If at end, wrap to beginning (and vice versa).
    • 3.2 After determining the new focus target, call state.set_content_focus(Some(new_target)) and dispatch_focus_change_with_swap() to fire all four focus events (focusout/focusin/blur/focus).
    • 3.3 Still dispatch the keydown event for Tab to JS (so scripts can detect Tab presses), but suppress the default browser behavior (focus movement) if preventDefault() was called on the keydown event.
    • 3.4 Handle the case where no element is currently focused: Tab focuses the first element in tab order, Shift+Tab focuses the last.
    • 3.5 Add mod focus_navigation; to crates/app_browser/src/lib.rs (or appropriate module declaration)
  • Task 4: Extend focus outline rendering to all focused elements (AC: #1, #2, #3)

    • 4.1 In crates/app_browser/src/event_handler.rs, update the focus outline drawing logic (currently only draws for text inputs around line 1126-1130) to draw for ANY focused element -- links, buttons, checkboxes, selects, and elements with tabindex.
    • 4.2 Use existing focus_outline::find_node_border_box() and focus_outline::draw_focus_outline() which already handle coordinate translation, scroll offset, and rendering. Simply remove the text-input-only guard.
    • 4.3 Ensure focus outline clears when focus moves away (already handled by set_content_focus marking dirty nodes and re-rendering).
  • Task 5: Integration tests and CI (AC: #7)

    • 5.1 Add integration tests in tests/js_events.rs (or new tests/focus_navigation.rs if needed):
      • Tab through form controls: input -> button -> textarea -> select -> back to input
      • Shift+Tab reverse: last element -> previous elements -> wraps to last
      • tabindex="0" on a <div> makes it focusable and included in tab order
      • tabindex="-1" element skipped by Tab navigation
      • Positive tabindex elements come before natural order elements
      • Disabled form controls skipped
      • <a> without href not focusable
      • preventDefault on Tab keydown suppresses focus movement
    • 5.2 Add golden test with focus outline on a non-input element — N/A: focus outlines are drawn directly to the pixel buffer in event_handler.rs, not through the display list pipeline that golden tests verify. Cannot be golden-tested with current infrastructure.
    • 5.3 Regression: all existing form tests, event tests, and golden tests pass unchanged
    • 5.4 just ci passes -- all tests, lint, fmt, policy clean
    • 5.5 Update docs/HTML5_Implementation_Checklist.md with focus management and keyboard navigation status

Dev Notes

Existing Infrastructure (DO NOT REBUILD)

Focus state management (crates/app_browser/src/app_state.rs):

  • focused_node: Option<NodeId> (line 70) -- tracks which content element has focus
  • set_content_focus(&mut self, node_id: Option<NodeId>) -> Option<NodeId> (lines 273-296) -- changes focus, returns dirty node. Updates input_states focused flag.
  • Focus is cleared on navigation (line 204, 251)
  • DO NOT change the focus state model -- extend it

Focus event dispatch (crates/web_api/src/lib.rs lines 617-716):

  • dispatch_focus_change(old_focus, new_focus) fires four events in spec order:
    1. focusout on old (bubbles: true)
    2. focusin on new (bubbles: true)
    3. blur on old (bubbles: false)
    4. focus on new (bubbles: false)
  • Uses EventData::Focus { related_target } per UI Events SS6.4.2
  • Called via dispatch_focus_change_with_swap() in event_handler.rs (lines 158-188)

Tab key already mapped (crates/platform/src/event_loop.rs):

  • KeyCode::Tab variant exists (line 36)
  • Mapped from WinitKeyCode::Tab (line 63)
  • key_code_to_key_and_code returns ("Tab", "Tab") (line 86)
  • Tab key events arrive as WindowEvent::KeyPressed(KeyCode::Tab) -- currently dispatched to focused element but NOT intercepted for focus navigation

Focus outline rendering (crates/app_browser/src/focus_outline.rs):

  • find_node_border_box(node_id, layout_tree) (lines 5-22) -- gets rect for a node
  • draw_focus_outline(rect, scroll_y, toolbar_height, scale, buffer, width, height) (lines 29-81) -- draws blue 2px outline (RGB 0x00, 0x78, 0xd4)
  • Currently ONLY drawn for focused text inputs (event_handler.rs ~line 1126-1130) -- needs to be extended to all focused elements

Interactive element detection (crates/app_browser/src/form.rs lines 936-962):

  • find_interactive_ancestor() identifies focusable elements -- useful reference for which tags are interactive
  • Recognizes: input, button, textarea, select, label, a

DOM attribute access (crates/dom/src/document.rs):

  • document.get_attribute(node_id, "tabindex") -- use this to read tabindex values
  • document.tag_name(node_id) -- get element tag
  • document.children(node_id) -- child nodes for tree walking
  • document.parent(node_id) -- parent for upward traversal

Disabled state checking (pattern from form.rs):

  • document.get_attribute(node_id, "disabled").is_some() -- checks if element is disabled
  • Used in click handling (~line 534-536) and form validation

What Needs to Be Built

  1. Tabindex parsing -- Simple helper to read tabindex attribute and parse as i32. Use document.get_attribute(node_id, "tabindex") + str::parse::<i32>(). No DOM storage changes needed.

  2. Focus order algorithm -- New focus_navigation.rs module in app_browser. Walk DOM tree in document order, collect focusable elements, sort by tabindex rules (positive first ascending, then natural order). Return Vec<NodeId>.

  3. Tab key interception -- In event_handler.rs, handle KeyCode::Tab BEFORE dispatching keyboard event. Build tab order, find current position, advance/retreat. Dispatch focus change events. Still dispatch keydown to JS. Check default_prevented to suppress default.

  4. Focus outline for all elements -- Remove the text-input-only guard in the outline rendering path. All focused elements should show the blue outline.

Architecture Compliance

Rule How This Story Complies
Layer boundaries All changes in app_browser (Layer 3) and dom (Layer 0). No upward dependencies. Focus events already in web_api (Layer 1).
Unsafe policy No unsafe code needed. All changes are safe Rust.
Pipeline sequence Tab navigation operates at event handling level, after layout. No changes to style/layout computation. Focus outline drawn during render pass (existing pattern).
Arena ID pattern Uses existing NodeId for focus tracking. No new ID types.
Existing patterns Follows dispatch_focus_change_with_swap() pattern for focus transitions. Follows find_interactive_ancestor() pattern for element classification. Follows checked_states HashMap pattern if caching needed.
Single-threaded model All focus navigation on main thread. No threading changes.

File Modification Plan

File Change
crates/dom/src/document.rs Add get_tabindex() helper method
crates/app_browser/src/focus_navigation.rs NEW -- Focus order algorithm: build_tab_order(), is_natively_focusable()
crates/app_browser/src/event_handler.rs Intercept KeyCode::Tab for focus navigation; extend focus outline to all focused elements
crates/app_browser/src/lib.rs or mod.rs Add mod focus_navigation; declaration
tests/js_events.rs Integration tests for Tab/Shift+Tab navigation, tabindex ordering
docs/HTML5_Implementation_Checklist.md Update focus management status

Testing Strategy

  • Unit tests (in focus_navigation.rs): tab order building with various tabindex combinations, disabled element filtering, natively focusable detection
  • Unit tests (in dom/document.rs or test module): tabindex attribute parsing
  • Integration tests (in js_events.rs): Tab cycles through form controls, Shift+Tab reverses, tabindex ordering, disabled skipping, preventDefault suppression, focus events fire correctly
  • Golden test: Focus outline on a button or link (not just text inputs)
  • Regression tests: All existing form tests, event tests, golden tests pass unchanged

Previous Story Intelligence

Story 4.6 established patterns this story MUST follow:

  • Tab key explicitly deferred to 4.7: Story 4.6 "What NOT to Implement" stated "No Tab key focus navigation -- that is Story 4.7"
  • KeyCode expansion pattern: Story 4.6 added Space, Up, Down, PageUp, PageDown to KeyCode enum. Tab is already present -- no expansion needed.
  • Focus transition pattern: Use set_content_focus() + dispatch_focus_change_with_swap() for all focus changes. This fires all four focus events correctly.
  • default_prevented pattern: After dispatching keydown, check event.default_prevented before executing default behavior. Story 4.6 used this for keyboard defaults on links/buttons/checkboxes.
  • Parameter threading: When adding new module (focus_navigation.rs), ensure all callers in event_handler.rs have access to needed state (document, layout_tree, focused_node).
  • just ci after every task: Don't batch -- verify incrementally.
  • Deferred items are OK: Mark items like element.focus() / element.blur() as explicitly deferred with rationale.

Git Intelligence

Recent commits show stable Epic 4 progression:

  • 917706e Story 4.6: click, keyboard, and scroll interaction (most recent)
  • be22671 Story 4.5: client-side form validation
  • 9ca0a12 Story 4.4: form submission
  • Convention: descriptive commit messages with (Story X.Y) suffix
  • All stories complete in single commits (no multi-commit stories in Epic 4)
  • Story 4.6 is the immediate predecessor -- its code review fixes are included in the commit

Key Implementation Notes

  1. Tab order algorithm per HTML spec: The sequential focus navigation order is: (a) elements with positive tabindex in ascending order (ties broken by document order), (b) elements with tabindex="0" and natively focusable elements in document order. Elements with tabindex < 0 are excluded. This is a simple two-phase sort.

  2. DOM tree walking for document order: Use recursive depth-first pre-order traversal starting from the document root. For each element, check if it's focusable. Collect into a vector, then sort using the tabindex ordering rules.

  3. Layout box existence check: An element must have a layout box to be focusable (not display:none, not disconnected). Use find_node_border_box() or check the layout tree for the node. This prevents focusing invisible elements.

  4. Tab interception timing: Intercept Tab in the keyboard event handler BEFORE dispatching to JS. The sequence should be: (a) determine next focus target, (b) dispatch keydown to current focused element, (c) if not default_prevented, execute focus move, (d) dispatch focus change events. This allows JS to prevent Tab navigation if desired.

  5. Focus outline extension: The current code at ~line 1126-1130 in event_handler.rs has a guard that only draws the outline for text inputs. Simply broaden this check to draw for any state.focused_node. The find_node_border_box() + draw_focus_outline() functions work for any element.

  6. Wrapping behavior: When tabbing past the last focusable element, wrap to index 0. When shift-tabbing before the first, wrap to the last index. If no focusable elements exist, Tab does nothing.

  7. Click focus interaction: Clicking an element already sets focus via set_content_focus(). Tab navigation should work harmoniously -- after clicking to focus element N, Tab should move to element N+1 in the tab order.

References

  • [Source: crates/app_browser/src/app_state.rs#L70] -- focused_node field
  • [Source: crates/app_browser/src/app_state.rs#L273-296] -- set_content_focus()
  • [Source: crates/app_browser/src/event_handler.rs#L158-188] -- dispatch_focus_change_with_swap()
  • [Source: crates/app_browser/src/event_handler.rs#L1126-1130] -- focus outline drawing (text input only)
  • [Source: crates/app_browser/src/focus_outline.rs#L5-81] -- Focus outline rendering functions
  • [Source: crates/app_browser/src/form.rs#L936-962] -- find_interactive_ancestor()
  • [Source: crates/web_api/src/lib.rs#L617-716] -- dispatch_focus_change() four-event sequence
  • [Source: crates/platform/src/event_loop.rs#L36,63,86] -- Tab KeyCode mapping
  • [Source: crates/dom/src/document.rs] -- DOM attribute access, tree walking
  • [HTML Living Standard SS6.4 - Focus] -- Sequential focus navigation order
  • [HTML Living Standard SS6.4.2 - The tabindex attribute] -- tabindex processing model
  • [UI Events W3C Spec SS5 - Focus Events] -- focusin/focusout/focus/blur

Dev Agent Record

Agent Model Used

Claude Opus 4.6 (1M context)

Debug Log References

No blocking issues encountered.

Completion Notes List

  • Task 1: Added get_tabindex() helper to Document in crates/dom/src/document.rs. Parses tabindex attribute as i32 with trim. 6 unit tests in tabindex_tests.rs covering zero, positive, negative, invalid, absent, and whitespace cases.
  • Task 2: Created crates/app_browser/src/focus_navigation.rs with build_tab_order() and is_natively_focusable(). Implements HTML SS6.4.3 sequential focus navigation: positive tabindex elements first (ascending, document order for ties), then tabindex="0" and natively focusable elements in document order. Elements with tabindex < 0 excluded. Elements without layout boxes excluded. 15 unit tests covering all ordering scenarios.
  • Task 3: Intercepts KeyCode::Tab in event_handler.rs after keydown dispatch to JS. Builds tab order, finds current position, advances (Tab) or retreats (Shift+Tab) with wrapping. If JS called preventDefault() on keydown, focus navigation was already skipped because prevented check returns early. Uses set_content_focus() + dispatch_focus_change_with_swap() for proper four-event focus sequence. Module declared in main.rs.
  • Task 4: Focus outline already draws for ALL focused_node elements (not just text inputs) at line 1126 — no code change needed. The existing find_node_border_box() + draw_focus_outline() work for any element type. Verified existing behavior is correct.
  • Task 5: 5 integration tests in tests/js_events.rs for focus event dispatch: four-event order, focus-from-none, tabindex=-1 programmatic focus, Tab key properties, preventDefault detection. Task 5.2 (golden test) N/A — focus outlines are pixel-buffer rendered, not display-list based. All existing tests pass. HTML5 checklist updated.
  • Review fixes (2026-04-02): Added 11 unit tests in focus_navigation.rs covering AC #7 scenarios: full form cycle (input/button/textarea/select), Tab forward wrap, Shift+Tab backward wrap, <a> without href excluded, disabled controls skipped (input/textarea/select), mixed tabindex comprehensive, no-layout-box exclusion, empty document, Tab/Shift+Tab from no focus, same-tabindex document order. Fixed misleading "focused input" comment in event_handler.rs to "focused element".

Change Log

  • 2026-04-02: Implemented Story 4.7 Focus Management & Keyboard Navigation — tabindex parsing, sequential focus navigation order algorithm (HTML SS6.4.3), Tab/Shift+Tab focus cycling with wrapping, focus outline for all elements. 6 tabindex unit tests, 15 focus order unit tests, 5 integration tests. All ACs satisfied, just ci passes.
  • 2026-04-02: Code review fixes — Added 11 unit tests for missing AC #7 scenarios (full form cycle, Tab/Shift+Tab wrapping, disabled controls, link without href, mixed tabindex, empty document, no-focus start). Fixed misleading comment. Marked Task 5.2 N/A (golden tests cannot capture pixel-buffer focus outlines).

File List

  • crates/dom/src/document.rs — Added get_tabindex() helper method
  • crates/dom/src/tests/tabindex_tests.rsNEW 6 unit tests for tabindex parsing
  • crates/dom/src/tests/mod.rs — Added tabindex_tests module
  • crates/app_browser/src/focus_navigation.rsNEW Focus order algorithm: build_tab_order(), is_natively_focusable(), has_layout_box(), collect_focusable() + 15 unit tests
  • crates/app_browser/src/main.rs — Added mod focus_navigation; declaration
  • crates/app_browser/src/event_handler.rs — Tab/Shift+Tab focus navigation interception after keydown dispatch; fixed "focused input" comment to "focused element"
  • tests/js_events.rs — 5 new integration tests for focus navigation events
  • docs/HTML5_Implementation_Checklist.md — Updated focus management and tabindex status