Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Can we talk about the class sort order? [Prettier Plugin] #12821

Open
asimpletune opened this issue Jan 24, 2024 Discussed in #12804 · 3 comments
Open

Can we talk about the class sort order? [Prettier Plugin] #12821

asimpletune opened this issue Jan 24, 2024 Discussed in #12804 · 3 comments

Comments

@asimpletune
Copy link

Discussed in #12804

Originally posted by asimpletune January 22, 2024
Originally I was going to write this in the PR for biomejs, but then I figured it's actually a tailwind-wide question.

As I understand, the sort order is based on the order of how class are generated in the resulting CSS. I think this is a very good start, and handles most of what one wants. However with variants I don't think that is the case, and I'd like to explain why.

The best possible outcome of this discussion is I just realize that I'm wrong (extremely likely possibility) and that's 100% ok with me, even preferred. If I'm wrong, then we can all sleep at night knowing we already do things the best we know how. However, there is the possibility that we do not currently have the best way of sorting classes, in which case there is room for improvement. So let's hope I'm wrong, as that is certainly easier, but not shy away from the discussion.

Ok, here goes.

The rules behind Tailwind's class ordering

So the plugin's ordering works by the order of the generated CSS. This is mostly very good.

First, definitely, it is important to have classes that modify/override the same rules be sorted by specificity, so it's clear which takes precedence. The example from the docs of p-4 pt-2 makes the case crystal clear, since pt-2 is more specific than p-4. The specific case should override the generic case.

By that same logic, another way of expressing this rule is that "like goes with like", and ties go to whichever is more specific. After all, what could be more alike than two classes modifying the same rule?

Additionally, in the tailwind docs there is mention of a high level goal for the ordering as well:

The actual order of the different utilities is loosely based on the box model, and tries to put high impact classes that affect the layout at the beginning and decorative classes at the end, while also trying to keep related utilities together:

This also makes sense, with the most impactful, layout rules coming first, decorative ones coming last.

There's a third goal, which I'm just mentioning for completeness, that user defined classes come first.

Therefore, according to the docs, we have 3 sorting rules:

  1. user defined classes
  2. layout first, then decoration
  3. like with like, with the more specific coming later

On all accounts, I am 100% in agreement and I think it all makes perfect sense. However, there is another rule, regarding modifiers that basically conflicts with the first 3! It's a complete contradiction.

The issue comes with modifiers

Modifiers like hover: and focus: are grouped together and sorted after any plain utilities

The problem is this violates the rules above. It basically, logically, means 'order by impact, ties are broken by specificity, ... but modifiers are immune from all of this and are tacked on in the end'. Why?

Like I said, please don't mistake my meaning. This is better than nothing, but having the modifiers come at the end is honestly not useful.

I understand that modifiers need to be distinguished from regular utility classes, that is clear, but it doesn't need to happen with sorting! I know something is a modifier by virtue of it having a modifier in the name. So, from this view, we're duplicating the benefits that we already get, which on its own isn't harmful but it does beg the question:

Are there actively negative affects of distinguishing modifiers from utility classes by sorting?

The downside of having it this way is we undo all the good we accomplished with our 3 sorting rules from above, because now I must read all of my styles to understand what they do. By this logic, we gain a benefit that we already had, but it comes at the price of losing other benefits, so by this view it's a net loss. I will try to make this last point more clear, before explaining how this 'net loss' is totally avoidable.

Since modifiers come last, and a modifier can precede any utility class, that implies that I have to read all of my code to understand the layout aspects, because they're no longer sorted earlier. Further more, I can no longer rely on the "like matches like" rule, because there may be more "alike" that are hidden somewhere in whatever modifiers I have.

Even worse, this is for every modifier as well too! It's not like there's an invisible line where on one side is all the non-modifier stuff is sorted, and then on the other the modifier stuff is sorted. It's more like on the modifier side of the line, the 3 rules are respected but for each modifier! This means I MUST read every modifier to make sure nothing important was missed. In other words, I can't scan my code and rely upon the ordering to tell me what I need to know, because things aren't sorted anymore by impact or like with like.

However, this problem is only important if you have a lot of modifiers. Most people probably don't, which is why I want to give a real, non-contrived example of what I think is a reasonable thing to make, that at times has unnecessary friction because of this:

'Tabbed Window' example

I made a Mac-style tabbed window for displaying code that's all related in different tabs. I try to make everything I write work without requiring JS to function correctly, and I also optimize for browser reader modes. This means my markup is going to be a little bit more complicated than I'd like, and I have to therefore use modifiers a lot. The end result is really cool, I think, and I love that in reader mode they're just normal document sections, complete with their tab's title being the section title. Here are some screenshots:

Normal mode:
Screenshot 2024-01-22 at 3 40 22 PM

After clicking the green button:
Screenshot 2024-01-22 at 3 40 49 PM

After clicking the yellow button:
Screenshot 2024-01-22 at 3 41 04 PM

The end result is it basically works how you expect, but the logic is complicated.

The most important of that logic comes down to layout, as I want to expand the window if they press the expand button, or shrink if they want to minimize that example. This alone would be fine, because the layout isn't that complicated after all, however I also have to handle the styling of the different tabs.

