Skip to content

Allow focus moves within a document's own subtree with target - #3

Draft
ffiori wants to merge 4 commits into
mainfrom
user/ffiori/focus-threaded-source-with-target
Draft

Allow focus moves within a document's own subtree with target#3
ffiori wants to merge 4 commits into
mainfrom
user/ffiori/focus-threaded-source-with-target

Conversation

@ffiori

@ffiori ffiori commented Sep 4, 2026

Copy link
Copy Markdown
Owner

The "focus-without-user-activation" feature currently refuses any programmatic focus move into a document that is not allowed to use it, regardless of where the focus already is, and regardless of who asked. Two consequences:

This closes both. It supersedes whatwg#11519, which tried to close the first by consulting the current global object inside the algorithm — a value that resolves to the target for same-origin calls and to the caller only for cross-origin window.focus(), so the current text is accidentally origin-dependent.

Discussion: whatwg#11839. Previous spec PR for this feature: whatwg#10672.

What changes

allow focus steps gains the document that asked for the move, and succeeds in two new cases: the focus is already inside the target, or the asker could have taken the focus itself and the target is inside the asker's subtree.

allow focus steps(target, focusSetterDocument):
  1. target is allowed to use the feature                  -> true
  2. target's relevant global has transient activation     -> true
  3. target is not fully active                            -> false
  4. has focus steps(target)                               -> true
  5. focusSetterDocument is not fully active               -> false
  6. focusSetterDocument's inclusive descendant navigables
     does not contain target's node navigable              -> false
  7. focusSetterDocument is allowed to use the feature     -> true
  8. focusSetterDocument's relevant global has transient
     activation                                            -> true
  9. has focus steps(focusSetterDocument)                  -> true
 10.                                                          false

Steps 5–9 read as: the setter could have moved the focus to itself, and the target is inside the setter's subtree. Steps 1 and 2 are unchanged from main and still evaluated first, so nothing that focuses today stops focusing.

Step 6 is load-bearing. Without it, a document that merely happens to hold the focus could move it into an unrelated Document — a sibling navigable, or one in the top-level traversable that opened it — which would let a document that is not allowed to use the feature be focused anyway. Within the setter's own subtree it is safe, because an embedder is the party that determines its descendants' permissions policy.

has focus steps is true when the focus is in a document or in any of its descendant navigables, since it walks down the focus chain from the top-level traversable and returns true as soon as it reaches its argument.

Threading the setter

The setter is threaded explicitly rather than looked up wherever it happens to be needed. It is read from the incumbent global object at six script entry points — Window.focus(), HTMLOrSVGOrMathMLElement.focus(), dialog.show(), dialog.showModal(), showPopover() and togglePopover() — and then passed as an argument through show popover, show a modal dialog, the dialog focusing steps and the popover focusing steps.

Declarative invocations carry the invoking element's node document instead, so they do not depend on whatever happened to be on the stack:

Path setter
popovertarget / command on a button the invoker's node document
commandfor="…" command="show-modal" the invoking element's node document
a select opening its own picker the select's node document
the autofocus insertion steps the element's node document
Window.focus(), focus(), show(), showModal(), showPopover(), togglePopover() the incumbent global object's associated Document

Only allow focus steps is exported; show popover, show a modal dialog and the two focusing steps are HTML-internal, so the added arguments are not a cross-specification change.

Behaviour

A hosts B hosts C, with the feature denied for B and C:

Focused Call Result
B B focuses one of its own elements allow
B B focuses iframe C allow
C B focuses one of its own elements allow
A B focuses one of its own elements deny
A A calls iframeB.focus() allow
A A calls bWindow.focus() allow
A B calls window.focus() on itself deny
B B focuses an element in a cousin navigable deny

Focus moves across top-level traversables are unaffected: a document whose node navigable has a null parent is always allowed to use a feature whose default allowlist is 'self', so step 1 short-circuits for any top-level target.

focus() on an iframe element and focus() on that iframe's Window now agree — the first passes at step 1 (the element's document is the embedder's), the second at step 7 (the embedder is the setter and contains the child). On main they disagree, which is the only reason focus delegation happens to work through one API and not the other.

Considered and not done

