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

[css-shadow-parts-1] Can class name selectors apply to a part? #3431

Open
JanMiksovsky opened this issue Dec 11, 2018 · 30 comments
Open

[css-shadow-parts-1] Can class name selectors apply to a part? #3431

JanMiksovsky opened this issue Dec 11, 2018 · 30 comments

Comments

@JanMiksovsky
Copy link

We're experimenting with the shadow part support in Chrome (behind the flag), and have a number of situations where we'd like to conditionally style parts based on the state of the relevant part.

E.g., we have a Tabs component, and would like to expose the tab buttons as parts for styling. Significantly, the selected state of the tab button is relevant to styling, i.e., a selected tab button should look different than an unselected tab button.

The selected state of the tab button is not reflected in any existing pseudo-class. (While it'd be theoretically possible to reimplement the tab buttons as check boxes to expose the :checked pseudo-class, that's an extremely awkward approach just to get some styling.)

Short of having some way for components to expose custom pseudo-classes, we were wondering if it'd be possible to access classes on a part: e.g.,

my-tabs-component::part(tab-button) {
  color: black;
}

my-tabs-component::part(tab-button).selected {
  color: red;
}

Here the component author communicates part state to the outside world by applying CSS classes to the parts.

It's not explicitly indicated in https://drafts.csswg.org/css-shadow-parts/#part, but our experiments in Chrome at least suggest that the above is not supported. That places hard limits on how customizable we can make our components, even when ::part is widely supported.

It doesn't seem like supporting class selectors on a part leaks much critical information or would produce brittle components. If the component author needs to refactor their component's shadow tree — e.g., to replace one type of shadow element with a different type of element — it'd be safe for them to support the same class names on the new element.

@tabatkins
Copy link
Member

No, this isn't possible, by design.

The point of ::part is to hide the internal details of the component, and expose only exactly the parts the component author explicitly wants to. Random classes are an internal detail; there's no telling what use the component author has for them, or whether they're appropriate for outside code to use.

One can give a single element multiple part names, and differentiate state based on that. I also thought that you could do, say, ::part(one two) to select only elements with both part names, but it turns out you can't; that's probably something we want to add, so you can more easily add a "selected" part name in your example, and then use ::part(tab-button selected).

@fergald , what do you think?

@JanMiksovsky
Copy link
Author

@tabatkins Thanks for your thoughts! We care less about the precise mechanism and more about the scenario.

My initial thought is that your multiple-parts solution would probably be sufficient to support styling our tabs component and a number of similar components.

I do wonder if we'll hit cases where part state is more complex than a binary value, or multiple state values are in place. Another approach would give the author a way to expose only those part aspects that should be considered available for styling purposes. Using attributes as an example, something like

  <tab-button part="tab-button" part-attributes="selected" selected>Tab 1</tab-button>

might explicitly open up the component-specific selected attribute for styling:

my-tabs-component::part(tab-button)[selected] { ... }

We'll continue exploring the application of shadow parts to our library and look for cases were the current spec appears to be insufficient.

@fergald
Copy link
Contributor

fergald commented Dec 12, 2018

@tabatkins Implementaiton-wise, supporting multiple part names ANDed together seems easy. Adding a separate part-attributes would be considerably more complicated. Does attributes add any extra capabilities that are impossible with ::part(foo bar) or does it just add some namespacing?

@JanMiksovsky
Copy link
Author

@fergald I'd suggested attributes so that someone could target specific values for non-binary attributes via attribute selectors: e.g., ::part(foo)[attr="value"].

I can certainly understand why multiple part names would be simpler. I'll do a preliminary review of our components looking for cases where that might not be sufficient.

@fergald
Copy link
Contributor

fergald commented Dec 12, 2018

I think there would be ergonomic advantages to [attr="value"] if you were able to set attributes indiviually as you can on a HTML element but if you're setting them inside part-attribute="attr=value" then there's very little win on top of just doing part="attr=value" which gives you a part named attr=value.

We also need to think about forwarding from deeper shadow-trees. Would we need a way to forward and rename attributes? If they're just part names we already have that ability (although we would want wildcard forwarding to be able to forward attr=value, see #3422).

@JanMiksovsky
Copy link
Author

I've done an initial review of our Elix component library to look for situations where someone might want to style a part based on component state or part state. These components, roughly 60 in number, are intentionally generic in appearance so that they can eventually be styled to match an app's visual aesthetic or branding, so they seem like a reasonable corpus of use cases for shadow parts.

