Repository: marigold-ui/marigold
Pull request #1972 (2022-04-07 11:08:58)

feat: introduce `useComponentStyles` hook
Ref #1923

changeset-bot[bot]: ###  ⚠️  No Changeset found

Latest commit: 354fe83298fcb2a6ac93015ab4eb908d7b5be364

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. **If these changes should result in a version bump, you need to add a changeset.**

<details><summary>This PR includes no changesets</summary>

  When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

</details>

[Click here to learn what changesets are, and how to add one](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add a changeset to this PR](https://github.com/marigold-ui/marigold/new/theming-1.0?filename=.changeset/dull-eyes-walk.md&value=---%0A%22%40marigold%2Fsystem%22%3A%20patch%0A---%0A%0Afeat%3A%20introduce%20%60useComponentStyles%60%20hook%0A)



Patch:
diff --git a/.changeset/dull-eyes-walk.md b/.changeset/dull-eyes-walk.md
new file mode 100644
index 000000000..32b8d277b
--- /dev/null
+++ b/.changeset/dull-eyes-walk.md
@@ -0,0 +1,5 @@
+---
+"@marigold/system": patch
+---
+
+feat: introduce `useComponentStyles` hook
diff --git a/packages/system/package.json b/packages/system/package.json
index b7a4d1c06..f5128befb 100644
--- a/packages/system/package.json
+++ b/packages/system/package.json
@@ -29,7 +29,9 @@
     "@emotion/react": "11.8.2",
     "@marigold/types": "workspace:*",
     "@theme-ui/css": "0.14.2",
-    "csstype": "3.0.11"
+    "csstype": "3.0.11",
+    "deepmerge": "^4.2.2",
+    "react-fast-compare": "^3.2.0"
   },
   "peerDependencies": {
     "react": "^16.x || ^17.0.0",
diff --git a/packages/system/src/useComponentStyles.ts b/packages/system/src/useComponentStyles.ts
new file mode 100644
index 000000000..3b1987b73
--- /dev/null
+++ b/packages/system/src/useComponentStyles.ts
@@ -0,0 +1,131 @@
+import merge from 'deepmerge';
+import { useRef } from 'react';
+import isEqual from 'react-fast-compare';
+
+import { CSSObject } from './types';
+import { useTheme } from './useTheme';
+
+// Types
+// ---------------
+type IndexObject = { [key: string]: any };
+
+export type ComponentState =
+  | 'hover'
+  | 'focus'
+  | 'active'
+  | 'visited'
+  | 'disabled'
+  | 'readOnly'
+  | 'checked'
+  | 'indeterminate'
+  | 'error';
+
+// Helper
+// ---------------
+/**
+ * Safely get a dot-notated path within a nested object, with ability
+ * to return a default if the full key path does not exist or
+ * the value is undefined
+ *
+ * Based on: https://github.com/developit/dlv
+ */
+const get = (obj: object, path: string, fallback?: any): any => {
+  const key = path.split('.');
+
+  let result = obj;
+  for (let i = 0, length = key.length; i < length; i++) {
+    if (!result) break;
+    result = (result as any)[key[i]];
+  }
+
+  return result === undefined ? fallback : result;
+};
+
+/**
+ * Convert an object of states, where the key is the state name and
+ * the value is a boolean, to an array of strings.
+ */
+const statesToFlags = ({
+  disabled,
+  ...states
+}: { [key in ComponentState]?: boolean } = {}): ComponentState[] => {
+  let flags = Object.keys(states).filter(
+    key => states[key as keyof typeof states]
+  ) as ComponentState[];
+
+  /**
+   * Adding `disabled` at the end of the array so that it
+   * will be the most prominent state and override the others.
+   */
+  if (disabled) {
+    flags.push('disabled');
+  }
+
+  return flags;
+};
+
+// Hook
+// ---------------
+export interface ComponentStylesProps {
+  variant?: string;
+  size?: string;
+  states?: { [key in ComponentState]?: boolean };
+}
+
+export function useComponentStyles(
+  componentName: string,
+  props?: ComponentStylesProps,
+  options?: {
+    parts: never;
+  }
+): CSSObject;
+
+export function useComponentStyles<
+  Part extends string,
+  Parts extends ReadonlyArray<Part>
+>(
+  componentName: string,
+  props?: ComponentStylesProps,
+  options?: {
+    parts: Parts;
+  }
+): {
+  [P in Parts[number]]: CSSObject;
+};
+
+export function useComponentStyles(
+  componentName: string,
+  props: ComponentStylesProps = {},
+  options: any = {}
+) {
+  const { theme } = useTheme();
+  const componentStyles = get(theme, `components.${componentName}`);
+
+  // Store styles in ref to prevent re-computation
+  const stylesRef = useRef({});
+
+  if (componentStyles) {
+    const base = componentStyles.base || {};
+    const size = componentStyles.size?.[props.size as any] || {};
+    const variant = componentStyles.variant?.[props.variant as any] || {};
+    const states = statesToFlags(props.states).map(
+      state => componentStyles.state?.[state] || {}
+    );
+
+    // We deep merge so that parts (if they exists) also get put together
+    const styles = merge.all([base, size, ...states, variant]) as IndexObject;
+
+    // If a part does not exists in the theme, well add an empty object
+    if (options.parts) {
+      options.parts.forEach((part: string) => {
+        styles[part] = styles[part] ?? {};
+      });
+    }
+
+    if (!isEqual(stylesRef.current, styles)) {
+      stylesRef.current = styles;
+    }
+  }
+
+  return stylesRef.current;
+}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index d6062675e..770386016 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -335,12 +335,16 @@ importers:
       '@marigold/types': workspace:*
       '@theme-ui/css': 0.14.2
       csstype: 3.0.11
+      deepmerge: ^4.2.2
+      react-fast-compare: ^3.2.0
       tsup: 5.12.4
     dependencies:
       '@emotion/react': 11.8.2_react@17.0.2
       '@marigold/types': link:../types
       '@theme-ui/css': 0.14.2_@emotion+react@11.8.2
       csstype: 3.0.11
+      deepmerge: 4.2.2
+      react-fast-compare: 3.2.0
     devDependencies:
       '@marigold/tsconfig': link:../../config/tsconfig
       tsup: 5.12.4_typescript@4.6.3


Test patch:
diff --git a/packages/system/src/useComponentStyles.test.tsx b/packages/system/src/useComponentStyles.test.tsx
new file mode 100644
index 000000000..29b056fdd
--- /dev/null
+++ b/packages/system/src/useComponentStyles.test.tsx
@@ -0,0 +1,715 @@
+import React from 'react';
+import { render, screen } from '@testing-library/react';
+import { renderHook } from '@testing-library/react-hooks';
+import { ThemeProvider } from './useTheme';
+
+import { useComponentStyles } from './useComponentStyles';
+import { Box } from './Box';
+
+// Setup
+// ---------------
+const theme = {
+  /**
+   * Design tokens will not applied in most tests!
+   */
+  colors: {
+    primary: '#0070f3',
+    secondary: '#ff4081',
+    white: '#f8f9fa',
+    black: '#212529',
+    grey: '#adb5bd',
+    blue: '#228be6',
+    red: '#c92a2a',
+    pink: '#d6336c',
+  },
+  fontSizes: {
+    'small-1': '12px',
+    'small-2': '14px',
+    'medium-1': '16px',
+    'medium-2': '18px',
+    'large-1': '20px',
+  },
+  space: {
+    none: 0,
+    'small-1': 4,
+    'medium-1': 8,
+    'large-1': 16,
+  },
+  sizes: {
+    none: 0,
+    'small-1': 16,
+    'medium-1': 32,
+    'large-1': 48,
+  },
+  components: {
+    // Component without parts
+    Button: {
+      base: {
+        appearance: 'none',
+        bg: 'white',
+      },
+      size: {
+        small: {
+          height: 'small-1',
+        },
+        medium: {
+          height: 'medium-1',
+        },
+        large: {
+          height: 'large-1',
+        },
+      },
+      variant: {
+        primary: {
+          color: 'primary',
+        },
+        secondary: {
+          color: 'secondary',
+        },
+      },
+      state: {
+        hover: {
+          bg: 'blue',
+        },
+        error: {
+          bg: 'red',
+        },
+        focus: {
+          outline: '2px solid',
+          outlineColor: 'blue',
+        },
+        disabled: {
+          cursor: 'not-allowed',
+          bg: 'grey',
+        },
+      },
+    },
+    // Component with multiple parts
+    Checkbox: {
+      base: {
+        container: {
+          display: 'flex',
+          alignItems: 'center',
+          gap: 'small-1',
+        },
+        icon: {
+          height: 'small-1',
+          width: 'small-1',
+        },
+        label: {
+          color: 'black',
+          fontSize: 'small-2',
+        },
+      },
+      state: {
+        checked: {
+          icon: {
+            opacity: 1,
+            bg: 'blue',
+          },
+        },
+        unchecked: {
+          icon: {
+            opacity: 0,
+          },
+        },
+        error: {
+          container: {
+            border: '1px solid',
+            borderColor: 'red',
+          },
+          icon: {
+            fill: 'red',
+          },
+          label: {
+            color: 'red',
+          },
+        },
+      },
+      size: {
+        small: {
+          container: {
+            p: 'small-1',
+          },
+          icon: {
+            height: 'small-1',
+            width: 'small-1',
+          },
+        },
+        medium: {
+          container: {
+            p: 'medium-1',
+          },
+          icon: {
+            height: 'medium-1',
+            width: 'medium-1',
+          },
+          label: {
+            fontSize: 'medium-2',
+          },
+        },
+        large: {
+          container: {
+            p: 'large-1',
+          },
+          icon: {
+            height: 'large-1',
+            width: 'large-1',
+          },
+          label: {
+            fontSize: 'large-1',
+          },
+        },
+      },
+      variant: {
+        pink: {
+          label: {
+            color: 'pink',
+          },
+        },
+      },
+    },
+    GotNoBase: {},
+  },
+};
+
+const wrapper: React.FC = ({ children }) => (
+  <ThemeProvider theme={theme}>{children}</ThemeProvider>
+);
+
+// Tests
+// ---------------
+describe('smoketests', () => {
+  test('works without a theme', () => {
+    const { result } = renderHook(() => useComponentStyles('NotExisting'));
+    expect(result.current).toEqual({});
+  });
+
+  test('returns empty object if component does not exist in theme', () => {
+    const { result } = renderHook(() => useComponentStyles('NotExisting'), {
+      wrapper,
+    });
+    expect(result.current).toEqual({});
+  });
+});
+
+describe('useComponentStyles (simple)', () => {
+  test('get base styles for a component', () => {
+    const { result } = renderHook(() => useComponentStyles('Button'), {
+      wrapper,
+    });
+    expect(result.current).toMatchInlineSnapshot(`
+          {
+            "appearance": "none",
+            "bg": "white",
+          }
+      `);
+  });
+
+  test('returns empty object if component has no base styles', () => {
+    const { result } = renderHook(() => useComponentStyles('GotNoBase'), {
+      wrapper,
+    });
+    expect(result.current).toEqual({});
+  });
+
+  test('get variant styles for a component', () => {
+    let view = renderHook(
+      () => useComponentStyles('Button', { variant: 'primary' }),
+      {
+        wrapper,
+      }
+    );
+    expect(view.result.current).toMatchInlineSnapshot(`
+          {
+            "appearance": "none",
+            "bg": "white",
+            "color": "primary",
+          }
+      `);
+
+    view = renderHook(
+      () => useComponentStyles('Button', { variant: 'secondary' }),
+      {
+        wrapper,
+      }
+    );
+    expect(view.result.current).toMatchInlineSnapshot(`
+          {
+            "appearance": "none",
+            "bg": "white",
+            "color": "secondary",
+          }
+      `);
+  });
+
+  test('works if variant does not exist', () => {
+    const { result } = renderHook(
+      () => useComponentStyles('Button', { variant: 'non-existing-variant' }),
+      {
+        wrapper,
+      }
+    );
+    expect(result.current).toMatchInlineSnapshot(`
+          {
+            "appearance": "none",
+            "bg": "white",
+          }
+      `);
+  });
+
+  test('base styles are applied together with variant', () => {
+    const { result } = renderHook(
+      () => useComponentStyles('Button', { variant: 'primary' }),
+      {
+        wrapper,
+      }
+    );
+    expect(result.current).toMatchObject({
+      appearance: 'none',
+      bg: 'white',
+    });
+  });
+
+  test('get size styles for a component', () => {
+    let view = renderHook(
+      () => useComponentStyles('Button', { size: 'small' }),
+      {
+        wrapper,
+      }
+    );
+    expect(view.result.current).toMatchInlineSnapshot(`
+      {
+        "appearance": "none",
+        "bg": "white",
+        "height": "small-1",
+      }
+    `);
+
+    view = renderHook(() => useComponentStyles('Button', { size: 'medium' }), {
+      wrapper,
+    });
+    expect(view.result.current).toMatchInlineSnapshot(`
+      {
+        "appearance": "none",
+        "bg": "white",
+        "height": "medium-1",
+      }
+    `);
+  });
+
+  test('works if size does not exist', () => {
+    const { result } = renderHook(
+      () => useComponentStyles('Button', { size: 'non-existing-size' }),
+      {
+        wrapper,
+      }
+    );
+    expect(result.current).toMatchInlineSnapshot(`
+      {
+        "appearance": "none",
+        "bg": "white",
+      }
+    `);
+  });
+
+  test('base styles are applied together with size', () => {
+    const { result } = renderHook(
+      () => useComponentStyles('Button', { size: 'small' }),
+      {
+        wrapper,
+      }
+    );
+    expect(result.current).toMatchObject({
+      appearance: 'none',
+      bg: 'white',
+    });
+  });
+
+  test('get state styles for a component', () => {
+    let view = renderHook(
+      () => useComponentStyles('Button', { states: { hover: true } }),
+      {
+        wrapper,
+      }
+    );
+    expect(view.result.current).toMatchInlineSnapshot(`
+      {
+        "appearance": "none",
+        "bg": "blue",
+      }
+    `);
+
+    view = renderHook(
+      () => useComponentStyles('Button', { states: { error: true } }),
+      {
+        wrapper,
+      }
+    );
+    expect(view.result.current).toMatchInlineSnapshot(`
+      {
+        "appearance": "none",
+        "bg": "red",
+      }
+    `);
+  });
+
+  test('get multiple states (disabled overrides other states)', () => {
+    let view = renderHook(
+      () =>
+        useComponentStyles('Button', {
+          states: { hover: true, focus: true },
+        }),
+      {
+        wrapper,
+      }
+    );
+    expect(view.result.current).toMatchInlineSnapshot(`
+      {
+        "appearance": "none",
+        "bg": "blue",
+        "outline": "2px solid",
+        "outlineColor": "blue",
+      }
+    `);
+
+    view = renderHook(
+      () =>
+        useComponentStyles('Button', {
+          states: { hover: true, disabled: true },
+        }),
+      {
+        wrapper,
+      }
+    );
+    expect(view.result.current).toMatchInlineSnapshot(`
+      {
+        "appearance": "none",
+        "bg": "grey",
+        "cursor": "not-allowed",
+      }
+    `);
+  });
+
+  test('works if state does not exist', () => {
+    const { result } = renderHook(
+      () => useComponentStyles('Button', { states: { visited: true } }),
+      {
+        wrapper,
+      }
+    );
+    expect(result.current).toMatchInlineSnapshot(`
+      {
+        "appearance": "none",
+        "bg": "white",
+      }
+    `);
+  });
+
+  test('only applies set styles', () => {
+    const { result } = renderHook(
+      () =>
+        useComponentStyles('Button', {
+          states: { hover: true, disabled: false },
+        }),
+      {
+        wrapper,
+      }
+    );
+    expect(result.current).toMatchInlineSnapshot(`
+      {
+        "appearance": "none",
+        "bg": "blue",
+      }
+    `);
+  });
+});
+
+describe('useComponentStyles (complex)', () => {
+  test('get base styles for a component (with parts)', () => {
+    const { result } = renderHook(
+      () =>
+        useComponentStyles(
+          'Checkbox',
+          {},
+          { parts: ['container', 'icon', 'label'] }
+        ),
+      {
+        wrapper,
+      }
+    );
+    expect(result.current).toMatchInlineSnapshot(`
+      {
+        "container": {
+          "alignItems": "center",
+          "display": "flex",
+          "gap": "small-1",
+        },
+        "icon": {
+          "height": "small-1",
+          "width": "small-1",
+        },
+        "label": {
+          "color": "black",
+          "fontSize": "small-2",
+        },
+      }
+    `);
+  });
+
+  test('get variant styles for a component (with parts)', () => {
+    const { result } = renderHook(
+      () =>
+        useComponentStyles(
+          'Checkbox',
+          { variant: 'pink' },
+          { parts: ['container', 'icon', 'label'] }
+        ),
+      {
+        wrapper,
+      }
+    );
+    expect(result.current).toMatchInlineSnapshot(`
+      {
+        "container": {
+          "alignItems": "center",
+          "display": "flex",
+          "gap": "small-1",
+        },
+        "icon": {
+          "height": "small-1",
+          "width": "small-1",
+        },
+        "label": {
+          "color": "pink",
+          "fontSize": "small-2",
+        },
+      }
+    `);
+  });
+
+  test('get size styles for a component (with parts)', () => {
+    const { result } = renderHook(
+      () =>
+        useComponentStyles(
+          'Checkbox',
+          { size: 'small' },
+          { parts: ['container', 'icon', 'label'] }
+        ),
+      {
+        wrapper,
+      }
+    );
+    expect(result.current).toMatchInlineSnapshot(`
+      {
+        "container": {
+          "alignItems": "center",
+          "display": "flex",
+          "gap": "small-1",
+          "p": "small-1",
+        },
+        "icon": {
+          "height": "small-1",
+          "width": "small-1",
+        },
+        "label": {
+          "color": "black",
+          "fontSize": "small-2",
+        },
+      }
+    `);
+  });
+
+  test('get state styles for a component (with parts)', () => {
+    const { result } = renderHook(
+      () =>
+        useComponentStyles(
+          'Checkbox',
+          { states: { checked: true } },
+          { parts: ['container', 'icon', 'label'] }
+        ),
+      {
+        wrapper,
+      }
+    );
+    expect(result.current).toMatchInlineSnapshot(`
+      {
+        "container": {
+          "alignItems": "center",
+          "display": "flex",
+          "gap": "small-1",
+        },
+        "icon": {
+          "bg": "blue",
+          "height": "small-1",
+          "opacity": 1,
+          "width": "small-1",
+        },
+        "label": {
+          "color": "black",
+          "fontSize": "small-2",
+        },
+      }
+    `);
+  });
+
+  test('returns empty objects if part does not exist', () => {
+    const { result } = renderHook(
+      () =>
+        useComponentStyles(
+          'Checkbox',
+          {},
+          { parts: ['container', 'non-existing-part'] }
+        ),
+      {
+        wrapper,
+      }
+    );
+    expect(result.current['non-existing-part']).toMatchInlineSnapshot(`{}`);
+  });
+});
+
+describe('style superiority', () => {
+  test('override order: base < size < state < variant', () => {
+    const { result } = renderHook(
+      () =>
+        useComponentStyles('Button', {
+          size: 'small',
+          variant: 'pink',
+          states: { disabled: true },
+        }),
+      {
+        wrapper,
+      }
+    );
+    expect(result.current).toMatchInlineSnapshot(`
+      {
+        "appearance": "none",
+        "bg": "grey",
+        "cursor": "not-allowed",
+        "height": "small-1",
+      }
+    `);
+  });
+
+  test('override order: base < size < state < variant (with parts)', () => {
+    const { result } = renderHook(
+      () =>
+        useComponentStyles(
+          'Checkbox',
+          {
+            size: 'small',
+            variant: 'pink',
+            states: { error: true },
+          },
+          { parts: ['container', 'icon', 'label'] }
+        ),
+      {
+        wrapper,
+      }
+    );
+    expect(result.current).toMatchInlineSnapshot(`
+      {
+        "container": {
+          "alignItems": "center",
+          "border": "1px solid",
+          "borderColor": "red",
+          "display": "flex",
+          "gap": "small-1",
+          "p": "small-1",
+        },
+        "icon": {
+          "fill": "red",
+          "height": "small-1",
+          "width": "small-1",
+        },
+        "label": {
+          "color": "pink",
+          "fontSize": "small-2",
+        },
+      }
+    `);
+  });
+});
+
+describe('style usage', () => {
+  test('styles are not transpiled with tokens', () => {
+    const { result } = renderHook(
+      () =>
+        useComponentStyles('Button', {
+          size: 'small',
+          variant: 'pink',
+          states: { disabled: true },
+        }),
+      {
+        wrapper,
+      }
+    );
+    expect(result.current).toMatchInlineSnapshot(`
+    {
+      "appearance": "none",
+      "bg": "grey",
+      "cursor": "not-allowed",
+      "height": "small-1",
+    }
+  `);
+  });
+
+  test('usage with <Box>', () => {
+    const Button: React.FC = ({ children }) => {
+      const styles = useComponentStyles('Button');
+      return (
+        <Box __baseCSS={styles} data-testid="button">
+          {children}
+        </Box>
+      );
+    };
+
+    render(
+      <ThemeProvider theme={theme}>
+        <Button>Click me!</Button>
+      </ThemeProvider>
+    );
+
+    const element = screen.getByTestId('button');
+    expect(element).toHaveStyle('appearance: none');
+    expect(element).toHaveStyle(`background: ${theme.colors.white}`);
+  });
+
+  test('usage with <Box> (with parts)', () => {
+    const Checkbox: React.FC = ({ children }) => {
+      const styles = useComponentStyles(
+        'Checkbox',
+        {},
+        { parts: ['container', 'icon', 'label'] }
+      );
+      return (
+        <Box data-testid="container" __baseCSS={styles.container}>
+          <Box data-testid="label" as="label" __baseCSS={styles.label}>
+            {children}
+            <Box data-testid="icon" __baseCSS={styles.icon} />
+          </Box>
+        </Box>
+      );
+    };
+
+    render(
+      <ThemeProvider theme={theme}>
+        <Checkbox>Click me!</Checkbox>
+      </ThemeProvider>
+    );
+
+    const container = screen.getByTestId('container');
+    expect(container).toHaveStyle('display: flex');
+    expect(container).toHaveStyle('align-items: center');
+    expect(container).toHaveStyle(`gap: ${theme.space['small-1']}px`);
+
+    const label = screen.getByTestId('label');
+    expect(label).toHaveStyle(`color: ${theme.colors.black}`);
+    expect(label).toHaveStyle(`font-size: ${theme.fontSizes['small-2']}`);
+
+    const icon = screen.getByTestId('icon');
+    expect(icon).toHaveStyle(`height: ${theme.sizes['small-1']}px`);
+    expect(icon).toHaveStyle(`width: ${theme.sizes['small-1']}px`);
+  });
+});