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

implement $css rune #127

Open
wants to merge 3 commits into
base: svelte5
Choose a base branch
from
Open

Conversation

JanNitschke
Copy link

@JanNitschke JanNitschke commented Jan 16, 2025

Adds a $css rune that allows to pass class names freely

What?

A rune that is replaced with the generated classname during compile.

Why

Decouple css modules from the class attribute. This rune allows to use css module classes with every attribute without global configuration.

I created this because I needed to pass multiple classes to a component and didn't want to add them all in the includeAttributes option. This rune feels like a very svelte 5 solution to me.

Example

<script>
    const test = $css("red");
    const statement = true? $css("blue") : $css("green");
    function testFunction() {
        return $css("red");
     }
</script>
<style module>
        .red { color: red; }
        .blue { color: blue; }
        .green { color: green; }
</style>
<span test={$css("red")}>Red</span>
<span class={$css("blue")}>Blue</span>
<span lol={$css("green")}>Green</span>

@JanNitschke
Copy link
Author

I created this for my own projects, but wanted to contribute it back so you can include it if you like it. Feel free to give me Feedback, I am willing to modify this.

@JanNitschke
Copy link
Author

This also solves issue #80 but in another way.

<script lang="ts">
  export let black: boolean;
  export let white: boolean;
</script>

<button class={[black && $css("black"), white && $css("white")]}  />

<style module>
  .white {
    background-color: #fff;
  }
  .black {
    background-color: #000;
  }
</style>

Or

<script lang="ts">
  export let dark: boolean
</script>

<button class={dark?$css("black"):$css("white")}  />

<style module>
  .white {
    background-color: #fff;
  }
  .black {
    background-color: #000;
  }
</style>

@micantoine
Copy link
Owner

@JanNitschke, This feature is having for sure some advantages such as:

  • applying the module class to any attributes
  • easily passing it down to any child components
  • assigning it to a variable

However, the $ is a reserved keyword and having custom runes is currently not possible (an open discussion exists though).

Even though $css() is not a rune per se (just looking alike), and is being preprocessed before svelte compilation; the IDE is "screaming" at us which does not make a nice developing experience.

$css is illegal

To avoid that, I'm wondering if we could have it imported from the package but with a different convention (not starting by $). It will still be a "fake" function that allows us to parse the data and it will also exist from the IDE's perspective.

// svelte-preprocess-cssmodules package
export const css = (str) => {};
// Svelte component
import { css } from 'svelte-preprocess-cssmodules';

const color = css('red');

or with a more "special" syntax

// svelte-preprocess-cssmodules package
export const style$ = (str) => {};
// Svelte component
import { style$ } from 'svelte-preprocess-cssmodules';

const color = style$('red');

On the other hand, it might be annoying to import the function on every component. So we could find a way to make the type global (when using typescript)

@JanNitschke
Copy link
Author

@micantoine Thank you for your kind words!

I created a standalone version of this where I ran into the issues you mentioned.
https://github.com/JanNitschke/svelte-css-rune

To make it compatible with typescript I added a global declaration to the import:
https://github.com/JanNitschke/svelte-css-rune/blob/2edd353b33220d6ebc2677f228dae97e1a59305b/lib/index.ts#L8C1-L27C2

You then have to add it to the typescript environment:
https://github.com/JanNitschke/svelte-css-rune#typescript

I haven't tried it without typescript yet, so I have no solutions for LSP warnings without TS.

I also had a discussion with some of the svelte core team about the rune syntax on discord. One suggestion was parsing the import statements of the file, figuring out the imported name and then replacing that. But I'm not so sure that this is the right solution, as this makes it look like a normal function to the user. Another idea I had was using it with template string syntax, so it won't collide with a $css rune should svelte ever introduce one.

I would love to hear your opinion about this! I will keep you updated should I find a better solution!

@micantoine
Copy link
Owner

micantoine commented Jan 27, 2025

I ran into the issues you mentioned.

