Browse Source

Initial commit

Migrate from GitHub, rewrite common components to TypeScript (main and test files). Also replaced Storybook to Docz.
tags/0.3.0
TheoryOfNekomata 4 years ago
commit
969b03a890
49 changed files with 22809 additions and 0 deletions
  1. +11
    -0
      .editorconfig
  2. +2
    -0
      .env.example
  3. +70
    -0
      .gitignore
  4. +14
    -0
      .npmignore
  5. +9
    -0
      .prettierrc
  6. +75
    -0
      README.md
  7. +3
    -0
      doczrc.js
  8. +3
    -0
      global.d.ts
  9. +14
    -0
      jest.config.js
  10. +16
    -0
      jest.setup.ts
  11. +42
    -0
      lib/components/Button/Button.mdx
  12. +127
    -0
      lib/components/Button/Button.test.tsx
  13. +145
    -0
      lib/components/Button/Button.tsx
  14. +24
    -0
      lib/components/Checkbox/Checkbox.mdx
  15. +75
    -0
      lib/components/Checkbox/Checkbox.test.tsx
  16. +154
    -0
      lib/components/Checkbox/Checkbox.tsx
  17. +19
    -0
      lib/components/Icon/Icon.mdx
  18. +38
    -0
      lib/components/Icon/Icon.test.tsx
  19. +111
    -0
      lib/components/Icon/Icon.tsx
  20. +29
    -0
      lib/components/RadioButton/RadioButton.mdx
  21. +80
    -0
      lib/components/RadioButton/RadioButton.test.tsx
  22. +154
    -0
      lib/components/RadioButton/RadioButton.tsx
  23. +24
    -0
      lib/components/Select/Select.mdx
  24. +124
    -0
      lib/components/Select/Select.test.tsx
  25. +306
    -0
      lib/components/Select/Select.tsx
  26. +22
    -0
      lib/components/Slider/Slider.mdx
  27. +97
    -0
      lib/components/Slider/Slider.test.tsx
  28. +380
    -0
      lib/components/Slider/Slider.tsx
  29. +19
    -0
      lib/components/TextInput/TextInput.mdx
  30. +167
    -0
      lib/components/TextInput/TextInput.test.tsx
  31. +348
    -0
      lib/components/TextInput/TextInput.tsx
  32. +17
    -0
      lib/index.ts
  33. +48
    -0
      lib/services/isEmpty.test.ts
  34. +10
    -0
      lib/services/isEmpty.ts
  35. +60
    -0
      lib/services/splitValueAndUnit.test.ts
  36. +25
    -0
      lib/services/splitValueAndUnit.ts
  37. +94
    -0
      lib/services/stringify.test.ts
  38. +28
    -0
      lib/services/stringify.ts
  39. +3
    -0
      lib/services/utilities.ts
  40. +54
    -0
      package.json
  41. +19
    -0
      plop/templates/component/{{pascalCase name}}.mdx.hbs
  42. +37
    -0
      plop/templates/component/{{pascalCase name}}.test.tsx.hbs
  43. +43
    -0
      plop/templates/component/{{pascalCase name}}.tsx.hbs
  44. +30
    -0
      plopfile.js
  45. +38
    -0
      rollup.config.js
  46. +25
    -0
      tsconfig.json
  47. +5
    -0
      utilities/fast-check/arbitraries.ts
  48. +14
    -0
      utilities/jest/extensions.ts
  49. +19557
    -0
      yarn.lock

+ 11
- 0
.editorconfig View File

@@ -0,0 +1,11 @@
root = true

[*]
charset = utf-8
end_of_line = lf
indent_size = 2
indent_style = space
insert_final_newline = true
max_line_length = 120
tab_width = 8
trim_trailing_whitespace = true

+ 2
- 0
.env.example View File

@@ -0,0 +1,2 @@
# Port where Storybook will run
NP_STORYBOOK_PORT=

+ 70
- 0
.gitignore View File

@@ -0,0 +1,70 @@
Thumbs.db
ehthumbs.db
ehthumbs_vista.db
*.stackdump
[Dd]esktop.ini
$RECYCLE.BIN/
*.cab
*.msi
*.msix
*.msm
*.msp
*.lnk
.DS_Store
.AppleDouble
.LSOverride
._*
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
.AppleDB
.AppleDesktop
Network Trash Folder
Temporary Items
.apdisk
.idea/
cmake-build-debug/
cmake-build-release/
*.iws
out/
.idea_modules/
atlassian-ide-plugin.xml
.idea/replstate.xml
com_crashlytics_export_strings.xml
crashlytics.properties
crashlytics-build.properties
fabric.properties
.idea/httpRequests
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pids
*.pid
*.seed
*.pid.lock
lib-cov
coverage
.nyc_output
.grunt
bower_components
.lock-wscript
build/Release
node_modules/
jspm_packages/
typings/
.npm
.eslintcache
.node_repl_history
*.tgz
.yarn-integrity
.env
.next
dist/
.gitignore
.docz/

+ 14
- 0
.npmignore View File

@@ -0,0 +1,14 @@
.storybook/
coverage/
docs/
lib/
plop/
utilities/
.babelrc
.env
.env.example
lib/index.stories.ts
jest.config.js
jest.setup.ts
plopfile.js
rollup.config.js

+ 9
- 0
.prettierrc View File

@@ -0,0 +1,9 @@
{
"jsxSingleQuote": false,
"singleQuote": true,
"printWidth": 120,
"semi": false,
"trailingComma": "all",
"quoteProps": "consistent",
"arrowParens": "always"
}

+ 75
- 0
README.md View File

@@ -0,0 +1,75 @@
# Tesseract Web - React