A fullscreen carve-out. Raised in whatwg#11519 for A → B → C where C is fullscreen and B takes the focus. It cannot be made effective here: requestFullscreen() consumes activation, but any subsequent interaction with the fullscreen content runs the activation notification steps, which give every ancestor Window transient activation without consuming it — so step 2 short-circuits ahead of any guard. A guard placed before step 2 would instead refuse a user-initiated move out of fullscreen, and would also refuse moves that are allowed today, such as a page focusing its own control frame while an unrelated child is fullscreen. This looks like whatwg/fullscreen#15 rather than something for this feature; happy to add a carve-out in a follow-up if editors disagree.

Deciding purely from the target. Would remove the need for a setter entirely, but then contentWindow.focus() could not be used for delegation and the two focus APIs would stay inconsistent.

Deciding purely from the setter. Simpler, but it drops step 6, which is what stops a focus-holding frame from pushing focus into an unrelated cousin; and consulting the setter rather than the element's own document in focus() refuses several same-origin cases that work on main.

Points that need an explicit decision

  1. has focus steps is true for a document when the focus is in any of its descendants, so a document may take the focus back from a child navigable the user was interacting with. This is intended, and is the behaviour embedders have asked for, but it deserves a resolution rather than falling out of has focus steps.
  2. A document that is allowed to use the feature may delegate focus to any document in its subtree, including past an intermediate navigable that denied the feature. This follows from treating the embedder as the party that determines its descendants' policy.

(See WHATWG Working Mode: Changes for more details.)

ffiori and others added 4 commits September 3, 2026 14:49
Carry the initiating document through direct focus, dialog, and popover algorithms. Use explicit invoking documents for user-agent paths and block the focused-subtree exception while a focused descendant is fullscreen.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e74da64d-6b71-41bf-8b15-4e34d5f7a6ec
Prevent caller-based authorization from reaching dialog focusing steps that dereference a null node navigable for detached or synthetic documents.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e74da64d-6b71-41bf-8b15-4e34d5f7a6ec
Keep the focus-without-user-activation change scoped to caller attribution and focused descendants. Fullscreen interaction can be specified separately once its exact boundary is agreed.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Copilot-Session: e74da64d-6b71-41bf-8b15-4e34d5f7a6ec
Builds on the threading in this branch. `allow focus steps` took only
`focusSetterDocument`, so the document the focus would move *into* was never
consulted. Two consequences.

A document that holds the focus could move it anywhere. A (top, allowed) embeds
cross-origin B and V, both denied. The user clicked into B once and the
activation has expired, so the focus is in B. B runs
`top.frames['v'].focus()`: B is not allowed and has no activation, but B holds
the focus, so the steps returned true and the focus landed in V -- which was
denied the feature precisely to stop that. V is not related to B at all.

And three things that work on main stopped working, because element.focus()
consulted the setter instead of the element's own document:

  * a denied frame calling `aWin.focus()` on its allowed parent;
  * a denied frame focusing an element of an allowed same-origin ancestor;
  * a same-origin helper frame driving focus through a shared ancestor's DOM,
    e.g. `parent.document.getElementById('fB').focus()`.

Both are fixed by consulting the target first and scoping the setter's authority
to its own subtree:

  1. target is allowed to use the feature                 -> true
  2. target's relevant global has transient activation    -> true
  3. target is not fully active                           -> false
  4. has focus steps(target)                              -> true
  5. focusSetterDocument is not fully active              -> false
  6. focusSetterDocument does not contain target          -> false
  7. focusSetterDocument is allowed to use the feature    -> true
  8. focusSetterDocument has transient activation         -> true
  9. has focus steps(focusSetterDocument)                 -> true
 10.                                                         false

Steps 1 and 2 are main's, unchanged and still first, so nothing that focuses
today stops focusing. Steps 5-9 say: the setter could have moved the focus to
itself, and the target is inside the setter's subtree. Scoping by containment is
what stops the cousin case, and it is safe within the subtree because an
embedder determines its descendants' permissions policy.

The two focus APIs still agree. `iframeB.focus()` is checked against A with A as
the setter and passes at step 1; `bWin.focus()` is checked against B with A as
the setter and passes at step 7. A child calling `window.focus()` on itself is
its own setter and passes none of 7-9.

Also collapses the descendant loop. `has focus steps` walks down the focus chain
from the top-level traversable and returns true as soon as it reaches its
argument, so it is already true when the focus is in that document or in any of
its descendant navigables. The loop over inclusive descendant navigables was
exactly one `has focus steps` call.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
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