Make combobox keyboard accessible

This commit is contained in:
Justin Edmund 2023-06-16 06:20:19 -07:00
parent 10e53b2b83
commit 044d7bebee
5 changed files with 183 additions and 10 deletions

View file

@ -6,11 +6,20 @@ interface Props {
groupName: string groupName: string
name: string name: string
selected: boolean selected: boolean
tabIndex?: number
children: string children: string
onClick: (event: React.ChangeEvent<HTMLInputElement>) => void onClick: (event: React.ChangeEvent<HTMLInputElement>) => void
} }
const Segment: React.FC<Props> = (props: Props) => { const Segment: React.FC<Props> = (props: Props) => {
// Selects the segment when the user presses the spacebar
const handleKeyDown = (event: React.KeyboardEvent<HTMLLabelElement>) => {
if (event.key === ' ') {
event.preventDefault()
event.currentTarget.click()
}
}
return ( return (
<div className="Segment"> <div className="Segment">
<input <input
@ -21,7 +30,13 @@ const Segment: React.FC<Props> = (props: Props) => {
checked={props.selected} checked={props.selected}
onChange={props.onClick} onChange={props.onClick}
/> />
<label htmlFor={props.name}>{props.children}</label> <label
htmlFor={props.name}
tabIndex={props.tabIndex}
onKeyDown={handleKeyDown}
>
{props.children}
</label>
</div> </div>
) )
} }

View file

@ -6,12 +6,14 @@ interface Props {
className?: string className?: string
elementClass?: string elementClass?: string
blended?: boolean blended?: boolean
tabIndex?: number
} }
const SegmentedControl: React.FC<Props> = ({ const SegmentedControl: React.FC<Props> = ({
className, className,
elementClass, elementClass,
blended, blended,
tabIndex,
children, children,
}) => { }) => {
const classes = classNames( const classes = classNames(
@ -23,7 +25,7 @@ const SegmentedControl: React.FC<Props> = ({
elementClass elementClass
) )
return ( return (
<div className="SegmentedControlWrapper"> <div className="SegmentedControlWrapper" tabIndex={tabIndex}>
<div className={classes}>{children}</div> <div className={classes}>{children}</div>
</div> </div>
) )

View file

@ -8,7 +8,8 @@
font-size: $font-regular; font-size: $font-regular;
padding: ($unit * 1.5) $unit-2x; padding: ($unit * 1.5) $unit-2x;
&:hover { &:hover,
&:focus {
background-color: var(--option-bg-hover); background-color: var(--option-bg-hover);
color: var(--text-primary); color: var(--text-primary);
cursor: pointer; cursor: pointer;

View file

@ -1,4 +1,4 @@
import { createRef, useCallback, useEffect, useState } from 'react' import { createRef, useCallback, useEffect, useState, useRef } from 'react'
import { useRouter } from 'next/router' import { useRouter } from 'next/router'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import classNames from 'classnames' import classNames from 'classnames'
@ -16,7 +16,6 @@ import { appState } from '~utils/appState'
interface Props { interface Props {
showAllRaidsOption: boolean showAllRaidsOption: boolean
currentRaid?: Raid currentRaid?: Raid
currentRaidSlug?: string
defaultRaid?: Raid defaultRaid?: Raid
minimal?: boolean minimal?: boolean
onChange?: (raid?: Raid) => void onChange?: (raid?: Raid) => void
@ -30,6 +29,7 @@ import CrossIcon from '~public/icons/Cross.svg'
import './index.scss' import './index.scss'
const NUM_SECTIONS = 3 const NUM_SECTIONS = 3
const NUM_ELEMENTS = 5
enum Sort { enum Sort {
ASCENDING, ASCENDING,
@ -54,9 +54,12 @@ const RaidCombobox = (props: Props) => {
const [query, setQuery] = useState('') const [query, setQuery] = useState('')
const [sections, setSections] = useState<RaidGroup[][]>() const [sections, setSections] = useState<RaidGroup[][]>()
const [currentRaid, setCurrentRaid] = useState<Raid>() const [currentRaid, setCurrentRaid] = useState<Raid>()
const [tabIndex, setTabIndex] = useState(NUM_ELEMENTS + 1)
// Refs // Refs
const listRef = createRef<HTMLDivElement>() const listRef = createRef<HTMLDivElement>()
const inputRef = createRef<HTMLInputElement>()
const sortButtonRef = createRef<HTMLButtonElement>()
// ---------------------------------------------- // ----------------------------------------------
// Methods: Lifecycle Hooks // Methods: Lifecycle Hooks
@ -75,12 +78,13 @@ const RaidCombobox = (props: Props) => {
} }
}, []) }, [])
// Update current raid when the currentRaidSlug prop changes // Set current raid and section when the current raid changes
useEffect(() => { useEffect(() => {
if (props.currentRaidSlug) { if (props.currentRaid) {
setCurrentRaid(slugToRaid(props.currentRaidSlug)) setCurrentRaid(props.currentRaid)
setCurrentSection(props.currentRaid.group.section)
} }
}) }, [props.currentRaid])
// Scroll to the top of the list when the user switches tabs // Scroll to the top of the list when the user switches tabs
useEffect(() => { useEffect(() => {
@ -89,10 +93,70 @@ const RaidCombobox = (props: Props) => {
} }
}, [currentSection]) }, [currentSection])
useEffect(() => {
setTabIndex(NUM_ELEMENTS + 1)
}, [currentSection])
// ---------------------------------------------- // ----------------------------------------------
// Methods: Event Handlers // Methods: Event Handlers
// ---------------------------------------------- // ----------------------------------------------
// Handle Escape key press event
const handleEscapeKeyPressed = useCallback(() => {
if (listRef.current) {
listRef.current.focus()
}
}, [open, currentRaid, sortButtonRef])
// Handle Arrow key press event by focusing the list item above or below the current one based on the direction
const handleArrowKeyPressed = useCallback(
(direction: 'Up' | 'Down') => {
if (!listRef.current) return
// Get the currently focused item
const current = listRef.current.querySelector('.Raid:focus')
// Select the item above or below based on direction
if (current) {
// If there is no item below, select the next parent element and then select the first element in that group
if (direction === 'Down' && !current.nextElementSibling) {
const nextParent =
current.parentElement?.parentElement?.nextElementSibling
if (nextParent) {
const next = nextParent.querySelector('.Raid')
if (next) {
;(next as HTMLElement).focus()
}
}
}
// If there is no item above, select the previous parent element and then select the first element in that group
if (direction === 'Up' && !current.previousElementSibling) {
const previousParent =
current.parentElement?.parentElement?.previousElementSibling
if (previousParent) {
const next = previousParent.querySelector('.Raid:last-child')
if (next) {
;(next as HTMLElement).focus()
}
}
}
}
// Select the item above or below based on direction
if (current) {
const next =
direction === 'Up'
? current.previousElementSibling
: current.nextElementSibling
if (next) {
;(next as HTMLElement).focus()
}
}
},
[open, currentRaid, listRef]
)
// Scroll to an item in the list when it is selected // Scroll to an item in the list when it is selected
const scrollToItem = useCallback( const scrollToItem = useCallback(
(node) => { (node) => {
@ -101,6 +165,8 @@ const RaidCombobox = (props: Props) => {
const { top: itemTop } = node.getBoundingClientRect() const { top: itemTop } = node.getBoundingClientRect()
listRef.current.scrollTop = itemTop - listTop listRef.current.scrollTop = itemTop - listTop
console.log('Focusing node')
node.focus()
setScrolled(true) setScrolled(true)
} }
}, },
@ -127,6 +193,42 @@ const RaidCombobox = (props: Props) => {
[setSections] [setSections]
) )
const handleSortButtonKeyDown = (
event: React.KeyboardEvent<HTMLButtonElement>
) => {
// If the tab key is pressed without the Shift key, focus the raid list
if (event.key === 'Tab' && !event.shiftKey) {
if (listRef.current) {
listRef.current.focus()
}
}
}
const handleListKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Tab' && !event.shiftKey) {
event.preventDefault()
if (inputRef.current) {
inputRef.current.focus()
}
} else if (event.key === 'Tab' && event.shiftKey) {
event.preventDefault()
if (sortButtonRef.current) {
sortButtonRef.current.focus()
}
}
// If the enter key is pressed, focus the first raid item in the list
else if (event.key === 'Enter') {
event.preventDefault()
if (listRef.current) {
const raid = listRef.current.querySelector('.Raid')
if (raid) {
;(raid as HTMLElement).focus()
}
}
}
}
// Handle value change for the raid selection // Handle value change for the raid selection
function handleValueChange(raid: Raid) { function handleValueChange(raid: Raid) {
setCurrentRaid(raid) setCurrentRaid(raid)
@ -219,6 +321,13 @@ const RaidCombobox = (props: Props) => {
const isRef = isSelected ? scrollToItem : undefined const isRef = isSelected ? scrollToItem : undefined
const imageUrl = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/raids/${raid.slug}.png` const imageUrl = `${process.env.NEXT_PUBLIC_SIERO_IMG_URL}/raids/${raid.slug}.png`
const sectionIndex = sections?.[currentSection - 1]?.findIndex((group) =>
group.raids.some((r) => r.id === raid.id)
)
const raidTabIndex =
sectionIndex !== undefined ? tabIndex + sectionIndex : -1
return ( return (
<RaidItem <RaidItem
className={isSelected ? 'Selected' : ''} className={isSelected ? 'Selected' : ''}
@ -227,7 +336,10 @@ const RaidCombobox = (props: Props) => {
key={key} key={key}
selected={isSelected} selected={isSelected}
ref={isRef} ref={isRef}
tabIndex={0}
value={raid.slug} value={raid.slug}
onEscapeKeyPressed={handleEscapeKeyPressed}
onArrowKeyPressed={handleArrowKeyPressed}
onSelect={() => handleValueChange(raid)} onSelect={() => handleValueChange(raid)}
> >
{raid.name[locale]} {raid.name[locale]}
@ -243,6 +355,7 @@ const RaidCombobox = (props: Props) => {
groupName="raid_section" groupName="raid_section"
name="events" name="events"
selected={currentSection === 2} selected={currentSection === 2}
tabIndex={2}
onClick={() => setCurrentSection(2)} onClick={() => setCurrentSection(2)}
> >
{t('raids.sections.events')} {t('raids.sections.events')}
@ -251,6 +364,7 @@ const RaidCombobox = (props: Props) => {
groupName="raid_section" groupName="raid_section"
name="raids" name="raids"
selected={currentSection === 1} selected={currentSection === 1}
tabIndex={3}
onClick={() => setCurrentSection(1)} onClick={() => setCurrentSection(1)}
> >
{t('raids.sections.raids')} {t('raids.sections.raids')}
@ -259,6 +373,7 @@ const RaidCombobox = (props: Props) => {
groupName="raid_section" groupName="raid_section"
name="solo" name="solo"
selected={currentSection === 3} selected={currentSection === 3}
tabIndex={4}
onClick={() => setCurrentSection(3)} onClick={() => setCurrentSection(3)}
> >
{t('raids.sections.solo')} {t('raids.sections.solo')}
@ -284,6 +399,9 @@ const RaidCombobox = (props: Props) => {
leftAccessoryIcon={<ArrowIcon />} leftAccessoryIcon={<ArrowIcon />}
leftAccessoryClassName={sort === Sort.DESCENDING ? 'Flipped' : ''} leftAccessoryClassName={sort === Sort.DESCENDING ? 'Flipped' : ''}
onClick={reverseSort} onClick={reverseSort}
onKeyDown={handleSortButtonKeyDown}
ref={sortButtonRef}
tabIndex={5}
/> />
</Tooltip> </Tooltip>
) )
@ -294,7 +412,7 @@ const RaidCombobox = (props: Props) => {
if (currentRaid) { if (currentRaid) {
const element = ( const element = (
<> <>
{!props.minimal && ( {!props.minimal ? (
<div className="Info"> <div className="Info">
<span className="Group">{currentRaid.group.name[locale]}</span> <span className="Group">{currentRaid.group.name[locale]}</span>
<span className="Separator">/</span> <span className="Separator">/</span>
@ -302,6 +420,10 @@ const RaidCombobox = (props: Props) => {
{currentRaid.name[locale]} {currentRaid.name[locale]}
</span> </span>
</div> </div>
) : (
<span className={classNames({ Raid: true }, linkClass)}>
{currentRaid.name[locale]}
</span>
)} )}
{currentRaid.group.extra && !props.minimal && ( {currentRaid.group.extra && !props.minimal && (
@ -326,6 +448,8 @@ const RaidCombobox = (props: Props) => {
<CommandInput <CommandInput
className="Input" className="Input"
placeholder={t('search.placeholders.raid')} placeholder={t('search.placeholders.raid')}
tabIndex={1}
ref={inputRef}
value={query} value={query}
onValueChange={setQuery} onValueChange={setQuery}
/> />
@ -387,6 +511,9 @@ const RaidCombobox = (props: Props) => {
<div <div
className={classNames({ Raids: true, Searching: query !== '' })} className={classNames({ Raids: true, Searching: query !== '' })}
ref={listRef} ref={listRef}
role="listbox"
tabIndex={6}
onKeyDown={handleListKeyDown}
> >
{renderRaidSections()} {renderRaidSections()}
</div> </div>

View file

@ -12,8 +12,11 @@ interface Props {
} }
extra: boolean extra: boolean
selected: boolean selected: boolean
tabIndex?: number
value: string | number value: string | number
onSelect: () => void onSelect: () => void
onArrowKeyPressed?: (direction: 'Up' | 'Down') => void
onEscapeKeyPressed?: () => void
} }
const RaidItem = React.forwardRef<HTMLDivElement, PropsWithChildren<Props>>( const RaidItem = React.forwardRef<HTMLDivElement, PropsWithChildren<Props>>(
function Item( function Item(
@ -22,7 +25,10 @@ const RaidItem = React.forwardRef<HTMLDivElement, PropsWithChildren<Props>>(
value, value,
extra, extra,
selected, selected,
tabIndex,
children, children,
onEscapeKeyPressed,
onArrowKeyPressed,
...props ...props
}: PropsWithChildren<Props>, }: PropsWithChildren<Props>,
forwardedRef forwardedRef
@ -32,12 +38,34 @@ const RaidItem = React.forwardRef<HTMLDivElement, PropsWithChildren<Props>>(
props.className props.className
) )
const handleKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === 'Escape' && onEscapeKeyPressed) {
event.preventDefault()
onEscapeKeyPressed()
}
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
event.preventDefault()
if (onArrowKeyPressed) {
console.log(event.key)
onArrowKeyPressed(event.key === 'ArrowUp' ? 'Up' : 'Down')
}
}
if (event.key === 'Enter') {
event.preventDefault()
props.onSelect()
}
}
return ( return (
<CommandItem <CommandItem
{...props} {...props}
className={classes} className={classes}
tabIndex={tabIndex}
value={`${value}`} value={`${value}`}
onClick={props.onSelect} onClick={props.onSelect}
onKeyDown={handleKeyDown}
ref={forwardedRef} ref={forwardedRef}
> >
{icon ? <img alt={icon.alt} src={icon.src} /> : ''} {icon ? <img alt={icon.alt} src={icon.src} /> : ''}