Front-end components for Web using the [Tesseract design system](https://make.modal.sh/design), written for [React](https://reactjs.org).

[![package: @tesseract-design/react](https://img.shields.io/badge/package-%40tesseract--design%2Freact-C78AB3?style=flat-square&labelColor=666666)](https://pack.modal.sh/js/@tesseract-design/react)
[![jest: 25.1.0](https://img.shields.io/badge/jest-25.1.0-C21325?style=flat-square&labelColor=666666&logo=Jest&logoColor=ffffff)](https://github.com/facebook/jest)
[![react: 16.13.1](https://img.shields.io/badge/react-16.13.1-61DAFB?style=flat-square&labelColor=666666&logo=React&logoColor=ffffff)](https://github.com/facebook/react)
[![styled-components: 5.1.0](https://img.shields.io/badge/styled--components-5.1.0-DB7093?style=flat-square&labelColor=666666&logo=styled-components&logoColor=ffffff)](https://github.com/styled-components/styled-components)

## Installation

Since this package resides in the [Modal.sh JavaScript Package Registry](https://pack.modal.sh/js/), you may need to
adjust configuration in your chosen package manager.

With [Yarn](https://yarnpkg.com), add this to your `.yarnrc` file:

```
"@tesseract-design:registry" "https://pack.modal.sh/js/"
```

Then, install the package by running the following command:

```shell script
yarn add @tesseract-design/react
```

## Usage

The package includes components as named exports. Simply import the components you need individually or use a namespace
import, like so:

```jsx harmony
import * as React from 'react'
import ReactDOM from 'react-dom'
import * as T from '@tesseract-design/react'

const LoginForm = etcProps => (
<form
{...etcProps}
>
<fieldset>
<legend>
Log In
</legend>
<div>
<T.TextInput
block
label="Username"
/>
</div>
<div>
<T.TextInput
block
type="password"
label="Password"
/>
</div>
<div>
<T.Button>
Log In
</T.Button>
</div>
</fieldset>
</form>
)

const mountNode = window.document.createElement('div')

ReactDOM.render(
<LoginForm />,
mountNode,
)

window.document.body.appendChild(mountNode)
```

+ 3
- 0
doczrc.js View File

@@ -0,0 +1,3 @@
export default {
typescript: true,
}

+ 3
- 0
global.d.ts View File

@@ -0,0 +1,3 @@
import 'jest-enzyme'
import 'jest-extended'
import './utilities/jest/extensions'

+ 14
- 0
jest.config.js View File

@@ -0,0 +1,14 @@
module.exports = {
setupFilesAfterEnv: [
'jest-enzyme',
'jest-extended',
'./jest.setup.ts',
],
collectCoverageFrom: [
'./lib/**/*.{ts,tsx}',
'!./lib/**/*.stories.{ts,tsx}'
],
preset: 'ts-jest',
testTimeout: 30000,
modulePathIgnorePatterns: ['<rootDir>/.docz'],
}

+ 16
- 0
jest.setup.ts View File

@@ -0,0 +1,16 @@
import * as Enzyme from 'enzyme'
import * as fc from 'fast-check'
import Adapter from 'enzyme-adapter-react-16'
import * as extensions from './utilities/jest/extensions'
import * as Axe from 'jest-axe'

Enzyme.configure({
adapter: new Adapter()
})

fc.configureGlobal({
verbose: true,
})

expect.extend(Axe.toHaveNoViolations)
expect.extend(extensions)

+ 42
- 0
lib/components/Button/Button.mdx View File

@@ -0,0 +1,42 @@
---
name: Button
menu: Components
---

import { Playground, Props } from 'docz'
import Button from './Button'

# Button

Component for performing an action upon activation (e.g. when clicked).

<Playground>
<Button>Perform Action</Button>
</Playground>

## Props

<Props of={Button} />

## Usage Notes

The default variant is `outline` as the `primary` variant is intended for
the most essential call-to-action in a section or a page. Choose `primary`
only if the component encapsulates the primary action of the view (e.g. log-in
in a log-in form), else resort to using `outline` (specifying the prop is optional).

The design of the button label is intended to be in uppercase as per the
[Tesseract Design Guidelines](https://make.modal.sh/design/tesseract). Avoid using
pronounceable initials in the button label, unless:

- It is well-known.

Initials such as URL and API are popular to a very wide selection of people. However,
initials such as [SOAP (Simple Object Access Protocol)](https://en.wikipedia.org/wiki/SOAP)
or [POST (power-on self test)](https://en.wikipedia.org/wiki/Power-on_self-test) may be
confusing if not enough context is provided.

- The context wherein the initials are used is clearly indicated.

For example, using [cURL](https://en.wikipedia.org/wiki/CURL) (which would be displayed as "CURL") in a page that
deals with cURL requests is acceptable.

+ 127
- 0
lib/components/Button/Button.test.tsx View File

@@ -0,0 +1,127 @@
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 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()
})

it('should be a component', () => {
expect(Button).toBeComponent()
})

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 fullwidth when declared as block', () => {
const wrapper = Enzyme.shallow(<Button block />)

expect(wrapper.find('button')).toHaveStyle('width', '100%')
})

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 label', () => {
fc.assert(
fc.property(fc.anything(), (label) => {
const wrapper = Enzyme.shallow(<Button>{label}</Button>)

expect(wrapper).toHaveText(stringify(label))
}),
{
numRuns: 300,
},
)
})

describe.each(AVAILABLE_VARIANTS)('with %p variant', (rawVariant) => {
const variant = rawVariant as Variant

it('should render background color', () => {
const wrapper = Enzyme.shallow(<Button variant={variant} />)
const button = wrapper.find('button')

if (CUSTOM_VARIANTS.includes(variant)) {
expect(button).not.toHaveStyle('backgroundColor', 'transparent')
return
}

expect(button).toHaveStyle('backgroundColor', 'transparent')
})

it('should render foreground color', () => {
const wrapper = Enzyme.shallow(<Button variant={variant} />)
const button = wrapper.find('button')

expect(button).toHaveStyle('color', expect.any(String))
})
})

describe('with unknown variants', () => {
let originalConsoleError: any

beforeEach(() => {
// silence console.error() from prop type validation since
// we're checking for unknown variants
originalConsoleError = console.error
console.error = () => {}
})

afterEach(() => {
console.error = originalConsoleError
})

it('should render background color', () => {
fc.assert(
fc.property(fc.string().filter((s) => !AVAILABLE_VARIANTS.includes(s)), (rawVariant) => {
const variant = rawVariant as Variant
const wrapper = Enzyme.shallow(<Button variant={variant} />)
const button = wrapper.find('button')

expect(button).toHaveStyle('backgroundColor', 'transparent')
}),
)
})

it('should render foreground color', () => {
fc.assert(
fc.property(fc.string().filter((s) => !AVAILABLE_VARIANTS.includes(s)), (rawVariant) => {
const variant = rawVariant as Variant
const wrapper = Enzyme.shallow(<Button variant={variant} />)
const button = wrapper.find('button')

expect(button).toHaveStyle('color', expect.any(String))
}),
)
})
})

it('should guarantee minimal accessibility', () => {
fc.assert(
fc.asyncProperty(fc.string(1, 20), async (s) => {
const wrapper = Enzyme.mount(<Button>{s}</Button>)
const results = await Axe.axe(wrapper.getDOMNode())

expect(results).toHaveNoViolations()
}),
)
})

+ 145
- 0
lib/components/Button/Button.tsx View File

@@ -0,0 +1,145 @@
import * as React from 'react'
import * as PropTypes from 'prop-types'
import styled from 'styled-components'
import { Size, SizeMap } from '../../services/utilities'
import stringify from '../../services/stringify'

export type Variant = 'outline' | 'primary'

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,
':focus': {
'--color-accent': 'var(--color-active, Highlight)',
'outline': 0,
},
':disabled': {
opacity: 0.5,
cursor: 'not-allowed',
},
'::-moz-focus-inner': {
outline: 0,
border: 0,
},
})

Base.displayName = 'button'

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',
'::before': {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
content: "''",
borderRadius: '0.125rem',
opacity: 0.5,
pointerEvents: 'none',
},
[`${Base}:focus &::before`]: {
boxShadow: '0 0 0 0.375rem var(--color-accent, blue)',
},
})

Border.displayName = 'span'

const propTypes = {
/**
* Size of the component.
*/
size: PropTypes.oneOf<Size>(['small', 'medium', 'large']),
/**
* 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,
/**
* Text to identify the action associated upon activation of the component.
*/
children: PropTypes.any,
/**
* Can the component be activated?
*/
disabled: PropTypes.bool,
}

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 { [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',
}}
>
<Border />
{stringify(children)}
</Base>
)
},
)

Button.propTypes = propTypes

Button.displayName = 'Button'

export default Button

+ 24
- 0
lib/components/Checkbox/Checkbox.mdx View File

@@ -0,0 +1,24 @@
---
name: Checkbox
menu: Components
---

import { Playground, Props, Link } from 'docz'
import Checkbox from './Checkbox'

# Checkbox

Component for values that have an on/off state.

<Playground>
<Checkbox label="Accept Terms" />
</Playground>

## Props

<Props of={Checkbox} />

## See Also

- <Link to="../lib-components-select-select">Select</Link> for a similar component suitable for selecting more values.
- <Link to="../lib-components-radio-button-radio-button">Radio Button</Link> for a similar component on selecting a single value among very few choices.

+ 75
- 0
lib/components/Checkbox/Checkbox.test.tsx View File

@@ -0,0 +1,75 @@
import * as fc from 'fast-check'
import * as Enzyme from 'enzyme'
import * as Axe from 'jest-axe'
import * as React from 'react'
import Checkbox from './Checkbox'
import stringify from '../../services/stringify'

it('should exist', () => {
expect(Checkbox).toBeDefined()
})

it('should be a component', () => {
expect(Checkbox).toBeComponent()
})

it('should render without crashing given required props', () => {
expect(() => <Checkbox />).not.toThrow()
})

it('should render a label to describe the intrinsic value of the component', () => {
const wrapper = Enzyme.shallow(<Checkbox label="foo" />)

expect(wrapper.find('label').children()).toHaveLength(4)
})

it('should render the label when not undefined or null', () => {
fc.assert(
fc.property(fc.anything().filter((v) => !Array.isArray(v) && typeof v !== 'undefined' && v !== null), (label) => {
const wrapper = Enzyme.shallow(<Checkbox label={label} />)

expect(
wrapper
.find('label')
.children()
.last(),
).toHaveText(stringify(label))
}),
{
numRuns: 300,
},
)
})

it('should render the label when undefined or null', () => {
fc.assert(
fc.property(fc.oneof(fc.constant(void 0), fc.constant(null)), (label) => {
const wrapper = Enzyme.shallow(<Checkbox label={label} />)

expect(
wrapper
.find('label')
.children()
.last()
.text(),
).toHaveLength(0)
}),
)
})

it('should render the input', () => {
const wrapper = Enzyme.shallow(<Checkbox />)

expect(wrapper.find('label').find('input')).toHaveLength(1)
})

it('should guarantee minimal accessibility', () => {
fc.assert(
fc.asyncProperty(fc.string(1, 20), async (s) => {
const wrapper = Enzyme.mount(<Checkbox label={s} />)
const results = await Axe.axe(wrapper.getDOMNode())

expect(results).toHaveNoViolations()
}),
)
})

+ 154
- 0
lib/components/Checkbox/Checkbox.tsx View File

@@ -0,0 +1,154 @@
import * as React from 'react'
import * as PropTypes from 'prop-types'
import styled from 'styled-components'
import stringify from '../../services/stringify'
import Icon from '../Icon/Icon'

const Base = styled('div')({
display: 'block',
})

const CaptureArea = styled('label')({
'marginTop': '0.25rem',
'::after': {
content: '""',
},
})

CaptureArea.displayName = 'label'

const Input = styled('input')({
position: 'absolute',
left: -999999,
width: 1,
height: 1,
})

Input.displayName = 'input'

const IndicatorWrapper = styled('span')({
borderColor: 'var(--color-accent, blue)',
boxSizing: 'border-box',
backgroundColor: 'transparent',
borderRadius: '0.25rem',
position: 'relative',
width: '1.5rem',
height: '1.5rem',
minWidth: '1.5rem',
maxWidth: '1.5rem',
display: 'inline-flex',
verticalAlign: 'top',
justifyContent: 'center',
alignItems: 'center',
cursor: 'pointer',
transitionProperty: 'border-color',
[`${Input}:focus ~ &`]: {
'--color-accent': 'var(--color-active, Highlight)',
},
[`${Input}:disabled ~ &`]: {
cursor: 'not-allowed',
opacity: 0.5,
},
})

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',
'::before': {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
content: "''",
borderRadius: '0.125rem',
opacity: 0.5,
pointerEvents: 'none',
},
[`${Base}:focus-within &::before`]: {
boxShadow: '0 0 0 0.375rem var(--color-accent, blue)',
},
})

