[csswg-drafts] [css-mixins] Proposal: `@like` rule for repurposing page default styles (#10222)

LeaVerou has just created a new issue for https://github.com/w3c/csswg-drafts:

== [css-mixins] Proposal: `@like` rule for repurposing page default styles ==
## 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](https://almanac.httparchive.org/en/2022/css#fig-5) 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:
- https://github.com/w3c/csswg-drafts/issues/1855
- _(pretty sure there have been more)_

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.

```css
.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:

```css
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:

```css
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:

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

## 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:
```css
input { background: white }
input[type=text] { color: black }
input[type=text]:focus { box-shadow: 0 0 .1em blue }
```

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

becomes:

```css
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:

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

.button {
 @like button;
}
```

becomes:

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

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

`@scope` too:

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

.button {
 @like button;
}
```

becomes:

```css
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:

```css
a { @like b; }
b { @like a; }
```

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

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

Please view or discuss this issue at https://github.com/w3c/csswg-drafts/issues/10222 using your GitHub account


-- 
Sent via github-notify-ml as configured in https://github.com/w3c/github-notify-ml-config

Received on Wednesday, 17 April 2024 00:47:53 UTC