Files
rust_browser/_bmad-output/implementation-artifacts/4-6-click-keyboard-and-scroll-interaction.md
Zachary D. Rowitsch 917706e7cd Add click, keyboard, and scroll interaction with hit testing, container scroll, and review fixes (Story 4.6)
Implement z-index-aware hit testing, overflow clipping, keyboard default behaviors
(Enter/Space on links/buttons/checkboxes), horizontal and keyboard page scrolling,
container-level overflow:auto/scroll with scroll chaining, and scroll event dispatch.
Code review fixes: scroll events target actual scrolled container element, deduplicated
link navigation via navigate_link_href() helper, and reference-based scroll offset API.

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

25 KiB

Story 4.6: Click, Keyboard & Scroll Interaction

Status: done

Story

As a web user, I want my clicks, key presses, and scroll actions to work correctly on all page content, so that I can interact with web pages naturally.

Acceptance Criteria

  1. Click hit testing accuracy: Clicking on an element delivers the click event to the topmost visible element at the click coordinates. Hit testing accounts for z-index stacking order and overflow: hidden clipping. Clicking on overlapping elements correctly targets the topmost per stacking context.

  2. Keyboard event dispatch: Pressing keys while an element is focused dispatches keydown and keyup events with correct key, code, repeat, and modifier properties (ctrlKey, shiftKey, altKey, metaKey) per UI Events spec. Events target the focused element or document if nothing is focused.

  3. Keyboard default behaviors: Enter on a focused <a> element navigates to the link href. Space on a checkbox toggles it. Space on a button triggers click. Arrow keys in text inputs move the cursor (already implemented). These default behaviors are suppressed if preventDefault() is called on the keydown event.

  4. Page-level scroll improvement: Mouse wheel and trackpad scrolling works vertically (already exists) and horizontally. Keyboard scrolling: Space/Shift+Space scroll by viewport height, Page Up/Page Down scroll by viewport height, Home/End scroll to top/bottom of page. Arrow Up/Down scroll by a line height (~40px) when no text input is focused.

  5. Scroll chaining for nested containers: An element with overflow: auto or overflow: scroll that has content exceeding its bounds becomes a scrollable container. Scroll events first apply to the innermost scrollable container at the pointer position. When the inner container reaches its scroll boundary, excess scroll propagates to the parent scrollable container (scroll chaining).

  6. Scroll event dispatch: After a scroll position changes, a non-cancelable, non-bubbling scroll event is dispatched on the scrolled element (or document for page-level scroll).

  7. Integration tests verify hit testing, keyboard dispatch with default behaviors, page scrolling (wheel + keyboard), container scrolling, scroll chaining, and just ci passes.

What NOT to Implement

  • No pointer events (pointerdown, pointermove, pointerup) -- beyond scope, mouse events are sufficient.
  • No touch/gesture events -- desktop browser only.
  • No smooth scroll CSS property (scroll-behavior: smooth) -- immediate scroll only.
  • No scrollbar click/drag interaction -- ScrollIndicator remains visual only.
  • No scroll snap (scroll-snap-type) -- deferred.
  • No element.scrollTop/element.scrollLeft JS properties -- deferred to Web API story.
  • No element.scrollIntoView() JS method -- deferred.
  • No keypress event -- deprecated per UI Events spec.
  • No IME composition events (compositionstart/compositionupdate/compositionend) -- deferred.
  • No Tab key focus navigation -- that is Story 4.7.
  • No horizontal scrollbar rendering -- vertical only for now.