const Indicator = styled('span')({
backgroundColor: 'var(--color-accent, blue)',
color: 'var(--color-bg, white)',
width: 0,
height: 0,
opacity: 0,
position: 'relative',
boxSizing: 'border-box',
display: 'inline-grid',
placeContent: 'center',
borderRadius: 'inherit',
lineHeight: 1,
transitionProperty: 'background-color, color, width, height, opacity',
[`${Input}:checked + ${IndicatorWrapper} &`]: {
opacity: 1,
width: '100%',
height: '100%',
},
})

const Label = styled('span')({
display: 'block',
verticalAlign: 'top',
float: 'right',
width: 'calc(100% - 2.5rem)',
fontFamily: 'var(--font-family-base)',
pointerEvents: 'none',
})

const LabelContent = styled('span')({
display: 'inline',
pointerEvents: 'auto',
})

const propTypes = {
/**
* Short textual description indicating the nature of the component's value.
*/
label: PropTypes.any,
}

type Props = PropTypes.InferProps<typeof propTypes>

/**
* Component for values that have an on/off state.
* @see {@link Select} for a similar component suitable for selecting more values.
* @see {@link RadioButton} for a similar component on selecting a single value among very few choices.
* @type {React.ComponentType<{readonly label?: string} & React.ClassAttributes<unknown>>}
*/
const Checkbox = React.forwardRef<HTMLInputElement, Props>(({ label = '', ...etcProps }, ref) => (
<Base>
<CaptureArea>
<Input {...etcProps} ref={ref} type="checkbox" />
<IndicatorWrapper>
<Border />
<Indicator>
<Icon name="check" label="" />
</Indicator>
</IndicatorWrapper>
{typeof label! !== 'undefined' && label !== null && ' '}
<Label>
<LabelContent>{stringify(label)}</LabelContent>
</Label>
</CaptureArea>
</Base>
))

Checkbox.propTypes = propTypes

Checkbox.displayName = 'Checkbox'

export default Checkbox

+ 19
- 0
lib/components/Icon/Icon.mdx View File

@@ -0,0 +1,19 @@
---
name: Icon
menu: Components
---

import { Playground, Props } from 'docz'
import Icon from './Icon'

# Icon

Component for displaying graphics.

<Playground>
<Icon name="check" label="OK" size="1.5rem" weight={0.125} />
</Playground>

## Props

<Props of={Icon} />

+ 38
- 0
lib/components/Icon/Icon.test.tsx View File

@@ -0,0 +1,38 @@
import * as fc from 'fast-check'
import * as Enzyme from 'enzyme'
import * as React from 'react'
import * as FeatherIcon from 'react-feather'
import { paramCase } from 'param-case'
import Icon from './Icon'

const FEATHER_ICONS = Object.keys(FeatherIcon).map((k) => paramCase(k))

it('should exist', () => {
expect(Icon).toBeDefined()
})

it('should be a component', () => {
expect(Icon).toBeComponent()
})

it('should render without crashing given required props', () => {
expect(() => <Icon name="foo" />).not.toThrow()
})

describe('on XML icons', () => {
test.each(FEATHER_ICONS)('should render the %p XML icon', (name) => {
const wrapper = Enzyme.shallow(<Icon name={name} />)

expect(wrapper).toHaveLength(1)
})
})

it('should render null for an unknown icon name', () => {
fc.assert(
fc.property(fc.string().filter((v) => ![...FEATHER_ICONS].includes(paramCase(v.toLowerCase()))), (v) => {
const wrapper = Enzyme.shallow(<Icon name={v} />)

expect(wrapper.getElement()).toBe(null)
}),
)
})

+ 111
- 0
lib/components/Icon/Icon.tsx View File

@@ -0,0 +1,111 @@
import * as React from 'react'
import * as PropTypes from 'prop-types'
import * as FeatherIcon from 'react-feather'
import styled from 'styled-components'
import { pascalCase, pascalCaseTransformMerge } from 'pascal-case'
import splitValueAndUnit from '../../services/splitValueAndUnit'

const Label = styled('span')({
'position': 'absolute',
'left': -999999,
'width': 1,
'height': 1,
':empty': {
display: 'none',
},
})

const StyledIcon = styled('svg')({
stroke: 'currentColor',
strokeLinecap: 'round',
display: 'inline-block',
verticalAlign: 'middle',
})

const propTypes = {
/**
* Name of the icon to display.
*/
name: PropTypes.string.isRequired,
/**
* Width of the icon's strokes.
*/
weight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
/**
* Size of the icon. This controls both the width and the height.
*/
size: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
/**
* CSS style of the icon. For icon dimensions, use `size` instead.
*/
style: PropTypes.object,
/**
* Describe of what the component represents.
*/
label: PropTypes.string,
/**
* Class name used for styling.
*/
className: PropTypes.string,
}

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',
size = '1.5rem',
style = {},
label = name,
className = '',
...etcProps
}) => {
const iconName = pascalCase(name, { transform: pascalCaseTransformMerge })
const { [iconName as keyof typeof FeatherIcon]: TheIcon = null } = FeatherIcon
const { magnitude: sizeValue, unit: sizeUnit } = splitValueAndUnit(size)
const { magnitude: weightValue } = splitValueAndUnit(weight)
const factor = weightValue * (3 / 2)

if (TheIcon !== null) {
return (
<span {...etcProps} style={style!}>
<StyledIcon
className={className!}
as={TheIcon}
size={undefined}
color={undefined}
strokeLinecap={undefined}
strokeWidth={undefined}
style={{
width: size!,
height: size!,
strokeWidth: `${factor / sizeValue}${sizeUnit}`,
}}
aria-label={label!}
/>
<Label aria-hidden="true">{label}</Label>
</span>
)
}

return null
}

Icon.propTypes = propTypes

Icon.displayName = 'Icon'

export default Icon

+ 29
- 0
lib/components/RadioButton/RadioButton.mdx View File

@@ -0,0 +1,29 @@
---
name: Radio Button
menu: Components
---

import { Playground, Props, Link } from 'docz'
import RadioButton from './RadioButton'

# Radio Button

Component for values which are to be selected from a few list of options.

<Playground>
<div style={{ margin: '1rem 0' }}>
<RadioButton name="flavor" label="Chocolate" />
</div>
<div style={{ margin: '1rem 0' }}>
<RadioButton name="flavor" label="Vanilla" />
</div>
</Playground>

## Props

<Props of={RadioButton} />

## See Also

- <Link to="../lib-components-checkbox-checkbox">Checkbox</Link> for a similar component on selecting values among very few choices.
- <Link to="../lib-components-select-select">Select</Link> for a similar component suitable for selecting more values.

+ 80
- 0
lib/components/RadioButton/RadioButton.test.tsx View File

@@ -0,0 +1,80 @@
import * as fc from 'fast-check'
import * as Enzyme from 'enzyme'
import * as Axe from 'jest-axe'
import * as React from 'react'
import RadioButton from './RadioButton'
import stringify from '../../services/stringify'

it('should exist', () => {
expect(RadioButton).toBeDefined()
})

it('should be a component', () => {
expect(RadioButton).toBeComponent()
})

it('should render without crashing given required props', () => {
expect(() => <RadioButton name="foo" />).not.toThrow()
})

it("should render a label to indicate the nature of the component's value", () => {
const wrapper = Enzyme.shallow(<RadioButton name="foo" label="foo" />)

expect(wrapper.find('label').children()).toHaveLength(4)
})

