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>
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
-
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 withtabindex >= 0. -
Shift+Tab moves focus backward: Pressing Shift+Tab moves focus to the previous focusable element in document order.
-
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. -
tabindex="-1" skipped in tab order: Elements with
tabindex="-1"are excluded from sequential Tab navigation but can be focused programmatically viaelement.focus()in JavaScript. -
Positive tabindex ordering: Elements with positive
tabindexvalues (1, 2, 3...) are visited first in ascending tabindex order before anytabindex="0"or natively focusable elements. Among elements with the same positive tabindex, document order breaks ties. -
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.
-
Integration tests verify tab order with various tabindex configurations (positive, zero, negative, absent), Shift+Tab reverse navigation, focus wrapping, disabled element skipping, and
just cipasses.
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
autofocusattribute handling -- deferred. - No focus trapping / inert attribute -- beyond scope.
- No
accesskeyattribute -- deferred. - No roving tabindex for composite widgets (radio groups, toolbars) -- beyond scope.
- No
:focus-visibleCSS pseudo-class -- deferred to a CSS story. - No
document.activeElementJS property -- deferred to a Web API story. - No focus event
relatedTargetpropagation to JS -- the current four-event focus sequence (focusout/focusin/blur/focus) already dispatchesrelatedTargetinternally; no changes needed.
Tasks / Subtasks
-
Task 1: Parse and store tabindex attribute (AC: #1, #3, #4, #5)
- 1.1 In
crates/dom/src/document.rsor appropriate DOM location, add a helperfn get_tabindex(&self, node_id: NodeId) -> Option<i32>that reads thetabindexattribute from the element and parses it as an integer. ReturnsNoneif 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.
- 1.1 In
-
Task 2: Build focusable element discovery and ordering (AC: #1, #3, #4, #5)
- 2.1 Create
crates/app_browser/src/focus_navigation.rswithfn 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) -> boolhelper 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
- 2.1 Create
-
Task 3: Implement Tab/Shift+Tab focus navigation (AC: #1, #2, #6)
- 3.1 In
crates/app_browser/src/event_handler.rs, interceptKeyCode::Tabin 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))anddispatch_focus_change_with_swap()to fire all four focus events (focusout/focusin/blur/focus). - 3.3 Still dispatch the
keydownevent for Tab to JS (so scripts can detect Tab presses), but suppress the default browser behavior (focus movement) ifpreventDefault()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;tocrates/app_browser/src/lib.rs(or appropriate module declaration)
- 3.1 In
-
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()andfocus_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_focusmarking dirty nodes and re-rendering).
- 4.1 In
-
Task 5: Integration tests and CI (AC: #7)
- 5.1 Add integration tests in
tests/js_events.rs(or newtests/focus_navigation.rsif 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 cipasses -- all tests, lint, fmt, policy clean - 5.5 Update
docs/HTML5_Implementation_Checklist.mdwith focus management and keyboard navigation status
- 5.1 Add integration tests in
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 focusset_content_focus(&mut self, node_id: Option<NodeId>) -> Option<NodeId>(lines 273-296) -- changes focus, returns dirty node. Updatesinput_statesfocused 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:focusouton old (bubbles: true)focusinon new (bubbles: true)bluron old (bubbles: false)focuson 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::Tabvariant 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 nodedraw_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 valuesdocument.tag_name(node_id)-- get element tagdocument.children(node_id)-- child nodes for tree walkingdocument.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
-
Tabindex parsing -- Simple helper to read
tabindexattribute and parse as i32. Usedocument.get_attribute(node_id, "tabindex")+str::parse::<i32>(). No DOM storage changes needed. -
Focus order algorithm -- New
focus_navigation.rsmodule inapp_browser. Walk DOM tree in document order, collect focusable elements, sort by tabindex rules (positive first ascending, then natural order). ReturnVec<NodeId>. -
Tab key interception -- In
event_handler.rs, handleKeyCode::TabBEFORE dispatching keyboard event. Build tab order, find current position, advance/retreat. Dispatch focus change events. Still dispatch keydown to JS. Checkdefault_preventedto suppress default. -
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.rsor 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
KeyCodeenum. 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_preventedbefore 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 ciafter 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:
917706eStory 4.6: click, keyboard, and scroll interaction (most recent)be22671Story 4.5: client-side form validation9ca0a12Story 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
-
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.
-
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.
-
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. -
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.
-
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. Thefind_node_border_box()+draw_focus_outline()functions work for any element. -
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.
-
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 toDocumentincrates/dom/src/document.rs. Parsestabindexattribute as i32 with trim. 6 unit tests intabindex_tests.rscovering zero, positive, negative, invalid, absent, and whitespace cases. - Task 2: Created
crates/app_browser/src/focus_navigation.rswithbuild_tab_order()andis_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::Tabinevent_handler.rsafter keydown dispatch to JS. Builds tab order, finds current position, advances (Tab) or retreats (Shift+Tab) with wrapping. If JS calledpreventDefault()on keydown, focus navigation was already skipped becausepreventedcheck returns early. Usesset_content_focus()+dispatch_focus_change_with_swap()for proper four-event focus sequence. Module declared inmain.rs. - Task 4: Focus outline already draws for ALL
focused_nodeelements (not just text inputs) at line 1126 — no code change needed. The existingfind_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.rsfor 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.rscovering 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 cipasses. - 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— Addedget_tabindex()helper methodcrates/dom/src/tests/tabindex_tests.rs— NEW 6 unit tests for tabindex parsingcrates/dom/src/tests/mod.rs— Addedtabindex_testsmodulecrates/app_browser/src/focus_navigation.rs— NEW Focus order algorithm:build_tab_order(),is_natively_focusable(),has_layout_box(),collect_focusable()+ 15 unit testscrates/app_browser/src/main.rs— Addedmod focus_navigation;declarationcrates/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 eventsdocs/HTML5_Implementation_Checklist.md— Updated focus management and tabindex status