Skip to content

feat(focus): §6.6.4 get the focusable area + shadow delegatesFocus retarget (PR-A1)#354

Open
send wants to merge 5 commits into
mainfrom
feat/focus-get-the-focusable-area
Open

feat(focus): §6.6.4 get the focusable area + shadow delegatesFocus retarget (PR-A1)#354
send wants to merge 5 commits into
mainfrom
feat/focus-get-the-focusable-area

Conversation

@send

@send send commented Jun 16, 2026

Copy link
Copy Markdown
Owner

Summary

First slice (PR-A1) of the canonical focus-transition program (structural slot #11-canonical-focus-update-steps). Builds the WHATWG HTML §6.6.4 get the focusable area retarget in engine-independent elidex-dom-api, and wires the content-thread click→focus path through it so a click on a shadow host with delegatesFocus focuses the host's delegate instead of the host.

What lands

  • get_the_focusable_area(dom, target, trigger) (§6.6.4 #get-the-focusable-area) — branch 6 (delegates-focus shadow host → focus_delegate) with the 6.1/6.2 "keep focus already inside this host" shortcut. Branches 1–5 (image-map area / scrollable region / viewport / navigable / navigable container) are unmodelled-or-cross-frame in elidex's single-EcsDom model and documented as falling through; branch 7 = null.
  • focus_delegate / autofocus_delegate / first-focusable-descendant (§6.6.4 #focus-delegate / #autofocus-delegate) — light-tree descendant walks (the spec is explicit these are not shadow-including), recursing through get_the_focusable_area for nested hosts.
  • FocusTrigger{Click,Other} — the spec's focus trigger (drives the autofocus click-focusable filter).
  • Shell focus_target_for_click wired at the content-thread handle_click = get_the_focusable_area(hit, Click).unwrap_or(hit), called unconditionally (branch-6-only non-null ⇒ reproduces the focusing-steps step-1 gate without a §6.6.2-complete predicate).

Spec / criterion-2 correctness (pre-push review fix)

is_focusable deliberately omits §6.6.2 criterion 2 (a delegates-focus shadow host is not a focusable area — kept focusable globally so host.focus() is not a no-op; slot #11-shadow-focus-delegation). The descendant walk therefore uses a criterion-2-aware is_focusable_area (via the shared child_focusable_area sub-step) so a nested delegates-focus host that also carries tabindex delegates into its shadow tree (step 6.4) rather than being returned as itself. Regression-tested.

Scope boundary (→ PR-A2)

  • Editing-host fallback (focusing-steps step 2) and the other pointer-click→content-focus entries (in-process / OOP iframe, legacy single-thread App) converge in PR-A2, when set_focus becomes a thin caller of the canonical §6.6.4 focus update steps and the retarget moves to that single shared seam — rather than sprinkling focus_target_for_click at N call sites (One-issue-one-way: no strangler middle state). Documented in-code.
  • <dialog> focus-delegate branch omitted (<dialog> is not a valid shadow host; reached only from showModal()/autofocus → slot #11-flush-autofocus-candidates-page-load).

Layering / ECS-native

Algorithm lives entirely in engine-independent elidex-dom-api; the shell holds only a thin wrapper (Layering mandate). State is read from the ShadowRoot component + ElementState::FOCUS bit — no side-store.

Tests / gate

  • 9 new focus tests (delegate-to-first, delegatesFocus=false→null, plain→null, keep-focus-inside-host §6.2, autofocus-wins, nested-host recursion, nested-focusable-host C2 regression, trigger default, shell retarget wiring).
  • cargo fmt + mise run ci green (11751 tests, check/doc/lint/deny). Pre-push gate: /elidex-plan-review (F1 spec-bug caught pre-impl) + /code-review (C2 bug) + /simplify + /review + /elidex-review.

🤖 Generated with Claude Code

send and others added 3 commits June 16, 2026 18:00
…target (PR-A1)

Add the engine-independent §6.6.4 "get the focusable area" retarget to
elidex-dom-api: `get_the_focusable_area` (shadow `delegatesFocus` branch 6 +
the 6.1/6.2 "keep focus already inside the host" shortcut), `focus_delegate`,
`autofocus_delegate`, and the `FocusTrigger` enum. The focus-delegate walk uses
plain (light-tree) descendants — `EcsDom::children` excludes shadow roots —
recursing through get-the-focusable-area for nested hosts, per spec.

The shell pointer-click path retargets the raw hit through the new
`focus::focus_target_for_click` before `set_focus`; called unconditionally
(it returns null for any genuine focusable area, reproducing focusing-steps
step 1 without a §6.6.2 C2-aware predicate — kept C2-blind so Tab-nav /
reconcile_focus are untouched, that is PR-A3's axis).

First slice of the `#11-canonical-focus-update-steps` umbrella; closes the
get-area half of `#11-shadow-focus-delegation`. The transition core (focusing
steps / focus update steps / event sink / editing-host fallback) is PR-A2. The
focus-delegate `<dialog>` branch is deferred to its real caller (showModal /
autofocus) rather than landing as unreachable, untestable code.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`#focus-trigger` is not an anchor — the "focus trigger" string is defined in
get-the-focusable-area; cite `#get-the-focusable-area`. "click focusable" is a
§6.6.2 dfn (`#click-focusable`), not the coarser §6.6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…lf (pre-push review)

The focus-delegate descendant walk (§6.6.4 focus-delegate step 6.3 /
autofocus-delegate step 1.2) tested "is the descendant a focusable area"
with `is_focusable`, which deliberately omits §6.6.2 criterion 2 (a
delegates-focus shadow host is NOT a focusable area; the global predicate
keeps it focusable so `host.focus()` is not a no-op — slot
#11-shadow-focus-delegation). A nested delegates-focus host that also
carried a `tabindex` was therefore returned as itself instead of
delegating into its shadow tree (step 6.4). Add a criterion-2-aware
`is_focusable_area` predicate scoped to the delegate walk + a shared
`child_focusable_area` sub-step (folds the byte-identical area-resolution
out of both walkers); regression test for the tabindex-bearing nested
host. Tighten get_the_focusable_area's safety doc (the unconditional shell
retarget is correct because branch 6 only fires for delegates-focus hosts,
which §6.6.2 says are not focusable areas — not because `is_focusable`
rejects them). Switch the two walkers to zero-alloc `children_iter`.

Document that the other pointer-click→content-focus entries (in-process /
OOP iframe, legacy App) converge in PR-A2's shared focus-update-steps seam
rather than sprinkling the retarget at N call sites (One-issue-one-way).

/code-review + /simplify findings; mise run ci green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@send

send commented Jun 16, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: b608de5eed

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/shell/elidex-shell/src/content/focus.rs Outdated
Comment thread crates/shell/elidex-shell/src/content/event_handlers.rs Outdated
Comment thread crates/dom/elidex-dom-api/src/focus.rs
Comment thread crates/dom/elidex-dom-api/src/focus.rs
Comment thread crates/dom/elidex-dom-api/src/focus.rs Outdated
Comment thread crates/dom/elidex-dom-api/src/focus.rs
Codex R1 surfaced 6 threads; this fixes the one in PR-A1's own live behavior.

**F1 (P2, real, fixed)**: clicking a `delegatesFocus` shadow host that carries
`tabindex` but has no focusable shadow delegate focused the *host* — `get_the_
focusable_area` returns null (no delegate), the old `unwrap_or(hit)` passed the
host to `set_focus`, and the shell predicate uses `is_focusable`, which omits
§6.6.2 criterion 2, so the host (tabindex) was accepted and received `FOCUS`
even though a delegates-focus host is not a focusable area. Rewrite
`focus_target_for_click` as the proper §6.6.4 focusing-steps step-1 gate
returning `Option<Entity>`: retarget → delegate; else if the hit is itself a
criterion-2-aware focusable area (`is_focusable_area`) → the hit; else `None` →
`handle_click` blurs (the host must not receive focus). The retarget is still
called unconditionally, so a delegatesFocus host *with* a tabindex still
delegates (PR-A1 plan-review F1 preserved). Expose `is_focusable_area` from
elidex-dom-api; regression test for the empty-shadow-tree host.

Deferred (genuine plan-reviewed cross-PR scope boundaries, recorded with slots):
- F2 iframe click retarget → PR-A2 shared focus-update-steps seam (sprinkling at
  N sites = strangler; the seam owns it).
- F4 split focus.rs (1362→1771) → PR-A2 `#11-focus-module-split`.
- F5 form-disabled delegate candidate → PR-A3 `#11-focusable-area-fieldset-
  inherited-disabled` (dom-api cannot depend on elidex-form — umbrella F1).
- F6 delegatesFocus host `:focus` focus-chain matching → PR-A3
  `#11-delegates-focus-host-focus-chain` (downstream of the retarget).
F3 (FP): get-the-focusable-area does not ancestor-delegate a non-focusable
shadow descendant to the host (not a §6.6.4 branch); residual blur is the
pre-existing N5 / focusing-steps step-1 return semantics (PR-A2).

mise run ci green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@send

send commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

Codex R1 disposition (commit `fc9732d0`)

# Finding Disposition
F1 focus.rs:40 — fall back to a delegatesFocus host Fixed. Rewrote `focus_target_for_click` as the §6.6.4 focusing-steps step-1 gate (Option): retarget→delegate; else if hit is a §6.6.2-criterion-2-aware focusable area→hit; else None→blur. A delegatesFocus host with no delegate is not a focusable area, so it blurs instead of being focused via the C2-omitting is_focusable. Regression test added.
F2 event_handlers.rs:66 — retarget iframe click paths Deferred → PR-A2. The in-process/OOP-iframe + legacy-App click entries converge when set_focus becomes a thin caller of the canonical focus-update-steps seam (the retarget moves to that seam's head). Wiring focus_target_for_click at N sites now = strangler middle state (One-issue-one-way). Documented in-code.
F3 focus.rs:588 — retarget clicks inside delegated shadow trees FP (mechanism). §6.6.4 "get the focusable area" does not ancestor-delegate a non-focusable shadow descendant to its host (no such branch; ancestor retarget is the editing-host fallback, step 2). The residual "blur on non-focusable click" is elidex's pre-existing N5 / step-1 return semantics → PR-A2.
F4 focus.rs:542 — split delegatesFocus out of focus.rs Deferred → PR-A2 (#11-focus-module-split, re-scoped to A2 in the umbrella plan-review).
F5 focus.rs:633 — skip form-disabled delegate candidates Deferred → PR-A3 (#11-focusable-area-fieldset-inherited-disabled). `elidex-dom-api` cannot depend on `elidex-form` (`FormControlState.disabled`); unifying the effective-focusability predicate across the delegate walk + writer is the umbrella plan-review's A3 question (F1).
F6 focus.rs:603 — preserve :focus on delegatesFocus hosts Deferred → PR-A3 (new slot #11-delegates-focus-host-focus-chain). The retarget correctly focuses the delegate; making the host match `:focus` while its shadow tree holds the focused area is the downstream focus-chain / host-visible-focus mechanism, separate from the retarget.

All six map to A1's own fix (F1), an FP, or genuine plan-reviewed cross-PR scope boundaries (A2/A3) with explicit slots. `mise run ci` green.

@send

send commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: fc9732d077

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread crates/shell/elidex-shell/src/content/focus.rs
Comment thread crates/dom/elidex-dom-api/src/focus.rs
Comment thread crates/dom/elidex-dom-api/src/focus.rs Outdated
Comment thread crates/dom/elidex-dom-api/src/focus.rs
Comment thread crates/shell/elidex-shell/src/content/focus.rs
Codex R2 (on fc9732d) surfaced 5 threads; one is a new A1-scope correctness
regression, the rest are re-raises of R1's deferred A2/A3 work.

**R2-c (real, fixed)**: the §6.6.4 focus-delegate descendant walk
(`autofocus_delegate` / `first_delegate_descendant`) used `children_iter`, which
caps iteration at `MAX_ANCESTOR_DEPTH` (10_000) *siblings* — a silent breadth
truncation introduced by the prior /simplify pass (it replaced the uncapped,
cycle-bounded `children()`). The delegate search is a spec-ordered *exhaustive*
traversal, so dropping a later sibling would skip the real focus delegate. Switch
both walkers to `EcsDom::child_list_uncapped` — the core-DOM primitive that exists
precisely for searches where "dropping children is a correctness bug, not a safe
approximation". The recursion `depth` guard is retained (it bounds nesting depth
for stack safety, not sibling breadth — the dimension Codex flagged).

Re-raises (R1 dispositions unchanged, genuine cross-PR scope boundaries):
- R2-a (non-focusable shadow-descendant click should delegate) — reclassified
  from R1-FP to **real → PR-A2**: `get the focusable area` correctly returns null
  for a non-host descendant (verified: its branches only match a shadow *host*),
  so the desired "click anywhere in a delegatesFocus host delegates" behavior
  needs the click→focus-target *ancestor* resolution (the focusing-steps step-2 /
  editing-host-fallback walk), which is A2's canonical focus-update-steps, not a
  get-the-focusable-area branch.
- R2-b form-disabled delegate candidate → A3 (`#11-focusable-area-fieldset-inherited-disabled`).
- R2-d split focus.rs → A2 (`#11-focus-module-split`).
- R2-e route every click entry through the retarget → A2 shared seam.

mise run ci green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@send

send commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

Codex R2 disposition (commit `eef79bca`)

# Finding Disposition
R2-c focus.rs:711 — walk all delegate descendants Fixed. The delegate walkers used children_iter (capped at MAX_ANCESTOR_DEPTH=10 000 siblings — a breadth truncation introduced by the earlier /simplify pass). Switched both to EcsDom::child_list_uncapped, the core-DOM primitive for exhaustive searches where "dropping children is a correctness bug." The depth guard stays (bounds nesting depth for stack safety, not the breadth you flagged).
R2-a focus.rs:55 — resolve delegate clicks from shadow descendants Real → PR-A2 (reclassified from R1-FP). Verified: get the focusable area only matches a shadow host branch, returning null for a non-host descendant — correct. The "click a non-focusable part of a delegatesFocus host → delegate" behavior needs the click→focus-target ancestor resolution (focusing-steps step 2 / editing-host fallback walk), which is A2's canonical focus-update-steps, not a get-the-focusable-area branch.
R2-b focus.rs:657 — apply shell disabled overlay during delegate search → PR-A3 (#11-focusable-area-fieldset-inherited-disabled). `elidex-dom-api` cannot depend on `elidex-form` (FormControlState.disabled); unifying the effective-focusability predicate across the delegate walk + writer is the umbrella plan-review's A3 question.
R2-d focus.rs:522 — split the focus module → PR-A2 (#11-focus-module-split).
R2-e focus.rs:47 — route every click entry through the retarget → PR-A2 shared focus-update-steps seam (wiring at N sites now = strangler middle state).

R2-a/b/d/e are genuine plan-reviewed cross-PR scope boundaries (A2/A3) with explicit slots; R2-c is fixed in A1. `mise run ci` green.

@send

send commented Jun 17, 2026

Copy link
Copy Markdown
Owner Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: eef79bcaa7

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +53 to +55
if let Some(area) = get_the_focusable_area(dom, hit, FocusTrigger::Click) {
Some(area)
} else if is_focusable_area(dom, hit) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Retarget clicks from shadow descendants before blurring

When hit testing returns a non-focusable element inside a delegatesFocus shadow tree (layout hit testing walks composed_children_flat, so shadow children can be the raw hit), this only asks get_the_focusable_area about that inner element; because it is not itself a delegates-focus host, the helper returns None and handle_click blurs. W3C Shadow DOM §6.2 Focus requires a click on a non-focusable node in a delegating host's shadow tree to focus the host's delegate, so common wrapper markup like <span><input></span> in the shadow root still fails unless this path climbs to the nearest delegating host before deciding there is no focus target.

Useful? React with 👍 / 👎.

Comment on lines +69 to +70
match focus_target_for_click(&state.pipeline.dom, hit_entity) {
Some(target) => set_focus(&mut state.pipeline, target),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Preserve :focus matching on delegatesFocus hosts

When this path retargets a clicked host to its shadow delegate, set_focus sets ElementState::FOCUS only on the delegate; existing selector matching for :focus reads that bit directly on the candidate (crates/css/elidex-css/src/selector/matching.rs:204/:356). For a delegatesFocus host with host :focus styles, the click now focuses the inner control but the host no longer matches :focus, even though W3C Shadow DOM §6.2 Focus says :focus applies to the host in addition to the focused shadow-tree element.

Useful? React with 👍 / 👎.

Comment on lines +540 to +542
/// §6.6.4 "get the focusable area" (WHATWG HTML `#get-the-focusable-area`) — map
/// a focus target that is **not itself a focusable area** to the focusable area
/// that should receive focus, performing the shadow-`delegatesFocus` retarget.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P3 Badge Split the overgrown focus module before more slices land

The Axis 5 project-context check asks reviewers to surface substantive additions to files already over 1000 lines: focus.rs was 1362 lines before this patch and is now 1790, with the new §6.6.4 delegate algorithm and tests adding hundreds more lines to the same module. Before PR-A2/A3 add more focus-update logic here, splitting the delegate algorithm/tests into a focused submodule would reduce the decision surface and keep the canonical focus path easier to review.

Useful? React with 👍 / 👎.

Comment on lines +656 to +657
if is_focusable_area(dom, child) {
Some(child)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Skip form-disabled descendants while choosing delegates

When a delegatesFocus host's first focusable-looking descendant is disabled only through the form subsystem (FormControlState.disabled, e.g. fieldset inheritance), this predicate still treats it as a focusable area because elidex_dom_api::focus::is_focusable is attribute-only; the shell overlay rejects it later in set_focus, causing blur instead of continuing to the next eligible descendant. Since the new delegate search runs before that overlay, clicks on such hosts select the wrong delegate and fail to focus a later enabled control.

Useful? React with 👍 / 👎.

Comment on lines +597 to +598
if dom.is_host_including_ancestor_or_self(target, focused) {
return Some(focused);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Retarget a focused delegatesFocus host instead of keeping it

If script or an older path has already focused a delegatesFocus host itself (possible today because is_focusable intentionally omits criterion 2), a subsequent click on that host takes this keep-current-focus branch because the host is an inclusive ancestor of itself and returns the host instead of its delegate. That leaves the click path in exactly the state this PR is meant to avoid: a delegating shadow host remains the focused area until some later focus move.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant