sveltejs / svelte

Cybernetically enhanced web apps
https://svelte.dev
MIT License
78.36k stars 4.1k forks source link

Allow multiple classes in class: directive #7170

Open Bastian opened 2 years ago

Bastian commented 2 years ago

Describe the problem

Utility-first CSS frameworks like Tailwind use very granular CSS classes (e.g. bg-red-500 for a red background, shadow-lg for a large box-shadow, ...). You often want to apply styles conditional with the class: directive. Unfortunately, it only works for a single class at the moment which means you have to duplicate it quite often. A very simple example for a Button component with Svelte and Tailwind might look like this:

<script lang="ts">
    export let color: 'primary' | 'danger' = 'primary';
</script>

<button
    on:click
    class="px-3 py-2 text-white rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
    class:bg-blue-700={color === 'primary'}
    class:hover:bg-blue-800={color === 'primary'}
    class:ring-blue-400={color === 'primary'}
    class:bg-red-600={color === 'danger'}
    class:hover:bg-red-700={color === 'danger'}
    class:ring-red-500={color === 'danger'}
>
    <slot />
</button>

image

This is very boiler-plate-heavy and annoying to work with. It's also just a very simple example for showcasing and usually gets even uglier in real-world examples. When using a utility-first framework you run into this issue a lot.

Describe the proposed solution

Allow the use of multiple CSS classes in the class: directive with a class:"x y z"={true} syntax. This would allow the example above to be simplified like this:

<script lang="ts">
    export let color: 'primary' | 'danger' = 'primary';
</script>

<button
    on:click
    class="px-3 py-2 text-white rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
    class:"bg-blue-700 hover:bg-blue-800 ring-blue-400"={color === 'primary'}
    class:"bg-red-600 hover:bg-red-700 ring-red-500"={color === 'danger'}
>
    <slot />
</button>

Alternatives considered

There's been a very similar issue (https://github.com/sveltejs/svelte/issues/3376) which unfortunately has been closed and not been re-opened despite getting a lot of follow-up comments that argue for its usefulness. In this issue, some alternatives have been discussed:

Using Tailwind's @apply directive

Tailwind does provide a @apply directive to extract multiple Tailwind-classes into a custom CSS class. For the example above, this could look like this:

<script lang="ts">
    export let color: 'primary' | 'danger' = 'primary';
</script>

<button
    on:click
    class="px-3 py-2 text-white rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
    class:primary={color === 'primary'}
    class:danger={color === 'danger'}
>
    <slot />
</button>

<style lang="postcss">
    .danger {
        @apply bg-red-600 hover:bg-red-700 ring-red-500;
    }

    .primary {
        @apply bg-blue-700 hover:bg-blue-600 ring-blue-400;
    }
</style>

While this appears to be a good solution (and is used by many to circumvent the issue), using the @apply directive goes against the utility-first workflow. Adam Wathan (the creator of Tailwind) advised against using it (Source):

Confession: The apply feature in Tailwind basically only exists to trick people who are put off by long lists of classes into trying the framework.

You should almost never use it 😬

Additionally, there are other Utility-CSS frameworks that usually don't have this feature.

Using the ternary operator

<script lang="ts">
    export let color: 'primary' | 'danger' = 'primary';
</script>

<button
    on:click
    class="px-3 py-2 text-white rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2
    {color === 'primary' ? 'bg-blue-700 hover:bg-blue-800 ring-blue-400' : ''}
    {color === 'danger' ? 'bg-red-600 hover:bg-red-700 ring-red-500' : ''}
    "
>
    <slot />
</button>

This does work, but obviously also introduces a lot of boilerplate code. The whole point of the class: directive is to eliminate this kind of code.

Writing a Svelte Preprocessor

I'm not familiar with preprocessors, but this has been a frequent suggestion in the original issue. There even exists one already: https://github.com/paulovieira/svelte-preprocess-class-directive

This might be a viable option but I would much rather prefer support out-of-the-box instead of relying on a third-party library. Besides not being actively maintained, the linked preprocessor uses an alternative, non-ideal syntax like described in the next section "Alternative syntaxes".

Alternative syntaxes

Many other syntaxes have been suggested, e.g. class:x,y,z={true), .x.y.z={true}, class:{"x y z"}={true}, ... The problem with most of them is that they either introduce breaking changes (e.g., class:x,y,z={true} is already valid syntax for the class x,y,z) and/or limit it to a sub-set of CSS-classes because , and . are valid characters in CSS class names. While not very common in "classic" CSS classes, they are often used by utility frameworks like Tailwind (e.g. gap-[2.75rem], grid-rows-[200px_minmax(900px,_1fr)_100px], or row-[span_16_/_span_16]). class:{"x y z"}={true} would work and should be supported as an alternative syntax (just like class={"x y z"} also works) but is also unnecessary (yet small) boilerplate in most cases.

