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 (
+ null : onChange}
+ component={component}
+ />
+ )
+ }
+ )}
+
+ {loading && }
+
+
+
+ )
+}
+
+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 = (
+
+ {filterable && (
+
+ )}
+
+
+
+ {/* Put (infinite) loading stuff here */ ''}
+
+
+
+ )
+
+ 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 (
+ {
+ if (!disabled) {
+ onClick(value)
+ }
+ }}
+ >
+
+ {label}
+
+
+
+
+ )
+}
+
+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}) => (
+ {
+ e.stopPropagation()
+ onClear(e)
+ }}
+ >
+
+
+
+
+
+)
+
+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}
+ />
+
+ {
+ onChange(nextValue)
+ closeMenu()
+ }}
+ onClose={closeMenu}
+ onFilterChange={onFilterChange}
+ onKeyDown={onKeyDown}
+ />
+
+ )
+}
+
+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 (
null : onChange}
component={component}
/>
@@ -105,6 +105,7 @@ export function MenuOptionsList({
MenuOptionsList.propTypes = {
comboBoxId: PropTypes.string.isRequired,
+ focussedOptionIndex: PropTypes.number.isRequired,
idPrefix: PropTypes.string.isRequired,
options: optionsProp.isRequired,
onChange: PropTypes.func.isRequired,
diff --git a/components/select/src/single-select-a11y/menu.js b/components/select/src/single-select-a11y/menu.js
index 1dd040ddc..bc6c5b8af 100644
--- a/components/select/src/single-select-a11y/menu.js
+++ b/components/select/src/single-select-a11y/menu.js
@@ -10,6 +10,7 @@ import { optionsProp } from './shared-prop-types.js'
export function Menu({
comboBoxId,
+ focussedOptionIndex,
idPrefix,
options,
onChange,
@@ -62,16 +63,17 @@ export function Menu({
)}
{label}
@@ -52,28 +52,31 @@ function DefaultStyle({ label, disabled, selected }) {
DefaultStyle.propTypes = {
label: PropTypes.string.isRequired,
disabled: PropTypes.bool,
- selected: PropTypes.bool,
+ highlighted: PropTypes.bool,
}
export function Option({
- value,
- label,
+ comboBoxId,
index,
- selected,
+ label,
+ value,
onClick,
+ component: StyleComponent = DefaultStyle,
dataTest,
disabled,
- comboBoxId,
- component: StyleComponent = DefaultStyle,
+ highlighted,
+ selected,
...rest
}) {
return (
{
if (!disabled) {
onClick(value)
@@ -84,8 +87,8 @@ export function Option({
value={value}
label={label}
index={index}
- selected={selected}
disabled={disabled}
+ highlighted={highlighted}
{...rest}
>
{label}
@@ -112,8 +115,8 @@ Option.propTypes = {
label: PropTypes.string.isRequired,
value: PropTypes.string.isRequired,
onClick: PropTypes.func.isRequired,
- dataTest: PropTypes.string,
component: PropTypes.elementType,
+ dataTest: PropTypes.string,
disabled: PropTypes.bool,
- selected: PropTypes.bool,
+ highlighted: PropTypes.bool,
}
diff --git a/components/select/src/single-select-a11y/selected-value-container.js b/components/select/src/single-select-a11y/selected-value-container.js
index ff57cbeab..8628dfc46 100644
--- a/components/select/src/single-select-a11y/selected-value-container.js
+++ b/components/select/src/single-select-a11y/selected-value-container.js
@@ -22,6 +22,7 @@ export const SelectedValueContainer = forwardRef(function Container(
onBlur,
onClick: _onClick,
onFocus,
+ onKeyPress,
},
ref
) {
@@ -47,7 +48,7 @@ export const SelectedValueContainer = forwardRef(function Container(
className={cx({ error, warning, valid, disabled, dense })}
data-test={dataTest}
ref={ref}
- aria-controls={`listbox-${idPrefix}`}
+ aria-controls={`${idPrefix}-listbox`}
aria-expanded={expanded.toString()}
aria-haspopup="listbox"
aria-labelledby={labelledBy}
@@ -58,6 +59,10 @@ export const SelectedValueContainer = forwardRef(function Container(
onFocus={onFocus}
onBlur={onBlur}
onClick={onClick}
+ onKeyDown={e => {
+ e.stopPropagation()
+ onKeyPress(e)
+ }}
>
{children}
@@ -118,6 +123,7 @@ SelectedValueContainer.propTypes = {
children: PropTypes.any.isRequired,
comboBoxId: PropTypes.string.isRequired,
idPrefix: PropTypes.string.isRequired,
+ onKeyPress: PropTypes.func.isRequired,
autoFocus: PropTypes.bool,
dataTest: PropTypes.string,
dense: PropTypes.bool,
diff --git a/components/select/src/single-select-a11y/selected-value.js b/components/select/src/single-select-a11y/selected-value.js
index 85728424c..8d18527dd 100644
--- a/components/select/src/single-select-a11y/selected-value.js
+++ b/components/select/src/single-select-a11y/selected-value.js
@@ -7,29 +7,30 @@ import { SelectedValuePlaceholder } from './selected-value-placeholder.js'
import { SelectedValuePrefix } from './selected-value-prefix.js'
export function SelectedValue({
- comboBoxRef,
+ clearText,
+ comboBoxId,
idPrefix,
+ valueLabel,
+ onKeyPress,
+ autoFocus,
+ clearable,
+ comboBoxRef,
+ dataTest,
+ dense,
+ disabled,
+ error,
expanded,
+ hasSelection,
labelledBy,
- comboBoxId,
+ placeholder,
+ prefix,
tabIndex,
+ valid,
+ warning,
onBlur,
- onFocus,
- hasSelection,
- valueLabel,
- prefix,
- dataTest,
- placeholder,
onClear,
- clearText,
- clearable,
- disabled,
onClick,
- error,
- warning,
- valid,
- dense,
- autoFocus,
+ onFocus,
}) {
// @TODO
const inputMaxHeight = '300px'
@@ -54,6 +55,7 @@ export function SelectedValue({
onBlur={onBlur}
onClick={onClick}
onFocus={onFocus}
+ onKeyPress={onKeyPress}
>
{prefix && (
option.value === value)?.label || ''
+ if (
+ !valueLabel
+ && options.length
+ && !options.find(option => option.value === '')
+ && !placeholder
+ ) {
+ throw new Error('You must either provide a "valueLabel" or include an empty option in the options array')
+ }
+
// Stateful
// ========
// 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 [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!
+ const openMenu = useCallback(() => setExpanded(true), [])
+ const toggleMenu = useCallback(() => {
+ if (expanded) {
+ closeMenu()
+ } else {
+ openMenu()
+ }
+ }, [expanded])
+
+ const selectFocussedOption = useCallback(() => {
+ const option = options[focussedOptionIndex]
+
+ if (option) {
+ onChange(option.value)
+ }
+ }, [focussedOptionIndex, options])
+
+ const handleKeyPress = useHandleKeyPress({
+ value,
+ onChange,
+ expanded,
+ options,
+ openMenu,
+ closeMenu,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ selectFocussedOption,
+ })
return (
onChange('')}
onClick={toggleMenu}
onFocus={onFocus}
+ onKeyPress={handleKeyPress}
/>
{
const [value, setValue] = useState('')
return (
- option.value === value).label
- : ''
- }
- onChange={(nextValue) => setValue(nextValue)}
- options={fiveOptions}
- />
+ <>
+ option.value === value).label
+ : ''
+ }
+ onChange={(nextValue) => setValue(nextValue)}
+ options={fiveOptions}
+ />
+
+ console.log(e)}>
+ None
+ One
+ Two
+ Three
+ Four
+ Five
+ Six
+
+ >
)
}
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
index 80c9f3578..0e5c891ab 100644
--- a/components/select/src/single-select-a11y/single-select-a11y.test.js
+++ b/components/select/src/single-select-a11y/single-select-a11y.test.js
@@ -12,7 +12,6 @@ describe(' ', () => {
onBlur={onBlur}
idPrefix="a11y"
value="foo"
- valueLabel="Foo"
onChange={() => null}
options={[{ value: 'foo', label: 'Foo' }]}
/>
@@ -33,7 +32,6 @@ describe(' ', () => {
onFocus={onFocus}
idPrefix="a11y"
value="foo"
- valueLabel="Foo"
onChange={() => null}
options={[{ value: 'foo', label: 'Foo' }]}
/>
@@ -55,7 +53,6 @@ describe(' ', () => {
onFocus={onFocus}
idPrefix="a11y"
value="foo"
- valueLabel="Foo"
onChange={() => null}
options={[{ value: 'foo', label: 'Foo' }]}
/>
@@ -70,7 +67,6 @@ describe(' ', () => {
loading
idPrefix="a11y"
value="foo"
- valueLabel="Foo"
onChange={() => null}
options={[{ value: 'foo', label: 'Foo' }]}
/>
@@ -90,7 +86,6 @@ describe(' ', () => {
menuLoadingText="Loading text"
idPrefix="a11y"
value="foo"
- valueLabel="Foo"
onChange={() => null}
options={[{ value: 'foo', label: 'Foo' }]}
/>
@@ -111,7 +106,6 @@ describe(' ', () => {
menuMaxHeight="100px"
idPrefix="a11y"
value="foo"
- valueLabel="Foo"
onChange={() => null}
options={[{ value: 'foo', label: 'Foo' }]}
/>
@@ -145,7 +139,7 @@ describe(' ', () => {
null}
options={[{ value: 'foo', label: 'Foo' }]}
/>
@@ -163,9 +157,9 @@ describe(' ', () => {
', () => {
value=""
valueLabel=""
onChange={onChange}
- options={[{ value: 'foo', label: 'Foo', component: CustomOption }]}
+ options={[
+ { value: '', label: 'None' },
+ { value: 'foo', label: 'Foo', component: CustomOption },
+ ]}
/>
)
@@ -244,9 +241,11 @@ describe(' ', () => {
clearText="Clear a11y select"
idPrefix="a11y"
value=""
- valueLabel=""
onChange={jest.fn()}
- options={[{ value: 'foo', label: 'Foo' }]}
+ options={[
+ { value: '', label: 'None' },
+ { value: 'foo', label: 'Foo' },
+ ]}
/>
)
@@ -260,7 +259,6 @@ describe(' ', () => {
disabled
idPrefix="a11y"
value="foo"
- valueLabel="Foo"
onChange={jest.fn()}
options={[{ value: 'foo', label: 'Foo' }]}
/>
@@ -282,7 +280,6 @@ describe(' ', () => {
empty={Empty
}
idPrefix="a11y"
value=""
- valueLabel=""
onChange={jest.fn()}
options={[]}
/>
@@ -306,8 +303,7 @@ describe(' ', () => {
onFilterChange={onFilterChange}
noMatchText="No options found"
idPrefix="a11y"
- value=""
- valueLabel=""
+ value="foo"
onChange={jest.fn()}
options={[{ value: 'foo', label: 'Foo' }]}
/>
@@ -340,7 +336,7 @@ describe(' ', () => {
value=""
valueLabel=""
onChange={jest.fn()}
- options={[{ value: 'foo', label: 'Foo' }]}
+ options={[{ value: '', label: 'None' }, { value: 'foo', label: 'Foo' }]}
/>
)
@@ -364,6 +360,7 @@ describe(' ', () => {
valueLabel=""
onChange={jest.fn()}
options={[
+ { value: '', label: 'None' },
{ value: 'foo', label: 'Foo' },
{ value: 'bar', label: 'Bar' },
{ value: 'foo', label: 'Foo' },
@@ -406,4 +403,160 @@ describe(' ', () => {
expect(combobox).toContainElement(withTextBar)
})
+
+
+ /**************************
+ * *
+ * ===================== *
+ * Keyboard interactions *
+ * ===================== *
+ * *
+ **************************/
+
+ describe.each([
+ { key: ' ' },
+ { key: 'Enter' },
+ { key: 'ArrowDown', altKey: true },
+ { key: 'ArrowUp', altKey: true },
+ ])('$key ($altKey)', (keyDownOptions) => {
+ test('open the menu', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ fireEvent.keyDown(screen.getByRole('combobox'), keyDownOptions)
+ expect(screen.queryByRole('listbox')).not.toBeNull()
+ expect(onChange).not.toHaveBeenCalled()
+ })
+ })
+
+ describe.each([
+ { key: ' ' },
+ { key: 'Enter' },
+ { key: 'ArrowDown', altKey: true },
+ { key: 'ArrowUp', altKey: true },
+ ])('$key ($altKey)', (keyDownOptions) => {
+ test('close the menu', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole('combobox'))
+ expect(screen.queryByRole('listbox')).not.toBeNull()
+ fireEvent.keyDown(screen.getByRole('combobox'), keyDownOptions)
+ expect(screen.queryByRole('listbox')).toBeNull()
+ expect(onChange).not.toHaveBeenCalled()
+ })
+ })
+
+ describe.each([
+ { key: 'Escape' },
+ { key: 'Enter' },
+ { key: 'ArrowDown', altKey: true },
+ { key: 'ArrowUp', altKey: true },
+ ])('$key ($altKey)', (keyDownOptions) => {
+ test('close the menu and select the highlighted option after having highlighted another option', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ fireEvent.click(screen.getByRole('combobox'))
+ expect(screen.queryByRole('listbox')).not.toBeNull()
+
+ // highlighting the next option
+ fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' })
+
+ fireEvent.keyDown(
+ screen.getByRole('combobox'),
+ keyDownOptions,
+ )
+
+ expect(screen.queryByRole('listbox')).toBeNull()
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange).toHaveBeenCalledWith('foo')
+ })
+ })
+
+ it('should select the next option when closed and user presses ArrowDown', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ expect(screen.queryByRole('listbox')).toBeNull()
+
+ // highlighting the next option
+ fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' })
+ expect(screen.queryByRole('listbox')).toBeNull()
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange).toHaveBeenCalledWith('foo')
+ })
+
+ it('should select the previous option when closed and user presses ArrowUp', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ expect(screen.queryByRole('listbox')).toBeNull()
+
+ // highlighting the next option
+ fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowUp' })
+ expect(screen.queryByRole('listbox')).toBeNull()
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange).toHaveBeenCalledWith('foo')
+ })
})
diff --git a/components/select/src/single-select-a11y/use-handle-key-press.js b/components/select/src/single-select-a11y/use-handle-key-press.js
new file mode 100644
index 000000000..23ccd4b65
--- /dev/null
+++ b/components/select/src/single-select-a11y/use-handle-key-press.js
@@ -0,0 +1,148 @@
+import { useCallback } from 'react'
+
+const OPEN_KEYS = ['ArrowDown', 'ArrowUp', 'Enter', ' ']
+
+export function useHandleKeyPress({
+ value,
+ expanded,
+ options,
+ openMenu,
+ closeMenu,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ selectFocussedOption,
+ onChange,
+}) {
+ const selectNextOption = useCallback(() => {
+ if (focussedOptionIndex < options.length - 1) {
+ onChange(options[focussedOptionIndex + 1].value)
+ }
+ }, [focussedOptionIndex, options])
+
+ const selectPrevOption = useCallback(() => {
+ if (focussedOptionIndex > 0) {
+ onChange(options[focussedOptionIndex - 1].value)
+ }
+ }, [focussedOptionIndex, options])
+
+ const focusNextOption = useCallback(() => {
+ if (focussedOptionIndex < options.length - 1) {
+ setFocussedOptionIndex(focussedOptionIndex + 1)
+ }
+ }, [focussedOptionIndex, options])
+
+ const focusPrevOption = useCallback(() => {
+ if (focussedOptionIndex > 0) {
+ setFocussedOptionIndex(focussedOptionIndex - 1)
+ }
+ }, [focussedOptionIndex, options])
+
+ const focusFirstOption = useCallback(() => {
+ setFocussedOptionIndex(0)
+ }, [options])
+
+ const focusLastOption = useCallback(() => {
+ setFocussedOptionIndex(options.length - 1)
+ }, [focussedOptionIndex, options])
+
+ const keyToAction = {
+ }
+
+ const handleKeyPress = useCallback((e) => {
+ const { key, altKey, ctrlKey, metaKey } = e
+
+ if (
+ expanded &&
+ (
+ key === 'Escape' ||
+ key === 'Enter' ||
+ key === ' ' ||
+ (key === 'ArrowUp' && altKey) ||
+ (key === 'ArrowDown' && altKey)
+ )
+ ) {
+ if (value !== options[focussedOptionIndex].value) {
+ selectFocussedOption()
+ }
+
+ closeMenu()
+ return
+ }
+
+ if (
+ !expanded &&
+ (
+ key === ' ' ||
+ key === 'Enter' ||
+ (key === 'ArrowUp' && altKey) ||
+ (key === 'ArrowDown' && altKey)
+ )
+ ) {
+ openMenu()
+ return
+ }
+
+ if (key === 'ArrowDown') {
+ if (!expanded) {
+ selectNextOption()
+ } else {
+ focusNextOption()
+ }
+
+ return
+ }
+
+ if (key === 'ArrowUp') {
+ if (!expanded) {
+ selectPrevOption()
+ } else {
+ focusPrevOption()
+ }
+
+ return
+ }
+
+ if (key === 'Home') {
+ focusFirstOption()
+ return
+ }
+
+ if (key === 'End') {
+ focusLastOption()
+ return
+ }
+
+ if (
+ key === 'Backspace' ||
+ key === 'Clear' ||
+ (key.length === 1 && key !== ' ' && !altKey && !ctrlKey && !metaKey)
+ ) {
+ console.log('> typing')
+ // @TODO: Handle typing
+ return
+ }
+
+ if (expanded && key === 'PageUp') {
+ // @TODO: SelectActions.PageUp
+ return
+ }
+
+ if (expanded && key === 'PageDown') {
+ // @TODO: SelectActions.PageDown
+ return
+ }
+
+ console.log('> do nothing')
+ }, [
+ expanded,
+ closeMenu,
+ openMenu,
+ selectFocussedOption,
+ focusNextOption,
+ focusPrevOption,
+ focusFirstOption,
+ focusLastOption,
+ ])
+
+ return handleKeyPress
+}
From fa4932172c80de3d0b19c68edd1bf0b1c0c98c3b Mon Sep 17 00:00:00 2001
From: Mohammer5
Date: Thu, 17 Oct 2024 18:44:28 +0800
Subject: [PATCH 09/39] chore: fix linter issues
---
.../features/position/index.js | 12 ----
.../select/src/single-select-a11y/option.js | 1 -
.../selected-value-clear-button.js | 10 ++-
.../selected-value-container.js | 2 +-
.../single-select-a11y.e2e.stories.js | 2 +-
.../single-select-a11y/single-select-a11y.js | 33 +++++----
.../single-select-a11y.test.js | 69 ++++++++++++-------
storybook/src/load-stories.js | 7 +-
8 files changed, 78 insertions(+), 58 deletions(-)
diff --git a/components/select/src/single-select-a11y/features/position/index.js b/components/select/src/single-select-a11y/features/position/index.js
index 6d9962d85..f775d754c 100644
--- a/components/select/src/single-select-a11y/features/position/index.js
+++ b/components/select/src/single-select-a11y/features/position/index.js
@@ -32,9 +32,6 @@ When('the window is scrolled down', () => {
})
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')
@@ -55,9 +52,6 @@ Then('the top of the menu is aligned with the bottom of the input', () => {
})
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
@@ -78,9 +72,6 @@ Then('the bottom of the menu is aligned with the top of the input', () => {
})
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')
@@ -102,9 +93,6 @@ Then('it is rendered on top of the SingleSelect', () => {
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')
diff --git a/components/select/src/single-select-a11y/option.js b/components/select/src/single-select-a11y/option.js
index e1bd9b565..abe993a6d 100644
--- a/components/select/src/single-select-a11y/option.js
+++ b/components/select/src/single-select-a11y/option.js
@@ -65,7 +65,6 @@ export function Option({
dataTest,
disabled,
highlighted,
- selected,
...rest
}) {
return (
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
index 57b1d4551..35b82add4 100644
--- a/components/select/src/single-select-a11y/selected-value-clear-button.js
+++ b/components/select/src/single-select-a11y/selected-value-clear-button.js
@@ -4,7 +4,7 @@ import PropTypes from 'prop-types'
import React from 'react'
import i18n from '../locales/index.js'
-export const ClearButton = ({ onClear, clearText, dataTest}) => (
+export const ClearButton = ({ onClear, clearText, dataTest }) => (
{
- const clearButton =
+ const clearButton = (
+
+ )
if (!clearText) {
return clearButton
diff --git a/components/select/src/single-select-a11y/selected-value-container.js b/components/select/src/single-select-a11y/selected-value-container.js
index 8628dfc46..391f761e5 100644
--- a/components/select/src/single-select-a11y/selected-value-container.js
+++ b/components/select/src/single-select-a11y/selected-value-container.js
@@ -59,7 +59,7 @@ export const SelectedValueContainer = forwardRef(function Container(
onFocus={onFocus}
onBlur={onBlur}
onClick={onClick}
- onKeyDown={e => {
+ onKeyDown={(e) => {
e.stopPropagation()
onKeyPress(e)
}}
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
index f54a8bbf5..df78e235f 100644
--- 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
@@ -1,4 +1,4 @@
-import React, { useEffect, useMemo, useState } from 'react'
+import React from 'react'
import { SingleSelectA11y } from './single-select-a11y.js'
export default {
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 922d742c4..b30710664 100644
--- a/components/select/src/single-select-a11y/single-select-a11y.js
+++ b/components/select/src/single-select-a11y/single-select-a11y.js
@@ -47,15 +47,20 @@ export function SingleSelectA11y({
// Non-stateful
// ========
const comboBoxId = `${idPrefix}-combo`
- const valueLabel = _valueLabel || options.find(option => option.value === value)?.label || ''
+ const valueLabel =
+ _valueLabel ||
+ options.find((option) => option.value === value)?.label ||
+ ''
if (
- !valueLabel
- && options.length
- && !options.find(option => option.value === '')
- && !placeholder
+ !valueLabel &&
+ options.length &&
+ !options.find((option) => option.value === '') &&
+ !placeholder
) {
- throw new Error('You must either provide a "valueLabel" or include an empty option in the options array')
+ throw new Error(
+ 'You must either provide a "valueLabel" or include an empty option in the options array'
+ )
}
// Stateful
@@ -64,11 +69,9 @@ 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)
+ const foundIndex = options.findIndex((option) => option.value === value)
- return foundIndex !== -1
- ? foundIndex
- : 0
+ return foundIndex !== -1 ? foundIndex : 0
})
const [selectRef, setSelectRef] = useState()
const [expanded, setExpanded] = useState(false)
@@ -80,7 +83,7 @@ export function SingleSelectA11y({
} else {
openMenu()
}
- }, [expanded])
+ }, [expanded, openMenu, closeMenu])
const selectFocussedOption = useCallback(() => {
const option = options[focussedOptionIndex]
@@ -88,7 +91,7 @@ export function SingleSelectA11y({
if (option) {
onChange(option.value)
}
- }, [focussedOptionIndex, options])
+ }, [focussedOptionIndex, options, onChange])
const handleKeyPress = useHandleKeyPress({
value,
@@ -190,12 +193,12 @@ SingleSelectA11y.propTypes = {
/** 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,
+ /** A callback that will be called with the new value or an empty string **/
+ onChange: PropTypes.func.isRequired,
+
/** Will focus the select initially **/
autoFocus: PropTypes.bool,
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
index 0e5c891ab..9e43f7426 100644
--- a/components/select/src/single-select-a11y/single-select-a11y.test.js
+++ b/components/select/src/single-select-a11y/single-select-a11y.test.js
@@ -20,6 +20,10 @@ describe(' ', () => {
fireEvent.blur(screen.getByRole('combobox'))
// first argument passed to onBlur is a react event
+ //
+ // Reg. eslint: Eslint complains about using hasOwnProperty,
+ // but accessing `.nativeEvent` directly causes react to log an error
+ // eslint-disable-next-line no-prototype-builtins
expect(onBlur.mock.calls[0][0].hasOwnProperty('nativeEvent')).toBe(true)
expect(onBlur).toHaveBeenCalledTimes(1)
})
@@ -40,7 +44,13 @@ describe(' ', () => {
fireEvent.focus(screen.getByRole('combobox'))
// first argument passed to onFocus is a react event
- expect(onFocus.mock.calls[0][0].hasOwnProperty('nativeEvent')).toBe(true)
+ //
+ // Reg. eslint: Eslint complains about using hasOwnProperty,
+ // but accessing `.nativeEvent` directly causes react to log an error
+ // eslint-disable-next-line no-prototype-builtins
+ expect(onFocus.mock.calls[0][0].hasOwnProperty('nativeEvent')).toBe(
+ true
+ )
expect(onFocus).toHaveBeenCalledTimes(1)
})
@@ -130,8 +140,11 @@ describe(' ', () => {
)
const placeholder = screen.getByText('Placeholder text')
- const dataTestValue = placeholder.attributes.getNamedItem('data-test').value
- expect(dataTestValue).toBe('dhis2-singleselecta11y-selectedvalue-placeholder')
+ const dataTestValue =
+ placeholder.attributes.getNamedItem('data-test').value
+ expect(dataTestValue).toBe(
+ 'dhis2-singleselecta11y-selectedvalue-placeholder'
+ )
})
it('should accept a prefix', () => {
@@ -146,8 +159,11 @@ describe(' ', () => {
)
const placeholder = screen.getByText('Prefix text')
- const dataTestValue = placeholder.attributes.getNamedItem('data-test').value
- expect(dataTestValue).toBe('dhis2-singleselecta11y-selectedvalue-prefix')
+ const dataTestValue =
+ placeholder.attributes.getNamedItem('data-test').value
+ expect(dataTestValue).toBe(
+ 'dhis2-singleselecta11y-selectedvalue-prefix'
+ )
})
it('should allow options to be selected', () => {
@@ -174,15 +190,15 @@ describe(' ', () => {
fireEvent.click(option)
expect(onChange).toHaveBeenCalledTimes(1)
- expect(onChange).toHaveBeenCalledWith("foo")
+ expect(onChange).toHaveBeenCalledWith('foo')
})
it('should allow custom options to be selected', () => {
const onChange = jest.fn()
+
+ // eslint-disable-next-line react/prop-types
const CustomOption = ({ value, label }) => (
-
- {label}
-
+ {label}
)
render(
@@ -207,7 +223,7 @@ describe(' ', () => {
fireEvent.click(customOption)
expect(onChange).toHaveBeenCalledTimes(1)
- expect(onChange).toHaveBeenCalledWith("foo")
+ expect(onChange).toHaveBeenCalledWith('foo')
})
it('should be clearable when there is a selected value', () => {
@@ -317,7 +333,7 @@ describe(' ', () => {
expect(searchInput).toBeInstanceOf(HTMLInputElement)
expect(searchInput).toBeVisible()
- fireEvent.change(searchInput, { target: { value: 'Search term'} })
+ fireEvent.change(searchInput, { target: { value: 'Search term' } })
expect(onFilterChange).toHaveBeenCalledTimes(1)
expect(onFilterChange).toHaveBeenCalledWith('Search term')
@@ -336,7 +352,10 @@ describe(' ', () => {
value=""
valueLabel=""
onChange={jest.fn()}
- options={[{ value: '', label: 'None' }, { value: 'foo', label: 'Foo' }]}
+ options={[
+ { value: '', label: 'None' },
+ { value: 'foo', label: 'Foo' },
+ ]}
/>
)
@@ -372,7 +391,9 @@ describe(' ', () => {
// Is this because of unnecessary re-renders?
expect(consoleError).toHaveBeenNthCalledWith(
1,
- expect.stringContaining('Encountered two children with the same key'),
+ expect.stringContaining(
+ 'Encountered two children with the same key'
+ ),
'foo',
expect.anything()
)
@@ -404,14 +425,13 @@ describe(' ', () => {
expect(combobox).toContainElement(withTextBar)
})
-
/**************************
- * *
- * ===================== *
- * Keyboard interactions *
- * ===================== *
- * *
- **************************/
+ * *
+ * ===================== *
+ * Keyboard interactions *
+ * ===================== *
+ * *
+ **************************/
describe.each([
{ key: ' ' },
@@ -497,12 +517,11 @@ describe(' ', () => {
expect(screen.queryByRole('listbox')).not.toBeNull()
// highlighting the next option
- fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' })
+ fireEvent.keyDown(screen.getByRole('combobox'), {
+ key: 'ArrowDown',
+ })
- fireEvent.keyDown(
- screen.getByRole('combobox'),
- keyDownOptions,
- )
+ fireEvent.keyDown(screen.getByRole('combobox'), keyDownOptions)
expect(screen.queryByRole('listbox')).toBeNull()
expect(onChange).toHaveBeenCalledTimes(1)
diff --git a/storybook/src/load-stories.js b/storybook/src/load-stories.js
index ab8d6caf6..d0f9526e1 100644
--- a/storybook/src/load-stories.js
+++ b/storybook/src/load-stories.js
@@ -52,7 +52,12 @@ exports.loadStories = () => {
case icons.includes(curcomp): {
console.info(`custom => Loading stories for '${curcomp}'`)
return [
- path.join(ICONS_DIR, 'src', '**', '*.prod.stories.@(js|jsx|mdx)'),
+ path.join(
+ ICONS_DIR,
+ 'src',
+ '**',
+ '*.prod.stories.@(js|jsx|mdx)'
+ ),
]
}
case constants.includes(curcomp): {
From 3266f8679ad44cb4a0eea651f7e52d372e54eec4 Mon Sep 17 00:00:00 2001
From: Mohammer5
Date: Thu, 17 Oct 2024 19:12:11 +0800
Subject: [PATCH 10/39] feat(select a11y): implement typing while menu is open
---
.../selected-value-container.js | 15 +-
.../use-handle-key-press.js | 265 ++++++++++++------
2 files changed, 182 insertions(+), 98 deletions(-)
diff --git a/components/select/src/single-select-a11y/selected-value-container.js b/components/select/src/single-select-a11y/selected-value-container.js
index 391f761e5..24a27b06e 100644
--- a/components/select/src/single-select-a11y/selected-value-container.js
+++ b/components/select/src/single-select-a11y/selected-value-container.js
@@ -28,11 +28,16 @@ export const SelectedValueContainer = forwardRef(function Container(
) {
// 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()
- }
- }, [])
+ useEffect(
+ () => {
+ if (autoFocus) {
+ ref.current.focus()
+ }
+ },
+ // We want to run this only once:
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ []
+ )
const onClick = useCallback(
(...args) => {
diff --git a/components/select/src/single-select-a11y/use-handle-key-press.js b/components/select/src/single-select-a11y/use-handle-key-press.js
index 23ccd4b65..546875201 100644
--- a/components/select/src/single-select-a11y/use-handle-key-press.js
+++ b/components/select/src/single-select-a11y/use-handle-key-press.js
@@ -1,6 +1,70 @@
-import { useCallback } from 'react'
+import { useCallback, useEffect, useRef, useState } from 'react'
-const OPEN_KEYS = ['ArrowDown', 'ArrowUp', 'Enter', ' ']
+const TYPING_DEBOUNCE_TIME = 300 // ms
+
+function useHandleTyping({
+ options,
+ setFocussedOptionIndex,
+
+ // @TODO: Scroll to highlighted option when not/partially visible
+ // eslint-disable-next-line no-unused-vars
+ listboxHTMLElement,
+}) {
+ const timeoutRef = useRef()
+ const [value, setValue] = useState('')
+ const [typing, setTyping] = useState(false)
+
+ // This will reset the typed value after a given time
+ useEffect(() => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
+ timeoutRef.current = null
+ }
+
+ if (value) {
+ timeoutRef.current = setTimeout(() => {
+ setValue('')
+ setTyping(false)
+ }, TYPING_DEBOUNCE_TIME)
+ } else {
+ setTyping(false)
+ }
+ }, [
+ // adding value to the dependencies array so that the hooks runs every time the value changes
+ value,
+ ])
+
+ useEffect(() => {
+ if (value) {
+ const optionIndex = options.findIndex((option) =>
+ option.label.toLowerCase().startsWith(value.toLowerCase())
+ )
+
+ if (optionIndex !== -1) {
+ setFocussedOptionIndex(optionIndex)
+ }
+ }
+ }, [value, options, setFocussedOptionIndex])
+
+ const onTyping = useCallback((e) => {
+ const { key } = e
+ setTyping(true)
+
+ if (key === 'Backspace') {
+ setValue((prevValue) => prevValue.slice(0, -1))
+ return
+ }
+
+ if (key === 'Clear') {
+ setValue('')
+ return
+ }
+
+ setValue((prevValue) => `${prevValue}${key}`)
+ }, [])
+
+ return { onTyping, typing }
+}
export function useHandleKeyPress({
value,
@@ -13,136 +77,151 @@ export function useHandleKeyPress({
selectFocussedOption,
onChange,
}) {
+ const { onTyping, typing } = useHandleTyping({
+ options,
+ setFocussedOptionIndex,
+ listboxHTMLElement: null, // @TODO
+ })
+
+ console.log('> typing:', typing)
+
const selectNextOption = useCallback(() => {
if (focussedOptionIndex < options.length - 1) {
onChange(options[focussedOptionIndex + 1].value)
}
- }, [focussedOptionIndex, options])
+ }, [focussedOptionIndex, options, onChange])
const selectPrevOption = useCallback(() => {
if (focussedOptionIndex > 0) {
onChange(options[focussedOptionIndex - 1].value)
}
- }, [focussedOptionIndex, options])
+ }, [focussedOptionIndex, options, onChange])
const focusNextOption = useCallback(() => {
if (focussedOptionIndex < options.length - 1) {
setFocussedOptionIndex(focussedOptionIndex + 1)
}
- }, [focussedOptionIndex, options])
+ }, [focussedOptionIndex, options, setFocussedOptionIndex])
const focusPrevOption = useCallback(() => {
if (focussedOptionIndex > 0) {
setFocussedOptionIndex(focussedOptionIndex - 1)
}
- }, [focussedOptionIndex, options])
+ }, [focussedOptionIndex, setFocussedOptionIndex])
const focusFirstOption = useCallback(() => {
setFocussedOptionIndex(0)
- }, [options])
+ }, [setFocussedOptionIndex])
const focusLastOption = useCallback(() => {
setFocussedOptionIndex(options.length - 1)
- }, [focussedOptionIndex, options])
-
- const keyToAction = {
- }
-
- const handleKeyPress = useCallback((e) => {
- const { key, altKey, ctrlKey, metaKey } = e
-
- if (
- expanded &&
- (
- key === 'Escape' ||
- key === 'Enter' ||
- key === ' ' ||
- (key === 'ArrowUp' && altKey) ||
- (key === 'ArrowDown' && altKey)
- )
- ) {
- if (value !== options[focussedOptionIndex].value) {
- selectFocussedOption()
+ }, [options, setFocussedOptionIndex])
+
+ const handleKeyPress = useCallback(
+ (e) => {
+ const { key, altKey, ctrlKey, metaKey } = e
+
+ if (
+ expanded &&
+ (key === 'Escape' ||
+ key === 'Enter' ||
+ (key === ' ' && !typing) ||
+ (key === 'ArrowUp' && altKey) ||
+ (key === 'ArrowDown' && altKey))
+ ) {
+ if (value !== options[focussedOptionIndex].value) {
+ selectFocussedOption()
+ }
+
+ closeMenu()
+ return
}
- closeMenu()
- return
- }
+ if (
+ !expanded &&
+ ((key === ' ' && !typing) ||
+ key === 'Enter' ||
+ (key === 'ArrowUp' && altKey) ||
+ (key === 'ArrowDown' && altKey))
+ ) {
+ openMenu()
+ return
+ }
- if (
- !expanded &&
- (
- key === ' ' ||
- key === 'Enter' ||
- (key === 'ArrowUp' && altKey) ||
- (key === 'ArrowDown' && altKey)
- )
- ) {
- openMenu()
- return
- }
+ if (key === 'ArrowDown') {
+ if (!expanded) {
+ selectNextOption()
+ } else {
+ focusNextOption()
+ }
- if (key === 'ArrowDown') {
- if (!expanded) {
- selectNextOption()
- } else {
- focusNextOption()
+ return
}
- return
- }
+ if (key === 'ArrowUp') {
+ if (!expanded) {
+ selectPrevOption()
+ } else {
+ focusPrevOption()
+ }
- if (key === 'ArrowUp') {
- if (!expanded) {
- selectPrevOption()
- } else {
- focusPrevOption()
+ return
}
- return
- }
-
- if (key === 'Home') {
- focusFirstOption()
- return
- }
+ if (key === 'Home') {
+ focusFirstOption()
+ return
+ }
- if (key === 'End') {
- focusLastOption()
- return
- }
+ if (key === 'End') {
+ focusLastOption()
+ return
+ }
- if (
- key === 'Backspace' ||
- key === 'Clear' ||
- (key.length === 1 && key !== ' ' && !altKey && !ctrlKey && !metaKey)
- ) {
- console.log('> typing')
- // @TODO: Handle typing
- return
- }
+ if (
+ key === 'Backspace' ||
+ key === 'Clear' ||
+ (key.length === 1 &&
+ key !== ' ' &&
+ !altKey &&
+ !ctrlKey &&
+ !metaKey) ||
+ (key === ' ' && typing)
+ ) {
+ onTyping(e)
+ return
+ }
- if (expanded && key === 'PageUp') {
- // @TODO: SelectActions.PageUp
- return
- }
+ if (expanded && key === 'PageUp') {
+ // @TODO: SelectActions.PageUp
+ return
+ }
- if (expanded && key === 'PageDown') {
- // @TODO: SelectActions.PageDown
- return
- }
+ if (expanded && key === 'PageDown') {
+ // @TODO: SelectActions.PageDown
+ return
+ }
- console.log('> do nothing')
- }, [
- expanded,
- closeMenu,
- openMenu,
- selectFocussedOption,
- focusNextOption,
- focusPrevOption,
- focusFirstOption,
- focusLastOption,
- ])
+ // Do nothing
+ },
+ [
+ expanded,
+ closeMenu,
+ openMenu,
+ options,
+ value,
+ typing,
+ focussedOptionIndex,
+ selectFocussedOption,
+ selectNextOption,
+ selectPrevOption,
+ focusNextOption,
+ focusPrevOption,
+ focusFirstOption,
+ focusLastOption,
+ onTyping,
+ ]
+ )
return handleKeyPress
}
From 31285c6682662db89cb52871ac9c943f521c0503 Mon Sep 17 00:00:00 2001
From: Mohammer5
Date: Fri, 18 Oct 2024 10:24:09 +0800
Subject: [PATCH 11/39] feat(select a11y): scroll highlighted option into view
---
.../single-select-a11y/is-option-hidden.js | 13 ++++
.../src/single-select-a11y/menu-loading.js | 34 +++++++++
.../single-select-a11y/menu-options-list.js | 76 +++++++------------
.../select/src/single-select-a11y/menu.js | 14 +++-
.../src/single-select-a11y/selected-value.js | 19 ++++-
.../single-select-a11y/single-select-a11y.js | 17 +++--
.../single-select-a11y.prod.stories.js | 2 +-
.../use-handle-key-press.js | 48 ++++++++----
8 files changed, 147 insertions(+), 76 deletions(-)
create mode 100644 components/select/src/single-select-a11y/is-option-hidden.js
create mode 100644 components/select/src/single-select-a11y/menu-loading.js
diff --git a/components/select/src/single-select-a11y/is-option-hidden.js b/components/select/src/single-select-a11y/is-option-hidden.js
new file mode 100644
index 000000000..f7035d0fe
--- /dev/null
+++ b/components/select/src/single-select-a11y/is-option-hidden.js
@@ -0,0 +1,13 @@
+export function isOptionHidden(option, scrollContainer) {
+ const optionOffsetTop = option.getBoundingClientRect().top
+ const optionHeight = option.offsetHeight
+ const optionOffsetBottom = optionOffsetTop + optionHeight
+ const containerOffsetTop = scrollContainer.getBoundingClientRect().top
+ const containerHeight = scrollContainer.offsetHeight
+ const containerOffsetBottom = containerOffsetTop + containerHeight
+
+ return (
+ optionOffsetBottom > containerOffsetBottom ||
+ optionOffsetTop < containerOffsetTop
+ )
+}
diff --git a/components/select/src/single-select-a11y/menu-loading.js b/components/select/src/single-select-a11y/menu-loading.js
new file mode 100644
index 000000000..9e7491702
--- /dev/null
+++ b/components/select/src/single-select-a11y/menu-loading.js
@@ -0,0 +1,34 @@
+import { colors, spacers, theme } from '@dhis2/ui-constants'
+import { CircularLoader } from '@dhis2-ui/loader'
+import PropTypes from 'prop-types'
+import React from 'react'
+
+export function MenuLoading({ message }) {
+ return (
+
+
+
+
+
+ {message}
+
+
+
+ )
+}
+
+MenuLoading.propTypes = {
+ message: 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
index e90086185..5dae04aca 100644
--- a/components/select/src/single-select-a11y/menu-options-list.js
+++ b/components/select/src/single-select-a11y/menu-options-list.js
@@ -1,42 +1,12 @@
-import { colors, spacers, theme } from '@dhis2/ui-constants'
-import { CircularLoader } from '@dhis2-ui/loader'
import PropTypes from 'prop-types'
-import React from 'react'
+import React, { useEffect, useRef } from 'react'
+import { isOptionHidden } from './is-option-hidden.js'
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,
+ expanded,
focussedOptionIndex,
idPrefix,
labelledBy,
@@ -44,15 +14,38 @@ export function MenuOptionsList({
selected,
dataTest,
disabled,
- empty,
loading,
- loadingText,
onChange,
onBlur,
onKeyDown,
}) {
+ const listBoxRef = useRef()
+
+ // scrolls the highlighted option into view when:
+ // * the highlighted option changes
+ // * the menu opens
+ useEffect(() => {
+ const { current: listBox } = listBoxRef
+ const highlightedOption = expanded
+ ? listBox.childNodes[focussedOptionIndex]
+ : null
+
+ if (highlightedOption) {
+ const listBoxParent = listBox.parentNode
+ const optionHidden = isOptionHidden(
+ highlightedOption,
+ listBoxParent
+ )
+
+ if (optionHidden) {
+ highlightedOption.scrollIntoView()
+ }
+ }
+ }, [expanded, focussedOptionIndex])
+
return (
- {!options.length && empty}
-
{options.map(
(
{
@@ -91,30 +82,21 @@ export function MenuOptionsList({
)
}
)}
-
- {loading && }
-
-
)
}
MenuOptionsList.propTypes = {
comboBoxId: PropTypes.string.isRequired,
+ expanded: PropTypes.bool.isRequired,
focussedOptionIndex: PropTypes.number.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
index bc6c5b8af..5a4b49064 100644
--- a/components/select/src/single-select-a11y/menu.js
+++ b/components/select/src/single-select-a11y/menu.js
@@ -5,6 +5,7 @@ import cx from 'classnames'
import PropTypes from 'prop-types'
import React, { useEffect, useState } from 'react'
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'
@@ -62,24 +63,25 @@ export function Menu({
/>
)}
+ {!options.length && {empty}
}
+
- {/* Put (infinite) loading stuff here */ ''}
+ {loading && }
)
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({
)}
-
+ {
+ e.stopPropagation()
+ comboBoxRef.current.focus()
+ onClick()
+ }}
+ >
-
+
)
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}
}
(
div)
const comboBoxRef = useRef()
+ const listBoxRef = useRef()
const [focussedOptionIndex, setFocussedOptionIndex] = useState(0)
const [selectRef, setSelectRef] = useState()
const [expanded, setExpanded] = useState(false)
@@ -105,6 +106,7 @@ export function SingleSelectA11y({
options,
openMenu,
closeMenu,
+ listBoxRef,
focussedOptionIndex,
setFocussedOptionIndex,
selectFocussedOption,
@@ -141,7 +143,6 @@ export function SingleSelectA11y({
disabled={disabled}
error={error}
expanded={expanded}
- handleKeyPress={handleKeyPress}
hasSelection={!!value}
idPrefix={idPrefix}
labelledBy={labelledBy}
@@ -172,6 +173,7 @@ export function SingleSelectA11y({
hidden={!expanded}
idPrefix={idPrefix}
labelledBy={labelledBy}
+ listBoxRef={listBoxRef}
loading={loading}
loadingText={menuLoadingText}
maxHeight={menuMaxHeight}
@@ -196,7 +198,7 @@ SingleSelectA11y.propTypes = {
idPrefix: PropTypes.string.isRequired,
/** An array of options **/
- options: optionsProp.isRequired,
+ options: PropTypes.arrayOf(optionProp).isRequired,
/** As of now, this component does not support being uncontrolled */
value: PropTypes.string.isRequired,
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 9f87eaeb2..53fe672cd 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,20 +427,35 @@ export const WithOptionsAndLoadingText = () => {
}
export const WithManyOptions = () => {
- const [value, setValue] = useState('art_entry_point:_no_pmtct')
+ // const [value, setValue] = useState('art_entry_point:_no_pmtct')
+ const [value, setValue] = useState('10')
+ const selectOptions = Array.apply(null, Array(100)).map((x, i) => i)
return (
- option.value === value).label
- : ''
- }
- onChange={(nextValue) => setValue(nextValue)}
- options={options}
- />
+ <>
+ option.value === value).label
+ // : ''
+ // }
+ onChange={(nextValue) => setValue(nextValue)}
+ options={selectOptions.map((i) => ({
+ value: i.toString(),
+ label: `Select option ${i + 1}`,
+ }))}
+ />
+
+ console.log('> onChange', e)}>
+ {selectOptions.map((i) => (
+
+ Select option {i + 1}
+
+ ))}
+
+ >
)
}
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/index.js b/components/select/src/single-select-a11y/use-handle-key-press/index.js
new file mode 100644
index 000000000..a6fb3becf
--- /dev/null
+++ b/components/select/src/single-select-a11y/use-handle-key-press/index.js
@@ -0,0 +1 @@
+export { useHandleKeyPress } from './use-handle-key-press.js'
diff --git a/components/select/src/single-select-a11y/use-handle-key-press.js b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js
similarity index 67%
rename from components/select/src/single-select-a11y/use-handle-key-press.js
rename to components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js
index 9de5fb8d4..b69971c83 100644
--- a/components/select/src/single-select-a11y/use-handle-key-press.js
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js
@@ -1,77 +1,6 @@
-import { useCallback, useEffect, useRef, useState } from 'react'
-
-const TYPING_DEBOUNCE_TIME = 300 // ms
-
-function useHandleTyping({
- expanded,
- options,
- setFocussedOptionIndex,
- onChange,
-}) {
- const timeoutRef = useRef()
- const [value, setValue] = useState('')
- const [typing, setTyping] = useState(false)
-
- // This will reset the typed value after a given time
- useEffect(() => {
- if (timeoutRef.current) {
- clearTimeout(timeoutRef.current)
- timeoutRef.current = null
- }
-
- if (value) {
- timeoutRef.current = setTimeout(() => {
- setValue('')
- setTyping(false)
- }, TYPING_DEBOUNCE_TIME)
- } else {
- setTyping(false)
- }
- }, [
- // adding value to the dependencies array so that the hooks runs every time the value changes
- value,
- ])
-
- const prevValueRef = useRef()
- useEffect(() => {
- 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) {
- if (expanded) {
- setFocussedOptionIndex(optionIndex)
- } else {
- const nextSelectedOption = options[optionIndex]
- onChange(nextSelectedOption.value)
- }
- }
- }
- }, [value, options, setFocussedOptionIndex, expanded, onChange])
-
- const onTyping = useCallback((e) => {
- const { key } = e
- setTyping(true)
-
- if (key === 'Backspace') {
- setValue((prevValue) => prevValue.slice(0, -1))
- return
- }
-
- if (key === 'Clear') {
- setValue('')
- return
- }
-
- setValue((prevValue) => `${prevValue}${key}`)
- }, [])
-
- return { onTyping, typing }
-}
+import { useCallback } from 'react'
+import { useHandleTyping } from './use-handle-typing.js'
+import { usePageUpDown } from './use-page-up-down.js'
export function useHandleKeyPress({
value,
@@ -79,6 +8,7 @@ export function useHandleKeyPress({
options,
openMenu,
closeMenu,
+ listBoxRef,
focussedOptionIndex,
setFocussedOptionIndex,
selectFocussedOption,
@@ -91,6 +21,12 @@ export function useHandleKeyPress({
onChange,
})
+ const { pageDown, pageUp } = usePageUpDown(
+ listBoxRef,
+ focussedOptionIndex,
+ setFocussedOptionIndex
+ )
+
const selectNextOption = useCallback(() => {
const currentOptionIndex = options.findIndex(
(option) => option.value === value
@@ -209,12 +145,12 @@ export function useHandleKeyPress({
}
if (expanded && key === 'PageUp') {
- // @TODO: SelectActions.PageUp
+ pageUp()
return
}
if (expanded && key === 'PageDown') {
- // @TODO: SelectActions.PageDown
+ pageDown()
return
}
@@ -235,6 +171,8 @@ export function useHandleKeyPress({
focusPrevOption,
focusFirstOption,
focusLastOption,
+ pageDown,
+ pageUp,
onTyping,
]
)
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-handle-typing.js b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-typing.js
new file mode 100644
index 000000000..e75d8be9d
--- /dev/null
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-typing.js
@@ -0,0 +1,74 @@
+import { useCallback, useEffect, useRef, useState } from 'react'
+
+const TYPING_DEBOUNCE_TIME = 300 // ms
+
+export function useHandleTyping({
+ expanded,
+ options,
+ setFocussedOptionIndex,
+ onChange,
+}) {
+ const timeoutRef = useRef()
+ const [value, setValue] = useState('')
+ const [typing, setTyping] = useState(false)
+
+ // This will reset the typed value after a given time
+ useEffect(() => {
+ if (timeoutRef.current) {
+ clearTimeout(timeoutRef.current)
+ timeoutRef.current = null
+ }
+
+ if (value) {
+ timeoutRef.current = setTimeout(() => {
+ setValue('')
+ setTyping(false)
+ }, TYPING_DEBOUNCE_TIME)
+ } else {
+ setTyping(false)
+ }
+ }, [
+ // adding value to the dependencies array so that the hooks runs every time the value changes
+ value,
+ ])
+
+ const prevValueRef = useRef()
+ useEffect(() => {
+ 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) {
+ if (expanded) {
+ setFocussedOptionIndex(optionIndex)
+ } else {
+ const nextSelectedOption = options[optionIndex]
+ onChange(nextSelectedOption.value)
+ }
+ }
+ }
+ }, [value, options, setFocussedOptionIndex, expanded, onChange])
+
+ const onTyping = useCallback((e) => {
+ const { key } = e
+ setTyping(true)
+
+ if (key === 'Backspace') {
+ setValue((prevValue) => prevValue.slice(0, -1))
+ return
+ }
+
+ if (key === 'Clear') {
+ setValue('')
+ return
+ }
+
+ setValue((prevValue) => `${prevValue}${key}`)
+ }, [])
+
+ return { onTyping, typing }
+}
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-page-up-down.js b/components/select/src/single-select-a11y/use-handle-key-press/use-page-up-down.js
new file mode 100644
index 000000000..f667ea703
--- /dev/null
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-page-up-down.js
@@ -0,0 +1,103 @@
+import { useCallback } from 'react'
+import { isOptionHidden } from '../is-option-hidden.js'
+
+export function usePageUpDown(
+ listBoxRef,
+ focussedOptionIndex,
+ setFocussedOptionIndex
+) {
+ const pageDown = useCallback(() => {
+ const listBoxParent = listBoxRef.current.parentNode
+ const options = Array.from(listBoxRef.current.childNodes)
+ const highestVisibleIndex = options.reduce(
+ (curIndex, option, index) => {
+ if (
+ // When option is not visible
+ isOptionHidden(option, listBoxParent) ||
+ // When option is not the highest-index one so far
+ index <= curIndex
+ ) {
+ return curIndex
+ }
+
+ return index
+ },
+ -1
+ )
+
+ // No visible option (e.g. when menu is empty)
+ if (highestVisibleIndex === -1) {
+ return
+ }
+
+ // Highlight last visible option
+ if (highestVisibleIndex > focussedOptionIndex) {
+ setFocussedOptionIndex(highestVisibleIndex)
+ return
+ }
+
+ const visibleOptionsAmount = options.filter(
+ (option) => !isOptionHidden(option, listBoxParent)
+ ).length
+
+ const nextHighlightedOptionIndex = Math.min(
+ options.length - 1,
+ focussedOptionIndex + visibleOptionsAmount
+ )
+
+ // If there's no next option and we already have the last option in the list highlighted
+ if (!options[nextHighlightedOptionIndex]) {
+ return
+ }
+
+ const nextTopOptionIndex = Math.min(
+ options.length - 1,
+ focussedOptionIndex + 1
+ )
+
+ const nextTopOption = options[nextTopOptionIndex]
+ const scrollPosition = nextTopOption.offsetTop
+ listBoxParent.scrollTop = scrollPosition
+ setFocussedOptionIndex(nextHighlightedOptionIndex)
+ }, [focussedOptionIndex, setFocussedOptionIndex, listBoxRef])
+
+ const pageUp = useCallback(() => {
+ const listBoxParent = listBoxRef.current.parentNode
+ const options = Array.from(listBoxRef.current.childNodes)
+ const lowestVisibleIndex = options.findIndex(
+ (option) => !isOptionHidden(option, listBoxParent)
+ )
+
+ // No visible option (e.g. when menu is empty)
+ if (lowestVisibleIndex === -1) {
+ return
+ }
+
+ // Highlight last visible option
+ if (lowestVisibleIndex < focussedOptionIndex) {
+ setFocussedOptionIndex(lowestVisibleIndex)
+ return
+ }
+
+ const visibleOptionsAmount = options.filter(
+ (option) => !isOptionHidden(option, listBoxParent)
+ ).length
+
+ const nextTopOptionIndex = Math.max(
+ 0,
+ focussedOptionIndex - visibleOptionsAmount
+ )
+
+ // If there's no next option and we already have the last option in the list highlighted
+ if (!options[nextTopOptionIndex]) {
+ return
+ }
+
+ const nextTopOption = options[nextTopOptionIndex]
+ const scrollPosition = nextTopOption.offsetTop
+ listBoxParent.scrollTop = scrollPosition
+ setFocussedOptionIndex(nextTopOptionIndex)
+ }, [focussedOptionIndex, setFocussedOptionIndex, listBoxRef])
+
+ return { pageDown, pageUp }
+}
From b36fb34384510b50967f8ebe79fcfed601459d80 Mon Sep 17 00:00:00 2001
From: Mohammer5
Date: Mon, 21 Oct 2024 18:45:20 +0800
Subject: [PATCH 13/39] chore(select a11y): add empty test cases with @TODO
comment
---
.../src/single-select-a11y/single-select-a11y.test.js | 6 ++++++
1 file changed, 6 insertions(+)
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
index 9e43f7426..2bb282378 100644
--- a/components/select/src/single-select-a11y/single-select-a11y.test.js
+++ b/components/select/src/single-select-a11y/single-select-a11y.test.js
@@ -578,4 +578,10 @@ describe(' ', () => {
expect(onChange).toHaveBeenCalledTimes(1)
expect(onChange).toHaveBeenCalledWith('foo')
})
+
+ // @TODO
+ it.skip('should move up an entire page', () => {})
+ it.skip('should move up half a page to the first option', () => {})
+ it.skip('should move down an entire page', () => {})
+ it.skip('should move down half a page to the last option', () => {})
})
From 5c870956e069f6ac5a0edbe5bcd4b0c2ed0b3a30 Mon Sep 17 00:00:00 2001
From: Mohammer5
Date: Tue, 22 Oct 2024 17:12:57 +0800
Subject: [PATCH 14/39] test(select a11y): add pageUp/pageDown tests (cypress)
for
---
.../keyboard-interactions.test.e2e.js | 256 ++++++++++++++++++
.../src/single-select-a11y/menu/option.js | 2 +-
.../single-select-a11y.e2e.stories.js | 26 +-
.../single-select-a11y.prod.stories.js | 41 +--
.../single-select-a11y.test.js | 167 +++++++++++-
cypress.config.js | 2 +-
6 files changed, 454 insertions(+), 40 deletions(-)
create mode 100644 components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js
diff --git a/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js b/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js
new file mode 100644
index 000000000..993dd3731
--- /dev/null
+++ b/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js
@@ -0,0 +1,256 @@
+describe(' ', () => {
+ it('should highlight the first option on the currently displayed page', () => {
+ // Given a select with 100 options is displayed
+ cy.visitStory('Single Select A11y', 'Hundret Options')
+
+ // And the menu is visible
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
+
+ // And the 74th option is being highlighted
+ cy.findByRole('combobox').focus().type(`Select option 73`)
+
+ // And the 70th option is the first option on the current page
+ let optionOffset
+ cy.findAllByRole('option')
+ .eq(70)
+ .then((option) => {
+ const { offsetTop } = option.get(0)
+ optionOffset = offsetTop
+ })
+
+ cy.findByRole('option', { selected: true })
+ .invoke('parent') // listbox
+ .invoke('parent') // scrollable div
+ .then((listBoxParent) => {
+ console.log('> listBoxParent', listBoxParent.get(0))
+ console.log('> optionOffset', optionOffset)
+ listBoxParent.get(0).scrollTop = optionOffset
+ })
+
+ cy.findAllByRole('option').eq(70).should('be.visible')
+
+ // When the PageUp key is pressed
+ cy.findByRole('combobox').trigger('keydown', {
+ key: 'PageUp',
+ force: true,
+ })
+
+ // Then the first option on the currently displayed page is highlighted
+ cy.findByRole('option', { selected: true })
+ .invoke('attr', 'aria-label')
+ .should('equal', 'Select option 70')
+ })
+
+ it('should highlight the first option on the previous page', () => {
+ // Given a select with 100 options is displayed
+ cy.visitStory('Single Select A11y', 'Hundret Options')
+
+ // And the menu is visible
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
+
+ // And the 70th option is being highlighted
+ // Will automatically scroll there and make it the first option on the page
+ cy.findByRole('combobox').focus().type(`Select option 70`)
+
+ // When the PageUp key is pressed
+ cy.findByRole('combobox').trigger('keydown', {
+ key: 'PageUp',
+ force: true,
+ })
+
+ // Then the first option on the previous page is highlighted
+ cy.findByRole('option', { selected: true })
+ .invoke('attr', 'aria-label')
+ .should('equal', 'Select option 61')
+
+ // And the previously highlighted option is not visible
+ cy.findAllByRole('option').eq(70).should('not.be.visible')
+ })
+
+ it('should highlight the first option', () => {
+ // Given a select with 100 options is displayed
+ cy.visitStory('Single Select A11y', 'Hundret Options')
+
+ // And the menu is visible
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
+
+ // And the 2nd option is being highlighted
+ cy.findByRole('combobox').focus().type(`Select option 1`)
+
+ // And the 2nd option is the first option on the current page
+ let optionOffset
+ cy.findAllByRole('option')
+ .eq(1)
+ .then((option) => {
+ const { offsetTop } = option.get(0)
+ optionOffset = offsetTop
+ })
+
+ cy.findByRole('option', { selected: true })
+ .invoke('parent') // listbox
+ .invoke('parent') // scrollable div
+ .then((listBoxParent) => {
+ console.log('> listBoxParent', listBoxParent.get(0))
+ console.log('> optionOffset', optionOffset)
+ listBoxParent.get(0).scrollTop = optionOffset
+ })
+
+ // When the PageUp key is pressed
+ cy.findByRole('combobox').trigger('keydown', {
+ key: 'PageUp',
+ force: true,
+ })
+
+ // Then the first option is being highlighted
+ cy.all(
+ () => cy.findAllByRole('option').first().invoke('get', 0),
+ () => cy.findByRole('option', { selected: true }).invoke('get', 0)
+ ).then(([firstOption, highlightedOption]) => {
+ expect(highlightedOption).to.equal(firstOption)
+ })
+ })
+
+ it('should highlight the last option on the currently displayed page', () => {
+ // Given a select with 100 options is displayed
+ cy.visitStory('Single Select A11y', 'Hundret Options')
+
+ // And the menu is visible
+ // first option will be highlighted automatically
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
+
+ // When the PageDown key is pressed
+ cy.findByRole('combobox').trigger('keydown', {
+ key: 'PageDown',
+ force: true,
+ })
+
+ // Then the last option on the currently displayed page is highlighted
+ cy.all(
+ () => cy.get('[role="option"]:visible').last().invoke('get', 0),
+ () => cy.findByRole('option', { selected: true }).invoke('get', 0)
+ ).then(([lastVisibleOption, highlightedOption]) => {
+ expect(highlightedOption).to.equal(lastVisibleOption)
+ })
+ })
+
+ it(
+ 'should highlight the last option on the next page',
+ // We don't want the options to scroll when we check whether they're
+ // visible or not (as that'd make them visible)
+ { scrollBehavior: false },
+ () => {
+ // Given a select with 100 options is displayed
+ cy.visitStory('Single Select A11y', 'Hundret Options')
+
+ // And the menu is visible
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
+
+ // And the option last visible option is being highlighted
+ cy.get('[role="option"]:visible').then(($visibleOptions) => {
+ const visibleOptionsAmount = $visibleOptions.length
+
+ for (let i = 0; i < visibleOptionsAmount - 1; ++i) {
+ cy.findByRole('combobox').trigger('keydown', {
+ key: 'ArrowDown',
+ force: true,
+ })
+
+ if (i === visibleOptionsAmount - 2) {
+ cy.wrap(i).as('lastVisibleOptionIndex')
+ }
+ }
+ })
+
+ cy.get('@lastVisibleOptionIndex').then((lastVisibleOptionIndex) => {
+ cy.get('[role="option"]')
+ .eq(lastVisibleOptionIndex + 1) // 1-based
+ .invoke('attr', 'aria-selected')
+ .should('equal', 'true')
+ })
+
+ // When the PageDown key is pressed
+ cy.findByRole('combobox').trigger('keydown', {
+ key: 'PageDown',
+ force: true,
+ })
+
+ // Then the next page is shown
+ cy.get('@lastVisibleOptionIndex').then((lastVisibleOptionIndex) => {
+ cy.get('[role="option"]')
+ .eq(lastVisibleOptionIndex + 1)
+ .should('not.be.visible')
+ cy.get('[role="option"]')
+ .eq(lastVisibleOptionIndex + 2)
+ .should('be.visible')
+ })
+
+ // And the last option on the next page is highlighted
+ cy.get('[role="option"]:visible')
+ .last()
+ .invoke('attr', 'aria-selected')
+ .should('equal', 'true')
+
+ // And the previously highlighted option is not visible
+ cy.get('@lastVisibleOptionIndex').then((lastVisibleOptionIndex) => {
+ cy.get('[role="option"]')
+ .eq(lastVisibleOptionIndex + 1)
+ .invoke('attr', 'aria-selected')
+ .should('equal', 'false')
+ })
+ }
+ )
+
+ it(
+ 'should highlight the last option',
+ // We don't want the options to scroll when we check whether they're
+ // visible or not (as that'd make them visible)
+ { scrollBehavior: false },
+ () => {
+ // Given a select with 100 options is displayed
+ cy.visitStory('Single Select A11y', 'Hundret Options')
+
+ // And the menu is visible
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
+
+ // And the 2nd-last option is being highlighted and visible
+ for (
+ let i = 0;
+ i < 11; // This will bring us to the second last option exactly
+ ++i
+ ) {
+ cy.findByRole('combobox').trigger('keydown', {
+ key: 'PageDown',
+ force: true,
+ })
+ }
+ cy.findAllByRole('option').eq(98).should('be.visible')
+
+ // And the last option is not visible
+ cy.findAllByRole('option').last().should('not.be.visible')
+
+ // When the PageDown key is pressed
+ cy.findByRole('combobox').trigger('keydown', {
+ key: 'PageDown',
+ force: true,
+ })
+
+ // Then the last option is highlighted
+ cy.all(
+ () => cy.findAllByRole('option').last().invoke('get', 0),
+ () =>
+ cy.findByRole('option', { selected: true }).invoke('get', 0)
+ ).then(([lastOption, highlightedOption]) => {
+ expect(highlightedOption).to.equal(lastOption)
+ })
+
+ // And the last option is visible
+ cy.findAllByRole('option').last().should('be.visible')
+ }
+ )
+})
diff --git a/components/select/src/single-select-a11y/menu/option.js b/components/select/src/single-select-a11y/menu/option.js
index 4ad2a5ef8..bcc3837d6 100644
--- a/components/select/src/single-select-a11y/menu/option.js
+++ b/components/select/src/single-select-a11y/menu/option.js
@@ -74,7 +74,7 @@ export function Option({
data-test={dataTest}
disabled={disabled}
role="option"
- aria-selected={highlighted || ''}
+ aria-selected={highlighted || 'false'}
aria-disabled={disabled}
aria-label={label}
onClick={() => {
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
index df78e235f..2785ae080 100644
--- 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
@@ -1,4 +1,4 @@
-import React from 'react'
+import React, { useState } from 'react'
import { SingleSelectA11y } from './single-select-a11y.js'
export default {
@@ -102,7 +102,7 @@ const fiveOptions = options.slice(0, 5)
export const DefaultPosition = () => (
null}
options={fiveOptions}
/>
@@ -112,7 +112,7 @@ export const FlippedPosition = () => (
<>
null}
options={options}
/>
@@ -136,7 +136,7 @@ export const ShiftedIntoView = () => (
<>
null}
options={options}
/>
@@ -155,3 +155,21 @@ export const ShiftedIntoView = () => (
`}
>
)
+
+const hundretOptions = Array.apply(null, Array(100)).map((x, i) => ({
+ value: `${i}`,
+ label: `Select option ${i}`,
+}))
+
+export const HundretOptions = () => {
+ const [value, setValue] = useState('0')
+
+ return (
+
+ )
+}
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 53fe672cd..a1c11677f 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
@@ -394,6 +394,7 @@ export const WithOptionsAndLoading = () => {
}
onChange={(nextValue) => setValue(nextValue)}
options={[
+ { label: 'None', value: '' },
{ value: '1', label: 'Option 1' },
{ value: '2', label: 'Option 2' },
{ value: '3', label: 'Option 3' },
@@ -418,6 +419,7 @@ export const WithOptionsAndLoadingText = () => {
}
onChange={(nextValue) => setValue(nextValue)}
options={[
+ { label: 'None', value: '' },
{ value: '1', label: 'Option 1' },
{ value: '2', label: 'Option 2' },
{ value: '3', label: 'Option 3' },
@@ -427,35 +429,20 @@ export const WithOptionsAndLoadingText = () => {
}
export const WithManyOptions = () => {
- // const [value, setValue] = useState('art_entry_point:_no_pmtct')
- const [value, setValue] = useState('10')
- const selectOptions = Array.apply(null, Array(100)).map((x, i) => i)
+ const [value, setValue] = useState('art_entry_point:_no_pmtct')
return (
- <>
- option.value === value).label
- // : ''
- // }
- onChange={(nextValue) => setValue(nextValue)}
- options={selectOptions.map((i) => ({
- value: i.toString(),
- label: `Select option ${i + 1}`,
- }))}
- />
-
- console.log('> onChange', e)}>
- {selectOptions.map((i) => (
-
- Select option {i + 1}
-
- ))}
-
- >
+ option.value === value).label
+ : ''
+ }
+ onChange={(nextValue) => setValue(nextValue)}
+ 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
index 2bb282378..15b12209d 100644
--- a/components/select/src/single-select-a11y/single-select-a11y.test.js
+++ b/components/select/src/single-select-a11y/single-select-a11y.test.js
@@ -425,14 +425,13 @@ describe(' ', () => {
expect(combobox).toContainElement(withTextBar)
})
- /**************************
+ /***************************
* *
* ===================== *
* Keyboard interactions *
* ===================== *
* *
**************************/
-
describe.each([
{ key: ' ' },
{ key: 'Enter' },
@@ -579,9 +578,163 @@ describe(' ', () => {
expect(onChange).toHaveBeenCalledWith('foo')
})
- // @TODO
- it.skip('should move up an entire page', () => {})
- it.skip('should move up half a page to the first option', () => {})
- it.skip('should move down an entire page', () => {})
- it.skip('should move down half a page to the last option', () => {})
+ it('should highlight the next option', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ // open the menu
+ expect(screen.queryByRole('listbox')).toBeNull()
+ const comboBox = screen.getByRole('combobox')
+ fireEvent.click(comboBox)
+ expect(screen.queryByRole('listbox')).not.toBeNull()
+
+ // the first option should be highlighted
+ const highlightedOptionBefore = screen.getByRole('option', {
+ selected: true,
+ })
+ expect(
+ highlightedOptionBefore.attributes.getNamedItem('aria-label').value
+ ).toBe('None')
+
+ // The second option should be highlighted
+ fireEvent.keyDown(comboBox, { key: 'ArrowDown' })
+ const highlightedOptionAfter = screen.getByRole('option', {
+ selected: true,
+ })
+ expect(
+ highlightedOptionAfter.attributes.getNamedItem('aria-label').value
+ ).toBe('Foo')
+ })
+
+ it('should highlight the previous option', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ // open the menu
+ expect(screen.queryByRole('listbox')).toBeNull()
+ const comboBox = screen.getByRole('combobox')
+ fireEvent.click(comboBox)
+ expect(screen.queryByRole('listbox')).not.toBeNull()
+
+ // the last option should be highlighted
+ const highlightedOptionBefore = screen.getByRole('option', {
+ selected: true,
+ })
+ expect(
+ highlightedOptionBefore.attributes.getNamedItem('aria-label').value
+ ).toBe('Bar')
+
+ // The second option should be highlighted
+ fireEvent.keyDown(comboBox, { key: 'ArrowUp' })
+ const highlightedOptionAfter = screen.getByRole('option', {
+ selected: true,
+ })
+ expect(
+ highlightedOptionAfter.attributes.getNamedItem('aria-label').value
+ ).toBe('Foo')
+ })
+
+ it('should highlight the first option', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ // open the menu
+ expect(screen.queryByRole('listbox')).toBeNull()
+ const comboBox = screen.getByRole('combobox')
+ fireEvent.click(comboBox)
+ expect(screen.queryByRole('listbox')).not.toBeNull()
+
+ // the last option should be highlighted
+ const highlightedOptionBefore = screen.getByRole('option', {
+ selected: true,
+ })
+ expect(
+ highlightedOptionBefore.attributes.getNamedItem('aria-label').value
+ ).toBe('Bar')
+
+ // The first option should be highlighted
+ fireEvent.keyDown(comboBox, { key: 'Home' })
+ const highlightedOptionAfter = screen.getByRole('option', {
+ selected: true,
+ })
+ expect(
+ highlightedOptionAfter.attributes.getNamedItem('aria-label').value
+ ).toBe('None')
+ })
+
+ it('should highlight the last option', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ // open the menu
+ expect(screen.queryByRole('listbox')).toBeNull()
+ const comboBox = screen.getByRole('combobox')
+ fireEvent.click(comboBox)
+ expect(screen.queryByRole('listbox')).not.toBeNull()
+
+ // the first option should be highlighted
+ const highlightedOptionBefore = screen.getByRole('option', {
+ selected: true,
+ })
+ expect(
+ highlightedOptionBefore.attributes.getNamedItem('aria-label').value
+ ).toBe('None')
+
+ // The last option should be highlighted
+ fireEvent.keyDown(comboBox, { key: 'End' })
+ const highlightedOptionAfter = screen.getByRole('option', {
+ selected: true,
+ })
+ expect(
+ highlightedOptionAfter.attributes.getNamedItem('aria-label').value
+ ).toBe('Bar')
+ })
})
diff --git a/cypress.config.js b/cypress.config.js
index 17f05f725..52bb6bf9f 100644
--- a/cypress.config.js
+++ b/cypress.config.js
@@ -15,7 +15,7 @@ module.exports = defineConfig({
e2e: {
setupNodeEvents,
baseUrl: 'http://localhost:5000',
- specPattern: '**/src/**/*.feature',
+ specPattern: '**/src/**/*{.feature,.test.e2e.js}',
experimentalRunAllSpecs: true,
},
})
From 577375d884ba558431d6e39569811b4474f25a62 Mon Sep 17 00:00:00 2001
From: Mohammer5
Date: Wed, 23 Oct 2024 08:39:19 +0800
Subject: [PATCH 15/39] chore(select a11y): make position cucumber test into a
vanilla cypress test
---
collections/forms/i18n/en.pot | 106 ++++++++++
.../features/menu-positioning.test.e2e.js | 187 ++++++++++++++++++
.../features/position.feature | 26 ---
.../features/position/index.js | 114 -----------
.../single-select-a11y.prod.stories.js | 3 +-
5 files changed, 295 insertions(+), 141 deletions(-)
create mode 100644 collections/forms/i18n/en.pot
create mode 100644 components/select/src/single-select-a11y/features/menu-positioning.test.e2e.js
delete mode 100644 components/select/src/single-select-a11y/features/position.feature
delete mode 100644 components/select/src/single-select-a11y/features/position/index.js
diff --git a/collections/forms/i18n/en.pot b/collections/forms/i18n/en.pot
new file mode 100644
index 000000000..599e729bf
--- /dev/null
+++ b/collections/forms/i18n/en.pot
@@ -0,0 +1,106 @@
+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-10-23T00:35:22.918Z\n"
+"PO-Revision-Date: 2024-10-23T00:35:22.918Z\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/components/select/src/single-select-a11y/features/menu-positioning.test.e2e.js b/components/select/src/single-select-a11y/features/menu-positioning.test.e2e.js
new file mode 100644
index 000000000..5ea33c8aa
--- /dev/null
+++ b/components/select/src/single-select-a11y/features/menu-positioning.test.e2e.js
@@ -0,0 +1,187 @@
+describe('SingleSelectA11y: Menu positioning', () => {
+ it('should open in the default position', () => {
+ // Given there is enough space below the anchor to fit the SingleSelect menu
+ cy.visitStory('Single Select A11y', 'Default position')
+
+ // When the SingleSelect is clicked
+ cy.findByRole('combobox').click()
+
+ // Then the top of the menu is aligned with the bottom of the input
+ 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)
+ })
+
+ // And the left of the SingleSelect is aligned with the left of the anchor
+ 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)
+ })
+ })
+
+ it('should flipp the position when insufficient space below', () => {
+ // Given there is not enough space below the anchor to fit the SingleSelect menu
+ cy.visitStory('Single Select A11y', 'Flipped position')
+
+ // When the SingleSelect is clicked
+ cy.findByRole('combobox').click()
+
+ // Then the bottom of the menu is aligned with the top of the input
+ 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)
+ })
+
+ // And the left of the SingleSelect is aligned with the left of the anchor
+ 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)
+ })
+ })
+
+ it('should shift the menu into view when insufficient space below and above', () => {
+ // 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()
+
+ // Then it is rendered on top of the SingleSelect
+ 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)
+ })
+
+ // And the left of the SingleSelect is aligned with the left of the anchor
+ 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)
+ })
+ })
+
+ it('should keep the menu in position while the window is scrolled', () => {
+ // Given there is enough space below the anchor to fit the SingleSelect menu
+ cy.visitStory('Single Select A11y', 'Default position')
+
+ // When the SingleSelect is clicked
+ cy.findByRole('combobox').click()
+
+ // And the window is scrolled down
+ cy.get('body').then(($body) => $body.height('5000px')) // Ensure the body can scroll first
+ cy.scrollTo(0, 800)
+
+ // Then the top of the menu is aligned with the bottom of the input
+ 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)
+ })
+
+ // And the left of the SingleSelect is aligned with the left of the anchor
+ 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/features/position.feature b/components/select/src/single-select-a11y/features/position.feature
deleted file mode 100644
index 791f22929..000000000
--- a/components/select/src/single-select-a11y/features/position.feature
+++ /dev/null
@@ -1,26 +0,0 @@
-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
deleted file mode 100644
index f775d754c..000000000
--- a/components/select/src/single-select-a11y/features/position/index.js
+++ /dev/null
@@ -1,114 +0,0 @@
-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', () => {
- 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', () => {
- 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', () => {
- 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',
- () => {
- 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.prod.stories.js b/components/select/src/single-select-a11y/single-select-a11y.prod.stories.js
index a1c11677f..51236f436 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
@@ -571,6 +571,7 @@ export const WithRTL = () => {
export const WithPlaceholder = () => {
const [value, setValue] = useState('')
+ const withoutEmptyOptions = options.slice(1)
return (
{
: ''
}
onChange={(nextValue) => setValue(nextValue)}
- options={fiveOptions}
+ options={withoutEmptyOptions}
/>
)
}
From 4f412a712bde2b7558d2e0c0847a64d852e2ae57 Mon Sep 17 00:00:00 2001
From: Mohammer5
Date: Wed, 23 Oct 2024 13:08:13 +0800
Subject: [PATCH 16/39] fix(select a11y): make PageDown keypress respect
disabled options
---
collections/forms/i18n/en.pot | 4 +-
.../single-select-a11y.e2e.stories.js | 64 +++++++++-
.../use-handle-key-press.js | 7 +-
.../use-handle-key-press/use-handle-typing.js | 10 +-
.../use-highlight-last-option-on-next-page.js | 64 ++++++++++
.../use-highlight-last-visible-option.js | 52 +++++++++
.../use-handle-key-press/use-page-up-down.js | 110 ++++++++++--------
7 files changed, 250 insertions(+), 61 deletions(-)
create mode 100644 components/select/src/single-select-a11y/use-handle-key-press/use-highlight-last-option-on-next-page.js
create mode 100644 components/select/src/single-select-a11y/use-handle-key-press/use-highlight-last-visible-option.js
diff --git a/collections/forms/i18n/en.pot b/collections/forms/i18n/en.pot
index 599e729bf..1c0f7570c 100644
--- a/collections/forms/i18n/en.pot
+++ b/collections/forms/i18n/en.pot
@@ -5,8 +5,8 @@ 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: 2024-10-23T00:35:22.918Z\n"
-"PO-Revision-Date: 2024-10-23T00:35:22.918Z\n"
+"POT-Creation-Date: 2024-10-23T01:23:16.733Z\n"
+"PO-Revision-Date: 2024-10-23T01:23:16.734Z\n"
msgid "Upload file"
msgstr "Upload file"
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
index 2785ae080..d4a4b6df1 100644
--- 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
@@ -156,13 +156,14 @@ export const ShiftedIntoView = () => (
>
)
-const hundretOptions = Array.apply(null, Array(100)).map((x, i) => ({
- value: `${i}`,
- label: `Select option ${i}`,
-}))
-
export const HundretOptions = () => {
const [value, setValue] = useState('0')
+ const [hundretOptions] = useState(
+ Array.apply(null, Array(100)).map((x, i) => ({
+ value: `${i}`,
+ label: `Select option ${i}`,
+ }))
+ )
return (
{
/>
)
}
+
+export const HundretOptionsWithDisabled = () => {
+ const [value, setValue] = useState('0')
+ const [hundretOptions] = useState(
+ Array.apply(null, Array(100)).map((x, i) => ({
+ value: `${i}`,
+ label: `Select option ${i}`,
+ disabled: i === 17 || i === 18,
+ }))
+ )
+
+ return (
+
+ )
+}
+
+export const NativeSelect = () => {
+ const [value, setValue] = useState('0')
+ const [hundretOptions] = useState(
+ Array.apply(null, Array(100)).map((x, i) => ({
+ value: `${i}`,
+ label: `Select option ${i}`,
+ disabled: i > 19,
+ }))
+ )
+
+ return (
+ <>
+ setValue(e.target.value)}>
+ {hundretOptions.map((option) => (
+
+ {option.label}
+
+ ))}
+
+
+
+ >
+ )
+}
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js
index b69971c83..6ac3b1538 100644
--- a/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js
@@ -21,11 +21,12 @@ export function useHandleKeyPress({
onChange,
})
- const { pageDown, pageUp } = usePageUpDown(
+ const { pageDown, pageUp } = usePageUpDown({
+ options,
listBoxRef,
focussedOptionIndex,
- setFocussedOptionIndex
- )
+ setFocussedOptionIndex,
+ })
const selectNextOption = useCallback(() => {
const currentOptionIndex = options.findIndex(
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-handle-typing.js b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-typing.js
index e75d8be9d..55923f6e4 100644
--- a/components/select/src/single-select-a11y/use-handle-key-press/use-handle-typing.js
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-typing.js
@@ -8,11 +8,11 @@ export function useHandleTyping({
setFocussedOptionIndex,
onChange,
}) {
- const timeoutRef = useRef()
const [value, setValue] = useState('')
const [typing, setTyping] = useState(false)
// This will reset the typed value after a given time
+ const timeoutRef = useRef()
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
@@ -32,14 +32,18 @@ export function useHandleTyping({
value,
])
+ // This will focus the first option with a label starting with the typed sequence
const prevValueRef = useRef()
useEffect(() => {
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())
+ const optionIndex = options.findIndex(
+ (option) =>
+ option.label
+ .toLowerCase()
+ .startsWith(value.toLowerCase()) && !option.disabled
)
if (optionIndex !== -1) {
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-highlight-last-option-on-next-page.js b/components/select/src/single-select-a11y/use-handle-key-press/use-highlight-last-option-on-next-page.js
new file mode 100644
index 000000000..a53372a49
--- /dev/null
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-highlight-last-option-on-next-page.js
@@ -0,0 +1,64 @@
+import { useCallback } from 'react'
+import { isOptionHidden } from '../is-option-hidden.js'
+
+export function useHighlightLastOptionOnNextPage({
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ listBoxRef,
+}) {
+ return useCallback(
+ (listBoxParent) => {
+ const optionElements = Array.from(listBoxRef.current.childNodes)
+ const visibleOptionsAmount = options.filter(
+ (_, index) =>
+ !isOptionHidden(optionElements[index], listBoxParent)
+ ).length
+
+ const nextHighlightedOptionIndex = Math.min(
+ options.length - 1,
+ focussedOptionIndex + visibleOptionsAmount
+ )
+
+ // If there's no next option and we already have the last option in the list highlighted
+ if (!options[nextHighlightedOptionIndex]) {
+ return
+ }
+
+ if (!options[nextHighlightedOptionIndex].disabled) {
+ // This will be the first option in the list
+ const { offsetTop: scrollPosition } =
+ optionElements[
+ nextHighlightedOptionIndex - visibleOptionsAmount + 1
+ ]
+
+ listBoxParent.scrollTop = scrollPosition
+ setFocussedOptionIndex(nextHighlightedOptionIndex)
+ return
+ }
+
+ const followingEnabledOptionIndex =
+ nextHighlightedOptionIndex +
+ options
+ .slice(nextHighlightedOptionIndex)
+ .findIndex((option) => !option.disabled)
+
+ // There is no enabled option after the disabled option that's at the end of the next page
+ // So we stay where we are
+ if (followingEnabledOptionIndex === -1) {
+ return
+ }
+
+ // There is an enabled option after the disabled option that's at the end of the next page
+ // So that'll be the new highlighted option and the bottom of the next displayed page
+ const { offsetTop: adjustedScrollPosition } =
+ optionElements[
+ followingEnabledOptionIndex - visibleOptionsAmount + 1
+ ]
+
+ listBoxParent.scrollTop = adjustedScrollPosition
+ setFocussedOptionIndex(followingEnabledOptionIndex)
+ },
+ [options, focussedOptionIndex, setFocussedOptionIndex, listBoxRef]
+ )
+}
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-highlight-last-visible-option.js b/components/select/src/single-select-a11y/use-handle-key-press/use-highlight-last-visible-option.js
new file mode 100644
index 000000000..d9fec50f6
--- /dev/null
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-highlight-last-visible-option.js
@@ -0,0 +1,52 @@
+import { useCallback } from 'react'
+
+export function useHighlightLastVisibleOption({
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+}) {
+ return useCallback(
+ (highestVisibleIndex) => {
+ if (!options[highestVisibleIndex].disabled) {
+ setFocussedOptionIndex(highestVisibleIndex)
+ return
+ }
+
+ // Last option on page is disabled, find next option that's not disabled
+ const followingEnabledOptionIndex = options
+ .slice(highestVisibleIndex)
+ .findIndex((option) => !option.disabled)
+
+ if (followingEnabledOptionIndex >= 0) {
+ // We need to add the highest visible index because the index is lower due to slicing the array
+ setFocussedOptionIndex(
+ followingEnabledOptionIndex + highestVisibleIndex
+ )
+ return
+ }
+
+ // No following enabled option, trying to find the closest previous sibling of the last option on the current page
+ const closestToEndOfPageEnabledOptionIndex = options
+ .slice(
+ // We don't include the currently highlighted option
+ focussedOptionIndex + 1,
+ highestVisibleIndex
+ )
+ .findLastIndex((option) => !option.disabled)
+
+ if (closestToEndOfPageEnabledOptionIndex >= 0) {
+ setFocussedOptionIndex(
+ closestToEndOfPageEnabledOptionIndex +
+ // We need to add the focused index and 1 because the index is lower due to slicing the array
+ focussedOptionIndex +
+ 1
+ )
+ return
+ }
+
+ // The currently highlighted option is the last enabled option
+ return
+ },
+ [options, focussedOptionIndex, setFocussedOptionIndex]
+ )
+}
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-page-up-down.js b/components/select/src/single-select-a11y/use-handle-key-press/use-page-up-down.js
index f667ea703..636bf7dc9 100644
--- a/components/select/src/single-select-a11y/use-handle-key-press/use-page-up-down.js
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-page-up-down.js
@@ -1,67 +1,60 @@
import { useCallback } from 'react'
import { isOptionHidden } from '../is-option-hidden.js'
+import { useHighlightLastOptionOnNextPage } from './use-highlight-last-option-on-next-page.js'
+import { useHighlightLastVisibleOption } from './use-highlight-last-visible-option.js'
-export function usePageUpDown(
- listBoxRef,
+function usePageDown({
+ options,
focussedOptionIndex,
- setFocussedOptionIndex
-) {
- const pageDown = useCallback(() => {
+ setFocussedOptionIndex,
+ listBoxRef,
+}) {
+ const highlightLastVisibleOption = useHighlightLastVisibleOption({
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ })
+
+ const highlightLastOptionOnNextPage = useHighlightLastOptionOnNextPage({
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ listBoxRef,
+ })
+
+ return useCallback(() => {
const listBoxParent = listBoxRef.current.parentNode
const options = Array.from(listBoxRef.current.childNodes)
- const highestVisibleIndex = options.reduce(
- (curIndex, option, index) => {
- if (
- // When option is not visible
- isOptionHidden(option, listBoxParent) ||
- // When option is not the highest-index one so far
- index <= curIndex
- ) {
- return curIndex
- }
-
- return index
- },
- -1
+ const highestVisibleIndex = options.findLastIndex(
+ (option) => !isOptionHidden(option, listBoxParent)
)
- // No visible option (e.g. when menu is empty)
- if (highestVisibleIndex === -1) {
- return
- }
-
- // Highlight last visible option
if (highestVisibleIndex > focussedOptionIndex) {
- setFocussedOptionIndex(highestVisibleIndex)
+ highlightLastVisibleOption(highestVisibleIndex)
return
}
- const visibleOptionsAmount = options.filter(
- (option) => !isOptionHidden(option, listBoxParent)
- ).length
-
- const nextHighlightedOptionIndex = Math.min(
- options.length - 1,
- focussedOptionIndex + visibleOptionsAmount
- )
-
- // If there's no next option and we already have the last option in the list highlighted
- if (!options[nextHighlightedOptionIndex]) {
+ if (highestVisibleIndex > -1) {
+ highlightLastOptionOnNextPage(listBoxParent)
return
}
- const nextTopOptionIndex = Math.min(
- options.length - 1,
- focussedOptionIndex + 1
- )
-
- const nextTopOption = options[nextTopOptionIndex]
- const scrollPosition = nextTopOption.offsetTop
- listBoxParent.scrollTop = scrollPosition
- setFocussedOptionIndex(nextHighlightedOptionIndex)
- }, [focussedOptionIndex, setFocussedOptionIndex, listBoxRef])
+ // No visible option (e.g. when menu is empty)
+ return
+ }, [
+ focussedOptionIndex,
+ listBoxRef,
+ highlightLastVisibleOption,
+ highlightLastOptionOnNextPage,
+ ])
+}
- const pageUp = useCallback(() => {
+function usePageUp({
+ listBoxRef,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+}) {
+ return useCallback(() => {
const listBoxParent = listBoxRef.current.parentNode
const options = Array.from(listBoxRef.current.childNodes)
const lowestVisibleIndex = options.findIndex(
@@ -98,6 +91,27 @@ export function usePageUpDown(
listBoxParent.scrollTop = scrollPosition
setFocussedOptionIndex(nextTopOptionIndex)
}, [focussedOptionIndex, setFocussedOptionIndex, listBoxRef])
+}
+
+export function usePageUpDown({
+ options,
+ listBoxRef,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+}) {
+ const pageDown = usePageDown({
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ listBoxRef,
+ })
+
+ const pageUp = usePageUp({
+ options,
+ listBoxRef,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ })
return { pageDown, pageUp }
}
From 50c6755b35c0f214715d956fe58b5337c3c6bf3a Mon Sep 17 00:00:00 2001
From: Mohammer5
Date: Wed, 23 Oct 2024 16:45:32 +0800
Subject: [PATCH 17/39] fix(select a11y): make PageUp keypress respect disabled
options
---
collections/forms/i18n/en.pot | 4 +-
.../single-select-a11y.e2e.stories.js | 4 +-
.../use-handle-key-press.js | 12 +-
...highlight-first-option-on-previous-page.js | 63 ++++++++++
.../use-highlight-first-visible-option.js | 38 ++++++
.../use-handle-key-press/use-page-down.js | 50 ++++++++
.../use-handle-key-press/use-page-up-down.js | 117 ------------------
.../use-handle-key-press/use-page-up.js | 50 ++++++++
8 files changed, 215 insertions(+), 123 deletions(-)
create mode 100644 components/select/src/single-select-a11y/use-handle-key-press/use-highlight-first-option-on-previous-page.js
create mode 100644 components/select/src/single-select-a11y/use-handle-key-press/use-highlight-first-visible-option.js
create mode 100644 components/select/src/single-select-a11y/use-handle-key-press/use-page-down.js
delete mode 100644 components/select/src/single-select-a11y/use-handle-key-press/use-page-up-down.js
create mode 100644 components/select/src/single-select-a11y/use-handle-key-press/use-page-up.js
diff --git a/collections/forms/i18n/en.pot b/collections/forms/i18n/en.pot
index 1c0f7570c..577f10009 100644
--- a/collections/forms/i18n/en.pot
+++ b/collections/forms/i18n/en.pot
@@ -5,8 +5,8 @@ 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: 2024-10-23T01:23:16.733Z\n"
-"PO-Revision-Date: 2024-10-23T01:23:16.734Z\n"
+"POT-Creation-Date: 2024-10-23T07:57:36.332Z\n"
+"PO-Revision-Date: 2024-10-23T07:57:36.333Z\n"
msgid "Upload file"
msgstr "Upload file"
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
index d4a4b6df1..b9f622e9a 100644
--- 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
@@ -176,12 +176,12 @@ export const HundretOptions = () => {
}
export const HundretOptionsWithDisabled = () => {
- const [value, setValue] = useState('0')
+ const [value, setValue] = useState('10')
const [hundretOptions] = useState(
Array.apply(null, Array(100)).map((x, i) => ({
value: `${i}`,
label: `Select option ${i}`,
- disabled: i === 17 || i === 18,
+ disabled: i === 1 || i === 17 || i === 18,
}))
)
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js
index 6ac3b1538..04a978763 100644
--- a/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js
@@ -1,6 +1,7 @@
import { useCallback } from 'react'
import { useHandleTyping } from './use-handle-typing.js'
-import { usePageUpDown } from './use-page-up-down.js'
+import { usePageDown } from './use-page-down.js'
+import { usePageUp } from './use-page-up.js'
export function useHandleKeyPress({
value,
@@ -21,7 +22,14 @@ export function useHandleKeyPress({
onChange,
})
- const { pageDown, pageUp } = usePageUpDown({
+ const pageDown = usePageDown({
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ listBoxRef,
+ })
+
+ const pageUp = usePageUp({
options,
listBoxRef,
focussedOptionIndex,
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-highlight-first-option-on-previous-page.js b/components/select/src/single-select-a11y/use-handle-key-press/use-highlight-first-option-on-previous-page.js
new file mode 100644
index 000000000..494a6ba51
--- /dev/null
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-highlight-first-option-on-previous-page.js
@@ -0,0 +1,63 @@
+import { useCallback } from 'react'
+import { isOptionHidden } from '../is-option-hidden.js'
+
+export function useHighlightFirstOptionOnPreviousPage({
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ listBoxRef,
+}) {
+ return useCallback(() => {
+ const listBoxParent = listBoxRef.current.parentNode
+ const optionElements = Array.from(listBoxRef.current.childNodes)
+ const visibleOptionsAmount = optionElements.filter(
+ (optionElement) => !isOptionHidden(optionElement, listBoxParent)
+ ).length
+
+ const nextTopOptionIndex = Math.max(
+ 0,
+ focussedOptionIndex - visibleOptionsAmount
+ )
+
+ // If there's no next option and we already have the last option in the list highlighted
+ if (!options[nextTopOptionIndex]) {
+ return
+ }
+
+ if (!options[nextTopOptionIndex].disabled) {
+ const nextTopOption = optionElements[nextTopOptionIndex]
+ const scrollPosition = nextTopOption.offsetTop
+ listBoxParent.scrollTop = scrollPosition
+ setFocussedOptionIndex(nextTopOptionIndex)
+ return
+ }
+
+ const lowerEnabledOptionIndex = options
+ .slice(0, nextTopOptionIndex)
+ .findLastIndex((option) => !option.disabled)
+
+ if (lowerEnabledOptionIndex !== -1) {
+ const lowerEnabledOption = optionElements[lowerEnabledOptionIndex]
+ const lowerScrollPosition = lowerEnabledOption.offsetTop
+ listBoxParent.scrollTop = lowerScrollPosition
+ setFocussedOptionIndex(lowerEnabledOptionIndex)
+ return
+ }
+
+ const inbetweenEnabledOptionIndex =
+ nextTopOptionIndex +
+ options
+ .slice(nextTopOptionIndex, focussedOptionIndex)
+ .findIndex((option) => !option.disabled)
+
+ if (inbetweenEnabledOptionIndex === -1) {
+ // We're already on the first enabled option
+ return
+ }
+
+ const inbetweenTopOption = optionElements[inbetweenEnabledOptionIndex]
+ const scrollPosition = inbetweenTopOption.offsetTop
+ listBoxParent.scrollTop = scrollPosition
+ setFocussedOptionIndex(inbetweenEnabledOptionIndex)
+ }, [options, focussedOptionIndex, setFocussedOptionIndex, listBoxRef])
+}
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-highlight-first-visible-option.js b/components/select/src/single-select-a11y/use-handle-key-press/use-highlight-first-visible-option.js
new file mode 100644
index 000000000..ef2746485
--- /dev/null
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-highlight-first-visible-option.js
@@ -0,0 +1,38 @@
+import { useCallback } from 'react'
+
+export function useHighlightFirstVisibleOption({
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+}) {
+ return useCallback(
+ (lowestVisibleIndex) => {
+ if (!options[lowestVisibleIndex].disabled) {
+ setFocussedOptionIndex(lowestVisibleIndex)
+ return
+ }
+
+ const previousEnabledOptionIndex = options
+ .slice(0, lowestVisibleIndex)
+ .findLastIndex((option) => !option.disabled)
+
+ if (previousEnabledOptionIndex !== -1) {
+ setFocussedOptionIndex(previousEnabledOptionIndex)
+ return
+ }
+
+ const nextEnabledOptionIndex =
+ lowestVisibleIndex +
+ options
+ .slice(lowestVisibleIndex, focussedOptionIndex)
+ .findIndex((option) => !option.disabled)
+
+ if (nextEnabledOptionIndex !== -1) {
+ return
+ }
+
+ setFocussedOptionIndex(nextEnabledOptionIndex)
+ },
+ [options, focussedOptionIndex, setFocussedOptionIndex]
+ )
+}
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-page-down.js b/components/select/src/single-select-a11y/use-handle-key-press/use-page-down.js
new file mode 100644
index 000000000..8e373b29b
--- /dev/null
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-page-down.js
@@ -0,0 +1,50 @@
+import { useCallback } from 'react'
+import { isOptionHidden } from '../is-option-hidden.js'
+import { useHighlightLastOptionOnNextPage } from './use-highlight-last-option-on-next-page.js'
+import { useHighlightLastVisibleOption } from './use-highlight-last-visible-option.js'
+
+export function usePageDown({
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ listBoxRef,
+}) {
+ const highlightLastVisibleOption = useHighlightLastVisibleOption({
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ })
+
+ const highlightLastOptionOnNextPage = useHighlightLastOptionOnNextPage({
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ listBoxRef,
+ })
+
+ return useCallback(() => {
+ const listBoxParent = listBoxRef.current.parentNode
+ const options = Array.from(listBoxRef.current.childNodes)
+ const highestVisibleIndex = options.findLastIndex(
+ (option) => !isOptionHidden(option, listBoxParent)
+ )
+
+ if (highestVisibleIndex > focussedOptionIndex) {
+ highlightLastVisibleOption(highestVisibleIndex)
+ return
+ }
+
+ if (highestVisibleIndex > -1) {
+ highlightLastOptionOnNextPage(listBoxParent)
+ return
+ }
+
+ // No visible option (e.g. when menu is empty)
+ return
+ }, [
+ focussedOptionIndex,
+ listBoxRef,
+ highlightLastVisibleOption,
+ highlightLastOptionOnNextPage,
+ ])
+}
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-page-up-down.js b/components/select/src/single-select-a11y/use-handle-key-press/use-page-up-down.js
deleted file mode 100644
index 636bf7dc9..000000000
--- a/components/select/src/single-select-a11y/use-handle-key-press/use-page-up-down.js
+++ /dev/null
@@ -1,117 +0,0 @@
-import { useCallback } from 'react'
-import { isOptionHidden } from '../is-option-hidden.js'
-import { useHighlightLastOptionOnNextPage } from './use-highlight-last-option-on-next-page.js'
-import { useHighlightLastVisibleOption } from './use-highlight-last-visible-option.js'
-
-function usePageDown({
- options,
- focussedOptionIndex,
- setFocussedOptionIndex,
- listBoxRef,
-}) {
- const highlightLastVisibleOption = useHighlightLastVisibleOption({
- options,
- focussedOptionIndex,
- setFocussedOptionIndex,
- })
-
- const highlightLastOptionOnNextPage = useHighlightLastOptionOnNextPage({
- options,
- focussedOptionIndex,
- setFocussedOptionIndex,
- listBoxRef,
- })
-
- return useCallback(() => {
- const listBoxParent = listBoxRef.current.parentNode
- const options = Array.from(listBoxRef.current.childNodes)
- const highestVisibleIndex = options.findLastIndex(
- (option) => !isOptionHidden(option, listBoxParent)
- )
-
- if (highestVisibleIndex > focussedOptionIndex) {
- highlightLastVisibleOption(highestVisibleIndex)
- return
- }
-
- if (highestVisibleIndex > -1) {
- highlightLastOptionOnNextPage(listBoxParent)
- return
- }
-
- // No visible option (e.g. when menu is empty)
- return
- }, [
- focussedOptionIndex,
- listBoxRef,
- highlightLastVisibleOption,
- highlightLastOptionOnNextPage,
- ])
-}
-
-function usePageUp({
- listBoxRef,
- focussedOptionIndex,
- setFocussedOptionIndex,
-}) {
- return useCallback(() => {
- const listBoxParent = listBoxRef.current.parentNode
- const options = Array.from(listBoxRef.current.childNodes)
- const lowestVisibleIndex = options.findIndex(
- (option) => !isOptionHidden(option, listBoxParent)
- )
-
- // No visible option (e.g. when menu is empty)
- if (lowestVisibleIndex === -1) {
- return
- }
-
- // Highlight last visible option
- if (lowestVisibleIndex < focussedOptionIndex) {
- setFocussedOptionIndex(lowestVisibleIndex)
- return
- }
-
- const visibleOptionsAmount = options.filter(
- (option) => !isOptionHidden(option, listBoxParent)
- ).length
-
- const nextTopOptionIndex = Math.max(
- 0,
- focussedOptionIndex - visibleOptionsAmount
- )
-
- // If there's no next option and we already have the last option in the list highlighted
- if (!options[nextTopOptionIndex]) {
- return
- }
-
- const nextTopOption = options[nextTopOptionIndex]
- const scrollPosition = nextTopOption.offsetTop
- listBoxParent.scrollTop = scrollPosition
- setFocussedOptionIndex(nextTopOptionIndex)
- }, [focussedOptionIndex, setFocussedOptionIndex, listBoxRef])
-}
-
-export function usePageUpDown({
- options,
- listBoxRef,
- focussedOptionIndex,
- setFocussedOptionIndex,
-}) {
- const pageDown = usePageDown({
- options,
- focussedOptionIndex,
- setFocussedOptionIndex,
- listBoxRef,
- })
-
- const pageUp = usePageUp({
- options,
- listBoxRef,
- focussedOptionIndex,
- setFocussedOptionIndex,
- })
-
- return { pageDown, pageUp }
-}
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-page-up.js b/components/select/src/single-select-a11y/use-handle-key-press/use-page-up.js
new file mode 100644
index 000000000..fd8e871e9
--- /dev/null
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-page-up.js
@@ -0,0 +1,50 @@
+import { useCallback } from 'react'
+import { isOptionHidden } from '../is-option-hidden.js'
+import { useHighlightFirstOptionOnPreviousPage } from './use-highlight-first-option-on-previous-page.js'
+import { useHighlightFirstVisibleOption } from './use-highlight-first-visible-option.js'
+
+export function usePageUp({
+ options,
+ listBoxRef,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+}) {
+ const highlightFirstVisibleOption = useHighlightFirstVisibleOption({
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ })
+
+ const highlightFirstOptionOnPreviousPage =
+ useHighlightFirstOptionOnPreviousPage({
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ listBoxRef,
+ })
+
+ return useCallback(() => {
+ const listBoxParent = listBoxRef.current.parentNode
+ const optionElements = Array.from(listBoxRef.current.childNodes)
+ const lowestVisibleIndex = optionElements.findIndex(
+ (optionElement) => !isOptionHidden(optionElement, listBoxParent)
+ )
+
+ // No visible option (e.g. when menu is empty)
+ if (lowestVisibleIndex === -1) {
+ return
+ }
+
+ if (lowestVisibleIndex < focussedOptionIndex) {
+ highlightFirstVisibleOption(lowestVisibleIndex)
+ return
+ }
+
+ highlightFirstOptionOnPreviousPage()
+ }, [
+ focussedOptionIndex,
+ listBoxRef,
+ highlightFirstOptionOnPreviousPage,
+ highlightFirstVisibleOption,
+ ])
+}
From 76fa69836d123db113f63cfa4fd1cd6aaafac7d4 Mon Sep 17 00:00:00 2001
From: Mohammer5
Date: Mon, 28 Oct 2024 11:20:03 +0800
Subject: [PATCH 18/39] fix(select a11y): make remaining keypress logic respect
disabled options
---
collections/forms/i18n/en.pot | 4 +-
.../single-select-a11y/single-select-a11y.js | 1 +
.../use-handle-key-press.js | 164 ++++++++++++------
.../use-handle-key-press/use-handle-typing.js | 10 +-
4 files changed, 121 insertions(+), 58 deletions(-)
diff --git a/collections/forms/i18n/en.pot b/collections/forms/i18n/en.pot
index 577f10009..2b1b5f1f1 100644
--- a/collections/forms/i18n/en.pot
+++ b/collections/forms/i18n/en.pot
@@ -5,8 +5,8 @@ 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: 2024-10-23T07:57:36.332Z\n"
-"PO-Revision-Date: 2024-10-23T07:57:36.333Z\n"
+"POT-Creation-Date: 2024-10-28T03:18:56.736Z\n"
+"PO-Revision-Date: 2024-10-28T03:18:56.736Z\n"
msgid "Upload file"
msgstr "Upload file"
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 ea065ddce..0b8f7964d 100644
--- a/components/select/src/single-select-a11y/single-select-a11y.js
+++ b/components/select/src/single-select-a11y/single-select-a11y.js
@@ -101,6 +101,7 @@ export function SingleSelectA11y({
const handleKeyPress = useHandleKeyPress({
value,
+ disabled,
onChange,
expanded,
options,
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js
index 04a978763..cdd692b6d 100644
--- a/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js
@@ -3,8 +3,75 @@ import { useHandleTyping } from './use-handle-typing.js'
import { usePageDown } from './use-page-down.js'
import { usePageUp } from './use-page-up.js'
+function isEnabled({ disabled }) {
+ return !disabled
+}
+
+function findNextOptionIndex({ options, activeIndex }) {
+ const startIndex = activeIndex + 1
+ const optionsToSearch = options.slice(startIndex)
+
+ // Need to add back the count we removed by slicing the options array
+ return startIndex + optionsToSearch.findIndex(isEnabled)
+}
+
+function findPrevOptionIndex({ options, activeIndex }) {
+ return options.slice(0, activeIndex).findLastIndex(isEnabled)
+}
+
+function findFirstOptionIndex({ options }) {
+ return options.findIndex(isEnabled)
+}
+
+function findLastOptionIndex({ options }) {
+ return options.findLastIndex(isEnabled)
+}
+
+function useSelectOption(findIndexCallback, { options, onChange, value }) {
+ return useCallback(() => {
+ const currentOptionIndex = options.findIndex(
+ (option) => option.value === value
+ )
+
+ const nextSelectedOptionIndex = findIndexCallback({
+ options,
+ activeIndex: currentOptionIndex,
+ })
+
+ if (nextSelectedOptionIndex === -1) {
+ return
+ }
+
+ onChange(options[nextSelectedOptionIndex].value)
+ }, [findIndexCallback, options, onChange, value])
+}
+
+function useFocusOption(
+ findIndexCallback,
+ { options, focussedOptionIndex, setFocussedOptionIndex }
+) {
+ return useCallback(() => {
+ const nextFocussedIndex = findIndexCallback({
+ options,
+ activeIndex: focussedOptionIndex,
+ })
+
+ if (nextFocussedIndex === -1) {
+ return
+ }
+
+ setFocussedOptionIndex(nextFocussedIndex)
+ }, [
+ findIndexCallback,
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ ])
+}
+
export function useHandleKeyPress({
value,
+ disabled,
expanded,
options,
openMenu,
@@ -36,51 +103,47 @@ export function useHandleKeyPress({
setFocussedOptionIndex,
})
- const selectNextOption = useCallback(() => {
- const currentOptionIndex = options.findIndex(
- (option) => option.value === value
- )
- const nextSelectedOption = options[currentOptionIndex + 1]
-
- if (nextSelectedOption) {
- onChange(nextSelectedOption.value)
- }
- }, [options, onChange, value])
-
- const selectPrevOption = useCallback(() => {
- const currentOptionIndex = options.findIndex(
- (option) => option.value === value
- )
- const nextSelectedOption = options[currentOptionIndex - 1]
+ const selectNextOption = useSelectOption(findNextOptionIndex, {
+ options,
+ onChange,
+ value,
+ })
- if (nextSelectedOption) {
- onChange(nextSelectedOption.value)
- }
- }, [options, onChange, value])
+ const selectPrevOption = useSelectOption(findPrevOptionIndex, {
+ options,
+ onChange,
+ value,
+ })
- const focusNextOption = useCallback(() => {
- if (focussedOptionIndex < options.length - 1) {
- setFocussedOptionIndex(focussedOptionIndex + 1)
- }
- }, [focussedOptionIndex, options, setFocussedOptionIndex])
+ const focusNextOption = useFocusOption(findNextOptionIndex, {
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ })
- const focusPrevOption = useCallback(() => {
- if (focussedOptionIndex > 0) {
- setFocussedOptionIndex(focussedOptionIndex - 1)
- }
- }, [focussedOptionIndex, setFocussedOptionIndex])
+ const focusPrevOption = useFocusOption(findPrevOptionIndex, {
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ })
- const focusFirstOption = useCallback(() => {
- setFocussedOptionIndex(0)
- }, [setFocussedOptionIndex])
+ const focusFirstOption = useFocusOption(findFirstOptionIndex, {
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ })
- const focusLastOption = useCallback(() => {
- setFocussedOptionIndex(options.length - 1)
- }, [options, setFocussedOptionIndex])
+ const focusLastOption = useFocusOption(findLastOptionIndex, {
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ })
const handleKeyPress = useCallback(
- (e) => {
- const { key, altKey, ctrlKey, metaKey } = e
+ ({ key, altKey, ctrlKey, metaKey }) => {
+ if (disabled) {
+ return
+ }
if (
expanded &&
@@ -109,23 +172,23 @@ export function useHandleKeyPress({
return
}
+ if (key === 'ArrowDown' && !expanded) {
+ selectNextOption()
+ return
+ }
+
if (key === 'ArrowDown') {
- if (!expanded) {
- selectNextOption()
- } else {
- focusNextOption()
- }
+ focusNextOption()
+ return
+ }
+ if (key === 'ArrowUp' && !expanded) {
+ selectPrevOption()
return
}
if (key === 'ArrowUp') {
- if (!expanded) {
- selectPrevOption()
- } else {
- focusPrevOption()
- }
-
+ focusPrevOption()
return
}
@@ -149,7 +212,7 @@ export function useHandleKeyPress({
!metaKey) ||
(key === ' ' && typing)
) {
- onTyping(e)
+ onTyping(key)
return
}
@@ -166,6 +229,7 @@ export function useHandleKeyPress({
// Do nothing
},
[
+ disabled,
expanded,
closeMenu,
openMenu,
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-handle-typing.js b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-typing.js
index 55923f6e4..564655a55 100644
--- a/components/select/src/single-select-a11y/use-handle-key-press/use-handle-typing.js
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-typing.js
@@ -40,10 +40,9 @@ export function useHandleTyping({
prevValueRef.current = value
const optionIndex = options.findIndex(
- (option) =>
- option.label
- .toLowerCase()
- .startsWith(value.toLowerCase()) && !option.disabled
+ ({ disabled, label }) =>
+ !disabled &&
+ label.toLowerCase().startsWith(value.toLowerCase())
)
if (optionIndex !== -1) {
@@ -57,8 +56,7 @@ export function useHandleTyping({
}
}, [value, options, setFocussedOptionIndex, expanded, onChange])
- const onTyping = useCallback((e) => {
- const { key } = e
+ const onTyping = useCallback((key) => {
setTyping(true)
if (key === 'Backspace') {
From ed14df7df730f163a6c926eb1056b59cc48ed026 Mon Sep 17 00:00:00 2001
From: Mohammer5
Date: Mon, 28 Oct 2024 15:02:37 +0800
Subject: [PATCH 19/39] test(select a11y): cover keyboard interactions +
disabled with tests
---
collections/forms/i18n/en.pot | 4 +-
.../keyboard-interactions.test.e2e.js | 419 +++++++++++++++---
.../single-select-a11y.e2e.stories.js | 9 +-
.../single-select-a11y.test.js | 289 ++++++++++++
4 files changed, 658 insertions(+), 63 deletions(-)
diff --git a/collections/forms/i18n/en.pot b/collections/forms/i18n/en.pot
index 2b1b5f1f1..375e2a093 100644
--- a/collections/forms/i18n/en.pot
+++ b/collections/forms/i18n/en.pot
@@ -5,8 +5,8 @@ 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: 2024-10-28T03:18:56.736Z\n"
-"PO-Revision-Date: 2024-10-28T03:18:56.736Z\n"
+"POT-Creation-Date: 2024-10-28T03:31:24.818Z\n"
+"PO-Revision-Date: 2024-10-28T03:31:24.818Z\n"
msgid "Upload file"
msgstr "Upload file"
diff --git a/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js b/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js
index 993dd3731..bfc6b396b 100644
--- a/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js
+++ b/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js
@@ -1,16 +1,18 @@
describe(' ', () => {
it('should highlight the first option on the currently displayed page', () => {
- // Given a select with 100 options is displayed
+ cy.log('**Given a select with 100 options is displayed**')
cy.visitStory('Single Select A11y', 'Hundret Options')
- // And the menu is visible
+ cy.log('**And the menu is visible**')
cy.findByRole('combobox').click()
cy.findByRole('option', { selected: true }).should('be.visible')
- // And the 74th option is being highlighted
+ cy.log('**And the 74th option is being highlighted**')
cy.findByRole('combobox').focus().type(`Select option 73`)
- // And the 70th option is the first option on the current page
+ cy.log(
+ '**And the 70th option is the first option on the current page**'
+ )
let optionOffset
cy.findAllByRole('option')
.eq(70)
@@ -23,64 +25,152 @@ describe(' ', () => {
.invoke('parent') // listbox
.invoke('parent') // scrollable div
.then((listBoxParent) => {
- console.log('> listBoxParent', listBoxParent.get(0))
- console.log('> optionOffset', optionOffset)
listBoxParent.get(0).scrollTop = optionOffset
})
cy.findAllByRole('option').eq(70).should('be.visible')
- // When the PageUp key is pressed
+ cy.log('**When the PageUp key is pressed**')
cy.findByRole('combobox').trigger('keydown', {
key: 'PageUp',
force: true,
})
- // Then the first option on the currently displayed page is highlighted
+ cy.log(
+ '**Then the first option on the currently displayed page is highlighted**'
+ )
cy.findByRole('option', { selected: true })
.invoke('attr', 'aria-label')
.should('equal', 'Select option 70')
})
+ it('should highlight the first enabled option before the first (disabled) option on the currently displayed page', () => {
+ cy.log(
+ '**Given a select with 100 options is displayed and option #17 is disabled**'
+ )
+ cy.visitStory('Single Select A11y', 'Hundret Options With Disabled')
+
+ cy.log('**And the menu is visible**')
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
+
+ cy.log('**And the 26th option is being highlighted**')
+ cy.findByRole('combobox').focus().type(`Select option 25`)
+
+ cy.log(
+ '**And the 17th option is the first option on the current page**'
+ )
+ let optionOffset
+ cy.findAllByRole('option')
+ .eq(17)
+ .then((option) => {
+ const { offsetTop } = option.get(0)
+ optionOffset = offsetTop
+ })
+
+ cy.findByRole('option', { selected: true })
+ .invoke('parent') // listbox
+ .invoke('parent') // scrollable div
+ .then((listBoxParent) => {
+ listBoxParent.get(0).scrollTop = optionOffset
+ })
+
+ cy.findAllByRole('option').eq(17).should('be.visible')
+
+ cy.log('**When the PageUp key is pressed**')
+ cy.findByRole('combobox').trigger('keydown', {
+ key: 'PageUp',
+ force: true,
+ })
+
+ cy.log(
+ '**Then the first option on the currently displayed page is highlighted**'
+ )
+ cy.findByRole('option', { selected: true })
+ .invoke('attr', 'aria-label')
+ .should('equal', 'Select option 16')
+ })
+
it('should highlight the first option on the previous page', () => {
- // Given a select with 100 options is displayed
+ cy.log('**Given a select with 100 options is displayed**')
cy.visitStory('Single Select A11y', 'Hundret Options')
- // And the menu is visible
+ cy.log('**And the menu is visible**')
cy.findByRole('combobox').click()
cy.findByRole('option', { selected: true }).should('be.visible')
- // And the 70th option is being highlighted
+ cy.log('**And the 70th option is being highlighted**')
// Will automatically scroll there and make it the first option on the page
cy.findByRole('combobox').focus().type(`Select option 70`)
- // When the PageUp key is pressed
+ cy.log('**When the PageUp key is pressed**')
cy.findByRole('combobox').trigger('keydown', {
key: 'PageUp',
force: true,
})
- // Then the first option on the previous page is highlighted
+ cy.log('**Then the first option on the previous page is highlighted**')
cy.findByRole('option', { selected: true })
.invoke('attr', 'aria-label')
.should('equal', 'Select option 61')
- // And the previously highlighted option is not visible
+ // That option is truly the first option
+ cy.get('[role="option"]:visible')
+ .first()
+ .invoke('attr', 'aria-label')
+ .should('equal', 'Select option 61')
+
+ cy.log('**And the previously highlighted option is not visible**')
cy.findAllByRole('option').eq(70).should('not.be.visible')
})
+ it('should highlight the first enabled option before the first (disabled) option on the previous page', () => {
+ cy.log(
+ '**Given a select with 100 options is displayed and option #17 is disabled**'
+ )
+ cy.visitStory('Single Select A11y', 'Hundret Options With Disabled')
+
+ cy.log('**And the menu is visible**')
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
+
+ cy.log('**And the 27th option is being highlighted**')
+ // Will automatically scroll there and make it the first option on the page
+ cy.findByRole('combobox').focus().type(`Select option 27`)
+
+ cy.log('**When the PageUp key is pressed**')
+ cy.findByRole('combobox').trigger('keydown', {
+ key: 'PageUp',
+ force: true,
+ })
+
+ cy.log('**Then the first option on the previous page is highlighted**')
+ cy.findByRole('option', { selected: true })
+ .invoke('attr', 'aria-label')
+ .should('equal', 'Select option 16')
+
+ cy.log('**And that option is truly the first option**')
+ cy.get('[role="option"]:visible')
+ .first()
+ .invoke('attr', 'aria-label')
+ .should('equal', 'Select option 16')
+
+ cy.log('**And the previously highlighted option is not visible**')
+ cy.findAllByRole('option').eq(27).should('not.be.visible')
+ })
+
it('should highlight the first option', () => {
- // Given a select with 100 options is displayed
+ cy.log('**Given a select with 100 options is displayed**')
cy.visitStory('Single Select A11y', 'Hundret Options')
- // And the menu is visible
+ cy.log('**And the menu is visible**')
cy.findByRole('combobox').click()
cy.findByRole('option', { selected: true }).should('be.visible')
- // And the 2nd option is being highlighted
+ cy.log('**And the 2nd option is being highlighted**')
cy.findByRole('combobox').focus().type(`Select option 1`)
- // And the 2nd option is the first option on the current page
+ cy.log('**And the 2nd option is the first option on the current page**')
let optionOffset
cy.findAllByRole('option')
.eq(1)
@@ -93,20 +183,62 @@ describe(' ', () => {
.invoke('parent') // listbox
.invoke('parent') // scrollable div
.then((listBoxParent) => {
- console.log('> listBoxParent', listBoxParent.get(0))
- console.log('> optionOffset', optionOffset)
listBoxParent.get(0).scrollTop = optionOffset
})
- // When the PageUp key is pressed
+ cy.log('**When the PageUp key is pressed**')
cy.findByRole('combobox').trigger('keydown', {
key: 'PageUp',
force: true,
})
- // Then the first option is being highlighted
+ cy.log('**Then the first option is being highlighted**')
cy.all(
- () => cy.findAllByRole('option').first().invoke('get', 0),
+ () => cy.findAllByRole('option').invoke('get', 0),
+ () => cy.findByRole('option', { selected: true }).invoke('get', 0)
+ ).then(([firstOption, highlightedOption]) => {
+ expect(highlightedOption).to.equal(firstOption)
+ })
+ })
+
+ it('should highlight the second option when the first option is disabled', () => {
+ cy.log(
+ '**Given a select with 100 options is displayed and option #2 is disabled**'
+ )
+ cy.visitStory('Single Select A11y', 'Hundret Options With Disabled')
+
+ cy.log('**And the menu is visible**')
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
+
+ cy.log('**And the 3rd option is being highlighted**')
+ cy.findByRole('combobox').focus().type(`Select option 2`)
+
+ cy.log('**And the 3rd option is the first option on the current page**')
+ let optionOffset
+ cy.findAllByRole('option')
+ .eq(2)
+ .then((option) => {
+ const { offsetTop } = option.get(0)
+ optionOffset = offsetTop
+ })
+
+ cy.findByRole('option', { selected: true })
+ .invoke('parent') // listbox
+ .invoke('parent') // scrollable div
+ .then((listBoxParent) => {
+ listBoxParent.get(0).scrollTop = optionOffset
+ })
+
+ cy.log('**When the PageUp key is pressed**')
+ cy.findByRole('combobox').trigger('keydown', {
+ key: 'PageUp',
+ force: true,
+ })
+
+ cy.log('**Then the second option is being highlighted**')
+ cy.all(
+ () => cy.findAllByRole('option').invoke('get', 1),
() => cy.findByRole('option', { selected: true }).invoke('get', 0)
).then(([firstOption, highlightedOption]) => {
expect(highlightedOption).to.equal(firstOption)
@@ -114,21 +246,23 @@ describe(' ', () => {
})
it('should highlight the last option on the currently displayed page', () => {
- // Given a select with 100 options is displayed
+ cy.log('**Given a select with 100 options is displayed**')
cy.visitStory('Single Select A11y', 'Hundret Options')
- // And the menu is visible
+ cy.log('**And the menu is visible**')
// first option will be highlighted automatically
cy.findByRole('combobox').click()
cy.findByRole('option', { selected: true }).should('be.visible')
- // When the PageDown key is pressed
+ cy.log('**When the PageDown key is pressed**')
cy.findByRole('combobox').trigger('keydown', {
key: 'PageDown',
force: true,
})
- // Then the last option on the currently displayed page is highlighted
+ cy.log(
+ '**Then the last option on the currently displayed page is highlighted**'
+ )
cy.all(
() => cy.get('[role="option"]:visible').last().invoke('get', 0),
() => cy.findByRole('option', { selected: true }).invoke('get', 0)
@@ -137,49 +271,162 @@ describe(' ', () => {
})
})
+ it('should highlight the first enabled option after the last (disabled) option on the currently displayed page', () => {
+ cy.log(
+ '**Given a select with 100 options is displayed and option #17 is disabled**'
+ )
+ cy.visitStory('Single Select A11y', 'Hundret Options With Disabled')
+
+ cy.log(
+ '**And the 9th option is being selected (will cause it to be the first option on the page when opening the menu)**'
+ )
+ cy.findByRole('combobox').focus().type(`Select option 9`)
+
+ cy.log('**And the menu is visible**')
+ // first option will be highlighted automatically
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
+
+ // That option is truly the first option
+ cy.get('[role="option"]:visible')
+ .first()
+ .invoke('attr', 'aria-label')
+ .should('equal', 'Select option 9')
+
+ cy.log('**When the PageDown key is pressed**')
+ cy.findByRole('combobox').trigger('keydown', {
+ key: 'PageDown',
+ force: true,
+ })
+
+ cy.log(
+ '**Then the first enabled option after last option on the currently displayed page is highlighted**'
+ )
+ cy.all(
+ () => cy.findAllByRole('option').invoke('get', 19),
+ () => cy.findByRole('option', { selected: true }).invoke('get', 0)
+ ).then(([eighteensOptions, highlightedOption]) => {
+ expect(highlightedOption).to.equal(eighteensOptions)
+ })
+
+ // For some reason, without the timeout,
+ // cypress will still get the previously visible page
+ // when using the `:visible` pseudo-selector
+ cy.wait(0)
+
+ cy.log(
+ '**And the first enabled option after last option on the currently displayed page is the first visible option**'
+ )
+ cy.all(
+ () => cy.get('[role="option"]:visible').invoke('get', 0),
+ () => cy.findByRole('option', { selected: true }).invoke('get', 0)
+ ).then(([lastVisibleOption, highlightedOption]) => {
+ expect(highlightedOption).to.equal(lastVisibleOption)
+ })
+ })
+
it(
'should highlight the last option on the next page',
// We don't want the options to scroll when we check whether they're
// visible or not (as that'd make them visible)
{ scrollBehavior: false },
() => {
- // Given a select with 100 options is displayed
+ const LAST_VISIBLE_OPTION_INDEX = 8
+
+ cy.log('**Given a select with 100 options is displayed**')
cy.visitStory('Single Select A11y', 'Hundret Options')
- // And the menu is visible
+ cy.log('**And the last option on the first page is selected**')
+ cy.findByRole('combobox').focus().type(`Select option 8`)
+
+ cy.log('**And the menu is visible**')
cy.findByRole('combobox').click()
cy.findByRole('option', { selected: true }).should('be.visible')
- // And the option last visible option is being highlighted
- cy.get('[role="option"]:visible').then(($visibleOptions) => {
- const visibleOptionsAmount = $visibleOptions.length
+ cy.get('[role="option"]')
+ .eq(LAST_VISIBLE_OPTION_INDEX)
+ .invoke('attr', 'aria-selected')
+ .should('equal', 'true')
+
+ cy.log('**When the PageDown key is pressed**')
+ cy.findByRole('combobox').trigger('keydown', {
+ key: 'PageDown',
+ force: true,
+ })
+
+ cy.log('**Then the next page is shown**')
+ cy.get('[role="option"]')
+ .eq(LAST_VISIBLE_OPTION_INDEX)
+ .should('not.be.visible')
+ cy.get('[role="option"]')
+ .eq(LAST_VISIBLE_OPTION_INDEX + 1)
+ .should('be.visible')
+
+ cy.log('**And the last option on the next page is highlighted**')
+ cy.get('[role="option"]:visible')
+ .last()
+ .invoke('attr', 'aria-selected')
+ .should('equal', 'true')
+
+ cy.log('**And the previously highlighted option is not visible**')
+ cy.get('[role="option"]')
+ .eq(LAST_VISIBLE_OPTION_INDEX + 1)
+ .invoke('attr', 'aria-selected')
+ .should('equal', 'false')
+ }
+ )
+
+ it(
+ 'should highlight the first enabled option after the last (disabled) option on the next page',
+ // We don't want the options to scroll when we check whether they're
+ // visible or not (as that'd make them visible)
+ { scrollBehavior: false },
+ () => {
+ cy.log(
+ '**Given a select with 100 options is displayed and option #17 is disabled**'
+ )
+ cy.visitStory('Single Select A11y', 'Hundret Options With Disabled')
+
+ cy.log('**And the 2nd option is being selected**')
+ cy.findByRole('combobox').focus().type(`Select option 9`)
- for (let i = 0; i < visibleOptionsAmount - 1; ++i) {
- cy.findByRole('combobox').trigger('keydown', {
- key: 'ArrowDown',
- force: true,
- })
+ cy.log('**And the menu is visible**')
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
- if (i === visibleOptionsAmount - 2) {
- cy.wrap(i).as('lastVisibleOptionIndex')
- }
- }
+ cy.log(
+ '**And the 2nd option is the first option on the current page**'
+ )
+ cy.all(
+ () => cy.findAllByRole('option').eq(1),
+ // scrollable div
+ () =>
+ cy
+ .findByRole('option', { selected: true })
+ .invoke('parent')
+ .invoke('parent')
+ ).then(([nextTopOption, listBoxParent]) => {
+ const { offsetTop } = nextTopOption.get(0)
+ listBoxParent.get(0).scrollTop = offsetTop
})
+ cy.wrap(9).as('lastVisibleOptionIndex')
+
cy.get('@lastVisibleOptionIndex').then((lastVisibleOptionIndex) => {
cy.get('[role="option"]')
- .eq(lastVisibleOptionIndex + 1) // 1-based
+ .eq(lastVisibleOptionIndex)
.invoke('attr', 'aria-selected')
.should('equal', 'true')
})
- // When the PageDown key is pressed
+ cy.log('**When the PageDown key is pressed**')
cy.findByRole('combobox').trigger('keydown', {
key: 'PageDown',
force: true,
})
- // Then the next page is shown
+ cy.log('**Then the next page is shown,**')
+ // but one option is skipped due to the disabled option at the end of the next page
cy.get('@lastVisibleOptionIndex').then((lastVisibleOptionIndex) => {
cy.get('[role="option"]')
.eq(lastVisibleOptionIndex + 1)
@@ -189,19 +436,16 @@ describe(' ', () => {
.should('be.visible')
})
- // And the last option on the next page is highlighted
+ cy.log('**And the last option on the next page is highlighted**')
cy.get('[role="option"]:visible')
.last()
.invoke('attr', 'aria-selected')
.should('equal', 'true')
- // And the previously highlighted option is not visible
- cy.get('@lastVisibleOptionIndex').then((lastVisibleOptionIndex) => {
- cy.get('[role="option"]')
- .eq(lastVisibleOptionIndex + 1)
- .invoke('attr', 'aria-selected')
- .should('equal', 'false')
- })
+ cy.get('[role="option"]:visible')
+ .last()
+ .invoke('attr', 'aria-label')
+ .should('equal', 'Select option 19')
}
)
@@ -211,14 +455,16 @@ describe(' ', () => {
// visible or not (as that'd make them visible)
{ scrollBehavior: false },
() => {
- // Given a select with 100 options is displayed
+ cy.log('**Given a select with 100 options is displayed**')
cy.visitStory('Single Select A11y', 'Hundret Options')
- // And the menu is visible
+ cy.log('**And the menu is visible**')
cy.findByRole('combobox').click()
cy.findByRole('option', { selected: true }).should('be.visible')
- // And the 2nd-last option is being highlighted and visible
+ cy.log(
+ '**And the 2nd-last option is being highlighted and visible**'
+ )
for (
let i = 0;
i < 11; // This will bring us to the second last option exactly
@@ -231,16 +477,16 @@ describe(' ', () => {
}
cy.findAllByRole('option').eq(98).should('be.visible')
- // And the last option is not visible
+ cy.log('**And the last option is not visible**')
cy.findAllByRole('option').last().should('not.be.visible')
- // When the PageDown key is pressed
+ cy.log('**When the PageDown key is pressed**')
cy.findByRole('combobox').trigger('keydown', {
key: 'PageDown',
force: true,
})
- // Then the last option is highlighted
+ cy.log('**Then the last option is highlighted**')
cy.all(
() => cy.findAllByRole('option').last().invoke('get', 0),
() =>
@@ -249,8 +495,63 @@ describe(' ', () => {
expect(highlightedOption).to.equal(lastOption)
})
- // And the last option is visible
+ cy.log('**And the last option is visible**')
cy.findAllByRole('option').last().should('be.visible')
}
)
+
+ it(
+ 'should highlight the last enabled option',
+ // We don't want the options to scroll when we check whether they're
+ // visible or not (as that'd make them visible)
+ { scrollBehavior: false },
+ () => {
+ cy.log(
+ '**Given a select with 100 options is displayed and option #99 is disabled**'
+ )
+ cy.visitStory('Single Select A11y', 'Hundret Options With Disabled')
+
+ cy.log('**And the 3rd-last option is being selected**')
+ cy.findByRole('combobox').focus().type(`Select option 97`)
+
+ cy.log('**And the menu is visible**')
+ cy.findByRole('combobox').click()
+ cy.findByRole('option', { selected: true }).should('be.visible')
+
+ cy.log(
+ '**And the 3rd-last option is the last option on the current page**'
+ )
+ cy.all(
+ () => cy.findAllByRole('option').eq(89),
+ () => cy.findByRole('listbox').invoke('parent') // scrollable div
+ ).then(([nextTopOption, listBoxParent]) => {
+ const { offsetTop } = nextTopOption.get(0)
+ listBoxParent.get(0).scrollTop = offsetTop
+ })
+
+ cy.findAllByRole('option').eq(97).should('be.visible')
+ cy.findAllByRole('option').eq(98).should('not.be.visible')
+ cy.findAllByRole('option').last().should('not.be.visible')
+
+ cy.log('**When the PageDown key is pressed**')
+ cy.findByRole('combobox').trigger('keydown', {
+ key: 'PageDown',
+ force: true,
+ })
+
+ cy.log('**Then the last option is highlighted**')
+ cy.all(
+ () => cy.findAllByRole('option').invoke('get', 98),
+ () =>
+ cy.findByRole('option', { selected: true }).invoke('get', 0)
+ ).then(([secondLastOption, highlightedOption]) => {
+ expect(highlightedOption).to.equal(secondLastOption)
+ })
+
+ cy.log('**And the second last option is visible**')
+ cy.findAllByRole('option').eq(98).should('be.visible')
+ cy.log('**And the last option is not visible**')
+ cy.findAllByRole('option').eq(99).should('not.be.visible')
+ }
+ )
})
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
index b9f622e9a..318e68f27 100644
--- 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
@@ -181,7 +181,12 @@ export const HundretOptionsWithDisabled = () => {
Array.apply(null, Array(100)).map((x, i) => ({
value: `${i}`,
label: `Select option ${i}`,
- disabled: i === 1 || i === 17 || i === 18,
+ disabled: [
+ 0, // first otpion
+ 17,
+ 18,
+ 99, // last otpion
+ ].includes(i),
}))
)
@@ -201,7 +206,7 @@ export const NativeSelect = () => {
Array.apply(null, Array(100)).map((x, i) => ({
value: `${i}`,
label: `Select option ${i}`,
- disabled: i > 19,
+ disabled: i > 19 && i < 30,
}))
)
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
index 15b12209d..86b3fe68b 100644
--- a/components/select/src/single-select-a11y/single-select-a11y.test.js
+++ b/components/select/src/single-select-a11y/single-select-a11y.test.js
@@ -460,6 +460,35 @@ describe(' ', () => {
})
})
+ describe.each([
+ { key: ' ' },
+ { key: 'Enter' },
+ { key: 'ArrowDown', altKey: true },
+ { key: 'ArrowUp', altKey: true },
+ ])('disabled & $key ($altKey)', (keyDownOptions) => {
+ test('not open the menu', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ fireEvent.keyDown(screen.getByRole('combobox'), keyDownOptions)
+ expect(screen.queryByRole('listbox')).toBeNull()
+ expect(onChange).not.toHaveBeenCalled()
+ })
+ })
+
describe.each([
{ key: ' ' },
{ key: 'Enter' },
@@ -553,6 +582,56 @@ describe(' ', () => {
expect(onChange).toHaveBeenCalledWith('foo')
})
+ it('should not select the next option when closed, disabled and user presses ArrowDown', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ expect(screen.queryByRole('listbox')).toBeNull()
+
+ // highlighting the next option
+ fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' })
+ expect(screen.queryByRole('listbox')).toBeNull()
+ expect(onChange).toHaveBeenCalledTimes(0)
+ })
+
+ it('should select the second-next option when closed, next option is disabled and user presses ArrowDown', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ expect(screen.queryByRole('listbox')).toBeNull()
+
+ // highlighting the next option
+ fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' })
+ expect(screen.queryByRole('listbox')).toBeNull()
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange).toHaveBeenCalledWith('bar')
+ })
+
it('should select the previous option when closed and user presses ArrowUp', () => {
const onChange = jest.fn()
@@ -578,6 +657,56 @@ describe(' ', () => {
expect(onChange).toHaveBeenCalledWith('foo')
})
+ it('should not select the previous option when closed, disabled and user presses ArrowUp', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ expect(screen.queryByRole('listbox')).toBeNull()
+
+ // highlighting the next option
+ fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowUp' })
+ expect(screen.queryByRole('listbox')).toBeNull()
+ expect(onChange).toHaveBeenCalledTimes(0)
+ })
+
+ it('should select the second-previous option when closed, previous option disabled and user presses ArrowUp', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ expect(screen.queryByRole('listbox')).toBeNull()
+
+ // highlighting the next option
+ fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowUp' })
+ expect(screen.queryByRole('listbox')).toBeNull()
+ expect(onChange).toHaveBeenCalledTimes(1)
+ expect(onChange).toHaveBeenCalledWith('')
+ })
+
it('should highlight the next option', () => {
const onChange = jest.fn()
@@ -618,6 +747,46 @@ describe(' ', () => {
).toBe('Foo')
})
+ it('should highlight the second next option when the next option is disabled', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ // open the menu
+ expect(screen.queryByRole('listbox')).toBeNull()
+ const comboBox = screen.getByRole('combobox')
+ fireEvent.click(comboBox)
+ expect(screen.queryByRole('listbox')).not.toBeNull()
+
+ // the first option should be highlighted
+ const highlightedOptionBefore = screen.getByRole('option', {
+ selected: true,
+ })
+ expect(
+ highlightedOptionBefore.attributes.getNamedItem('aria-label').value
+ ).toBe('None')
+
+ // The second option should be highlighted
+ fireEvent.keyDown(comboBox, { key: 'ArrowDown' })
+ const highlightedOptionAfter = screen.getByRole('option', {
+ selected: true,
+ })
+ expect(
+ highlightedOptionAfter.attributes.getNamedItem('aria-label').value
+ ).toBe('Bar')
+ })
+
it('should highlight the previous option', () => {
const onChange = jest.fn()
@@ -658,6 +827,46 @@ describe(' ', () => {
).toBe('Foo')
})
+ it('should highlight the second previous option when previous option is disabled', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ // open the menu
+ expect(screen.queryByRole('listbox')).toBeNull()
+ const comboBox = screen.getByRole('combobox')
+ fireEvent.click(comboBox)
+ expect(screen.queryByRole('listbox')).not.toBeNull()
+
+ // the last option should be highlighted
+ const highlightedOptionBefore = screen.getByRole('option', {
+ selected: true,
+ })
+ expect(
+ highlightedOptionBefore.attributes.getNamedItem('aria-label').value
+ ).toBe('Bar')
+
+ // The second option should be highlighted
+ fireEvent.keyDown(comboBox, { key: 'ArrowUp' })
+ const highlightedOptionAfter = screen.getByRole('option', {
+ selected: true,
+ })
+ expect(
+ highlightedOptionAfter.attributes.getNamedItem('aria-label').value
+ ).toBe('None')
+ })
+
it('should highlight the first option', () => {
const onChange = jest.fn()
@@ -698,6 +907,46 @@ describe(' ', () => {
).toBe('None')
})
+ it('should highlight the first enabled option', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ // open the menu
+ expect(screen.queryByRole('listbox')).toBeNull()
+ const comboBox = screen.getByRole('combobox')
+ fireEvent.click(comboBox)
+ expect(screen.queryByRole('listbox')).not.toBeNull()
+
+ // the last option should be highlighted
+ const highlightedOptionBefore = screen.getByRole('option', {
+ selected: true,
+ })
+ expect(
+ highlightedOptionBefore.attributes.getNamedItem('aria-label').value
+ ).toBe('Bar')
+
+ // The first option should be highlighted
+ fireEvent.keyDown(comboBox, { key: 'Home' })
+ const highlightedOptionAfter = screen.getByRole('option', {
+ selected: true,
+ })
+ expect(
+ highlightedOptionAfter.attributes.getNamedItem('aria-label').value
+ ).toBe('Foo')
+ })
+
it('should highlight the last option', () => {
const onChange = jest.fn()
@@ -737,4 +986,44 @@ describe(' ', () => {
highlightedOptionAfter.attributes.getNamedItem('aria-label').value
).toBe('Bar')
})
+
+ it('should highlight the last enabled option', () => {
+ const onChange = jest.fn()
+
+ render(
+
+ )
+
+ // open the menu
+ expect(screen.queryByRole('listbox')).toBeNull()
+ const comboBox = screen.getByRole('combobox')
+ fireEvent.click(comboBox)
+ expect(screen.queryByRole('listbox')).not.toBeNull()
+
+ // the first option should be highlighted
+ const highlightedOptionBefore = screen.getByRole('option', {
+ selected: true,
+ })
+ expect(
+ highlightedOptionBefore.attributes.getNamedItem('aria-label').value
+ ).toBe('None')
+
+ // The last option should be highlighted
+ fireEvent.keyDown(comboBox, { key: 'End' })
+ const highlightedOptionAfter = screen.getByRole('option', {
+ selected: true,
+ })
+ expect(
+ highlightedOptionAfter.attributes.getNamedItem('aria-label').value
+ ).toBe('Foo')
+ })
})
From 5875efd47b6b5e63ae31aeb2ad9dd441710123f6 Mon Sep 17 00:00:00 2001
From: Mohammer5
Date: Mon, 28 Oct 2024 15:28:47 +0800
Subject: [PATCH 20/39] chore(select a11y): add comment to all props and remove
superfluous props
---
.../menu/menu-options-list.js | 3 --
.../src/single-select-a11y/menu/menu.js | 3 --
.../single-select-a11y/single-select-a11y.js | 54 ++++++++++++++-----
3 files changed, 42 insertions(+), 18 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 e69072146..e784ecc4e 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
@@ -18,7 +18,6 @@ export const MenuOptionsList = forwardRef(function MenuOptionsList(
loading,
onChange,
onBlur,
- onKeyDown,
},
ref
) {
@@ -54,7 +53,6 @@ export const MenuOptionsList = forwardRef(function MenuOptionsList(
aria-busy={loading.toString()}
data-test={dataTest}
onBlur={onBlur}
- onKeyDown={onKeyDown}
>
{options.map(
(
@@ -100,5 +98,4 @@ MenuOptionsList.propTypes = {
loading: PropTypes.bool,
selected: PropTypes.string,
onBlur: PropTypes.func,
- onKeyDown: PropTypes.func,
}
diff --git a/components/select/src/single-select-a11y/menu/menu.js b/components/select/src/single-select-a11y/menu/menu.js
index aa52c5129..aa4dd4bfc 100644
--- a/components/select/src/single-select-a11y/menu/menu.js
+++ b/components/select/src/single-select-a11y/menu/menu.js
@@ -33,7 +33,6 @@ export function Menu({
onBlur,
onClose,
onFilterChange,
- onKeyDown,
}) {
const [menuWidth, setMenuWidth] = useState('auto')
const dataTestPrefix = `${dataTest}-menu`
@@ -80,7 +79,6 @@ export function Menu({
selected={selected}
onChange={onChange}
onBlur={onBlur}
- onKeyDown={onKeyDown}
/>
{loading && }
@@ -152,5 +150,4 @@ Menu.propTypes = {
onBlur: PropTypes.func,
onClose: PropTypes.func,
onFilterChange: PropTypes.func,
- onKeyDown: 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 0b8f7964d..3ff7d814a 100644
--- a/components/select/src/single-select-a11y/single-select-a11y.js
+++ b/components/select/src/single-select-a11y/single-select-a11y.js
@@ -26,7 +26,6 @@ export function SingleSelectA11y({
filterPlaceholder = '',
filterValue = '',
filterable = false,
- inputMaxHeight = '',
labelledBy = '',
loading = false,
menuLoadingText = '',
@@ -42,10 +41,7 @@ export function SingleSelectA11y({
onBlur = () => undefined,
onFilterChange = () => undefined,
onFocus = () => undefined,
- onKeyDown = () => undefined,
}) {
- // Non-stateful
- // ========
const comboBoxId = `${idPrefix}-combo`
const valueLabel =
_valueLabel ||
@@ -63,9 +59,6 @@ export function SingleSelectA11y({
)
}
- // Stateful
- // ========
-
// Using `useState` here so components get notified when the value changes (from null -> div)
const comboBoxRef = useRef()
const listBoxRef = useRef()
@@ -188,7 +181,6 @@ export function SingleSelectA11y({
}}
onClose={closeMenu}
onFilterChange={onFilterChange}
- onKeyDown={onKeyDown}
/>
)
@@ -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}
+ />
+
+ console.log(e)}>
+ None
+ One
+ Two
+ Three
+ Four
+ Five
+ Six
+
+ >
+ )
+}
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}
+
+ {filterable && (
+
+ )}
+
+ {!options.length && !filterValue && {empty} }
+
+ {!options.length &&
+ filterValue &&
+ // We don't want to show the noMatchText when
+ // we're in the process of loading options
+ !loading && {noMatchText} }
+
+
+
+ {loading && }
+
+
+
)
@@ -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}
/>
undefined,
+ onFilterChange: () => undefined,
+ onFocus: () => undefined,
+}
+
SingleSelectA11y.propTypes = {
/** necessary for IDs that are required for accessibility **/
idPrefix: PropTypes.string.isRequired,
@@ -197,9 +254,6 @@ SingleSelectA11y.propTypes = {
/** An array of options **/
options: PropTypes.arrayOf(optionProp).isRequired,
- /** 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 **/
onChange: PropTypes.func.isRequired,
@@ -234,6 +288,9 @@ SingleSelectA11y.propTypes = {
/** Applies 'error' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props **/
error: sharedPropTypes.statusPropType,
+ /** Help text that will be displayed below the input **/
+ filterHelpText: PropTypes.string,
+
/** Value will be used as aria-label attribute on the filter input **/
filterLabel: PropTypes.string,
@@ -276,6 +333,9 @@ SingleSelectA11y.propTypes = {
/** Applies 'valid' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props **/
valid: sharedPropTypes.statusPropType,
+ /** As of now, this component does not support being uncontrolled **/
+ value: PropTypes.string,
+
/**
* 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
@@ -300,4 +360,6 @@ SingleSelectA11y.propTypes = {
/** Will be called when the combobox is being focused **/
onFocus: PropTypes.func,
+
+ /** Will be called when the user presses enter after erntering a search term **/
}
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 51236f436..20bbdbec3 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
@@ -1,4 +1,3 @@
-import React, { useEffect, useMemo, useState } from 'react'
import { SingleSelectA11y } from './single-select-a11y.js'
export default {
@@ -6,785 +5,31 @@ export default {
component: SingleSelectA11y,
}
-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',
- },
-]
-
-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}
- />
-
- console.log(e)}>
- None
- One
- Two
- Three
- Four
- Five
- Six
-
- >
- )
-}
-
-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={[
- { 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={[
- { label: 'None', value: '' },
- { value: '1', label: 'Option 1' },
- { value: '2', label: 'Option 2' },
- { value: '3', label: 'Option 3' },
- ]}
- />
- )
-}
-
-export const WithManyOptions = () => {
- const [value, setValue] = useState('art_entry_point:_no_pmtct')
-
- 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('')
- const withoutEmptyOptions = options.slice(1)
-
- return (
- option.value === value).label
- : ''
- }
- onChange={(nextValue) => setValue(nextValue)}
- options={withoutEmptyOptions}
- />
- )
-}
-
-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}
- />
-
-
- >
- )
-}
+export { WithoutSelection } from './__stories__/WithoutSelection.js'
+export { WithSelection } from './__stories__/WithSelection.js'
+export { WithOnFocus } from './__stories__/WithOnFocus.js'
+export { WithOnBlur } from './__stories__/WithOnBlur.js'
+export { WithCustomOptions } from './__stories__/WithCustomOptions.js'
+export { WithInitialFocus } from './__stories__/WithInitialFocus.js'
+export { Dense } from './__stories__/Dense.js'
+export { Empty } from './__stories__/Empty.js'
+export { EmptyWithEmptyText } from './__stories__/EmptyWithEmptyText.js'
+export { EmptyWithEmptyNode } from './__stories__/EmptyWithEmptyNode.js'
+export { WithOptionsAndLoading } from './__stories__/WithOptionsAndLoading.js'
+export { WithOptionsAndLoadingText } from './__stories__/WithOptionsAndLoadingText.js'
+export { WithManyOptions } from './__stories__/WithManyOptions.js'
+export { WithCustomLowMaxHeight } from './__stories__/WithCustomLowMaxHeight.js'
+export { WithOptionsAndDisabled } from './__stories__/WithOptionsAndDisabled.js'
+export { WithSelectionAndDisabled } from './__stories__/WithSelectionAndDisabled.js'
+export { WithPrefix } from './__stories__/WithPrefix.js'
+export { WithPrefixAndSelection } from './__stories__/WithPrefixAndSelection.js'
+export { WithRTL } from './__stories__/WithRTL.js'
+export { WithPlaceholder } from './__stories__/WithPlaceholder.js'
+export { WithPlaceholderAndSelection } from './__stories__/WithPlaceholderAndSelection.js'
+export { WithDisabledOption } from './__stories__/WithDisabledOption.js'
+export { WithClearButton } from './__stories__/WithClearButton.js'
+export { WithFilterField } from './__stories__/WithFilterField.js'
+export { WithNoMatchText } from './__stories__/WithNoMatchText.js'
+export { DefaultPosition } from './__stories__/DefaultPosition.js'
+export { FlippedPosition } from './__stories__/FlippedPosition.js'
+export { ShiftedIntoView } from './__stories__/ShiftedIntoView.js'
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/index.js b/components/select/src/single-select-a11y/use-handle-key-press/index.js
index a6fb3becf..6254c9150 100644
--- a/components/select/src/single-select-a11y/use-handle-key-press/index.js
+++ b/components/select/src/single-select-a11y/use-handle-key-press/index.js
@@ -1 +1,2 @@
export { useHandleKeyPress } from './use-handle-key-press.js'
+export { useHandleKeyPressOnFilterInput } from './use-handle-key-press-on-filter-input.js'
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-focus-option.js b/components/select/src/single-select-a11y/use-handle-key-press/use-focus-option.js
new file mode 100644
index 000000000..4d44f8c28
--- /dev/null
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-focus-option.js
@@ -0,0 +1,24 @@
+import { useCallback } from 'react'
+
+export function useFocusOption(
+ findIndexCallback,
+ { options, focussedOptionIndex, setFocussedOptionIndex }
+) {
+ return useCallback(() => {
+ const nextFocussedIndex = findIndexCallback({
+ options,
+ activeIndex: focussedOptionIndex,
+ })
+
+ if (nextFocussedIndex === -1) {
+ return
+ }
+
+ setFocussedOptionIndex(nextFocussedIndex)
+ }, [
+ findIndexCallback,
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ ])
+}
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press-on-filter-input.js b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press-on-filter-input.js
new file mode 100644
index 000000000..3d53e207b
--- /dev/null
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press-on-filter-input.js
@@ -0,0 +1,135 @@
+import { useCallback } from 'react'
+import { useFocusOption } from './use-focus-option.js'
+import { usePageDown } from './use-page-down.js'
+import { usePageUp } from './use-page-up.js'
+import {
+ findNextOptionIndex,
+ findPrevOptionIndex,
+ findFirstOptionIndex,
+ findLastOptionIndex,
+} from './utils.js'
+
+export function useHandleKeyPressOnFilterInput({
+ value,
+ options,
+ closeMenu,
+ listBoxRef,
+ focusComboBox,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ selectFocussedOption,
+}) {
+ const pageDown = usePageDown({
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ listBoxRef,
+ })
+
+ const pageUp = usePageUp({
+ options,
+ listBoxRef,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ })
+
+ const focusNextOption = useFocusOption(findNextOptionIndex, {
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ })
+
+ const focusPrevOption = useFocusOption(findPrevOptionIndex, {
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ })
+
+ const focusFirstOption = useFocusOption(findFirstOptionIndex, {
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ })
+
+ const focusLastOption = useFocusOption(findLastOptionIndex, {
+ options,
+ focussedOptionIndex,
+ setFocussedOptionIndex,
+ })
+
+ const handleKeyPress = useCallback(
+ (e) => {
+ const { key, altKey } = e
+
+ if (
+ key === 'Escape' ||
+ key === 'Enter' ||
+ (key === 'ArrowUp' && altKey) ||
+ (key === 'ArrowDown' && altKey)
+ ) {
+ if (value !== options[focussedOptionIndex].value) {
+ selectFocussedOption()
+ }
+
+ closeMenu()
+
+ // As the filter is being focused, we want to move focus back to the combobox
+ focusComboBox()
+
+ return
+ }
+
+ if (key === 'ArrowDown') {
+ e.preventDefault() // Disable moving cursor to end
+ focusNextOption()
+ return
+ }
+
+ if (key === 'ArrowUp') {
+ e.preventDefault() // Disable moving cursor to start
+ focusPrevOption()
+ return
+ }
+
+ if (key === 'Home') {
+ e.preventDefault() // Disable moving cursor to start
+ focusFirstOption()
+ return
+ }
+
+ if (key === 'End') {
+ e.preventDefault() // Disable moving cursor to end
+ focusLastOption()
+ return
+ }
+
+ if (key === 'PageUp') {
+ pageUp()
+ return
+ }
+
+ if (key === 'PageDown') {
+ pageDown()
+ return
+ }
+
+ // Do nothing
+ },
+ [
+ closeMenu,
+ options,
+ value,
+ focussedOptionIndex,
+ selectFocussedOption,
+ focusComboBox,
+ focusNextOption,
+ focusPrevOption,
+ focusFirstOption,
+ focusLastOption,
+ pageDown,
+ pageUp,
+ ]
+ )
+
+ return handleKeyPress
+}
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js
index cdd692b6d..21e05a1c9 100644
--- a/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-handle-key-press.js
@@ -1,31 +1,14 @@
import { useCallback } from 'react'
+import { useFocusOption } from './use-focus-option.js'
import { useHandleTyping } from './use-handle-typing.js'
import { usePageDown } from './use-page-down.js'
import { usePageUp } from './use-page-up.js'
-
-function isEnabled({ disabled }) {
- return !disabled
-}
-
-function findNextOptionIndex({ options, activeIndex }) {
- const startIndex = activeIndex + 1
- const optionsToSearch = options.slice(startIndex)
-
- // Need to add back the count we removed by slicing the options array
- return startIndex + optionsToSearch.findIndex(isEnabled)
-}
-
-function findPrevOptionIndex({ options, activeIndex }) {
- return options.slice(0, activeIndex).findLastIndex(isEnabled)
-}
-
-function findFirstOptionIndex({ options }) {
- return options.findIndex(isEnabled)
-}
-
-function findLastOptionIndex({ options }) {
- return options.findLastIndex(isEnabled)
-}
+import {
+ findNextOptionIndex,
+ findPrevOptionIndex,
+ findFirstOptionIndex,
+ findLastOptionIndex,
+} from './utils.js'
function useSelectOption(findIndexCallback, { options, onChange, value }) {
return useCallback(() => {
@@ -46,29 +29,6 @@ function useSelectOption(findIndexCallback, { options, onChange, value }) {
}, [findIndexCallback, options, onChange, value])
}
-function useFocusOption(
- findIndexCallback,
- { options, focussedOptionIndex, setFocussedOptionIndex }
-) {
- return useCallback(() => {
- const nextFocussedIndex = findIndexCallback({
- options,
- activeIndex: focussedOptionIndex,
- })
-
- if (nextFocussedIndex === -1) {
- return
- }
-
- setFocussedOptionIndex(nextFocussedIndex)
- }, [
- findIndexCallback,
- options,
- focussedOptionIndex,
- setFocussedOptionIndex,
- ])
-}
-
export function useHandleKeyPress({
value,
disabled,
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/utils.js b/components/select/src/single-select-a11y/use-handle-key-press/utils.js
new file mode 100644
index 000000000..2cd4e462e
--- /dev/null
+++ b/components/select/src/single-select-a11y/use-handle-key-press/utils.js
@@ -0,0 +1,23 @@
+export function isEnabled({ disabled }) {
+ return !disabled
+}
+
+export function findNextOptionIndex({ options, activeIndex }) {
+ const startIndex = activeIndex + 1
+ const optionsToSearch = options.slice(startIndex)
+
+ // Need to add back the count we removed by slicing the options array
+ return startIndex + optionsToSearch.findIndex(isEnabled)
+}
+
+export function findPrevOptionIndex({ options, activeIndex }) {
+ return options.slice(0, activeIndex).findLastIndex(isEnabled)
+}
+
+export function findFirstOptionIndex({ options }) {
+ return options.findIndex(isEnabled)
+}
+
+export function findLastOptionIndex({ options }) {
+ return options.findLastIndex(isEnabled)
+}
From fe8496fa7ce51c59cb3b2def2a56afdae1ec2a35 Mon Sep 17 00:00:00 2001
From: Mohammer5
Date: Mon, 4 Nov 2024 09:57:10 +0800
Subject: [PATCH 24/39] feat(ui collection): export SingleSelectA11y
---
collections/ui/API.md | 103 +++++++++++++++++++++++++++---------
collections/ui/src/index.js | 1 +
2 files changed, 80 insertions(+), 24 deletions(-)
diff --git a/collections/ui/API.md b/collections/ui/API.md
index 26b04758f..ca6debd45 100644
--- a/collections/ui/API.md
+++ b/collections/ui/API.md
@@ -1866,15 +1866,15 @@ import { SingleSelect } from '@dhis2/ui'
|onFocus|function||||
|onKeyDown|function||||
-### Menu
+### SingleSelectA11y
#### Usage
-To use `Menu`, you can import the component from the `@dhis2/ui` library
+To use `SingleSelectA11y`, you can import the component from the `@dhis2/ui` library
```js
-import { Menu } from '@dhis2/ui'
+import { SingleSelectA11y } from '@dhis2/ui'
```
@@ -1882,27 +1882,39 @@ import { Menu } from '@dhis2/ui'
|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||||
+|idPrefix|string||*|necessary for IDs that are required for accessibility *|
+|options|arrayOf(custom)||*|An array of options *|
+|value|string|`''`||As of now, this component does not support being uncontrolled *|
+|onChange|function||*|A callback that will be called with the new value or an empty string *|
+|autoFocus|boolean|`false`||Will focus the select initially *|
+|className|string|`''`||Additional class names that will be applied to the root element *|
+|clearText|custom|`''`||This will allow us to put an aria-label on the clear button *|
+|clearable|boolean|`false`||Whether a clear button should be displayed or not *|
+|customOption|elementType|||Allows to override what's rendered inside the `button[role="option"]`. Can be overriden on an individual option basis *|
+|dataTest|string|`'dhis2-singleselecta11y'`||A value for a `data-test` attribute on the root element *|
+|dense|boolean|`false`||Renders a select with lower height *|
+|disabled|boolean|`false`||Disables all interactions with the select (except focussing) *|
+|empty|node|`false`||Text or component to display when there are no options *|
+|error|custom|`false`||Applies 'error' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props *|
+|filterLabel|string|`''`||Value will be used as aria-label attribute on the filter input *|
+|filterPlaceholder|string|`''`||Placeholder for the filter input *|
+|filterValue|string|`''`||Value of the filter input *|
+|filterable|boolean|`false`||Whether the select should display a filter input *|
+|labelledBy|string|`''`||Should contain the id of the element that labels the select, if applicable *|
+|loading|boolean|`false`||Will show a loading indicator at the end of the options-list *|
+|menuLoadingText|string|`''`||Text that will be displayed next to the loading indicator *|
+|menuMaxHeight|string|`'288px'`||Allows to modify the max height of the menu *|
+|noMatchText|custom|`''`||String that will be displayed when the select is being filtered but the options array is empty *|
+|optionUpdateStrategy|'off' │ 'polite' │ 'assertive'|`'polite'`||For a11y: How aggressively the user should be updated about changes in options *|
+|placeholder|string|`''`||String to show when there's no value and no valueLabel *|
+|prefix|string|`''`||String that will be displayed before the label of the selected option *|
+|tabIndex|string │ number|`'0'`||Standard HTML tab-index attribute that will be put on the combobox's root element *|
+|valid|custom|`false`||Applies 'valid' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props *|
+|valueLabel|custom|`''`||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.|
+|warning|custom|`false`||Applies 'warning' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props *|
+|onBlur|function|`() => undefined`||Will be called when the combobox is loses focus *|
+|onFilterChange|function|`() => undefined`||Will be called when the filter value changes *|
+|onFocus|function|`() => undefined`||Will be called when the combobox is being focused *|
### SingleSelectField
@@ -1977,6 +1989,49 @@ import { SingleSelectOption } from '@dhis2/ui'
|disabled|boolean||||
|onClick|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||*||
+|focussedOptionIndex|number||*||
+|idPrefix|string||*||
+|listBoxRef|{ "current": "instanceOf(HTMLElement)" }||*||
+|options|arrayOf(custom)||*||
+|onChange|function||*||
+|customOption|elementType||||
+|dataTest|string||||
+|disabled|boolean||||
+|empty|node||||
+|filterLabel|string||||
+|filterPlaceholder|string||||
+|filterValue|string||||
+|filterable|boolean||||
+|hidden|boolean||||
+|labelledBy|string||||
+|loading|boolean||||
+|loadingText|string||||
+|maxHeight|string||||
+|noMatchText|string||||
+|optionUpdateStrategy|'off' │ 'polite' │ 'assertive'||||
+|selectRef|instanceOf(HTMLElement)||||
+|selected|string||||
+|onBlur|function||||
+|onClose|function||||
+|onFilterChange|function||||
+
### SelectorBar
#### Usage
diff --git a/collections/ui/src/index.js b/collections/ui/src/index.js
index 4cc7ca097..617e9a102 100644
--- a/collections/ui/src/index.js
+++ b/collections/ui/src/index.js
@@ -66,6 +66,7 @@ export {
MultiSelectField,
MultiSelectOption,
SingleSelect,
+ SingleSelectA11y,
SingleSelectField,
SingleSelectOption,
} from '@dhis2-ui/select'
From 11b227e15619e3b29a0eac38297515db5e5e2abd Mon Sep 17 00:00:00 2001
From: Mohammer5
Date: Mon, 4 Nov 2024 09:57:35 +0800
Subject: [PATCH 25/39] chore(select a11y): update API.md
---
components/select/API.md | 50 ++++++++++++++++++++++++++++++++++++++++
1 file changed, 50 insertions(+)
diff --git a/components/select/API.md b/components/select/API.md
index d2498c24a..b5903d5c5 100644
--- a/components/select/API.md
+++ b/components/select/API.md
@@ -159,6 +159,56 @@ import { SingleSelect } from '@dhis2/ui'
|onFocus|function||||
|onKeyDown|function||||
+### SingleSelectA11y
+
+#### Usage
+
+To use `SingleSelectA11y`, you can import the component from the `@dhis2/ui` library
+
+
+```js
+import { SingleSelectA11y } from '@dhis2/ui'
+```
+
+
+#### Props
+
+|Name|Type|Default|Required|Description|
+|---|---|---|---|---|
+|idPrefix|string||*|necessary for IDs that are required for accessibility *|
+|options|arrayOf(custom)||*|An array of options *|
+|value|string|`''`||As of now, this component does not support being uncontrolled *|
+|onChange|function||*|A callback that will be called with the new value or an empty string *|
+|autoFocus|boolean|`false`||Will focus the select initially *|
+|className|string|`''`||Additional class names that will be applied to the root element *|
+|clearText|custom(function)|`''`||This will allow us to put an aria-label on the clear button *|
+|clearable|boolean|`false`||Whether a clear button should be displayed or not *|
+|customOption|elementType|||Allows to override what's rendered inside the `button[role="option"]`. Can be overriden on an individual option basis *|
+|dataTest|string|`'dhis2-singleselecta11y'`||A value for a `data-test` attribute on the root element *|
+|dense|boolean|`false`||Renders a select with lower height *|
+|disabled|boolean|`false`||Disables all interactions with the select (except focussing) *|
+|empty|node|`false`||Text or component to display when there are no options *|
+|error|custom|`false`||Applies 'error' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props *|
+|filterLabel|string|`''`||Value will be used as aria-label attribute on the filter input *|
+|filterPlaceholder|string|`''`||Placeholder for the filter input *|
+|filterValue|string|`''`||Value of the filter input *|
+|filterable|boolean|`false`||Whether the select should display a filter input *|
+|labelledBy|string|`''`||Should contain the id of the element that labels the select, if applicable *|
+|loading|boolean|`false`||Will show a loading indicator at the end of the options-list *|
+|menuLoadingText|string|`''`||Text that will be displayed next to the loading indicator *|
+|menuMaxHeight|string|`'288px'`||Allows to modify the max height of the menu *|
+|noMatchText|custom(function)|`''`||String that will be displayed when the select is being filtered but the options array is empty *|
+|optionUpdateStrategy|'off' │ 'polite' │ 'assertive'|`'polite'`||For a11y: How aggressively the user should be updated about changes in options *|
+|placeholder|string|`''`||String to show when there's no value and no valueLabel *|
+|prefix|string|`''`||String that will be displayed before the label of the selected option *|
+|tabIndex|string │ number|`'0'`||Standard HTML tab-index attribute that will be put on the combobox's root element *|
+|valid|custom|`false`||Applies 'valid' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props *|
+|valueLabel|custom(function)|`''`||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.|
+|warning|custom|`false`||Applies 'warning' appearance for validation feedback. Mutually exclusive with `warning` and `valid` props *|
+|onBlur|function|`() => undefined`||Will be called when the combobox is loses focus *|
+|onFilterChange|function|`() => undefined`||Will be called when the filter value changes *|
+|onFocus|function|`() => undefined`||Will be called when the combobox is being focused *|
+
### SingleSelectField
#### Usage
From e6a883308112bdccd4bc914927514d1afad4ff54 Mon Sep 17 00:00:00 2001
From: Mohammer5
Date: Mon, 4 Nov 2024 11:41:06 +0800
Subject: [PATCH 26/39] feat(select a11y): add onEndReached prop
---
.../__stories__/InfiniteLoading.js | 71 ++++++++++++++++
.../keyboard-interactions.test.e2e.js | 7 +-
.../single-select-a11y/menu/menu-loading.js | 4 +
.../menu/menu-options-list.js | 6 ++
.../src/single-select-a11y/menu/menu.js | 84 ++++++++++++-------
.../src/single-select-a11y/menu/option.js | 40 ++++++++-
.../single-select-a11y/single-select-a11y.js | 7 +-
.../single-select-a11y.prod.stories.js | 1 +
.../single-select-a11y.test.js | 2 +-
9 files changed, 188 insertions(+), 34 deletions(-)
create mode 100644 components/select/src/single-select-a11y/__stories__/InfiniteLoading.js
diff --git a/components/select/src/single-select-a11y/__stories__/InfiniteLoading.js b/components/select/src/single-select-a11y/__stories__/InfiniteLoading.js
new file mode 100644
index 000000000..6b3750097
--- /dev/null
+++ b/components/select/src/single-select-a11y/__stories__/InfiniteLoading.js
@@ -0,0 +1,71 @@
+import React, { useCallback, useState } from 'react'
+import { SingleSelectA11y } from '../single-select-a11y.js'
+import { options } from './options.js'
+
+// The select displays exactly 9 options per page,
+// using 10 so the user has to scroll to see the effect
+const CHUNK_SIZE = 10
+
+const optionChunks = options.reduce((chunks, option, index) => {
+ if (index === 0) {
+ return [[option]]
+ }
+
+ const prevChunkIndex = chunks.length - 1
+ const prevChunk = chunks[prevChunkIndex]
+ const prevChunkLength = prevChunk.length
+
+ if (prevChunkLength < CHUNK_SIZE) {
+ return [...chunks.slice(0, prevChunkIndex), [...prevChunk, option]]
+ }
+
+ return [...chunks, [option]]
+}, [])
+
+export const InfiniteLoading = () => {
+ const [loading, setLoading] = useState(false)
+ const [curLoadedPage, setCurLoadedPage] = useState(0)
+ const [value, setValue] = useState('')
+ const [loadedOptions, setLoadedOptions] = useState(optionChunks[0])
+ const valueLabel = value
+ ? loadedOptions.find((option) => option.value === value).label
+ : ''
+
+ const loadNextOptions = useCallback(() => {
+ const nextPage = curLoadedPage + 1
+ console.log('>', { nextPage, loading })
+
+ if (
+ // We're already loading a page
+ loading ||
+ // No need to load anything when already loaded everything
+ nextPage >= optionChunks.length
+ ) {
+ console.log('> do nothing')
+ return
+ }
+
+ setLoading(true)
+ setCurLoadedPage(nextPage)
+
+ // Fake network request to show loader a reasonable amount of time
+ setTimeout(() => {
+ const nextChunk = optionChunks[nextPage]
+ setLoadedOptions((prevOptions) => [...prevOptions, ...nextChunk])
+ setLoading(false)
+ }, 1500)
+ }, [curLoadedPage, loading])
+
+ return (
+ setValue(nextValue)}
+ options={loadedOptions}
+ onEndReached={loadNextOptions}
+ />
+ )
+}
diff --git a/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js b/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js
index bfc6b396b..4df11efd4 100644
--- a/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js
+++ b/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js
@@ -23,6 +23,7 @@ describe(' ', () => {
cy.findByRole('option', { selected: true })
.invoke('parent') // listbox
+ .invoke('parent') // .listbox-container
.invoke('parent') // scrollable div
.then((listBoxParent) => {
listBoxParent.get(0).scrollTop = optionOffset
@@ -70,6 +71,7 @@ describe(' ', () => {
cy.findByRole('option', { selected: true })
.invoke('parent') // listbox
+ .invoke('parent') // .listbox-container
.invoke('parent') // scrollable div
.then((listBoxParent) => {
listBoxParent.get(0).scrollTop = optionOffset
@@ -181,6 +183,7 @@ describe(' ', () => {
cy.findByRole('option', { selected: true })
.invoke('parent') // listbox
+ .invoke('parent') // .listbox-container
.invoke('parent') // scrollable div
.then((listBoxParent) => {
listBoxParent.get(0).scrollTop = optionOffset
@@ -225,6 +228,7 @@ describe(' ', () => {
cy.findByRole('option', { selected: true })
.invoke('parent') // listbox
+ .invoke('parent') // .listbox-container
.invoke('parent') // scrollable div
.then((listBoxParent) => {
listBoxParent.get(0).scrollTop = optionOffset
@@ -404,6 +408,7 @@ describe(' ', () => {
cy
.findByRole('option', { selected: true })
.invoke('parent')
+ .invoke('parent') // .listbox-container
.invoke('parent')
).then(([nextTopOption, listBoxParent]) => {
const { offsetTop } = nextTopOption.get(0)
@@ -523,7 +528,7 @@ describe(' ', () => {
)
cy.all(
() => cy.findAllByRole('option').eq(89),
- () => cy.findByRole('listbox').invoke('parent') // scrollable div
+ () => cy.findByRole('listbox').invoke('parent').invoke('parent') // scrollable div
).then(([nextTopOption, listBoxParent]) => {
const { offsetTop } = nextTopOption.get(0)
listBoxParent.get(0).scrollTop = offsetTop
diff --git a/components/select/src/single-select-a11y/menu/menu-loading.js b/components/select/src/single-select-a11y/menu/menu-loading.js
index 9e7491702..cdf7cbe50 100644
--- a/components/select/src/single-select-a11y/menu/menu-loading.js
+++ b/components/select/src/single-select-a11y/menu/menu-loading.js
@@ -14,6 +14,9 @@ export function MenuLoading({ message }) {
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 (
null : onChange}
component={component || customOption}
+ onBecameVisible={isLast ? onEndReached : undefined}
/>
)
}
@@ -102,4 +107,5 @@ MenuOptionsList.propTypes = {
optionUpdateStrategy: PropTypes.oneOf(['off', 'polite', 'assertive']),
selected: PropTypes.string,
onBlur: PropTypes.func,
+ onEndReached: PropTypes.func,
}
diff --git a/components/select/src/single-select-a11y/menu/menu.js b/components/select/src/single-select-a11y/menu/menu.js
index 2d71ce5ce..87b3cbcfd 100644
--- a/components/select/src/single-select-a11y/menu/menu.js
+++ b/components/select/src/single-select-a11y/menu/menu.js
@@ -37,6 +37,7 @@ export function Menu({
selected,
onBlur,
onClose,
+ onEndReached,
onFilterChange,
onFilterInputKeyDown,
onSearch,
@@ -58,6 +59,15 @@ export function Menu({
return null
}
+ const hasNoFilterMatch =
+ !options.length &&
+ filterValue &&
+ // We don't want to show the noMatchText when
+ // we're in the process of loading options
+ !loading
+
+ const isEmpty = !options.length && !filterValue
+
return (
{filterable && (
@@ -81,36 +91,39 @@ export function Menu({
/>
)}
- {!options.length && !filterValue &&
{empty} }
-
- {!options.length &&
- filterValue &&
- // We don't want to show the noMatchText when
- // we're in the process of loading options
- !loading &&
{noMatchText} }
-
-
-
- {loading &&
}
+ {isEmpty &&
{empty} }
+
+ {hasNoFilterMatch &&
{noMatchText} }
+
+
+
+
+ {loading && (
+
+
+
+ )}
+
@@ -156,6 +181,7 @@ Menu.propTypes = {
selected: PropTypes.string,
onBlur: PropTypes.func,
onClose: PropTypes.func,
+ onEndReached: PropTypes.func,
onFilterChange: PropTypes.func,
onFilterInputKeyDown: PropTypes.func,
onSearch: PropTypes.func,
diff --git a/components/select/src/single-select-a11y/menu/option.js b/components/select/src/single-select-a11y/menu/option.js
index bcc3837d6..46f4f515e 100644
--- a/components/select/src/single-select-a11y/menu/option.js
+++ b/components/select/src/single-select-a11y/menu/option.js
@@ -1,7 +1,9 @@
import { colors, spacers } from '@dhis2/ui-constants'
import cx from 'classnames'
import PropTypes from 'prop-types'
-import React from 'react'
+import React, { useEffect, useRef } from 'react'
+
+const VISIBILE_INTERSECTION_RATIO = 0.99
function DefaultStyle({ label, disabled, highlighted }) {
return (
@@ -66,10 +68,42 @@ export function Option({
dataTest,
disabled,
highlighted,
+ listBoxRef,
+ onBecameVisible,
...rest
}) {
+ const buttonRef = useRef()
+
+ useEffect(() => {
+ if (onBecameVisible) {
+ const scrollableContainer = listBoxRef.current.parentNode.parentNode
+ const intersectionOptions = {
+ root: scrollableContainer,
+ rootMargin: '0px',
+ threshold: 1,
+ }
+
+ const intersectionHandler = (entries) => {
+ entries.forEach(({ intersectionRatio }) => {
+ if (intersectionRatio >= VISIBILE_INTERSECTION_RATIO) {
+ onBecameVisible()
+ }
+ })
+ }
+
+ const observer = new IntersectionObserver(
+ intersectionHandler,
+ intersectionOptions
+ )
+
+ observer.observe(buttonRef.current)
+ return () => observer.disconnect()
+ }
+ }, [onBecameVisible, listBoxRef])
+
return (
@@ -355,11 +357,12 @@ SingleSelectA11y.propTypes = {
/** Will be called when the combobox is loses focus **/
onBlur: PropTypes.func,
+ /** Will be called when the last option is scrolled into the visible area **/
+ onEndReached: 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,
-
- /** Will be called when the user presses enter after erntering a search term **/
}
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 20bbdbec3..346afaf13 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
@@ -13,6 +13,7 @@ export { WithCustomOptions } from './__stories__/WithCustomOptions.js'
export { WithInitialFocus } from './__stories__/WithInitialFocus.js'
export { Dense } from './__stories__/Dense.js'
export { Empty } from './__stories__/Empty.js'
+export { InfiniteLoading } from './__stories__/InfiniteLoading.js'
export { EmptyWithEmptyText } from './__stories__/EmptyWithEmptyText.js'
export { EmptyWithEmptyNode } from './__stories__/EmptyWithEmptyNode.js'
export { WithOptionsAndLoading } from './__stories__/WithOptionsAndLoading.js'
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
index 69133a06d..89e7640e8 100644
--- a/components/select/src/single-select-a11y/single-select-a11y.test.js
+++ b/components/select/src/single-select-a11y/single-select-a11y.test.js
@@ -124,7 +124,7 @@ describe(' ', () => {
fireEvent.click(screen.getByRole('combobox'))
const listbox = screen.getByRole('listbox')
- const listboxContainer = listbox.parentNode
+ const listboxContainer = listbox.parentNode.parentNode
expect(listboxContainer.style.maxHeight).toBe('100px')
})
From c0d0d09eadc337d2a6e4826bfdef2e90b7a820d6 Mon Sep 17 00:00:00 2001
From: Mohammer5
Date: Mon, 4 Nov 2024 15:43:08 +0800
Subject: [PATCH 27/39] fix(select a11y): handle focussed option index
separately when filtering
---
.../__stories__/InfiniteLoading.js | 2 -
.../keyboard-interactions.test.e2e.js | 7 +-
.../single-select-a11y/menu/menu-filter.js | 4 +
.../src/single-select-a11y/menu/menu.js | 26 +++--
.../src/single-select-a11y/menu/option.js | 2 +-
.../single-select-a11y/single-select-a11y.js | 51 +++++++++-
.../single-select-a11y.test.js | 14 ++-
.../use-highlight-last-option-on-next-page.js | 99 +++++++++----------
.../use-handle-key-press/use-page-down.js | 2 +-
9 files changed, 127 insertions(+), 80 deletions(-)
diff --git a/components/select/src/single-select-a11y/__stories__/InfiniteLoading.js b/components/select/src/single-select-a11y/__stories__/InfiniteLoading.js
index 6b3750097..6744436fa 100644
--- a/components/select/src/single-select-a11y/__stories__/InfiniteLoading.js
+++ b/components/select/src/single-select-a11y/__stories__/InfiniteLoading.js
@@ -33,7 +33,6 @@ export const InfiniteLoading = () => {
const loadNextOptions = useCallback(() => {
const nextPage = curLoadedPage + 1
- console.log('>', { nextPage, loading })
if (
// We're already loading a page
@@ -41,7 +40,6 @@ export const InfiniteLoading = () => {
// No need to load anything when already loaded everything
nextPage >= optionChunks.length
) {
- console.log('> do nothing')
return
}
diff --git a/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js b/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js
index 4df11efd4..bfc6b396b 100644
--- a/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js
+++ b/components/select/src/single-select-a11y/features/keyboard-interactions.test.e2e.js
@@ -23,7 +23,6 @@ describe(' ', () => {
cy.findByRole('option', { selected: true })
.invoke('parent') // listbox
- .invoke('parent') // .listbox-container
.invoke('parent') // scrollable div
.then((listBoxParent) => {
listBoxParent.get(0).scrollTop = optionOffset
@@ -71,7 +70,6 @@ describe(' ', () => {
cy.findByRole('option', { selected: true })
.invoke('parent') // listbox
- .invoke('parent') // .listbox-container
.invoke('parent') // scrollable div
.then((listBoxParent) => {
listBoxParent.get(0).scrollTop = optionOffset
@@ -183,7 +181,6 @@ describe(' ', () => {
cy.findByRole('option', { selected: true })
.invoke('parent') // listbox
- .invoke('parent') // .listbox-container
.invoke('parent') // scrollable div
.then((listBoxParent) => {
listBoxParent.get(0).scrollTop = optionOffset
@@ -228,7 +225,6 @@ describe(' ', () => {
cy.findByRole('option', { selected: true })
.invoke('parent') // listbox
- .invoke('parent') // .listbox-container
.invoke('parent') // scrollable div
.then((listBoxParent) => {
listBoxParent.get(0).scrollTop = optionOffset
@@ -408,7 +404,6 @@ describe(' ', () => {
cy
.findByRole('option', { selected: true })
.invoke('parent')
- .invoke('parent') // .listbox-container
.invoke('parent')
).then(([nextTopOption, listBoxParent]) => {
const { offsetTop } = nextTopOption.get(0)
@@ -528,7 +523,7 @@ describe(' ', () => {
)
cy.all(
() => cy.findAllByRole('option').eq(89),
- () => cy.findByRole('listbox').invoke('parent').invoke('parent') // scrollable div
+ () => cy.findByRole('listbox').invoke('parent') // scrollable div
).then(([nextTopOption, listBoxParent]) => {
const { offsetTop } = nextTopOption.get(0)
listBoxParent.get(0).scrollTop = offsetTop
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 c90ec1cec..8d6aee173 100644
--- a/components/select/src/single-select-a11y/menu/menu-filter.js
+++ b/components/select/src/single-select-a11y/menu/menu-filter.js
@@ -5,6 +5,7 @@ import React from 'react'
import i18n from '../../locales/index.js'
export function MenuFilter({
+ idPrefix,
value,
onChange,
dataTest,
@@ -16,6 +17,8 @@ export function MenuFilter({
{filterable && (
-
- {loading && (
-
-
-
- )}
+ {loading && (
+
+
+
+ )}
+
@@ -52,5 +53,6 @@ MenuFilter.propTypes = {
dataTest: PropTypes.string,
label: PropTypes.string,
placeholder: PropTypes.string,
+ tabIndex: PropTypes.string,
onKeyDown: PropTypes.func,
}
diff --git a/components/select/src/single-select-a11y/menu/menu-loading.js b/components/select/src/single-select-a11y/menu/menu-loading.js
index cdf7cbe50..936db6391 100644
--- a/components/select/src/single-select-a11y/menu/menu-loading.js
+++ b/components/select/src/single-select-a11y/menu/menu-loading.js
@@ -14,9 +14,9 @@ export function MenuLoading({ message }) {
+
+ )
+}
+
+ClearButton.propTypes = {
+ clearText: PropTypes.string.isRequired,
+ onClear: PropTypes.func.isRequired,
+ dataTest: PropTypes.string,
+}
+
+function ClearButtonWithTooltip({ onClear, clearText, dataTest }) {
+ const clearButton = (
+
+ )
+
+ if (!clearText) {
+ return clearButton
+ }
+
+ return (
+
+ {clearButton}
+
+ )
+}
+
+ClearButtonWithTooltip.propTypes = {
+ clearText: PropTypes.string.isRequired,
+ onClear: PropTypes.func.isRequired,
+ dataTest: PropTypes.string,
+}
+
+export { ClearButtonWithTooltip as ClearButton }
diff --git a/components/select/src/single-select-a11y/selected-value/selected-value-container.js b/components/select/src/single-select-a11y/selected-value/container.js
similarity index 94%
rename from components/select/src/single-select-a11y/selected-value/selected-value-container.js
rename to components/select/src/single-select-a11y/selected-value/container.js
index cb7886a46..b0cf4d6d5 100644
--- a/components/select/src/single-select-a11y/selected-value/selected-value-container.js
+++ b/components/select/src/single-select-a11y/selected-value/container.js
@@ -3,11 +3,10 @@ import cx from 'classnames'
import PropTypes from 'prop-types'
import React, { forwardRef, useCallback, useEffect } from 'react'
-export const SelectedValueContainer = forwardRef(function Container(
+export const Container = forwardRef(function Container(
{
children,
comboBoxId,
- idPrefix,
autoFocus,
dataTest,
dense,
@@ -15,6 +14,7 @@ export const SelectedValueContainer = forwardRef(function Container(
error,
expanded,
labelledBy,
+ name,
placeholder,
tabIndex,
valid,
@@ -53,7 +53,7 @@ export const SelectedValueContainer = forwardRef(function Container(
className={cx({ error, warning, valid, disabled, dense })}
data-test={dataTest}
ref={ref}
- aria-controls={`${idPrefix}-listbox`}
+ aria-controls={`${name}-listbox`}
aria-expanded={expanded.toString()}
aria-haspopup="listbox"
aria-labelledby={labelledBy}
@@ -124,10 +124,10 @@ export const SelectedValueContainer = forwardRef(function Container(
)
})
-SelectedValueContainer.propTypes = {
+Container.propTypes = {
children: PropTypes.any.isRequired,
comboBoxId: PropTypes.string.isRequired,
- idPrefix: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
onKeyDown: PropTypes.func.isRequired,
autoFocus: PropTypes.bool,
dataTest: PropTypes.string,
diff --git a/components/select/src/single-select-a11y/selected-value/selected-value-placeholder.js b/components/select/src/single-select-a11y/selected-value/placeholder.js
similarity index 73%
rename from components/select/src/single-select-a11y/selected-value/selected-value-placeholder.js
rename to components/select/src/single-select-a11y/selected-value/placeholder.js
index ddd43f7b1..103a70f28 100644
--- a/components/select/src/single-select-a11y/selected-value/selected-value-placeholder.js
+++ b/components/select/src/single-select-a11y/selected-value/placeholder.js
@@ -2,15 +2,7 @@ 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
- }
-
+export function Placeholder({ placeholder, className, dataTest }) {
return (
{placeholder}
@@ -25,7 +17,7 @@ export const SelectedValuePlaceholder = ({
)
}
-SelectedValuePlaceholder.propTypes = {
+Placeholder.propTypes = {
dataTest: PropTypes.string.isRequired,
className: PropTypes.string,
placeholder: PropTypes.string,
diff --git a/components/select/src/single-select-a11y/selected-value/selected-value-prefix.js b/components/select/src/single-select-a11y/selected-value/prefix.js
similarity index 85%
rename from components/select/src/single-select-a11y/selected-value/selected-value-prefix.js
rename to components/select/src/single-select-a11y/selected-value/prefix.js
index 332699147..1a71c426a 100644
--- a/components/select/src/single-select-a11y/selected-value/selected-value-prefix.js
+++ b/components/select/src/single-select-a11y/selected-value/prefix.js
@@ -2,7 +2,7 @@ import { colors, spacers } from '@dhis2/ui-constants'
import PropTypes from 'prop-types'
import React from 'react'
-export function SelectedValuePrefix({ prefix, className, dataTest }) {
+export function Prefix({ prefix, className, dataTest }) {
return (
{prefix}
@@ -19,7 +19,7 @@ export function SelectedValuePrefix({ prefix, className, dataTest }) {
)
}
-SelectedValuePrefix.propTypes = {
+Prefix.propTypes = {
dataTest: PropTypes.string.isRequired,
className: PropTypes.string,
prefix: PropTypes.string,
diff --git a/components/select/src/single-select-a11y/selected-value/selected-value-clear-button.js b/components/select/src/single-select-a11y/selected-value/selected-value-clear-button.js
deleted file mode 100644
index 06c17a702..000000000
--- a/components/select/src/single-select-a11y/selected-value/selected-value-clear-button.js
+++ /dev/null
@@ -1,86 +0,0 @@
-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 }) => (
-
{
- e.stopPropagation()
- onClear(e)
- }}
- >
-
-
-
-
-
-)
-
-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/selected-value.js b/components/select/src/single-select-a11y/selected-value/selected-value.js
index 19da6c378..19b64bd8a 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
@@ -1,16 +1,16 @@
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'
+import { ClearButton } from './clear-button.js'
+import { Container } from './container.js'
+import { Placeholder } from './placeholder.js'
+import { Prefix } from './prefix.js'
export function SelectedValue({
clearText,
comboBoxId,
- idPrefix,
- valueLabel,
+ name,
+ selectedLabel,
onKeyDown,
autoFocus,
clearable,
@@ -21,6 +21,7 @@ export function SelectedValue({
error,
expanded,
hasSelection,
+ inputMaxHeight,
labelledBy,
placeholder,
prefix,
@@ -32,22 +33,20 @@ export function SelectedValue({
onClick,
onFocus,
}) {
- // @TODO
- const inputMaxHeight = '300px'
const dataTestPrefix = `${dataTest}-selectedvalue`
return (
-
{prefix && (
-
+
)}
- {!valueLabel && !prefix && (
-
)}
- {valueLabel}
+ {selectedLabel}
{hasSelection && clearable && !disabled && (
-
-
+
)
}
SelectedValue.propTypes = {
clearText: PropTypes.string.isRequired,
comboBoxId: PropTypes.string.isRequired,
- idPrefix: PropTypes.string.isRequired,
- valueLabel: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
+ selectedLabel: PropTypes.string.isRequired,
onKeyDown: PropTypes.func.isRequired,
autoFocus: PropTypes.bool,
clearable: PropTypes.bool,
@@ -135,6 +131,7 @@ SelectedValue.propTypes = {
error: PropTypes.bool,
expanded: PropTypes.bool,
hasSelection: PropTypes.bool,
+ inputMaxHeight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
labelledBy: PropTypes.string,
placeholder: PropTypes.string,
prefix: PropTypes.string,
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
index 318e68f27..b156bd10f 100644
--- 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
@@ -101,8 +101,11 @@ const fiveOptions = options.slice(0, 5)
export const DefaultPosition = () => (
null}
options={fiveOptions}
/>
@@ -111,8 +114,11 @@ export const DefaultPosition = () => (
export const FlippedPosition = () => (
<>
null}
options={options}
/>
@@ -135,8 +141,11 @@ export const FlippedPosition = () => (
export const ShiftedIntoView = () => (
<>
null}
options={options}
/>
@@ -157,7 +166,10 @@ export const ShiftedIntoView = () => (
)
export const HundretOptions = () => {
- const [value, setValue] = useState('0')
+ const [selected, setSelected] = useState({
+ value: '0',
+ label: `Select option 0`,
+ })
const [hundretOptions] = useState(
Array.apply(null, Array(100)).map((x, i) => ({
value: `${i}`,
@@ -167,16 +179,19 @@ export const HundretOptions = () => {
return (
)
}
export const HundretOptionsWithDisabled = () => {
- const [value, setValue] = useState('10')
+ const [selected, setSelected] = useState({
+ value: '10',
+ label: `Select option 10`,
+ })
const [hundretOptions] = useState(
Array.apply(null, Array(100)).map((x, i) => ({
value: `${i}`,
@@ -192,43 +207,10 @@ export const HundretOptionsWithDisabled = () => {
return (
)
}
-
-export const NativeSelect = () => {
- const [value, setValue] = useState('0')
- const [hundretOptions] = useState(
- Array.apply(null, Array(100)).map((x, i) => ({
- value: `${i}`,
- label: `Select option ${i}`,
- disabled: i > 19 && i < 30,
- }))
- )
-
- return (
- <>
- setValue(e.target.value)}>
- {hundretOptions.map((option) => (
-
- {option.label}
-
- ))}
-
-
-
- >
- )
-}
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 1fe43f274..ecef3df29 100644
--- a/components/select/src/single-select-a11y/single-select-a11y.js
+++ b/components/select/src/single-select-a11y/single-select-a11y.js
@@ -8,7 +8,7 @@ import { Menu } from './menu/index.js'
import { SelectedValue } from './selected-value/index.js'
import { optionProp } from './shared-prop-types.js'
import {
- useHandleKeyPress,
+ useHandleKeyPressOnCombobox,
useHandleKeyPressOnFilterInput,
} from './use-handle-key-press/index.js'
@@ -53,14 +53,14 @@ function useFocussedOptionIndex({ filterable, filterValue, options }) {
}
export function SingleSelectA11y({
+ name,
options,
- idPrefix,
onChange,
autoFocus = false,
className = '',
clearText: _clearText = '',
clearable = false,
- customOption = undefined,
+ optionComponent = undefined,
dataTest = 'dhis2-singleselecta11y',
dense = false,
disabled = false,
@@ -71,6 +71,7 @@ export function SingleSelectA11y({
filterPlaceholder: _filterPlaceholder = '',
filterValue = '',
filterable = false,
+ inputMaxHeight = '',
labelledBy = '',
loading = false,
menuLoadingText: _menuLoadingText = '',
@@ -79,12 +80,12 @@ export function SingleSelectA11y({
optionUpdateStrategy = 'polite',
placeholder = '',
prefix = '',
+ selected = { label: '', value: '' },
tabIndex = '0',
valid = false,
- value = '',
warning = false,
- valueLabel: _valueLabel = '',
onBlur = () => undefined,
+ onClear = () => undefined,
onEndReached = () => undefined,
onFilterChange = () => undefined,
onFocus = () => undefined,
@@ -98,23 +99,7 @@ export function SingleSelectA11y({
const menuLoadingText = _menuLoadingText || i18n.t('Loading options')
const noMatchText = _noMatchText || i18n.t('No options found')
- const comboBoxId = `${idPrefix}-combo`
- const valueLabel =
- _valueLabel ||
- options.find((option) => option.value === value)?.label ||
- ''
-
- if (
- value &&
- !valueLabel &&
- options.length &&
- !options.find((option) => option.value === '') &&
- !placeholder
- ) {
- throw new Error(
- 'You must either provide a "valueLabel" or include an empty option in the options array'
- )
- }
+ const comboBoxId = `${name}-combo`
const comboBoxRef = useRef()
const listBoxRef = useRef()
@@ -128,16 +113,20 @@ export function SingleSelectA11y({
const [selectRef, setSelectRef] = useState()
const [expanded, setExpanded] = useState(false)
const closeMenu = useCallback(() => setExpanded(false), [])
+
+ const selectedValue = selected?.value || ''
+ const selectedLabel = selected?.label || ''
const openMenu = useCallback(() => {
const selectedOptionIndex = options.findIndex(
- (option) => option.value === value
+ (option) => option.value === selectedValue
)
if (selectedOptionIndex !== focussedOptionIndex) {
setFocussedOptionIndex(selectedOptionIndex)
}
setExpanded(true)
- }, [options, value, focussedOptionIndex, setFocussedOptionIndex])
+ }, [options, selectedValue, focussedOptionIndex, setFocussedOptionIndex])
+
const toggleMenu = useCallback(() => {
if (expanded) {
closeMenu()
@@ -150,7 +139,7 @@ export function SingleSelectA11y({
const option = options[focussedOptionIndex]
if (option) {
- onChange(option.value)
+ onChange(option)
}
}, [focussedOptionIndex, options, onChange])
@@ -159,8 +148,8 @@ export function SingleSelectA11y({
[comboBoxRef]
)
- const handleKeyDown = useHandleKeyPress({
- value,
+ const handleKeyDown = useHandleKeyPressOnCombobox({
+ value: selectedValue,
disabled,
onChange,
expanded,
@@ -175,7 +164,7 @@ export function SingleSelectA11y({
})
const handleKeyDownOnFilterInput = useHandleKeyPressOnFilterInput({
- value,
+ value: selectedValue,
options,
loading,
closeMenu,
@@ -217,19 +206,18 @@ export function SingleSelectA11y({
disabled={disabled}
error={error}
expanded={expanded}
- hasSelection={!!value}
- idPrefix={idPrefix}
+ hasSelection={!!selectedValue}
+ inputMaxHeight={inputMaxHeight}
labelledBy={labelledBy}
- options={options}
+ name={name}
placeholder={placeholder}
prefix={prefix}
tabIndex={tabIndex.toString()}
- value={value}
warning={warning}
valid={valid}
- valueLabel={valueLabel}
+ selectedLabel={selectedLabel}
onBlur={onBlur}
- onClear={() => onChange('')}
+ onClear={onClear}
onClick={toggleMenu}
onFocus={onFocus}
onKeyDown={handleKeyDown}
@@ -237,7 +225,7 @@ export function SingleSelectA11y({
{
@@ -275,7 +263,7 @@ export function SingleSelectA11y({
SingleSelectA11y.propTypes = {
/** necessary for IDs that are required for accessibility **/
- idPrefix: PropTypes.string.isRequired,
+ name: PropTypes.string.isRequired,
/** An array of options **/
options: PropTypes.arrayOf(optionProp).isRequired,
@@ -295,10 +283,6 @@ SingleSelectA11y.propTypes = {
/** 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 **/
- customOption: PropTypes.elementType,
-
/** A value for a `data-test` attribute on the root element **/
dataTest: PropTypes.string,
@@ -329,6 +313,9 @@ SingleSelectA11y.propTypes = {
/** Whether the select should display a filter input **/
filterable: PropTypes.bool,
+ /** Max height of the container displaying the selected value **/
+ inputMaxHeight: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
+
/** Should contain the id of the element that labels the select, if applicable **/
labelledBy: PropTypes.string,
@@ -344,43 +331,39 @@ 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),
+ /** Allows to override what's rendered inside the `button[role="option"]`.
+ * Can be overriden on an individual option basis **/
+ optionComponent: PropTypes.elementType,
+
/** 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 **/
+ /** String to show when there's no selected option **/
placeholder: PropTypes.string,
/** String that will be displayed before the label of the selected option **/
prefix: PropTypes.string,
+ selected: PropTypes.shape({
+ label: PropTypes.string.isRequired,
+ value: PropTypes.string.isRequired,
+ }),
+
/** 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,
- /** As of now, this component does not support being uncontrolled **/
- value: PropTypes.string,
-
- /**
- * 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),
-
/** 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 combobox is loses focus **/
+ onClear: PropTypes.func,
+
/** Will be called when the last option is scrolled into the visible area **/
onEndReached: PropTypes.func,
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 20e3134f2..994cd4593 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
@@ -19,6 +19,7 @@ export { EmptyWithEmptyText } from './__stories__/EmptyWithEmptyText.js'
export { EmptyWithEmptyNode } from './__stories__/EmptyWithEmptyNode.js'
export { WithOptionsAndLoading } from './__stories__/WithOptionsAndLoading.js'
export { WithOptionsAndLoadingText } from './__stories__/WithOptionsAndLoadingText.js'
+export { WithoutOptionsAndLoading } from './__stories__/WithoutOptionsAndLoading.js'
export { WithManyOptions } from './__stories__/WithManyOptions.js'
export { WithCustomLowMaxHeight } from './__stories__/WithCustomLowMaxHeight.js'
export { WithOptionsAndDisabled } from './__stories__/WithOptionsAndDisabled.js'
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
index 253224fae..8cf3ff6af 100644
--- a/components/select/src/single-select-a11y/single-select-a11y.test.js
+++ b/components/select/src/single-select-a11y/single-select-a11y.test.js
@@ -32,8 +32,8 @@ describe(' ', () => {
render(
null}
options={[{ value: 'foo', label: 'Foo' }]}
/>
@@ -56,8 +56,8 @@ describe(' ', () => {
render(
null}
options={[{ value: 'foo', label: 'Foo' }]}
/>
@@ -83,8 +83,8 @@ describe(' ', () => {
null}
options={[{ value: 'foo', label: 'Foo' }]}
/>
@@ -97,8 +97,8 @@ describe(' ', () => {
render(
null}
options={[{ value: 'foo', label: 'Foo' }]}
/>
@@ -116,8 +116,8 @@ describe(' ', () => {
null}
options={[{ value: 'foo', label: 'Foo' }]}
/>
@@ -136,8 +136,8 @@ describe(' ', () => {
render(
null}
options={[{ value: 'foo', label: 'Foo' }]}
/>
@@ -153,8 +153,8 @@ describe(' ', () => {
it('should accept a placeholder', () => {
render(
null}
options={[{ value: 'foo', label: 'Foo' }]}
@@ -173,8 +173,8 @@ describe(' ', () => {
render(
null}
options={[{ value: 'foo', label: 'Foo' }]}
/>
@@ -193,8 +193,8 @@ describe(' ', () => {
render(
', () => {
fireEvent.click(option)
expect(onChange).toHaveBeenCalledTimes(1)
- expect(onChange).toHaveBeenCalledWith('foo')
+ expect(onChange).toHaveBeenCalledWith({ value: 'foo', label: 'Foo' })
})
it('should allow custom options to be selected', () => {
@@ -225,9 +225,9 @@ describe(' ', () => {
render(
', () => {
fireEvent.click(screen.getByRole('combobox'))
- const customOption = screen.getByTestId('custom-option-foo')
+ const optionComponent = screen.getByTestId('custom-option-foo')
const option = screen.getByTestId('custom-option-foo').parentNode
expect(option.attributes.getNamedItem('role').value).toBe('option')
- fireEvent.click(customOption)
+ fireEvent.click(optionComponent)
expect(onChange).toHaveBeenCalledTimes(1)
- expect(onChange).toHaveBeenCalledWith('foo')
+ expect(onChange).toHaveBeenCalledWith({ value: 'foo', label: 'Foo' })
})
it('should allow individual custom options to be selected', () => {
@@ -259,8 +259,8 @@ describe(' ', () => {
render(
', () => {
fireEvent.click(screen.getByRole('combobox'))
- const customOption = screen.getByTestId('custom-option-foo')
+ const optionComponent = screen.getByTestId('custom-option-foo')
const option = screen.getByTestId('custom-option-foo').parentNode
expect(option.attributes.getNamedItem('role').value).toBe('option')
- fireEvent.click(customOption)
+ fireEvent.click(optionComponent)
expect(onChange).toHaveBeenCalledTimes(1)
- expect(onChange).toHaveBeenCalledWith('foo')
+ expect(onChange).toHaveBeenCalledWith({ value: 'foo', label: 'Foo' })
})
it('should be clearable when there is a selected value', () => {
- const onChange = jest.fn()
+ const onClear = jest.fn()
render(
null}
+ onClear={onClear}
options={[{ value: 'foo', label: 'Foo' }]}
/>
)
@@ -302,8 +303,7 @@ describe(' ', () => {
fireEvent.click(clearButton)
- expect(onChange).toHaveBeenCalledTimes(1)
- expect(onChange).toHaveBeenCalledWith('')
+ expect(onClear).toHaveBeenCalledTimes(1)
})
it('should not be clearable when there is no selected value', () => {
@@ -311,8 +311,8 @@ describe(' ', () => {
', () => {
render(
@@ -350,8 +350,8 @@ describe(' ', () => {
render(
@@ -374,8 +374,8 @@ describe(' ', () => {
filterable
onFilterChange={onFilterChange}
noMatchText="No options found"
- idPrefix="a11y"
- value="foo"
+ name="a11y"
+ selected={{ value: 'foo', label: 'Foo' }}
onChange={jest.fn()}
options={[{ value: 'foo', label: 'Foo' }]}
/>
@@ -404,8 +404,8 @@ describe(' ', () => {
filterLabel="Custom filter label"
onFilterChange={onFilterChange}
noMatchText="No options found"
- idPrefix="a11y"
- value=""
+ name="a11y"
+ selected={null}
valueLabel=""
onChange={jest.fn()}
options={[
@@ -425,8 +425,8 @@ describe(' ', () => {
render(
', () => {
render(
', () => {
render(
', () => {
render(
', () => {
render(
', () => {
expect(screen.queryByRole('listbox')).toBeNull()
expect(onChange).toHaveBeenCalledTimes(1)
- expect(onChange).toHaveBeenCalledWith('foo')
+ expect(onChange).toHaveBeenCalledWith({
+ value: 'foo',
+ label: 'Foo',
+ })
})
})
@@ -581,8 +584,8 @@ describe(' ', () => {
render(
', () => {
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' })
expect(screen.queryByRole('listbox')).toBeNull()
expect(onChange).toHaveBeenCalledTimes(1)
- expect(onChange).toHaveBeenCalledWith('foo')
+ expect(onChange).toHaveBeenCalledWith({ value: 'foo', label: 'Foo' })
})
it('should not select the next option when closed, disabled and user presses ArrowDown', () => {
@@ -607,8 +610,8 @@ describe(' ', () => {
render(
', () => {
render(
', () => {
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowDown' })
expect(screen.queryByRole('listbox')).toBeNull()
expect(onChange).toHaveBeenCalledTimes(1)
- expect(onChange).toHaveBeenCalledWith('bar')
+ expect(onChange).toHaveBeenCalledWith({ value: 'bar', label: 'Bar' })
})
it('should select the previous option when closed and user presses ArrowUp', () => {
@@ -656,8 +659,8 @@ describe(' ', () => {
render(
', () => {
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowUp' })
expect(screen.queryByRole('listbox')).toBeNull()
expect(onChange).toHaveBeenCalledTimes(1)
- expect(onChange).toHaveBeenCalledWith('foo')
+ expect(onChange).toHaveBeenCalledWith({ value: 'foo', label: 'Foo' })
})
it('should not select the previous option when closed, disabled and user presses ArrowUp', () => {
@@ -682,8 +685,8 @@ describe(' ', () => {
render(
', () => {
render(
', () => {
fireEvent.keyDown(screen.getByRole('combobox'), { key: 'ArrowUp' })
expect(screen.queryByRole('listbox')).toBeNull()
expect(onChange).toHaveBeenCalledTimes(1)
- expect(onChange).toHaveBeenCalledWith('')
+ expect(onChange).toHaveBeenCalledWith({ value: '', label: 'None' })
})
it('should highlight the next option', () => {
@@ -731,8 +734,8 @@ describe(' ', () => {
render(
', () => {
render(
', () => {
render(
', () => {
render(
', () => {
render(
', () => {
render(
', () => {
render(
', () => {
render(
{
const listBoxParent = listBoxRef.current.parentNode
const optionElements = Array.from(listBoxRef.current.childNodes)
- const visibleOptionsAmount = optionElements.filter(
- (optionElement) => !isOptionHidden(optionElement, listBoxParent)
- ).length
+ const visibleOptionsAmount = Math.floor(
+ listBoxParent.offsetHeight / optionElements[0].offsetHeight
+ )
const nextTopOptionIndex = Math.max(
0,
diff --git a/components/select/src/single-select-a11y/use-handle-key-press/use-highlight-last-option-on-next-page.js b/components/select/src/single-select-a11y/use-handle-key-press/use-highlight-last-option-on-next-page.js
index 8bc418aff..4b707c784 100644
--- a/components/select/src/single-select-a11y/use-handle-key-press/use-highlight-last-option-on-next-page.js
+++ b/components/select/src/single-select-a11y/use-handle-key-press/use-highlight-last-option-on-next-page.js
@@ -1,5 +1,4 @@
import { useCallback } from 'react'
-import { isOptionHidden } from '../is-option-hidden.js'
export function useHighlightLastOptionOnNextPage({
options,
@@ -10,9 +9,9 @@ export function useHighlightLastOptionOnNextPage({
return useCallback(() => {
const listBoxParent = listBoxRef.current.parentNode
const optionElements = Array.from(listBoxRef.current.childNodes)
- const visibleOptionsAmount = options.filter(
- (_, index) => !isOptionHidden(optionElements[index], listBoxParent)
- ).length
+ const visibleOptionsAmount = Math.floor(
+ listBoxParent.offsetHeight / optionElements[0].offsetHeight
+ )
const nextHighlightedOptionIndex = Math.min(
options.length - 1,
diff --git a/docs/docs/recipes/single-select-a11y-server-side-filtering.js b/docs/docs/recipes/single-select-a11y-server-side-filtering.js
index ad6069dee..7610b8416 100644
--- a/docs/docs/recipes/single-select-a11y-server-side-filtering.js
+++ b/docs/docs/recipes/single-select-a11y-server-side-filtering.js
@@ -83,7 +83,7 @@ function useLoadFilteredDataElementsQuery(customOptions) {
}, 200)
})
},
- [abortController, engine]
+ [abortController, engine, customOptions]
)
return { loading, error, refetch }
@@ -94,11 +94,8 @@ function ServerSideFilteringSelect() {
useState(false)
const [selectedOption, setSelectedOption] = useState({
value: 'fbfJHSPpUQD',
- label: '',
+ label: 'Loading...',
})
- const valueLabel = initializedSelectedLabel
- ? selectedOption.label
- : 'Loading...'
const [loadedOptions, setLoadedOptions] = useState([])
const [defaultPager, setDefaultPager] = useState({
@@ -169,17 +166,6 @@ function ServerSideFilteringSelect() {
loadDataElementsQuery.loading || loadFilteredDataElementsQuery.loading
const options = searchTerm ? filteredOptions : loadedOptions
- const selectOption = useCallback(
- (nextValue) => {
- const nextSelectedOption = options.find(
- ({ value }) => value === nextValue
- )
-
- setSelectedOption(nextSelectedOption)
- },
- [options]
- )
-
const setSearchTerm = useCallback(
(nextSearchTerm) => {
_setSearchTerm(nextSearchTerm)
@@ -233,19 +219,18 @@ function ServerSideFilteringSelect() {
return (
)
diff --git a/docs/docs/recipes/single-select-a11y-server-side-filtering.md b/docs/docs/recipes/single-select-a11y-server-side-filtering.md
index f7d4511e5..75b1e6dad 100644
--- a/docs/docs/recipes/single-select-a11y-server-side-filtering.md
+++ b/docs/docs/recipes/single-select-a11y-server-side-filtering.md
@@ -46,12 +46,13 @@ As this is a demo, we simply define the selected value statically:
```js
const [selectedOption, setSelectedOption] = useState({
value: 'fbfJHSPpUQD', // This value should come from the props
- label: '',
+ label: 'Loading...',
})
```
The reason we're storing the object rather than just the selected value is:
-When the user searches for options while having already selected an option,
+The SingleSelectA11y component expects the whole option as selected value, so
+that when the user searches for options while having already selected an option,
we could lose the label we want to display. This can happen when the user
searches for an option, then selects an option from the search result,
and then searches the options again.
@@ -65,15 +66,6 @@ before the initially selected option's label hasn't been fetched yet).
const [initializedSelectedLabel, setInitializedSelectedLabel] = useState(false)
```
-This allows us to define the label of the selected option even when we don't
-have a value yet:
-
-```js
-const valueLabel = initializedSelectedLabel
- ? selectedOption?.label
- : 'Loading...'
-```
-
With this, we can create component that returns a select component:
```jsx
@@ -82,19 +74,15 @@ function OurSelectComponent() {
useState(false)
const [selectedOption, setSelectedOption] = useState({
value: 'fbfJHSPpUQD', // This value should come from the props
- label: '',
+ label: 'Loading...',
})
- const valueLabel = initializedSelectedLabel
- ? selectedOption.label
- : 'Loading'
return (
null} // @TODO
+ selected={selectedOption}
+ onChange={setSelectedOption}
options={[]} // @TODO
/>
)
@@ -182,11 +170,10 @@ const [loadedOptions, setLoadedOptions] = useState([])
return (
null} // @TODO
+ selected={selectedOption}
+ onChange={setSelectedOption}
options={loadedOptions} // <--
/>
)}
@@ -261,11 +248,10 @@ With the logic that loads the options, we can set the select's loading state:
```jsx
null} // @TODO
options={loadedOptions}
/>
@@ -392,7 +378,7 @@ function useLoadFilteredDataElementsQuery(customOptions) {
}, 200)
})
},
- [abortController, engine]
+ [abortController, engine, customOptions]
)
return { loading, error, refetch }
@@ -453,21 +439,6 @@ The options we want to render now depend on whether we have a filter value or no
const options = searchTerm ? filteredOptions : loadedOptions
```
-With this value, we can finally create the callback passed to `onChange`:
-
-```js
-const selectOption = useCallback(
- (nextValue) => {
- const nextSelectedOption = options.find(
- ({ value }) => value === nextValue
- )
-
- setSelectedOption(nextSelectedOption)
- },
- [options]
-)
-```
-
Because there are two different loading states, we can combine them into a single value, which we'll pass to the SingleSelectA11y component, as well as the props required for the search to work:
```
@@ -514,18 +485,17 @@ const loadNextPage = useCallback(() => {
return (
)
@@ -620,7 +590,7 @@ function useLoadFilteredDataElementsQuery(customOptions) {
}, 200)
})
},
- [abortController, engine]
+ [abortController, engine, customOptions]
)
return { loading, error, refetch }
@@ -771,7 +741,7 @@ function OurSelectComponent() {
return (
{
// Handle actual value changes
- const [value, setValue] = useState('')
+ const [selected, setSelected] = useState({
+ value: '',
+ label: 'No selection',
+ })
// Handle filtering
const [searchTerm, setSearchTerm] = useState('')
@@ -32,22 +36,14 @@ export const SimpleFilterSelect = () => {
[searchTerm]
)
- // We have to default to an empty string in case there is no selection
- // and the options array does not include an "empty"-option,
- // e.g. `{ value: '', label: 'None' }`
- const valueLabel =
- options.find((option) => option.value === value)?.label ||
- 'No selection'
-
return (
)
diff --git a/docs/docs/recipes/single-select-a11y-simple-filtering.md b/docs/docs/recipes/single-select-a11y-simple-filtering.md
index ee68de18c..c2602a413 100644
--- a/docs/docs/recipes/single-select-a11y-simple-filtering.md
+++ b/docs/docs/recipes/single-select-a11y-simple-filtering.md
@@ -27,6 +27,7 @@ Consider the following options:
```js
const options = [
+ { label: 'No selection', value: '' },
{ label: 'ANC 1st visit', value: 'anc_1st_visit' },
{ label: 'ANC 2nd visit', value: 'anc_2nd_visit' },
{ label: 'ARI treated follow-up', value: 'ari_treated_follow-up' },
@@ -69,19 +70,6 @@ const filteredOptions = useMemo(
)
```
-Normally the select component will extract the label of a selected option
-from the options array so it can display the currently selected option.
-When filtering, that option might not be present if the filtered array,
-so we'll have to make sure we can tell the select component what the label is:
-
-```js
-// We have to default to an empty string in case there is no selection
-// and the options array does not include an "empty"-option,
-// e.g. `{ value: '', label: 'None' }`
-const valueLabel =
- options.find((option) => option.value === value)?.label || 'No selection'
-```
-
Now all necessary parts are available to create a simple searchable select:
```jsx
@@ -110,7 +98,10 @@ function filterOptions(options, searchTerm) {
export const SimpleFilterSelect = () => {
// Handle actual value changes
- const [value, setValue] = useState('')
+ const [selected, setSelected] = useState({
+ value: '',
+ label: 'No selection',
+ })
// Handle filtering
const [searchTerm, setSearchTerm] = useState('')
@@ -119,22 +110,14 @@ export const SimpleFilterSelect = () => {
[options, searchTerm]
)
- // We have to default to an empty string in case there is no selection
- // and the options array does not include an "empty"-option,
- // e.g. `{ value: '', label: 'None' }`
- const valueLabel =
- options.find((option) => option.value === value)?.label ||
- 'No selection'
-
return (
)