box-ui-elements-mlh
Version:
292 lines (266 loc) • 11 kB
Flow
// @flow
import * as React from 'react';
import classNames from 'classnames';
import uniqueId from 'lodash/uniqueId';
import { List } from 'immutable';
import Tooltip from '../tooltip';
import { KEYS } from '../../constants';
import RoundPill from './RoundPill';
import Pill from './Pill';
import SuggestedPillsRow from './SuggestedPillsRow';
import type { RoundOption, Option, OptionValue, SuggestedPillsFilter } from './flowTypes';
function stopDefaultEvent(event) {
event.preventDefault();
event.stopPropagation();
}
type Props = {
allowInvalidPills: boolean,
className?: string,
disabled?: boolean,
error?: React.Node,
/** Called on pill render to get a specific class name to use for a particular option. Note: Only has effect when showRoundedPills is true. */
getPillClassName?: (option: Option) => string,
/** Function to retrieve the image URL associated with a pill */
getPillImageUrl?: (data: { id: string | number, [key: string]: any }) => string,
inputProps: Object,
onInput: Function,
onRemove: Function,
onSuggestedPillAdd?: Function,
placeholder: string,
selectedOptions: Array<Object> | List<Object>,
/** Whether to show avatars in pills (if rounded style is enabled) */
showAvatars?: boolean,
/** Whether to use rounded style for pills */
showRoundedPills?: boolean,
suggestedPillsData?: Array<Object>,
suggestedPillsFilter?: SuggestedPillsFilter,
suggestedPillsTitle?: string,
validator: (option: Option | OptionValue) => boolean,
};
type State = {
isFocused: boolean,
selectedIndex: number,
};
class PillSelector extends React.Component<Props, State> {
static defaultProps = {
allowInvalidPills: false,
disabled: false,
error: '',
inputProps: {},
placeholder: '',
selectedOptions: [],
validator: () => true,
};
state = {
isFocused: false,
selectedIndex: -1,
};
getNumSelected = (): number => {
const { selectedOptions } = this.props;
return typeof selectedOptions.size === 'number' ? selectedOptions.size : selectedOptions.length;
};
getPillsByKey = (key: string): Array<any> => {
const { selectedOptions } = this.props;
return selectedOptions.map(option => option[key]);
};
inputEl: HTMLInputElement;
handleClick = () => {
this.inputEl.focus();
};
handleFocus = () => {
this.setState({ isFocused: true });
};
handleBlur = () => {
this.setState({ isFocused: false });
};
hiddenEl: HTMLSpanElement;
handleKeyDown = (event: SyntheticKeyboardEvent<>) => {
const inputValue = this.inputEl.value;
const numPills = this.getNumSelected();
const { selectedIndex } = this.state;
switch (event.key) {
case KEYS.backspace: {
let index = -1;
if (selectedIndex >= 0) {
// remove selected pill
index = selectedIndex;
this.resetSelectedIndex();
this.inputEl.focus();
} else if (inputValue === '') {
// remove last pill
index = numPills - 1;
}
if (index >= 0) {
const { onRemove, selectedOptions } = this.props;
const selectedOption =
// $FlowFixMe
typeof selectedOptions.get === 'function' ? selectedOptions.get(index) : selectedOptions[index];
onRemove(selectedOption, index);
stopDefaultEvent(event);
}
break;
}
case KEYS.arrowLeft:
if (selectedIndex >= 0) {
// select previous pill
this.setState({
selectedIndex: Math.max(selectedIndex - 1, 0),
});
stopDefaultEvent(event);
} else if (inputValue === '' && numPills > 0) {
// select last pill
this.hiddenEl.focus();
this.setState({ selectedIndex: numPills - 1 });
stopDefaultEvent(event);
}
break;
case KEYS.arrowRight: {
if (selectedIndex >= 0) {
const index = selectedIndex + 1;
if (index >= numPills) {
// deselect last pill
this.resetSelectedIndex();
this.inputEl.focus();
} else {
// select next pill
this.setState({ selectedIndex: index });
}
stopDefaultEvent(event);
}
break;
}
// no default
}
};
errorMessageID = uniqueId('errorMessage');
hiddenRef = (hiddenEl: ?HTMLSpanElement) => {
if (hiddenEl) {
this.hiddenEl = hiddenEl;
}
};
resetSelectedIndex = () => {
if (this.state.selectedIndex !== -1) {
this.setState({ selectedIndex: -1 });
}
};
render() {
const { isFocused, selectedIndex } = this.state;
const {
allowInvalidPills,
className,
disabled,
error,
getPillClassName,
getPillImageUrl,
inputProps,
onInput,
onRemove,
onSuggestedPillAdd,
placeholder,
selectedOptions,
showAvatars,
showRoundedPills,
suggestedPillsData,
suggestedPillsFilter,
suggestedPillsTitle,
validator,
...rest
} = this.props;
const suggestedPillsEnabled = suggestedPillsData && suggestedPillsData.length > 0;
const hasError = !!error;
const classes = classNames('bdl-PillSelector', 'pill-selector-input-wrapper', {
'is-disabled': disabled,
'bdl-is-disabled': disabled,
'is-focused': isFocused,
'show-error': hasError,
'pill-selector-suggestions-enabled': suggestedPillsEnabled,
'bdl-PillSelector--suggestionsEnabled': suggestedPillsEnabled,
});
const ariaAttrs = {
'aria-invalid': hasError,
'aria-errormessage': this.errorMessageID,
};
return (
<Tooltip isShown={hasError} text={error || ''} position="middle-right" theme="error">
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<span
className={classes}
onBlur={this.handleBlur}
onClick={this.handleClick}
onFocus={this.handleFocus}
onKeyDown={this.handleKeyDown}
>
{showRoundedPills
? selectedOptions.map((option: RoundOption, index: number) => {
return (
<RoundPill
className={getPillClassName ? getPillClassName(option) : undefined}
getPillImageUrl={getPillImageUrl}
isValid={allowInvalidPills ? validator(option) : true}
isDisabled={disabled}
isSelected={index === selectedIndex}
key={option.value}
onRemove={onRemove.bind(this, option, index)}
// $FlowFixMe option.text is for backwards compatibility
text={option.displayText || option.text}
showAvatar
id={option.id}
hasWarning={option.hasWarning}
isExternal={option.isExternalUser}
/>
);
})
: selectedOptions.map((option: Option, index: number) => {
// TODO: This and associated types will be removed once all views are updates with round pills.
return (
<Pill
isValid={allowInvalidPills ? validator(option) : true}
isDisabled={disabled}
isSelected={index === selectedIndex}
key={option.value}
onRemove={onRemove.bind(this, option, index)}
// $FlowFixMe option.text is for backwards compatibility
text={option.displayText || option.text}
/>
);
})}
{/* hidden element for focus/key events during pill selection */}
<span
aria-hidden="true"
className="accessibility-hidden"
onBlur={this.resetSelectedIndex}
ref={this.hiddenRef}
tabIndex={-1}
data-testid="pill-selection-helper"
/>
<textarea
{...ariaAttrs}
{...rest}
{...inputProps}
autoComplete="off"
className={classNames('bdl-PillSelector-input', 'pill-selector-input', className, {
'bdl-PillSelector-input--showAvatars': showAvatars,
})}
disabled={disabled}
onInput={onInput}
placeholder={this.getNumSelected() === 0 ? placeholder : ''}
ref={input => {
this.inputEl = input;
}}
/>
<SuggestedPillsRow
onSuggestedPillAdd={onSuggestedPillAdd}
selectedPillsValues={this.getPillsByKey('value')}
suggestedPillsFilter={suggestedPillsFilter}
suggestedPillsData={suggestedPillsData}
title={suggestedPillsTitle}
/>
<span id={this.errorMessageID} className="accessibility-hidden" role="alert">
{error}
</span>
</span>
</Tooltip>
);
}
}
export default PillSelector;