Webcomponents: Custom pseudo-classes for host elements via shadow roots (:state)

Created on 19 Feb 2018  Â·  63Comments  Â·  Source: WICG/webcomponents

Some elements like the ones in A-Frame render to WebGL. They are styled with display:none so that DOM rendering is disabled, and the state of the custom elements are used in drawing to a canvas webgl context.

It'd be great if there was a way to define when a custom element has a :hover/:active/etc state so that we can do something like the following with custom elements that render in special ways:

<my-sphere position="30 30 30">
</my-sphere>
<style>
  my-sphere { --radius: 30px }
  my-sphere:hover { --radius: 40px }
</style>

There's currently no way to make this happen (apart from parsing the CSS). Perhaps it'd be great to have an API that makes it easy to define when :hover state is applied to a custom element.

The implementation of the element could then use a ray tracer to detect mouse hover in the WebGL context, then turn on or off the :hover state, allowing the user of the custom elements to easily style certain things like the radius of a sphere.

needs concrete proposal shadow-dom

Most helpful comment

Google Chrome Canary 79.0.3939.0 or later has :state() implementation based on the explainer, behind the experimental flag chrome://flags/#enable-experimental-web-platform-features .

All 63 comments

This is an API request to manually set :hover state on an element?

How's this different from, e.g., changing a class or something like that?

This is an API request to manually set :hover state on an element?

Yes, or something similar. Maybe custom states, similar to your :part idea but not tied to any particular element inside the custom element.

Maybe, for user land, something like :state(some-state), in order to be separated from builtin states.

How's this different from, e.g., changing a class or something like that?

That's changing the outside state that the user should define, whereas this feature would let outside user hook into inside-defined state. I think only outside user should define classes. Not to say it isn't possible to do it that way, but it doesn't feel as clean.

@tabatkins @domenic

I'm pretty sure the idea of a custom state like :state(blah) came up before.

Yeah, we've definitely had discussion about that in the past; I think it kinda got ignored in the larger shuffle of things surrounding Shadow DOM. ^_^

But yeah, it would be really easy to hang a set-like off of ShadowRoot (maybe DOMTokenList? I forget whether the design of that is considered a legacy mistake or not) that just listed state names that the element matches, and add :state() to respond to that.

I think this is a good idea. I'm not sure on the exact design. @TabAtkins suggests putting it on shadow root, and in the past we've coupled some features there (such as custom styles, and in the future custom a11y semantics). To me putting it on custom elements makes the most sense, but I'm not sure on the design. And you could also imagine a design that works on all elements.

Here's some more concrete strawpeople:

Works on all elements

element.states.add("foo");
element.states.add("bar");

element.matches(":state(foo)"); // or maybe ":--foo" or similar

Here element.states is a DOMTokenList as @TabAtkins suggests. Although it's a bit unusual to have a DOMTokenList that isn't connected to a visible content attribute, hrm.

Works on custom elements

customElements.define("x-tag", class XTag extends HTMLElement {
  getStatesCallback() {
    const states = ["foo"];
    if (this._isBar) {
      states.push("bar");
    }
    return states;
  }
});

This seems not great because it'd require calling into getStatesCallback() all the time.

Works on shadow roots

element.shadowRoot.states.add("foo");
element.shadowRoot.states.add("bar");

element.matches(":state(foo)"); // or maybe ":--foo" or similar

I guess in the end this ends up being pretty clean...

Ah yeah, I guess there's no need to hook this on shadow roots; all custom elements could find this useful.

The question, tho, is just how useful this is over just using classes. :state(foo) and .foo look mighty similar - what does this different namespace bring to the table?

It allows your elements to expose internal states to the external world, without interfering with any user-defined classes. I.e. it allows class="" to stay entirely consumer-controlled.

That's valid. Tho if it's on all elements, it can still be fiddled with by consumers. The ShadowRoot version worked well for that; a different form of the custom element one that instead created a token list and passed it to a CE callback (for the CE to stash on its own) would give us the same ability without having to poll anything.