Initial findings

  • 26 components have parts with a binary part state, like the selected example in the TabStrip example mentioned above.
  • 21 components have parts with state reflected by a string that can take one of a limited set of values; i.e., an enum. Example: the MenuButton component has a horizontalAlign property that governs the alignment of the popup menu with respect to the button that invokes the menu. This horizontalAlign property takes one of 5 enumerated string values: "end", "left", "right", "start", or "stretch". If a MenuButton is exposed as a part, the designer may want to conditionally apply styling to that part based on that menu's horizontal alignment.
  • 6 components have 2 or more parts that each expose part state, in some cases the same kind of state but with different visual semantics. E.g., a stock Carousel component has two subcomponents: 1) a sliding panel showing images or other content, and 2) a row of dots indicating which item is shown. Each subcomponent has elements it will want to expose as parts, and the outer Carousel will want to forward those parts to the outside world to make them available for styling. Significantly, both subcomponents support a selected state, but the visual semantics of selection differ: for the panel showing images/etc., the selected state governs visibility; for the dots, the selected state governs brightness or some other means of visually indicating selection.

So out of our 60 components, it looks like we'll hit this issue in a substantial portion of them.

I think allowing multiple part names will cover the scenarios I can envision so far. That said, for the 21 cases described above where state is essentially an enum, it'll get cumbersome to define part names for each enum value. In the horizontalAlign property, there are 5 possible string values, so we'll need to generate one of 5 part names to tack onto the part: horizontal-align-end, horizontal-align-left, horizontal-align-right, etc.

Part attributes

As I think through using multiple part names to indicate part state, I'm becoming worried that it'll get quite complex.

The problem is that a MenuButton knows what values of horizontalAlign are valid. A component using a MenuButton instance in its shadow does not — but it's that outer component that will need to stick a part like horizontal-align-left on the MenuButton instance. That means the outer component needs to track the current value of the menu button's horizontalAlign property ("left", etc.), munge it to some part variation (horizontal-align-left, etc.), and manage that on the part. That's non-trivial JS that needs to be written for any parts with styling that might depend on component or part state. It could be made to work, but will be cumbersome.

The reason I suggested considering something involving attributes is that native HTML elements already reflect their inner state as attributes for styling purposes. It'd be reasonable to ask component authors to do the same for those properties that might be implicated in styling.

We can easily extend our MenuButton component to reflect a horizontal-align="left" attribute on itself when the underlying horizontalAlign property changes. If the MenuButton is being using directly in light DOM, then style rules can be written to target that attribute value. If the MenuButton is being used as a part, the page author can write style rules that target the part name and the attribute value. That is, styling something directly or as a part would be the same.

I think there would be ergonomic advantages to [attr="value"] if you were able to set attributes indiviually as you can on a HTML element but if you're setting them inside part-attribute="attr=value" then there's very little win on top of just doing part="attr=value" which gives you a part named attr=value.

My suggestion was not to let someone set attributes inside part-attribute. Rather, the part-attribute would give a, say, space-delimited list of part attributes that the component author wants to expose to the outside.

Example: Suppose a MenuButton has two properties/attributes that might be interesting to people styling the component: horizontalAlign and opened. Someone using the menu button as a part could write:

<menu-button part="menu-button" part-attributes="horizontal-align opened">

Or, if they only want to let someone style the "opened" attribute on the part, they write part-attributes="opened". That is, part-attributes means something like, "attributes someone styling this part might want to target". Another name might be styleable-attributes.

The MenuButton instance then reflects the current state of the horizontalAlign and opened properties as attributes. Someone using a menu button in light DOM can write:

menu-button[horizontal-align="left"] { ... }

And someone targeting a MenuButton as a part would write:

my-component::part(menu-button)[horizontal-align="left"] { ... }

That is, it's essentially the same syntax.

  • It's also the same work for the person writing MenuButton, who knows the component best anyway.
  • This is consistent with how someone writes CSS targeting native element states via attributes.
  • People using ::part() could potentially use the full range of attribute selectors ([attr~=value], etc.), not just existence/non-existence.
  • It's less work for the person using MenuButton as a part. That person just needs to know whether they want to let someone write styles like the above example, and they can capture their decision in markup. They don't need to think about values of horizontal-align or add/remove part values dynamically in JS.

We also need to think about forwarding from deeper shadow-trees. Would we need a way to forward and rename attributes?

FWIW, I believe that, if the person exposing a part deems it reasonable to expose an attribute on that part for styling, that other component authors further up the chain would probably be fine leaving such flexibility in place as they forward the part. It's zero additional work for them. That is, I don't think someone forwarding a part will need to narrow the set of attributes available for styling.

