w3c / csswg-drafts

CSS Working Group Editor Drafts
https://drafts.csswg.org/
Other
4.36k stars 641 forks source link

[selectors][mediaqueries] :media() pseudo-class as a shortcut for one-off media queries #6247

Open LeaVerou opened 3 years ago

LeaVerou commented 3 years ago

Authors often need to duplicate rules across media queries and selectors. For example, this kind of thing is common:

@media (prefers-color-scheme: dark) {
    :root {
        /* dark mode rules */
    }
}

:root.dark {
    /* duplicated dark mode rules */
}

To my knowledge, there is no way to reduce this duplication with CSS, even if we take CSS nesting into account. Authors either write flimsy JS to toggle the class per the media query, use preprocessor mixins, or just live with the duplication.

Also, often media queries only contain a single rule, and could benefit from a more concise syntax.

With this proposal, the code above would be written as:

:root:is(.dark, :media(prefers-color-scheme: dark)) {
    /* dark mode rules */
}

Any :media() pseudos within a selector would desugar as media queries, joined with and and prepended with not accordingly (e.g. :media(foo):not(:media(bar)) should desugar to @media (foo) not (bar) {...}).

tabatkins commented 3 years ago

I definitely agree with this functionality. Having some tests appear in an MQ vs others appearing in a selector means we have to do some awkward nesting and reduplication. This is also the motivation behind a generic conditional rule that combines @media and @supports (the @when proposal that I need to revive).

With that background, perhaps we should make the pseudo-class name more generic, so that it can handle support queries as well?

LeaVerou commented 3 years ago

With that background, perhaps we should make the pseudo-class name more generic, so that it can handle support queries as well?

Agreed, I was wondering about it when I opened the issue, but decided against it as I thought it would complicate the syntax too much. But if you also think so, let's do that.

argyleink commented 3 years ago

I ran into this last week and after asking Tab about it, have found myself here!

Here's a reduced case https://codepen.io/argyleink/pen/OJpmEWE where I would hope to eliminate the duplication between light and dark

bkimmel commented 3 years ago

I love this idea. I kinda wish media queries just worked this way in the first place.

mirisuzanne commented 3 years ago

We discussed this as an approach to container queries, and it's probably good to consider them as part of this conversation. If the only syntax is selector-based, it makes larger changes (impacting multiple selectors) more repetitive – though nesting could help that problem:

.card:container(width > 30em) {
  & .card-image { … }
  & .card-content { … }
  & .card-footer { … }
}

With container queries specifically, this also introduces a different way to solve the "named container" problem – but it can be kinda confusing as well. Right now we always have the selector-target query its nearest container:

/* body and card are both containers */
body, .card { contain: inline-size style layout; }

@container (width > 30em) {
  .card { /* card queries the size of body */ }
  .card .content { /* content queries the size of card */ }
}

.card:container(width > 30em) .content {
  /* card queries the size of body, in order to update content */
}

I don't see it as a universally better solution than the at-rule syntax, but I do think it can provide some interesting trade-offs. Interested to see where this conversation goes.

andruud commented 3 years ago

Adding :container probably exceeds acceptable complexity limits. It makes invalidation much more annoying, since we would need to figure out if a given selector would have matched without :container pseudos, and in order do that correctly we would probably need to evaluate any non-matching selector with :container twice, which I'd rather not do.

Also, you know Emilio won't accept that it makes Element.matches depend on layout.

Also also, it's not compatible with other CSS things, e.g.:

body {
  width: 40em;
  contain: inline-size style layout;
}

body:has(.card:container(width > 30em)) {
  width: 20em;
}
daKmoR commented 2 years ago

My use case is "to provide the user a way to override the system setting for light/dark mode". e.g. as a user, I want to set a dark/light mode on a per-website basis and not change my system settings.

typically that is nowadays implemented in a way that sets an attribute on the body like <body theme="dark"> or <body theme="light">.