When one tab is selected, I use a document fragment link (to that tab) in combination with some tailwind modifiers to make sure the expected thing happens with the tabs's styling. The default tab though has no document fragment link in the browser so it may not use any modifier at all. And things quickly become sort of a mess, as I'm sure you can understand. There's making sure the tabs background is lighter, its text is lighter, but the non-selected ones are respectively darker, and that when you hover over a tab it becomes medium levels of light.

Having a default that only applies to one tab, plus decoration that happens when one tab is selected, and separate decoration for everything if a tab is not selected, which the default tab must also adopt because it's not the default case any more is... very complicated.

Ok, so it's complicated, but my point is that the modifier sort order does not help. I have peers, I have groups, I have all sorts of stuff going on there. They all handle layout and decoration, but then those things also exist in the non-modifier area of the class list. I still think this is a cool thing to make, and I think it's not some extreme example of having crazy amounts of modifiers. The code still consists of totally readable, semantic HTML, and I barely had to write any CSS.

We already have the better way

I want to thank whoever read all this way, and I don't want to end this without highlighting what I think would be a better way to do this. Therefore, I think the first 3 rules actually are great, and modifiers should just be forced to respect them.

  1. user defined classes
  2. layout first, then decoration (even if preceded by a modifier)
  3. like with like, with the more specific coming later (even if preceded by a modifier)

This actually makes a lot more sense to with how they work, since modifiers will already override competing rules of less specific stuff. I think the difficulty would be that it is just more complicated to implement, but that's a separate discussion. I'd like to know what other users and maintainers think.

Thanks for reading.

@adamwathan
Copy link
Member

Hey! Can you you post a few before and after examples comparing how things are sorted now vs. how you think they should be sorted?

@asimpletune
Copy link
Author

Hi Adam, sure.

This is the title to a tab for Mac style "tabbed window" that I made. The tab is styled based on whether it's in the default state, the active state, or neither:

<!-- now -->
<h4
  class="not-prose flex h-8 w-1/2 place-content-center
    border-r border-zinc-500
    bg-gray-400
    text-xs 
    text-gray-200
    hover:!bg-gray-500 hover:!text-gray-300
    group-target:!bg-gray-400 group-target:!text-gray-200
    group-has-[:target]/tabs:bg-gray-600 group-has-[:target]/tabs:text-gray-400"
>

<!-- proposed -->
<h4
  class="flex h-8 w-1/2 place-content-center
    border-r border-zinc-500
    bg-gray-400 hover:!bg-gray-500 hover:!bg-gray-500 group-target:!bg-gray-400 group-has-[:target]/tabs:bg-gray-600
    text-xs 
    text-gray-200 hover:!text-gray-300 group-target:!text-gray-200 group-has-[:target]/tabs:text-gray-400
    not-prose 
    "
>

background goes with background, text color goes with text color. If a two classes override the same rule then the one with higher specificity is sorted after the lower.

not-prose could go first or last, but ideally it would go last. It's not really a user defined class, and it doesn't even affect the element whose class attribute it's written in. It's the only tailwind related thing that I can think of that has a cascading effect, but it only affects child elements.

Another example:

<!-- now -->
<section
  class=" tab-panel-content
  max-h-96          
  overflow-auto
  border-t border-zinc-600
  group-has-[input.min-window:checked]/window:hidden
  group-has-[input.max-window:checked]/window:max-h-[89dvh]
  group-has-[input.max-window:checked]/window:max-w-[100vw]
  prose-img:max-h-full
  prose-img:max-w-full
  md:group-has-[input.max-window:checked]/window:max-h-[82dvh]
  md:group-has-[input.max-window:checked]/window:max-w-[82vw]
  "
  >
  
  <!-- proposed -->
<section
  class="tab-panel-content
  group-has-[input.min-window:checked]/window:hidden
  max-h-96
  group-has-[input.max-window:checked]/window:max-h-[89dvh]          
  group-has-[input.max-window:checked]/window:max-w-[100vw]
  md:group-has-[input.max-window:checked]/window:max-h-[82dvh]
  md:group-has-[input.max-window:checked]/window:max-w-[82vw]
  overflow-auto
  border-t border-zinc-600
  prose-img:max-h-full
  prose-img:max-w-full
  "
>

The user defined class tab-panel-content comes first. After that however, hidden is the most impactful from a layout pov, followed by height/width, followed last by decoration (overflow-auto, border stuff). Again, customizations to prose come last.

Final example:

<!-- now -->
<div
  class="fixed left-0 top-0 z-20 hidden 
    bg-black/90
    peer-has-[input.max-window:checked]:block
    peer-has-[input.max-window:checked]:h-screen
    peer-has-[input.max-window:checked]:w-screen"
>

<!-- proposed -->
<div
  class="fixed left-0 top-0 z-20 hidden 
    peer-has-[input.max-window:checked]:block
    peer-has-[input.max-window:checked]:h-screen
    peer-has-[input.max-window:checked]:w-screen
    bg-black/90"
>

All the stuff related to position comes first. Then the display mode. In this case peer-has-[input.max-window:checked]:block is more specific than hidden so that comes after. And then the height and width stuff. Finally background stuff comes last, as that's decoration.

@asimpletune
Copy link
Author

@adamwathan

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

2 participants