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-mixins] Proposal: @like rule for repurposing page default styles #10222

Open
LeaVerou opened this issue Apr 17, 2024 · 20 comments
Open

[css-mixins] Proposal: @like rule for repurposing page default styles #10222

LeaVerou opened this issue Apr 17, 2024 · 20 comments

Comments

@LeaVerou
Copy link
Member

LeaVerou commented Apr 17, 2024

Background & Problem statement

One of the most common issues with web components is that they need to adopt the page’s existing generic styles, but can't. E.g. <foo-button> cannot adopt button styling unless the author duplicates button styling explicitly for it, making it hard to experiment with different components and making authors gravitate towards monolithic component libraries where they only need to define styles once.

Even outside of web components, it’s common to want to cross-reference or extend styles, e.g. <a class="button"> is a very common pattern with button being in the top 5 of most common class names on the Web.

Even in terms of architecture, defining default styles and then styling other structures by being able to reference these default styles would be a big win.

Mixins somewhat mitigate but do not completely solve this problem, as they require a shared contract between the page author and the author pulling in those styles (which may be separate entities). E.g. components cannot trust that a certain mixin has been defined on the page, and even if they could, it's tedious for the page author to define new mixins every time they want to try out a new component (not to mention that as mixins are currently envisioned this would require a lot of code duplication).

@like is a way to target and "adopt" a set of existing CSS rules, without requiring a two-way contract. It can work with any existing stylesheet. The concept is not new, and is very similar to Sass' @extend. Past proposals include:

In this proposal I've tried to define something more concrete, and cut scope enough to avoid some of the hairiest issues while still satisfying the most prominent use cases.

The @like rule

I’m envisioning a rule that would match certain rules (excluding the current rule) based on its argument and pull in their styles after applying the cascade. The target rule (the rule that contains the @like) would be excluded from the matching.

.button {
	@like button;
}

my-input {
	@like input[type=text];
}

table.functions {
	tbody th {
		@like code;
	}
}

details.callout > summary {
	@like h4;
}

This may seem like too much magic, but below I discuss ways to scope it down, and make it more concrete and more implementable. However, no amount of scoping will make this a low-effort feature, the argument is just that it's also a high reward feature: done well this has the potential to solve a multitude of very prominent author pain points.

Meta-selectors

The argument to @like is not a selector, but a meta-selector: a way to describe a family of selectors. Meta-selectors match any selector that consists of the same criteria regardless of specificity (and in some cases looser criteria, see below). For example, a button meta-selector does not just match button, but also :is(button), :where(button) and so on.

