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

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

Open
trusktr opened this issue Feb 19, 2018 · 59 comments
Open

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

trusktr opened this issue Feb 19, 2018 · 59 comments

Comments

@trusktr
Copy link

@trusktr trusktr commented Feb 19, 2018

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.

@rniwa

This comment has been minimized.

Copy link
Contributor

@rniwa rniwa commented Feb 20, 2018

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

@emilio

This comment has been minimized.

Copy link

@emilio emilio commented Feb 20, 2018

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

@trusktr

This comment has been minimized.

Copy link
Author

@trusktr trusktr commented Feb 20, 2018

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.

@rniwa

This comment has been minimized.

Copy link
Contributor

@rniwa rniwa commented Feb 21, 2018

@tabatkins @domenic

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

@tabatkins

This comment has been minimized.

Copy link
Member

@tabatkins tabatkins commented Feb 21, 2018

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.

@domenic

This comment has been minimized.

Copy link
Contributor

@domenic domenic commented Feb 21, 2018

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...

@tabatkins

This comment has been minimized.

Copy link
Member

@tabatkins tabatkins commented Feb 21, 2018

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?

@domenic

This comment has been minimized.

Copy link
Contributor

@domenic domenic commented Feb 21, 2018

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.

@tabatkins

This comment has been minimized.

Copy link
Member

@tabatkins tabatkins commented Feb 21, 2018

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.

@rniwa

This comment has been minimized.

Copy link
Contributor

@rniwa rniwa commented Feb 21, 2018

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.

@tabatkins

This comment has been minimized.

Copy link
Member

@tabatkins tabatkins commented Feb 21, 2018

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.

@trusktr

This comment has been minimized.

Copy link
Author

@trusktr trusktr commented Feb 22, 2018

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)
@trusktr

This comment has been minimized.

Copy link
Author

@trusktr trusktr commented Feb 22, 2018

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.

@annevk

This comment has been minimized.

Copy link
Member

@annevk annevk commented Feb 22, 2018

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.)

@rniwa

This comment has been minimized.

Copy link
Contributor

@rniwa rniwa commented Feb 22, 2018

@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.

@annevk

This comment has been minimized.

Copy link
Member

@annevk annevk commented Feb 23, 2018

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.

@rniwa

This comment has been minimized.

Copy link
Contributor

@rniwa rniwa commented Feb 23, 2018

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.

@annevk

This comment has been minimized.

Copy link
Member

@annevk annevk commented Feb 23, 2018

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.

@rniwa

This comment has been minimized.

Copy link
Contributor

@rniwa rniwa commented Feb 23, 2018

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.

@caridy

This comment has been minimized.

Copy link

@caridy caridy commented Feb 23, 2018

@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.

@rniwa

This comment has been minimized.

Copy link
Contributor

@rniwa rniwa commented Feb 23, 2018

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

@trusktr

This comment has been minimized.

Copy link
Author

@trusktr trusktr commented Feb 24, 2018

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.

@domenic

This comment has been minimized.

Copy link
Contributor

@domenic domenic commented Feb 24, 2018

I would suggest testing instead of speculating

@annevk annevk changed the title A way to implement :hover, :active, etc, for custom elements. Custom pseudo-classes for host elements via shadow roots (:state) Mar 5, 2018
@tkent-google

This comment has been minimized.

Copy link
Contributor

@tkent-google tkent-google commented Apr 18, 2019

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.

@annevk

This comment has been minimized.

Copy link
Member

@annevk annevk commented Apr 18, 2019

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.

@tkent-google

This comment has been minimized.

Copy link
Contributor

@tkent-google tkent-google commented Apr 19, 2019

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.

@domenic

This comment has been minimized.

Copy link
Contributor

@domenic domenic commented Apr 19, 2019

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.

@othermaciej

This comment has been minimized.

Copy link
Member

@othermaciej othermaciej commented Apr 19, 2019

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.

@dvoytenko

This comment has been minimized.

Copy link

@dvoytenko dvoytenko commented Apr 22, 2019

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

@annevk

This comment has been minimized.

Copy link
Member

@annevk annevk commented Apr 25, 2019

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

@JanMiksovsky

This comment has been minimized.

Copy link

@JanMiksovsky JanMiksovsky commented Apr 26, 2019

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.

@annevk

This comment has been minimized.

Copy link
Member

@annevk annevk commented Apr 29, 2019

@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.

@othermaciej

This comment has been minimized.

Copy link
Member

@othermaciej othermaciej commented Apr 30, 2019

@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.

@JanMiksovsky

This comment has been minimized.

Copy link

@JanMiksovsky JanMiksovsky commented Apr 30, 2019

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.

@othermaciej

This comment has been minimized.

Copy link
Member

@othermaciej othermaciej commented Apr 30, 2019

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.

@domenic

This comment has been minimized.

Copy link
Contributor

@domenic domenic commented Apr 30, 2019

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.

@rniwa

This comment has been minimized.

Copy link
Contributor

@rniwa rniwa commented Apr 30, 2019

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.

@annevk

This comment has been minimized.

Copy link
Member

@annevk annevk commented May 1, 2019

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)).

@othermaciej

This comment has been minimized.

Copy link
Member

@othermaciej othermaciej commented May 1, 2019

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

This comment has been minimized.

Copy link
Contributor

@tkent-google tkent-google commented May 10, 2019

@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 #813

@rakina

This comment has been minimized.

Copy link
Contributor

@rakina rakina commented Sep 2, 2019

I made a proposal explainer for the API here: #832, PTAL if interested.

@JanMiksovsky

This comment has been minimized.

Copy link

@JanMiksovsky JanMiksovsky commented Sep 12, 2019

@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.

@karlhorky

This comment has been minimized.

Copy link

@karlhorky karlhorky commented Oct 8, 2019

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) { ... }
@tabatkins

This comment has been minimized.

Copy link
Member

@tabatkins tabatkins commented Oct 8, 2019

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.)

@tkent-google

This comment has been minimized.

Copy link
Contributor

@tkent-google tkent-google commented Oct 11, 2019

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 .

@dvoytenko

This comment has been minimized.

Copy link

@dvoytenko dvoytenko commented Oct 11, 2019

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>
@justinfagnani

This comment has been minimized.

Copy link

@justinfagnani justinfagnani commented Oct 11, 2019

@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.

@dvoytenko

This comment has been minimized.

Copy link

@dvoytenko dvoytenko commented Oct 11, 2019

@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.

@tkent-google

This comment has been minimized.

Copy link
Contributor

@tkent-google tkent-google commented Dec 13, 2019

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.

@karlhorky

This comment has been minimized.

Copy link

@karlhorky karlhorky commented Dec 13, 2019

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

w3ctag/design-reviews#428 (comment)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
You can’t perform that action at this time.