it('should render a label to describe the intrinsic value of the component', () => {
fc.assert(
fc.property(fc.anything(), (label) => {
const wrapper = Enzyme.shallow(<RadioButton label={label} name="foo" />)

expect(
wrapper
.find('label')
.children()
.last(),
).toHaveText(stringify(label))
}),
{
numRuns: 300,
},
)
})

it('should render the label when undefined or null', () => {
fc.assert(
fc.property(fc.oneof(fc.constant(void 0), fc.constant(null)), (label) => {
const wrapper = Enzyme.shallow(<RadioButton name="foo" label={label} />)

expect(
wrapper
.find('label')
.children()
.last()
.text(),
).toHaveLength(0)
}),
)
})

it('should render the input', () => {
const wrapper = Enzyme.shallow(<RadioButton name="foo" />)

expect(wrapper.find('label').find('input')).toHaveLength(1)
})

it('should guarantee minimal accessibility', async () => {
await fc.assert(
fc.asyncProperty(fc.string().filter((s) => s.trim().length > 0), async (s) => {
const wrapper = Enzyme.mount(
<div role="application">
<RadioButton name="foo" label={s} />
</div>,
)

const results = await Axe.axe(wrapper.getDOMNode<HTMLHtmlElement>())

expect(results).toHaveNoViolations()
}),
)
})

+ 154
- 0
lib/components/RadioButton/RadioButton.tsx View File

@@ -0,0 +1,154 @@
import * as React from 'react'
import * as PropTypes from 'prop-types'
import styled from 'styled-components'
import stringify from '../../services/stringify'

const Base = styled('div')({
display: 'block',
})

const CaptureArea = styled('label')({
'marginTop': '0.25rem',
'::after': {
content: '""',
clear: 'both',
},
})

CaptureArea.displayName = 'label'

const Input = styled('input')({
position: 'absolute',
left: -999999,
width: 1,
height: 1,
})

Input.displayName = 'input'

const IndicatorWrapper = styled('span')({
borderColor: 'var(--color-accent, blue)',
boxSizing: 'border-box',
backgroundColor: 'transparent',
borderRadius: '0.75rem',
position: 'relative',
width: '1.5rem',
height: '1.5rem',
minWidth: '1.5rem',
maxWidth: '1.5rem',
display: 'inline-flex',
verticalAlign: 'top',
justifyContent: 'center',
alignItems: 'center',
cursor: 'pointer',
transitionProperty: 'border-color',
[`${Input}:focus ~ &`]: {
'--color-accent': 'var(--color-active, Highlight)',
},
[`${Input}:disabled ~ &`]: {
cursor: 'not-allowed',
opacity: 0.5,
},
})

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',
'::before': {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
content: "''",
borderRadius: '0.75rem',
opacity: 0.5,
pointerEvents: 'none',
},
[`${Base}:focus-within &::before`]: {
boxShadow: '0 0 0 0.375rem var(--color-accent, blue)',
},
})

const Indicator = styled('span')({
backgroundColor: 'var(--color-accent, blue)',
color: 'var(--color-bg, white)',
width: 0,
height: 0,
opacity: 0,
boxSizing: 'border-box',
display: 'inline-grid',
placeContent: 'center',
borderRadius: '50%',
transitionProperty: 'background-color, color, width, height, opacity',
[`${Input}:checked + ${IndicatorWrapper} &`]: {
width: `${(100 * 2) / 3}%`,
height: `${(100 * 2) / 3}%`,
opacity: 1,
},
})

const Label = styled('span')({
display: 'block',
verticalAlign: 'top',
float: 'right',
width: 'calc(100% - 2.5rem)',
fontFamily: 'var(--font-family-base)',
pointerEvents: 'none',
})

const LabelContent = styled('span')({
display: 'inline',
pointerEvents: 'auto',
})

const propTypes = {
/**
* Group where the component belongs.
*/
name: PropTypes.string.isRequired,
/**
* Short textual description indicating the nature of the component's value.
*/
label: PropTypes.any,
}

type Props = PropTypes.InferProps<typeof propTypes>

/**
* Component for values which are to be selected from a few list of options.
* @see {@link Checkbox} for a similar component on selecting values among very few choices.
* @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>
))

RadioButton.propTypes = propTypes

RadioButton.displayName = 'RadioButton'

export default RadioButton

+ 24
- 0
lib/components/Select/Select.mdx View File

@@ -0,0 +1,24 @@
---
name: Select
menu: Components
---

import { Playground, Props, Link } from 'docz'
import Select from './Select'

# Select

Component for selecting values from a larger number of options.

<Playground>
<Select />
</Playground>

## Props

<Props of={Select} />

## See Also

- <Link to="../lib-components-checkbox-checkbox">Checkbox</Link> for a similar component on selecting values among very few choices.
- <Link to="../lib-components-radio-button-radio-button">Radio Button</Link> for a similar component on selecting a single value among very few choices.

+ 124
- 0
lib/components/Select/Select.test.tsx View File

@@ -0,0 +1,124 @@
import * as fc from 'fast-check'
import * as Enzyme from 'enzyme'
import * as Axe from 'jest-axe'
import * as React from 'react'
import Select from './Select'
import stringify from '../../services/stringify'

it('should exist', () => {
expect(Select).toBeDefined()
})

it('should be a component', () => {
expect(Select).toBeComponent()
})

it('should render without crashing given required props', () => {
expect(() => <Select />).not.toThrow()
})

it('should render a base element to put interactive elements on', () => {
const wrapper = Enzyme.shallow(<Select />)

expect(wrapper.find('label')).toHaveLength(1)
})

it('should render a select component for choosing values', () => {
const wrapper = Enzyme.shallow(<Select />)

expect(wrapper.find('label').find('select')).toHaveLength(1)
})

describe('on aiding user input', () => {
it("should render a label to indicate the nature of the component's value", () => {
const wrapper = Enzyme.shallow(<Select label="foo" />)

expect(wrapper.find('label').find('span')).toHaveLength(1)
})

it('should render the label text', () => {
fc.assert(
fc.property(fc.anything(), (label) => {
const wrapper = Enzyme.shallow(<Select label={label} />)

expect(wrapper.find('label').find('span')).toHaveText(stringify(label))
}),
{
numRuns: 300,
},
)
})
})

it('should render a hint for describing valid input values', () => {
fc.assert(
fc.property(fc.anything(), (label) => {
const wrapper = Enzyme.shallow(<Select hint={label} />)

const renderedLabel = stringify(label)

if (renderedLabel.length > 0) {
expect(wrapper.find('div').children()).toHaveLength(5)

expect(wrapper.find('div').childAt(3)).toHaveText(`(${renderedLabel})`)
} else {
expect(wrapper.find('div').children()).toHaveLength(3)
}
}),
)
})

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 />)

expect(wrapper.find('div')).toHaveStyle('opacity', 0.5)
})

it('should allow selection of multiple values', () => {
const wrapper = Enzyme.shallow(<Select multiple />)

expect(
wrapper
.find('label')
.find('select')
.prop('multiple'),
).toBeTruthy()
})

it('should guarantee minimal accessibility', () => {
fc.assert(
fc.asyncProperty(fc.string(1, 20), async (s) => {
const wrapper = Enzyme.mount(<Select label={s} />)
const results = await Axe.axe(wrapper.getDOMNode())

expect(results).toHaveNoViolations()
}),
)
})

+ 306
- 0
lib/components/Select/Select.tsx View File

@@ -0,0 +1,306 @@
import * as React from 'react'
import * as PropTypes from 'prop-types'
import styled from 'styled-components'
import stringify from '../../services/stringify'
import { Size, SizeMap } from '../../services/utilities'
import Icon from '../Icon/Icon'

const MIN_HEIGHTS: SizeMap<string | number> = {
small: '2.5rem',
medium: '3rem',
large: '4rem',
}

const LABEL_VERTICAL_PADDING_SIZES: SizeMap<string | number> = {
small: '0.125rem',
medium: '0.25rem',
large: '0.5rem',
}

const VERTICAL_PADDING_SIZES: SizeMap<string | number> = {
small: '0.6rem',
medium: '0.85rem',
large: '1.25rem',
}

const INPUT_FONT_SIZES: SizeMap<string | number> = {
small: '0.85em',
medium: '0.85em',
large: '1em',
}

const SECONDARY_TEXT_SIZES: SizeMap<string | number> = {
small: '0.65em',
medium: '0.75em',
large: '0.85em',
}

const ComponentBase = styled('div')({
'position': 'relative',
'borderRadius': '0.25rem',
'fontFamily': 'var(--font-family-base)',
'maxWidth': '100%',
':focus-within': {
'--color-accent': 'var(--color-active, Highlight)',
},
})

ComponentBase.displayName = 'div'

const CaptureArea = styled('label')({
display: 'block',
})

CaptureArea.displayName = 'label'

const LabelWrapper = styled('span')({
color: 'var(--color-accent, blue)',
boxSizing: 'border-box',
position: 'absolute',
top: 0,
left: 0,
paddingLeft: '0.5rem',
fontSize: '0.85em',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 'bolder',
zIndex: 2,
pointerEvents: 'none',
transitionProperty: 'color',
lineHeight: 1,
userSelect: 'none',
})

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',
':focus': {
outline: 0,
},
':disabled': {
cursor: 'not-allowed',
},
'::-moz-focus-inner': {
outline: 0,
border: 0,
},
})

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',
'::before': {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
content: "''",
borderRadius: '0.125rem',
opacity: 0.5,
pointerEvents: 'none',
},
[`${ComponentBase}:focus-within &::before`]: {
boxShadow: '0 0 0 0.375rem var(--color-accent, blue)',
},
})

const HintWrapper = styled('span')({
boxSizing: 'border-box',
position: 'absolute',
bottom: 0,
left: 0,
paddingLeft: '1rem',
fontSize: '0.85em',
opacity: 0.5,
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
zIndex: 2,
pointerEvents: 'none',
lineHeight: 1,
userSelect: 'none',
})

const IndicatorWrapper = styled('span')({
color: 'var(--color-accent, blue)',
boxSizing: 'border-box',
position: 'absolute',
top: 0,
right: 0,
height: '100%',
display: 'grid',
placeContent: 'center',
padding: '0 1rem',
zIndex: 1,
pointerEvents: 'none',
transitionProperty: 'color',
lineHeight: 1,
userSelect: 'none',
})

const propTypes = {
/**
* Short textual description indicating the nature of the component's value.
*/
label: PropTypes.any,
/**
* Class name for the component, used for styling.
*/
className: PropTypes.string,
/**
* Short textual description as guidelines for valid input values.
*/
hint: PropTypes.any,
/**
* 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?
*/
multiple: PropTypes.bool,
/**
* Is the component active?
*/
disabled: PropTypes.bool,
/**
* CSS styles.
*/
style: PropTypes.object,
}

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,
multiple = false,
disabled = false,
style = {},
...etcProps
},
ref,
) => {
const 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: !multiple ? MIN_HEIGHTS[size!] : '0.5rem',
fontSize: SECONDARY_TEXT_SIZES[size!],
}}
>
{stringify(label)}
</LabelWrapper>
{stringify(label).length > 0 && ' '}
<Input
{...etcProps}
ref={ref}
disabled={disabled!}
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,
paddingTop: VERTICAL_PADDING_SIZES[size!],
paddingBottom: VERTICAL_PADDING_SIZES[size!],
paddingRight: !multiple ? MIN_HEIGHTS[size!] : '1rem',
}}
/>
</CaptureArea>
{stringify(hint).length > 0 && ' '}
{stringify(hint).length > 0 && (
<HintWrapper
style={{
paddingTop: LABEL_VERTICAL_PADDING_SIZES[size!],
paddingBottom: LABEL_VERTICAL_PADDING_SIZES[size!],
paddingRight: MIN_HEIGHTS[size!],
fontSize: SECONDARY_TEXT_SIZES[size!],
}}
>
({stringify(hint)})
</HintWrapper>
)}
{!multiple && (
<IndicatorWrapper
style={{
width: MIN_HEIGHTS[size!],
}}
>
<Icon name="chevron-down" label="" />
</IndicatorWrapper>
)}
</ComponentBase>
)
},
)

