UNPKG

bloom-inputs

Version:
759 lines (691 loc) 21 kB
import React from 'react' import PropTypes from 'prop-types' import ErrorTip from '../error-tip' import Loading from '../loading' import { requiredPropsLogger } from '../../util/required-props-logger' import '../../styles/inputs.scss' import './select-input.scss' /* SUPPORTS: - typeahead - esc and arrow keys - tabbing - regular old onClick - multiple values */ const compareLetters = (str1, str2) => { // finding out if the first part of comparison string (str2) matches the typeahead (str1) return str1 ? str2 .toLowerCase() .slice(0, str1.length) .split('') .reduce( (total, curr, index) => total && curr === str1.toLowerCase()[index] ) : true } class SelectInput extends React.Component { state = { focused: false, focusedOption: null, hasUsedPresentationElements: false, initialFocus: false, noMatches: false, showList: false, sortBy: null, sortedOpts: null } selectOpt = val => { this.focusOnTypeAhead(null, true, false) this.setState({ focusedOption: null, hasUsedPresentationElements: true, showList: false, sortBy: null, sortedOpts: this.props.options }) let value = val if (this.props.multiple) { if (this.props.value.indexOf(value) === -1) { value = this.props.value && Array.isArray(this.props.value) ? [...this.props.value, value.toString()] : this.props.value ? [this.props.value, value.toString()] : [value.toString()] } else { value = this.props.value } } this.props.onChange(this.props.formId, this.props.name, value) } focusOnPlaceholderButton = e => { if (e) { e.preventDefault() } if (!this.state.initialFocus) { this.onFocusIn(e) this.setState({ initialFocus: true, showList: true }) } } focusOnTypeAhead = (e, override = false, showList = true) => { const typeaheadId = `${this.props.name}-placeholder` const allowFocus = !this.state.initialFocus || override if (!this.state.initialFocus) { this.onFocusIn(e) } if (e) { e.preventDefault() } if (document.getElementById(typeaheadId) && allowFocus) { if (document.activeElement && document.activeElement.id !== typeaheadId) { document.getElementById(typeaheadId).focus() } this.setState({ initialFocus: true, showList: showList }) } } isInsideTheSelectWrapper = domElement => { let parent = domElement while (parent && parent.tagName) { if (parent.id === `${this.props.name}-label`) { return true } else if (parent.tagName === 'BODY') { return false } else { parent = parent.parentNode } } } isInsideTheSelectPlaceholder = domElement => { let parent = domElement while (parent && parent.tagName) { if (parent.id === `${this.props.name}-placeholder-label`) { return true } else if (parent.tagName === 'BODY') { return false } else { parent = parent.parentNode } } } onKeyDown = e => { const key = e.which || e.keyCode const currValue = this.state.focusedOption || null const options = this.state.sortedOpts.filter(opt => { let temp = opt.value ? opt.value : opt if (this.props.multiple) { return this.props.value.indexOf(temp) === -1 } else { return this.props.value !== temp } }) // close if esc key if (key === 27) { const typeaheadId = `${this.props.name}-placeholder` if (document.getElementById(typeaheadId)) { document.getElementById(typeaheadId).focus() } this.setState({ focusedOption: null, showList: false, sortBy: null, sortedOpts: this.props.options }) } else if (key === 40 || key === 38) { if (!options.length) { this.setState({ showList: true }) return } // arrow keys e.preventDefault() let nextValue = currValue ? options.find( (opt, i) => opt && opt.value ? options[i - 1] && options[i - 1].value.toString() === currValue.toString() : options[i - 1] && options[i - 1].toString() === currValue.toString() ) : options[0].value ? options[0].value : options[0] let prevValue = currValue ? options.find( (opt, indx) => opt.value ? options[indx + 1] && options[indx + 1].value.toString() === currValue.toString() : options[indx + 1] && options[indx + 1].toString() === currValue.toString() ) : options[0].value ? options[0].value : options[0] nextValue = nextValue && nextValue.value ? nextValue.value : nextValue prevValue = prevValue && prevValue.value ? prevValue.value : prevValue if (key === 40) { // arrow down, open and go to next opt const newDownFocus = ( nextValue || (options[0] && options[0].value ? options[0].value.toString() : options[0]) ).toString() this.setState( { focusedOption: newDownFocus, hasUsedPresentationElements: true, showList: true }, () => { const elem = document.getElementById( `input-${this.props.name}-placeholder-${( newDownFocus || '' ).replace(/\s/g, '-')}` ) if (elem) { elem.focus() } else { console.error( 'cannot find option with id ' + `input-${this.props.name}-placeholder-${( newDownFocus || '' ).replace(/\s/g, '-')}` ) } } ) } else if (key === 38) { // arrow up, go to prev opt const newUpFocus = ( prevValue || (options[options.length - 1] && options[options.length - 1].value ? options[options.length - 1].value : options[options.length - 1]) ).toString() this.setState( { focusedOption: newUpFocus, hasUsedPresentationElements: true, showList: true }, () => { const elem = newUpFocus ? document.getElementById( `input-${this.props.name}-placeholder-${( newUpFocus || '' ).replace(/\s/g, '-')}` ) : document.getElementById(`${this.props.name}-placeholder`) if (elem) { elem.focus() } else { console.error( 'cannot find option with id ' + `input-${this.props.name}-placeholder-${( newUpFocus || '' ).replace(/\s/g, '-')}` ) } } ) } } } onFocusIn = e => { if (e) { e.preventDefault() } this.setState({ initialFocus: true, focused: true }) if (this.props.onFocus) { this.props.onFocus(this.props.formId, this.props.name) } } onFocusOut = e => { this.setState({ focused: false }) this.closeOpts(e) if (this.props.onBlur) { this.props.onBlur(e) } } closeOpts = e => { if (e) { e.persist() } if ( e && ((e.relatedTarget && !this.isInsideTheSelectPlaceholder(e.relatedTarget)) || !e.relatedTarget) ) { this.setState({ focusedOption: null, showList: false }) const select = document.getElementById(this.props.name) if (this.props.onBlur) { this.props.onBlur(null, select) } } } sortResults = e => { let value = e && e.target && e.target.value && e.target.value[0] === ' ' ? e.target.value.trim() : (e && e.target && e.target.value) || null const sortValue = value && value.toString().replace(/\s/g, '') && value.toString().replace(/\s/g, '').length ? value .toString() .replace(/(Filtering by:\s)/g, '') .replace(/(Filtering by:)/g, '') : '' const sortedOpts = this.props.options.filter(opt => compareLetters(sortValue, opt.label ? opt.label : opt) ) this.setState({ hasUsedPresentationElements: true, noMatches: !((!!this.props.value || !!sortValue) && !!sortedOpts.length), showList: true, sortBy: sortValue, sortedOpts: sortedOpts }) } toggleList = e => { e.preventDefault() if (!this.state.initialFocus) { this.onFocusIn(e) } this.setState({ hasUsedPresentationElements: true, focusedOption: null, noMatches: !this.state.showList ? this.state.noMatches : false, showList: !this.state.showList }) } componentWillReceiveProps = newProps => { const newOptLabels = newProps.options.map(opt => opt.label.toString()) const oldOptLabels = this.props.options.map(opt => opt.label.toString()) if (newProps.options.length != this.props.options.length) { this.setState({ sortBy: null, sortedOpts: newProps.options }) } else if ( newOptLabels.sort().toString() !== oldOptLabels.sort().toString() ) { const sortedOpts = this.state.sortBy ? newProps.options.filter(opt => compareLetters(this.state.sortBy, opt.label ? opt.label : opt) ) : newProps.options this.setState({ sortedOpts }) } } componentDidMount() { this.setState({ sortedOpts: this.props.options }) const requiredProps = ['formId', 'label', 'name', 'onChange', 'options'] requiredPropsLogger(this.props, requiredProps, [], true) } removeOpt = (e, option) => { if (e) { e.preventDefault() } const index = this.props.value.indexOf(option) const newValues = this.props.value .slice(0, index) .concat(this.props.value.slice(index + 1)) this.props.onChange(this.props.formId, this.props.name, newValues) } renderPlaceholderOptions = sortedOpts => { const { multiple, name, value } = this.props return sortedOpts.map((opt, i) => { const isSelected = multiple ? value.indexOf((opt.value || opt).toString()) > -1 : (opt.value || opt).toString() === value.toString() return opt.label ? ( <li key={`${name}-opt-${i}`} role='option'> <button className={`SelectInput-opt ${isSelected ? 'is-selected' : ''}`} id={`input-${name}-placeholder-${opt.value .toString() .replace(/\s/g, '-')}`} tabIndex={isSelected ? -1 : 1} onClick={e => { e.preventDefault() this.selectOpt(opt.value) }} aria-labelledby={`${name}-opt-${i}-text`} > <span id={`${name}-opt-${i}-text`}>{opt.label}</span> </button> </li> ) : ( <li key={`${name}-opt-${i}`} role='option'> <button className={`SelectInput-opt ${isSelected ? 'is-selected' : ''}`} id={`input-${name}-placeholder-${opt .toString() .replace(/\s/g, '-')}`} tabIndex={isSelected ? -1 : 1} onClick={e => { e.preventDefault() this.selectOpt(opt) }} aria-labelledby={`${name}-opt-${i}-text`} > <span id={`${name}-opt-${i}-text`}>{opt}</span> </button> </li> ) }) } render() { const { clearable, containerClass, error, formData, formId, name, label, loading, multiple, onBlur, onChange, options, placeholder, showLabel, suppressErrors, typeAhead, validateAs, value, ...props } = this.props const sortedOpts = this.state.sortedOpts || options const opts = sortedOpts.map((opt, i) => { return opt.label ? ( <option key={`${name}-opt-${i}`} value={opt.value} tabIndex={-1}> {opt.label} </option> ) : ( <option key={`${name}-opt-${i}`} value={opt} tabIndex={-1}> {opt} </option> ) }) const placeholderOpts = this.renderPlaceholderOptions(sortedOpts) let attr = { ...props } if (props.required) { attr['required'] = true attr['aria-required'] = true } let err = error if (this.state.noMatches) { err = 'No matches found.' } else if ( Object.keys(this.props).indexOf('value') === -1 && formData && Object.keys(formData).indexOf(name) > -1 ) { // formData prop was passed in instead of value and error attr.value = formData[name].value err = formData[name].error } else { attr.value = value } if (!onChange) { attr.readOnly = true } const closableMultipleButtons = [] // in case options' values are different from their labels let translateVal = options[0] && !!options[0].label let activeOptLabel if (translateVal && (value || value === 0 || value === false)) { if (multiple) { activeOptLabel = '' options.forEach(opt => { if (value.indexOf(opt.value.toString()) > -1) { closableMultipleButtons.push( <button className='SelectInput-removeMultipleButton' aria-label='Click to remove this option' onClick={e => this.removeOpt(e, opt.value)} > {opt.label} <span className='SelectInput-removeMultipleButton-x' /> </button> ) } }) } else { if (multiple) { activeOptLabel = '' options.forEach(opt => { if (value.indexOf(opt.toString()) > -1) { closableMultipleButtons.push( <button className='SelectInput-removeMultipleButton' aria-label='Click to remove this option' onClick={e => this.removeOpt(e, opt)} > {opt} <span className='SelectInput-removeMultipleButton-x' /> </button> ) } }) } else { activeOptLabel = options.filter( opt => opt.value.toString() === value.toString() )[0] activeOptLabel = activeOptLabel ? activeOptLabel.label : 'Select' } } } const typeAheadDisplay = this.state.sortBy ? `Filtering by: ${this.state.sortBy}` : this.state.sortBy === '' ? '' : translateVal ? activeOptLabel : value || 'Select' const displayValue = translateVal ? activeOptLabel || 'Select' : value || 'Select' const labelText = ( <span className={`Input-label-text ${!showLabel ? 'u-sr-only' : ''}`} id={`${name}-label-text`} > {label} {attr.required && ( <span> {'\u00A0'}*<span className='u-sr-only'> required field</span> </span> )} {loading ? <Loading /> : null} </span> ) const placeholderElement = ( <div className={`Input-label SelectInput ${containerClass || ''}`} id={`${name}-placeholder-label`} aria-labelledby={`${name}-label-text`} > {labelText} <span aria-controls={name} className='SelectInput-placeholderWrapper'> {options.length && typeAhead ? ( <input aria-controls={name} aria-label={`${ value ? `Selected Option: ${displayValue}` : 'Typeahead' }.\ Type characters to filter your list of Selectable Options, or press the arrow keys to view full list.`} aria-multiline={false} className={`Btn Input-placeholder non-sr-only ${ this.state.showList ? 'is-open' : '' } ${error ? 'Input--invalid' : ''} ${ this.state.sortBy || this.state.sortBy === '' ? 'SelectInput-typeahead-helperText' : '' }`} id={`${name}-placeholder`} name='autofill-buster' onChange={this.sortResults} onClick={() => this.setState({ showList: true })} placeholder={placeholder} role='searchbox' type='text' value={typeAheadDisplay} /> ) : ( <span aria-label={`${ value ? `Selected Option: ${displayValue}. ` : '' }Press the arrow keys to view and choose Selectable Options.`} className={`${ !options.length ? 'Btn is-disabled' : 'Btn' } Input-placeholder non-sr-only ${ this.state.showList ? 'is-open' : '' } ${error ? 'Input--invalid' : ''}`} disabled={!options.length} id={`${name}-placeholder`} onClick={e => { e.preventDefault() this.setState({ showList: true }) }} tabIndex={0} > {placeholder && !value ? ( <span className='u-grayed-out'>{placeholder}</span> ) : ( displayValue )} </span> )} {err && !this.state.showList && !this.state.focused && !suppressErrors && ( <ErrorTip contents={err} className='ErrorTip--select' /> )} <ul className={this.state.showList ? 'SelectInput-opts show-list' : 'SelectInput-opts hide-list' } aria-atomic aria-expanded={this.state.showList} aria-labelledby={`${name}-label-text`} id={name} role='listbox' > {placeholderOpts} </ul> </span> </div> ) const clearButton = clearable ? ( <button aria-label='Clear this Input' className='SelectInput-clearButton' disabled={!attr.value} onClick={e => { e.preventDefault() this.selectOpt('') }} /> ) : ( '' ) return ( <div onBlur={this.onFocusOut} onFocus={ typeAhead ? e => this.focusOnTypeAhead(e, false, !this.state.showList) : e => this.focusOnPlaceholderButton(e) } onKeyDown={this.onKeyDown} id={`${name}-label`} className={`SelectInput-wrapper ${containerClass || ''}`} > {placeholderElement} <label className={'Input-label SelectInput u-sr-only'} htmlFor={name} id={`${name}-label`} tabIndex={-1} aria-hidden > <select name={name} id={name} className='u-sr-only' data-validate={validateAs} onChange={e => this.selectOpt(e.target.value)} aria-labelledby={`${name}-label-text`} tabIndex={-1} {...attr} > <option value=''>Select</option> {opts} </select> </label> {clearButton} {closableMultipleButtons} </div> ) } } SelectInput.propTypes = { clearable: PropTypes.bool, containerClass: PropTypes.string, error: PropTypes.string, formData: PropTypes.object, formId: PropTypes.string.isRequired, label: PropTypes.string.isRequired, name: PropTypes.string.isRequired, loading: PropTypes.string, multiple: PropTypes.bool, onBlur: PropTypes.func, onFocus: PropTypes.func, onChange: PropTypes.func.isRequired, options: PropTypes.arrayOf( PropTypes.oneOfType([ PropTypes.shape({ label: PropTypes.string.isRequired, value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]) }), PropTypes.string ]).isRequired ), required: PropTypes.bool, showLabel: PropTypes.bool, suppressErrors: PropTypes.bool, typeAhead: PropTypes.bool, validateAs: PropTypes.string, value: PropTypes.oneOfType([ PropTypes.string, PropTypes.number, PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])) ]) } SelectInput.defaultProps = { options: [], typeAhead: true, value: '' } export default SelectInput