Importance

would make my life easier

Final words

As mentioned above, this is technically a duplicate of https://github.com/sveltejs/svelte/issues/3376. However, since there have been no responses from any maintainers (even when pinging them) on the original issue, I've decided to open this issue with a summary of the discussion in the original issue. I would very much appreciate a re-evaluation of the original decision to not support this feature, either in this issue or by re-opening the original one. Thank you for the awesome work on Svelte!

irishburlybear commented 2 years ago

I need this in my life.

Conduitry commented 2 years ago

cool

tobiaskohlbau commented 2 years ago

Current alternative to using the tenary operator would be to use an action like the following. It's more or less the same count of characters but can be in some circumstances be more verbose. REPL

  function clazz(node, props) {
   for (let prop of props) {
      if (prop[0]) {
         node.classList.add(...prop[1].split(" "));
      }
   }

   return {
      update(props) {
         for (let prop of props) {
            if (prop[0]) {
               node.classList.add(...prop[1].split(" "));
            } else {
               node.classList.remove(...prop[1].split(" "));
            }
         }
      },
   };
  }
<button
    on:click
    class="px-3 py-2 text-white rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
    use:clazz={[
                [color === 'primary', "bg-blue-700 hover:bg-blue-800 ring-blue-400"],
                [color === 'danger', "bg-red-600 hover:bg-red-700 ring-red-500"]
                ]}
>
    <slot />
</button>
fernandolguevara commented 2 years ago

Hey! If anyone wants to use this feature here is my vite plugin

npm i svelte-multicssclass

update your vite.config

// vite.config.js

import { sveltekit } from '@sveltejs/kit/vite';
import { multicssclass } from 'svelte-multicssclass';

/** @type {import('vite').UserConfig} */
const config = {
  plugins: [multicssclass(), sveltekit()],
};

export default config;

before:

<label
  class:text-gray-500="{isValid}"
  class:bg-gray-50="{isValid}"
  class:border-gray-300="{isValid}"
  class:text-red-700="{!isValid}"
  class:bg-red-50="{!isValid}"
  class:border-red-300="{!isValid}"
>
  text
</label>
usage:
  - choose a separator char ;  ,  | or configure your own multicssclass({ sep: '@' })
  - write your classes using the sep 
      <element class:class1;class2;class3={condition} />
      Custom sep
     <element class:class1@class2@class3={condition} />
  - two separators for toggle 
      <element class:true-class1;true-class2;;false-class1;false-class2={condition} />
      Custom sep 
      <element class:true-class1@true-class2@@false-class1@false-class2={condition} />

after:

<label
  class:text-gray-500;bg-gray-50;border-gray-300;;text-red-700;bg-red-50;border-red-300="{isValid}"
>
  text
</label>

<!-- OR -->

<label
  class:text-gray-500,bg-gray-50,border-gray-300,,text-red-700,bg-red-50,border-red-300="{isValid}"
>
  text
</label>

<!-- OR -->

<label
  class:text-gray-500|bg-gray-50|border-gray-300||text-red-700|bg-red-50|border-red-300="{isValid}"
>
  text
</label>

enjoy

🌌

bwklein commented 1 year ago

Maybe a simple option to pass an array into the class directive.

class:[class1,class2]={someBooleanVariable}

Where class1 and class2 would be applied if someBooleanVariable is true.

rynz commented 12 months ago

I really like the proposed syntax

<script lang="ts">
    export let color: 'primary' | 'danger' = 'primary';
</script>

<button
    on:click
    class="px-3 py-2 text-white rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2"
    class:"bg-blue-700 hover:bg-blue-800 ring-blue-400"={color === 'primary'}
    class:"bg-red-600 hover:bg-red-700 ring-red-500"={color === 'danger'}
>
    <slot />
</button>

Because https://github.com/tailwindlabs/prettier-plugin-tailwindcss and https://github.com/tailwindlabs/tailwindcss-intellisense will easily adapt to it too.

NonVideri commented 12 months ago

I like this syntax and wholeheartedly support this proposal.

rynz commented 12 months ago

What's your thoughts @Rich-Harris and @adamwathan?

