Repository: tailwindlabs/tailwindcss (TypeScript)

Issue:
CSS rules of the form `.foo.bar` don't behave as expected with `peer-checked:bar`
**What version of Tailwind CSS are you using?**

v3.1.8

**What build tool (or framework if it abstracts the build tool) are you using?**

postcss-cli 10.0.0

**What version of Node.js are you using?**

v16.14.0

**What browser are you using?**

N/A

**What operating system are you using?**

Linux

**Reproduction URL**

https://play.tailwindcss.com/4zvuUG5xci

Seems Tailwind Play is still on v3.1.5, but I see the issue in v3.1.8

**Describe your issue**

If I have a standard CSS rule defined like this:

```css
@layer components {
  .foo.bar {
    background-color: green;
  }
}
```

Then I would expect `peer-checked:bar` to define the following:

```css
.peer:checked ~ .foo.peer-checked\:bar {
  background-color: green;
}
```

Instead it generates:

```css
.foo.peer:checked ~ .peer-checked\:bar {
  background-color: green;
}
```

`.foo` is applied to the `.peer` instead of the `.peer-checked\:bar`.

Changing the original rule to `.bar.foo` fixes the issue (i.e. it works as expected with the first class in the list).


Hints:
Hey! Can you share a more real-world example of why you would even be doing this? I agree the behavior doesn't feel right here but the usage looks really bizarre to me (`.bar` is not a utility in your example, it's part of a complex selector), and I'm honestly more inclined to throw an exception here than to change the behavior.
I also have this same question and I cannot find any proper answers on the internet and also need a solution. Works like a charm!
It there already an Solution to this problem? I have the same problem.
[cuims](https://www.cuims.net/)
Sorry for the delay, I've been on holiday for the last couple of weeks. Our use case is admittedly a bit weird and complicated so I'm not surprised it hasn't really been an issue.

We have a "visual check box" component where we control how it appears using CSS classes, e.g. adding the `checkbox--checked` class makes it appear checked and `checkbox--disabled` makes it appear disabled. This enables us to build more complex components using Tailwind, for example we can use `<input type="checkbox" class="sr-only peer">` to control one of these visual check boxes by adding classes like `peer-checked:checkbox--checked peer-disabled:checkbox--disabled`.

This works really well and allows us to create custom components that are controlled entirely by CSS, no JS involved. However, if we want to render a checked and disabled checkbox ideally we would define the style using a `.checkbox--checked.checkbox--disabled` rule, but due to the bug above that doesn't work with `peer-checked:checkbox--checked peer-disabled:checkbox--disabled`. Instead we've had to create a `checkbox--checked-and-disabled` class and use that with the appropriate Tailwind modifiers.

Note that we use this in various different situations not just with `peer`. There are obviously other ways we could implement this that might result in simpler CSS, but the flexibility was important for our use cases.

Patch:
diff --git a/src/util/formatVariantSelector.js b/src/util/formatVariantSelector.js
--- a/src/util/formatVariantSelector.js
+++ b/src/util/formatVariantSelector.js
@@ -29,6 +29,58 @@ export function formatVariantSelector(current, ...others) {
   return current
 }
 
+/**
+ * Given any node in a selector this gets the "simple" selector it's a part of
+ * A simple selector is just a list of nodes without any combinators
+ * Technically :is(), :not(), :has(), etc… can have combinators but those are nested
+ * inside the relevant node and won't be picked up so they're fine to ignore
+ *
+ * @param {import('postcss-selector-parser').Node} node
+ * @returns {import('postcss-selector-parser').Node[]}
+ **/
+function simpleSelectorForNode(node) {
+  /** @type {import('postcss-selector-parser').Node[]} */
+  let nodes = []
+
+  // Walk backwards until we hit a combinator node (or the start)
+  while (node.prev() && node.prev().type !== 'combinator') {
+    node = node.prev()
+  }
+
+  // Now record all non-combinator nodes until we hit one (or the end)
+  while (node && node.type !== 'combinator') {
+    nodes.push(node)
+    node = node.next()
+  }
+
+  return nodes
+}
+
+/**
+ * Resorts the nodes in a selector to ensure they're in the correct order
+ * Tags go before classes, and pseudo classes go after classes
+ *
+ * @param {import('postcss-selector-parser').Selector} sel
+ * @returns {import('postcss-selector-parser').Selector}
+ **/
+function resortSelector(sel) {
+  sel.sort((a, b) => {
+    if (a.type === 'tag' && b.type === 'class') {
+      return -1
+    } else if (a.type === 'class' && b.type === 'tag') {
+      return 1
+    } else if (a.type === 'class' && b.type === 'pseudo' && b.value !== ':merge') {
+      return -1
+    } else if (a.type === 'pseudo' && a.value !== ':merge' && b.type === 'class') {
+      return 1
+    }
+
+    return sel.index(a) - sel.index(b)
+  })
+
+  return sel
+}
+
 export function finalizeSelector(
   format,
   {
@@ -88,12 +140,47 @@ export function finalizeSelector(
     }
   })
 
+  let simpleStart = selectorParser.comment({ value: '/*__simple__*/' })
+  let simpleEnd = selectorParser.comment({ value: '/*__simple__*/' })
+
   // We can safely replace the escaped base now, since the `base` section is
   // now in a normalized escaped value.
   ast.walkClasses((node) => {
-    if (node.value === base) {
-      node.replaceWith(...formatAst.nodes)
+    if (node.value !== base) {
+      return
+    }
+
+    let parent = node.parent
+    let formatNodes = formatAst.nodes[0].nodes
+
+    // Perf optimization: if the parent is a single class we can just replace it and be done
+    if (parent.nodes.length === 1) {
+      node.replaceWith(...formatNodes)
+      return
     }
+
+    let simpleSelector = simpleSelectorForNode(node)
+    parent.insertBefore(simpleSelector[0], simpleStart)
+    parent.insertAfter(simpleSelector[simpleSelector.length - 1], simpleEnd)
+
+    for (let child of formatNodes) {
+      parent.insertBefore(simpleSelector[0], child)
+    }
+
+    node.remove()
+
+    // Re-sort the simple selector to ensure it's in the correct order
+    simpleSelector = simpleSelectorForNode(simpleStart)
+    let firstNode = parent.index(simpleStart)
+
+    parent.nodes.splice(
+      firstNode,
+      simpleSelector.length,
+      ...resortSelector(selectorParser.selector({ nodes: simpleSelector })).nodes
+    )
+
+    simpleStart.remove()
+    simpleEnd.remove()
   })
 
   // This will make sure to move pseudo's to the correct spot (the end for


Test patch:
diff --git a/tests/variants.test.js b/tests/variants.test.js
--- a/tests/variants.test.js
+++ b/tests/variants.test.js
@@ -855,3 +855,92 @@ test('hoverOnlyWhenSupported adds hover and pointer media features by default',
     `)
   })
 })
+
+test('multi-class utilities handle selector-mutating variants correctly', () => {
+  let config = {
+    content: [
+      {
+        raw: html`<div
+          class="hover:foo hover:bar hover:baz group-hover:foo group-hover:bar group-hover:baz peer-checked:foo peer-checked:bar peer-checked:baz"
+        ></div>`,
+      },
+      {
+        raw: html`<div
+          class="hover:foo1 hover:bar1 hover:baz1 group-hover:foo1 group-hover:bar1 group-hover:baz1 peer-checked:foo1 peer-checked:bar1 peer-checked:baz1"
+        ></div>`,
+      },
+    ],
+    corePlugins: { preflight: false },
+  }
+
+  let input = css`
+    @tailwind utilities;
+    @layer utilities {
+      .foo.bar.baz {
+        color: red;
+      }
+      .foo1 .bar1 .baz1 {
+        color: red;
+      }
+    }
+  `
+
+  return run(input, config).then((result) => {
+    expect(result.css).toMatchFormattedCss(css`
+      .hover\:foo.bar.baz:hover {
+        color: red;
+      }
+      .hover\:bar.foo.baz:hover {
+        color: red;
+      }
+      .hover\:baz.foo.bar:hover {
+        color: red;
+      }
+      .hover\:foo1:hover .bar1 .baz1 {
+        color: red;
+      }
+      .foo1 .hover\:bar1:hover .baz1 {
+        color: red;
+      }
+      .foo1 .bar1 .hover\:baz1:hover {
+        color: red;
+      }
+      .group:hover .group-hover\:foo.bar.baz {
+        color: red;
+      }
+      .group:hover .group-hover\:bar.foo.baz {
+        color: red;
+      }
+      .group:hover .group-hover\:baz.foo.bar {
+        color: red;
+      }
+      .group:hover .group-hover\:foo1 .bar1 .baz1 {
+        color: red;
+      }
+      .foo1 .group:hover .group-hover\:bar1 .baz1 {
+        color: red;
+      }
+      .foo1 .bar1 .group:hover .group-hover\:baz1 {
+        color: red;
+      }
+      .peer:checked ~ .peer-checked\:foo.bar.baz {
+        color: red;
+      }
+      .peer:checked ~ .peer-checked\:bar.foo.baz {
+        color: red;
+      }
+      .peer:checked ~ .peer-checked\:baz.foo.bar {
+        color: red;
+      }
+      .peer:checked ~ .peer-checked\:foo1 .bar1 .baz1 {
+        color: red;
+      }
+      .foo1 .peer:checked ~ .peer-checked\:bar1 .baz1 {
+        color: red;
+      }
+      .foo1 .bar1 .peer:checked ~ .peer-checked\:baz1 {
+        color: red;
+      }
+    `)
+  })
+})