Interactive Element
-
- Disabled Element
+
+ Element with ID
,
);
@@ -987,7 +983,7 @@ describe('tastyGlobal() API', () => {
expect(styleContent).toContain('.interactive-element');
expect(styleContent).toContain(':hover');
- expect(styleContent).toContain('[disabled]');
+ expect(styleContent).toContain('[id]');
expect(styleContent).toContain('background-color: var(--white-color)');
});
@@ -1230,6 +1226,53 @@ describe('tastyGlobal() API', () => {
);
});
+ it('should warn when combinator lacks spaces in selector affix ($)', () => {
+ const consoleErrorSpy = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+
+ const Component = tasty({
+ styles: {
+ Item: {
+ $: '>Body>Row',
+ color: '#primary',
+ },
+ },
+ });
+
+ render(
);
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('[Tasty] Invalid selector affix ($) syntax'),
+ );
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('>Body>Row'),
+ );
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should not warn when combinator has proper spaces in selector affix ($)', () => {
+ const consoleErrorSpy = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+
+ const Component = tasty({
+ styles: {
+ Item: {
+ $: '> Body > Row',
+ color: '#primary',
+ },
+ },
+ });
+
+ render(
);
+
+ expect(consoleErrorSpy).not.toHaveBeenCalled();
+
+ consoleErrorSpy.mockRestore();
+ });
+
it('should support multiple global style components with different selectors', () => {
const GlobalHeading = tasty('h1.special', {
preset: 'h1',
diff --git a/src/tasty/tasty.tsx b/src/tasty/tasty.tsx
index d59d9dd0f..5800cf333 100644
--- a/src/tasty/tasty.tsx
+++ b/src/tasty/tasty.tsx
@@ -41,7 +41,7 @@ const IS_PROPERTIES_ENTRIES = Object.entries(IS_PROPERTIES_MAP);
/**
* Helper function to handle is* properties consistently
- * Transforms is* props to HTML attributes and adds corresponding data-is-* attributes
+ * Transforms is* props to HTML attributes and adds corresponding data-* attributes
*/
function handleIsProperties(props: Record
) {
for (const [isProperty, targetAttribute] of IS_PROPERTIES_ENTRIES) {
@@ -50,8 +50,8 @@ function handleIsProperties(props: Record) {
delete props[isProperty];
}
- // Add data-is-* attribute if target attribute is truthy and doesn't already exist
- const dataAttribute = `data-is-${targetAttribute}`;
+ // Add data-* attribute if target attribute is truthy and doesn't already exist
+ const dataAttribute = `data-${targetAttribute}`;
if (!(dataAttribute in props) && props[targetAttribute]) {
props[dataAttribute] = '';
}
@@ -353,8 +353,10 @@ function tastyElement(
for (const prop of propsToCheck) {
const key = prop as unknown as string;
- if (Object.prototype.hasOwnProperty.call(otherProps as object, key)) {
- if (!propStyles) propStyles = {};
+
+ if (!propStyles) propStyles = {};
+
+ if (key in otherProps) {
(propStyles as any)[key] = (otherProps as any)[key];
delete (otherProps as any)[key];
}
@@ -376,6 +378,8 @@ function tastyElement(
[breakpoints?.join(',')],
);
+ const propStylesKey = stringifyStyles(propStyles);
+
// Optimize style computation and cache key generation
const { allStyles, cacheKey, useDefaultStyles } = useMemo(() => {
const hasStyles =
@@ -399,7 +403,7 @@ function tastyElement(
cacheKey: key,
useDefaultStyles: useDefault,
};
- }, [styles, propStyles, breakpointsKey]);
+ }, [styles, propStylesKey, breakpointsKey]);
// Compute rules synchronously; inject via insertion effect
const directResult: RenderResult = useMemo(() => {
@@ -461,8 +465,8 @@ function tastyElement(
'data-qa': (qa as string | undefined) || defaultQa,
'data-qaval': (qaVal as string | undefined) || defaultQaVal,
...(otherDefaultProps as unknown as Record),
- ...(otherProps as unknown as Record),
...(modProps || {}),
+ ...(otherProps as unknown as Record),
className: finalClassName,
ref,
} as Record;
diff --git a/src/tasty/types.ts b/src/tasty/types.ts
index 2c8ef9926..177960ffc 100644
--- a/src/tasty/types.ts
+++ b/src/tasty/types.ts
@@ -64,7 +64,7 @@ export interface BasePropsWithoutChildren
/** Whether the element has the inline layout outside */
inline?: boolean;
/** The list of element modifiers **/
- mods?: { [key: string]: boolean | undefined | null };
+ mods?: { [key: string]: boolean | string | number | undefined | null };
/** Whether the element is hidden (`hidden` attribute is set) */
isHidden?: boolean;
/** Whether the element is disabled (`disabled` attribute is set) */
diff --git a/src/tasty/utils/modAttrs.ts b/src/tasty/utils/modAttrs.ts
index 7f954d3a4..f81021d64 100644
--- a/src/tasty/utils/modAttrs.ts
+++ b/src/tasty/utils/modAttrs.ts
@@ -9,8 +9,31 @@ import { camelToKebab } from './case-converter';
function modAttrs(map: AllBaseProps['mods']): Record | null {
return map
? Object.keys(map).reduce((attrs, key) => {
- if (map[key]) {
- attrs[`data-is-${camelToKebab(key)}`] = '';
+ const value = map[key];
+
+ // Skip null, undefined, false
+ if (value == null || value === false) {
+ return attrs;
+ }
+
+ const attrName = `data-${camelToKebab(key)}`;
+
+ if (value === true) {
+ // Boolean true: data-{name}=""
+ attrs[attrName] = '';
+ } else if (typeof value === 'string') {
+ // String value: data-{name}="value"
+ attrs[attrName] = value;
+ } else if (typeof value === 'number') {
+ // Number: convert to string
+ attrs[attrName] = String(value);
+ } else {
+ // Reject other types (objects, arrays, functions)
+ if (process.env.NODE_ENV !== 'production') {
+ console.warn(
+ `CubeUIKit: Invalid mod value for "${key}". Expected boolean, string, or number, got ${typeof value}`,
+ );
+ }
}
return attrs;
diff --git a/src/tasty/utils/renderStyles.ts b/src/tasty/utils/renderStyles.ts
index 956e04bb6..94c79c0c5 100644
--- a/src/tasty/utils/renderStyles.ts
+++ b/src/tasty/utils/renderStyles.ts
@@ -1,6 +1,14 @@
/**
- * Style rendering that works with structured style objects
- * Eliminates CSS string parsing for better performance
+ * Style rendering that works with structured style objects.
+ * Eliminates CSS string parsing for better performance.
+ *
+ * Key optimizations:
+ * - Early exit checks (hasSameAttributeConflicts) handle common cases without overhead
+ * - Logical rule caching (logicalRulesCache) memoizes expensive style processing
+ * - Conflict detection prevents invalid CSS selector combinations
+ * - Not-selector optimization reduces CSS size
+ * - Priority-based filtering handles mod precedence correctly
+ * - Consolidated mod processing eliminates code duplication
*/
import { Lru } from '../parser/lru';
@@ -16,6 +24,7 @@ import {
import {
computeState,
getModSelector,
+ stringifyStyles,
StyleHandler,
StyleMap,
styleStateMapToStyleStateDataList,
@@ -54,6 +63,9 @@ type HandlerQueueItem = {
isResponsive: boolean;
};
+// Cache logical rules per styles+breakpoints to avoid recomputation across identical calls
+const logicalRulesCache = new Lru(5000);
+
// Normalize selector suffixes coming from `$` in style handler results.
// Some legacy handlers return suffixes starting with `&` (e.g. '& > *').
// The renderer expects suffixes without the ampersand because it adds
@@ -85,6 +97,18 @@ function transformSelectorAffix(affix: string): string {
const trimmed = affix.trim();
if (!trimmed) return ' ';
+ // Validate that combinators have spaces around them
+ // Check for capitalized words adjacent to combinators without spaces
+ const invalidPattern = /[A-Z][a-z]*[>+~]|[>+~][A-Z][a-z]*/;
+ if (invalidPattern.test(trimmed)) {
+ console.error(
+ `[Tasty] Invalid selector affix ($) syntax: "${affix}"\n` +
+ `Combinators (>, +, ~) must have spaces around them when used with element names.\n` +
+ `Example: Use "$: '> Body > Row'" instead of "$: '>Body>Row'"\n` +
+ `This is a design choice: the parser uses simple whitespace splitting for performance.`,
+ );
+ }
+
const tokens = trimmed.split(/\s+/);
const transformed = tokens.map((token) =>
/^[A-Z]/.test(token) ? `[data-element="${token}"]` : token,
@@ -124,53 +148,49 @@ interface ParsedAttributeSelector {
fullSelector: string;
}
-// Cache for parsed attribute selectors with bounded size to prevent memory leaks
-const attributeSelectorCache = new Lru(
- 5000,
-);
-
+/**
+ * Parse attribute selector into its components.
+ * Simple regex parsing - JS engines optimize repeated patterns internally.
+ */
function parseAttributeSelector(
selector: string,
): ParsedAttributeSelector | null {
- // Check cache first
- const cached = attributeSelectorCache.get(selector);
- if (cached !== undefined) {
- return cached;
- }
-
- // Match patterns like [data-size="medium"] or [data-is-selected]
+ // Match patterns like [data-size="medium"] or [data-selected]
const match = selector.match(/^\[([^=\]]+)(?:="([^"]+)")?\]$/);
- const result = match
+ return match
? {
attribute: match[1],
value: match[2] || 'true', // Handle boolean attributes
fullSelector: selector,
}
: null;
-
- // Cache the result
- attributeSelectorCache.set(selector, result);
- return result;
}
function hasConflictingAttributeSelectors(
mods: string[],
parsedMods?: Map,
): boolean {
- const attributeMap = new Map();
+ const attributeValues = new Map();
+ const attributeBooleans = new Set();
for (const mod of mods) {
const parsed = parsedMods?.get(mod) ?? parseAttributeSelector(mod);
- if (parsed && parsed.value !== 'true') {
- if (!attributeMap.has(parsed.attribute)) {
- attributeMap.set(parsed.attribute, []);
+ if (!parsed) continue;
+
+ if (parsed.value === 'true') {
+ // Boolean attribute
+ attributeBooleans.add(parsed.attribute);
+ } else {
+ // Value attribute
+ if (!attributeValues.has(parsed.attribute)) {
+ attributeValues.set(parsed.attribute, []);
}
- attributeMap.get(parsed.attribute)!.push(parsed.value);
+ attributeValues.get(parsed.attribute)!.push(parsed.value);
}
}
- // Check if any attribute has multiple values
- for (const values of attributeMap.values()) {
+ // Check for multiple different values for the same attribute
+ for (const values of attributeValues.values()) {
if (values.length > 1) return true;
}
@@ -199,16 +219,20 @@ interface AttributeMaps {
function buildAttributeMaps(
currentMods: string[],
allMods: string[],
+ parsedModsCache?: Map,
): AttributeMaps {
const allAttributes = new Map>();
const currentAttributes = new Map();
- const parsedMods = new Map();
-
- // Parse all mods once and cache results
- const allModsSet = new Set([...currentMods, ...allMods]);
- for (const mod of allModsSet) {
- if (!parsedMods.has(mod)) {
- parsedMods.set(mod, parseAttributeSelector(mod));
+ const parsedMods =
+ parsedModsCache || new Map();
+
+ // Parse all mods once and cache results (only if cache not provided)
+ if (!parsedModsCache) {
+ const allModsSet = new Set([...currentMods, ...allMods]);
+ for (const mod of allModsSet) {
+ if (!parsedMods.has(mod)) {
+ parsedMods.set(mod, parseAttributeSelector(mod));
+ }
}
}
@@ -234,6 +258,58 @@ function buildAttributeMaps(
return { allAttributes, currentAttributes, parsedMods };
}
+/**
+ * Check if a combination of positive and negative selectors creates a contradiction
+ * Returns true if the combination is INVALID and should be pruned
+ */
+function hasContradiction(
+ currentMods: string[],
+ notMods: string[],
+ parsedMods: Map,
+): boolean {
+ // Build maps of positive selector states
+ const positiveAttributes = new Map();
+ const positiveBooleans = new Set();
+
+ for (const mod of currentMods) {
+ const parsed = parsedMods.get(mod);
+ if (parsed) {
+ if (parsed.value === 'true') {
+ // Boolean attribute (e.g., [data-theme])
+ positiveBooleans.add(parsed.attribute);
+ } else {
+ // Value attribute (e.g., [data-theme="danger"])
+ if (!positiveAttributes.has(parsed.attribute)) {
+ positiveAttributes.set(parsed.attribute, []);
+ }
+ positiveAttributes.get(parsed.attribute)!.push(parsed.value);
+ }
+ }
+ }
+
+ // Check negative selectors for contradictions
+ for (const mod of notMods) {
+ const parsed = parsedMods.get(mod);
+ if (parsed) {
+ if (parsed.value === 'true') {
+ // Negative boolean: !([data-theme])
+ // Case 6: Value positive + attribute negative = CONTRADICTION
+ if (
+ positiveAttributes.has(parsed.attribute) ||
+ positiveBooleans.has(parsed.attribute)
+ ) {
+ return true; // INVALID: can't have value without attribute
+ }
+ } else {
+ // Negative value: !([data-theme="danger"])
+ // No contradiction - this is valid
+ }
+ }
+ }
+
+ return false;
+}
+
function optimizeNotSelectors(
currentMods: string[],
allMods: string[],
@@ -244,23 +320,213 @@ function optimizeNotSelectors(
const notMods = allMods.filter((mod) => !currentMods.includes(mod));
const optimizedNotMods: string[] = [];
+ // Precompute presence of negative boolean attributes to avoid repeated scans
+ const negativeBooleanByAttr = new Set();
+ for (const mod of notMods) {
+ const p = maps.parsedMods.get(mod);
+ if (p && p.value === 'true') {
+ negativeBooleanByAttr.add(p.attribute);
+ }
+ }
+
+ // Build maps of positive selector states for subsumption optimization
+ const positiveAttributes = new Map();
+ const positiveBooleans = new Set();
+
+ for (const mod of currentMods) {
+ const parsed = maps.parsedMods.get(mod);
+ if (parsed) {
+ if (parsed.value === 'true') {
+ positiveBooleans.add(parsed.attribute);
+ } else {
+ if (!positiveAttributes.has(parsed.attribute)) {
+ positiveAttributes.set(parsed.attribute, []);
+ }
+ positiveAttributes.get(parsed.attribute)!.push(parsed.value);
+ }
+ }
+ }
+
for (const mod of notMods) {
const parsed = maps.parsedMods.get(mod);
if (parsed && parsed.value !== 'true') {
+ // Negative value selector
// If we already have a value for this attribute, skip this not selector
- // because it's already mutually exclusive
+ // because it's already mutually exclusive (optimization)
+ if (maps.currentAttributes.has(parsed.attribute)) {
+ continue;
+ }
+ }
+
+ // If we have a positive value for this attribute, skip the negative boolean
+ // This avoids producing selectors like [data-attr="x"]:not([data-attr])
+ if (parsed && parsed.value === 'true') {
if (maps.currentAttributes.has(parsed.attribute)) {
continue;
}
}
+ // Case 4 subsumption: If we have a value positive and boolean positive for same attribute
+ // The value implies the boolean, so we can skip the boolean from positive mods
+ // (This is handled elsewhere - the value selector is more specific)
+
+ // Case 7 subsumption: If we have a value negative and boolean negative for same attribute
+ // The boolean negative implies value negative, so skip the value negative
+ if (parsed && parsed.value !== 'true') {
+ // If we also have the boolean attribute in negative mods, skip the value negative
+ if (negativeBooleanByAttr.has(parsed.attribute)) {
+ continue;
+ }
+ }
+
optimizedNotMods.push(mod);
}
return optimizedNotMods;
}
+/**
+ * Quick check if there are any same-attribute conflicts (boolean vs value for same attribute).
+ * Returns true if conflicts exist, false if filtering can be skipped.
+ * This early exit optimization handles the common case efficiently.
+ */
+function hasSameAttributeConflicts(
+ allMods: string[],
+ parsedModsCache: Map,
+): boolean {
+ const attributeCounts = new Map<
+ string,
+ { hasBool: boolean; hasValue: boolean }
+ >();
+
+ for (const mod of allMods) {
+ const parsed = parsedModsCache.get(mod);
+ if (!parsed) continue;
+
+ const isBool = parsed.value === 'true';
+ const existing = attributeCounts.get(parsed.attribute);
+
+ if (existing) {
+ // Already have both types for this attribute - conflict exists!
+ if ((isBool && existing.hasValue) || (!isBool && existing.hasBool)) {
+ return true;
+ }
+ // Update the types we've seen
+ if (isBool) existing.hasBool = true;
+ else existing.hasValue = true;
+ } else {
+ attributeCounts.set(parsed.attribute, {
+ hasBool: isBool,
+ hasValue: !isBool,
+ });
+ }
+ }
+
+ return false;
+}
+
+/**
+ * Filter mods based on priority order for same-attribute conflicts.
+ * If a boolean selector has higher priority than value selectors for the same attribute,
+ * remove the value selectors (they would be shadowed by the boolean).
+ *
+ * Priority is determined by order in the styleStates (after reversal - earlier = higher priority)
+ */
+function filterModsByPriority(
+ allMods: string[],
+ styleStates: Record,
+ lookupStyles: string[],
+ parsedModsCache: Map,
+): string[] {
+ // Early exit: if no same-attribute conflicts exist, skip all the expensive work
+ // This handles the common case efficiently without any cache overhead
+ if (!hasSameAttributeConflicts(allMods, parsedModsCache)) {
+ return allMods;
+ }
+
+ // Build priority map: for each mod, find its earliest appearance in any state list
+ const modPriorities = new Map();
+
+ for (const style of lookupStyles) {
+ const states = styleStates[style];
+ if (!states || states.length === 0) continue; // Skip empty states
+
+ // states are already reversed (higher priority = lower index)
+ for (let index = 0; index < states.length; index++) {
+ const state = states[index];
+ if (!state.mods || state.mods.length === 0) continue; // Skip empty mods
+
+ for (const mod of state.mods) {
+ const currentPriority = modPriorities.get(mod);
+ if (currentPriority === undefined || index < currentPriority) {
+ modPriorities.set(mod, index);
+ }
+ }
+ }
+ }
+
+ // Group mods by attribute
+ const attributeGroups = new Map<
+ string,
+ Array<{
+ mod: string;
+ isBool: boolean;
+ priority: number;
+ }>
+ >();
+
+ for (const mod of allMods) {
+ const parsed = parsedModsCache.get(mod);
+ if (!parsed) continue;
+
+ const priority = modPriorities.get(mod);
+ if (priority === undefined) continue;
+
+ const isBool = parsed.value === 'true';
+
+ let group = attributeGroups.get(parsed.attribute);
+ if (!group) {
+ group = [];
+ attributeGroups.set(parsed.attribute, group);
+ }
+
+ group.push({
+ mod,
+ isBool,
+ priority,
+ });
+ }
+
+ // Filter: for each attribute, if boolean has higher priority than any value, remove values
+ const modsToRemove = new Set();
+
+ for (const [attribute, group] of attributeGroups.entries()) {
+ // Only process attributes with more than one mod
+ if (group.length <= 1) continue;
+
+ const boolMods = group.filter((m) => m.isBool);
+ const valueMods = group.filter((m) => !m.isBool);
+
+ // Only check if we have both types
+ if (boolMods.length === 0 || valueMods.length === 0) continue;
+
+ // Check if any boolean has higher priority (lower index) than all values
+ for (const boolMod of boolMods) {
+ const hasHigherPriorityThanAllValues = valueMods.every(
+ (valueMod) => boolMod.priority < valueMod.priority,
+ );
+
+ if (hasHigherPriorityThanAllValues) {
+ // This boolean shadows all value mods for this attribute
+ valueMods.forEach((valueMod) => modsToRemove.add(valueMod.mod));
+ }
+ }
+ }
+
+ return allMods.filter((mod) => !modsToRemove.has(mod));
+}
+
/**
* Explode a style handler result into logical rules with proper mapping
* Phase 1: Handler fan-out ($ selectors, arrays)
@@ -458,6 +724,190 @@ function convertHandlerResultToCSS(result: any, selectorSuffix = ''): string {
return `&${finalSelectorSuffix}${normalizedSingle}{\n${renderedStyles}}\n`;
}
+/**
+ * Process state maps with mod combinations and generate logical rules.
+ * This consolidates the common logic for handling mod combinations, priority filtering,
+ * contradiction checking, and selector optimization.
+ */
+function processStateMapsWithModCombinations(
+ styleStates: Record,
+ lookupStyles: string[],
+ zones: ResponsiveZone[],
+ handler: StyleHandler,
+ parentSuffix: string,
+ allLogicalRules: LogicalRule[],
+ cachedNormalizeStyleZones: (value: any, zoneNumber: number) => any,
+ breakpointIdx?: number,
+ responsiveOrigin: boolean = false,
+): void {
+ // Collect all mods from style states
+ const allMods: string[] = [];
+ const seenMods = new Set();
+
+ for (const style of lookupStyles) {
+ const states = styleStates[style];
+ if (!states) continue;
+
+ for (const state of states) {
+ if (state.mods) {
+ for (const mod of state.mods) {
+ if (!seenMods.has(mod)) {
+ seenMods.add(mod);
+ allMods.push(mod);
+ }
+ }
+ }
+ }
+ }
+
+ if (allMods.length === 0) {
+ // No mods - just call handler with default state values
+ const stateProps: Record = {};
+ lookupStyles.forEach((style) => {
+ const states = styleStates[style];
+ if (states && states.length > 0) {
+ stateProps[style] = states[0].value;
+ }
+ });
+ const result = handler(stateProps as any);
+ if (!result) return;
+
+ const logicalRules = explodeHandlerResult(
+ result,
+ zones,
+ parentSuffix,
+ breakpointIdx,
+ responsiveOrigin,
+ );
+ allLogicalRules.push(...logicalRules);
+ return;
+ }
+
+ // Parse all mods once and share the cache across all operations
+ const parsedModsCache = new Map();
+ for (const mod of allMods) {
+ parsedModsCache.set(mod, parseAttributeSelector(getModSelector(mod)));
+ }
+
+ // Apply priority-based filtering for same-attribute boolean vs value conflicts
+ const filteredMods = filterModsByPriority(
+ allMods,
+ styleStates,
+ lookupStyles,
+ parsedModsCache,
+ );
+
+ // Precompute attribute maps once for all combinations
+ const attributeMaps = buildAttributeMaps([], filteredMods, parsedModsCache);
+
+ // Generate combinations with conflict-aware pruning
+ const conflictChecker = createAttributeConflictChecker(
+ attributeMaps.parsedMods,
+ );
+ const combinations = getModCombinationsIterative(
+ filteredMods,
+ true,
+ conflictChecker,
+ );
+
+ // Process each mod combination
+ combinations.forEach((modCombination) => {
+ const stateProps: Record = {};
+
+ // Find matching state for each style
+ lookupStyles.forEach((style) => {
+ const states = styleStates[style];
+ const matchingState = states.find((state: any) =>
+ computeState(state.model, (mod) => modCombination.includes(mod)),
+ );
+ if (matchingState) {
+ stateProps[style] = matchingState.value;
+ }
+ });
+
+ // Use precomputed maps for efficient not selector optimization
+ const currentMaps = buildAttributeMaps(
+ modCombination,
+ filteredMods,
+ parsedModsCache,
+ );
+
+ // Compute raw NOTs for contradiction check (before optimization)
+ const rawNotMods = filteredMods.filter(
+ (mod) => !modCombination.includes(mod),
+ );
+
+ // Check for contradictions between positive and negative selectors
+ if (hasContradiction(modCombination, rawNotMods, currentMaps.parsedMods)) {
+ return; // Skip this invalid combination
+ }
+
+ // Optimize NOT selectors afterwards (pure simplification)
+ const optimizedNotMods = optimizeNotSelectors(
+ modCombination,
+ filteredMods,
+ currentMaps,
+ );
+
+ // Build the mod selector string
+ const modsSelectors = `${modCombination
+ .map(getModSelector)
+ .join('')}${optimizedNotMods
+ .map((mod) => {
+ const sel = getModSelector(mod);
+ return sel.startsWith(':not(') ? sel.slice(5, -1) : `:not(${sel})`;
+ })
+ .join('')}`;
+
+ // Check if any state value is responsive (array)
+ const hasResponsiveStateValues = lookupStyles.some((style) =>
+ Array.isArray(stateProps[style]),
+ );
+
+ if (hasResponsiveStateValues) {
+ // Fan out by breakpoint for responsive state values
+ const propsByPoint = zones.map((_, i) => {
+ const pointProps: Record = {};
+ lookupStyles.forEach((style) => {
+ const v = stateProps[style];
+ if (Array.isArray(v)) {
+ const arr = cachedNormalizeStyleZones(v, zones.length);
+ pointProps[style] = arr?.[i];
+ } else {
+ pointProps[style] = v;
+ }
+ });
+ return pointProps;
+ });
+
+ propsByPoint.forEach((props, bpIdx) => {
+ const res = handler(props as any);
+ if (!res) return;
+ const logical = explodeHandlerResult(
+ res,
+ zones,
+ `${modsSelectors}${parentSuffix}`,
+ bpIdx,
+ true,
+ );
+ allLogicalRules.push(...logical);
+ });
+ } else {
+ // Simple non-responsive state values
+ const result = handler(stateProps as any);
+ if (!result) return;
+ const logical = explodeHandlerResult(
+ result,
+ zones,
+ `${modsSelectors}${parentSuffix}`,
+ breakpointIdx,
+ responsiveOrigin,
+ );
+ allLogicalRules.push(...logical);
+ }
+ });
+}
+
/**
* Convert logical rules to final StyleResult format
*/
@@ -738,83 +1188,30 @@ function generateLogicalRules(
});
if (hasStateMapsAtPoint) {
- const allMods = new Set();
+ // Build styleStates from point props
const styleStates: Record = {};
-
lookupStyles.forEach((style) => {
const v = pointProps[style];
if (v && typeof v === 'object' && !Array.isArray(v)) {
- const { states, mods } = styleStateMapToStyleStateDataList(v);
+ const { states } = styleStateMapToStyleStateDataList(v);
styleStates[style] = states;
- mods.forEach((m: string) => allMods.add(m));
} else {
styleStates[style] = [{ mods: [], notMods: [], value: v }];
}
});
- const allModsArray = Array.from(allMods);
-
- // Precompute attribute maps once for all combinations
- const attributeMaps = buildAttributeMaps([], allModsArray);
-
- // Generate combinations with conflict-aware pruning
- const conflictChecker = createAttributeConflictChecker(
- attributeMaps.parsedMods,
- );
- const combinations = getModCombinationsIterative(
- allModsArray,
+ // Use the consolidated helper for mod combination processing
+ processStateMapsWithModCombinations(
+ styleStates,
+ lookupStyles,
+ zones || [],
+ handler,
+ parentSuffix,
+ allLogicalRules,
+ cachedNormalizeStyleZones,
+ breakpointIdx,
true,
- conflictChecker,
);
-
- combinations.forEach((modCombination) => {
- const stateProps: Record = {};
-
- lookupStyles.forEach((style) => {
- const states = styleStates[style];
- const matchingState = states.find((state: any) =>
- computeState(state.model, (mod) =>
- modCombination.includes(mod),
- ),
- );
- if (matchingState) {
- stateProps[style] = matchingState.value;
- }
- });
-
- // Use precomputed maps for efficient not selector optimization
- const currentMaps = buildAttributeMaps(
- modCombination,
- allModsArray,
- );
- const optimizedNotMods = optimizeNotSelectors(
- modCombination,
- allModsArray,
- currentMaps,
- );
- const modsSelectors = `${modCombination
- .map(getModSelector)
- .join('')}${optimizedNotMods
- .map((mod) => {
- const sel = getModSelector(mod);
- return sel.startsWith(':not(')
- ? sel.slice(5, -1)
- : `:not(${sel})`;
- })
- .join('')}`;
-
- const result = handler(stateProps as any);
- if (!result) return;
-
- const logicalRules = explodeHandlerResult(
- result,
- zones || [],
- `${modsSelectors}${parentSuffix}`,
- breakpointIdx,
- true,
- );
- allLogicalRules.push(...logicalRules);
- });
} else {
const result = handler(pointProps as any);
if (!result) return;
@@ -837,119 +1234,29 @@ function generateLogicalRules(
});
if (hasStateMaps) {
- // Process each style property individually for state resolution
- const allMods = new Set();
+ // Build styleStates from styleMap
const styleStates: Record = {};
-
lookupStyles.forEach((style) => {
const value = styleMap[style];
if (value && typeof value === 'object' && !Array.isArray(value)) {
- const { states, mods } = styleStateMapToStyleStateDataList(value);
+ const { states } = styleStateMapToStyleStateDataList(value);
styleStates[style] = states;
- mods.forEach((mod) => allMods.add(mod));
} else {
// Simple value, create a single state
styleStates[style] = [{ mods: [], notMods: [], value }];
}
});
- // Generate all possible mod combinations
- const allModsArray = Array.from(allMods);
-
- // Precompute attribute maps once for all combinations
- const attributeMaps = buildAttributeMaps([], allModsArray);
-
- // Generate combinations with conflict-aware pruning
- const conflictChecker = createAttributeConflictChecker(
- attributeMaps.parsedMods,
+ // Use the consolidated helper for mod combination processing
+ processStateMapsWithModCombinations(
+ styleStates,
+ lookupStyles,
+ zones || [],
+ handler,
+ parentSuffix,
+ allLogicalRules,
+ cachedNormalizeStyleZones,
);
- const combinations = getModCombinationsIterative(
- allModsArray,
- true,
- conflictChecker,
- );
-
- combinations.forEach((modCombination) => {
- const stateProps: Record = {};
-
- lookupStyles.forEach((style) => {
- const states = styleStates[style];
- // Find the matching state for this mod combination
- const matchingState = states.find((state) => {
- return computeState(state.model, (mod) =>
- modCombination.includes(mod),
- );
- });
- if (matchingState) {
- stateProps[style] = matchingState.value;
- }
- });
-
- // Use precomputed maps for efficient not selector optimization
- const currentMaps = buildAttributeMaps(
- modCombination,
- allModsArray,
- );
- const optimizedNotMods = optimizeNotSelectors(
- modCombination,
- allModsArray,
- currentMaps,
- );
- const modsSelectors = `${modCombination
- .map(getModSelector)
- .join('')}${optimizedNotMods
- .map((mod) => {
- const sel = getModSelector(mod);
- return sel.startsWith(':not(')
- ? sel.slice(5, -1)
- : `:not(${sel})`;
- })
- .join('')}`;
-
- // If any state value is responsive (array), fan-out by breakpoint
- const hasResponsiveStateValues = lookupStyles.some((style) =>
- Array.isArray(stateProps[style]),
- );
-
- if (hasResponsiveStateValues) {
- const propsByPoint = zones.map((_, i) => {
- const pointProps: Record = {};
- lookupStyles.forEach((style) => {
- const v = stateProps[style];
- if (Array.isArray(v)) {
- const arr = cachedNormalizeStyleZones(v, zones.length);
- pointProps[style] = arr?.[i];
- } else {
- pointProps[style] = v;
- }
- });
- return pointProps;
- });
-
- propsByPoint.forEach((props, breakpointIdx) => {
- const res = handler(props as any);
- if (!res) return;
- const logical = explodeHandlerResult(
- res,
- zones || [],
- `${modsSelectors}${parentSuffix}`,
- breakpointIdx,
- true,
- );
- allLogicalRules.push(...logical);
- });
- } else {
- // Simple non-responsive state values
- const result = handler(stateProps as any);
- if (!result) return;
- const logical = explodeHandlerResult(
- result,
- zones || [],
- `${modsSelectors}${parentSuffix}`,
- );
- allLogicalRules.push(...logical);
- }
- });
} else {
// Simple case: no state maps, call handler directly
const result = handler(styleMap as any);
@@ -1011,8 +1318,15 @@ export function renderStyles(
return directSelector ? [] : { rules: [] };
}
- // Generate logical rules using shared pipeline
- const allLogicalRules = generateLogicalRules(styles, responsive);
+ // Generate logical rules using shared pipeline (memoized per styles+breakpoints)
+ const stylesKey = stringifyStyles(styles);
+ const bpKey = (responsive || []).join(',');
+ const lrKey = `${stylesKey}#${bpKey}`;
+ let allLogicalRules = logicalRulesCache.get(lrKey);
+ if (!allLogicalRules) {
+ allLogicalRules = generateLogicalRules(styles, responsive);
+ logicalRulesCache.set(lrKey, allLogicalRules);
+ }
const zones = pointsToZones(responsive || []);
if (directSelector) {
diff --git a/src/tasty/utils/styles.ts b/src/tasty/utils/styles.ts
index 1c69f43b6..b81e5bf96 100644
--- a/src/tasty/utils/styles.ts
+++ b/src/tasty/utils/styles.ts
@@ -118,10 +118,61 @@ const MOD_NAME_CACHE = new Map();
export function getModSelector(modName: string): string {
if (!MOD_NAME_CACHE.has(modName)) {
- MOD_NAME_CACHE.set(
- modName,
- modName.match(/^[a-z]/) ? `[data-is-${camelToKebab(modName)}]` : modName,
+ let selector: string;
+
+ // Check if it's a shorthand value mod: key=value, key^=value, key$=value, key*=value
+ // Supports: key=value, key="value", key='value', and with ^=, $=, *= operators
+ const valueModMatch = modName.match(
+ /^([a-z][a-z0-9-]*)(\^=|\$=|\*=|=)(.+)$/i,
);
+ if (valueModMatch) {
+ const key = valueModMatch[1];
+ const operator = valueModMatch[2];
+ let value = valueModMatch[3];
+
+ // Remove quotes if present
+ if (
+ (value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))
+ ) {
+ value = value.slice(1, -1);
+ }
+
+ // Convert to full attribute selector with the appropriate operator
+ selector = `[data-${camelToKebab(key)}${operator}"${value}"]`;
+ } else if (modName.match(/^[a-z]/)) {
+ // Boolean mod: convert camelCase to kebab-case
+ selector = `[data-${camelToKebab(modName)}]`;
+ } else {
+ // Check if it contains :has() with capitalized element names
+ if (modName.includes(':has(')) {
+ selector = modName.replace(/:has\(([^)]+)\)/g, (match, content) => {
+ // Validate that combinators have spaces around them
+ // Check for capitalized words adjacent to combinators without spaces
+ const invalidPattern = /[A-Z][a-z]*[>+~]|[>+~][A-Z][a-z]*/;
+ if (invalidPattern.test(content)) {
+ console.error(
+ `[Tasty] Invalid :has() selector syntax: "${modName}"\n` +
+ `Combinators (>, +, ~) must have spaces around them when used with element names.\n` +
+ `Example: Use ":has(Body > Row)" instead of ":has(Body>Row)"\n` +
+ `This is a design choice: the parser uses simple whitespace splitting for performance.`,
+ );
+ }
+
+ // Transform capitalized words to [data-element="..."] selectors
+ const tokens = content.split(/\s+/);
+ const transformed = tokens.map((token: string) =>
+ /^[A-Z]/.test(token) ? `[data-element="${token}"]` : token,
+ );
+ return `:has(${transformed.join(' ')})`;
+ });
+ } else {
+ // Pass through (e.g., :hover, .class, [attr])
+ selector = modName;
+ }
+ }
+
+ MOD_NAME_CACHE.set(modName, selector);
}
return MOD_NAME_CACHE.get(modName);
@@ -339,7 +390,7 @@ export function extractStyles(
}
const STATES_REGEXP =
- /([&|!^])|([()])|([a-z][a-z0-9-]+)|(:[a-z][a-z0-9-]+\([^)]+\)|:[a-z][a-z0-9-]+)|(\.[a-z][a-z0-9-]+)|(\[[^\]]+])/gi;
+ /([&|!^])|([()])|([a-z][a-z0-9-]+=(?:"[^"]*"|'[^']*'|[^\s&|!^()]+))|([a-z][a-z0-9-]+)|(:[a-z][a-z0-9-]+\([^)]+\)|:[a-z][a-z0-9-]+)|(\.[a-z][a-z0-9-]+)|(\[[^\]]+])/gi;
export const STATE_OPERATORS = {
NOT: '!',
AND: '&',
diff --git a/src/tasty/value-mods.test.tsx b/src/tasty/value-mods.test.tsx
new file mode 100644
index 000000000..dcdba0885
--- /dev/null
+++ b/src/tasty/value-mods.test.tsx
@@ -0,0 +1,654 @@
+import { render } from '@testing-library/react';
+
+import { tasty } from './tasty';
+import { getModSelector } from './utils/styles';
+
+describe('Value Mods', () => {
+ describe('modAttrs generation', () => {
+ const TestElement = tasty({
+ as: 'div',
+ styles: {},
+ });
+
+ it('should generate data-* attributes for boolean true', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveAttribute('data-hovered', '');
+ });
+
+ it('should not generate attributes for boolean false', () => {
+ const { container } = render();
+ expect(container.firstChild).not.toHaveAttribute('data-hovered');
+ });
+
+ it('should not generate attributes for null/undefined', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).not.toHaveAttribute('data-a');
+ expect(container.firstChild).not.toHaveAttribute('data-b');
+ });
+
+ it('should generate data-* attributes for string values', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toHaveAttribute('data-theme', 'danger');
+ expect(container.firstChild).toHaveAttribute('data-size', 'large');
+ });
+
+ it('should generate data-* attributes for empty string', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveAttribute('data-label', '');
+ });
+
+ it('should convert numbers to strings', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveAttribute('data-level', '2');
+ });
+
+ it('should handle mixed boolean and value mods', () => {
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toHaveAttribute('data-hovered', '');
+ expect(container.firstChild).not.toHaveAttribute('data-pressed');
+ expect(container.firstChild).toHaveAttribute('data-theme', 'danger');
+ expect(container.firstChild).toHaveAttribute('data-size', 'large');
+ });
+
+ it('should preserve "is" prefix in camelCase names', () => {
+ const { container } = render();
+ expect(container.firstChild).toHaveAttribute('data-is-selected', '');
+ });
+ });
+
+ describe('Style binding with value mods', () => {
+ it('should apply styles for value mods using shorthand syntax', () => {
+ const Button = tasty({
+ as: 'button',
+ styles: {
+ fill: {
+ '': '#surface',
+ 'theme=danger': '#red',
+ 'theme=warning': '#yellow',
+ },
+ },
+ });
+
+ const { container, rerender } = render(
+ ,
+ );
+
+ const button = container.firstChild as HTMLElement;
+ expect(button).toHaveAttribute('data-theme', 'danger');
+
+ rerender();
+ expect(button).toHaveAttribute('data-theme', 'warning');
+ });
+
+ it('should apply styles combining boolean and value mods', () => {
+ const Card = tasty({
+ as: 'div',
+ styles: {
+ fill: {
+ '': '#surface',
+ hovered: '#surface-hover',
+ 'theme=danger': '#red',
+ 'hovered & theme=danger': '#light-red',
+ },
+ },
+ });
+
+ const { container } = render(
+ Content,
+ );
+
+ const card = container.firstChild as HTMLElement;
+ expect(card).toHaveAttribute('data-hovered', '');
+ expect(card).toHaveAttribute('data-theme', 'danger');
+ });
+
+ it('should support quoted value syntax', () => {
+ const Element = tasty({
+ as: 'div',
+ styles: {
+ color: {
+ '': '#text',
+ 'variant="primary"': '#purple',
+ "variant='secondary'": '#gray',
+ },
+ },
+ });
+
+ const { container, rerender } = render(
+ Primary,
+ );
+
+ expect(container.firstChild).toHaveAttribute('data-variant', 'primary');
+
+ rerender(Secondary);
+ expect(container.firstChild).toHaveAttribute('data-variant', 'secondary');
+ });
+
+ it('should support full attribute selector syntax', () => {
+ const Element = tasty({
+ as: 'div',
+ styles: {
+ color: {
+ '': '#text',
+ '[data-theme="danger"]': '#red',
+ },
+ },
+ });
+
+ const { container } = render(
+ Danger,
+ );
+
+ expect(container.firstChild).toHaveAttribute('data-theme', 'danger');
+ });
+ });
+
+ describe('Priority order for same-attribute selectors', () => {
+ it('should respect first-match priority when boolean has higher priority than value', () => {
+ const Component = tasty({
+ as: 'div',
+ styles: {
+ color: {
+ 'theme=danger': 'red',
+ theme: 'blue', // Higher priority after reversal
+ },
+ },
+ });
+
+ const { container } = render();
+ const element = container.firstChild as HTMLElement;
+
+ // Element should have data-theme="danger"
+ expect(element).toHaveAttribute('data-theme', 'danger');
+
+ // Check computed color - should use boolean selector (higher priority)
+ // The value selector should be filtered out before CSS generation
+ const style = window.getComputedStyle(element);
+ expect(style.color).not.toBe('red'); // Value selector should not apply
+ });
+
+ it('should allow both selectors when value has higher priority', () => {
+ const Component = tasty({
+ as: 'div',
+ styles: {
+ color: {
+ theme: 'blue',
+ 'theme=danger': 'red', // Higher priority after reversal
+ },
+ },
+ });
+
+ const { container } = render();
+ const element = container.firstChild as HTMLElement;
+
+ expect(element).toHaveAttribute('data-theme', 'danger');
+ // Both CSS rules should be generated, value selector wins via cascade
+ });
+
+ it('should handle multiple attributes independently', () => {
+ const Component = tasty({
+ as: 'div',
+ styles: {
+ color: {
+ theme: 'blue', // Boolean higher priority
+ 'theme=danger': 'red',
+ },
+ fontSize: {
+ 'size=large': '20px', // Value higher priority
+ size: '16px',
+ },
+ },
+ });
+
+ const { container } = render(
+ ,
+ );
+ const element = container.firstChild as HTMLElement;
+
+ expect(element).toHaveAttribute('data-theme', 'danger');
+ expect(element).toHaveAttribute('data-size', 'large');
+ // theme=danger should be filtered, size=large should not
+ });
+
+ it('should work with complex combinations', () => {
+ const Component = tasty({
+ as: 'div',
+ styles: {
+ color: {
+ 'theme=danger & hovered': 'lightcoral',
+ 'theme & hovered': 'lightblue', // Boolean theme higher priority
+ },
+ },
+ });
+
+ const { container } = render(
+ ,
+ );
+ const element = container.firstChild as HTMLElement;
+
+ expect(element).toHaveAttribute('data-theme', 'danger');
+ expect(element).toHaveAttribute('data-hovered', '');
+ // First combination should be filtered due to boolean theme priority
+ });
+
+ it('should not filter when only value selectors exist', () => {
+ const Component = tasty({
+ as: 'div',
+ styles: {
+ color: {
+ 'theme=danger': 'red',
+ 'theme=warning': 'yellow',
+ 'theme=success': 'green',
+ },
+ },
+ });
+
+ const { container } = render();
+ const element = container.firstChild as HTMLElement;
+
+ expect(element).toHaveAttribute('data-theme', 'danger');
+ // All value selectors should remain (no boolean to filter them)
+ });
+
+ it('should not filter when only boolean selectors exist', () => {
+ const Component = tasty({
+ as: 'div',
+ styles: {
+ color: {
+ theme: 'blue',
+ hovered: 'lightblue',
+ },
+ },
+ });
+
+ const { container } = render(
+ ,
+ );
+ const element = container.firstChild as HTMLElement;
+
+ expect(element).toHaveAttribute('data-theme', 'danger');
+ expect(element).toHaveAttribute('data-hovered', '');
+ // Both boolean selectors should remain
+ });
+
+ it('should work with full attribute selector syntax for boolean', () => {
+ const Component = tasty({
+ as: 'button',
+ styles: {
+ color: {
+ '[aria-label="Submit"]': 'red',
+ '[aria-label]': 'blue', // Higher priority after reversal
+ },
+ },
+ });
+
+ const { container } = render(
+ Click me,
+ );
+ const element = container.firstChild as HTMLElement;
+
+ expect(element).toHaveAttribute('aria-label', 'Submit');
+
+ // Check computed color - should use boolean selector (higher priority)
+ const style = window.getComputedStyle(element);
+ expect(style.color).not.toBe('red'); // Value selector should not apply
+ });
+
+ it('should work with full attribute selector syntax when value has priority', () => {
+ const Component = tasty({
+ as: 'button',
+ styles: {
+ color: {
+ '[aria-label]': 'blue',
+ '[aria-label="Submit"]': 'red', // Higher priority after reversal
+ },
+ },
+ });
+
+ const { container } = render(
+ Click me,
+ );
+ const element = container.firstChild as HTMLElement;
+
+ expect(element).toHaveAttribute('aria-label', 'Submit');
+ // Both CSS rules should be generated, value selector wins via cascade
+ });
+
+ it('should work with mixed shorthand and full selector syntax', () => {
+ const Component = tasty({
+ as: 'div',
+ styles: {
+ color: {
+ 'theme=danger': 'red',
+ '[data-theme]': 'blue', // Higher priority after reversal
+ },
+ },
+ });
+
+ const { container } = render();
+ const element = container.firstChild as HTMLElement;
+
+ expect(element).toHaveAttribute('data-theme', 'danger');
+
+ // Check computed color - should use boolean selector (higher priority)
+ const style = window.getComputedStyle(element);
+ expect(style.color).not.toBe('red'); // Value selector should not apply
+ });
+
+ it('should work with data-* prefix in full selector syntax', () => {
+ const Component = tasty({
+ as: 'div',
+ styles: {
+ fontSize: {
+ '[data-size="large"]': '20px',
+ '[data-size]': '16px', // Higher priority after reversal
+ },
+ },
+ });
+
+ const { container } = render();
+ const element = container.firstChild as HTMLElement;
+
+ expect(element).toHaveAttribute('data-size', 'large');
+
+ // Check computed fontSize - should use boolean selector (higher priority)
+ const style = window.getComputedStyle(element);
+ expect(style.fontSize).not.toBe('20px'); // Value selector should not apply
+ });
+ });
+
+ describe('Migration from data-is-* to data-*', () => {
+ it('should generate data-disabled instead of data-is-disabled', () => {
+ const DisabledElement = tasty({
+ as: 'div',
+ styles: {},
+ });
+
+ const { container } = render(
+ Content,
+ );
+ expect(container.firstChild).toHaveAttribute('disabled');
+ expect(container.firstChild).toHaveAttribute('data-disabled', '');
+ });
+
+ it('should generate data-checked instead of data-is-checked', () => {
+ const CheckboxElement = tasty({
+ as: 'input',
+ styles: {},
+ });
+
+ const { container } = render(
+ ,
+ );
+ expect(container.firstChild).toHaveAttribute('checked');
+ expect(container.firstChild).toHaveAttribute('data-checked', '');
+ });
+
+ it('should generate data-hidden instead of data-is-hidden', () => {
+ const HiddenElement = tasty({
+ as: 'div',
+ styles: {},
+ });
+
+ const { container } = render(
+ Content,
+ );
+ expect(container.firstChild).toHaveAttribute('hidden');
+ expect(container.firstChild).toHaveAttribute('data-hidden', '');
+ });
+ });
+
+ describe(':has() selector transformation', () => {
+ describe('getModSelector unit tests', () => {
+ it('should transform :has(Item) to :has([data-element="Item"])', () => {
+ expect(getModSelector(':has(Item)')).toBe(
+ ':has([data-element="Item"])',
+ );
+ });
+
+ it('should transform :has(Body > Row) with multiple elements', () => {
+ expect(getModSelector(':has(Body > Row)')).toBe(
+ ':has([data-element="Body"] > [data-element="Row"])',
+ );
+ });
+
+ it('should preserve non-capitalized selectors in :has()', () => {
+ expect(getModSelector(':has(.selected)')).toBe(':has(.selected)');
+ });
+
+ it('should handle mixed selectors :has(.class Item)', () => {
+ expect(getModSelector(':has(.wrapper Item)')).toBe(
+ ':has(.wrapper [data-element="Item"])',
+ );
+ });
+
+ it('should handle :has() with combinators', () => {
+ expect(getModSelector(':has(> Item)')).toBe(
+ ':has(> [data-element="Item"])',
+ );
+ });
+
+ it('should handle multiple capitalized elements with spaces', () => {
+ expect(getModSelector(':has(Body Item Row)')).toBe(
+ ':has([data-element="Body"] [data-element="Item"] [data-element="Row"])',
+ );
+ });
+
+ it('should warn when combinator lacks spaces in :has()', () => {
+ const consoleErrorSpy = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+
+ // Test various invalid patterns
+ getModSelector(':has(Body>Row)');
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('[Tasty] Invalid :has() selector syntax'),
+ );
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining(':has(Body>Row)'),
+ );
+
+ consoleErrorSpy.mockClear();
+
+ getModSelector(':has(Header+Content)');
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('[Tasty] Invalid :has() selector syntax'),
+ );
+
+ consoleErrorSpy.mockClear();
+
+ getModSelector(':has(List~Item)');
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringContaining('[Tasty] Invalid :has() selector syntax'),
+ );
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should not warn when combinator has proper spaces in :has()', () => {
+ const consoleErrorSpy = jest
+ .spyOn(console, 'error')
+ .mockImplementation(() => {});
+
+ getModSelector(':has(Body > Row)');
+ getModSelector(':has(Header + Content)');
+ getModSelector(':has(List ~ Item)');
+ getModSelector(':has(> Item)');
+
+ expect(consoleErrorSpy).not.toHaveBeenCalled();
+
+ consoleErrorSpy.mockRestore();
+ });
+
+ it('should transform type^=fullscreen to [data-type^="fullscreen"]', () => {
+ expect(getModSelector('type^=fullscreen')).toBe(
+ '[data-type^="fullscreen"]',
+ );
+ });
+
+ it('should transform type$=screen to [data-type$="screen"]', () => {
+ expect(getModSelector('type$=screen')).toBe('[data-type$="screen"]');
+ });
+
+ it('should transform type*=full to [data-type*="full"]', () => {
+ expect(getModSelector('type*=full')).toBe('[data-type*="full"]');
+ });
+
+ it('should handle quoted values with ^= operator', () => {
+ expect(getModSelector('type^="fullscreen"')).toBe(
+ '[data-type^="fullscreen"]',
+ );
+ });
+
+ it('should handle single-quoted values with $= operator', () => {
+ expect(getModSelector("type$='screen'")).toBe('[data-type$="screen"]');
+ });
+
+ it('should handle quoted values with *= operator', () => {
+ expect(getModSelector('name*="test"')).toBe('[data-name*="test"]');
+ });
+
+ it('should convert camelCase keys with operators', () => {
+ expect(getModSelector('dataType^=fullscreen')).toBe(
+ '[data-data-type^="fullscreen"]',
+ );
+ });
+ });
+
+ it('should transform :has(Item) to :has([data-element="Item"]) in component', () => {
+ const Container = tasty({
+ as: 'div',
+ styles: {
+ padding: {
+ '': '1x',
+ ':has(Item)': '2x',
+ },
+ },
+ });
+
+ const Item = tasty({
+ as: 'div',
+ 'data-element': 'Item',
+ styles: {},
+ });
+
+ const { container } = render(
+
+ - Child
+ ,
+ );
+
+ const element = container.firstChild as HTMLElement;
+
+ // Verify the elements are rendered
+ expect(element).toBeInTheDocument();
+ expect(
+ element.querySelector('[data-element="Item"]'),
+ ).toBeInTheDocument();
+ });
+
+ it('should transform :has(Body > Row) with multiple elements', () => {
+ const Container = tasty({
+ as: 'div',
+ styles: {
+ display: {
+ '': 'block',
+ ':has(Body > Row)': 'flex',
+ },
+ },
+ });
+
+ const Body = tasty({
+ as: 'div',
+ 'data-element': 'Body',
+ styles: {},
+ });
+
+ const Row = tasty({
+ as: 'div',
+ 'data-element': 'Row',
+ styles: {},
+ });
+
+ const { container } = render(
+
+
+ Content
+
+ ,
+ );
+
+ const element = container.firstChild as HTMLElement;
+
+ // The element should have styles applied
+ expect(element).toBeInTheDocument();
+ });
+
+ it('should preserve non-capitalized selectors in :has()', () => {
+ const Container = tasty({
+ as: 'div',
+ styles: {
+ border: {
+ '': '1px solid #border',
+ ':has(.selected)': '2px solid #primary',
+ },
+ },
+ });
+
+ const { container } = render(
+
+ Child
+ ,
+ );
+
+ const element = container.firstChild as HTMLElement;
+
+ // The element should be rendered
+ expect(element).toBeInTheDocument();
+ });
+
+ it('should handle mixed selectors :has(.class Item)', () => {
+ const Container = tasty({
+ as: 'div',
+ styles: {
+ background: {
+ '': '#surface',
+ ':has(.wrapper Item)': '#primary',
+ },
+ },
+ });
+
+ const Item = tasty({
+ as: 'div',
+ 'data-element': 'Item',
+ styles: {},
+ });
+
+ const { container } = render(
+
+
+ - Content
+
+ ,
+ );
+
+ const element = container.firstChild as HTMLElement;
+
+ // The element should be rendered with styles
+ expect(element).toBeInTheDocument();
+ });
+ });
+});