We have a few options for how our Web Components can support :focus-visible summarized below.
I'd like to use this thread to determine which approach we would like to take for @microsoft/fast-components.
:focus-visible with fallback/negationThe easiest and most light-weight pattern is to apply focus and focus-visible style, and then negate the :focus styles when :focus-visible is supported.
/* Provide basic, default focus styles. */
:host(:focus) { }
/*
Remove default focus styles for mouse users ONLY if
:focus-visible is supported on this platform.
*/
:host(:focus:not(:focus-visible)) { }
/*
Optionally: If :focus-visible is supported on this
platform, provide enhanced focus styles for keyboard
focus.
*/
:host(:focus-visible) { }
Pros:
Cons:
We can implement support for the polyfill that our React components implement: https://github.com/WICG/focus-visible. Supporting a shadow DOM environment is a bit more involved. Typically, we would construct a selector using the class the above polyfill adds to the HTML element to determine if the polyfill exists or not, eg.
html.js-focus-visible some-element.js-focus-visible {
/* focus visible styles */
}
html:not(.js-focus-visible) some-element:focus {
/* fallback to focus selector */
}
This selector does not resolve for CSS stylesheets defined for Web Components though due to the shadow DOMs style encapsulation - a host element cannot select for the parent context of the host.
Note: While there *is a selector (:host-context()) that does do this, it is not widely adopted by browsers and has gotten push-back from Mozilla and WebKit. No clear indication on whether it will be adopted by those vendors.*
Additionally, by default, the polyfill is only automatically applied to the DOM scope it is loaded into - it is not registered for component's Shadow DOMs. This means that, by default, focusable elements that do not exist in the light-DOM in which the script is registered will not get augmented by the polyfill.
The polyfill can be register a shadow root though, which will bring support to that DOM tree. Polyfill documentation warns of negative performance impact here. I didn't do extensive testing, but registering it against 2000 components added anywhere from 0.02ms - 1ms to component construction (components lower in the DOM took longer, perhaps because of the next point?) and added about 15mb to the memory footprint.
If we go this route, my recommendation would be that we only register the polyfill for Shadow DOMs that can contain a focusable element. Then, to work around the above host context selector issue, all focusable custom elements would determine if the polyfill is available on the window, and if so, set an attribute on the host so that we can construct the proper selector:
// Note we will first check for :focus-visible support - if the browser supports
// it natively we don't need to do any of this.
if (window.applyFocusVisiblePolyfill !== null) {
this.setAttribute("data-js-focus-visible", "");
} else {
window.addEventListener('focus-visible-polyfill-ready',
() => this.setAttribute("data-js-focus-visible", ""),
{ once: true });
}
:host(:not([data-js-focus-visible]):focus) {
/* fallback */
}
:host([data-js-focus-visible][data-focus-visible-added]) {
/* focus-visible polyfill styles */
}
:host(:focus-visible) {
/* native focus-visible styles */
}
Pros:
Cons:
I wasn't familiar with :focus-visible so this was a great learning opportunity for me. Thanks @nicholasrice for putting this together!
A few questions:
0.02ms - 1ms per component - again, the components registered last tended to take longer, making me think the cost it is related to the increased memory footprint.
I don't have a good estimate of focusable elements but my intuition is that maximums will be far lower than 2000. I think the most likely case for a large number of focusable elements would be large lists, and generally those lists are virtualized anyway.
We might be able to move the polyfill work to the connectedCallback if that helps - we would want to test scenarios like Dialog that bring focus immediately to inner elements to validate it behaves correctly.
Are there any focusable elements of our library that won't actually be web components, but only CSS?
Potentially - we haven't worked through all the components yet so I can't say for certain, but components like "hypertext" could end up being css classes. That is a good point and does complicate the story a bit - those components would have to define a selector to match both the root and any host shadow elements:
:root:not([data-js-focus-visible]):foucs,
:host(:not([data-js-focus-visible]):focus) {
/* fallback */
}
:root[data-js-focus-visible] .foo[data-focus-visible-added],
:host([data-js-focus-visible][data-focus-visible-added]) {
/* focus-visible polyfill styles */
}
.foo:focus-visible,
:host(:focus-visible) {
/* native focus-visible styles */
}
I think CSS class components is something we'll need to explore further - there are a couple additional complications with them:
For #1, I think we can address that through providing our typography as a JS import that can be composed as well as maybe a helper function that adds it to the body or something like that.
Point #2 is the trickier one.
My initial thought here is to forego the polyfill and use focus-visible with fallback. I'd like to avoid a dependency here if we can, especially given the performance impacts. It also seems like it would add complexity in terms of implementation to try and offset the performance impact. In terms of the cons, I think adoption may come sooner than expected in this case (or I am hopeful). Firefox has shipped with a -moz- prefix, Safari has this in technical preview, Edge and Chrome have this behind a flag (for awhile now) - I'm not opposed to it, but I'd like to avoid the added complexity here if we can.
I'm in agreement with @chrisdholt
I'm inclined to agree also - just a note though that the -moz-focusring doesn't quite work like focus-visible: https://codepen.io/patrickhlauke/pen/WvzGag
I was looking for the flag again and can't seem to find a way to turn it on. It's strange that some of the css4 selectors are implemented and others are not. It seems like no one currently has focus-visible though. Is it possible it's not actually that close to being supported?
I have some concerns about reverting the style though, as in the fallback with negation example. Generally we find it best to construct the style sheets additive, and without a standard way to apply focus state it may be complex to remove.
This leads in to some of the exploration I was doing around consistent focus implementation previously. My goal was to have a single way we could apply the styling, as well as a simple way for consumers to customize the style for brand preference or to match another framework or OS. For instance, both Material and macOS surround the control and / or animate. The only true solution I could come up with required wrapping an element with another focus decorator. Using ::before or ::after doesn't work because many times you want to be outside of the content box.
The goal of the single application was to support outline styled controls because our current implementation uses an inner box-shadow, which messes with an actual shadow and is complicated to animate or get correct width. Also applying high contrast focus state is not simple and straightforward.
All this causes me to lean more toward having a bit more control, potentially with our own polyfill. Perhaps this could be more dynamic and defer the cost if we were wrapping a control only when it needed the focus decorator. Realizing everything adds up, even 1ms doesn't seem like much of a perf concern if we had to go that route.
@bheston by wrapping the control do you mean changing the DOM and rendering an additional piece of DOM to illustrate focus?
The flag would be "Experimental Web Platform Features" I believe for both Edge and Chrome.
@bheston is that work mutually exclusive of how we determine (with CSS) whether the focus indicator should be visible? Presumably that abstraction would use similar levers as those described above to determine when to draw the focus indicator? Sorry if I'm missing your point but to me the implementation details of the focus indicator are distinct from how we determine when it should be visible, whatever it is.
How far along is that exploration you have? This might be something we can stage. We can make a call on whether we want focus-visible polyfill support or not, and then later land improvements to how @microsoft/fast-components handles focus-indicators all-up.
Chatted with @bheston offline - his primary concern with option 1 right now is needing to negate styles. I think we can circumvent this requirement but doing a quick bit of feature detection and setting a "focus" string to either "focus" or "focus-visible" depending on support. That way, implementation will look something like:
:host(:${focusVisible}) { // focus styles }
which will evaluate to :host(:focus) or :host(:focus-visible) depending on feature support.
Nice! If we can keep it all in CSS, I definitely think that's the way to go.
Most helpful comment
My initial thought here is to forego the polyfill and use focus-visible with fallback. I'd like to avoid a dependency here if we can, especially given the performance impacts. It also seems like it would add complexity in terms of implementation to try and offset the performance impact. In terms of the cons, I think adoption may come sooner than expected in this case (or I am hopeful). Firefox has shipped with a -moz- prefix, Safari has this in technical preview, Edge and Chrome have this behind a flag (for awhile now) - I'm not opposed to it, but I'd like to avoid the added complexity here if we can.