For implementation complexity to be manageable, meta-selectors need to start from a very restricted set of criteria. The MVP could perhaps be a single compound selector, including:

  • 0-1 type selector (or possibly even a mandatory type selector)
  • 0-N action pseudo-classes (:hover etc)
  • 0-N attribute presence and/or attribute equality selectors (input[type=number]). (substring matching makes it harder to determine equivalence and most use cases don't need it)
  • :not() with a list of compound selectors matching the above (e.g. input:not([type]))
  • Certain pseudo elements? E.g. ::placeholder?

It will probably take a fair bit of work to define which selectors a meta-selector corresponds to, but some initial thoughts are:

  • Order of selectors in a compound selector is ignored (i.e. .foo.bar = .bar.foo)
  • Duplicate selectors are ignored (.foo.foo = .foo)
  • :is() and :where() is ignored (except for grouping), e.g. :is(foo) = :where(foo) = foo
  • Double :not() is ignored, e.g. :not(:not(foo)) = foo

What exactly is imported?

Narrower selectors?

As currently defined, adopting button styles by my-button is still fairly intensive:

my-button {
	@like button;

	&:hover { @like button:hover }
	&:active { @like button:active }
	&:focus { @like button:focus }
	&:hover:focus { @like button:hover:focus }
	&:disabled { @like button:disabled }
	/* ... */
}

And even after all this, we may end up with subtly different styles if we happened to use a different order of pseudo-classes than the page author.

An alternative design would be to also identify more narrow selectors that match the meta-selector and pull them in automatically.
This means that this:

my-button {
	@like button;
}

Would also automatically style my-button:hover like button:hover, but also my-button.success like button.success.
For certain things it may not even make sense, e.g. my-input { @like input; } would also adopt input[type=number] as my-input[type=number], but my-input may not even have a type attribute (or it may have one with different semantics.
Overall, this means less control for the component author, but more robust styles in the general case.

A hybrid approach could only port action pseudo-classes, which seem to always be desirable, and anything else needs to be adopted manually. Or for the author to explicitly declare which states to adopt, and then they are automatically adopted in the right order.

Broader selectors?

It gets worse when we add multiple criteria:

input { background: white }
input[type=text] { color: black }

my-input {
	@like input[type=text];
	color: blue;
}

For author intent to work, we want to adopt both input and input[type-text], but we also want to preserve their relative specificity. Which brings us to…

Relationship to the cascade

There are two conflicting goals here:

  1. For this to have the intended result, it's important that the behavior of the cascade is preserved.
  2. Any styles defined for the rule itself should probably have lower priority than adopted rules.

Also, given that a single rule can have multiple @like rules, it becomes especially important that conflicts are resolved in a predictable way.

One realization is that specificity in the adopted rules is useful to resolve conflicts between the adopted rules, but is not relevant in resolving conflicts between our @like-using rule and the rules it’s adopting from. E.g. in the example above, it would be a mistake if input[type=text] had higher specificity than the declarations within my-input.

Some ideas:

  1. Adopted rules are treated as if in a separate layer. However, you typically don’t want everything in the current rule to override everything in the adopted rule, e.g. button:hover should probably still have higher specificity than the base my-button rule (but lower than my-button:hover). In the manual model where all pseudos are adopted separately and @like basically adopts a computed list of declarations this is addressed by natural specificity.
  2. The matching compound selector is replaced by &, i.e. assuming this page style:
input { background: white }
input[type=text] { color: black }
input[type=text]:focus { box-shadow: 0 0 .1em blue }

this rule:

my-input {
	@like input[type=text];
	color: blue;
}

becomes:

my-input {
	background: white;
	color: black;
	color: blue;
	
	&:focus {
		box-shadow: 0 0 .1em blue;
	}
}

Scoping

It is important that scoping is preserved: if the page includes special styles for e.g. .foo button then we want our .button to behave the same within .foo. Perhaps it would make sense if any selectors containing a compound selector that matches our meta-selector were also pulled in and rewritten accordingly:

button { border: 1px solid gray }
.callout button { border-color: var(--color); }

.button {
	@like button;
}

becomes:

button { border: 1px solid gray }
.callout button { border-color: var(--color) }

.button {
	border: 1px solid gray;
	
	&:is(.callout &) {
		border-color: var(--color);
	}
}

@scope too:

button { border: 1px solid gray }
@scope (.callout) {
	button { border-color: var(--color); }
}

.button {
	@like button;
}

becomes:

button { border: 1px solid gray }
@scope (.callout) {
	button { border-color: var(--color); }
}

.button {
	border: 1px solid gray;

	@scope (.callout) {
		border-color: var(--color);
	}
}

Cycles

Cycle detection would be necessary to avoid cycles like:

a { @like b; }
b { @like a; }

In this case, @like would get ignored in all affected rules.

Note that if we adopt importing narrower selectors too, cycles are not just selectors with the same criteria.
This is also a cycle:

button:hover {
	@like button;
}

## Behavior in Shadow DOM

It seems reasonable that by default the meta-selector would be tree-scoped.
However, a huge use case is shadow DOM elements adopting default styles from the light DOM, even elements of the same type (e.g. style a shadow DOM `<button>` the same as a light DOM `<button>` in the same place). 

There is currently a lot of discussion around this in https://github.com/WICG/webcomponents/issues/909 , but the concept of _meta-selectors_ seems quite useful here too. Perhaps there could be a parameter of `@like` to basically say "adopt styles from the parent tree" or "adopt trees from all parent trees". This would allow the kind of granular filtering that use cases need, without the additional burden of wrapping rules in `@sheet` that still requires the page author to do work to integrate a component.

One tricky bit around this is that in regular `@like`, meta-selectors only need to match selectors with the same criteria. Looser criteria are already applying. E.g. in something like this:

```css
* { box-sizing: border-box }
textarea { width: 100% }

my-textarea {
	@like textarea;
}

we don’t need to pull in the * rule, because it already matches my-textarea. However, if adopting styles from an ancestor tree, the meta-selector needs to also match and pull in looser selectors.

v2+: Additional criteria

Post-MVP, the syntax could be extended to introduce filters on what types of rules or declarations are pulled in.
This could be:

  • Only from specific layers, or layers other than the current layer
  • Restrict to specific CSS properties (e.g. backgrounds, borders, typography, etc.)

Despite its length, this is still extremely handwavy, but it should hopefully be enough to start the discussion and see if something like this could possibly be implementable in some way, shape, or form.

@bkardell
Copy link
Contributor

Some potentially related past discussions... At least linking them incase folks who were watching them are interested.

#2296
#3596
Then, there was also https://tabatkins.github.io/specs/css-aliases/#custom-selectors which began in mailing lists but I would have sworn should have an issue (or more) that spun that work off - but I can't find them.

@mayank99
Copy link

mayank99 commented Apr 17, 2024

See also: #10094

(not exactly the same thing, but has some overlap and is similarly interesting for shadow dom)

@LeaVerou
Copy link
Member Author

LeaVerou commented Apr 19, 2024

Thanks @bkardell and @mayank99 for the effort to spot related discussions!

Comments:

  • [css-selectors] Selectors for “text-ish” and “button-ish” inputs #2296 seems like it would improve the ergonomics of @like, but doesn’t really solve any of the same pain points unless the meaning of these selectors is somehow mutable (and would still be way more restricted)
  • [selectors] Add :role() pseudo-class #3596 same as above. This perhaps makes it easier to target button-like and textbox-like controls, but not to style existing elements like them, unless authors universally adopt :role(textbox) and :role(button) for these styles (and I’m unsure if that’s the right solution as it seems to mix style and behavior — in most web apps there are buttons that look like buttons, and buttons that don't look anything like buttons, especially given the lack of good ARIA roles to describe today’s UIs. E.g. even interactive visualizations where you hover over a e.g. a pie chart wedge or scatterplot circle to see more info about that data have to use role=button because there’s nothing better.
  • [css-cascade] Proposal: @layer initial should always be the first layer #10094 That may be an interesting concept synergy, e.g. adopted styles could be imported as if part of the initial layer or something.
  • Custom selectors could also help improve ergonomics here, though the relationship seems a bit tenuous (but maybe you had something in mind @bkardell that I'm not seeing?)

@tabatkins
Copy link
Member

Just double-checking my understanding here - it seems that this isn't similar to @extend, but rather is @extend, right? You're asking for a foo selector to be treated like a bar selector across the rest of the page, like having my-input treated like input[type=text], so any rule that specifies input[type=text] can potentially match my-input elements too.

@LeaVerou
Copy link
Member Author

Just double-checking my understanding here - it seems that this isn't similar to @extend, but rather is @extend, right?

Possibly? I’m not sure what are the specifics of how @extend works; e.g.

  • whether it does string matching or actually parses the selector into a meta-selector and uses that to match rules; or somewhere in between.
  • what are the limitations for the syntax of its argument
  • how it handles conflicts and cycles
  • what its precedence is
  • etc

@tabatkins
Copy link
Member

Sure, those are all important open questions for anything like this. Besides those, tho, I was just checking my understanding that it's otherwise identical, and not subtly different in a way that I was missing in my first read.

@LeaVerou
Copy link
Member Author

It just occurred to me that there’s another really nice use case that this addresses: Aliasing long selector chains into nice utility classes.

pre {
	@like .code-labels.count-lines.line-numbers;
}

Or extending overly scoped styles:

.warning {
	@like aside.callout.warning;
}

@Crissov
Copy link
Contributor

Crissov commented Jun 27, 2024

Reference Selectors from #3714 would introduce a special selector syntax to make specific rulesets importable in other places, while this proposal would make any effective ruleset importable in other places based on standard selectors. The motivation for this is that component authors want to incorporate styles that they have no control over nor access to.

Without pouring too much thought into this proposed solution, it feels incredibly powerful – too powerful in fact. I cannot imagine this could be implemented in an efficient manner, but I may be wrong. Also, selectors inside rule blocks make me feel icky.

Authors should indeed be able to declare that a (part of a) component is of a particular generic “type”, and then get a site-specific default styling for it, but since :role() never got anywhere unfortunately, we do not have the necessary abstract semantic base in existing stylesheets: they all work on concrete predefined element type names specific to the document type (i.e. HTML) and on arbitrary custom class names. I don’t know how to solve this.

@LeaVerou
Copy link
Member Author

The issue with things like reference selectors that I'm trying to avoid is that reference selectors require a shared contract between the style and the code that requires it, meaning they cannot be used by a component that you just drop into your page and its style adapts to fit.

@Crissov
Copy link
Contributor

Crissov commented Jun 30, 2024

I understand that, but do not support the suggested solution.

I think a proper way forward to solve the most important of your use cases is as follows:

  1. Introduce a way to register either selectors or styles for semantic “templates”, with keywords based on ARIA roles.
  2. Add sample assignments in the default stylesheet for HTML. Author styles would automatically update these.
  3. Let authors overwrite these assignments.
  4. Let component authors call in such templates into their components. (This could be unified with either the appearance or the all property, but I’m not convinced that’s a good idea.)
/* Variant 1: registered styles */
input[type=button] {template: button; color: green;}
input[type=button] {outline-color: green;} /* identical selector */
input {border-color: green;} /* used indirectly */
input[type=button]#more-specific {background: red;} /* not used */
my-component {template-apply: button;}
/* Variant 2: registered selector */
@template button: input[type=button], button;
input[type=button] {color: green;}
input {border-color: green;} /* used indirectly */
input[type=button]#more-specific {background: red;} /* not used */
my-component {template: button;}
/* not viable, because authors would actively need to specify the pseudo-class */
:role(button), button, input[type=button] {color: green;}
my-component {role: button;} /* optional feature, alternatively: 
`<my-component role="button">` */

@LeaVerou
Copy link
Member Author

LeaVerou commented Jul 1, 2024

@Crissov "Proper" in what way? For whom? You’re proposing a solution with no use cases and no motivation. There’s a big "why??" missing here.

@Crissov
Copy link
Contributor

Crissov commented Jul 1, 2024

“Proper” in a way that does not need selectors inside style rule blocks.

@andruud
Copy link
Member

andruud commented Jul 1, 2024

This looks promising to me, although probably quite hard to do. I think we'd need to cut this down a bit more to approach it.

Mixins somewhat mitigate but do not completely solve this problem, as they require a shared contract between the page author and the author pulling in those styles (which may be separate entities). E.g. components cannot trust that a certain mixin has been defined on the page, and even if they could, it's tedious for the page author to define new mixins every time they want to try out a new component (not to mention that as mixins are currently envisioned this would require a lot of code duplication).

I do feel however that we should not add both @like (or similar) and mixins when they overlap so much. (As far as I can tell, this proposal can basically do mixins, except for the arguments part). cc @mirisuzanne

@LeaVerou
Copy link
Member Author

LeaVerou commented Jul 2, 2024

@Crissov why is this goal, especially when the end result is not meeting a good chunk of user and author needs? Remember that theoretical purity is at the bottom of the PoC.


@andruud There is certainly overlap, though preprocessors have historically supported cosntructs for both, and authors have used both so it seems they do serve distinct user needs. That said, if we can do mixins via a similar mechanism and reduce the number of primitives authors need to learn, even better. It would be good to enumerate the conceptual differences in terms of user needs to see if some of the different reuse mechanisms we are discussing could be merged.

A first stab:

  • Conceptually, mixins don’t necessarily match anything and don’t really stand alone, they only get meaning when included. Though in theory, that could be represented by a new selector type (e.g. placeholder selectors) + @like + a way to set custom property values on that included rule (since it doesn’t match anything, so there are no existing values).
  • @like is designed to pull in base styles defined independently by the page and is quite limited in terms of pulling in descendant rules, whereas mixins can have descendant rules of any amount of complexity. Though the ergonomics of @like are not actually harmed by relaxing this, this restriction primarily exists to make it easier to implement.
  • Higher level custom properties are also similar to mixins, but cascade normally and only provide a single argument (though future extensions to @property could allow more elaborate parsing)
  • Functions are related, but IMO have a distinct purpose, to reuse complex expressions.

As a terrible handwavy strawman just to get the conversation going, what if @like could have two forms:

  1. @like <meta-selector> (as described above)
  2. @like <meta-selector> { <custom-property-declarations> }

The declarations in 2 would be used to set any base values for custom properties that don’t have one. Since placeholder selectors match no element, their custom properties have no values anyway, so effectively this sets them. Some prose wrangling would be needed to make sure this also sets registered custom properties, instead of having them default to their initial value.

Note this doesn’t cover registering scoped parameters. But perhaps that’s a problem that needs to be solved at the @property level anyway.

Even if we decide we want to pursue mixins as a separate path later on, something like @like could alleviate the immediate need for many types of mixins, buying us more time to figure out the design of mixins and how they fit in to the rest of the language.

@Crissov
Copy link
Contributor

Crissov commented Jul 2, 2024

This enables circular dependencies unless resolved somehow:

foo {
  color: green;
  @like bar;
}
bar {
  @like foo;
  background: green;
  color: orange;
}

quuz > baz {
  font-size: 2em;
}
quuz {
  @like baz;
}

Is this a problem?


Would it be possible to move the referencing statement into the selector?

bar:extends(foo) {
  background: green;
}

I would not object to a syntax like that. (Although the circular dependency consideration applies as well.)

@LeaVerou
Copy link
Member Author

LeaVerou commented Jul 3, 2024

@Crissov This is discussed in the OP.

@Crissov
Copy link
Contributor

Crissov commented Jul 3, 2024

Sorry, I somehow ignored the Cycles section indeed 🤦‍♂️

However, I fail to see where it is discussed (here or in #1855) why the at-rule and its (meta) selector argument needed to be inside the rule block. It would not need to be a kind of pseudo-class as shown in my last comment, but that seemed like the most straightforward syntax to me yesterday.

bar @extends foo {
  background: green;
}

@LeaVerou
Copy link
Member Author

LeaVerou commented Jul 3, 2024

It cannot be a pseudo-class as that increases complexity since you can have multiple per selector. The <selector> @extends <meta-selector> syntax doesn't have that issue, but is syntactically unclear: is that an at-rule or a style rule?
Whereas I believe an at-rule in the style rule is parse-able with the existing syntax, and has the added benefit that you can define the order as being significant.

Another syntax could be a special property, e.g. like: selector(.foo); which would work like a shorthand specifying the computed style of all properties from the rules the meta-selector matches. A bonus of that is that it could cascade normally and its conflict resolution uses existing language mechanisms.

@mirisuzanne
Copy link
Contributor

mirisuzanne commented Jul 3, 2024

It seem the main advantage of this approach is having access styles from existing elements (like button). But once we start adapting it to handle more generic 'mixin' cases (with placeholder selectors) – we're back to requiring a handshake on terms.

What if we come at this from the other side? One of the things that got me most excited about the mixin proposal was @astearns suggestion that there could be some core mixins provided (like one to access keyframe styles). A similar solution might work here: a core mixin (@apply like(button:hover);) that can be used to access styles from another selector?

@mayank99
Copy link

mayank99 commented Jul 3, 2024

Oh I love the idea of a built-in like mixin.

Coincidentally, I recently had a use case exactly for applying button:hover-like styles on another selector, and I ended up implementing it using cyclic space toggles, which led to some hard-to-understand code. I'd love to be able to do @apply like(button:hover) instead, for simplicity.

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

7 participants