WICG / container-queries

Other
92 stars 10 forks source link

2019 Proposal/Solution for Container Queries #12

Open matthew-dean opened 5 years ago

matthew-dean commented 5 years ago

Before I go into details of this algorithm / solution, it's useful to establish some semantics.

First, there is some historical conflation of the terms "container queries" and "element queries". Depending on the solution (how someone thinks about the problem), these are often conflated to mean the same thing. There's discussion here about renaming the repo from "container queries" to "element queries" based on various conventions, etc, but I find that what's often missed when people talk about container queries vs. element queries is that people think they're talking about the same thing, when they often aren't.

To clarify:

Someone may say, "Yes, but isn't the container always an element?" In short, no. In some proposals, yes. In this proposal, no, and for (IMO) very good reasons.

The 2019 Container Queries Proposal

Summary

This algorithm / approach has the following:

What are we querying?

This is absolutely crucial, and what makes this query algorithm fast (as fast as media queries), deterministic, and single-pass.

What we are querying is the allocated width/height of the content box that this element will be placed in.

screen shot 2019-01-06 at 12 23 45 pm

Immediately, someone may point out that the content box eventual dimensions can be affected by children. In order for this algorithm to be successful, that's why we must query what the content box is irrespective of children. In other words, what are the dimensions if no children were present?**

** Note: this doesn't mean we would measure as if the container were truly empty; specifically, an :empty selector would not apply during measurement if children are present, children just don't affect the allocated width/height for the purposes of this algorithm.

Why the content box?

Just to briefly address this question, the reason why we query the content box specifically vs. the parent element's width is to avoid ambiguity / surprise when switching box models. Queries always refer to the "available space" to the child element, so that you can set properties of the child based on what pixels (or other units) will actually be available. Querying the parent element's actual (or calculated) width value would strongly bind the child element to the parent's box model. Therefore, the container must be a known constant: the content box dimensions of the parent before children are rendered.

Syntax additions

:container([MQ]) - selector query of the container, using MQ Level 3 or Level 4-style queries, and can query width / height [/ inline / block] (-axes) aw / ah / ai / ab - units relating to 1% of width / height / inline-axis / block-axis (semantically similar to vw / vh / vi / vb)

Examples

<div class="parent">
  <div class="child"></div>
</div>
.parent {
  width: 100px;
  height: 200px;
}
.child {
  background: red;
  color: white;
}
/* Also can be written (min-width: 100px) */
.child:container(width >= 100px) { 
  background: blue;
}

So far, this is similar to most other container query algorithms. Where it differs is that this query is non-circular.

For example:

.parent {
  min-width: 50px;
}
.child:container(width <= 50px) { 
  width: 200px;
}
/** Will not apply **/
.child:container(width >= 200px) {
  width: 50px;
}

The .parent class, when laying out, has only been allocated a width of 50px. Therefore the allocated width of .parent will stay fixed at 50px unless it is allocated a new width (not sized by children). (100aw = 50px)

Here's another simple example, assuming the same markup

.parent {
  float: left;
}
.child {
  width: 200px;
  height: 100px;
  background: red;
}
.child:container(min-width: 200px) {
  background: blue;
}

In this example, because a float "wraps" around the child, and has no defined width to calculate irrespective of children, then the allocated width/height are both 0 (zero). Meaning the container query (min-width: 200px) will not apply, and the background of .child will be red.

We can make the container query apply by changing this to the following:

.parent {
  float: left;
  width: 200px;
  height: 100px;
}
.child {
  width: 100%;
  height: 100%;
  background: red;
}
.child:container(min-width: 200px) {
  background: blue;
}

In the above example, the background of .child will be blue.

Other collapsing boxes

Other examples of boxes that don't have allocated width / height would be inline boxes (display: inline-box / inline-flex / inline-grid), or using width: fit-content / min-content. If they don't have a width / min-width or height / min-height value that can calculate an allocated value before the layout of children is determined, then those values will be zero.

width: auto

Note that block boxes with a width of auto will have an allocated (non-zero) value. That is, an allocated width can be determined irrespective of children.