This is precisely why I think this feature only makes sense on shadow root. In the case the element has some states, it's much better to just use classes. The reason you want to expose a state as opposed to modifying classes is that modifying the host element is an anti-pattern / violation of encapsulation when you have a shadow root.

Hm, yeah, that seems like a convincing argument for why this would be tied to "has a shadow root", rather than "is a custom element" or just "is an element" - it's explicitly meant to expose something class-like, but without fiddling with the public API of the element.

In the following example, a shadow root is not required, and the states not modifiable from outside:

// if this feature is out after builtin modules, then something like
import { ElementStates } from ':system'
// otherwise
const { ElementStates } = window

import Privates from './privates-helper'
const _ = new Privates

import glUtils from './glUtils'

class GlSphere extends HTMLElement {

  constructor() {
    super()
    _(this).states = new ElementStates( this ) // hooks into the HTML engine
  }

  connectedCallback() {
    glUtils.whenMouseEnter(this, () => {
      _(this).states.add('hover')

      // ... check for CSS custom properties and update the WebGL scene ...
    })
    glUtils.whenMouseLeave(this, () => {
      _(this).states.remove('hover')

      // ... check for CSS custom properties and update the WebGL scene ...
    })
  }

  disconnectedCallback() {
    _(this).states.destroy() // so `this` can be GC'ed
  }

}

customElements.define('gl-sphere', GlSphere)

This is interesting, because, if the ElementStates were already created, then perhaps the following would happen on the outside:

const el = document.createElement('gl-sphere')
const states = new ElementStates( el ) // DOMException, it was already created for that element (because gl-sphere created it in the constructor)

but

const el = document.createElement('div')
const states = new ElementStates( el )
states.add('foo') // it works

Or, maybe the HTML engine can throw an error if new ElementStates is not called inside a custom element constructor, to force the feature to be a from-the-inside feature only.

I tend to agree with @rniwa on using shadow roots as the extension hook for all things, in order to preserve the encapsulation boundary.

(I do see a small problem here with tying a11y to shadow roots in that we allow attachShadow() on a large number of elements with custom a11y bindings.)

@annevk With respect to AOM, the idea is that using AOM property would, in effect, override the default values of builtin elements, which can then be overridden by ARIA and AOM property on the host element.

That seems fine, but it does mean that builtin elements have a "magic" place for storing such data. Basically for builtin elements for which you can call attachShadow() there's four places: magic internal slot -> shadow tree slot -> element AOM slot -> element ARIA slot. For custom elements there's three places: shadow tree slot -> element AOM slot -> element ARIA slot.

I don't think so. AOM exposed on ShadowRoot should reflect whatever builtin elements' ARIA values are. They're sort of default values of AOM on ShadowRoot.

You could imagine that in the future we can add a mechanism to define the default ARIA role & values on custom elements without attaching a shadow root. Those default values should be "reflected" in default AOM values exposed on ShadowRoot.

I think you're missing something. h1 has a default role of "heading". Where does this role come from? It cannot come from a builtin shadow root, because it doesn't have any and developers can add their own shadow root to it. So the default has to come from a magical place.

No. The default role is associated with the element class itself, not a particular instance of an element. Anyway, this discussion is way tangential to the issue of adding a mechanism to specify a state of an element so let's continue this elsewhere.

@domenic @tabatkins I really like the idea behind element.shadowRoot.states. If nobody is planning or is actively working on that, I can take a first stash at it since this is an important use-case for us.

@caridy : it would be great if you can come up with a concrete proposal for it.

I tend to agree with @rniwa on using shadow roots as the extension hook for all things

The A-Frame elements don't have shadow roots. I don't have a perf test, but seems like adding shadow roots to them all is a fair amount of weight considering how long shadow root prototype chains are, almost like duplicating the number of nodes, right? Plus shadow root cause dividing algorithm, which also adds runtime cost, which is unnecessary for Elements that don't even need it. In a case like A-Frame Elements, the goal is to save all resources for the WebGL rendering performance.

I would suggest testing instead of speculating

There is agreement on :state(...) coupled with an API on ShadowRoot to declare the states (likely DOMTokenList). Matching happens against the host element.

