From 969b03a890a20107f8038945bc1addd7ae8b0cbb Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Fri, 24 Jul 2020 22:09:00 +0800 Subject: [PATCH] Initial commit Migrate from GitHub, rewrite common components to TypeScript (main and test files). Also replaced Storybook to Docz. --- .editorconfig | 11 + .env.example | 2 + .gitignore | 70 + .npmignore | 14 + .prettierrc | 9 + README.md | 75 + doczrc.js | 3 + global.d.ts | 3 + jest.config.js | 14 + jest.setup.ts | 16 + lib/components/Button/Button.mdx | 42 + lib/components/Button/Button.test.tsx | 127 + lib/components/Button/Button.tsx | 145 + lib/components/Checkbox/Checkbox.mdx | 24 + lib/components/Checkbox/Checkbox.test.tsx | 75 + lib/components/Checkbox/Checkbox.tsx | 154 + lib/components/Icon/Icon.mdx | 19 + lib/components/Icon/Icon.test.tsx | 38 + lib/components/Icon/Icon.tsx | 111 + lib/components/RadioButton/RadioButton.mdx | 29 + .../RadioButton/RadioButton.test.tsx | 80 + lib/components/RadioButton/RadioButton.tsx | 154 + lib/components/Select/Select.mdx | 24 + lib/components/Select/Select.test.tsx | 124 + lib/components/Select/Select.tsx | 306 + lib/components/Slider/Slider.mdx | 22 + lib/components/Slider/Slider.test.tsx | 97 + lib/components/Slider/Slider.tsx | 380 + lib/components/TextInput/TextInput.mdx | 19 + lib/components/TextInput/TextInput.test.tsx | 167 + lib/components/TextInput/TextInput.tsx | 348 + lib/index.ts | 17 + lib/services/isEmpty.test.ts | 48 + lib/services/isEmpty.ts | 10 + lib/services/splitValueAndUnit.test.ts | 60 + lib/services/splitValueAndUnit.ts | 25 + lib/services/stringify.test.ts | 94 + lib/services/stringify.ts | 28 + lib/services/utilities.ts | 3 + package.json | 54 + .../component/{{pascalCase name}}.mdx.hbs | 19 + .../{{pascalCase name}}.test.tsx.hbs | 37 + .../component/{{pascalCase name}}.tsx.hbs | 43 + plopfile.js | 30 + rollup.config.js | 38 + tsconfig.json | 25 + utilities/fast-check/arbitraries.ts | 5 + utilities/jest/extensions.ts | 14 + yarn.lock | 19557 ++++++++++++++++ 49 files changed, 22809 insertions(+) create mode 100644 .editorconfig create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .npmignore create mode 100644 .prettierrc create mode 100644 README.md create mode 100644 doczrc.js create mode 100644 global.d.ts create mode 100644 jest.config.js create mode 100644 jest.setup.ts create mode 100644 lib/components/Button/Button.mdx create mode 100644 lib/components/Button/Button.test.tsx create mode 100644 lib/components/Button/Button.tsx create mode 100644 lib/components/Checkbox/Checkbox.mdx create mode 100644 lib/components/Checkbox/Checkbox.test.tsx create mode 100644 lib/components/Checkbox/Checkbox.tsx create mode 100644 lib/components/Icon/Icon.mdx create mode 100644 lib/components/Icon/Icon.test.tsx create mode 100644 lib/components/Icon/Icon.tsx create mode 100644 lib/components/RadioButton/RadioButton.mdx create mode 100644 lib/components/RadioButton/RadioButton.test.tsx create mode 100644 lib/components/RadioButton/RadioButton.tsx create mode 100644 lib/components/Select/Select.mdx create mode 100644 lib/components/Select/Select.test.tsx create mode 100644 lib/components/Select/Select.tsx create mode 100644 lib/components/Slider/Slider.mdx create mode 100644 lib/components/Slider/Slider.test.tsx create mode 100644 lib/components/Slider/Slider.tsx create mode 100644 lib/components/TextInput/TextInput.mdx create mode 100644 lib/components/TextInput/TextInput.test.tsx create mode 100644 lib/components/TextInput/TextInput.tsx create mode 100644 lib/index.ts create mode 100644 lib/services/isEmpty.test.ts create mode 100644 lib/services/isEmpty.ts create mode 100644 lib/services/splitValueAndUnit.test.ts create mode 100644 lib/services/splitValueAndUnit.ts create mode 100644 lib/services/stringify.test.ts create mode 100644 lib/services/stringify.ts create mode 100644 lib/services/utilities.ts create mode 100644 package.json create mode 100644 plop/templates/component/{{pascalCase name}}.mdx.hbs create mode 100644 plop/templates/component/{{pascalCase name}}.test.tsx.hbs create mode 100644 plop/templates/component/{{pascalCase name}}.tsx.hbs create mode 100644 plopfile.js create mode 100644 rollup.config.js create mode 100644 tsconfig.json create mode 100644 utilities/fast-check/arbitraries.ts create mode 100644 utilities/jest/extensions.ts create mode 100644 yarn.lock diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..92dc0fc --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0e95d43 --- /dev/null +++ b/.env.example @@ -0,0 +1,2 @@ +# Port where Storybook will run +NP_STORYBOOK_PORT= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4f41954 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.npmignore b/.npmignore new file mode 100644 index 0000000..035c435 --- /dev/null +++ b/.npmignore @@ -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 diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 0000000..75c93d5 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "jsxSingleQuote": false, + "singleQuote": true, + "printWidth": 120, + "semi": false, + "trailingComma": "all", + "quoteProps": "consistent", + "arrowParens": "always" +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..8fb7b9b --- /dev/null +++ b/README.md @@ -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 => ( +
+
+ + Log In + +
+ +
+
+ +
+
+ + Log In + +
+
+
+) + +const mountNode = window.document.createElement('div') + +ReactDOM.render( + , + mountNode, +) + +window.document.body.appendChild(mountNode) +``` diff --git a/doczrc.js b/doczrc.js new file mode 100644 index 0000000..8d8c4b8 --- /dev/null +++ b/doczrc.js @@ -0,0 +1,3 @@ +export default { + typescript: true, +} diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 0000000..9f022a8 --- /dev/null +++ b/global.d.ts @@ -0,0 +1,3 @@ +import 'jest-enzyme' +import 'jest-extended' +import './utilities/jest/extensions' diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..5e77eb6 --- /dev/null +++ b/jest.config.js @@ -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: ['/.docz'], +} diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..22b3929 --- /dev/null +++ b/jest.setup.ts @@ -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) diff --git a/lib/components/Button/Button.mdx b/lib/components/Button/Button.mdx new file mode 100644 index 0000000..aeb236c --- /dev/null +++ b/lib/components/Button/Button.mdx @@ -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). + + + + + +## Props + + + +## 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. diff --git a/lib/components/Button/Button.test.tsx b/lib/components/Button/Button.test.tsx new file mode 100644 index 0000000..e2c1b70 --- /dev/null +++ b/lib/components/Button/Button.test.tsx @@ -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(() => ) + + 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() + const results = await Axe.axe(wrapper.getDOMNode()) + + expect(results).toHaveNoViolations() + }), + ) +}) diff --git a/lib/components/Button/Button.tsx b/lib/components/Button/Button.tsx new file mode 100644 index 0000000..adec171 --- /dev/null +++ b/lib/components/Button/Button.tsx @@ -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 = { + 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(['small', 'medium', 'large']), + /** + * Variant of the component. + */ + variant: PropTypes.oneOf>(['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 + +const defaultVariantStyleSet: React.CSSProperties = { + backgroundColor: 'transparent', + color: 'var(--color-accent, blue)', +} + +const variantStyleSets: Record = { + outline: defaultVariantStyleSet, + primary: { + backgroundColor: 'var(--color-accent, blue)', + color: 'var(--color-bg, white)', + }, +} + +const Button = React.forwardRef( + ({ size = 'medium', variant = 'outline', block = false, disabled = false, children, ...etcProps }, ref) => { + const { [variant as Variant]: theVariantStyleSet = defaultVariantStyleSet } = variantStyleSets + + return ( + + + {stringify(children)} + + ) + }, +) + +Button.propTypes = propTypes + +Button.displayName = 'Button' + +export default Button diff --git a/lib/components/Checkbox/Checkbox.mdx b/lib/components/Checkbox/Checkbox.mdx new file mode 100644 index 0000000..1fc4f58 --- /dev/null +++ b/lib/components/Checkbox/Checkbox.mdx @@ -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. + + + + + +## Props + + + +## See Also + +- Select for a similar component suitable for selecting more values. +- Radio Button for a similar component on selecting a single value among very few choices. diff --git a/lib/components/Checkbox/Checkbox.test.tsx b/lib/components/Checkbox/Checkbox.test.tsx new file mode 100644 index 0000000..602782f --- /dev/null +++ b/lib/components/Checkbox/Checkbox.test.tsx @@ -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(() => ).not.toThrow() +}) + +it('should render a label to describe the intrinsic value of the component', () => { + const wrapper = Enzyme.shallow() + + 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() + + 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() + + expect( + wrapper + .find('label') + .children() + .last() + .text(), + ).toHaveLength(0) + }), + ) +}) + +it('should render the input', () => { + const wrapper = Enzyme.shallow() + + 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() + const results = await Axe.axe(wrapper.getDOMNode()) + + expect(results).toHaveNoViolations() + }), + ) +}) diff --git a/lib/components/Checkbox/Checkbox.tsx b/lib/components/Checkbox/Checkbox.tsx new file mode 100644 index 0000000..ae8104b --- /dev/null +++ b/lib/components/Checkbox/Checkbox.tsx @@ -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 + +/** + * 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>} + */ +const Checkbox = React.forwardRef(({ label = '', ...etcProps }, ref) => ( + + + + + + + + + + {typeof label! !== 'undefined' && label !== null && ' '} + + + +)) + +Checkbox.propTypes = propTypes + +Checkbox.displayName = 'Checkbox' + +export default Checkbox diff --git a/lib/components/Icon/Icon.mdx b/lib/components/Icon/Icon.mdx new file mode 100644 index 0000000..9c6064d --- /dev/null +++ b/lib/components/Icon/Icon.mdx @@ -0,0 +1,19 @@ +--- +name: Icon +menu: Components +--- + +import { Playground, Props } from 'docz' +import Icon from './Icon' + +# Icon + +Component for displaying graphics. + + + + + +## Props + + diff --git a/lib/components/Icon/Icon.test.tsx b/lib/components/Icon/Icon.test.tsx new file mode 100644 index 0000000..981fc80 --- /dev/null +++ b/lib/components/Icon/Icon.test.tsx @@ -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(() => ).not.toThrow() +}) + +describe('on XML icons', () => { + test.each(FEATHER_ICONS)('should render the %p XML icon', (name) => { + const wrapper = Enzyme.shallow() + + 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() + + expect(wrapper.getElement()).toBe(null) + }), + ) +}) diff --git a/lib/components/Icon/Icon.tsx b/lib/components/Icon/Icon.tsx new file mode 100644 index 0000000..4f1f01d --- /dev/null +++ b/lib/components/Icon/Icon.tsx @@ -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 + +/** + * 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 = ({ + 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 ( + + + + + ) + } + + return null +} + +Icon.propTypes = propTypes + +Icon.displayName = 'Icon' + +export default Icon diff --git a/lib/components/RadioButton/RadioButton.mdx b/lib/components/RadioButton/RadioButton.mdx new file mode 100644 index 0000000..5dde51d --- /dev/null +++ b/lib/components/RadioButton/RadioButton.mdx @@ -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. + + +
+ +
+
+ +
+
+ +## Props + + + +## See Also + +- Checkbox for a similar component on selecting values among very few choices. +- Select for a similar component suitable for selecting more values. diff --git a/lib/components/RadioButton/RadioButton.test.tsx b/lib/components/RadioButton/RadioButton.test.tsx new file mode 100644 index 0000000..958b13e --- /dev/null +++ b/lib/components/RadioButton/RadioButton.test.tsx @@ -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(() => ).not.toThrow() +}) + +it("should render a label to indicate the nature of the component's value", () => { + const wrapper = Enzyme.shallow() + + 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() + + 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() + + expect( + wrapper + .find('label') + .children() + .last() + .text(), + ).toHaveLength(0) + }), + ) +}) + +it('should render the input', () => { + const wrapper = Enzyme.shallow() + + 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( +
+ +
, + ) + + const results = await Axe.axe(wrapper.getDOMNode()) + + expect(results).toHaveNoViolations() + }), + ) +}) diff --git a/lib/components/RadioButton/RadioButton.tsx b/lib/components/RadioButton/RadioButton.tsx new file mode 100644 index 0000000..b217bc6 --- /dev/null +++ b/lib/components/RadioButton/RadioButton.tsx @@ -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 + +/** + * 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>} + */ +const RadioButton = React.forwardRef(({ label = '', name, ...etcProps }, ref) => ( + + + + + + + + {typeof label! !== 'undefined' && label !== null && ' '} + + + +)) + +RadioButton.propTypes = propTypes + +RadioButton.displayName = 'RadioButton' + +export default RadioButton diff --git a/lib/components/Select/Select.mdx b/lib/components/Select/Select.mdx new file mode 100644 index 0000000..822df08 --- /dev/null +++ b/lib/components/Select/Select.mdx @@ -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. + + + ).not.toThrow() +}) + +it('should render a base element to put interactive elements on', () => { + const wrapper = Enzyme.shallow() + + 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() + + 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() + + expect(BLOCK_DISPLAYS).toContain(wrapper.find('div').prop('style')!.display) + }) + + it('should render the input fullwidth', () => { + const wrapper = Enzyme.shallow() + + expect(BLOCK_DISPLAYS).toContain( + wrapper + .find('label') + .find('select') + .prop('style')!.display, + ) + }) +}) + +it('should render as half-opaque when disabled', () => { + const wrapper = Enzyme.shallow() + + 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( + + {stringify(hint).length > 0 && ' '} + {stringify(hint).length > 0 && ( + + ({stringify(hint)}) + + )} + {!multiple && ( + + + + )} + + ) + }, +) + +Select.propTypes = propTypes + +Select.displayName = 'Select' + +export default Select diff --git a/lib/components/Slider/Slider.mdx b/lib/components/Slider/Slider.mdx new file mode 100644 index 0000000..df68d2e --- /dev/null +++ b/lib/components/Slider/Slider.mdx @@ -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 `` element. + + + + + +## Props + + diff --git a/lib/components/Slider/Slider.test.tsx b/lib/components/Slider/Slider.test.tsx new file mode 100644 index 0000000..9bb2c30 --- /dev/null +++ b/lib/components/Slider/Slider.test.tsx @@ -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(() => ).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() + + expect(wrapper.find('label').find('span')).toHaveText(v) + }), + ) +}) + +const EnzymeMountMethod: Record = { + 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() + + expect(wrapper.find('label').find('input')).toHaveLength(1) + }) + + it('should render as half-opaque when disabled', () => { + const wrapper = mountMethod() + + expect(wrapper.find('div').first()).toHaveStyle('opacity', 0.5) + }) + + describe.each(['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() + + 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(fc.string().filter((s) => !s.includes(';')), fc.float().filter((v) => v !== 0)), + (v) => { + const wrapper = mountMethod() + + 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))`, + }) + }, + ), + ) + }) + }) +}) diff --git a/lib/components/Slider/Slider.tsx b/lib/components/Slider/Slider.tsx new file mode 100644 index 0000000..eaea7fb --- /dev/null +++ b/lib/components/Slider/Slider.tsx @@ -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(['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 + +/** + * 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 `` 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 = ({ + 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 ( + + + + + + {stringify(label)} + + + + + + + + + + + + ) + } + + return ( + + + + + + + {stringify(label)} + + {stringify(label).length > 0 && ' '} + + + + + + ) +} + +Slider.propTypes = propTypes + +Slider.displayName = 'Slider' + +export default Slider diff --git a/lib/components/TextInput/TextInput.mdx b/lib/components/TextInput/TextInput.mdx new file mode 100644 index 0000000..b34db7c --- /dev/null +++ b/lib/components/TextInput/TextInput.mdx @@ -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. + + + + + +## Props + + diff --git a/lib/components/TextInput/TextInput.test.tsx b/lib/components/TextInput/TextInput.test.tsx new file mode 100644 index 0000000..06a48a9 --- /dev/null +++ b/lib/components/TextInput/TextInput.test.tsx @@ -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(() => ).not.toThrow() +}) + +it('should render a base element to put interactive elements on', () => { + const wrapper = Enzyme.shallow() + + 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() + + 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() + + 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() + + expect(wrapper.find('div').childAt(3)).not.toHaveStyle('padding', '1rem') +}) + +it('should render as half-opaque when disabled', () => { + const wrapper = Enzyme.shallow() + + 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() + + expect(wrapper.find('label').find(tag)).toHaveLength(1) + }) + + it('should render the rest of the passed props', () => { + const wrapper = Enzyme.shallow() + + 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() + + 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() + + const { display } = wrapper.find('div').prop('style')! + + expect(BLOCK_DISPLAYS).toContain(display) + }) + + it('should render the input fullwidth', () => { + const wrapper = Enzyme.shallow() + + 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() + + 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() + + 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() + + 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() + const results = await Axe.axe(wrapper.getDOMNode()) + + expect(results).toHaveNoViolations() + }), + ) +}) diff --git a/lib/components/TextInput/TextInput.tsx b/lib/components/TextInput/TextInput.tsx new file mode 100644 index 0000000..6a63d37 --- /dev/null +++ b/lib/components/TextInput/TextInput.tsx @@ -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 = { + small: '2.5rem', + medium: '3rem', + large: '4rem', +} + +const LABEL_VERTICAL_PADDING_SIZES: SizeMap = { + small: '0.125rem', + medium: '0.25rem', + large: '0.5rem', +} + +const VERTICAL_PADDING_SIZES: SizeMap = { + small: '0.6rem', + medium: '0.85rem', + large: '1.25rem', +} + +const INPUT_FONT_SIZES: SizeMap = { + small: '0.85em', + medium: '0.85em', + large: '1em', +} + +const SECONDARY_TEXT_SIZES: SizeMap = { + 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(['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 + +/** + * 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>} + */ +const TextInput = React.forwardRef( + ( + { + 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 ( + + + + + {stringify(label)} + + {stringify(label).length > 0 && ' '} + {multiline && ( +