So, given a viewport of 1024px, and this markup:

<body>
  <div class="wrapper"></div>
</body>
body {
  padding: 0; margin: 0;
}
.wrapper:container(width >= 1024px) {
  background: red;
}

In this case, a user agent stylesheet styles <body> as a block with a width of auto. Therefore the wrapper's container query applies and the background will be red.

An example with CSS Grid

Note that a container query applies to the pre-children content box of the parent. If you are slotting children into a grid, the queries of direct children of the grid will not be related to column / row slotting.

Meaning:

.parent {
  width: 300px;
  display: grid;
  grid-template-columns: 1fr 1fr 1fr;
}
.child {
  grid-column: 1;
}
/** Does not apply */
.child:container(width = 100px) {
  background: blue;
}
/** Does apply */
.child:container(width = 300px) {
  background: red;
}

The above example helps make the important distinction between element queries and container queries. We're not querying the width that will be calculated for .child. We are strictly querying the initial content box of .parent.

However, we could easily nest children to "query" the width of the column for some powerful layout possibilities.

Given the following:

<div class="grid">
  <div class="grid-item">
    <div class="child">First</div>
  </div>
  <div class="grid-item">
    <div class="child">Second</div>
  </div>
</div>
.grid {
  display: grid;
  width: 300px;
  grid-template-columns: 2fr 1fr;
}
.child {
  height: 100px;
  color: white;
}
.child:container(width = 200px) {
  background: red;
}
.child:container(width = 100px) {
  background: blue;
}

This would give you a layout like this:

screen shot 2019-01-06 at 1 27 21 pm

Once .grid-item is slotted into a column, it has a calculated initial content box width that can then be queried by the .child.

Using height / inline / block

Most of these examples have been querying width because of the way we typically layout pages, and because of default collapsing behavior. But, given a fixed (or minimum) height on a parent, it can also be queried.

.parent {
  height: 100px;
}
.child:container(height >= 100px) {
  background: blue;
}

We can also query the inline-axis or block-axis, depending on writing direction or layout settings.

.child:container(inline >= 100px) {
  background: blue;
}
.child:container(block >= 100px) {
  background: blue;
}

Page / programmatic resizing

An important thing to note is that while the initial content box dimensions of an element used for a query can't be affected by children layout, it doesn't mean it's a "fixed" value per page load. A parent element may be resized by resize of the viewport (depending on initial width / height values) or changed programmatically.

On resize, the browser must determine if a parent element has a new initial content box size. In other words, just because the element is "resized", doesn't mean the initial content box size has changed.

This is best demonstrated by example. In the following example, no amount of "resizing" the viewport / browser window will cause the container query to match.

.parent {
  float: left;
}
.child {
  width: 100vw;
}
.child:container(width >= 50vw) {
  background: blue;
}

The initial content box width of parent is always 0 (zero), even though the .parent element may be visibly resizing on-screen. (As an aside, this is an advantage over ResizeObserver-based polyfills of content queries, which are subject to circularity, since they respond to any resize of the element.)

In the following example, however, resizing of the viewport will trigger matches / un-matches of the container query, and will dynamically apply those styles.

/** Assuming parent is a child of `<body>` */
.parent {
  width: auto;
}
.child {
  background: blue;
}
.child:container(width >= 200px) {
  background: black;
}
.child:container(width >= 400px) {
  background: red;
}

In the above example, note that container queries follow rules of the cascade. By default, .child has a background of blue. Once the available content width is 200px or greater, the background is black, overriding blue. Once the available content width is 400px or greater, the background is red, overriding black and blue.

Resizing with JavaScript

If the .parent has explicit dimensions set as inline styles with JavaScript, then the available content box width would be updated, and container queries that query the .parent content box would be re-evaluated.

Using allocated units

2019 Container Queries are extremely powerful, but have an important companion piece which is allocated units. This allows you to easily make your CSS styles more modular / adaptive regardless of container.

Allocated units (aw / ah / ai / ab) are units relating to 1% of initial container box width / height / inline-axis / block-axis, respectively.

