├── .npmignore ├── .prettierignore ├── .travis.yml ├── src ├── components │ ├── CropMarks │ │ ├── index.ts │ │ ├── CropMarks.tsx │ │ └── CropMark.tsx │ ├── ComponentHost │ │ ├── index.ts │ │ ├── ComponentHost.stories.tsx │ │ └── ComponentHost.tsx │ └── AlignmentContainer │ │ ├── index.ts │ │ ├── ComponentHost.stories.tsx │ │ └── AlignmentContainer.tsx ├── .prettierrc ├── common │ ├── libs.ts │ ├── index.ts │ ├── css │ │ ├── index.ts │ │ ├── glamor.ts │ │ ├── test │ │ │ ├── css-flex.test.ts │ │ │ ├── css.test.ts │ │ │ ├── css-spacing.test.ts │ │ │ ├── css-image.test.ts │ │ │ └── css-positioning.test.ts │ │ ├── types.ts │ │ └── css.ts │ ├── alignment.ts │ ├── color.ts │ └── util.ts ├── index.ts ├── test │ ├── index.ts │ └── Foo.tsx ├── index.test.ts ├── types.ts └── decorators │ └── host.tsx ├── .prettierrc ├── static └── favicon.ico ├── .gitignore ├── .storybook ├── .babelrc └── config.js ├── .editorconfig ├── tsconfig.json ├── tslint.json ├── LICENSE ├── package.json ├── CHANGELOG.md └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | src/ 2 | -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | *.md 2 | *.yml 3 | *.yaml 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - stable 4 | -------------------------------------------------------------------------------- /src/components/CropMarks/index.ts: -------------------------------------------------------------------------------- 1 | export * from './CropMarks'; 2 | -------------------------------------------------------------------------------- /src/components/ComponentHost/index.ts: -------------------------------------------------------------------------------- 1 | export * from './ComponentHost'; 2 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /src/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } 5 | -------------------------------------------------------------------------------- /src/components/AlignmentContainer/index.ts: -------------------------------------------------------------------------------- 1 | export * from './AlignmentContainer'; 2 | -------------------------------------------------------------------------------- /static/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/philcockfield/storybook-host/HEAD/static/favicon.ico -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .build 3 | .cache 4 | lib 5 | node_modules 6 | npm-debug.log 7 | yarn-error.log 8 | -------------------------------------------------------------------------------- /src/common/libs.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import * as R from 'ramda'; 3 | 4 | export { R, React }; 5 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export { host, host as default, IHostProps } from './decorators/host'; 2 | export { AlignEdge } from './types'; 3 | -------------------------------------------------------------------------------- /src/test/index.ts: -------------------------------------------------------------------------------- 1 | export { storiesOf } from '@storybook/react'; 2 | export { React, css, color } from '../common'; 3 | export * from './Foo'; 4 | -------------------------------------------------------------------------------- /.storybook/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "modules": false 7 | } 8 | ] 9 | ] 10 | } -------------------------------------------------------------------------------- /src/common/index.ts: -------------------------------------------------------------------------------- 1 | import * as color from './color'; 2 | 3 | export { color }; 4 | export * from './libs'; 5 | export * from './util'; 6 | export * from './css'; 7 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | 3 | describe('storybook-host tests', () => { 4 | it('succeeds', () => { 5 | expect(123).to.equal(123); 6 | }); 7 | }); 8 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from '@storybook/react'; 2 | 3 | 4 | // Load stories. 5 | const req = require.context('../lib', true, /stories.js$/); 6 | configure(() => { 7 | req.keys().forEach(filename => req(filename)); 8 | }, module); 9 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*.{ts,tsx,js,json,jsx}] 4 | indent_style = space 5 | indent_size = 2 6 | charset = utf-8 7 | trim_trailing_whitespace = true 8 | insert_final_newline = true 9 | 10 | [*.md] 11 | trim_trailing_whitespace = false 12 | -------------------------------------------------------------------------------- /src/common/css/index.ts: -------------------------------------------------------------------------------- 1 | import { GlamorValue, CssProps, IStyle } from './types'; 2 | import { format, transformStyle } from './css'; 3 | import { className, merge } from './glamor'; 4 | 5 | const api = format as any; 6 | api.className = className; 7 | api.merge = merge; 8 | api.transform = transformStyle; 9 | 10 | export { GlamorValue, CssProps }; 11 | export const css = format as IStyle; 12 | -------------------------------------------------------------------------------- /src/common/css/glamor.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * See: 3 | * https://github.com/threepointone/glamor 4 | */ 5 | import { style, merge } from 'glamor'; 6 | import { CssProps } from './types'; 7 | import { format } from './css'; 8 | 9 | export { merge }; 10 | 11 | /** 12 | * Converts a set of properties into hashed CSS class-names. 13 | */ 14 | export function className(...styles: Array): string { 15 | const names = styles.map(s => style(format(s) as CssProps)); 16 | return `${merge(names)}`; 17 | } 18 | -------------------------------------------------------------------------------- /src/common/css/test/css-flex.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { transformStyle } from '../css'; 3 | 4 | describe('Flex', () => { 5 | it('does not fail when undefined is passed', () => { 6 | const result = transformStyle({ 7 | Flex: undefined, 8 | }); 9 | expect(result).to.eql({}); 10 | }); 11 | 12 | it('does not fail when false is passed', () => { 13 | const result = transformStyle({ 14 | Flex: false, 15 | }); 16 | expect(result).to.eql({}); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | export type AlignEdge = 2 | | 'left' 3 | | 'center' 4 | | 'right' 5 | | 'top' 6 | | 'middle' 7 | | 'bottom' 8 | | 'left top' 9 | | 'left middle' 10 | | 'left bottom' 11 | | 'center top' 12 | | 'center middle' 13 | | 'center bottom' 14 | | 'right top' 15 | | 'right middle' 16 | | 'right bottom' 17 | | 'top left' 18 | | 'middle left' 19 | | 'bottom left' 20 | | 'top center' 21 | | 'middle center' 22 | | 'bottom center' 23 | | 'top right' 24 | | 'middle right' 25 | | 'bottom right'; 26 | -------------------------------------------------------------------------------- /src/test/Foo.tsx: -------------------------------------------------------------------------------- 1 | import { React, css, GlamorValue } from '../common'; 2 | 3 | export interface IFooProps { 4 | children?: React.ReactNode; 5 | style?: GlamorValue; 6 | } 7 | 8 | export const Foo = (props: IFooProps) => { 9 | const styles = { 10 | base: css({ 11 | position: 'relative', 12 | boxSizing: 'border-box', 13 | padding: 10, 14 | background: 'rgba(255, 0, 0, 0.1)', 15 | }), 16 | }; 17 | return ( 18 |
{props.children || 'Hello'}
19 | ); 20 | }; 21 | -------------------------------------------------------------------------------- /src/common/css/test/css.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { transformStyle } from '../css'; 3 | 4 | describe('React: CSS', () => { 5 | it('is a function', () => { 6 | expect(transformStyle).to.be.an.instanceof(Function); 7 | }); 8 | 9 | it('returns the given object', () => { 10 | const style = { color: 'red' }; 11 | expect(transformStyle(style)).to.equal(style); 12 | }); 13 | 14 | it('returns an empty object if no `style` parameter is given', () => { 15 | expect(transformStyle()).to.eql({}); 16 | }); 17 | 18 | it('removes undefined values', () => { 19 | const style = { color: undefined, background: null }; 20 | expect(transformStyle(style)).to.eql({}); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "rootDir": "src", 4 | "outDir": "lib", 5 | "target": "es5", 6 | "sourceMap": true, 7 | "module": "commonjs", 8 | "moduleResolution": "node", 9 | "isolatedModules": false, 10 | "jsx": "react", 11 | "experimentalDecorators": true, 12 | "emitDecoratorMetadata": true, 13 | "declaration": true, 14 | "removeComments": true, 15 | "noLib": false, 16 | "skipLibCheck": true, 17 | "preserveConstEnums": true, 18 | "suppressImplicitAnyIndexErrors": true, 19 | "pretty": true, 20 | "strictNullChecks": true, 21 | "noImplicitAny": true, 22 | "noImplicitReturns": true, 23 | "noFallthroughCasesInSwitch": true, 24 | "noUnusedLocals": true, 25 | "lib": [ 26 | "esnext", 27 | "dom" 28 | ] 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "tslint:latest", 4 | "tslint-react", 5 | "tslint-config-prettier" 6 | ], 7 | "rules": { 8 | "no-consecutive-blank-lines": [ 9 | false 10 | ], 11 | "no-var-requires": false, 12 | "object-literal-sort-keys": false, 13 | "quotemark": [ 14 | true, 15 | "single", 16 | "jsx-single" 17 | ], 18 | "jsx-no-lambda": true, 19 | "no-shadowed-variable": false, 20 | "ordered-imports": false, 21 | "only-arrow-functions": false, 22 | "no-empty-interface": false, 23 | "interface-over-type-literal": false, 24 | "jsx-alignment": false, 25 | "member-ordering": false, 26 | "object-literal-shorthand": false, 27 | "max-classes-per-file": false, 28 | "no-submodule-imports": false, 29 | "no-implicit-dependencies": false 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/decorators/host.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { ComponentHost, IHostOptions } from '../components/ComponentHost'; 3 | export { IHostOptions as IHostProps }; 4 | import { makeDecorator, StoryContext } from '@storybook/addons'; 5 | 6 | /** 7 | * Decorator to concisely insert the helpers. 8 | * 9 | * storiesOf('primitives.Button', module) 10 | * .addDecorator(host({ header: 'My Header' })) 11 | * .add(...) 12 | */ 13 | export const host = makeDecorator({ 14 | name: 'host', 15 | parameterName: 'host', 16 | wrapper: ( 17 | storyFn: (context: StoryContext) => any, 18 | context, 19 | { options, parameters }, 20 | ) => { 21 | return ( 22 | 28 | ); 29 | }, 30 | }); 31 | -------------------------------------------------------------------------------- /src/common/css/types.ts: -------------------------------------------------------------------------------- 1 | import { CSSProperties } from 'glamor'; 2 | import { transformStyle } from './css'; 3 | 4 | export interface IImageOptions { 5 | width?: number; 6 | height?: number; 7 | } 8 | 9 | export interface IBackgroundImageStyles { 10 | backgroundImage: string; 11 | width?: number; 12 | height?: number; 13 | backgroundSize: string; 14 | backgroundRepeat: string; 15 | } 16 | 17 | export type FormatImage = ( 18 | image1x: string | undefined, 19 | image2x: string | undefined, 20 | options?: IImageOptions, 21 | ) => IBackgroundImageStyles; 22 | 23 | export type Falsy = undefined | null | false; 24 | export class GlamorValue {} 25 | export interface IFormatCss { 26 | (...styles: Array): GlamorValue; 27 | image: FormatImage; 28 | } 29 | 30 | export type CssProps = CSSProperties; 31 | export type ClassName = (...styles: Array) => string; 32 | 33 | export interface IStyle extends IFormatCss { 34 | className: ClassName; 35 | transform: typeof transformStyle; 36 | merge: (...rules: any[]) => CssProps; 37 | } 38 | -------------------------------------------------------------------------------- /src/components/AlignmentContainer/ComponentHost.stories.tsx: -------------------------------------------------------------------------------- 1 | import { React, storiesOf, Foo, css, color } from '../../test'; 2 | import { host, AlignEdge } from '../..'; 3 | 4 | const describe = (name: string, props: any) => { 5 | storiesOf('AlignmentContainer', module) 6 | .addDecorator( 7 | host({ 8 | title: `Aligns the component within the host: "${props.align}"`, 9 | ...props, 10 | }), 11 | ) 12 | .add(name, () => {name}); 13 | }; 14 | 15 | const align = (edge: AlignEdge, props: any = {}) => { 16 | describe(edge.toString(), { ...props, align: edge }); 17 | }; 18 | 19 | const Test = (props: any) => { 20 | const styles = { 21 | base: css({ 22 | width: 150, 23 | height: 150, 24 | border: `solid 1px ${color.format(-0.1)}`, 25 | }), 26 | }; 27 | return ; 28 | }; 29 | 30 | align('top left'); 31 | align('top center'); 32 | align('top right'); 33 | 34 | align('middle left'); 35 | align('middle center'); 36 | align('middle right'); 37 | 38 | align('bottom left'); 39 | align('bottom center'); 40 | align('bottom right'); 41 | -------------------------------------------------------------------------------- /src/common/css/test/css-spacing.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { transformStyle } from '../css'; 3 | 4 | describe('padding', function() { 5 | it('PaddingX', () => { 6 | const result = transformStyle({ 7 | PaddingX: 14, 8 | paddingLeft: 1234, // Overwritten. 9 | }) as any; 10 | expect(result.paddingLeft).to.equal(14); 11 | expect(result.paddingRight).to.equal(14); 12 | }); 13 | 14 | it('PaddingY', () => { 15 | const result = transformStyle({ 16 | PaddingY: 20, 17 | }) as any; 18 | expect(result.paddingTop).to.equal(20); 19 | expect(result.paddingBottom).to.equal(20); 20 | }); 21 | }); 22 | 23 | describe('margin', function() { 24 | it('MarginX', () => { 25 | const result = transformStyle({ 26 | MarginX: 14, 27 | marginLeft: 1234, // Overwritten. 28 | }) as any; 29 | expect(result.marginLeft).to.equal(14); 30 | expect(result.marginRight).to.equal(14); 31 | }); 32 | 33 | it('MarginY', () => { 34 | const result = transformStyle({ 35 | MarginY: 20, 36 | }) as any; 37 | expect(result.marginTop).to.equal(20); 38 | expect(result.marginBottom).to.equal(20); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2016 Phil Cockfield (https://github.com/philcockfield/modules) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all 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 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/common/alignment.ts: -------------------------------------------------------------------------------- 1 | import * as R from 'ramda'; 2 | import { AlignEdge } from '../types'; 3 | 4 | export { AlignEdge }; 5 | export type AlignHorizontal = 'left' | 'center' | 'right'; 6 | export type AlignVertical = 'top' | 'middle' | 'bottom'; 7 | 8 | const HORIZONTAL: AlignHorizontal[] = ['left', 'center', 'right']; 9 | const VERTICAL: AlignVertical[] = ['top', 'middle', 'bottom']; 10 | 11 | const contains = (array: string[], value: string) => 12 | R.any(item => item === value, array); 13 | const isVertical = (value: string) => contains(VERTICAL, value); 14 | const isHorizontal = (value: string) => contains(HORIZONTAL, value); 15 | 16 | /** 17 | * Extracts the edge alignments from the given value. 18 | */ 19 | export function edges( 20 | value: AlignEdge, 21 | defaultHorizontal: string, 22 | defaultVertical: string, 23 | ): { horizontal: AlignHorizontal; vertical: AlignVertical } { 24 | const parts = value.split(' '); 25 | 26 | // If only one axis was specified fill in the missing value. 27 | if (parts.length < 2) { 28 | if (isHorizontal(parts[0])) { 29 | parts[1] = defaultVertical; 30 | } 31 | if (isVertical(parts[0])) { 32 | parts[1] = defaultHorizontal; 33 | } 34 | } 35 | 36 | // Extract axis values. 37 | const horizontal = (isHorizontal(parts[0]) 38 | ? parts[0] 39 | : parts[1]) as AlignHorizontal; 40 | const vertical = (isVertical(parts[0]) 41 | ? parts[0] 42 | : parts[1]) as AlignVertical; 43 | 44 | // Finish up. 45 | return { horizontal, vertical }; 46 | } 47 | -------------------------------------------------------------------------------- /src/components/CropMarks/CropMarks.tsx: -------------------------------------------------------------------------------- 1 | import { React, css, GlamorValue } from '../../common'; 2 | import { CropMark } from './CropMark'; 3 | 4 | export interface ICropMarksProps { 5 | width?: number | string; 6 | height?: number | string; 7 | maxWidth?: number | string; 8 | background?: number | string; 9 | cropMarkColor?: string; 10 | cropMarksVisible?: boolean; 11 | border?: string; 12 | children?: any; 13 | style?: GlamorValue; 14 | } 15 | 16 | /** 17 | * Positions a set of crop-marks around it's contents. 18 | */ 19 | export const CropMarks = (props: ICropMarksProps) => { 20 | const { 21 | width = 'auto', 22 | height = 'auto', 23 | maxWidth = 'auto', 24 | background, 25 | cropMarkColor, 26 | cropMarksVisible = true, 27 | border, 28 | children, 29 | } = props; 30 | 31 | const styles = { 32 | base: css({ 33 | position: 'relative', 34 | boxSizing: 'border-box', 35 | background, 36 | width, 37 | height, 38 | maxWidth, 39 | border, 40 | }), 41 | }; 42 | 43 | const cropMarkProps = { 44 | color: cropMarkColor, 45 | }; 46 | return ( 47 |
48 | {children} 49 | {cropMarksVisible && ( 50 |
51 | 52 | 53 | 54 | 55 |
56 | )} 57 |
58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /src/common/color.ts: -------------------------------------------------------------------------------- 1 | import * as R from 'ramda'; 2 | import * as tinycolor from 'tinycolor2'; 3 | 4 | const RED = `rgba(255, 0, 0, 0.1)`; 5 | 6 | /** 7 | * Creates a new tiny-color instance. 8 | * https://github.com/bgrins/TinyColor 9 | */ 10 | export function create(value: any) { 11 | return tinycolor(value); 12 | } 13 | export const black = () => create('black'); 14 | export const white = () => create('white'); 15 | 16 | /** 17 | * Takes a value of various types and converts it into a color. 18 | */ 19 | export function format(value: string | number | boolean): string { 20 | if (value === true) { 21 | return RED; 22 | } 23 | if (R.is(Number, value)) { 24 | return toGrayAlpha(value as number); 25 | } 26 | return value as string; 27 | } 28 | 29 | /** 30 | * A number between -1 (black) and 1 (white). 31 | */ 32 | export function toGrayAlpha(value: number): string { 33 | if (value < -1) { 34 | value = -1; 35 | } 36 | if (value > 1) { 37 | value = 1; 38 | } 39 | 40 | // Black. 41 | if (value < 0) { 42 | return `rgba(0, 0, 0, ${Math.abs(value)})`; 43 | } 44 | 45 | // White. 46 | if (value > 0) { 47 | return `rgba(255, 255, 255, ${value})`; 48 | } 49 | 50 | return `rgba(0, 0, 0, 0.0)`; // Transparent. 51 | } 52 | 53 | /** 54 | * A number between -1 (black) and 1 (white). 55 | */ 56 | export function toGrayHex(value: number): string { 57 | if (value < -1) { 58 | value = -1; 59 | } 60 | if (value > 1) { 61 | value = 1; 62 | } 63 | 64 | // Black. 65 | if (value < 0) { 66 | return white() 67 | .darken(Math.abs(value) * 100) 68 | .toHexString(); 69 | } 70 | 71 | // White. 72 | if (value > 0) { 73 | return black() 74 | .lighten(value * 100) 75 | .toHexString(); 76 | } 77 | 78 | return white().toHexString(); 79 | } 80 | -------------------------------------------------------------------------------- /src/components/CropMarks/CropMark.tsx: -------------------------------------------------------------------------------- 1 | import { React, css } from '../../common'; 2 | 3 | export interface ICropMarkProps { 4 | edge: 'topLeft' | 'topRight' | 'bottomLeft' | 'bottomRight'; 5 | offset?: number; 6 | color?: string; 7 | size?: number; 8 | } 9 | 10 | /** 11 | * A single crop-mark within the . 12 | */ 13 | export const CropMark = (props: ICropMarkProps) => { 14 | const { edge, offset = 5, color = 'rgba(0, 0, 0, 0.15)', size = 20 } = props; 15 | 16 | let base: any; 17 | let xAxis: any; 18 | let yAxis: any; 19 | 20 | switch (edge) { 21 | case 'topLeft': 22 | base = { Absolute: `-${size - 1} auto auto -${size - 1}` }; 23 | xAxis = { Absolute: `null ${offset} 0 0` }; 24 | yAxis = { Absolute: `0 0 ${offset} auto` }; 25 | break; 26 | 27 | case 'topRight': 28 | base = { Absolute: `-${size - 1} -${size - 1} auto auto` }; 29 | xAxis = { Absolute: `auto 0 0 ${offset}` }; 30 | yAxis = { Absolute: `0 auto ${offset} 0` }; 31 | break; 32 | 33 | case 'bottomLeft': 34 | base = { Absolute: `auto auto -${size - 1} -${size - 1}` }; 35 | xAxis = { Absolute: `0 ${offset} auto 0` }; 36 | yAxis = { Absolute: `${offset} 0 0 auto` }; 37 | break; 38 | 39 | case 'bottomRight': 40 | base = { Absolute: `null -${size - 1} -${size - 1} auto` }; 41 | xAxis = { Absolute: `0 0 auto ${offset}` }; 42 | yAxis = { Absolute: `${offset} auto 0 0` }; 43 | break; 44 | 45 | default: // Ignore. 46 | } 47 | 48 | base.width = size; 49 | base.height = size; 50 | xAxis.borderBottom = `solid 1px ${color}`; 51 | yAxis.borderRight = `solid 1px ${color}`; 52 | const styles = { base, xAxis, yAxis }; 53 | 54 | let el: any; 55 | if (size > 0) { 56 | el = ( 57 |
58 |
59 |
60 |
61 | ); 62 | } 63 | return el; 64 | }; 65 | -------------------------------------------------------------------------------- /src/components/AlignmentContainer/AlignmentContainer.tsx: -------------------------------------------------------------------------------- 1 | import { React, css } from '../../common'; 2 | import { AlignEdge, edges } from '../../common/alignment'; 3 | import { FlexDirectionProperty } from 'csstype'; 4 | 5 | export interface IAlignmentContainerProps { 6 | children?: React.ReactNode; 7 | align?: AlignEdge; 8 | } 9 | 10 | /** 11 | * Flex-box container providing edge alignment of child content. 12 | */ 13 | export const AlignmentContainer = (props: IAlignmentContainerProps) => { 14 | const { horizontal, vertical } = edges( 15 | props.align || 'top center', 16 | 'center', 17 | 'top', 18 | ); 19 | // tslint:disable-next-line 20 | let direction: FlexDirectionProperty | undefined = undefined; 21 | let alignItems = ''; 22 | let justifyContent = ''; 23 | 24 | if (horizontal === 'left' && vertical === 'top') { 25 | direction = 'row'; 26 | alignItems = 'flex-start'; 27 | } 28 | 29 | if (horizontal === 'left' && vertical === 'middle') { 30 | direction = 'row'; 31 | alignItems = 'center'; 32 | } 33 | 34 | if (horizontal === 'left' && vertical === 'bottom') { 35 | direction = 'row'; 36 | alignItems = 'flex-end'; 37 | } 38 | 39 | if (horizontal === 'right' && vertical === 'top') { 40 | direction = 'column'; 41 | alignItems = 'flex-end'; 42 | } 43 | 44 | if (horizontal === 'right' && vertical === 'middle') { 45 | direction = 'row'; 46 | alignItems = 'center'; 47 | justifyContent = 'flex-end'; 48 | } 49 | 50 | if (horizontal === 'right' && vertical === 'bottom') { 51 | direction = 'column-reverse'; 52 | alignItems = 'flex-end'; 53 | } 54 | 55 | if (horizontal === 'center' && vertical === 'top') { 56 | direction = 'column'; 57 | alignItems = 'center'; 58 | } 59 | 60 | if (horizontal === 'center' && vertical === 'middle') { 61 | direction = 'column'; 62 | alignItems = 'center'; 63 | justifyContent = 'center'; 64 | } 65 | 66 | if (horizontal === 'center' && vertical === 'bottom') { 67 | direction = 'column-reverse'; 68 | alignItems = 'center'; 69 | } 70 | 71 | const styles = { 72 | base: css({ 73 | alignItems, 74 | justifyContent, 75 | Absolute: 0, 76 | display: 'flex', 77 | flexDirection: direction, 78 | }), 79 | }; 80 | 81 | return
{props.children}
; 82 | }; 83 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "storybook-host", 3 | "version": "5.2.0", 4 | "description": "A React Storybook decorator with helpful display options for hosting components under test", 5 | "main": "./lib/index.js", 6 | "typings": "./lib/index.d.ts", 7 | "scripts": { 8 | "ui": "start-storybook -p 3000 -c ./.storybook -s ./static", 9 | "start": "npm run build && npm run ui", 10 | "test": "./node_modules/mocha/bin/mocha --require ts-node/register --watch-extensions ts,tsx 'src/**/*.test.ts{,x}'", 11 | "tdd": "npm run test -- --reporter min --watch", 12 | "prettier": "node ./node_modules/prettier/bin-prettier all --write 'src/**/*.ts{,x}'", 13 | "tslint": "node ./node_modules/tslint/bin/tslint 'src/**/*.ts{,x}' --format verbose --fix $@", 14 | "lint": "npm run prettier && npm run tslint", 15 | "build": "rm -rf ./lib && node ./node_modules/typescript/bin/tsc", 16 | "prepare": "npm run lint && npm test && npm run build" 17 | }, 18 | "dependencies": { 19 | "@storybook/addons": "^5.1.9", 20 | "@types/tinycolor2": "^1.4.1", 21 | "glamor": "^2.20.40", 22 | "ramda": "^0.25.0", 23 | "tinycolor2": "^1.4.1" 24 | }, 25 | "devDependencies": { 26 | "@babel/core": "^7.1.2", 27 | "@emotion/core": "10.0.14", 28 | "@storybook/react": "^5.1.9", 29 | "@types/chai": "^4.1.7", 30 | "@types/mocha": "^5.2.5", 31 | "@types/node": "^12.0.10", 32 | "@types/ramda": "^0.26.0", 33 | "@types/react": "^16.4.18", 34 | "@types/react-dom": "^16.0.9", 35 | "@types/storybook__react": "^4.0.2", 36 | "babel-loader": "^8.0.4", 37 | "chai": "^4.2.0", 38 | "mocha": "^5.2.0", 39 | "prettier": "^1.14.3", 40 | "react": "^16.6.0", 41 | "react-dom": "^16.6.0", 42 | "ts-node": "^7.0.1", 43 | "tslint": "^5.11.0", 44 | "tslint-config-prettier": "^1.15.0", 45 | "tslint-react": "^3.6.0", 46 | "typescript": "^3.1.6", 47 | "webpack": "^4.23.1" 48 | }, 49 | "peerDependencies": { 50 | "react": "^0.14.7 || ^15.0.0 || ^16.0.0 || ^17.0.0" 51 | }, 52 | "repository": { 53 | "type": "git", 54 | "url": "https://github.com/philcockfield/storybook-host" 55 | }, 56 | "keywords": [ 57 | "react", 58 | "react-storybook", 59 | "helper", 60 | "ui" 61 | ], 62 | "author": { 63 | "name": "Phil Cockfield", 64 | "email": "phil@cockfield.net", 65 | "url": "https://github.com/philcockfield/modules" 66 | }, 67 | "license": "MIT" 68 | } 69 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Change Log 2 | 3 | All notable changes to this project will be documented in this file. 4 | This project adheres to [Semantic Versioning](http://semver.org/). 5 | 6 | 7 | ## [5.2.0] - 2020-10-26 8 | #### Changed 9 | - Added `react@17.0.0` to list of supported peer dependencies ([issue 57](https://github.com/philcockfield/storybook-host/issues/57)). 10 | 11 | 12 | ## [5.1.0] - 2019-06-27 13 | #### Fixed 14 | 15 | - PR #44: Max width prop (thanks to @duncanmcdougall). 16 | - PR #46: Bump js-yaml from 3.12.0 to 3.13.1 (the 🤖 did it). 17 | - PR #44: feat: allow developer to specific flex properties on component host and allow storybook 5+ parameter config (thanks to @frederickfogerty). 18 | 19 | #### Changed 20 | - Updated refs, now on version 5x of storybook. 21 | 22 | 23 | 24 | ## [5.0.3] - 2018-11-03 25 | #### Fixed 26 | 27 | - PR #40: Fix backwards compatibility issue with React Fragments (thanks to @chadfawcett). 28 | - PR #42: Update dependencies to Storybook 4.x (thanks to @itsdanielmatos). 29 | 30 | ## [5.0.1] - 2018-09-27 31 | #### Changed 32 | - Updated ref versions. 33 | 34 | #### Fixed 35 | - [Fix disabled styles when cropMarks is set to false](https://github.com/philcockfield/storybook-host/pull/38) thanks to @itsdanielmatos 36 | 37 | ## [5.0.0] - 2018-06-08 38 | 39 | #### Changed 40 | 41 | - Updated NPM refs. 42 | - Switched from `radium` to `glamor` css (internal usage). 43 | 44 | #### Removed 45 | 46 | - `nomalize.css` reference. 47 | 48 | ## [4.1.5] - 2017-12-30 49 | 50 | #### Changed 51 | 52 | - Updated package.json to latest NPM refs. 53 | - Added React `^16.0.0` to `peerDependencies`. 54 | 55 | ## [4.1.2] - 2017-12-30 56 | 57 | #### Changed 58 | 59 | - Updated package.json to latest NPM refs. 60 | - Added React `^16.0.0` to `peerDependencies`. 61 | 62 | ## [4.1.1] - 2017-09-08 63 | 64 | #### Changed 65 | 66 | - TypeScript building to ES5 (target `/lib` folder). 67 | 68 | ## [4.1.0] - 2017-08-14 69 | 70 | #### Removed 71 | 72 | - Removed unused exports: `storiesOf`, `Story`, `knobs`. 73 | 74 | ## [4.0.0] - 2017-08-08 75 | 76 | #### Changed 77 | 78 | - Updated to latest versions of Storybook / Knobs. 79 | 80 | #### Removed 81 | 82 | - Remove MobX and MobX Devtools. 83 | 84 | ## [3.0.0] - 2017-06-07 85 | 86 | #### Changed 87 | 88 | - Update to Storybook 3.0. 89 | 90 | ## [1.1.0] - 2017-05-21 91 | 92 | #### Changed 93 | 94 | - Updated NPM references. 95 | - Change default `mobXDevTools` to `false`. 96 | 97 | ## [1.0.14] - 2017-01-30 98 | 99 | #### Added 100 | 101 | - `flex:boolean` option to host. 102 | 103 | ## [1.0.0] - 2016-10-21 104 | 105 | #### Added 106 | 107 | - `` initial release. 108 | -------------------------------------------------------------------------------- /src/common/css/test/css-image.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { transformStyle, MEDIA_QUERY_RETINA } from '../css'; 3 | import { css } from '..'; 4 | const browserWindow: any = global; 5 | const { image } = css; 6 | 7 | describe('React: CSS - image', () => { 8 | describe('image()', () => { 9 | afterEach(() => { 10 | delete browserWindow.devicePixelRatio; 11 | }); 12 | 13 | it('is attached to the [css] function as a property', () => { 14 | expect(css.image).to.equal(image); 15 | }); 16 | 17 | it('throws if an image was not specified', () => { 18 | expect(() => { 19 | image(undefined, undefined); 20 | }).to.throw(); 21 | }); 22 | 23 | it('returns the 1x resolution', () => { 24 | browserWindow.devicePixelRatio = 1; 25 | const result = image('1x', '2x'); 26 | expect(result.backgroundImage).to.equal('url(1x)'); 27 | }); 28 | 29 | it('returns the 2x resolution', () => { 30 | browserWindow.devicePixelRatio = 2; 31 | const result = image('1x.png', '2x.png'); 32 | expect(result.backgroundImage).to.equal('url(1x.png)'); 33 | expect(result[MEDIA_QUERY_RETINA].backgroundImage).to.equal( 34 | 'url(2x.png)', 35 | ); 36 | }); 37 | 38 | it('returns the 1x resolution on hi-res screen when no 2x image (undefined)', () => { 39 | browserWindow.devicePixelRatio = 2; 40 | expect( 41 | image('1x', undefined, { width: 10, height: 20 }).backgroundImage, 42 | ).to.equal('url(1x)'); 43 | }); 44 | 45 | it('has width and height values (defaults)', () => { 46 | const result = image('1x', '2x'); 47 | expect(result.width).to.equal(10); 48 | expect(result.height).to.equal(10); 49 | }); 50 | 51 | it('has width and height values (specified)', () => { 52 | const result = image('1x', '2x', { width: 20, height: 150 }); 53 | expect(result.width).to.equal(20); 54 | expect(result.height).to.equal(150); 55 | }); 56 | 57 | it('has [backgroundSize]', () => { 58 | const result = image('1x', '2x', { width: 20, height: 150 }); 59 | expect(result.backgroundSize).to.equal('20px 150px'); 60 | }); 61 | 62 | it('does not repeat the background', () => { 63 | const result = image('1x', '2x', { width: 20, height: 150 }); 64 | expect(result.backgroundRepeat).to.equal('no-repeat'); 65 | }); 66 | }); 67 | 68 | describe('Image replacement via css() method', () => { 69 | it('replaces `Image` with style settings (1x)', () => { 70 | browserWindow.devicePixelRatio = 1; 71 | const style = transformStyle({ Image: ['1x', '2x', 20, 30] }) as any; 72 | expect(style.Image).to.equal(undefined); 73 | expect(style.backgroundImage).to.equal('url(1x)'); 74 | expect(style.width).to.equal(20); 75 | expect(style.height).to.equal(30); 76 | expect(style.backgroundSize).to.equal('20px 30px'); 77 | expect(style.backgroundRepeat).to.equal('no-repeat'); 78 | }); 79 | 80 | it('replaces `Image` with style settings (2x)', () => { 81 | browserWindow.devicePixelRatio = 2; 82 | const style = transformStyle({ 83 | Image: ['1x.JPG', '2x.JPG', 20, 30], 84 | }) as any; 85 | expect(style.Image).to.equal(undefined); 86 | expect(style.backgroundImage).to.equal('url(1x.JPG)'); 87 | expect(style[MEDIA_QUERY_RETINA].backgroundImage).to.equal('url(2x.JPG)'); 88 | }); 89 | }); 90 | }); 91 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # storybook-host 2 | 3 | [![Build Status](https://travis-ci.org/philcockfield/storybook-host.svg?branch=master)](https://travis-ci.org/philcockfield/storybook-host) 4 | 5 | A [React Storybook](https://storybooks.js.org/) decorator with powerful display options for 6 | hosting, sizing and framing your components. 7 | 8 | ## Install 9 | 10 | npm install -D storybook-host 11 | 12 | ## Try in Storybook 13 | 14 | npm start 15 | 16 | ## Usage 17 | 18 | ```js 19 | import { storiesOf } from '@storybook/react'; 20 | import { host } from 'storybook-host'; 21 | import { MyComponent } from './MyComponent'; 22 | 23 | storiesOf('helpers.storybook', module) 24 | .addDecorator( 25 | host({ 26 | title: 'A host container for components under test.', 27 | align: 'center bottom', 28 | height: '80%', 29 | width: 400, 30 | }), 31 | ) 32 | .add('MyComponent', () => ); 33 | ``` 34 | 35 | ![Screen Shot](https://cloud.githubusercontent.com/assets/185555/19583290/dc0041fc-9797-11e6-9893-62bb03822eca.png) 36 | 37 | ## Properties 38 | 39 | ```js 40 | host({ 41 | title: , 42 | hr: , 43 | align: , 44 | height: , 45 | width: , 46 | background: , 47 | backdrop: , 48 | cropMarks: , 49 | border: , 50 | padding: , 51 | }); 52 | ``` 53 | 54 | #### `title: string` 55 | 56 | The title display that is displayed at the top of the window. 57 | Use this to to name and provide a decription of the component under test. 58 | 59 | #### `hr: boolean` 60 | 61 | Flag indicating if the horizontal rule under the title should be shown. Default: `true`. 62 | 63 | #### `align: string [x y]` 64 | 65 | A string indicating how to align the component within the host. The string takes to parts (`x` and `y`) 66 | seperated by a space. The order of horizontal vs. vertical does not matter, 67 | eg `top left` is the same as `left top`. 68 | 69 | - Horizontal (X) 70 | - `left` 71 | - `center` 72 | - `right` 73 | - Vertical (Y) 74 | - `top` 75 | - `middle` 76 | - `bottom` 77 | 78 | #### `width: number | string | undefined` 79 | 80 | The width to lock the component at, eg: `400` (number as pixels) or `400px` or `100%`. 81 | 82 | #### `height: number | string | undefined` 83 | 84 | The height to lock the component at, eg: `200` (number as pixels) or `200px` or `100%`. 85 | 86 | #### `maxWidth: number | string | undefined` 87 | 88 | The maximum width to restrict the component to, eg: `400` (number as pixels) or `400px` or `100%`. 89 | 90 | #### `background: boolean | number | string` 91 | 92 | The background color to draw behind the component. 93 | 94 | - `true`: ruby red (eg. `rgba(255, 0, 0, 0.1)`). Useful for quick visualization of component size. 95 | - `string`: A CSS background-color value. 96 | - `number (-1:black..0..1:white)` 97 | 98 | #### `backdrop: boolean | number | string` 99 | 100 | The background color of the entire host panel. Same value types as `background`. 101 | 102 | #### `cropMarks: boolean` 103 | 104 | Flag indicating if the crop-marks should be visible. Default: `true`. 105 | 106 | #### `border: string | number | boolean` 107 | 108 | Optional border for the component. 109 | 110 | #### `padding: number | string` 111 | 112 | The padding of the host container. 113 | -------------------------------------------------------------------------------- /src/components/ComponentHost/ComponentHost.stories.tsx: -------------------------------------------------------------------------------- 1 | import { React, storiesOf, Foo } from '../../test'; 2 | import { host } from '../..'; 3 | 4 | const STORY = 'ComponentHost'; 5 | 6 | storiesOf(STORY, module) 7 | .addDecorator( 8 | host({ 9 | title: 'A host container for components under test.', 10 | align: 'center bottom', 11 | height: '80%', 12 | width: 400, 13 | // hr: false, 14 | // padding: '0 200px', 15 | // padding: [20, 20, 35, 20], 16 | 17 | background: true, 18 | // backdrop: '#2196F3', // BLUE 19 | // backdrop: true, 20 | // cropMarks: false, 21 | // border: 'dashed 1px red', 22 | border: -0.1, 23 | // border: true, 24 | }), 25 | ) 26 | .add('MyComponent', () => ); 27 | 28 | storiesOf(STORY, module) 29 | .addDecorator( 30 | host({ title: 'Backdrop set to a color with boolean.', backdrop: true }), 31 | ) 32 | .add('backdrop: true (RED)', () => ); 33 | 34 | storiesOf(STORY, module) 35 | .addDecorator( 36 | host({ title: 'Backdrop set to a hex color.', backdrop: '#2196F3' }), 37 | ) 38 | .add('backdrop: blue', () => ); 39 | 40 | storiesOf(STORY, module) 41 | .addDecorator( 42 | host({ 43 | title: 'Backdrop set to a color with number (-1..1).', 44 | backdrop: -0.1, 45 | }), 46 | ) 47 | .add('backdrop: -0.1', () => ); 48 | 49 | storiesOf(STORY, module) 50 | .addDecorator( 51 | host({ 52 | title: 'Backdrop set to a color with number (-1..1).', 53 | backdrop: -0.5, 54 | }), 55 | ) 56 | .add('backdrop: -0.5', () => ); 57 | 58 | storiesOf(STORY, module) 59 | .addDecorator( 60 | host({ 61 | title: 'Backdrop set to a color with number (-1..1).', 62 | backdrop: -0.8, 63 | }), 64 | ) 65 | .add('backdrop: -0.8', () => ); 66 | 67 | storiesOf(STORY, module) 68 | .addDecorator(host({ title: 'Backdrop not set (none).' })) 69 | .add('backdrop: none (white)', () => ); 70 | 71 | storiesOf(STORY, module) 72 | .addDecorator( 73 | host({ 74 | title: 'Component background set with number.', 75 | backdrop: -0.1, 76 | background: 1, 77 | border: -0.3, 78 | }), 79 | ) 80 | .add('backdrop: -0.1, background: 1', () => ); 81 | 82 | storiesOf(STORY, module) 83 | .addDecorator( 84 | host({ 85 | title: 'Width height set to 100%', 86 | width: '100%', 87 | height: '100%', 88 | }), 89 | ) 90 | .add('width/height: 100%', () => ); 91 | 92 | storiesOf(STORY, module) 93 | .addDecorator( 94 | host({ 95 | title: 'maxWidth set to 400px', 96 | width: '100%', 97 | maxWidth: 400, 98 | }), 99 | ) 100 | .add('maxWidth: 400px', () => ); 101 | 102 | storiesOf(STORY, module) 103 | .addDecorator( 104 | host({ 105 | title: 'Flex applied to component container (boolean)', 106 | width: '100%', 107 | height: '100%', 108 | flex: true, 109 | }), 110 | ) 111 | .add('flex: true', () => ); 112 | 113 | storiesOf(STORY, module) 114 | .addDecorator( 115 | host({ 116 | title: 117 | 'Flex applied to component container, with child filling available space', 118 | width: '100%', 119 | height: '100%', 120 | flex: true, 121 | }), 122 | ) 123 | .add('flex: fill', () => ); 124 | 125 | storiesOf(STORY, module) 126 | .addDecorator( 127 | host({ 128 | title: 129 | 'Flex applied to component container, with children filling available space', 130 | width: '100%', 131 | height: '100%', 132 | }), 133 | ) 134 | .add( 135 | 'flex (column): fill', 136 | () => ( 137 | <> 138 | 139 | 140 | 141 | ), 142 | { 143 | host: { 144 | flex: { 145 | flexDirection: 'column', 146 | alignItems: 'center', 147 | }, 148 | }, 149 | }, 150 | ); 151 | -------------------------------------------------------------------------------- /src/common/util.ts: -------------------------------------------------------------------------------- 1 | import { R } from './libs'; 2 | 3 | /** 4 | * Returns a copy of the array with falsey values removed. 5 | * Removes: 6 | * - null 7 | * - undefined 8 | * - empty-string ('') 9 | * 10 | * @param {Array} value: The value to examine. 11 | * @return {Array}. 12 | */ 13 | export const compact = (value: any[]) => 14 | R.pipe( 15 | R.reject(R.isNil), 16 | R.reject(R.isEmpty), 17 | )(value); 18 | 19 | /** 20 | * Determines whether the value is a simple object (ie. not a class instance). 21 | * @param value: The value to examine. 22 | * @return {Boolean}. 23 | */ 24 | export const isPlainObject = (value: any): boolean => { 25 | if (R.is(Object, value) === false) { 26 | return false; 27 | } 28 | 29 | // Not plain if it has a modified constructor. 30 | const ctr = value.constructor; 31 | if (typeof ctr !== 'function') { 32 | return false; 33 | } 34 | 35 | // If has modified prototype. 36 | const prot = ctr.prototype; 37 | if (R.is(Object, prot) === false) { 38 | return false; 39 | } 40 | 41 | // If the constructor does not have an object-specific method. 42 | if (prot.hasOwnProperty('isPrototypeOf') === false) { 43 | return false; 44 | } 45 | 46 | // Finish up. 47 | return true; 48 | }; 49 | 50 | /** 51 | * A safe way to test any value as to wheather is is 'blank' 52 | * meaning it can be either: 53 | * - null 54 | * - undefined 55 | * - empty-string ('') 56 | * - empty-array ([]). 57 | */ 58 | export const isBlank = (value: any): boolean => { 59 | if (value === null || value === undefined) { 60 | return true; 61 | } 62 | if (R.is(Array, value) && compact(value).length === 0) { 63 | return true; 64 | } 65 | if (R.is(String, value) && value.trim() === '') { 66 | return true; 67 | } 68 | return false; 69 | }; 70 | 71 | /** 72 | * Determines whether the given value is a number, or can be 73 | * parsed into a number. 74 | * 75 | * NOTE: Examines string values to see if they are numeric. 76 | * 77 | * @param value: The value to examine. 78 | * @returns true if the value is a number. 79 | */ 80 | export const isNumeric = (value: any) => { 81 | if (isBlank(value)) { 82 | return false; 83 | } 84 | const num = parseFloat(value); 85 | if (num === undefined) { 86 | return false; 87 | } 88 | if (num.toString().length !== value.toString().length) { 89 | return false; 90 | } 91 | return !Number.isNaN(num); 92 | }; 93 | 94 | /** 95 | * Converts a value to a number if possible. 96 | * @param value: The value to convert. 97 | * @returns the converted number, otherwise the original value. 98 | */ 99 | export const toNumber = (value: any) => { 100 | if (isBlank(value)) { 101 | return value; 102 | } 103 | const num = parseFloat(value); 104 | if (num === undefined) { 105 | return value; 106 | } 107 | if (num.toString().length !== value.toString().length) { 108 | return value; 109 | } 110 | return Number.isNaN(num) ? value : num; 111 | }; 112 | 113 | /** 114 | * Converts a value to boolean (if it can). 115 | * @param value: The value to convert. 116 | * @param defaultValue: The value to return if the given value is null/undefined. 117 | * @returns the converted boolean, otherwise the original value. 118 | */ 119 | export const toBool = (value: any, defaultValue: any = undefined) => { 120 | if (R.isNil(value)) { 121 | return defaultValue; 122 | } 123 | if (R.is(Boolean, value)) { 124 | return value; 125 | } 126 | const asString = value 127 | .toString() 128 | .trim() 129 | .toLowerCase(); 130 | if (asString === 'true') { 131 | return true; 132 | } 133 | if (asString === 'false') { 134 | return false; 135 | } 136 | return defaultValue; 137 | }; 138 | 139 | /** 140 | * Converts a string it's actual type if it can be derived. 141 | * @param {string} string: The string to convert. 142 | * @return the original or converted value. 143 | */ 144 | export const toType = (value: any) => { 145 | if (!R.is(String, value)) { 146 | return value; 147 | } 148 | const lowerCase = value.toLowerCase().trim(); 149 | 150 | // Boolean. 151 | if (lowerCase === 'true') { 152 | return true; 153 | } 154 | if (lowerCase === 'false') { 155 | return false; 156 | } 157 | 158 | // Number. 159 | const num = toNumber(lowerCase); 160 | if (R.is(Number, num)) { 161 | return num; 162 | } 163 | 164 | // Originanl type. 165 | return value; 166 | }; 167 | -------------------------------------------------------------------------------- /src/components/ComponentHost/ComponentHost.tsx: -------------------------------------------------------------------------------- 1 | import { React, R, css, color, GlamorValue } from '../../common'; 2 | import { AlignEdge } from '../../common/alignment'; 3 | import { AlignmentContainer } from '../AlignmentContainer'; 4 | import { CropMarks } from '../CropMarks'; 5 | import { CSSProperties } from 'react'; 6 | import { StoryContext } from '@storybook/addons'; 7 | 8 | const RED = 'rgba(255, 0, 0, 0.1)'; 9 | 10 | type FlexConfig = { 11 | flexDirection?: CSSProperties['flexDirection']; 12 | justifyContent?: CSSProperties['justifyContent']; 13 | alignItems?: CSSProperties['alignItems']; 14 | flexWrap?: CSSProperties['flexWrap']; 15 | alignContent?: CSSProperties['alignContent']; 16 | }; 17 | 18 | export interface IHostOptions { 19 | title?: string; 20 | hr?: boolean; 21 | padding?: number | string | number[]; 22 | align?: AlignEdge; 23 | width?: number | string; 24 | height?: number | string; 25 | maxWidth?: number | string; 26 | background?: string | number | boolean; 27 | backdrop?: string | number | boolean; 28 | cropMarks?: boolean; 29 | border?: string | number | boolean; // Number between -1 (black) and 1 (white). 30 | styles?: any; // NB: For inserting global . 31 | flex?: boolean | FlexConfig; 32 | } 33 | 34 | export interface IComponentHostProps { 35 | story: (context: StoryContext) => any; 36 | context: StoryContext; 37 | options: IHostOptions; 38 | parameters: IHostOptions; 39 | } 40 | 41 | /** 42 | * A host container for components under test. 43 | */ 44 | export const ComponentHost = (props: IComponentHostProps) => { 45 | const combinedOptions = { 46 | ...props.options, 47 | ...props.parameters, 48 | flex: (() => { 49 | if (props.parameters.flex === false) { 50 | // parameters overrides 51 | return false; 52 | } 53 | if (isObject(props.options.flex) || isObject(props.parameters.flex)) { 54 | return { 55 | ...(isObject(props.options.flex) ? props.options.flex : {}), 56 | ...(isObject(props.parameters.flex) ? props.parameters.flex : {}), 57 | }; 58 | } else if (props.options.flex || props.parameters.flex) { 59 | // handle flex: true 60 | return true; 61 | } 62 | return false; 63 | })(), 64 | }; 65 | 66 | const { 67 | title, 68 | align, 69 | width, 70 | height, 71 | maxWidth, 72 | padding = 50, 73 | background, 74 | backdrop = 'white', 75 | cropMarks = true, 76 | border = 0, 77 | flex, 78 | } = combinedOptions; 79 | 80 | const { story } = props; 81 | let { hr } = combinedOptions; 82 | 83 | // Default values. 84 | hr = hr === false ? false : true; 85 | const componentBackground = formatColor(background); 86 | const backdropColor = color.create(formatColor(backdrop)); 87 | const isBackdropDark = isDark(backdropColor); 88 | 89 | const cropMarkColor = isBackdropDark 90 | ? 'rgba(255, 255, 255, 0.3)' 91 | : 'rgba(0, 0, 0, 0.1)'; 92 | 93 | const titleColor = isBackdropDark 94 | ? 'rgba(255, 255, 255, 0.7)' 95 | : 'rgba(0, 0, 0, 0.6)'; 96 | 97 | let componentBorder = border as string; 98 | if (R.is(Number, border)) { 99 | componentBorder = `solid 1px ${color.toGrayAlpha(border as number)}`; 100 | } 101 | if (border === true) { 102 | componentBorder = `dashed 1px rgba(0, 0, 0, 0.2)`; 103 | } 104 | 105 | const styles = { 106 | base: css({ 107 | Absolute: 0, 108 | display: 'flex', 109 | flexDirection: 'column', 110 | background: backdropColor.toRgbString(), 111 | }), 112 | normalizeText: css({ 113 | // Copied in from `normalize.css` template. 114 | fontFamily: 'sans-serif' /* 1 */, 115 | lineHeight: 1.15 /* 2 */, 116 | msTextSizeAdjust: '100%' /* 3 */, 117 | WebkitTextSizeAdjust: '100%' /* 3 */, 118 | }), 119 | header: css({ 120 | borderBottom: hr ? `solid 1px ${cropMarkColor}` : undefined, 121 | paddingTop: 2, 122 | paddingBottom: hr ? 15 : undefined, 123 | marginLeft: 15, 124 | marginTop: 15, 125 | marginRight: 15, 126 | }), 127 | h2: css({ 128 | fontWeight: 200, 129 | fontSize: 20, 130 | padding: 0, 131 | margin: 0, 132 | color: titleColor, 133 | }), 134 | body: css({ 135 | boxSizing: 'border-box', 136 | position: 'relative', 137 | flex: 1, 138 | margin: formatMarginPadding(padding), 139 | }), 140 | }; 141 | 142 | const flexStyle: GlamorValue = (() => { 143 | if (!flex) { 144 | return {}; 145 | } 146 | if (flex === true) { 147 | return { 148 | display: 'flex', 149 | }; 150 | } 151 | return { 152 | display: 'flex', 153 | ...flex, 154 | }; 155 | })(); 156 | 157 | return ( 158 |
159 | {title && ( 160 |
161 |

{title}

162 |
163 | )} 164 | {combinedOptions.styles} 165 |
166 | 167 | 177 | {story(props.context)} 178 | 179 | 180 |
181 |
182 | ); 183 | }; 184 | 185 | /** 186 | * INTERNAL 187 | */ 188 | function formatColor(value?: string | number | boolean): string | void { 189 | if (value === undefined) { 190 | return; 191 | } 192 | if (R.is(Number, value)) { 193 | return color.toGrayHex(value as number); 194 | } 195 | if (value === true) { 196 | return RED; 197 | } 198 | return value as string; 199 | } 200 | 201 | function isDark(color: tinycolor.Instance): boolean { 202 | return color.getAlpha() < 0.4 ? false : color.getBrightness() < 130; 203 | } 204 | 205 | function formatMarginPadding( 206 | value: number | string | number[], 207 | ): number | string { 208 | if (R.is(Array, value)) { 209 | return (value as number[]) 210 | .slice(0, 4) 211 | .map(n => `${n}px`) 212 | .join(' '); 213 | } 214 | return value as number | string; 215 | } 216 | 217 | function isObject(value: any): value is object { 218 | return value !== null && typeof value === 'object'; 219 | } 220 | -------------------------------------------------------------------------------- /src/common/css/test/css-positioning.test.ts: -------------------------------------------------------------------------------- 1 | import { expect } from 'chai'; 2 | import { transformStyle, toPositionEdges } from '../css'; 3 | 4 | describe('React: transformStyle - positioning', () => { 5 | describe('converting from transformStyle - function', () => { 6 | it('converts an `Absolute` value (deep)', () => { 7 | const style = transformStyle({ Absolute: '10 20em 30px 40' }) as any; 8 | expect(style.position).to.equal('absolute'); 9 | expect(style.top).to.equal(10); 10 | expect(style.right).to.equal('20em'); 11 | expect(style.bottom).to.equal('30px'); 12 | expect(style.left).to.equal(40); 13 | }); 14 | 15 | it('converts a `Fixed` value', () => { 16 | const style = transformStyle({ Fixed: '10 20em 30px 40' }) as any; 17 | expect(style.position).to.equal('fixed'); 18 | expect(style.top).to.equal(10); 19 | expect(style.right).to.equal('20em'); 20 | expect(style.bottom).to.equal('30px'); 21 | expect(style.left).to.equal(40); 22 | }); 23 | 24 | it('converts array value (with null\'s)', () => { 25 | const style = transformStyle({ 26 | Absolute: ['10', null, '30px', '40'], 27 | }) as any; 28 | expect(style.position).to.equal('absolute'); 29 | expect(style.top).to.equal(10); 30 | expect(style.right).to.equal(undefined); 31 | expect(style.bottom).to.equal('30px'); 32 | expect(style.left).to.equal(40); 33 | }); 34 | 35 | it('does nothing with an [undefined] value', () => { 36 | expect(transformStyle({ Absolute: undefined })).to.eql({}); 37 | }); 38 | 39 | it('does nothing with an [empty-string] value', () => { 40 | expect(transformStyle({ Absolute: '' })).to.eql({}); 41 | }); 42 | }); 43 | 44 | describe('AbsoluteCenter', function() { 45 | it('converts `x`', () => { 46 | const style = transformStyle({ AbsoluteCenter: 'x' }) as any; 47 | expect(style.position).to.equal('absolute'); 48 | expect(style.left).to.equal('50%'); 49 | expect(style.top).to.equal(undefined); 50 | expect(style.transform).to.equal('translateX(-50%)'); 51 | }); 52 | 53 | it('converts `y`', () => { 54 | const style = transformStyle({ AbsoluteCenter: 'y' }) as any; 55 | expect(style.position).to.equal('absolute'); 56 | expect(style.left).to.equal(undefined); 57 | expect(style.top).to.equal('50%'); 58 | expect(style.transform).to.equal('translateY(-50%)'); 59 | }); 60 | 61 | it('converts `xy`', () => { 62 | const style = transformStyle({ AbsoluteCenter: 'xy' }) as any; 63 | expect(style.position).to.equal('absolute'); 64 | expect(style.left).to.equal('50%'); 65 | expect(style.top).to.equal('50%'); 66 | expect(style.transform).to.equal('translate(-50%, -50%)'); 67 | }); 68 | 69 | it('retains top value (x)', () => { 70 | const style = transformStyle({ 71 | left: 0, 72 | top: 0, 73 | AbsoluteCenter: 'x', 74 | }) as any; 75 | expect(style.position).to.equal('absolute'); 76 | expect(style.left).to.equal('50%'); 77 | expect(style.top).to.equal(0); 78 | expect(style.transform).to.equal('translateX(-50%)'); 79 | }); 80 | 81 | it('retains left value (y)', () => { 82 | const style = transformStyle({ 83 | left: 0, 84 | top: 0, 85 | AbsoluteCenter: 'y', 86 | }) as any; 87 | expect(style.position).to.equal('absolute'); 88 | expect(style.left).to.equal(0); 89 | expect(style.top).to.equal('50%'); 90 | expect(style.transform).to.equal('translateY(-50%)'); 91 | }); 92 | }); 93 | 94 | describe('toPositionEdges', () => { 95 | it('all edges from string', () => { 96 | const style = toPositionEdges('Absolute', '10 20 30em 40') as any; 97 | expect(style.position).to.equal('absolute'); 98 | expect(style.top).to.equal(10); 99 | expect(style.right).to.equal(20); 100 | expect(style.bottom).to.equal('30em'); 101 | expect(style.left).to.equal(40); 102 | }); 103 | 104 | it('string containing `null`', () => { 105 | const style = toPositionEdges('Absolute', '10 null 30em null') as any; 106 | expect(style.top).to.equal(10); 107 | expect(style.right).to.equal(undefined); 108 | expect(style.bottom).to.equal('30em'); 109 | expect(style.left).to.equal(undefined); 110 | }); 111 | 112 | describe('array', () => { 113 | it('all edges', () => { 114 | const style = toPositionEdges('Absolute', [ 115 | '10', 116 | '20', 117 | '30em', 118 | '40', 119 | ]) as any; 120 | expect(style.top).to.equal(10); 121 | expect(style.right).to.equal(20); 122 | expect(style.bottom).to.equal('30em'); 123 | expect(style.left).to.equal(40); 124 | }); 125 | 126 | it('all edges (0)', () => { 127 | const style = toPositionEdges('Absolute', [0, 0, 0, 0]) as any; 128 | expect(style.top).to.equal(0); 129 | expect(style.right).to.equal(0); 130 | expect(style.bottom).to.equal(0); 131 | expect(style.left).to.equal(0); 132 | }); 133 | 134 | it('empty array', () => { 135 | const style = toPositionEdges('Absolute', []) as any; 136 | expect(style).to.equal(undefined); 137 | }); 138 | 139 | it('single value array [0]', () => { 140 | const style = toPositionEdges('Absolute', [0]) as any; 141 | expect(style.top).to.equal(0); 142 | expect(style.right).to.equal(0); 143 | expect(style.bottom).to.equal(0); 144 | expect(style.left).to.equal(0); 145 | }); 146 | 147 | it('array containing `null` values', () => { 148 | const style = toPositionEdges('Absolute', [ 149 | null, 150 | 10, 151 | null, 152 | null, 153 | ]) as any; 154 | expect(style.top).to.equal(undefined); 155 | expect(style.right).to.equal(10); 156 | expect(style.bottom).to.equal(undefined); 157 | expect(style.left).to.equal(undefined); 158 | }); 159 | 160 | it('array containing all `null` values', () => { 161 | const style = toPositionEdges('Absolute', [ 162 | null, 163 | null, 164 | null, 165 | null, 166 | ]) as any; 167 | expect(style).to.equal(undefined); 168 | }); 169 | }); 170 | 171 | describe('shorthand', () => { 172 | it('empty-string', () => { 173 | const style = toPositionEdges('Absolute', '') as any; 174 | expect(style).to.equal(undefined); 175 | }); 176 | 177 | it('undefined', () => { 178 | const style = toPositionEdges('Absolute') as any; 179 | expect(style).to.equal(undefined); 180 | }); 181 | 182 | it('1-value', () => { 183 | const style = toPositionEdges('Absolute', '10') as any; 184 | expect(style.top).to.equal(10); 185 | expect(style.right).to.equal(10); 186 | expect(style.bottom).to.equal(10); 187 | expect(style.left).to.equal(10); 188 | }); 189 | 190 | it('1-value / Number', () => { 191 | const style = toPositionEdges('Absolute', 10) as any; 192 | expect(style.top).to.equal(10); 193 | expect(style.right).to.equal(10); 194 | expect(style.bottom).to.equal(10); 195 | expect(style.left).to.equal(10); 196 | }); 197 | 198 | it('2-values', () => { 199 | const style = toPositionEdges('Absolute', '10 30em') as any; 200 | expect(style.top).to.equal(10); 201 | expect(style.right).to.equal('30em'); 202 | expect(style.bottom).to.equal(10); 203 | expect(style.left).to.equal('30em'); 204 | }); 205 | 206 | it('3-values', () => { 207 | const style = toPositionEdges('Absolute', '10 30em 40') as any; 208 | expect(style.top).to.equal(10); 209 | expect(style.right).to.equal('30em'); 210 | expect(style.left).to.equal('30em'); 211 | expect(style.bottom).to.equal(40); 212 | }); 213 | }); 214 | }); 215 | }); 216 | -------------------------------------------------------------------------------- /src/common/css/css.ts: -------------------------------------------------------------------------------- 1 | import { R, React } from '../libs'; 2 | import { isBlank, toNumber, isPlainObject } from '../util'; 3 | import { 4 | IFormatCss, 5 | IImageOptions, 6 | IBackgroundImageStyles, 7 | Falsy, 8 | GlamorValue, 9 | } from './types'; 10 | import { css as glamorCss } from 'glamor'; 11 | 12 | export const MEDIA_QUERY_RETINA = `@media (-webkit-min-device-pixel-ratio: 2), (min-resolution: 192dpi)`; 13 | 14 | /** 15 | * Constructs a style object for an image. 16 | * 17 | * For turning image files (PNG/JPG/SVG) into data-uri's see: 18 | * https://github.com/webpack/url-loader 19 | * 20 | * @param {string} image1x: The normal image resolution (base64 encoded) 21 | * @param {string} image2x: The retina image resolution (base64 encoded) 22 | * @param {integer} width: Optional. The width of the image. 23 | * @param {integer} height: Optional. The height of the image. 24 | */ 25 | export const image = ( 26 | image1x: string | undefined, 27 | image2x: string | undefined, 28 | options: IImageOptions = { width: 10, height: 10 }, 29 | ): IBackgroundImageStyles => { 30 | // Prepare image based on current screen density. 31 | if (!image1x) { 32 | throw new Error('Must have at least a 1x image.'); 33 | } 34 | const { width, height } = options; 35 | const result: any = { 36 | width, 37 | height, 38 | backgroundImage: `url(${image1x})`, 39 | backgroundSize: `${width}px ${height}px`, 40 | backgroundRepeat: 'no-repeat', 41 | }; 42 | 43 | if (image2x) { 44 | result[MEDIA_QUERY_RETINA] = { 45 | backgroundImage: `url(${image2x})`, 46 | }; 47 | } 48 | 49 | // Finish up. 50 | return result; 51 | }; 52 | 53 | const mergeAndReplace = (key: string, value: any, target: any) => { 54 | Object.assign(target, value); 55 | delete target[key]; 56 | return target; 57 | }; 58 | 59 | const formatImage = ( 60 | key: string, 61 | value: Array, 62 | target: any, 63 | ) => { 64 | // Wrangle parameters. 65 | let [image1x, image2x, width, height] = value; // tslint:disable-line 66 | 67 | if (R.is(Number, image2x)) { 68 | height = width; 69 | width = image2x; 70 | image2x = undefined; 71 | } 72 | const options = { 73 | width: width as number, 74 | height: height as number, 75 | }; 76 | const style = image(image1x as string, image2x as string, options); 77 | mergeAndReplace(key, style, target); 78 | }; 79 | 80 | export const toPositionEdges = ( 81 | key: string, 82 | value: any = undefined, 83 | ): { 84 | position: string; 85 | top: number | void; 86 | right: number | void; 87 | bottom: number | void; 88 | left: number | void; 89 | } | void => { 90 | if (value === undefined || value === null) { 91 | return undefined; 92 | } 93 | if (R.is(String, value) && isBlank(value)) { 94 | return undefined; 95 | } 96 | if (R.is(Array, value) && value.length === 0) { 97 | return undefined; 98 | } 99 | if (!R.is(Array, value)) { 100 | value = value.toString().split(' '); 101 | } 102 | const edges = value.map((item: any) => toNumber(item)); 103 | let top: number | void; 104 | let right: number | void; 105 | let bottom: number | void; 106 | let left: number | void; 107 | 108 | const getEdge = (index: number): number | void => { 109 | const edge = edges[index]; 110 | if (edge === null || edge === 'null' || edge === '') { 111 | return undefined; 112 | } 113 | return edge; 114 | }; 115 | 116 | switch (edges.length) { 117 | case 1: 118 | top = getEdge(0); 119 | bottom = getEdge(0); 120 | left = getEdge(0); 121 | right = getEdge(0); 122 | break; 123 | 124 | case 2: 125 | top = getEdge(0); 126 | bottom = getEdge(0); 127 | left = getEdge(1); 128 | right = getEdge(1); 129 | break; 130 | 131 | case 3: 132 | top = getEdge(0); 133 | left = getEdge(1); 134 | right = getEdge(1); 135 | bottom = getEdge(2); 136 | break; 137 | 138 | default: 139 | top = getEdge(0); 140 | right = getEdge(1); 141 | bottom = getEdge(2); 142 | left = getEdge(3); 143 | } 144 | 145 | if ( 146 | top === undefined && 147 | right === undefined && 148 | bottom === undefined && 149 | left === undefined 150 | ) { 151 | return undefined; 152 | } 153 | return { 154 | position: key.toLowerCase(), 155 | top, 156 | right, 157 | bottom, 158 | left, 159 | }; 160 | }; 161 | 162 | export const formatPositionEdges = (key: string, target: any) => { 163 | const styles = toPositionEdges(key, target[key]); 164 | mergeAndReplace(key, styles, target); 165 | }; 166 | 167 | /** 168 | * AbsoluteCenter 169 | * - x 170 | * - y 171 | * - xy 172 | */ 173 | const formatAbsoluteCenter = ( 174 | key: string, 175 | value: string | boolean | number, 176 | target: any, 177 | ) => { 178 | if (value === true) { 179 | value = 'xy'; 180 | } 181 | if (value === false || value === undefined || value === null) { 182 | return; 183 | } 184 | const styles = { 185 | position: 'absolute', 186 | left: target.left, 187 | top: target.top, 188 | transform: '', 189 | }; 190 | const stringValue = value 191 | .toString() 192 | .trim() 193 | .toLowerCase(); 194 | if (stringValue.includes('x')) { 195 | styles.left = '50%'; 196 | } 197 | if (stringValue.includes('y')) { 198 | styles.top = '50%'; 199 | } 200 | let transform: string; 201 | switch (value) { 202 | case 'yx': 203 | case 'xy': 204 | transform = 'translate(-50%, -50%)'; 205 | break; 206 | case 'x': 207 | transform = 'translateX(-50%)'; 208 | break; 209 | case 'y': 210 | transform = 'translateY(-50%)'; 211 | break; 212 | default: 213 | throw new Error(`AbsoluteCenter value '${value}' not supported.`); 214 | } 215 | styles.transform = `${target.transform || ''} ${transform}`.trim(); 216 | mergeAndReplace(key, styles, target); 217 | }; 218 | 219 | /** 220 | * Spacing on the X:Y plane. 221 | */ 222 | function formatSpacingPlane( 223 | plane: 'x' | 'y', 224 | prefix: 'margin' | 'padding', 225 | key: string, 226 | value: any, 227 | target: any, 228 | ) { 229 | const styles = {}; 230 | 231 | switch (plane) { 232 | case 'x': 233 | styles[`${prefix}Left`] = value; 234 | styles[`${prefix}Right`] = value; 235 | break; 236 | 237 | case 'y': 238 | styles[`${prefix}Top`] = value; 239 | styles[`${prefix}Bottom`] = value; 240 | break; 241 | 242 | default: 243 | break; // Ignore. 244 | } 245 | 246 | mergeAndReplace(key, styles, target); 247 | } 248 | 249 | /** 250 | * Sets up vertical scrolling including iOS momentum scrolling. 251 | * See: 252 | * https://css-tricks.com/snippets/css/momentum-scrolling-on-ios-overflow-elements/ 253 | */ 254 | function formatScroll(key: string, value: any, target: any) { 255 | if (value === true) { 256 | const styles = { 257 | overflowX: 'hidden', 258 | overflowY: 'scroll', 259 | WebkitOverflowScrolling: 'touch', 260 | }; 261 | mergeAndReplace(key, styles, target); 262 | } 263 | 264 | if (value === false) { 265 | const styles = { 266 | overflow: 'hidden', 267 | }; 268 | mergeAndReplace(key, styles, target); 269 | } 270 | } 271 | 272 | // -------------------------------------------------- 273 | 274 | const AlignMap: { [k: string]: string } = { 275 | center: 'center', 276 | left: 'flex-start', 277 | top: 'flex-start', 278 | start: 'flex-start', 279 | right: 'flex-end', 280 | bottom: 'flex-end', 281 | end: 'flex-end', 282 | full: 'stretch', 283 | stretch: 'stretch', 284 | baseline: 'baseline', 285 | }; 286 | function convertCrossAlignToFlex(token: string): string | undefined { 287 | return AlignMap[token] || undefined; // undefined if not recognised; 288 | } 289 | 290 | const MainAlignMap: { [k: string]: string } = { 291 | center: 'center', 292 | left: 'flex-start', 293 | top: 'flex-start', 294 | start: 'flex-start', 295 | right: 'flex-end', 296 | bottom: 'flex-end', 297 | end: 'flex-end', 298 | spaceBetween: 'space-between', 299 | spaceAround: 'space-around', 300 | spaceEvenly: 'space-evenly', 301 | }; 302 | function convertMainAlignToFlex(token: string): string | undefined { 303 | return MainAlignMap[token] || undefined; // undefined if not recognised; 304 | } 305 | 306 | /** 307 | * Format a flex css helper 308 | * Format: []-- 309 | */ 310 | function formatFlexPosition( 311 | key: string, 312 | value: string, 313 | target: React.CSSProperties, 314 | ) { 315 | let direction: 'row' | 'column' | undefined; // Assume horizontal 316 | let mainAlignment: string | undefined; 317 | let crossAlignment: string | undefined; 318 | 319 | // Tokenize string 320 | const tokens: string[] = value.split('-').map(token => token.trim()); 321 | 322 | tokens.map(token => { 323 | const tokenIsOneOf = (options: string[]) => options.includes(token); 324 | if (direction == null && tokenIsOneOf(['horizontal', 'vertical'])) { 325 | direction = token === 'vertical' ? 'column' : 'row'; // tslint:disable-line 326 | return; 327 | } 328 | 329 | if ( 330 | tokenIsOneOf([ 331 | 'center', 332 | 'start', 333 | 'end', 334 | 'left', 335 | 'right', 336 | 'top', 337 | 'bottom', 338 | 'full', 339 | 'baseline', 340 | ]) 341 | ) { 342 | if (crossAlignment == null) { 343 | if (direction == null && tokenIsOneOf(['left', 'right'])) { 344 | direction = 'column'; 345 | } 346 | if (direction == null && tokenIsOneOf(['top', 'bottom'])) { 347 | direction = 'row'; 348 | } 349 | crossAlignment = convertCrossAlignToFlex(token); 350 | return; 351 | } 352 | mainAlignment = convertMainAlignToFlex(token); 353 | return; 354 | } 355 | 356 | if (tokenIsOneOf(['spaceAround', 'spaceBetween', 'spaceEvenly'])) { 357 | mainAlignment = convertMainAlignToFlex(token); 358 | return; 359 | } 360 | }); 361 | 362 | const styles = { 363 | display: 'flex', 364 | flexDirection: direction, 365 | alignItems: crossAlignment, 366 | justifyContent: mainAlignment, 367 | }; 368 | 369 | mergeAndReplace(key, styles, target); 370 | } 371 | 372 | export const transformStyle = ( 373 | style: React.CSSProperties | GlamorValue | Falsy = {}, 374 | ): React.CSSProperties | GlamorValue => { 375 | if (style == null) { 376 | return {}; 377 | } 378 | if (style === false) { 379 | return {}; 380 | } 381 | if (!R.is(Object, style)) { 382 | return style; 383 | } 384 | Object.keys(style).forEach(key => { 385 | const value = style[key]; 386 | if (value === false || R.isNil(value)) { 387 | delete style[key]; 388 | } else if (isPlainObject(value)) { 389 | // NB: This is not using formatCss, as we only want the transform, we don't want to convert it to a glamor value. 390 | style[key] = transformStyle(value); // <== RECURSION. 391 | } else { 392 | switch (key) { 393 | case 'Image': 394 | formatImage(key, value, style); 395 | break; 396 | case 'Absolute': 397 | formatPositionEdges(key, style); 398 | break; 399 | case 'Fixed': 400 | formatPositionEdges(key, style); 401 | break; 402 | case 'AbsoluteCenter': 403 | formatAbsoluteCenter(key, value, style); 404 | break; 405 | case 'MarginX': 406 | formatSpacingPlane('x', 'margin', key, value, style); 407 | break; 408 | case 'MarginY': 409 | formatSpacingPlane('y', 'margin', key, value, style); 410 | break; 411 | case 'PaddingX': 412 | formatSpacingPlane('x', 'padding', key, value, style); 413 | break; 414 | case 'PaddingY': 415 | formatSpacingPlane('y', 'padding', key, value, style); 416 | break; 417 | case 'Flex': 418 | formatFlexPosition(key, value, style); 419 | break; 420 | case 'Scroll': 421 | formatScroll(key, value, style); 422 | break; 423 | default: 424 | // Ignore. 425 | } 426 | } 427 | }); 428 | 429 | return style; 430 | }; 431 | 432 | /** 433 | * Helpers for constructing a CSS object. 434 | * NB: This doesn't *actually* return React.CSSProperties, but 435 | */ 436 | const formatCss = ( 437 | ...styles: Array 438 | ): GlamorValue => { 439 | const newStyles = styles.map(transformStyle); 440 | 441 | // Finish up. 442 | return glamorCss(...newStyles) as {}; 443 | }; 444 | 445 | (formatCss as any).image = image; 446 | export const format = formatCss as IFormatCss; 447 | --------------------------------------------------------------------------------