We are also interested in potentially doing a second, parallel API for "private" states (which are only match-able from inside the shadow tree), but we want to see how the public version goes first.

Hm, I haven't heard about that yet. What's the justification for it?

@domenic @tabatkins I think I'm convinced that we can go with public CSS states only. Thinking twice about it, our use-cases for private CSS states lead to one big problem: authors of components will end up (accidentally or not) creating something that can't be customized from outside. We have suffered from that in some standard HTML elements and we should probably avoid that trap in CE.

I'm ready to withdraw our request for private CSS states at this point. I will talk to @diervo today to focus on the public version.

Another idea that avoids using a ShadowRoot, which is based on the idea of callbacks for primitives from https://github.com/w3c/webcomponents/issues/187#issuecomment-388740230:

Re-doing my example from above:

import Privates from './privates-helper'
import glUtils from './glUtils'

const _ = new Privates

class GlSphere extends HTMLElement {

  receiveFeatureKeys(keys) {
    _(this).keys = keys
  }

  connectedCallback() {

    glUtils.whenMouseEnter(this, () => {
      // maybe `this.states` is a `DOMTokenList` or similar that all elements have
      // keys.elementState is unique for this instance, maybe a Symbol
      this.states.add('hover', _(this).keys.elementState)
    })

    glUtils.whenMouseLeave(this, () => {
      this.states.remove('hover', _(this).keys.elementState)
    })

  }

}

customElements.define('gl-sphere', GlSphere)

Adding more ideas in https://github.com/w3c/webcomponents/issues/758

It seems that with the recent development around ElementInternals discussed here https://github.com/w3c/webcomponents/issues/758, the states should be part of that rather than the ShadowRoot instance, which solves most of the contention points from this discussion.

I very much like the idea of a :state() pseudoclass selector for custom elements. I thought it up myself when thinking about the CSS WG discussion about classes for ::part() pseudoelements—only to discover a link to here from there. Of course someone else thought of it first.

But the API I proposed in that thread is different from what's been discussed here: I suggest defining the state (as exposed to the outside) in terms of a selector that you test for a match (any match) inside the shadow tree. That would avoid jumping down into mouseEnter and mouseLeave event handlers to recreate something like a :hover state.

Full comment: https://github.com/w3c/csswg-drafts/issues/3431#issuecomment-459995872

Form-associated custom elements discussed in https://github.com/w3c/webcomponents/issues/187 doesn't provide a way to match an element with form-related built-in pseudo classes (*1). I'd like to extend the API for them.

Assume that the current API is like:

this.#internals = this.attachInternals();
this.#internals.states.add('foo');  // this element matches to custom state 'foo'

I'd like to extend states to accept the built-in pseudo classes.

this.#internals.states.add(':checked');  // this element matches to :checked.

*1) :checked, :indeterminate, :default, :in-range, :out-of-range, :required, :optional, :read-only, :read-write

@tkent-google so that means it's up to the JS implementation to maintain the invariants between them? E.g., you could create an element that matches both :optional and :required? I suppose that might be okay.

It's up to the JS implementation to maintain the invariants between them?

Right.
It's much simpler than providing API for each of pseudo classes.

The main thing is that if we did an API on the primitives the pseudo-classes build on, that would at the same time also help AT and potentially other consumers of such data. It would be much more involved though and it's not immediately clear to me how realistic that would be.

My intention is that the API just affects selector matching, and we'll have another API to change accessibility states, like this.

this.#internals.stats.add(':checked');
this.#internals.ariaChecked = 'true';

I expect someone develops a higher-level library on top of these primitive APIs.

Yeah, it's worth noting that the AT states and pseudo-classes are not coupled today, in that you can set aria-checked="true" and that does not make :checked start matching. So keeping them as separate primitives makes sense to me.

The way built-in form controls work, there is an internal state that is exposed to AT which is separate from the ARIA state (and also separate from the default state which is set by the "checked" attribute). The internal state affects CSS pseudo-class matching, submission, and what is exposed to AT (unless ARIA has been used as an override). It would be nice if built-in controls had a single notion of state, or at least combined as many as possible, with specific overrides when/if needed. Otherwise, there's a risk that the states get out of sync or that one is entirely forgotten.