Select.propTypes = propTypes

Select.displayName = 'Select'

export default Select

+ 22
- 0
lib/components/Slider/Slider.mdx View File

@@ -0,0 +1,22 @@
---
name: Slider
menu: Components
---

import { Playground, Props } from 'docz'
import Slider from './Slider'

# Slider

Component for inputting numeric values in a graphical manner.

The component is styled using client-side scripts. When the component is rendered server-side,
the component falls back into the original `<input type="range">` element.

<Playground>
<Slider />
</Playground>

## Props

<Props of={Slider} />

+ 97
- 0
lib/components/Slider/Slider.test.tsx View File

@@ -0,0 +1,97 @@
import * as fc from 'fast-check'
import * as Enzyme from 'enzyme'
import * as React from 'react'
import Slider, { Orientation } from './Slider'

it('should exist', () => {
expect(Slider).toBeDefined()
})

it('should be a component', () => {
expect(Slider).toBeComponent()
})

it('should render without crashing given required props', () => {
expect(() => <Slider />).not.toThrow()
})

it('should render a label for describing the nature of the value associated with the component', () => {
fc.assert(
fc.property(fc.string(1, 20), (v) => {
const wrapper = Enzyme.shallow(<Slider label={v} />)

expect(wrapper.find('label').find('span')).toHaveText(v)
}),
)
})

const EnzymeMountMethod: Record<string, Function> = {
shallow: Enzyme.shallow,
mount: Enzyme.mount,
}

type MountType = keyof typeof EnzymeMountMethod

describe.each`
label | mountType
${'fallback'} | ${'shallow'}
${'enhanced'} | ${'mount'}
`('on $label mode', ({ mountType: maybeMountType }) => {
const mountType: MountType = maybeMountType! as MountType
const { [mountType]: mountMethod } = EnzymeMountMethod

it('should render an input', () => {
const wrapper = mountMethod(<Slider />)

expect(wrapper.find('label').find('input')).toHaveLength(1)
})

it('should render as half-opaque when disabled', () => {
const wrapper = mountMethod(<Slider disabled />)

expect(wrapper.find('div').first()).toHaveStyle('opacity', 0.5)
})

describe.each<Orientation>(['horizontal', 'vertical'])('on %p orientation', (orientation) => {
const parallelDimension = orientation === 'horizontal' ? 'width' : 'height'

const perpendicularDimension = orientation === 'horizontal' ? 'height' : 'width'

it('should render the sizing styles', () => {
fc.assert(
fc.property(fc.float(), (v) => {
const wrapper = mountMethod(<Slider length={v} orientation={orientation} />)

const sizingWrapper = wrapper.find('SizingWrapper').last()

expect(sizingWrapper).toHaveStyle({
[parallelDimension]: v,
[perpendicularDimension]: '3rem',
})
}),
)
})

it('should render the transform styles', () => {
fc.assert(
fc.property(
fc.oneof<string | number>(fc.string().filter((s) => !s.includes(';')), fc.float().filter((v) => v !== 0)),
(v) => {
const wrapper = mountMethod(<Slider length={v} orientation={orientation} />)

const transformWrapper = wrapper.find('TransformWrapper')

expect(transformWrapper).toHaveStyle({
[parallelDimension]: v,
[perpendicularDimension]: '100%',
transform:
orientation === 'horizontal'
? undefined
: `rotate(-90deg) translateX(calc(${typeof v! === 'number' ? `${v}px` : v} * -1))`,
})
},
),
)
})
})
})

+ 380
- 0
lib/components/Slider/Slider.tsx View File

@@ -0,0 +1,380 @@
import * as React from 'react'
import * as PropTypes from 'prop-types'
import * as ReachSlider from '@reach/slider'
import styled from 'styled-components'
import stringify from '../../services/stringify'

const Wrapper = styled('div')({
'position': 'relative',
'display': 'inline-block',
'verticalAlign': 'top',
':focus-within': {
'--color-accent': 'var(--color-active, Highlight)',
},
})

Wrapper.displayName = 'div'

const Base = styled(ReachSlider.SliderInput)({
'boxSizing': 'border-box',
'position': 'absolute',
'bottom': 0,
'right': 0,
':active': {
cursor: 'grabbing',
},
})

Base.displayName = 'input'

const Track = styled(ReachSlider.SliderTrack)({
'borderRadius': '0.125rem',
'position': 'relative',
'width': '100%',
'height': '100%',
'::before': {
display: 'block',
position: 'absolute',
content: "''",
opacity: 0.25,
width: '100%',
height: '100%',
backgroundColor: 'currentColor',
borderRadius: '0.125rem',
},
})

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',
'::before': {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
content: "''",
borderRadius: '50%',
opacity: 0.5,
pointerEvents: 'none',
},
':focus::before': {
boxShadow: '0 0 0 0.25rem var(--color-accent, blue)',
},
})

const Highlight = styled(ReachSlider.SliderTrackHighlight)({
backgroundColor: 'var(--color-accent, blue)',
borderRadius: '0.125rem',
})

const SizingWrapper = styled('span')({
width: '100%',
display: 'inline-block',
verticalAlign: 'top',
position: 'relative',
})

