commit 969b03a890a20107f8038945bc1addd7ae8b0cbb Author: TheoryOfNekomata Date: Fri Jul 24 22:09:00 2020 +0800 Initial commit Migrate from GitHub, rewrite common components to TypeScript (main and test files). Also replaced Storybook to Docz. 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 && ( +