Do you foresee events being emitted whenever a custom state is changed?

F2F tentative conclusion: use ElementInternals, provided @rniwa is convinced by an example @JanMiksovsky will provide.

I've posted hypothetical code for a carousel component that uses both custom parts and custom pseudo-classes set via element internals: https://gist.github.com/JanMiksovsky/79a4868e48f554e3a147c578e97e6d42.

As mentioned in today's discussion, this is based on a representative real-world use case that has come up in many of our components.

@tkent-google the other thing that came out of the F2F that I forgot to mention is that it'd be great to split out the built-in pseudo-classes proposal into a new issue as a v2 feature that can be discussed on its own.

@JanMiksovsky is the idea in that example that the dot elements are so simple that they don't need Shadow DOM to implement their rendering, but do need states? And therefore we need a way to define states independent of Shadow DOM?

The example as written will result in the dots not rendering at all (since they default to display:inline and have no content or other styling that would force them to render anything) so it's hard to evaluate the example. I'd expect a dot to do something like contain a bullet character, or be a block (or inline block?) that renders as a circle through use of border radius. And presumably you'd want dots to know how to space themselves properly, and perhaps to have a configurable size. With more of that filled in, I'm not sure it would still seem like a good idea for them to have no Shadow DOM.

The expectation — but not requirement — would be that elements exposing pseudo-classes would often have a shadow root. I tried to keep the example as short as possible to focus on the aspects related to custom parts and pseudo-classes, and tried to reflect that with the comment in DotElement:

// Not shown: Creation and population of shadow root...

The example is a simplification of our Carousel component, whose dots indeed have a shadow root. (As you guess, it has a div that renders as a circle through border-radius.)

When I have a moment, I'll try to revise the gist to show the use of a shadow root.

The reason this is mildly important: a past consensus was to expose this on ShadowRoot. This would be a bit more consistent with ::part. The main reason to expose it on ElementInternals instead would be if we believe it's useful to have custom states without having a Shadow DOM. I guess you could imagine custom elements that use the light DOM for their internals, but we felt no need to make ::part available in that case. Going the other way, is :state useful if you use Shadow DOM without custom elements? Doesn't seem intuitively obvious that ::part would be a Shadow DOM thing but :state would be a Custom Elements thing.

Hmm, it seems clear that ::part is intimately tied to shadow DOM, as it's about exposing elements that are inside your shadow tree. Whereas :state does not rely on the shadow tree concepts at all.

We need to decide this based on use cases instead of rather subjective idea of how things should be organized conceptually.

During F2F, someone did raise a use case of such a state on an element with a shadow root that is not a custom element. I think the question really here is whether it's more common scenario to use this state with a custom element or with a shadow tree.

It seems that adding this capability to ShadowRoot is safer option of the two in the sense that if someone writing a custom element wanted to use a state, they can always attach a trivial shadow root which consists of a single slot element whereas a someone attaching a shadow root on a builtin element instead of a custom element doesn't have any workaround available to them.

I think that if we had designed ElementInternals first, we would have put attachShadow() there and not made it available on 18 built-in elements as well.

I also don't see the consistency argument with ::part() so much. There's no API on ShadowRoot for that. And ::part() makes certain encapsulated bits of the host accessible, whereas :state() reflects the state of the host and has no real relation with the encapsulated bits.

If there are use cases for using :state() on those 18 built-in elements, we should really figure out to what extent that goes for the other APIs we are putting on ElementInternals. For ARIA we identified conflicts (e.g., h1 has implied role=heading). For :state() v2 from @tkent-google above it seems likely we might get conflicts as well (e.g., with the proposed :heading(n)).

Possible tangent: The :heading(n) pseudo-class is not a state in this sense (unlike :hover, :active, :checked and things like that.) While some CSS pseudo-classes represent internal states, others, such as :nth-child, :matches and :empty don't represent internal states, but rather express conditions about the structure of the DOM. :heading(n) seems like it belongs more in the latter category.