Apologies if the examples are abstruse. The number 1 request we get from potential users of our library is whether they can style the components in CSS, so we're interested in making this easy for those users. If it'd be helpful to talk through any of this, I'll make myself available for a chat.

@fergald
Copy link
Contributor

fergald commented Dec 13, 2018

Thanks for all that info. The part-attributes stuff makes more sense as you've clarified it. I have some concerns about it exposing more implementation details than is desirable, e.g. if I write a component and it contains some sub-component and I export some of that sub-components parts, then I have no control over the exported attribute names and values, they may be inconsistent with the names and values I use in the parent component and if I swap out that sub-component for an entirely different implementation, they may have an entirely different set of names of values. So there would be no way to ensure a stable API given different implementations (whereas part renaming does give us that even when exposing parts that are not controlled by the parent component's author).

All that said, perfect is the enemy of good. The value of being able to do this at all seems high and it also seems unlikely that we would ever go as far as adding something to allow transforming attribute names and values to allow a stable API to be presented in cases like these.

The problem is that a MenuButton knows what values of horizontalAlign are valid. A component using a MenuButton instance in its shadow does not — but it's that outer component that will need to stick a part like horizontal-align-left on the MenuButton instance. That means the outer component needs to track the current value of the menu button's horizontalAlign property ("left", etc.), munge it to some part variation (horizontal-align-left, etc.), and manage that on the part. That's non-trivial JS that needs to be written for any parts with styling that might depend on component or part state. It could be made to work, but will be cumbersome.

I'm not sure I follow this example. The containing component would not put a part= on the MenuButton, it would put exportparts=. E.g.

<style>.bar::part(button-foo) { ... }</style>
<custom-elem class="bar">
  # shadow
  <menu-button exportparts="foo: button-foo">
  # shadow
    <div part="foo">...</div>
  </menu-button>
</custom-elem>

If we added wildcard exports then this would change to be

...
  <menu-button exportparts="foo: button-foo attr-*">
...

only the inner component would have to worry about tracking state and munging things to look like part="foo attr-horizontal-align=left".

It's still hacky. I think part-attributes= makes a lot of sense but I'm also interested in how far we can get without it, since we still haven't got the basic part= and exportparts= enabled in any browser yet, so adding this seems a long way off.

@caridy
Copy link

caridy commented Dec 18, 2018