Let's re-use our grid example.

<div class="grid">
  <div class="grid-item">
    <div class="child">First Child</div>
  </div>
  <div class="grid-item">
    <div class="child">Second Child</div>
  </div>
</div>

Based on how much space .child may take, we can scale font-size to be reflective of that additional space.

.grid {
  display: grid;
  width: 310px;
  grid-template-columns: 2fr 1fr;
  grid-gap: 10px;
}
.child {
  height: 100px;
  padding: 10px;
  color: white;
  background: blue;
  font-size: 15aw;  /** 15% of allocated content box width */
}

This would result in:

screen shot 2019-01-06 at 2 30 07 pm

Of course, you can use a combination of calc() or newer min(), max() or clamp() CSS functions (when available) to moderate the range / effect of allocated units.

As a result, you can define individual, reusable "modules" that adapt to defined containers.

Note that 100aw or 100ah etc. may resolve to 0 (zero) if the parent content box has no intrinsic dimensions.

Question to resolve: Would it be important to define a syntax for a default container width/height for a child element if a query returns a zero for either value? Or is min() / clamp() sufficient? 🤔

More advanced queries

A variety of examples of queries

/** MQ4 - value range */
.a:container(400px < width < 1000px) {}

/** can target children of queries, as expected */
.a:container(width > 10em) .b {}

/** negating queries - not equal to 100px */
.a:not(:container(width = 100px)) {}

/** Joining queries */
.a:container(width > 100px):container(height > 100px) {}

/** Nesting queries */
.a:container(width > 100px) .b:container(width > 50px) {}

Feedback

Feedback welcome. There are likely to be opinions™ around various points of this proposal, such as syntax. I think the selling points are, because this query algorithm is one-way, fast, and predictable, it's something that I believe could be implemented in browsers much sooner than previous proposals, mostly because of zero circularity. Layout / queries can be determined in a single pass, as quickly as media queries are today. This makes this a potential drop-in replacement for many/most media queries, since as noted by many other, smarter people, many people use @media queries when what they mean are container queries (the available width for my component).

That said, there are obvious use cases of using both @media and 2019 Container Queries. I just didn't want to get too far in the weeds of creating use cases / examples, as this proposal was already quite long.

dbaron commented 5 years ago

