From 67ee3a99187e95a329be8f13b15102681aa0324e Mon Sep 17 00:00:00 2001 From: Flaminia Cavallo Date: Thu, 18 Jul 2024 05:41:36 -0700 Subject: [PATCH] feat: add keyword navigability to transfer, happy path --- components/transfer/src/options-container.js | 35 ++++--- .../transfer/src/use-options-navigation.js | 99 +++++++++++++++++++ 2 files changed, 123 insertions(+), 11 deletions(-) create mode 100644 components/transfer/src/use-options-navigation.js diff --git a/components/transfer/src/options-container.js b/components/transfer/src/options-container.js index ce3c60a55e..cd53a1fae2 100644 --- a/components/transfer/src/options-container.js +++ b/components/transfer/src/options-container.js @@ -1,8 +1,9 @@ import { CircularLoader } from '@dhis2-ui/loader' import { spacers } from '@dhis2/ui-constants' import PropTypes from 'prop-types' -import React, { Fragment, useRef } from 'react' +import React, { useRef } from 'react' import { EndIntersectionDetector } from './end-intersection-detector.js' +import { useOptionsNavigation } from './use-options-navigation.js' import { useResizeCounter } from './use-resize-counter.js' export const OptionsContainer = ({ @@ -19,7 +20,9 @@ export const OptionsContainer = ({ toggleHighlightedOption, }) => { const optionsRef = useRef(null) - const wrapperRef = useRef(null) + const { wrapperRef, focusedOptionIndex, onOptionFocusedHandler } = + useOptionsNavigation(options) + const resizeCounter = useResizeCounter(wrapperRef.current) return ( @@ -29,29 +32,39 @@ export const OptionsContainer = ({ )} -
{!options.length && emptyComponent} - {options.map((option) => { + {options.map((option, index) => { const highlighted = !!highlightedOptions.find( (highlightedSourceOption) => highlightedSourceOption === option.value ) + const optionsClickHandlers = getOptionClickHandlers( + option, + selectionHandler, + toggleHighlightedOption + ) + + const isFocused = + focusedOptionIndex !== null + ? index === focusedOptionIndex + : index === 0 + return ( - + onOptionFocusedHandler(index)} + > {renderOption({ ...option, - ...getOptionClickHandlers( - option, - selectionHandler, - toggleHighlightedOption - ), + ...optionsClickHandlers, highlighted, selected, })} - + ) })} diff --git a/components/transfer/src/use-options-navigation.js b/components/transfer/src/use-options-navigation.js new file mode 100644 index 0000000000..da1ecbeb72 --- /dev/null +++ b/components/transfer/src/use-options-navigation.js @@ -0,0 +1,99 @@ +import { useEffect, useRef, useState } from 'react' + +export const useOptionsNavigation = (options) => { + const wrapperRef = useRef(null) + + const [focusedOptionIndex, setFocusedOptionIndex] = useState(null) + + const handleKeyDown = (event) => { + switch (event.key) { + case 'ArrowUp': + event.preventDefault() + const newUpFocusIndex = + focusedOptionIndex > 0 + ? focusedOptionIndex - 1 + : options.length - 1 + setFocusedOptionIndex(newUpFocusIndex) + if (event.shiftKey) { + // TODO: this is very ugly! + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + shiftKey: true, + }) + wrapperRef.current.children[ + newUpFocusIndex + ].children[0].dispatchEvent(clickEvent) + } + + break + case 'ArrowDown': + event.preventDefault() + const newDownFocusIndex = + focusedOptionIndex >= options.length - 1 + ? 0 + : focusedOptionIndex + 1 + setFocusedOptionIndex(newDownFocusIndex) + if (event.shiftKey) { + // TODO: this is very ugly! + const clickEvent = new MouseEvent('click', { + bubbles: true, + cancelable: true, + shiftKey: true, + }) + wrapperRef.current.children[ + newDownFocusIndex + ].children[0].dispatchEvent(clickEvent) + } + break + case ' ': + event.preventDefault() + if (wrapperRef && wrapperRef.current.children.length > 0) { + // TODO: this is ugly! + wrapperRef.current.children[ + focusedOptionIndex + ].children[0].click() + } + break + default: + break + } + } + + const onOptionFocusedHandler = (optionIndex) => { + setFocusedOptionIndex(optionIndex) + } + + useEffect(() => { + if (wrapperRef && wrapperRef.current.children.length > 0) { + if ( + focusedOptionIndex !== null && + wrapperRef.current.children[focusedOptionIndex] + ) { + wrapperRef.current.children[focusedOptionIndex]?.focus() + } + } + }, [wrapperRef, focusedOptionIndex, options]) + + useEffect(() => { + if (options && focusedOptionIndex >= options.length) { + setFocusedOptionIndex(null) + } + }, [options]) + + useEffect(() => { + if (wrapperRef?.current) { + wrapperRef.current.addEventListener('keydown', handleKeyDown) + + return () => { + wrapperRef.current.removeEventListener('keydown', handleKeyDown) + } + } + }, [handleKeyDown, wrapperRef]) + + return { + wrapperRef, + onOptionFocusedHandler, + focusedOptionIndex, + } +}