Yes, those LSP warnings are really something I'm trying to avoid. It brings a negative experience when using the library. So in our case we would have to definitely rename $css into something else to get rid of the "illegal variable name" message. Then we have to deal with the typescript one.

You then have to add it to the typescript environment:

It is one of the way indeed. So to replicate what you did:

Through global file (opt1)

  • Create or edit a FILENAME.d.ts file
  • Add /// <reference types="svelte-preprocess-cssmodules" /> at the top of the file

Through json config (opt2)

  • Edit the tsconfig.json file
  • Add svelte-preprocess-cssmodules to the compilerOptions types
{
  "compilerOptions": {
    "types": [ "svelte-preprocess-cssmodules" ],
  }
}

Ideally, I would like to stay await from configuration. The package is small and its use should be as smooth as possible. "No headaches", I plug in the preprocessor, and it's good to go!

An alternative, would be the local import which is the most common pattern. "I include what I want to use".

Local import

  import { style } from 'svelte-preprocess-cssmodules';
  let color = style('red');

Another idea I had was using it with template string syntax

This is basically what I did in version 1 of the preprocessor. I was parsing and replacing data with the following pattern $style.{classname}. The problem I had was the LSP "Unused CSS selector" warning"

<p class="$style.red">My red text</p>

<style>
  .red { color: red; }
  ~~~~~
</style>

The idea for version 2, was to let the developer code regular html and css and the replacement would be done by simply adding module to <style>. This was following svelte's philosophy and the LSP warning was still working properly.

<p class="red bold">My red text</p>

<style module>
  .red { color: red; }
  .bold { font-weight: 700 }
  ~~~~~
</style>

The other dilemma was between the native css modules approach (scoping classes) and the svelte system (scoping all selectors). I personally like everything to be scoped, so I created the mixed mode (which was the only approach in v1) to set a unique classname and to avoid inheritance issues.

<p class="red">My red text</p>

<style module="mixed">
  .red { color: red; }
  p { font-weight: 700 }
</style>

<!-- generating -->

<p class="red-3eertt svelte-21yer6">My red text</p>

<style>
  .red-3eertt  { color: red; }
  p.svelte-21yer6 { font-weight: 700 }
</style>

The native approach was set by default to follow the CSS Modules principles that developers are familiar with.

One suggestion was parsing the import statements of the file, figuring out the imported name and then replacing that

What import statements? not sure to understand, is it what I am doing with the style.module.css?

When using an external import, all the css classes of the stylesheet are attached to the import's name and can be used for any situation (dynamic variable, component props, html attributes...)

<script>
  import style from './app.module.css';
  import Children from 'Children.svelte';
 
  let active = $state(true);   
  let color = $state(style.red);
  let bgcolor = $derived(active ? style.back : style.white);
</script>

<div class={color} data-size={style.large}>
   <Children class={style.small} background={bgcolor} />
</div>

During the parsing I'm replacing the import line by an object

<script>
const style = { red: 'red-12era', black: 'back-12era', white: 'white-12era', large: 'large-12era', small: 'small-12era' };
import Children from 'Children.svelte';
...
</script>

That whole logic can actually be used with a placeholder variable. However, for an unused class, the "unused selector" warning will not be thrown since a dynamic value is being passed to the attribute.

<script>
  import { style$ } from 'svelte-preprocess-cssmodules';
  let color = $state(style$.red);
</script>

<p class={color}>hello</p>

<style module>
 .red { color: red; }
 .blue { color: blue; } /** no warning */
 small { font-size: 12px } /** warning */
 ~~~~~
</style>

generating

<script>
  const style$ = { red: 'red-waf5', blue: 'blue-waf5' };
  let color = $state(style$.red);
</script>

<p class={color}>hello</p>

<style>
 .red-waf5 { color: red; }
 .blue-waf5 { color: blue; }
</style>

Depending on the parsing the blue data could be removed. The object is being creating reading the classes listed in <style> but could alternatively be generated by the class names coming from the style$.{className} pattern.

Sum up