I always thought that this will be possible only via CSS tokens/states (WICG/webcomponents#738), where the possible states of your component are part of the public API of it, and using them in conjunction with parts seems to be enough for such styling, e.g.:

my-tab-component::part(tab-button) {
  color: black;
}

my-tab-component:state(selected)::part(tab-button) {
  color: red;
}

@tabatkins @domenic can you confirm that this compounding will be possible?

@JanMiksovsky this, of course, is not equivalent to what you're asking because it is not really the state of the part, but the state of the component that exposes the part, but I think it will cover a lot of ground. Also, consider that we haven't really work on the css state proposal, we need a formal proposal first.

@JanMiksovsky
Copy link
Author

@caridy Yes, this would be helped by representing component state via custom pseudo-classes. I referenced those in the original post, but I wasn't sure whether those were on a track to happen sooner than basic ::part support.

Note also that I'm talking about letting parts, not just overall components, that have state. In the tabs example, the overall tabs doesn't have the selected state, the individual tab buttons have a selected state. In other words, rather than something like:

my-tab-component:state(selected)::part(tab-button) {}

I'm more concerned about supporting something like

my-tab-component::part(tab-button):state(selected) {}

where the state applies to the part instead of the component.

The containing component would not put a part= on the MenuButton, it would put exportparts=.

@fergald You're correct, thank you for catching that.

I thought it might also be interesting to share some further experimentation here. We've created a working styled variation of our Elix Tabs component that uses CSS parts.

The second demo is the same tabs component as the first, styled using CSS parts. (The only exception is the crossfade effect on the pages, which we achieve by other means not relevant to this discussion.)

We achieved this style tabs demo through a riff on @tabatkins's idea to use multiple parts. Here, we apply one part name ("proxy") to unselected tabs, and a different part name ("proxy-selected") to the selected tab. (Apologies for the ambiguous "proxy" term instead of "tab button" in the demo CSS, but the base class for our Tabs component is actually an extremely general component called Explorer that is used for much more than just tabs.) Using different part names like that is obviously cumbersome, but works today, and gives us a glimpse of what we'd like to let our component customers achieve through CSS parts.

Although there's still a long road ahead, we're quite excited by being able to do this at all. The stock Tabs (elix-tabs) element looks mundane but provides a large number of features that may not be obvious: keyboard support (via arrow keys while a tab button is selected), ARIA, resilience if tabs are added/removed at runtime, etc. It will be fantastic if someone can receive all those benefits and still get the visual aesthetic they want through CSS.

That said, we remain convinced that some form of per-part styling will be necessary to let customers style our components as they would like. That could take the form of:

  1. Support for multiple parts as @tabatkins suggested above.
  2. Support for something like part-attributes that I proposed above.
  3. Support for custom pseudo-classes and the flexibility to specify those after a ::part.

@caridy
Copy link

caridy commented Dec 22, 2018

@JanMiksovsky I don't think my-tab-component::part(tab-button):state(selected) will fly, that represents a loosely API on the my-tab-component, since any changes in the component representing the tab-button part will automatically affect your component.

@JanMiksovsky
Copy link
Author

@caridy I think that's unavoidable — and also fine. I'm starting from the assumptions that:

  1. Devs want to build components from smaller subcomponents.
  2. Components (including subcomponents) may have idiosyncratic state, i.e., conditions that are not universally applicable or meaningful for all components.
  3. Page authors want to style components, including those portions provided by subcomponents, and including styling for unique states at both the component and subcomponent level.

So if I build components from subcomponents, then I also take on both the risk and opportunity of being able to swap out those subcomponents for better ones in the future. That might break someone's careful styling of my component that assumes the old subcomponent.

That's unfortunate, but to me no different than the possibility that future work might force me to make a breaking change in a component's JS API. And the very existence of a part at all implies a contract that might get broken. If I add a part, I can't guarantee to the world that I'm never going to take that part away.

I would think devs could approach CSS support exactly like JS support: try hard not break contracts, but allow for that possibility, and use conventions like semver and documentation to signal when contracts are getting broken.

@tabatkins
Copy link
Member

JS also has a lot of tools to help authors hide things (closure-based state, wrapper objects that expose a more minimal API, etc). HTML and CSS don't; they're fundamentally simpler languages in this regard.

We still don't want to expose anything from the component that the component author isn't explicitly deciding to expose (or at least, explicitly wildcarding, with the knowledge that what they're doing is a little dangerous). Implicitly exposing extra data from subelements breaks encapsulation in an important way here.

@domenic
Copy link
Collaborator

domenic commented Jan 10, 2019

@JanMiksovsky thank you for your detailed post at #3431 (comment) , and apologies for taking a while to process it. I think I'm not fully understanding something though, which is why in your component libraries there are so many parts-with-states.

If you look at built-in HTML elements with UA shadow DOM (or other complex internals) today, they all use shadow DOM for encapsulation. The encapsulation is complete enough that the parts themselves don't really have any state; instead that gets bubbled up to the host element. Any case where there's a complicated-enough sub-element that has its own potential states ends up being done through light DOM, not shadow DOM.

So for example, you have a my-tabs-component, but from what I can gather in the OP, you have put all interesting sub-parts inside the shadow DOM. This means you have to re-expose them as ::parts, and thus leads you to getting into trouble with sub-states and so on.

But this isn't how we would design a built-in element. There, the interesting sub-parts would be in the light DOM, e.g. something like

<tabs>
  <tab>
    <tabbutton>One</tabbutton>
    Page One
  </tab>
  <tab>
    <tabbutton>Two</tabbutton>
    Page Two
  </tab>
  <tab>
    <tabbutton>Three</tabbutton>
    Page Three
  </tab>
</tabs>

Indeed, I see you even have an example of that later down in your own docs, with "Custom content in default tab buttons"!

In other words, parts make sense for things that are internal parts of your main component, like ::backdrop, ::cue, ::placeholder, ::marker, ::-webkit-slider-thumb, etc. In those cases, the state reflects up to the main element (e.g. input:placeholder-shown, or a fictional dialog:backdrop-shown or similar). But if you have something like a tab button, which is its own first-class object with its own states, then those things should probably be in the light DOM, not hidden inside the shadow DOM. And if they're in the light DOM, they don't need to indirect through parts in this way.

In general one rule of thumb I might come up with is that any time there can be an arbitrary number of the sub-control, it's probably first-class enough to need light DOM exposure. If the sub-control is 1:1 with the main custom element, then maybe it's just a part.

With this framing in mind, do you have any other examples from your 26+21+6 components where it's a more clear-cut case of the sub-part having its own state, that run into the issue described in the OP? Because the specific OP issue doesn't make sense to me anymore now that I look at this in more detail. Especially seeing how you explicitly encourage users in your docs to have light DOM elixir-tab-button elements which could just be addressed with normal CSS selectors and not need ::part to select them.

@kevinpschaaf
Copy link

@domenic I agree that for many leafy "widgets" along the lines that the UA ships, composition via light DOM is preferable for the reasons you list. There are probably still interesting cases to investigate that still expose this issue -- perhaps the "carousel" example is a good one to study:

<my-carousel>
  <img src="a.png">
  <img src="b.png">
  <img src="c.png">
</my-carousel>

In its shadow DOM, it might create a "dot" affordance in the shadow DOM for each light DOM item in the carousel, and swiping images changes the selected dot. I could see wanting to allow style ::part(dot) and ::part(dot):state(selected) separately.

@kevinpschaaf
Copy link

However (and I think this is where @JanMiksovsky's "Devs want to build components from smaller subcomponents" comment comes in), if you're using Web Components as primitives not to just build leaf widgets, but to also compose them into higher levels of reusable functionality, you can pretty quickly hit these types of use cases.

Consider a mortgage calculator widget that is composed out of several custom elements (e.g. sliders and buttons and such), and some of those could have stylable state. It would not make sense to require the user to supply the "interest rate slider" in the light dom, and yet it might very well have styleable state. These are cases that the UA does not hit because it is not in the business of vending such high-level components (but it would be a shame for Web Components to not be able to support these cases).

@domenic
Copy link
Collaborator

domenic commented Jan 10, 2019

Well, sure, but if you're composing them, you don't need to hide all the pieces you're composing in the shadow DOM. If you want to give your user fine-grained control over, and exposure to, the sub-components you're composed of, then you should expose them through the light DOM. I'd rather we not re-invent the light DOM by taking shadow DOM, and adding more re-exposure features until we've created just a duplicate, awkward-to-use light DOM.

@JanMiksovsky
Copy link
Author

@domenic Thanks for giving this some thought; we really appreciate it.

Let me offer some more use cases where someone will want to expose multiple sub-elements as parts, and where those parts will often have state that people will want to reflect in styles.

Components with generated elements

The carousels @kevinpschaaf mentions above are a common example of a category of components that want to generate subelements. Our stock Carousel does this, and we have others that generate page numbers, thumbnails, or labels drawn from image captions.

Other examples:

  • Calendar components. Our MonthCalendar generates an element for each day of the month. Each of those days can have state: in our stock calendar, weekend days are represented specially, as is the current date. We also have an interactive calendar that adds a selection state for dates. Other calendar components include things like Google Photo’s timeline scroll bar.
  • Charts and other data visualizations. Charts have tons of generated elements: tick marks, labels, legends, points, bars, etc.
  • Maps.
  • Live data components: most recent Tweet, stock tickers, etc.

In these types of components, it’s simply not possible to ask the component user to generate the elements themselves and place them in the light DOM. The generation of that content is much of the component’s value.

A component could try to generate its own light DOM content, but that gets messy, no?

  • It would interfere with the real content the user is interested in having the component render.
  • It would also raise the likelihood of the kinds of unintentional CSS collisions Shadow DOM was designed to prevent.
  • It hinders composition. If I embed a chart component inside one of my own components, there’s no easy way for me to expose the chart’s generated light DOM content (which is now inside my component’s shadow) outside my own component. In contrast, we’re hoping ::theme will eventually address that cleanly.

Components with stock content

Another category of components with parts with multiple instances are components that provide stock content such as tools.

As a simple case of such stock content, the carousel above has left and right arrow buttons. An author will want to style the general appearance of those buttons, along with :hover and :disabled states. Although there are just two of these buttons, conceptually it’d be nice to define such styling once, rather than having to style ::left-arrow-button and ::right-arrow-button parts separately. We have other examples, such as a SlideshowWithPlayControls, that have more commands.

As the number of commands increases, the burden of having to style each button as its own independent part increases. A rich text editor or image editor component which offer the user a toolbar with a number of buttons for editing commands. Those buttons are stock content — elements built into the editor. The creator of the editor component wants to let someone style those buttons as a collection, instead of having to style each command separately. The buttons have state: disabled, checked, etc. It should not be necessary to expose those commands in the light DOM to make them available for styling.

A component that wants to provide elements for the author — generated elements like those dots, or stock elements like those editing commands — is going to want to let the page author style those elements. In both cases, it’s cumbersome to try to do that through light DOM elements.

As soon as we ask the component user to put something in the light DOM that could in any way be inferred or generated from their real data/content, some clever person will create a script to automate the creation of those light DOM elements. They’ll share that script with their team as a more helpful wrapper for the actual component. Different team members will create different wrapper scripts for different components; the wrappers will behave slightly differently. If that happens, it would mitigate many of the advantages of having introduced a component model.

I don’t think we’re trying to give the user complete fine-grained control over the subelements. The user can’t modify those subelements directly, they can’t listen to events on them, etc. The interest is specifically on styling. I share your concerns about increasing complexity, but think focusing on the styling problem prevents full-blown replication of a duplicate light DOM.

@domenic
Copy link
Collaborator

domenic commented Jan 11, 2019

Very interesting. It seems like we should probably be investing in more idiomatic solutions to those problems, instead of trying to shoehorn them into the web components model. For example, if you really want a component that's not an encapsulated component, but instead a "macro" for generating large chunks of light DOM, we should consider what is an ergonomic way to introduce DOM macros into the platform. Then, we won't have this weird thing where you're using shadow DOM for something that isn't really meant to be encapsulated, and then needing to work around it by re-exposing everything with these pseudo-light-DOM technologies.

I don't understand the difference between generated and stock content from this perspective; they both seem to be the case of "the component author wants to generate some sub-elements for the component user without them having to manually include them".

As soon as we ask the component user to put something in the light DOM that could in any way be inferred or generated from their real data/content, some clever person will create a script to automate the creation of those light DOM elements. They’ll share that script with their team as a more helpful wrapper for the actual component. Different team members will create different wrapper scripts for different components; the wrappers will behave slightly differently. If that happens, it would mitigate many of the advantages of having introduced a component model.

Indeed, it seems like the solution space should be to try to standardize or give guidance for these kind of generation scripts. One example primitive that might fit in well here would be "HTML includes".

@tabatkins
Copy link
Member

we should consider what is an ergonomic way to introduce DOM macros into the platform.

:grumbles in declarative: ^_^

But yeah, agree all round here. There are use-cases for exposing states on shadows, but they should be handleable just by letting you specify several part names; getting more complicated than that means you're probably trying to use Shadow DOM to solve a different problem, and we should just solve that problem directly.

@AmeliaBR
Copy link
Contributor

AmeliaBR commented Feb 2, 2019

Aside: this is a really nice issue thread of people providing comprehensively documented, politely argued perspectives on a complex issue. It was very easy to catch up on the issue. Yay, you!

I agree that state/pseudoclass selectors are necessary for fully theme-able custom elements. The web component author should be able to define what states exist in the component that justify a different rendering, and the web page author should be able to adjust the theme accordingly. And for a web component that contains theme-able parts, it is only natural that those parts might have states distinct from the state of the rest of the component.

While @domenic's general rules for composability are good guidance, I think @JanMiksovsky's examples of complex widgets or data display are all equally valid.

I strongly disagree with the most recent comments by Tab and Domenic, that these complex widgets are a failure of encapsulation. Encapsulation means that the details of implementation of lower levels should be abstracted away at the top level. It doesn't mean that the lower levels can't be complex. It seems totally reasonable to me to have a component where the top-level light DOM defines the unique content, while the shadow DOMs generate complex widgets for modifying that content (like a rich text editor) or displaying it (like an interactive data visualization).

From a script point of you, the outer web page only cares that the inner widget is doing its job, maybe watching for updates to the light-DOM content. But for styling? To create a full theme for a rich text editor, you're going to need toggle button states and focus states and selected text states and checked item in a drop-down menu states.

So that means states not only for the widget as a whole (e.g., empty, saved, invalid), but for individual repeated parts like buttons and drop-downs.

I do not like the idea of describing "a part that has a certain state" with a combination of multiple part keywords, as @tabatkins proposes in #3502. That is confusing pseudoelements with pseudoclasses.

However, I also do not like the idea of using regular CSS attribute selectors to snoop into the shadow DOM. We've gone down that route before, and it contradicts with the goals of encapsulation.

So, I agree that a :state(<ident>) pseudoclass is the best syntax, where the state pseudoclass can either be applied on the primary selector (states of the custom element) or on a ::part(<ident>) pseudoelement. So these would be logically very different selectors:

my-widget:state(active)::part(slider) {
/* themes for the slider when the widget is in the active state */
}
my-widget::part(slider):state(active) {
/* themes for the active slider in the widget */
}

Possible :state() API

We need the state selector to be efficient for the browser to calculate, so we can't have it jumping down into JavaScript to check if a custom element or part currently matches a given state.

But I think we also want to support simple states like :hover and :checked on elements inside shadow DOM. Which means that it would be inefficient if the web component author needed to add and remove attributes (as suggested above and in #3502) or manually set DOM properties (as suggested in WICG/webcomponents#738) every time these changed.

So, my recommendation: Define states as a mapping between a state name (exposed to the outside) and a selector (tested inside the shadow DOM).

So for example, if you have a multi-select list that is implemented as a list of checkboxes, you might have a selected state mapping for the <my-option> custom element that looks for a match to #checkbox:checked inside the shadow tree. Outside the shadow tree, you don't need to know anything about that checkbox, it's id, or even that it is actually a checkbox, you're just testing for my-option:state(selected).

For states on parts instead of on the shadow root, the selector would be tested against that part element. And maybe also its descendants—although that could be handled by allowing the :has() selector, which would allow you to do things like map a mixed state to :has( .item:checked ):has( .item:not(:checked) ) (i.e., the overall selector would only match if there are both checked and unchecked children).

The states applicable to the web component and each part would need to be explicitly set by the web component author. For passing through states from nested web components, the selector could include :state(<nested-component-state-name>).

I'm assuming that these state-selector pairs would be defined in a JavaScript API. A states property on the custom element class, whose value is a dictionary of {<statename>: <selector>} pairs.
For defining states on parts, there would be a separate dictionary of dictionaries: {<partname>: {<statename>: <selector>}}.

As much as I like declarative APIs, trying to define this in an attribute would be a mess. The syntax proposed for the exportparts attribute only covers mappings of identifiers to other single-token identifiers. It uses commas and colons as delimiters, so would require quotes inside of the attribute value to include full CSS selectors.

Attributes also can't be used to set states on the shadow root / component as a whole, and would be either redundant or inconsistent if you have many similar elements (same part type, e.g., menu-button) which should be exposing the same states (e.g., pressed, disabled).

Final idea:

You'll notice that most of the state names I'm suggesting match the names of existing pseudoclass selectors (:disabled, :checked, :selected). That's a feature, not a bug. This API would provide an author with the way to recreate all the native form pseudoclasses. Or to turn it around, the native pseudoclasses would reflect states set on the closed shadow trees of native form elements by the native implementations.

The native pseudoclasses would be redefined as shorthands for the :state() selector.

So :checked would be exactly equal to :state(checked). :disabled would be exactly equal to :state(disabled) Except with one difference—once a browser supports :state(), they would support any token inside there as a valid selector, whether it matches anything or not. So it could be used to make future pseudoclasses backwards compatible (at least, the boolean ones).

@AmeliaBR
Copy link
Contributor

AmeliaBR commented Feb 2, 2019

As much as I like declarative APIs, trying to define this in an attribute would be a mess.

Belated brain flash: What if we—hear me out, now—used CSS syntax? So states for the custom element could be defined with an @state rule in a stylesheet that applies to the shadow tree, similar to @tabatkins's custom selectors proposal.

@state { /* state for the entire widget */
  checked: #checkbox:checked ;
  /* the widget is checked if the shadow element with id `checkbox` is checked */
}
@state drop-down-button { /* state for any element with part="drop-down-button" */
  focus: :focus-within;
  /* the part will match the focus pseudoclass for the outside tree
      if it contains a focused element within the shadow tree */
}

If allowing colons inside a value in a declaration-like structure is a syntax issue, the selectors could be wrapped in {} to clearly isolate them.

@AmeliaBR
Copy link
Contributor

AmeliaBR commented Feb 2, 2019

The native pseudoclasses would be redefined as shorthands for the :state() selector.

One potential problem with this is that custom elements already match certain state-like pseudoclasses. The author-defined states would need to cascade on to the default ones.

For a generic custom element (<my-widget>), the default states would be something like

@state {
  hover: :root:hover;
  active: :root:active;
  focus-within: :root:focus-within;
}

If an author provided a new definition of the active state (something beyond "is the user holding down their mouse somewhere in this box tree?"), it would replace the my-widget:active pseudoclass, but the my-widget:hover pseudoclass would work as normal.

For custom elements that upgrade native elements with the is attribute, the set of native states would be longer. But the benefits of being able to override them would be greater. The native pseudoclass could apply as the graceful degradation state if the custom element definition never loads.

For example, consider an enhanced version of <input type="tel"/> that breaks it into a fieldset with multiple parts (area code drop-down, telephone, extension). You could define valid and invalid states, and so on, using selectors that check that all parts of the fieldset are valid in order for the input as a whole to be valid. But the basic browser behavior for :valid would apply if the element never got upgraded.

@rniwa
Copy link

rniwa commented Feb 3, 2019

On the contrary to what you've stated, reading DOM state to update :state(~) would be zillion times faster than what you're proposing. With what you're proposing, we'd have to create a dependency graph of all states that could in turn affect other :state which can then in turn affect other :state. Something like that would be hard to implement efficiently.

We should go with a JS DOM API. It would be simpler & more efficiently implementable.

@AmeliaBR
Copy link
Contributor

AmeliaBR commented Feb 3, 2019

reading DOM state to update :state(~) would be zillion times faster than what you're proposing.

Understood. But updating that DOM state from JavaScript would slow things down for interactive pseudoclasses that change a lot. (Like the example in the webcomponents issue for re-implementing :hover with mouse event handlers.)

@rniwa
Copy link

rniwa commented Feb 3, 2019

But updating that DOM state from JavaScript would slow things down for interactive pseudoclasses that change a lot

Hardly. What would be way more expensive is the updating of the relevant style, layout, & paint. What's really expensive is having to track the dependency between elements, and figure out if hovering an element can in turn affect other element's :state state. That can quickly lead to O(n^2) or even exponential algorithms if implemented naively.

Note that we DO need JS-based solution regardless because elements can have states other than the ones natively supported by the browser. e.g. custom checkbox implementation may supported, in addition to :checked and :disabled, :state(implicitly-checked) which indicates that the checkbox is marked due to other options made by the user. An editable list may have a state such as :state(mutating) during which the list item is actively being mutated by the user.

Once we accepted that we do need a mechanism for JS to set a custom state on an element, then it's only natural to let the script update all the states instead of inventing separate / independent mechanism to do the same. Otherwise, we'd have to figure out how selector based approach & JS API interact, etc...

@AmeliaBR
Copy link
Contributor

AmeliaBR commented Feb 3, 2019

Note that we DO need JS-based solution regardless

I was assuming that fully custom states would be set the old-fashioned way, by setting a class or data attribute from JS. But yes, that would clearly be less efficient than just adding a token to a state DOM property directly.

And you clearly know more than I do about implementation headaches, @rniwa. I was thinking of this purely from the perspective of passing values up the tree of shadow trees, but selectors are maybe a little too generic for that. I'd forgotten that things like :host-context exist and could send interactions back down the tree. (So much for early morning brain storms.)


The first half of my comment still stands:
There's a logical need for custom state properties, both for the component as a whole, and for ::part() within it. And it should be possible for either type of state to be generated by forwarding a state from a nested component.

So if there isn't a declarative way to do that, there needs to be a StateObserver or similar to allow the JS of the outer component to react to a change in the inner component, and update the state that it (or its parts) exposes. But StateObserver sounds like a good idea anyway, so that's not a criticism.

I also still like the idea of letting the custom state mechanism override standard pseudoclasses (:state(checked) === :checked). I think that still works as a good fallback mechanism, no matter how the custom state is generated.

@rniwa
Copy link

rniwa commented Feb 22, 2019

It should be possible for either type of state to be generated by forwarding a state from a nested component.

In theory, that sounds like a valid use case but I really don't see how we can implement such a feature without having some catastrophic performance characteristics regardless of whether we do it in CSS or JS.

@LeaVerou
Copy link
Member

LeaVerou commented Oct 15, 2020

JS set :state() is certainly useful, but there needs to be some sort of way to do what @AmeliaBR suggested, otherwise we may see authors re-implementing native pseudo-classes with JS, which is undesirable.
@rniwa what if these "forwarded" selectors cannot include :state()? And what if they're only compound selectors? Wouldn't those two things make it easier to implement?

@rniwa
Copy link

rniwa commented Nov 15, 2020

JS set :state() is certainly useful, but there needs to be some sort of way to do what @AmeliaBR suggested, otherwise we may see authors re-implementing native pseudo-classes with JS, which is undesirable.
@rniwa what if these "forwarded" selectors cannot include :state()? And what if they're only compound selectors? Wouldn't those two things make it easier to implement?

I don't see how. The fundamental problem is states like :hover implicitly affecting random other elements. That would quickly lead to O(n^2) behavior given the inherited value, etc...

@LeaVerou
Copy link
Member

Affecting random other elements how? I'm failing to see how Amelia's proposal leads to O(n^2) behavior, but if you elaborate perhaps we could find a solution, possibly via a set of limitations that fixes the performance characteristics without crippling the use cases too much.

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

9 participants