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

Closed
trusktr opened this issue Feb 19, 2018 · 65 comments
Closed

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

trusktr opened this issue Feb 19, 2018 · 65 comments

Comments

@trusktr
Copy link
Contributor

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
Copy link
Collaborator

rniwa commented Feb 20, 2018

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

@emilio
Copy link

emilio commented Feb 20, 2018

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

@trusktr
Copy link
Contributor Author

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
Copy link
Collaborator

rniwa commented Feb 21, 2018

@tabatkins @domenic

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

@tabatkins
Copy link

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
Copy link
Collaborator

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
Copy link

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
Copy link
Collaborator

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
Copy link

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
Copy link
Collaborator

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
Copy link

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
Copy link
Contributor Author

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
Copy link
Contributor Author

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
Copy link
Collaborator

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
Copy link
Collaborator

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
Copy link
Collaborator

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
Copy link
Collaborator

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
Copy link
Collaborator

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
Copy link
Collaborator

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
Copy link

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
Copy link
Collaborator

rniwa commented Feb 23, 2018

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

@trusktr
Copy link
Contributor Author

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
Copy link
Collaborator

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
Copy link
Contributor

@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
Copy link
Member

rakina commented Sep 2, 2019

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

@JanMiksovsky
Copy link

@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
Copy link

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
Copy link

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
Copy link
Contributor

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
Copy link

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
Copy link
Contributor

@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
Copy link

@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
Copy link
Contributor

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
Copy link

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

@yinonov
Copy link

yinonov commented Jan 27, 2020

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

@tkent-google
Copy link
Contributor

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.

@othermaciej
Copy link

I'm not a fan of the proposed change. I left a comment. WICG/custom-state-pseudo-class#6 (comment)

@trusktr
Copy link
Contributor Author

trusktr commented Apr 27, 2020

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

pull bot pushed a commit to Alan-love/chromium that referenced this issue Feb 2, 2021
I2S: https://t.co/1I7pjWEm8X
Spec: https://wicg.github.io/custom-state-pseudo-class/

Bug: 1012098
Bug: WICG/webcomponents#738
Change-Id: I259d3252428e0390084650a6647dfc8c2a87a850
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2661438
Commit-Queue: Mason Freed <masonfreed@chromium.org>
Auto-Submit: Kent Tamura <tkent@chromium.org>
Reviewed-by: Mason Freed <masonfreed@chromium.org>
Cr-Commit-Position: refs/heads/master@{#849245}
mjfroman pushed a commit to mjfroman/moz-libwebrtc-third-party that referenced this issue Oct 14, 2022
I2S: https://t.co/1I7pjWEm8X
Spec: https://wicg.github.io/custom-state-pseudo-class/

Bug: 1012098
Bug: WICG/webcomponents#738
Change-Id: I259d3252428e0390084650a6647dfc8c2a87a850
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/2661438
Commit-Queue: Mason Freed <masonfreed@chromium.org>
Auto-Submit: Kent Tamura <tkent@chromium.org>
Reviewed-by: Mason Freed <masonfreed@chromium.org>
Cr-Commit-Position: refs/heads/master@{#849245}
GitOrigin-RevId: 78fbd97dbf148a448be60f038701037ac6cb9151
@annevk
Copy link
Collaborator

annevk commented Nov 18, 2022

@Westbrook
Copy link
Collaborator

https://developer.mozilla.org/en-US/docs/Web/CSS/:state is now Baseline, closing. 🥳

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests