Skip to content

Commit 0a0dd65

Browse files
committed
feat(ui-avatar,emotion): add theming solution to functional components
1 parent 9c257f4 commit 0a0dd65

File tree

10 files changed

+571
-452
lines changed

10 files changed

+571
-452
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
---
2+
title: Themed components
3+
category: Contributor Guides
4+
order: 10
5+
---
6+
7+
## Making InstUI-like components with theming
8+
9+
InstUI uses [Emotion](https://emotion.sh/docs/introduction) under the hood to theme and style its components.
10+
If you want to read about the design behind the system and how to build `class-based` components with InstUI, please read [this](https://instructure.design/#emotion).
11+
12+
This page will show you how to build `functional` react components with InstUI.
13+
14+
### Anatomy of a functional InstUI component
15+
16+
To make similar and similarly maintainable components to InstUI, you should follow a basic structure. This is not strictly necessary but recommended and this guide will assume you do use it.
17+
18+
A fully equipped InstUI component has three files: `index.tsx`, `style.ts`, `theme.ts` and uses the `useStyles` hook.
19+
20+
Let's take a look at the simplest example possible:
21+
22+
```html
23+
---
24+
type: code
25+
---
26+
27+
// index.tsx // /** @jsx jsx */ import { jsx, useStyle } from
28+
'@instructure/emotion' import generateStyle from './styles' import
29+
generateComponentTheme from './theme' const InstUIComponent = (props: PropsType)
30+
=> { const styles = useStyle({ generateStyle, generateComponentTheme,
31+
componentId: "InstUIComponent_id", //any unique id params: { color: props.color,
32+
variant: props.variant, themeOverride: props.themeOverride } }) return (
33+
<div css="{" styles?.root }>content</div>
34+
) } export default InstUIComponent
35+
```
36+
37+
```html
38+
---
39+
type: code
40+
---
41+
42+
// style.ts const generateStyle = ( componentTheme: componentThemeType, params:
43+
ParamType): AvatarStyle => { const { color, variant } = params // assuming you
44+
passed the `color` and `variant` to the useStyle hook const variantStyles = {
45+
circle: { width: '2.5em', position: 'relative', borderRadius: '100%', overflow:
46+
'hidden' }, rectangle: { width: '3em' } } const colorVariants = { default:
47+
componentTheme.defaultColor, green: componentTheme.niceGreenColor,
48+
nonThemedColor: "pink" } return { instUIComponent: { //for the root element's
49+
style label: 'instUIComponent', color: colorVariants[color], backgroundColor:
50+
componentTheme.bgColor, ...variantStyles[variant], }, aChildElement: { label:
51+
'instUIComponent_aChildElement', // this label is needed. Please prefix it with
52+
the root label fontWeight: "400" //you can hardcode values. Don't need to get
53+
them from the theme necessarily . } } } export default generateStyle
54+
```
55+
56+
```html
57+
---
58+
type: code
59+
---
60+
61+
// theme.ts import type { Theme } from '@instructure/ui-themes' const
62+
generateComponentTheme = (theme: Theme) => { const { colors } = theme // the
63+
theme you are using. See instUI's theme docs as well const componentVariables =
64+
{ defaultColor: colors?.contrasts?.white1010, niceGreenColor:
65+
colors.contrasts.green4570, bgColor: "purple" //this is hardcoded, but added to
66+
the theme, so it can be overridden } return { ...componentVariables } } export
67+
default generateComponentTheme
68+
```
69+
70+
Let's take a look at the key parts of the examples:
71+
72+
The `useStyle` hook calculates the styles for the component. It needs an object with:
73+
74+
- `generateStyle` function, this function contains all the `css` information (`style.ts` file in the example).
75+
- `generateComponentTheme` is an optional param. This provides variables that act as the theme of the components. These can be derived from the global theme object or hardcoded. All can be overridden.
76+
- `componentId` depends on `generateComponentTheme`. It's mandatory if `generateComponentTheme` is provided. It must be a unique string to identify the component by and used for [component level overrides](https://instructure.design/#using-theme-overrides/#Overriding%20theme%20for%20a%20specific%20component%20in%20a%20subtree).
77+
- `params` is an optional object with any data you need to pass to `generateStyle`. To enable themeOverrides on the component, you must pass the `themeOverride` prop to `params`.
78+
79+
`useStyle` returns an object with the css classes. Pass it to the DOM through emotion's `css` prop (see example).
80+
81+
#### The `generateComponentTheme`
82+
83+
The `generateComponentTheme` defines, calculates and exposes variables that are considered `component theme variables`. These variables will be used in the `generateStyle` method to "theme" the component's style. These variables are overwritable by the [various override methods](https://instructure.design/#using-theme-overrides).
84+
`generateComponentTheme` gets the `theme` as parameter. Return an object (`componentVariables`) with keys that will act as the `component theme variables`. This method will be injected to `generateStyle`.
85+
86+
#### The `generateStyle`
87+
88+
You define the css in the `generateStyle` method. It has access to the themes, defined in the `generateComponentTheme` (in the example: `componentTheme`) and the `params` which are passed to the `useStyle` hook.
89+
Note: if you set the `label` to a unique value for every css class, it makes testing and debugging much easier because emotion appends to the end of the hashed class name it generates.

packages/emotion/src/EmotionTypes.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,11 @@ type GenerateStyle = (
116116
state?: State
117117
) => StyleObject
118118

119+
type GenerateStyleFunctional = (
120+
componentTheme: ComponentTheme,
121+
params: Record<string, unknown>
122+
) => StyleObject
123+
119124
type ComponentStyle<Keys extends string = string> = Record<
120125
Keys,
121126
StyleObject | string | number | undefined
@@ -139,6 +144,7 @@ export type {
139144
State,
140145
GenerateComponentTheme,
141146
GenerateStyle,
147+
GenerateStyleFunctional,
142148
ComponentStyle
143149
}
144150
/*
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
/*
2+
* The MIT License (MIT)
3+
*
4+
* Copyright (c) 2015 - present Instructure, Inc.
5+
*
6+
* Permission is hereby granted, free of charge, to any person obtaining a copy
7+
* of this software and associated documentation files (the "Software"), to deal
8+
* in the Software without restriction, including without limitation the rights
9+
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
10+
* copies of the Software, and to permit persons to whom the Software is
11+
* furnished to do so, subject to the following conditions:
12+
*
13+
* The above copyright notice and this permission notice shall be included in all
14+
* copies or substantial portions of the Software.
15+
*
16+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
17+
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
18+
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
19+
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
20+
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
21+
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
22+
* SOFTWARE.
23+
*/
24+
25+
/** @jsx jsx */
26+
import { useState } from 'react'
27+
28+
import { expect, mount, stub, within } from '@instructure/ui-test-utils'
29+
import { jsx, InstUISettingsProvider, WithStyleProps, useStyle } from '../index'
30+
31+
type Props = {
32+
inverse?: boolean
33+
} & WithStyleProps<ComponentTheme>
34+
35+
type Theme = {
36+
key: string
37+
colors: {
38+
contrasts: {
39+
grey1111: string
40+
green4570: string
41+
blue4570: string
42+
}
43+
}
44+
}
45+
46+
type ComponentTheme = {
47+
textColor: string
48+
textColorInverse: string
49+
backgroundColor: string
50+
}
51+
52+
describe('useStyle', async () => {
53+
const grey1111 = 'rgb(0, 128, 0)'
54+
const green4570 = 'rgb(10, 10, 10)'
55+
const blue4570 = 'rgb(255, 255, 0)'
56+
const exampleTheme: Theme = {
57+
key: 'exampleTheme',
58+
colors: {
59+
contrasts: {
60+
grey1111,
61+
green4570,
62+
blue4570
63+
}
64+
}
65+
}
66+
67+
const generateComponentTheme = function (theme: Theme): ComponentTheme {
68+
const { colors } = theme
69+
return {
70+
textColor: colors.contrasts.grey1111,
71+
textColorInverse: colors.contrasts.green4570,
72+
backgroundColor: colors.contrasts.blue4570
73+
}
74+
}
75+
76+
type StyleParams = {
77+
inverse: boolean
78+
clearBackground: boolean
79+
themeOverride: Props['themeOverride']
80+
}
81+
82+
const generateStyle = function (
83+
componentTheme: ComponentTheme,
84+
params: StyleParams
85+
) {
86+
const { inverse, clearBackground } = params
87+
88+
return {
89+
exampleComponent: {
90+
label: 'exampleComponent',
91+
color: componentTheme.textColor,
92+
background: componentTheme.backgroundColor,
93+
insetInlineStart: '8px',
94+
...(inverse && { color: componentTheme.textColorInverse }),
95+
...(clearBackground && { background: 'transparent' })
96+
}
97+
}
98+
}
99+
100+
const ThemedComponent = ({ inverse = false, themeOverride }: Props) => {
101+
const [clearBackground, setClearBackground] = useState(false)
102+
103+
const styles = useStyle({
104+
generateStyle,
105+
generateComponentTheme,
106+
componentId: 'ThemedComponent',
107+
params: { inverse, clearBackground, themeOverride }
108+
})
109+
110+
const handleClick = () => {
111+
setClearBackground(true)
112+
}
113+
114+
return (
115+
<div css={styles!.exampleComponent}>
116+
<p>Hello World</p>
117+
<button onClick={handleClick}>Button</button>
118+
</div>
119+
)
120+
}
121+
122+
describe('with theme provided by InstUISettingsProvider', async () => {
123+
it('should add css class suffixed with label', async () => {
124+
const subject = await mount(
125+
<InstUISettingsProvider theme={exampleTheme}>
126+
<ThemedComponent />
127+
</InstUISettingsProvider>
128+
)
129+
const emotionClassRegex = new RegExp(/^css-.+-exampleComponent$/)
130+
131+
expect(subject.getDOMNode().classList[0]).to.match(emotionClassRegex)
132+
})
133+
134+
it('should apply correct css props', async () => {
135+
const subject = await mount(
136+
<InstUISettingsProvider theme={exampleTheme}>
137+
<ThemedComponent />
138+
</InstUISettingsProvider>
139+
)
140+
const component = subject.getDOMNode()
141+
const computedStyle = getComputedStyle(component)
142+
143+
expect(computedStyle.color).to.equal('rgb(0, 128, 0)')
144+
expect(computedStyle.backgroundColor).to.equal('rgb(255, 255, 0)')
145+
})
146+
147+
describe('should allow configuration through the themeOverride prop', async () => {
148+
it('when it is an object', async () => {
149+
const subject = await mount(
150+
<InstUISettingsProvider theme={exampleTheme}>
151+
<ThemedComponent
152+
themeOverride={{
153+
textColor: 'rgb(128, 0, 128)'
154+
}}
155+
/>
156+
</InstUISettingsProvider>
157+
)
158+
const component = subject.getDOMNode()
159+
const computedStyle = getComputedStyle(component)
160+
161+
expect(computedStyle.color).to.equal('rgb(128, 0, 128)')
162+
expect(computedStyle.backgroundColor).to.equal('rgb(255, 255, 0)')
163+
})
164+
165+
it('when it is a function', async () => {
166+
const subject = await mount(
167+
<InstUISettingsProvider theme={exampleTheme}>
168+
<ThemedComponent
169+
themeOverride={(componentTheme) => ({
170+
textColor: componentTheme.backgroundColor
171+
})}
172+
/>
173+
</InstUISettingsProvider>
174+
)
175+
const component = subject.getDOMNode()
176+
const computedStyle = getComputedStyle(component)
177+
178+
expect(computedStyle.color).to.equal('rgb(255, 255, 0)')
179+
expect(computedStyle.backgroundColor).to.equal('rgb(255, 255, 0)')
180+
})
181+
})
182+
183+
it('should ignore empty themeOverride props', async () => {
184+
const subject = await mount(
185+
<InstUISettingsProvider theme={exampleTheme}>
186+
<ThemedComponent themeOverride={{}} />
187+
</InstUISettingsProvider>
188+
)
189+
const component = subject.getDOMNode()
190+
const computedStyle = getComputedStyle(component)
191+
192+
expect(computedStyle.color).to.equal('rgb(0, 128, 0)')
193+
expect(computedStyle.backgroundColor).to.equal('rgb(255, 255, 0)')
194+
})
195+
})
196+
197+
describe('should update css props', async () => {
198+
it('when props are updated', async () => {
199+
// `setProps` can be called on the outer component,
200+
// so have to add the theme ad themeOverride here, and suppress the error
201+
stub(console, 'warn') // suppress "no theme provided error"
202+
203+
const subject = await mount(
204+
<ThemedComponent
205+
inverse={false}
206+
themeOverride={{
207+
textColor: grey1111,
208+
textColorInverse: blue4570,
209+
backgroundColor: green4570
210+
}}
211+
/>
212+
)
213+
const component = subject.getDOMNode()
214+
215+
expect(getComputedStyle(component).color).to.equal(grey1111)
216+
217+
await subject.setProps({ inverse: true })
218+
219+
expect(getComputedStyle(component).color).to.equal(blue4570)
220+
})
221+
222+
it('when state is updated', async () => {
223+
const subject = await mount(
224+
<InstUISettingsProvider theme={exampleTheme}>
225+
<ThemedComponent />
226+
</InstUISettingsProvider>
227+
)
228+
const main = within(subject.getDOMNode())
229+
const clearBackgroundButton = await main.find('button')
230+
const component = main.getDOMNode()
231+
232+
expect(getComputedStyle(component).backgroundColor).to.equal(
233+
'rgb(255, 255, 0)'
234+
)
235+
236+
await clearBackgroundButton.click()
237+
238+
expect(getComputedStyle(component).backgroundColor).to.equal(
239+
'rgba(0, 0, 0, 0)'
240+
)
241+
})
242+
})
243+
244+
describe('should apply bi-directional polyfill on styles object', async () => {
245+
it('in default "ltr" mode', async () => {
246+
const subject = await mount(
247+
<InstUISettingsProvider theme={exampleTheme}>
248+
<ThemedComponent />
249+
</InstUISettingsProvider>
250+
)
251+
const component = subject.getDOMNode()
252+
const computedStyle = getComputedStyle(component)
253+
254+
// `inset-inline-start` should be transformed to 'left' in 'ltr' mode
255+
expect(computedStyle.left).to.equal('8px')
256+
expect(computedStyle.right).to.equal('auto')
257+
})
258+
259+
it('in "rtl" mode', async () => {
260+
const subject = await mount(
261+
<InstUISettingsProvider theme={exampleTheme} dir="rtl">
262+
<ThemedComponent />
263+
</InstUISettingsProvider>
264+
)
265+
const component = subject.getDOMNode().firstElementChild
266+
const computedStyle = getComputedStyle(component!)
267+
268+
// `inset-inline-start` should be transformed to 'right' in 'rtl' mode
269+
expect(computedStyle.left).to.equal('auto')
270+
expect(computedStyle.right).to.equal('8px')
271+
})
272+
})
273+
})

packages/emotion/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ export {
3737
mapSpacingToShorthand
3838
} from './styleUtils'
3939

40+
export { useStyle } from './useStyle'
41+
4042
export type { ComponentStyle, StyleObject, Overrides } from './EmotionTypes'
4143
export type { WithStyleProps } from './withStyle'
4244
export type {

0 commit comments

Comments
 (0)