Skip to content

Commit df05f0e

Browse files
committed
feat(tasty): mod values support * 2
1 parent 8fe5964 commit df05f0e

File tree

2 files changed

+380
-25
lines changed

2 files changed

+380
-25
lines changed

src/tasty/utils/renderStyles.ts

Lines changed: 165 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -157,20 +157,27 @@ function hasConflictingAttributeSelectors(
157157
mods: string[],
158158
parsedMods?: Map<string, ParsedAttributeSelector | null>,
159159
): boolean {
160-
const attributeMap = new Map<string, string[]>();
160+
const attributeValues = new Map<string, string[]>();
161+
const attributeBooleans = new Set<string>();
161162

162163
for (const mod of mods) {
163164
const parsed = parsedMods?.get(mod) ?? parseAttributeSelector(mod);
164-
if (parsed && parsed.value !== 'true') {
165-
if (!attributeMap.has(parsed.attribute)) {
166-
attributeMap.set(parsed.attribute, []);
165+
if (!parsed) continue;
166+
167+
if (parsed.value === 'true') {
168+
// Boolean attribute
169+
attributeBooleans.add(parsed.attribute);
170+
} else {
171+
// Value attribute
172+
if (!attributeValues.has(parsed.attribute)) {
173+
attributeValues.set(parsed.attribute, []);
167174
}
168-
attributeMap.get(parsed.attribute)!.push(parsed.value);
175+
attributeValues.get(parsed.attribute)!.push(parsed.value);
169176
}
170177
}
171178

172-
// Check if any attribute has multiple values
173-
for (const values of attributeMap.values()) {
179+
// Check for multiple different values for the same attribute
180+
for (const values of attributeValues.values()) {
174181
if (values.length > 1) return true;
175182
}
176183

@@ -270,7 +277,10 @@ function hasContradiction(
270277
if (parsed.value === 'true') {
271278
// Negative boolean: !([data-theme])
272279
// Case 6: Value positive + attribute negative = CONTRADICTION
273-
if (positiveAttributes.has(parsed.attribute)) {
280+
if (
281+
positiveAttributes.has(parsed.attribute) ||
282+
positiveBooleans.has(parsed.attribute)
283+
) {
274284
return true; // INVALID: can't have value without attribute
275285
}
276286
} else {
@@ -323,6 +333,14 @@ function optimizeNotSelectors(
323333
}
324334
}
325335

336+
// If we have a positive value for this attribute, skip the negative boolean
337+
// This avoids producing selectors like [data-attr="x"]:not([data-attr])
338+
if (parsed && parsed.value === 'true') {
339+
if (maps.currentAttributes.has(parsed.attribute)) {
340+
continue;
341+
}
342+
}
343+
326344
// Case 4 subsumption: If we have a value positive and boolean positive for same attribute
327345
// The value implies the boolean, so we can skip the boolean from positive mods
328346
// (This is handled elsewhere - the value selector is more specific)
@@ -354,6 +372,103 @@ function optimizeNotSelectors(
354372
return optimizedNotMods;
355373
}
356374

375+
/**
376+
* Filter mods based on priority order for same-attribute conflicts.
377+
* If a boolean selector has higher priority than value selectors for the same attribute,
378+
* remove the value selectors (they would be shadowed by the boolean).
379+
*
380+
* Priority is determined by order in the styleStates (after reversal - earlier = higher priority)
381+
*/
382+
function filterModsByPriority(
383+
allMods: string[],
384+
styleStates: Record<string, any>,
385+
lookupStyles: string[],
386+
parsedModsCache?: Map<string, ParsedAttributeSelector | null>,
387+
): string[] {
388+
// Parse all mods once
389+
const parsedMods =
390+
parsedModsCache || new Map<string, ParsedAttributeSelector | null>();
391+
if (!parsedModsCache) {
392+
for (const mod of allMods) {
393+
if (!parsedMods.has(mod)) {
394+
parsedMods.set(mod, parseAttributeSelector(getModSelector(mod)));
395+
}
396+
}
397+
}
398+
399+
// Build priority map: for each mod, find its earliest appearance in any state list
400+
const modPriorities = new Map<string, number>();
401+
402+
for (const style of lookupStyles) {
403+
const states = styleStates[style];
404+
if (!states) continue;
405+
406+
// states are already reversed (higher priority = lower index)
407+
states.forEach((state: any, index: number) => {
408+
if (!state.mods) return;
409+
410+
state.mods.forEach((mod: string) => {
411+
const currentPriority = modPriorities.get(mod);
412+
if (currentPriority === undefined || index < currentPriority) {
413+
modPriorities.set(mod, index);
414+
}
415+
});
416+
});
417+
}
418+
419+
// Group mods by attribute
420+
const attributeGroups = new Map<
421+
string,
422+
Array<{
423+
mod: string;
424+
isBool: boolean;
425+
priority: number;
426+
}>
427+
>();
428+
429+
for (const mod of allMods) {
430+
const parsed = parsedMods.get(mod);
431+
if (!parsed) continue;
432+
433+
const priority = modPriorities.get(mod);
434+
if (priority === undefined) continue;
435+
436+
const isBool = parsed.value === 'true';
437+
438+
if (!attributeGroups.has(parsed.attribute)) {
439+
attributeGroups.set(parsed.attribute, []);
440+
}
441+
442+
attributeGroups.get(parsed.attribute)!.push({
443+
mod,
444+
isBool,
445+
priority,
446+
});
447+
}
448+
449+
// Filter: for each attribute, if boolean has higher priority than any value, remove values
450+
const modsToRemove = new Set<string>();
451+
452+
for (const [attribute, group] of attributeGroups.entries()) {
453+
const boolMods = group.filter((m) => m.isBool);
454+
const valueMods = group.filter((m) => !m.isBool);
455+
456+
// Check if any boolean has higher priority (lower index) than all values
457+
for (const boolMod of boolMods) {
458+
const hasHigherPriorityThanAllValues = valueMods.every(
459+
(valueMod) => boolMod.priority < valueMod.priority,
460+
);
461+
462+
if (hasHigherPriorityThanAllValues && valueMods.length > 0) {
463+
// This boolean shadows all value mods for this attribute
464+
valueMods.forEach((valueMod) => modsToRemove.add(valueMod.mod));
465+
}
466+
}
467+
}
468+
469+
return allMods.filter((mod) => !modsToRemove.has(mod));
470+
}
471+
357472
/**
358473
* Explode a style handler result into logical rules with proper mapping
359474
* Phase 1: Handler fan-out ($ selectors, arrays)
@@ -847,15 +962,22 @@ function generateLogicalRules(
847962

848963
const allModsArray = Array.from(allMods);
849964

965+
// Apply priority-based filtering for same-attribute boolean vs value conflicts
966+
const filteredMods = filterModsByPriority(
967+
allModsArray,
968+
styleStates,
969+
lookupStyles,
970+
);
971+
850972
// Precompute attribute maps once for all combinations
851-
const attributeMaps = buildAttributeMaps([], allModsArray);
973+
const attributeMaps = buildAttributeMaps([], filteredMods);
852974

853975
// Generate combinations with conflict-aware pruning
854976
const conflictChecker = createAttributeConflictChecker(
855977
attributeMaps.parsedMods,
856978
);
857979
const combinations = getModCombinationsIterative(
858-
allModsArray,
980+
filteredMods,
859981
true,
860982
conflictChecker,
861983
);
@@ -878,25 +1000,31 @@ function generateLogicalRules(
8781000
// Use precomputed maps for efficient not selector optimization
8791001
const currentMaps = buildAttributeMaps(
8801002
modCombination,
881-
allModsArray,
1003+
filteredMods,
8821004
);
883-
const optimizedNotMods = optimizeNotSelectors(
884-
modCombination,
885-
allModsArray,
886-
currentMaps,
1005+
// Compute raw NOTs for contradiction check (before optimization)
1006+
const rawNotMods = filteredMods.filter(
1007+
(mod) => !modCombination.includes(mod),
8871008
);
8881009

8891010
// Check for contradictions between positive and negative selectors
8901011
if (
8911012
hasContradiction(
8921013
modCombination,
893-
optimizedNotMods,
1014+
rawNotMods,
8941015
currentMaps.parsedMods,
8951016
)
8961017
) {
8971018
return; // Skip this invalid combination
8981019
}
8991020

1021+
// Optimize NOT selectors afterwards (pure simplification)
1022+
const optimizedNotMods = optimizeNotSelectors(
1023+
modCombination,
1024+
filteredMods,
1025+
currentMaps,
1026+
);
1027+
9001028
const modsSelectors = `${modCombination
9011029
.map(getModSelector)
9021030
.join('')}${optimizedNotMods
@@ -961,15 +1089,22 @@ function generateLogicalRules(
9611089
// Generate all possible mod combinations
9621090
const allModsArray = Array.from(allMods);
9631091

1092+
// Apply priority-based filtering for same-attribute boolean vs value conflicts
1093+
const filteredMods = filterModsByPriority(
1094+
allModsArray,
1095+
styleStates,
1096+
lookupStyles,
1097+
);
1098+
9641099
// Precompute attribute maps once for all combinations
965-
const attributeMaps = buildAttributeMaps([], allModsArray);
1100+
const attributeMaps = buildAttributeMaps([], filteredMods);
9661101

9671102
// Generate combinations with conflict-aware pruning
9681103
const conflictChecker = createAttributeConflictChecker(
9691104
attributeMaps.parsedMods,
9701105
);
9711106
const combinations = getModCombinationsIterative(
972-
allModsArray,
1107+
filteredMods,
9731108
true,
9741109
conflictChecker,
9751110
);
@@ -993,25 +1128,31 @@ function generateLogicalRules(
9931128
// Use precomputed maps for efficient not selector optimization
9941129
const currentMaps = buildAttributeMaps(
9951130
modCombination,
996-
allModsArray,
1131+
filteredMods,
9971132
);
998-
const optimizedNotMods = optimizeNotSelectors(
999-
modCombination,
1000-
allModsArray,
1001-
currentMaps,
1133+
// Compute raw NOTs for contradiction check (before optimization)
1134+
const rawNotMods = filteredMods.filter(
1135+
(mod) => !modCombination.includes(mod),
10021136
);
10031137

10041138
// Check for contradictions between positive and negative selectors
10051139
if (
10061140
hasContradiction(
10071141
modCombination,
1008-
optimizedNotMods,
1142+
rawNotMods,
10091143
currentMaps.parsedMods,
10101144
)
10111145
) {
10121146
return; // Skip this invalid combination
10131147
}
10141148

1149+
// Optimize NOT selectors afterwards (pure simplification)
1150+
const optimizedNotMods = optimizeNotSelectors(
1151+
modCombination,
1152+
filteredMods,
1153+
currentMaps,
1154+
);
1155+
10151156
const modsSelectors = `${modCombination
10161157
.map(getModSelector)
10171158
.join('')}${optimizedNotMods

0 commit comments

Comments
 (0)