tokenami / tokenami

CSS-in-JS reinvented for scalable, typesafe design systems. A modern approach to just-in-time atomic CSS using CSS variables—no bundler required.
https://tokenami.com
MIT License
799 stars 9 forks source link

add an api similar to tailwind `@apply` for comp styles #182

Open jjenzz opened 9 months ago

jjenzz commented 9 months ago

the following code examples are old, see more recent comments for proposed changes.


components imply reuse which means we can end up with the same style rules repeated multiple times in our markup each time the component is consumed. this is where classes are useful bcos you repeat a small class string instead and the styles are defined once in the stylesheet (not repeated).

the difference in size could be negligible with compression however, a component's base styles rarely change and so moving them into the stylesheet allows them to be heavily cached.

the css utility would be needed for this but i'd ideally like to keep the ability to prototype quickly inline with it. some thoughts on an API are an optional apply() call added to the css utility:

const style = css.apply('acme-button')({
  '--background': 'green',
  '--padding': 12,
});

function Button() {
  return <button className={style.class} style={style()} />
}

this would generate something like the following in the stylesheet:

.acme-button,
[style*="--background:"] {
  background: var(--background);
}

.acme-button,
[style*="--padding:"] {
  padding: var(--padding);
}

.acme-button {
  --background: green;
  --padding: 12;
}

with this:

Variants and overrides would be passed as usual and would end up in the style attribute due to their dynamic nature (variants change via props, overrides can be different each time a component is consumed):

const style = css.apply('acme-button')(
  {
    '--background': 'green',
    '--padding': 12,
  },
  {
    size: {
      small: { '--padding': 8 },
      large: { '--padding': 16 },
    },
  }
);

function Button({ size, ...props  }) {
  return <button className={style.class} style={style({ size }, props.style)} />
}

i might be able to @apply the variants in stylesheet also eventually e.g. generate an .acme-button--size-small class, but that can be a later iteration.

Concerns

there have been some concerns around tailwind's desire to deprecate @apply due to issues it has caused for them but i don't believe similar issues are as likely (if at all) with the tokenami approach. i recreated an example Adam provided using the tokenami approach and it is not an issue there: https://codepen.io/jjenzz/pen/WNmwBQK.

jjenzz commented 9 months ago

i've done a PoC for an API like this instead:

// same as before
const button = css({ '--padding': 4 });
<button style={button(null, { '--color': 'red' })} /> 

// html output: 
// <button style="--padding:4;--color:red" />
//
// css output: 
// [style*="--color:"] { 
//   color: var(--color); 
// }
// [style*="--padding:"] { 
//   padding: calc(var(--grid)* var(--padding)); 
// }

// with auto-generated class
const button = css.compose({ '--padding': 4 });
<button className={button.class()} style={button(null, { '--color': 'red' })} /> 

// html output: 
// <button class="tk2343" style="--color:red" />

// css output: 
// [style*="--color:"] { 
//   color: var(--color); 
// }
// .tk2343 { 
//   padding: calc(var(--grid)* var(--padding)); 
// }
// .tk2343 { 
//   --padding: 4; 
// }

my only concern is passing through a class name to button.class e.g. button.class(props.className). in this case, the class passed might be first in the stylesheet so the button styles would override.

for this, the css utility can check if the props.className was generated by foo.class() (symbol) and if so, it would keep foo's base styles in the style attribute so they would override as expected. that way, styles would be in stylesheet except for overrides where they'd always be inline.

jjenzz commented 8 months ago

further work on this led me to an API as follows:

const styles = css.compose({
  button: {
    '--all': 'unset',

    variants: {
      size: {
        small: { '--padding': 2 },
        medium: { '--padding': 4 },
      }
    },
  },
  buttonIcon: {
    '--color': 'var(--color_sky-500)',
  },
});

the reason for the compound parts approach within one styles object was to allow the class name to be generated from the css.compose keys instead of the styles within the object. generating the class based on the styles would mean busting HTML cache every time the styles changed which defeats the point of exploring this solution.

the keyed class name approach solves the HTML cache busting, however, the whole class name idea in general would prevent something like the following from working because tokenami statically generates styles and cannot execute your code:

const styles = css.compose({
  button: {
    ...sharedStyles,
  },
});

this works currently because values are added in the style attribute, and tokenami will generate the correct atomic properties for sharedStyles when it finds the sharedStyles declaration in another file.

the class name approach breaks this because we need to move the values to the stylesheet (see below) but the static extraction cannot determine what the values are.

.button {
  --all: unset;
}

.button-icon {
  --color: var(--color_sky-500);
}

this same problem exists with responsiveVariants currently 🙈 so, i need to think some more. do we break spreading styles and provide a lint rule... or try to avoid this problem.

jjenzz commented 4 months ago

thinking about providing an extend api like SASS. any spreading within compose cld be flagged by ts plugin semantic diagnostics.

// button.tsx
const styles = css.compose({
  button: {
    '--bg-color': 'var(--color_sky-200)',
    '--outline': 'none',

    variants: {
      size: {
        small: { '--padding': 2 },
        medium: { '--padding': 4 },
      }
    },
  },
});

// link.tsx
import { styles as buttonStyles } from './button';

const styles = css.compose({
  link: {
    extend: [buttonStyles.button],
    '--text-decoration': 'none',
  },
});