However, that means that in this case media queries like @media (prefers-color-scheme: dark) { do become "hostile" because as soon as they are used somewhere I will be forced to "override" it back.

So not only do I need to duplicate the dark mode styles but also the light mode styles 😭

It will look something like this Codepen: https://codepen.io/daKmoR/pen/WNMZpMm

/* light mode is default */
:root {
  --p-color: black;
  --p-background: white;
}

@media (prefers-color-scheme: dark) {
  :root {
    /* dark mode rules */
    --p-color: white;
    --p-background: black;
  }
}

:root[theme=light] {
  /* override back to light mode - duplicating  */
  --p-color: black;
  --p-background: white;
}

:root[theme=dark] {
  /* duplicated dark mode rules */
  --p-color: white;
  --p-background: black;
}

p {
  color: var(--p-color);
  background: var(--p-background);
}

shadow root

In the above example, if someone uses a media query in a CSS file you can at least "solve" it by adding more duplicate CSS.

However, once you have a shadow root it means you can no longer solve it on CSS level 😭

Consider this code in a shadow root Codepen: https://codepen.io/daKmoR/pen/jOZGBgO

  <style>
    :host {
      --p-color: black;
      --p-background: white;
    }
    @media (prefers-color-scheme: dark) {
        :host {
            /* dark mode rules */
        --p-color: white;
        --p-background: black;
      }
    }
    p {
      color: var(--p-color);
      background: var(--p-background);
    }
    /* no way to override it based on the fakeroot theme */
    /* selectors like body[theme=light]" will not select anything */   
  </style>  
  <p>text in shadow root</p>

within the shadow root there is no way to adjust the color based on a bodys attribute as far as I know. outside of the shadow root you can not influence the css of the inside...

also imagine that this component with the shadow root is potentially an external dependency either from open source or from a different team/department.

Workaround

The only why I can think 😅

  1. Duplicate the theme attribute to EVERY component

    <body theme="dark">
    <my-el theme="dark"></my-el>
    <other-el theme="dark"></other-el>
    </body>
  2. Enfore a company wide "ban" on using @media (prefers-color-scheme: dark) and on any external dependency using it

With that you can then ALWAYS force using the theme attribute

:host([theme=dark]) {
  /* dark theme styles */
}

Imho these are massive downsides and basically prevent usage of a core platform feature like media queries.

Suggestion

Provide a way to set a "user prefers color scheme". Something that can be set via js.

window.setMedia('prefers-color-scheme', 'dark');`

and then all media queries like @media (prefers-color-scheme: dark) { would be true.

That would mean you can use media queries in all cases and no duplication in any css would be needed.

A see that this is a completely different "solution"... is there already an issue for a solution in this direction or shall I open a new one?

jakearchibald commented 1 year ago

Rather than make media queries & container queries work in selectors, would it be better to make selectors work in @ rules when nested?

I haven't thought hard about the parsing here, but something like:

:root {
  @media (prefers-color-scheme: dark),
  &.dark {
    /* dark mode rules */
  }
}
romainmenke commented 1 year ago

Might be more interesting to leverage @scope as mentioned in the examples. https://drafts.csswg.org/css-cascade-6/#scoped-styles

@scope (.light-scheme) {
  a { color: darkmagenta; }
}

@scope (.dark-scheme) {
  a { color: plum; }
}

With @when it could become :

@when media(prefers-color-scheme: dark) or scope(.dark) {
  /* styles */
}

I do think it will have some weird side-effects to mix conditional at-rules and selectors:

@when media(prefers-color-scheme: dark) {
  @layer foo {
    /* this is fine */
  }
}
.dark {
  @layer foo {
    /* this is not fine */
  }
}
@when media(prefers-color-scheme: dark) or scope(.dark) {
  @layer foo {
    /* ?? */
  }
}

Same is true for other syntax proposals.

:root {
  @media (prefers-color-scheme: dark),
  &.dark {
    @layer foo {
      /* ?? */
    }
  }
}
mirisuzanne commented 1 year ago

Could also consider a selector() function in the @when syntax? I'm not sure if that's been discussed.

I realized the other day that style queries help work around this issue, but with an unfortunate parent/child limitation. We can do something like:

@media (prefers-color-scheme: dark) {
  html { --mode: dark; }
}

.dark-mode { --mode: dark; }

/* the result of this query is based on the combined media-query/selector resolutions */
@container style(--mode: dark) {
  /* has to be a descendant of the element that sets the value 👎🏼 */
  body {
    background: black;
    color: white;
    /* the full list of colors only have to be defined in one place 👍🏼 */
  }
}

But it feels like a workaround, rather than a full solution.

tabatkins commented 1 year ago

Putting selectors into MQs/etc has the wrong semantics; the selector isn't a query with a truth result, it's actually selecting elements, a completely independent action from the conditional itself. :media() has the correct semantics.

jakearchibald commented 1 year ago

:media() has the correct semantics.

Well, not really. Pseudo-classes are query something about the element, whereas :media() is querying the viewport. It's a bit of a hack that it's classed to the element.

Fwiw, that's why I suggested:

:root {
  @media (prefers-color-scheme: dark),
  &.dark {
    /* dark mode rules */
  }
}

It's saying "this block of styles applies when this media query matches, or where this selector matches". It isn't putting MQs into element classes, nor is it putting selectors into MQs.

tabatkins commented 1 year ago

Pseudo-classes are query something about the element,

My point is that the semantics of a pseudo-class (any selector, really) is "filter the currently-matched set of elements according to condition X". The fact that :media()'s condition isn't element-specific doesn't change this, it just makes it feel a little funny. (We could always define that the pseudo-class only matches on the root element, to make it feel more "global", but I think that would make things unnecessarily annoying in practice.)

Conditional rules, on the other hand, either match or not, and activate or deactivate the rules inside of them. They haven't previously had any effect on the set of matched elements. Obviously we could add this, changing their pattern of behavior, but it would be a fairly significant change. We could no longer imagine conditional rules as "turning off" rules, but rather as a type of selector in itself that filters the set of matched elements.

(Also a nit: putting selector syntax nakedly into MQ syntax would be a no-go; the grammar is too wide and would force us to be very careful evolving both selectors and MQs in the future. But a selector() function would work.)

jonathantneal commented 1 year ago

Pseudo-classes are query something about the element, whereas :media() is querying the viewport.

I think context is something about the element. A similar API would be :has().

Here is a hypothetical example of context by target class or media query:

:root:is(.dark, :when(media(prefers-color-scheme: dark))) {
    /* dark mode rules */
}

Here is a hypothetical example of context by target class or style query:

:root:is(.dark, :when(style(--mode: dark))) {
    /* dark mode rules */
}
NickGard commented 1 year ago

Getting back to the problem of code duplication, could we do something similar to SASS's mixin?

Instead of setting a Custom Property flag and using container queries to style, you could add a block of code to a ruleset.

/* With flag */
.dark { --mode: dark; }
@media(prefers-color-scheme: dark) { --mode: dark; }

@container(style(--mode: dark)) {
  /* rules */
}

/* With mixin */
@mixin dark-mode {
  /* rules and nested rules that will map to the selector that includes the mixin */
}

.dark { @includes dark-mode }
@media(prefers-color-scheme: dark) {
  @includes dark-mode 
}

It doesn't have to be as full-featured as SASS's mixin, just the code reuse and dynamic scoping would be enough.

brandonmcconnell commented 1 month ago

Would this pseudo-class counterpart to the @media at-rule work directly on selectors or only inside :is() and :not()? I'd guess both, but I only see examples within other pseudo-class rules on this thread.

Assuming this applies to all applicable at-rules, this might also solve the issue/proposal I opened today in #10356.

This way, @starting-style rules can be applied inline with other selector-based rules to avoid redundancy. One of the spec's authors, @dbaron, also spoke into the discussion and voiced a similar sentiment.

I agree that we ended up with a solution that requires a bunch of repetition that I'm not happy about.

Having a way to use these at-rules inline as pseudos would be amazing.

I initially preferred the syntax proposed by @jakearchibald (above) and by @jothsa in #8840, but that would also impose some limitations. In the case of @starting-style, this would still not allow the at-rule to mix with selector logic in the ways necessary to remove redundancy.

However, with a pseudo-class rule counterpart available, much of the redundancy in this example:

dialog {
  transform: translateY(-50%);
  &, &::backdrop {
    transition: all 0.25s ease-out allow-discrete;
    opacity: 0;
  }
  &[open] {
    transform: translateY(0);
    &, &::backdrop {
      opacity: 1;
    }
  }
  @starting-style {
    &[open] {
      transform: translateY(-50%);
      &, &::backdrop {
        opacity: 0;
      }
    }
  }
}

…can be consolidated, leaving us with this:

dialog {
  &, &[open]:starting-style {
    transform: translateY(-50%);
    &, &::backdrop {
      transition: all 0.25s ease-out allow-discrete;
      opacity: 0;
    }
  }
  &[open] {
    transform: translateY(0);
    &, &::backdrop {
      opacity: 1;
    }
  }
}

I think @jonathantneal's suggestion of introducing a :when pseudo for this could also work well, as long as starting-style would be valid within @when() rules :when() wouldn't pose a significant risk of confusion (between :where() and :when()).

If we did want a way to match the query syntax exactly, we could consider :matches(@media(…)), but I think that might be a step backward with the more flexible @when on the horizon.


It might help to organize a comprehensive list of all at-rules that could benefit from a pseudo-class counterpart like this.

Or should we introduce only one new pseudo-class, :when() which can be used to test for any of these and others?