Skip to content

Instantly share code, notes, and snippets.

@jednano
Last active June 25, 2019 18:44
Show Gist options
  • Save jednano/ff9620409b476bc2830097d6ff176dcc to your computer and use it in GitHub Desktop.
Save jednano/ff9620409b476bc2830097d6ff176dcc to your computer and use it in GitHub Desktop.
Example of an extensible and themeable TypeScript React component.
import React from 'react'
export interface LabeledProps {
label: string
/**
* @default 'input'
*/
children?: React.ReactNode
/**
* @default 'label'
*/
Root?: React.ElementType
}
const Labeled: React.FC<LabeledProps> = ({
children = 'input',
label,
Root = 'label',
}) => (
<Root>
{label}
{children}
</Root>
)
export default Labeled
import { styled } from 'linaria/react'
import { themed, themeVar } from 'helpers/theme'
import Labeled, { LabeledProps } from './Labeled'
const sizeModifiers = {
s: (x: number) => x * 0.8,
m: (x: number) => x,
l: (x: number) => x * 1.2,
}
export interface ThemeProps {
/**
* @default 'm'
*/
size?: keyof typeof sizeModifiers
}
export default themed(Labeled)<
ThemeProps,
Pick<LabeledProps, 'Root' | 'children'>
>(
{
size: 'm',
},
{
Root: styled.label`
font-size: ${props => sizeModifiers[props.size](1)}rem;
color: ${themeVar('foregroundColor')};
`,
children: styled.input`
border: 1px solid ${props => props.size};
`,
},
)
import React from 'react'
import ThemedLabeledInput from './ThemedLabeledInput'
const UseThemedLabeled: React.FC = () => (
<ThemedLabeledInput label="First name" />
)
export default UseThemedLabeled
import kebabCase from '@queso/kebab-case'
import {
createContext,
Dispatch,
FC,
SetStateAction,
useContext,
useLayoutEffect,
useState,
} from 'react'
import Theme from 'models/Theme'
import withProps from './withProps'
import darkTheme from 'themes/dark'
export function themed<
T extends FC<any>,
TOriginal = T extends FC<infer U> ? U : any
>(Component: T) {
return injectTheme
function injectTheme<
ThemeProps extends Record<string, any>,
TElements extends Record<string, any>
>(
themeProps: ThemeProps,
elements: Partial<
Record<keyof TElements, FC<TOriginal & Required<ThemeProps>>>
>,
) {
const Themed: FC<any> = props => (
<Component {...withElements(themeProps, elements)} {...props} />
)
return Themed as FC<TOriginal & ThemeProps>
}
}
function withElements<
TStyles extends Record<string, any>,
TElements extends Record<string, any>
>(styles: TStyles, elements: TElements = {} as TElements) {
return Object.keys(elements).reduce(
(result, key) => {
result[key] = withProps(elements[key])(styles)
return result
},
{} as any,
) as TStyles & TElements
}
export const { themeVar, useTheme, useThemeLayoutEffect } = createTheme<Theme>(
darkTheme,
)
export function createTheme<Theme>(defaultTheme: Theme) {
const ThemeContext = createContext<[Theme, Dispatch<SetStateAction<Theme>>]>(
[defaultTheme, () => {}],
)
const ThemeProvider: FC = props => {
const [theme, setTheme] = useState(defaultTheme)
return (
<ThemeContext.Provider value={[theme, setTheme]}>
{props.children}
</ThemeContext.Provider>
)
}
return {
ThemeContext,
ThemeProvider,
themeVar,
useTheme,
useThemeLayoutEffect,
}
function themeVar(key: keyof Theme) {
return `var(--${kebabCase(key as string)})`
}
function useThemeLayoutEffect(): [Theme, Dispatch<SetStateAction<Theme>>] {
const [theme, setTheme] = useTheme()
useLayoutEffect(() => {
for (const key in theme) {
document.documentElement.style.setProperty(
`--${kebabCase(key)}`,
(theme[key] as any) as string,
)
}
}, [theme])
return [theme, setTheme]
}
function useTheme() {
return useContext(ThemeContext)
}
}
export default interface Theme {
foregroundColor: string
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment