Cover scenarios primarily for adjusted behavior on Button, as well as ensure components are exported correctly.tags/0.3.0
@@ -8,7 +8,6 @@ utilities/ | |||
.editorconfig | |||
.prettierrc | |||
doczrc.js | |||
global.d.ts | |||
jest.config.js | |||
jest.setup.ts | |||
plopfile.js | |||
@@ -4,6 +4,6 @@ | |||
"printWidth": 120, | |||
"semi": false, | |||
"trailingComma": "all", | |||
"quoteProps": "consistent", | |||
"quoteProps": "as-needed", | |||
"arrowParens": "always" | |||
} |
@@ -1,16 +1,17 @@ | |||
/// <reference types="jest-enzyme" /> | |||
/// <reference path="../../../utilities/jest/extensions.ts" /> | |||
import * as fc from 'fast-check' | |||
import * as Enzyme from 'enzyme' | |||
import * as Axe from 'jest-axe' | |||
import * as React from 'react' | |||
import Button, { Variant } from './Button' | |||
import Button, { Variant, ButtonElement } from './Button' | |||
import stringify from '../../services/stringify' | |||
const CUSTOM_VARIANTS: string[] = ['primary'] | |||
const AVAILABLE_VARIANTS: string[] = ['outline', 'primary'] | |||
const BLOCK_DISPLAYS = ['block', 'grid', 'flex', 'table'] | |||
it('should exist', () => { | |||
expect(Button).toBeDefined() | |||
}) | |||
@@ -23,21 +24,46 @@ it('should render without crashing given minimum required props', () => { | |||
expect(() => <Button />).not.toThrow() | |||
}) | |||
it('should render a border', () => { | |||
const wrapper = Enzyme.shallow(<Button />) | |||
expect(wrapper.find('button').find('span')).toHaveLength(1) | |||
it('should render a button element for button kind', () => { | |||
const wrapper = Enzyme.shallow(<Button element="button" />) | |||
expect(wrapper.name()).toBe('button') | |||
}) | |||
it('should render an a element for anchor kind', () => { | |||
const wrapper = Enzyme.shallow(<Button element="a" />) | |||
expect(wrapper.name()).toBe('a') | |||
}) | |||
it('should render fullwidth when declared as block', () => { | |||
const wrapper = Enzyme.shallow(<Button block />) | |||
describe('on unknown kinds', () => { | |||
let originalConsoleError: typeof console.error | |||
expect(wrapper.find('button')).toHaveStyle('width', '100%') | |||
beforeEach(() => { | |||
originalConsoleError = console.error | |||
console.error = () => {} | |||
}) | |||
afterEach(() => { | |||
console.error = originalConsoleError | |||
}) | |||
it('should render null', () => { | |||
fc.assert( | |||
fc.property(fc.string().filter(s => !['button', 'a'].includes(s)), (element) => { | |||
const wrapper = Enzyme.shallow(<Button element={element as ButtonElement} />) | |||
expect(wrapper.isEmptyRender()).toBe(true) | |||
}), | |||
{ | |||
numRuns: 300, | |||
}, | |||
) | |||
}) | |||
}) | |||
it('should render as block element when declared as block', () => { | |||
const wrapper = Enzyme.shallow(<Button block />) | |||
expect(BLOCK_DISPLAYS).toContain(wrapper.find('button').prop('style')!.display) | |||
it('should render a border', () => { | |||
const wrapper = Enzyme.shallow(<Button />) | |||
expect(wrapper.find('button').find('span')).toHaveLength(1) | |||
}) | |||
it('should render a label', () => { | |||
@@ -53,6 +79,18 @@ it('should render a label', () => { | |||
) | |||
}) | |||
describe('on disabled', () => { | |||
it('should render a disabled button element for button kind', () => { | |||
const wrapper = Enzyme.shallow(<Button disabled element="button" />) | |||
expect(wrapper.find('button')).toHaveProp('disabled', true) | |||
}) | |||
it('should render a span element for anchor kind', () => { | |||
const wrapper = Enzyme.shallow(<Button disabled element="a" />) | |||
expect(wrapper.name()).toBe('span') | |||
}) | |||
}) | |||
describe.each(AVAILABLE_VARIANTS)('with %p variant', (rawVariant) => { | |||
const variant = rawVariant as Variant | |||
@@ -77,7 +115,7 @@ describe.each(AVAILABLE_VARIANTS)('with %p variant', (rawVariant) => { | |||
}) | |||
describe('with unknown variants', () => { | |||
let originalConsoleError: any | |||
let originalConsoleError: typeof console.error | |||
beforeEach(() => { | |||
// silence console.error() from prop type validation since | |||
@@ -1,64 +1,86 @@ | |||
import * as React from 'react' | |||
import * as PropTypes from 'prop-types' | |||
import styled from 'styled-components' | |||
import styled, { CSSObject } from 'styled-components' | |||
import { Size, SizeMap } from '../../services/utilities' | |||
import stringify from '../../services/stringify' | |||
export type Variant = 'outline' | 'primary' | |||
export type ButtonElement = 'a' | 'button' | |||
type ButtonType = 'submit' | 'reset' | 'button' | |||
const MIN_HEIGHTS: SizeMap<string | number> = { | |||
small: '2.5rem', | |||
medium: '3rem', | |||
large: '4rem', | |||
} | |||
const Base = styled('button')({ | |||
'appearance': 'none', | |||
'padding': '0 1rem', | |||
'font': 'inherit', | |||
'fontFamily': 'var(--font-family-base)', | |||
'textTransform': 'uppercase', | |||
'fontWeight': 'bolder', | |||
'borderRadius': '0.25rem', | |||
'placeContent': 'center', | |||
'position': 'relative', | |||
'cursor': 'pointer', | |||
'border': 0, | |||
'userSelect': 'none', | |||
'textDecoration': 'none', | |||
'transitionProperty': 'background-color, color', | |||
'whiteSpace': 'nowrap', | |||
'lineHeight': 1, | |||
const disabledButtonStyles: CSSObject = { | |||
opacity: 0.5, | |||
cursor: 'not-allowed', | |||
} | |||
const buttonStyles: CSSObject = { | |||
display: 'grid', | |||
appearance: 'none', | |||
padding: '0 1rem', | |||
font: 'inherit', | |||
fontFamily: 'var(--font-family-base)', | |||
textTransform: 'uppercase', | |||
fontWeight: 'bolder', | |||
borderRadius: '0.25rem', | |||
placeContent: 'center', | |||
position: 'relative', | |||
cursor: 'pointer', | |||
border: 0, | |||
userSelect: 'none', | |||
textDecoration: 'none', | |||
transitionProperty: 'background-color, color', | |||
whiteSpace: 'nowrap', | |||
lineHeight: 1, | |||
':focus': { | |||
'--color-accent': 'var(--color-active, Highlight)', | |||
'outline': 0, | |||
}, | |||
':disabled': { | |||
opacity: 0.5, | |||
cursor: 'not-allowed', | |||
outline: 0, | |||
}, | |||
':disabled': disabledButtonStyles, | |||
'::-moz-focus-inner': { | |||
outline: 0, | |||
border: 0, | |||
}, | |||
}) | |||
} | |||
const disabledLinkButtonStyles: CSSObject = { | |||
...buttonStyles, | |||
...disabledButtonStyles, | |||
} | |||
const Base = styled('button')(buttonStyles) | |||
Base.displayName = 'button' | |||
const LinkBase = styled('a')(buttonStyles) | |||
LinkBase.displayName = 'a' | |||
const DisabledLinkBase = styled('span')(disabledLinkButtonStyles) | |||
DisabledLinkBase.displayName = 'span' | |||
const Border = styled('span')({ | |||
'borderColor': 'var(--color-accent, blue)', | |||
'boxSizing': 'border-box', | |||
'display': 'inline-block', | |||
'borderWidth': '0.125rem', | |||
'borderStyle': 'solid', | |||
'position': 'absolute', | |||
'top': 0, | |||
'left': 0, | |||
'width': '100%', | |||
'height': '100%', | |||
'borderRadius': 'inherit', | |||
'pointerEvents': 'none', | |||
'transitionProperty': 'border-color', | |||
borderColor: 'var(--color-accent, blue)', | |||
boxSizing: 'border-box', | |||
display: 'inline-block', | |||
borderWidth: '0.125rem', | |||
borderStyle: 'solid', | |||
position: 'absolute', | |||
top: 0, | |||
left: 0, | |||
width: '100%', | |||
height: '100%', | |||
borderRadius: 'inherit', | |||
pointerEvents: 'none', | |||
transitionProperty: 'border-color', | |||
'::before': { | |||
position: 'absolute', | |||
top: 0, | |||
@@ -77,6 +99,19 @@ const Border = styled('span')({ | |||
Border.displayName = 'span' | |||
const defaultVariantStyleSet: React.CSSProperties = { | |||
backgroundColor: 'transparent', | |||
color: 'var(--color-accent, blue)', | |||
} | |||
const variantStyleSets: Record<Variant, React.CSSProperties> = { | |||
outline: defaultVariantStyleSet, | |||
primary: { | |||
backgroundColor: 'var(--color-accent, blue)', | |||
color: 'var(--color-bg, white)', | |||
}, | |||
} | |||
const propTypes = { | |||
/** | |||
* Size of the component. | |||
@@ -85,11 +120,7 @@ const propTypes = { | |||
/** | |||
* Variant of the component. | |||
*/ | |||
variant: PropTypes.oneOf<Exclude<Variant, 'unknown'>>(['outline', 'primary']), | |||
/** | |||
* Should the component take up the remaining space parallel to the content flow? | |||
*/ | |||
block: PropTypes.bool, | |||
variant: PropTypes.oneOf<Variant>(['outline', 'primary']), | |||
/** | |||
* Text to identify the action associated upon activation of the component. | |||
*/ | |||
@@ -98,43 +129,88 @@ const propTypes = { | |||
* Can the component be activated? | |||
*/ | |||
disabled: PropTypes.bool, | |||
/** | |||
* The corresponding HTML element of the component. | |||
*/ | |||
element: PropTypes.oneOf<ButtonElement>(['a', 'button']), | |||
/** | |||
* The URL of the page to navigate to, if element is set to "a". | |||
*/ | |||
href: PropTypes.string, | |||
/** | |||
* The target on where to display the page navigated to, if element is set to "a". | |||
*/ | |||
target: PropTypes.string, | |||
/** | |||
* The relationship of the current page to the referred page in "href", if element is set to "a". | |||
*/ | |||
rel: PropTypes.string, | |||
/** | |||
* The type of the button, if element is set to "button". | |||
*/ | |||
type: PropTypes.oneOf<ButtonType>(['submit', 'reset', 'button']), | |||
} | |||
type Props = PropTypes.InferProps<typeof propTypes> | |||
const defaultVariantStyleSet: React.CSSProperties = { | |||
backgroundColor: 'transparent', | |||
color: 'var(--color-accent, blue)', | |||
} | |||
const variantStyleSets: Record<Variant, React.CSSProperties> = { | |||
outline: defaultVariantStyleSet, | |||
primary: { | |||
backgroundColor: 'var(--color-accent, blue)', | |||
color: 'var(--color-bg, white)', | |||
}, | |||
} | |||
const Button = React.forwardRef<HTMLButtonElement, Props>( | |||
({ size = 'medium', variant = 'outline', block = false, disabled = false, children, ...etcProps }, ref) => { | |||
const Button = React.forwardRef<HTMLAnchorElement | HTMLButtonElement | HTMLSpanElement, Props>( | |||
( | |||
{ | |||
size = 'medium', | |||
variant = 'outline', | |||
disabled = false, | |||
children, | |||
element = 'button', | |||
href, | |||
target, | |||
rel, | |||
type = 'button', | |||
}, | |||
ref, | |||
) => { | |||
const { [variant as Variant]: theVariantStyleSet = defaultVariantStyleSet } = variantStyleSets | |||
return ( | |||
<Base | |||
{...etcProps} | |||
ref={ref} | |||
disabled={disabled!} | |||
style={{ | |||
...theVariantStyleSet, | |||
minHeight: MIN_HEIGHTS[size as Size], | |||
width: block ? '100%' : undefined, | |||
display: block ? 'grid' : 'inline-grid', | |||
}} | |||
> | |||
const commonButtonStyles: React.CSSProperties = { | |||
...theVariantStyleSet, | |||
minHeight: MIN_HEIGHTS[size!], | |||
} | |||
const buttonContent = ( | |||
<React.Fragment> | |||
<Border /> | |||
{stringify(children)} | |||
</Base> | |||
</React.Fragment> | |||
) | |||
switch (element) { | |||
case 'button': | |||
return ( | |||
<Base type={type!} ref={ref as React.Ref<HTMLButtonElement>} disabled={disabled!} style={commonButtonStyles}> | |||
{buttonContent} | |||
</Base> | |||
) | |||
case 'a': | |||
if (disabled) { | |||
return ( | |||
<DisabledLinkBase ref={ref as React.Ref<HTMLSpanElement>} style={commonButtonStyles}> | |||
{buttonContent} | |||
</DisabledLinkBase> | |||
) | |||
} | |||
return ( | |||
<LinkBase | |||
href={href!} | |||
target={target!} | |||
rel={rel!} | |||
ref={ref as React.Ref<HTMLAnchorElement>} | |||
style={commonButtonStyles} | |||
> | |||
{buttonContent} | |||
</LinkBase> | |||
) | |||
default: | |||
break | |||
} | |||
return null | |||
}, | |||
) | |||
@@ -1,3 +1,6 @@ | |||
/// <reference types="jest-enzyme" /> | |||
/// <reference path="../../../utilities/jest/extensions.ts" /> | |||
import * as fc from 'fast-check' | |||
import * as Enzyme from 'enzyme' | |||
import * as Axe from 'jest-axe' | |||
@@ -9,7 +9,7 @@ const Base = styled('div')({ | |||
}) | |||
const CaptureArea = styled('label')({ | |||
'marginTop': '0.25rem', | |||
marginTop: '0.25rem', | |||
'::after': { | |||
content: '""', | |||
}, | |||
@@ -52,18 +52,18 @@ const IndicatorWrapper = styled('span')({ | |||
}) | |||
const Border = styled('span')({ | |||
'borderColor': 'var(--color-accent, blue)', | |||
'boxSizing': 'border-box', | |||
'display': 'inline-block', | |||
'borderWidth': '0.125rem', | |||
'borderStyle': 'solid', | |||
'position': 'absolute', | |||
'top': 0, | |||
'left': 0, | |||
'width': '100%', | |||
'height': '100%', | |||
'borderRadius': 'inherit', | |||
'transitionProperty': 'border-color', | |||
borderColor: 'var(--color-accent, blue)', | |||
boxSizing: 'border-box', | |||
display: 'inline-block', | |||
borderWidth: '0.125rem', | |||
borderStyle: 'solid', | |||
position: 'absolute', | |||
top: 0, | |||
left: 0, | |||
width: '100%', | |||
height: '100%', | |||
borderRadius: 'inherit', | |||
transitionProperty: 'border-color', | |||
'::before': { | |||
position: 'absolute', | |||
top: 0, | |||
@@ -1,3 +1,6 @@ | |||
/// <reference types="jest-enzyme" /> | |||
/// <reference path="../../../utilities/jest/extensions.ts" /> | |||
import * as fc from 'fast-check' | |||
import * as Enzyme from 'enzyme' | |||
import * as React from 'react' | |||
@@ -6,10 +6,10 @@ import { pascalCase, pascalCaseTransformMerge } from 'pascal-case' | |||
import splitValueAndUnit from '../../services/splitValueAndUnit' | |||
const Label = styled('span')({ | |||
'position': 'absolute', | |||
'left': -999999, | |||
'width': 1, | |||
'height': 1, | |||
position: 'absolute', | |||
left: -999999, | |||
width: 1, | |||
height: 1, | |||
':empty': { | |||
display: 'none', | |||
}, | |||
@@ -51,19 +51,6 @@ const propTypes = { | |||
type Props = PropTypes.InferProps<typeof propTypes> | |||
/** | |||
* Component for displaying graphical icons. | |||
* | |||
* @see {@link //feathericons.com|Feather Icons} for a complete list of icons. | |||
* @param {string} name - Name of the icon to display. | |||
* @param {string} weight - Width of the icon's strokes. | |||
* @param {string | number} [size] - Size of the icon. This controls both the width and the height. | |||
* @param {CSSProperties} [style] - CSS style of the icon. For icon dimensions, use `size` instead. | |||
* @param {string} [label] - Describe of what the component represents. | |||
* @param {string} [className] - Class name used for styling. | |||
* @param {object} etcProps - The rest of the props. | |||
* @returns {React.ReactElement | null} - The component elements. | |||
*/ | |||
const Icon: React.FC<Props> = ({ | |||
name, | |||
weight = '0.125rem', | |||
@@ -1,3 +1,6 @@ | |||
/// <reference types="jest-enzyme" /> | |||
/// <reference path="../../../utilities/jest/extensions.ts" /> | |||
import * as fc from 'fast-check' | |||
import * as Enzyme from 'enzyme' | |||
import * as Axe from 'jest-axe' | |||
@@ -8,7 +8,7 @@ const Base = styled('div')({ | |||
}) | |||
const CaptureArea = styled('label')({ | |||
'marginTop': '0.25rem', | |||
marginTop: '0.25rem', | |||
'::after': { | |||
content: '""', | |||
clear: 'both', | |||
@@ -52,18 +52,18 @@ const IndicatorWrapper = styled('span')({ | |||
}) | |||
const Border = styled('span')({ | |||
'borderColor': 'var(--color-accent, blue)', | |||
'boxSizing': 'border-box', | |||
'display': 'inline-block', | |||
'borderWidth': '0.125rem', | |||
'borderStyle': 'solid', | |||
'position': 'absolute', | |||
'top': 0, | |||
'left': 0, | |||
'width': '100%', | |||
'height': '100%', | |||
'borderRadius': 'inherit', | |||
'transitionProperty': 'border-color', | |||
borderColor: 'var(--color-accent, blue)', | |||
boxSizing: 'border-box', | |||
display: 'inline-block', | |||
borderWidth: '0.125rem', | |||
borderStyle: 'solid', | |||
position: 'absolute', | |||
top: 0, | |||
left: 0, | |||
width: '100%', | |||
height: '100%', | |||
borderRadius: 'inherit', | |||
transitionProperty: 'border-color', | |||
'::before': { | |||
position: 'absolute', | |||
top: 0, | |||
@@ -131,21 +131,26 @@ type Props = PropTypes.InferProps<typeof propTypes> | |||
* @see {@link Select} for a similar component suitable for selecting more values. | |||
* @type {React.ComponentType<{readonly label?: string, readonly name?: string} & React.ClassAttributes<unknown>>} | |||
*/ | |||
const RadioButton = React.forwardRef<HTMLInputElement, Props>(({ label = '', name, ...etcProps }, ref) => ( | |||
<Base> | |||
<CaptureArea> | |||
<Input {...etcProps} ref={ref} name={name} type="radio" /> | |||
<IndicatorWrapper> | |||
<Border /> | |||
<Indicator /> | |||
</IndicatorWrapper> | |||
{typeof label! !== 'undefined' && label !== null && ' '} | |||
<Label> | |||
<LabelContent>{stringify(label)}</LabelContent> | |||
</Label> | |||
</CaptureArea> | |||
</Base> | |||
)) | |||
const RadioButton = React.forwardRef<HTMLInputElement, Props>( | |||
( | |||
{ label = '', name, ...etcProps }, | |||
ref | |||
) => ( | |||
<Base> | |||
<CaptureArea> | |||
<Input {...etcProps} ref={ref} name={name} type="radio" /> | |||
<IndicatorWrapper> | |||
<Border /> | |||
<Indicator /> | |||
</IndicatorWrapper> | |||
{typeof label! !== 'undefined' && label !== null && ' '} | |||
<Label> | |||
<LabelContent>{stringify(label)}</LabelContent> | |||
</Label> | |||
</CaptureArea> | |||
</Base> | |||
) | |||
) | |||
RadioButton.propTypes = propTypes | |||
@@ -1,3 +1,6 @@ | |||
/// <reference types="jest-enzyme" /> | |||
/// <reference path="../../../utilities/jest/extensions.ts" /> | |||
import * as fc from 'fast-check' | |||
import * as Enzyme from 'enzyme' | |||
import * as Axe from 'jest-axe' | |||
@@ -68,33 +71,6 @@ it('should render a hint for describing valid input values', () => { | |||
) | |||
}) | |||
describe('on being declared a block component', () => { | |||
const BLOCK_DISPLAYS = ['block', 'grid', 'flex', 'table'] | |||
it('should render the base element fullwidth', () => { | |||
const wrapper = Enzyme.shallow(<Select block />) | |||
expect(BLOCK_DISPLAYS).toContain(wrapper.find('div').prop('style')!.display) | |||
}) | |||
it('should render the input fullwidth', () => { | |||
const wrapper = Enzyme.shallow(<Select block />) | |||
expect(wrapper.find('label').find('select')).toHaveStyle('width', '100%') | |||
}) | |||
it('should render the input as block element', () => { | |||
const wrapper = Enzyme.shallow(<Select block />) | |||
expect(BLOCK_DISPLAYS).toContain( | |||
wrapper | |||
.find('label') | |||
.find('select') | |||
.prop('style')!.display, | |||
) | |||
}) | |||
}) | |||
it('should render as half-opaque when disabled', () => { | |||
const wrapper = Enzyme.shallow(<Select disabled />) | |||
@@ -36,10 +36,10 @@ const SECONDARY_TEXT_SIZES: SizeMap<string | number> = { | |||
} | |||
const ComponentBase = styled('div')({ | |||
'position': 'relative', | |||
'borderRadius': '0.25rem', | |||
'fontFamily': 'var(--font-family-base)', | |||
'maxWidth': '100%', | |||
position: 'relative', | |||
borderRadius: '0.25rem', | |||
fontFamily: 'var(--font-family-base)', | |||
maxWidth: '100%', | |||
':focus-within': { | |||
'--color-accent': 'var(--color-active, Highlight)', | |||
}, | |||
@@ -76,21 +76,22 @@ const LabelWrapper = styled('span')({ | |||
LabelWrapper.displayName = 'span' | |||
const Input = styled('select')({ | |||
'backgroundColor': 'var(--color-bg, white)', | |||
'color': 'var(--color-fg, black)', | |||
'appearance': 'none', | |||
'boxSizing': 'border-box', | |||
'position': 'relative', | |||
'border': 0, | |||
'paddingLeft': '1rem', | |||
'margin': 0, | |||
'font': 'inherit', | |||
'minHeight': '4rem', | |||
'minWidth': '16rem', | |||
'maxWidth': '100%', | |||
'zIndex': 1, | |||
'cursor': 'pointer', | |||
'transitionProperty': 'background-color, color', | |||
display: 'block', | |||
backgroundColor: 'var(--color-bg, white)', | |||
color: 'var(--color-fg, black)', | |||
appearance: 'none', | |||
boxSizing: 'border-box', | |||
position: 'relative', | |||
border: 0, | |||
paddingLeft: '1rem', | |||
margin: 0, | |||
font: 'inherit', | |||
minHeight: '4rem', | |||
minWidth: '8rem', | |||
maxWidth: '100%', | |||
zIndex: 1, | |||
cursor: 'pointer', | |||
transitionProperty: 'background-color, color', | |||
':focus': { | |||
outline: 0, | |||
}, | |||
@@ -106,20 +107,20 @@ const Input = styled('select')({ | |||
Input.displayName = 'select' | |||
const Border = styled('span')({ | |||
'borderColor': 'var(--color-accent, blue)', | |||
'boxSizing': 'border-box', | |||
'display': 'inline-block', | |||
'borderWidth': '0.125rem', | |||
'borderStyle': 'solid', | |||
'position': 'absolute', | |||
'top': 0, | |||
'left': 0, | |||
'width': '100%', | |||
'height': '100%', | |||
'borderRadius': 'inherit', | |||
'zIndex': 2, | |||
'pointerEvents': 'none', | |||
'transitionProperty': 'border-color', | |||
borderColor: 'var(--color-accent, blue)', | |||
boxSizing: 'border-box', | |||
display: 'inline-block', | |||
borderWidth: '0.125rem', | |||
borderStyle: 'solid', | |||
position: 'absolute', | |||
top: 0, | |||
left: 0, | |||
width: '100%', | |||
height: '100%', | |||
borderRadius: 'inherit', | |||
zIndex: 2, | |||
pointerEvents: 'none', | |||
transitionProperty: 'border-color', | |||
'::before': { | |||
position: 'absolute', | |||
top: 0, | |||
@@ -188,10 +189,6 @@ const propTypes = { | |||
* Size of the component. | |||
*/ | |||
size: PropTypes.oneOf<Size>(['small', 'medium', 'large']), | |||
/** | |||
* Should the component take up the remaining space parallel to the content flow? | |||
*/ | |||
block: PropTypes.bool, | |||
/** | |||
* Can multiple values be selected? | |||
*/ | |||
@@ -208,22 +205,13 @@ const propTypes = { | |||
type Props = PropTypes.InferProps<typeof propTypes> | |||
/** | |||
* Component for selecting values from a larger number of options. | |||
* @see {@link Checkbox} for a similar component on selecting values among very few choices. | |||
* @see {@link RadioButton} for a similar component on selecting a single value among very few choices. | |||
* @type {React.ComponentType<{readonly label?: string, readonly hint?: string, readonly className?: string, readonly | |||
* size?: 'small' | 'medium' | 'large', readonly multiple?: boolean, readonly block?: boolean} & | |||
* React.ClassAttributes<unknown>>} | |||
*/ | |||
const Select = React.forwardRef<HTMLSelectElement, Props>( | |||
( | |||
{ | |||
label = '', | |||
className = '', | |||
hint = '', | |||
size: sizeProp = 'medium', | |||
block = false, | |||
size = 'medium', | |||
multiple = false, | |||
disabled = false, | |||
style = {}, | |||
@@ -231,11 +219,9 @@ const Select = React.forwardRef<HTMLSelectElement, Props>( | |||
}, | |||
ref, | |||
) => { | |||
const size = sizeProp as Size | |||
return ( | |||
<ComponentBase | |||
style={{ | |||
display: block ? 'block' : 'inline-block', | |||
opacity: disabled ? 0.5 : undefined, | |||
}} | |||
> | |||
@@ -259,10 +245,8 @@ const Select = React.forwardRef<HTMLSelectElement, Props>( | |||
multiple={multiple!} | |||
style={{ | |||
...style, | |||
display: block ? 'block' : 'inline-block', | |||
verticalAlign: 'top', | |||
fontSize: INPUT_FONT_SIZES[size!], | |||
width: block || multiple ? '100%' : undefined, | |||
height: multiple ? undefined : MIN_HEIGHTS[size!], | |||
minHeight: MIN_HEIGHTS[size!], | |||
resize: multiple ? 'vertical' : undefined, | |||
@@ -1,3 +1,6 @@ | |||
/// <reference types="jest-enzyme" /> | |||
/// <reference path="../../../utilities/jest/extensions.ts" /> | |||
import * as fc from 'fast-check' | |||
import * as Enzyme from 'enzyme' | |||
import * as React from 'react' | |||
@@ -5,9 +5,9 @@ import styled from 'styled-components' | |||
import stringify from '../../services/stringify' | |||
const Wrapper = styled('div')({ | |||
'position': 'relative', | |||
'display': 'inline-block', | |||
'verticalAlign': 'top', | |||
position: 'relative', | |||
display: 'inline-block', | |||
verticalAlign: 'top', | |||
':focus-within': { | |||
'--color-accent': 'var(--color-active, Highlight)', | |||
}, | |||
@@ -16,10 +16,10 @@ const Wrapper = styled('div')({ | |||
Wrapper.displayName = 'div' | |||
const Base = styled(ReachSlider.SliderInput)({ | |||
'boxSizing': 'border-box', | |||
'position': 'absolute', | |||
'bottom': 0, | |||
'right': 0, | |||
boxSizing: 'border-box', | |||
position: 'absolute', | |||
bottom: 0, | |||
right: 0, | |||
':active': { | |||
cursor: 'grabbing', | |||
}, | |||
@@ -28,10 +28,10 @@ const Base = styled(ReachSlider.SliderInput)({ | |||
Base.displayName = 'input' | |||
const Track = styled(ReachSlider.SliderTrack)({ | |||
'borderRadius': '0.125rem', | |||
'position': 'relative', | |||
'width': '100%', | |||
'height': '100%', | |||
borderRadius: '0.125rem', | |||
position: 'relative', | |||
width: '100%', | |||
height: '100%', | |||
'::before': { | |||
display: 'block', | |||
position: 'absolute', | |||
@@ -45,15 +45,15 @@ const Track = styled(ReachSlider.SliderTrack)({ | |||
}) | |||
const Handle = styled(ReachSlider.SliderHandle)({ | |||
'cursor': 'grab', | |||
'width': '1.25rem', | |||
'height': '1.25rem', | |||
'backgroundColor': 'var(--color-accent, blue)', | |||
'borderRadius': '50%', | |||
'zIndex': 1, | |||
'transformOrigin': 'center', | |||
'outline': 0, | |||
'position': 'relative', | |||
cursor: 'grab', | |||
width: '1.25rem', | |||
height: '1.25rem', | |||
backgroundColor: 'var(--color-accent, blue)', | |||
borderRadius: '50%', | |||
zIndex: 1, | |||
transformOrigin: 'center', | |||
outline: 0, | |||
position: 'relative', | |||
'::before': { | |||
position: 'absolute', | |||
top: 0, | |||
@@ -113,12 +113,12 @@ const LabelWrapper = styled('span')({ | |||
LabelWrapper.displayName = 'span' | |||
const FallbackTrack = styled('span')({ | |||
'padding': '1.875rem 0.75rem 0.875rem', | |||
'boxSizing': 'border-box', | |||
'position': 'absolute', | |||
'top': 0, | |||
'left': 0, | |||
'pointerEvents': 'none', | |||
padding: '1.875rem 0.75rem 0.875rem', | |||
boxSizing: 'border-box', | |||
position: 'absolute', | |||
top: 0, | |||
left: 0, | |||
pointerEvents: 'none', | |||
'::before': { | |||
display: 'block', | |||
content: "''", | |||
@@ -131,17 +131,17 @@ const FallbackTrack = styled('span')({ | |||
}) | |||
const FallbackSlider = styled('input')({ | |||
'boxSizing': 'border-box', | |||
'backgroundColor': 'transparent', | |||
'verticalAlign': 'top', | |||
'margin': 0, | |||
'width': '100%', | |||
'height': '2rem', | |||
'marginTop': '1rem', | |||
'appearance': 'none', | |||
'outline': 0, | |||
'position': 'absolute', | |||
'left': 0, | |||
boxSizing: 'border-box', | |||
backgroundColor: 'transparent', | |||
verticalAlign: 'top', | |||
margin: 0, | |||
width: '100%', | |||
height: '2rem', | |||
marginTop: '1rem', | |||
appearance: 'none', | |||
outline: 0, | |||
position: 'absolute', | |||
left: 0, | |||
'::-moz-focus-inner': { | |||
outline: 0, | |||
border: 0, | |||
@@ -1,3 +1,6 @@ | |||
/// <reference types="jest-enzyme" /> | |||
/// <reference path="../../../utilities/jest/extensions.ts" /> | |||
import * as fc from 'fast-check' | |||
import * as Enzyme from 'enzyme' | |||
import * as Axe from 'jest-axe' | |||
@@ -64,13 +67,12 @@ it('should render as half-opaque when disabled', () => { | |||
}) | |||
describe.each` | |||
multiline | inputWidth | tag | |||
${false} | ${'100%'} | ${'input'} | |||
${true} | ${undefined} | ${'textarea'} | |||
`('on multiline (multiline=$multiline)', ({ tag: rawTag, multiline: rawMultiline, inputWidth: rawInputWidth }) => { | |||
multiline | tag | |||
${false} | ${'input'} | |||
${true} | ${'textarea'} | |||
`('on multiline (multiline=$multiline)', ({ tag: rawTag, multiline: rawMultiline, }) => { | |||
const tag = rawTag as string | |||
const multiline = rawMultiline as boolean | |||
const inputWidth = rawInputWidth as string | |||
it('should render an element to input text on', () => { | |||
const wrapper = Enzyme.shallow(<TextInput multiline={multiline} />) | |||
@@ -99,39 +101,6 @@ describe.each` | |||
.prop('style')!.paddingRight, | |||
).not.toBe('1rem') | |||
}) | |||
describe('on being declared a block component', () => { | |||
const BLOCK_DISPLAYS = ['block', 'grid', 'flex', 'table'] | |||
it('should render the base element fullwidth', () => { | |||
const wrapper = Enzyme.shallow(<TextInput multiline={multiline} block />) | |||
const { display } = wrapper.find('div').prop('style')! | |||
expect(BLOCK_DISPLAYS).toContain(display) | |||
}) | |||
it('should render the input fullwidth', () => { | |||
const wrapper = Enzyme.shallow(<TextInput multiline={multiline} block />) | |||
if (tag === 'textarea') { | |||
expect(wrapper.find('label').find(tag)).not.toHaveStyle('width') | |||
} else { | |||
expect(wrapper.find('label').find(tag)).toHaveStyle('width', inputWidth) | |||
} | |||
}) | |||
it('should render the input as block element', () => { | |||
const wrapper = Enzyme.shallow(<TextInput multiline={multiline} block />) | |||
const { display } = wrapper | |||
.find('label') | |||
.find(tag) | |||
.prop('style')! | |||
expect(BLOCK_DISPLAYS).toContain(display) | |||
}) | |||
}) | |||
}) | |||
describe('on aiding user input', () => { | |||
@@ -3,7 +3,6 @@ import * as PropTypes from 'prop-types' | |||
import styled from 'styled-components' | |||
import stringify from '../../services/stringify' | |||
import { Size, SizeMap } from '../../services/utilities' | |||
import { ChangeEvent, InputHTMLAttributes, TextareaHTMLAttributes } from 'react' | |||
const MIN_HEIGHTS: SizeMap<string | number> = { | |||
small: '2.5rem', | |||
@@ -36,10 +35,10 @@ const SECONDARY_TEXT_SIZES: SizeMap<string | number> = { | |||
} | |||
const ComponentBase = styled('div')({ | |||
'position': 'relative', | |||
'borderRadius': '0.25rem', | |||
'fontFamily': 'var(--font-family-base)', | |||
'maxWidth': '100%', | |||
position: 'relative', | |||
borderRadius: '0.25rem', | |||
fontFamily: 'var(--font-family-base)', | |||
maxWidth: '100%', | |||
':focus-within': { | |||
'--color-accent': 'var(--color-active, Highlight)', | |||
}, | |||
@@ -78,20 +77,20 @@ const LabelWrapper = styled('span')({ | |||
LabelWrapper.displayName = 'span' | |||
const Border = styled('span')({ | |||
'borderColor': 'var(--color-accent, blue)', | |||
'boxSizing': 'border-box', | |||
'display': 'inline-block', | |||
'borderWidth': '0.125rem', | |||
'borderStyle': 'solid', | |||
'position': 'absolute', | |||
'top': 0, | |||
'left': 0, | |||
'width': '100%', | |||
'height': '100%', | |||
'borderRadius': 'inherit', | |||
'zIndex': 2, | |||
'pointerEvents': 'none', | |||
'transitionProperty': 'border-color', | |||
borderColor: 'var(--color-accent, blue)', | |||
boxSizing: 'border-box', | |||
display: 'inline-block', | |||
borderWidth: '0.125rem', | |||
borderStyle: 'solid', | |||
position: 'absolute', | |||
top: 0, | |||
left: 0, | |||
width: '100%', | |||
height: '100%', | |||
borderRadius: 'inherit', | |||
zIndex: 2, | |||
pointerEvents: 'none', | |||
transitionProperty: 'border-color', | |||
'::before': { | |||
position: 'absolute', | |||
top: 0, | |||
@@ -109,23 +108,24 @@ const Border = styled('span')({ | |||
}) | |||
const Input = styled('input')({ | |||
'backgroundColor': 'var(--color-bg, white)', | |||
'color': 'var(--color-fg, black)', | |||
'verticalAlign': 'top', | |||
'paddingTop': 0, | |||
'paddingBottom': 0, | |||
'boxSizing': 'border-box', | |||
'position': 'relative', | |||
'border': 0, | |||
'borderRadius': 'inherit', | |||
'paddingLeft': '1rem', | |||
'margin': 0, | |||
'font': 'inherit', | |||
'minHeight': '4rem', | |||
'minWidth': '16rem', | |||
'maxWidth': '100%', | |||
'zIndex': 1, | |||
'transitionProperty': 'background-color, color', | |||
display: 'block', | |||
backgroundColor: 'var(--color-bg, white)', | |||
color: 'var(--color-fg, black)', | |||
verticalAlign: 'top', | |||
paddingTop: 0, | |||
paddingBottom: 0, | |||
boxSizing: 'border-box', | |||
position: 'relative', | |||
border: 0, | |||
borderRadius: 'inherit', | |||
paddingLeft: '1rem', | |||
margin: 0, | |||
font: 'inherit', | |||
minHeight: '4rem', | |||
minWidth: '8rem', | |||
maxWidth: '100%', | |||
zIndex: 1, | |||
transitionProperty: 'background-color, color', | |||
':focus': { | |||
outline: 0, | |||
color: 'var(--color-fg, black)', | |||
@@ -138,22 +138,23 @@ const Input = styled('input')({ | |||
Input.displayName = 'input' | |||
const TextArea = styled('textarea')({ | |||
'backgroundColor': 'var(--color-bg, white)', | |||
'color': 'var(--color-fg, black)', | |||
'verticalAlign': 'top', | |||
'width': '100%', | |||
'boxSizing': 'border-box', | |||
'position': 'relative', | |||
'border': 0, | |||
'borderRadius': 'inherit', | |||
'paddingLeft': '1rem', | |||
'margin': 0, | |||
'font': 'inherit', | |||
'minHeight': '4rem', | |||
'minWidth': '16rem', | |||
'maxWidth': '100%', | |||
'zIndex': 1, | |||
'transitionProperty': 'background-color, color', | |||
display: 'block', | |||
backgroundColor: 'var(--color-bg, white)', | |||
color: 'var(--color-fg, black)', | |||
verticalAlign: 'top', | |||
width: '100%', | |||
boxSizing: 'border-box', | |||
position: 'relative', | |||
border: 0, | |||
borderRadius: 'inherit', | |||
paddingLeft: '1rem', | |||
margin: 0, | |||
font: 'inherit', | |||
minHeight: '4rem', | |||
minWidth: '16rem', | |||
maxWidth: '100%', | |||
zIndex: 1, | |||
transitionProperty: 'background-color, color', | |||
':focus': { | |||
outline: 0, | |||
}, | |||
@@ -216,10 +217,6 @@ const propTypes = { | |||
* Additional description, usually graphical, indicating the nature of the component's value. | |||
*/ | |||
indicator: PropTypes.node, | |||
/** | |||
* Should the component take up the remaining space parallel to the content flow? | |||
*/ | |||
block: PropTypes.bool, | |||
/** | |||
* Should the component accept multiple lines of input? | |||
*/ | |||
@@ -240,12 +237,6 @@ const propTypes = { | |||
type Props = PropTypes.InferProps<typeof propTypes> | |||
/** | |||
* Component for inputting textual values. | |||
* @type {React.ComponentType<{readonly label?: string, readonly hint?: string, readonly multiline?: boolean, readonly | |||
* className?: string, readonly indicator?: *, readonly size?: 'small' | 'medium' | 'large', readonly block?: | |||
* boolean} & React.ClassAttributes<unknown>>} | |||
*/ | |||
const TextInput = React.forwardRef<HTMLInputElement | HTMLTextAreaElement, Props>( | |||
( | |||
{ | |||
@@ -253,92 +244,84 @@ const TextInput = React.forwardRef<HTMLInputElement | HTMLTextAreaElement, Props | |||
className = '', | |||
hint = '', | |||
indicator = null, | |||
size: sizeProp = 'medium', | |||
block = false, | |||
size = 'medium', | |||
multiline = false, | |||
disabled = false, | |||
autoResize = false, | |||
placeholder = '', | |||
}, | |||
ref, | |||
) => { | |||
const size: Size = sizeProp as Size | |||
return ( | |||
<ComponentBase | |||
style={{ | |||
display: block ? 'block' : 'inline-block', | |||
opacity: disabled ? 0.5 : undefined, | |||
}} | |||
> | |||
<Border /> | |||
<CaptureArea className={className!}> | |||
<LabelWrapper | |||
style={{ | |||
paddingTop: LABEL_VERTICAL_PADDING_SIZES[size!], | |||
paddingBottom: LABEL_VERTICAL_PADDING_SIZES[size!], | |||
paddingRight: indicator ? MIN_HEIGHTS[size!] : '0.5rem', | |||
fontSize: SECONDARY_TEXT_SIZES[size!], | |||
}} | |||
> | |||
{stringify(label)} | |||
</LabelWrapper> | |||
{stringify(label).length > 0 && ' '} | |||
{multiline && ( | |||
<TextArea | |||
placeholder={placeholder!} | |||
ref={ref as React.Ref<HTMLTextAreaElement>} | |||
disabled={disabled!} | |||
style={{ | |||
display: block ? 'block' : 'inline-block', | |||
fontSize: INPUT_FONT_SIZES[size!], | |||
minHeight: MIN_HEIGHTS[size!], | |||
paddingTop: VERTICAL_PADDING_SIZES[size!], | |||
paddingBottom: VERTICAL_PADDING_SIZES[size!], | |||
paddingRight: indicator ? MIN_HEIGHTS[size!] : '1rem', | |||
}} | |||
/> | |||
)} | |||
{!multiline && ( | |||
<Input | |||
placeholder={placeholder!} | |||
ref={ref as React.Ref<HTMLInputElement>} | |||
disabled={disabled!} | |||
style={{ | |||
display: block ? 'block' : 'inline-block', | |||
fontSize: INPUT_FONT_SIZES[size!], | |||
width: block ? '100%' : undefined, | |||
minHeight: MIN_HEIGHTS[size!], | |||
paddingRight: indicator ? MIN_HEIGHTS[size!] : '1rem', | |||
}} | |||
/> | |||
)} | |||
</CaptureArea> | |||
{stringify(hint).length > 0 && ' '} | |||
{stringify(hint).length > 0 && ( | |||
<HintWrapper | |||
) => ( | |||
<ComponentBase | |||
style={{ | |||
opacity: disabled ? 0.5 : undefined, | |||
}} | |||
> | |||
<Border /> | |||
<CaptureArea className={className!}> | |||
<LabelWrapper | |||
style={{ | |||
paddingTop: LABEL_VERTICAL_PADDING_SIZES[size!], | |||
paddingBottom: LABEL_VERTICAL_PADDING_SIZES[size!], | |||
paddingRight: indicator ? MIN_HEIGHTS[size!] : '0.5rem', | |||
fontSize: SECONDARY_TEXT_SIZES[size!], | |||
}} | |||
> | |||
{stringify(label)} | |||
</LabelWrapper> | |||
{stringify(label).length > 0 && ' '} | |||
{multiline && ( | |||
<TextArea | |||
placeholder={placeholder!} | |||
ref={ref as React.Ref<HTMLTextAreaElement>} | |||
disabled={disabled!} | |||
style={{ | |||
paddingTop: LABEL_VERTICAL_PADDING_SIZES[size!], | |||
paddingBottom: LABEL_VERTICAL_PADDING_SIZES[size!], | |||
fontSize: INPUT_FONT_SIZES[size!], | |||
minHeight: MIN_HEIGHTS[size!], | |||
paddingTop: VERTICAL_PADDING_SIZES[size!], | |||
paddingBottom: VERTICAL_PADDING_SIZES[size!], | |||
paddingRight: indicator ? MIN_HEIGHTS[size!] : '1rem', | |||
fontSize: SECONDARY_TEXT_SIZES[size!], | |||
}} | |||
> | |||
({stringify(hint)}) | |||
</HintWrapper> | |||
/> | |||
)} | |||
{(indicator as PropTypes.ReactComponentLike) && ( | |||
<IndicatorWrapper | |||
{!multiline && ( | |||
<Input | |||
placeholder={placeholder!} | |||
ref={ref as React.Ref<HTMLInputElement>} | |||
disabled={disabled!} | |||
style={{ | |||
width: MIN_HEIGHTS[size!], | |||
height: MIN_HEIGHTS[size!], | |||
fontSize: INPUT_FONT_SIZES[size!], | |||
minHeight: MIN_HEIGHTS[size!], | |||
paddingRight: indicator ? MIN_HEIGHTS[size!] : '1rem', | |||
}} | |||
> | |||
{indicator} | |||
</IndicatorWrapper> | |||
/> | |||
)} | |||
</ComponentBase> | |||
) | |||
}, | |||
</CaptureArea> | |||
{stringify(hint).length > 0 && ' '} | |||
{stringify(hint).length > 0 && ( | |||
<HintWrapper | |||
style={{ | |||
paddingTop: LABEL_VERTICAL_PADDING_SIZES[size!], | |||
paddingBottom: LABEL_VERTICAL_PADDING_SIZES[size!], | |||
paddingRight: indicator ? MIN_HEIGHTS[size!] : '1rem', | |||
fontSize: SECONDARY_TEXT_SIZES[size!], | |||
}} | |||
> | |||
({stringify(hint)}) | |||
</HintWrapper> | |||
)} | |||
{(indicator as PropTypes.ReactComponentLike) && ( | |||
<IndicatorWrapper | |||
style={{ | |||
width: MIN_HEIGHTS[size!], | |||
height: MIN_HEIGHTS[size!], | |||
}} | |||
> | |||
{indicator} | |||
</IndicatorWrapper> | |||
)} | |||
</ComponentBase> | |||
), | |||
) | |||
TextInput.propTypes = propTypes | |||
@@ -0,0 +1,26 @@ | |||
/// <reference types="jest-enzyme" /> | |||
/// <reference path="../utilities/jest/extensions.ts" /> | |||
import * as T from './index' | |||
const Tesseract = T as Record<string, unknown> | |||
describe.each` | |||
name | componentId | |||
${'button'} | ${'Button'} | |||
${'checkbox'} | ${'Checkbox'} | |||
${'icon'} | ${'Icon'} | |||
${'radio button'} | ${'RadioButton'} | |||
${'select'} | ${'Select'} | |||
${'slider'} | ${'Slider'} | |||
${'text input'} | ${'TextInput'} | |||
`('on $name component', ({ componentId, }) => { | |||
const theComponentId = componentId as string | |||
it('should exist', () => { | |||
expect(Tesseract[theComponentId]).toBeDefined() | |||
}) | |||
it('should be a component', () => { | |||
expect(Tesseract[theComponentId]).toBeComponent() | |||
}) | |||
}) |
@@ -16,12 +16,9 @@ describe('lib/services/isEmpty', () => { | |||
it('should return a boolean value', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.anything(), | |||
v => { | |||
expect(typeof isEmpty(v)).toBe('boolean') | |||
} | |||
) | |||
fc.property(fc.anything(), (v) => { | |||
expect(typeof isEmpty(v)).toBe('boolean') | |||
}), | |||
) | |||
}) | |||
@@ -36,12 +33,9 @@ describe('lib/services/isEmpty', () => { | |||
it('should return `false` on an argument with value that is neither `undefined` nor `null`', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.anything().filter(v => typeof v !== 'undefined' && v !== null), | |||
v => { | |||
expect(isEmpty(v)).toBe(false) | |||
} | |||
) | |||
fc.property(fc.anything().filter((v) => typeof v !== 'undefined' && v !== null), (v) => { | |||
expect(isEmpty(v)).toBe(false) | |||
}), | |||
) | |||
}) | |||
}) | |||
@@ -2,9 +2,6 @@ interface IsEmpty { | |||
(v: any): boolean | |||
} | |||
const isEmpty: IsEmpty = v => ( | |||
typeof v === 'undefined' | |||
|| v === null | |||
) | |||
const isEmpty: IsEmpty = (v) => typeof v === 'undefined' || v === null | |||
export default isEmpty |
@@ -1,5 +1,5 @@ | |||
import * as fc from 'fast-check' | |||
import splitValueAndUnit, { Unit, } from './splitValueAndUnit' | |||
import splitValueAndUnit, { Unit } from './splitValueAndUnit' | |||
it('should exist', () => { | |||
expect(splitValueAndUnit).toBeDefined() | |||
@@ -15,46 +15,33 @@ it('should accept 1 argument', () => { | |||
it('should throw a TypeError when invalid values are supplied', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.anything().filter(s => !['string', 'number'].includes(typeof s)), | |||
s => { | |||
expect(() => splitValueAndUnit(s)).toThrowError(TypeError) | |||
} | |||
) | |||
fc.property(fc.anything().filter((s) => !['string', 'number'].includes(typeof s)), (s) => { | |||
expect(() => splitValueAndUnit(s)).toThrowError(TypeError) | |||
}), | |||
) | |||
}) | |||
it('should parse valid CSS numbers', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.tuple( | |||
fc.float(), | |||
fc.oneof<Unit>( | |||
fc.constant('px'), | |||
fc.constant('rem'), | |||
fc.constant('%'), | |||
) | |||
), | |||
([magnitude, unit,]) => { | |||
fc.tuple(fc.float(), fc.oneof<Unit>(fc.constant('px'), fc.constant('rem'), fc.constant('%'))), | |||
([magnitude, unit]) => { | |||
expect(splitValueAndUnit(`${magnitude}${unit}`)).toEqual({ | |||
magnitude, | |||
unit, | |||
}) | |||
} | |||
) | |||
}, | |||
), | |||
) | |||
}) | |||
it('should parse numbers as CSS numbers with implicit pixel units', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.float(), | |||
magnitude => { | |||
expect(splitValueAndUnit(magnitude)).toEqual({ | |||
magnitude, | |||
unit: 'px', | |||
}) | |||
} | |||
) | |||
fc.property(fc.float(), (magnitude) => { | |||
expect(splitValueAndUnit(magnitude)).toEqual({ | |||
magnitude, | |||
unit: 'px', | |||
}) | |||
}), | |||
) | |||
}) |
@@ -1,15 +1,15 @@ | |||
export type Unit = 'px' | '%' | 'rem' | |||
export interface ValueAndUnit { | |||
magnitude: number, | |||
unit: Unit, | |||
magnitude: number | |||
unit: Unit | |||
} | |||
interface SplitValueAndUnit { | |||
(value: any): ValueAndUnit | |||
} | |||
const splitValueAndUnit: SplitValueAndUnit = value => { | |||
const splitValueAndUnit: SplitValueAndUnit = (value) => { | |||
if (!['string', 'number'].includes(typeof value)) { | |||
throw TypeError('Argument must be a valid CSS number') | |||
} | |||
@@ -16,12 +16,9 @@ it('should accept 1 argument', () => { | |||
it('should return a string value', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.anything(), | |||
v => { | |||
expect(stringify(v)).toBeString() | |||
} | |||
) | |||
fc.property(fc.anything(), (v) => { | |||
expect(stringify(v)).toBeString() | |||
}), | |||
) | |||
}) | |||
@@ -36,58 +33,42 @@ describe('on arguments', () => { | |||
it('should stringify non-objects', () => { | |||
fc.assert( | |||
fc.property( | |||
fcArb.nonObject(), | |||
fc.string(), | |||
v => { | |||
expect(stringify(v)).toBe(String(v)) | |||
} | |||
) | |||
fc.property(fcArb.nonObject(), fc.string(), (v) => { | |||
expect(stringify(v)).toBe(String(v)) | |||
}), | |||
) | |||
}) | |||
it('should stringify objects', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.object(), | |||
v => { | |||
expect(stringify(v)).toBe(JSON.stringify(v)) | |||
} | |||
) | |||
fc.property(fc.object(), (v) => { | |||
expect(stringify(v)).toBe(JSON.stringify(v)) | |||
}), | |||
) | |||
}) | |||
describe('on arrays', () => { | |||
it('should stringify empty arrays', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.array(fcArb.nonObject(), 0), | |||
v => { | |||
expect(stringify(v)).toBe('') | |||
} | |||
) | |||
fc.property(fc.array(fcArb.nonObject(), 0), (v) => { | |||
expect(stringify(v)).toBe('') | |||
}), | |||
) | |||
}) | |||
it('should stringify arrays with single values', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.array(fcArb.nonObject(), 1, 1), | |||
v => { | |||
expect(stringify(v)).toBe(String(v[0])) | |||
} | |||
) | |||
fc.property(fc.array(fcArb.nonObject(), 1, 1), (v) => { | |||
expect(stringify(v)).toBe(String(v[0])) | |||
}), | |||
) | |||
}) | |||
it('should stringify arrays with 2 or more values', () => { | |||
fc.assert( | |||
fc.property( | |||
fc.array(fcArb.nonObject(), 2, 20), | |||
v => { | |||
expect(stringify(v)).toContain(',') | |||
} | |||
) | |||
fc.property(fc.array(fcArb.nonObject(), 2, 20), (v) => { | |||
expect(stringify(v)).toContain(',') | |||
}), | |||
) | |||
}) | |||
}) | |||
@@ -1,18 +1,18 @@ | |||
import isEmpty from './isEmpty' | |||
interface Stringify { | |||
(v: any): string, | |||
(v: any): string | |||
} | |||
const stringify: Stringify = v => { | |||
const stringify: Stringify = (v) => { | |||
if (isEmpty(v)) { | |||
return '' | |||
} | |||
if (Array.isArray(v)) { | |||
return v | |||
.filter(v => !isEmpty(v)) | |||
.map(v => stringify(v)) | |||
.filter((v) => !isEmpty(v)) | |||
.map((v) => stringify(v)) | |||
.join(',') | |||
} | |||
@@ -1,6 +1,6 @@ | |||
{ | |||
"name": "@tesseract-design/react-common", | |||
"version": "0.0.0", | |||
"version": "0.0.1", | |||
"description": "Common front-end components for Web using the Tesseract design system, written in React.", | |||
"directories": { | |||
"lib": "dist" | |||