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

Unify API #427

Open
brandonkal opened this issue May 30, 2019 · 8 comments
Open

Unify API #427

brandonkal opened this issue May 30, 2019 · 8 comments
Labels
enhancement: proposal 💬 Improvement of current behaviour that needs to be discussed needs: triage 🏷 Issue needs to be checked and prioritized

Comments

@brandonkal
Copy link

brandonkal commented May 30, 2019

Describe the feature

CSS in JS libraries are maintaining two seperate APIs, namely styled and css. There are good reasons for both, but in react projects, it is an unnecessary complication. I wish to show that with Linaria's approach it is possible to get the best of both (composition, selection, prop interpolation, and automated css variable interpolations)!

Problem

Having a single consistant and powerful API is important. Using both together creates some edge cases. Exclusively using the current styled API can cause certain components (those that use Hooks) to not render and you quickly end up creating many components with deep nesting. An example of this is an input with a "clear" button. It must be wrapped in a div to contain and position the button but a styled component would cause the ref to refer to the parent div rather than the input as expected.

For this reason and others, Styled Components are also not currently suitable for building more complex components such as those found in UI libraries. It can be done, but it results in deep nesting. Because Linaria's styled runtime size is near zero, Linaria is in a unique position to unify these two approaches.

Proposal

Step 1

The first step would be to support a render prop on the styled component, this would solve many issues with building reusable components:

  • The exported component can be used as a child selector for overriding styles.
  • refs could be passed down without forgoing the benefits that the styled function provides
  • default props (e.g. input type="password", button type="button") can be set easily. Styled Components has a seperate attr() function, but simply supplying a custom render function is a more powerful solution.
  • Components with hooks can render themselves (fixing that issue) if provided as a render prop.
  • shallower render trees. Exported components can include the styles required to function. No more of this:
// Styled(PasswordWrapper) This is done so that the exported module can be selected in a parent styled tag
<Input.Password>
    <PasswordWrapper> {/* Root customized component */}
          <ComponentRoot className="InputWrap_i17nyggi">
               <div className="InputWrap_i17nyggi">
                  <input ... />
                  <span onClick={}>Show</span>
               </div>
          </ComponentRoot>
     </PasswordWrapper>
</Input.Password>

This is a simple example, but it can get worse, e.g. the component consumer then wraps the component to customize the styles, or more children use styled. It was worse with emotion, where everything with a css prop was automatically wrapped in multiple Context.Consumers.

The styled function provides a lot of utility and adding a render prop would eliminate a case where css must be used instead.

Step 2

Introduce the styled.fragment. The styled API would also change slightly so that it can be used as a replacement for the css function in every case. The babel plugin could optionally would rewrite styled to css where appropriate though this is not required.
This solves the composition issue with styled.

Fragments support interpolation and cannot be used alone if they contain references to component props.
When called, they return their contents. This is unlike css, which returns a className. They do generate unique classnames. This allows elements that compose this element to be selected. To access the fragment's generated classname directly, call its className property.

const shared = styled.fragment`
  background: beige;
`

const Typography = styled.fragment`
  color: black;
  line-height: 1.5;
  margin: 6px;
  /* We can interpolate here because CSS vars are always applied to */
  /* the element that composes this style */
  font-size: ${props => props.size}px;
`

// Styled fragments only return class name and styles prop. Which can compose
// Then we can compose like this:
const Article = styled.article`
  p {
    ${Typography}
  }
`

const Footer = styled.footer`
  ${Typography}
  font-size: 0.8em; /* Override what is specified in Typography */
`

const Header = styled.header`
  ${shared}
  color: blue;
`

const Card = styled.div`
  background: beige;
  padding: 2em;
  border-color: black;

  /* Selecting child elements with the shared class name applied */
  & .${shared.className} {
    /* Just like any JSX styled element this styled.fragment has a className */
    color: pink;
  }
`

Generated output:

.Article.Typography_fragment p,
.Footer.Typography_fragment {
  color: black;
  line-height: 1.5;
  margin: 6px;
  font-size: var(--Typography_fragment_fontSize-var);
}

/* Because we are nesting and expect an override, we have to match the specificity */
.Footer.Typography_fragment {
  /* The cool thing about variables here is that we are not using it here, even though it is set on footer.style */
  /* It may also be possible to statically determine that the var should not be applied to <Footer /> */
  font-size: 0.8em;
}

.Header {
  color: blue;
}

/* a demonstration of atomization */
.shared_fragment {
  background: beige;
}

.Card {
  padding: 2em;
  border-color: black;
  /* background style removed as it is shared (as determined by compiler) */
}

/* Our override */
.Card .shared_fragment {
   color: pink;
}

Generated markup:

<article
  class="Article Typography_fragment"
  style="--Typography_fragment_fontSize-var: '16px';"
>
  <p>lorem</p>
  <p>ipsum</p>
</article>

<header class="Header shared_fragment">Welcome to the site!</header>

<footer
  class="Footer Typography_fragment"
  style="--Typography_fragment_fontSize-var: '16px';"
>
  Copyright
</footer>

<div class="Card shared_fragment">Shuffle</div>

If styled is called without an argument, fragment could be assumed. But doing so means no css syntax highlighting currently.

<button
  className={cx(
    styled()`
      color: black;
      border: red;
    `,
    'btn'
  )}
>
  Click
</button>

Step 3

Proposed syntax where styled accepts a function as its render prop:

  1. receives props and ref React.forwardRef
  2. Styled component adds classname and styles to filteredProps
  3. Styled component sets displayName and __linaria object on Root
  4. Styled helper acccepts render function (tag, filteredProps)
export const Input = styled.input`
  margin: 0;
  width: 100%;
  height: 32px;
  padding: 4px 11px;
  color: rgba(0, 0, 0, 0.65);
  font-size: 14px;
  line-height: 1.5;
  border: 1px solid #d9d9d9;
  border-radius: 4px;
  transition: all 0.3s;
`((filteredProps, ref, tag) => {
  // I have moved tag to the end here so it can be ignored.
  const { styles, className } = filteredProps
  // Use hooks here etc...
  return (
    <div {...className} {...styles}>
      <input ref={ref} {...filteredProps} />
    </div>
  )
})

Fragments without prop interpolations are allowed as a css replacement as such, this could be done:

<article
  className={cx(
    styled.fragment`
      color: red;
    `,
    importedFragment
  )}
>
  Composed Styles!
</article>

Related Issues

This should fix: #244 and #234 plus #418.

@pbitkowski pbitkowski added the rfc label May 30, 2019
@brandonkal
Copy link
Author

brandonkal commented May 30, 2019

I've been thinking about this a lot, so I'd be interested in hearing thoughts. It is clear to me that using a classname only approach for styling descendent selectors does introduce complexity though that is a risk a developer takes when using descendant selectors.

I do believe using classnames is the right approach. The styled component is a nice abstraction as you don't have to think about classnames but with the exception of the styles for variables, they really do the same thing:

const Button = styled.button`
    color: red;
`
<Button />
<button className={css`
    color: red;
`} />

This is why I am not a fan of this:

const ButtonGroup = styled.div`
  ${Button} {
    margin: 0;
  }
  /* vs */
  .${Button} {
    margin: 0;
  }
`

Two different ways to select an applied className. You have to look at the implementation of Button to determine if a leading dot should be used. Both methods do the same thing from the application developer's perspective. It appears the reason for the difference is that the first came from styled components which chose the first. The second came from emotion because emotion's css function used to return a classname.

Pros for using styled:

  • Define inline and it is still selectable. With the className approach, this is not possible.

Pros for using css:

  • Because css is compiled at build, there is no penalty to having it within a render function vs lifting its scope. This is quite convenient for keeping styles closer to what they modify.
  • Natural composition
    Cons of using css:
  • Things are done twice: CSS rules defined + rules for classname composition must be repeated. styled already has access to all component props, so this composition logic can be generated at build time.

Modifier Classes

Astroturf supports arbitrary modifier classes defined in the css function. This creates a problem when you wrap components: #234 (comment).

One solution is to use an arbitary prefix such as $. I don't like this approach because then there is no easy way to also pass through these properties. Also, JSX may eventually support something like this: <Box ${modifier} /> which looks too similar. I would propose to instead determine if a prop should pass through where it is defined. After all, we can think of our classes as functions of state:

const Button = styled.button`
  color: black;
  border: 1px solid black;
  background-color: white;

  &[props|primary] {
    color: blue;
    border: 1px solid blue;
  }

  &[props|color=green--] {
    color: green;
  }
`;

<Button primary color="green">

I am using the props namespace here to make it clear that these are functions of props.
It feels more like CSS, but the following could work with some work:

${props => (props.primary && generateClassName(&, css.fragment`
    color: blue;
    border: 1px solid blue;
`), true)}

where generateClassName is a function that takes a prefix and a css string.

Because rule blocks are a function of state, we can pass three arguments in the CSS:

  1. Condition
  2. passThrough
  3. The rule template string

So above, the first rule has the condition that props.primary is truthy. The -- suffix tells styled to not pass through the property. Props are passed through to custom components by default (unless the render function it is a styled DOM node where valid props are known). Simply include a -- to opt out of this for a specific prop.

This avoids something like this:

<MyStyledButton $primary primary />