xpertekShaun commented 12 months ago
        export let button_type;
    const cls = {
        default: 'px-3 py-2 text-white rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2',
        primary: 'bg-blue-500 hover:bg-blue-600 focus:ring-blue-500 focus:ring-offset-blue-200',
        danger: 'bg-red-500 hover:bg-red-600 focus:ring-red-500 focus:ring-offset-red-200'
    };
    const style = cls.default + ' ' + cls[button_type];

Im against adding new syntax to svelte when it can be replicated in simple js. And additionally there are far more suited libraries to handle this type of thing like class-variance-authority

dawidmachon commented 12 months ago

Svelte should have any solution for that. Adding a lost class statement should be achievable in easier mode: array or something.

harryqt commented 9 months ago

@Rich-Harris It would be fantastic if v5 include this feature.

sanfilippopablo commented 5 months ago

Honestly I just settled for clsx

nonameolsson commented 5 months ago

Honestly I just settled for clsx

Interesting, could you provide a code example on how you do this?

sanfilippopablo commented 5 months ago

Yes! With clsx the example at the top of this issue could be expressed as:

<script lang="ts">
  import clsx from "clsx";
  export let color: 'primary' | 'danger' = 'primary';
</script>

<button
  on:click
  class={clsx("px-3 py-2 text-white rounded shadow-lg focus:outline-none focus:ring-2 focus:ring-offset-2", {
    "bg-blue-700 hover:bg-blue-800 ring-blue-400": color === 'primary',
    "bg-red-600 hover:bg-red-700 ring-red-500": color === 'danger'
  })}
>
  <slot />
</button>
MrHBS commented 5 months ago

@dummdidumm Do you think this can be added to v5 milestone?

Rich-Harris commented 5 months ago

I've added it to the milestone, which isn't a commitment to do it for 5.0, but means it will be considered so that we don't miss the window provided by the semver major.

dummdidumm commented 5 months ago

Idea: Right now the " is an illegal character. We can use this to our advantage to introduce multiple classes and class names with weird characters like this:

Since quotes are illegal right now, this could be done in a minor later on.

Rich-Harris commented 4 months ago

I have to admit the class:"quoted"={value} syntax really irks me — it feels like a real anomaly, and makes me wonder why I can't do things like this:

<div class:"foo {bar} baz"={value}>...</div>

Is the {bar} supposed to be treated literally? Or should it be interpolated? Either answer would be extremely strange.

An alternative could be to introduce a classes directive, with classnames separated by commas or pipes:

<div classes:a,b,c={value}>...</div>
<div classes:a|b|c={value}>...</div>

As far as I'm aware neither character is used in standard Tailwind. The comma is probably the better choice since | has an existing meaning with directives.

I'm not too worried about accommodating weird characters — as long as there's an escape hatch (which there is) then we don't need to optimise for edge cases.

bwklein commented 4 months ago

@Rich-Harris could we use the same format and just use class: and it is assumed to be a 1...n array of classes? Then we don't need a new parameter and it would be backwards compatible.

I don't think the plural form adds much for comprehension of the purpose and use.

rynz commented 4 months ago

I agree with @Rich-Harris and @bwklein.

<div class:a,b,c={value}>...</div>

HTML doesn't have/need a plural form for multi-class either so I don't think anyone would be upset if they had the option to append additional class names with a , in the same way they can do it in HTML with a space.

orbiteleven commented 4 months ago

Perhaps a straw-man here, but why not just build something like clsx into the class attribute? Something like:

<button class={[
  'btn',
  {
    'btn-primary': isPrimary,
    'btn-link': isLink,
  }
]}>
  <slot />
</button>
frederikhors commented 4 months ago

Perhaps a straw-man here, but why not just build something like clsx into the class attribute? Something like:

<button class={[
  'btn',
  {
    'btn-primary': isPrimary,
    'btn-link': isLink,
  }
]}>
  <slot />
</button>

This would be amazing!!!

dummdidumm commented 4 months ago

I have to admit the class:"quoted"={value} syntax really irks me — it feels like a real anomaly, and makes me wonder why I can't do things like this:

<div class:"foo {bar} baz"={value}>...</div>

I mean.. we could allow that to be a dynamic expression, couldn't we? What else irks you / makes it feel like an anomaly? Because it's just the class string syntax, just before the equals sign.

Comma-based solutions make the whole thing feel crammed (not breathing room between classes) and I fear tailwind's micro syntax might grab this character at some point, too.

Serator commented 4 months ago

I fear tailwind's micro syntax might grab this character at some point, too

