tw-in-js / twind

The smallest, fastest, most feature complete Tailwind-in-JS solution in existence.
https://twind.style
MIT License
3.8k stars 104 forks source link

Component Proposal #73

Closed sastan closed 3 years ago

sastan commented 3 years ago

Thank you all for the feedback, questions, ideas and suggestions. I have edited the proposed solution o reflect the discussion. Proposed Draft 2020-13-01 04:50 GMT


Related issues #7, #60

While working on twind/styled (PR #7) i realized that twind does not have a good component composition model. With component composition we mean re-using styles for several components while allowing to override certain styles like changing the background color.

Problem Statement

As a component author I want to re-use tailwind directive styles for defining my component and allow users of the component to override styles using tailwind directive. Additionally I want to be able to extend a component and override or add some styles using tailwind rules.

The problem we try to solve is component based composition while tw should keep the expected tailwind behavior.

One way to do composition is utility combinations to recreate the same component in many different places (see Extracting Components). I would call this class composition as it applies or groups several class names for a component.

Details with an example and its problems ```jsx const Button = ({ className, children}) => { return } const ButtonBlock = ({ className, children}) => { return } ``` The example above does not reliably work because the injected css classes have all the same specificity and therefore the order they appear in the stylesheet determine which styles are applied. It is really difficult to know which directive does override another. Lets stick with `bg-*` but there are others. The `bg` prefix and its plugin handle several css properties where `background-color` is only one of them. - `background-color`: `bg-current`, `bg-gray-50`, ... (see https://tailwindcss.com/docs/background-color) - `background-attachment`: `bg-local`, ... (see https://tailwindcss.com/docs/background-attachment) - `--tw-bg-opacity`: `bg-opacity-10`, ... (see https://tailwindcss.com/docs/background-opacity) - and a lot more - not to forget about user plugins and inline directives This ambiguity makes class based composition really difficult. That was the reason we introduced the `override` variant. Consider the following `twind/styled` (PR #7) example: ```js const Button = twind.button` text(base blue-600) rounded-sm border(& solid 2 blue-600) m-4 py-1 px-4 ` // Create a child component overriding some colors const PurpleButton = twind(Button)` override:(text-purple-600 border-purple-600) ` ``` As you see it is difficult to override certain utility classes on usage or when creating a child component. For this to work twind introduced the `override` variant which increases the specificity of the classes it is applied to. But what do you do for a grandchild component or if you want to override the `PurpleButton` styles? `override:override:...`? There must be a better way to solve this problem.

tailwind has a component concept using @apply which basically merges the css rules of several tailwind classes into one class. twin.macro does the same.

That is something I would call style composition and is currently not available in twind.

Details of tailwind @apply Tailwindcss provides [@apply to extract component classes](https://tailwindcss.com/docs/extracting-components#extracting-component-classes-with-apply) which merges the underlying styles of the utility classes into a single css class. That is something i would call _style composition_ and is currently not available in twind. ```css .btn-indigo { @apply py-2 px-4 bg-indigo-500 text-white font-semibold rounded-lg shadow-md hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-indigo-400 focus:ring-opacity-75; } ``` [twind.macro](https://github.com/ben-rogerson/twin.macro) does the same during build time to generate css-in-js objects which are evaluated with a runtime like emotion or styled-component: ```js const hoverStyles = css` &:hover { border-color: black; ${tw`text-black`} } ` const Input = ({ hasHover }) => ( ) ``` > The `tw` function from `twin.macro` acts like the `@apply` helper from tailwindcss.

Lets summarize both composition approaches:

Proposed Solution

Recap of available APIs in twind and their transformations:

When i look at this i see a missing piece:

const btn = tw.apply`inline-block bg-gray-500 text-base`
// => generates on css class with all declarations of the above rules when used

const btnBlick = tw.apply`${btn} block`
// => generates on css class with all declarations of btn & block
// Never used => never injected

<button class={tw`${btn}`}>gray-500</button>
// => tw-btn

<button class={tw`${btn} bg-red-500 text-lg`}>red-500 large</button>
// => tw-btn bg-red-500 text-lg

That API needs to

Here are some examples using tw.apply to get a feeling for the API:

Basic usage Please note that the utility classes are always defined **after** the component styles which allows them to overrides certain styles. ```js import { tw } from 'twind' const btn = tw.apply` py-2 px-4 font-semibold rounded-lg shadow-md focus:(outline-none ring(2 indigo-400 opacity-75)) ` tw`${btn} font-bold` // => .tw-btn .font-bold // CSS: // .tw-XXXX { padding-top: 0.5rem; padding-bottom: 0.5rem; padding-left: 1rem; padding-right: 1rem; font-weight: 600; ...} // .font-bold { font-weight: 700; } const btnLarge = tw.apply`${btn} py-4 px-8` // Result: () => ({ paddingTop: '1rem', paddingBottom: '1rem', paddingLeft: '2rem', paddingRight: '2rem', fontWeight: '600', ... }) tw`${btnLarge} rounded-md` // => .tw-btn-large .rounded-md // CSS: // .tw-btn-large { padding-top: 1rem; padding-bottom: 1rem; padding-left: 2rem; padding-right: 2rem; font-weight: 600; ... } // .rounded-md { ... } ```
twin.macro and styled-component compatibility eg generate one class The would be possible as the returned function has `toString` and `valueOf` methods which inject the styles and return the class name: ```jsx // => tw-red-blue document.body.className = tw.apply`bg-blue bg-red` // => tw-blue-red ``` Or use this helper: ```jsx // There is a better name out there somewhere const twind = (...args) => tw(tw.apply(...args)) // => tw-red-blue document.body.className = twind`bg-blue bg-red` // => tw-blue-red ```
`css` can be used within `tw.apply` ```js const btn = tw.apply` py-2 px-4 ${css({ borderColor: 'black', })} ` ```
Using within css – pending `tw.apply` can be used with `css` ( (_pending variable arguments, array support_): ```js const prose = css( tw.apply`text-gray-700 dark:text-gray-300`, { p: tw.apply`my-5`, h1: tw.apply`text-black dark:text-white`, }, { h1: { fontWeight: '800', fontSize: '2.25em', marginTop: '0', marginBottom: '0.8888889em', lineHeight: '1.1111111', } } ) ``` Using template literal syntax (_pending, but i'm working on it_): ```js const prose = css` ${tw.apply`text-gray-700 dark:text-gray-300`) p { ${tw.apply('my-5')} } h1 { ${tw.apply`text-black dark:text-white`} font-weight: 800; font-size: 2.25em; margin-top: 0; margin-bottom: 0.8888889em; line-height: 1.1111111; } ` ```
`twind/styled` would then be a small react wrapper around `tw.apply` ```js const Button = twind.button` text(base blue-600) rounded-sm border(& solid 2 blue-600) m-4 py-1 px-4 ` const PurpleButton = twind(Button)` text-purple-600 border-purple-600 ` ```
Using tailwind directives with `animation` from `twind/css` ```js const motion = animation('.6s ease-in-out infinite', { '0%': tw.apply`scale-100`, '50%': tw.apply`scale-125 rotate-45`, '100%': tw.apply`scale-100 rotate-0`, }) ```
A react button component ```jsx import { tw } from 'twind' const variantMap = { success: "green", primary: "blue", warning: "yellow", info: "gray", danger: "red" } const sizeMap = { sm: tw.apply`text-xs py(2 md:1) px-2`, md: tw.apply`text-sm py(3 md:2) px-2`, lg: tw.apply`text-lg py-2 px-4`, xl: tw.apply`text-xl py-3 px-6` } const baseStyles = tw.apply` w(full md:auto) text(sm white uppercase) px-4 border-none transition-colors duration-300 ` function Button({ size = 'md', variant = "primary", round = false, disabled = false, className, children }) { // Collect all styles into one class const instanceStyles = tw.apply` ${baseStyles} bg-${variantMap[variant]}(600 700(hover:& focus:&))) ${sizeMap[size]} rounded-${round ? "full" : "lg"} ${disabled && "bg-gray-400 text-gray-100 cursor-not-allowed"} ` // Allow passed classNames to override instance styles return } render() ```

Discared Proposed Solutions ### 1. Nested `tw` (https://github.com/tw-in-js/twind/issues/73#issuecomment-758094856) - `tw` by itself behaves as it does now, untouched - nested `tw` has a new behavior ```js tw`bg-red bg-blue`; // css .bg-red {}, .bg-blue {} are appended // result is bg-red bg-blue const base = tw`bg-red`; // css .bg-red {} is NOT appended as it already was on line 1 // result is bg-red tw`${base} bg-blue`; // css .tw-generated-bg-blue{} is appended // result is bg-red tw-generated-bg-blue ``` Open question @43081j: How to ensure that `generated-bg-blue` has a higher precedence than `bg-red`? ### 2. Reverse Translation (https://github.com/tw-in-js/twind/issues/73#issuecomment-758062412) > Enhance `tw` to detect directives that override previous ones and omit those from the result class names string. ```js const btn = tw`py-2 px-4 font-semibold` // => py-2 px-4 font-semibold tw`${btn} py-4 px-8` // => font-semibold py-4 px-8 tw`py-4 ${btn} px-8` // => py-2 font-semibold px-8 ``` **Algorithm** 1. transform all rules to their css equivalent 2. merge all css into one object 3. for each rule check if their css is contained within the css object; if that is the case include it in the output Step 1 and 2 are possible. Step 3 may have some edge cases like what to do if the css is a partial match: ```css .bg-red-500 { --tw-bg-opacity: 1; background-color: rgba(239, 68, 68, var(--tw-bg-opacity)); } .bg-opacity-5 { --tw-bg-opacity: 0.05; } ``` `bg-opacity-5` partially overrides `bg-red-500`. Both must be included in the output. Another edge case may be if the `css` helper is used. And i'm sure there a some i haven't identified yet. ### 3. twind/compose > Introduce `compose` as a new function which would extract the styles of the provided directives and returns an inline directive with an css style object containing all deep merged rules which can be used with `tw`. The generated styles would have a lower precedence than the utility classes which would allow to use tailwind directives to override styles. The following examples use template literals but well known `tw` arguments like strings, arrays, objects, and inline directives, would be supported. ```js import { compose } from 'twind/compose' const btn = compose` py-2 px-4 font-semibold rounded-lg shadow-md focus:(outline-none ring(2 indigo-400 opacity-75)) ` // Result: () => ({ paddingTop: '0.5rem', paddingBottom: '0.5rem', paddingLeft: '1rem', paddingRight: '1rem', fontWeight: '600', ... }) // CSS: // .tw-XXXX { padding-top: 0.5rem; padding-bottom: 0.5rem; padding-left: 1rem; padding-right: 1rem; font-weight: 600; ...} const btnLarge = compose`${btn} py-4 px-8` // Result: () => ({ paddingTop: '1rem', paddingBottom: '1rem', paddingLeft: '2rem', paddingRight: '2rem', fontWeight: '600', ... }) // CSS: // .tw-YYYY { padding-top: 1rem; padding-bottom: 1rem; padding-left: 2rem; padding-right: 2rem; font-weight: 600; ... } tw`${btnLarge} rounded-md` // => .tw-btn .tw-btn-large .rounded-md ``` `css` can be used within `compose`: ```js const btn = compose` py-2 px-4 ${css({ borderColor: 'black', })} ` ```
Using within css – pending (Click to expand) `compose` can be used with `css` ( (_pending variable arguments, array support_): ```js const prose = css( compose`text-gray-700 dark:text-gray-300`, { p: compose`my-5`, h1: compose`text-black dark:text-white`, }, { h1: { fontWeight: '800', fontSize: '2.25em', marginTop: '0', marginBottom: '0.8888889em', lineHeight: '1.1111111', } } ) ``` Using template literal syntax (_pending, but i'm working on it_): ```js const prose = css` ${compose`text-gray-700 dark:text-gray-300`) p { ${compose('my-5')} } h1 { ${compose`text-black dark:text-white`} font-weight: 800; font-size: 2.25em; margin-top: 0; margin-bottom: 0.8888889em; line-height: 1.1111111; } ` ```
`twind/styled` would then be a small react wrapper around the base `compose`: ```js const Button = twind.button` text(base blue-600) rounded-sm border(& solid 2 blue-600) m-4 py-1 px-4 ` const PurpleButton = twind(Button)` text-purple-600 border-purple-600 ` ```
Show more examples (click to expand) Using tailwind directives with `animation` from `twind/css`: ```js const motion = animation('.6s ease-in-out infinite', { '0%': compose`scale-100`, '50%': compose`scale-125 rotate-45`, '100%': compose`scale-100 rotate-0`, }) ``` Here is an example for an react button component: ```jsx import { tw } from 'twind' import { compose } from 'twind/compose' const variantMap = { success: "green", primary: "blue", warning: "yellow", info: "gray", danger: "red" } const sizeMap = { sm: compose`text-xs py(2 md:1) px-2`, md: compose`text-sm py(3 md:2) px-2`, lg: compose`text-lg py-2 px-4`, xl: compose`text-xl py-3 px-6` } const baseStyles = compose` w(full md:auto) text(sm white uppercase) px-4 border-none transition-colors duration-300 ` function Button({ size = 'md', variant = "primary", round = false, disabled = false, className, children }) { const instanceStyles = compose` ${baseStyles} bg-${variantMap[variant]}(600 700(hover:& focus:&))) ${sizeMap[size]} rounded-${round ? "full" : "lg"} ${disabled && "bg-gray-400 text-gray-100 cursor-not-allowed"} ` // Allow passed classNames to override instance styles return } render() ```
### 4. Enhance `twind/css` > Extend `twind/css` to extract the styles of the provided directives and return an inline directive with an css style object containing all deep merged rules which can be used with `tw`. The generated styles would have a lower precedence than the utility classes which would allow to use tailwind directives to override styles. [css](https://github.com/tw-in-js/twind/blob/main/docs/css-in-js.md) currently accepts an css object. We could extend it to accept strings which are directives: `css` would now be a translator from tailwind rules to css object. > Please note that the template literal syntax may come with issues in editors and prettier as it may be mistaken for _real_ css. If anyone has a solution please comment below. ```js const btn = css('py-2 px-4 font-semibold') // => { padding-top: 0.5rem; padding-bottom: 0.5rem; padding-left: 1rem; padding-right: 1rem; font-weight: 600; } tw`${btn} py-4 px-8` // => tw-xxx py-4 px-8 const largeBtn = css`${btn} py-4 px-8` // => { padding-top: 1rem; padding-bottom: 1rem; padding-left: 2rem; padding-right: 2rem; font-weight: 600; } tw`${largeBtn} font-bold` // => tw-yyyy font-bold ```
Show more examples (click to expand) Using tailwind directives with `animation` from `twind/css`: ```js import { css, animation } from 'twind/css' const motion = animation('.6s ease-in-out infinite', { '0%': css('scale-100'), '50%': css('scale-125 rotate-45'), '100%': css('scale-100 rotate-0'), }) ``` Here is an example for an react button component: ```jsx import { tw } from 'twind' import { css } from 'twind/css' const variantMap = { success: "green", primary: "blue", warning: "yellow", info: "gray", danger: "red" } const sizeMap = { sm: css('text-xs py(2 md:1) px-2'), md: css('text-sm py(3 md:2) px-2'), lg: css('text-lg py-2 px-4'), xl: css('text-xl py-3 px-6') } const baseStyles = css(` w(full md:auto) text(sm white uppercase) px-4 border-none transition-colors duration-300 `) function Button({ size = 'md', variant = "primary", round = false, disabled = false, className, children }) { const instanceStyles = css(` ${baseStyles} bg-${variantMap[variant]}(600 700(hover:& focus:&))) ${sizeMap[size]} rounded-${round ? "full" : "lg"} ${disabled && "bg-gray-400 text-gray-100 cursor-not-allowed"} `) // Allow passed classNames to override instance styles return } render() ```

Summary

I hope have summarized all sides of the discussion and everybody sees theirs points reflected in the proposed solution.


Thank you for reading this whole thing ❤️

/cc @tw-in-js/contributors

mattrossman commented 3 years ago

Looks like a good approach, and compose sounds like a reasonable name!

Something to think about is offering a way to customize the generate class names, e.g. prepending a string or running some kind of transformation function. Styled components has a handy babel plugin that automatically prepends the component name and/or file name before generated hashes so you can easily debug styles. I don't think twind could achieve this same experience without Babel, but in the meantime a way to manually add this info might be useful for debugging, e.g.


compose('PurpleButton')`py-4 px-8 rounded-lg bg-purple-600`
// -> .PurpleButton-XXXX
smeijer commented 3 years ago

Emotion uses labels for that.

let style = css`
  color: hotpink;
  label: some-name;
`

If twind would support labels, or className prefixes, we could do something similar. That way it could even work when adding labels inside the className with the shim.

pseudo:

<button class="label:PurpleButton py-4 px-8 />

And

compose`
  label:PurpleButton
  py-4 px-8 
  rounded-lg 
  bg-purple-600`
// -> .PurpleButton-XXXX 

The separator could be either a : (label:x) to be like emotion, or a - (label-x) to be more tailwind like.

sastan commented 3 years ago

I like the label idea and would move that to separate issue.

itsMapleLeaf commented 3 years ago

The one thing I'm hesitant about is adding more complexity to the public interface. This proposal might raise more questions around "when I should use tw vs. when I should use compose", and so on.

Is there a more direct solution to making overrides more reliable? I'd prefer that. I think the current composition model is lovely, it's just that the lib's approach of outputting tailwind class names is at odds with the cascade. That's one of the big wins to using Emotion and others, that you pretty much never have to worry about correctly applied overrides.

One naïve idea that comes to mind, is generating extra CSS classes with a suffix like -override-x each time a specificity override is needed to apply the right class:

tw`text-blue-500 text-red-500`
// outputs text-blue-500 text-red-500

tw`text-red-500 text-blue-500`
// outputs text-red-500 text-blue-500-override-1

I have a feeling this would be a pain to make work 🙃 just throwing things out there

JoviDeCroock commented 3 years ago

Reading this makes me wonder whether or not we can merge functionality of tw and compose to avoid adding additional API-surface, users could get confused about when to use what and a huge selling point here is that 'you just need tw to start'.

My thinking would be that when we invoke tw and encounter an existing class we could consider that as the base and the added ones as extensions on the base.

const base = tw'bg-blue'

const extended = tw`${base} bg-red`

So this would be one approach, this would enforce the user for generic components to stringify base and add additional classes as a prop.

Another option would be to consider the lexical ordering, so when we encounter bg twice in the same interpolation we could forget about the first encounter or make the second one !important.

I personally think the combination of the above two options could turn out pretty strong.

Just throwing some idea's out there, I do think the idea in itself is sound but I'd look for options not to introduce more API surface.

Do note that this limitation also currently exists in Tailwind and often gets the advise of making the 'stable' overridable styles as props for instance a button flavor,...

43081j commented 3 years ago

I agree it would be super nice if we can have these two concepts in one (tw and compose) as it seems it'll almost certainly lead to confusion around which one is the right one.

There is a piece I'm missing, too, though...

Keep in mind im using web components, if someone does this:

html`<div class=${tw`bg-blue`}></div>`; // results in class="bg-blue"

const base = tw`bg-blue`;
html`<div class=${tw`${base} bg-red`}></div>`; // results in class="bg-blue bg-red"?

surely the simplest route would be to follow order, and to know bg-red naturally overrides bg-blue. so we'd just ignore bg-blue.

so your internal implementation of tw could do a merge of two object representations of these styles, but the result of tw would be the end object mapped back to tailwind classes.

also, in tailwind itself if you had class="bg-blue bg-red", what does it do? @JoviDeCroock is this what you meant when you said they already have this limitation?

maybe im just missing something or skipped an example

JoviDeCroock commented 3 years ago

@43081j in the current implementation the order of which the tailwind css-stylesheet creates this classes will determine what background will be shown.

.bg-blue {
  background-color: blue;
}

.bg-red {
  background-color: red;
}
.bg-red {
  background-color: red;
}

.bg-blue {
  background-color: blue;
}

The above two stylesheets will lead to different results when it comes to which class gets applied to the dom-element.

I could be mixing it up but for <div className="bg-blue bg-red /> in the first sheet it will be blue and in the second it will be red, depending on which of these two classes gets seen first.

This is a limitation within css since there's no inspection what a class actually does, in JS-land this should be solvable.

43081j commented 3 years ago

I understand that part, but to solve that could we not just deal with overrides when we call tw?

for example:


tw`bg-blue bg-red`; // bg-red
tw`${tw`bg-blue`} bg-red`; // bg-red
tw`bg-blue`; // bg-blue

as it doesn't seem "normal" that anyone would ever purposely want two conflicting classes.

i assume the only reason anyone would ever have conflicting class names would be if they're trying to compose or have done it by accident. in which case, we could just de-dupe for them and drop the overridden classes like above.

this way the CSS would be what it is now, and would only ever contain the tailwind classes (not our own extra 'generated classes').

right now, twind pretty much seems to output the same css as is inside tailwind itself. but if we do this proposal, that wouldn't be true anymore as it would also contain a bunch of our own generated classes.

JoviDeCroock commented 3 years ago

Yes, your above comment is a good elaboration on

often gets the advise of making the 'stable' overridable styles as props for instance a button flavor,...

You'll often see the pattern return where for instance an extendable style has this property called flavor.

<Button flavor="primary" />

Which will determine these overridable attributes, there are often special cases though when it comes to margins/paddings/.... so I can see the merit there, ran into a few of those myself where a composable component would need a bit more margin, ....

43081j commented 3 years ago

that could still be solved by de-duping conflicting classes though, right?

just wondering if we don't need to go as far as producing our own CSS, and can instead control the class names more to ensure conflicting ones are removed.

again, i could be missing something though. if there's a case where that wouldn't work, it'd be useful to know

JoviDeCroock commented 3 years ago

Well it depends, right? You would need to be able to inspect classes to see what it contains which is a lot more complicated than it sounds.

const base = tw`bg-blue-500`
const ex = tw`${base} bg-red-500`

This needs a reverse-translation step to see what base contains, which is a lot of effort for the merit it actually has.

lukejacksonn commented 3 years ago

Not having to generate CSS-in-JS objects in order to compose (i.e. just class name inspection) is desirable and have considered the possibility before, however I get stumped at this cases like this:

const base = tw`text-center text-xl`
const ex = tw`${base} text-sm`

In cases like @JoviDeCroock posted above it might seem trivial to dedupe the class names based on the common bg- prefix, you could say something like the last bg- directive wins.. the problem arises when the starting parts of each directive in the composed class are common.

Once both these rule sets have been compiled (and if they were stored internally as CSS-in-JS objects) they they are easy to compare and compose:

const composedStyles = {
  ...({ textAlign: 'center', fontSize: '2rem' }),
  ...({ fontSize: '1rem' })
}

By nature of object extension the latter fontSize will win. I think this is what @JoviDeCroock is hinting at with a "reverse-translation step" and without something like a label: directive like @smeijer I can't think how composition of directives like this is possible without actual CSS-in-JS generation and then deep merging of objects (this is how Oceanwind worked).

Anyway, I am excited by all the possibilities listed here still. Just thought I'd share the context I have in this problem space!

43081j commented 3 years ago
const base = tw`bg-blue-500`
const ex = tw`${base} bg-red-500`

i still don't fully understand why this is non-trivial.

i get that ${base} is already the processed input (so may no longer be bg-blue-500). but in this example wouldn't it just result in: bg-blue-500 bg-red-500 and somewhere we note that all bg classes override each other? so we end up with bg-red-500?

is the difficult part keeping track of what overrides what?

@lukejacksonn do you have an example maybe of where this wouldn't be so simple?

the JS representations make it very easy to compose, but it'd also be very nice if we could only deal with class names, making our output CSS almost equivalent to what tailwind itself would have.

sastan commented 3 years ago

Thank you all for your input. I try to address these here and will update the initial post.

Why not keep track of which directives have been used?

The problem is, sorry for not being more clear in the problem statement, that it is really difficult to know which directive does override another. Lets stick with bg-* but there are others. The bg prefix and its plugin handle several css properties where background-color is only one of them.

This ambiguity makes class based composition really difficult. That was the reason we introduced the override variant.

tailwindcss and twin-macro both use style composition which merges the css properties which would solve this problem for most cases.

I still think that style composition is the best possible way right now to solve component composition while keeping utility classes for all other case.

When I should use tw vs. when I should use compose?

I totally agree that the addition of compose makes the public API more difficult to reason about. Here are my reasons why i think that compose is something different than tw

tw parses the passed tokens, injects the respective css rules (several css classes, one for each token – just like tailwind) and returns a string with class names to use. After the call of tw the user can be sure that the css styles are available in the stylesheet. Each token will

compose would parse the passed tokens, merges the css rules and returns an inline directive to be used with tw. No styles have been added to the stylesheet. Only after the result of compose has been passed to tw the styles (one css class) will be available.

I think that tw and compose solve two different use cases. tw injects several classes and compose holds and merges several directives into one class to allow composition.

43081j commented 3 years ago

ah i get you now, thanks for the great explanation 👍

not all bg-* are actually setting the background, they may be setting some other variable or some narrower property. and the same will apply to most other classes.

so really this is an issue tailwind themselves also have (technically)?

what this means is we'd no longer be an implementation of tailwind, as we are now, but would also have our own feature set on top of that (composition). this library would diverge to be "tailwind and more".

thinking about it, i think personally i would argue that it remain "dumb" and just append the styles as it does now, maybe in the same order tailwind has them at least. so bg-blue bg-red would result in the two rules being appended and conflicting, which would be your own fault as the consumer.

if that is my opinion then i suppose im in agreement with two functions (compose and tw) as it means tw can remain "pure" and implement exactly what tailwind itself does.

otherwise, HTML written using tailwind classes might behave differently using this library than it would using tailwind itself.

itsMapleLeaf commented 3 years ago

Here's an idea. This is probably somewhat of a sin in TS land (and in general), but what if we attached properties to the resulting string from tw that tells which css properties are in it?

const twMeta = Symbol()
type TwMeta = { cssProperties: {} }

interface String {
    [twMeta]?: TwMeta
}

const className = tw`bg-blue-500`

className[twMeta] // { backgroundColor: "blue", "--tw-opacity": 1 }

Then, if the tw function sees an interpolation, and one of the interpolated strings has metadata attached to it, instead of producing a normal tailwind classname, it merges the properties and produces a composed class.

First issue that comes to mind is the possibility of losing that metadata attachment in transport somehow, but I'm not sure if/how that could happen. I feel like that'd only happen if the user's trying to do something really clever.

I guess the goal is just to have some metadata associated with each generated class name string somewhere, so then the tw function should know whether or not to do composition.

bebraw commented 3 years ago

To approach from another angle, I would solve this at setup phase through aliases:

import { setup } from "https://cdn.skypack.dev/twind/shim"

const btn = `
  py-2 px-4
  font-semibold
  rounded-lg shadow-md
  focus:(outline-none ring(2 indigo-400 opacity-75))
`

const btnIndigo = `${btn} bg-indigo(500 hover:700) text-white`

setup({
   ...
  aliases: { btn, btnIndigo }
})

Then, in code you would refer to the aliases through btn and btnIndigo.

In practice, I've found that having composition alone isn't enough and you need a proper component model to have a complete abstraction but for simple ones something like here would be enough and I believe it would replicate Tailwind's @apply nicely.

The caveat of my approach is that now you are forced to use setup to define aliases but at the same time it gives you a single place where to find them.

sastan commented 3 years ago

To approach from another angle, I would solve this at setup phase through aliases:

import { setup } from "https://cdn.skypack.dev/twind/shim"

const btn = `
  py-2 px-4
  font-semibold
  rounded-lg shadow-md
  focus:(outline-none ring(2 indigo-400 opacity-75))
`

const btnIndigo = `${btn} bg-indigo(500 hover:700) text-white`

setup({
   ...
  aliases: { btn, btnIndigo }
})

Then, in code you would refer to the aliases through btn and btnIndigo.

This is already possible using plugins:

setup({
  plugins: {
    btn: `
      py-2 px-4
      font-semibold
      rounded-lg shadow-md
      focus:(outline-none ring(2 indigo-400 opacity-75))
   `,
   btnIndigo : `btn bg-indigo(500 hover:700) text-white`,
  }
})

While trying to link to the docs i noticed that this is missing. Sorry for that. I'll try to add this later.

sastan commented 3 years ago

If we do not want to introduce compose and make composition a first-class tw feature i have identified two areas we need to focus. First-class composition would mean the following:

const button = tw`bg-gray-500 rounded`
// => bg-gray-500 rounded

const redButton = tw`${redButton} bg-red-500`
// current implementation => bg-gray-500 rounded bg-red-500
// what we want => rounded bg-red-500

This should be the injected css:

/* No .bg-gray-500 injected */
.bg-red-500 {
  --tw-bg-opacity: 1;
  background-color: rgba(239, 68, 68, var(--tw-bg-opacity));
}

.rounded {
  border-radius: 0.25rem;
}

As @JoviDeCroock has suggested this needs some way of reverse translation and maybe (optional) making style injection lazy like we already do for keyframes.

1. Lazy Injection (optional)

Currently styles are injected into the sheet during the evaluation of the tw call which would lead to injection of styles that may not be needed.

To work around that we could switch to deferred injection of styles: tw returns an object with toString and valueOf which when called inject the styles. This is how twind/css keyframes is implemented.

2. Reverse Translation

This is the tough one. What i imagine could work would be something like this:

  1. transform all rules to their css equivalent
  2. merge all css into one object
  3. for each rule check if their css is contained within the css object; if that is the case include it in the output

    Step 1 and 2 are possible. Step 3 may have some edge cases like what to do if the css is a partial match:

    .bg-red-500 {
    --tw-bg-opacity: 1;
    background-color: rgba(239, 68, 68, var(--tw-bg-opacity));
    }
    
    .bg-opacity-5 {
    --tw-bg-opacity: 0.05;
    }

    bg-opacity-5 partially overrides bg-red-500. Both must be included in the output.

    Another edge case may be if the css helper is used. And i'm sure there a some i haven't identified yet.

I think first-class composition would be great but i'm unsure if it could be implemented in a reliable, performant way. compose on the other hand adds an additional API that maybe confusing to use but could be easier to implement.

itsMapleLeaf commented 3 years ago

The ease of implementation on the part of compose is a significant upside, but I feel like reducing complexity and handling more edge cases on the user's end is more important. Or in other words, making things work intuitively as one would expect them to.

I'll also point out that the requirement of generating vanilla tailwind classes is at odds with trying to nail down a good UX here. We're falling victim to the CSS cascade 😕

43081j commented 3 years ago

just to take a step back again, do you have an example of when someone would need this?

sorry to keep coming back to this. but lets say tailwind's own css defines this:

.bg-blue {
  background: blue;
}
.bg-red {
  background: red;
}

with tailwind, if you did class="bg-blue bg-red", you'd end up with red of course. tailwind inevitably must have this problem right now, and must have their own order of rules.

this isn't really a "problem" because its your own fault for specifying two backgrounds.

so whats a real use case of when you'd purposely want to do this?

for example:

const base = tw`bg-red`;
tw`${base} bg-blue`;

we would right now produce bg-red bg-blue which would be reliant on what order we chose for our overall stylesheet.

presumably the use case here is when you want to do this kind of composition and have the stylesheet ordered by your input rather than what we decided initially.

if thats the case, i'd still take the hit on API size to keep this extra functionality separate. again, tw right now pretty much maps to the same thing tailwind itself would do, which is what users would expect. if you add this composition into tw itself, you've branched off into having your own behaviour no longer aligned to tailwind.

if anything, i'd order rules the same as tailwind somehow (when using tw) and have a separate composition API which appends custom classes after all the defaults/base.

edit:

i suppose its not so bad/misaligned as long as this new behaviour only happens to nested tw results. so if you never compose that way, things work exactly as they do now

sastan commented 3 years ago

with tailwind, if you did class="bg-blue bg-red", you'd end up with red of course. tailwind inevitably must have this problem > right now, and must have their own order of rules.

this isn't really a "problem" because its your own fault for specifying two backgrounds.

so whats a real use case of when you'd purposely want to do this?

The use case to allow user of a component or component author to override some styles (the example is in react but it applies to other frameworks aswell):

const Button = ({ className, children}) => {
  return <button className={tw`bg-gray-500 text-base ${className}`}>{children}</button>
}

const ButtonBlock = ({ className, children}) => {
  return <Button className={`block ${className}`}>{children}</Button>
}

<Button>gray-500</Button>
<Button className="bg-red-500 text-lg">red-500 large</Button>
itsMapleLeaf commented 3 years ago

i suppose its not so bad/misaligned as long as this new behaviour only happens to nested tw results. so if you never compose that way, things work exactly as they do now

Yeah, this is pretty much where I'm at. I think the extra power and peace of mind is worth misalignment in some cases

tailwind inevitably must have this problem right now, and must have their own order of rules.

It does, and the best way I've seen to solve it is to not have class overrides, or in other words, only include the classes you want. So instead of [red, condition && blue], we'd say condition ? blue : red so there's no chance of a conflict. But, working with twin macro and other CSS in JS libs, it was nice to not have to worry about that. We're in JS land, and if we have the power to circumvent the cascade in a user-friendly way, we should. But I also recognize priorities of this project may differ from my own, just throwing this out there.

43081j commented 3 years ago

ok yeah the case makes more sense now.

IMO then i would aim for that kind of api:

to aim for this:

tw`bg-red bg-blue`;
// css .bg-red {}, .bg-blue {} are appended
// result is bg-red bg-blue

const base = tw`bg-red`;
// css .bg-red {} is NOT appended as it already was on line 1
// result is bg-red

tw`${base} bg-blue`;
// css .generated-bg-blue{} is appended
// result is bg-red generated-bg-blue

where the last one is tough to detect as you need a map somewhere, or some cache, to know "this string is a nested tw result".

edit:

something logically like:

function tw(strings, ...expressions) {
  if (expressions.some((expr) => exprIsTw(expr))) {
    // here we somehow key/hash this call's args
    // and generate a new css class with it, which we
    // prefix all the tailwind classes in `strings` with
    // which came after a `tw` expression.
    // we then append those classes if they haven't
    // been already (we'd know because its a hash/key in a map)
  } else {
    // do as normal
  }
}
sastan commented 3 years ago

I have adjusted the proposal text to outline the problem, explain our requirements and show several possible solutions. Please continue to discuss so that we find a viable solution.

JoviDeCroock commented 3 years ago

I think what @43081j suggests is great, iirc there is already a rules-to-className cache or what that only in oceanwind? If there is one we could probably take

function tw(strings, ...expressions) {
  if (expr = expressions.filter((expr) => exprIsTw(expr))) {
    // convert className to oldSetofRules and retrigger tw
  } else {
    // do as normal
  }
}

The code provided and translate classNames back to their corresponding tw string, this allows for reuse and presedence checks.

itsMapleLeaf commented 3 years ago

Thanks for the update. Nested tw feels like the best way to go for me.

How to ensure that generated-bg-blue has a higher precedence than bg-red?

Not sure if this is possible, or if there are potential issues with this, but maybe you could keep two DOM stylesheets, one for the regular TW rules, and one for the generated rules, so the generated rules would always be in the DOM after the normal rules, therefore having higher precedence. Another option would be to specify the generated rules twice, so .generated.generated {}

itsMapleLeaf commented 3 years ago

Is a part of this proposal to solve the issue shown here? https://codesandbox.io/s/flamboyant-water-pd4xl?file=/src/index.js

There's no nesting or anything, this is just hitting the cascade. Might be unrelated, but I wonder if there's a way to make this work with class name order. override is a thing, but I figure we were trying to avoid needing that

sastan commented 3 years ago

https://codesandbox.io/s/flamboyant-water-pd4xl?file=/src/index.js

That is normal tailwindcss behavior. text-blue-500 is defined later in the stylesheet and wins. That would be solved only with solution 2. Reverse Translation.

sastan commented 3 years ago

Not sure if this is possible, or if there are potential issues with this, but maybe you could keep two DOM stylesheets, one for the regular TW rules, and one for the generated rules, so the generated rules would always be in the DOM after the normal rules, therefore having higher precedence. Another option would be to specify the generated rules twice, so .generated.generated {}

We already have a presedence order and can control in which order css rules are inserted in the stylesheet. My problem is how do we solve this for arbitrary depths:

// Somewhere in the app 
tw`bg-green`
tw`bg-blue`
tw`bg-red`
// => order in stylesheet: .green{} .blue{} .red{}

// Later in the app
const red = tw`bg-red`;

const blue = tw`${red} bg-blue`;
// => should be blue but is red because .red{} is later in the stylesheet

const green = tw`${blue} bg-green`;
// => should be green but is red because .red{} is later in the stylesheet
sastan commented 3 years ago

I'm trying to wrap my head around how solution 1. Nested tw could be implemented.

Some issues i see:

const base = tw`bg-red`;
tw`${base} bg-blue`;
tw`bg-green ${base} bg-blue`;
  1. Injection of unused styles: bg-red would be injected into the stylesheet (because of line 1) although it is not needed.
  2. in line 3: it should be blue right? but it will be green as bg-green is injected last into the stylesheet
  3. what about rules that have already been injected by other parts of the app (https://github.com/tw-in-js/twind/issues/73#issuecomment-758385255)
  4. How do we detect that a interpolation is somehow special?

    tw`${base} bg-blue`;

    vs

    tw`${"bg-red"} bg-blue`;
sastan commented 3 years ago

I'm still in favor of putting this into a separate module because

43081j commented 3 years ago

I have been thinking about it, and comparing it to how lit-element works in fact.

In lit-element, the way they know if an expression is another template is by having a TemplateResult:

const base = css`xyz`; // TemplateResult instance
css`foo ${base} bar`; // detects that `base` is a TemplateResult and does any merging, etc

and in my proposed solution, having some exprIsTw helper is working around this gap of knowing for sure what is a tw result and what isn't.

so it probably does make sense to have a compose if you want to be safe:

const base = tw`foo`; // this is a string now
tw`x ${base} y`; // would be "x foo y" because it is string interpolation
tw`x ${compose(base)} y`; // would be whatever our generated classes are, because we're explicitly telling it to

it would be super nice if it "just worked" via tw only, but you're right it adds weight to it and complicates the implementation.

if you can keep the implementation in the base the same roughly, and all of this compose stuff be an extra separate layer, that'd be good i think and removes the need for an exprIsTw.

i think a primary goal should be to keep tw aligned to tailwind itself, which means not doing any custom shuffling around of rules etc

itsMapleLeaf commented 3 years ago

If tailwind alignment is important, then compose is a good solution. But personally, I'd be willing to throw tailwind alignment out the window, if it meant being able to rely on class name order for the correct styles. Ergo, generating different hashed class names and styles for red blue vs. blue red, similar to twin.macro. That seems like a different issue, though

sastan commented 3 years ago

Ergo, generating different hashed class names and styles for red blue vs. blue red, similar to twin.macro.

That would be possible with compose.

sastan commented 3 years ago
const base = tw`foo`; // this is a string now
tw`x ${base} y`; // would be "x foo y" because it is string interpolation
tw`x ${compose(base)} y`; // would be whatever our generated classes are, because we're explicitly telling it to

I really like the inline compose example.

it would be super nice if it "just worked" via tw only, but you're right it adds weight to it and complicates the implementation.

Me too.

if you can keep the implementation in the base the same roughly, and all of this compose stuff be an extra separate layer, that'd be good i think and removes the need for an exprIsTw. i think a primary goal should be to keep tw aligned to tailwind itself, which means not doing any custom shuffling around of rules etc

I agree.

itsMapleLeaf commented 3 years ago

Ergo, generating different hashed class names and styles for red blue vs. blue red, similar to twin.macro.

That would be possible with compose.

Right, I mean without an entire separate API to accomplish it, having tw itself always return an order-sensitive hash. It feels more natural and ergonomic this way. Maybe this can be done through a plugin in tw?

sastan commented 3 years ago

It feels more natural and ergonomic this way.

😄 For you. I think (hope) most css and tailwind users are aware that the order of class names in the class attribute does not matter. We would change the expected behavior.

It should be possible with the new api to do exactly what you want:

// order-sensitive hash; x = the new api
const osh = (...args) => tw(x(...args))

osh`bg-red bg-blue`
// => tw-red-blue

osh`bg-blue bg-red`
// => tw-blue-red
sastan commented 3 years ago

This seems to be a really though one. Thanks for all the thoughts and ideas.

I think we now agree that the problem is component based composition and tw should keep the expected behavior tailwind behavior.

tailwind has a component concept using @apply which basically merges the css rules of several tailwind classes into one class. twin.macro does the same.

I would say we change our perspective to view this as a component definition problem.

As a component author i want to re-use tailwind directive styles for defining my component and allow users of the component to override styles using tailwind directive. Additionally i want to be able to extend a component and override or add some styles using tailwind rules.

// Using x as an placeholder for the name

const btn = x`inline-block bg-gray-500 text-base`
// => generates on css class with all declarations of the above rules when used

const btnBlick = x`${btn} block`
// => generates on css class with all declarations of btn & block
// Never used => never injected

<button class={tw`${btn}`}>gray-500</button>
// => tw-btn

<button class={tw`${btn} bg-red-500 text-lg`}>red-500 large</button>
// => tw-btn bg-red-500 text-lg

Lets take a look at the available APIs in twind and their transformations:

When i look at this i see a missing piece:

That API needs to

Why lazy? Additionally to prevent unnecessary style injection it may interfere with importing a component library that uses this API before invoking setup.

Could we agree that such an API would be useful?

Naming this functionality is difficult here are some ideas:

itsMapleLeaf commented 3 years ago

I think (hope) most css and tailwind users are aware that the order of class names in the class attribute does not matter.

This Very Scientific Poll™️ convinced me otherwise! And I learned from this very tweet that class name order in HTML didn't matter 🙃 https://twitter.com/mxstbr/status/1038073603311448064

All in all, I feel like this is going to be a bit of a surprising footgun for people coming from twin.macro, or from CSS-in-JS in general. I'd even argue that not having to worry about the cascade is sort of a CIJ staple at this point.

It should be possible with the new api to do exactly what you want:

This is good. Definitely add this to the docs as a recommended way of avoiding the cascade

  • tw.apply => to mirror tailwindcss @apply

I like this, it follows the same logic as vanilla TW: creating a new utility class from existing ones.

sastan commented 3 years ago

Here are some examples using tw.apply to get a feeling for the API:

Basic usage Please note that the utility classes are always defined **after** the component styles which allows them to overrides certain styles. ```js import { tw } from 'twind' const btn = tw.apply` py-2 px-4 font-semibold rounded-lg shadow-md focus:(outline-none ring(2 indigo-400 opacity-75)) ` tw`${btn} font-bold` // => .tw-btn .font-bold // CSS: // .tw-XXXX { padding-top: 0.5rem; padding-bottom: 0.5rem; padding-left: 1rem; padding-right: 1rem; font-weight: 600; ...} // .font-bold { font-weight: 700; } const btnLarge = tw.apply`${btn} py-4 px-8` // Result: () => ({ paddingTop: '1rem', paddingBottom: '1rem', paddingLeft: '2rem', paddingRight: '2rem', fontWeight: '600', ... }) tw`${btnLarge} rounded-md` // => .tw-btn-large .rounded-md // CSS: // .tw-btn-large { padding-top: 1rem; padding-bottom: 1rem; padding-left: 2rem; padding-right: 2rem; font-weight: 600; ... } // .rounded-md { ... } ```
twin.macro and styled-component compatibility eg generate one class The would be possible as the returned function has `toString` and `valueOf` methods which inject the styles and return the class name: ```jsx // => tw-red-blue document.body.className = tw.apply`bg-blue bg-red` // => tw-blue-red ``` Or use this helper: ```jsx // There is a better name out there somewhere const twind = (...args) => tw(tw.apply(...args)) // => tw-red-blue document.body.className = twind`bg-blue bg-red` // => tw-blue-red ```
`css` can be used within `tw.apply` ```js const btn = tw.apply` py-2 px-4 ${css({ borderColor: 'black', })} ` ```
Using within css – pending `tw.apply` can be used with `css` ( (_pending variable arguments, array support_): ```js const prose = css( tw.apply`text-gray-700 dark:text-gray-300`, { p: tw.apply`my-5`, h1: tw.apply`text-black dark:text-white`, }, { h1: { fontWeight: '800', fontSize: '2.25em', marginTop: '0', marginBottom: '0.8888889em', lineHeight: '1.1111111', } } ) ``` Using template literal syntax (_pending, but i'm working on it_): ```js const prose = css` ${tw.apply`text-gray-700 dark:text-gray-300`) p { ${tw.apply('my-5')} } h1 { ${tw.apply`text-black dark:text-white`} font-weight: 800; font-size: 2.25em; margin-top: 0; margin-bottom: 0.8888889em; line-height: 1.1111111; } ` ```
`twind/styled` would then be a small react wrapper around `tw.apply` ```js const Button = twind.button` text(base blue-600) rounded-sm border(& solid 2 blue-600) m-4 py-1 px-4 ` const PurpleButton = twind(Button)` text-purple-600 border-purple-600 ` ```
Using tailwind directives with `animation` from `twind/css` ```js const motion = animation('.6s ease-in-out infinite', { '0%': tw.apply`scale-100`, '50%': tw.apply`scale-125 rotate-45`, '100%': tw.apply`scale-100 rotate-0`, }) ```
A react button component ```jsx import { tw } from 'twind' const variantMap = { success: "green", primary: "blue", warning: "yellow", info: "gray", danger: "red" } const sizeMap = { sm: tw.apply`text-xs py(2 md:1) px-2`, md: tw.apply`text-sm py(3 md:2) px-2`, lg: tw.apply`text-lg py-2 px-4`, xl: tw.apply`text-xl py-3 px-6` } const baseStyles = tw.apply` w(full md:auto) text(sm white uppercase) px-4 border-none transition-colors duration-300 ` function Button({ size = 'md', variant = "primary", round = false, disabled = false, className, children }) { // Collect all styles into one class const instanceStyles = tw.apply` ${baseStyles} bg-${variantMap[variant]}(600 700(hover:& focus:&))) ${sizeMap[size]} rounded-${round ? "full" : "lg"} ${disabled && "bg-gray-400 text-gray-100 cursor-not-allowed"} ` // Allow passed classNames to override instance styles return } render() ```
just214 commented 3 years ago

I'm dealing with a family emergency right now so I'll be offline for the next week or so. I'll have to catch up on all of this when I get back, but great ideas and feedback from what I have seen so far!

sastan commented 3 years ago

I have update the propsal text to reflect the idea from https://github.com/tw-in-js/twind/issues/73#issuecomment-758784186.


@gojutin I hope your family will be fine and you find the time to be with them in these trying times.

sastan commented 3 years ago

It would be great if you could give me a 👍 if you are happy with the proposed solution. Please leave a comment if you are not.

/cc @tw-in-js/contributors

sastan commented 3 years ago

Hey guys,

I'm sorry bother you. I started using the tw.apply and it didn't felt right. One coworker run straight into: tw.apply(customTW, args) – which does not work. I thought about it and it seemed to be legit case. That's why I changed tw.apply to just apply:

// import { apply, theme} from 'twind'

// twind/css re-exports apply and theme for convenience
import { css, apply, theme } from 'twind/css'

const prose = css`
  /* Extract CSS from these Tailwind rules */
  ${apply`text-gray-700 dark:text-gray-300`}

  /* Combine with CSS declarations */
  p {
    color: rebeccapurple;
    background-color: ${theme('colors.gray.500')};
    &:hover {
      ${apply`text-purple-700`}
    }
  }

  /* Define global styles */
  :global {
    a {
      /* Access theme values */
      color: ${theme('colors.blue.500')}
    }
  }
`

I already pushed it to main and released v0.13.0 with the new API.

I hope that is ok with you all... I try to do better next time.

tylerforesthauser commented 3 years ago

This does make sense to me, for what it's worth. Feels like a good set of tools, very reminiscent of native Tailwind functions + Emotion css syntax. I dig it. I'll be playing for sure!

just214 commented 3 years ago

I'll do some experimenting with the new API this week and report back, but it seems very intuitive from your examples.