From 5989517ecc59007cfc1544284bcfe0c8b40cba5d Mon Sep 17 00:00:00 2001 From: John Coburn Date: Wed, 26 Jun 2024 10:10:13 -0500 Subject: [PATCH] STCOM-1091 Upgrade Downshift dependency. (#2283) * upgrade downshift dependency v2 -> v9 XD * refactor Selection to use downshift. Implement option group feature - STCOM-1278 * assign proper aria-attributes to list items, properly render when no list items are present * add hook to use provided ref or use an internal ref * fill out props for Selection migration * remove only from selection tests * refactor AutoSugggest. Convert to functional component. * refactor MultiSelection component to use Downshift hooks. * clean up tests * apply aria-labelledby to filter field * factor out OptionListWrapper component * lint selection components * whitespace * fix option click removal in MultiSelection * get Selection in line with conventions in MultiSelection * lint AutoSuggest * actually return something from getClass in AutoSuggest. * clean up sonardad's 'intentionality' suggestions * fix tests for multiSelection - use preventKeyAction in getDropdownProps * render proper empy list message in Selection * remove toggle from custom control click handler * test multiselection onChange handler * minor lint in OptionSegment, multiselection * add tests for Selection option groups, indented style... * add onChange tests for AutoSuggest * remove only, you goof * document/test option groups for Selection * add hook for generating an id or accepting a provided id * ensure same value is returned in getHookExecutionResult * fix incorrect aria-labelledby attribute in SelectionList, ensure elements have correct label associations under various labeling scenarios * Multiselection - fix actions feature * Selection - fix error message about SelectedItem becoming controlled * remove unnecessary variable in SelectionOverlay * fix problem with changing the value for AutoSuggest * absorb changes from STCOM-1299 into new MultiSelect * apply isDisabled to items, disabled to filter field * MultiSelection - clicking the control should open the menu, fix propType on Selection for 'warning' * skip legacy popover test * remove renderLoading prop from MultiSelect Option List * log changes --- CHANGELOG.md | 2 + hooks/tests/useProvidedIdOrCreate-test.js | 25 + hooks/useProvidedIdOrCreate/index.js | 1 + .../useProvidedIdOrCreate.js | 8 + hooks/useProvidedRefOrCreate/index.js | 1 + .../useProvidedRefOrCreate.js | 12 + lib/AutoSuggest/AutoSuggest.css | 17 + lib/AutoSuggest/AutoSuggest.js | 341 +++--- lib/AutoSuggest/stories/BasicUsage.js | 24 +- lib/AutoSuggest/tests/AutoSuggest-test.js | 49 +- lib/MultiSelection/MultiDownshift.js | 164 --- lib/MultiSelection/MultiSelectFilterField.js | 131 +- lib/MultiSelection/MultiSelectOptionsList.js | 305 ++--- .../MultiSelectResponsiveRenderer.js | 20 +- lib/MultiSelection/MultiSelectValueInput.js | 40 - lib/MultiSelection/MultiSelection.js | 1089 +++++++++-------- lib/MultiSelection/OptionsListWrapper.js | 54 - lib/MultiSelection/SelectOption.js | 8 +- lib/MultiSelection/SelectedValuesList.js | 141 +-- lib/MultiSelection/ValueChip.js | 54 +- .../tests/MultiSelection-test.js | 735 ++++++----- .../tests/MultiSelectionHarness.js | 31 - lib/Popover/tests/Popover-test.js | 2 +- lib/Selection/DefaultOptionFormatter.js | 8 +- lib/Selection/OptionSegment.js | 35 +- lib/Selection/SelectList.js | 311 ----- lib/Selection/Selection.css | 16 + lib/Selection/Selection.js | 362 +++++- lib/Selection/SelectionList.js | 29 + lib/Selection/SelectionOverlay.js | 113 ++ lib/Selection/SingleSelect.js | 797 ------------ lib/Selection/readme.md | 20 +- lib/Selection/stories/BasicUsage.js | 3 +- lib/Selection/stories/GroupedOptions.js | 92 ++ lib/Selection/stories/Selection.stories.js | 2 + lib/Selection/tests/Selection-test.js | 120 +- lib/Selection/utils.js | 92 +- lib/Timepicker/tests/Timepicker-test.js | 2 +- package.json | 2 +- tests/helpers/getHookExecutionResult.js | 11 +- 40 files changed, 2275 insertions(+), 2994 deletions(-) create mode 100644 hooks/tests/useProvidedIdOrCreate-test.js create mode 100644 hooks/useProvidedIdOrCreate/index.js create mode 100644 hooks/useProvidedIdOrCreate/useProvidedIdOrCreate.js create mode 100644 hooks/useProvidedRefOrCreate/index.js create mode 100644 hooks/useProvidedRefOrCreate/useProvidedRefOrCreate.js delete mode 100644 lib/MultiSelection/MultiDownshift.js delete mode 100644 lib/MultiSelection/MultiSelectValueInput.js delete mode 100644 lib/MultiSelection/OptionsListWrapper.js delete mode 100644 lib/MultiSelection/tests/MultiSelectionHarness.js delete mode 100644 lib/Selection/SelectList.js create mode 100644 lib/Selection/SelectionList.js create mode 100644 lib/Selection/SelectionOverlay.js delete mode 100644 lib/Selection/SingleSelect.js create mode 100644 lib/Selection/stories/GroupedOptions.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 9601c638a..31d0cec90 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,8 @@ * Add `dndProvided` prop to the `` to render a placeholder for a draggable row. Refs STCOM-1297. * Adjust styles for links within default ``. Refs STCOM-1276. * Support Optimistic Locking in Tags - allow disable and show loading indicator in MultiSelect. Refs STCOM-1299. +* Update `downshift` dependency. Refactor ``, ``, `` to functional components. Refs STCOM-1091. +* Implement option grouping feature in ``. Refs STCOM-1278. ## [12.1.0](https://github.com/folio-org/stripes-components/tree/v12.1.0) (2024-03-12) [Full Changelog](https://github.com/folio-org/stripes-components/compare/v12.0.0...v12.1.0) diff --git a/hooks/tests/useProvidedIdOrCreate-test.js b/hooks/tests/useProvidedIdOrCreate-test.js new file mode 100644 index 000000000..590b8802f --- /dev/null +++ b/hooks/tests/useProvidedIdOrCreate-test.js @@ -0,0 +1,25 @@ +import { + describe, + it, +} from 'mocha'; +import { expect } from 'chai'; + +import getHookExecutionResult from '../../tests/helpers/getHookExecutionResult'; +import useProvidedIdOrCreate from '../useProvidedIdOrCreate'; + +describe('useProvidedIdOrCreate', () => { + it('should return the provided id', async () => { + const res = await getHookExecutionResult(useProvidedIdOrCreate, 'testId'); + expect(res).to.equal('testId'); + }); + + it('with no id parameter provided, it should return a generated id', async () => { + const res = await getHookExecutionResult(useProvidedIdOrCreate); + expect(res).to.not.equal('testId'); + }); + + it('supplies generated id with prefix', async () => { + const res = await getHookExecutionResult(useProvidedIdOrCreate, [undefined, 'testPrefix-']); + expect(res).to.match(new RegExp('^testPrefix-')); + }); +}); diff --git a/hooks/useProvidedIdOrCreate/index.js b/hooks/useProvidedIdOrCreate/index.js new file mode 100644 index 000000000..30eb46572 --- /dev/null +++ b/hooks/useProvidedIdOrCreate/index.js @@ -0,0 +1 @@ +export { default } from './useProvidedIdOrCreate'; diff --git a/hooks/useProvidedIdOrCreate/useProvidedIdOrCreate.js b/hooks/useProvidedIdOrCreate/useProvidedIdOrCreate.js new file mode 100644 index 000000000..d48aceaf1 --- /dev/null +++ b/hooks/useProvidedIdOrCreate/useProvidedIdOrCreate.js @@ -0,0 +1,8 @@ +import { useId, useRef } from 'react'; + +const useProvidedIdOrCreate = (id, prefix = '') => { + const autoId = `${prefix}${useId()}`; + return useRef(id || autoId).current; +}; + +export default useProvidedIdOrCreate; diff --git a/hooks/useProvidedRefOrCreate/index.js b/hooks/useProvidedRefOrCreate/index.js new file mode 100644 index 000000000..78baef90d --- /dev/null +++ b/hooks/useProvidedRefOrCreate/index.js @@ -0,0 +1 @@ +export { default } from './useProvidedRefOrCreate'; diff --git a/hooks/useProvidedRefOrCreate/useProvidedRefOrCreate.js b/hooks/useProvidedRefOrCreate/useProvidedRefOrCreate.js new file mode 100644 index 000000000..98439e923 --- /dev/null +++ b/hooks/useProvidedRefOrCreate/useProvidedRefOrCreate.js @@ -0,0 +1,12 @@ +import { useRef } from "react"; + +/** useProvidedRefOrCreate + * There are some situations where we only want to create a new ref if one is not provided to a component as a prop. + * @param providedRef The ref to use - if undefined, will use the ref from a call to React.useRef + */ +export default function useProvidedRefOrCreate(providedRef) { + const internalRef = useRef(null); + return providedRef ? + providedRef : + internalRef; +} diff --git a/lib/AutoSuggest/AutoSuggest.css b/lib/AutoSuggest/AutoSuggest.css index 6e3b3d35e..8fcbf80d2 100644 --- a/lib/AutoSuggest/AutoSuggest.css +++ b/lib/AutoSuggest/AutoSuggest.css @@ -26,3 +26,20 @@ display: 'inline-block'; position: 'relative'; } + +.autoSuggestItem { + padding: 1rem; + cursor: default; + display: flex; + justify-content: space-between; + background-color: white; + font-weight: normal; + + &.cursor { + background-color: var(--color-fill-hover); + } + + &.selected { + font-weight: bold; + } +} diff --git a/lib/AutoSuggest/AutoSuggest.js b/lib/AutoSuggest/AutoSuggest.js index b62922085..5d464512a 100644 --- a/lib/AutoSuggest/AutoSuggest.js +++ b/lib/AutoSuggest/AutoSuggest.js @@ -1,217 +1,182 @@ -import React from 'react'; -import Downshift from 'downshift'; +import React, { useRef, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; -import uniqueId from 'lodash/uniqueId'; - +import { useCombobox } from 'downshift'; +import classNames from 'classnames'; +import noop from 'lodash/noop'; +import isEqual from 'lodash/isEqual'; +import Label from '../Label'; import Popper from '../Popper'; import TextField from '../TextField'; import formField from '../FormField'; import parseMeta from '../FormField/parseMeta'; +import useProvidedIdOrCreate from '../../hooks/useProvidedIdOrCreate'; import css from './AutoSuggest.css'; -import Label from '../Label'; -const defaultProps = { - includeItem: (item, searchString) => item.value.includes(searchString), - onChange: () => { }, - onFocus: () => { }, - onSelect: () => { }, - renderOption: item => (item ? item.value : ''), - renderValue: item => (item ? item.value : ''), - valueKey: 'value', - validationEnabled: true, -}; +const getInputWidth = (container) => container?.offsetWidth; + +const getClass = ({ + item, + index, + selectedItem, + highlightedIndex +}) => classNames([ + `${css.autoSuggestItem}`, + { [css.cursor]: highlightedIndex === index }, + { [css.selected]: isEqual(selectedItem, item) } +]); + +const defaultIncludeItem = (item, searchString) => item.value.includes(searchString); +const defaultRender = item => (item ? item.value : ''); + +const getObjectFromValue = (value, valueKey, items) => { + return items.filter((o) => o[valueKey] === value)[0] +} -const propTypes = { +const AutoSuggest = ({ + error, + id, + includeItem = defaultIncludeItem, + items, + label, + name, + onBlur, + onChange = noop, + onFocus = noop, + onSelect = noop, + placeholder, + popper, + renderOption = defaultRender, + renderValue = defaultRender, + required, + validationEnabled = true, + value, + valueKey = 'value', +}) => { + const container = useRef(null); + const textfield = useRef(null); + const testId = useProvidedIdOrCreate(id, 'autoSuggest-'); + const [filterValue, setFilterValue] = useState(value); + + const filteredItems = useMemo(() => items + .filter(item => !filterValue || includeItem(item, filterValue)), + [filterValue, items] + ); + + const { + getInputProps, + getItemProps, + getLabelProps, + getMenuProps, + selectedItem, + highlightedIndex, + isOpen, + } = useCombobox({ + id: testId, + items: filteredItems, + itemToString: renderValue, + onChange: selectedValue => onChange(selectedValue[valueKey]), + onInputValueChange: ({ inputValue: newValue }) => { onChange(newValue); setFilterValue(newValue); }, + selectedItem: getObjectFromValue(value, valueKey, items), + inputValue: filterValue, + onSelect, + initialInputValue: filterValue, + }); + + const control = ( +
+ { label && ( + + )} + +
+ ); + + const list = ( +
    + {isOpen + ? filteredItems.map((item, index) => ( +
  • + {renderOption(item)} +
  • + )) + : null} +
+ ); + + return ( +
+ {control} + + {list} + +
+ ); +} + +AutoSuggest.propTypes = { error: PropTypes.node, id: PropTypes.string, includeItem: PropTypes.func, - items: PropTypes.arrayOf(PropTypes.object).isRequired, + items: PropTypes.arrayOf(PropTypes.object), label: PropTypes.node, name: PropTypes.string, onBlur: PropTypes.func, onChange: PropTypes.func, onFocus: PropTypes.func, onSelect: PropTypes.func, - placeholder: PropTypes.string, + placeholder: PropTypes.node, popper: PropTypes.object, renderOption: PropTypes.func, renderValue: PropTypes.func, required: PropTypes.bool, - screenReaderMessage: PropTypes.string, validationEnabled: PropTypes.bool, value: PropTypes.string, valueKey: PropTypes.string, }; -class AutoSuggest extends React.Component { - static propTypes = propTypes; - static defaultProps = defaultProps; - - constructor(props) { - super(props); - this.container = React.createRef(); - this.textfield = React.createRef(); - this.testId = this.props.id || uniqueId('autoSuggest-'); - } - - getInputWidth = () => { // eslint-disable-line consistent-return - if (this.container.current) { - return this.container.current.offsetWidth; - } - } - - renderControl = (downshiftAPI) => { - const { - error, - includeItem, - items, - label, - name, - onBlur, - onFocus, - placeholder, - popper, - renderOption, - required, - validationEnabled, - valueKey, - } = this.props; - - const { - getInputProps, - getItemProps, - getLabelProps, - getMenuProps, - selectedItem, - highlightedIndex, - isOpen, - inputValue, - } = downshiftAPI; - - const testId = this.testId; - - const inputProps = { - autoComplete: 'off', - required, - name, - validationEnabled, - inputRef: this.textfield, - id: testId, - error, - }; - if (placeholder) { - inputProps.placeholder = placeholder; - } - - const labelProps = getLabelProps({ - htmlFor: testId - }); - - const control = ( -
- { label && ( - - )} - -
- ); - - const list = ( -
    - {isOpen - ? items - .filter(item => !inputValue || includeItem(item, inputValue)) - .map((item, index) => ( -
  • - {renderOption(item)} -
  • - )) - : null} -
- ); - - return ( - <> - {control} - - {list} - - - ); - } - - render() { - const { - onChange, - onSelect, - renderValue, - value, - valueKey, - } = this.props; - - return ( - onChange(selectedItem[valueKey])} - onInputValueChange={(inputValue) => onChange(inputValue)} - selectedItem={value} - inputValue={value} - onSelect={onSelect} - > - {({ ...downshiftAPI }) => ( - // eslint-disable-next-line jsx-a11y/aria-proptypes -
- {this.renderControl({ - ...downshiftAPI - })} -
- )} -
- ); - } -} - export default formField( AutoSuggest, ({ meta }) => ({ diff --git a/lib/AutoSuggest/stories/BasicUsage.js b/lib/AutoSuggest/stories/BasicUsage.js index a44c94a1b..48f9f3530 100644 --- a/lib/AutoSuggest/stories/BasicUsage.js +++ b/lib/AutoSuggest/stories/BasicUsage.js @@ -2,7 +2,7 @@ * AutoSuggest: Basic Usage */ -import React from 'react'; +import React, { useState } from 'react'; import AutoSuggest from '../AutoSuggest'; const items = [ @@ -28,11 +28,17 @@ const items = [ }, ]; -export default () => ( - item?.label} - renderValue={item => item?.label} - label="Enter type" - /> -); +export default () => { + const [value, setValue] = useState('eBook'); + + return ( + item?.label} + renderValue={item => item?.label} + label="Enter type" + value={value} + onChange={(v) => { setValue(v); console.log(v)}} + /> + ) +}; diff --git a/lib/AutoSuggest/tests/AutoSuggest-test.js b/lib/AutoSuggest/tests/AutoSuggest-test.js index 4f06d7ff6..774a14e8e 100644 --- a/lib/AutoSuggest/tests/AutoSuggest-test.js +++ b/lib/AutoSuggest/tests/AutoSuggest-test.js @@ -1,7 +1,7 @@ import React from 'react'; import { describe, beforeEach, it } from 'mocha'; -import { AutoSuggest as Interactor, runAxeTest } from '@folio/stripes-testing'; - +import { AutoSuggest as Interactor, converge, runAxeTest } from '@folio/stripes-testing'; +import sinon from 'sinon'; import { mountWithContext } from '../../../tests/helpers'; import AutoSuggest from '../AutoSuggest'; @@ -30,11 +30,16 @@ const testItems = [ describe('AutoSuggest', () => { const autosuggest = Interactor('autoSuggestTest'); - + const onChange = sinon.spy(); describe('rendering', () => { beforeEach(async () => { + await onChange.resetHistory(); await mountWithContext( - + ); }); @@ -53,6 +58,10 @@ describe('AutoSuggest', () => { it('has no axe errors - open list', runAxeTest); + it('calls the onChange handler with selected value', async () => { + await converge(() => { if (!onChange.calledWith('b')) throw new Error('Expected onChange handler to be called with value "b"') }); + }); + describe('selecting an option', () => { beforeEach(async () => { await autosuggest.select('book'); @@ -65,7 +74,39 @@ describe('AutoSuggest', () => { it('sets the value of the input to the selected option', async () => { await autosuggest.has({ value: 'book' }); }); + + it('calls the onChange handler with selected value', async () => { + await converge(() => { if (!onChange.calledWith('book')) throw new Error('Expected onChange handler to be called with value "book"') }); + }); }); }); }); + + describe('rendering with pre-existing value', () => { + beforeEach(async () => { + await mountWithContext( + + ); + }); + + it('presents value', async () => { + await autosuggest.has({ value: 'audiobook' }); + }); + + describe('selecting a different value', () => { + beforeEach(async () => { + await autosuggest.fillIn(''); + await autosuggest.select('ebook'); + }); + + + it('presents value', async () => { + await autosuggest.has({ value: 'ebook' }); + }); + }); + }) }); diff --git a/lib/MultiSelection/MultiDownshift.js b/lib/MultiSelection/MultiDownshift.js deleted file mode 100644 index 772c9d4a9..000000000 --- a/lib/MultiSelection/MultiDownshift.js +++ /dev/null @@ -1,164 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import findIndex from 'lodash/findIndex'; -import isEqual from 'lodash/isEqual'; -import Downshift from 'downshift'; - -class MultiDownshift extends React.Component { - static propTypes = { - actionHelpers: PropTypes.func, - children: PropTypes.func, - downshiftRef: PropTypes.object, - onAdd: PropTypes.func, - onChange: PropTypes.func, - onRemove: PropTypes.func, - onSelect: PropTypes.func, - onStateChange: PropTypes.func, - value: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.arrayOf(PropTypes.string), - PropTypes.arrayOf(PropTypes.object), - ]), - }; - - stateReducer(state, changes) { - switch (changes.type) { - case Downshift.stateChangeTypes.blurInput: - if (!changes.selectedItem) { - return { - isOpen: false - }; - } - return {}; - case Downshift.stateChangeTypes.changeInput: - return { - ...changes, - highlightedIndex: 0, - }; - case Downshift.stateChangeTypes.keyDownEnter: - case Downshift.stateChangeTypes.clickItem: - if (Object.prototype.hasOwnProperty.call(changes.selectedItem, 'onSelect')) { - const params = { - ...state, - ...this.actionHelpers(), - filterText: state.inputValue - }; - changes.selectedItem.onSelect(params); - return { - inputValue: '', - isOpen: false - }; - } - return { - ...changes, - highlightedIndex: state.highlightedIndex, - isOpen: true - }; - default: - return changes; - } - } - - changeCallback = (newValue, downshift) => { - if (this.props.onSelect) { - this.props.onSelect( - newValue, - this.getStateAndHelpers(downshift) - ); - } - if (this.props.onChange) { - this.props.onChange( - newValue, - this.getStateAndHelpers(downshift) - ); - } - }; - - handleSelection = (selectedItem, downshift) => { - const { value, onRemove, onAdd } = this.props; - if (findIndex(value, (item) => isEqual(item, selectedItem)) !== -1) { - this.removeItem(selectedItem, (newValue) => { - this.changeCallback(newValue, downshift); - if (onRemove) { - onRemove(selectedItem, downshift); - } - }); - } else { - this.addSelectedItem(selectedItem, - (newValue) => { - this.changeCallback(newValue, downshift); - if (onAdd) { - onAdd(selectedItem, downshift); - } - }); - } - }; - - removeItem = (item, cb = () => {}) => { - const newValue = this.props.value.filter(i => !isEqual(i, item)); - cb(newValue); - }; - - addSelectedItem(item, cb = () => {}) { - const newValue = [...this.props.value, item]; - cb(newValue); - } - - getSelectedItems = () => this.props.value; - - getRemoveButtonProps = ({ - onClick, - item, - index, - ...props - } = {}) => { - return { - onClick: e => { - // TODO: use something like downshift's composeEventHandlers utility instead - if (onClick) { - onClick(e); - } - e.stopPropagation(); - this.handleSelection(item, { removeButtonIndex: index }); - }, - ...props - }; - }; - - getStateAndHelpers(downshift) { - const selectedItems = this.props.value; - const { getRemoveButtonProps, removeItem } = this; - const { actionHelpers } = this.props; - const stateAndHelpers = { - getRemoveButtonProps, - removeItem, - selectedItems, - filterValue: downshift.inputValue, - removeButtonUsed: -1, - internalChangeCallback: (newValue) => { this.changeCallback(newValue, downshift); }, - ...actionHelpers(), - ...downshift - }; - return stateAndHelpers; - } - - render() { - const { children, ...props } = this.props; - // TODO: compose together props (rather than overwriting them) like downshift does - return ( - - {downshift => children(this.getStateAndHelpers(downshift))} - - ); - } -} - -export default MultiDownshift; diff --git a/lib/MultiSelection/MultiSelectFilterField.js b/lib/MultiSelection/MultiSelectFilterField.js index ccc576f17..6e8573f53 100644 --- a/lib/MultiSelection/MultiSelectFilterField.js +++ b/lib/MultiSelection/MultiSelectFilterField.js @@ -1,82 +1,67 @@ import React from 'react'; import PropTypes from 'prop-types'; +import noop from 'lodash/noop'; import css from './MultiSelect.css'; -class MultiSelectFilterField extends React.Component { - static propTypes = { - ariaLabelledBy: PropTypes.string, - atSmallMedia: PropTypes.bool, - backspaceDeletes: PropTypes.bool, - disabled: PropTypes.bool, - getInputProps: PropTypes.func, - inputRef: PropTypes.object, - internalChangeCallback: PropTypes.func, - onBlur: PropTypes.func, - onFocus: PropTypes.func, - onRemove: PropTypes.func, - optionsLength: PropTypes.number, - placeholder: PropTypes.string, - removeItem: PropTypes.func, - selectedItems: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.arrayOf(PropTypes.string), - PropTypes.arrayOf(PropTypes.object) - ]), - setHighlightedIndex: PropTypes.func, - } - - handleInputKeyDown = (event) => { - const { - atSmallMedia, - internalChangeCallback, - removeItem, - onRemove, - optionsLength, - selectedItems, - } = this.props; - - if (event.keyCode === 8 && event.target.value.length === 0) { // backspace - if (this.props.backspaceDeletes && !atSmallMedia) { - removeItem(selectedItems[selectedItems.length - 1], internalChangeCallback); - onRemove(selectedItems[selectedItems.length - 1]); - } - } - if (event.keyCode === 36) { // Home - this.props.setHighlightedIndex(0); - } - if (event.keyCode === 35) { // End - this.props.setHighlightedIndex(optionsLength - 1); - } - } - - render() { - const { - ariaLabelledBy, - getInputProps, - onFocus, - onBlur, - placeholder, - inputRef, - disabled, - ...rest - } = this.props; - - return ( - { + return ( + { + setFilterFocus(true); + onFocusProp(e); + }, + onBlur: (e) => { + setFilterFocus(false); + onBlurProp(e); + }, placeholder, - 'className': css.multiSelectFilterField, + className: css.multiSelectFilterField, disabled, - })} - /> - ); - } + onClick: (e) => e.stopPropagation(), + onChange: (e) => setFilterValue(e.target.value), + preventKeyAction: true, + id, + }) + )} + /> + ); } +MultiSelectFilterField.propTypes = { + ariaLabelledBy: PropTypes.string, + disabled: PropTypes.bool, + filterValue: PropTypes.string, + getDropdownProps: PropTypes.func, + getInputProps: PropTypes.func, + id: PropTypes.string, + inputRef: PropTypes.object, + menuId: PropTypes.string, + onBlur: PropTypes.func, + onFocus: PropTypes.func, + placeholder: PropTypes.string, + setFilterFocus: PropTypes.func, + setFilterValue: PropTypes.func, +}; + export default MultiSelectFilterField; diff --git a/lib/MultiSelection/MultiSelectOptionsList.js b/lib/MultiSelection/MultiSelectOptionsList.js index 5dd53ce21..3a7bad0ed 100644 --- a/lib/MultiSelection/MultiSelectOptionsList.js +++ b/lib/MultiSelection/MultiSelectOptionsList.js @@ -1,212 +1,119 @@ import React from 'react'; import PropTypes from 'prop-types'; -import findIndex from 'lodash/findIndex'; -import isEqual from 'lodash/isEqual'; -import Icon from '../Icon'; -import SelectOption from './SelectOption'; -import MultiSelectFilterField from './MultiSelectFilterField'; import css from './MultiSelect.css'; +import Popper from '../Popper'; +import Icon from '../Icon'; -import OptionsListWrapper from './OptionsListWrapper'; - -class MultiSelectOptionsList extends React.Component { - static propTypes = { - actions: PropTypes.arrayOf(PropTypes.shape({ - onSelect: PropTypes.func.isRequired, - })), - ariaLabelledBy: PropTypes.string, - asyncFiltering: PropTypes.bool, - atSmallMedia: PropTypes.bool, - containerWidth: PropTypes.number, - controlRef: PropTypes.object, - downshiftActions: PropTypes.object, - emptyMessage: PropTypes.node, - error: PropTypes.node, - exactMatch: PropTypes.bool, - formatter: PropTypes.func, - getInputProps: PropTypes.func, - getItemProps: PropTypes.func, - getMenuProps: PropTypes.func, - highlightedIndex: PropTypes.number, - id: PropTypes.string, - inputKeyDown: PropTypes.func, - inputRef: PropTypes.object, - inputValue: PropTypes.string, - internalChangeCallback: PropTypes.func, - isOpen: PropTypes.bool, - itemToString: PropTypes.func, - maxHeight: PropTypes.number, - modifiers: PropTypes.object, - placeholder: PropTypes.string, - removeItem: PropTypes.func, - renderedItems: PropTypes.oneOfType([ - PropTypes.arrayOf(PropTypes.string), - PropTypes.arrayOf(PropTypes.object) - ]), - renderToOverlay: PropTypes.bool, - selectedItems: PropTypes.oneOfType([ - PropTypes.string, - PropTypes.arrayOf(PropTypes.string), - PropTypes.arrayOf(PropTypes.object) - ]), - setHighlightedIndex: PropTypes.func, - useLegacy: PropTypes.bool, - warning: PropTypes.node, - } - - componentDidUpdate(prevProps) { - if (this.props.atSmallMedia) { - if (prevProps.isOpen !== this.props.isOpen) { - if (this.props.isOpen) { - if (this.props.inputRef && this.props.inputRef.current) { - this.props.inputRef.current.focus(); - } - } else if (this.props.controlRef && this.props.controlRef.current) { - this.props.controlRef.current.focus(); - } - } - } - } - - getMenuStyle = () => { - const style = {}; - if (!this.props.atSmallMedia) { - style.width = `${this.props.containerWidth}px`; - } else { - style.width = '100%'; - } - return style; - } - - getListStyle = () => { - const style = {}; - if (this.props.atSmallMedia) { - style.maxHeight = '60vh'; - } else { - style.maxHeight = `${this.props.maxHeight}px`; - } - return style; - } - - getLoadingIcon = () => { - const { - asyncFiltering, - renderedItems - } = this.props; - - if (asyncFiltering && !renderedItems) { - return ; - } - return null; - } +const getMenuStyle = (atSmallMedia, containerWidth) => { + return atSmallMedia ? + { width: `${containerWidth}px` } : + { width: '100%' } +} - render() { - const { - actions, - ariaLabelledBy, - atSmallMedia, - controlRef, - internalChangeCallback, - exactMatch, - getInputProps, - inputRef, - isOpen, - itemToString, - getMenuProps, - getItemProps, - selectedItems, - highlightedIndex, - inputValue, - emptyMessage, - modifiers, - placeholder, - renderedItems, - error, - warning, - formatter, - downshiftActions, - useLegacy, - renderToOverlay, - disabled, - } = this.props; +const getListStyle = (atSmallMedia, maxHeight) => { + return atSmallMedia ? + { maxHeight: '60vh' } : + { maxHeight: `${maxHeight}px` } +}; - const filterProps = { - ariaLabelledBy, - atSmallMedia, - selectedItems, - internalChangeCallback, - backspaceDeletes: false, - getInputProps, - inputRef, - placeholder, - }; +const getPortal = (renderToOverlay) => { + return renderToOverlay ? document.getElementById('OverlayContainer') : undefined; +} +const MultiSelectOptionsList = ({ + asyncFiltering, + atSmallMedia, + containerWidth, + controlRef, + emptyMessage, + error, + getMenuProps, + id, + isOpen, + labelId, + maxHeight, + modifiers, + renderActions, + renderedItems, + renderFilterInput, + renderOptions, + renderToOverlay, + warning, +}) => { + const control = ( + <> + {atSmallMedia && renderFilterInput()} +
+ {error &&
{error}
} + {warning &&
{warning}
} + {renderedItems && renderedItems?.length === 0 && +
{emptyMessage}
+ } + {asyncFiltering && !renderedItems && ()} +
+
    + {renderOptions()} + {renderActions()} +
+ + ); + if (atSmallMedia) { return ( - - {atSmallMedia && - - } -
- {error &&
{error}
} - {warning &&
{warning}
} - {renderedItems && renderedItems.length === 0 && -
{emptyMessage}
- } -
-
    - { this.getLoadingIcon() } - { renderedItems && renderedItems.length > 0 && - renderedItems.map((item, index) => ( - isEqual(o, item)) !== -1, - })} - > - {formatter({ option: item, searchTerm: inputValue })} - - ))} - {renderedItems && actions && actions.length > 0 && actions.map((item, index) => { - const actionIndex = renderedItems.length + index; - - return ( - - { - item.render({ - filterValue: inputValue, - exactMatch, - renderedItems, - }) - } - - ); - }) - } -
-
- ); + { control} + + ) } -} + return ( + + {control} + + ); +}; + + +MultiSelectOptionsList.propTypes = { + asyncFiltering: PropTypes.bool, + atSmallMedia: PropTypes.bool, + containerWidth: PropTypes.number, + controlRef: PropTypes.object, + emptyMessage: PropTypes.node, + error: PropTypes.node, + getMenuProps: PropTypes.func, + id: PropTypes.string, + isOpen: PropTypes.bool, + labelId: PropTypes.string, + maxHeight: PropTypes.number, + modifiers: PropTypes.object, + renderActions: PropTypes.func, + renderedItems: PropTypes.oneOfType([ + PropTypes.arrayOf(PropTypes.string), + PropTypes.arrayOf(PropTypes.object) + ]), + renderFilterInput: PropTypes.func, + renderOptions: PropTypes.func, + renderToOverlay: PropTypes.bool, + useLegacy: PropTypes.bool, + warning: PropTypes.node, +}; export default MultiSelectOptionsList; diff --git a/lib/MultiSelection/MultiSelectResponsiveRenderer.js b/lib/MultiSelection/MultiSelectResponsiveRenderer.js index 40e06279a..fa734d2b0 100644 --- a/lib/MultiSelection/MultiSelectResponsiveRenderer.js +++ b/lib/MultiSelection/MultiSelectResponsiveRenderer.js @@ -1,19 +1,26 @@ import React from 'react'; import PropTypes from 'prop-types'; import ReactDOM from 'react-dom'; -import MultiSelectOptionsList from './MultiSelectOptionsList'; import css from './MultiSelect.css'; -const MultiSelectResponsiveRenderer = (props) => { +/** + * MultiSelectResponsiveRenderer - + * Small screens - renders children to a portal to the div#OverlayContainer along with + * a darkened backdrop. + * Larger-than-small - renders standard Multiselect dropdown. + * @param {*} param0 + * @returns + */ +const MultiSelectResponsiveRenderer = ({ atSmallMedia, children, isOpen }) => { const elem = document.getElementById('OverlayContainer'); - if (props.atSmallMedia && elem) { + if (atSmallMedia && elem) { return ( ReactDOM.createPortal( ( -