SizingWrapper.displayName = 'SizingWrapper'

const TransformWrapper = styled('span')({
display: 'inline-block',
verticalAlign: 'top',
transformOrigin: 'top left',
})

TransformWrapper.displayName = 'TransformWrapper'

const LabelWrapper = styled('span')({
color: 'var(--color-accent, blue)',
boxSizing: 'border-box',
fontSize: '0.75em',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 'bolder',
zIndex: 2,
pointerEvents: 'none',
transformOrigin: 'top left',
position: 'absolute',
top: 0,
left: 0,
transitionProperty: 'color',
})

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',
'::before': {
display: 'block',
content: "''",
opacity: 0.25,
width: '100%',
height: '100%',
backgroundColor: 'currentColor',
borderRadius: '0.125rem',
},
})

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,
'::-moz-focus-inner': {
outline: 0,
border: 0,
},
'::-webkit-slider-thumb': {
cursor: 'grab',
width: '1.25rem',
height: '1.25rem',
backgroundColor: 'var(--color-accent, blue)',
borderRadius: '50%',
zIndex: 1,
transformOrigin: 'center',
outline: 0,
position: 'relative',
appearance: 'none',
},
'::-moz-range-thumb': {
cursor: 'grab',
border: 0,
width: '1.25rem',
height: '1.25rem',
backgroundColor: 'var(--color-accent, blue)',
borderRadius: '50%',
zIndex: 1,
transformOrigin: 'center',
outline: 0,
position: 'relative',
appearance: 'none',
},
})

FallbackSlider.displayName = 'input'

const ClickArea = styled('label')({
verticalAlign: 'top',
width: '100%',
height: '100%',
})

ClickArea.displayName = 'label'

export type Orientation = 'vertical' | 'horizontal'

type Dimension = 'width' | 'height'

const propTypes = {
/**
* The component orientation.
*/
orientation: PropTypes.oneOf<Orientation>(['vertical', 'horizontal']),
/**
* Short textual description indicating the nature of the component's value.
*/
label: PropTypes.any,
/**
* CSS size for the component length.
*/
length: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
/**
* Class name used for styling.
*/
className: PropTypes.string,
/**
* Is the component active?
*/
disabled: PropTypes.bool,
}

type Props = PropTypes.InferProps<typeof propTypes>

/**
* Component for inputting numeric values in a graphical manner.
*
* The component is styled using client-side scripts. When the component is rendered server-side,
* the component falls back into the original `<input type="range">` element.
*
* @see {@link //reacttraining.com/reach-ui/slider/#sliderinput|Reach UI Slider} for the client-side implementation.
* @param {'vertical' | 'horizontal'} [orientation] - The component orientation.
* @param {*} [label] - Short textual description indicating the nature of the component's value.
* @param {string | number} [length] - CSS size for the component length.
* @param {string} [className] - Class name used for styling.
* @param {boolean} [disabled] - Is the component active?
* @param {object} etcProps - The component props.
* @returns {React.ReactElement} The component elements.
*/
const Slider: React.FC<Props> = ({
orientation = 'horizontal',
label = '',
length = '16rem',
className = '',
disabled = false,
...etcProps
}) => {
const [isClient, setIsClient] = React.useState(false)

React.useEffect(() => {
window.document.body.style.setProperty('--reach-slider', '1')
setIsClient(true)
}, [])

const parallelDimension: Dimension = orientation === 'horizontal' ? 'width' : 'height'

const perpendicularDimension: Dimension = orientation === 'horizontal' ? 'height' : 'width'

const perpendicularReference = orientation === 'horizontal' ? 'top' : 'left'

const perpendicularTransform = orientation === 'horizontal' ? 'translateY' : 'translateX'

if (isClient) {
return (
<Wrapper
className={className!}
style={{
opacity: disabled ? 0.5 : undefined,
[parallelDimension]: length!,
}}
>
<ClickArea>
<SizingWrapper
style={{
[parallelDimension]: length!,
[perpendicularDimension]: '3rem',
}}
>
<TransformWrapper
style={{
[parallelDimension]: length!,
[perpendicularDimension]: '100%',
transform:
orientation === 'horizontal'
? undefined
: `rotate(-90deg) translateX(calc(${
typeof length! === 'number' ? `${length as number}px` : (length as string)
} * -1))`,
}}
>
<LabelWrapper
style={{
maxWidth: length!,
}}
>
{stringify(label)}
</LabelWrapper>
</TransformWrapper>
</SizingWrapper>
<Base
{...etcProps}
orientation={orientation!}
disabled={disabled!}
style={{
[parallelDimension]: length,
[perpendicularDimension]: '2rem',
padding: orientation === 'horizontal' ? '0.875rem 0.75rem' : '0.75rem 0.875rem',
cursor: disabled ? 'not-allowed' : undefined,
}}
>
<Track>
<Highlight
style={{
[perpendicularDimension]: '100%',
}}
/>
<Handle
style={{
[perpendicularReference]: '50%',
transform: `${perpendicularTransform}(-50%)`,
cursor: disabled ? 'not-allowed' : undefined,
}}
/>
</Track>
</Base>
</ClickArea>
</Wrapper>
)
}

return (
<Wrapper
className={className!}
style={{
opacity: disabled ? 0.5 : undefined,
[parallelDimension]: length!,
}}
>
<SizingWrapper
style={{
[parallelDimension]: length!,
[perpendicularDimension]: '3rem',
}}
>
<TransformWrapper
style={{
[parallelDimension]: length!,
[perpendicularDimension]: '100%',
transform:
orientation === 'horizontal'
? undefined
: `rotate(-90deg) translateX(calc(${typeof length! === 'number' ? `${length}px` : length} * -1))`,
}}
>
<FallbackTrack
style={{
width: length!,
height: '3rem',
padding: '1.875rem 0.75rem 0.875rem',
}}
/>
<ClickArea>
<LabelWrapper
style={{
maxWidth: length!,
}}
>
{stringify(label)}
</LabelWrapper>
{stringify(label).length > 0 && ' '}
<FallbackSlider
{...etcProps}
style={{
width: length!,
}}
disabled={disabled!}
type="range"
/>
</ClickArea>
</TransformWrapper>
</SizingWrapper>
</Wrapper>
)
}

Slider.propTypes = propTypes

Slider.displayName = 'Slider'

export default Slider

+ 19
- 0
lib/components/TextInput/TextInput.mdx View File

@@ -0,0 +1,19 @@
---
name: Text Input
menu: Components
---

import { Playground, Props } from 'docz'
import TextInput from './TextInput'

# Text Input

Component for inputting textual values.

<Playground>
<TextInput label="Username" placeholder="johndoe" hint="the name you use to log in" />
</Playground>

## Props

<Props of={TextInput} />

+ 167
- 0
lib/components/TextInput/TextInput.test.tsx View File

@@ -0,0 +1,167 @@
import * as fc from 'fast-check'
import * as Enzyme from 'enzyme'
import * as Axe from 'jest-axe'
import * as React from 'react'
import TextInput from './TextInput'
import stringify from '../../services/stringify'

it('should exist', () => {
expect(TextInput).toBeDefined()
})

it('should be a component', () => {
expect(TextInput).toBeComponent()
})

it('should render without crashing given required props', () => {
expect(() => <TextInput />).not.toThrow()
})

it('should render a base element to put interactive elements on', () => {
const wrapper = Enzyme.shallow(<TextInput />)

expect(wrapper.find('label')).toHaveLength(1)
})

it('should render an indicator as additional description for the content', () => {
fc.assert(
fc.property(fc.string(1, 20), (indicator) => {
const wrapper = Enzyme.shallow(<TextInput indicator={indicator} />)

expect(wrapper.find('div').childAt(2)).toHaveText(indicator)
}),
)
})

it('should render a hint for describing valid input values', () => {
fc.assert(
fc.property(fc.anything(), (label) => {
const wrapper = Enzyme.shallow(<TextInput hint={label} />)

const renderedLabel = stringify(label)

if (renderedLabel.length > 0) {
expect(wrapper.find('div').children()).toHaveLength(4)

expect(wrapper.find('div').childAt(3)).toHaveText(`(${renderedLabel})`)
} else {
expect(wrapper.find('div').children()).toHaveLength(2)
}
}),
)
})

it('should render a padding on the hint element when an indicator is present', () => {
const wrapper = Enzyme.shallow(<TextInput hint="foo" indicator="foobar" />)

expect(wrapper.find('div').childAt(3)).not.toHaveStyle('padding', '1rem')
})

it('should render as half-opaque when disabled', () => {
const wrapper = Enzyme.shallow(<TextInput disabled />)

expect(wrapper.find('div')).toHaveStyle('opacity', 0.5)
})

