Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changepacks/changepack_log_selectors.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"changes": {
"bindings/devup-ui-wasm/package.json": "Patch"
},
"note": "Selectors: a nested rule without `&` in a styled or css template is scoped to the component instead of becoming a global rule (`.child {}` and `> p {}` select descendants, `:hover {}` and `::before {}` the component), and a rule nested in a selector list applies under each of its selectors; `content` keeps the quotes of its strings (`content: 'x'`, `content: \"\"`); `params` go on the pseudo-class that takes them, so `_groupNthChild` gives `:is(...):nth-child(2n) .a` also when nested; a bare `selectors` key is a pseudo-class name, an attribute of the element or a descendant selector (`'div p'`, `'> p'`), under a parent too, and keeps the case of class names; `::part()`, `::slotted()`, `::highlight()`, `::picker()` and the view-transition pseudo-elements get two colons. New build errors: a `_name` prop or selector key that names nothing (`_notASelector`, `selectors={{ div: ... }}`), and a string under a selector that is not CSS text (`_hover=\"external-class\"`)",
"date": "2026-09-30T00:00:00.000Z"
}
43 changes: 43 additions & 0 deletions apps/landing/src/app/(detail)/docs/api/selector/page.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,49 @@ The attributes defined in Pseudo-Class correspond to those defined in Selector,
</tbody>
</table>

Any other pseudo-class or pseudo-element works the same way, as `_nthChild` for `:nth-child` or `_placeholder` for `::placeholder`. A name that selects nothing, such as `_notASelector`, is a build error rather than CSS that matches nothing.

Pseudo-classes that take arguments get them from `params`, also in their group form:

```tsx
const example = (
<Box
_groupNthChild={{ params: ['odd'], color: 'red' }}
_nthChild={{ params: ['2n'], bg: 'gray' }}
/>
)
// :is([role=group],[data-group]):nth-child(odd) .a{color:red}
// .b:nth-child(2n){background:gray}
```

A selector takes styles: an object, or CSS text such as `_hover="color: red"`. Any other string, such as a class name, is a build error.

## `selectors`

`selectors` takes any selector. A key with `&` says where the element goes; a key without `&` is relative to the element:

- a pseudo-class name, as `hover` or `focusVisible`, is that pseudo-class of the element; a name that selects nothing, such as `div`, is a build error
- a key starting with `:`, `::` or `[` applies to the element itself, as `':hover'` or `'[aria-busy]'`
- any other selector selects descendants, as `'.child'`, `'> p'` or `'div p'`

```tsx
const example = (
<Box
selectors={{
'&:hover, .parent &': { color: 'red' },
'[aria-busy]': { opacity: 0.5 },
'> p': { m: 0 },
}}
/>
)
// .a:hover{color:red}
// .parent .b{color:red}
// .c[aria-busy]{opacity:.5}
// .d > p{margin:0}
```

Nested rules in `styled` and `css` templates follow styled-components and Emotion: `.child { }` and `> p { }` select descendants, `:hover { }` and `::before { }` apply to the component, and a rule nested in a selector list applies under each of its selectors.

## Media Shorthands

These props wrap their styles in a fixed media query. They accept responsive arrays and nest in either direction with selectors and other at-rules.
Expand Down
118 changes: 116 additions & 2 deletions libs/css/src/constant.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,6 @@ pub(super) static GLOBAL_ENUM_STYLE_PROPERTY: phf::Map<&str, phf::Map<&str, phf:
pub(super) static OPTIMIZE_MULTI_CSS_VALUE_PROPERTY: phf::Set<&str> = phf_set! {
"font-family",
"src",
"content",
"animation-name",
};

Expand All @@ -115,15 +114,130 @@ pub(super) static DOUBLE_SEPARATOR: phf::Set<&str> = phf_set! {
"first-letter",
"first-line",
"grammar-error",
"highlight",
"marker",
"part",
"picker",
"picker-icon",
"placeholder",
"scroll-marker",
"scroll-marker-group",
"selection",
"slotted",
"spelling-error",
"target-text",
"view-transition"
"view-transition",
"view-transition-group",
"view-transition-image-pair",
"view-transition-new",
"view-transition-old"
};

