0.1.12 • Published 2 years ago

@jsxui/system v0.1.12

Weekly downloads
-
License
MIT
Repository
github
Last release
2 years ago

@jsxui/system

Utilities to configure your Design System for any framework or platform.

Install

yarn add @jsxui/system
npm install @jsxui/system

Get Started

This package ships with a helper function to create a design system configuration that returns context aware utilities.

createSystem

After installing, import the helper function and create your design system configuration:

import { createSystem } from "@jsxui/system"

export const { createProps, theme, tokens } = createSystem({
  mediaQueries: {
    small: { minWidth: 0 },
    medium: { minWidth: 960 },
    large: { minWidth: 1280 },
    dark: { prefersColorScheme: "dark" },
  },
  colors: {
    primary: { initial: "#0070f3", dark: "#0693d2" },
    secondary: { initial: "#ff4081", dark: "#c60055" },
    background: { initial: "#fafafa", dark: "#212121" },
    foreground: { initial: "#212121", dark: "#fafafa" },
  },
})

export type ColorValue = keyof typeof theme.colors

createProps

Use the createProps helper utility to create your first set of component props. We'll start by creating a set of style props to display different text style variations:

import { createProps, ColorValue, theme } from "system"

const textVariant = createProps({
  styles: {
    fontSize: (size: number) => size,
    color: (color: ColorValue) => ({ color: theme.colors[color] }),
  },
})

Notice that we start by defining styles. These are functions that will be applied to the variant's props and can return either a single value or an object of multiple values. If you've written Sass before, these are similar to mixins.

Now that we have a few styles defined, we can start to add each variant. We can also define any default props that should be applied to all variants like the text color:

import { createProps, ColorValue } from "system"

const textVariant = createProps({
  styles: {
    fontSize: (size: number) => size,
    color: (color: ColorValue) => ({ color: theme.colors[color] }),
  },
  defaults: {
    color: "foreground",
  },
  variants: {
    heading1: {
      as: "h1",
      fontSize: { initial: "2rem", medium: "3rem", large: "4rem" },
    },
    heading2: {
      as: "h2",
      fontSize: { initial: "1.5rem", medium: "2rem", large: "3rem" },
    },
    body: {
      as: "p",
      fontSize: { initial: "1rem", medium: "1.25rem", large: "1.5rem" },
    },
  },
})

And that's it! We've created our first variant that can be used to style text and display the appropriate element in our UI.

Using with other libraries

This text variant can be used with any library. For an example, let's see how we can use it with Styled Components, a popular CSS-in-JS library:

import type { AttributeProps, StyleProps } from "@jsxui/system"
import styled from "styled-components"
import { textVariant } from "system"

export type TextAttributeProps = AttributeProps<typeof textVariant>

export type TextStyleProps = StyleProps<typeof textVariant>

export const Text = styled.p.attrs<TextAttributeProps>(
  (props) => textVariant.getProps(props.variant, props.states).attributes
)<TextStyleProps>((props) => textVariant.getProps(props.variant, props.states).styles)

Now we can use our Text component in our application with fully typed variants and states:

import { Text } from "system"

function App() {
  return (
    <Text variant="heading1" color="primary">
      Hello World
    </Text>
  )
}

If you'd like to use CSS properties, use the collectVariants helper to create global styles:

import { useMemo } from "react"
import { ThemeKey } from "@jsxui/system"
import { collectVariants, theme } from "system"
import { createGlobalStyle, ThemeProvider } from "styled-components"

export function AppProvider() {
  const GlobalStyles = useMemo(() => {
    return createGlobalStyle(collectVariants())
  }, [])

  return (
    <>
      <GlobalStyles />
      <App />
    </>
  )
}

Now variants will reference a global CSS variable. This has the benefit of allowing us to easily change the theme without having to update all of our components and is particularly useful for server-side rendering.

Theming

Most applications need to be able to change the theme of their design system. This is where the theme object comes in.

Taking our previous example, we can create a theme object that can be used to change the colors of our text. We'll start by creating a Theme component:

import { ThemeKey } from "@jsxui/system"
import { collectVariants, theme } from "system"
import { createGlobalStyle, ThemeProvider } from "styled-components"

const GlobalStyles = createGlobalStyle(collectVariants)

const ThemeContext = React.createContext<ThemeKey<typeof theme>>(null)

export function AppProvider({ theme }: { theme: ThemeKey<typeof theme> }) {
  return (
    <>
      <GlobalStyles />
      <Theme value={theme}>
        <App />
      </Theme>
    </>
  )
}

Using the AppProvider component we can provide the proper CSS properties that can be used by leaf components.

import { AppProvider } from "system"

export function App({ children }) {
  return <AppProvider>{children}</AppProvider>
}

To set a specific theme for a tree of components, use the ThemeProvider component:

import { AppProvider, ThemeProvider } from "system"

export function App() {
  return (
    <AppProvider>
      <Text>Hello World</Text>
      {/* Force theme to always be dark regardless of user preference */}
      <Theme variant="dark">
        <Text>Hello World</Text>
      </Theme>
    </AppProvider>
  )
}

States

Variants can affect more than just the CSS properties. For example, a variant can affect the element type of a component. In our variant above we specified that the body variant should be rendered as an p element. What if we wanted to nest our Text component though? This would render inaccessible markup as we're nesting two block-level elements in one another.

To fix this, we can use the states object to specify which states a prop value can define. This allows us to swap out the element while in a specific state:

import { createContext } from "react"
import type { AttributeProps, StyleProps } from "@jsxui/system"
import styled from "styled-components"
import { textVariant } from "system"

export type TextAttributeProps = AttributeProps<typeof textVariant>

export type TextStyleProps = StyleProps<typeof textVariant>

export const TextDescendantContext = createContext(false)

export const Text = styled<TextAttributeProps>((props) => {
  const isDescendant = React.useContext(TextDescendantContext)
  const { as: Element, ...props } = textVariant.getProps(props.variant, {
    descendant: isDescendant,
    ...props.states,
  }).attributes

  return (
    <TextDescendantContext.Provider value={true}>
      <Element {...props} />
    </TextDescendantContext.Provider>
  )
})<TextStyleProps>((props) => textVariant.getProps(props.variant, props.states).styles)

Now this will allow us to render a span when the Text component is a descendant of itself giving us proper semantic markup.

Overriding

Each variant can be overriden.

import { textVariant } from "system"

textVariant.override("heading1", {
  fontSize: { initial: "2rem", medium: "3rem", large: "4rem" },
})

This is helpful inside of a component system if we want to be able to override the defaults for a tree:

Guides

Button

import type { StyleProps } from "@jsxui/system"
import type { BoxSizeValue, ColorValue } from "system"
import { createProps, mergeVariants, theme } from "system"
import styled from "styled-components"

const colorTransform = (color: ColorValue) => theme.colors[color]

const buttonVariant = createProps({
  name: "variant",
  states: ["disabled", "pressed", "focused"],
  styles: {
    backgroundColor: colorTransform,
    color: colorTransform,
    borderColor: colorTransform,
    borderSize: (size: number) => size,
    opacity: (opacity: number) => opacity,
  },
  defaults: {
    opacity: { disabled: 0.65 },
    variant: "primary",
  },
  variants: {
    primary: {
      color: "foreground",
      backgroundColor: "interactiveBackgroundPrimary",
    },
    primaryOutline: {
      color: "interactiveForegroundPrimary",
      borderColor: "interactiveBorderPrimary",
      borderSize: 1,
    },
    secondary: {
      color: "foreground",
      backgroundColor: "interactiveBackgroundSecondary",
    },
    secondaryOutline: {
      color: "interactiveForegroundSecondary",
      borderColor: "interactiveBorderSecondary",
      borderSize: 1,
    },
  },
})

const sizeVariant = createProps({
  name: "size",
  styles: {
    fontSize: (size: string) => size,
    minHeight: (size: number) => theme.boxSizes[size],
    spaceAround: (size: number) => {
      const value = theme.boxSpacings[size]
      return {
        paddingLeft: value,
        paddingRight: value,
      }
    },
  },
  variants: {
    small: {
      fontSize: "16px",
      minHeight: "20px",
      spaceAround: "4px",
    },
    medium: {
      fontSize: "20px",
      minHeight: "24px",
      spaceAround: "8px",
    },
    large: {
      fontSize: "24px",
      minHeight: "32px",
      spaceAround: "16px",
    },
  },
})

const variants = mergeVariants(buttonVariant, sizeVariant)

export const Button = styled.button<StyleProps<typeof variants>>((props) =>
  variants.getProps(props.variant, { disabled: props.disabled })
)

// Example
function App() {
  return (
    <>
      <Button size={{ initial: "small", medium: "large" }} variant="secondary">
        Hello World
      </Button>
      <Button disabled>Disabled</Button>
    </>
  )
}

Image

import { createProps } from "system"

const imageVariant = createProps({
  styles: {
    width: (value: string) => value,
  },
})

const Image = styled(({ source, title }) => {
  return source.dark ? (
    <picture>
      <source media="(prefers-color-scheme:dark)" srcset={source.dark} />
      <img src={source.default} title={title} />
    </picture>
  ) : (
    <img src={source.default} title={title} />
  )
})({ display: "block" }, (props) => imageVariant.getProps(props.variant).styles)

// Example
function App() {
  return (
    <Image
      source={{
        initial: "light.jpg",
        dark: "dark.jpg",
      }}
    />
  )
}

Future Ideas

Composable Systems

Composable systems allow a whole system to be easily and deterministically overridden.

import { createSystem } from "@jsxui/system"

const system = createSystem({
  mediaQueries: {
    small: { minWidth: 640 },
    medium: { minWidth: 1024 },
    large: { minWidth: 1440 },
  },
  colors: {
    background: "#fff",
    foreground: "#000",
  },
})

const alternateSystem = createSystem(system, {
  colors: {
    background: "#000",
    foreground: "#fff",
  },
})

const textProps = alternateSystem.createProps({
  transforms: {
    color: (size: keyof typeof alternateSystem.theme.colors) => {
      return alternateSystem.theme.colors[size]
    },
  },
  defaults: {
    variant: "body",
  },
  variants: {
    body: {
      color: "foreground",
    },
  },
})

const Text = styled.span(textVariant.getProps)

function App() {
  return <Text>Hello World</Text> // color: #fff
}

Composable Props

Composable props allow prop definitions to be easily and deterministically overridden.

import { createProps } from "system"

const textProps = createProps({
  variants: {
    heading1: {
      fontSize: { initial: "2rem", medium: "3rem", large: "4rem" },
    },
  },
})

// Only override the font size at the largest breakpoint
const alternateTextProps = createProps(textProps, {
  variants: {
    heading1: {
      fontSize: { large: "6rem" },
    },
  },
})