Skip to content
Closed
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { Styles } from "@webstudio-is/sdk";

/**
* Extract all unique selectors (states) used in the project styles
* Returns them sorted by most recently used (based on order in styles map)
*/
export const getUsedSelectors = (styles: Styles): string[] => {
const selectorsSet = new Set<string>();
const selectorsOrder: string[] = [];

// Iterate through all styles and collect unique states
for (const styleDecl of styles.values()) {
if (styleDecl.state && styleDecl.state.trim()) {
const selector = styleDecl.state;
if (!selectorsSet.has(selector)) {
selectorsSet.add(selector);
// Add to front to maintain most recent first
selectorsOrder.unshift(selector);
}
}
}

return selectorsOrder;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { humanizeString } from "~/shared/string-utils";

export type ValidationResult = {
valid: boolean;
message?: string;
type?: "pseudo-class" | "pseudo-element" | "complex-selector";
};

export type ComponentState = {
category: "states" | "component-states";
selector: string;
label: string;
};

/**
* Validates CSS selector syntax
* Supports:
* - Pseudo classes: :hover, :focus-visible, :has()
* - Pseudo elements: ::before, ::after, ::placeholder
* - Complex selectors: :has(:focus-visible), :not(:disabled)
*/
export const validateSelector = (selector: string): ValidationResult => {
// Empty selector is valid (removes state)
if (!selector.trim()) {
return { valid: true };
}

// Must start with : or ::
if (!selector.startsWith(":")) {
return {
valid: false,
message:
"Selector must start with : (pseudo-class) or :: (pseudo-element)",
};
}

// Try to validate using browser's querySelector
try {
// Test if selector is valid by attempting to use it
// We use a dummy element to avoid affecting the actual DOM
const testElement = document.createElement("div");
testElement.matches(selector);
return {
valid: true,
type: selector.startsWith("::") ? "pseudo-element" : "pseudo-class",
};
} catch (error) {
// Check if it's a functional pseudo-class that querySelector can't handle in matches()
const functionalPseudoClasses = [
":has",
":is",
":where",
":not",
":nth-child",
":nth-of-type",
":nth-last-child",
":nth-last-of-type",
];

const isFunctional = functionalPseudoClasses.some((pc) =>
selector.startsWith(pc + "(")
);

if (isFunctional) {
// Basic validation for functional pseudo-classes
const openParens = (selector.match(/\(/g) || []).length;
const closeParens = (selector.match(/\)/g) || []).length;
if (openParens === closeParens && openParens > 0) {
return { valid: true, type: "complex-selector" };
}
}

// Check for pseudo-elements that can't be tested with matches()
const pseudoElements = [
"::before",
"::after",
"::first-line",
"::first-letter",
"::selection",
"::placeholder",
"::marker",
"::backdrop",
];

if (pseudoElements.some((pe) => selector.startsWith(pe))) {
return { valid: true, type: "pseudo-element" };
}

return {
valid: false,
message: "Invalid CSS selector syntax",
};
}
};

/**
* Generates a human-readable label for a selector
*/
export const getSelectorLabel = (
selector: string,
predefinedStates: ComponentState[]
): string => {
// Check if we have a predefined label
const predefined = predefinedStates.find((s) => s.selector === selector);
if (predefined) {
return predefined.label;
}

// Generate label from selector
// ::before -> Before
// :hover -> Hover
// :has(:focus-visible) -> Has Focus Visible
return humanizeString(selector.replace(/^::?/, ""));
};
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import { subscribe } from "~/shared/pubsub";
import { $selectedInstance } from "~/shared/awareness";
import { $instanceTags } from "./shared/model";
import { humanizeString } from "~/shared/string-utils";
import { getUsedSelectors } from "./shared/recent-selectors";

// Declare command for this module
declare module "~/shared/pubsub" {
Expand Down Expand Up @@ -345,6 +346,13 @@ export const StyleSourcesSection = () => {
const selectedInstanceStatesByStyleSourceId = useStore(
$selectedInstanceStatesByStyleSourceId
);
const selectedOrLastStyleSourceSelector = useStore(
$selectedOrLastStyleSourceSelector
);
const styles = useStore($styles);

// Extract all used selectors from project styles
const usedSelectors = getUsedSelectors(styles);

// Subscribe to focusStyleSourceInput command
useEffect(() => {
Expand All @@ -362,9 +370,6 @@ export const StyleSourcesSection = () => {
selectedInstanceStatesByStyleSourceId.get(styleSource.id) ?? []
)
);
const selectedOrLastStyleSourceSelector = useStore(
$selectedOrLastStyleSourceSelector
);

const [editingItemId, setEditingItemId] = useState<StyleSource["id"]>();

Expand All @@ -388,6 +393,7 @@ export const StyleSourcesSection = () => {
value={value}
selectedItemSelector={selectedOrLastStyleSourceSelector}
componentStates={componentStates}
recentlyUsedSelectors={usedSelectors}
onCreateItem={createStyleSource}
onSelectAutocompleteItem={({ id }) => {
addStyleSourceToInstance(id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -367,7 +367,9 @@ export const StyleSourceControl = ({
</StyleSourceButton>
</Flex>
{stateLabel !== undefined && (
<StyleSourceState source={source}>{stateLabel}</StyleSourceState>
<Tooltip content={state || stateLabel} side="top">
<StyleSourceState source={source}>{stateLabel}</StyleSourceState>
</Tooltip>
)}
{showMenu && (
<Menu open={menuOpen} onOpenChange={setMenuOpen}>
Expand Down
Loading
Loading