Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Shadow DOM support (v5.3.0) #570

Merged
merged 22 commits into from
Apr 20, 2022
Merged

Shadow DOM support (v5.3.0) #570

merged 22 commits into from
Apr 20, 2022

Conversation

stefcameron
Copy link
Member

@stefcameron stefcameron commented Jan 28, 2022

👉 See #304 for initial PR and discussion history

This PR adds support for shadow dom (as requested in #50). Currently I just placed all tests in a single e2e spec, because it was much easier to handle, I will probably break it down into the 4 apis before we merge. The code is separated into 2 parts:

  1. changes to check isTabbable/isFocusable
  2. alternative to dom query which walks the dom

new options API:

  • getShadowRoot(element): called for any element without shadowRoot property in order to detect shadow dom. (return shadowRoot in order to query internally, true to indicate there is a shadow-root, but it's not available or false for no shadow-root)

  • dev support for fixtures with declarative shadow dom:

    • add development shadow-root-utils to handle hydration, polyfill and expose closed shadow root for tests.
    • add options to render a subtree of a fixture setupFixture(content, {**caseId**}).
    • change debug page to include and work with shadow dom examples.
  • support isTabbable/isFocusable:

    • separate radio buttons group between each light/shadow (found unexpected behavior in chromium).
    • check for display in shadow dom and across shadow boundary when possible:
      • if a shadow root is detected, but cannot be accessed, then fallback to zero-size display check.
      • caveat: if an element, or one of it's ancestors, is nested under an undetected shadow-root with display=none or is not slotted, then the element will be wrongly considered focusable (only for displayCheck: "full").
  • support scanning dom via walking instead of query

    • when getShadowRoot option is supplied then walking is picked over query and shadow-roots and slots are visited
    • new type of candidate list with scoped items is supported. a scope item is not a tabbable/focusable item by itself, but contains a list of nested elements that needs to be sorted between themselves and inserted according to the scoped item computed position (shadow-roots and slots generates scoped items, while shadow hosts can also be focusable/tabbable themselves).
    • sort now accepts the new scoped candidate list, sort and flatten it.
  • delegatesFocus is not implemented here.

    • I played with it, but it only works in chromium (can't get it to work on Firefox, although MDN states differently), and in any case it's marked as deprecated not implemented in Firefox
    • if this were to be added then it's simply a matter of adding a check in isTabbable/isFocusable. The check try to find shadowRoot.delegatesFocus (currently exposed on chromium), or ask for options.delegatesFocus(node) and allow whoever uses Tabbable to provide the flag according to available shadow knowledge and browser support.
    • added support for shadowrootdelegatesfocus in the declarative shadow dom dev utils for now
  • types

  • documentation - README updated (API changes, instructions, etc.).

  • make sure source is compatible with the acceptable ECMAScript version

  • Add support for getShadowRoot: true

  • Changeset

Some thoughts:

  • closed mode shadow roots cannot be accessed without help from whoever defined them. This can be solved with a component framework/platform or apparently anyone with access to the page before the components are defined by overriding customElements.define or Element.prototype.attachShadow 🤔
  • it might be helpful to offer a way to mark dom elements and run a query instead of walking, but that's just optimizations features that can be added later.
  • now that there is an iterative dom walk option, iframes content can be scanned in a similar way.
  • change debug page to render one fixture/fixture case at a time, currently tabindex between fixtures make it hard to use.
  • maybe change source to latest ECMAScript to clean it up and just transpile for legacy versions.
  • also it would be nice to break down the source into separate files as its hard to navigate through.

Fixes #50

@changeset-bot
Copy link

changeset-bot bot commented Jan 28, 2022

🦋 Changeset detected

Latest commit: a52dbdb

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
tabbable Minor

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@stefcameron stefcameron changed the title Beta 530 Shadow DOM support Jan 28, 2022
@codecov
Copy link

codecov bot commented Jan 28, 2022

Codecov Report

Merging #570 (b30ebaa) into master (a331ef1) will increase coverage by 0.41%.
The diff coverage is 96.11%.

❗ Current head b30ebaa differs from pull request most recent head a52dbdb. Consider uploading reports for the commit a52dbdb to get more accurate results

@@            Coverage Diff             @@
##           master     #570      +/-   ##
==========================================
+ Coverage   97.20%   97.61%   +0.41%     
==========================================
  Files           1        1              
  Lines         143      210      +67     
  Branches       62      101      +39     
==========================================
+ Hits          139      205      +66     
- Misses          4        5       +1     
Impacted Files Coverage Δ
src/index.js 97.61% <96.11%> (+0.41%) ⬆️

Continue to review full report at Codecov.

Legend - Click here to learn more
Δ = absolute <relative> (impact), ø = not affected, ? = missing data
Powered by Codecov. Last update a331ef1...a52dbdb. Read the comment docs.

@stefcameron stefcameron changed the title Shadow DOM support Shadow DOM support (v5.3.0) Jan 28, 2022
@stefcameron
Copy link
Member Author

stefcameron commented Jan 28, 2022

@idoros @scomea @kee-oth [email protected] has been released with @idoros's initial implementation, which is now all on the beta-530 branch in tabbable.

I will be publishing betas of focus-trap and focus-trap-react (no one has requested this in focus-trap-react so I will hold off on that one for now) that use this as well.

@scomea
Copy link

scomea commented Feb 16, 2022

Firefox claims to have implemented delegatesFocus support in November - https://www.ghacks.net/2021/11/02/firefox-94-0-release-here-is-what-is-new-and-changed/

@scomea
Copy link

scomea commented Feb 16, 2022

Yay!

I plugged it in and got it to work with some FAST stuff on a local branch.

Want to poke at it some more though. I had to fiddle some with the options before I landed on this:

const myElements: FocusableElement[] = tabbable(this, {getShadowRoot: ()=>{return undefined}});

Is that the right way?

I feel like the basic "just check the shadow dom without special knowledge" option would be easier to understand if this syntax worked as well:
const myElements: FocusableElement[] = tabbable(this, {getShadowRoot: true});

ie. 'getShadowRoot' typed as boolean | (node: FocusableElement) => ShadowRoot | boolean | undefined;

@scomea
Copy link

scomea commented Feb 16, 2022

Started a draft pr @fast to test it out - microsoft/fast#5606

@stefcameron
Copy link
Member Author

Yay!

I plugged it in and got it to work with some FAST stuff on a local branch.

🚀

Want to poke at it some more though. I had to fiddle some with the options before I landed on this:

const myElements: FocusableElement[] = tabbable(this, {getShadowRoot: ()=>{return undefined}});

Is that the right way?

If you return a falsy value like undefined, tabbable will completely ignore all children (normal or children of a light shadow) under the given node. Here, it seems you're just going to ignore all things because you aren't gating the check on a node at all.

The purpose of getShadowRoot() is basically to return to tabbable the shadow that it should dig into to find more candidates.

Here's what happens if you return a truthy value, and the difference between a plain true boolean and returning a node (i.e. a reference to the shadow to dig into):

shadowRoot === true ? element.children : shadowRoot.children

The function is called only if element.shadowRoot is null (either doesn't have one, or it's a closed shadow). If you return true, we skip the shadow and drill into light DOM children. If you return something else, we assume it's a closed shadow reference and look at its children.

@idoros Please confirm if I'm interpreting this correctly...

I wonder if true here is a little hard to understand from a usability point of view. I guess the meaning is to tell tabbable to keep looking deeper for possible shadows on normally-reachable children. And now we have a new false behavior where we can tell tabbable to stop drilling and move on. Or we give it a reference to a closed shadow that it normally wouldn't be able to see. Still a bit fuzzy for me, honestly.

I feel like the basic "just check the shadow dom without special knowledge" option would be easier to understand if this syntax worked as well: const myElements: FocusableElement[] = tabbable(this, {getShadowRoot: true});

ie. 'getShadowRoot' typed as boolean | (node: FocusableElement) => ShadowRoot | boolean | undefined;

I think that would result in tabbable only looking at open shadows. Is that what you want? Feels a bit strange to assign a boolean to an option name that really sounds like a function, but I get what you're asking.

@idoros
Copy link
Contributor

idoros commented Feb 22, 2022

Please confirm if I'm interpreting this correctly...

Interpreted correctly. I agree with @scomea that it might be helpful to also accept getShadowRoot: true to allow for a simple way to enable open shadow root support (even that the name starts with a get ).

Firefox claims to have implemented delegatesFocus support in November

Seems that they have. knowing about this might provide improvement for isTabbable and isFocusable and reduce some edge cases where tabbable and focusable return the extra component host element. @scomea do you see issues with fast related to delegatesFocus?

@stefcameron
Copy link
Member Author

stefcameron commented Feb 22, 2022

Please confirm if I'm interpreting this correctly...

Interpreted correctly. I agree with @scomea that it might be helpful to also accept getShadowRoot: true to allow for a simple way to enable open shadow root support (even that the name starts with a get ).

😅 Thanks for confirming! I think I can get into getShadowRoot: true now that I've let that simmer a bit and feel more confident in understanding the different options:

  • getShadowRoot: true -- eagerly look for and "get" open shadow roots from all children (deep) when they exist
  • getShadowRoot: false -- (default) normal behavior, which means skip all (do not "get" any) shadow roots while traversing children
  • getShadowRoot: (element) => boolean | ShadowRoot -- choose when to keep digging for shadows (true | false), and when to dig into a closed shadow (ShadowRoot)

@stefcameron
Copy link
Member Author

@idoros There's a critical nuance I'm not understanding in the implementation, which is the difference between returning true and false from getShadowRoot():

      // iterate over content
      const shadowRoot = element.shadowRoot || options.getShadowRoot(element);
      if (shadowRoot) {
        // add shadow dom scope
        const nestedCandidates = getCandidatesIteratively(
          shadowRoot === true ? element.children : shadowRoot.children,         <- HERE (1)
          true,
          options
        );
        if (options.flatten) {
          candidates.push(...nestedCandidates);
        } else {
          candidates.push({
            scope: element,
            candidates: nestedCandidates,
          });
        }
      } else {
        // add light dom scope
        elementsToCheck.unshift(...element.children);            <- AND HERE (2)
      }
  1. If getShadowRoot() returns true, we seem to dig into elements.children, one at a time, but in a recursive call to getCandidatesIteratively().

  2. If getShadowRoot() returns false, we fall to the else clause, and still end-up looking at every elements.children.

What is the subtle difference here? Either way, it seems we're digging into light DOM children in search of shadows, and if we find an open shadow, we automatically look inside of it.

So true or false, as well as no shadow at all (open or closed), we do the same thing in the end.

The only difference is when there's a closed shadow and getShadowRoot() returns it. Then we dig into its children in search of candidates.

@idoros
Copy link
Contributor

idoros commented Mar 2, 2022

Seems like you are correct. To be honest I can't remember, but from my comments in the original PR there is a difference between true and false for isTabbable/isFocusable - returning true causes a fallback to the zero area size check in isHidden since there is no way of knowing if an element is nested under some display: none shadow element.

@stefcameron
Copy link
Member Author

@idoros

returning true causes a fallback to the zero area size check in isHidden since there is no way of knowing if an element is nested under some display: none shadow element.

Thanks for pointing that out. Now true vs false is making more sense:

  • True: "there is a shadow DOM, but I'm not going to tell you what it is" (for whatever reason)
  • False: "there is no shadow DOM, really"

In light of those definitions, then I see how "true" informs the isHidden() case of treating it as if it had zero area since we know something's there, but we're not told what it is.

But what happens there if the getShadowRoot(node) call returns the closed shadow root? That will be a truthy value, and we will still make this assumption, but we'll be wrong this time, because we've been given the shadow root.

I think that if needs to become:

      if (
        parentElement &&
        !parentElement.shadowRoot &&
        getShadowRoot(parentElement) === true
      ) {

WDYT?

@idoros
Copy link
Contributor

idoros commented Mar 3, 2022

But what happens there if the getShadowRoot(node) call returns the closed shadow root?... WDYT?

I think I missed this, and when a closed shadow root is passed, a better check can be done

@stefcameron stefcameron force-pushed the beta-530 branch 2 times, most recently from 305b81e to 31198f7 Compare March 6, 2022 01:34
@scomea
Copy link

scomea commented Mar 8, 2022

So far no "double focus" issues on components that use "delegate focus", so that's good. Still poking around.

@stefcameron stefcameron force-pushed the beta-530 branch 3 times, most recently from 5290547 to de1637e Compare March 11, 2022 20:02
@stefcameron
Copy link
Member Author

@idoros @scomea

I agree with @scomea that it might be helpful to also accept getShadowRoot: true to allow for a simple way to enable open shadow root support (even that the name starts with a get ).

Coming back to this after rebasing the branch (which included 2 fixes from master, one in particular being #604 that caused some conflicts, see de1637e for the changes) and I think I have a better understanding of the nuances going on with getShadowRoot(), what it returns, and how it affects the outcome of tabbable() or focusable().

And so this request to support getShadowRoot: true in order "enable open shadow root support") puzzles me because as it stands, tabbable will always look into open shadow roots by default, even if you don't provide the getShadowRoot option.

That's because for every candidate element we find, we do this test:

const shadowRoot = element.shadowRoot || options.getShadowRoot(element);

So if there's an open shadow, we don't even look at the getShadowRoot option.

If you want to opt-out of shadow DOM support, for whatever reason, then you have to provide getShadowRoot: () => false or getShadowRoot: () => {} or some function that returns a falsy value.

So then, do we really need to support getShadowRoot: true/false for some reason?

Opting out shadow DOM support means tabbable will most certainly not return the full set of nodes (if you use web components with closed shadows). Why would you want that, and would you want that often enough that we should have getShadowRoot: false as a convenience?

idoros and others added 22 commits April 20, 2022 17:50
- new development shadow-root-utils
- refactor setupFixture to render using new utils
- refactor current shadow dom tests to use declarative root
- debug page renders shadow root fixtures correctly
- iterate down dom instead of query when getShadowRoot is provided
- new candidate list/tree format with scoped lists
- as requested in PR
This goes along with disabling it for `tabbable()` and `focusable()`
when the option isn't given.
Note this is the equivalent of `getShadowRoot: () => false` which
simply enables shadow DOM support for all open shadows.
…e default if set to some NaN (#610)

* fix(index.js) The tabIndex of contentEditable elements was assumed to be zero in any case, not only in the case it was not specifically set.

* Simplified and optimized 'getTabIndex'.

* Made better use of short-circuit evaluation in 'isNodeMatchingSelectorTabbable', reducing the chances to call the computationally expensive 'isNodeMatchingSelectorFocusable'.

* (Re)Added 'isScope' parameter to 'getTabIndex'. This parameter wasn't present in the master branch, so I lost it in the rebase process.

* Added tests for a `contenteditable` with negative tab index.

* Fixed bug, now the getTabIndex can return 0 not only when the tabindex is not explicitly set, but also when is set to a value that gives NaN when parsed as integer (which would have been resulted in the default browser tabIndex, as if the tabindex wasn't set at all). Also added test for the case an element has a tab index that can't be turned into an integer.

* Added changeset, added entry in CHANGELOG.md and wrote more tests.

* Be consistent with asterisks

* Sync package.json/yarn.lock with beta-530 base branch

Co-authored-by: Stefan Cameron <[email protected]>
Can't repro the issue, but might as well keep the test since we
seem to like fieldsets but not forms for some reason.
@stefcameron
Copy link
Member Author

Well, no time like the present. I haven't heard from anyone for a long time now, so I'm going to assume that "no news is good news" and will merge and publish. Big thanks to everyone who contributed and reviewed!

@stefcameron stefcameron merged commit 685a906 into master Apr 20, 2022
@stefcameron stefcameron deleted the beta-530 branch April 20, 2022 23:01
@stefcameron
Copy link
Member Author

Published in v5.3.0 just now. 🎉

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.

Tab Across Web Components
4 participants