Opt 1: "Magic" Function (Your runes alike)

  • Use special syntax but not the reserved runes pattern.
  • Parse each function
  • Need global settings to deal with typescript warning.
<p class="{css$('red')}">hello</p>

<style module>
  .red { color: red; }
</style>

generating

<p class="{'red-asd12'}">hello</p>

<style module>
  .red-asd12 { color: red; }
</style>

Opt 2: Import "magic" Function

  • Import from the package
  • Parse each function
<script>
  import { css$ } from 'svelte-preprocess-cssmodules';
</script>

<p class="{css$('red')}">hello</p>

<style module>
  .red { color: red; }
</style>

generating

<p class="{'red-asd12'}">hello</p>

<style module>
  .red-asd12 { color: red; }
</style>

Opt 3: "Magic" Variable

  • Use special syntax but no reserved keyword.
  • Parse each function
  • Need global settings to deal with typescript warning.
<p class="{style$.red}">hello</p>

<style module>
  .red { color: red; }
</style>

generating

<p class="{'red-asd12'}">hello</p>

<style module>
  .red-asd12 { color: red; }
</style>

Opt 4: Import "Magic" Variable

  • Import from the package
  • Parse each variable
<script>
  import { style$ } from 'svelte-preprocess-cssmodules';
</script>

<p class="{style$.red}">hello</p>

<style module>
  .red { color: red; }
</style>

generating

<p class="{'red-asd12'}">hello</p>

<style module>
  .red-asd12 { color: red; }
</style>

Opt 5: Import Placeholder Variable

  • Import from the package
  • Replace only the import to create a data object
<script>
  import { style$ } from 'svelte-preprocess-cssmodules';
</script>

<p class="{style$.red}">hello</p>

<style module>
  .red { color: red; }
</style>

generating

<script>
  const style$ = { red: 'red-asd12' };
</script>

<p class="{style$.red}">hello</p>

<style module>
  .red-asd12 { color: red; }
</style>

Opt 6: Namespaced string (like v1)

  • Use special syntax
  • Parse each string
  • Unused selector warning
<p class="$style.red">hello</p>

<style module>
  .red { color: red; }
  ~~~~~
</style>

generating

<p class="red-asd12">hello</p>

<style module>
  .red-asd12 { color: red; }
</style>

Conclusion

One focus for the library was to follow svelte's philosophy of using simple html and css (class="red") and the preprocessor would do the hard work.

In most cases, it is enough, since classes can even be toggled on and off from the class attribute and also be passed to a child component.

Now, there is some limitation where handling dynamic variable assignment is not possible unless the style is being imported from an external stylesheet style.module.css.

Adding the option could definitely be useful and the developers could adopt the approach they prefers or even mix it up.

My preference might lean toward the opt5 "placeholder variable". I find it easier to work with a variable and I could use all its advantages (eg: destructuring would not be possible with the opt3/4 "Magic variable"). A "Magic placeholder variable" could also be supported for the developers who wish to do the global typescript settings.

What you're doing with your preprocessor is similar to the approach of verson 1 of the svelte-preprocess-cssmodules: No module attribute added and applying :global() to the targetted classes only.

It could be revived in addition to one of the above options?

<script>
  let bgcolor = $state(style$.red);
</script>

<p class="{style$.white} {color} bold">hello</p>

<style>
  .red { color: red; }
  .white { color: white; }
  .bold { font-weight: bold; }
  p { font-size: 2rem };
</style>
  • parsing the pattern style$.{classname}
  • gathering module classnames
  • create style$ object
  • apply :global to the classes listed in style$

generating

<script>
  const style$ = { red: 'red-qwe12', white: 'white-qwe12' };
  let bgcolor = $state(style$.red);
</script>

<p class="{style$.white} {color} bold">hello</p>

<style>
  :global(.red-qwe12) { color: red; }
  :global(.white-qwe12) { color: white; }
  .bold.svelte-asg42 { font-weight: bold; }
  p.svelte-asg42 { font-size: 2rem };
</style>

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

Successfully merging this pull request may close these issues.

2 participants