Make combobox keyboard accessible
This commit is contained in:
parent
10e53b2b83
commit
044d7bebee
5 changed files with 183 additions and 10 deletions
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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} /> : ''}
|
||||
|
|
|
|||
Loading…
Reference in a new issue