From 1889bf5bc898a093a1c826afcae1d74c3e9459d5 Mon Sep 17 00:00:00 2001 From: Mohammer5 Date: Wed, 16 Oct 2024 10:15:02 +0800 Subject: [PATCH 01/39] chore: fix linter issues --- collections/forms/i18n/en.pot | 106 ---------------------------------- collections/ui/API.md | 39 +++++++++++++ components/input/API.md | 1 + 3 files changed, 40 insertions(+), 106 deletions(-) delete mode 100644 collections/forms/i18n/en.pot diff --git a/collections/forms/i18n/en.pot b/collections/forms/i18n/en.pot deleted file mode 100644 index bffb1bd6f..000000000 --- a/collections/forms/i18n/en.pot +++ /dev/null @@ -1,106 +0,0 @@ -msgid "" -msgstr "" -"Project-Id-Version: i18next-conv\n" -"MIME-Version: 1.0\n" -"Content-Type: text/plain; charset=utf-8\n" -"Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2024-09-26T14:15:11.940Z\n" -"PO-Revision-Date: 2024-09-26T14:15:11.941Z\n" - -msgid "Upload file" -msgstr "Upload file" - -msgid "Upload files" -msgstr "Upload files" - -msgid "Remove" -msgstr "Remove" - -msgid "Please provide an alpha-numeric value" -msgstr "Please provide an alpha-numeric value" - -msgid "Please provide a boolean value" -msgstr "Please provide a boolean value" - -msgid "Please enter between {{lowerBound}} and {{upperBound}} characters" -msgstr "Please enter between {{lowerBound}} and {{upperBound}} characters" - -msgid "" -"Please make sure the value of this input matches the value in " -"\"{{otherField}}\"." -msgstr "" -"Please make sure the value of this input matches the value in " -"\"{{otherField}}\"." - -msgid "Please enter a maximum of {{upperBound}} characters" -msgstr "Please enter a maximum of {{upperBound}} characters" - -msgid "Please enter a number with a maximum of {{upperBound}}" -msgstr "Please enter a number with a maximum of {{upperBound}}" - -msgid "Please enter at least {{lowerBound}} characters" -msgstr "Please enter at least {{lowerBound}} characters" - -msgid "Please enter a number of at least {{lowerBound}}" -msgstr "Please enter a number of at least {{lowerBound}}" - -msgid "Number cannot be less than {{lowerBound}} or more than {{upperBound}}" -msgstr "Number cannot be less than {{lowerBound}} or more than {{upperBound}}" - -msgid "" -"Please make sure the value of this input matches the pattern " -"{{patternString}}." -msgstr "" -"Please make sure the value of this input matches the pattern " -"{{patternString}}." - -msgid "Password should be a string" -msgstr "Password should be a string" - -msgid "Password should be at least 8 characters long" -msgstr "Password should be at least 8 characters long" - -msgid "Password should be no longer than 34 characters" -msgstr "Password should be no longer than 34 characters" - -msgid "Password should contain at least one lowercase letter" -msgstr "Password should contain at least one lowercase letter" - -msgid "Password should contain at least one UPPERCASE letter" -msgstr "Password should contain at least one UPPERCASE letter" - -msgid "Password should contain at least one number" -msgstr "Password should contain at least one number" - -msgid "Password should have at least one special character" -msgstr "Password should have at least one special character" - -msgid "Please provide a valid email address" -msgstr "Please provide a valid email address" - -msgid "Please provide a value" -msgstr "Please provide a value" - -msgid "Please provide a round number without decimals" -msgstr "Please provide a round number without decimals" - -msgid "Please provide a valid international phone number." -msgstr "Please provide a valid international phone number." - -msgid "Please provide a number" -msgstr "Please provide a number" - -msgid "Please provide a string" -msgstr "Please provide a string" - -msgid "Please provide a valid url" -msgstr "Please provide a valid url" - -msgctxt " - or @" -msgid "" -"Please provide a username between 4 and 255 characters long and possibly " -"separated by . " -msgstr "" -"Please provide a username between 4 and 255 characters long and possibly " -"separated by . _ - or @" diff --git a/collections/ui/API.md b/collections/ui/API.md index a6dc338e0..26b04758f 100644 --- a/collections/ui/API.md +++ b/collections/ui/API.md @@ -780,6 +780,7 @@ import { Input } from '@dhis2/ui' |Name|Type|Default|Required|Description| |---|---|---|---|---| +|ariaLabel|string|||Add an aria-label attribute to the input element *| |autoComplete|string|||The [native `autocomplete` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-autocomplete)| |className|string|||| |clearable|boolean|||Makes the input field clearable| @@ -1865,6 +1866,44 @@ import { SingleSelect } from '@dhis2/ui' |onFocus|function|||| |onKeyDown|function|||| +### Menu + +#### Usage + +To use `Menu`, you can import the component from the `@dhis2/ui` library + + +```js +import { Menu } from '@dhis2/ui' +``` + + +#### Props + +|Name|Type|Default|Required|Description| +|---|---|---|---|---| +|comboBoxId|string||*|| +|idPrefix|string||*|| +|options|custom|||| +|onChange|function||*|| +|disabled|boolean|||| +|empty|node|||| +|filterLabel|string|||| +|filterPlaceholder|string|||| +|filterValue|string|||| +|filterable|boolean|||| +|hidden|boolean|||| +|labelledBy|string|||| +|loading|boolean|||| +|loadingText|string|||| +|maxHeight|string|||| +|selectRef|instanceOf(HTMLElement)|||| +|selected|string|||| +|onBlur|function|||| +|onClose|function|||| +|onFilterChange|function|||| +|onKeyDown|function|||| + ### SingleSelectField #### Usage diff --git a/components/input/API.md b/components/input/API.md index d5079d956..8c95223bd 100644 --- a/components/input/API.md +++ b/components/input/API.md @@ -14,6 +14,7 @@ import { Input } from '@dhis2/ui' |Name|Type|Default|Required|Description| |---|---|---|---|---| +|ariaLabel|string|||Add an aria-label attribute to the input element *| |autoComplete|string|||The [native `autocomplete` attribute](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input#attr-autocomplete)| |className|string|||| |clearable|boolean|||Makes the input field clearable| From 930efa8ebfce12d50b3a6ae4d5037b1282e25f69 Mon Sep 17 00:00:00 2001 From: Mohammer5 Date: Wed, 16 Oct 2024 10:15:26 +0800 Subject: [PATCH 02/39] feat(input): allow aria-label attribute --- components/input/src/input/input.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/components/input/src/input/input.js b/components/input/src/input/input.js index 6f702aa3f..e175b2629 100644 --- a/components/input/src/input/input.js +++ b/components/input/src/input/input.js @@ -148,6 +148,7 @@ export class Input extends Component { render() { const { role, + ariaLabel, className, type = 'text', dense, @@ -183,6 +184,7 @@ export class Input extends Component { > {prefixIcon && {prefixIcon}} Date: Wed, 16 Oct 2024 10:16:29 +0800 Subject: [PATCH 03/39] feat(select a11y): implement --- .../src/single-select-a11y/menu-filter.js | 44 ++++ .../single-select-a11y/menu-options-list.js | 120 ++++++++++ .../select/src/single-select-a11y/menu.js | 139 ++++++++++++ .../select/src/single-select-a11y/option.js | 119 ++++++++++ .../selected-value-clear-button.js | 80 +++++++ .../selected-value-container.js | 135 +++++++++++ .../selected-value-placeholder.js | 32 +++ .../selected-value-prefix.js | 26 +++ .../src/single-select-a11y/selected-value.js | 131 +++++++++++ .../single-select-a11y/shared-prop-types.js | 10 + .../single-select-a11y/single-select-a11y.js | 210 ++++++++++++++++++ 11 files changed, 1046 insertions(+) create mode 100644 components/select/src/single-select-a11y/menu-filter.js create mode 100644 components/select/src/single-select-a11y/menu-options-list.js create mode 100644 components/select/src/single-select-a11y/menu.js create mode 100644 components/select/src/single-select-a11y/option.js create mode 100644 components/select/src/single-select-a11y/selected-value-clear-button.js create mode 100644 components/select/src/single-select-a11y/selected-value-container.js create mode 100644 components/select/src/single-select-a11y/selected-value-placeholder.js create mode 100644 components/select/src/single-select-a11y/selected-value-prefix.js create mode 100644 components/select/src/single-select-a11y/selected-value.js create mode 100644 components/select/src/single-select-a11y/shared-prop-types.js create mode 100644 components/select/src/single-select-a11y/single-select-a11y.js diff --git a/components/select/src/single-select-a11y/menu-filter.js b/components/select/src/single-select-a11y/menu-filter.js new file mode 100644 index 000000000..4cc218188 --- /dev/null +++ b/components/select/src/single-select-a11y/menu-filter.js @@ -0,0 +1,44 @@ +import { colors, spacers } from '@dhis2/ui-constants' +import { Input } from '@dhis2-ui/input' +import PropTypes from 'prop-types' +import React from 'react' +import i18n from '../locales/index.js' + +export function MenuFilter({ value, onChange, dataTest, placeholder, label }) { + return ( +
+ onChange(value)} + type="text" + name="filter" + placeholder={placeholder} + initialFocus + /> + + +
+ ) +} + +MenuFilter.propTypes = { + value: PropTypes.string.isRequired, + onChange: PropTypes.func.isRequired, + dataTest: PropTypes.string, + label: PropTypes.string, + placeholder: PropTypes.string, +} diff --git a/components/select/src/single-select-a11y/menu-options-list.js b/components/select/src/single-select-a11y/menu-options-list.js new file mode 100644 index 000000000..e2bf65a4a --- /dev/null +++ b/components/select/src/single-select-a11y/menu-options-list.js @@ -0,0 +1,120 @@ +import { colors, spacers, theme } from '@dhis2/ui-constants' +import { CircularLoader } from '@dhis2-ui/loader' +import PropTypes from 'prop-types' +import React from 'react' +import { Option } from './option.js' +import { optionsProp } from './shared-prop-types.js' + +function Loading({ message }) { + return ( +
+
+ +
+ + {message} + + +
+ ) +} + +Loading.propTypes = { + message: PropTypes.string, +} + +export function MenuOptionsList({ + comboBoxId, + idPrefix, + labelledBy, + options, + selected, + dataTest, + disabled, + empty, + loading, + loadingText, + onChange, + onBlur, + onKeyDown, +}) { + return ( +
+ {!options.length && empty} + + {options.map( + ( + { + value, + label, + component, + disabled: optionDisabled = false, + }, + index + ) => { + const isSelected = value === selected + return ( +
+ ) +} + +MenuOptionsList.propTypes = { + comboBoxId: PropTypes.string.isRequired, + idPrefix: PropTypes.string.isRequired, + options: optionsProp.isRequired, + onChange: PropTypes.func.isRequired, + dataTest: PropTypes.string, + disabled: PropTypes.bool, + empty: PropTypes.node, + labelledBy: PropTypes.string, + loading: PropTypes.bool, + loadingText: PropTypes.string, + selected: PropTypes.string, + onBlur: PropTypes.func, + onKeyDown: PropTypes.func, +} diff --git a/components/select/src/single-select-a11y/menu.js b/components/select/src/single-select-a11y/menu.js new file mode 100644 index 000000000..1dd040ddc --- /dev/null +++ b/components/select/src/single-select-a11y/menu.js @@ -0,0 +1,139 @@ +import { colors, elevations } from '@dhis2/ui-constants' +import { Layer } from '@dhis2-ui/layer' +import { Popper } from '@dhis2-ui/popper' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React, { useEffect, useState } from 'react' +import { MenuFilter } from './menu-filter.js' +import { MenuOptionsList } from './menu-options-list.js' +import { optionsProp } from './shared-prop-types.js' + +export function Menu({ + comboBoxId, + idPrefix, + options, + onChange, + dataTest, + disabled, + empty, + filterLabel, + filterPlaceholder, + filterValue, + filterable, + hidden, + labelledBy, + loading, + loadingText, + maxHeight, + selectRef, + selected, + onBlur, + onClose, + onFilterChange, + onKeyDown, +}) { + const [menuWidth, setMenuWidth] = useState('auto') + const dataTestPrefix = `${dataTest}-menu` + + useEffect(() => { + if (selectRef) { + const callback = () => setMenuWidth(`${selectRef.offsetWidth}px`) + selectRef.addEventListener('resize', callback) + + // We want to know the width as soon as the + callback() + return () => selectRef.removeEventListener('resize', callback) + } + }, [selectRef]) + + const menu = ( + + ) + + if (hidden) { + return menu + } + + return ( + + + {menu} + + + ) +} + +Menu.propTypes = { + comboBoxId: PropTypes.string.isRequired, + idPrefix: PropTypes.string.isRequired, + options: optionsProp.isRequired, + onChange: PropTypes.func.isRequired, + dataTest: PropTypes.string, + disabled: PropTypes.bool, + empty: PropTypes.node, + filterLabel: PropTypes.string, + filterPlaceholder: PropTypes.string, + filterValue: PropTypes.string, + filterable: PropTypes.bool, + hidden: PropTypes.bool, + labelledBy: PropTypes.string, + loading: PropTypes.bool, + loadingText: PropTypes.string, + maxHeight: PropTypes.string, + selectRef: PropTypes.instanceOf(HTMLElement), + selected: PropTypes.string, + onBlur: PropTypes.func, + onClose: PropTypes.func, + onFilterChange: PropTypes.func, + onKeyDown: PropTypes.func, +} diff --git a/components/select/src/single-select-a11y/option.js b/components/select/src/single-select-a11y/option.js new file mode 100644 index 000000000..59dc7ba4e --- /dev/null +++ b/components/select/src/single-select-a11y/option.js @@ -0,0 +1,119 @@ +import { colors, spacers } from '@dhis2/ui-constants' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React from 'react' + +function DefaultStyle({ label, disabled, selected }) { + return ( + + {label} + + + + ) +} + +DefaultStyle.propTypes = { + label: PropTypes.string.isRequired, + disabled: PropTypes.bool, + selected: PropTypes.bool, +} + +export function Option({ + value, + label, + index, + selected, + onClick, + dataTest, + disabled, + comboBoxId, + component: StyleComponent = DefaultStyle, + ...rest +}) { + return ( + + ) +} + +Option.propTypes = { + comboBoxId: PropTypes.string.isRequired, + index: PropTypes.number.isRequired, + label: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + onClick: PropTypes.func.isRequired, + dataTest: PropTypes.string, + component: PropTypes.elementType, + disabled: PropTypes.bool, + selected: PropTypes.bool, +} diff --git a/components/select/src/single-select-a11y/selected-value-clear-button.js b/components/select/src/single-select-a11y/selected-value-clear-button.js new file mode 100644 index 000000000..57b1d4551 --- /dev/null +++ b/components/select/src/single-select-a11y/selected-value-clear-button.js @@ -0,0 +1,80 @@ +import { colors, theme } from '@dhis2/ui-constants' +import { Tooltip } from '@dhis2-ui/tooltip' +import PropTypes from 'prop-types' +import React from 'react' +import i18n from '../locales/index.js' + +export const ClearButton = ({ onClear, clearText, dataTest}) => ( + +) + +ClearButton.propTypes = { + clearText: PropTypes.string.isRequired, + onClear: PropTypes.func.isRequired, + dataTest: PropTypes.string, +} + +export const SelectedValueClearButton = ({ onClear, clearText, dataTest }) => { + const clearButton = + + if (!clearText) { + return clearButton + } + + return ( + + {clearButton} + + ) +} + +SelectedValueClearButton.propTypes = { + clearText: PropTypes.string.isRequired, + onClear: PropTypes.func.isRequired, + dataTest: PropTypes.string, +} diff --git a/components/select/src/single-select-a11y/selected-value-container.js b/components/select/src/single-select-a11y/selected-value-container.js new file mode 100644 index 000000000..ff57cbeab --- /dev/null +++ b/components/select/src/single-select-a11y/selected-value-container.js @@ -0,0 +1,135 @@ +import { colors, theme } from '@dhis2/ui-constants' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React, { forwardRef, useCallback, useEffect } from 'react' + +export const SelectedValueContainer = forwardRef(function Container( + { + children, + comboBoxId, + idPrefix, + autoFocus, + dataTest, + dense, + disabled, + error, + expanded, + labelledBy, + placeholder, + tabIndex, + valid, + warning, + onBlur, + onClick: _onClick, + onFocus, + }, + ref +) { + // Using useEffect so we have access to the ref, which will be set before + // the first call to the useEffect's callback + useEffect(() => { + if (autoFocus) { + ref.current.focus() + } + }, []) + + const onClick = useCallback( + (...args) => { + if (!disabled) { + _onClick(...args) + } + }, + [disabled, _onClick] + ) + + return ( +
+ {children} + + +
+ ) +}) + +SelectedValueContainer.propTypes = { + children: PropTypes.any.isRequired, + comboBoxId: PropTypes.string.isRequired, + idPrefix: PropTypes.string.isRequired, + autoFocus: PropTypes.bool, + dataTest: PropTypes.string, + dense: PropTypes.bool, + disabled: PropTypes.bool, + error: PropTypes.bool, + expanded: PropTypes.bool, + labelledBy: PropTypes.string, + placeholder: PropTypes.string, + tabIndex: PropTypes.string, + valid: PropTypes.bool, + warning: PropTypes.bool, + onBlur: PropTypes.func, + onClick: PropTypes.func, + onFocus: PropTypes.func, +} diff --git a/components/select/src/single-select-a11y/selected-value-placeholder.js b/components/select/src/single-select-a11y/selected-value-placeholder.js new file mode 100644 index 000000000..ddd43f7b1 --- /dev/null +++ b/components/select/src/single-select-a11y/selected-value-placeholder.js @@ -0,0 +1,32 @@ +import { colors } from '@dhis2/ui-constants' +import PropTypes from 'prop-types' +import React from 'react' + +export const SelectedValuePlaceholder = ({ + placeholder, + className, + dataTest, +}) => { + if (!placeholder) { + return null + } + + return ( +
+ {placeholder} + + +
+ ) +} + +SelectedValuePlaceholder.propTypes = { + dataTest: PropTypes.string.isRequired, + className: PropTypes.string, + placeholder: PropTypes.string, +} diff --git a/components/select/src/single-select-a11y/selected-value-prefix.js b/components/select/src/single-select-a11y/selected-value-prefix.js new file mode 100644 index 000000000..332699147 --- /dev/null +++ b/components/select/src/single-select-a11y/selected-value-prefix.js @@ -0,0 +1,26 @@ +import { colors, spacers } from '@dhis2/ui-constants' +import PropTypes from 'prop-types' +import React from 'react' + +export function SelectedValuePrefix({ prefix, className, dataTest }) { + return ( +
+ {prefix} + + +
+ ) +} + +SelectedValuePrefix.propTypes = { + dataTest: PropTypes.string.isRequired, + className: PropTypes.string, + prefix: PropTypes.string, +} diff --git a/components/select/src/single-select-a11y/selected-value.js b/components/select/src/single-select-a11y/selected-value.js new file mode 100644 index 000000000..85728424c --- /dev/null +++ b/components/select/src/single-select-a11y/selected-value.js @@ -0,0 +1,131 @@ +import { IconChevronDown16 } from '@dhis2/ui-icons' +import PropTypes from 'prop-types' +import React from 'react' +import { SelectedValueClearButton } from './selected-value-clear-button.js' +import { SelectedValueContainer } from './selected-value-container.js' +import { SelectedValuePlaceholder } from './selected-value-placeholder.js' +import { SelectedValuePrefix } from './selected-value-prefix.js' + +export function SelectedValue({ + comboBoxRef, + idPrefix, + expanded, + labelledBy, + comboBoxId, + tabIndex, + onBlur, + onFocus, + hasSelection, + valueLabel, + prefix, + dataTest, + placeholder, + onClear, + clearText, + clearable, + disabled, + onClick, + error, + warning, + valid, + dense, + autoFocus, +}) { + // @TODO + const inputMaxHeight = '300px' + const dataTestPrefix = `${dataTest}-selectedvalue` + + return ( + + {prefix && ( + + )} + +
+ {!valueLabel && !prefix && ( + + )} + + {valueLabel} +
+ + {hasSelection && clearable && !disabled && ( +
+ +
+ )} + +
+ +
+ + +
+ ) +} + +SelectedValue.propTypes = { + clearText: PropTypes.string.isRequired, + comboBoxId: PropTypes.string.isRequired, + idPrefix: PropTypes.string.isRequired, + valueLabel: PropTypes.string.isRequired, + autoFocus: PropTypes.bool, + clearable: PropTypes.bool, + comboBoxRef: PropTypes.shape({ + current: PropTypes.instanceOf(HTMLElement), + }), + dataTest: PropTypes.string, + dense: PropTypes.bool, + disabled: PropTypes.bool, + error: PropTypes.bool, + expanded: PropTypes.bool, + hasSelection: PropTypes.bool, + labelledBy: PropTypes.string, + placeholder: PropTypes.string, + prefix: PropTypes.string, + tabIndex: PropTypes.string, + valid: PropTypes.bool, + warning: PropTypes.bool, + onBlur: PropTypes.func, + onClear: PropTypes.func, + onClick: PropTypes.func, + onFocus: PropTypes.func, +} diff --git a/components/select/src/single-select-a11y/shared-prop-types.js b/components/select/src/single-select-a11y/shared-prop-types.js new file mode 100644 index 000000000..9a4022313 --- /dev/null +++ b/components/select/src/single-select-a11y/shared-prop-types.js @@ -0,0 +1,10 @@ +import PropTypes from 'prop-types' + +export const optionsProp = PropTypes.arrayOf( + PropTypes.shape({ + label: PropTypes.string.isRequired, + value: PropTypes.string.isRequired, + component: PropTypes.elementType, + disabled: PropTypes.bool, + }) +) diff --git a/components/select/src/single-select-a11y/single-select-a11y.js b/components/select/src/single-select-a11y/single-select-a11y.js new file mode 100644 index 000000000..3e875c490 --- /dev/null +++ b/components/select/src/single-select-a11y/single-select-a11y.js @@ -0,0 +1,210 @@ +/* eslint-disable no-unused-vars */ +import { requiredIf } from '@dhis2/prop-types' +import { sharedPropTypes } from '@dhis2/ui-constants' +import cx from 'classnames' +import PropTypes from 'prop-types' +import React, { useCallback, useRef, useState } from 'react' +import { Menu } from './menu.js' +import { SelectedValue } from './selected-value.js' +import { optionsProp } from './shared-prop-types.js' + +export function SingleSelectA11y({ + options, + idPrefix, + onChange, + autoFocus = false, + className = '', + clearText = '', + clearable = false, + dataTest = 'dhis2-singleselecta11y', + dense = false, + disabled = false, + empty = false, + error = false, + filterLabel = '', + filterPlaceholder = '', + filterValue = '', + filterable = false, + inputMaxHeight = '', + labelledBy = '', + loading = false, + menuLoadingText = '', + menuMaxHeight = '280px', + noMatchText = '', + placeholder = '', + prefix = '', + tabIndex = '-1', + valid = false, + value = '', + warning = false, + valueLabel: _valueLabel = '', + onBlur = () => undefined, + onFilterChange = () => undefined, + onFocus = () => undefined, + onKeyDown = () => undefined, +}) { + // Non-stateful + // ======== + const comboBoxId = `combo-${idPrefix}` + const valueLabel = _valueLabel || options.find(option => option.value === value)?.label || '' + + // Stateful + // ======== + + // Using `useState` here so components get notified when the value changes (from null -> div) + const comboBoxRef = useRef() + const [selectRef, setSelectRef] = useState() + const [expanded, setExpanded] = useState(false) + const closeMenu = useCallback(() => setExpanded(false), []) + const toggleMenu = useCallback( + () => setExpanded((prevExpanded) => !prevExpanded), + [] + ) + // const focusCombo = useCallback(() => comboBoxRef.current?.focus(), []) + // const blurCombo = useCallback(() => comboBoxRef.current?.blur(), []) + // const onComboKeyDown = useCallback(() => null, []) // @TODO: Rename me! + + return ( +
{ + if ( + // when setting the state (with `setSelectRef), this + // component will rerender and this function will be run + // again twice. The first call will provide a null-value + !div || + // Making sure we're not setting state + // if we already have the correct element + div === selectRef + ) { + return + } + + setSelectRef(div) + }} + > + onChange('')} + onClick={toggleMenu} + onFocus={onFocus} + /> + +
+ ) +} + +SingleSelectA11y.propTypes = { + /** necessary for IDs that are required for accessibility **/ + idPrefix: PropTypes.string.isRequired, + + /** An array of options **/ + options: optionsProp.isRequired, + + /** A callback that will be called with the new value or an empty string **/ + onChange: PropTypes.func.isRequired, + + /** As of now, this component does not support being uncontrolled */ + value: PropTypes.string.isRequired, + + /** Will focus the select initially **/ + autoFocus: PropTypes.bool, + + /** Additional class names that will be applied to the root element **/ + className: PropTypes.string, + + /** This will allow us to put an aria-label on the clear button **/ + clearText: requiredIf((props) => props.clearable, PropTypes.string), + + /** Whether a clear button should be displayed or not **/ + clearable: PropTypes.bool, + + /** A value for a `data-test` attribute on the root element **/ + dataTest: PropTypes.string, + + dense: PropTypes.bool, + disabled: PropTypes.bool, + empty: PropTypes.node, + error: sharedPropTypes.statusPropType, + filterLabel: PropTypes.string, + filterPlaceholder: PropTypes.string, + filterValue: PropTypes.string, + filterable: PropTypes.bool, + inputMaxHeight: PropTypes.string, + labelledBy: PropTypes.string, + loading: PropTypes.bool, + menuLoadingText: PropTypes.string, + menuMaxHeight: PropTypes.string, + noMatchText: requiredIf((props) => props.filterable, PropTypes.string), + placeholder: PropTypes.string, + prefix: PropTypes.string, + tabIndex: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + valid: sharedPropTypes.statusPropType, + + /** + * When the option is not in the options list (e.g. not loaded or list is + * filtered), but a selected value needs to be displayed, then this prop can + * be used to supply the text to be shown. + **/ + valueLabel: requiredIf((props) => { + if (props.options.find(({ value }) => props.value === value)) { + return false + } + + return props.value + }, PropTypes.string), + + warning: sharedPropTypes.statusPropType, + onBlur: PropTypes.func, + onFilterChange: PropTypes.func, + onFocus: PropTypes.func, + onKeyDown: PropTypes.func, +} From c33e8c47d44e53eeb13b520babfc97e993e9116e Mon Sep 17 00:00:00 2001 From: Mohammer5 Date: Wed, 16 Oct 2024 10:16:51 +0800 Subject: [PATCH 04/39] docs(select a11y): add production stories for --- .../single-select-a11y.prod.stories.js | 771 ++++++++++++++++++ 1 file changed, 771 insertions(+) create mode 100644 components/select/src/single-select-a11y/single-select-a11y.prod.stories.js diff --git a/components/select/src/single-select-a11y/single-select-a11y.prod.stories.js b/components/select/src/single-select-a11y/single-select-a11y.prod.stories.js new file mode 100644 index 000000000..3837de7b8 --- /dev/null +++ b/components/select/src/single-select-a11y/single-select-a11y.prod.stories.js @@ -0,0 +1,771 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { SingleSelectA11y } from './single-select-a11y.js' + +export default { + title: 'Single Select A11y', + component: SingleSelectA11y, +} + +const options = [ + { + label: 'ANC 1st visit', + value: 'anc_1st_visit', + }, + { + label: 'ANC 2nd visit', + value: 'anc_2nd_visit', + }, + { + label: 'ANC 3rd visit', + value: 'anc_3rd_visit', + }, + { + label: 'ANC 4th or more visits', + value: 'anc_4th_or_more_visits', + }, + { + label: 'ARI treated with antibiotics (pneumonia) follow-up', + value: 'ari_treated_with_antibiotics_(pneumonia)_follow-up', + }, + { + label: 'ARI treated with antibiotics (pneumonia) new', + value: 'ari_treated_with_antibiotics_(pneumonia)_new', + }, + { + label: 'ARI treated with antibiotics (pneumonia) referrals', + value: 'ari_treated_with_antibiotics_(pneumonia)_referrals', + }, + { + label: 'ARI treated without antibiotics (cough) follow-up', + value: 'ari_treated_without_antibiotics_(cough)_follow-up', + }, + { + label: 'ARI treated without antibiotics (cough) new', + value: 'ari_treated_without_antibiotics_(cough)_new', + }, + { + label: 'ARI treated without antibiotics (cough) referrals', + value: 'ari_treated_without_antibiotics_(cough)_referrals', + }, + { + label: 'ART No clients who stopped TRT due to TRT failure', + value: 'art_no_clients_who_stopped_trt_due_to_trt_failure', + }, + { + label: 'ART No clients who stopped TRT due to adverse clinical status/event', + value: 'art_no_clients_who_stopped_trt_due_to_adverse_clinical_status/event', + }, + { + label: 'ART No clients with change of regimen due to drug toxicity', + value: 'art_no_clients_with_change_of_regimen_due_to_drug_toxicity', + }, + { + label: 'ART No clients with new adverse drug reaction', + value: 'art_no_clients_with_new_adverse_drug_reaction', + }, + { + label: 'ART No started Opportunist Infection prophylaxis', + value: 'art_no_started_opportunist_infection_prophylaxis', + }, + { + label: 'ART clients with new adverse clinical event', + value: 'art_clients_with_new_adverse_clinical_event', + }, + { + label: 'ART defaulters', + value: 'art_defaulters', + }, + { + label: 'ART enrollment stage 1', + value: 'art_enrollment_stage_1', + }, + { + label: 'ART enrollment stage 2', + value: 'art_enrollment_stage_2', + }, + { + label: 'ART enrollment stage 3', + value: 'art_enrollment_stage_3', + }, + { + label: 'ART enrollment stage 4', + value: 'art_enrollment_stage_4', + }, + { + label: 'ART entry point: No PMTCT', + value: 'art_entry_point:_no_pmtct', + }, +] + +const fiveOptions = options.slice(0, 5) + +export const WithoutSelection = () => { + const [value, setValue] = useState('') + + return ( + setValue(nextValue)} + options={fiveOptions} + /> + ) +} + +export const WithSelection = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} + +export const WithOnFocus = () => { + const [value, setValue] = useState('') + const [focusTime, setFocusTime] = useState('') + + return ( + <> + setFocusTime(new Date().toISOString())} + idPrefix="a11y" + value={value} + valueLabel={ + value + ? options.find((option) => option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + +

Last time select received focus: {focusTime || 'never'}

+ + ) +} + +export const WithOnBlur = () => { + const [value, setValue] = useState('') + const [blurTime, setBlurTime] = useState('') + + return ( + <> + setBlurTime(new Date().toISOString())} + idPrefix="a11y" + value={value} + valueLabel={ + value + ? options.find((option) => option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + +

Last time select received blur: {blurTime || 'never'}

+ + ) +} + +const CustomOption = ({ label }) => ( + + {label} + + { + e.stopPropagation() + alert('Custom "button" clicked!') + }} + > + x + + + + +) + +export const WithCustomOptions = () => { + const [value, setValue] = useState('') + const optionsWithCustomStyle = useMemo(() => { + return options.slice(0, 3).map((option) => ({ + ...option, + component: CustomOption, + })) + }, []) + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={optionsWithCustomStyle} + /> + ) +} + +export const WithInitialFocus = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} + +export const Dense = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} + +export const Empty = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={[]} + /> + ) +} + +export const EmptyWithEmptyText = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={[]} + empty="Custom empty text" + /> + ) +} + +export const EmptyWithEmptyNode = () => { + const [value, setValue] = useState('') + const emptyNode = ( +
+ Custom empty text +
+ ) + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={[]} + empty={emptyNode} + /> + ) +} + +export const WithOptionsAndLoading = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={[ + { value: '1', label: 'Option 1' }, + { value: '2', label: 'Option 2' }, + { value: '3', label: 'Option 3' }, + ]} + /> + ) +} + +export const WithOptionsAndLoadingText = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={[ + { value: '1', label: 'Option 1' }, + { value: '2', label: 'Option 2' }, + { value: '3', label: 'Option 3' }, + ]} + /> + ) +} + +export const WithManyOptions = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={options} + /> + ) +} + +export const WithCustomLowMaxHeight = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={options} + menuMaxHeight="100px" + /> + ) +} + +export const WithOptionsAndDisabled = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} + +export const WithSelectionAndDisabled = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} + +export const WithPrefix = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} + +export const WithPrefixAndSelection = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} + +export const WithRTL = () => { + const [value, setValue] = useState('anc_1st_visit') + + // as options are rendered in Portal, the body dir (of the iframe) needs to be set to 'rtl' + useEffect(() => { + document.body.dir = 'rtl' + return () => { + document.body.dir = 'ltr' + } + }, []) + + return ( +
+ option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> +
+ ) +} + +export const WithPlaceholder = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} + +export const WithPlaceholderAndSelection = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} + +const fiveOptionsWithFourthDisabled = [ + ...fiveOptions.slice(0, 3), + { ...fiveOptions[3], disabled: true }, + ...fiveOptions.slice(4), +] +export const WithDisabledOption = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptionsWithFourthDisabled} + /> + ) +} + +export const WithClearButton = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} + +export const WithFilterField = () => { + const [value, setValue] = useState('') + const [filterValue, setFilterValue] = useState('') + const filteredOptions = useMemo(() => { + return filterValue + ? fiveOptions.filter( + ({ label, value }) => + label.match(new RegExp(filterValue), 'i') && value !== '' + ) + : fiveOptions + }, [filterValue]) + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + filterable + filterPlaceholder="Filter placeholder" + filterValue={filterValue} + onFilterChange={setFilterValue} + noMatchText="No results for your filter" + options={filteredOptions} + /> + ) +} + +export const DefaultPosition = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( +
+
+ option.value === value) + .label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> +
+
+ ) +} + +export const FlippedPosition = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( +
+
+ option.value === value) + .label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> +
+
+ ) +} + +export const ShiftedIntoView = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( + <> + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + + + + ) +} From c5dc2666336e68b36e2e6edfcb75867bc109ae06 Mon Sep 17 00:00:00 2001 From: Mohammer5 Date: Wed, 16 Oct 2024 10:17:19 +0800 Subject: [PATCH 05/39] chore(select a11y): add translations file --- components/select/i18n/en.pot | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/components/select/i18n/en.pot b/components/select/i18n/en.pot index 9696225e8..68097623e 100644 --- a/components/select/i18n/en.pot +++ b/components/select/i18n/en.pot @@ -5,20 +5,26 @@ msgstr "" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1)\n" -"POT-Creation-Date: 2021-05-10T11:56:32.496Z\n" -"PO-Revision-Date: 2021-05-10T11:56:32.496Z\n" +"POT-Creation-Date: 2024-10-10T14:39:31.815Z\n" +"PO-Revision-Date: 2024-10-10T14:39:31.816Z\n" msgid "Clear" -msgstr "" +msgstr "Clear" msgid "No data found" -msgstr "" +msgstr "No data found" msgid "Type to filter options" -msgstr "" +msgstr "Type to filter options" msgid "Loading options" -msgstr "" +msgstr "Loading options" msgid "No options found" -msgstr "" +msgstr "No options found" + +msgid "Search options" +msgstr "Search options" + +msgid "{{clearText}}" +msgstr "{{clearText}}" From a1bea036cf83dfdc7b1fb2a787db1a62fd14116a Mon Sep 17 00:00:00 2001 From: Mohammer5 Date: Wed, 16 Oct 2024 10:17:41 +0800 Subject: [PATCH 06/39] test(select a11y): add tests (cypress & jest) for --- .../features/position.feature | 26 ++ .../features/position/index.js | 126 ++++++ .../single-select-a11y.e2e.stories.js | 157 +++++++ .../single-select-a11y.test.js | 409 ++++++++++++++++++ cypress/support/e2e.js | 1 + package.json | 8 +- yarn.lock | 372 +++++++++++++--- 7 files changed, 1039 insertions(+), 60 deletions(-) create mode 100644 components/select/src/single-select-a11y/features/position.feature create mode 100644 components/select/src/single-select-a11y/features/position/index.js create mode 100644 components/select/src/single-select-a11y/single-select-a11y.e2e.stories.js create mode 100644 components/select/src/single-select-a11y/single-select-a11y.test.js diff --git a/components/select/src/single-select-a11y/features/position.feature b/components/select/src/single-select-a11y/features/position.feature new file mode 100644 index 000000000..791f22929 --- /dev/null +++ b/components/select/src/single-select-a11y/features/position.feature @@ -0,0 +1,26 @@ +Feature: Position of SingleSelect menu dropdown + + Scenario: Default rendering + Given there is enough space below the anchor to fit the SingleSelect menu + When the SingleSelect is clicked + Then the top of the menu is aligned with the bottom of the input + And the left of the SingleSelect is aligned with the left of the anchor + + Scenario: Flipped rendering when insufficient space below + Given there is not enough space below the anchor to fit the SingleSelect menu + When the SingleSelect is clicked + Then the bottom of the menu is aligned with the top of the input + And the left of the SingleSelect is aligned with the left of the anchor + + Scenario: Shifting into view when insufficient space below and above + Given there is not enough space above or below the anchor to fit the SingleSelect menu + When the SingleSelect is clicked + Then it is rendered on top of the SingleSelect + And the left of the SingleSelect is aligned with the left of the anchor + + Scenario: Staying in position during when the window is scrolled + Given there is enough space below the anchor to fit the SingleSelect menu + When the SingleSelect is clicked + And the window is scrolled down + Then the top of the menu is aligned with the bottom of the input + And the left of the SingleSelect is aligned with the left of the anchor diff --git a/components/select/src/single-select-a11y/features/position/index.js b/components/select/src/single-select-a11y/features/position/index.js new file mode 100644 index 000000000..6d9962d85 --- /dev/null +++ b/components/select/src/single-select-a11y/features/position/index.js @@ -0,0 +1,126 @@ +import { Given, Then, When } from '@badeball/cypress-cucumber-preprocessor' + +Given( + 'there is enough space below the anchor to fit the SingleSelect menu', + () => { + cy.visitStory('Single Select A11y', 'Default position') + } +) + +Given( + 'there is not enough space below the anchor to fit the SingleSelect menu', + () => { + cy.visitStory('Single Select A11y', 'Flipped position') + } +) + +Given( + 'there is not enough space above or below the anchor to fit the SingleSelect menu', + () => { + cy.visitStory('Single Select A11y', 'Shifted into view') + } +) + +When('the SingleSelect is clicked', () => { + cy.findByRole('combobox').click() +}) + +When('the window is scrolled down', () => { + // Ensure the body can scroll first + cy.get('body').then(($body) => $body.height('5000px')) + cy.scrollTo(0, 800) +}) + +Then('the top of the menu is aligned with the bottom of the input', () => { + const selectDataTest = '[data-test="dhis2-uicore-singleselect"]' + const menuDataTest = '[data-test="dhis2-uicore-select-menu-menuwrapper"]' + + cy.all( + () => cy.findByRole('combobox'), + () => cy.findByRole('listbox') + ).should(([selectedValue, menu]) => { + expect(selectedValue.length).to.equal(1) + expect(menu.length).to.equal(1) + + const $selectedValue = selectedValue[0] + const $menu = menu[0] + + const selectedValueRect = $selectedValue.getBoundingClientRect() + const menuRect = $menu.getBoundingClientRect() + + // The listbox is inside a container with a border, + // hence the increased delta + expect(menuRect.top).to.be.closeTo(selectedValueRect.bottom, 2) + }) +}) + +Then('the bottom of the menu is aligned with the top of the input', () => { + const selectDataTest = '[data-test="dhis2-uicore-singleselect"]' + const menuDataTest = '[data-test="dhis2-uicore-select-menu-menuwrapper"]' + + cy.all( + () => cy.findByRole('combobox'), + // We need to get the parent as the menu itself + // extends withing the div container + () => cy.findByRole('listbox').invoke('parent') + ).should(([selectedValue, menu]) => { + expect(selectedValue.length).to.equal(1) + expect(menu.length).to.equal(1) + + const $selectedValue = selectedValue[0] + const $menu = menu[0] + + const selectedValueRect = $selectedValue.getBoundingClientRect() + const menuRect = $menu.getBoundingClientRect() + + expect(selectedValueRect.top).to.be.closeTo(menuRect.bottom, 1) + }) +}) + +Then('it is rendered on top of the SingleSelect', () => { + const selectDataTest = '[data-test="dhis2-uicore-singleselect"]' + const menuDataTest = '[data-test="dhis2-uicore-select-menu-menuwrapper"]' + + cy.all( + () => cy.findByRole('combobox'), + () => cy.findByRole('listbox') + ).should(([selectedValue, menu]) => { + expect(selectedValue.length).to.equal(1) + expect(menu.length).to.equal(1) + + const $selectedValue = selectedValue[0] + const $menu = menu[0] + + const selectedValueRect = $selectedValue.getBoundingClientRect() + const menuRect = $menu.getBoundingClientRect() + + expect(selectedValueRect.top).to.be.greaterThan(menuRect.top) + expect(menuRect.bottom).to.be.greaterThan(selectedValueRect.bottom) + }) +}) + +Then( + 'the left of the SingleSelect is aligned with the left of the anchor', + () => { + const selectDataTest = '[data-test="dhis2-uicore-singleselect"]' + const menuDataTest = '[data-test="dhis2-uicore-select-menu-menuwrapper"]' + + cy.all( + () => cy.findByRole('combobox'), + () => cy.findByRole('listbox') + ).should(([selectedValue, menu]) => { + expect(selectedValue.length).to.equal(1) + expect(menu.length).to.equal(1) + + const $selectedValue = selectedValue[0] + const $menu = menu[0] + + const selectedValueRect = $selectedValue.getBoundingClientRect() + const menuRect = $menu.getBoundingClientRect() + + // The listbox is inside a container with a border, + // hence the increased delta + expect(selectedValueRect.left).to.be.closeTo(menuRect.left, 2) + }) + } +) diff --git a/components/select/src/single-select-a11y/single-select-a11y.e2e.stories.js b/components/select/src/single-select-a11y/single-select-a11y.e2e.stories.js new file mode 100644 index 000000000..f54a8bbf5 --- /dev/null +++ b/components/select/src/single-select-a11y/single-select-a11y.e2e.stories.js @@ -0,0 +1,157 @@ +import React, { useEffect, useMemo, useState } from 'react' +import { SingleSelectA11y } from './single-select-a11y.js' + +export default { + title: 'Single Select A11y', + component: SingleSelectA11y, +} + +const options = [ + { + label: 'ANC 1st visit', + value: 'anc_1st_visit', + }, + { + label: 'ANC 2nd visit', + value: 'anc_2nd_visit', + }, + { + label: 'ANC 3rd visit', + value: 'anc_3rd_visit', + }, + { + label: 'ANC 4th or more visits', + value: 'anc_4th_or_more_visits', + }, + { + label: 'ARI treated with antibiotics (pneumonia) follow-up', + value: 'ari_treated_with_antibiotics_(pneumonia)_follow-up', + }, + { + label: 'ARI treated with antibiotics (pneumonia) new', + value: 'ari_treated_with_antibiotics_(pneumonia)_new', + }, + { + label: 'ARI treated with antibiotics (pneumonia) referrals', + value: 'ari_treated_with_antibiotics_(pneumonia)_referrals', + }, + { + label: 'ARI treated without antibiotics (cough) follow-up', + value: 'ari_treated_without_antibiotics_(cough)_follow-up', + }, + { + label: 'ARI treated without antibiotics (cough) new', + value: 'ari_treated_without_antibiotics_(cough)_new', + }, + { + label: 'ARI treated without antibiotics (cough) referrals', + value: 'ari_treated_without_antibiotics_(cough)_referrals', + }, + { + label: 'ART No clients who stopped TRT due to TRT failure', + value: 'art_no_clients_who_stopped_trt_due_to_trt_failure', + }, + { + label: 'ART No clients who stopped TRT due to adverse clinical status/event', + value: 'art_no_clients_who_stopped_trt_due_to_adverse_clinical_status/event', + }, + { + label: 'ART No clients with change of regimen due to drug toxicity', + value: 'art_no_clients_with_change_of_regimen_due_to_drug_toxicity', + }, + { + label: 'ART No clients with new adverse drug reaction', + value: 'art_no_clients_with_new_adverse_drug_reaction', + }, + { + label: 'ART No started Opportunist Infection prophylaxis', + value: 'art_no_started_opportunist_infection_prophylaxis', + }, + { + label: 'ART clients with new adverse clinical event', + value: 'art_clients_with_new_adverse_clinical_event', + }, + { + label: 'ART defaulters', + value: 'art_defaulters', + }, + { + label: 'ART enrollment stage 1', + value: 'art_enrollment_stage_1', + }, + { + label: 'ART enrollment stage 2', + value: 'art_enrollment_stage_2', + }, + { + label: 'ART enrollment stage 3', + value: 'art_enrollment_stage_3', + }, + { + label: 'ART enrollment stage 4', + value: 'art_enrollment_stage_4', + }, + { + label: 'ART entry point: No PMTCT', + value: 'art_entry_point:_no_pmtct', + }, +] + +const fiveOptions = options.slice(0, 5) + +export const DefaultPosition = () => ( + null} + options={fiveOptions} + /> +) + +export const FlippedPosition = () => ( + <> + null} + options={options} + /> + + + +) + +export const ShiftedIntoView = () => ( + <> + null} + options={options} + /> + + + +) diff --git a/components/select/src/single-select-a11y/single-select-a11y.test.js b/components/select/src/single-select-a11y/single-select-a11y.test.js new file mode 100644 index 000000000..80c9f3578 --- /dev/null +++ b/components/select/src/single-select-a11y/single-select-a11y.test.js @@ -0,0 +1,409 @@ +import '@testing-library/jest-dom/extend-expect' +import { render, fireEvent, screen } from '@testing-library/react' +import React from 'react' +import { SingleSelectA11y } from './single-select-a11y.js' + +describe('', () => { + it('should accept an onBlur handler', () => { + const onBlur = jest.fn() + + render( + null} + options={[{ value: 'foo', label: 'Foo' }]} + /> + ) + + fireEvent.blur(screen.getByRole('combobox')) + + // first argument passed to onBlur is a react event + expect(onBlur.mock.calls[0][0].hasOwnProperty('nativeEvent')).toBe(true) + expect(onBlur).toHaveBeenCalledTimes(1) + }) + + it('should accept an onFocus handler', () => { + const onFocus = jest.fn() + + render( + null} + options={[{ value: 'foo', label: 'Foo' }]} + /> + ) + + fireEvent.focus(screen.getByRole('combobox')) + + // first argument passed to onFocus is a react event + expect(onFocus.mock.calls[0][0].hasOwnProperty('nativeEvent')).toBe(true) + expect(onFocus).toHaveBeenCalledTimes(1) + }) + + it('should accept autoFocus', () => { + const onFocus = jest.fn() + + render( + null} + options={[{ value: 'foo', label: 'Foo' }]} + /> + ) + + expect(onFocus).toHaveBeenCalledTimes(1) + }) + + it('should accept loading', () => { + render( + null} + options={[{ value: 'foo', label: 'Foo' }]} + /> + ) + + fireEvent.click(screen.getByRole('combobox')) + + const loadingIndicator = screen.getByRole('progressbar') + + expect(loadingIndicator).toBeTruthy() + }) + + it('should display a loading text', () => { + render( + null} + options={[{ value: 'foo', label: 'Foo' }]} + /> + ) + + fireEvent.click(screen.getByRole('combobox')) + + const loadingIndicator = screen.getByRole('progressbar') + expect(loadingIndicator).toBeTruthy() + + const loadingText = screen.getByText('Loading text') + expect(loadingText).toBeTruthy() + }) + + it('should accept a max height', () => { + render( + null} + options={[{ value: 'foo', label: 'Foo' }]} + /> + ) + + fireEvent.click(screen.getByRole('combobox')) + + const listbox = screen.getByRole('listbox') + const listboxContainer = listbox.parentNode + expect(listboxContainer.style.maxHeight).toBe('100px') + }) + + it('should accept a placeholder', () => { + render( + null} + options={[{ value: 'foo', label: 'Foo' }]} + /> + ) + + const placeholder = screen.getByText('Placeholder text') + const dataTestValue = placeholder.attributes.getNamedItem('data-test').value + expect(dataTestValue).toBe('dhis2-singleselecta11y-selectedvalue-placeholder') + }) + + it('should accept a prefix', () => { + render( + null} + options={[{ value: 'foo', label: 'Foo' }]} + /> + ) + + const placeholder = screen.getByText('Prefix text') + const dataTestValue = placeholder.attributes.getNamedItem('data-test').value + expect(dataTestValue).toBe('dhis2-singleselecta11y-selectedvalue-prefix') + }) + + it('should allow options to be selected', () => { + const onChange = jest.fn() + + render( + + ) + + fireEvent.click(screen.getByRole('combobox')) + + const option = screen.getByText('Foo').parentNode + expect(option.attributes.getNamedItem('role').value).toBe('option') + + fireEvent.click(option) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith("foo") + }) + + it('should allow custom options to be selected', () => { + const onChange = jest.fn() + const CustomOption = ({ value, label }) => ( + + {label} + + ) + + render( + + ) + + fireEvent.click(screen.getByRole('combobox')) + + const customOption = screen.getByTestId('custom-option-foo') + const option = screen.getByTestId('custom-option-foo').parentNode + expect(option.attributes.getNamedItem('role').value).toBe('option') + + fireEvent.click(customOption) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith("foo") + }) + + it('should be clearable when there is a selected value', () => { + const onChange = jest.fn() + + render( + + ) + + const clearButton = screen.getByLabelText('Clear a11y select') + expect(clearButton).toBeInstanceOf(HTMLElement) + + fireEvent.click(clearButton) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith('') + }) + + it('should not be clearable when there is no selected value', () => { + render( + + ) + + const clearButton = screen.queryByLabelText('Clear a11y select') + expect(clearButton).toBeNull() + }) + + it('should be disabled', () => { + render( + + ) + + fireEvent.click(screen.getByRole('combobox')) + + // Element is in the dom, but invisible, which makes it inaccessible + const listbox = screen.queryByRole('listbox') + expect(listbox).toBeNull() + + const clearButton = screen.queryByLabelText('Clear a11y select') + expect(clearButton).toBeNull() + }) + + it('should have an empty-text in the menu', () => { + render( + Empty

} + idPrefix="a11y" + value="" + valueLabel="" + onChange={jest.fn()} + options={[]} + /> + ) + + const emptyTextBeforeOpen = screen.queryByText('Empty') + expect(emptyTextBeforeOpen).not.toBeVisible() + + fireEvent.click(screen.getByRole('combobox')) + + const emptyTextAfterOpen = screen.queryByText('Empty') + expect(emptyTextAfterOpen).toBeVisible() + }) + + it('should provide updates about the filter value', () => { + const onFilterChange = jest.fn() + + render( + + ) + + expect(screen.queryByLabelText('Search options')).not.toBeVisible() + + fireEvent.click(screen.getByRole('combobox')) + + const searchInput = screen.getByLabelText('Search options') + expect(searchInput).toBeInstanceOf(HTMLInputElement) + expect(searchInput).toBeVisible() + + fireEvent.change(searchInput, { target: { value: 'Search term'} }) + + expect(onFilterChange).toHaveBeenCalledTimes(1) + expect(onFilterChange).toHaveBeenCalledWith('Search term') + }) + + it('should apply a custom filter label', () => { + const onFilterChange = jest.fn() + + render( + + ) + + expect(screen.getByLabelText('Custom filter label')).not.toBeNull() + }) + + it('should not allow duplicate option values', () => { + const onFilterChange = jest.fn() + const consoleError = jest.fn() + + jest.spyOn(console, 'error').mockImplementation(consoleError) + + render( + + ) + + // @TODO: For some reason this is called three times + // Is this because of unnecessary re-renders? + expect(consoleError).toHaveBeenNthCalledWith( + 1, + expect.stringContaining('Encountered two children with the same key'), + 'foo', + expect.anything() + ) + + console.error.mockRestore() + }) + + it('should display the selected option', () => { + const onChange = jest.fn() + + render( + + ) + + const combobox = screen.getByRole('combobox') + + const withTextBar = screen.getByText('Bar', { + selector: '.selected-option-label', + }) + + expect(combobox).toContainElement(withTextBar) + }) +}) diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js index 4329ceeff..ea339804a 100644 --- a/cypress/support/e2e.js +++ b/cypress/support/e2e.js @@ -1,4 +1,5 @@ import '@dhis2/cypress-commands' +import '@testing-library/cypress/add-commands' // Add additional support functions here import './all.js' diff --git a/package.json b/package.json index c2c9d733a..12798e582 100644 --- a/package.json +++ b/package.json @@ -64,9 +64,11 @@ "@dhis2/cypress-plugins": "^10.0.5", "@manypkg/cli": "^0.18.0", "@svgr/cli": "^5.5.0", + "@testing-library/cypress": "^8", "@testing-library/dom": "^10.4.0", "@testing-library/jest-dom": "^6.6.2", "@testing-library/react": "^16.0.1", + "@testing-library/react-hooks": "^7.0.1", "@testing-library/user-event": "^14.5.2", "@types/react": "^18", "ast-types": "^0.14.2", @@ -81,8 +83,8 @@ "rimraf": "^3.0.2", "wait-on": "^6.0.0" }, - "overrides": { - "react": "$react", - "react-dom": "$react" + "resolutions": { + "react": "16.13.1", + "react-dom": "16.13.1" } } diff --git a/yarn.lock b/yarn.lock index ce477e43e..a84aeb118 100644 --- a/yarn.lock +++ b/yarn.lock @@ -205,7 +205,21 @@ js-tokens "^4.0.0" picocolors "^1.0.0" -"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.25.2", "@babel/compat-data@^7.25.9": +"@babel/code-frame@^7.24.7": + version "7.26.2" + resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.26.2.tgz#4b5fab97d33338eff916235055f0ebc21e573a85" + integrity sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ== + dependencies: + "@babel/helper-validator-identifier" "^7.25.9" + js-tokens "^4.0.0" + picocolors "^1.0.0" + +"@babel/compat-data@^7.22.6", "@babel/compat-data@^7.25.2": + version "7.25.2" + resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.2.tgz" + integrity sha512-bYcppcpKBvX4znYaPEeFau03bp89ShqNMLs+rmdptMw+heSZh9+z84d2YG+K7cYLbWwzdjtDoW/uqZmPjulClQ== + +"@babel/compat-data@^7.25.9": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.26.0.tgz#f02ba6d34e88fadd5e8861e8b38902f43cc1c819" integrity sha512-qETICbZSLe7uXv9VE8T/RWOdIE5qqyTucOt4zLYMafj2MRO271VGgLd4RACJMeBO37UPWhXiKMBk7YlJ0fOzQA== @@ -232,7 +246,28 @@ semver "^5.4.1" source-map "^0.5.0" -"@babel/core@^7.1.0", "@babel/core@^7.1.6", "@babel/core@^7.11.1", "@babel/core@^7.12.3", "@babel/core@^7.16.0", "@babel/core@^7.18.6", "@babel/core@^7.18.9", "@babel/core@^7.19.6", "@babel/core@^7.24.4", "@babel/core@^7.25.2", "@babel/core@^7.6.2", "@babel/core@^7.7.2", "@babel/core@^7.7.5", "@babel/core@^7.8.0": +"@babel/core@^7.1.0", "@babel/core@^7.1.6", "@babel/core@^7.11.1", "@babel/core@^7.12.3", "@babel/core@^7.16.0", "@babel/core@^7.18.6", "@babel/core@^7.18.9", "@babel/core@^7.19.6", "@babel/core@^7.24.4", "@babel/core@^7.6.2", "@babel/core@^7.7.2", "@babel/core@^7.7.5", "@babel/core@^7.8.0": + version "7.25.2" + resolved "https://registry.npmjs.org/@babel/core/-/core-7.25.2.tgz" + integrity sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA== + dependencies: + "@ampproject/remapping" "^2.2.0" + "@babel/code-frame" "^7.24.7" + "@babel/generator" "^7.25.0" + "@babel/helper-compilation-targets" "^7.25.2" + "@babel/helper-module-transforms" "^7.25.2" + "@babel/helpers" "^7.25.0" + "@babel/parser" "^7.25.0" + "@babel/template" "^7.25.0" + "@babel/traverse" "^7.25.2" + "@babel/types" "^7.25.2" + convert-source-map "^2.0.0" + debug "^4.1.0" + gensync "^1.0.0-beta.2" + json5 "^2.2.3" + semver "^6.3.1" + +"@babel/core@^7.25.2": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.26.0.tgz#d78b6023cc8f3114ccf049eb219613f74a747b40" integrity sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg== @@ -273,6 +308,17 @@ "@jridgewell/trace-mapping" "^0.3.25" jsesc "^3.0.2" +"@babel/generator@^7.25.0": + version "7.26.2" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.26.2.tgz#87b75813bec87916210e5e01939a4c823d6bb74f" + integrity sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw== + dependencies: + "@babel/parser" "^7.26.2" + "@babel/types" "^7.26.0" + "@jridgewell/gen-mapping" "^0.3.5" + "@jridgewell/trace-mapping" "^0.3.25" + jsesc "^3.0.2" + "@babel/helper-annotate-as-pure@^7.18.6", "@babel/helper-annotate-as-pure@^7.24.7": version "7.24.7" resolved "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.24.7.tgz" @@ -340,7 +386,15 @@ "@babel/traverse" "^7.24.8" "@babel/types" "^7.24.8" -"@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.24.7", "@babel/helper-module-imports@^7.25.9": +"@babel/helper-module-imports@^7.10.4", "@babel/helper-module-imports@^7.24.7": + version "7.24.7" + resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.24.7.tgz" + integrity sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA== + dependencies: + "@babel/traverse" "^7.24.7" + "@babel/types" "^7.24.7" + +"@babel/helper-module-imports@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz#e7f8d20602ebdbf9ebbea0a0751fb0f2a4141715" integrity sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw== @@ -348,7 +402,17 @@ "@babel/traverse" "^7.25.9" "@babel/types" "^7.25.9" -"@babel/helper-module-transforms@^7.12.1", "@babel/helper-module-transforms@^7.24.7", "@babel/helper-module-transforms@^7.24.8", "@babel/helper-module-transforms@^7.25.0", "@babel/helper-module-transforms@^7.26.0": +"@babel/helper-module-transforms@^7.12.1", "@babel/helper-module-transforms@^7.24.7", "@babel/helper-module-transforms@^7.24.8", "@babel/helper-module-transforms@^7.25.0": + version "7.25.2" + resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz" + integrity sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ== + dependencies: + "@babel/helper-module-imports" "^7.24.7" + "@babel/helper-simple-access" "^7.24.7" + "@babel/helper-validator-identifier" "^7.24.7" + "@babel/traverse" "^7.25.2" + +"@babel/helper-module-transforms@^7.25.2", "@babel/helper-module-transforms@^7.26.0": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz#8ce54ec9d592695e58d84cd884b7b5c6a2fdeeae" integrity sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw== @@ -408,17 +472,27 @@ "@babel/traverse" "^7.24.7" "@babel/types" "^7.24.7" -"@babel/helper-string-parser@^7.25.9": +"@babel/helper-string-parser@^7.24.8", "@babel/helper-string-parser@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== -"@babel/helper-validator-identifier@^7.14.9", "@babel/helper-validator-identifier@^7.24.7", "@babel/helper-validator-identifier@^7.25.9": +"@babel/helper-validator-identifier@^7.14.9", "@babel/helper-validator-identifier@^7.24.7": + version "7.24.7" + resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.24.7.tgz" + integrity sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w== + +"@babel/helper-validator-identifier@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7" integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== -"@babel/helper-validator-option@^7.24.7", "@babel/helper-validator-option@^7.24.8", "@babel/helper-validator-option@^7.25.9": +"@babel/helper-validator-option@^7.24.7", "@babel/helper-validator-option@^7.24.8": + version "7.24.8" + resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz" + integrity sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q== + +"@babel/helper-validator-option@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz#86e45bd8a49ab7e03f276577f96179653d41da72" integrity sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw== @@ -432,7 +506,7 @@ "@babel/traverse" "^7.25.0" "@babel/types" "^7.25.0" -"@babel/helpers@^7.12.5", "@babel/helpers@^7.26.0": +"@babel/helpers@^7.12.5", "@babel/helpers@^7.25.0", "@babel/helpers@^7.26.0": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.26.0.tgz#30e621f1eba5aa45fe6f4868d2e9154d884119a4" integrity sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw== @@ -450,7 +524,21 @@ js-tokens "^4.0.0" picocolors "^1.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.1.6", "@babel/parser@^7.12.7", "@babel/parser@^7.14.7", "@babel/parser@^7.17.0", "@babel/parser@^7.18.8", "@babel/parser@^7.20.7", "@babel/parser@^7.25.9", "@babel/parser@^7.26.0", "@babel/parser@^7.7.0", "@babel/parser@^7.9.4": +"@babel/parser@^7.1.0", "@babel/parser@^7.1.6", "@babel/parser@^7.12.7", "@babel/parser@^7.14.7", "@babel/parser@^7.17.0", "@babel/parser@^7.18.8", "@babel/parser@^7.20.7", "@babel/parser@^7.7.0", "@babel/parser@^7.9.4": + version "7.25.3" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz" + integrity sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw== + dependencies: + "@babel/types" "^7.25.2" + +"@babel/parser@^7.25.0", "@babel/parser@^7.25.3", "@babel/parser@^7.26.2": + version "7.26.2" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.2.tgz#fd7b6f487cfea09889557ef5d4eeb9ff9a5abd11" + integrity sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ== + dependencies: + "@babel/types" "^7.26.0" + +"@babel/parser@^7.25.9", "@babel/parser@^7.26.0": version "7.26.1" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.1.tgz#44e02499960df2cdce2c456372a3e8e0c3c5c975" integrity sha512-reoQYNiAJreZNsJzyrDNzFQ+IQ5JFiIzAHJg9bn94S3l+4++J7RsIhNMoB+lgP/9tpmiAQqspv+xfdxTSzREOw== @@ -1385,7 +1473,23 @@ dependencies: regenerator-runtime "^0.14.0" -"@babel/template@^7.12.7", "@babel/template@^7.24.7", "@babel/template@^7.25.0", "@babel/template@^7.25.9", "@babel/template@^7.3.3": +"@babel/runtime@^7.14.6": + version "7.25.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.7.tgz#7ffb53c37a8f247c8c4d335e89cdf16a2e0d0fb6" + integrity sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w== + dependencies: + regenerator-runtime "^0.14.0" + +"@babel/template@^7.12.7", "@babel/template@^7.24.7", "@babel/template@^7.25.0", "@babel/template@^7.3.3": + version "7.25.0" + resolved "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz" + integrity sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q== + dependencies: + "@babel/code-frame" "^7.24.7" + "@babel/parser" "^7.25.0" + "@babel/types" "^7.25.0" + +"@babel/template@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.25.9.tgz#ecb62d81a8a6f5dc5fe8abfc3901fc52ddf15016" integrity sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg== @@ -1394,7 +1498,20 @@ "@babel/parser" "^7.25.9" "@babel/types" "^7.25.9" -"@babel/traverse@^7.1.6", "@babel/traverse@^7.12.9", "@babel/traverse@^7.18.8", "@babel/traverse@^7.18.9", "@babel/traverse@^7.24.7", "@babel/traverse@^7.24.8", "@babel/traverse@^7.25.0", "@babel/traverse@^7.25.1", "@babel/traverse@^7.25.3", "@babel/traverse@^7.25.9", "@babel/traverse@^7.7.0", "@babel/traverse@^7.7.2": +"@babel/traverse@^7.1.6", "@babel/traverse@^7.12.9", "@babel/traverse@^7.18.8", "@babel/traverse@^7.18.9", "@babel/traverse@^7.24.7", "@babel/traverse@^7.24.8", "@babel/traverse@^7.25.0", "@babel/traverse@^7.25.1", "@babel/traverse@^7.25.3", "@babel/traverse@^7.7.0", "@babel/traverse@^7.7.2": + version "7.25.3" + resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.3.tgz" + integrity sha512-HefgyP1x754oGCsKmV5reSmtV7IXj/kpaE1XYY+D9G5PvKKoFfSbiS4M77MdjuwlZKDIKFCffq9rPU+H/s3ZdQ== + dependencies: + "@babel/code-frame" "^7.24.7" + "@babel/generator" "^7.25.0" + "@babel/parser" "^7.25.3" + "@babel/template" "^7.25.0" + "@babel/types" "^7.25.2" + debug "^4.3.1" + globals "^11.1.0" + +"@babel/traverse@^7.25.2", "@babel/traverse@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84" integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw== @@ -1415,7 +1532,16 @@ "@babel/helper-validator-identifier" "^7.14.9" to-fast-properties "^2.0.0" -"@babel/types@^7.0.0", "@babel/types@^7.12.6", "@babel/types@^7.12.7", "@babel/types@^7.18.9", "@babel/types@^7.2.0", "@babel/types@^7.20.0", "@babel/types@^7.20.7", "@babel/types@^7.24.7", "@babel/types@^7.24.8", "@babel/types@^7.25.0", "@babel/types@^7.25.2", "@babel/types@^7.25.9", "@babel/types@^7.26.0", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0": +"@babel/types@^7.0.0", "@babel/types@^7.12.6", "@babel/types@^7.12.7", "@babel/types@^7.18.9", "@babel/types@^7.2.0", "@babel/types@^7.20.0", "@babel/types@^7.20.7", "@babel/types@^7.24.7", "@babel/types@^7.24.8", "@babel/types@^7.25.0", "@babel/types@^7.25.2", "@babel/types@^7.3.3", "@babel/types@^7.4.4", "@babel/types@^7.7.0": + version "7.25.2" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz" + integrity sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q== + dependencies: + "@babel/helper-string-parser" "^7.24.8" + "@babel/helper-validator-identifier" "^7.24.7" + to-fast-properties "^2.0.0" + +"@babel/types@^7.25.9", "@babel/types@^7.26.0": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.26.0.tgz#deabd08d6b753bc8e0f198f8709fb575e31774ff" integrity sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA== @@ -1830,11 +1956,16 @@ dependencies: "@cucumber/messages" ">=19.1.4 <=24" -"@cucumber/html-formatter@21.3.1", "@cucumber/html-formatter@^21.3.1": +"@cucumber/html-formatter@21.3.1": version "21.3.1" resolved "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-21.3.1.tgz" integrity sha512-M1zbre7e8MsecXheqNv62BKY5J06YJSv1LmsD7sJ3mu5t1jirLjj2It1HqPsX5CQAfg9n69xFRugPgLMSte9TA== +"@cucumber/html-formatter@^21.3.1": + version "21.4.0" + resolved "https://registry.npmjs.org/@cucumber/html-formatter/-/html-formatter-21.4.0.tgz" + integrity sha512-Vg8FpBFWUl6tXWdh+4Hk4Pf8r7KJhkNxMRULCLtjrIgM3cjap/jbc0hxiX6ta7a54CpUPAmmUOURTxuqsm64lQ== + "@cucumber/message-streams@4.0.1", "@cucumber/message-streams@^4.0.1": version "4.0.1" resolved "https://registry.npmjs.org/@cucumber/message-streams/-/message-streams-4.0.1.tgz" @@ -5013,6 +5144,14 @@ resolved "https://registry.npmjs.org/@teppeis/multimaps/-/multimaps-3.0.0.tgz" integrity sha512-ID7fosbc50TbT0MK0EG12O+gAP3W3Aa/Pz4DaTtQtEvlc9Odaqi0de+xuZ7Li2GtK4HzEX7IuRWS/JmZLksR3Q== +"@testing-library/cypress@^8": + version "8.0.7" + resolved "https://registry.yarnpkg.com/@testing-library/cypress/-/cypress-8.0.7.tgz#18315eba3cf8852808afadf122e4858406384015" + integrity sha512-3HTV725rOS+YHve/gD9coZp/UcPK5xhr4H0GMnq/ni6USdtzVtSOG9WBFtd8rYnrXk8rrGD+0toRFYouJNIG0Q== + dependencies: + "@babel/runtime" "^7.14.6" + "@testing-library/dom" "^8.1.0" + "@testing-library/dom@^10.4.0": version "10.4.0" resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-10.4.0.tgz#82a9d9462f11d240ecadbf406607c6ceeeff43a8" @@ -5027,6 +5166,20 @@ lz-string "^1.5.0" pretty-format "^27.0.2" +"@testing-library/dom@^8.1.0": + version "8.20.1" + resolved "https://registry.npmjs.org/@testing-library/dom/-/dom-8.20.1.tgz" + integrity sha512-/DiOQ5xBxgdYRC8LNk7U+RWat0S3qRLeIw3ZIkMQ9kkVlRmwD/Eg8k8CqIpD6GW7u20JIUOfMKbxtiLutpjQ4g== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^5.0.1" + aria-query "5.1.3" + chalk "^4.1.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.5.0" + pretty-format "^27.0.2" + "@testing-library/jest-dom@^6.6.2": version "6.6.3" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz#26ba906cf928c0f8172e182c6fe214eb4f9f2bd2" @@ -5040,6 +5193,17 @@ lodash "^4.17.21" redent "^3.0.0" +"@testing-library/react-hooks@^7.0.1": + version "7.0.2" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-7.0.2.tgz#3388d07f562d91e7f2431a4a21b5186062ecfee0" + integrity sha512-dYxpz8u9m4q1TuzfcUApqi8iFfR6R0FaMbr2hjZJy1uC8z+bO/K4v8Gs9eogGKYQop7QsrBTFkv/BCF7MzD2Cg== + dependencies: + "@babel/runtime" "^7.12.5" + "@types/react" ">=16.9.0" + "@types/react-dom" ">=16.9.0" + "@types/react-test-renderer" ">=16.9.0" + react-error-boundary "^3.1.0" + "@testing-library/react@^16.0.1": version "16.0.1" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.0.1.tgz#29c0ee878d672703f5e7579f239005e4e0faa875" @@ -5372,6 +5536,13 @@ resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== +"@types/react-dom@>=16.9.0": + version "18.3.1" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.3.1.tgz#1e4654c08a9cdcfb6594c780ac59b55aad42fe07" + integrity sha512-qW1Mfv8taImTthu4KoXgDfLuk4bydU6Q/TkADnDWWHwi4NX4BR+LWfTp2sVmTqRrsHvyDDTelgelxJ+SsejKKQ== + dependencies: + "@types/react" "*" + "@types/react-router-config@*", "@types/react-router-config@^5.0.6": version "5.0.11" resolved "https://registry.npmjs.org/@types/react-router-config/-/react-router-config-5.0.11.tgz" @@ -5398,13 +5569,19 @@ "@types/history" "^4.7.11" "@types/react" "*" -"@types/react@*": - version "17.0.80" - resolved "https://registry.npmjs.org/@types/react/-/react-17.0.80.tgz" - integrity sha512-LrgHIu2lEtIo8M7d1FcI3BdwXWoRQwMoXOZ7+dPTW0lYREjmlHl3P0U1VD0i/9tppOuv8/sam7sOjx34TxSFbA== +"@types/react-test-renderer@>=16.9.0": + version "18.3.0" + resolved "https://registry.npmjs.org/@types/react-test-renderer/-/react-test-renderer-18.3.0.tgz" + integrity sha512-HW4MuEYxfDbOHQsVlY/XtOvNHftCVEPhJF2pQXXwcUiUF+Oyb0usgp48HSgpK5rt8m9KZb22yqOeZm+rrVG8gw== + dependencies: + "@types/react" "*" + +"@types/react@*", "@types/react@>=16.9.0": + version "18.3.3" + resolved "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz" + integrity sha512-hti/R0pS0q1/xx+TsI73XIqk26eBsISZ2R0wUijXIngRK9R/e7Xw/cXVxQK7R5JjW+SV4zGcn5hXjudkN/pLIw== dependencies: "@types/prop-types" "*" - "@types/scheduler" "^0.16" csstype "^3.0.2" "@types/react@^18": @@ -5444,11 +5621,6 @@ dependencies: "@types/node" "*" -"@types/scheduler@^0.16": - version "0.16.8" - resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.8.tgz#ce5ace04cfeabe7ef87c0091e50752e36707deff" - integrity sha512-WZLiwShhwLRmeV6zH+GkbOFT6Z6VklCItrDioxUnv+u4Ll+8vKeFySoFyK/0ctcRpOmwAicELfmys1sDc/Rw+A== - "@types/semver@^6.0.1": version "6.2.7" resolved "https://registry.npmjs.org/@types/semver/-/semver-6.2.7.tgz" @@ -5897,11 +6069,16 @@ acorn@^8.0.4, acorn@^8.11.0, acorn@^8.12.1, acorn@^8.2.4, acorn@^8.7.1, acorn@^8 resolved "https://registry.npmjs.org/acorn/-/acorn-8.12.1.tgz" integrity sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg== -address@1.1.2, address@^1.0.1, address@^1.1.2: +address@1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/address/-/address-1.1.2.tgz" integrity sha512-aT6camzM4xEA54YVJYSqxz1kv4IHnQZRtThJJHhUMRExaU5spC7jX5ugSwTaTgJliIgs4VhZOk7htClvQ/LmRA== +address@^1.0.1, address@^1.1.2: + version "1.2.2" + resolved "https://registry.npmjs.org/address/-/address-1.2.2.tgz" + integrity sha512-4B/qKCfeE/ODUaAUpSwfzazo5x29WD4r3vXiWsB7I2mSDAihwEqKO+g8GELZUQSSAo5e1XTYh3ZVfLyxBc12nA== + adjust-sourcemap-loader@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/adjust-sourcemap-loader/-/adjust-sourcemap-loader-4.0.0.tgz" @@ -6173,20 +6350,20 @@ argparse@^2.0.1: resolved "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz" integrity sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q== -aria-query@5.3.0: - version "5.3.0" - resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" - integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== - dependencies: - dequal "^2.0.3" - -aria-query@^5.0.0, aria-query@~5.1.3: +aria-query@5.1.3, aria-query@~5.1.3: version "5.1.3" resolved "https://registry.npmjs.org/aria-query/-/aria-query-5.1.3.tgz" integrity sha512-R5iJ5lkuHybztUfuOAznmboyjWq8O6sqNqtK7CLOqdydi54VNbORp49mb14KbWgG1QD3JFO9hJdZ+y4KutfdOQ== dependencies: deep-equal "^2.0.5" +aria-query@5.3.0, aria-query@^5.0.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.3.0.tgz#650c569e41ad90b51b3d7df5e5eed1c7549c103e" + integrity sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A== + dependencies: + dequal "^2.0.3" + arr-diff@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz" @@ -7534,7 +7711,7 @@ cli-cursor@^3.1.0: dependencies: restore-cursor "^3.1.0" -cli-table3@0.6.3, cli-table3@^0.6.0, cli-table3@^0.6.2, cli-table3@~0.6.1: +cli-table3@0.6.3: version "0.6.3" resolved "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz" integrity sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg== @@ -7543,6 +7720,15 @@ cli-table3@0.6.3, cli-table3@^0.6.0, cli-table3@^0.6.2, cli-table3@~0.6.1: optionalDependencies: "@colors/colors" "1.5.0" +cli-table3@^0.6.0, cli-table3@^0.6.2, cli-table3@~0.6.1: + version "0.6.5" + resolved "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.5.tgz" + integrity sha512-+W/5efTR7y5HRD7gACw9yQjqMVvEMLBHmboM/kPWam+H+Hmyrgjh6YncVKK122YZkXrLudzTuAukUw9FnMf7IQ== + dependencies: + string-width "^4.2.0" + optionalDependencies: + "@colors/colors" "1.5.0" + cli-table@^0.3.11: version "0.3.11" resolved "https://registry.npmjs.org/cli-table/-/cli-table-0.3.11.tgz" @@ -8007,13 +8193,18 @@ conventional-commits-parser@^3.0.0: split2 "^3.0.0" through2 "^4.0.0" -convert-source-map@1.7.0, convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: +convert-source-map@1.7.0: version "1.7.0" resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.7.0.tgz" integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA== dependencies: safe-buffer "~5.1.1" +convert-source-map@^1.4.0, convert-source-map@^1.5.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: + version "1.9.0" + resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz" + integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== + convert-source-map@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz" @@ -8068,11 +8259,16 @@ core-js@^3.19.2, core-js@^3.23.3: resolved "https://registry.npmjs.org/core-js/-/core-js-3.37.1.tgz" integrity sha512-Xn6qmxrQZyB0FFY8E3bgRXei3lWDJHhvI+u0q9TKIYM49G8pAr0FgnnrFRAmsbptZL1yxRADVXn+x5AGsbBfyw== -core-util-is@1.0.2, core-util-is@~1.0.0: +core-util-is@1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz" integrity sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ== +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + cors@latest: version "2.8.5" resolved "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz" @@ -10178,11 +10374,16 @@ extract-zip@2.0.1: optionalDependencies: "@types/yauzl" "^2.9.1" -extsprintf@1.3.0, extsprintf@^1.2.0: +extsprintf@1.3.0: version "1.3.0" resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.3.0.tgz" integrity sha512-11Ndz7Nv+mvAC1j0ktTa7fAb0vLyGGX+rMHNBYQviQDGU0Hw7lhctJANqbPhu9nV9/izT/IntTgZ7Im/9LJs9g== +extsprintf@^1.2.0: + version "1.4.1" + resolved "https://registry.npmjs.org/extsprintf/-/extsprintf-1.4.1.tgz" + integrity sha512-Wrk35e8ydCKDj/ArClo1VrPVmN8zph5V4AtHwIuHhvMXsKf73UT3BOD+azBIW+3wOJ4FhEH7zyaJCFvChjYvMA== + fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: version "3.1.3" resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" @@ -10932,15 +11133,15 @@ glob@^10.3.10, glob@^10.4.1: package-json-from-dist "^1.0.0" path-scurry "^1.11.1" -glob@^7.0.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.5, glob@^7.1.6, glob@^7.1.7, glob@~7.1.1: - version "7.1.7" - resolved "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz" - integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== +glob@^7.0.0, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, glob@^7.1.5, glob@^7.1.6, glob@^7.1.7: + version "7.2.3" + resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== dependencies: fs.realpath "^1.0.0" inflight "^1.0.4" inherits "2" - minimatch "^3.0.4" + minimatch "^3.1.1" once "^1.3.0" path-is-absolute "^1.0.0" @@ -10955,6 +11156,18 @@ glob@^8.1.0: minimatch "^5.0.1" once "^1.3.0" +glob@~7.1.1: + version "7.1.7" + resolved "https://registry.npmjs.org/glob/-/glob-7.1.7.tgz" + integrity sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.0.4" + once "^1.3.0" + path-is-absolute "^1.0.0" + global-dirs@^0.1.0, global-dirs@^0.1.1: version "0.1.1" resolved "https://registry.npmjs.org/global-dirs/-/global-dirs-0.1.1.tgz" @@ -14040,11 +14253,16 @@ miller-rabin@^4.0.0: bn.js "^4.0.0" brorand "^1.0.1" -mime-db@1.52.0, "mime-db@>= 1.43.0 < 2": +mime-db@1.52.0: version "1.52.0" resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== +"mime-db@>= 1.43.0 < 2": + version "1.53.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.53.0.tgz" + integrity sha512-oHlN/w+3MQ3rba9rqFr6V/ypF10LSkdwUysQL7GkXoTgIWeV+tcXGA852TBxH+gsh8UWoyhR1hKcoMJTuWflpg== + mime-db@~1.33.0: version "1.33.0" resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.33.0.tgz" @@ -14102,14 +14320,14 @@ minimalistic-crypto-utils@^1.0.1: resolved "https://registry.yarnpkg.com/minimalistic-crypto-utils/-/minimalistic-crypto-utils-1.0.1.tgz#f6c00c1c0b082246e5c4d99dfb8c7c083b2b582a" integrity sha512-JIYlbt6g8i5jKfJ3xz7rF0LXmv2TkDxBLUkiBeZ7bAx4GnnNMr8xFpGnOxn6GhTEHx3SjRrZEoU+j04prX1ktg== -minimatch@3.0.4, minimatch@~3.0.2: +minimatch@3.0.4: version "3.0.4" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.4.tgz" integrity sha512-yJHVQEhyqPLUTgt9B83PXu6W3rx4MvvHvSUvToogpwoGDOUQ+yDrR0HRot+yOCdCO7u4hX3pWft6kWBBcqh0UA== dependencies: brace-expansion "^1.1.7" -minimatch@3.1.2, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.2: +minimatch@3.1.2, minimatch@^3.0.2, minimatch@^3.0.4, minimatch@^3.0.5, minimatch@^3.1.1, minimatch@^3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz" integrity sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw== @@ -14130,6 +14348,13 @@ minimatch@^9.0.4: dependencies: brace-expansion "^2.0.1" +minimatch@~3.0.2: + version "3.0.8" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-3.0.8.tgz" + integrity sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q== + dependencies: + brace-expansion "^1.1.7" + minimist-options@4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz" @@ -16229,13 +16454,22 @@ react-docgen@^7.0.0: resolve "^1.22.1" strip-indent "^4.0.0" -"react-dom@^16.8.0 || ^17.0.0 || ^18.0.0", react-dom@^18, react-dom@^18.3.1: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.3.1.tgz#c2265d79511b57d479b3dd3fdfa51536494c5cb4" - integrity sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw== +react-dom@16.13.1, "react-dom@^16.8.0 || ^17.0.0 || ^18.0.0", react-dom@^18, react-dom@^18.3.1: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-16.13.1.tgz#c1bd37331a0486c078ee54c4740720993b2e0e7f" + integrity sha512-81PIMmVLnCNLO/fFOQxdQkvEq/+Hfpv24XNJfpyZhTRfO0QcmQIF/PgCa1zCOj2w1hrn12MFLyaJ/G0+Mxtfag== dependencies: loose-envify "^1.1.0" - scheduler "^0.23.2" + object-assign "^4.1.1" + prop-types "^15.6.2" + scheduler "^0.19.1" + +react-error-boundary@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" react-error-overlay@^6.0.11, react-error-overlay@^6.0.7: version "6.0.11" @@ -16267,7 +16501,7 @@ react-helmet-async@*, react-helmet-async@^1.3.0: "react-is@^16.12.0 || ^17.0.0 || ^18.0.0", react-is@^18.2.0: version "18.3.1" - resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e" + resolved "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz" integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg== react-is@^16.13.1, react-is@^16.6.0, react-is@^16.7.0: @@ -16449,12 +16683,14 @@ react-textarea-autosize@^8.3.2: use-composed-ref "^1.3.0" use-latest "^1.2.1" -"react@^16.8.0 || ^17.0.0 || ^18.0.0", react@^18, react@^18.3.1: - version "18.3.1" - resolved "https://registry.yarnpkg.com/react/-/react-18.3.1.tgz#49ab892009c53933625bd16b2533fc754cab2891" - integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== +react@16.13.1, "react@^16.8.0 || ^17.0.0 || ^18.0.0", react@^18, react@^18.3.1: + version "16.13.1" + resolved "https://registry.yarnpkg.com/react/-/react-16.13.1.tgz#2e818822f1a9743122c063d6410d85c1e3afe48e" + integrity sha512-YMZQQq32xHLX0bz5Mnibv1/LHb3Sqzngu7xstSM+vrkE5Kzr9xE0yMByK5kMoTK30YVJE61WfbxIFFvfeDKT1w== dependencies: loose-envify "^1.1.0" + object-assign "^4.1.1" + prop-types "^15.6.2" read-cache@^1.0.0: version "1.0.0" @@ -16563,13 +16799,20 @@ rechoir@^0.6.2: dependencies: resolve "^1.1.6" -recursive-readdir@2.2.2, recursive-readdir@^2.2.2: +recursive-readdir@2.2.2: version "2.2.2" resolved "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.2.tgz" integrity sha512-nRCcW9Sj7NuZwa2XvH9co8NPeXUBhZP7CRKJtU+cS6PW9FpCIFoI5ib0NT1ZrbNuPoRy0ylyCaUL8Gih4LSyFg== dependencies: minimatch "3.0.4" +recursive-readdir@^2.2.2: + version "2.2.3" + resolved "https://registry.npmjs.org/recursive-readdir/-/recursive-readdir-2.2.3.tgz" + integrity sha512-8HrF5ZsXk5FAH9dgsx3BlUer73nIhuj+9OrQwEbLTPOBzGkL1lsFCR01am+v+0m2Cmbs1nP12hLDl5FA7EszKA== + dependencies: + minimatch "^3.0.5" + redent@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz" @@ -17164,7 +17407,7 @@ safe-buffer@5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== -safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@^5.2.1: +safe-buffer@5.2.1, safe-buffer@>=5.1.0, safe-buffer@^5.0.1, safe-buffer@^5.1.0, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@^5.2.0, safe-buffer@^5.2.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== @@ -17215,6 +17458,14 @@ saxes@^5.0.1: dependencies: xmlchars "^2.2.0" +scheduler@^0.19.1: + version "0.19.1" + resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.19.1.tgz#4f3e2ed2c1a7d65681f4c854fa8c5a1ccb40f196" + integrity sha512-n/zwRWRYSUj0/3g/otKDRPMh6qv2SYMWNq85IEa8iZyAv8od9zDYpGSnpBEjNgcMNq6Scbu5KfIPxNF72R/2EA== + dependencies: + loose-envify "^1.1.0" + object-assign "^4.1.1" + scheduler@^0.23.2: version "0.23.2" resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.2.tgz#414ba64a3b282892e944cf2108ecc078d115cdc3" @@ -18124,7 +18375,14 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -string_decoder@^1.1.1, string_decoder@~1.1.1: +string_decoder@^1.1.1: + version "1.3.0" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== From 3d5021fe320a9cc8599481933bb9b841e64146d3 Mon Sep 17 00:00:00 2001 From: Mohammer5 Date: Thu, 17 Oct 2024 17:52:42 +0800 Subject: [PATCH 07/39] chore(storybook): use prod stories only when starting yarn from a workspace --- storybook/src/load-stories.js | 8 ++++---- yarn.lock | 38 ++++++++++++++++++++++------------- 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/storybook/src/load-stories.js b/storybook/src/load-stories.js index fbc03dc36..ab8d6caf6 100644 --- a/storybook/src/load-stories.js +++ b/storybook/src/load-stories.js @@ -31,7 +31,7 @@ exports.loadStories = () => { curcomp, 'src', '**', - '*.stories.@(js|jsx|mdx)' + '*.prod.stories.@(js|jsx|mdx)' ), ] } @@ -44,7 +44,7 @@ exports.loadStories = () => { curcomp, 'src', '**', - '*.stories.@(js|jsx|mdx)' + '*.prod.stories.@(js|jsx|mdx)' ), ] } @@ -52,7 +52,7 @@ exports.loadStories = () => { case icons.includes(curcomp): { console.info(`custom => Loading stories for '${curcomp}'`) return [ - path.join(ICONS_DIR, 'src', '**', '*.stories.@(js|jsx|mdx)'), + path.join(ICONS_DIR, 'src', '**', '*.prod.stories.@(js|jsx|mdx)'), ] } case constants.includes(curcomp): { @@ -62,7 +62,7 @@ exports.loadStories = () => { CONSTANTS_DIR, 'src', '**', - '*.stories.@(js|jsx|mdx)' + '*.prod.stories.@(js|jsx|mdx)' ), ] } diff --git a/yarn.lock b/yarn.lock index a84aeb118..34ab7bc05 100644 --- a/yarn.lock +++ b/yarn.lock @@ -402,7 +402,7 @@ "@babel/traverse" "^7.25.9" "@babel/types" "^7.25.9" -"@babel/helper-module-transforms@^7.12.1", "@babel/helper-module-transforms@^7.24.7", "@babel/helper-module-transforms@^7.24.8", "@babel/helper-module-transforms@^7.25.0": +"@babel/helper-module-transforms@^7.12.1", "@babel/helper-module-transforms@^7.24.7", "@babel/helper-module-transforms@^7.24.8", "@babel/helper-module-transforms@^7.25.0", "@babel/helper-module-transforms@^7.25.2": version "7.25.2" resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz" integrity sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ== @@ -412,7 +412,7 @@ "@babel/helper-validator-identifier" "^7.24.7" "@babel/traverse" "^7.25.2" -"@babel/helper-module-transforms@^7.25.2", "@babel/helper-module-transforms@^7.26.0": +"@babel/helper-module-transforms@^7.26.0": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz#8ce54ec9d592695e58d84cd884b7b5c6a2fdeeae" integrity sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw== @@ -433,7 +433,12 @@ resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.10.4.tgz" integrity sha512-O4KCvQA6lLiMU9l2eawBPMf1xPP8xPfB3iEQw150hOVTqj/rfXz0ThTb4HEzqQfs2Bmo5Ay8BzxfzVtBrr9dVg== -"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.20.2", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.24.7", "@babel/helper-plugin-utils@^7.24.8", "@babel/helper-plugin-utils@^7.25.9", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": +"@babel/helper-plugin-utils@^7.0.0", "@babel/helper-plugin-utils@^7.10.4", "@babel/helper-plugin-utils@^7.12.13", "@babel/helper-plugin-utils@^7.14.5", "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.20.2", "@babel/helper-plugin-utils@^7.22.5", "@babel/helper-plugin-utils@^7.24.7", "@babel/helper-plugin-utils@^7.24.8", "@babel/helper-plugin-utils@^7.8.0", "@babel/helper-plugin-utils@^7.8.3": + version "7.24.8" + resolved "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz" + integrity sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg== + +"@babel/helper-plugin-utils@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz#9cbdd63a9443a2c92a725cca7ebca12cc8dd9f46" integrity sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw== @@ -472,7 +477,12 @@ "@babel/traverse" "^7.24.7" "@babel/types" "^7.24.7" -"@babel/helper-string-parser@^7.24.8", "@babel/helper-string-parser@^7.25.9": +"@babel/helper-string-parser@^7.24.8": + version "7.24.8" + resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz" + integrity sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ== + +"@babel/helper-string-parser@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== @@ -524,20 +534,13 @@ js-tokens "^4.0.0" picocolors "^1.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.1.6", "@babel/parser@^7.12.7", "@babel/parser@^7.14.7", "@babel/parser@^7.17.0", "@babel/parser@^7.18.8", "@babel/parser@^7.20.7", "@babel/parser@^7.7.0", "@babel/parser@^7.9.4": +"@babel/parser@^7.1.0", "@babel/parser@^7.1.6", "@babel/parser@^7.12.7", "@babel/parser@^7.14.7", "@babel/parser@^7.17.0", "@babel/parser@^7.18.8", "@babel/parser@^7.20.7", "@babel/parser@^7.25.0", "@babel/parser@^7.25.3", "@babel/parser@^7.7.0", "@babel/parser@^7.9.4": version "7.25.3" resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz" integrity sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw== dependencies: "@babel/types" "^7.25.2" -"@babel/parser@^7.25.0", "@babel/parser@^7.25.3", "@babel/parser@^7.26.2": - version "7.26.2" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.2.tgz#fd7b6f487cfea09889557ef5d4eeb9ff9a5abd11" - integrity sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ== - dependencies: - "@babel/types" "^7.26.0" - "@babel/parser@^7.25.9", "@babel/parser@^7.26.0": version "7.26.1" resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.1.tgz#44e02499960df2cdce2c456372a3e8e0c3c5c975" @@ -545,6 +548,13 @@ dependencies: "@babel/types" "^7.26.0" +"@babel/parser@^7.26.2": + version "7.26.2" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.26.2.tgz#fd7b6f487cfea09889557ef5d4eeb9ff9a5abd11" + integrity sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ== + dependencies: + "@babel/types" "^7.26.0" + "@babel/plugin-bugfix-firefox-class-in-computed-class-key@^7.25.3": version "7.25.3" resolved "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.3.tgz" @@ -1498,7 +1508,7 @@ "@babel/parser" "^7.25.9" "@babel/types" "^7.25.9" -"@babel/traverse@^7.1.6", "@babel/traverse@^7.12.9", "@babel/traverse@^7.18.8", "@babel/traverse@^7.18.9", "@babel/traverse@^7.24.7", "@babel/traverse@^7.24.8", "@babel/traverse@^7.25.0", "@babel/traverse@^7.25.1", "@babel/traverse@^7.25.3", "@babel/traverse@^7.7.0", "@babel/traverse@^7.7.2": +"@babel/traverse@^7.1.6", "@babel/traverse@^7.12.9", "@babel/traverse@^7.18.8", "@babel/traverse@^7.18.9", "@babel/traverse@^7.24.7", "@babel/traverse@^7.24.8", "@babel/traverse@^7.25.0", "@babel/traverse@^7.25.1", "@babel/traverse@^7.25.2", "@babel/traverse@^7.25.3", "@babel/traverse@^7.7.0", "@babel/traverse@^7.7.2": version "7.25.3" resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.3.tgz" integrity sha512-HefgyP1x754oGCsKmV5reSmtV7IXj/kpaE1XYY+D9G5PvKKoFfSbiS4M77MdjuwlZKDIKFCffq9rPU+H/s3ZdQ== @@ -1511,7 +1521,7 @@ debug "^4.3.1" globals "^11.1.0" -"@babel/traverse@^7.25.2", "@babel/traverse@^7.25.9": +"@babel/traverse@^7.25.9": version "7.25.9" resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.25.9.tgz#a50f8fe49e7f69f53de5bea7e413cd35c5e13c84" integrity sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw== From 285fa70ebeaced950e288ee19d516976cbf13aaa Mon Sep 17 00:00:00 2001 From: Mohammer5 Date: Thu, 17 Oct 2024 17:53:17 +0800 Subject: [PATCH 08/39] feat(select a11y): add some keyboard handling --- .../single-select-a11y/menu-options-list.js | 7 +- .../select/src/single-select-a11y/menu.js | 11 +- .../select/src/single-select-a11y/option.js | 29 +-- .../selected-value-container.js | 8 +- .../src/single-select-a11y/selected-value.js | 35 ++-- .../single-select-a11y/single-select-a11y.js | 59 +++++- .../single-select-a11y.prod.stories.js | 40 ++-- .../single-select-a11y.test.js | 185 ++++++++++++++++-- .../use-handle-key-press.js | 148 ++++++++++++++ 9 files changed, 448 insertions(+), 74 deletions(-) create mode 100644 components/select/src/single-select-a11y/use-handle-key-press.js diff --git a/components/select/src/single-select-a11y/menu-options-list.js b/components/select/src/single-select-a11y/menu-options-list.js index e2bf65a4a..e90086185 100644 --- a/components/select/src/single-select-a11y/menu-options-list.js +++ b/components/select/src/single-select-a11y/menu-options-list.js @@ -37,6 +37,7 @@ Loading.propTypes = { export function MenuOptionsList({ comboBoxId, + focussedOptionIndex, idPrefix, labelledBy, options, @@ -53,12 +54,11 @@ export function MenuOptionsList({ return (
@@ -78,13 +78,13 @@ export function MenuOptionsList({ return (
) diff --git a/components/select/src/single-select-a11y/selected-value.js b/components/select/src/single-select-a11y/selected-value.js index 8d18527dd..52512fb1a 100644 --- a/components/select/src/single-select-a11y/selected-value.js +++ b/components/select/src/single-select-a11y/selected-value.js @@ -85,9 +85,17 @@ export function SelectedValue({ )} -
+
+ ) diff --git a/components/select/src/single-select-a11y/single-select-a11y.js b/components/select/src/single-select-a11y/single-select-a11y.js index b30710664..374cac8c8 100644 --- a/components/select/src/single-select-a11y/single-select-a11y.js +++ b/components/select/src/single-select-a11y/single-select-a11y.js @@ -68,15 +68,20 @@ export function SingleSelectA11y({ // Using `useState` here so components get notified when the value changes (from null -> div) const comboBoxRef = useRef() - const [focussedOptionIndex, setFocussedOptionIndex] = useState(() => { - const foundIndex = options.findIndex((option) => option.value === value) - - return foundIndex !== -1 ? foundIndex : 0 - }) + const [focussedOptionIndex, setFocussedOptionIndex] = useState(0) const [selectRef, setSelectRef] = useState() const [expanded, setExpanded] = useState(false) const closeMenu = useCallback(() => setExpanded(false), []) - const openMenu = useCallback(() => setExpanded(true), []) + const openMenu = useCallback(() => { + const selectedOptionIndex = options.findIndex( + (option) => option.value === value + ) + if (selectedOptionIndex !== focussedOptionIndex) { + setFocussedOptionIndex(selectedOptionIndex) + } + + setExpanded(true) + }, [options, value, focussedOptionIndex]) const toggleMenu = useCallback(() => { if (expanded) { closeMenu() diff --git a/components/select/src/single-select-a11y/single-select-a11y.prod.stories.js b/components/select/src/single-select-a11y/single-select-a11y.prod.stories.js index 9f4112e89..9f87eaeb2 100644 --- a/components/select/src/single-select-a11y/single-select-a11y.prod.stories.js +++ b/components/select/src/single-select-a11y/single-select-a11y.prod.stories.js @@ -427,7 +427,7 @@ export const WithOptionsAndLoadingText = () => { } export const WithManyOptions = () => { - const [value, setValue] = useState('') + const [value, setValue] = useState('art_entry_point:_no_pmtct') return ( { - if (value) { + if (value && value !== prevValueRef.current) { + // We only want to do this when the value changed + prevValueRef.current = value + const optionIndex = options.findIndex((option) => option.label.toLowerCase().startsWith(value.toLowerCase()) ) if (optionIndex !== -1) { - setFocussedOptionIndex(optionIndex) + if (expanded) { + setFocussedOptionIndex(optionIndex) + } else { + const nextSelectedOption = options[optionIndex] + onChange(nextSelectedOption.value) + } } } - }, [value, options, setFocussedOptionIndex]) + }, [value, options, setFocussedOptionIndex, expanded, onChange]) const onTyping = useCallback((e) => { const { key } = e @@ -78,24 +85,33 @@ export function useHandleKeyPress({ onChange, }) { const { onTyping, typing } = useHandleTyping({ + expanded, options, setFocussedOptionIndex, - listboxHTMLElement: null, // @TODO + onChange, }) - console.log('> typing:', typing) - const selectNextOption = useCallback(() => { - if (focussedOptionIndex < options.length - 1) { - onChange(options[focussedOptionIndex + 1].value) + const currentOptionIndex = options.findIndex( + (option) => option.value === value + ) + const nextSelectedOption = options[currentOptionIndex + 1] + + if (nextSelectedOption) { + onChange(nextSelectedOption.value) } - }, [focussedOptionIndex, options, onChange]) + }, [options, onChange, value]) const selectPrevOption = useCallback(() => { - if (focussedOptionIndex > 0) { - onChange(options[focussedOptionIndex - 1].value) + const currentOptionIndex = options.findIndex( + (option) => option.value === value + ) + const nextSelectedOption = options[currentOptionIndex - 1] + + if (nextSelectedOption) { + onChange(nextSelectedOption.value) } - }, [focussedOptionIndex, options, onChange]) + }, [options, onChange, value]) const focusNextOption = useCallback(() => { if (focussedOptionIndex < options.length - 1) { From 79034103f60d799d9d397136170e2970e9e38d3f Mon Sep 17 00:00:00 2001 From: Mohammer5 Date: Mon, 21 Oct 2024 18:39:31 +0800 Subject: [PATCH 12/39] feat(select a11y): handle pageUp and pageDown keys --- .../src/single-select-a11y/menu/index.js | 1 + .../{ => menu}/menu-filter.js | 2 +- .../{ => menu}/menu-loading.js | 0 .../{ => menu}/menu-options-list.js | 51 ++++----- .../src/single-select-a11y/{ => menu}/menu.js | 12 +- .../single-select-a11y/{ => menu}/option.js | 1 + .../selected-value/index.js | 1 + .../selected-value-clear-button.js | 2 +- .../selected-value-container.js | 0 .../selected-value-placeholder.js | 0 .../selected-value-prefix.js | 0 .../{ => selected-value}/selected-value.js | 0 .../single-select-a11y/shared-prop-types.js | 14 +-- .../single-select-a11y/single-select-a11y.js | 16 +-- .../single-select-a11y.prod.stories.js | 39 +++++-- .../use-handle-key-press/index.js | 1 + .../use-handle-key-press.js | 90 +++------------ .../use-handle-key-press/use-handle-typing.js | 74 +++++++++++++ .../use-handle-key-press/use-page-up-down.js | 103 ++++++++++++++++++ 19 files changed, 275 insertions(+), 132 deletions(-) create mode 100644 components/select/src/single-select-a11y/menu/index.js rename components/select/src/single-select-a11y/{ => menu}/menu-filter.js (97%) rename components/select/src/single-select-a11y/{ => menu}/menu-loading.js (100%) rename components/select/src/single-select-a11y/{ => menu}/menu-options-list.js (78%) rename components/select/src/single-select-a11y/{ => menu}/menu.js (91%) rename components/select/src/single-select-a11y/{ => menu}/option.js (98%) create mode 100644 components/select/src/single-select-a11y/selected-value/index.js rename components/select/src/single-select-a11y/{ => selected-value}/selected-value-clear-button.js (98%) rename components/select/src/single-select-a11y/{ => selected-value}/selected-value-container.js (100%) rename components/select/src/single-select-a11y/{ => selected-value}/selected-value-placeholder.js (100%) rename components/select/src/single-select-a11y/{ => selected-value}/selected-value-prefix.js (100%) rename components/select/src/single-select-a11y/{ => selected-value}/selected-value.js (100%) create mode 100644 components/select/src/single-select-a11y/use-handle-key-press/index.js rename components/select/src/single-select-a11y/{ => use-handle-key-press}/use-handle-key-press.js (67%) create mode 100644 components/select/src/single-select-a11y/use-handle-key-press/use-handle-typing.js create mode 100644 components/select/src/single-select-a11y/use-handle-key-press/use-page-up-down.js diff --git a/components/select/src/single-select-a11y/menu/index.js b/components/select/src/single-select-a11y/menu/index.js new file mode 100644 index 000000000..bf206853e --- /dev/null +++ b/components/select/src/single-select-a11y/menu/index.js @@ -0,0 +1 @@ +export { Menu } from './menu.js' diff --git a/components/select/src/single-select-a11y/menu-filter.js b/components/select/src/single-select-a11y/menu/menu-filter.js similarity index 97% rename from components/select/src/single-select-a11y/menu-filter.js rename to components/select/src/single-select-a11y/menu/menu-filter.js index 4cc218188..a4ec8102b 100644 --- a/components/select/src/single-select-a11y/menu-filter.js +++ b/components/select/src/single-select-a11y/menu/menu-filter.js @@ -2,7 +2,7 @@ import { colors, spacers } from '@dhis2/ui-constants' import { Input } from '@dhis2-ui/input' import PropTypes from 'prop-types' import React from 'react' -import i18n from '../locales/index.js' +import i18n from '../../locales/index.js' export function MenuFilter({ value, onChange, dataTest, placeholder, label }) { return ( diff --git a/components/select/src/single-select-a11y/menu-loading.js b/components/select/src/single-select-a11y/menu/menu-loading.js similarity index 100% rename from components/select/src/single-select-a11y/menu-loading.js rename to components/select/src/single-select-a11y/menu/menu-loading.js diff --git a/components/select/src/single-select-a11y/menu-options-list.js b/components/select/src/single-select-a11y/menu/menu-options-list.js similarity index 78% rename from components/select/src/single-select-a11y/menu-options-list.js rename to components/select/src/single-select-a11y/menu/menu-options-list.js index 5dae04aca..e69072146 100644 --- a/components/select/src/single-select-a11y/menu-options-list.js +++ b/components/select/src/single-select-a11y/menu/menu-options-list.js @@ -1,31 +1,32 @@ import PropTypes from 'prop-types' -import React, { useEffect, useRef } from 'react' -import { isOptionHidden } from './is-option-hidden.js' +import React, { forwardRef, useEffect } from 'react' +import { isOptionHidden } from '../is-option-hidden.js' +import { optionProp } from '../shared-prop-types.js' import { Option } from './option.js' -import { optionsProp } from './shared-prop-types.js' - -export function MenuOptionsList({ - comboBoxId, - expanded, - focussedOptionIndex, - idPrefix, - labelledBy, - options, - selected, - dataTest, - disabled, - loading, - onChange, - onBlur, - onKeyDown, -}) { - const listBoxRef = useRef() +export const MenuOptionsList = forwardRef(function MenuOptionsList( + { + comboBoxId, + expanded, + focussedOptionIndex, + idPrefix, + labelledBy, + options, + selected, + dataTest, + disabled, + loading, + onChange, + onBlur, + onKeyDown, + }, + ref +) { // scrolls the highlighted option into view when: // * the highlighted option changes // * the menu opens useEffect(() => { - const { current: listBox } = listBoxRef + const { current: listBox } = ref const highlightedOption = expanded ? listBox.childNodes[focussedOptionIndex] : null @@ -41,11 +42,11 @@ export function MenuOptionsList({ highlightedOption.scrollIntoView() } } - }, [expanded, focussedOptionIndex]) + }, [expanded, focussedOptionIndex, ref]) return (
) -} +}) MenuOptionsList.propTypes = { comboBoxId: PropTypes.string.isRequired, expanded: PropTypes.bool.isRequired, focussedOptionIndex: PropTypes.number.isRequired, idPrefix: PropTypes.string.isRequired, - options: optionsProp.isRequired, + options: PropTypes.arrayOf(optionProp).isRequired, onChange: PropTypes.func.isRequired, dataTest: PropTypes.string, disabled: PropTypes.bool, diff --git a/components/select/src/single-select-a11y/menu.js b/components/select/src/single-select-a11y/menu/menu.js similarity index 91% rename from components/select/src/single-select-a11y/menu.js rename to components/select/src/single-select-a11y/menu/menu.js index 5a4b49064..aa52c5129 100644 --- a/components/select/src/single-select-a11y/menu.js +++ b/components/select/src/single-select-a11y/menu/menu.js @@ -4,10 +4,10 @@ import { Popper } from '@dhis2-ui/popper' import cx from 'classnames' import PropTypes from 'prop-types' import React, { useEffect, useState } from 'react' +import { optionProp } from '../shared-prop-types.js' import { MenuFilter } from './menu-filter.js' import { MenuLoading } from './menu-loading.js' import { MenuOptionsList } from './menu-options-list.js' -import { optionsProp } from './shared-prop-types.js' export function Menu({ comboBoxId, @@ -24,6 +24,7 @@ export function Menu({ filterable, hidden, labelledBy, + listBoxRef, loading, loadingText, maxHeight, @@ -66,6 +67,7 @@ export function Menu({ {!options.length &&
{empty}
} (
) @@ -216,29 +208,61 @@ SingleSelectA11y.propTypes = { /** This will allow us to put an aria-label on the clear button **/ clearText: requiredIf((props) => props.clearable, PropTypes.string), - /** Whether a clear button should be displayed or not **/ + /** Whether a clear button should be displayed or not */ clearable: PropTypes.bool, - /** A value for a `data-test` attribute on the root element **/ + /** A value for a `data-test` attribute on the root element */ dataTest: PropTypes.string, + /** Renders a select with lower height **/ dense: PropTypes.bool, + + /** Disables all interactions with the select (except focussing) */ disabled: PropTypes.bool, + + /** Text or component to display when there are no options */ empty: PropTypes.node, + + /** Applies 'error' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props */ error: sharedPropTypes.statusPropType, + + /** Value will be used as aria-label attribute on the filter input **/ filterLabel: PropTypes.string, + + /** Placeholder for the filter input **/ filterPlaceholder: PropTypes.string, + + /** Value of the filter input **/ filterValue: PropTypes.string, + + /** Whether the select should display a filter input **/ filterable: PropTypes.bool, - inputMaxHeight: PropTypes.string, + + /** Should contain the id of the element that labels the select, if applicable **/ labelledBy: PropTypes.string, + + /** Will show a loading indicator at the end of the options-list **/ loading: PropTypes.bool, + + /** Text that will be displayed next to the loading indicator **/ menuLoadingText: PropTypes.string, + + /** Allows to modify the max height of the menu **/ menuMaxHeight: PropTypes.string, + + /** String that will be displayed when the select is being filtered but the options array is empty **/ noMatchText: requiredIf((props) => props.filterable, PropTypes.string), + + /** String to show when there's no value and no valueLabel **/ placeholder: PropTypes.string, + + /** String that will be displayed before the label of the selected option **/ prefix: PropTypes.string, + + /** Standard HTML tab-index attribute that will be put on the combobox's root element **/ tabIndex: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), + + /** Applies 'valid' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props */ valid: sharedPropTypes.statusPropType, /** @@ -254,9 +278,15 @@ SingleSelectA11y.propTypes = { return props.value }, PropTypes.string), + /** Applies 'warning' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props */ warning: sharedPropTypes.statusPropType, + + /** Will be called when the combobox is loses focus */ onBlur: PropTypes.func, + + /** Will be called when the filter value changes **/ onFilterChange: PropTypes.func, + + /** Will be called when the combobox is being focused */ onFocus: PropTypes.func, - onKeyDown: PropTypes.func, } From ee3ab423fced35ad9146f47118465d810b665bf3 Mon Sep 17 00:00:00 2001 From: Mohammer5 Date: Mon, 28 Oct 2024 17:46:30 +0800 Subject: [PATCH 21/39] feat(select a11y): allow to specify custom option generally --- .../menu/menu-options-list.js | 4 ++- .../src/single-select-a11y/menu/menu.js | 3 ++ .../single-select-a11y/single-select-a11y.js | 6 ++++ .../single-select-a11y.test.js | 34 +++++++++++++++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/components/select/src/single-select-a11y/menu/menu-options-list.js b/components/select/src/single-select-a11y/menu/menu-options-list.js index e784ecc4e..9c296f286 100644 --- a/components/select/src/single-select-a11y/menu/menu-options-list.js +++ b/components/select/src/single-select-a11y/menu/menu-options-list.js @@ -7,6 +7,7 @@ import { Option } from './option.js' export const MenuOptionsList = forwardRef(function MenuOptionsList( { comboBoxId, + customOption, expanded, focussedOptionIndex, idPrefix, @@ -76,7 +77,7 @@ export const MenuOptionsList = forwardRef(function MenuOptionsList( comboBoxId={comboBoxId} disabled={disabled || optionDisabled} onClick={isSelected ? () => null : onChange} - component={component} + component={component || customOption} /> ) } @@ -92,6 +93,7 @@ MenuOptionsList.propTypes = { idPrefix: PropTypes.string.isRequired, options: PropTypes.arrayOf(optionProp).isRequired, onChange: PropTypes.func.isRequired, + customOption: PropTypes.elementType, dataTest: PropTypes.string, disabled: PropTypes.bool, labelledBy: PropTypes.string, diff --git a/components/select/src/single-select-a11y/menu/menu.js b/components/select/src/single-select-a11y/menu/menu.js index aa4dd4bfc..6e508a1e1 100644 --- a/components/select/src/single-select-a11y/menu/menu.js +++ b/components/select/src/single-select-a11y/menu/menu.js @@ -15,6 +15,7 @@ export function Menu({ idPrefix, options, onChange, + customOption, dataTest, disabled, empty, @@ -68,6 +69,7 @@ export function Menu({ ', () => { {label} ) + render( + + ) + + fireEvent.click(screen.getByRole('combobox')) + + const customOption = screen.getByTestId('custom-option-foo') + const option = screen.getByTestId('custom-option-foo').parentNode + expect(option.attributes.getNamedItem('role').value).toBe('option') + + fireEvent.click(customOption) + + expect(onChange).toHaveBeenCalledTimes(1) + expect(onChange).toHaveBeenCalledWith('foo') + }) + + it('should allow individual custom options to be selected', () => { + const onChange = jest.fn() + + // eslint-disable-next-line react/prop-types + const CustomOption = ({ value, label }) => ( + {label} + ) + render( Date: Mon, 28 Oct 2024 17:54:37 +0800 Subject: [PATCH 22/39] feat(select a11y): allow to specify aria-busy update strategy --- .../menu/menu-options-list.js | 4 ++- .../single-select-a11y/single-select-a11y.js | 27 +++++++++++-------- 2 files changed, 19 insertions(+), 12 deletions(-) diff --git a/components/select/src/single-select-a11y/menu/menu-options-list.js b/components/select/src/single-select-a11y/menu/menu-options-list.js index 9c296f286..87a3b21db 100644 --- a/components/select/src/single-select-a11y/menu/menu-options-list.js +++ b/components/select/src/single-select-a11y/menu/menu-options-list.js @@ -12,6 +12,7 @@ export const MenuOptionsList = forwardRef(function MenuOptionsList( focussedOptionIndex, idPrefix, labelledBy, + optionUpdateStrategy, options, selected, dataTest, @@ -50,7 +51,7 @@ export const MenuOptionsList = forwardRef(function MenuOptionsList( role="listbox" id={`${idPrefix}-listbox`} aria-labelledby={labelledBy} - aria-live="polite" + aria-live={optionUpdateStrategy} aria-busy={loading.toString()} data-test={dataTest} onBlur={onBlur} @@ -98,6 +99,7 @@ MenuOptionsList.propTypes = { disabled: PropTypes.bool, labelledBy: PropTypes.string, loading: PropTypes.bool, + optionUpdateStrategy: PropTypes.oneOf(['off', 'polite', 'assertive']), selected: PropTypes.string, onBlur: PropTypes.func, } diff --git a/components/select/src/single-select-a11y/single-select-a11y.js b/components/select/src/single-select-a11y/single-select-a11y.js index 633bfd763..40f5e9fcb 100644 --- a/components/select/src/single-select-a11y/single-select-a11y.js +++ b/components/select/src/single-select-a11y/single-select-a11y.js @@ -32,6 +32,7 @@ export function SingleSelectA11y({ menuLoadingText = '', menuMaxHeight = '288px', noMatchText = '', + optionUpdateStrategy = 'polite', placeholder = '', prefix = '', tabIndex = '0', @@ -173,6 +174,7 @@ export function SingleSelectA11y({ loading={loading} loadingText={menuLoadingText} maxHeight={menuMaxHeight} + optionUpdateStrategy={optionUpdateStrategy} options={options} selectRef={selectRef} selected={value} @@ -195,7 +197,7 @@ SingleSelectA11y.propTypes = { /** An array of options **/ options: PropTypes.arrayOf(optionProp).isRequired, - /** As of now, this component does not support being uncontrolled */ + /** As of now, this component does not support being uncontrolled **/ value: PropTypes.string.isRequired, /** A callback that will be called with the new value or an empty string **/ @@ -210,26 +212,26 @@ SingleSelectA11y.propTypes = { /** This will allow us to put an aria-label on the clear button **/ clearText: requiredIf((props) => props.clearable, PropTypes.string), - /** Whether a clear button should be displayed or not */ + /** Whether a clear button should be displayed or not **/ clearable: PropTypes.bool, /** Allows to override what's rendered inside the `button[role="option"]`. - * Can be overriden on an individual option basis */ + * Can be overriden on an individual option basis **/ customOption: PropTypes.elementType, - /** A value for a `data-test` attribute on the root element */ + /** A value for a `data-test` attribute on the root element **/ dataTest: PropTypes.string, /** Renders a select with lower height **/ dense: PropTypes.bool, - /** Disables all interactions with the select (except focussing) */ + /** Disables all interactions with the select (except focussing) **/ disabled: PropTypes.bool, - /** Text or component to display when there are no options */ + /** Text or component to display when there are no options **/ empty: PropTypes.node, - /** Applies 'error' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props */ + /** Applies 'error' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props **/ error: sharedPropTypes.statusPropType, /** Value will be used as aria-label attribute on the filter input **/ @@ -259,6 +261,9 @@ SingleSelectA11y.propTypes = { /** String that will be displayed when the select is being filtered but the options array is empty **/ noMatchText: requiredIf((props) => props.filterable, PropTypes.string), + /** For a11y: How aggressively the user should be updated about changes in options **/ + optionUpdateStrategy: PropTypes.oneOf(['off', 'polite', 'assertive']), + /** String to show when there's no value and no valueLabel **/ placeholder: PropTypes.string, @@ -268,7 +273,7 @@ SingleSelectA11y.propTypes = { /** Standard HTML tab-index attribute that will be put on the combobox's root element **/ tabIndex: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), - /** Applies 'valid' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props */ + /** Applies 'valid' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props **/ valid: sharedPropTypes.statusPropType, /** @@ -284,15 +289,15 @@ SingleSelectA11y.propTypes = { return props.value }, PropTypes.string), - /** Applies 'warning' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props */ + /** Applies 'warning' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props **/ warning: sharedPropTypes.statusPropType, - /** Will be called when the combobox is loses focus */ + /** Will be called when the combobox is loses focus **/ onBlur: PropTypes.func, /** Will be called when the filter value changes **/ onFilterChange: PropTypes.func, - /** Will be called when the combobox is being focused */ + /** Will be called when the combobox is being focused **/ onFocus: PropTypes.func, } From 4cce702c2355249a11334399548f908fd81fbc81 Mon Sep 17 00:00:00 2001 From: Mohammer5 Date: Mon, 4 Nov 2024 09:55:17 +0800 Subject: [PATCH 23/39] fix: handle keyboard input on filter input; + code org cleanup --- components/select/src/index.js | 1 + .../__stories__/DefaultPosition.js | 41 + .../single-select-a11y/__stories__/Dense.js | 22 + .../single-select-a11y/__stories__/Empty.js | 21 + .../__stories__/EmptyWithEmptyNode.js | 33 + .../__stories__/EmptyWithEmptyText.js | 22 + .../__stories__/FlippedPosition.js | 40 + .../__stories__/ShiftedIntoView.js | 38 + .../__stories__/WithClearButton.js | 22 + .../__stories__/WithCustomLowMaxHeight.js | 22 + .../__stories__/WithCustomOptions.js | 83 ++ .../__stories__/WithDisabledOption.js | 27 + .../__stories__/WithFilterField.js | 35 + .../__stories__/WithInitialFocus.js | 35 + .../__stories__/WithManyOptions.js | 21 + .../__stories__/WithNoMatchText.js | 22 + .../__stories__/WithOnBlur.js | 28 + .../__stories__/WithOnFocus.js | 28 + .../__stories__/WithOptionsAndDisabled.js | 22 + .../__stories__/WithOptionsAndLoading.js | 28 + .../__stories__/WithOptionsAndLoadingText.js | 29 + .../__stories__/WithPlaceholder.js | 23 + .../WithPlaceholderAndSelection.js | 22 + .../__stories__/WithPrefix.js | 22 + .../__stories__/WithPrefixAndSelection.js | 22 + .../single-select-a11y/__stories__/WithRTL.js | 32 + .../__stories__/WithSelection.js | 21 + .../__stories__/WithSelectionAndDisabled.js | 22 + .../__stories__/WithoutSelection.js | 16 + .../single-select-a11y/__stories__/options.js | 96 +++ .../select/src/single-select-a11y/index.js | 1 + .../src/single-select-a11y/menu/empty.js | 27 + .../single-select-a11y/menu/menu-filter.js | 14 +- .../src/single-select-a11y/menu/menu.js | 136 +-- .../src/single-select-a11y/menu/no-match.js | 28 + .../selected-value-container.js | 6 +- .../selected-value/selected-value.js | 6 +- .../single-select-a11y/single-select-a11y.js | 134 ++- .../single-select-a11y.prod.stories.js | 811 +----------------- .../use-handle-key-press/index.js | 1 + .../use-handle-key-press/use-focus-option.js | 24 + .../use-handle-key-press-on-filter-input.js | 135 +++ .../use-handle-key-press.js | 54 +- .../use-handle-key-press/utils.js | 23 + 44 files changed, 1356 insertions(+), 940 deletions(-) create mode 100644 components/select/src/single-select-a11y/__stories__/DefaultPosition.js create mode 100644 components/select/src/single-select-a11y/__stories__/Dense.js create mode 100644 components/select/src/single-select-a11y/__stories__/Empty.js create mode 100644 components/select/src/single-select-a11y/__stories__/EmptyWithEmptyNode.js create mode 100644 components/select/src/single-select-a11y/__stories__/EmptyWithEmptyText.js create mode 100644 components/select/src/single-select-a11y/__stories__/FlippedPosition.js create mode 100644 components/select/src/single-select-a11y/__stories__/ShiftedIntoView.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithClearButton.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithCustomLowMaxHeight.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithCustomOptions.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithDisabledOption.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithFilterField.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithInitialFocus.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithManyOptions.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithNoMatchText.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithOnBlur.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithOnFocus.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithOptionsAndDisabled.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithOptionsAndLoading.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithOptionsAndLoadingText.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithPlaceholder.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithPlaceholderAndSelection.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithPrefix.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithPrefixAndSelection.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithRTL.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithSelection.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithSelectionAndDisabled.js create mode 100644 components/select/src/single-select-a11y/__stories__/WithoutSelection.js create mode 100644 components/select/src/single-select-a11y/__stories__/options.js create mode 100644 components/select/src/single-select-a11y/index.js create mode 100644 components/select/src/single-select-a11y/menu/empty.js create mode 100644 components/select/src/single-select-a11y/menu/no-match.js create mode 100644 components/select/src/single-select-a11y/use-handle-key-press/use-focus-option.js create mode 100644 components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press-on-filter-input.js create mode 100644 components/select/src/single-select-a11y/use-handle-key-press/utils.js diff --git a/components/select/src/index.js b/components/select/src/index.js index b554387f8..717d48596 100644 --- a/components/select/src/index.js +++ b/components/select/src/index.js @@ -3,5 +3,6 @@ export { MultiSelectField } from './multi-select-field/index.js' export { MultiSelectOption } from './multi-select-option/index.js' export { SingleSelect } from './single-select/index.js' +export { SingleSelectA11y } from './single-select-a11y/index.js' export { SingleSelectField } from './single-select-field/index.js' export { SingleSelectOption } from './single-select-option/index.js' diff --git a/components/select/src/single-select-a11y/__stories__/DefaultPosition.js b/components/select/src/single-select-a11y/__stories__/DefaultPosition.js new file mode 100644 index 000000000..9476342e4 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/DefaultPosition.js @@ -0,0 +1,41 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { fiveOptions } from './options.js' + +export const DefaultPosition = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( +
+
+ option.value === value + ).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> +
+
+ ) +} diff --git a/components/select/src/single-select-a11y/__stories__/Dense.js b/components/select/src/single-select-a11y/__stories__/Dense.js new file mode 100644 index 000000000..1dbb334d4 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/Dense.js @@ -0,0 +1,22 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { fiveOptions } from './options.js' + +export const Dense = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/Empty.js b/components/select/src/single-select-a11y/__stories__/Empty.js new file mode 100644 index 000000000..21b873079 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/Empty.js @@ -0,0 +1,21 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { options } from './options.js' + +export const Empty = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={[]} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/EmptyWithEmptyNode.js b/components/select/src/single-select-a11y/__stories__/EmptyWithEmptyNode.js new file mode 100644 index 000000000..7732ff5b1 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/EmptyWithEmptyNode.js @@ -0,0 +1,33 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { options } from './options.js' + +export const EmptyWithEmptyNode = () => { + const [value, setValue] = useState('') + const emptyNode = ( +
+ Custom empty text +
+ ) + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={[]} + empty={emptyNode} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/EmptyWithEmptyText.js b/components/select/src/single-select-a11y/__stories__/EmptyWithEmptyText.js new file mode 100644 index 000000000..703d9ba26 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/EmptyWithEmptyText.js @@ -0,0 +1,22 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { options } from './options.js' + +export const EmptyWithEmptyText = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={[]} + empty="Custom empty text" + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/FlippedPosition.js b/components/select/src/single-select-a11y/__stories__/FlippedPosition.js new file mode 100644 index 000000000..6eea4f0c8 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/FlippedPosition.js @@ -0,0 +1,40 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { fiveOptions } from './options.js' + +export const FlippedPosition = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( +
+
+ option.value === value + ).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> +
+
+ ) +} diff --git a/components/select/src/single-select-a11y/__stories__/ShiftedIntoView.js b/components/select/src/single-select-a11y/__stories__/ShiftedIntoView.js new file mode 100644 index 000000000..a878b92db --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/ShiftedIntoView.js @@ -0,0 +1,38 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { fiveOptions } from './options.js' + +export const ShiftedIntoView = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( + <> + option.value === value) + .label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + + + + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithClearButton.js b/components/select/src/single-select-a11y/__stories__/WithClearButton.js new file mode 100644 index 000000000..e935c947f --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithClearButton.js @@ -0,0 +1,22 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { fiveOptions } from './options.js' + +export const WithClearButton = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithCustomLowMaxHeight.js b/components/select/src/single-select-a11y/__stories__/WithCustomLowMaxHeight.js new file mode 100644 index 000000000..8472d346f --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithCustomLowMaxHeight.js @@ -0,0 +1,22 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { options } from './options.js' + +export const WithCustomLowMaxHeight = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={options} + menuMaxHeight="100px" + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithCustomOptions.js b/components/select/src/single-select-a11y/__stories__/WithCustomOptions.js new file mode 100644 index 000000000..d1c649d4e --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithCustomOptions.js @@ -0,0 +1,83 @@ +import React, { useState, useMemo } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { options } from './options.js' + +const CustomOption = ({ label }) => ( + + {label} + + { + e.stopPropagation() + alert('Custom "button" clicked!') + }} + > + x + + + + +) + +export const WithCustomOptions = () => { + const [value, setValue] = useState('') + const optionsWithCustomStyle = useMemo(() => { + return options.slice(0, 3).map((option) => ({ + ...option, + component: CustomOption, + })) + }, []) + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={optionsWithCustomStyle} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithDisabledOption.js b/components/select/src/single-select-a11y/__stories__/WithDisabledOption.js new file mode 100644 index 000000000..3a7702279 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithDisabledOption.js @@ -0,0 +1,27 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { fiveOptions } from './options.js' + +const fiveOptionsWithFourthDisabled = [ + ...fiveOptions.slice(0, 3), + { ...fiveOptions[3], disabled: true }, + ...fiveOptions.slice(4), +] + +export const WithDisabledOption = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptionsWithFourthDisabled} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithFilterField.js b/components/select/src/single-select-a11y/__stories__/WithFilterField.js new file mode 100644 index 000000000..b0f069b57 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithFilterField.js @@ -0,0 +1,35 @@ +import React, { useMemo, useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { options } from './options.js' + +export const WithFilterField = () => { + const [value, setValue] = useState('') + const [filterValue, setFilterValue] = useState('') + const filteredOptions = useMemo(() => { + return filterValue + ? options.filter( + ({ label, value }) => + label.match(new RegExp(filterValue), 'i') && value !== '' + ) + : options + }, [filterValue]) + + const valueLabel = value + ? options.find((option) => option.value === value).label + : '' + + return ( + setValue(nextValue)} + filterable + filterPlaceholder="Filter placeholder" + filterValue={filterValue} + onFilterChange={setFilterValue} + noMatchText="No results for your filter" + options={filteredOptions} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithInitialFocus.js b/components/select/src/single-select-a11y/__stories__/WithInitialFocus.js new file mode 100644 index 000000000..736ded59d --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithInitialFocus.js @@ -0,0 +1,35 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { fiveOptions } from './options.js' + +export const WithInitialFocus = () => { + const [value, setValue] = useState('') + + return ( + <> + option.value === value) + .label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + + + + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithManyOptions.js b/components/select/src/single-select-a11y/__stories__/WithManyOptions.js new file mode 100644 index 000000000..d14b38f70 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithManyOptions.js @@ -0,0 +1,21 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { options } from './options.js' + +export const WithManyOptions = () => { + const [value, setValue] = useState('art_entry_point:_no_pmtct') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={options} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithNoMatchText.js b/components/select/src/single-select-a11y/__stories__/WithNoMatchText.js new file mode 100644 index 000000000..5acc1fd74 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithNoMatchText.js @@ -0,0 +1,22 @@ +import React, { useMemo, useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' + +export const WithNoMatchText = () => { + const [value, setValue] = useState('foo') + const [filterValue, setFilterValue] = useState('Bar') + const filteredOptions = useMemo(() => [], []) + + return ( + setValue(nextValue)} + filterable + filterPlaceholder="Filter placeholder" + filterValue={filterValue} + onFilterChange={setFilterValue} + noMatchText="No results for your filter" + options={filteredOptions} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithOnBlur.js b/components/select/src/single-select-a11y/__stories__/WithOnBlur.js new file mode 100644 index 000000000..622eb9775 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithOnBlur.js @@ -0,0 +1,28 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { fiveOptions } from './options.js' + +export const WithOnBlur = () => { + const [value, setValue] = useState('') + const [blurTime, setBlurTime] = useState('') + + return ( + <> + setBlurTime(new Date().toISOString())} + idPrefix="a11y" + value={value} + valueLabel={ + value + ? fiveOptions.find((option) => option.value === value) + .label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + +

Last time select received blur: {blurTime || 'never'}

+ + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithOnFocus.js b/components/select/src/single-select-a11y/__stories__/WithOnFocus.js new file mode 100644 index 000000000..e350f1e4d --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithOnFocus.js @@ -0,0 +1,28 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { fiveOptions } from './options.js' + +export const WithOnFocus = () => { + const [value, setValue] = useState('') + const [focusTime, setFocusTime] = useState('') + + return ( + <> + setFocusTime(new Date().toISOString())} + idPrefix="a11y" + value={value} + valueLabel={ + value + ? fiveOptions.find((option) => option.value === value) + .label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + +

Last time select received focus: {focusTime || 'never'}

+ + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithOptionsAndDisabled.js b/components/select/src/single-select-a11y/__stories__/WithOptionsAndDisabled.js new file mode 100644 index 000000000..acefcf4b2 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithOptionsAndDisabled.js @@ -0,0 +1,22 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { fiveOptions } from './options.js' + +export const WithOptionsAndDisabled = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithOptionsAndLoading.js b/components/select/src/single-select-a11y/__stories__/WithOptionsAndLoading.js new file mode 100644 index 000000000..c453b2fd0 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithOptionsAndLoading.js @@ -0,0 +1,28 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' + +const options = [ + { label: 'None', value: '' }, + { value: '1', label: 'Option 1' }, + { value: '2', label: 'Option 2' }, + { value: '3', label: 'Option 3' }, +] + +export const WithOptionsAndLoading = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={options} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithOptionsAndLoadingText.js b/components/select/src/single-select-a11y/__stories__/WithOptionsAndLoadingText.js new file mode 100644 index 000000000..b1ab26439 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithOptionsAndLoadingText.js @@ -0,0 +1,29 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' + +const options = [ + { label: 'None', value: '' }, + { value: '1', label: 'Option 1' }, + { value: '2', label: 'Option 2' }, + { value: '3', label: 'Option 3' }, +] + +export const WithOptionsAndLoadingText = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={options} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithPlaceholder.js b/components/select/src/single-select-a11y/__stories__/WithPlaceholder.js new file mode 100644 index 000000000..70d27f42a --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithPlaceholder.js @@ -0,0 +1,23 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { options } from './options.js' + +export const WithPlaceholder = () => { + const [value, setValue] = useState('') + const withoutEmptyOptions = options.slice(1) + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={withoutEmptyOptions} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithPlaceholderAndSelection.js b/components/select/src/single-select-a11y/__stories__/WithPlaceholderAndSelection.js new file mode 100644 index 000000000..b13999682 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithPlaceholderAndSelection.js @@ -0,0 +1,22 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { fiveOptions } from './options.js' + +export const WithPlaceholderAndSelection = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithPrefix.js b/components/select/src/single-select-a11y/__stories__/WithPrefix.js new file mode 100644 index 000000000..57af47deb --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithPrefix.js @@ -0,0 +1,22 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { fiveOptions } from './options.js' + +export const WithPrefix = () => { + const [value, setValue] = useState('') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithPrefixAndSelection.js b/components/select/src/single-select-a11y/__stories__/WithPrefixAndSelection.js new file mode 100644 index 000000000..9d6de8c2b --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithPrefixAndSelection.js @@ -0,0 +1,22 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { fiveOptions } from './options.js' + +export const WithPrefixAndSelection = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithRTL.js b/components/select/src/single-select-a11y/__stories__/WithRTL.js new file mode 100644 index 000000000..4fa224ab8 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithRTL.js @@ -0,0 +1,32 @@ +import React, { useEffect, useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { fiveOptions } from './options.js' + +export const WithRTL = () => { + const [value, setValue] = useState('anc_1st_visit') + + // as options are rendered in Portal, the body dir (of the iframe) needs to be set to 'rtl' + useEffect(() => { + document.body.dir = 'rtl' + return () => { + document.body.dir = 'ltr' + } + }, []) + + return ( +
+ option.value === value) + .label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> +
+ ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithSelection.js b/components/select/src/single-select-a11y/__stories__/WithSelection.js new file mode 100644 index 000000000..db78e79dc --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithSelection.js @@ -0,0 +1,21 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { fiveOptions } from './options.js' + +export const WithSelection = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithSelectionAndDisabled.js b/components/select/src/single-select-a11y/__stories__/WithSelectionAndDisabled.js new file mode 100644 index 000000000..bb8c50732 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithSelectionAndDisabled.js @@ -0,0 +1,22 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { fiveOptions } from './options.js' + +export const WithSelectionAndDisabled = () => { + const [value, setValue] = useState('anc_1st_visit') + + return ( + option.value === value).label + : '' + } + onChange={(nextValue) => setValue(nextValue)} + options={fiveOptions} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/WithoutSelection.js b/components/select/src/single-select-a11y/__stories__/WithoutSelection.js new file mode 100644 index 000000000..aa17177f9 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/WithoutSelection.js @@ -0,0 +1,16 @@ +import React, { useState } from 'react' +import { SingleSelectA11y } from '../single-select-a11y.js' +import { fiveOptions } from './options.js' + +export const WithoutSelection = () => { + const [value, setValue] = useState('') + + return ( + setValue(nextValue)} + options={fiveOptions} + /> + ) +} diff --git a/components/select/src/single-select-a11y/__stories__/options.js b/components/select/src/single-select-a11y/__stories__/options.js new file mode 100644 index 000000000..bcbde0f18 --- /dev/null +++ b/components/select/src/single-select-a11y/__stories__/options.js @@ -0,0 +1,96 @@ +export const options = [ + { + label: 'None', + value: '', + }, + { + label: 'ANC 1st visit', + value: 'anc_1st_visit', + }, + { + label: 'ANC 2nd visit', + value: 'anc_2nd_visit', + }, + { + label: 'ANC 3rd visit', + value: 'anc_3rd_visit', + }, + { + label: 'ANC 4th or more visits', + value: 'anc_4th_or_more_visits', + }, + { + label: 'ARI treated with antibiotics (pneumonia) follow-up', + value: 'ari_treated_with_antibiotics_(pneumonia)_follow-up', + }, + { + label: 'ARI treated with antibiotics (pneumonia) new', + value: 'ari_treated_with_antibiotics_(pneumonia)_new', + }, + { + label: 'ARI treated with antibiotics (pneumonia) referrals', + value: 'ari_treated_with_antibiotics_(pneumonia)_referrals', + }, + { + label: 'ARI treated without antibiotics (cough) follow-up', + value: 'ari_treated_without_antibiotics_(cough)_follow-up', + }, + { + label: 'ARI treated without antibiotics (cough) new', + value: 'ari_treated_without_antibiotics_(cough)_new', + }, + { + label: 'ARI treated without antibiotics (cough) referrals', + value: 'ari_treated_without_antibiotics_(cough)_referrals', + }, + { + label: 'ART No clients who stopped TRT due to TRT failure', + value: 'art_no_clients_who_stopped_trt_due_to_trt_failure', + }, + { + label: 'ART No clients who stopped TRT due to adverse clinical status/event', + value: 'art_no_clients_who_stopped_trt_due_to_adverse_clinical_status/event', + }, + { + label: 'ART No clients with change of regimen due to drug toxicity', + value: 'art_no_clients_with_change_of_regimen_due_to_drug_toxicity', + }, + { + label: 'ART No clients with new adverse drug reaction', + value: 'art_no_clients_with_new_adverse_drug_reaction', + }, + { + label: 'ART No started Opportunist Infection prophylaxis', + value: 'art_no_started_opportunist_infection_prophylaxis', + }, + { + label: 'ART clients with new adverse clinical event', + value: 'art_clients_with_new_adverse_clinical_event', + }, + { + label: 'ART defaulters', + value: 'art_defaulters', + }, + { + label: 'ART enrollment stage 1', + value: 'art_enrollment_stage_1', + }, + { + label: 'ART enrollment stage 2', + value: 'art_enrollment_stage_2', + }, + { + label: 'ART enrollment stage 3', + value: 'art_enrollment_stage_3', + }, + { + label: 'ART enrollment stage 4', + value: 'art_enrollment_stage_4', + }, + { + label: 'ART entry point: No PMTCT', + value: 'art_entry_point:_no_pmtct', + }, +] + +export const fiveOptions = options.slice(0, 5) diff --git a/components/select/src/single-select-a11y/index.js b/components/select/src/single-select-a11y/index.js new file mode 100644 index 000000000..f03b7d586 --- /dev/null +++ b/components/select/src/single-select-a11y/index.js @@ -0,0 +1 @@ +export { SingleSelectA11y } from './single-select-a11y.js' diff --git a/components/select/src/single-select-a11y/menu/empty.js b/components/select/src/single-select-a11y/menu/empty.js new file mode 100644 index 000000000..fe6ddfb2d --- /dev/null +++ b/components/select/src/single-select-a11y/menu/empty.js @@ -0,0 +1,27 @@ +import { colors, spacers, theme } from '@dhis2/ui-constants' +import PropTypes from 'prop-types' +import React from 'react' + +const Empty = ({ children }) => ( +
+ {children} + + +
+) + +Empty.propTypes = { + children: PropTypes.string.isRequired, +} + +export { Empty } diff --git a/components/select/src/single-select-a11y/menu/menu-filter.js b/components/select/src/single-select-a11y/menu/menu-filter.js index a4ec8102b..c90ec1cec 100644 --- a/components/select/src/single-select-a11y/menu/menu-filter.js +++ b/components/select/src/single-select-a11y/menu/menu-filter.js @@ -4,19 +4,26 @@ import PropTypes from 'prop-types' import React from 'react' import i18n from '../../locales/index.js' -export function MenuFilter({ value, onChange, dataTest, placeholder, label }) { +export function MenuFilter({ + value, + onChange, + dataTest, + placeholder, + label, + onKeyDown, +}) { return (
onChange(value)} type="text" name="filter" placeholder={placeholder} - initialFocus + onKeyDown={(_, e) => onKeyDown(e)} /> -
- ) - if (hidden) { - return menu + return null } return ( @@ -120,7 +65,64 @@ export function Menu({ placement="bottom-start" observeReferenceResize > - {menu} + ) @@ -148,9 +150,13 @@ Menu.propTypes = { loading: PropTypes.bool, loadingText: PropTypes.string, maxHeight: PropTypes.string, + noMatchText: PropTypes.string, + optionUpdateStrategy: PropTypes.oneOf(['off', 'polite', 'assertive']), selectRef: PropTypes.instanceOf(HTMLElement), selected: PropTypes.string, onBlur: PropTypes.func, onClose: PropTypes.func, onFilterChange: PropTypes.func, + onFilterInputKeyDown: PropTypes.func, + onSearch: PropTypes.func, } diff --git a/components/select/src/single-select-a11y/menu/no-match.js b/components/select/src/single-select-a11y/menu/no-match.js new file mode 100644 index 000000000..0e69ffc9d --- /dev/null +++ b/components/select/src/single-select-a11y/menu/no-match.js @@ -0,0 +1,28 @@ +import { colors, spacers, theme } from '@dhis2/ui-constants' +import PropTypes from 'prop-types' +import React from 'react' + +const NoMatch = ({ children, className }) => ( +
+ {children} + + +
+) + +NoMatch.propTypes = { + children: PropTypes.string.isRequired, + className: PropTypes.string, +} + +export { NoMatch } diff --git a/components/select/src/single-select-a11y/selected-value/selected-value-container.js b/components/select/src/single-select-a11y/selected-value/selected-value-container.js index 24a27b06e..cb7886a46 100644 --- a/components/select/src/single-select-a11y/selected-value/selected-value-container.js +++ b/components/select/src/single-select-a11y/selected-value/selected-value-container.js @@ -22,7 +22,7 @@ export const SelectedValueContainer = forwardRef(function Container( onBlur, onClick: _onClick, onFocus, - onKeyPress, + onKeyDown, }, ref ) { @@ -66,7 +66,7 @@ export const SelectedValueContainer = forwardRef(function Container( onClick={onClick} onKeyDown={(e) => { e.stopPropagation() - onKeyPress(e) + onKeyDown(e) }} > {children} @@ -128,7 +128,7 @@ SelectedValueContainer.propTypes = { children: PropTypes.any.isRequired, comboBoxId: PropTypes.string.isRequired, idPrefix: PropTypes.string.isRequired, - onKeyPress: PropTypes.func.isRequired, + onKeyDown: PropTypes.func.isRequired, autoFocus: PropTypes.bool, dataTest: PropTypes.string, dense: PropTypes.bool, diff --git a/components/select/src/single-select-a11y/selected-value/selected-value.js b/components/select/src/single-select-a11y/selected-value/selected-value.js index 52512fb1a..70c1bbe1c 100644 --- a/components/select/src/single-select-a11y/selected-value/selected-value.js +++ b/components/select/src/single-select-a11y/selected-value/selected-value.js @@ -11,7 +11,7 @@ export function SelectedValue({ comboBoxId, idPrefix, valueLabel, - onKeyPress, + onKeyDown, autoFocus, clearable, comboBoxRef, @@ -55,7 +55,7 @@ export function SelectedValue({ onBlur={onBlur} onClick={onClick} onFocus={onFocus} - onKeyPress={onKeyPress} + onKeyDown={onKeyDown} > {prefix && ( undefined, - onFilterChange = () => undefined, - onFocus = () => undefined, + dataTest, + dense, + disabled, + empty, + error, + filterHelpText, + filterLabel, + filterPlaceholder, + filterValue, + filterable, + labelledBy, + loading, + menuLoadingText, + menuMaxHeight, + noMatchText, + optionUpdateStrategy, + placeholder, + prefix, + tabIndex, + valid, + value, + warning, + valueLabel: _valueLabel, + onBlur, + onFilterChange, + onFocus, }) { const comboBoxId = `${idPrefix}-combo` const valueLabel = @@ -51,6 +54,7 @@ export function SingleSelectA11y({ '' if ( + value && !valueLabel && options.length && !options.find((option) => option.value === '') && @@ -94,7 +98,12 @@ export function SingleSelectA11y({ } }, [focussedOptionIndex, options, onChange]) - const handleKeyPress = useHandleKeyPress({ + const focusComboBox = useCallback( + () => comboBoxRef.current?.focus(), + [comboBoxRef] + ) + + const handleKeyDown = useHandleKeyPress({ value, disabled, onChange, @@ -108,6 +117,17 @@ export function SingleSelectA11y({ selectFocussedOption, }) + const handleKeyDownOnFilterInput = useHandleKeyPressOnFilterInput({ + value, + options, + closeMenu, + listBoxRef, + focusComboBox, + focussedOptionIndex, + setFocussedOptionIndex, + selectFocussedOption, + }) + return (
onChange('')} onClick={toggleMenu} onFocus={onFocus} - onKeyPress={handleKeyPress} + onKeyDown={handleKeyDown} />
diff --git a/components/select/src/single-select-a11y/menu/menu-options-list.js b/components/select/src/single-select-a11y/menu/menu-options-list.js index 87a3b21db..3fbf66bc2 100644 --- a/components/select/src/single-select-a11y/menu/menu-options-list.js +++ b/components/select/src/single-select-a11y/menu/menu-options-list.js @@ -20,6 +20,7 @@ export const MenuOptionsList = forwardRef(function MenuOptionsList( loading, onChange, onBlur, + onEndReached, }, ref ) { @@ -67,8 +68,11 @@ export const MenuOptionsList = forwardRef(function MenuOptionsList( index ) => { const isSelected = value === selected + const isLast = index === options.length - 1 + return (