Allow focus moves within a document's own subtree with target - #3
Draft
ffiori wants to merge 4 commits into
Draft
Conversation
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 stepsgains 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.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
mainand 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 stepsis 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()andtogglePopover()— and then passed as an argument throughshow 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:
popovertarget/commandon abuttoncommandfor="…" command="show-modal"selectopening its own pickerselect's node documentautofocusinsertion stepsWindow.focus(),focus(),show(),showModal(),showPopover(),togglePopover()DocumentOnly
allow focus stepsis exported;show popover,show a modal dialogand 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:
iframeCiframeB.focus()bWindow.focus()window.focus()on itselfFocus 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 aniframeelement andfocus()on thatiframe'sWindownow 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). Onmainthey 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 ancestorWindowtransient 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 onmain.Points that need an explicit decision
has focus stepsis 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 ofhas focus steps.(See WHATWG Working Mode: Changes for more details.)