diff --git a/package-lock.json b/package-lock.json index b63386a..4f6fac2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5435,6 +5435,11 @@ } } }, + "cleave.js": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/cleave.js/-/cleave.js-1.4.10.tgz", + "integrity": "sha512-7AWnh+6pgQbbRT+e7WnK/yc+U50zhtQZklvEcKDiFDpfcu3kb300BdV7FMYtGOIsAzD5TKy7KH38ZwgyWyFE2Q==" + }, "cli-boxes": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-1.0.0.tgz", @@ -14657,6 +14662,23 @@ "type-check": "~0.3.2" } }, + "libphonenumber-js": { + "version": "1.7.13", + "resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.7.13.tgz", + "integrity": "sha512-JZWskCCYnYk+lCSkoTmK3qbJh+KrVZHN+VJtqa3qTjz73+RtYECiPiicTS3yBpB46SoQ52HmbC5M7nbR7GEUqg==", + "requires": { + "minimist": "^1.2.0", + "semver-compare": "^1.0.0", + "xml2js": "^0.4.17" + }, + "dependencies": { + "minimist": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.0.tgz", + "integrity": "sha1-o1AIsg9BOD7sH7kU9M1d95omQoQ=" + } + } + }, "load-cfg": { "version": "0.13.3", "resolved": "https://registry.npmjs.org/load-cfg/-/load-cfg-0.13.3.tgz", @@ -19275,8 +19297,7 @@ "sax": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz", - "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==", - "dev": true + "integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw==" }, "scan-directory": { "version": "1.0.0", @@ -19353,6 +19374,11 @@ "integrity": "sha512-RS9R6R35NYgQn++fkDWaOmqGoj4Ek9gGs+DPxNUZKuwE183xjJroKvyo1IzVFeXvUrvmALy6FWD5xrdJT25gMg==", "dev": true }, + "semver-compare": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz", + "integrity": "sha1-De4hahyUGrN+nvsXiPavxf9VN/w=" + }, "send": { "version": "0.16.2", "resolved": "https://registry.npmjs.org/send/-/send-0.16.2.tgz", @@ -22646,6 +22672,20 @@ "integrity": "sha512-A5CUptxDsvxKJEU3yO6DuWBSJz/qizqzJKOMIfUJHETbBw/sFaDxgd6fxm1ewUaM0jZ444Fc5vC5ROYurg/4Pw==", "dev": true }, + "xml2js": { + "version": "0.4.19", + "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.19.tgz", + "integrity": "sha512-esZnJZJOiJR9wWKMyuvSE1y6Dq5LCuJanqhxslH2bxM6duahNZ+HMpCLhBQGZkbX6xRf8x1Y2eJlgt2q3qo49Q==", + "requires": { + "sax": ">=0.6.0", + "xmlbuilder": "~9.0.1" + } + }, + "xmlbuilder": { + "version": "9.0.7", + "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-9.0.7.tgz", + "integrity": "sha1-Ey7mPS7FVlxVfiD0wi35rKaGsQ0=" + }, "xregexp": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xregexp/-/xregexp-4.0.0.tgz", diff --git a/package.json b/package.json index 995f1ba..53873b7 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "release": "standard-version" }, "dependencies": { + "cleave.js": "^1.4.10", + "libphonenumber-js": "^1.7.13", "lodash": "^4.17.11", "mitt": "^1.1.3", "polished": "^2.0.0", diff --git a/src/Form/DateInput.js b/src/Form/DateInput.js new file mode 100644 index 0000000..f63a4a4 --- /dev/null +++ b/src/Form/DateInput.js @@ -0,0 +1,96 @@ +import React, { useState, useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import DateFormatter from 'cleave.js/src/shortcuts/DateFormatter'; +import Input from './Input'; +import { createEasyInput } from './EasyInput'; +import { getNextCursorPosition } from '../utils'; + +export const getRawMaxLength = pattern => { + const formatter = new DateFormatter(pattern); + const blocks = formatter.getBlocks(); + return blocks.reduce((sum, block) => sum + block, 0); +}; + +const formatDate = (pattern, delimiter, dateString = '') => { + const formatter = new DateFormatter(pattern); + + // Process our date string, bounding values between 1 and 31, and prepending 0s for + // for single digit blocks that can't have 2 numbers, e.g. 5 + let tmpDate = formatter.getValidatedDate(`${dateString}`); + + // Blocks look something like [2, 2, 4], telling us how long each chunk should be + return formatter.getBlocks().reduce((str, blockLength, index, blockArr) => { + const block = tmpDate.substring(0, blockLength); + if (!block) { + return str; + } + + tmpDate = tmpDate.substring(blockLength); + + // Append the delimiter if our block is complete and we're not at the last block + const shouldAppendDelimiter = block.length === blockLength && index < blockArr.length - 1; + + return `${str}${block}${shouldAppendDelimiter ? delimiter : ''}`; + }, ''); +}; + +function DateInput({ delimiter, pattern, forwardedRef, value: propValue, onKeyDown, onChange, ...inputProps }) { + const format = value => formatDate(pattern, delimiter, value); + const [currentValue, setValue] = useState(format(propValue)); + const inputRef = forwardedRef || useRef(); + + useEffect(() => { + if (propValue !== currentValue) { + setValue(format(propValue)); + } + }, [propValue]); + + const handleKeyDown = event => { + const isLetterLike = /^\w{1}$/.test(event.key); + if (isLetterLike && currentValue.replace(/\D/g, '').length >= getRawMaxLength(pattern)) { + event.preventDefault(); + event.stopPropagation(); + } + + if (onKeyDown) { + onKeyDown(event); + } + }; + + const handleChange = (name, newValue, event) => { + const nextValue = newValue.length < currentValue.length ? newValue.trim() : format(newValue); + const nextCursorPosition = getNextCursorPosition(event.target.selectionStart, currentValue, nextValue); + + setValue(nextValue); + setTimeout(() => { + inputRef.current.setSelectionRange(nextCursorPosition, nextCursorPosition); + }); + + if (onChange) { + onChange(name, nextValue, event); + } + }; + + return ( + + ); +} + +DateInput.propTypes = { + ...Input.propTypes, + pattern: PropTypes.arrayOf(PropTypes.string), + delimiter: PropTypes.string, +}; + +DateInput.defaultProps = { + pattern: ['m', 'd', 'Y'], + delimiter: '/', +}; + +export default createEasyInput(DateInput); diff --git a/src/Form/DateInput.spec.js b/src/Form/DateInput.spec.js new file mode 100644 index 0000000..0e7a0bf --- /dev/null +++ b/src/Form/DateInput.spec.js @@ -0,0 +1,87 @@ +import React from 'react'; +import { renderWithTheme, fireEvent, act } from '../../test/utils'; +import DateInput, { getRawMaxLength } from './DateInput'; +import ThemeProvider from '../ThemeProvider'; + +describe('', () => { + const renderInput = props => { + const utils = renderWithTheme(); + return { + ...utils, + input: utils.getByPlaceholderText('Input'), + }; + }; + + const updateInputValue = (input, value) => { + act(() => { + fireEvent.change(input, { target: { value } }); + }); + }; + + test('#getMaxDateLength', () => { + expect(getRawMaxLength(['m', 'd', 'Y'])).toEqual(8); + expect(getRawMaxLength(['m', 'd', 'y'])).toEqual(6); + }); + + test('snapshot', () => { + const { asFragment } = renderInput(); + expect(asFragment()).toMatchSnapshot(); + }); + + test('initally sets the value', () => { + const value = '12/05/1992'; + const { input } = renderInput({ value }); + + expect(input.value).toEqual(value); + }); + + test("prefixes month and day block with a 0 when second number isn't possible", () => { + const { input } = renderInput(); + + updateInputValue(input, '4'); + expect(input.value).toEqual('04/'); + + updateInputValue(input, '04/4'); + expect(input.value).toEqual('04/04/'); + }); + + test("doesn't prefix when second number is possible", () => { + const { input } = renderInput(); + + updateInputValue(input, '1'); + expect(input.value).toEqual('1'); + + updateInputValue(input, '12/1'); + expect(input.value).toEqual('12/1'); + }); + + test('truncates a date string that is too long', () => { + const { input } = renderInput({ value: '12/05/19922222' }); + + expect(input.value).toEqual('12/05/1992'); + }); + + test('can remove trailing slash when backspacing', () => { + const { input } = renderInput({ value: '12/05/' }); + + fireEvent.keyDown(input, { key: 'Backspace' }); + updateInputValue(input, '12/05'); + expect(input.value).toEqual('12/05'); + }); + + test('takes custom delimter and pattern', () => { + const { input } = renderInput({ value: '2000-5', pattern: ['Y', 'm', 'd'], delimiter: '-' }); + + expect(input.value).toEqual('2000-05-'); + }); + + test('updates internally when value prop changes', () => { + const { input, rerender } = renderInput({ value: '01/05' }); + rerender( + + + + ); + expect(input.value).toEqual('01/09/1990'); + }); +}); diff --git a/src/Form/EasyInput.js b/src/Form/EasyInput.js index 285802f..f47ce0e 100644 --- a/src/Form/EasyInput.js +++ b/src/Form/EasyInput.js @@ -12,18 +12,19 @@ function EasyInput({ name, Component, ...props }) { const state = useContext(Context); if (!state) { - return + return ; } const value = state.values[name]; - const defaultValue = Component.defaultProps && Component.defaultProps.defaultValue !== undefined - ? Component.defaultProps.defaultValue - : ''; + const defaultValue = + Component.defaultProps && Component.defaultProps.defaultValue !== undefined + ? Component.defaultProps.defaultValue + : ''; return ( - forwardRef((props, ref) => - ); + forwardRef((props, ref) => ); export default EasyInput; diff --git a/src/Form/Formbot.example.js b/src/Form/Formbot.example.js index 924b1d8..014c8ac 100644 --- a/src/Form/Formbot.example.js +++ b/src/Form/Formbot.example.js @@ -4,11 +4,12 @@ import Select from './Select'; import Formbot, { Context } from './Formbot'; import Form from './Form'; import Button from '../Button'; -import FormGroup from './FormGroup'; import Fieldset from './Fieldset'; import CheckboxGroup from './CheckboxGroup'; import RadioGroup from './RadioGroup'; import Switch from './Switch'; +import PhoneInput from './PhoneInput'; +import DateInput from './DateInput'; const selectValues = [{ id: 1, value: 'male', label: 'Male' }, { id: 1, value: 'female', label: 'Female' }]; @@ -42,10 +43,8 @@ const radioValues = [ const Values = () => { const state = useContext(Context); - return ( -
{JSON.stringify(state.values, null, 2)}
- ) -} + return
{JSON.stringify(state.values, null, 2)}
; +}; export default class FormbotExample extends React.Component { nameRef = createRef(); @@ -86,24 +85,13 @@ export default class FormbotExample extends React.Component {
- -
- + diff --git a/src/Form/Input.js b/src/Form/Input.js index e3b9a95..de31d2e 100644 --- a/src/Form/Input.js +++ b/src/Form/Input.js @@ -181,7 +181,7 @@ class Input extends Component { onChange = e => { this.setState({ value: e.target.value }); - this.props.onChange(e.target.name, e.target.value); + this.props.onChange(e.target.name, e.target.value, e); }; handleAutogrowRef = node => { diff --git a/src/Form/Input.mdx b/src/Form/Input.mdx index 8a3fca1..effdef3 100644 --- a/src/Form/Input.mdx +++ b/src/Form/Input.mdx @@ -5,6 +5,8 @@ name: Input import { Playground, PropsTable } from 'docz' import Input from './Input' +import PhoneInput from './PhoneInput'; +import DateInput from './DateInput'; import Button from '../Button' import Formbot from './Formbot'; @@ -93,3 +95,11 @@ Form input component. Different sizes are available. + +## Formatted Inputs + +### Phone Input + + +### Date Input + diff --git a/src/Form/PhoneInput.js b/src/Form/PhoneInput.js new file mode 100644 index 0000000..b37d8f6 --- /dev/null +++ b/src/Form/PhoneInput.js @@ -0,0 +1,90 @@ +import React, { useState, useEffect, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { AsYouType, isSupportedCountry, getCountryCallingCode, parseDigits } from 'libphonenumber-js/min'; +import examplePhoneNumbers from 'libphonenumber-js/examples.mobile.json'; +import Input from './Input'; +import { createEasyInput } from './EasyInput'; +import { getNextCursorPosition } from '../utils'; + +export const getRawMaxLength = (countryCode, value) => { + const countryCallingCode = getCountryCallingCode(countryCode); + const beginsWithCountryCode = parseDigits(value).substr(0, countryCallingCode.length) === countryCallingCode; + const examplePhoneNumber = examplePhoneNumbers[countryCode]; + return beginsWithCountryCode ? countryCallingCode.length + examplePhoneNumber.length : examplePhoneNumber.length; +}; + +function PhoneInput({ countryCode, forwardedRef, value: propValue, onKeyDown, onChange, ...inputProps }) { + const countryCodeSupported = isSupportedCountry(countryCode); + if (!countryCodeSupported) { + throw new Error(`${countryCode} is not supported`); + } + + const format = (value = '') => { + const parsed = parseDigits(value).substr(0, getRawMaxLength(countryCode, value)); + return new AsYouType(countryCode).input(parsed); + }; + + const [currentValue, setValue] = useState(format(propValue)); + const inputRef = forwardedRef || useRef(); + + useEffect(() => { + if (propValue !== currentValue) { + setValue(format(propValue)); + } + }, [propValue]); + + const handleKeyDown = event => { + const isLetterLike = /^\w{1}$/.test(event.key); + if (isLetterLike) { + const cursorPos = event.target.selectionStart; + const nextFormattedValue = format( + currentValue.substring(0, cursorPos) + event.key + currentValue.substring(cursorPos) + ); + const nextValueRawLength = parseDigits(currentValue).length + 1; + if (nextValueRawLength > getRawMaxLength(countryCode, nextFormattedValue)) { + event.preventDefault(); + event.stopPropagation(); + } + } + + if (onKeyDown) { + onKeyDown(event); + } + }; + + const handleChange = (name, newValue, event) => { + const nextValue = newValue.length < currentValue.length ? newValue.trim() : format(newValue); + const nextCursorPosition = getNextCursorPosition(event.target.selectionStart, currentValue, nextValue); + + setValue(nextValue); + setTimeout(() => { + inputRef.current.setSelectionRange(nextCursorPosition, nextCursorPosition); + }); + + if (onChange) { + onChange(name, nextValue, event); + } + }; + + return ( + + ); +} + +PhoneInput.propTypes = { + ...Input.propTypes, + countryCode: PropTypes.string, +}; + +PhoneInput.defaultProps = { + countryCode: 'US', +}; + +export default createEasyInput(PhoneInput); diff --git a/src/Form/PhoneInput.spec.js b/src/Form/PhoneInput.spec.js new file mode 100644 index 0000000..6818bdb --- /dev/null +++ b/src/Form/PhoneInput.spec.js @@ -0,0 +1,79 @@ +import React from 'react'; +import { renderWithTheme, fireEvent, act } from '../../test/utils'; +import PhoneInput from './PhoneInput'; +import ThemeProvider from '../ThemeProvider'; + +describe('', () => { + const renderInput = props => { + const utils = renderWithTheme(); + return { + ...utils, + input: utils.getByPlaceholderText('Input'), + }; + }; + + const updateInputValue = (input, value) => { + act(() => { + fireEvent.change(input, { target: { value } }); + }); + }; + + test('snapshot', () => { + const { asFragment } = renderInput(); + expect(asFragment()).toMatchSnapshot(); + }); + + test('initally sets the value', () => { + const value = '4087213456'; + const { input } = renderInput({ value }); + + expect(input.value).toEqual('(408) 721-3456'); + }); + + test('area code and hyphens', () => { + const { input } = renderInput({ value: '408' }); + + expect(input.value).toEqual('(408)'); + + updateInputValue(input, '(408) 7214'); + expect(input.value).toEqual('(408) 721-4'); + }); + + test('can delete paren', () => { + const { input } = renderInput({ value: '(408)' }); + + fireEvent.keyDown(input, { key: 'Backspace' }); + updateInputValue(input, '(408'); + expect(input.value).toEqual('(408'); + }); + + test('updates internally when value prop changes', () => { + const { input, rerender } = renderInput({ value: '(408)' }); + rerender( + + + + ); + expect(input.value).toEqual('(408) 721-3456'); + }); + + test('handles country codes', () => { + const { input } = renderInput({ value: '14088675309' }); + + expect(input.value).toEqual('1 (408) 867-5309'); + }); + + describe('truncates a number that is too long', () => { + test('with country code', () => { + const { input } = renderInput({ value: '1408867530903133' }); + + expect(input.value).toEqual('1 (408) 867-5309'); + }); + + test('without country code', () => { + const { input } = renderInput({ value: '408867530903133' }); + + expect(input.value).toEqual('(408) 867-5309'); + }); + }); +}); diff --git a/src/Form/Select.js b/src/Form/Select.js index c9a0e05..8d2f484 100644 --- a/src/Form/Select.js +++ b/src/Form/Select.js @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import React, { Component, createRef } from 'react'; import styled, { css } from 'styled-components'; import PropTypes from 'prop-types'; import Field from './Field'; @@ -9,8 +9,8 @@ import Label from './Label'; import { createEasyInput } from './EasyInput'; import { createComponent } from '../utils'; -const SelectContain = createComponent({ - name: 'Select', +const SelectContainer = createComponent({ + name: 'SelectContainer', as: Flex, style: ({ size, theme, value, borderRadius = theme.radius }) => css` background: white; @@ -18,7 +18,6 @@ const SelectContain = createComponent({ height: ${theme.heights[size]}px; outline: none; width: 100%; - padding: 0.5rem; position: relative; border-radius: ${borderRadius}px; transition: 250ms all; @@ -34,21 +33,10 @@ const SelectContain = createComponent({ color: ${p => p.theme.colors.grayMid}; } `}; - - select { - position: relative; - z-index: 2; - outline: none; - width: 100%; - font-size: ${p => p.theme.fontSizes[p.size]}px; - background: transparent; - border: none; - -webkit-appearance: none; - } `, }); -const IconContain = styled(Flex)` +const IconContainer = styled(Flex)` position: absolute; height: 100%; right: 0; @@ -56,6 +44,27 @@ const IconContain = styled(Flex)` z-index: 1; `; +const SelectInput = createComponent({ + name: 'Select', + tag: 'select', + style: ({ theme, size }) => css` + position: relative; + z-index: 2; + padding: 0 8px; + outline: none; + width: 100%; + font-size: ${theme.fontSizes[size]}px; + background: transparent; + border: none; + -webkit-appearance: none; + `, +}); + +const SelectOption = createComponent({ + name: 'SelectOption', + tag: 'option', +}); + class Select extends Component { static propTypes = { name: PropTypes.string.isRequired, @@ -67,12 +76,12 @@ class Select extends Component { onBlur: PropTypes.func, size: PropTypes.string, label: PropTypes.string, - } + }; static defaultProps = { onChange() {}, onBlur() {}, - } + }; static getDerivedStateFromProps(props, state) { if (props.value !== undefined && props.value !== state.value) { @@ -84,17 +93,23 @@ class Select extends Component { } state = { - value: "", + value: '', }; + innerRef = createRef(); + + get ref() { + return this.props.forwardedRef || this.innerRef; + } + handleChange = e => { this.setState({ value: e.target.value }); this.props.onChange(e.target.name, e.target.value); - } + }; handleBlur = e => { - this.props.onBlur(e.target.name) - } + this.props.onBlur(e.target.name); + }; render() { const { id, name, options, placeholder, error, size = 'md', label, ...props } = this.props; @@ -103,26 +118,28 @@ class Select extends Component { return ( {label && } - - - + + - + + {error && {error}} - ) + ); } } diff --git a/src/Form/__snapshots__/DateInput.spec.js.snap b/src/Form/__snapshots__/DateInput.spec.js.snap new file mode 100644 index 0000000..d024b17 --- /dev/null +++ b/src/Form/__snapshots__/DateInput.spec.js.snap @@ -0,0 +1,76 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` snapshot 1`] = ` + + .c1 { + position: relative; + -webkit-transition: 175ms; + transition: 175ms; +} + +.c1 + .c0 { + margin-top: 1rem; +} + +.c2 { + position: relative; +} + +.c3 { + border: 1px solid #DEE0E4; + height: 36px; + display: block; + outline: none; + width: 100%; + padding: 8px; + border-radius: 4px; + -webkit-transition: 250ms all; + transition: 250ms all; + -webkit-appearance: none; + font-family: inherit; + font-size: 12px; +} + +.c3:hover, +.c3:focus, +.c3:active { + border-color: #8E97A7; +} + +.c3::-webkit-input-placeholder { + color: #8E97A7; +} + +.c3::-moz-placeholder { + color: #8E97A7; +} + +.c3:-ms-input-placeholder { + color: #8E97A7; +} + +.c3::placeholder { + color: #8E97A7; +} + +.c3[disabled] { + opacity: 0.65; +} + +
+
+ +
+
+
+`; diff --git a/src/Form/__snapshots__/PhoneInput.spec.js.snap b/src/Form/__snapshots__/PhoneInput.spec.js.snap new file mode 100644 index 0000000..621b9a0 --- /dev/null +++ b/src/Form/__snapshots__/PhoneInput.spec.js.snap @@ -0,0 +1,76 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` snapshot 1`] = ` + + .c1 { + position: relative; + -webkit-transition: 175ms; + transition: 175ms; +} + +.c1 + .c0 { + margin-top: 1rem; +} + +.c2 { + position: relative; +} + +.c3 { + border: 1px solid #DEE0E4; + height: 36px; + display: block; + outline: none; + width: 100%; + padding: 8px; + border-radius: 4px; + -webkit-transition: 250ms all; + transition: 250ms all; + -webkit-appearance: none; + font-family: inherit; + font-size: 12px; +} + +.c3:hover, +.c3:focus, +.c3:active { + border-color: #8E97A7; +} + +.c3::-webkit-input-placeholder { + color: #8E97A7; +} + +.c3::-moz-placeholder { + color: #8E97A7; +} + +.c3:-ms-input-placeholder { + color: #8E97A7; +} + +.c3::placeholder { + color: #8E97A7; +} + +.c3[disabled] { + opacity: 0.65; +} + +
+
+ +
+
+
+`; diff --git a/src/utils.js b/src/utils.js index 30b65f8..e7fa78b 100644 --- a/src/utils.js +++ b/src/utils.js @@ -72,3 +72,14 @@ export const findNextFocusableElement = (root, currentNode) => { return treeWalker.currentNode; }; + +export const getNextCursorPosition = (cursorPos, oldValue, newValue) => { + const cursorDiff = newValue.length - oldValue.length; + let nextPosition = cursorPos; + + if (cursorDiff > 1 || cursorDiff < -1) { + nextPosition += cursorDiff < 0 ? cursorDiff + 1 : cursorDiff - 1; + } + + return Math.max(nextPosition, 0); +};