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