describe.each`
multiline | inputWidth | tag
${false} | ${'100%'} | ${'input'}
${true} | ${undefined} | ${'textarea'}
`('on multiline (multiline=$multiline)', ({ tag: rawTag, multiline: rawMultiline, inputWidth: rawInputWidth }) => {
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} />)

expect(wrapper.find('label').find(tag)).toHaveLength(1)
})

it('should render the rest of the passed props', () => {
const wrapper = Enzyme.shallow(<TextInput multiline={multiline} placeholder="foo" />)

expect(
wrapper
.find('label')
.find(tag)
.prop('placeholder'),
).toBe('foo')
})

it('should render a padding on the input element when an indicator is present', () => {
const wrapper = Enzyme.shallow(<TextInput multiline={multiline} indicator="foobar" />)

expect(
wrapper
.find('div')
.find(tag)
.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', () => {
it("should render a label to indicate the nature of the component's value", () => {
const wrapper = Enzyme.shallow(<TextInput label="foo" />)

expect(wrapper.find('label').find('span')).toHaveLength(1)
})

it('should render the label text', () => {
fc.assert(
fc.property(fc.anything(), (label) => {
const wrapper = Enzyme.shallow(<TextInput label={label} />)

expect(wrapper.find('label').find('span')).toHaveText(stringify(label))
}),
{
numRuns: 300,
},
)
})
})

it('should guarantee minimal accessibility', () => {
fc.assert(
fc.asyncProperty(fc.string(1, 20), async (s) => {
const wrapper = Enzyme.mount(<TextInput label={s} />)
const results = await Axe.axe(wrapper.getDOMNode())

expect(results).toHaveNoViolations()
}),
)
})

+ 348
- 0
lib/components/TextInput/TextInput.tsx View File

@@ -0,0 +1,348 @@
import * as React from 'react'
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',
medium: '3rem',
large: '4rem',
}

const LABEL_VERTICAL_PADDING_SIZES: SizeMap<string | number> = {
small: '0.125rem',
medium: '0.25rem',
large: '0.5rem',
}

const VERTICAL_PADDING_SIZES: SizeMap<string | number> = {
small: '0.6rem',
medium: '0.85rem',
large: '1.25rem',
}

const INPUT_FONT_SIZES: SizeMap<string | number> = {
small: '0.85em',
medium: '0.85em',
large: '1em',
}

const SECONDARY_TEXT_SIZES: SizeMap<string | number> = {
small: '0.65em',
medium: '0.75em',
large: '0.85em',
}

const ComponentBase = styled('div')({
'position': 'relative',
'borderRadius': '0.25rem',
'fontFamily': 'var(--font-family-base)',
'maxWidth': '100%',
':focus-within': {
'--color-accent': 'var(--color-active, Highlight)',
},
})

ComponentBase.displayName = 'div'

const CaptureArea = styled('label')({
display: 'block',
borderRadius: 'inherit',
overflow: 'hidden',
})

CaptureArea.displayName = 'label'

const LabelWrapper = styled('span')({
color: 'var(--color-accent, blue)',
boxSizing: 'border-box',
position: 'absolute',
top: 0,
left: 0,
paddingLeft: '0.5rem',
fontSize: '0.85em',
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
fontWeight: 'bolder',
zIndex: 2,
pointerEvents: 'none',
transitionProperty: 'color',
lineHeight: 1,
userSelect: 'none',
})

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',
'::before': {
position: 'absolute',
top: 0,
left: 0,
width: '100%',
height: '100%',
content: "''",
borderRadius: '0.125rem',
opacity: 0.5,
pointerEvents: 'none',
},
[`${ComponentBase}:focus-within &::before`]: {
boxShadow: '0 0 0 0.375rem var(--color-accent, blue)',
},
})

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',
':focus': {
outline: 0,
color: 'var(--color-fg, black)',
},
':disabled': {
cursor: 'not-allowed',
},
})

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',
':focus': {
outline: 0,
},
})

TextArea.displayName = 'textarea'

const HintWrapper = styled('span')({
boxSizing: 'border-box',
position: 'absolute',
bottom: 0,
left: 0,
paddingLeft: '1rem',
fontSize: '0.85em',
opacity: 0.5,
maxWidth: '100%',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
zIndex: 2,
pointerEvents: 'none',
lineHeight: 1,
userSelect: 'none',
})

const IndicatorWrapper = styled('span')({
color: 'var(--color-accent, blue)',
boxSizing: 'border-box',
position: 'absolute',
bottom: 0,
right: 0,
display: 'grid',
placeContent: 'center',
padding: '0 1rem',
zIndex: 1,
pointerEvents: 'none',
transitionProperty: 'color',
lineHeight: 1,
userSelect: 'none',
})

const propTypes = {
/**
* Short textual description indicating the nature of the component's value.
*/
label: PropTypes.any,
/**
* Class name for the component, used for styling.
*/
className: PropTypes.string,
/**
* Short textual description as guidelines for valid input values.
*/
hint: PropTypes.any,
/**
* Size of the component.
*/
size: PropTypes.oneOf<Size>(['small', 'medium', 'large']),
/**
* 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?
*/
multiline: PropTypes.bool,
/**
* Is the component active?
*/
disabled: PropTypes.bool,
/**
* Should the component resize itself to show all its value?
*/
autoResize: PropTypes.bool,
/**
* Placeholder of the component when there is no value.
*/
placeholder: PropTypes.string,
}

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>(
(
{
label = '',
className = '',
hint = '',
indicator = null,
size: sizeProp = 'medium',
block = false,
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
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

TextInput.displayName = 'TextInput'

export default TextInput

+ 17
- 0
lib/index.ts View File

@@ -0,0 +1,17 @@
import Button from './components/Button/Button'
import Checkbox from './components/Checkbox/Checkbox'
import Icon from './components/Icon/Icon'
import RadioButton from './components/RadioButton/RadioButton'
import Select from './components/Select/Select'
import Slider from './components/Slider/Slider'
import TextInput from './components/TextInput/TextInput'

export {
Button,
Checkbox,
Icon,
RadioButton,
Select,
Slider,
TextInput,
}

+ 48
- 0
lib/services/isEmpty.test.ts View File

@@ -0,0 +1,48 @@
import * as fc from 'fast-check'
import isEmpty from './isEmpty'

describe('lib/services/isEmpty', () => {
it('should exist', () => {
expect(isEmpty).toBeDefined()
})

it('should be a function', () => {
expect(isEmpty).toBeFunction()
})

it('should accept 1 argument', () => {
expect(isEmpty).toHaveLength(1)
})

it('should return a boolean value', () => {
fc.assert(
fc.property(
fc.anything(),
v => {
expect(typeof isEmpty(v)).toBe('boolean')
}
)
)
})

describe('on arguments', () => {
it('should return `true` on an argument with value of `undefined`', () => {
expect(isEmpty(undefined)).toBe(true)
})

it('should return `true` on an argument with value of `null`', () => {
expect(isEmpty(null)).toBe(true)
})

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)
}
)
)
})
})
})

+ 10
- 0
lib/services/isEmpty.ts View File

@@ -0,0 +1,10 @@
interface IsEmpty {
(v: any): boolean
}

const isEmpty: IsEmpty = v => (
typeof v === 'undefined'
|| v === null
)

export default isEmpty

+ 60
- 0
lib/services/splitValueAndUnit.test.ts View File

@@ -0,0 +1,60 @@
import * as fc from 'fast-check'
import splitValueAndUnit, { Unit, } from './splitValueAndUnit'

it('should exist', () => {
expect(splitValueAndUnit).toBeDefined()
})

it('should be a function', () => {
expect(splitValueAndUnit).toBeFunction()
})

it('should accept 1 argument', () => {
expect(splitValueAndUnit).toHaveLength(1)
})

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)
}
)
)
})

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,]) => {
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',
})
}
)
)
})

+ 25
- 0
lib/services/splitValueAndUnit.ts View File

@@ -0,0 +1,25 @@
export type Unit = 'px' | '%' | 'rem'

export interface ValueAndUnit {
magnitude: number,
unit: Unit,
}

interface SplitValueAndUnit {
(value: any): ValueAndUnit
}

const splitValueAndUnit: SplitValueAndUnit = value => {
if (!['string', 'number'].includes(typeof value)) {
throw TypeError('Argument must be a valid CSS number')
}

const valueString = typeof value! === 'number' ? `${value}px` : String(value)
const magnitude = parseFloat(valueString)
return {
magnitude,
unit: valueString.slice(String(magnitude).length) as Unit,
}
}

export default splitValueAndUnit

+ 94
- 0
lib/services/stringify.test.ts View File

