diff --git a/CHANGELOG.md b/CHANGELOG.md index 4cdfc5c62..b20fce4e0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,19 @@ # Change history for stripes-components -## 12.2.0 IN PROGRESS +## 12.3.0 IN PROGRESS + +* `TextArea` - move focus to the field after clearing the field by clicking on the `x` icon. Refs STCOM-1369. +* Change `Repeatable field` focus behaviour. Refs STCOM-1341. +* Fix `` bug with option list closing when scrollbar is used. Refs STCOM-1371. +* `` - fix bug handling empty string options/values. Refs STCOM-1373. +* Include Kosovo in the countries list. Refs STCOM-1354. +* `` - switch to MutationObserver to resolve focus-management issues. Refs STCOM-1372. +* Bump `stripes-react-hotkeys` to `v3.2.0` for compatibility with `findDOMNode()` changes. STCOM-1343. +* Pin `currency-codes` to `v2.1.0` to avoid duplicate entries in `v2.2.0`. Refs STCOM-1379. + +## [12.2.0](https://github.com/folio-org/stripes-components/tree/v12.2.0) (2024-10-11) +[Full Changelog](https://github.com/folio-org/stripes-components/compare/v12.1.0...v12.2.0) + * Add specific loading props to MCL to pass to Prev/Next pagination row, Refs STCOM-1305 * Exclude invalid additional currencies. Refs STCOM-1274. * Validate ref in `Paneset` before dereferencing it. Refs STCOM-1235. diff --git a/lib/CurrencySelect/tests/CurrencySelect-test.js b/lib/CurrencySelect/tests/CurrencySelect-test.js index 203d776c2..0c018b237 100644 --- a/lib/CurrencySelect/tests/CurrencySelect-test.js +++ b/lib/CurrencySelect/tests/CurrencySelect-test.js @@ -33,10 +33,26 @@ describe('CurrencySelect', () => { describe('utility functions', () => { const CURRENCY_COUNT = 159; - it('expects currency maps to contain the same element counts', () => { + it('expects currency maps to contain the same element counts (reduce by CODE)', () => { expect(Object.keys(currenciesByCode).length).to.equal(CURRENCY_COUNT); + }); + + // this test fails with currency-codes v2.2.0 which supplies duplicate + // entries for BolĂ­var Soberano. it isn't clear if this is intentional + // (and so this map-by-name function should never have been written) or + // accidental (names are unique in previous releases). + // + // if we unpin the dependency from v2.1.0 then we need to remove this function, + // a breaking change. leave comments at STCOM-1379. + it('expects currency maps to contain the same element counts (reduce by NAME)', () => { expect(Object.keys(currenciesByName).length).to.equal(CURRENCY_COUNT); + }); + + it('expects currency maps to contain the same element counts (reduce by NUMBER)', () => { expect(Object.keys(currenciesByNumber).length).to.equal(CURRENCY_COUNT); + }); + + it('expects currency maps to contain the same element counts', () => { expect(currenciesOptions.length).to.equal(CURRENCY_COUNT); }); diff --git a/lib/RepeatableField/RepeatableField.js b/lib/RepeatableField/RepeatableField.js index 5b340812d..d44e17af8 100644 --- a/lib/RepeatableField/RepeatableField.js +++ b/lib/RepeatableField/RepeatableField.js @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef, useEffect, useState } from 'react'; import classnames from 'classnames'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; @@ -8,10 +8,9 @@ import Button from '../Button'; import Headline from '../Headline'; import EmptyMessage from '../EmptyMessage'; import IconButton from '../IconButton'; -import { RepeatableFieldContent } from "./RepeatableFieldContent"; +import { RepeatableFieldContent } from './RepeatableFieldContent'; import css from './RepeatableField.css'; -import { useFocusedIndex } from "./hooks/useFocusedIndex"; - +import { getFirstFocusable } from '../../util/getFocusableElements'; const RepeatableField = ({ canAdd = true, @@ -29,15 +28,63 @@ const RepeatableField = ({ onRemove, renderField, }) => { + const rootRef = useRef(null); const showDeleteBtn = typeof onRemove === 'function'; const fieldsLength = fields.length; - const focusedIndex = useFocusedIndex(fieldsLength); + const [hasBeenFocused, setHasBeenFocused] = useState(false); + + // use mutation observers to handle focus-management since we only have internal state. + useEffect(() => { + const observer = new MutationObserver(mutations => { + mutations.forEach(mutation => { + if (mutation.type !== 'childList') return; + + const addedNode = mutation.addedNodes?.[0]; + const removedNode = mutation.removedNodes?.[0]; + let rowElem; + // Handle added node + // we check if the user has interacted with the component before handling this, otherwise focus could be + // unwantedly moved when a form is initialized. + if (hasBeenFocused) { + if (addedNode && + addedNode.nodeType === 1 && // looking for nodeType: element only... not attribute (2) or text (3) + addedNode.matches(`.${css.repeatableFieldItem}`)) { // only apply to repeatable field item addition removal + rowElem = getFirstFocusable(addedNode); + rowElem?.focus(); + } + } + + // Handle removed node + if (removedNode && + mutation.previousSibling && + mutation.previousSibling.matches(`.${css.repeatableFieldItem}`)) { + rowElem = getFirstFocusable(mutation.previousSibling); + rowElem?.focus(); + } + }); + }); + + if (rootRef.current) { + // observe for item additions/removals from list. + observer.observe(rootRef.current, { + childList: true, + subtree: true, + }); + } + + return () => { + observer.disconnect(); + }; + }, [hasBeenFocused]) return (
{ setHasBeenFocused(true) }} > {legend && ( - + {renderField(field, index, fields)} { diff --git a/lib/RepeatableField/RepeatableFieldContent.js b/lib/RepeatableField/RepeatableFieldContent.js index 1cdf00a2c..edbeae103 100644 --- a/lib/RepeatableField/RepeatableFieldContent.js +++ b/lib/RepeatableField/RepeatableFieldContent.js @@ -1,32 +1,17 @@ -import React, { useCallback } from "react"; -import PropTypes from "prop-types"; +import React from 'react'; +import PropTypes from 'prop-types'; -import { getFirstFocusable } from "../../util/getFocusableElements"; +import css from './RepeatableField.css'; -import css from "./RepeatableField.css"; - -export const RepeatableFieldContent = ({ children, isFocused }) => { - const callbackRef = useCallback((node) => { - if (node) { - const elem = getFirstFocusable(node, true, true); - - if (isFocused) { - elem?.focus(); - } - } - }, [isFocused]) - - return ( -
- {children} -
- ); -} +export const RepeatableFieldContent = ({ children }) => ( +
+ {children} +
+); RepeatableFieldContent.propTypes = { children: PropTypes.oneOfType([ PropTypes.node, PropTypes.func, ]).isRequired, - isFocused: PropTypes.bool.isRequired, } diff --git a/lib/RepeatableField/hooks/useIsElementFocused.js b/lib/RepeatableField/hooks/useIsElementFocused.js new file mode 100644 index 000000000..0944bbe0f --- /dev/null +++ b/lib/RepeatableField/hooks/useIsElementFocused.js @@ -0,0 +1,30 @@ +import { useEffect, useState } from "react"; + +export const useIsElementFocused = (ref) => { + const [isFocused, setIsFocused] = useState(false); + + useEffect(() => { + const checkIfFocused = () => { + if (ref.current) { + const focusedElement = document.activeElement; + if (ref.current.contains(focusedElement)) { + setIsFocused(true); + } else { + setIsFocused(false); + } + } + }; + + window.addEventListener("focusin", checkIfFocused); + window.addEventListener("focusout", checkIfFocused); + + checkIfFocused(); + + return () => { + window.removeEventListener("focusin", checkIfFocused); + window.removeEventListener("focusout", checkIfFocused); + }; + }, [ref]); + + return isFocused; +}; diff --git a/lib/Selection/Selection.css b/lib/Selection/Selection.css index df73dc993..8b851b13c 100644 --- a/lib/Selection/Selection.css +++ b/lib/Selection/Selection.css @@ -19,10 +19,18 @@ .selectionList { list-style: none; - padding: 4px; + padding: 2px 4px; + border-top: 2px solid transparent; + border-bottom: 2px solid transparent; margin-bottom: 0; overflow: auto; position: relative; + outline: 0; + + &:focus { + border-top-color: var(--primary); + border-bottom-color: var(--primary); + } } .selectListSection { diff --git a/lib/Selection/Selection.js b/lib/Selection/Selection.js index eccfde626..dc16cfacc 100644 --- a/lib/Selection/Selection.js +++ b/lib/Selection/Selection.js @@ -44,7 +44,7 @@ const getControlWidth = (control) => { const getItemClass = (item, i, props) => { const { value } = item; const { selectedItem, highlightedIndex, dataOptions } = props; - if (!value) { + if (value === undefined) { return; } @@ -260,7 +260,7 @@ const Selection = ({ const rendered = []; for (let i = 0; i < data.length; i++) { const item = data[i] - if (item.value) { + if (item.value !== undefined) { const reducedIndex = reconcileReducedIndex(item, reducedListItems); rendered.push(
  • { isOpen && renderOptions()} diff --git a/lib/Selection/stories/BasicUsage.js b/lib/Selection/stories/BasicUsage.js index 76c503e5e..28a0e7bea 100644 --- a/lib/Selection/stories/BasicUsage.js +++ b/lib/Selection/stories/BasicUsage.js @@ -14,6 +14,7 @@ const hugeOptionsList = syncGenerate(3000, 0, () => { // the dataOptions prop takes an array of objects with 'label' and 'value' keys const countriesOptions = [ + { value: '', label: 'blank' }, { value: 'AU', label: 'Australia' }, { value: 'CN', label: 'China' }, { value: 'DK', label: 'Denmark' }, diff --git a/lib/Selection/tests/Selection-test.js b/lib/Selection/tests/Selection-test.js index 2c7a68729..2950c341a 100644 --- a/lib/Selection/tests/Selection-test.js +++ b/lib/Selection/tests/Selection-test.js @@ -47,7 +47,8 @@ describe('Selection', () => { { value: 'test2', label: 'Option 2' }, { value: 'sample0', label: 'Sample 0' }, { value: 'invalid', label: 'Sample 1' }, - { value: 'sample2', label: 'Sample 2' } + { value: 'sample2', label: 'Sample 2' }, + { value: '', label: '' }, ]; const groupedOptions = [ @@ -281,6 +282,16 @@ describe('Selection', () => { it('focuses the control/trigger', () => { selection.has({ focused: true }); }); + + describe('clicking a "blank" option', () => { + beforeEach(async () => { + await selection.choose(''); + }); + + it('sets control value to ""', () => { + selection.has({ value: 'select control' }); + }); + }); }); describe('filtering options', () => { @@ -743,7 +754,7 @@ describe('Selection', () => { beforeEach(async () => { filterSpy.resetHistory(); await mountWithContext( - + ); }); @@ -756,7 +767,7 @@ describe('Selection', () => { await asyncSelection.filterOptions('tes'); }); - it ('calls filter function only once (not for each letter)', async () => converge(() => { if (filterSpy.calledTwice) { throw new Error('Selection - onFilter should only be called once.'); }})); + it('calls filter function only once (not for each letter)', async () => converge(() => { if (filterSpy.calledTwice) { throw new Error('Selection - onFilter should only be called once.'); } })); it('displays spinner in optionsList', () => asyncSelectionList().is({ loading: true })); diff --git a/lib/TextArea/TextArea.js b/lib/TextArea/TextArea.js index 036445f59..eab452e28 100644 --- a/lib/TextArea/TextArea.js +++ b/lib/TextArea/TextArea.js @@ -10,6 +10,7 @@ import parseMeta from '../FormField/parseMeta'; import formField from '../FormField'; import TextFieldIcon from '../TextField/TextFieldIcon'; import omitProps from '../../util/omitProps'; +import nativeChangeField from '../../util/nativeChangeFieldValue'; import sharedInputStylesHelper from '../sharedStyles/sharedInputStylesHelper'; import formStyles from '../sharedStyles/form.css'; @@ -284,6 +285,22 @@ class TextArea extends Component { onKeyDown(event); } + clearField = () => { + const { onClearField } = this.props; + + if (typeof onClearField === 'function') { + onClearField(); + } + + // Clear value on input natively, dispatch an event to be picked up by handleChange, and focus on input again + if (this.textareaRef.current) { + nativeChangeField(this.textareaRef, true, ''); + if (this.state.value !== '') { + this.setState({ value: '' }); + } + } + } + render() { /* eslint-disable no-unused-vars */ const { @@ -308,7 +325,6 @@ class TextArea extends Component { warning, hasClearIcon, clearFieldId, - onClearField, ...rest } = this.props; @@ -359,7 +375,7 @@ class TextArea extends Component { aria-label={ariaLabel} icon="times-circle-solid" id={clearFieldId || `clickable-${this.testId}-clear-field`} - onClick={onClearField} + onClick={this.clearField} tabIndex="-1" /> )} diff --git a/lib/TextArea/tests/TextArea-test.js b/lib/TextArea/tests/TextArea-test.js index 40293cffa..00f7d4415 100644 --- a/lib/TextArea/tests/TextArea-test.js +++ b/lib/TextArea/tests/TextArea-test.js @@ -15,7 +15,11 @@ describe('TextArea', () => { describe('rendering a basic TextArea', async () => { beforeEach(async () => { await mountWithContext( -