wix-style-react
Version:
505 lines (389 loc) • 17 kB
JavaScript
import React, { Component } from 'react';
import PropTypes from 'prop-types';
import InputWithOptions from '../InputWithOptions';
import SearchIcon from 'wix-ui-icons-common/Search';
import { StringUtils } from '../utils/StringUtils';
import { st, classes } from './Search.st.css';
import Input from '../Input/Input';
import { optionValidator } from '../DropdownLayout/DropdownLayout';
// because lodash debounce is not compatible with jest timeout mocks
function debounce(fn, wait) {
let timeout;
return function (...args) {
const context = this;
clearTimeout(timeout);
timeout = setTimeout(() => fn.apply(context, args), wait);
};
}
/**
* Search component with suggestions based on input value listed in dropdown
*/
class Search extends Component {
static displayName = 'Search';
static propTypes = {
/** Associate a control with the regions that it controls */
ariaControls: PropTypes.string,
/** Associate a region with its descriptions. Similar to aria-controls but instead associating descriptions to the region and description identifiers are separated with a space. */
ariaDescribedby: PropTypes.string,
/** Define a string that labels the current element in case where a text label is not visible on the screen */
ariaLabel: PropTypes.string,
/** Sets the value of native autocomplete attribute (consult the [HTML spec](https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#attr-fe-autocomplete) for possible values) */
autocomplete: PropTypes.string,
/** Focus the element on mount (standard React input autoFocus) */
autoFocus: PropTypes.bool,
/** Select the entire text of the element on focus (standard React input autoSelect) */
autoSelect: PropTypes.bool,
/** Control the border style of input */
border: PropTypes.oneOf(['standard', 'round', 'bottomLine']),
/** Specifies a CSS class name to be appended to the component’s root element */
className: PropTypes.string,
/** Displays clear button (X) on a non-empty input */
clearButton: PropTypes.bool,
/** Closes DropdownLayout when option is selected */
closeOnSelect: PropTypes.bool,
/** Render a custom input component instead of the default html input tag */
customInput: PropTypes.node,
/** Applies a data-hook HTML attribute that can be used in the tests */
dataHook: PropTypes.string,
/** Specifies the `onChange` debounce in milliseconds */
debounceMs: PropTypes.number,
/** Defines the initial value of an input for those who want to use this component un-controlled */
defaultValue: PropTypes.string,
/** Specifies whether the input should be disabled or not */
disabled: PropTypes.bool,
/** Restricts input editing */
disableEditing: PropTypes.bool,
/** Sets the offset of the dropdown from the left in pixels */
dropdownOffsetLeft: PropTypes.string,
/** Sets the width of the dropdown in pixels */
dropdownWidth: PropTypes.string,
/** Specifies whether to collapse input to search icon only. Once clicked, icon will expand to a full search input. */
expandable: PropTypes.bool,
/** Specifies the width of an input in an expanded state */
expandWidth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
/** USED FOR TESTING - forces focus state on the input */
forceFocus: PropTypes.bool,
/** USED FOR TESTING - forces hover state on the input */
forceHover: PropTypes.bool,
/** Specifies whether there are more items to be loaded */
hasMore: PropTypes.bool,
/** Specifies whether the status suffix should be hidden */
hideStatusSuffix: PropTypes.bool,
/** Highlight word parts that match search criteria in bold */
highlight: PropTypes.bool,
/** Assigns an unique identifier for the root element */
id: PropTypes.string,
/** Specifies whether `<DropdownLayout/>` is in a container component. If true, some styles such as shadows, positioning and padding will be added to the component contentContainer. */
inContainer: PropTypes.bool,
/** Specifies whether lazy loading of the dropdown layout items is enabled */
infiniteScroll: PropTypes.bool,
/** Allows to render a custom input component instead of the default `<Input/>` */
inputElement: PropTypes.element,
/** Defines a callback function which is called on a request to render more list items */
loadMore: PropTypes.func,
/** Sets a maximum value of an input. Similar to HTML5 max attribute. */
max: PropTypes.number,
/** Sets the maximum height of the `dropdownLayout` in pixels */
maxHeightPixels: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
/** Sets the maximum number of characters that can be entered into a field */
maxLength: PropTypes.number,
/** Sets a minimum value of an input. Similar to HTML5 min attribute */
min: PropTypes.number,
/** Sets the minimum width of dropdownLayout in pixels */
minWidthPixels: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
/** Reference element data when a form is submitted */
name: PropTypes.string,
/** Specifies whether input shouldn’t have rounded corners on its left */
noLeftBorderRadius: PropTypes.bool,
/** Specifies whether input shouldn’t have rounded corners on its right */
noRightBorderRadius: PropTypes.bool,
/** Defines a standard input `onBlur` callback */
onBlur: PropTypes.func,
/** Defines a standard input `onChange` callback */
onChange: PropTypes.func,
/** Displays clear button (X) on a non-empty input and calls a callback function with no arguments */
onClear: PropTypes.func,
/** Defines a callback function which is called whenever the user presses the escape key */
onClose: PropTypes.func,
/** Defines a callback function called on `compositionstart`/`compositionend` events */
onCompositionChange: PropTypes.func,
/** Defines a callback handler that is called when the presses -enter- */
onEnterPressed: PropTypes.func,
/** Defines a callback handler that is called when the user presses -escape- */
onEscapePressed: PropTypes.func,
/** Defines a standard input `onFocus` callback */
onFocus: PropTypes.func,
/** Defines a standard input `onClick` callback */
onInputClicked: PropTypes.func,
/** Defines a standard input `onKeyDown` callback */
onKeyDown: PropTypes.func,
/** Defines a standard input `onKeyUp` callback */
onKeyUp: PropTypes.func,
/** Defines a callback function which is called when the user performs a submit action. Submit action triggers are:
* "Enter", "Tab", [typing any defined delimiters], paste action.
* `onManuallyInput(values: Array<string>): void` - the array of strings is the result of splitting the input value by the given delimiters */
onManuallyInput: PropTypes.func,
/** Defines a callback function which is called whenever the user enters dropdown layout with the mouse cursor */
onMouseEnter: PropTypes.func,
/** Defines a callback function which is called whenever the user exits from dropdown layout with a mouse cursor */
onMouseLeave: PropTypes.func,
/** Defines a callback function which is called whenever an option becomes focused (hovered/active). Receives the relevant option object from the original `props.options array`. */
onOptionMarked: PropTypes.func,
/** Defines a callback function which is called when options dropdown is hidden */
onOptionsHide: PropTypes.func,
/** Defines a callback function which is called when the options dropdown is shown */
onOptionsShow: PropTypes.func,
/** Defines a callback handler that is called when user pastes text from a clipboard (using mouse or keyboard shortcut) */
onPaste: PropTypes.func,
/** Defines a callback function which is called whenever user selects a different option in the list */
onSelect: PropTypes.func,
/** Array of objects:
* - `id <string / number>` *required*: the id of the option, should be unique;
* - value `<function / string / node>` *required*: can be a string, react element or a builder function;
* - disabled `<bool>` *default value- false*: whether this option is disabled or not;
* - linkTo `<string>`: when provided the option will be an anchor to the given value;
* - title `<bool>` *default value- false* **deprecated**: please use `listItemSectionBuilder` for rendering a title;
* - overrideStyle `<bool>` *default value- false* **deprecated**: please use `overrideOptionStyle` for override option styles;
* - overrideOptionStyle `<bool>` *default value- false* - when set to `true`, the option will be responsible for its own styles. No styles will be applied from the DropdownLayout itself;
* - label `<string>`: the string displayed within an input when the option is selected. This is used when using `<DropdownLayout/>` with an `<Input/>`.
*/
options: PropTypes.arrayOf(optionValidator),
/** Handles container overflow */
overflow: PropTypes.string,
/** Sets a pattern that the typed value must match to be valid (regex) */
pattern: PropTypes.string,
/** Sets a placeholder message to display */
placeholder: PropTypes.string,
/** Allows to pass common popover props. Check `<Popover/>` API for a full list. */
popoverProps: PropTypes.shape({
appendTo: PropTypes.oneOf([
'window',
'scrollParent',
'parent',
'viewport',
]),
maxWidth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
minWidth: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
flip: PropTypes.bool,
fixed: PropTypes.bool,
placement: PropTypes.oneOf([
'auto-start',
'auto',
'auto-end',
'top-start',
'top',
'top-end',
'right-start',
'right',
'right-end',
'bottom-end',
'bottom',
'bottom-start',
'left-end',
'left',
'left-start',
]),
dynamicWidth: PropTypes.bool,
}),
/** Defines a custom function for options filtering */
predicate: PropTypes.func,
/** Specifies whether input is read only */
readOnly: PropTypes.bool,
/** Specifies that an input must be filled out before submitting the form */
required: PropTypes.bool,
/** Flip the component horizontally so it’s more suitable for RTL */
rtl: PropTypes.bool,
/** Specifies whether the selected option will be highlighted when the dropdown is reopened */
selectedHighlight: PropTypes.bool,
/** Specifies selected option by its id */
selectedId: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
/** Controls whether to show options if input is empty */
showOptionsIfEmptyInput: PropTypes.bool,
/** Controls the size of the input */
size: PropTypes.oneOf(['small', 'medium', 'large']),
/** Specify the status of a field */
status: PropTypes.oneOf(['error', 'warning', 'loading']),
/** Defines the message to display on status icon hover. If not given or empty there will be no tooltip. */
statusMessage: PropTypes.node,
/** Indicates that element can be focused and where it participates in sequential keyboard navigation */
tabIndex: PropTypes.number,
/** Handles text overflow behavior. It can either `clip` (default) or display `ellipsis`. */
textOverflow: PropTypes.string,
/** Controls the placement of a status tooltip */
tooltipPlacement: PropTypes.string,
/** Specifies the type of `<input>` element to display. The default type is text. */
type: PropTypes.string,
/** Specifies the current value of the element */
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
};
static defaultProps = {
...InputWithOptions.defaultProps,
clearButton: true,
placeholder: 'Search',
expandable: false,
expandWidth: '100%',
debounceMs: 0,
onChange: () => {},
highlight: true,
border: 'round',
};
constructor(props) {
super(props);
const initialValue = this._getIsControlled()
? props.value
: props.defaultValue || '';
this._onChangeHandler = this._createDebouncedOnChange();
this.state = {
inputValue: initialValue,
collapsed: props.expandable && !initialValue && !props.autoFocus,
};
}
searchInput = React.createRef();
componentDidUpdate(prevProps) {
if (prevProps.value !== this.props.value) {
this.setState({ inputValue: this.props.value });
}
if (
prevProps.debounceMs !== this.props.debounceMs ||
prevProps.onChange !== this.props.onChange
) {
this._onChangeHandler = this._createDebouncedOnChange();
}
}
/**
* Creates an onChange debounced function
*/
_createDebouncedOnChange = () => {
const { debounceMs, onChange } = this.props;
return debounceMs > 0 ? debounce(onChange, debounceMs) : onChange;
};
_getIsControlled = () => 'value' in this.props && 'onChange' in this.props;
_getFilteredOptions = () => {
const { options, predicate } = this.props;
const searchText = this._currentValue();
if (!searchText || !searchText.length) {
return options;
}
const filterFn = predicate || this._stringFilter;
return options.filter(filterFn);
};
_stringFilter = option => {
const searchText = this._currentValue();
return StringUtils.includesCaseInsensitive(option.value, searchText.trim());
};
_onChange = e => {
e.persist();
this.setState(
{
inputValue: e.target.value,
},
() => {
this._onChangeHandler(e);
},
);
};
_onClear = event => {
const { expandable } = this.props;
const { collapsed } = this.state;
const stateChanges = {};
if (!this._getIsControlled()) {
stateChanges.inputValue = '';
}
if (expandable && !collapsed && this._currentValue === '') {
stateChanges.collapsed = true;
this.searchInput.current && this.searchInput.current.blur();
}
this.setState(stateChanges, () => {
this._onClearHandler(event);
});
};
_onClearHandler = event => {
const { onClear } = this.props;
if (onClear) onClear(event);
};
_currentValue = () => this.state.inputValue;
_onFocus = event => {
const { onFocus } = this.props;
if (this.state.collapsed && this.props.expandable) {
this.setState({
collapsed: false,
});
}
onFocus && onFocus(event);
};
_onBlur = async event => {
const { onBlur } = this.props;
onBlur && (await onBlur(event));
if (!this.state.collapsed && this.props.expandable) {
const value = this._currentValue();
if (value === '') {
this.setState({
collapsed: true,
});
}
}
};
_onWrapperClick = () => {
if (
!this.props.expandable ||
(this.props.expandable && this.state.collapsed)
) {
this.searchInput.current && this.searchInput.current.focus();
}
};
_onWrapperMouseDown = e => {
// We need to capture mouse down and prevent it's event if the input
// is already open
if (this.props.expandable && !this.state.collapsed) {
const value = this._currentValue();
if (value === '') {
e.preventDefault();
}
}
};
render() {
const { defaultValue, dataHook, expandWidth, highlight, ...restProps } =
this.props;
const { expandable, size } = restProps;
const { collapsed, inputValue } = this.state;
const contentStyle =
expandable && !collapsed ? { width: expandWidth } : undefined;
return (
<div
data-hook={dataHook}
className={st(classes.root, {
expandable,
expanded: expandable && collapsed,
size,
})}
onClick={this._onWrapperClick}
onMouseDown={this._onWrapperMouseDown}
data-expandable={expandable || null}
data-collapsed={(expandable && collapsed) || null}
>
<div className={classes.content} style={contentStyle}>
<InputWithOptions
{...restProps}
value={inputValue}
ref={this.searchInput}
prefix={
<Input.IconAffix>
<SearchIcon />
</Input.IconAffix>
}
dataHook="search-inputwithoptions"
menuArrow={false}
closeOnSelect
options={this._getFilteredOptions()}
onClear={restProps.clearButton ? this._onClear : undefined}
onChange={this._onChange}
onFocus={this._onFocus}
onBlur={this._onBlur}
highlight={highlight}
/>
</div>
</div>
);
}
}
export default Search;