From f0b61f35343691e0c34ee764a32d6f39f778776d Mon Sep 17 00:00:00 2001 From: Simon Jentsch Date: Thu, 19 Dec 2024 16:50:21 +0100 Subject: [PATCH] feat: Use own styled system "lib" again --- src/Box/Box.tsx | 8 +- src/Box/color.ts | 24 --- src/Box/interpolations/color.spec.ts | 49 ++++++ src/Box/interpolations/color.ts | 31 ++++ src/Box/interpolations/layout.spec.ts | 115 +++++++++++++ src/Box/interpolations/layout.ts | 55 +++++++ src/Box/interpolations/spacing.spec.ts | 104 ++++++++++++ src/Box/interpolations/spacing.ts | 86 ++++++++++ src/Box/spacingInterpolation.ts | 98 ----------- src/Box/system.spec.ts | 140 ++++++++++++++++ src/Box/system.ts | 215 +++++++++++++++++++++++++ src/breakpoints/mediaQueryFns.ts | 14 +- src/theme/breakpoints.ts | 44 +++-- src/utils/enforceArray.ts | 4 +- src/utils/isNumber.ts | 5 + 15 files changed, 829 insertions(+), 163 deletions(-) delete mode 100644 src/Box/color.ts create mode 100644 src/Box/interpolations/color.spec.ts create mode 100644 src/Box/interpolations/color.ts create mode 100644 src/Box/interpolations/layout.spec.ts create mode 100644 src/Box/interpolations/layout.ts create mode 100644 src/Box/interpolations/spacing.spec.ts create mode 100644 src/Box/interpolations/spacing.ts delete mode 100644 src/Box/spacingInterpolation.ts create mode 100644 src/Box/system.spec.ts create mode 100644 src/Box/system.ts create mode 100644 src/utils/isNumber.ts diff --git a/src/Box/Box.tsx b/src/Box/Box.tsx index 6f938fc..a43c0ba 100644 --- a/src/Box/Box.tsx +++ b/src/Box/Box.tsx @@ -1,18 +1,18 @@ import styled from '@emotion/styled'; -import { layout } from '@styled-system/layout'; import { flexbox } from '@styled-system/flexbox'; import { position } from '@styled-system/position'; -import type { LayoutProps, FlexboxProps, PositionProps } from 'styled-system'; +import { layout, type FlexboxProps, type PositionProps } from 'styled-system'; import { system } from '@styled-system/core'; -import { color, ColorProps } from './color'; +import { color, ColorProps } from './interpolations/color'; import { CssFunctionReturn } from '../types'; import { baseStyle } from '../shared/baseStyle'; import { getByPath } from '../utils/getByPath'; import { themeVars } from '../theme/themeVars'; import { interpolateCssProp } from '../utils/interpolateCssProp'; -import { margin, MarginProps, PaddingProps, padding } from './spacingInterpolation'; +import { margin, MarginProps, PaddingProps, padding } from './interpolations/spacing'; import { ifProp } from '../styleHelpers/styleProp'; +import { LayoutProps } from './interpolations/layout'; export interface BoxCssProps { css?: CssFunctionReturn; diff --git a/src/Box/color.ts b/src/Box/color.ts deleted file mode 100644 index 3527f45..0000000 --- a/src/Box/color.ts +++ /dev/null @@ -1,24 +0,0 @@ -// Only types - -import type { TextColorProps, BackgroundColorProps } from 'styled-system'; -import { system } from '@styled-system/core'; -import { themeVars } from '../theme/themeVars'; -import { getByPath } from '../utils/getByPath'; - -export interface ColorProps { - bgColor?: BackgroundColorProps['backgroundColor']; - textColor?: TextColorProps['color']; - opacity?: number; -} - -export const color = system({ - textColor: { - property: 'color', - transform: (value: string) => getByPath(themeVars.colors, value) || value, - }, - bgColor: { - property: 'backgroundColor', - transform: (value: string) => getByPath(themeVars.colors, value) || value, - }, - opacity: true, -}); diff --git a/src/Box/interpolations/color.spec.ts b/src/Box/interpolations/color.spec.ts new file mode 100644 index 0000000..6d366d7 --- /dev/null +++ b/src/Box/interpolations/color.spec.ts @@ -0,0 +1,49 @@ +import { defaultTheme } from '../../theme'; +import { PabloThemeableProps } from '../../theme/types'; +import { color } from './color'; + +let props: PabloThemeableProps = { + theme: defaultTheme, +} as any; + +beforeEach(() => { + props = { + theme: defaultTheme, + } as any; +}); + +test('bg color', () => { + expect(color({ ...props, bgColor: 'brand.main' })).toEqual({ + backgroundColor: 'var(--pbl-theme-colors-brand-main)', + }); +}); + +test('text color', () => { + expect(color({ ...props, textColor: 'brand.main' })).toEqual({ + color: 'var(--pbl-theme-colors-brand-main)', + }); +}); + +test('opacity', () => { + expect(color({ ...props, opacity: 0.5 })).toEqual({ + opacity: 0.5, + }); + expect(color({ ...props, opacity: '0.4' })).toEqual({ + opacity: '0.4', + }); +}); + +test('styled interpolation functions', () => { + expect(color.bgColor('brand.main')(props)).toEqual({ + backgroundColor: 'var(--pbl-theme-colors-brand-main)', + }); + expect(color.textColor('brand.main')(props)).toEqual({ + color: 'var(--pbl-theme-colors-brand-main)', + }); + expect(color.opacity(0.5)(props)).toEqual({ + opacity: 0.5, + }); + expect(color.opacity('0.4')(props)).toEqual({ + opacity: '0.4', + }); +}); diff --git a/src/Box/interpolations/color.ts b/src/Box/interpolations/color.ts new file mode 100644 index 0000000..54601c6 --- /dev/null +++ b/src/Box/interpolations/color.ts @@ -0,0 +1,31 @@ +// Only types + +import type { TextColorProps, BackgroundColorProps } from 'styled-system'; +import { themeVars } from '../../theme/themeVars'; +import { getByPath } from '../../utils/getByPath'; +import { InterpolationTransformFn, system } from '../system'; + +export interface ColorProps { + bgColor?: BackgroundColorProps['backgroundColor']; + textColor?: TextColorProps['color']; + opacity?: number; +} + +const colorTransform: InterpolationTransformFn = (value) => + getByPath(themeVars.colors, value) || value; + +export const color = system([ + { + properties: ['color'], + fromProps: ['textColor'], + transform: colorTransform, + }, + { + properties: ['backgroundColor'], + fromProps: ['bgColor'], + transform: colorTransform, + }, + { + properties: ['opacity'], + }, +]); diff --git a/src/Box/interpolations/layout.spec.ts b/src/Box/interpolations/layout.spec.ts new file mode 100644 index 0000000..b8fe7fe --- /dev/null +++ b/src/Box/interpolations/layout.spec.ts @@ -0,0 +1,115 @@ +import { defaultTheme } from '../../theme'; +import { PabloThemeableProps } from '../../theme/types'; +import { layout } from './layout'; + +let props: PabloThemeableProps = { + theme: defaultTheme, +} as any; + +beforeEach(() => { + props = { + theme: defaultTheme, + } as any; +}); + +test('width', () => { + expect(layout({ ...props, width: 100 })).toEqual({ + width: '100px', + }); + expect(layout({ ...props, width: '100%' })).toEqual({ + width: '100%', + }); + expect(layout({ ...props, width: 0.9 })).toEqual({ + width: '90%', + }); +}); + +test('height', () => { + expect(layout({ ...props, height: 100 })).toEqual({ + height: '100px', + }); + expect(layout({ ...props, height: '100%' })).toEqual({ + height: '100%', + }); +}); + +test('minWidth', () => { + expect(layout({ ...props, minWidth: 100 })).toEqual({ + minWidth: '100px', + }); + expect(layout({ ...props, minWidth: '100%' })).toEqual({ + minWidth: '100%', + }); +}); + +test('minHeight', () => { + expect(layout({ ...props, minHeight: 100 })).toEqual({ + minHeight: '100px', + }); + expect(layout({ ...props, minHeight: '100%' })).toEqual({ + minHeight: '100%', + }); +}); + +test('maxWidth', () => { + expect(layout({ ...props, maxWidth: 100 })).toEqual({ + maxWidth: '100px', + }); + expect(layout({ ...props, maxWidth: '100%' })).toEqual({ + maxWidth: '100%', + }); +}); + +test('maxHeight', () => { + expect(layout({ ...props, maxHeight: 100 })).toEqual({ + maxHeight: '100px', + }); + expect(layout({ ...props, maxHeight: '100%' })).toEqual({ + maxHeight: '100%', + }); +}); + +test('squareSize', () => { + expect(layout({ ...props, squareSize: 100 })).toEqual({ + width: '100px', + height: '100px', + }); + expect(layout({ ...props, squareSize: '100%' })).toEqual({ + width: '100%', + height: '100%', + }); +}); + +test('display', () => { + expect(layout({ ...props, display: 'flex' })).toEqual({ + display: 'flex', + }); +}); + +test('styled interpolation functions', () => { + expect(layout.width(100)(props)).toEqual({ + width: '100px', + }); + expect(layout.height(100)(props)).toEqual({ + height: '100px', + }); + expect(layout.minWidth(100)(props)).toEqual({ + minWidth: '100px', + }); + expect(layout.minHeight(100)(props)).toEqual({ + minHeight: '100px', + }); + expect(layout.maxWidth(100)(props)).toEqual({ + maxWidth: '100px', + }); + expect(layout.maxHeight(100)(props)).toEqual({ + maxHeight: '100px', + }); + expect(layout.squareSize(100)(props)).toEqual({ + width: '100px', + height: '100px', + }); + expect(layout.display('flex')(props)).toEqual({ + display: 'flex', + }); +}); diff --git a/src/Box/interpolations/layout.ts b/src/Box/interpolations/layout.ts new file mode 100644 index 0000000..fb57dc3 --- /dev/null +++ b/src/Box/interpolations/layout.ts @@ -0,0 +1,55 @@ +import type * as CSS from 'csstype'; +import { pixelTransform, ResponsiveValue, system } from '../system'; +import { PabloTheme } from '../../theme/types'; +import { isNumber } from '../../utils/isNumber'; + +interface LayoutProps { + width?: ResponsiveValue; + height?: ResponsiveValue; + minWidth?: ResponsiveValue; + minHeight?: ResponsiveValue; + maxWidth?: ResponsiveValue; + maxHeight?: ResponsiveValue; + squareSize?: ResponsiveValue; + display?: ResponsiveValue; +} + +const widthTransform = (value: number | string, theme: PabloTheme) => + isNumber(value) && value <= 1 ? `${value * 100}%` : pixelTransform(value, theme); + +const layout = system([ + { + properties: ['width'], + transform: widthTransform, + }, + { + properties: ['height'], + transform: pixelTransform, + }, + { + properties: ['minWidth'], + transform: pixelTransform, + }, + { + properties: ['minHeight'], + transform: pixelTransform, + }, + { + properties: ['maxWidth'], + transform: pixelTransform, + }, + { + properties: ['maxHeight'], + transform: pixelTransform, + }, + { + properties: ['width', 'height'], + fromProps: ['squareSize'], + transform: pixelTransform, + }, + { + properties: ['display'], + }, +]); + +export { layout, widthTransform, type LayoutProps }; diff --git a/src/Box/interpolations/spacing.spec.ts b/src/Box/interpolations/spacing.spec.ts new file mode 100644 index 0000000..4b00961 --- /dev/null +++ b/src/Box/interpolations/spacing.spec.ts @@ -0,0 +1,104 @@ +import { defaultTheme } from '../../theme'; +import { PabloThemeableProps } from '../../theme/types'; +import { margin, padding } from './spacing'; + +let props: PabloThemeableProps = { + theme: defaultTheme, +} as any; + +beforeEach(() => { + props = { + theme: defaultTheme, + } as any; +}); + +test('Margin system', () => { + expect(margin({ m: 10, ...props })).toEqual({ + margin: '80px', + }); +}); + +test('Margin system single props', () => { + expect(margin({ mt: 1, mr: 2, mb: 3, ml: 4, ...props })).toEqual({ + marginTop: '8px', + marginRight: '16px', + marginBottom: '24px', + marginLeft: '32px', + }); +}); + +test('Margin system with x and y props', () => { + expect(margin({ mx: 10, my: 20, ...props })).toEqual({ + marginLeft: '80px', + marginRight: '80px', + marginTop: '160px', + marginBottom: '160px', + }); +}); + +test('Padding system', () => { + expect(padding({ p: 10, ...props })).toEqual({ + padding: '80px', + }); +}); + +test('Padding system single props', () => { + expect(padding({ pt: 1, pr: 2, pb: 3, pl: 4, ...props })).toEqual({ + paddingTop: '8px', + paddingRight: '16px', + paddingBottom: '24px', + paddingLeft: '32px', + }); +}); + +test('Padding system with x and y props', () => { + expect(padding({ px: 10, py: 20, ...props })).toEqual({ + paddingLeft: '80px', + paddingRight: '80px', + paddingTop: '160px', + paddingBottom: '160px', + }); +}); + +test('Padding system with gap', () => { + expect(padding({ gap: 10, ...props })).toEqual({ + gap: '80px 80px', + }); + expect(padding({ gap: [[10, 1]], ...props })).toEqual({ + gap: '80px 8px', + }); +}); + +test('styled interpolation functions', () => { + expect(margin.all(10)(props)).toEqual({ + margin: '80px', + }); + expect(margin.top(1)(props)).toEqual({ + marginTop: '8px', + }); + expect(margin.x(10)(props)).toEqual({ + marginLeft: '80px', + marginRight: '80px', + }); + expect(margin.y(10)(props)).toEqual({ + marginTop: '80px', + marginBottom: '80px', + }); + expect(padding.all(10)(props)).toEqual({ + padding: '80px', + }); + expect(padding.top(1)(props)).toEqual({ + paddingTop: '8px', + }); + expect(padding.x(10)(props)).toEqual({ + paddingLeft: '80px', + paddingRight: '80px', + }); + expect(padding.y(10)(props)).toEqual({ + paddingTop: '80px', + paddingBottom: '80px', + }); + expect(padding.gap(10)(props)).toEqual({ + gap: '80px 80px', + }); +}); diff --git a/src/Box/interpolations/spacing.ts b/src/Box/interpolations/spacing.ts new file mode 100644 index 0000000..c00aa94 --- /dev/null +++ b/src/Box/interpolations/spacing.ts @@ -0,0 +1,86 @@ +import { PabloTheme } from '../../theme/types'; +import { ResponsiveValue, spacingTransform, system } from '../system'; + +const getGapSpacing = (value: any, theme: PabloTheme) => { + if (Array.isArray(value)) { + return value.map((val) => spacingTransform(val, theme)).join(' '); + } + + const spacing = spacingTransform(value, theme); + return [spacing, spacing].join(' '); +}; + +interface MarginProps { + m?: ResponsiveValue; + mt?: ResponsiveValue; + mr?: ResponsiveValue; + mb?: ResponsiveValue; + ml?: ResponsiveValue; + mx?: ResponsiveValue; + my?: ResponsiveValue; +} + +interface PaddingProps { + p?: ResponsiveValue; + pt?: ResponsiveValue; + pr?: ResponsiveValue; + pb?: ResponsiveValue; + pl?: ResponsiveValue; + px?: ResponsiveValue; + py?: ResponsiveValue; + gap?: ResponsiveValue>; +} + +const getConfig =

(property: P, shortHand: S) => + [ + { + properties: [property], + transform: spacingTransform, + fromProps: [shortHand], + as: 'all', + }, + { + properties: [`${property}Top`], + transform: spacingTransform, + fromProps: [`${shortHand}t`], + as: 'top', + }, + { + properties: [`${property}Right`], + transform: spacingTransform, + fromProps: [`${shortHand}r`], + as: 'right', + }, + { + properties: [`${property}Bottom`], + transform: spacingTransform, + fromProps: [`${shortHand}b`], + as: 'bottom', + }, + { + properties: [`${property}Left`], + transform: spacingTransform, + fromProps: [`${shortHand}l`], + as: 'left', + }, + { + properties: [`${property}Left`, `${property}Right`], + transform: spacingTransform, + fromProps: [`${shortHand}x`], + as: 'x', + }, + { + properties: [`${property}Top`, `${property}Bottom`], + transform: spacingTransform, + fromProps: [`${shortHand}y`], + as: 'y', + }, + ] as const; + +const margin = system([...getConfig('margin', 'm')]); +const padding = system([ + ...getConfig('padding', 'p'), + { properties: ['gap'], transform: getGapSpacing }, +]); + +export { margin, padding, MarginProps, PaddingProps }; diff --git a/src/Box/spacingInterpolation.ts b/src/Box/spacingInterpolation.ts deleted file mode 100644 index c65f6ce..0000000 --- a/src/Box/spacingInterpolation.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { system } from '@styled-system/core'; -import { ResponsiveValue } from 'styled-system'; - -const DEFAULT_SCALE = 8; - -const getSpacing = (value: any, scale: number) => { - if (value === undefined) { - return undefined; - } - - if (typeof value === 'string') { - return value; - } - return `${value * scale}px`; -}; - -const getGapSpacing = (value: any, scale: number) => { - if (Array.isArray(value)) { - return value.map((val) => getSpacing(val, scale)).join(' '); - } - - const spacing = getSpacing(value, scale); - return [spacing, spacing].join(' '); -}; - -interface MarginProps { - m?: ResponsiveValue; - mt?: ResponsiveValue; - mr?: ResponsiveValue; - mb?: ResponsiveValue; - ml?: ResponsiveValue; - mx?: ResponsiveValue; - my?: ResponsiveValue; -} - -interface PaddingProps { - p?: ResponsiveValue; - pt?: ResponsiveValue; - pr?: ResponsiveValue; - pb?: ResponsiveValue; - pl?: ResponsiveValue; - px?: ResponsiveValue; - py?: ResponsiveValue; - gap?: ResponsiveValue>; -} - -const getConfig = (property: string, shortHand: string) => ({ - [shortHand]: { - property, - scale: 'spacing', - transform: getSpacing, - defaultScale: DEFAULT_SCALE, - }, - [`${shortHand}t`]: { - property: `${property}Top`, - scale: 'spacing', - transform: getSpacing, - defaultScale: DEFAULT_SCALE, - }, - [`${shortHand}r`]: { - property: `${property}Right`, - scale: 'spacing', - transform: getSpacing, - defaultScale: DEFAULT_SCALE, - }, - [`${shortHand}b`]: { - property: `${property}Bottom`, - scale: 'spacing', - transform: getSpacing, - defaultScale: DEFAULT_SCALE, - }, - [`${shortHand}l`]: { - property: `${property}Left`, - scale: 'spacing', - transform: getSpacing, - defaultScale: DEFAULT_SCALE, - }, - [`${shortHand}x`]: { - properties: [`${property}Left`, `${property}Right`], - scale: 'spacing', - transform: getSpacing, - defaultScale: DEFAULT_SCALE, - }, - [`${shortHand}y`]: { - properties: [`${property}Top`, `${property}Bottom`], - scale: 'spacing', - transform: getSpacing, - defaultScale: DEFAULT_SCALE, - }, -}); - -const margin = system(getConfig('margin', 'm')); -const padding = system({ - ...getConfig('padding', 'p'), - gap: { property: 'gap', scale: 'spacing', transform: getGapSpacing, defaultScale: DEFAULT_SCALE }, -}); - -export { margin, padding, MarginProps, PaddingProps }; diff --git a/src/Box/system.spec.ts b/src/Box/system.spec.ts new file mode 100644 index 0000000..76cf31f --- /dev/null +++ b/src/Box/system.spec.ts @@ -0,0 +1,140 @@ +import { defaultTheme } from '../theme'; +import { pixelTransform, system, systemInterpolation } from './system'; + +const callInterpolation = (interpolation: (props: object) => object, props?: object) => + interpolation({ + ...props, + theme: defaultTheme as any, + }); + +test('Create a system interpolation function', () => { + const interpolationFn = systemInterpolation({ + properties: ['margin'], + transform: pixelTransform, + }); + const interpolation = interpolationFn(10); + expect(callInterpolation(interpolation)).toEqual({ margin: '10px' }); +}); + +test('Create a system interpolation function with multiple properties', () => { + const interpolationFn = systemInterpolation({ + properties: ['margin-left', 'margin-right'], + transform: pixelTransform, + }); + const interpolation = interpolationFn(10); + expect(callInterpolation(interpolation)).toEqual({ + 'margin-left': '10px', + 'margin-right': '10px', + }); +}); + +test('Create a system interpolation from props', () => { + const interpolation = system({ + properties: ['margin'], + fromProps: ['m'], + transform: pixelTransform, + }); + expect( + callInterpolation(interpolation, { + m: 12, + }) + ).toEqual({ margin: '12px' }); +}); + +test('Create a system interpolation from props with multiple configs', () => { + const interpolation = system([ + { + properties: ['margin'], + fromProps: ['m'], + transform: pixelTransform, + }, + { + properties: ['padding'], + fromProps: ['p'], + transform: pixelTransform, + }, + ]); + expect( + callInterpolation(interpolation, { + m: 12, + p: 13, + }) + ).toEqual({ margin: '12px', padding: '13px' }); +}); + +test('Create a system interpolation from props with responsive values as array', () => { + const interpolation = system({ + properties: ['margin'], + fromProps: ['m'], + transform: pixelTransform, + }); + expect( + callInterpolation(interpolation, { + m: [12, 15, 18], + }) + ).toEqual({ + margin: '12px', + '@media min-width: only screen and (min-width: var(--pbl-theme-breakpoints-sm))': { + margin: '15px', + }, + '@media min-width: only screen and (min-width: var(--pbl-theme-breakpoints-md))': { + margin: '18px', + }, + }); +}); + +test('Create a system interpolation from props with responsive values as object', () => { + const interpolation = system([ + { + properties: ['margin'], + fromProps: ['m'], + transform: pixelTransform, + }, + ]); + expect( + callInterpolation(interpolation, { + m: { base: 12, sm: 15, lg: 18 }, + }) + ).toEqual({ + margin: '12px', + '@media min-width: only screen and (min-width: var(--pbl-theme-breakpoints-sm))': { + margin: '15px', + }, + '@media min-width: only screen and (min-width: var(--pbl-theme-breakpoints-lg))': { + margin: '18px', + }, + }); +}); + +test('Expose styled components function with "as" name', () => { + const interpolation = system([ + { + properties: ['margin'], + fromProps: ['m'], + transform: pixelTransform, + as: 'all', + }, + ]); + expect(callInterpolation(interpolation.all(12))).toEqual({ margin: '12px' }); +}); + +test('Expose styled components function without "as" name and use "properties', () => { + const interpolation = system([ + { + properties: ['margin'], + fromProps: ['m'], + transform: pixelTransform, + }, + ]); + expect(callInterpolation(interpolation.m(12))).toEqual({ margin: '12px' }); +}); + +test('Expose styled components function without "as" or "fromProps name and use "properties', () => { + const interpolation = system([ + { + properties: ['margin'], + transform: pixelTransform, + }, + ]); + expect(callInterpolation(interpolation.margin(12))).toEqual({ margin: '12px' }); +}); diff --git a/src/Box/system.ts b/src/Box/system.ts new file mode 100644 index 0000000..971b74c --- /dev/null +++ b/src/Box/system.ts @@ -0,0 +1,215 @@ +import type { CSSObject } from '@emotion/react'; +import { mediaQueryAbove } from '../breakpoints/mediaQueryFns'; +import { themeVars } from '../theme'; +import { Breakpoint } from '../theme/breakpoints'; +import { PabloTheme, PabloThemeableProps } from '../theme/types'; +import { enforceArray } from '../utils/enforceArray'; +type InterpolationReturn = string | number | null | undefined; +type IdentityTransformFn = + InterpolationTransformFn; +const identityTransform: IdentityTransformFn = (value: T): T => value; +type InterpolationTransformFn = ( + value: T, + theme: PabloTheme +) => R; +type ResponsiveValue = T | Array | Record; + +type InterpolationFn = (props: PabloThemeableProps) => CSSObject; +type SystemInterpolationFn = (value: T) => InterpolationFn; + +interface SystemInterpolationPropertyConfig { + properties: readonly string[]; + transform?: InterpolationTransformFn; +} + +interface SystemPropertyConfig extends SystemInterpolationPropertyConfig { + fromProps?: readonly string[]; + as?: PropertyKey; +} + +interface InterpolationFunction

{ + (props: PabloThemeableProps & P): CSSObject; +} + +type ExtractSystemProp> = T extends { + fromProps?: infer B extends readonly string[]; +} + ? B[number] + : T['properties'][number]; + +type SingleSystemConfigProps, T = any> = { + [K in ExtractSystemProp]?: TransformParameterType< + Extract + >; +}; + +type ArraySystemConfigProps[], T = any> = { + [K in ExtractSystemProp]?: TransformParameterType< + Extract + >; +}; + +type SystemConfigProps< + C extends SystemPropertyConfig | readonly SystemPropertyConfig[], + T = any, +> = C extends SystemPropertyConfig[] + ? ArraySystemConfigProps + : C extends SystemPropertyConfig + ? SingleSystemConfigProps + : never; + +type TransformParameterType = + T['transform'] extends InterpolationTransformFn + ? Parameters[0] + : InterpolationReturn; + +type ExtractStyledFunctionKey> = T extends { + as: infer A extends string; +} + ? A + : T extends { fromProps?: infer B extends readonly string[] } + ? B[number] + : T['properties'][number]; + +type StyledInterpolationFunctions, T = any> = { + [K in ExtractStyledFunctionKey]: SystemInterpolationFn>; +}; + +type ArrayStyledInterpolationFunctions = { + [K in ExtractStyledFunctionKey]: SystemInterpolationFn< + TransformParameterType< + Extract + > + >; +}; + +type SystemFn< + C extends readonly SystemPropertyConfig[] | SystemPropertyConfig, + T = any, +> = InterpolationFunction> & + (C extends readonly SystemPropertyConfig[] + ? ArrayStyledInterpolationFunctions + : C extends SystemPropertyConfig + ? StyledInterpolationFunctions + : never); + +const stringableTransform = + (transformFn: InterpolationTransformFn>) => + (value: T, theme: PabloTheme): ReturnType => { + if (typeof value === 'string') { + return value; + } + return transformFn(value as Exclude, theme); + }; +const pixelTransform: InterpolationTransformFn = stringableTransform( + (value) => `${value}px` +); +const spacingTransform: InterpolationTransformFn = stringableTransform( + (value, theme) => `${value * theme.spacing}px` +); + +const interpolateSingleValue = ( + properties: readonly string[], + value: any, + props: PabloThemeableProps, + transform: InterpolationTransformFn = identityTransform, + forBreakpoint: string | null = null +) => { + const transformedValue = transform(value, props.theme); + return properties.map((property) => [property, transformedValue, forBreakpoint] as const); +}; + +const interpolate = ( + properties: readonly string[], + value: any | readonly any[], + props: PabloThemeableProps, + transform: InterpolationTransformFn = identityTransform +) => { + if (value === undefined) { + return []; + } + + if (Array.isArray(value)) { + const breakpointNames = Array.from(props.theme.breakpoints.keys()); + return value.flatMap((v, index) => + interpolateSingleValue(properties, v, props, transform, breakpointNames[index]) + ); + } + if (typeof value === 'object') { + return Object.entries(value).flatMap(([key, value]) => { + return interpolateSingleValue(properties, value, props, transform, key); + }); + } + return interpolateSingleValue(properties, value, props, transform); +}; + +const makeObject = ( + pairs: (readonly [string, string | number | undefined | null, string | null])[] +) => { + return pairs.reduce((acc, [key, value, breakpointName]) => { + if (breakpointName && breakpointName !== 'base') { + const bp = themeVars.breakpoints[breakpointName]; + const breakpointKey = `@media min-width: ${mediaQueryAbove(bp)}`; + + if (!acc[breakpointKey]) { + acc[breakpointKey] = {}; + } + acc[breakpointKey][key] = value; + return acc; + } + acc[key] = value; + return acc; + }, {}); +}; + +const systemInterpolation = + (config: SystemInterpolationPropertyConfig) => + (value: TransformParameterType) => + (props: PabloThemeableProps) => + makeObject(interpolate(config.properties, value, props, config.transform)); + +const createSystemProperty = (config: SystemPropertyConfig) => { + const fromProps = config.fromProps || config.properties; + const interpolateFn = (props) => + enforceArray(fromProps) + .filter((propName) => props[propName]) + .flatMap((propName) => { + return interpolate(config.properties, props[propName], props, config.transform); + }); + return interpolateFn; +}; + +const createSystemProperties = (configs: T): SystemFn => { + const interpolationFn = (props: PabloThemeableProps): CSSObject => { + return makeObject(configs.flatMap((config) => createSystemProperty(config)(props))); + }; + + configs.forEach((config) => { + const fromProps = config.fromProps || config.properties; + if (config.as) { + (interpolationFn as any)[config.as] = systemInterpolation(config); + } else { + fromProps.forEach((property) => { + (interpolationFn as any)[property] = systemInterpolation(config); + }); + } + }); + + return interpolationFn as SystemFn; +}; + +const system = ( + config: T +): SystemFn => { + const arrayConfig = enforceArray(config); + return createSystemProperties(arrayConfig) as SystemFn; +}; + +export type { + ResponsiveValue, + InterpolationTransformFn, + IdentityTransformFn, + SystemInterpolationPropertyConfig, + SystemPropertyConfig, +}; +export { system, systemInterpolation, pixelTransform, spacingTransform, identityTransform }; diff --git a/src/breakpoints/mediaQueryFns.ts b/src/breakpoints/mediaQueryFns.ts index 7c3b229..b4a516e 100644 --- a/src/breakpoints/mediaQueryFns.ts +++ b/src/breakpoints/mediaQueryFns.ts @@ -1,20 +1,16 @@ -import { BreakpointsArray } from '../theme/breakpoints'; +export type MediaQueryFn = (breakpoint: string, breakpoints: string[], index: number) => string; -export type MediaQueryFn = ( - breakpoint: string, - breakpoints: BreakpointsArray, - index: number -) => string; +export type MediaQuerySimpleFn = (breakpoint: string) => string; -export const mediaQueryAbove: MediaQueryFn = (breakpoint) => +export const mediaQueryAbove: MediaQuerySimpleFn = (breakpoint) => `only screen and (min-width: ${breakpoint})`; -export const mediaQueryBelow: MediaQueryFn = (breakpoint) => +export const mediaQueryBelow: MediaQuerySimpleFn = (breakpoint) => `only screen and (max-width: calc(${breakpoint} - 1px))`; export const mediaQueryOnly: MediaQueryFn = (breakpoint, breakpoints, index) => { if (index === breakpoints.length - 1) { - return mediaQueryAbove(breakpoint, breakpoints, index); + return mediaQueryAbove(breakpoint); } return `only screen and (min-width: ${breakpoints[index]}) and (max-width: calc(${ diff --git a/src/theme/breakpoints.ts b/src/theme/breakpoints.ts index 47b97c7..1cdc5c6 100644 --- a/src/theme/breakpoints.ts +++ b/src/theme/breakpoints.ts @@ -1,33 +1,25 @@ import { createThemeVarArray } from './createThemeVars'; -export type Breakpoint = 'sm' | 'md' | 'lg' | 'xl'; +export type DefinableBreakpoint = 'sm' | 'md' | 'lg' | 'xl'; +export type Breakpoint = 'base' | DefinableBreakpoint; -export const breakpointNames: Breakpoint[] = ['sm', 'md', 'lg', 'xl']; - -export type BreakpointsArray = [string, string, string, string]; - -export type Breakpoints = BreakpointsArray & - Record & { - breakpointNames: Breakpoint[]; - }; +export type Breakpoints = Map; export function createBreakpoints( - array: BreakpointsArray, - usedBreakpointNames: Breakpoint[] = breakpointNames -): Breakpoints { - const newBreakpoints: Breakpoints = usedBreakpointNames.reduce( - (acc, bp, index) => ({ - ...acc, - [bp]: array[index], - }), - { array, breakpointNames: usedBreakpointNames } as unknown as Breakpoints - ); - - return newBreakpoints as Breakpoints; + tuples: [DefinableBreakpoint, number][] +): Map { + return new Map([['base', 0], ...tuples]); } -export const defaultBreakpoints: BreakpointsArray = ['700px', '1000px', '1200px', '1920px']; - -export const breakpoints = createBreakpoints(defaultBreakpoints, breakpointNames); - -export const breakpointVars = createThemeVarArray('breakpoints', breakpointNames); +export const defaultBreakpointsTuple: [DefinableBreakpoint, number][] = [ + ['sm', 700], + ['md', 1000], + ['lg', 1200], + ['xl', 1920], +]; + +export const breakpoints = createBreakpoints(defaultBreakpointsTuple); +export const breakpointVars = createThemeVarArray( + 'breakpoints', + defaultBreakpointsTuple.map(([name]) => name) +); diff --git a/src/utils/enforceArray.ts b/src/utils/enforceArray.ts index 4ae1e53..f1c3acd 100644 --- a/src/utils/enforceArray.ts +++ b/src/utils/enforceArray.ts @@ -1,5 +1,5 @@ -const enforceArray = (value: T | T[]): T[] => { - return Array.isArray(value) ? value : [value]; +const enforceArray = (value: T | T[] | readonly T[]): T[] => { + return Array.isArray(value) ? (value as unknown as T[]) : [value as T]; }; export { enforceArray }; diff --git a/src/utils/isNumber.ts b/src/utils/isNumber.ts new file mode 100644 index 0000000..086120d --- /dev/null +++ b/src/utils/isNumber.ts @@ -0,0 +1,5 @@ +const isNumber = (n: any): n is number => { + return typeof n === 'number' && !isNaN(n); +}; + +export { isNumber };