From a4b5d08bdc2ef507e69be04fc6705042f12a94c3 Mon Sep 17 00:00:00 2001 From: Mohammer5 Date: Fri, 18 Oct 2024 10:24:09 +0800 Subject: [PATCH] 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({ )} -
+
+ ) 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) {