Skip to content

Commit

Permalink
feat(select a11y): scroll highlighted option into view
Browse files Browse the repository at this point in the history
  • Loading branch information
Mohammer5 committed Oct 18, 2024
1 parent 4b9af2c commit a4b5d08
Show file tree
Hide file tree
Showing 8 changed files with 147 additions and 76 deletions.
13 changes: 13 additions & 0 deletions components/select/src/single-select-a11y/is-option-hidden.js
Original file line number Diff line number Diff line change
@@ -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
)
}
34 changes: 34 additions & 0 deletions components/select/src/single-select-a11y/menu-loading.js
Original file line number Diff line number Diff line change
@@ -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 (
<div className="container">
<div>
<CircularLoader small />
</div>

{message}

<style jsx>{`
.container {
display: flex;
gap: ${spacers.dp16};
align-items: center;
justify-content: center;
color: ${colors.grey700};
font-family: ${theme.fonts};
font-size: 13px;
padding-block: ${spacers.dp8};
padding-inline: ${spacers.dp24};
}
`}</style>
</div>
)
}

MenuLoading.propTypes = {
message: PropTypes.string,
}
76 changes: 29 additions & 47 deletions components/select/src/single-select-a11y/menu-options-list.js
Original file line number Diff line number Diff line change
@@ -1,58 +1,51 @@
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 (
<div className="container">
<div>
<CircularLoader small />
</div>

{message}

<style jsx>{`
.container {
display: flex;
gap: ${spacers.dp16};
align-items: center;
justify-content: center;
color: ${colors.grey700};
font-family: ${theme.fonts};
font-size: 13px;
padding-block: ${spacers.dp8};
padding-inline: ${spacers.dp24};
}
`}</style>
</div>
)
}

Loading.propTypes = {
message: PropTypes.string,
}

export function MenuOptionsList({
comboBoxId,
expanded,
focussedOptionIndex,
idPrefix,
labelledBy,
options,
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 (
<div
ref={listBoxRef}
role="listbox"
id={`${idPrefix}-listbox`}
aria-labelledby={labelledBy}
Expand All @@ -62,8 +55,6 @@ export function MenuOptionsList({
onBlur={onBlur}
onKeyDown={onKeyDown}
>
{!options.length && empty}

{options.map(
(
{
Expand Down Expand Up @@ -91,30 +82,21 @@ export function MenuOptionsList({
)
}
)}

{loading && <Loading message={loadingText} />}

<style jsx>{`
div:empty {
height: 16px;
}
`}</style>
</div>
)
}

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,
Expand Down
14 changes: 10 additions & 4 deletions components/select/src/single-select-a11y/menu.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -62,24 +63,25 @@ export function Menu({
/>
)}

{!options.length && <div className="empty-container">{empty}</div>}

<MenuOptionsList
comboBoxId={comboBoxId}
dataTest={`${dataTestPrefix}-list`}
disabled={disabled}
empty={empty}
expanded={!hidden}
focussedOptionIndex={focussedOptionIndex}
idPrefix={idPrefix}
labelledBy={labelledBy}
options={options}
loading={loading}
loadingText={loadingText}
options={options}
selected={selected}
onChange={onChange}
onBlur={onBlur}
onKeyDown={onKeyDown}
/>

{/* Put (infinite) loading stuff here */ ''}
{loading && <MenuLoading message={loadingText} />}

<style jsx>{`
.listbox-container {
Expand All @@ -94,6 +96,10 @@ export function Menu({
.hidden {
display: none;
}
.empty-container {
height: 16px;
}
`}</style>
</div>
)
Expand Down
19 changes: 17 additions & 2 deletions components/select/src/single-select-a11y/selected-value.js
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,17 @@ export function SelectedValue({
</div>
)}

<div className="root-right">
<button
className="toggle-icon"
aria-label="Open select"
onClick={(e) => {
e.stopPropagation()
comboBoxRef.current.focus()
onClick()
}}
>
<IconChevronDown16 />
</div>
</button>

<style jsx>{`
.selected-option-label {
Expand All @@ -99,6 +107,13 @@ export function SelectedValue({
.clear-button-container {
margin-inline-start: auto;
}
.toggle-icon {
display: block;
background: none;
padding: 0;
border: 0;
}
`}</style>
</SelectedValueContainer>
)
Expand Down
17 changes: 11 additions & 6 deletions components/select/src/single-select-a11y/single-select-a11y.js
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -427,7 +427,7 @@ export const WithOptionsAndLoadingText = () => {
}

export const WithManyOptions = () => {
const [value, setValue] = useState('')
const [value, setValue] = useState('art_entry_point:_no_pmtct')

return (
<SingleSelectA11y
Expand Down
48 changes: 32 additions & 16 deletions components/select/src/single-select-a11y/use-handle-key-press.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,10 @@ import { useCallback, useEffect, useRef, useState } from 'react'
const TYPING_DEBOUNCE_TIME = 300 // ms

function useHandleTyping({
expanded,
options,
setFocussedOptionIndex,

// @TODO: Scroll to highlighted option when not/partially visible
// eslint-disable-next-line no-unused-vars
listboxHTMLElement,
onChange,
}) {
const timeoutRef = useRef()
const [value, setValue] = useState('')
Expand All @@ -34,17 +32,26 @@ function useHandleTyping({
value,
])

const prevValueRef = useRef()
useEffect(() => {
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
Expand Down Expand Up @@ -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) {
Expand Down

0 comments on commit a4b5d08

Please sign in to comment.