From 53a9e5cb5d255e27f9e624d31fa065de27ad7905 Mon Sep 17 00:00:00 2001 From: Tamas Kovacs Date: Wed, 30 Oct 2024 14:55:43 +0100 Subject: [PATCH] docs(ui-select,ui-selectable): function based examples are added to Select, Selectable and Accessing the DOM Closes: INSTUI-4221 --- docs/guides/accessing-the-dom.md | 152 +- packages/ui-select/src/Select/README.md | 3289 +++++++++++------ .../ui-selectable/src/Selectable/README.md | 586 ++- 3 files changed, 2718 insertions(+), 1309 deletions(-) diff --git a/docs/guides/accessing-the-dom.md b/docs/guides/accessing-the-dom.md index 9276058ed8..64ddc2a2ba 100644 --- a/docs/guides/accessing-the-dom.md +++ b/docs/guides/accessing-the-dom.md @@ -25,70 +25,120 @@ class MyComponent extends React.Component { return
Content
} } -``` -Good Usage Example with `ref`: +``` -```js +```javascript --- -type: example +type: code --- +const MyComponent = React.forwardRef((props, ref) => { + return
Content
+}) +``` -class GoodComponent extends React.Component { - constructor(props) { - super(props) - this.ref = React.createRef() +Good Usage Example with `ref`: + +- ```js + class GoodComponent extends React.Component { + constructor(props) { + super(props) + this.ref = React.createRef() + } + render() { + return Good Position target component + } } - render() { - return (Good Position target component) + const Example = () => { + return ( + + } + placement="end center" + offsetX="20px" + > + + Positioned content + + + + ) } -} + render() + ``` -const Example = () => { - return ( - - } - placement='end center' - offsetX='20px' - > - Positioned content - - - ) -} +- ```js + const GoodComponent = React.forwardRef((props, ref) => { + return Good Position target component + }) -render() -``` + const Example = () => { + return ( + + } + placement="end center" + offsetX="20px" + > + + Positioned content + + + + ) + } + render() + ``` Bad Usage Example without `ref`, that will result in InstUI calling `ReactDOM.findDOMNode()` and throw warnings: -```js ---- -type: example ---- -class BadComponent extends React.Component { - constructor(props) { - super(props) +- ```js + class BadComponent extends React.Component { + constructor(props) { + super(props) + } + render() { + return Bad Position target component + } } - render() { - return (Bad Position target component) + + const Example = () => { + return ( + + } + placement="end center" + offsetX="20px" + > + + Positioned content + + + + ) } -} + render() + ``` -const Example = () => { - return ( - - } - placement='end center' - offsetX='20px' - > - Positioned content - - - ) -} +- ```js + const BadComponent = React.forwardRef((props, ref) => { + return Bad Position target component + }) -render() -``` + const Example = () => { + return ( + + } + placement="end center" + offsetX="20px" + > + + Positioned content + + + + ) + } + render() + ``` diff --git a/packages/ui-select/src/Select/README.md b/packages/ui-select/src/Select/README.md index 3559ccff57..b670085a12 100644 --- a/packages/ui-select/src/Select/README.md +++ b/packages/ui-select/src/Select/README.md @@ -14,77 +14,187 @@ describes: Select `Select` is a controlled-only component. The consuming app or component must manage any state needed. A variety of request callbacks are provided as prompts for state updates. `onRequestShowOptions`, for example, is fired when `Select` thinks the `isShowingOptions` prop should be updated to `true`. Of course, the consumer can always choose how to react to these callbacks. -```javascript ---- -type: example ---- +- ```javascript + class SingleSelectExample extends React.Component { + state = { + inputValue: this.props.options[0].label, + isShowingOptions: false, + highlightedOptionId: null, + selectedOptionId: this.props.options[0].id, + announcement: null + } -class SingleSelectExample extends React.Component { - state = { - inputValue: this.props.options[0].label, - isShowingOptions: false, - highlightedOptionId: null, - selectedOptionId: this.props.options[0].id, - announcement: null - } + getOptionById(queryId) { + return this.props.options.find(({ id }) => id === queryId) + } - getOptionById (queryId) { - return this.props.options.find(({ id }) => id === queryId) - } + handleShowOptions = (event) => { + this.setState({ + isShowingOptions: true + }) + } - handleShowOptions = (event) => { - this.setState({ - isShowingOptions: true - }) - } + handleHideOptions = (event) => { + const { selectedOptionId } = this.state + const option = this.getOptionById(selectedOptionId).label + this.setState({ + isShowingOptions: false, + highlightedOptionId: null, + inputValue: selectedOptionId ? option : '', + announcement: 'List collapsed.' + }) + } - handleHideOptions = (event) => { - const { selectedOptionId } = this.state - const option = this.getOptionById(selectedOptionId).label - this.setState({ - isShowingOptions: false, - highlightedOptionId: null, - inputValue: selectedOptionId ? option : '', - announcement: 'List collapsed.' - }) - } + handleBlur = (event) => { + this.setState({ + highlightedOptionId: null + }) + } - handleBlur = (event) => { - this.setState({ - highlightedOptionId: null - }) - } + handleHighlightOption = (event, { id }) => { + event.persist() + const optionsAvailable = `${this.props.options.length} options available.` + const nowOpen = !this.state.isShowingOptions + ? `List expanded. ${optionsAvailable}` + : '' + const option = this.getOptionById(id).label + this.setState((state) => ({ + highlightedOptionId: id, + inputValue: event.type === 'keydown' ? option : state.inputValue, + announcement: `${option} ${nowOpen}` + })) + } - handleHighlightOption = (event, { id }) => { - event.persist() - const optionsAvailable = `${this.props.options.length} options available.` - const nowOpen = !this.state.isShowingOptions ? `List expanded. ${optionsAvailable}` : '' - const option = this.getOptionById(id).label - this.setState((state) => ({ - highlightedOptionId: id, - inputValue: event.type === 'keydown' ? option : state.inputValue, - announcement: `${option} ${nowOpen}` - })) - } + handleSelectOption = (event, { id }) => { + const option = this.getOptionById(id).label + this.setState({ + selectedOptionId: id, + inputValue: option, + isShowingOptions: false, + announcement: `"${option}" selected. List collapsed.` + }) + } - handleSelectOption = (event, { id }) => { - const option = this.getOptionById(id).label - this.setState({ - selectedOptionId: id, - inputValue: option, - isShowingOptions: false, - announcement: `"${option}" selected. List collapsed.` - }) + render() { + const { + inputValue, + isShowingOptions, + highlightedOptionId, + selectedOptionId, + announcement + } = this.state + + return ( +
+ + document.getElementById('flash-messages')} + liveRegionPoliteness="assertive" + screenReaderOnly + > + {announcement} + +
+ ) + } } - render () { - const { - inputValue, - isShowingOptions, - highlightedOptionId, - selectedOptionId, - announcement - } = this.state + render( + + + + ) + ``` + +- ```js + const SingleSelectExample = ({ options }) => { + const [inputValue, setInputValue] = useState(options[0].label) + const [isShowingOptions, setIsShowingOptions] = useState(false) + const [highlightedOptionId, setHighlightedOptionId] = useState(null) + const [selectedOptionId, setSelectedOptionId] = useState(options[0].id) + const [announcement, setAnnouncement] = useState(null) + + const getOptionById = (queryId) => { + return options.find(({ id }) => id === queryId) + } + + const handleShowOptions = (event) => { + setIsShowingOptions(true) + } + + const handleHideOptions = (event) => { + const option = getOptionById(selectedOptionId).label + setIsShowingOptions(false) + setHighlightedOptionId(null) + setSelectedOptionId(selectedOptionId ? option : '') + setAnnouncement('List collapsed.') + } + + const handleBlur = (event) => { + setHighlightedOptionId(null) + } + + const handleHighlightOption = (event, { id }) => { + event.persist() + const optionsAvailable = `${options.length} options available.` + const nowOpen = !isShowingOptions + ? `List expanded. ${optionsAvailable}` + : '' + const option = getOptionById(id).label + setHighlightedOptionId(id) + setInputValue(event.type === 'keydown' ? option : inputValue) + setAnnouncement(`${option} ${nowOpen}`) + } + + const handleSelectOption = (event, { id }) => { + const option = getOptionById(id).label + setSelectedOptionId(id) + setInputValue(option) + setIsShowingOptions(false) + setAnnouncement(`"${option}" selected. List collapsed.`) + } return (
@@ -93,13 +203,13 @@ class SingleSelectExample extends React.Component { assistiveText="Use arrow keys to navigate options." inputValue={inputValue} isShowingOptions={isShowingOptions} - onBlur={this.handleBlur} - onRequestShowOptions={this.handleShowOptions} - onRequestHideOptions={this.handleHideOptions} - onRequestHighlightOption={this.handleHighlightOption} - onRequestSelectOption={this.handleSelectOption} + onBlur={handleBlur} + onRequestShowOptions={handleShowOptions} + onRequestHideOptions={handleHideOptions} + onRequestHighlightOption={handleHighlightOption} + onRequestSelectOption={handleSelectOption} > - {this.props.options.map((option) => { + {options.map((option) => { return ( - { option.label } + {option.label} ) })} @@ -117,38 +227,36 @@ class SingleSelectExample extends React.Component { liveRegionPoliteness="assertive" screenReaderOnly > - { announcement } + {announcement}
) } -} - -render( - - - -) -``` + render( + + + + ) + ``` #### Providing autocomplete behavior @@ -156,449 +264,381 @@ It's best practice to always provide autocomplete functionality to help users ma > Note: Select makes some conditional assumptions about keyboard behavior. For example, if the list is NOT showing, up/down arrow keys and the space key, will show the list. Otherwise, the arrows will navigate options and the space key will type a space character. -```javascript ---- -type: example ---- - -class AutocompleteExample extends React.Component { - state = { - inputValue: '', - isShowingOptions: false, - highlightedOptionId: null, - selectedOptionId: null, - filteredOptions: this.props.options, - announcement: null - } +- ```javascript + class AutocompleteExample extends React.Component { + state = { + inputValue: '', + isShowingOptions: false, + highlightedOptionId: null, + selectedOptionId: null, + filteredOptions: this.props.options, + announcement: null + } - getOptionById (queryId) { - return this.props.options.find(({ id }) => id === queryId) - } + getOptionById(queryId) { + return this.props.options.find(({ id }) => id === queryId) + } - getOptionsChangedMessage (newOptions) { - let message = newOptions.length !== this.state.filteredOptions.length - ? `${newOptions.length} options available.` // options changed, announce new total - : null // options haven't changed, don't announce - if (message && newOptions.length > 0) { - // options still available - if (this.state.highlightedOptionId !== newOptions[0].id) { - // highlighted option hasn't been announced - const option = this.getOptionById(newOptions[0].id).label - message = `${option}. ${message}` + getOptionsChangedMessage(newOptions) { + let message = + newOptions.length !== this.state.filteredOptions.length + ? `${newOptions.length} options available.` // options changed, announce new total + : null // options haven't changed, don't announce + if (message && newOptions.length > 0) { + // options still available + if (this.state.highlightedOptionId !== newOptions[0].id) { + // highlighted option hasn't been announced + const option = this.getOptionById(newOptions[0].id).label + message = `${option}. ${message}` + } } + return message } - return message - } - filterOptions = (value) => { - return this.props.options.filter(option => ( - option.label.toLowerCase().startsWith(value.toLowerCase()) - )) - } + filterOptions = (value) => { + return this.props.options.filter((option) => + option.label.toLowerCase().startsWith(value.toLowerCase()) + ) + } - matchValue () { - const { - filteredOptions, - inputValue, - highlightedOptionId, - selectedOptionId - } = this.state - - // an option matching user input exists - if (filteredOptions.length === 1) { - const onlyOption = filteredOptions[0] - // automatically select the matching option - if (onlyOption.label.toLowerCase() === inputValue.toLowerCase()) { - return { - inputValue: onlyOption.label, - selectedOptionId: onlyOption.id, - filteredOptions: this.filterOptions('') + matchValue() { + const { + filteredOptions, + inputValue, + highlightedOptionId, + selectedOptionId + } = this.state + + // an option matching user input exists + if (filteredOptions.length === 1) { + const onlyOption = filteredOptions[0] + // automatically select the matching option + if (onlyOption.label.toLowerCase() === inputValue.toLowerCase()) { + return { + inputValue: onlyOption.label, + selectedOptionId: onlyOption.id, + filteredOptions: this.filterOptions('') + } } } - } - // allow user to return to empty input and no selection - if (inputValue.length === 0) { - return { selectedOptionId: null } - } - // no match found, return selected option label to input - if (selectedOptionId) { - const selectedOption = this.getOptionById(selectedOptionId) - return { inputValue: selectedOption.label } - } - // input value is from highlighted option, not user input - // clear input, reset options - if (highlightedOptionId) { - if (inputValue === this.getOptionById(highlightedOptionId).label) { - return { - inputValue: '', - filteredOptions: this.filterOptions('') + // allow user to return to empty input and no selection + if (inputValue.length === 0) { + return { selectedOptionId: null } + } + // no match found, return selected option label to input + if (selectedOptionId) { + const selectedOption = this.getOptionById(selectedOptionId) + return { inputValue: selectedOption.label } + } + // input value is from highlighted option, not user input + // clear input, reset options + if (highlightedOptionId) { + if (inputValue === this.getOptionById(highlightedOptionId).label) { + return { + inputValue: '', + filteredOptions: this.filterOptions('') + } } } } - } - handleShowOptions = (event) => { - this.setState(({ filteredOptions }) => ({ - isShowingOptions: true, - announcement: `List expanded. ${filteredOptions.length} options available.` - })) - } + handleShowOptions = (event) => { + this.setState(({ filteredOptions }) => ({ + isShowingOptions: true, + announcement: `List expanded. ${filteredOptions.length} options available.` + })) + } - handleHideOptions = (event) => { - const { selectedOptionId, inputValue } = this.state - this.setState({ - isShowingOptions: false, - highlightedOptionId: null, - announcement: 'List collapsed.', - ...this.matchValue() - }) - } + handleHideOptions = (event) => { + const { selectedOptionId, inputValue } = this.state + this.setState({ + isShowingOptions: false, + highlightedOptionId: null, + announcement: 'List collapsed.', + ...this.matchValue() + }) + } - handleBlur = (event) => { - this.setState({ highlightedOptionId: null }) - } + handleBlur = (event) => { + this.setState({ highlightedOptionId: null }) + } - handleHighlightOption = (event, { id }) => { - event.persist() - const option = this.getOptionById(id) - if (!option) return // prevent highlighting of empty option - this.setState((state) => ({ - highlightedOptionId: id, - inputValue: event.type === 'keydown' ? option.label : state.inputValue, - announcement: option.label - })) - } + handleHighlightOption = (event, { id }) => { + event.persist() + const option = this.getOptionById(id) + if (!option) return // prevent highlighting of empty option + this.setState((state) => ({ + highlightedOptionId: id, + inputValue: event.type === 'keydown' ? option.label : state.inputValue, + announcement: option.label + })) + } - handleSelectOption = (event, { id }) => { - const option = this.getOptionById(id) - if (!option) return // prevent selecting of empty option - this.setState({ - selectedOptionId: id, - inputValue: option.label, - isShowingOptions: false, - filteredOptions: this.props.options, - announcement: `${option.label} selected. List collapsed.` - }) - } + handleSelectOption = (event, { id }) => { + const option = this.getOptionById(id) + if (!option) return // prevent selecting of empty option + this.setState({ + selectedOptionId: id, + inputValue: option.label, + isShowingOptions: false, + filteredOptions: this.props.options, + announcement: `${option.label} selected. List collapsed.` + }) + } - handleInputChange = (event) => { - const value = event.target.value - const newOptions = this.filterOptions(value) - this.setState((state) => ({ - inputValue: value, - filteredOptions: newOptions, - highlightedOptionId: newOptions.length > 0 ? newOptions[0].id : null, - isShowingOptions: true, - selectedOptionId: value === '' ? null : state.selectedOptionId, - announcement: this.getOptionsChangedMessage(newOptions) - })) - } + handleInputChange = (event) => { + const value = event.target.value + const newOptions = this.filterOptions(value) + this.setState((state) => ({ + inputValue: value, + filteredOptions: newOptions, + highlightedOptionId: newOptions.length > 0 ? newOptions[0].id : null, + isShowingOptions: true, + selectedOptionId: value === '' ? null : state.selectedOptionId, + announcement: this.getOptionsChangedMessage(newOptions) + })) + } - render () { - const { - inputValue, - isShowingOptions, - highlightedOptionId, - selectedOptionId, - filteredOptions, - announcement - } = this.state + render() { + const { + inputValue, + isShowingOptions, + highlightedOptionId, + selectedOptionId, + filteredOptions, + announcement + } = this.state - return ( -
- } + renderAfterInput={} + > + {filteredOptions.length > 0 ? ( + filteredOptions.map((option) => { + return ( + + {!option.disabled + ? option.label + : `${option.label} (unavailable)`} + + ) + }) + ) : ( + + --- - ) - }) : ( - - --- - - )} - - document.getElementById('flash-messages')} - liveRegionPoliteness="assertive" - screenReaderOnly - > - { announcement } - -
- ) - } -} - -render( - - - -) -``` - -#### Highlighting and selecting options - -To mark an option as "highlighted", use the option's `isHighlighted` prop. Note that only one highlighted option is permitted. Similarly, use `isSelected` to mark an option or multiple options as "selected". When allowing multiple selections, it's best to render a [Tag](#Tag) for each selected option via the `renderBeforeInput` prop. - -```javascript ---- -type: example ---- - -class MultipleSelectExample extends React.Component { - state = { - inputValue: '', - isShowingOptions: false, - highlightedOptionId: null, - selectedOptionId: ['opt1', 'opt6'], - filteredOptions: this.props.options, - announcement: null + )} + + document.getElementById('flash-messages')} + liveRegionPoliteness="assertive" + screenReaderOnly + > + {announcement} + + + ) + } } - getOptionById (queryId) { - return this.props.options.find(({ id }) => id === queryId) - } + render( + + + + ) + ``` + +- ```js + const AutocompleteExample = ({ options }) => { + const [inputValue, setInputValue] = useState('') + const [isShowingOptions, setIsShowingOptions] = useState(false) + const [highlightedOptionId, setHighlightedOptionId] = useState(null) + const [selectedOptionId, setSelectedOptionId] = useState(null) + const [filteredOptions, setFilteredOptions] = useState(options) + const [announcement, setAnnouncement] = useState(null) + + const getOptionById = (queryId) => { + return options.find(({ id }) => id === queryId) + } - getOptionsChangedMessage (newOptions) { - let message = newOptions.length !== this.state.filteredOptions.length - ? `${newOptions.length} options available.` // options changed, announce new total - : null // options haven't changed, don't announce - if (message && newOptions.length > 0) { - // options still available - if (this.state.highlightedOptionId !== newOptions[0].id) { - // highlighted option hasn't been announced - const option = this.getOptionById(newOptions[0].id).label - message = `${option}. ${message}` + const getOptionsChangedMessage = (newOptions) => { + let message = + newOptions.length !== filteredOptions.length + ? `${newOptions.length} options available.` // options changed, announce new total + : null // options haven't changed, don't announce + if (message && newOptions.length > 0) { + // options still available + if (highlightedOptionId !== newOptions[0].id) { + // highlighted option hasn't been announced + const option = getOptionById(newOptions[0].id).label + message = `${option}. ${message}` + } } + return message } - return message - } - filterOptions = (value) => { - const { selectedOptionId } = this.state - return this.props.options.filter(option => (option.label.toLowerCase().startsWith(value.toLowerCase()) - )) - } + const filterOptions = (value) => { + return options.filter((option) => + option.label.toLowerCase().startsWith(value.toLowerCase()) + ) + } - matchValue () { - const { - filteredOptions, - inputValue, - highlightedOptionId, - selectedOptionId - } = this.state - - // an option matching user input exists - if (filteredOptions.length === 1) { - const onlyOption = filteredOptions[0] - // automatically select the matching option - if (onlyOption.label.toLowerCase() === inputValue.toLowerCase()) { - return { - inputValue: '', - selectedOptionId: [...selectedOptionId, onlyOption.id], - filteredOptions: this.filterOptions('') + const matchValue = () => { + // an option matching user input exists + if (filteredOptions.length === 1) { + const onlyOption = filteredOptions[0] + // automatically select the matching option + if (onlyOption.label.toLowerCase() === inputValue.toLowerCase()) { + setInputValue(onlyOption.label) + setSelectedOptionId(onlyOption.id) + setFilteredOptions(filterOptions('')) } } - } - // input value is from highlighted option, not user input - // clear input, reset options - if (highlightedOptionId) { - if (inputValue === this.getOptionById(highlightedOptionId).label) { - return { - inputValue: '', - filteredOptions: this.filterOptions('') + // allow user to return to empty input and no selection + else if (inputValue.length === 0) { + setSelectedOptionId(null) + } + // no match found, return selected option label to input + else if (selectedOptionId) { + const selectedOption = getOptionById(selectedOptionId) + setInputValue(selectedOption.label) + } + // input value is from highlighted option, not user input + // clear input, reset options + else if (highlightedOptionId) { + if (inputValue === getOptionById(highlightedOptionId).label) { + setInputValue('') + setFilteredOptions(filterOptions('')) } } } - } - - handleShowOptions = (event) => { - this.setState({ isShowingOptions: true }) - } - - handleHideOptions = (event) => { - this.setState({ - isShowingOptions: false, - ...this.matchValue() - }) - } - handleBlur = (event) => { - this.setState({ - highlightedOptionId: null - }) - } + const handleShowOptions = (event) => { + setIsShowingOptions(true) + setAnnouncement( + `List expanded. ${filteredOptions.length} options available.` + ) + } - handleHighlightOption = (event, { id }) => { - event.persist() - const option = this.getOptionById(id) - if (!option) return // prevent highlighting empty option - this.setState((state) => ({ - highlightedOptionId: id, - inputValue: event.type === 'keydown' ? option.label : state.inputValue, - announcement: option.label - })) - } + const handleHideOptions = (event) => { + setIsShowingOptions(false) + setHighlightedOptionId(false) + setAnnouncement('List collapsed.') + matchValue() + } - handleSelectOption = (event, { id }) => { - const option = this.getOptionById(id) - if (!option) return // prevent selecting of empty option - this.setState((state) => ({ - selectedOptionId: [...state.selectedOptionId, id], - highlightedOptionId: null, - filteredOptions: this.filterOptions(''), - inputValue: '', - isShowingOptions: false, - announcement: `${option.label} selected. List collapsed.` - })) - } + const handleBlur = (event) => { + setHighlightedOptionId(null) + } - handleInputChange = (event) => { - const value = event.target.value - const newOptions = this.filterOptions(value) - this.setState({ - inputValue: value, - filteredOptions: newOptions, - highlightedOptionId: newOptions.length > 0 ? newOptions[0].id : null, - isShowingOptions: true, - announcement: this.getOptionsChangedMessage(newOptions) - }) - } + const handleHighlightOption = (event, { id }) => { + event.persist() + const option = getOptionById(id) + if (!option) return // prevent highlighting of empty option + setHighlightedOptionId(id) + setInputValue(event.type === 'keydown' ? option.label : inputValue) + setAnnouncement(option.label) + } - handleKeyDown = (event) => { - const { selectedOptionId, inputValue } = this.state - if (event.keyCode === 8) { - // when backspace key is pressed - if (inputValue === '' && selectedOptionId.length > 0) { - // remove last selected option, if input has no entered text - this.setState((state) => ({ - highlightedOptionId: null, - selectedOptionId: state.selectedOptionId.slice(0, -1) - })) - } + const handleSelectOption = (event, { id }) => { + const option = getOptionById(id) + if (!option) return // prevent selecting of empty option + setSelectedOptionId(id) + setInputValue(option.label) + setIsShowingOptions(false) + setFilteredOptions(options) + setAnnouncement(`${option.label} selected. List collapsed.`) } - } - // remove a selected option tag - dismissTag (e, tag) { - // prevent closing of list - e.stopPropagation() - e.preventDefault() - - const newSelection = this.state.selectedOptionId.filter((id) => id !== tag) - this.setState({ - selectedOptionId: newSelection, - highlightedOptionId: null, - announcement: `${this.getOptionById(tag).label} removed`, - }, () => { - this.inputRef.focus() - }) - } - // render tags when multiple options are selected - renderTags () { - const { selectedOptionId } = this.state - return selectedOptionId.map((id, index) => ( - 0 ? 'xxx-small 0 xxx-small xx-small' : 'xxx-small 0'} - onClick={(e) => this.dismissTag(e, id)} - /> - )) - } - render () { - const { - inputValue, - isShowingOptions, - highlightedOptionId, - selectedOptionId, - filteredOptions, - announcement - } = this.state + const handleInputChange = (event) => { + const value = event.target.value + const newOptions = filterOptions(value) + setInputValue(value) + setFilteredOptions(newOptions) + setHighlightedOptionId(newOptions.length > 0 ? newOptions[0].id : null) + setIsShowingOptions(true) + setSelectedOptionId(value === '' ? null : selectedOptionId) + setAnnouncement(getOptionsChangedMessage(newOptions)) + } return (
(this.inputRef = el)} + onBlur={this.handleBlur} + onInputChange={this.handleInputChange} + onRequestShowOptions={this.handleShowOptions} + onRequestHideOptions={this.handleHideOptions} + onRequestHighlightOption={this.handleHighlightOption} + onRequestSelectOption={this.handleSelectOption} + onKeyDown={this.handleKeyDown} + renderBeforeInput={ + selectedOptionId.length > 0 ? this.renderTags() : null + } + > + {filteredOptions.length > 0 ? ( + filteredOptions.map((option, index) => { + if (selectedOptionId.indexOf(option.id) === -1) { + return ( + + {option.label} + + ) + } + }) + ) : ( + + --- + + )} + + document.getElementById('flash-messages')} + liveRegionPoliteness="assertive" + screenReaderOnly + > + {announcement} + +
+ ) + } + } + + render( + + + + ) + ``` + +- ```js + const MultipleSelectExample = ({ options }) => { + const [inputValue, setInputValue] = useState('') + const [isShowingOptions, setIsShowingOptions] = useState(false) + const [highlightedOptionId, setHighlightedOptionId] = useState(null) + const [selectedOptionId, setSelectedOptionId] = useState(['opt1', 'opt6']) + const [filteredOptions, setFilteredOptions] = useState(options) + const [announcement, setAnnouncement] = useState(null) + const inputRef = useRef(null) + + const getOptionById = (queryId) => { + return options.find(({ id }) => id === queryId) + } + + const getOptionsChangedMessage = (newOptions) => { + let message = + newOptions.length !== filteredOptions.length + ? `${newOptions.length} options available.` // options changed, announce new total + : null // options haven't changed, don't announce + if (message && newOptions.length > 0) { + // options still available + if (highlightedOptionId !== newOptions[0].id) { + // highlighted option hasn't been announced + const option = getOptionById(newOptions[0].id).label + message = `${option}. ${message}` + } + } + return message + } + + const filterOptions = (value) => { + return options.filter((option) => + option.label.toLowerCase().startsWith(value.toLowerCase()) + ) + } + + const matchValue = () => { + // an option matching user input exists + if (filteredOptions.length === 1) { + const onlyOption = filteredOptions[0] + // automatically select the matching option + if (onlyOption.label.toLowerCase() === inputValue.toLowerCase()) { + setInputValue('') + setSelectedOptionId([...selectedOptionId, onlyOption.id]) + setFilteredOptions(filterOptions('')) + } + } + // input value is from highlighted option, not user input + // clear input, reset options + else if (highlightedOptionId) { + if (inputValue === getOptionById(highlightedOptionId).label) { + setInputValue('') + setFilteredOptions(filterOptions('')) + } + } + } + + const handleShowOptions = (event) => { + setIsShowingOptions(true) + } + + const handleHideOptions = (event) => { + setIsShowingOptions(false) + matchValue() + } + + const handleBlur = (event) => { + setHighlightedOptionId(null) + } + + const handleHighlightOption = (event, { id }) => { + event.persist() + const option = getOptionById(id) + if (!option) return // prevent highlighting empty option + setHighlightedOptionId(id) + setInputValue(event.type === 'keydown' ? option.label : inputValue) + setAnnouncement(option.label) + } + + const handleSelectOption = (event, { id }) => { + const option = getOptionById(id) + if (!option) return // prevent selecting of empty option + setSelectedOptionId([...selectedOptionId, id]) + setHighlightedOptionId(null) + setFilteredOptions(filterOptions('')) + setInputValue('') + setIsShowingOptions(false) + setAnnouncement(`${option.label} selected. List collapsed.`) + } + + const handleInputChange = (event) => { + const value = event.target.value + const newOptions = filterOptions(value) + setInputValue(value) + setFilteredOptions(newOptions) + sethHighlightedOptionId(newOptions.length > 0 ? newOptions[0].id : null) + setIsShowingOptions(true) + setAnnouncement(getOptionsChangedMessage(newOptions)) + } + + const handleKeyDown = (event) => { + if (event.keyCode === 8) { + // when backspace key is pressed + if (inputValue === '' && selectedOptionId.length > 0) { + // remove last selected option, if input has no entered text + setHighlightedOptionId(null) + setSelectedOptionId(selectedOptionId.slice(0, -1)) + } + } + } + + // remove a selected option tag + const dismissTag = (e, tag) => { + // prevent closing of list + e.stopPropagation() + e.preventDefault() + + const newSelection = selectedOptionId.filter((id) => id !== tag) + + setSelectedOptionId(newSelection) + setHighlightedOptionId(null) + setAnnouncement(`${getOptionById(tag).label} removed`) + + inputRef.current.focus() + } + + const renderTags = () => { + return selectedOptionId.map((id, index) => ( + 0 ? 'xxx-small 0 xxx-small xx-small' : 'xxx-small 0'} + onClick={(e) => dismissTag(e, id)} + /> + )) + } + + return ( +
+ + document.getElementById('flash-messages')} + liveRegionPoliteness="assertive" + screenReaderOnly + > + {announcement} + +
+ ) + } + + render( + + + + ) + ``` + +#### Composing option groups + +In addition to `` Select also accepts `` as children. This is meant to serve the same purpose as `` elements. Group only requires you provide a label via its `renderLabel` prop. Groups and their associated options also accept icons or other stylistic additions if needed. + +- ```javascript + class GroupSelectExample extends React.Component { + state = { + inputValue: this.props.options['Western'][0].label, + isShowingOptions: false, + highlightedOptionId: null, + selectedOptionId: this.props.options['Western'][0].id, + announcement: null + } + + getOptionById(id) { + const { options } = this.props + let match = null + Object.keys(options).forEach((key, index) => { + for (let i = 0; i < options[key].length; i++) { + const option = options[key][i] + if (id === option.id) { + // return group property with the object just to make it easier + // to check which group the option belongs to + match = { ...option, group: key } + break + } + } + }) + return match + } + + getGroupChangedMessage(newOption) { + const currentOption = this.getOptionById(this.state.highlightedOptionId) + const isNewGroup = + !currentOption || currentOption.group !== newOption.group + let message = isNewGroup ? `Group ${newOption.group} entered. ` : '' + message += newOption.label + return message + } + + handleShowOptions = (event) => { + this.setState({ + isShowingOptions: true, + highlightedOptionId: null + }) + } + + handleHideOptions = (event) => { + const { selectedOptionId } = this.state + this.setState({ + isShowingOptions: false, + highlightedOptionId: null, + inputValue: this.getOptionById(selectedOptionId).label + }) + } + + handleBlur = (event) => { + this.setState({ + highlightedOptionId: null + }) + } + + handleHighlightOption = (event, { id }) => { + event.persist() + const newOption = this.getOptionById(id) + this.setState((state) => ({ + highlightedOptionId: id, + inputValue: + event.type === 'keydown' ? newOption.label : state.inputValue, + announcement: this.getGroupChangedMessage(newOption) + })) + } + + handleSelectOption = (event, { id }) => { + this.setState({ + selectedOptionId: id, + inputValue: this.getOptionById(id).label, + isShowingOptions: false, + announcement: `${this.getOptionById(id).label} selected.` + }) + } + + renderLabel(text, variant) { + return ( + + + {text} + ) - }) + } + + renderGroup() { + const { options } = this.props + const { highlightedOptionId, selectedOptionId } = this.state + + return Object.keys(options).map((key, index) => { + const badgeVariant = key === 'Eastern' ? 'success' : 'primary' + return ( + + {options[key].map((option) => ( + + {option.label} + + ))} + + ) + }) + } + + render() { + const { + inputValue, + isShowingOptions, + highlightedOptionId, + selectedOptionId, + filteredOptions, + announcement + } = this.state + + return ( +
+ + document.getElementById('flash-messages')} + liveRegionPoliteness="assertive" + screenReaderOnly + > + {announcement} + +
+ ) + } } - render () { - const { - inputValue, - isShowingOptions, - highlightedOptionId, - selectedOptionId, - filteredOptions, - announcement - } = this.state + render( + + + + ) + ``` + +- ```js + const GroupSelectExample = ({ options }) => { + const [inputValue, setInputValue] = useState(options['Western'][0].label) + const [isShowingOptions, setIsShowingOptions] = useState(false) + const [highlightedOptionId, setHighlightedOptionId] = useState(null) + const [selectedOptionId, setSelectedOptionId] = useState( + options['Western'][0].id + ) + const [announcement, setAnnouncement] = useState(null) + + const getOptionById = (id) => { + let match = null + Object.keys(options).forEach((key, index) => { + for (let i = 0; i < options[key].length; i++) { + const option = options[key][i] + if (id === option.id) { + // return group property with the object just to make it easier + // to check which group the option belongs to + match = { ...option, group: key } + break + } + } + }) + return match + } + + const getGroupChangedMessage = (newOption) => { + const currentOption = getOptionById(highlightedOptionId) + const isNewGroup = + !currentOption || currentOption.group !== newOption.group + let message = isNewGroup ? `Group ${newOption.group} entered. ` : '' + message += newOption.label + return message + } + + const handleShowOptions = (event) => { + setIsShowingOptions(true) + setHighlightedOptionId(null) + } + + const handleHideOptions = (event) => { + setIsShowingOptions(false) + setHighlightedOptionId(null) + setInputValue(getOptionById(selectedOptionId).label) + } + + const handleBlur = (event) => { + setHighlightedOptionId(null) + } + + const handleHighlightOption = (event, { id }) => { + event.persist() + const newOption = getOptionById(id) + setHighlightedOptionId(id) + setInputValue(event.type === 'keydown' ? newOption.label : inputValue) + setAnnouncement(getGroupChangedMessage(newOption)) + } + + const handleSelectOption = (event, { id }) => { + setSelectedOptionId(id) + setInputValue(getOptionById(id).label) + setIsShowingOptions(false) + setAnnouncement(`${getOptionById(id).label} selected.`) + } + + const renderLabel = (text, variant) => { + return ( + + + {text} + + ) + } + + const renderGroup = () => { + return Object.keys(options).map((key, index) => { + const badgeVariant = key === 'Eastern' ? 'success' : 'primary' + return ( + + {options[key].map((option) => ( + + {option.label} + + ))} + + ) + }) + } return (
@@ -779,196 +1455,342 @@ class GroupSelectExample extends React.Component { assistiveText="Type or use arrow keys to navigate options." inputValue={inputValue} isShowingOptions={isShowingOptions} - onBlur={this.handleBlur} - onRequestShowOptions={this.handleShowOptions} - onRequestHideOptions={this.handleHideOptions} - onRequestHighlightOption={this.handleHighlightOption} - onRequestSelectOption={this.handleSelectOption} + onBlur={handleBlur} + onRequestShowOptions={handleShowOptions} + onRequestHideOptions={handleHideOptions} + onRequestHighlightOption={handleHighlightOption} + onRequestSelectOption={handleSelectOption} renderBeforeInput={ } > - {this.renderGroup()} + {renderGroup()} document.getElementById('flash-messages')} liveRegionPoliteness="assertive" screenReaderOnly > - { announcement } + {announcement}
) } -} - -render( - - - -) -``` + + render( + + + + ) + ``` ##### Using groups with autocomplete on Safari Due to a WebKit bug if you are using `Select.Group` with autocomplete, the screenreader won't announce highlight/selection changes. This only seems to be an issue in Safari. Here is an example how you can work around that: -```javascript ---- -type: example ---- +- ```javascript + class GroupSelectAutocompleteExample extends React.Component { + state = { + inputValue: '', + isShowingOptions: false, + highlightedOptionId: null, + selectedOptionId: null, + filteredOptions: this.props.options, + announcement: null + } -class GroupSelectAutocompleteExample extends React.Component { - state = { - inputValue: '', - isShowingOptions: false, - highlightedOptionId: null, - selectedOptionId: null, - filteredOptions: this.props.options, - announcement: null - } + getOptionById(id) { + const options = this.props.options + return Object.values(options) + .flat() + .find((o) => o?.id === id) + } - getOptionById (id) { - const options = this.props.options - return Object.values(options) - .flat() - .find((o) => o?.id === id); - } + filterOptions(value, options) { + const filteredOptions = {} + Object.keys(options).forEach((key) => { + filteredOptions[key] = options[key]?.filter((option) => + option.label.toLowerCase().includes(value.toLowerCase()) + ) + }) + const optionsWithoutEmptyKeys = Object.keys(filteredOptions) + .filter((k) => filteredOptions[k].length > 0) + .reduce((a, k) => ({ ...a, [k]: filteredOptions[k] }), {}) + return optionsWithoutEmptyKeys + } - filterOptions (value, options) { - const filteredOptions = {}; - Object.keys(options).forEach((key) => { - filteredOptions[key] = options[key]?.filter( - (option) => - option.label.toLowerCase().includes(value.toLowerCase()) - ); - }); - const optionsWithoutEmptyKeys = Object.keys(filteredOptions) - .filter((k) => filteredOptions[k].length > 0) - .reduce((a, k) => ({ ...a, [k]: filteredOptions[k] }), {}); - return optionsWithoutEmptyKeys; - }; - - handleShowOptions = (event) => { - this.setState({ - isShowingOptions: true, - highlightedOptionId: null - }) - } + handleShowOptions = (event) => { + this.setState({ + isShowingOptions: true, + highlightedOptionId: null + }) + } - handleHideOptions = (event) => { - const { selectedOptionId } = this.state - this.setState({ - isShowingOptions: false, - highlightedOptionId: null - }) - } + handleHideOptions = (event) => { + const { selectedOptionId } = this.state + this.setState({ + isShowingOptions: false, + highlightedOptionId: null + }) + } - handleBlur = (event) => { - this.setState({ - highlightedOptionId: null - }) - } + handleBlur = (event) => { + this.setState({ + highlightedOptionId: null + }) + } - handleHighlightOption = (event, { id }) => { - event.persist() - const option = this.getOptionById(id) - setTimeout(() => { + handleHighlightOption = (event, { id }) => { + event.persist() + const option = this.getOptionById(id) + setTimeout(() => { + this.setState((state) => ({ + announcement: option.label + })) + }, 0) this.setState((state) => ({ + highlightedOptionId: id + })) + } + + handleSelectOption = (event, { id }) => { + const option = this.getOptionById(id) + if (!option) return // prevent selecting of empty option + this.setState({ + selectedOptionId: id, + inputValue: option.label, + isShowingOptions: false, + filteredOptions: this.props.options, announcement: option.label + }) + } + + handleInputChange = (event) => { + const value = event.target.value + const newOptions = this.filterOptions(value, this.props.options) + this.setState((state) => ({ + inputValue: value, + filteredOptions: newOptions, + highlightedOptionId: newOptions.length > 0 ? newOptions[0].id : null, + isShowingOptions: true, + selectedOptionId: value === '' ? null : state.selectedOptionId })) - }, 0) - this.setState((state) => ({ - highlightedOptionId: id, - })) - } + } - handleSelectOption = (event, { id }) => { - const option = this.getOptionById(id) - if (!option) return // prevent selecting of empty option - this.setState({ - selectedOptionId: id, - inputValue: option.label, - isShowingOptions: false, - filteredOptions: this.props.options, - announcement: option.label - }) - } + renderGroup() { + const filteredOptions = this.state.filteredOptions + const { highlightedOptionId, selectedOptionId } = this.state - handleInputChange = (event) => { - const value = event.target.value - const newOptions = this.filterOptions(value, this.props.options) - this.setState((state) => ({ - inputValue: value, - filteredOptions: newOptions, - highlightedOptionId: newOptions.length > 0 ? newOptions[0].id : null, - isShowingOptions: true, - selectedOptionId: value === '' ? null : state.selectedOptionId, - })) - } + return Object.keys(filteredOptions).map((key, index) => { + return ( + + {filteredOptions[key].map((option) => ( + + {option.label} + + ))} + + ) + }) + } - renderGroup () { - const filteredOptions = this.state.filteredOptions - const { highlightedOptionId, selectedOptionId } = this.state + renderScreenReaderHelper() { + const announcement = this.state.announcement + return ( + window.safari && ( + + + {announcement} + + + ) + ) + } + + render() { + const { + inputValue, + isShowingOptions, + highlightedOptionId, + selectedOptionId, + filteredOptions + } = this.state - return Object.keys(filteredOptions).map((key, index) => { return ( - - {filteredOptions[key].map((option) => ( - - { option.label } - - ))} - +
+ + {this.renderScreenReaderHelper()} +
) - }) + } } - renderScreenReaderHelper () { - const announcement = this.state.announcement - return window.safari && ( - - {announcement} - - ) - } + render( + + + + ) + ``` + +- ```js + const GroupSelectAutocompleteExample = ({ options }) => { + const [inputValue, setInputValue] = useState('') + const [isShowingOptions, setIsShowingOptions] = useState(false) + const [highlightedOptionId, setHighlightedOptionId] = useState(null) + const [selectedOptionId, setSelectedOptionId] = useState(null) + const [filteredOptions, setFilteredOptions] = useState(options) + const [announcement, setAnnouncement] = useState(null) + + const getOptionById = (id) => { + return Object.values(options) + .flat() + .find((o) => o?.id === id) + } - render () { - const { - inputValue, - isShowingOptions, - highlightedOptionId, - selectedOptionId, - filteredOptions - } = this.state + const filterOptions = (value, options) => { + const filteredOptions = {} + Object.keys(options).forEach((key) => { + filteredOptions[key] = options[key]?.filter((option) => + option.label.toLowerCase().includes(value.toLowerCase()) + ) + }) + const optionsWithoutEmptyKeys = Object.keys(filteredOptions) + .filter((k) => filteredOptions[k].length > 0) + .reduce((a, k) => ({ ...a, [k]: filteredOptions[k] }), {}) + return optionsWithoutEmptyKeys + } + + const handleShowOptions = (event) => { + setIsShowingOptions(true) + setHighlightedOptionId(null) + } + + const handleHideOptions = (event) => { + setIsShowingOptions(false) + setHighlightedOptionId(null) + } + + const handleBlur = (event) => { + setHighlightedOptionId(null) + } + + const handleHighlightOption = (event, { id }) => { + event.persist() + const option = getOptionById(id) + setTimeout(() => { + setAnnouncement(option.label) + }, 0) + setHighlightedOptionId(id) + } + + const handleSelectOption = (event, { id }) => { + const option = getOptionById(id) + if (!option) return // prevent selecting of empty option + setSelectedOptionId(id) + setInputValue(option.label) + setIsShowingOptions(false) + setFilteredOptions(options) + setAnnouncement(option.label) + } + + const handleInputChange = (event) => { + const value = event.target.value + const newOptions = filterOptions(value, options) + setInputValue(value) + setFilteredOptions(newOptions) + setHighlightedOptionId(newOptions.length > 0 ? newOptions[0].id : null) + setIsShowingOptions(true) + setSelectedOptionId(value === '' ? null : selectedOptionId) + } + + const renderGroup = () => { + return Object.keys(filteredOptions).map((key, index) => { + return ( + + {filteredOptions[key].map((option) => ( + + {option.label} + + ))} + + ) + }) + } + + const renderScreenReaderHelper = () => { + return ( + window.safari && ( + + + {announcement} + + + ) + ) + } return (
@@ -978,193 +1800,379 @@ class GroupSelectAutocompleteExample extends React.Component { assistiveText="Type or use arrow keys to navigate options." inputValue={inputValue} isShowingOptions={isShowingOptions} - onBlur={this.handleBlur} - onInputChange={this.handleInputChange} - onRequestShowOptions={this.handleShowOptions} - onRequestHideOptions={this.handleHideOptions} - onRequestHighlightOption={this.handleHighlightOption} - onRequestSelectOption={this.handleSelectOption} + onBlur={handleBlur} + onInputChange={handleInputChange} + onRequestShowOptions={handleShowOptions} + onRequestHideOptions={handleHideOptions} + onRequestHighlightOption={handleHighlightOption} + onRequestSelectOption={handleSelectOption} > - {this.renderGroup()} + {renderGroup()} - {this.renderScreenReaderHelper()} + {renderScreenReaderHelper()}
) } -} - -render( - - - -) -``` + + render( + + + + ) + ``` #### Asynchronous option loading If no results match the user's search, it's recommended to leave `isShowingOptions` as `true` and to display an "empty option" as a way of communicating that there are no matches. Similarly, it's helpful to display a [Spinner](#Spinner) in an empty option while options load. -```javascript ---- -type: example ---- - -class AsyncExample extends React.Component { - state = { - inputValue: '', - isShowingOptions: false, - isLoading: false, - highlightedOptionId: null, - selectedOptionId: null, - selectedOptionLabel: '', - filteredOptions: [], - announcement: null - } +- ```javascript + class AsyncExample extends React.Component { + state = { + inputValue: '', + isShowingOptions: false, + isLoading: false, + highlightedOptionId: null, + selectedOptionId: null, + selectedOptionLabel: '', + filteredOptions: [], + announcement: null + } - timeoutId = null + timeoutId = null - getOptionById (queryId) { - return this.state.filteredOptions.find(({ id }) => id === queryId) - } + getOptionById(queryId) { + return this.state.filteredOptions.find(({ id }) => id === queryId) + } - filterOptions = (value) => { - return this.props.options.filter(option => ( - option.label.toLowerCase().startsWith(value.toLowerCase()) - )) - } + filterOptions = (value) => { + return this.props.options.filter((option) => + option.label.toLowerCase().startsWith(value.toLowerCase()) + ) + } - matchValue () { - const { - filteredOptions, - inputValue, - selectedOptionId, - selectedOptionLabel - } = this.state - - // an option matching user input exists - if (filteredOptions.length === 1) { - const onlyOption = filteredOptions[0] - // automatically select the matching option - if (onlyOption.label.toLowerCase() === inputValue.toLowerCase()) { - return { - inputValue: onlyOption.label, - selectedOptionId: onlyOption.id + matchValue() { + const { + filteredOptions, + inputValue, + selectedOptionId, + selectedOptionLabel + } = this.state + + // an option matching user input exists + if (filteredOptions.length === 1) { + const onlyOption = filteredOptions[0] + // automatically select the matching option + if (onlyOption.label.toLowerCase() === inputValue.toLowerCase()) { + return { + inputValue: onlyOption.label, + selectedOptionId: onlyOption.id + } } } + // allow user to return to empty input and no selection + if (inputValue.length === 0) { + return { selectedOptionId: null, filteredOptions: [] } + } + // no match found, return selected option label to input + if (selectedOptionId) { + return { inputValue: selectedOptionLabel } + } } - // allow user to return to empty input and no selection - if (inputValue.length === 0) { - return { selectedOptionId: null, filteredOptions: [] } - } - // no match found, return selected option label to input - if (selectedOptionId) { - return { inputValue: selectedOptionLabel } - } - } - - handleShowOptions = (event) => { - this.setState(({ filteredOptions }) => ({ - isShowingOptions: true - })) - } - - handleHideOptions = (event) => { - const { selectedOptionId, inputValue } = this.state - this.setState({ - isShowingOptions: false, - highlightedOptionId: null, - announcement: 'List collapsed.', - ...this.matchValue() - }) - } - handleBlur = (event) => { - this.setState({ highlightedOptionId: null }) - } + handleShowOptions = (event) => { + this.setState(({ filteredOptions }) => ({ + isShowingOptions: true + })) + } - handleHighlightOption = (event, { id }) => { - event.persist() - const option = this.getOptionById(id) - if (!option) return // prevent highlighting of empty option - this.setState((state) => ({ - highlightedOptionId: id, - inputValue: event.type === 'keydown' ? option.label : state.inputValue, - announcement: option.label - })) - } + handleHideOptions = (event) => { + const { selectedOptionId, inputValue } = this.state + this.setState({ + isShowingOptions: false, + highlightedOptionId: null, + announcement: 'List collapsed.', + ...this.matchValue() + }) + } - handleSelectOption = (event, { id }) => { - const option = this.getOptionById(id) - if (!option) return // prevent selecting of empty option - this.setState({ - selectedOptionId: id, - selectedOptionLabel: option.label, - inputValue: option.label, - isShowingOptions: false, - announcement: `${option.label} selected. List collapsed.`, - filteredOptions: [this.getOptionById(id)] - }) - } + handleBlur = (event) => { + this.setState({ highlightedOptionId: null }) + } - handleInputChange = (event) => { - const value = event.target.value - clearTimeout(this.timeoutId) + handleHighlightOption = (event, { id }) => { + event.persist() + const option = this.getOptionById(id) + if (!option) return // prevent highlighting of empty option + this.setState((state) => ({ + highlightedOptionId: id, + inputValue: event.type === 'keydown' ? option.label : state.inputValue, + announcement: option.label + })) + } - if (!value || value === '') { + handleSelectOption = (event, { id }) => { + const option = this.getOptionById(id) + if (!option) return // prevent selecting of empty option this.setState({ - isLoading: false, - inputValue: value, - isShowingOptions: true, - selectedOptionId: null, - selectedOptionLabel: null, - filteredOptions: [], - }) - } else { - this.setState({ - isLoading: true, - inputValue: value, - isShowingOptions: true, - filteredOptions: [], - highlightedOptionId: null, - announcement: 'Loading options.' + selectedOptionId: id, + selectedOptionLabel: option.label, + inputValue: option.label, + isShowingOptions: false, + announcement: `${option.label} selected. List collapsed.`, + filteredOptions: [this.getOptionById(id)] }) + } - this.timeoutId = setTimeout(() => { - const newOptions = this.filterOptions(value) + handleInputChange = (event) => { + const value = event.target.value + clearTimeout(this.timeoutId) + + if (!value || value === '') { this.setState({ - filteredOptions: newOptions, isLoading: false, - announcement: `${newOptions.length} options available.` + inputValue: value, + isShowingOptions: true, + selectedOptionId: null, + selectedOptionLabel: null, + filteredOptions: [] }) - }, 1500) + } else { + this.setState({ + isLoading: true, + inputValue: value, + isShowingOptions: true, + filteredOptions: [], + highlightedOptionId: null, + announcement: 'Loading options.' + }) + + this.timeoutId = setTimeout(() => { + const newOptions = this.filterOptions(value) + this.setState({ + filteredOptions: newOptions, + isLoading: false, + announcement: `${newOptions.length} options available.` + }) + }, 1500) + } + } + + render() { + const { + inputValue, + isShowingOptions, + isLoading, + highlightedOptionId, + selectedOptionId, + filteredOptions, + announcement + } = this.state + + return ( +
+ + document.getElementById('flash-messages')} + liveRegionPoliteness="assertive" + screenReaderOnly + > + {announcement} + +
+ ) } } - render () { - const { - inputValue, - isShowingOptions, - isLoading, - highlightedOptionId, - selectedOptionId, - filteredOptions, - announcement - } = this.state + render( + + + + ) + ``` + +- ```js + const AsyncExample = ({ options }) => { + const [inputValue, setInputValue] = useState('') + const [isShowingOptions, setIsShowingOptions] = useState(false) + const [isLoading, setIsLoading] = useState(false) + const [highlightedOptionId, setHighlightedOptionId] = useState(null) + const [selectedOptionId, setSelectedOptionId] = useState(null) + const [selectedOptionLabel, setSelectedOptionLabel] = useState('') + const [filteredOptions, setFilteredOptions] = useState([]) + const [announcement, setAnnouncement] = useState(null) + + let timeoutId = null + + const getOptionById = (queryId) => { + return filteredOptions.find(({ id }) => id === queryId) + } + + const filterOptions = (value) => { + return options.filter((option) => + option.label.toLowerCase().startsWith(value.toLowerCase()) + ) + } + + const matchValue = () => { + // an option matching user input exists + if (filteredOptions.length === 1) { + const onlyOption = filteredOptions[0] + // automatically select the matching option + if (onlyOption.label.toLowerCase() === inputValue.toLowerCase()) { + setInputValue(onlyOption.label) + setSelectedOptionId(onlyOption.id) + return + } + } + // allow user to return to empty input and no selection + if (inputValue.length === 0) { + setSelectedOptionId(null) + setFilteredOptions([]) + return + } + // no match found, return selected option label to input + if (selectedOptionId) { + setInputValue(selectedOptionLabel) + return + } + } + + const handleShowOptions = (event) => { + setIsShowingOptions(true) + } + + const handleHideOptions = (event) => { + setIsShowingOptions(false) + setHighlightedOptionId(null) + setAnnouncement('List collapsed.') + matchValue() + } + + const handleBlur = (event) => { + setHighlightedOptionId(null) + } + + const handleHighlightOption = (event, { id }) => { + event.persist() + const option = getOptionById(id) + if (!option) return // prevent highlighting of empty option + + setHighlightedOptionId(id) + setInputValue(event.type === 'keydown' ? option.label : inputValue) + setAnnouncement(option.label) + } + + const handleSelectOption = (event, { id }) => { + const option = getOptionById(id) + if (!option) return // prevent selecting of empty option + setSelectedOptionId(id) + setSelectedOptionLabel(option.label) + setInputValue(option.label) + setIsShowingOptions(false) + setAnnouncement(`${option.label} selected. List collapsed.`) + setFilteredOptions([getOptionById(id)]) + } + + const handleInputChange = (event) => { + const value = event.target.value + clearTimeout(timeoutId) + + if (!value || value === '') { + setIsLoading(false) + setInputValue(value) + setIsShowingOptions(true) + setSelectedOptionId(null) + setSelectedOptionLabel(null) + setFilteredOptions([]) + } else { + setIsLoading(true) + setInputValue(value) + setIsShowingOptions(true) + setFilteredOptions([]) + setHighlightedOptionId(null) + setAnnouncement('Loading options.') + + timeoutId = setTimeout(() => { + const newOptions = filterOptions(value) + setFilteredOptions(newOptions) + setIsLoading(false) + setAnnouncement(`${newOptions.length} options available.`) + }, 1500) + } + } return (
@@ -1173,31 +2181,39 @@ class AsyncExample extends React.Component { assistiveText="Type to search" inputValue={inputValue} isShowingOptions={isShowingOptions} - onBlur={this.handleBlur} - onInputChange={this.handleInputChange} - onRequestShowOptions={this.handleShowOptions} - onRequestHideOptions={this.handleHideOptions} - onRequestHighlightOption={this.handleHighlightOption} - onRequestSelectOption={this.handleSelectOption} + onBlur={handleBlur} + onInputChange={handleInputChange} + onRequestShowOptions={handleShowOptions} + onRequestHideOptions={handleHideOptions} + onRequestHighlightOption={handleHighlightOption} + onRequestSelectOption={handleSelectOption} > - {filteredOptions.length > 0 ? filteredOptions.map((option) => { - return ( - - {option.label} - - ) - }) : ( + {filteredOptions.length > 0 ? ( + filteredOptions.map((option) => { + return ( + + {option.label} + + ) + }) + ) : ( - {isLoading - ? - : inputValue !== '' ? 'No results' : 'Type to search'} + {isLoading ? ( + + ) : inputValue !== '' ? ( + 'No results' + ) : ( + 'Type to search' + )} )} @@ -1206,112 +2222,228 @@ class AsyncExample extends React.Component { liveRegionPoliteness="assertive" screenReaderOnly > - { announcement } + {announcement}
) } -} - -render( - - - -) -``` + + render( + + + + ) + ``` ### Icons To display icons (or other elements) before or after an option, pass it via the `renderBeforeLabel` and `renderAfterLabel` prop to `Select.Option`. You can pass a function as well, which will have a `props` parameter, so you can access the properties of that `Select.Option` (e.g. if it is currently `isHighlighted`). The available props are: `[ id, isDisabled, isSelected, isHighlighted, children ]`. -```javascript ---- -type: example ---- -class SingleSelectExample extends React.Component { - state = { - inputValue: this.props.options[0].label, - isShowingOptions: false, - highlightedOptionId: null, - selectedOptionId: this.props.options[0].id, - announcement: null - } +- ```js + class SingleSelectExample extends React.Component { + state = { + inputValue: this.props.options[0].label, + isShowingOptions: false, + highlightedOptionId: null, + selectedOptionId: this.props.options[0].id, + announcement: null + } - getOptionById (queryId) { - return this.props.options.find(({ id }) => id === queryId) - } + getOptionById(queryId) { + return this.props.options.find(({ id }) => id === queryId) + } - handleShowOptions = (event) => { - this.setState({ - isShowingOptions: true - }) - } + handleShowOptions = (event) => { + this.setState({ + isShowingOptions: true + }) + } - handleHideOptions = (event) => { - const { selectedOptionId } = this.state - const option = this.getOptionById(selectedOptionId).label - this.setState({ - isShowingOptions: false, - highlightedOptionId: null, - inputValue: selectedOptionId ? option : '', - announcement: 'List collapsed.' - }) - } + handleHideOptions = (event) => { + const { selectedOptionId } = this.state + const option = this.getOptionById(selectedOptionId).label + this.setState({ + isShowingOptions: false, + highlightedOptionId: null, + inputValue: selectedOptionId ? option : '', + announcement: 'List collapsed.' + }) + } - handleBlur = (event) => { - this.setState({ - highlightedOptionId: null - }) - } + handleBlur = (event) => { + this.setState({ + highlightedOptionId: null + }) + } - handleHighlightOption = (event, { id }) => { - event.persist() - const optionsAvailable = `${this.props.options.length} options available.` - const nowOpen = !this.state.isShowingOptions ? `List expanded. ${optionsAvailable}` : '' - const option = this.getOptionById(id).label - this.setState((state) => ({ - highlightedOptionId: id, - inputValue: event.type === 'keydown' ? option : state.inputValue, - announcement: `${option} ${nowOpen}` - })) - } + handleHighlightOption = (event, { id }) => { + event.persist() + const optionsAvailable = `${this.props.options.length} options available.` + const nowOpen = !this.state.isShowingOptions + ? `List expanded. ${optionsAvailable}` + : '' + const option = this.getOptionById(id).label + this.setState((state) => ({ + highlightedOptionId: id, + inputValue: event.type === 'keydown' ? option : state.inputValue, + announcement: `${option} ${nowOpen}` + })) + } - handleSelectOption = (event, { id }) => { - const option = this.getOptionById(id).label - this.setState({ - selectedOptionId: id, - inputValue: option, - isShowingOptions: false, - announcement: `"${option}" selected. List collapsed.` - }) + handleSelectOption = (event, { id }) => { + const option = this.getOptionById(id).label + this.setState({ + selectedOptionId: id, + inputValue: option, + isShowingOptions: false, + announcement: `"${option}" selected. List collapsed.` + }) + } + + render() { + const { + inputValue, + isShowingOptions, + highlightedOptionId, + selectedOptionId, + announcement + } = this.state + + return ( +
+ + document.getElementById('flash-messages')} + liveRegionPoliteness="assertive" + screenReaderOnly + > + {announcement} + +
+ ) + } } - render () { - const { - inputValue, - isShowingOptions, - highlightedOptionId, - selectedOptionId, - announcement - } = this.state + render( + + + }, + { + id: 'opt3', + label: 'Colored Icon', + renderBeforeLabel: (props) => { + let color = 'brand' + if (props.isHighlighted) color = 'primary-inverse' + if (props.isSelected) color = 'primary' + if (props.isDisabled) color = 'warning' + return + } + } + ]} + /> + + ) + ``` + +- ```js + const SingleSelectExample = ({ options }) => { + const [inputValue, setInputValue] = useState(options[0].label) + const [isShowingOptions, setIsShowingOptions] = useState(false) + const [highlightedOptionId, setHighlightedOptionId] = useState(null) + const [selectedOptionId, setSelectedOptionId] = useState(options[0].id) + const [announcement, setAnnouncement] = useState(null) + + const getOptionById = (queryId) => { + return options.find(({ id }) => id === queryId) + } + + const handleShowOptions = (event) => { + setIsShowingOptions(true) + } + + const handleHideOptions = (event) => { + const option = getOptionById(selectedOptionId).label + setIsShowingOptions(false) + setHighlightedOptionId(null) + setInputValue(selectedOptionId ? option : '') + setAnnouncement('List collapsed.') + } + + const handleBlur = (event) => { + setHighlightedOptionId(null) + } + + const handleHighlightOption = (event, { id }) => { + event.persist() + const optionsAvailable = `${options.length} options available.` + const nowOpen = !isShowingOptions + ? `List expanded. ${optionsAvailable}` + : '' + const option = getOptionById(id).label + setHighlightedOptionId(id) + setInputValue(event.type === 'keydown' ? option : inputValue) + setAnnouncement(`${option} ${nowOpen}`) + } + + const handleSelectOption = (event, { id }) => { + const option = getOptionById(id).label + setSelectedOptionId(id) + setInputValue(option) + setIsShowingOptions(false) + setAnnouncement(`"${option}" selected. List collapsed.`) + } return (
@@ -1320,13 +2452,13 @@ class SingleSelectExample extends React.Component { assistiveText="Use arrow keys to navigate options." inputValue={inputValue} isShowingOptions={isShowingOptions} - onBlur={this.handleBlur} - onRequestShowOptions={this.handleShowOptions} - onRequestHideOptions={this.handleHideOptions} - onRequestHighlightOption={this.handleHighlightOption} - onRequestSelectOption={this.handleSelectOption} + onBlur={handleBlur} + onRequestShowOptions={handleShowOptions} + onRequestHideOptions={handleHideOptions} + onRequestHighlightOption={handleHighlightOption} + onRequestSelectOption={handleSelectOption} > - {this.props.options.map((option) => { + {options.map((option) => { return ( - { option.label } + {option.label} ) })} @@ -1345,43 +2477,42 @@ class SingleSelectExample extends React.Component { liveRegionPoliteness="assertive" screenReaderOnly > - { announcement } + {announcement}
) } -} -render( - - - }, - { - id: 'opt3', - label: 'Colored Icon', - renderBeforeLabel: (props) => { - let color = 'brand' - if (props.isHighlighted) color = 'primary-inverse' - if (props.isSelected) color = 'primary' - if (props.isDisabled) color = 'warning' - return + render( + + + }, + { + id: 'opt3', + label: 'Colored Icon', + renderBeforeLabel: (props) => { + let color = 'brand' + if (props.isHighlighted) color = 'primary-inverse' + if (props.isSelected) color = 'primary' + if (props.isDisabled) color = 'warning' + return + } } - } - ]} - /> - -) -``` + ]} + /> + + ) + ``` #### Providing assistive text for screen readers diff --git a/packages/ui-selectable/src/Selectable/README.md b/packages/ui-selectable/src/Selectable/README.md index 9c7b5632d2..a4a3be2e49 100644 --- a/packages/ui-selectable/src/Selectable/README.md +++ b/packages/ui-selectable/src/Selectable/README.md @@ -4,153 +4,384 @@ describes: Selectable `Selectable` is a low level utility component that can be used to create combobox widgets. Before composing your own component, make sure an existing component, like [Select](#Select), can't be adapted for your use case. -```javascript ---- -type: example ---- - -class CustomSelect extends React.Component { - state = { - isShowingOptions: false, - highlightedOptionId: this.props.options[0].id, - selectedOptionId: this.props.options[0].id, - inputValue: this.props.options[0].label, - filteredOptions: this.props.options - } +- ```javascript + class CustomSelect extends React.Component { + state = { + isShowingOptions: false, + highlightedOptionId: this.props.options[0].id, + selectedOptionId: this.props.options[0].id, + inputValue: this.props.options[0].label, + filteredOptions: this.props.options + } - filterOptions = (value) => { - return this.props.options.filter(option => ( - option.label.toLowerCase().startsWith(value.toLowerCase()) - )) - } + filterOptions = (value) => { + return this.props.options.filter((option) => + option.label.toLowerCase().startsWith(value.toLowerCase()) + ) + } - matchValue () { - const { filteredOptions, inputValue, selectedOptionId } = this.state - if (filteredOptions.length === 1) { - if (filteredOptions[0].label.toLowerCase() === inputValue.toLowerCase()) { - return { - inputValue: filteredOptions[0].label, - selectedOptionId: filteredOptions[0].id + matchValue() { + const { filteredOptions, inputValue, selectedOptionId } = this.state + if (filteredOptions.length === 1) { + if ( + filteredOptions[0].label.toLowerCase() === inputValue.toLowerCase() + ) { + return { + inputValue: filteredOptions[0].label, + selectedOptionId: filteredOptions[0].id + } } } + const index = this.getOptionIndex( + null, + selectedOptionId, + this.props.options + ) + return { inputValue: this.props.options[index].label } } - const index = this.getOptionIndex(null, selectedOptionId, this.props.options) - return { inputValue: this.props.options[index].label } - } - getInputStyles () { - return { - display: 'block', - width: '250px', - padding: '5px' + getInputStyles() { + return { + display: 'block', + width: '250px', + padding: '5px' + } } - } - getListStyles () { - const { isShowingOptions } = this.state - return { - background: 'white', - listStyle: 'none', - padding: 0, - margin: 0, - border: isShowingOptions && 'solid 1px lightgray' + getListStyles() { + const { isShowingOptions } = this.state + return { + background: 'white', + listStyle: 'none', + padding: 0, + margin: 0, + border: isShowingOptions && 'solid 1px lightgray' + } } - } - getOptionStyles (option) { - const { selectedOptionId, highlightedOptionId } = this.state - const selected = selectedOptionId === option.id - const highlighted = highlightedOptionId === option.id - let background = 'transparent' - if (selected) { - background = 'lightgray' - } else if (highlighted) { - background = '#eeeeee' - } - return { - background, - padding: '0 10px' + getOptionStyles(option) { + const { selectedOptionId, highlightedOptionId } = this.state + const selected = selectedOptionId === option.id + const highlighted = highlightedOptionId === option.id + let background = 'transparent' + if (selected) { + background = 'lightgray' + } else if (highlighted) { + background = '#eeeeee' + } + return { + background, + padding: '0 10px' + } } - } - getOptionIndex (direction, id, from) { - const { filteredOptions, highlightedOptionId } = this.state - const options = from ? from : filteredOptions - let index - - for (let i = 0; i <= options.length - 1; i++) { - if (typeof id === 'undefined') { - if (highlightedOptionId === options[i].id) { - index = i + direction - if (index < 0) { - index = 0 - } else if (index >= options.length - 1) { - index = options.length - 1 + getOptionIndex(direction, id, from) { + const { filteredOptions, highlightedOptionId } = this.state + const options = from ? from : filteredOptions + let index + + for (let i = 0; i <= options.length - 1; i++) { + if (typeof id === 'undefined') { + if (highlightedOptionId === options[i].id) { + index = i + direction + if (index < 0) { + index = 0 + } else if (index >= options.length - 1) { + index = options.length - 1 + } + break + } + } else { + if (id === options[i].id) { + index = i + break } - break - } - } else { - if (id === options[i].id) { - index = i - break } } + return index + } + + getHandlers() { + return this.props.isDisabled + ? {} + : { + onRequestShowOptions: (e) => + this.setState((state) => ({ + isShowingOptions: true, + highlightedOptionId: state.filteredOptions[0].id + })), + onRequestHideOptions: (e) => { + const index = this.getOptionIndex( + null, + this.state.selectedOptionId, + this.props.options + ) + this.setState((state) => ({ + isShowingOptions: false, + inputValue: this.props.options[index].label, + filteredOptions: this.props.options, + highlightedOptionId: null + })) + }, + onRequestHighlightOption: (e, { id, direction }) => { + let index = this.getOptionIndex(direction, id) + this.setState((state) => ({ + highlightedOptionId: state.filteredOptions[index] + ? state.filteredOptions[index].id + : null, + inputValue: + direction && state.filteredOptions[index] + ? state.filteredOptions[index].label + : state.inputValue + })) + }, + onRequestSelectOption: (e, { id }) => { + const index = this.getOptionIndex(null, id) + this.setState((state) => ({ + selectedOptionId: id, + inputValue: state.filteredOptions[index].label, + filteredOptions: this.props.options, + isShowingOptions: false, + highlightedOptionId: null + })) + } + } + } + + render() { + const { + isShowingOptions, + inputValue, + highlightedOptionId, + selectedOptionId, + filteredOptions + } = this.state + + return ( + + {({ + getRootProps, + getLabelProps, + getInputProps, + getTriggerProps, + getListProps, + getOptionProps + }) => ( + (this.rootRef = el) })} + > + + { + const newOptions = this.filterOptions(e.target.value) + this.setState({ + inputValue: e.target.value, + filteredOptions: newOptions, + isShowingOptions: true, + highlightedOptionId: newOptions[0] + ? newOptions[0].id + : null + }) + }, + onBlur: (e) => + this.setState({ + filteredOptions: this.props.options, + highlightedOptionId: null, + isShowingOptions: false, + ...this.matchValue() + }) + })} + /> +
    + {isShowingOptions && + filteredOptions.map((option) => ( +
  • + {option.label} +
  • + ))} +
+
+ )} +
+ ) } - return index } - getHandlers () { - return this.props.isDisabled ? {} : { - onRequestShowOptions: (e) => this.setState(state => ({ - isShowingOptions: true, - highlightedOptionId: state.filteredOptions[0].id - })), - onRequestHideOptions: (e) => { - const index = this.getOptionIndex(null, this.state.selectedOptionId, this.props.options) - this.setState(state => ({ - isShowingOptions: false, - inputValue: this.props.options[index].label, - filteredOptions: this.props.options, - highlightedOptionId: null, - })) - }, - onRequestHighlightOption: (e, { id, direction }) => { - let index = this.getOptionIndex(direction, id) - this.setState(state => ({ - highlightedOptionId: state.filteredOptions[index] ? state.filteredOptions[index].id : null, - inputValue: direction && state.filteredOptions[index] - ? state.filteredOptions[index].label - : state.inputValue - })) - }, - onRequestSelectOption: (e, { id }) => { - const index = this.getOptionIndex(null, id) - this.setState(state => ({ - selectedOptionId: id, - inputValue: state.filteredOptions[index].label, - filteredOptions: this.props.options, - isShowingOptions: false, - highlightedOptionId: null, - })) + render( + + + + ) + ``` + +- ```js + const CustomSelect = ({ options, isDisabled }) => { + const [isShowingOptions, setIsShowingOptions] = useState(false) + const [highlightedOptionId, setHighlightedOptionId] = useState( + options[0].id + ) + const [selectedOptionId, setSelectedOptionId] = useState(options[0].id) + const [inputValue, setInputValue] = useState(options[0].label) + const [filteredOptions, setFilteredOptions] = useState(options) + + const rootRef = useRef(null) + + const filterOptions = (value) => { + return options.filter((option) => + option.label.toLowerCase().startsWith(value.toLowerCase()) + ) + } + + const matchValue = () => { + if (filteredOptions.length === 1) { + if ( + filteredOptions[0].label.toLowerCase() === inputValue.toLowerCase() + ) { + setInputValue(filteredOptions[0].label) + setsSelectedOptionId(filteredOptions[0].id) + return + } } + const index = getOptionIndex(null, selectedOptionId, options) + setInputValue(options[index].label) + } + + const getInputStyles = () => { + return { + display: 'block', + width: '250px', + padding: '5px' + } + } + + const getListStyles = () => { + return { + background: 'white', + listStyle: 'none', + padding: 0, + margin: 0, + border: isShowingOptions && 'solid 1px lightgray' + } + } + + const getOptionStyles = (option) => { + const selected = selectedOptionId === option.id + const highlighted = highlightedOptionId === option.id + let background = 'transparent' + if (selected) { + background = 'lightgray' + } else if (highlighted) { + background = '#eeeeee' + } + return { + background, + padding: '0 10px' + } + } + + const getOptionIndex = (direction, id, from) => { + const options = from ? from : filteredOptions + let index + + for (let i = 0; i <= options.length - 1; i++) { + if (typeof id === 'undefined') { + if (highlightedOptionId === options[i].id) { + index = i + direction + if (index < 0) { + index = 0 + } else if (index >= options.length - 1) { + index = options.length - 1 + } + break + } + } else { + if (id === options[i].id) { + index = i + break + } + } + } + return index } - } - render () { - const { - isShowingOptions, - inputValue, - highlightedOptionId, - selectedOptionId, - filteredOptions - } = this.state + const getHandlers = () => { + return isDisabled + ? {} + : { + onRequestShowOptions: (e) => { + setIsShowingOptions(true) + setHighlightedOptionId(filteredOptions[0].id) + }, + onRequestHideOptions: (e) => { + const index = getOptionIndex(null, selectedOptionId, options) + setIsShowingOptions(false) + setInputValue(options[index].label) + setFilteredOptions(options) + setHighlightedOptionId(null) + }, + onRequestHighlightOption: (e, { id, direction }) => { + let index = getOptionIndex(direction, id) + setHighlightedOptionId( + filteredOptions[index] ? filteredOptions[index].id : null + ) + setInputValue( + direction && filteredOptions[index] + ? filteredOptions[index].label + : inputValue + ) + }, + onRequestSelectOption: (e, { id }) => { + const index = getOptionIndex(null, id) + setSelectedOptionId(id) + setInputValue(filteredOptions[index].label) + setFilteredOptions(options) + setIsShowingOptions(false) + setHighlightedOptionId(null) + } + } + } return ( {({ getRootProps, @@ -161,80 +392,77 @@ class CustomSelect extends React.Component { getOptionProps }) => ( this.rootRef = el})} + style={{ display: 'inline-block' }} + {...getRootProps({ ref: rootRef })} > { - const newOptions = this.filterOptions(e.target.value) - this.setState({ - inputValue: e.target.value, - filteredOptions: newOptions, - isShowingOptions: true, - highlightedOptionId: newOptions[0] ? newOptions[0].id : null - }) + const newOptions = filterOptions(e.target.value) + setInputValue(e.target.valu) + setFilteredOptions(newOptions) + setIsShowingOptions(true) + setHighlightedOptionId( + newOptions[0] ? newOptions[0].id : null + ) }, - onBlur: (e) => this.setState({ - filteredOptions: this.props.options, - highlightedOptionId: null, - isShowingOptions: false, - ...this.matchValue() - }) - }) - } /> -
    - {isShowingOptions && filteredOptions.map((option) => ( -
  • - {option.label} -
  • - ))} + onBlur: (e) => { + setFilteredOptions(options) + setHighlightedOptionId(null) + setIsShowingOptions(false) + matchValue() + } + })} + /> +
      + {isShowingOptions && + filteredOptions.map((option) => ( +
    • + {option.label} +
    • + ))}
    )} ) } -} - -render( - - - -) -``` + + render( + + + + ) + ``` Selectable has very few opinions about how a combobox component should be composed. It mostly aims to ensure all the proper WAI-ARIA roles and attributes are set on the right elements at the right times. Selectable uses a combination of controllable props and prop getters to set these attributes and provide accessible behavior.