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
name: string
selected: boolean
tabIndex?: number
children: string
onClick: (event: React.ChangeEvent<HTMLInputElement>) => void
}
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 (
<div className="Segment">
<input
@ -21,7 +30,13 @@ const Segment: React.FC<Props> = (props: Props) => {
checked={props.selected}
onChange={props.onClick}
/>
<label htmlFor={props.name}>{props.children}</label>
<label
htmlFor={props.name}
tabIndex={props.tabIndex}
onKeyDown={handleKeyDown}
>
{props.children}
</label>
</div>
)
}

View file

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

View file

@ -8,7 +8,8 @@
font-size: $font-regular;
padding: ($unit * 1.5) $unit-2x;
&:hover {
&:hover,
&:focus {
background-color: var(--option-bg-hover);
color: var(--text-primary);
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 { useTranslation } from 'react-i18next'
import classNames from 'classnames'
@ -16,7 +16,6 @@ import { appState } from '~utils/appState'
interface Props {
showAllRaidsOption: boolean
currentRaid?: Raid
currentRaidSlug?: string
defaultRaid?: Raid
minimal?: boolean
onChange?: (raid?: Raid) => void
@ -30,6 +29,7 @@ import CrossIcon from '~public/icons/Cross.svg'
import './index.scss'
const NUM_SECTIONS = 3
const NUM_ELEMENTS = 5
enum Sort {
ASCENDING,
@ -54,9 +54,12 @@ const RaidCombobox = (props: Props) => {
const [query, setQuery] = useState('')
const [sections, setSections] = useState<RaidGroup[][]>()
const [currentRaid, setCurrentRaid] = useState<Raid>()
const [tabIndex, setTabIndex] = useState(NUM_ELEMENTS + 1)
// Refs
const listRef = createRef<HTMLDivElement>()
const inputRef = createRef<HTMLInputElement>()
const sortButtonRef = createRef<HTMLButtonElement>()
// ----------------------------------------------
// 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(() => {
if (props.currentRaidSlug) {
setCurrentRaid(slugToRaid(props.currentRaidSlug))
if (props.currentRaid) {
setCurrentRaid(props.currentRaid)
setCurrentSection(props.currentRaid.group.section)
}
})
}, [props.currentRaid])
// Scroll to the top of the list when the user switches tabs
useEffect(() => {
@ -89,10 +93,70 @@ const RaidCombobox = (props: Props) => {
}
}, [currentSection])
useEffect(() => {
setTabIndex(NUM_ELEMENTS + 1)
}, [currentSection])
// ----------------------------------------------
// 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
const scrollToItem = useCallback(
(node) => {
@ -101,6 +165,8 @@ const RaidCombobox = (props: Props) => {
const { top: itemTop } = node.getBoundingClientRect()
listRef.current.scrollTop = itemTop - listTop
console.log('Focusing node')
node.focus()
setScrolled(true)
}
},
@ -127,6 +193,42 @@ const RaidCombobox = (props: Props) => {
[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
function handleValueChange(raid: Raid) {
setCurrentRaid(raid)
@ -219,6 +321,13 @@ const RaidCombobox = (props: Props) => {
const isRef = isSelected ? scrollToItem : undefined
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 (
<RaidItem
className={isSelected ? 'Selected' : ''}
@ -227,7 +336,10 @@ const RaidCombobox = (props: Props) => {
key={key}
selected={isSelected}
ref={isRef}
tabIndex={0}
value={raid.slug}
onEscapeKeyPressed={handleEscapeKeyPressed}
onArrowKeyPressed={handleArrowKeyPressed}
onSelect={() => handleValueChange(raid)}
>
{raid.name[locale]}
@ -243,6 +355,7 @@ const RaidCombobox = (props: Props) => {
groupName="raid_section"
name="events"
selected={currentSection === 2}
tabIndex={2}
onClick={() => setCurrentSection(2)}
>
{t('raids.sections.events')}
@ -251,6 +364,7 @@ const RaidCombobox = (props: Props) => {
groupName="raid_section"
name="raids"
selected={currentSection === 1}
tabIndex={3}
onClick={() => setCurrentSection(1)}
>
{t('raids.sections.raids')}
@ -259,6 +373,7 @@ const RaidCombobox = (props: Props) => {
groupName="raid_section"
name="solo"
selected={currentSection === 3}
tabIndex={4}
onClick={() => setCurrentSection(3)}
>
{t('raids.sections.solo')}
@ -284,6 +399,9 @@ const RaidCombobox = (props: Props) => {
leftAccessoryIcon={<ArrowIcon />}
leftAccessoryClassName={sort === Sort.DESCENDING ? 'Flipped' : ''}
onClick={reverseSort}
onKeyDown={handleSortButtonKeyDown}
ref={sortButtonRef}
tabIndex={5}
/>
</Tooltip>
)
@ -294,7 +412,7 @@ const RaidCombobox = (props: Props) => {
if (currentRaid) {
const element = (
<>
{!props.minimal && (
{!props.minimal ? (
<div className="Info">
<span className="Group">{currentRaid.group.name[locale]}</span>
<span className="Separator">/</span>
@ -302,6 +420,10 @@ const RaidCombobox = (props: Props) => {
{currentRaid.name[locale]}
</span>
</div>
) : (
<span className={classNames({ Raid: true }, linkClass)}>
{currentRaid.name[locale]}
</span>
)}
{currentRaid.group.extra && !props.minimal && (
@ -326,6 +448,8 @@ const RaidCombobox = (props: Props) => {
<CommandInput
className="Input"
placeholder={t('search.placeholders.raid')}
tabIndex={1}
ref={inputRef}
value={query}
onValueChange={setQuery}
/>
@ -387,6 +511,9 @@ const RaidCombobox = (props: Props) => {
<div
className={classNames({ Raids: true, Searching: query !== '' })}
ref={listRef}
role="listbox"
tabIndex={6}
onKeyDown={handleListKeyDown}
>
{renderRaidSections()}
</div>

View file

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