Skip to content

Commit

Permalink
fix(select a11y): make PageDown keypress respect disabled options
Browse files Browse the repository at this point in the history
  • Loading branch information
Mohammer5 committed Oct 23, 2024
1 parent 336e95a commit c3bd419
Show file tree
Hide file tree
Showing 7 changed files with 250 additions and 61 deletions.
4 changes: 2 additions & 2 deletions collections/forms/i18n/en.pot
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ msgstr ""
"Content-Type: text/plain; charset=utf-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n != 1)\n"
"POT-Creation-Date: 2024-10-23T00:35:22.918Z\n"
"PO-Revision-Date: 2024-10-23T00:35:22.918Z\n"
"POT-Creation-Date: 2024-10-23T01:23:16.733Z\n"
"PO-Revision-Date: 2024-10-23T01:23:16.734Z\n"

msgid "Upload file"
msgstr "Upload file"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,13 +156,14 @@ export const ShiftedIntoView = () => (
</>
)

const hundretOptions = Array.apply(null, Array(100)).map((x, i) => ({
value: `${i}`,
label: `Select option ${i}`,
}))

export const HundretOptions = () => {
const [value, setValue] = useState('0')
const [hundretOptions] = useState(
Array.apply(null, Array(100)).map((x, i) => ({
value: `${i}`,
label: `Select option ${i}`,
}))
)

return (
<SingleSelectA11y
Expand All @@ -173,3 +174,56 @@ export const HundretOptions = () => {
/>
)
}

export const HundretOptionsWithDisabled = () => {
const [value, setValue] = useState('0')
const [hundretOptions] = useState(
Array.apply(null, Array(100)).map((x, i) => ({
value: `${i}`,
label: `Select option ${i}`,
disabled: i === 17 || i === 18,
}))
)

return (
<SingleSelectA11y
idPrefix="a11y"
value={value}
onChange={setValue}
options={hundretOptions}
/>
)
}

export const NativeSelect = () => {
const [value, setValue] = useState('0')
const [hundretOptions] = useState(
Array.apply(null, Array(100)).map((x, i) => ({
value: `${i}`,
label: `Select option ${i}`,
disabled: i > 19,
}))
)

return (
<>
<select value={value} onChange={(e) => setValue(e.target.value)}>
{hundretOptions.map((option) => (
<option
key={option.value}
value={option.value}
disabled={option.disabled}
>
{option.label}
</option>
))}
</select>

<style jsx>{`
option:disabled {
color: grey;
}
`}</style>
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ export function useHandleKeyPress({
onChange,
})

const { pageDown, pageUp } = usePageUpDown(
const { pageDown, pageUp } = usePageUpDown({
options,
listBoxRef,
focussedOptionIndex,
setFocussedOptionIndex
)
setFocussedOptionIndex,
})

const selectNextOption = useCallback(() => {
const currentOptionIndex = options.findIndex(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@ export function useHandleTyping({
setFocussedOptionIndex,
onChange,
}) {
const timeoutRef = useRef()
const [value, setValue] = useState('')
const [typing, setTyping] = useState(false)

// This will reset the typed value after a given time
const timeoutRef = useRef()
useEffect(() => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
Expand All @@ -32,14 +32,18 @@ export function useHandleTyping({
value,
])

// This will focus the first option with a label starting with the typed sequence
const prevValueRef = useRef()
useEffect(() => {
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())
const optionIndex = options.findIndex(
(option) =>
option.label
.toLowerCase()
.startsWith(value.toLowerCase()) && !option.disabled
)

if (optionIndex !== -1) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { useCallback } from 'react'
import { isOptionHidden } from '../is-option-hidden.js'

export function useHighlightLastOptionOnNextPage({
options,
focussedOptionIndex,
setFocussedOptionIndex,
listBoxRef,
}) {
return useCallback(
(listBoxParent) => {
const optionElements = Array.from(listBoxRef.current.childNodes)
const visibleOptionsAmount = options.filter(
(_, index) =>
!isOptionHidden(optionElements[index], listBoxParent)
).length

const nextHighlightedOptionIndex = Math.min(
options.length - 1,
focussedOptionIndex + visibleOptionsAmount
)

// If there's no next option and we already have the last option in the list highlighted
if (!options[nextHighlightedOptionIndex]) {
return
}

if (!options[nextHighlightedOptionIndex].disabled) {
// This will be the first option in the list
const { offsetTop: scrollPosition } =
optionElements[
nextHighlightedOptionIndex - visibleOptionsAmount + 1
]

listBoxParent.scrollTop = scrollPosition
setFocussedOptionIndex(nextHighlightedOptionIndex)
return
}

const followingEnabledOptionIndex =
nextHighlightedOptionIndex +
options
.slice(nextHighlightedOptionIndex)
.findIndex((option) => !option.disabled)

// There is no enabled option after the disabled option that's at the end of the next page
// So we stay where we are
if (followingEnabledOptionIndex === -1) {
return
}

// There is an enabled option after the disabled option that's at the end of the next page
// So that'll be the new highlighted option and the bottom of the next displayed page
const { offsetTop: adjustedScrollPosition } =
optionElements[
followingEnabledOptionIndex - visibleOptionsAmount + 1
]

listBoxParent.scrollTop = adjustedScrollPosition
setFocussedOptionIndex(followingEnabledOptionIndex)
},
[options, focussedOptionIndex, setFocussedOptionIndex, listBoxRef]
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useCallback } from 'react'

export function useHighlightLastVisibleOption({
options,
focussedOptionIndex,
setFocussedOptionIndex,
}) {
return useCallback(
(highestVisibleIndex) => {
if (!options[highestVisibleIndex].disabled) {
setFocussedOptionIndex(highestVisibleIndex)
return
}

// Last option on page is disabled, find next option that's not disabled
const followingEnabledOptionIndex = options
.slice(highestVisibleIndex)
.findIndex((option) => !option.disabled)

if (followingEnabledOptionIndex >= 0) {
// We need to add the highest visible index because the index is lower due to slicing the array
setFocussedOptionIndex(
followingEnabledOptionIndex + highestVisibleIndex
)
return
}

// No following enabled option, trying to find the closest previous sibling of the last option on the current page
const closestToEndOfPageEnabledOptionIndex = options
.slice(
// We don't include the currently highlighted option
focussedOptionIndex + 1,
highestVisibleIndex
)
.findLastIndex((option) => !option.disabled)

if (closestToEndOfPageEnabledOptionIndex >= 0) {
setFocussedOptionIndex(
closestToEndOfPageEnabledOptionIndex +
// We need to add the focused index and 1 because the index is lower due to slicing the array
focussedOptionIndex +
1
)
return
}

// The currently highlighted option is the last enabled option
return
},
[options, focussedOptionIndex, setFocussedOptionIndex]
)
}
Original file line number Diff line number Diff line change
@@ -1,67 +1,60 @@
import { useCallback } from 'react'
import { isOptionHidden } from '../is-option-hidden.js'
import { useHighlightLastOptionOnNextPage } from './use-highlight-last-option-on-next-page.js'
import { useHighlightLastVisibleOption } from './use-highlight-last-visible-option.js'

export function usePageUpDown(
listBoxRef,
function usePageDown({
options,
focussedOptionIndex,
setFocussedOptionIndex
) {
const pageDown = useCallback(() => {
setFocussedOptionIndex,
listBoxRef,
}) {
const highlightLastVisibleOption = useHighlightLastVisibleOption({
options,
focussedOptionIndex,
setFocussedOptionIndex,
})

const highlightLastOptionOnNextPage = useHighlightLastOptionOnNextPage({
options,
focussedOptionIndex,
setFocussedOptionIndex,
listBoxRef,
})

return useCallback(() => {
const listBoxParent = listBoxRef.current.parentNode
const options = Array.from(listBoxRef.current.childNodes)
const highestVisibleIndex = options.reduce(
(curIndex, option, index) => {
if (
// When option is not visible
isOptionHidden(option, listBoxParent) ||
// When option is not the highest-index one so far
index <= curIndex
) {
return curIndex
}

return index
},
-1
const highestVisibleIndex = options.findLastIndex(
(option) => !isOptionHidden(option, listBoxParent)
)

// No visible option (e.g. when menu is empty)
if (highestVisibleIndex === -1) {
return
}

// Highlight last visible option
if (highestVisibleIndex > focussedOptionIndex) {
setFocussedOptionIndex(highestVisibleIndex)
highlightLastVisibleOption(highestVisibleIndex)
return
}

const visibleOptionsAmount = options.filter(
(option) => !isOptionHidden(option, listBoxParent)
).length

const nextHighlightedOptionIndex = Math.min(
options.length - 1,
focussedOptionIndex + visibleOptionsAmount
)

// If there's no next option and we already have the last option in the list highlighted
if (!options[nextHighlightedOptionIndex]) {
if (highestVisibleIndex > -1) {
highlightLastOptionOnNextPage(listBoxParent)
return
}

const nextTopOptionIndex = Math.min(
options.length - 1,
focussedOptionIndex + 1
)

const nextTopOption = options[nextTopOptionIndex]
const scrollPosition = nextTopOption.offsetTop
listBoxParent.scrollTop = scrollPosition
setFocussedOptionIndex(nextHighlightedOptionIndex)
}, [focussedOptionIndex, setFocussedOptionIndex, listBoxRef])
// No visible option (e.g. when menu is empty)
return
}, [
focussedOptionIndex,
listBoxRef,
highlightLastVisibleOption,
highlightLastOptionOnNextPage,
])
}

const pageUp = useCallback(() => {
function usePageUp({
listBoxRef,
focussedOptionIndex,
setFocussedOptionIndex,
}) {
return useCallback(() => {
const listBoxParent = listBoxRef.current.parentNode
const options = Array.from(listBoxRef.current.childNodes)
const lowestVisibleIndex = options.findIndex(
Expand Down Expand Up @@ -98,6 +91,27 @@ export function usePageUpDown(
listBoxParent.scrollTop = scrollPosition
setFocussedOptionIndex(nextTopOptionIndex)
}, [focussedOptionIndex, setFocussedOptionIndex, listBoxRef])
}

export function usePageUpDown({
options,
listBoxRef,
focussedOptionIndex,
setFocussedOptionIndex,
}) {
const pageDown = usePageDown({
options,
focussedOptionIndex,
setFocussedOptionIndex,
listBoxRef,
})

const pageUp = usePageUp({
options,
listBoxRef,
focussedOptionIndex,
setFocussedOptionIndex,
})

return { pageDown, pageUp }
}

0 comments on commit c3bd419

Please sign in to comment.