Files
webstudio/packages/css-data/src/selector-validation.ts
T
Oleg Isonenandzehjotkah 1f4c53419d feat: Pseudo elements (#5572)
This PR replaces the limited "States" dropdown in the Style Panel with a
flexible input field that supports any CSS selector, pseudo-class, or
pseudo-element. This allows for advanced styling capabilities such as
`:has()`, `:focus-visible`, and complex combinators.

## Key Changes

### Flexible Selector Input
- Replaced the fixed "States" category list with a free-form input
field.
- Supports **any** valid CSS selector (e.g., `:hover`, `::before`,
`:has(:checked)`).
- Validation logic ensures only valid selectors are accepted (triggered
on `Enter` key).

## How to Test
1. Select an element and open the Style Panel.
2. Click on the Local style source or a Token.
3. In the input field (bottom of menu), type a selector like `:hover` or
`:first-child` and press Enter.
4. Verify the selector is added and selected.

---------

Co-authored-by: zehjotkah <111518041+zehjotkah@users.noreply.github.com>
2026-01-19 17:28:47 +01:00

123 lines
3.9 KiB
TypeScript

import * as csstree from "css-tree";
import { pseudoElements } from "./__generated__/pseudo-elements";
import { pseudoClasses } from "./__generated__/pseudo-classes";
export type SelectorValidationResult =
| { success: false; error: string }
| { success: true; type: "pseudo-class" | "pseudo-element" | "attribute" };
/**
* Validates a CSS pseudo-class, pseudo-element, or attribute selector
* @param selector - The selector to validate (e.g., ":hover", "::before", "[data-state=open]")
* @returns SelectorValidationResult with success status and type or error message
*/
export const validateSelector = (
selector: string
): SelectorValidationResult => {
if (!selector || selector.trim() === "") {
return { success: false, error: "Selector cannot be empty" };
}
// Must start with a colon or bracket
if (!selector.startsWith(":") && !selector.startsWith("[")) {
return {
success: false,
error:
"Selector must start with a colon (:) for pseudo-classes, double colon (::) for pseudo-elements, or bracket ([) for attribute selectors",
};
}
try {
// Parse as a rule to validate syntax
// We wrap it in a dummy selector because css-tree requires a full rule
const ast = csstree.parse(`dummy${selector} {}`);
let foundPseudoClass = false;
let foundPseudoElement = false;
let foundAttribute = false;
let pseudoClassName: string | undefined;
let pseudoElementName: string | undefined;
// Walk the AST to find pseudo-class, pseudo-element, or attribute selectors
csstree.walk(ast, (node) => {
if (node.type === "PseudoClassSelector") {
foundPseudoClass = true;
pseudoClassName = node.name;
}
if (node.type === "PseudoElementSelector") {
foundPseudoElement = true;
pseudoElementName = node.name;
}
if (node.type === "AttributeSelector") {
foundAttribute = true;
}
});
// Determine the type based on what we found
if (foundPseudoElement) {
// Validate that the pseudo-element actually exists
// Some pseudo-elements are functional (e.g., ::part(), ::slotted())
// The list includes both "part" and "part()" variants
if (pseudoElementName) {
const withParens = `${pseudoElementName}()`;
if (
(pseudoElements as readonly string[]).includes(pseudoElementName) ||
(pseudoElements as readonly string[]).includes(withParens)
) {
return { success: true, type: "pseudo-element" };
}
}
// Invalid pseudo-element name
return {
success: false,
error: "Invalid pseudo-element",
};
}
if (foundPseudoClass) {
// Check if it's actually a legacy pseudo-element (single colon)
// Legacy syntax: :before, :after, :first-line, :first-letter
if (
pseudoClassName &&
(pseudoElements as readonly string[]).includes(pseudoClassName)
) {
return { success: true, type: "pseudo-element" };
}
// Validate that the pseudo-class actually exists
// Some pseudo-classes are functional (e.g., :has(), :not())
// The list includes both "has" and "has()" variants
if (pseudoClassName) {
const withParens = `${pseudoClassName}()`;
if (
(pseudoClasses as readonly string[]).includes(pseudoClassName) ||
(pseudoClasses as readonly string[]).includes(withParens)
) {
return { success: true, type: "pseudo-class" };
}
}
// Invalid pseudo-class name
return {
success: false,
error: "Invalid pseudo-class",
};
}
if (foundAttribute) {
return { success: true, type: "attribute" };
}
// If we get here, something unexpected happened
return {
success: false,
error: "Invalid CSS selector syntax",
};
} catch {
return {
success: false,
error: "Invalid CSS selector syntax",
};
}
};