When compiling, linaria would transform each rule block into its own const = Button_propsPrimary = css call. Perhaps something like this:

function css(prefix = "", suffix = "", stateCondition, passThrough = true)`rule` {}

const ButtonBase = css()`
     color: black;
      border: 1px solid black;
      background-color: white;
`

const Button_PropsPrimary = css(ButtonBase, "", (props) => !!props.primary, true)`
    color: blue;
    border: 1px solid blue;
`

const Button_PropsColorGreen = css(ButtonBase, "", (props) => props.color === "green", false)`
    color: green;
`

const ButtonClassName = cx(ButtonBase, Button_PropsPrimary, Button_PropsColorGreen)
<button className={ButtonClassName}>

Why

  • With this approach Styled handles the concatentation of classnames as a function of state. It feels weird repeating this logic manually.
  • Linaria can statically analyze the css text and determine which props to read.
  • This approach is similar to fela, but using CSS syntax.
  • Currently descendent selectors are not tree shakeable. Linaria would require complete knowledge of the DOM to safely tree shake unused styles. By splitting descendent selectors to their own variable, the bundler can instead handle these things.
  • We can use CSS features in cases where passing theme via context was used. i.e. you can define a dark theme in the leaf styled components. This is possible: .theme-dark & {color: white;} but the "theme-dark" className must be manually added rather than using component state.
  • Styled currently cannot react to props to conditionally apply rule blocks. It does a great job with individual rules (via CSS variables). Combined with this approach, styled could be used exclusively to get the best of both worlds.
  • More flexible prop names. The developer can still choose to prefix props that modify styles with a dollar sign, but this is not at all required. Instead forwarding is declared where the rest of styling logic is applied (inside the css).

We are currently doing everything twice.:

const buttonBase = css`
     color: black;
    background: white;
   padding: 5px;
`
const buttonPrimary = css`
    background: blue;
    color: white;
`
let buttonSize = {}
buttonSize.large = css`
    font-size: 3em;
`

function Button({ size, primary, ...props}) {
      const styles = cx([
          buttonBase,
          primary && buttonPrimary,
          size && buttonSize[size] && buttonSize[size]
     ])
     <button className={styles} {...props} />
}

This repetition is unnecessary. Let's define all styles in CSS, including reacting to props or state!

@brandonkal
Copy link
Author

I believe I've found a better way to unify these APIs.
This takes advantage of Linaria being compiled at build time.
Steps:

  1. The styled function gets some new public properties. The logic that is filtering props and calculating the new className and style attributes becomes a css() method.
  2. This css method can then be spread on any element.
  3. The styled render function calls this css() function with the given props when used as a JSX element. It is a simple matter of running that calculation and then React.createElement().
  4. In cases where a styled function is never used in JSX, has no interpolations, and only its css() method is called, the babel transform replaces it with the generated classname. Similar to how css'...' currently works.
const spacing = styled`
   margin-right: props.margin;
`.css() // props could be taken from render function closure if not passed as argument
...
<MenuItem>
    <Link className={spacing.className}><Icon /></Link>
    <span>text</span>
</MenuItem>

This needs some more thought. This would allow using all the features of styled() when required without necessarily being tied to rendering a specific element.

@brandonkal
Copy link
Author

Requesting a response.

@mikestopcontinues
Copy link
Contributor

Hey @brandonkal, is this issue still up-to-date? I'd like to comment, but I just want to make sure you haven't gone beyond where your last message left off. I've got a fairly complex project that I proof-of-concepted with Linaria, and while I've hit some snags, I'd like to stick with it. So I'd very much like to offer feedback (and perhaps code in the future).

@brandonkal
Copy link
Author

Hello. I haven't thought of this for some time but still believe the premise is valid.

@jayu jayu added enhancement: proposal 💬 Improvement of current behaviour that needs to be discussed needs: triage 🏷 Issue needs to be checked and prioritized and removed rfc labels Apr 1, 2020
@ersinakinci
Copy link

I just want to chime in and say this is a fantastic summary of the nuances involved with the css and styled API's for Linaria and most other CSS-in-JS libraries. I've encountered these same issues time and again but never wrote them out so concretely. I would fully support any effort to create a unified API, as @brandonkal suggests.

I'll have to read this a few more times to totally grok it and offer feedback.

@drewswaycool
Copy link

Is there a workaround for this in the meantime? Is this proposal still being considered or some other kind of implementation?

@pitops
Copy link

pitops commented Oct 16, 2021

any update?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement: proposal 💬 Improvement of current behaviour that needs to be discussed needs: triage 🏷 Issue needs to be checked and prioritized
Projects
None yet
Development

No branches or pull requests

7 participants