https://play.tailwindcss.com/a6j1Ed7RF2

<div class="m-10 [box-shadow:_0_10px_red,_0_-10px_blue] *:before:content-['_|_']">
  <div>A</div>
  <div>B</div>
</div>

Tailwind has had the ability to use CSS inside its classes for quite some time, where |, ,, ' and other characters are allowed.

Bastian commented 4 months ago

As far as I'm aware neither character is used in standard Tailwind.

Both can be used in Tailwind using their arbitrary values feature, and at least the , isn't that uncommon, e.g. for grids like grid-rows-[200px_minmax(900px,_1fr)_100px]. But Tailwind isn't the only CSS framework out there, and making assumptions about which characters are "weird" enough to not support them feels really wrong - and could lead to problems in the future for frameworks that may not even exist today.

A space character is the logical choice in my opinion. It's already used in normal HTML to separate multiple classes, and I assume it's easier for most third-party tools (formatters, linters, syntax highlighters, etc.) to adapt as well.

dummdidumm commented 4 months ago

To add to https://github.com/sveltejs/svelte/issues/7170#issuecomment-2068640727: #7294 asks for dynamic conditional classes, so just allowing expressions would solve this request, too

thebspin commented 4 months ago

I really like the Angular approach here where you can just do this:

class="w-1/5"
class={
        'md:w-10/12 lg:w-8/12 2xl:w-6/12':
            someVar
        'lg:w-10/12':
            anotherVar
        '2xl:w-8/12':
            yetAnotherVar
    }

The only "problem" i see is that currently in svelte you cannot combine a class with a dynamic class because attributes need to be unique (might be different in Svelte 5?)

Serator commented 4 months ago

Why not just use class (attribute) syntax with directives? Either make quotes mandatory or optional.

Based on the example above:

class="w-1/5"
class:"md:w-10/12 lg:w-8/12 2xl:w-6/12"={someVar}
class:lg:w-10/12={anotherVar}
class:2xl:w-8/12={yetAnotherVar}

class:"..." will allow the use of spaces, which is a native separator for classes. In the case of a single class, the quotes could be omitted, as is done now.

thebspin commented 4 months ago

Why not just use class (attribute) syntax with directives? Either make quotes mandatory or optional.

Based on the example above:

class="w-1/5"
class:"md:w-10/12 lg:w-8/12 2xl:w-6/12"={someVar}
class:lg:w-10/12={anotherVar}
class:2xl:w-8/12={yetAnotherVar}

class:"..." will allow the use of spaces, which is a native separator for classes. In the case of a single class, the quotes could be omitted, as is done now.

I like this option a lot as well.

webJose commented 1 month ago

My preference would be something like this:

<element-or-component-with-class-prop
    class={{
        "btn btn-sm": buttonCondition,
        "btn-primary border-0": fancyButtonCondition
    }}
    class="always-on-css-classes"
>

I have zero idea if something like this is possible. In words:

Why must it work with components? Because if you can only forego the need of clsx or similar packages for elements, then you cannot forego of the package if you also need it for components.

As for the double appearance, let's just say that class can appear any number of times, and the algorithm simply collect them all into a single value. If the value of a class attribute is an object, then it is conditional CSS; if it is a string, it is a list of CSS classes that must be added.

brunnerh commented 1 month ago

Treating a specific property differently is the kind of magic that causes trouble. It also would not handle cases where a component accepts classes in multiple properties for multiple internal elements.

dominikg commented 1 month ago
<script>
let {theme} = $props();
const themes = {
  "primary": "foo bar baz",
  "secondary": "qoox bla blub"
}
</script>
<button class="{themes[theme]} and whatever else you want">...</button>

there are many ways already to add multiple classes with a single condition. Creating a complex syntax to hide these groups in the template is just going to make the template harder to read.

abusing? double quotes in the attribute name makes the parser more complex and be a source for confusion.

webJose commented 1 month ago

I understand, @brunnerh. Still, if it can't work for components, this whole exercise is futile, IMO, because then your need for clsx or your favorite function is not fulfilled, so you might as well continue using it everywhere.

madeleineostoja commented 1 month ago

I agree that making class:""={} a special directive that takes a quoted string that behaves differently to all other quoted strings in existing attributes feels extremely weird, for the sake of overloading an existing shortcut when you can use clsx or similar to achieve very similar ergonomics for free.

But maybe I'm the odd one out here because I actually really dislike how many lightly sugared shortcuts Svelte already has. Eg: how much is something like style:prop="value" really saving you compared to style="prop: value;".