Similarly, not all pseudo-elements are parts, so for example ::part should not be extended to cover cover ::selection even if it addressed other kinds of built-in parts in a future extension.

@tkent-google the other thing that came out of the F2F that I forgot to mention is that it'd be great to split out the built-in pseudo-classes proposal into a new issue as a v2 feature that can be discussed on its own.

Ok, I filed https://github.com/w3c/webcomponents/issues/813

I made a proposal explainer for the API here: https://github.com/w3c/webcomponents/pull/832, PTAL if interested.

@rakina Thanks for writing that proposal. It looks like it should meet our needs.

One question: it's worth considering the parallelism of 1) setting of custom states and built-in pseudo-classes and 2) the application of styles to custom states and pseudo-classes. Above @tkent-google suggests using a colon in the parameter passed to states.add, as in states.add(':checked'). That would give the following matrix:

  • Set custom state: this.#internals.states.add('foo')
  • Set built-in pseudo-class: this.#internals.states.add(':checked')
  • Use custom state: my-element:state(foo)
  • Use built-in pseudo-class: my-element:checked

The above feels a little rough to me. A minor issue is that it feels odd to have a micro-syntax for the parameter to the states.add method. But a bigger issue is that the API call for setting a custom state or a built-in pseudo-class is essentially the same — but the CSS for referencing those two things are completely different. Moreover, as @othermaciej observes, "While some CSS pseudo-classes represent internal states, others, such as :nth-child, :matches and :empty don't represent internal states, but rather express conditions about the structure of the DOM."

I wonder if it'd be cleaner to try to use the term "state" in the API to always refer to custom state, and "pseudo-class" to always refer to built-in pseudo-classes:

  • Set custom state: this.#internals.states.add('foo')
  • Set built-in pseudo-class: this.#internals.pseudoClasses.add('checked') (note: no colon)
  • Use custom state: my-element:state(foo)
  • Use built-in pseudo-class: my-element:checked

This keeps custom state and built-in pseudo-classes separate.

In documentation, pages like Pseudo-classes would continue to consistently talk about pseudo-classes as a built-in feature. New documentation would then talk about state as a different thing: a custom feature of web components.

This makes it possible to more easily document things like the :state pseudo-class itself — which becomes the one bridge between the two concepts. E.g., "The :state CSS pseudo-class selector represents any custom element which currently has the indicated custom state applied." Writing such documentation would likely be harder if the concepts of state and pseudo-class are blurred.

Is this the place for end user feedback? Or more like here?

In any case, adding this here from my tweet:

It would be cool to be able to save more complex data structures than just a simple present / not present. For example:

string key, string value

I think there would be a lot of use cases for apis like this:

/* Separate state "variable" for mode of component */
my-element::state(mode="collapsed") { ... }
my-element::state(mode="preview") { ... }
my-element::state(mode="expanded") { ... }

/* Separate state "variable" for preview source */
my-element::state(preview="item-only") { ... }
my-element::state(preview="related") { ... }

other data structures?

The use cases here are more questionable. More just spitballing what use cases could exist...

my-parent::state(dependent=my-deeply-nested-child) { ... }

For now, at least, you can do any ident="string" use-case by just folding them together into ident-string. Without more powerful matching facilities a la attribute selectors, there's no benefit to having the two halves be separate over having them mushed together.

