This is a the current draft for a syntax for expressing element queries as an at-rule in CSS.
Inspired by ideas from Tommy Hodgins, Maxime Euzière, & Viktor Hubert
<element-query>
= @element <selector-list>
<condition-list>
<event-list>
{ <scoped-stylesheet>
}
<condition-list>
= ( <feature>
: <value>
) [ and ( <feature>
: <value>
) ]*
<feature>
= width
| height
| characters
| children
| scroll-x
| scroll-y
| aspect-ratio
| orientation
<value>
: <integer>
| <dimension>
| <ratio>
| <ident>
<event-list>
= [ on <event>
[ , <event>
]* ]
<event>
= window.load
| window.resize
| window.input
| window.scroll
| window.click
| self.resize
| self.scroll
| self.input
| self.click
| self.mutate
| self.intersect
| etc…
<scoped-stylesheet>
= <css-stylesheet>
plus :self
, maybe :parent
, and maybe :previous
Note: In addition to supporting things like
min-width
andmax-width
, possibly expand<condition-list>
to support Media Queries-style range features like(400px < width < 1000px)
A basic example could look like this. This would target all <div>
elements in a document and apply the inner <scoped-stylesheet>
to each <div>
that tests true
to the conditions in the <condition-list>
.
@element div (min-width: 500px) {
:self {
background: lime;
}
}
In this case the selector :self
allows us to target those elements matching the condition, leaving other <div>
elements on the page alone.
An equivalent rule that specifies events might look like this:
@element div (min-width: 500px) on self.resize {
:self {
background: lime;
}
}
In this case we're whitelisting the specific events we want to trigger a recalculation of these conditions rather than relying on the browser's best guess.
For an even more limited example, what if this scoped stylesheet only listened to the scroll
events of <textarea>
tags in the document?
@element textarea (min-scroll-y: 50%) on self.scroll {
:self {
background: hotpink;
}
}
Perhaps Mutation Observer could be used to help listen for changes in characters
and children
:
@element input (min-characters: 5) on self.mutate {
:self {
background: red;
}
}
While that should catch all of our changes, it should be equivalent to listening to self.input
for this:
@element input (min-characters: 5) on self.input {
:self {
background: red;
}
}
Which of course would also be caught by listening to window.input
, even though that will be recalculating the conditions far more than we will likely need:
@element input (min-characters: 5) on window.input {
:self {
background: red;
}
}
The <scoped-stylesheet>
is similar to a normal CSS stylesheet, with a few key differences:
- the stylesheet is scoped to each elements in the
<selector-list>
passing the conditions in the<condition-list>
- you can use new selectors
:self
,:parent
, and:previous
Thus, an element query with a scoped stylesheet like this:
@element #example (min-width: 500px) {
:self {}
h2 {}
:self p {}
* {}
* * {}
:self.class {}
.class:self {}
div:self {}
p :self {} /* not gonna work */
:parent {}
:parent :self {}
:previous {}
}
Could be thought of like this, if you think of :is()
being a selector that selects the element(s) in the document matching both a selector in the <selector-list>
as well as all conditions on the <condition-list>
in a uniquely identifying way.
This example uses an ID (#example
) for clarity, but everywhere you see :is(#example)
in the stylesheet below, keep in mind it's really like :is(:self)
. If the original selector had been div
, it wouldn't be :is(div)
, because that would match every <div>
, but only those <div>
tags that match all of the conditions of the <condition-list>
:
:is(#example) {}
:is(#example) h2 {}
:is(#example) p {}
:is(#example) * {}
:is(#example) * * {}
.class:is(#example) {}
.class:is(#example) {}
div:is(#example) {}
:is(#example) p :is(#example) {} /* not gonna work */
:has(:is(#example)) {}
:has(:is(#example)) :is(#example) {}
:has(+ :is(#example)) {}
The :self
selector refers to those element(s) in the document matching both a selector in the <selector-list>
as well as all of the conditions in the <condition-list>
.
The :parent
selector refers to the parentElement
of those element(s) in the document matching both a selector in the <selector-list>
as well as all of the conditions in the <condition-list>
.
The :previous
selector refers to the previousElementSibling
of those element(s) in the document matching both a selector in the <selector-list>
as well as all of the conditions in the <condition-list>
.
There are a number of conditions that are possible to support, but by far the most significant for web design will be width
. Here's a list of features, as well as some JavaScript tests to help explain what they do. Unlike JavaScript, these conditions should support CSS units.
The width
feature tests a value against an element's offsetWidth
.
@element div (min-width: 500px) {}
@element div (max-width: 500px) {}
@element div (500px <= width) {}
@element div (width <= 500px) {}
@element div (500px <= width < 1000px) {}
function minWidth(el, number) {
return number <= el.offsetWidth
}
function maxWidth(el, number) {
return number >= el.offsetWidth
}
The height
feature tests a value against an element's offsetHeight
.
@element div (min-height: 500px) {}
@element div (max-height: 500px) {}
@element div (500px <= height) {}
@element div (height <= 500px) {}
@element div (500px <= height < 1000px) {}
function minHeight(el, number) {
return number <= el.offsetHeight
}
function maxHeight(el, number) {
return number >= el.offsetHeight
}
The characters
feature tests a value against the length of an element's textContent
or value
.
@element div (min-characters: 5) {}
@element div (max-characters: 5) {}
@element div (5 <= characters) {}
@element div (characters <= 5) {}
@element div (5 <= characters < 10) {}
function minCharacters(el, number) {
return number <= ((el.value && el.value.length) || el.textContent.length)
}
function maxCharacters(el, number) {
return number >= ((el.value && el.value.length) || el.textContent.length)
}
The children
feature tests a value against the length of an element's children
.
@element div (min-children: 5) {}
@element div (max-children: 5) {}
@element div (5 <= children) {}
@element div (children <= 5) {}
@element div (5 <= children < 10) {}
function minChildren(el, number) {
return number <= el.children.length
}
function maxChildren(el, number) {
return number >= el.children.length
}
The scroll-x
feature tests a value against an element's scrollLeft
.
@element div (min-scroll-x: 50px) {}
@element div (max-scroll-x: 50px) {}
@element div (50 <= scroll-x) {}
@element div (scroll-x <= 50) {}
@element div (50 < scroll-x < 100) {}
function minScrollX(el, number) {
return number <= el.scrollLeft
}
function maxScrollX(el, number) {
return number >= el.scrollLeft
}
The scroll-y
feature tests a value against an element's scrollTop
.
@element div (min-scroll-y: 50px) {}
@element div (max-scroll-y: 50px) {}
@element div (50 <= scroll-y) {}
@element div (scroll-y <= 50) {}
@element div (50 < scroll-y < 100) {}
function minScrollY(el, number) {
return number <= el.scrollTop
}
function maxScrollY(el, number) {
return number >= el.scrollTop
}
The aspect-ratio
feature tests a value against the ratio of an element's offsetWidth
and offsetHeight
.
@element div (min-aspect-ratio: 16/9) {}
@element div (max-aspect-ratio: 16/9) {}
@element div (16/9 <= aspect-ratio) {}
@element div (aspect-ratio <= 16/9) {}
@element div (4/3 < aspect-ratio < 16/9) {}
function minAspectRatio(el, number) {
return number <= el.offsetWidth / el.offsetHeight
}
function maxAspectRatio(el, number) {
return number >= el.offsetWidth / el.offsetHeight
}
The aspect-ratio
feature tests a value against the ratio of an element's offsetWidth
and offsetHeight
.
@element div (orientation: portrait) {}
@element div (orientation: square) {}
@element div (orientation: lanscape) {}
function orientation(el, string) {
switch (string) {
case 'portrait': return el.offsetWidth < el.offsetHeight
case 'square': return el.offsetWidth === el.offsetHeight
case 'landscape': return el.offsetWidth > el.offsetHeight
}
}
In cases where features like width
, height
, aspect-ratio
, orientation
are concerned, using window.load
, window.resize
, and Resize Observer (via self.resize
) could be left up to be the browser, unless specified by the user.
In cases where features like characters
or children
are used, events like window.load
, window.input
, and Mutation Observer (via self.mutate
) could all work toward the same results.
Note: There should also be a way to trigger a recalculation of a stylesheet from JavaScript (Does this mean they would need a unique name of label?)
When you use these features, these are events that help you make sure the styles recalculate at the right times:
First, for every feature, window.load
seems like a great time to calculate what you need. Then, one or more of the following events can help:
width
:window.resize
, Resize Observerheight
:window.resize
, Resize Observercharacters
:window.input
,self.input
, Mutation Observerchildren
: Mutation Observerscroll-x
:window.scroll
,self.scroll
, Intersection Observerscroll-y
:window.scroll
,self.scroll
, Intersection Observeraspect-ratio
:window.resize
, Resize Observerorientation
:window.resize
, Resize Observer
In addition, if things like :hover
are used in the <selector-list>
of the query, events like self.mouseenter
and self.mouseleave
can be listened to. For :focus
it makes sense to listen to self.focus
and self.blur
. For :active
it makes sense to listen to self.input
, self.click
, etc.