Migrate from GitHub, rewrite common components to TypeScript (main and test files). Also replaced Storybook to Docz.tags/0.3.0
@@ -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 |
@@ -0,0 +1,2 @@ | |||||
# Port where Storybook will run | |||||
NP_STORYBOOK_PORT= |
@@ -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/ |
@@ -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 |
@@ -0,0 +1,9 @@ | |||||
{ | |||||
"jsxSingleQuote": false, | |||||
"singleQuote": true, | |||||
"printWidth": 120, | |||||
"semi": false, | |||||
"trailingComma": "all", | |||||
"quoteProps": "consistent", | |||||
"arrowParens": "always" | |||||
} |
@@ -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) | |||||
``` |
@@ -0,0 +1,3 @@ | |||||
export default { | |||||
typescript: true, | |||||
} |
@@ -0,0 +1,3 @@ | |||||
import 'jest-enzyme' | |||||
import 'jest-extended' | |||||
import './utilities/jest/extensions' |
@@ -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'], | |||||
} |
@@ -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) |
@@ -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. |
@@ -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() | |||||
}), | |||||
) | |||||
}) |
@@ -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 |
@@ -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. |
@@ -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() | |||||
}), | |||||
) | |||||
}) |
@@ -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 |
@@ -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} /> |
@@ -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) | |||||
}), | |||||
) | |||||
}) |
@@ -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 |
@@ -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. |
@@ -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() | |||||
}), | |||||
) | |||||
}) |
@@ -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 |
@@ -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. |
@@ -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() | |||||
}), | |||||
) | |||||
}) |
@@ -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 |
@@ -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} /> |
@@ -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))`, | |||||
}) | |||||
}, | |||||
), | |||||
) | |||||
}) | |||||
}) | |||||
}) |
@@ -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 |
@@ -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} /> |
@@ -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() | |||||
}), | |||||
) | |||||
}) |
@@ -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 |
@@ -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, | |||||
} |
@@ -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) | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
}) | |||||
}) |
@@ -0,0 +1,10 @@ | |||||
interface IsEmpty { | |||||
(v: any): boolean | |||||
} | |||||
const isEmpty: IsEmpty = v => ( | |||||
typeof v === 'undefined' | |||||
|| v === null | |||||
) | |||||
export default isEmpty |
@@ -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', | |||||
}) | |||||
} | |||||
) | |||||
) | |||||
}) |
@@ -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 |
@@ -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(',') | |||||
} | |||||
) | |||||
) | |||||
}) | |||||
}) | |||||
}) |
@@ -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 |
@@ -0,0 +1,3 @@ | |||||
export type Size = 'small' | 'medium' | 'large' | |||||
export type SizeMap<T> = Record<Size, T> |
@@ -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" | |||||
} | |||||
} |
@@ -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}} } /> |
@@ -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 |
@@ -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}} |
@@ -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}}', | |||||
}, | |||||
], | |||||
}) | |||||
} |
@@ -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(), | |||||
], | |||||
}, | |||||
] |
@@ -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" | |||||
] | |||||
} |
@@ -0,0 +1,5 @@ | |||||
import * as fc from 'fast-check' | |||||
export const nonObject = () => fc | |||||
.anything() | |||||
.filter(v => !['object', 'undefined'].includes(typeof v)) |
@@ -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), | |||||
}) |