diff --git a/lib/AutoSuggest/AutoSuggest.js b/lib/AutoSuggest/AutoSuggest.js index 5d464512a..52ed5e5ab 100644 --- a/lib/AutoSuggest/AutoSuggest.js +++ b/lib/AutoSuggest/AutoSuggest.js @@ -1,11 +1,12 @@ import React, { useRef, useMemo, useState } from 'react'; import PropTypes from 'prop-types'; +import { deprecated } from 'prop-types-extra'; 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 Popper, { OVERLAY_MODIFIERS } from '../Popper'; import TextField from '../TextField'; import formField from '../FormField'; import parseMeta from '../FormField/parseMeta'; @@ -48,6 +49,7 @@ const AutoSuggest = ({ renderOption = defaultRender, renderValue = defaultRender, required, + usePortal, validationEnabled = true, value, valueKey = 'value', @@ -56,11 +58,11 @@ const AutoSuggest = ({ const textfield = useRef(null); const testId = useProvidedIdOrCreate(id, 'autoSuggest-'); const [filterValue, setFilterValue] = useState(value); + const getPortalElement = useRef(() => document.getElementById('OverlayContainer')).current; const filteredItems = useMemo(() => items .filter(item => !filterValue || includeItem(item, filterValue)), - [filterValue, items] - ); + [filterValue, items]); const { getInputProps, @@ -82,6 +84,12 @@ const AutoSuggest = ({ initialInputValue: filterValue, }); + const popperProps = { + portal: (isOpen && usePortal) ? getPortalElement() : undefined, + modifiers: OVERLAY_MODIFIERS, + ...popper + }; + const control = (
{isOpen - ? filteredItems.map((item, index) => ( -
  • - {renderOption(item)} -
  • - )) + ? filteredItems.map((item, index) => ( +
  • + {renderOption(item)} +
  • + )) : null} ); @@ -149,7 +157,7 @@ const AutoSuggest = ({ return (
    {control} - + {list}
    @@ -168,10 +176,13 @@ AutoSuggest.propTypes = { onFocus: PropTypes.func, onSelect: PropTypes.func, placeholder: PropTypes.node, - popper: PropTypes.object, + popper: PropTypes.shape({ + portal: deprecated(PropTypes.element, 'use the boolean prop "usePortal" instead'), + }), renderOption: PropTypes.func, renderValue: PropTypes.func, required: PropTypes.bool, + usePortal: PropTypes.bool, validationEnabled: PropTypes.bool, value: PropTypes.string, valueKey: PropTypes.string, diff --git a/lib/AutoSuggest/readme.md b/lib/AutoSuggest/readme.md index 3cf08cd95..0e937249e 100644 --- a/lib/AutoSuggest/readme.md +++ b/lib/AutoSuggest/readme.md @@ -17,6 +17,9 @@ prop | description | default | required `onChange` | Callback called when the value changes | | `renderOption` | Callback that renders the item in the dropdown | `item => item.value` | `renderValue` | Callback that render the item in the input field | `item => item.value` | +`usePortal` | bool | If `true`, suggestion list will render to the `div[#OverlayContainer]` element in the FOLIO UI. | | `valueKey` | The key in the item object to use as the value. | `"value"` `withFinalForm` | toggle form time: true - final-form, false - redux-from (default) | | `popper` | object | Used to adjust placement of options list overlay via underlying Popper component. [See `` props](../Popper/readme.md) | | + + diff --git a/lib/AutoSuggest/tests/AutoSuggest-test.js b/lib/AutoSuggest/tests/AutoSuggest-test.js index 774a14e8e..149d186d6 100644 --- a/lib/AutoSuggest/tests/AutoSuggest-test.js +++ b/lib/AutoSuggest/tests/AutoSuggest-test.js @@ -1,7 +1,8 @@ import React from 'react'; import { describe, beforeEach, it } from 'mocha'; -import { AutoSuggest as Interactor, converge, runAxeTest } from '@folio/stripes-testing'; import sinon from 'sinon'; +import { AutoSuggest as Interactor, converge, runAxeTest, HTML } from '@folio/stripes-testing'; +import { RoledHTML } from '../../../tests/helpers/localInteractors'; import { mountWithContext } from '../../../tests/helpers'; import AutoSuggest from '../AutoSuggest'; @@ -109,4 +110,23 @@ describe('AutoSuggest', () => { }); }); }) + + describe('usePortal prop', () => { + beforeEach(async () => { + await mountWithContext( + <> +
    + + + ); + await autosuggest.enterFilter('b'); + }); + + it('renders menu to overlay element', () => HTML({ id: 'OverlayContainer' }).find(RoledHTML({ tagName:'UL' })).exists()) + }); }); diff --git a/lib/MultiSelection/MultiSelectOptionsList.js b/lib/MultiSelection/MultiSelectOptionsList.js index 5e86acfec..cc6da097c 100644 --- a/lib/MultiSelection/MultiSelectOptionsList.js +++ b/lib/MultiSelection/MultiSelectOptionsList.js @@ -16,8 +16,8 @@ const getListStyle = (atSmallMedia, maxHeight) => { { maxHeight: `${maxHeight}px` } }; -const getPortal = (renderToOverlay) => { - return renderToOverlay ? document.getElementById('OverlayContainer') : undefined; +const getPortal = (renderToOverlay, usePortal) => { + return (renderToOverlay || usePortal) ? document.getElementById('OverlayContainer') : undefined; } const MultiSelectOptionsList = ({ @@ -38,6 +38,7 @@ const MultiSelectOptionsList = ({ renderFilterInput, renderOptions, renderToOverlay, + usePortal, warning, }) => { const control = ( @@ -81,7 +82,7 @@ const MultiSelectOptionsList = ({ }} modifiers={modifiers} isOpen={isOpen} - portal={getPortal(renderToOverlay)} + portal={getPortal(renderToOverlay, usePortal)} placement="bottom-start" hideIfClosed > @@ -113,6 +114,7 @@ MultiSelectOptionsList.propTypes = { renderOptions: PropTypes.func, renderToOverlay: PropTypes.bool, useLegacy: PropTypes.bool, + usePortal: PropTypes.bool, warning: PropTypes.node, }; diff --git a/lib/MultiSelection/MultiSelection.js b/lib/MultiSelection/MultiSelection.js index 34c318aac..f19a65c0d 100644 --- a/lib/MultiSelection/MultiSelection.js +++ b/lib/MultiSelection/MultiSelection.js @@ -1,14 +1,15 @@ import React, { useRef, useEffect, useState, useMemo, useCallback } from 'react'; import PropTypes from 'prop-types'; +import { deprecated } from 'prop-types-extra'; import classnames from 'classnames'; import { isEqual, noop, debounce, } from 'lodash'; - import { useIntl } from 'react-intl'; import { useMultipleSelection, useCombobox } from 'downshift'; +import { OVERLAY_MODIFIERS } from '../Popper'; import SelectedValuesList from './SelectedValuesList'; import MultiSelectFilterField from './MultiSelectFilterField'; import MultiSelectResponsiveRenderer from './MultiSelectResponsiveRenderer'; @@ -88,10 +89,7 @@ const MultiSelection = ({ label: labelProp, maxHeight = 168, marginBottom0, - modifiers = { - flip: { boundariesElement: 'viewport', padding: 5 }, - preventOverflow: { boundariesElement: 'viewport', padding: 5 }, - }, + modifiers = OVERLAY_MODIFIERS, noBorder, onAdd = noop, onBlur = noop, @@ -102,6 +100,7 @@ const MultiSelection = ({ renderToOverlay = false, required = false, showLoading, + usePortal, validationEnabled, validStylesEnabled = false, value, @@ -663,6 +662,7 @@ const MultiSelection = ({ warning={warning} atSmallMedia={atSmallMedia} modifiers={modifiers} + usePortal={usePortal} />
    @@ -712,9 +712,10 @@ const propTypes = { onFocus: PropTypes.func, onRemove: PropTypes.func, placeholder: PropTypes.string, - renderToOverlay: PropTypes.bool, + renderToOverlay: deprecated(PropTypes.bool, 'use usePortal prop instead'), required: PropTypes.bool, showLoading: PropTypes.bool, + usePortal: PropTypes.bool, validationEnabled: PropTypes.bool, validStylesEnabled: PropTypes.bool, value: PropTypes.oneOfType([ diff --git a/lib/MultiSelection/readme.md b/lib/MultiSelection/readme.md index f058b83e2..0bc484799 100644 --- a/lib/MultiSelection/readme.md +++ b/lib/MultiSelection/readme.md @@ -41,8 +41,8 @@ Name | type | description | default | required `onChange` | func | Change event handler for when internal state changes. `selectedItems` is passed as parameter to function. | | `onRemove` | func | Event handler specifically called when an item is removed from the selection. The removed item is passed to the handler. | | `placeholder` | string | Rendered as a placeholder for the control when no value is present. | | -`renderToOverlay` | bool | For use in situations where the dropdown may be cut off due to a containing dom element's `overflow: hidden/auto` css attribute. | false | `showLoading` | bool | Should render loading indicator on the field | | +`usePortal` | bool | If `true`, option list will render to the `div[#OverlayContainer]` element in the FOLIO UI. | | `value` | array | Array of selected objects. | | `valueFormatter` | func | Render function that accepts an object with keys for the option. The function is called to display values in the selected values list. If the prop is missing, `formatter` will be used instead. | | `ariaLabelledBy` | string | Used for applying an accessible label if no `label` prop is provided | | diff --git a/lib/MultiSelection/tests/MultiSelection-test.js b/lib/MultiSelection/tests/MultiSelection-test.js index 407c1cfa2..6021799b2 100644 --- a/lib/MultiSelection/tests/MultiSelection-test.js +++ b/lib/MultiSelection/tests/MultiSelection-test.js @@ -873,4 +873,20 @@ describe('MultiSelect', () => { }); }); }); + + describe('usePortal prop', () => { + beforeEach(async () => { + await mountWithContext( + + ); + await multiselection.open(); + }); + + it('renders options list to overlay element', () => HTML({ id: 'OverlayContainer' }).find(MenuInteractor()).exists()); + }) }); diff --git a/lib/MultiSelection/tests/MultiSelectionHarness.js b/lib/MultiSelection/tests/MultiSelectionHarness.js index 58ebbed29..597fcee79 100644 --- a/lib/MultiSelection/tests/MultiSelectionHarness.js +++ b/lib/MultiSelection/tests/MultiSelectionHarness.js @@ -14,6 +14,7 @@ const MultiSelectionHarness = ({ return ( <> +
    ; diff --git a/lib/Popper/Popper.js b/lib/Popper/Popper.js index fcea7b500..1a87c88df 100644 --- a/lib/Popper/Popper.js +++ b/lib/Popper/Popper.js @@ -4,6 +4,11 @@ import PropTypes from 'prop-types'; import PopperJS from 'popper.js'; import css from './Popper.css'; +export const OVERLAY_MODIFIERS = { + flip: { boundariesElement: 'viewport', padding: 5 }, + preventOverflow: { boundariesElement: 'viewport', padding: 5 }, +} + export const AVAILABLE_PLACEMENTS = [ 'bottom', 'top', diff --git a/lib/Popper/index.js b/lib/Popper/index.js index a822eaf57..519b84812 100644 --- a/lib/Popper/index.js +++ b/lib/Popper/index.js @@ -1 +1 @@ -export { default, AVAILABLE_PLACEMENTS } from './Popper'; +export { default, AVAILABLE_PLACEMENTS, OVERLAY_MODIFIERS } from './Popper'; diff --git a/lib/Selection/Selection.css b/lib/Selection/Selection.css index 01286c00c..df73dc993 100644 --- a/lib/Selection/Selection.css +++ b/lib/Selection/Selection.css @@ -9,6 +9,7 @@ background-color: #fff; border: 1px solid #ccc; box-shadow: var(--shadow); + pointer-events: all; } .selectionListStatus { diff --git a/lib/Selection/Selection.js b/lib/Selection/Selection.js index 815287ffd..ffd8cf9a3 100644 --- a/lib/Selection/Selection.js +++ b/lib/Selection/Selection.js @@ -1,5 +1,6 @@ import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import PropTypes from 'prop-types'; +import { deprecated } from 'prop-types-extra'; import { useIntl } from 'react-intl'; import { useCombobox } from 'downshift'; import classNames from 'classnames'; @@ -113,6 +114,7 @@ const Selection = ({ readonly, required, useValidStyle = false, + usePortal, valid, value, warning, @@ -259,6 +261,7 @@ const Selection = ({ key={`${item.label}-option-${i}`} {...getItemProps({ index: reducedIndex, + onMouseUp: e => e.stopPropagation(), })} className={getItemClass(item, reducedIndex, { selectedItem, highlightedIndex, dataOptions })} > @@ -291,6 +294,11 @@ const Selection = ({ {...getInputProps({ ref: filterRef, value: filterValue, + // stopPropagation to keep from unwantedly triggering shortcuts and + // address downshift issues with portal rendering/functionality. + // https://github.com/downshift-js/downshift/issues/287 + onKeyDown: (e) => e.stopPropagation(), + onMouseUp: (e) => e.stopPropagation(), })} onClick={() => {}} onChange={(e) => updateFilterValue(e.target.value)} @@ -373,6 +381,7 @@ const Selection = ({ popper={popper} renderFilterInput={renderFilterInput} renderOptions={renderOptions} + usePortal={usePortal} width={getControlWidth(controlRef.current)} labelId={labelId} /> @@ -403,10 +412,13 @@ Selection.propTypes = { onFocus: PropTypes.func, optionAlignment: PropTypes.string, placeholder: PropTypes.node, - popper: PropTypes.object, + popper: PropTypes.shape({ + portal: deprecated(PropTypes.element, 'use the boolean usePortal prop of Selection instead') + }), readOnly: PropTypes.bool, readonly: PropTypes.bool, required: PropTypes.bool, + usePortal: PropTypes.bool, useValidStyle: PropTypes.bool, valid: PropTypes.bool, value: PropTypes.string, diff --git a/lib/Selection/SelectionOverlay.js b/lib/Selection/SelectionOverlay.js index 58bbffdfd..fc860c430 100644 --- a/lib/Selection/SelectionOverlay.js +++ b/lib/Selection/SelectionOverlay.js @@ -2,7 +2,7 @@ import React, { useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; import Portal from 'react-overlays/Portal'; import SelectionList from './SelectionList'; -import Popper from '../Popper'; +import Popper, { OVERLAY_MODIFIERS } from '../Popper'; import css from './Selection.css'; @@ -14,13 +14,16 @@ const SelectionOverlay = ({ listMaxHeight, onChangeFilterValue, optionAlignment, + popper, renderFilterInput, renderOptions, + usePortal, width, ...props }) => { const containerRef = useRef(null); const filterRef = useRef(null); + const getPortalElement = useRef(() => document.getElementById('OverlayContainer')).current; useEffect(() => { // if the overlay is open and focus is outside of it, move focus to the filter. @@ -56,7 +59,7 @@ const SelectionOverlay = ({ return (
    {isOpen && - +
    ` props](../Popper/readme.md) | | false | +`usePortal` | bool | If `true`, option list will render to the `div[#OverlayContainer]` element in the FOLIO UI. | | + ## Labeling Like other form controls in stripes-components, `` abides by standard conventions for labeling props if alternatives to `label` (visible label with the control) are required... `aria-label` and `aria-labelledby` are useful for this. See [Accessiblity for developers documentation](https://github.com/folio-org/stripes-components/blob/master/guides/AccessibilityDevPrimer.stories.mdx#labeling) for more details about which to choose. diff --git a/lib/Selection/tests/Selection-test.js b/lib/Selection/tests/Selection-test.js index 6f27c1b97..4d989d849 100644 --- a/lib/Selection/tests/Selection-test.js +++ b/lib/Selection/tests/Selection-test.js @@ -715,4 +715,19 @@ describe('Selection', () => { it('displays the value', () => selection.has({ singleValue: 'Option 2' })) }); }); + + describe('usePortal prop', () => { + beforeEach(async () => { + await mountWithContext( + + ); + await selection.open(); + }); + + it('renders overlay within portal element', () => HTML({ id: 'OverlayContainer' }).find(SelectListInteractor()).exists()); + }); }); diff --git a/lib/Selection/tests/SingleSelectionHarness.js b/lib/Selection/tests/SingleSelectionHarness.js index 513acbf48..1995e3f32 100644 --- a/lib/Selection/tests/SingleSelectionHarness.js +++ b/lib/Selection/tests/SingleSelectionHarness.js @@ -4,26 +4,27 @@ import Button from '../../Button/Button'; const SingleSelectionHarness = ({ initValue, - label, options: optionsProp, delayedOptions = [], onChange = (fn, val) => { fn(val) }, forcedValue, + ...props }) => { const [fieldVal, setFieldVal] = useState(initValue); const [options, updateOptions] = useState(optionsProp); const [increment, updateIncrement] = useState(0); return ( <> +
    ; { onChange(setFieldVal, val) }} + {...props} /> );