So I'd like to give some reactions to this proposal.

  1. As background, I should point out that one of the costs of doing container queries or element queries is separation of the style and layout phases for each section of the tree, based on the boundaries where container queries are used. In other words, if an element has a container query, then layout of the element and its ancestors must proceed before styling of its descendants. This has the cost of defeating various mechanisms that optimize these phases (particularly those involving paralellism). However, it's still less expensive (at least in cases where the correct style can't be predicted the first time) then the work involved in ResizeObserver-based solutions where the work for the descendants gets done twice.

  2. Given that, I'd note that I think this proposal introduces at least two categories of performance costs that are unnecessary for a good container queries proposal:

    • It introduces the cost of doing two layouts: it requires layout under the hypothetical that the element has no children, and then another layout for real. This distinction is not necessarily trivial; CSS's layout systems are complicated. While the "no children" hypothetical may sound simple, I think it is not in fact simple, and I think it basically makes this proposal a non-starter (also see paragraph 3). I'd also note that while you might think that the "no children" hypothetical is where the browser begins the layout algorithm -- that's somewhat true during initial layout, but I think less true during incremental relayout. Refactoring browser layout algorithms for all (or even many) display types to optimize this better than just doing two layouts also seems like an extremely large amount of work that wouldn't be justified by this feature alone.
    • The introduction of ai, aw, etc. units causes the performance penalty in (1) every time they are used. So while these units may look cheap, they're actually a somewhat expensive feature that I'd be hesitant to make a unit for because I think developers have a reasonable expectation that a unit in CSS is something that's cheap to compute and doesn't introduce substantial performance penalties. (That said, we've broken this expectation before, but I think this case is worse.)
  3. Many CSS layout algorithms have complex cross-dependencies. For example, in tables, flex, and grid, sizes of one item depend on the size of others. This makes this "no children" hypothetical even worse: given one of these display types that has container queries inside of multiple parts, you need to do a hypothetical layout of the entire thing, separately, for the hypothetical of each piece with a container query having no children. This gets worse in the presence of nested container queries, and gets worse still given the possibility that the result of one container query could influence the result of a different one (e.g., by changing the size of the children). I think this makes this proposal, at least as initially defined, unworkable. (It's possible I may be misunderstanding the model you're proposing, though, but it's a little hard to understand what you're proposing at a detailed level. I find the examples to be somewhat confusing and not clearly describe what's happening in each one; for example one uses the term "allocated width" which seems to be some intermediate state within your resolution model, but it's not clear what.)

These are the sorts of reasons why I think a workable solution for container queries or element queries is going to need to depend on something like CSS containment, which provides a much stricter boundary between what happens inside an element and what happens outside of it. Even then, I think if we loosen containment by providing single-dimension containment, we'd need to analyze carefully for risks of the problem in type (3) above.

matthew-dean commented 5 years ago

@dbaron Thanks for a well-thought-out, informed response! I'd been hoping to get some discussion on it.

Many CSS layout algorithms have complex cross-dependencies. For example, in tables, flex, and grid, sizes of one item depend on the size of others.

This is probably the most on-point criticism, which is that querying the container is difficult because CSS layout algorithms are often not only both directions (parent -> child, child -> parent), but also sibling -> sibling, and often a combination of those. So, querying parent from child is not as trivial as I made it sound.

These are the sorts of reasons why I think a workable solution for container queries or element queries is going to need to depend on something like CSS containment, which provides a much stricter boundary between what happens inside an element and what happens outside of it.

By this, do you mean some kind of syntax or property that isolates layout behavior to be single-directional, making queries predictable? Are there proposals for this? Would this be something like the "frameless iframe/webview" concept, where the sub-tree is isolated in some manner and can query it's own box like a standard media query?

I'd love to help find a workable proposal, and I don't disagree at all with the potential pitfalls, so I'd love your thoughts on what kind of tweaks could make it work.

dbaron commented 5 years ago

I probably should have been clear that I meant the CSS Containment spec. It currently contains some of the primitives that I think are needed, but not all of them: it doesn't have any mechanism for having size containment only in a single axis.

The idea that I was thinking of was the idea of a container queries mechanism that worked only on elements that have contain:layout and (in the relevant axis) contain: size. I don't have specific ideas on what syntax would make sense, or on what the fallback behavior would be when the containment conditions aren't met (or whether the use of the query would force the necessary containment, although my initial reaction is that that approach may be undesirable). (And the questions of syntax and fallback behavior are probably closely related.)

I think this idea has had some discussion in some other places as well, e.g., in w3c/csswg-drafts#3852

matthew-dean commented 4 years ago

@dbaron Can you explain why element-relative units are a substantially larger calculation than the proposed aspect-ratio property? I quoted your statement here: https://github.com/w3c/csswg-drafts/issues/333#issuecomment-525510638 but I don't really get how the calculation is anything more than infinitesimally more expensive. Either way, an aspect ratio would have to compute the size in one direction in order to compute the size of the other. The difference in defining a unit is that you could adjust what fraction of that calculation you're using.

dbaron commented 4 years ago

So if I'm understanding things correctly, relative units are something that would take some result of layout of the parent element (or maybe some ancestor element) and apply it to the style of a child element (or descendant). An aspect-ratio property would apply constraints during the layout process of a single element.

I think there are two differences:

ByteEater-pl commented 4 years ago

However, we could easily nest children to "query" the width of the column for some powerful layout possibilities.

Those .grid-item elements aren't semantic. I see 2 alternatives: • Would it work with a parent inserted via shadow DOM? I suppose so. If I understand correctly, there are no shadow boundaries in the box tree and this is the relevant stage here. • Seems a good use case for the proposed ::contents pseudo-element too.

Also, although achievable with mathematical functions, it'd be nice to have amin and amax too.