@@ -0,0 +1,94 @@
import * as fc from 'fast-check'
import * as fcArb from '../../utilities/fast-check/arbitraries'
import stringify from './stringify'

it('should exist', () => {
expect(stringify).toBeDefined()
})

it('should be a function', () => {
expect(stringify).toBeFunction()
})

it('should accept 1 argument', () => {
expect(stringify).toHaveLength(1)
})

it('should return a string value', () => {
fc.assert(
fc.property(
fc.anything(),
v => {
expect(stringify(v)).toBeString()
}
)
)
})

describe('on arguments', () => {
it('should consider `undefined` as empty string', () => {
expect(stringify(undefined)).toBe('')
})

it('should consider `null` as empty string', () => {
expect(stringify(null)).toBe('')
})

it('should stringify non-objects', () => {
fc.assert(
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))
}
)
)
})

describe('on arrays', () => {
it('should stringify empty arrays', () => {
fc.assert(
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]))
}
)
)
})

it('should stringify arrays with 2 or more values', () => {
fc.assert(
fc.property(
fc.array(fcArb.nonObject(), 2, 20),
v => {
expect(stringify(v)).toContain(',')
}
)
)
})
})
})

+ 28
- 0
lib/services/stringify.ts View File

@@ -0,0 +1,28 @@
import isEmpty from './isEmpty'

interface Stringify {
(v: any): string,
}

const stringify: Stringify = v => {
if (isEmpty(v)) {
return ''
}

if (Array.isArray(v)) {
return v
.filter(v => !isEmpty(v))
.map(v => stringify(v))
.join(',')
}

const rawStringified = String(v)

if (rawStringified === '[object Object]') {
return JSON.stringify(v)
}

return rawStringified
}

export default stringify

+ 3
- 0
lib/services/utilities.ts View File

@@ -0,0 +1,3 @@
export type Size = 'small' | 'medium' | 'large'

export type SizeMap<T> = Record<Size, T>

+ 54
- 0
package.json View File

@@ -0,0 +1,54 @@
{
"name": "@tesseract-design/react-common",
"version": "0.0.0",
"description": "Common front-end components for Web using the Tesseract design system, written in React.",
"main": "dist/bundle.cjs.js",
"module": "dist/bundle.esm.js",
"repository": "https://code.modal.sh/tesseract-design/react-common.git",
"author": "TheoryOfNekomata <allan.crisostomo@outlook.com>",
"license": "UNLICENSED",
"private": false,
"devDependencies": {
"@rollup/plugin-typescript": "^5.0.2",
"@types/enzyme": "^3.10.5",
"@types/enzyme-adapter-react-16": "^1.0.6",
"@types/jest": "^26.0.7",
"@types/jest-axe": "^3.5.0",
"@types/node": "^14.0.25",
"@types/prop-types": "^15.7.3",
"@types/react": "^16.9.43",
"@types/react-is": "^16.7.1",
"@types/styled-components": "^5.1.1",
"enzyme": "3.10.0",
"enzyme-adapter-react-16": "1.15.1",
"fast-check": "1.22.1",
"jest": "^26.1.0",
"jest-axe": "3.4.0",
"jest-enzyme": "7.1.2",
"jest-extended": "0.11.5",
"plop": "2.6.0",
"prettier": "1.19.1",
"react-is": "^16.13.1",
"rollup": "^2.23.0",
"rollup-plugin-peer-deps-external": "2.2.2",
"rollup-plugin-terser": "5.3.0",
"ts-jest": "^26.1.3",
"tslib": "^2.0.0",
"typescript": "^3.9.7"
},
"scripts": {
"test": "jest",
"build": "rm -rf dist/ && rollup -c",
"generate": "plop",
"docs": "docz"
},
"dependencies": {
"docz": "^2.3.1",
"pascal-case": "3.1.1",
"prop-types": "15.7.2",
"react": "16.13.1",
"react-dom": "16.13.1",
"react-feather": "2.0.3",
"styled-components": "5.1.0"
}
}

+ 19
- 0
plop/templates/component/{{pascalCase name}}.mdx.hbs View File

@@ -0,0 +1,19 @@
---
name: {{pascalCase name}}
menu: Components
---

import { Playground, Props } from 'docz'
import {{pascalCase name}} from './{{pascalCase name}}'

# {{pascalCase name}}

{{description}}

<Playground>
<{{pascalCase name}} />
</Playground>

## Props

<Props of={ {{pascalCase name}} } />

+ 37
- 0
plop/templates/component/{{pascalCase name}}.test.tsx.hbs View File

@@ -0,0 +1,37 @@
import * as fc from 'fast-check'
import * as Enzyme from 'enzyme'
import * as Axe from 'jest-axe'
import * as React from 'react'
import {{pascalCase name}} from './{{pascalCase name}}'

it('should exist', () => {
expect({{pascalCase name}}).toBeDefined()
})

it('should be a component', () => {
expect({{pascalCase name}}).toBeComponent()
})

it('should render without crashing given required props', () => {
expect(() => <{{pascalCase name}} name="" />).not.toThrow()
})

it('should guarantee minimal accessibility', () => {
fc.assert(
fc.asyncProperty(
fc.string(1, 20),
async s => {
const wrapper = Enzyme.mount(
<{{pascalCase name}}
name={s}
/>
)
const results = await Axe.axe(wrapper.getDOMNode())

expect(results).toHaveNoViolations()
}
)
)
})

// TODO add more tests

+ 43
- 0
plop/templates/component/{{pascalCase name}}.tsx.hbs View File

@@ -0,0 +1,43 @@
import * as React from 'react'
import * as PropTypes from 'prop-types'
import styled from 'styled-components'

const Base = styled('span')({
// TODO add styles
})

const propTypes = {
/**
* Name.
*/
name: PropTypes.string.isRequired,
// TODO add prop types
}

type Props = PropTypes.InferProps<typeof propTypes>

/**
* {{description}}
* @param {string} name - Name.
* @param {object} etcProps - The rest of the props.
* @returns {React.ReactElement} The component elements.
*/
const {{pascalCase name}}: React.FC<Props> = ({
name,
// TODO define more props
...etcProps
}) => {
// TODO put something before render, e.g. hooks

return (
<Base
{...etcProps}
>
{name}
</Base>
)
}

{{pascalCase name}}.propTypes = propTypes

export default {{pascalCase name}}

+ 30
- 0
plopfile.js View File

@@ -0,0 +1,30 @@
const testName = require('./plop/helpers/testName.js')
module.exports = plop => {
plop.setGenerator('component', {
description: 'Creates a component.',
prompts: [
{
name: 'name',
message: 'Enter the component name.',
validate: name => {
if (name.trim().length < 1) {
return 'Name is required.'
}
return true
},
},
{
name: 'description',
message: 'Describe your component.',
},
],
actions: [
{
type: 'addMany',
templateFiles: 'plop/templates/component/*',
base: 'plop/templates/component',
destination: 'lib/components/{{pascalCase name}}',
},
],
})
}

+ 38
- 0
rollup.config.js View File

@@ -0,0 +1,38 @@
import peerDepsExternal from 'rollup-plugin-peer-deps-external'
import { terser, } from 'rollup-plugin-terser'
import typescript from '@rollup/plugin-typescript'

import pkg from './package.json'

const ENTRY_POINT = './lib/index.ts'

export default [
{
input: ENTRY_POINT,
output: {
file: pkg.main,
format: 'cjs',
},
plugins: [
peerDepsExternal({
includeDependencies: true,
}),
typescript(),
process.env.NODE_ENV === 'production' && terser(),
],
},
{
input: ENTRY_POINT,
output: {
file: pkg.module,
format: 'esm',
},
plugins: [
peerDepsExternal({
includeDependencies: true,
}),
typescript(),
process.env.NODE_ENV === 'production' && terser(),
],
},
]

+ 25
- 0
tsconfig.json View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "es5",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "esnext",
"moduleResolution": "node",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
},
"exclude": [
"node_modules"
]
}

+ 5
- 0
utilities/fast-check/arbitraries.ts View File

@@ -0,0 +1,5 @@
import * as fc from 'fast-check'

export const nonObject = () => fc
.anything()
.filter(v => !['object', 'undefined'].includes(typeof v))

+ 14
- 0
utilities/jest/extensions.ts View File

@@ -0,0 +1,14 @@
import ReactIs from 'react-is'

declare global {
namespace jest {
interface Matchers<R> {
toBeComponent(): R,
}
}
}

export const toBeComponent = (received: any) => ({
message: () => `expected ${received} to be a component`,
pass: ReactIs.isValidElementType(received),
})

+ 19557
- 0
yarn.lock
File diff suppressed because it is too large
View File


Loading…
Cancel
Save