pub mod fonts;
pub mod ua;
pub mod visual_effects;
pub use velox_dom::style::*;
pub use fonts::{FontDescriptor, FontFamily, FontStyle, GenericFamily, LineHeight};
pub use visual_effects::{BorderRadius, BoxShadow as VisualBoxShadow, TextShadow};
use cssparser::ToCss;
use std::collections::HashMap;
use velox_dom::{Props, VNode};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum Combinator {
#[default]
None,
Descendant,
Child,
}
pub const PLACEHOLDER_STYLE_ATTR: &str = "style:placeholder";
fn shows_placeholder(tag: &str, props: &Props) -> bool {
tag == "input"
&& props
.attrs
.get("placeholder")
.is_some_and(|p| !p.trim().is_empty())
&& props.attrs.get("value").is_none_or(|v| v.is_empty())
}
#[derive(Debug, Clone, PartialEq)]
pub struct SelectorPart {
pub tag: String,
pub class: String,
pub hover: bool,
pub placeholder: bool,
pub attr_name: String,
pub attr_value: Option<String>,
pub combinator: Combinator,
}
impl SelectorPart {
fn matches_element(&self, tag: &str, props: &Props, hovered: bool) -> bool {
if self.hover && !hovered {
return false;
}
if self.placeholder && !shows_placeholder(tag, props) {
return false;
}
let tag_ok = self.tag.is_empty() || self.tag == "*" || self.tag == tag;
let class_ok = if self.class.is_empty() {
true
} else if let Some(classes) = props.attrs.get("class") {
classes.split_whitespace().any(|x| x == self.class)
} else {
false
};
let attr_ok = if self.attr_name.is_empty() {
true
} else if let Some(val) = props.attrs.get(&self.attr_name) {
if let Some(expected) = &self.attr_value {
val == expected
} else {
true
}
} else {
false
};
tag_ok && class_ok && attr_ok
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct CompoundSelector {
pub parts: Vec<SelectorPart>,
}
impl CompoundSelector {
fn new(parts: Vec<SelectorPart>) -> Self {
Self { parts }
}
fn has_placeholder(&self) -> bool {
self.parts.iter().any(|p| p.placeholder)
}
fn placeholder_is_subject(&self) -> bool {
self.parts.last().is_some_and(|p| p.placeholder)
&& self.parts.iter().filter(|p| p.placeholder).count() == 1
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Rule {
pub selector: CompoundSelector,
pub decls: HashMap<String, String>,
}
struct SheetParser {
rules: Vec<Rule>,
}
impl<'i> cssparser::QualifiedRuleParser<'i> for &mut SheetParser {
type Prelude = String;
type QualifiedRule = ();
type Error = ();
fn parse_prelude<'t>(
&mut self,
input: &mut cssparser::Parser<'i, 't>,
) -> Result<String, cssparser::ParseError<'i, ()>> {
let start = input.position();
while let Ok(token) = input.next_including_whitespace() {
let _ = token;
}
Ok(input.slice_from(start).trim().to_string())
}
fn parse_block<'t>(
&mut self,
prelude: String,
_start: &cssparser::ParserState,
input: &mut cssparser::Parser<'i, 't>,
) -> Result<(), cssparser::ParseError<'i, ()>> {
let mut decls = HashMap::new();
for (name, value) in
cssparser::DeclarationListParser::new(input, DeclarationParser).flatten()
{
if !name.is_empty() {
decls.insert(name, value);
}
}
if decls.is_empty() {
return Ok(());
}
for selector in parse_selector_list(&prelude) {
self.rules.push(Rule {
selector,
decls: decls.clone(),
});
}
Ok(())
}
}
impl<'i> cssparser::AtRuleParser<'i> for &mut SheetParser {
type Prelude = String;
type AtRule = ();
type Error = ();
fn parse_prelude<'t>(
&mut self,
name: cssparser::CowRcStr<'i>,
input: &mut cssparser::Parser<'i, 't>,
) -> Result<String, cssparser::ParseError<'i, ()>> {
let mut prelude = String::new();
write_component_values(input, &mut prelude)?;
Ok(format!("@{} {}", name, prelude.trim()))
}
fn parse_block<'t>(
&mut self,
prelude: String,
_start: &cssparser::ParserState,
input: &mut cssparser::Parser<'i, 't>,
) -> Result<(), cssparser::ParseError<'i, ()>> {
let prelude_trim = prelude.trim().to_lowercase();
if prelude_trim.starts_with("@keyframes")
|| prelude_trim.starts_with("@-webkit-keyframes")
|| prelude_trim.starts_with("@-moz-keyframes")
|| prelude_trim.starts_with("@font-face")
{
return Ok(());
}
{
use cssparser::RuleListParser;
let mut tmp = SheetParser { rules: Vec::new() };
{
let mut rule_list = RuleListParser::new_for_nested_rule(input, &mut tmp);
for rule in &mut rule_list {
let _ = rule;
}
}
self.rules.extend(tmp.rules);
}
Ok(())
}
fn rule_without_block(
&mut self,
prelude: String,
_start: &cssparser::ParserState,
) -> Result<(), ()> {
let _ = prelude;
Err(())
}
}
fn write_component_values<'i, 't>(
input: &mut cssparser::Parser<'i, 't>,
dest: &mut String,
) -> Result<(), cssparser::ParseError<'i, ()>> {
while let Ok(token) = input.next_including_whitespace() {
let closing = match *token {
cssparser::Token::Function(_) => Some(')'),
cssparser::Token::ParenthesisBlock => Some(')'),
cssparser::Token::SquareBracketBlock => Some(']'),
cssparser::Token::CurlyBracketBlock => Some('}'),
_ => None,
};
let _ = token.to_css(dest);
let Some(closing) = closing else {
continue;
};
let _ = input.parse_nested_block(|input| write_component_values(input, dest));
dest.push(closing);
}
Ok(())
}
struct DeclarationParser;
impl<'i> cssparser::DeclarationParser<'i> for DeclarationParser {
type Declaration = (String, String);
type Error = ();
fn parse_value<'t>(
&mut self,
name: cssparser::CowRcStr<'i>,
input: &mut cssparser::Parser<'i, 't>,
) -> Result<(String, String), cssparser::ParseError<'i, ()>> {
let mut value = String::new();
write_component_values(input, &mut value)?;
Ok((name.to_string(), value.trim().to_string()))
}
}
impl<'i> cssparser::AtRuleParser<'i> for DeclarationParser {
type Prelude = ();
type AtRule = (String, String);
type Error = ();
}
fn split_pseudos(base_trimmed: &str) -> (std::borrow::Cow<'_, str>, bool, bool) {
use std::borrow::Cow;
let Some(colon) = base_trimmed.find(':') else {
return (Cow::Borrowed(base_trimmed), false, false);
};
let name = base_trimmed[..colon].trim().to_string();
let mut rest = base_trimmed[colon + 1..].trim_start_matches(':');
let mut hover = false;
let mut placeholder = false;
while !rest.is_empty() {
let (pseudo, tail) = match rest.split_once(':') {
Some((p, t)) => (p, Some(t)),
None => (rest, None),
};
match pseudo.trim().to_ascii_lowercase().as_str() {
"hover" => hover = true,
"placeholder" => placeholder = true,
_ => {}
}
match tail {
Some(t) => rest = t.trim_start_matches(':'),
None => break,
}
}
(Cow::Owned(name), hover, placeholder)
}
fn parse_selector_part(raw: &str) -> Option<SelectorPart> {
let (base_raw, attr_raw): (String, Option<String>) = if let Some(lb) = raw.find('[') {
if let Some(rb_rel) = raw[lb..].find(']') {
let rb = lb + rb_rel;
let attr_inner = raw[lb + 1..rb].to_string();
let before = &raw[..lb];
let after = &raw[rb + 1..];
let base = format!("{}{}", before, after);
(base, Some(attr_inner))
} else {
return None;
}
} else {
(raw.to_string(), None)
};
let (attr_name, attr_value) = if let Some(inner) = attr_raw {
let inner = inner.trim().to_string();
if inner.is_empty() {
return None;
}
if let Some(eq) = inner.find('=') {
let name = inner[..eq].trim().to_string();
if name.is_empty() {
return None;
}
let mut val = inner[eq + 1..].trim().to_string();
if (val.starts_with('"') && val.ends_with('"') && val.len() >= 2)
|| (val.starts_with('\'') && val.ends_with('\'') && val.len() >= 2)
{
val = val[1..val.len() - 1].to_string();
}
(name, Some(val))
} else {
(inner, None)
}
} else {
(String::new(), None)
};
let base_trimmed = base_raw.trim();
if base_trimmed.is_empty() {
if attr_name.is_empty() {
return None;
}
if base_trimmed.is_empty() {
return Some(SelectorPart {
tag: String::new(),
class: String::new(),
hover: false,
placeholder: false,
attr_name,
attr_value,
combinator: Combinator::None,
});
}
}
let (name_raw, hover, placeholder) = split_pseudos(base_trimmed);
if name_raw.as_ref() == "*" {
return Some(SelectorPart {
tag: "*".to_string(),
class: String::new(),
hover,
placeholder,
attr_name,
attr_value,
combinator: Combinator::None,
});
}
if name_raw.is_empty() {
if attr_name.is_empty() {
return None;
}
return Some(SelectorPart {
tag: String::new(),
class: String::new(),
hover,
placeholder,
attr_name,
attr_value,
combinator: Combinator::None,
});
}
if let Some(rest) = name_raw.strip_prefix('.') {
let class = rest.trim();
if class.is_empty() {
return None;
}
Some(SelectorPart {
tag: String::new(),
class: class.to_string(),
hover,
placeholder,
attr_name,
attr_value,
combinator: Combinator::None,
})
} else if let Some((tag, class)) = name_raw.split_once('.') {
let tag = tag.trim();
let class = class.trim();
if tag.is_empty() || class.is_empty() {
return None;
}
Some(SelectorPart {
tag: tag.to_string(),
class: class.to_string(),
hover,
placeholder,
attr_name,
attr_value,
combinator: Combinator::None,
})
} else {
Some(SelectorPart {
tag: name_raw.into_owned(),
class: String::new(),
hover,
placeholder,
attr_name,
attr_value,
combinator: Combinator::None,
})
}
}
fn pad_child_combinators(raw: &str) -> String {
let mut out = String::with_capacity(raw.len() + 4);
let mut bracket_depth = 0usize;
let mut quote: Option<char> = None;
for ch in raw.chars() {
if let Some(q) = quote {
out.push(ch);
if ch == q {
quote = None;
}
continue;
}
match ch {
'[' => {
bracket_depth += 1;
out.push(ch);
}
']' => {
bracket_depth = bracket_depth.saturating_sub(1);
out.push(ch);
}
'"' | '\'' if bracket_depth > 0 => {
quote = Some(ch);
out.push(ch);
}
'>' if bracket_depth == 0 => out.push_str(" > "),
_ => out.push(ch),
}
}
out
}
fn parse_selector_list(selector: &str) -> Vec<CompoundSelector> {
let mut out = Vec::new();
for part in selector.split(',') {
let raw = part.trim();
if raw.is_empty() {
continue;
}
let mut parts: Vec<SelectorPart> = Vec::new();
let mut pending: Option<Combinator> = None;
let mut valid = true;
for token in pad_child_combinators(raw).split_whitespace() {
if token == ">" {
if parts.is_empty() {
valid = false;
break;
}
pending = Some(Combinator::Child);
continue;
}
match parse_selector_part(token) {
Some(mut sp) => {
sp.combinator = pending.take().unwrap_or(if parts.is_empty() {
Combinator::None
} else {
Combinator::Descendant
});
parts.push(sp);
}
None => {
valid = false;
break;
}
}
}
if valid && !parts.is_empty() {
out.push(CompoundSelector::new(parts));
}
}
out
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Stylesheet {
pub rules: Vec<Rule>,
}
impl Stylesheet {
pub fn parse(css: &str) -> Self {
use cssparser::{Parser, ParserInput, RuleListParser};
let mut input = ParserInput::new(css);
let mut parser = Parser::new(&mut input);
let mut sheet_parser = SheetParser { rules: Vec::new() };
let mut rule_list = RuleListParser::new_for_stylesheet(&mut parser, &mut sheet_parser);
for rule in &mut rule_list {
let _ = rule;
}
Stylesheet {
rules: sheet_parser.rules,
}
}
}
fn matches_selector(
sel: &CompoundSelector,
tag: &str,
props: &Props,
hovered: bool,
ancestors: &[&VNode],
) -> bool {
let Some((last, prefix)) = sel.parts.split_last() else {
return false;
};
if !last.matches_element(tag, props, hovered) {
return false;
}
match_prefix(prefix, ancestors, -1, last.combinator)
}
fn match_prefix(
prefix: &[SelectorPart],
ancestors: &[&VNode],
below: isize,
comb: Combinator,
) -> bool {
let Some((last, rest)) = prefix.split_last() else {
return true;
};
let candidates: Box<dyn Iterator<Item = usize> + '_> = match comb {
Combinator::Child => {
let pos = below + 1;
if pos < 0 {
return false;
}
Box::new(pos as usize..pos as usize + 1)
}
_ => Box::new((below + 1).max(0) as usize..ancestors.len()),
};
for pos in candidates {
if let VNode::Element {
tag: a_tag,
props: a_props,
..
} = ancestors[pos]
&& last.matches_element(a_tag, a_props, false)
&& match_prefix(rest, ancestors, pos as isize, last.combinator)
{
return true;
}
}
false
}
fn merge_styles(existing: Option<&str>, new_map: &HashMap<String, String>) -> String {
let mut map: HashMap<String, String> = HashMap::new();
for (k, v) in new_map {
map.insert(k.clone(), v.clone());
}
if let Some(s) = existing {
for decl in s.split(';') {
let decl = decl.trim();
if decl.is_empty() {
continue;
}
if let Some((k, v)) = decl.split_once(':') {
map.insert(k.trim().to_string(), v.trim().to_string());
}
}
}
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
let mut out = String::new();
for (i, k) in keys.iter().enumerate() {
if i > 0 {
out.push(' ');
}
out.push_str(k);
out.push_str(": ");
out.push_str(&map[*k]);
out.push(';');
}
out
}
pub fn apply_styles(node: &VNode, sheet: &Stylesheet) -> VNode {
apply_styles_with_hover(node, sheet, &|_, _| false)
}
pub fn apply_with_cascade(node: &VNode, author: &Stylesheet) -> VNode {
apply_with_cascade_with_hover(node, author, &|_, _| false)
}
pub fn apply_with_cascade_with_hover<F>(node: &VNode, author: &Stylesheet, is_hovered: &F) -> VNode
where
F: Fn(&str, &Props) -> bool,
{
let ua = crate::ua::ua_sheet();
apply_with_sheets(node, &[ua, author], is_hovered)
}
const INHERITABLE: &[&str] = &[
"color",
"font-size",
"font-family",
"font-weight",
"font-style",
"line-height",
"letter-spacing",
"text-align",
"vertical-align",
"white-space",
"visibility",
"cursor",
"text-decoration",
];
pub fn apply_styles_with_hover<F>(node: &VNode, sheet: &Stylesheet, is_hovered: &F) -> VNode
where
F: Fn(&str, &Props) -> bool,
{
apply_with_sheets(node, &[sheet], is_hovered)
}
fn apply_with_sheets<F>(node: &VNode, sheets: &[&Stylesheet], is_hovered: &F) -> VNode
where
F: Fn(&str, &Props) -> bool,
{
fn overlay_inheritable(map: &mut HashMap<String, String>, style: &str) {
for decl in style.split(';') {
let d = decl.trim();
if d.is_empty() {
continue;
}
if let Some((k, v)) = d.split_once(':') {
let k = k.trim();
let v = v.trim();
if INHERITABLE.contains(&k) {
map.insert(k.to_string(), v.to_string());
}
}
}
}
fn apply_rec<FN>(
node: &VNode,
sheets: &[&Stylesheet],
is_hovered: &FN,
inherited: &HashMap<String, String>,
ancestors: &[&VNode],
) -> VNode
where
FN: Fn(&str, &Props) -> bool,
{
match node {
VNode::Text(_) => node.clone(),
VNode::Element {
tag,
props,
children,
} => {
let hovered = is_hovered(tag, props);
let mut acc: HashMap<String, String> = inherited.clone();
let mut pseudo_acc: HashMap<String, String> = HashMap::new();
for sheet in sheets {
for rule in &sheet.rules {
if matches_selector(&rule.selector, tag, props, hovered, ancestors) {
let target = if rule.selector.has_placeholder() {
if !rule.selector.placeholder_is_subject() {
continue;
}
&mut pseudo_acc
} else {
&mut acc
};
for (k, v) in &rule.decls {
target.insert(k.clone(), v.clone());
}
}
}
}
let inline_style: Option<&str> = props.attrs.get("style").map(String::as_str);
let mut new_props = props.clone();
let merged = merge_styles(inline_style, &acc);
if !merged.is_empty() {
new_props = new_props.set("style", merged);
}
if !pseudo_acc.is_empty() {
let merged_pseudo = merge_styles(
new_props
.attrs
.get(PLACEHOLDER_STYLE_ATTR)
.map(String::as_str),
&pseudo_acc,
);
new_props = new_props.set(PLACEHOLDER_STYLE_ATTR, merged_pseudo);
}
let mut inherit_next: HashMap<String, String> = HashMap::with_capacity(acc.len());
for (k, v) in &acc {
if INHERITABLE.contains(&k.as_str()) {
inherit_next.insert(k.clone(), v.clone());
}
}
if let Some(inline) = inline_style {
overlay_inheritable(&mut inherit_next, inline);
}
let mut child_ancestors: Vec<&VNode> = Vec::with_capacity(ancestors.len() + 1);
child_ancestors.push(node);
child_ancestors.extend_from_slice(ancestors);
let new_children = children
.iter()
.map(|c| apply_rec(c, sheets, is_hovered, &inherit_next, &child_ancestors))
.collect();
VNode::Element {
tag: tag.clone(),
props: new_props,
children: new_children,
}
}
}
}
let inherited_root: HashMap<String, String> = HashMap::new();
apply_rec(node, sheets, is_hovered, &inherited_root, &[])
}
pub fn compute_styles_for_node(
node: &VNode,
inline_style: Option<&str>,
sheet: Option<&Stylesheet>,
is_hovered: bool,
ancestors: &[&VNode],
) -> ComputedStyle {
let mut computed = ComputedStyle::new();
if let Some(sheet) = sheet
&& let VNode::Element { tag, props, .. } = node
{
for rule in &sheet.rules {
if matches_selector(&rule.selector, tag, props, is_hovered, ancestors) {
if rule.selector.has_placeholder() {
continue;
}
for (prop, value) in &rule.decls {
computed.set_property(prop, value);
}
}
}
}
if let Some(style) = inline_style {
computed.apply_inline_style(style);
}
computed
}
#[cfg(test)]
mod placeholder_tests {
use super::*;
use velox_dom::h;
#[test]
fn a_double_colon_pseudo_element_is_not_mistaken_for_a_single_colon_one() {
let (name, hover, placeholder) = split_pseudos("input::placeholder");
assert_eq!(name.into_owned(), "input");
assert!(placeholder, "`::placeholder` was not recognised");
assert!(!hover, "`::placeholder` must not also set :hover");
}
#[test]
fn the_single_colon_pseudo_classes_still_parse() {
let (name, hover, placeholder) = split_pseudos("div:hover");
assert_eq!(
name.into_owned(),
"div",
":hover regressed while ::placeholder was being added"
);
assert!(hover);
assert!(!placeholder);
}
#[test]
fn a_selector_with_no_pseudo_is_untouched() {
let (field, hover, placeholder) = split_pseudos(".field");
assert!(matches!(field, std::borrow::Cow::Borrowed(".field")));
assert!(!hover);
assert!(!placeholder);
let (tag, hover, placeholder) = split_pseudos("input");
assert!(matches!(tag, std::borrow::Cow::Borrowed("input")));
assert!(!hover);
assert!(!placeholder);
}
#[test]
fn a_class_with_a_placeholder_pseudo_keeps_its_class() {
let (name, hover, placeholder) = split_pseudos(".field::placeholder");
assert_eq!(name.into_owned(), ".field");
assert!(placeholder);
assert!(!hover);
}
#[test]
fn two_pseudos_in_one_selector_keep_both_flags() {
let (name, hover, placeholder) = split_pseudos("input::placeholder:hover");
assert_eq!(name.into_owned(), "input");
assert!(placeholder);
assert!(hover);
}
#[test]
fn an_unknown_pseudo_is_ignored_rather_than_rejecting_the_selector() {
let (name, hover, placeholder) = split_pseudos("input::before");
assert_eq!(name.into_owned(), "input");
assert!(!placeholder);
assert!(!hover);
}
#[test]
fn the_placeholder_pseudo_is_case_insensitive() {
assert!(
split_pseudos("input::PLACEHOLDER").2,
"CSS pseudo-element names are ASCII case-insensitive"
);
}
fn props(pairs: &[(&str, &str)]) -> Props {
let mut p = Props::new();
for (k, v) in pairs {
p = p.set(*k, *v);
}
p
}
#[test]
fn a_placeholder_only_matches_a_control_that_is_showing_it() {
assert!(shows_placeholder(
"input",
&props(&[("placeholder", "hint")])
));
assert!(shows_placeholder(
"input",
&props(&[("placeholder", "hint"), ("value", "")])
));
}
#[test]
fn a_filled_input_is_not_showing_its_placeholder() {
assert!(
!shows_placeholder("input", &props(&[("placeholder", "hint"), ("value", "x")])),
"a ::placeholder rule that matched a filled field would repaint the \
VALUE text, which is how the double-colon bug presented"
);
}
#[test]
fn an_input_with_no_placeholder_attribute_does_not_match() {
assert!(!shows_placeholder("input", &props(&[])));
assert!(!shows_placeholder(
"input",
&props(&[("placeholder", " ")])
));
}
#[test]
fn a_non_input_never_shows_a_placeholder() {
assert!(!shows_placeholder(
"div",
&props(&[("placeholder", "hint")])
));
assert!(!shows_placeholder(
"textarea",
&props(&[("placeholder", "hint")])
));
}
fn styled_input(pairs: &[(&str, &str)], sheet: &Stylesheet) -> Props {
let tree = h("div", Props::new(), vec![h("input", props(pairs), vec![])]);
let mut out = None;
fn walk(node: &VNode, out: &mut Option<Props>) {
if let VNode::Element {
tag,
props,
children,
} = node
{
if tag == "input" {
*out = Some(props.clone());
}
for c in children {
walk(c, out);
}
}
}
walk(&apply_with_cascade(&tree, sheet), &mut out);
out.expect("the input must survive the cascade")
}
#[test]
fn a_placeholder_rule_lands_on_its_own_attribute_and_not_on_style() {
let sheet = Stylesheet::parse("input::placeholder { color: #ff0000; }");
let p = styled_input(&[("placeholder", "hint")], &sheet);
assert_eq!(
p.attrs.get(PLACEHOLDER_STYLE_ATTR).map(String::as_str),
Some("color: #ff0000;"),
"the ::placeholder declarations did not reach {PLACEHOLDER_STYLE_ATTR}"
);
assert!(
p.attrs.get("style").is_none_or(|s| !s.contains("ff0000")),
"the ::placeholder colour leaked into the element's own `style`, \
which is the VALUE text's style: the two would fight"
);
}
#[test]
fn a_placeholder_rule_does_not_land_on_a_filled_field() {
let sheet = Stylesheet::parse("input::placeholder { color: #ff0000; }");
let p = styled_input(&[("placeholder", "hint"), ("value", "typed")], &sheet);
assert!(
!p.attrs.contains_key(PLACEHOLDER_STYLE_ATTR),
"the ::placeholder rule applied to a field showing a value"
);
}
#[test]
fn a_placeholder_rule_is_not_inherited_by_descendants() {
let sheet = Stylesheet::parse("input::placeholder { color: #ff0000; }");
let tree = h(
"div",
Props::new(),
vec![h(
"input",
props(&[("placeholder", "hint")]),
vec![h("span", Props::new(), vec![])],
)],
);
let styled = apply_with_cascade(&tree, &sheet);
let child = match &styled {
VNode::Element { children, .. } => match &children[0] {
VNode::Element { children, .. } => match &children[0] {
VNode::Element { props, .. } => props.clone(),
_ => panic!("expected an element"),
},
_ => panic!("expected an element"),
},
_ => panic!("expected an element"),
};
assert!(
!child.attrs.contains_key(PLACEHOLDER_STYLE_ATTR),
"the placeholder declarations were inherited by a child element"
);
}
fn sel(css: &str) -> CompoundSelector {
Stylesheet::parse(&format!("{css} {{ color: red; }}"))
.rules
.into_iter()
.next()
.expect("the fixture must parse to exactly one rule")
.selector
}
#[test]
fn a_selector_with_no_placeholder_part_is_not_a_placeholder_selector() {
let s = sel(".wrap input.b .c");
assert!(!s.has_placeholder(), "no part names ::placeholder");
assert!(
!s.placeholder_is_subject(),
"a selector with no ::placeholder has no placeholder subject"
);
}
#[test]
fn a_placeholder_as_the_sole_subject_part_is_accepted() {
for css in [
"input::placeholder",
".field::placeholder",
"*::placeholder",
] {
let s = sel(css);
assert!(s.has_placeholder(), "{css}: no part named ::placeholder");
assert!(
s.placeholder_is_subject(),
"{css}: a sole ::placeholder part on the subject is real CSS"
);
}
}
#[test]
fn a_placeholder_after_a_descendant_chain_is_still_the_subject() {
for css in [".wrap input::placeholder", ".wrap > input::placeholder"] {
let s = sel(css);
assert!(
s.placeholder_is_subject(),
"{css}: the rightmost part is the ::placeholder subject, so this \
is valid CSS and must be accepted"
);
}
}
#[test]
fn a_placeholder_that_is_not_the_subject_is_rejected() {
for css in [".a::placeholder .b", ".a::placeholder > .b"] {
let s = sel(css);
assert!(s.has_placeholder(), "{css}: a part does name ::placeholder");
assert!(
!s.placeholder_is_subject(),
"{css}: the ::placeholder part is a chain LINK, not the subject. \
A pseudo-element has no box, so it cannot be an ancestor; real \
CSS matches nothing here and Velox must not paint the subject."
);
}
}
#[test]
fn a_placeholder_in_the_chain_order_does_not_change_the_subject() {
let s = sel(".a .b::placeholder");
assert!(
s.placeholder_is_subject(),
".a .b::placeholder ends in the subject and is real CSS"
);
}
#[test]
fn a_second_placeholder_part_disqualifies_the_selector() {
let s = sel(".a::placeholder .b::placeholder");
assert!(
!s.placeholder_is_subject(),
"a ::placeholder in the chain disqualifies the selector even when \
the subject also has one"
);
}
}