From 7bddd068b1c4a45676c742544355be766eb5502d Mon Sep 17 00:00:00 2001 From: Ugo Stephant Date: Mon, 22 Jun 2020 18:04:01 +0200 Subject: [PATCH] feat(tags-field): add TagsField component --- packages/junipero/lib/TagsField/index.js | 446 ++++++++++++++++++ .../junipero/lib/TagsField/index.stories.js | 82 ++++ packages/junipero/lib/TagsField/index.styl | 159 +++++++ packages/junipero/lib/index.styl | 1 + 4 files changed, 688 insertions(+) create mode 100644 packages/junipero/lib/TagsField/index.js create mode 100644 packages/junipero/lib/TagsField/index.stories.js create mode 100644 packages/junipero/lib/TagsField/index.styl diff --git a/packages/junipero/lib/TagsField/index.js b/packages/junipero/lib/TagsField/index.js new file mode 100644 index 000000000..06a8d2e94 --- /dev/null +++ b/packages/junipero/lib/TagsField/index.js @@ -0,0 +1,446 @@ +import React, { + forwardRef, + useEffect, + useImperativeHandle, + useReducer, + useRef, +} from 'react'; +import PropTypes from 'prop-types'; + +import { classNames, mockState } from '../utils'; +import { useTimeout } from '../hooks'; +import Dropdown from '../Dropdown'; +import DropdownMenu from '../DropdownMenu'; +import DropdownToggle from '../DropdownToggle'; +import DropdownItem from '../DropdownItem'; + +const TagsField = forwardRef(({ + autoFocus, + className, + label, + options, + placeholder, + search, + autoAddOnBlur = false, + disabled = false, + forceLabel = false, + noSearchResults = 'No result found :(', + onlyAllowSearchResults = false, + onlyAllowOneOccurence = true, + required = false, + searchMinCharacters = 2, + searchThreshold = 400, + value, + onBlur = () => {}, + onChange = () => {}, + onFocus = () => {}, + onKeyDown = () => {}, + onKeyPress = () => {}, + parseValue = val => val?.trim?.() || val, + parseTitle = val => val, + validate = val => !!val?.length || !required, + validateInput = val => !!val?.trim?.(), + validateTag = val => !!val, +}, ref) => { + const innerRef = useRef(); + const wrapperRef = useRef(); + const inputRef = useRef(); + const dropdownRef = useRef(); + const menuRef = useRef(); + const [state, dispatch] = useReducer(mockState, { + value: value || [], + valid: validate(value || []) && (value || []).every(t => validateTag(t)), + availableOptions: options, + inputValue: '', + inputValid: true, + inputDirty: false, + dirty: false, + focused: autoFocus ?? false, + searchResults: null, + searching: false, + opened: false, + }); + + useImperativeHandle(ref, () => ({ + innerRef, + wrapperRef, + inputRef, + dropdownRef, + menuRef, + internalValue: state.value, + inputValue: state.inputValue, + dirty: state.dirty, + opened: state.opened, + searchResults: state.searchResults, + searching: state.searching, + valid: state.valid, + focus, + blur, + reset, + add, + remove, + })); + + useEffect(() => { + state.value = value || []; + + dispatch({ + value: state.value, + valid: validate(state.value) && state.value.every(t => validateTag(t)), + }); + }, [value]); + + useTimeout(() => { + search_(); + }, searchThreshold, [state.inputValue]); + + const onInputChange_ = e => { + dispatch({ + inputValue: e.target.value, + inputDirty: true, + inputValid: validateInput(e.target.value), + searching: !!search, + }); + + if (!e.target.value.trim() && !!search && !options) { + dropdownRef.current?.close(); + } + }; + + const onInputFocus_ = e => { + dispatch({ focused: true }); + + if ( + (state.searchResults && state.inputValue) || + state.availableOptions?.length + ) { + dropdownRef.current?.open(); + } + + onFocus(e); + }; + + const onInputBlur_ = e => { + dispatch({ focused: false }); + + if (state.inputValue && autoAddOnBlur && validateInput(state.inputValue)) { + add(state.inputValue); + } + + onBlur(e); + }; + + const onKeyPress_ = e => { + if (disabled) { + return; + } + + if ( + e.key === 'Enter' && + !onlyAllowSearchResults && + validateInput(state.inputValue) + ) { + add(state.inputValue); + } + + onKeyPress(e); + }; + + const onKeyDown_ = e => { + if (disabled) { + return; + } + + if ( + (e.key === 'Backspace' || e.key === 'ArrowLeft') && + !state.inputValue.trim() && + state.value.length + ) { + focus(state.value.length - 1); + } + + onKeyDown(e); + }; + + const onTagKeyDown_ = (index, e) => { + if (disabled) { + return; + } + + if (e.key === 'Backspace') { + remove(index); + inputRef.current?.focus(); + } else if (e.key === 'ArrowLeft' && index > 0) { + focus(index - 1); + } else if (e.key === 'ArrowRight') { + focus(index + 1); + } + }; + + const onWrapperClick_ = e => { + if (disabled || e.target !== wrapperRef.current) { + return; + } else { + e.preventDefault(); + } + + focus(); + }; + + const onDropdownToggle_ = ({ opened }) => + dispatch({ opened }); + + const onOptionClick_ = (option, e) => { + e.preventDefault(); + + add(option); + }; + + const add = item => { + if (disabled || (typeof item === 'string' && !item.trim())) { + return; + } + + state.value.push(item); + state.availableOptions = options?.filter(o => + !onlyAllowOneOccurence || + !state.value.find(i => parseValue(i) === parseValue(o))); + + dispatch({ + value: state.value, + valid: validate(state.value) && state.value.every(t => validateTag(t)), + availableOptions: state.availableOptions, + inputValue: '', + inputDirty: false, + inputValid: true, + dirty: true, + }); + onChange({ value: state.value.map(i => parseValue(i)) }); + + if (state.searchResults) { + dispatch({ searchResults: null, searching: false }); + dropdownRef.current?.close(); + } + }; + + const remove = (index, e) => { + e?.preventDefault?.(); + + if (disabled) { + return; + } + + state.value.splice(index, 1); + state.availableOptions = options?.filter(o => + !onlyAllowOneOccurence || + !state.value.find(i => parseValue(i) === parseValue(o))); + + dispatch({ + value: state.value, + valid: validate(state.value) && state.value.every(t => validateTag(t)), + availableOptions: state.availableOptions, + dirty: true, + }); + onChange({ value: state.value.map(i => parseValue(i)) }); + }; + + const focus = index => { + if (index >= 0 && index < state.value.length) { + innerRef.current + ?.querySelector(`.tag:nth-child(${index + 1})`)?.focus?.(); + } else { + inputRef.current?.focus(); + } + }; + + const blur = () => { + inputRef.current?.blur(); + innerRef.current?.querySelector('.tag:focus')?.blur(); + }; + + const reset = () => { + state.value = value || []; + + dispatch({ + value: state.value, + dirty: false, + valid: validate(state.value) && state.value.every(t => validateTag(t)), + inputValue: '', + inputDirty: false, + inputValid: true, + }); + }; + + const search_ = async () => { + if (!state.inputValue) { + dispatch({ searching: false, searchResults: null }); + } + + if (!search || state.inputValue?.length < searchMinCharacters) { + return; + } + + const results = await search(state.inputValue); + dispatch({ searchResults: results, searching: false }); + dropdownRef.current?.open(); + }; + + const isEmpty = () => + !state.inputValue && !state.value.length; + + const renderOption = (o, index) => ( + + + { parseTitle(o) } + + + ); + + return ( +
+
+ { state.value.map?.((tag, i) => ( + + { parseTitle(tag) } + + + ))} +
+ + { !state.inputValue && placeholder && ( + { placeholder } + )} +
+ { label || placeholder } +
+ + { (search || state.availableOptions?.length) && ( + + + + { state.searchResults ? ( +
+ { state.searchResults.length + ? state.searchResults + ?.map((o, index) => renderOption(o, index)) + : ( +
{ noSearchResults }
+ ) } +
+ ) : ( +
+ { state.availableOptions + .map((o, index) => renderOption(o, index)) } +
+ ) } +
+
+ )} +
+ ); +}); + +TagsField.propTypes = { + autoFocus: PropTypes.bool, + autoAddOnBlur: PropTypes.bool, + autoSearchOnFocus: PropTypes.bool, + disabled: PropTypes.bool, + forceLabel: PropTypes.bool, + label: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object, + PropTypes.node, + PropTypes.func, + PropTypes.bool, + ]), + noSearchResults: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object, + PropTypes.node, + PropTypes.func, + ]), + onlyAllowSearchResults: PropTypes.bool, + onlyAllowOneOccurence: PropTypes.bool, + options: PropTypes.array, + placeholder: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object, + PropTypes.node, + PropTypes.func, + ]), + required: PropTypes.bool, + search: PropTypes.func, + searchMinCharacters: PropTypes.number, + searchThreshold: PropTypes.number, + validate: PropTypes.func, + validateInput: PropTypes.func, + validateTag: PropTypes.func, + value: PropTypes.array, + onBlur: PropTypes.func, + onChange: PropTypes.func, + onFocus: PropTypes.func, + onKeyDown: PropTypes.func, + onKeyPress: PropTypes.func, + parseTitle: PropTypes.func, + parseValue: PropTypes.func, +}; + +export default TagsField; diff --git a/packages/junipero/lib/TagsField/index.stories.js b/packages/junipero/lib/TagsField/index.stories.js new file mode 100644 index 000000000..0b1e71003 --- /dev/null +++ b/packages/junipero/lib/TagsField/index.stories.js @@ -0,0 +1,82 @@ +import React from 'react'; +import { action } from '@storybook/addon-actions'; + +import TagsField from './index'; + +export default { title: 'junipero/TagsField' }; + +const options = [ + { title: 'One', value: 'one' }, + { title: 'Two', value: 'two' }, + { title: 'Three', value: 'three' }, +]; + +const search = [ + { title: 'Four', value: 'four' }, + { title: 'Five', value: 'five' }, + { title: 'Six', value: 'six' }, +]; + +export const basic = () => ( + +); + +export const withLabel = () => ( + +); + +export const withForcedLabel = () => ( + +); + +export const autoFocused = () => ( + +); + +export const withValue = () => ( + +); + +export const disabled = () => ( + +); + +export const withSearch = () => ( + search.filter(o => (new RegExp(val, 'ig')).test(o.title))} + placeholder="Type a name..." + label="First names" + onChange={action('change')} + parseTitle={o => o?.title || o} + onlyAllowSearchResults + /> +); + +export const withSearchAndOptions = () => ( + search.filter(o => (new RegExp(val, 'ig')).test(o.title))} + placeholder="Type a name..." + label="First names" + onChange={action('change')} + parseTitle={o => o?.title || o} + onlyAllowSearchResults + options={options} + /> +); + +export const withValidation = () => ( + parseInt(tag) > 1000} + validateInput={val => parseInt(val) > 0} + onChange={action('change')} + /> +); diff --git a/packages/junipero/lib/TagsField/index.styl b/packages/junipero/lib/TagsField/index.styl new file mode 100644 index 000000000..7d8446806 --- /dev/null +++ b/packages/junipero/lib/TagsField/index.styl @@ -0,0 +1,159 @@ +@require "../theme/colors" + +.junipero.tags-input + display: inline-block + position: relative + min-width: 250px + width: auto + + &.focused + .wrapper + box-shadow: 0 0 0 2px rgba($color-eastern-blue, .5) + + &.disabled + opacity: .5 + pointer-events: none + user-select: none + + .no-items, .no-results + padding: 10px + color: $color-shuttle-gray + text-align: center + + .wrapper + padding: 5px 9px 0 + border-radius: 2px + background: $color-black-squeeze + border: none + outline: none + font-size: 16px + width: 100% + transition: box-shadow .1s ease-in-out + display: flex + align-items: center + justify-content: flex-start + flex-wrap: wrap + overflow: auto + position: relative + cursor: text + + .tag, .input + flex: 0 0 auto + display: block + margin-bottom: 5px + + .tag + padding: 2px 8px + border-radius: 4px + background: $color-eastern-blue + color: $color-white + margin-right: 5px + font-size: 14px + cursor: pointer + + &.active:not(.invalid), &:focus:not(.invalid) + background: $color-persian-green + + &.invalid + background: $color-monza + + a.remove + display: inline-block + width: 10px + height: 10px + cursor: pointer + margin-left: 4px + position: relative + vertical-align: middle + top: -1px + + &:before, &:after + content: '' + height: 100% + position: absolute + width: 2px + background: $color-white + left: calc(50% - 1px) + top: 0 + + &:before + transform: rotate(45deg) + + &:after + transform: rotate(-45deg) + + .input + position: relative + + input + padding: 2px 0 + display: block + background: none + border: none + font-size: 16px + + .placeholder, + .label + position: absolute + pointer-events: none + user-select: none + transform-origin: 0 0 + + .placeholder + left: 0 + top: 50% + transform: translateY(-50%) + color: $color-shuttle-gray + + .label + top: 4px + left: 9px + opacity: 0 + transform: translateY(5px) scale(0.7) + transition-property: opacity, transform + transition-duration: .1s + transition-timing-function: ease-out + color: $color-eastern-blue + + &:not(.empty), + &.label-enforced + .label + transform: translateY(0) scale(0.7) + opacity: 1 + + &:not(.labeled) + .label + display: none + + &.labeled + .wrapper + padding: 11px 9px 6px + + &:not(.empty), + &.label-enforced + .wrapper + padding: 21px 9px 0 + + input + padding: 0 + + &.with-search + .dropdown + display: block + + .dropdown-toggle + height: 0 + background: #000 + display: block + + &.invalid.dirty + .wrapper + background: $color-lavender-blush + + &.focused + .wrapper + box-shadow: 0 0 0 2px rgba($color-monza, .5) + + .placeholder, + .label + color: $color-monza diff --git a/packages/junipero/lib/index.styl b/packages/junipero/lib/index.styl index 9f09bfecc..ad3ce22f8 100644 --- a/packages/junipero/lib/index.styl +++ b/packages/junipero/lib/index.styl @@ -14,6 +14,7 @@ @import "./SelectField" @import "./SliderField" @import "./Tabs" +@import "./TagsField" @import "./TextField" .junipero