/// The pseudo-classes and pseudo-elements that take arguments, as `params`
pub(super) static FUNCTIONAL_PSEUDOS: phf::Set<&str> = phf_set! {
"active-view-transition-type",
"cue",
"cue-region",
"dir",
"has",
"highlight",
"host",
"host-context",
"is",
"lang",
"not",
"nth-child",
"nth-last-child",
"nth-last-of-type",
"nth-of-type",
"part",
"picker",
"slotted",
"state",
"view-transition-group",
"view-transition-image-pair",
"view-transition-new",
"view-transition-old",
"where"
};

/// The pseudo-classes a `_name` key or a bare `selectors` key can name
pub(super) static PSEUDO_CLASSES: phf::Set<&str> = phf_set! {
"active",
"active-view-transition",
"active-view-transition-type",
"any-link",
"autofill",
"blank",
"buffering",
"checked",
"current",
"default",
"defined",
"dir",
"disabled",
"empty",
"enabled",
"first",
"first-child",
"first-of-type",
"focus",
"focus-visible",
"focus-within",
"fullscreen",
"future",
"has",
"has-slotted",
"host",
"host-context",
"hover",
"in-range",
"indeterminate",
"invalid",
"is",
"lang",
"last-child",
"last-of-type",
"left",
"link",
"local-link",
"modal",
"muted",
"not",
"nth-child",
"nth-last-child",
"nth-last-of-type",
"nth-of-type",
"only-child",
"only-of-type",
"open",
"optional",
"out-of-range",
"past",
"paused",
"picture-in-picture",
"placeholder-shown",
"playing",
"popover-open",
"read-only",
"read-write",
"required",
"right",
"root",
"scope",
"seeking",
"stalled",
"state",
"target",
"target-current",
"target-within",
"user-invalid",
"user-valid",
"valid",
"visited",
"volume-locked",
"where",
"xr-overlay"
};

pub(super) static ZERO_PERCENT_FUNCTION: phf::Set<&str> = phf_set! {
Expand Down
70 changes: 63 additions & 7 deletions libs/css/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ use std::collections::BTreeMap;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{LazyLock, RwLock};

use crate::constant::{GLOBAL_ENUM_STYLE_PROPERTY, GLOBAL_STYLE_PROPERTY};
use crate::constant::{FUNCTIONAL_PSEUDOS, GLOBAL_ENUM_STYLE_PROPERTY, GLOBAL_STYLE_PROPERTY};
use crate::debug::is_debug;
use crate::file_map::get_file_num_by_filename;
use crate::num_to_nm_base::num_to_nm_base;
Expand Down Expand Up @@ -187,9 +187,9 @@ impl ExactSizeIterator for DisassembleProperty {}

#[must_use]
pub fn disassemble_property(property: &str) -> DisassembleProperty {
// Nested selector keys (`&:hover`, `:focus`, `.parent &`) are not properties;
// keep them verbatim so class names and case survive.
if property.starts_with(':') || property.contains('&') {
// Nested selector keys (`&:hover`, `:focus`, `.parent &`, `_hover`) are not
// properties; keep them verbatim so class names and case survive.
if property.starts_with([':', '_']) || property.contains('&') {
return DisassembleProperty::Fallback(Some(property.to_string()));
}
if let Some(properties) = HAS_CUSTOM_SHORTHANDS
Expand Down Expand Up @@ -287,12 +287,49 @@ pub fn get_custom_shorthand_names() -> Vec<String> {
)
}

/// `selector` with `params` on the last pseudo-class or pseudo-element that
/// takes them and has none yet, wherever nesting put it: a group form reads
/// `:is(...):nth-child(2n) &`, and a parent's selector may follow it
fn with_params(selector: &str, params: &str) -> String {
let at = params_position(selector).unwrap_or(selector.len());
format!("{}({params}){}", &selector[..at], &selector[at..])
}

fn params_position(selector: &str) -> Option<usize> {
let bytes = selector.as_bytes();
let mut depth = 0usize;
let mut found = None;
let mut index = 0;
while let Some(&byte) = bytes.get(index) {
match byte {
b'(' | b'[' => depth += 1,
b')' | b']' => depth = depth.saturating_sub(1),
b':' if depth == 0 => {
let start = index + 1 + usize::from(bytes.get(index + 1) == Some(&b':'));
let end = selector[start..]
.find(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
.map_or(selector.len(), |length| start + length);
if FUNCTIONAL_PSEUDOS.contains(&selector[start..end])
&& bytes.get(end) != Some(&b'(')
{
found = Some(end);
}
index = end;
continue;
}
_ => {}
}
index += 1;
}
found
}

#[must_use]
pub fn add_selector_params(selector: StyleSelector, params: &str) -> StyleSelector {
match selector {
StyleSelector::Selector(value) => StyleSelector::Selector(format!("{value}({params})")),
StyleSelector::Selector(value) => StyleSelector::Selector(with_params(&value, params)),
StyleSelector::Global(value, file) => {
StyleSelector::Global(format!("{value}({params})"), file)
StyleSelector::Global(with_params(&value, params), file)
}
StyleSelector::At {
kind,
Expand All @@ -303,7 +340,7 @@ pub fn add_selector_params(selector: StyleSelector, params: &str) -> StyleSelect
} => StyleSelector::At {
kind,
query,
selector: selector.map(|s| format!("{s}({params})")),
selector: selector.map(|s| with_params(&s, params)),
outer,
file,
},
Expand Down Expand Up @@ -1199,6 +1236,25 @@ mod tests {
add_selector_params(StyleSelector::Selector("hover:is".to_string()), "test"),
StyleSelector::Selector("hover:is(test)".to_string())
);
for (selector, expected) in [
(
":is([role=group],[data-group]):nth-child &",
":is([role=group],[data-group]):nth-child(2n) &",
),
(
":is([role=group],[data-group]):nth-child &:hover",
":is([role=group],[data-group]):nth-child(2n) &:hover",
),
("&:not(.x):nth-child", "&:not(.x):nth-child(2n)"),
("&[title=\":not\"]::part", "&[title=\":not\"]::part(2n)"),
("&:hover", "&:hover(2n)"),
] {
assert_eq!(
add_selector_params(StyleSelector::Selector(selector.to_string()), "2n"),
StyleSelector::Selector(expected.to_string()),
"{selector}"
);
}
assert_eq!(
add_selector_params(
StyleSelector::Global("&:is".to_string(), "file.ts".to_string()),
Expand Down
2 changes: 1 addition & 1 deletion libs/css/src/optimize_multi_css_value.rs
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ mod tests {
#[rstest]
#[case("font-family", true)]
#[case("src", true)]
#[case("content", true)]
#[case("content", false)]
#[case("animation-name", true)]
#[case("background", false)]
#[case("color", false)]
Expand Down
5 changes: 5 additions & 0 deletions libs/css/src/selector_separator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,12 +62,17 @@ mod tests {
"[aria-disabled='true']".into(),
SelectorSeparator::None
));

assert!(matches!(".child".into(), SelectorSeparator::Space));
assert!(matches!("#id".into(), SelectorSeparator::Space));
assert!(matches!("*".into(), SelectorSeparator::Space));
}

#[test]
fn test_display() {
assert_eq!(SelectorSeparator::Double.to_string(), "::");
assert_eq!(SelectorSeparator::Single.to_string(), ":");
assert_eq!(SelectorSeparator::Space.to_string(), " ");
assert_eq!(SelectorSeparator::None.to_string(), "");
}
}
Loading
Loading