├── jest.setup.js ├── .gitignore ├── test ├── fixtures │ ├── tsconfig.json │ ├── theme.treat.ts │ ├── types │ │ └── index.d.ts │ ├── theme.ts │ └── App.tsx ├── utils │ ├── getClassNames.ts │ ├── getStyles.ts │ └── startFixture.ts ├── resolveResponsiveProp.test.ts ├── createMq.test.ts └── Box.test.ts ├── jest-puppeteer.config.js ├── src ├── variants.ts ├── calicoTheme.d.ts ├── types.ts ├── index.ts ├── useBoxStyles.treat.ts ├── createCalicoTheme.ts ├── useBoxStyles.ts ├── Box.tsx ├── createMq.ts ├── utils.ts └── rules.ts ├── jest.preset.js ├── jest.config.js ├── tsconfig.json ├── .github └── workflows │ └── test.yml ├── LICENSE ├── package.json ├── CHANGELOG.md └── README.md /jest.setup.js: -------------------------------------------------------------------------------- 1 | jest.setTimeout(30000) 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .vscode 5 | shell.nix 6 | -------------------------------------------------------------------------------- /test/fixtures/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../tsconfig.json", 3 | "include": ["./"] 4 | } 5 | -------------------------------------------------------------------------------- /jest-puppeteer.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | launch: { 3 | headless: true, 4 | devtools: false, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /src/variants.ts: -------------------------------------------------------------------------------- 1 | export const variants = { 2 | color: { 3 | hover: true, 4 | focus: true, 5 | }, 6 | } as const 7 | -------------------------------------------------------------------------------- /src/calicoTheme.d.ts: -------------------------------------------------------------------------------- 1 | import { CalicoTheme } from './createCalicoTheme' 2 | 3 | declare module 'treat/theme' { 4 | export interface Theme extends CalicoTheme {} 5 | } 6 | -------------------------------------------------------------------------------- /test/fixtures/theme.treat.ts: -------------------------------------------------------------------------------- 1 | import { createTheme } from 'treat' 2 | 3 | import { theme as calicoTheme } from './theme' 4 | 5 | export const theme = createTheme(calicoTheme) 6 | -------------------------------------------------------------------------------- /test/fixtures/types/index.d.ts: -------------------------------------------------------------------------------- 1 | import { Theme as CalicoTheme } from '../theme' 2 | 3 | declare module 'treat/theme' { 4 | export interface Theme extends CalicoTheme {} 5 | } 6 | -------------------------------------------------------------------------------- /jest.preset.js: -------------------------------------------------------------------------------- 1 | const tsJest = require('ts-jest/jest-preset') 2 | const jestPuppeteer = require('jest-puppeteer/jest-preset') 3 | 4 | module.exports = Object.assign(tsJest, jestPuppeteer) 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: './jest.preset.js', 3 | setupFilesAfterEnv: ['./jest.setup.js'], 4 | transformIgnorePatterns: ['/node_modules/'], 5 | testPathIgnorePatterns: ['/node_modules/'], 6 | } 7 | -------------------------------------------------------------------------------- /test/utils/getClassNames.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'puppeteer' 2 | 3 | export const getClassNames = async (page: Page, selector: string) => 4 | await page.evaluate( 5 | ({ selector }) => { 6 | const el = document.querySelector(selector) 7 | if (!el) return undefined 8 | 9 | return el.className 10 | }, 11 | { selector }, 12 | ) 13 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export type ResponsiveProp = 4 | | AtomName 5 | | Readonly<[AtomName | null, AtomName]> 6 | | Readonly<[AtomName | null, AtomName | null, AtomName]> 7 | | Readonly<[AtomName | null, AtomName | null, AtomName | null, AtomName]> 8 | 9 | export type SafeReactHTMLAttributes = React.AllHTMLAttributes<'div'> & { 10 | loading?: string 11 | } 12 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { Box } from './Box' 2 | export type { BoxProps } from './Box' 3 | 4 | export { useBoxStyles, usePseudoBoxStyles } from './useBoxStyles' 5 | export type { 6 | BoxStylesProps as BaseBoxStylesProps, 7 | BoxHoverProps as BoxHoverProp, 8 | BoxFocusProps as BoxFocusProp, 9 | } from './useBoxStyles' 10 | 11 | export { createCalicoTheme, baseCalicoTheme } from './createCalicoTheme' 12 | export { createMq } from './createMq' 13 | export { normalizeResponsiveProp, resolveResponsiveProp } from './utils' 14 | 15 | export type { CalicoTheme } from './createCalicoTheme' 16 | 17 | export * from './types' 18 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types"], 3 | "compilerOptions": { 4 | "module": "esnext", 5 | "target": "ES6", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "sourceMap": true, 9 | "rootDir": "./src", 10 | "strict": true, 11 | "noUnusedLocals": true, 12 | "noUnusedParameters": true, 13 | "noImplicitReturns": true, 14 | "noFallthroughCasesInSwitch": true, 15 | "moduleResolution": "node", 16 | "baseUrl": "./", 17 | "paths": { 18 | "*": ["src/*", "node_modules/*"] 19 | }, 20 | "jsx": "react", 21 | "esModuleInterop": true, 22 | "resolveJsonModule": true 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | 7 | steps: 8 | - name: Begin CI... 9 | uses: actions/checkout@v2 10 | 11 | - name: Use Node 12 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12.x 15 | 16 | - name: Use cached node_modules 17 | uses: actions/cache@v1 18 | with: 19 | path: node_modules 20 | key: nodeModules-${{ hashFiles('**/yarn.lock') }} 21 | restore-keys: | 22 | nodeModules- 23 | - name: Install dependencies 24 | run: yarn install --frozen-lockfile 25 | env: 26 | CI: true 27 | 28 | - name: Test 29 | run: yarn test --ci --coverage --maxWorkers=2 30 | env: 31 | CI: true 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright © Wall-to-Wall Studios, Inc. 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 9 | of the Software, and to permit persons to whom the Software is furnished to do 10 | so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /test/utils/getStyles.ts: -------------------------------------------------------------------------------- 1 | import { Page } from 'puppeteer' 2 | 3 | export const getStyles = async ( 4 | page: Page, 5 | selector: string, 6 | pseudoState?: string, 7 | ) => { 8 | const client = await page.target().createCDPSession() 9 | 10 | await client.send('DOM.enable') 11 | await client.send('CSS.enable') 12 | 13 | const doc = (await client.send('DOM.getDocument')) as any 14 | const { nodeId } = (await client.send('DOM.querySelector', { 15 | nodeId: doc.root.nodeId, 16 | selector, 17 | })) as any 18 | 19 | if (pseudoState) { 20 | await client.send('CSS.forcePseudoState', { 21 | nodeId, 22 | forcedPseudoClasses: [pseudoState], 23 | }) 24 | } 25 | 26 | const styleForSingleNode = (await client.send('CSS.getMatchedStylesForNode', { 27 | nodeId, 28 | })) as any 29 | 30 | return styleForSingleNode.matchedCSSRules.reduce((prev: any, curr: any) => { 31 | const styles = Object.assign( 32 | //@ts-ignore 33 | ...curr.rule.style.cssProperties.map((rule: any) => ({ 34 | [rule.name]: rule.value, 35 | })), 36 | ) 37 | 38 | return { ...prev, ...styles } 39 | }, {}) 40 | } 41 | -------------------------------------------------------------------------------- /test/resolveResponsiveProp.test.ts: -------------------------------------------------------------------------------- 1 | import { resolveResponsiveProp } from '../src/utils' 2 | 3 | const atoms = { 4 | mobile: { 5 | string: 'mobileString', 6 | 1: 'mobileNumber', 7 | }, 8 | tablet: { 9 | string: 'tabletString', 10 | 1: 'tabletNumber', 11 | }, 12 | desktop: { 13 | string: 'desktopString', 14 | 1: 'desktopNumber', 15 | }, 16 | desktopWide: { 17 | string: 'desktopWideString', 18 | 1: 'desktopWideNumber', 19 | }, 20 | } 21 | 22 | test('correctly resolves unresponsive values', () => { 23 | expect(resolveResponsiveProp('string', atoms)).toBe(atoms.mobile.string) 24 | expect(resolveResponsiveProp(1, atoms)).toBe(atoms.mobile[1]) 25 | }) 26 | 27 | test('correctly resolves responsive values', () => { 28 | expect(resolveResponsiveProp([1, 'string'], atoms)).toBe( 29 | `${atoms.mobile[1]} ${atoms.tablet.string}`, 30 | ) 31 | 32 | expect(resolveResponsiveProp([1, 1, 1, 1], atoms)).toBe( 33 | `${atoms.mobile[1]} ${atoms.tablet[1]} ${atoms.desktop[1]} ${atoms.desktopWide[1]}`, 34 | ) 35 | }) 36 | 37 | test('handles null values', () => { 38 | expect(resolveResponsiveProp([null, 1], atoms)).toBe(atoms.tablet[1]) 39 | 40 | expect( 41 | resolveResponsiveProp([null, 1, 'string', 1], atoms), 42 | ).toBe(`${atoms.tablet[1]} ${atoms.desktop.string} ${atoms.desktopWide[1]}`) 43 | }) 44 | -------------------------------------------------------------------------------- /test/createMq.test.ts: -------------------------------------------------------------------------------- 1 | import { createMq } from '../src' 2 | 3 | const breakpoints = ['48rem', '60rem', '72rem'] 4 | const mq = createMq(breakpoints) 5 | 6 | test('allows for use of responsive arrays in treat files', () => { 7 | const styleObj = mq({ 8 | color: 'red', 9 | backgroundColor: ['green', 'red', 'blue'], 10 | 11 | ':hover': { 12 | textAlign: ['right', 'center'], 13 | }, 14 | }) 15 | 16 | expect(styleObj).toEqual({ 17 | color: 'red', 18 | backgroundColor: 'green', 19 | 20 | ':hover': { textAlign: 'right' }, 21 | '@media': { 22 | [`(min-width: ${breakpoints[0]})`]: { 23 | backgroundColor: 'red', 24 | ':hover': { textAlign: 'center' }, 25 | }, 26 | [`(min-width: ${breakpoints[1]})`]: { 27 | backgroundColor: 'blue', 28 | }, 29 | }, 30 | }) 31 | }) 32 | 33 | test('does not generate styles for null', () => { 34 | const styleObj = mq({ 35 | color: 'red', 36 | backgroundColor: ['green', null, 'blue'], 37 | 38 | ':hover': { 39 | textAlign: [null, 'center'], 40 | }, 41 | }) 42 | 43 | expect(styleObj).toEqual({ 44 | color: 'red', 45 | backgroundColor: 'green', 46 | 47 | '@media': { 48 | [`(min-width: ${breakpoints[0]})`]: { 49 | ':hover': { textAlign: 'center' }, 50 | }, 51 | [`(min-width: ${breakpoints[1]})`]: { 52 | backgroundColor: 'blue', 53 | }, 54 | }, 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /test/fixtures/theme.ts: -------------------------------------------------------------------------------- 1 | import { baseCalicoTheme, createCalicoTheme } from '../../src' 2 | 3 | export type Theme = typeof theme 4 | 5 | const space = { 6 | auto: 'auto', 7 | [-2]: '-0.5rem', 8 | [-1.5]: '-0.5rem', 9 | [-1]: '-0.25rem', 10 | [-0.5]: '-0.125rem', 11 | 0: 0, 12 | 0.5: '0.125rem', 13 | 1: '0.25rem', 14 | 1.5: '0.375rem', 15 | 2: '0.5rem', 16 | } 17 | 18 | const colors = { 19 | white: '#fff', 20 | black: '#000', 21 | } 22 | 23 | export const theme = createCalicoTheme({ 24 | // Sizes 25 | breakpoints: { 26 | mobile: '0rem', 27 | tablet: '48rem', 28 | desktop: '75rem', 29 | desktopWide: '90rem', 30 | }, 31 | 32 | rules: { 33 | color: colors, 34 | borderColor: colors, 35 | backgroundColor: colors, 36 | 37 | margin: space, 38 | marginTop: space, 39 | marginBottom: space, 40 | marginLeft: space, 41 | marginRight: space, 42 | 43 | padding: space, 44 | paddingTop: space, 45 | paddingBottom: space, 46 | paddingLeft: space, 47 | paddingRight: space, 48 | 49 | gap: space, 50 | 51 | fontFamily: { 52 | sans: 'system-ui', 53 | }, 54 | 55 | maxWidth: { 56 | small: '48rem', 57 | medium: '60rem', 58 | large: '75rem', 59 | xlarge: '90rem', 60 | }, 61 | lineHeight: { 62 | solid: 1, 63 | }, 64 | letterSpacing: { 65 | ...baseCalicoTheme.rules.letterSpacing, 66 | s: '0.05em', 67 | m: '0.1em', 68 | l: '0.2em', 69 | }, 70 | transitionDuration: { 71 | slow: '300ms', 72 | normal: '200ms', 73 | fast: '100ms', 74 | }, 75 | }, 76 | }) 77 | -------------------------------------------------------------------------------- /src/useBoxStyles.treat.ts: -------------------------------------------------------------------------------- 1 | import { styleTree, styleMap } from 'treat' 2 | import * as R from 'fp-ts/Record' 3 | import { pipe } from 'fp-ts/pipeable' 4 | 5 | import { styleSingleton, mapToBreakpoints, mapToPseudo } from './utils' 6 | 7 | export const styles = styleTree((theme) => 8 | pipe( 9 | theme.rules as Required, 10 | R.mapWithIndex((propertyName, atoms) => 11 | pipe( 12 | atoms as Record, 13 | R.map(styleSingleton(propertyName)), 14 | mapToBreakpoints(theme), 15 | R.map((atoms) => styleMap(atoms, propertyName)), 16 | ), 17 | ), 18 | ), 19 | ) 20 | 21 | export const hover = styleTree((theme) => 22 | pipe( 23 | theme.rules as Required, 24 | R.filterWithIndex((ruleName) => Boolean(theme.variants[ruleName]?.hover)), 25 | R.mapWithIndex((propertyName: keyof typeof theme.variants, atoms) => 26 | pipe( 27 | atoms as Record, 28 | R.map(styleSingleton(propertyName)), 29 | R.map(mapToPseudo(':hover')), 30 | mapToBreakpoints(theme), 31 | R.map((atoms) => styleMap(atoms, propertyName)), 32 | ), 33 | ), 34 | ), 35 | ) 36 | 37 | export const focus = styleTree((theme) => 38 | pipe( 39 | theme.rules as Required, 40 | R.filterWithIndex((ruleName) => Boolean(theme.variants[ruleName]?.focus)), 41 | R.mapWithIndex((propertyName: keyof typeof theme.variants, atoms) => 42 | pipe( 43 | atoms as Record, 44 | R.map(styleSingleton(propertyName)), 45 | R.map(mapToPseudo(':focus')), 46 | mapToBreakpoints(theme), 47 | R.map((atoms) => styleMap(atoms, propertyName)), 48 | ), 49 | ), 50 | ), 51 | ) 52 | -------------------------------------------------------------------------------- /src/createCalicoTheme.ts: -------------------------------------------------------------------------------- 1 | import { Style } from 'treat' 2 | import { StandardProperties } from 'csstype' 3 | import { map } from 'fp-ts/Record' 4 | import { pipe } from 'fp-ts/pipeable' 5 | 6 | import { rules } from './rules' 7 | import { variants } from './variants' 8 | import { createMq, MqStyles } from './createMq' 9 | 10 | type BreakpointKeys = 'mobile' | 'tablet' | 'desktop' | 'desktopWide' 11 | 12 | export interface CreateCalicoThemeInput { 13 | breakpoints: Record 14 | 15 | mq?: (mqStyles: MqStyles) => Style 16 | 17 | rules?: { 18 | [P in keyof StandardProperties]?: Record< 19 | string | number, 20 | NonNullable[P]> 21 | > 22 | } 23 | variants?: { 24 | [P in keyof StandardProperties]?: Partial> 25 | } 26 | } 27 | 28 | export const baseCalicoTheme = { 29 | rules, 30 | variants, 31 | } as const 32 | 33 | export type CalicoTheme = ReturnType 34 | 35 | /** 36 | * Creates a `treat` compatible theme object that merges with the default calico rules. 37 | * 38 | * @param theme Your theme object. 39 | * @returns The merged theme object. 40 | */ 41 | export const createCalicoTheme = ( 42 | theme: T, 43 | ) => { 44 | const mediaQueries = pipe( 45 | theme.breakpoints, 46 | map((value) => `screen and (min-width: ${value})`), 47 | ) 48 | 49 | const x = { 50 | mediaQueries, 51 | mq: createMq(Object.values(theme.breakpoints).slice(1)), 52 | ...baseCalicoTheme, 53 | ...theme, 54 | 55 | rules: { 56 | ...baseCalicoTheme.rules, 57 | ...theme.rules, 58 | }, 59 | 60 | variants: { 61 | ...baseCalicoTheme.variants, 62 | ...theme.variants, 63 | }, 64 | } as const 65 | 66 | return x 67 | } 68 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@walltowall/calico", 3 | "version": "0.5.0", 4 | "description": "React components for using atmoically generated styles via props.", 5 | "license": "MIT", 6 | "main": "src/index.ts", 7 | "sideEffects": false, 8 | "files": [ 9 | "src" 10 | ], 11 | "scripts": { 12 | "format": "prettier --write '{src,docs}/**/*.{ts,tsx,md}'", 13 | "test": "jest", 14 | "release": "standard-version" 15 | }, 16 | "homepage": "https://github.com/WalltoWall/calico", 17 | "repository": { 18 | "type": "git", 19 | "url": "https://github.com/WalltoWall/calico.git" 20 | }, 21 | "keywords": [ 22 | "react", 23 | "components", 24 | "static", 25 | "atomic", 26 | "css", 27 | "treat" 28 | ], 29 | "dependencies": { 30 | "clsx": "^1.1.1", 31 | "fp-ts": "^2.8.6", 32 | "react-polymorphic-box": "^3.0.2" 33 | }, 34 | "devDependencies": { 35 | "@babel/core": "^7.12.3", 36 | "@babel/preset-env": "^7.12.1", 37 | "@babel/preset-react": "^7.12.5", 38 | "@babel/preset-typescript": "^7.12.1", 39 | "@commitlint/cli": "^11.0.0", 40 | "@commitlint/config-conventional": "^11.0.0", 41 | "@types/expect-puppeteer": "^4.4.5", 42 | "@types/express": "^4.17.9", 43 | "@types/jest": "^26.0.15", 44 | "@types/jest-environment-puppeteer": "^4.4.0", 45 | "@types/memory-fs": "^0.3.2", 46 | "@types/mime-types": "^2.1.0", 47 | "@types/puppeteer": "^5.4.0", 48 | "@types/react": "^16.9.56", 49 | "@types/react-dom": "^16.9.9", 50 | "@types/webpack": "^4.41.25", 51 | "@types/webpack-merge": "^4.1.5", 52 | "babel-loader": "^8.2.1", 53 | "babel-plugin-treat": "^1.6.2", 54 | "csstype": "^3.0.5", 55 | "express": "^4.17.1", 56 | "html-webpack-plugin": "^4.5.0", 57 | "husky": "^4.3.0", 58 | "jest": "^26.6.3", 59 | "jest-puppeteer": "^4.4.0", 60 | "memory-fs": "^0.5.0", 61 | "mime-types": "^2.1.27", 62 | "prettier": "^2.1.2", 63 | "puppeteer": "^5.5.0", 64 | "react": "^17.0.1", 65 | "react-dom": "^17.0.1", 66 | "react-treat": "^1.6.1", 67 | "standard-version": "^9.0.0", 68 | "treat": "^1.6.1", 69 | "ts-jest": "^26.4.4", 70 | "tslib": "^2.0.3", 71 | "typescript": "^4.0.5", 72 | "webpack": "^4.44.2", 73 | "webpack-merge": "^5.4.0" 74 | }, 75 | "peerDependencies": { 76 | "react": ">=16.8", 77 | "react-treat": ">=1.4.0", 78 | "treat": ">=1.4.0" 79 | }, 80 | "prettier": { 81 | "semi": false, 82 | "singleQuote": true, 83 | "trailingComma": "all", 84 | "proseWrap": "always", 85 | "printWidth": 80 86 | }, 87 | "husky": { 88 | "hooks": { 89 | "commit-msg": "commitlint -E HUSKY_GIT_PARAMS" 90 | } 91 | }, 92 | "commitlint": { 93 | "extends": [ 94 | "@commitlint/config-conventional" 95 | ] 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/useBoxStyles.ts: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx' 2 | import { Theme } from 'treat/theme' 3 | import { useStyles } from 'react-treat' 4 | 5 | import { resolveResponsiveProp } from './utils' 6 | import { ResponsiveProp } from './types' 7 | 8 | import * as styleRefs from './useBoxStyles.treat' 9 | 10 | type NotUndefOrNever = Pick< 11 | T, 12 | { [K in keyof T]: T[K] extends undefined | never ? never : K }[keyof T] 13 | > 14 | 15 | export type BoxStylesProps = { 16 | [K in keyof Theme['rules']]?: ResponsiveProp 17 | } 18 | 19 | export type BoxHoverProps = NotUndefOrNever< 20 | { 21 | [K in keyof Theme['variants']]?: NonNullable< 22 | Theme['variants'][K] 23 | >['hover'] extends true 24 | ? ResponsiveProp 25 | : never 26 | } 27 | > 28 | 29 | export type BoxFocusProps = NotUndefOrNever< 30 | { 31 | [K in keyof Theme['variants']]?: NonNullable< 32 | Theme['variants'][K] 33 | >['focus'] extends true 34 | ? ResponsiveProp 35 | : never 36 | } 37 | > 38 | 39 | const resolveClassNames = (props: BoxStylesProps | undefined, styles: any) => { 40 | if (props === undefined) return 41 | 42 | let resolvedClassNames: (string | undefined)[] = [] 43 | 44 | for (const key in props) { 45 | const value = props[key as keyof Theme['rules']] 46 | if (value === null || value === undefined) continue 47 | 48 | resolvedClassNames.push( 49 | resolveResponsiveProp(value, styles[key as keyof Theme['rules']]), 50 | ) 51 | } 52 | 53 | return resolvedClassNames 54 | } 55 | 56 | /** 57 | * A React hook for mapping atomic styles to `className`s. This is the low 58 | * level hook that `` is built on. 59 | * 60 | * @param styles - The object of styles to map to `className`s 61 | * @returns A string containing the resolved `className`s. 62 | */ 63 | export function useBoxStyles(styles: BoxStylesProps | undefined): string { 64 | const boxStyles = useStyles(styleRefs) 65 | 66 | return clsx(resolveClassNames(styles, boxStyles.styles)) 67 | } 68 | 69 | /** 70 | * A React hook for mapping pseudo atomic styles to `className`s. This is the low 71 | * level hook that `` is built on. 72 | * 73 | * @remarks 74 | * Psuedo refers to pseudo-modifiers such as `:hover` and `:focus`. 75 | * 76 | * @param styles The object of styles to map to `className`s 77 | * @param psuedo The pseudo modifier to use. 78 | * @returns A string containing the resolved `className`s. 79 | */ 80 | export function usePseudoBoxStyles( 81 | styles: BoxFocusProps | undefined, 82 | pseudo: 'focus', 83 | ): string 84 | export function usePseudoBoxStyles( 85 | styles: BoxHoverProps | undefined, 86 | pseudo: 'hover', 87 | ): string 88 | export function usePseudoBoxStyles( 89 | styles: BoxHoverProps | BoxFocusProps | undefined, 90 | pseudo: 'focus' | 'hover', 91 | ): string { 92 | const boxStyles = useStyles(styleRefs) 93 | 94 | return clsx(resolveClassNames(styles, boxStyles[pseudo])) 95 | } 96 | -------------------------------------------------------------------------------- /test/utils/startFixture.ts: -------------------------------------------------------------------------------- 1 | import path from 'path' 2 | import { AddressInfo } from 'net' 3 | import MemoryFS from 'memory-fs' 4 | import webpack, { Configuration } from 'webpack' 5 | import mergeWebpackConfigs from 'webpack-merge' 6 | import mimeTypes from 'mime-types' 7 | import express from 'express' 8 | import HtmlWebpackPlugin from 'html-webpack-plugin' 9 | //@ts-ignore 10 | import TreatPlugin from 'treat/webpack-plugin' 11 | 12 | const defaultConfig: Configuration = { 13 | mode: 'production', 14 | output: { path: '/' }, 15 | resolve: { 16 | extensions: ['.js', '.json', '.tsx', '.ts'], 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.(js|ts|tsx)$/, 22 | include: [ 23 | path.resolve(__dirname, '../../src'), 24 | path.resolve(__dirname, '../../test'), 25 | /node_modules\/fp-ts/, 26 | ], 27 | use: [ 28 | { 29 | loader: 'babel-loader', 30 | options: { 31 | babelrc: false, 32 | presets: [ 33 | ['@babel/preset-env', { modules: false }], 34 | '@babel/preset-react', 35 | '@babel/preset-typescript', 36 | ], 37 | plugins: ['babel-plugin-treat'], 38 | }, 39 | }, 40 | ], 41 | }, 42 | ], 43 | }, 44 | plugins: [new HtmlWebpackPlugin(), new TreatPlugin()], 45 | } 46 | 47 | const buildFiles = (config: Configuration): Promise => 48 | new Promise((resolve, reject) => { 49 | const bundler = webpack(mergeWebpackConfigs(defaultConfig, config)) 50 | const outputFileSystem = new MemoryFS() 51 | bundler.outputFileSystem = outputFileSystem 52 | 53 | bundler.run((error, stats) => { 54 | if (error) reject(error) 55 | if (stats.hasErrors()) 56 | reject(new Error(stats.toString({ errorDetails: true }))) 57 | 58 | resolve(outputFileSystem) 59 | }) 60 | }) 61 | 62 | export interface FixtureServer { 63 | url: string 64 | close: () => void 65 | } 66 | 67 | const startServer = (fs: MemoryFS): Promise => 68 | new Promise((resolve) => { 69 | const app = express() 70 | 71 | app.get('*', (req, res) => { 72 | let filePath = req.path 73 | 74 | if (filePath === '/') filePath = '/index.html' 75 | 76 | try { 77 | const file = fs.readFileSync(filePath).toString() 78 | 79 | res.set('Content-Type', mimeTypes.lookup(filePath) as string) 80 | res.send(file) 81 | } catch (err) { 82 | console.error(err) 83 | 84 | res.status(404) 85 | } 86 | }) 87 | 88 | const server = app.listen(() => { 89 | resolve({ 90 | url: `http://localhost:${(server.address() as AddressInfo).port}`, 91 | close: () => { 92 | server.close() 93 | }, 94 | }) 95 | }) 96 | }) 97 | 98 | export const startFixture = async (config: Configuration) => { 99 | const fs = await buildFiles(config) 100 | 101 | return await startServer(fs) 102 | } 103 | -------------------------------------------------------------------------------- /src/Box.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { 3 | Box as PolymorphicBox, 4 | PolymorphicComponentProps, 5 | } from 'react-polymorphic-box' 6 | import clsx from 'clsx' 7 | 8 | import { SafeReactHTMLAttributes } from './types' 9 | import { 10 | useBoxStyles, 11 | usePseudoBoxStyles, 12 | BoxStylesProps, 13 | BoxHoverProps, 14 | BoxFocusProps, 15 | } from './useBoxStyles' 16 | 17 | const defaultElement = 'div' 18 | 19 | /** 20 | * A `` accepts all standard HTML props in addition to 21 | * some additional props for styling. 22 | */ 23 | type CalicoBoxProps = { 24 | // TODO: Remove in 1.0 release. 25 | /** 26 | * The HTML element to render the `Box` as. 27 | * 28 | * @deprecated Use the `as` prop instead. 29 | */ 30 | component?: React.ElementType 31 | 32 | /** The atomic styles to apply to this element. */ 33 | styles?: BoxStylesProps 34 | 35 | /** The atomic hover styles to apply to this element. */ 36 | hoverStyles?: BoxHoverProps 37 | 38 | /** The atomic hover styles to apply to this element. */ 39 | focusStyles?: BoxFocusProps 40 | } & Omit 41 | 42 | export type BoxProps< 43 | E extends React.ElementType = typeof defaultElement 44 | > = PolymorphicComponentProps 45 | 46 | // TODO: Remove in 1.0 release. 47 | let didWarnAboutComponentPropMigration = false 48 | 49 | /** 50 | * The basic building block of `calico`. By default, it renders a `
` element, 51 | * but this can be overridden via the `as` prop. 52 | * 53 | * @param props 54 | * 55 | * @example 56 | * const Example = () => 57 | */ 58 | export const Box = React.forwardRef( 59 | ( 60 | { 61 | styles, 62 | hoverStyles, 63 | focusStyles, 64 | className, 65 | component, 66 | ...restProps 67 | }: BoxProps, 68 | innerRef: typeof restProps.ref, 69 | ) => { 70 | const resolvedClassNames = 71 | clsx( 72 | useBoxStyles(styles), 73 | usePseudoBoxStyles(focusStyles, 'focus'), 74 | usePseudoBoxStyles(hoverStyles, 'hover'), 75 | className, 76 | ) || undefined 77 | 78 | // TODO: Remove in 1.0 release. 79 | if ( 80 | process.env.NODE_ENV !== 'production' && 81 | component && 82 | !didWarnAboutComponentPropMigration 83 | ) { 84 | console.warn( 85 | 'A Calico component was found using the `component` prop. The `component` prop is deprecated and has been replaced by the `as` prop and will be removed in v1.0. You should be able to rename `component` to `as` without any other changes.', 86 | ) 87 | didWarnAboutComponentPropMigration = true 88 | } 89 | 90 | return ( 91 | 97 | ) 98 | }, 99 | ) as (( 100 | props: BoxProps, 101 | ) => JSX.Element) & { displayName: string } 102 | 103 | Box.displayName = 'Box' 104 | -------------------------------------------------------------------------------- /test/fixtures/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import { TreatProvider } from 'react-treat' 4 | 5 | import { Box, BoxProps } from '../../src/Box' 6 | import { theme } from './theme.treat' 7 | 8 | const CompWithDefaultProps = (props: BoxProps) => ( 9 | 10 | ) 11 | 12 | const App = () => ( 13 | <> 14 | 23 | 32 | 38 | 54 | 65 | 73 | 86 | 94 | 101 | 108 | 120 | 126 | 132 | 138 | 139 | 140 | 141 | ) 142 | 143 | ReactDOM.render( 144 | 145 | , 146 | , 147 | document.body.appendChild(document.createElement('div')), 148 | ) 149 | -------------------------------------------------------------------------------- /src/createMq.ts: -------------------------------------------------------------------------------- 1 | import { SimplePseudos } from 'csstype' 2 | import { Style } from 'treat' 3 | 4 | type PlainStyle = string | number | null 5 | type StyleArray = PlainStyle[] | readonly PlainStyle[] 6 | 7 | type CSSProperty = Exclude 8 | type PsuedoCssProperty = Exclude< 9 | CSSProperty, 10 | 'selectors' | '@keyframes' | SimplePseudos 11 | > 12 | 13 | type PsuedoStyle = Partial> 14 | 15 | export type MqStyles = Partial< 16 | Record< 17 | CSSProperty, 18 | | PlainStyle 19 | | PsuedoStyle 20 | | StyleArray 21 | | Style['selectors'] 22 | | Style['@keyframes'] 23 | | null 24 | > 25 | > 26 | 27 | /** 28 | * Helper to determine if the provided value is a plain, non-responsive style. 29 | * @private 30 | * 31 | * @param val The value to check. 32 | * @returns A boolean indicating if the value is a plain style. 33 | */ 34 | const isPlainStyle = (val: unknown) => { 35 | return !Array.isArray(val) && typeof val !== 'object' 36 | } 37 | 38 | /** 39 | * Helper to determine if the provided value is a psuedo-style. 40 | * @private 41 | * 42 | * @param val The value to check. 43 | * @returns A boolean indicating if the value is a psuedo-style. 44 | */ 45 | const isPsuedoStyle = (val: unknown) => { 46 | return !Array.isArray(val) && typeof val === 'object' 47 | } 48 | 49 | /** 50 | * Higher order function to create a function that can be used to 51 | * create atomic style classNames with responsive arrays. 52 | * 53 | * @param breakpoints The breakpoint values to utilize for the 54 | * responsive array. Must be an array of valid CSS units. 55 | * @returns A function like the `style` function from `treat` but with 56 | * support for responsive arrays. 57 | */ 58 | export const createMq = (breakpoints: string[]) => { 59 | const mq = (mqStyles: MqStyles) => { 60 | const styles = Object.entries(mqStyles) 61 | 62 | let newObj = { '@media': {} } as any 63 | let mediaObj = newObj['@media'] 64 | 65 | for (const [cssKey, style] of styles) { 66 | if (cssKey === 'selectors') { 67 | newObj.selectors = style as Style['selectors'] 68 | continue 69 | } 70 | 71 | if (cssKey === '@keyframes') { 72 | newObj['@keyframes'] = style as Style['@keyframes'] 73 | continue 74 | } 75 | 76 | if (isPlainStyle(style)) { 77 | newObj[cssKey] = style as PlainStyle 78 | continue 79 | } 80 | 81 | if (isPsuedoStyle(style)) { 82 | const psuedoStyles = Object.entries(style as PsuedoStyle) 83 | 84 | for (const [psuedoCssKey, psuedoValue] of psuedoStyles) { 85 | if (isPlainStyle(psuedoValue)) { 86 | newObj[cssKey] = newObj[cssKey] ?? {} 87 | newObj[cssKey][psuedoCssKey] = psuedoValue as PlainStyle 88 | continue 89 | } 90 | 91 | for (const [idx, bpStyle] of (psuedoValue as StyleArray).entries()) { 92 | if (!bpStyle) continue 93 | if (idx === 0) { 94 | newObj[cssKey] = newObj[cssKey] ?? {} 95 | newObj[cssKey][psuedoCssKey] = bpStyle 96 | continue 97 | } 98 | 99 | const mediaQuery = `(min-width: ${breakpoints[idx - 1]})` 100 | mediaObj[mediaQuery] = mediaObj[mediaQuery] ?? {} 101 | mediaObj[mediaQuery][cssKey] = mediaObj[mediaQuery][cssKey] ?? {} 102 | mediaObj[mediaQuery][cssKey][psuedoCssKey] = bpStyle 103 | } 104 | } 105 | continue 106 | } 107 | 108 | for (const [idx, bpStyle] of (style as StyleArray).entries()) { 109 | if (!bpStyle) continue 110 | if (idx === 0) { 111 | newObj[cssKey] = bpStyle 112 | continue 113 | } 114 | 115 | const mediaQuery = `(min-width: ${breakpoints[idx - 1]})` 116 | mediaObj[mediaQuery] = mediaObj[mediaQuery] ?? {} 117 | mediaObj[mediaQuery][cssKey] = bpStyle 118 | } 119 | } 120 | 121 | return newObj as Style 122 | } 123 | 124 | return mq 125 | } 126 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import { Style } from 'treat' 2 | import clsx from 'clsx' 3 | import { Theme } from 'treat/theme' 4 | import { Properties, SimplePseudos } from 'csstype' 5 | import * as B from 'fp-ts/boolean' 6 | import * as R from 'fp-ts/Record' 7 | import { pipe } from 'fp-ts/function' 8 | import { eqNumber } from 'fp-ts/Eq' 9 | 10 | import { ResponsiveProp } from './types' 11 | 12 | /** 13 | * Creates a Style with a single property. 14 | * 15 | * @param propertyName CSS property to which `value` will be assigned. 16 | * 17 | * @returns Treat-compatible Style to pass to `style`. 18 | */ 19 | export const styleSingleton = < 20 | TPropertyName extends keyof Properties, 21 | TValue extends string | number 22 | >( 23 | propertyName: TPropertyName, 24 | ) => (value: TValue): Style => R.singleton(propertyName, value) 25 | 26 | /** 27 | * Assigns a Style to a particular breakpoint. 28 | * 29 | * @param breakpoint Breakpoint name to which styles will be assigned. 30 | * @param theme Theme with `breakpoints` tokens. 31 | * 32 | * @returns Style assigned to the breakpoint. 33 | */ 34 | export const makeResponsive = ( 35 | breakpoint: keyof Theme['breakpoints'], 36 | theme: Theme, 37 | ) => (style: Style) => { 38 | const minWidth = Number.parseFloat(theme.breakpoints[breakpoint]) 39 | const mediaQuery = theme.mediaQueries[breakpoint] 40 | 41 | return pipe( 42 | eqNumber.equals(minWidth, 0), 43 | B.fold