Tasks / Subtasks

  • Task 1: Improve hit testing for stacking contexts and overflow clipping (AC: #1)

    • 1.1 Update find_element_at_position() in hit_test.rs to respect z-index ordering: when testing children of a box with position: relative/absolute/fixed, iterate in reverse paint order (later-painted elements first) so topmost stacking context wins
    • 1.2 Add overflow clipping to hit testing: if a layout box has overflow: hidden/auto/scroll, clip the hit test region to the box's content area -- clicks outside the clip rect should not target children of that container
    • 1.3 Add unit tests: overlapping positioned elements (higher z-index wins), overflow:hidden clips child hit targets, basic hit testing for nested containers
  • Task 2: Add keyboard default behaviors for non-input elements (AC: #3)

    • 2.1 In event_handler.rs, after dispatching keydown event, check default_prevented flag. If not prevented, execute default behavior based on focused element type and key
    • 2.2 Enter key on focused <a> element: trigger navigation to href (follow handle_link_click() pattern)
    • 2.3 Space key on focused <input type="checkbox">: toggle checked state (follow existing checkbox click handler)
    • 2.4 Space key on focused <button> or <input type="submit">: trigger click event dispatch (synthetic click)
    • 2.5 Space key on focused <a> element: trigger navigation (same as Enter)
    • 2.6 Add unit/integration tests: Enter on link navigates, Space on checkbox toggles, Space on button fires click, preventDefault suppresses default
  • Task 3: Improve page-level scrolling with horizontal and keyboard support (AC: #4)

    • 3.1 Add scroll_x and max_scroll_x to AppState (alongside existing scroll_y/max_scroll_y)
    • 3.2 Handle delta_x in scroll wheel handler (event_handler.rs ~line 1590): apply to scroll_x, clamp to max_scroll_x
    • 3.3 Update build_display_list_with_scroll() to accept and apply both scroll_x and scroll_y offsets
    • 3.4 Add keyboard scroll handlers (when no text input focused and keydown not prevented): Space/PageDown scroll down by viewport height, Shift+Space/PageUp scroll up by viewport height, Home scroll to top (scroll_y=0), End scroll to bottom (scroll_y=max), ArrowUp/ArrowDown scroll by 40px
    • 3.5 Calculate max_scroll_x as max(0, doc_width - viewport_width) during layout
    • 3.6 Add tests: horizontal wheel scroll, keyboard scroll (Space, PageDown, PageUp, Home, End, ArrowUp, ArrowDown), keyboard scroll suppressed when text input focused
  • Task 4: Implement container-level scrolling for overflow:auto/scroll (AC: #5)

    • 4.1 Add scroll_offsets: HashMap<NodeId, (f32, f32)> to AppState for per-container scroll positions
    • 4.2 Implement find_scroll_container_at_position() in hit_test.rs: given mouse coordinates, find the innermost element with overflow: auto/scroll whose content exceeds its bounds
    • 4.3 In scroll wheel handler, determine scroll target: if pointer is over a scroll container, scroll that container first. Apply remaining delta to parent containers or page (scroll chaining)
    • 4.4 Update display list builder to apply per-container scroll offsets: when building items for a scrollable container, offset child items by the container's scroll offset and clip to container bounds
    • 4.5 Update hit testing to account for container scroll offsets: adjust coordinates by container scroll offset before testing children
    • 4.6 Calculate per-container max scroll values from content overflow (content_height - container_height, content_width - container_width)
    • 4.7 Add tests: div with overflow:auto scrolls its content, scroll chaining when inner container is at boundary, hit testing within scrolled container
  • Task 5: Dispatch scroll events (AC: #6)

    • 5.1 Add dispatch_scroll_event() to web_api: event type "scroll", bubbles: false, cancelable: false, target is the scrolled element (or document for page scroll)
    • 5.2 Add dispatch_scroll_event() wrapper to browser_runtime
    • 5.3 Fire scroll event after any scroll position change (page-level or container-level) in event_handler.rs
    • 5.4 Add integration tests: scroll event fires on page scroll, scroll event fires on container scroll, scroll event has correct target, scroll event is not cancelable
  • Task 6: Integration tests and CI (AC: #7)

    • 6.1 Hit testing integration tests: click on overlapping elements targets correct one, click outside overflow:hidden region does not target clipped child
    • 6.2 Keyboard integration tests: keydown/keyup events fire with correct properties, Enter on link navigates, Space on checkbox toggles, default behavior prevented by preventDefault
    • 6.3 Scroll integration tests: page scrolls via mouse wheel, keyboard scrolling works (Space, Page keys, Home/End), container scrolls independently, scroll chaining propagates to parent
    • 6.4 Scroll event integration tests: scroll event fires with correct properties
    • 6.5 Regression: all existing form tests, event tests, and golden tests pass unchanged
    • 6.6 just ci passes -- all tests, lint, fmt, policy clean
    • 6.7 Update docs/HTML5_Implementation_Checklist.md with click/keyboard/scroll interaction status

Dev Notes

Existing Infrastructure (DO NOT REBUILD)

Hit testing already works (crates/app_browser/src/hit_test.rs):

  • find_link_at_position() (line 22-37): recursively finds <a> elements at (x, y)
  • find_element_at_position() (line 129-137): finds deepest element at position
  • find_element_in_box() (line 140-191): depth-limited recursion (MAX_HIT_TEST_DEPTH=256), checks inline fragments then children
  • These need enhancement for stacking order and overflow clipping, NOT replacement

Keyboard events already dispatch (crates/app_browser/src/event_handler.rs):

  • key_code_to_key_and_code() (line 71-91): maps platform KeyCode to UI Events spec strings
  • dispatch_keyboard_event_with_swap() (line 95-151): dispatches keydown/keyup to JS via browser_runtime
  • Events target focused element or document root
  • Text input handling (cursor movement, selection, backspace, delete) all working
  • Missing: default behavior for non-input elements (links, buttons, checkboxes) after dispatch

Page scroll already works (crates/app_browser/src/event_handler.rs ~line 1590-1604):

  • ScrollWheel { delta_x, delta_y } received from platform event loop
  • Only delta_y is used; delta_x is ignored -- add horizontal support
  • Scroll offset applied in display list builder via build_display_list_with_scroll(tree, scroll_y)
  • max_scroll_y calculated as doc_height - viewport_height
  • Fragment scroll (scroll_to_fragment) exists in app_state.rs

Event dispatch infrastructure (crates/web_api/src/event_dispatch.rs):

  • Full three-phase DOM event dispatch: Capture -> Target -> Bubble
  • dispatch_mouse_event(), dispatch_keyboard_event(), dispatch_focus_change(), dispatch_input_event(), dispatch_change_event(), dispatch_submit_event(), dispatch_invalid_event() all available
  • default_prevented flag checked after dispatch -- pattern for conditional default behavior

Focus system (crates/app_browser/src/app_state.rs):

  • focused_node: Option<NodeId> tracks current focus
  • set_content_focus() manages transitions, returns dirty node
  • Focus outline rendered via focus_outline.rs
  • Four-event focus sequence (focusout, focusin, blur, focus) properly dispatched

ScrollIndicator (crates/display_list/src/lib.rs line 77-86):

  • Visual-only scrollbar: track_rect, thumb_rect, vertical flag
  • Already in display list rendering pipeline -- no changes needed for this story

Overflow properties (crates/layout/src/types.rs line 252-253):

  • overflow_x and overflow_y fields exist on LayoutBox
  • Values: Overflow::Visible, Overflow::Scroll, Overflow::Auto, Overflow::Hidden
  • Already parsed from CSS and stored -- just not used for scroll behavior yet

Platform keyboard support (crates/platform/src/event_loop.rs):

  • KeyCode enum includes: Enter, Backspace, Delete, Left, Right, Home, End, Tab, Escape, A, F5, R, L, BracketLeft, BracketRight, Period, Other
  • Missing from enum: Space, PageUp, PageDown, ArrowUp, ArrowDown -- add these to KeyCode and winit mapping
  • Modifiers struct tracks ctrl, alt, shift, meta
  • CharacterInput(char) for text typing

What Needs to Be Built

  1. Hit test improvements (hit_test.rs): Add z-index stacking order respect (reverse paint order iteration). Add overflow clipping check (reject hits outside clip rect of overflow:hidden/auto/scroll containers).

  2. Keyboard default behaviors (event_handler.rs): After keydown dispatch, if not prevented, check element type: Enter/Space on links -> navigate, Space on checkbox -> toggle, Space on button -> click. Follows existing default_prevented check pattern.

  3. Horizontal page scroll (app_state.rs + event_handler.rs + display_list/builder.rs): Add scroll_x/max_scroll_x, handle delta_x in wheel handler, pass both offsets to display list builder.

  4. Keyboard scroll (event_handler.rs): When no text input focused and keydown not prevented, handle Space/PageUp/PageDown/Home/End/ArrowUp/ArrowDown for page scrolling.

  5. Container scroll (app_state.rs + hit_test.rs + event_handler.rs + display_list/builder.rs): Per-container scroll offsets, find scroll container at position, scroll chaining logic, display list clipping/offsetting for scrolled containers.

  6. Platform KeyCode expansion (platform/src/event_loop.rs): Add Space, PageUp, PageDown, ArrowUp, ArrowDown to KeyCode enum. Map from winit virtual key codes.

  7. Scroll event dispatch (web_api + browser_runtime): New event type "scroll", non-cancelable, non-bubbling. Follow existing event dispatch patterns.

Architecture Compliance

Rule How This Story Complies
Layer boundaries Hit testing, scroll handling, keyboard routing in app_browser (Layer 3). Scroll event dispatch in web_api/browser_runtime (Layer 1/2). KeyCode additions in platform (Layer 1). Display list changes in display_list (Layer 1). No upward dependencies.
Unsafe policy No unsafe code needed. All changes are safe Rust.
Pipeline sequence Scroll offset application during display list building (existing pattern). Hit testing operates on layout tree. No changes to style/layout computation.
Arena ID pattern Uses existing NodeId for scroll container identification. No new ID types.
Existing patterns Follows dispatch_submit_event pattern for scroll events. Follows checked_states/select_states pattern for scroll offsets HashMap. Follows default_prevented check pattern for keyboard defaults.
Single-threaded model All scroll and event handling on main thread. No threading changes.

File Modification Plan

File Change
crates/platform/src/event_loop.rs Add Space, PageUp, PageDown, ArrowUp, ArrowDown to KeyCode enum; map from winit
crates/app_browser/src/hit_test.rs Add z-index stacking order, overflow clipping to hit testing
crates/app_browser/src/event_handler.rs Add keyboard default behaviors (Enter/Space on links/buttons/checkboxes), horizontal scroll support, keyboard scrolling, container scroll with chaining, scroll event dispatch
crates/app_browser/src/app_state.rs Add scroll_x, max_scroll_x, scroll_offsets: HashMap<NodeId, (f32, f32)>
crates/display_list/src/builder.rs Accept and apply scroll_x offset; apply per-container scroll offsets with clipping
crates/web_api/src/lib.rs Add dispatch_scroll_event() (bubbles: false, cancelable: false)
crates/browser_runtime/src/lib.rs Add dispatch_scroll_event() wrapper
crates/app_browser/src/main.rs Initialize scroll_x, scroll_offsets in AppState
tests/js_events.rs Integration tests for keyboard defaults, scroll events
docs/HTML5_Implementation_Checklist.md Update interaction event status

Testing Strategy

  • Unit tests (in hit_test.rs or new hit_test_tests.rs): z-index ordering in hit testing, overflow clipping in hit testing
  • Unit tests (in new scroll_tests.rs or existing test modules): container scroll offset clamping, scroll chaining logic, keyboard scroll calculation
  • Integration tests (in js_events.rs): keydown/keyup properties, Enter on link navigates, Space on checkbox toggles, preventDefault suppresses default, scroll event fires on scroll, scroll event target is correct
  • Integration tests (in new scroll_interaction.rs or existing): page keyboard scroll (Space/PageDown/PageUp/Home/End), container overflow scroll, horizontal scroll
  • Regression tests: All existing form tests, event tests, and golden tests must pass unchanged

Previous Story Intelligence

Story 4.5 established patterns this story MUST follow:

  • Parameter threading: When adding params (like scroll_x), update all call sites consistently (display list building, event handler, tests)
  • Runtime state over DOM: Use AppState fields for scroll positions, not DOM attributes
  • Event dispatch pattern: Follow dispatch_submit_event_with_swap() or dispatch_invalid_event_with_swap() patterns for dispatch_scroll_event_with_swap()
  • just ci after every task: Don't batch -- verify incrementally
  • Deferred items are OK: Mark as deferred with clear rationale (e.g., scrollbar interaction deferred)
  • Review bug patterns: 4.5 review found readonly fields missed in validation -- similarly ensure keyboard defaults cover all interactive element types (links, buttons, checkboxes, radio buttons)

Git Intelligence

Recent commits show stable Epic 4 progression:

  • be22671 Story 4.5: client-side form validation
  • 9ca0a12 Story 4.4: form submission
  • 71f263f Story 4.3: select menus
  • f8e0c47 Story 4.2: buttons, checkboxes, radio buttons
  • Convention: descriptive commit messages with (Story X.Y) suffix
  • All stories complete in single commits (no multi-commit stories in Epic 4)

Key Implementation Notes

  1. KeyCode enum expansion: The platform crate's KeyCode enum needs Space, PageUp, PageDown, ArrowUp, ArrowDown. The winit mapping is in event_loop.rs. Follow the existing pattern: add enum variants, add VirtualKeyCode::Space => KeyCode::Space mappings.

  2. Scroll container detection: A container is scrollable if overflow_x or overflow_y is Auto or Scroll AND content exceeds container bounds. Check LayoutBox dimensions: if content_height > box_height (for vertical) or content_width > box_width (for horizontal), the container is scrollable.

  3. Scroll chaining pattern: When scroll delta is received, find innermost scroll container at pointer position. Apply delta. If container hits its bound (scroll_y == 0 or scroll_y == max), compute remaining delta and propagate to parent scroll container. Repeat until all delta consumed or page level reached.

  4. Display list per-container scroll: When building display list items for children of a scrollable container, push a clip rect (container bounds), then offset all child coordinates by -scroll_offset. Pop clip after children. This is similar to existing PushClip/PopClip pattern.

  5. Hit testing with container scroll: When hit testing into a scrollable container, add the container's scroll offset to the test coordinates before checking children. This reverses the display list offset.

  6. Default behavior prevention pattern: After dispatch_keyboard_event_with_swap() returns, check event.default_prevented. If not prevented, match on (focused_element_type, key) to execute defaults. This mirrors how form submission checks default_prevented after submit event dispatch.

  7. Scroll event coalescing: Multiple scroll wheel events can arrive per frame. For simplicity, dispatch one scroll event per scroll position change. No need to coalesce in this story.

References

  • [Source: crates/app_browser/src/hit_test.rs#find_element_at_position] -- Hit testing entry point
  • [Source: crates/app_browser/src/hit_test.rs#find_element_in_box] -- Recursive hit test logic
  • [Source: crates/app_browser/src/event_handler.rs#~L71-91] -- key_code_to_key_and_code mapping
  • [Source: crates/app_browser/src/event_handler.rs#~L95-151] -- Keyboard event dispatch
  • [Source: crates/app_browser/src/event_handler.rs#~L1590-1604] -- Current scroll wheel handler
  • [Source: crates/app_browser/src/app_state.rs#~L54-56] -- scroll_y and max_scroll_y
  • [Source: crates/app_browser/src/app_state.rs#~L393] -- scroll_to_fragment
  • [Source: crates/display_list/src/builder.rs#~L97-100] -- Scroll offset in display list building
  • [Source: crates/display_list/src/lib.rs#~L77-86] -- ScrollIndicator display item
  • [Source: crates/layout/src/types.rs#~L252-253] -- overflow_x, overflow_y on LayoutBox
  • [Source: crates/platform/src/event_loop.rs#~L18-36] -- KeyCode enum
  • [Source: crates/web_api/src/event_dispatch.rs] -- Three-phase DOM event dispatch
  • [Source: crates/web_api/src/lib.rs#dispatch_mouse_event] -- Mouse event dispatch pattern
  • [Source: crates/web_api/src/lib.rs#dispatch_keyboard_event] -- Keyboard event dispatch pattern
  • [HTML Living Standard - Scrolling] -- Scroll processing model
  • [UI Events W3C Spec] -- KeyboardEvent key/code values, default actions
  • [CSSOM View Module] -- Scroll APIs and overflow scrolling behavior

Dev Agent Record

Agent Model Used

Claude Opus 4.6 (1M context)

Debug Log References

No blocking issues encountered.

Completion Notes List

  • Task 1: Improved hit testing in hit_test.rs — z-index stacking order (positioned children sorted by z-index, highest checked first), overflow clipping (children of overflow:hidden/auto/scroll containers only tested within padding box). 7 new unit tests covering z-index priority, negative z-index, overflow:hidden clipping, overflow:auto clipping, and overflow:visible no-clip.
  • Task 2: Keyboard default behaviors — Enter on focused <a> navigates to href, Space on checkbox toggles, Space on radio selects, Space on button/submit triggers synthetic click. Space defaults handled in CharacterInput path (since space is printable), Enter defaults in KeyPressed. Added Space, Up, Down, PageUp, PageDown to platform KeyCode enum with winit mappings.
  • Task 3: Page-level scroll improvements — added scroll_x/max_scroll_x to AppState, horizontal wheel scroll via delta_x, keyboard scrolling (Space/Shift+Space for page, PageUp/Down, Home/End, ArrowUp/Down for line). Display list builder accepts both scroll_x and scroll_y. Hit testing accounts for scroll_x in mouse coordinates.
  • Task 4: Container-level scrolling — scroll_offsets: HashMap<NodeId, (f32, f32)> in AppState, find_scroll_containers_at_position() finds scrollable containers at pointer, scroll chaining propagates remaining delta to parent containers then page level. Display list builder applies per-container scroll offsets and clips children. last_mouse_x/last_mouse_y track pointer for scroll target detection.
  • Task 5: Scroll event dispatch — dispatch_scroll_event() in web_api (bubbles: false, cancelable: false) + browser_runtime wrapper. dispatch_scroll_event_on_document() helper fires on document root after any scroll change. 3 integration tests: fires on target, does not bubble, not cancelable.
  • Task 6: 9 new integration tests in js_events.rs (3 scroll event + 6 keyboard). All 308 app_browser unit tests pass. All existing integration tests pass. just ci clean. HTML5 checklist updated.

Change Log

  • 2026-04-02: Implemented Story 4.6 Click, Keyboard & Scroll Interaction — hit testing z-index/overflow improvements, keyboard default behaviors for links/buttons/checkboxes, horizontal page scroll, keyboard page scroll, container-level overflow:auto/scroll scrolling with scroll chaining, scroll event dispatch. 7 new hit test unit tests, 9 new integration tests. All ACs satisfied, just ci passes.
  • 2026-04-02: Code review fixes — (H1) Scroll events now dispatch on the actual scrolled container element, not always document root (AC #6 fully compliant). (H2) Extracted navigate_link_href() helper to deduplicate Enter/Space link navigation code. (H3) Changed build_display_list_with_container_scroll API to accept &HashMap reference instead of owned clone. (L2) Removed redundant nested state.document checks. just ci passes.

File List

  • crates/platform/src/event_loop.rs — Added Space, Up, Down, PageUp, PageDown to KeyCode enum with winit mappings
  • crates/app_browser/src/hit_test.rs — Added z-index stacking order, overflow clipping, is_scroll_container(), content_overflow(), find_scroll_containers_at_position(), find_layout_box_by_id()
  • crates/app_browser/src/event_handler.rs — Keyboard default behaviors (Enter/Space on links/buttons/checkboxes), horizontal scroll, keyboard scrolling, container scroll with chaining, scroll event dispatch, scroll_x in hit testing
  • crates/app_browser/src/app_state.rs — Added scroll_x, max_scroll_x, scroll_offsets, last_mouse_x, last_mouse_y; reset on navigation
  • crates/app_browser/src/main.rs — Initialize new AppState fields
  • crates/display_list/src/builder.rs — Added scroll_offset_x, container_scroll_offsets, build_display_list_with_container_scroll(); applied container scroll offsets in render pipeline
  • crates/display_list/src/lib.rs — Export build_display_list_with_container_scroll
  • crates/web_api/src/lib.rs — Added dispatch_scroll_event() (bubbles: false, cancelable: false)
  • crates/browser_runtime/src/lib.rs — Added dispatch_scroll_event() wrapper
  • crates/app_browser/src/tests/hit_test_tests.rs — 7 new tests: z-index ordering (3), overflow clipping (4)
  • crates/display_list/src/tests/scroll_offset_tests.rs — Updated for new scroll_x parameter
  • crates/display_list/src/tests/clipping_tests.rs — Updated for new scroll_x parameter
  • crates/display_list/src/tests/sticky_positioning_tests.rs — Updated for new scroll_x parameter
  • tests/js_events.rs — 9 new integration tests: scroll event (3), keyboard events (6)
  • docs/HTML5_Implementation_Checklist.md — Updated interaction event status