(We can think about having more powerful matchers in v2, of course. But for the MVP I don't think we need them.)

Google Chrome Canary 79.0.3939.0 or later has :state() implementation based on the explainer, behind the experimental flag chrome://flags/#enable-experimental-web-platform-features .

Question: does anyone foresee use cases where a :state() would be applied to non-custom elements? E.g. a hypothetical x-accordion component where the :state could indicate the "expanded" state of a section:

<x-accordion>
  <section>...</section>     
  <section>...</section>     <-- :state(expanded)
</x-accordion>

@dvoytenko absolutely, especially in combination with ::part().

A custom element may very well want to make public a part that itself has state, and the part may not be a custom element itself. The natural way to do this would be to have the custom element set the state on the part. I would like that feature, and it's been discussed, but I would rather have basic custom state support sooner.

The workaround is to make a custom element just for to have custom state settable from the outside, maybe a <div-with-state> element.

@justinfagnani that's good. I'm asking because it seems unlikely that the whole ElementInternals would ever be exposed on a non-custom element. As the first step this seems very reasonable however.

We made a specification-look document in WICG; https://wicg.github.io/custom-state-pseudo-class/
Do you have any comments?

If this doesn't have any significant issues, I'd like to try to ship this in Google Chrome.

@tkent-google Looks like @WebReflection has some comments over in the other thread, just in case you're not following over there:

https://github.com/w3ctag/design-reviews/issues/428#issuecomment-565408602

how can we vote for this proposal? this seems like a very semantical improvement of the salad that gets created with attributes/classes within web components

Hi all,

CSSWG discussed this feature, and raised the following issue:

Switch syntax from :state(foo) to :--foo

I think the change is reasonable. Please add comments to the above issue if you have feedbacks.

I'm not a fan of the proposed change. I left a comment. https://github.com/WICG/custom-state-pseudo-class/issues/6#issuecomment-595584830

I really like the idea of "attaching internal" APIs to expose internal features.

I think that

#internals = this.attachInternals()
//...
this.#internals.pseudoClasses.add('hover')

would work perfectly for the scenario in the OP (which uses zero shadow roots).

I have some questions and thoughts:

1) Why another new property on all elements as opposed to separate classes (or something)?

One concern I have is that this adds yet one more property all elements now have. I like the separate-class idea (new ElementInternals(this)), because it doesn't add yet more properties to the already giant list that all elements inherit. The example using a separate class would be:

#internals = new ElementInternals(this)

With separate-classes, we could also split features to individual classes, making the opt-in clear:

#cssSates = new ElementCSSStates(this) // Note the "CSS" to make the feature name clear, bikesheddable
#pseudoClasses = new ElementPseudoClasses(this)

//...

this.#cssStates.add("foo")
this.#pseudoClasses.add("hover")

Is there a reason why all elements having an attachInternals property is clearly better than the separate-class idea?

The only one thing I can really think of is that this.attachInternals(). in VS Code intellisense will show possible completions for available APIs. But good documentation will do too.

2) Can certain performance characteristics be expressed in the spec?

I hope #internals = this.attachInternals(); does not instantiate all possible internal APIs, and that this.#internals.pseudoClasses is a getter that instantiates that particular pseudoClasses API only on first read.

Is optimization like this supposed to be expressed in the spec? Or do we just leave it to browsers to implement such optimizations if they desire?

3) If we have custom pseudo classes, what would happen when users try to add things like :empty?

What happens if a CE author tries to do

#internals = this.attachInternals();
//...
this.#internals.pseudoClasses.add('empty')
this.#internals.pseudoClasses.add('last-child')

or similar with others?

Maybe we need to somehow make a clear distinction between interaction states and the others (which merely describe the DOM)

If we limit the new surface to only custom :state(), then it will be a little strange to see :hover in some parts of styles for built-in elements, and :state(hover) for other elements like in the OP that render purely with WebGL where the CSS engine can not possibly know when to apply :hover (there's only a single canvas element that the mouse is interacting with).

4) keeping state only as custom element internals

@justinfagnani, you mentioned

A custom element may very well want to make public a part that itself has state, and the part may not be a custom element itself.

If this is the case, then classes would work perfectly here because the public surface area of class names on the internal part is still private to the custom element.

The custom element only needs to expose the part, and set classes on it (which the end user will not touch), and the end user will style it like this:

some-element::part(foo).expanded

This works great, because the custom element is using the public API internally on the internal shadow DOM, just as classes were designed for, then making that element stylable as a part.

Therefore I hope this convinces people that we do not need state as a public-side feature, because classes cover this case where custom elements want to denote the state internal non-custom elements are in.

Or is there a benefit of using :state() that I missed (other than aesthetics)?

Was this page helpful?
0 / 5 - 0 ratings