Skip to content

Commit

Permalink
feat(base): add useRovingFocus hook
Browse files Browse the repository at this point in the history
  • Loading branch information
hermanwikner committed Feb 16, 2022
1 parent 44be99f commit ade3cd7
Show file tree
Hide file tree
Showing 5 changed files with 358 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/@sanity/base/src/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export * from './progress'
export * from './collapseMenu'
export * from './TextWithTone'
export * from './PreviewCard'
export * from './rovingFocus'

// @todo: REMOVE THIS
export * from './_deprecated/CreateDocumentList'
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
// eslint-disable-next-line import/no-unassigned-import
import '@testing-library/jest-dom/extend-expect'
import {render, fireEvent} from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import React, {useState} from 'react'

import {Button, Card, studioTheme, ThemeProvider} from '@sanity/ui'
import {RovingFocusProps, useRovingFocus} from '..'

interface TestProps extends Pick<RovingFocusProps, 'direction' | 'initialFocus' | 'loop'> {
withDisabledButtons?: boolean
}

function RenderTestComponent(props: TestProps) {
const {direction, loop, withDisabledButtons, initialFocus} = props
const [rootElement, setRootElement] = useState<HTMLDivElement | null>(null)

useRovingFocus({
direction: direction,
loop: loop,
rootElement: rootElement,
initialFocus: initialFocus,
})

return (
<ThemeProvider theme={studioTheme}>
<Card ref={setRootElement} id="rootElement">
<Button text="Test" disabled={withDisabledButtons} />
<Button text="Test" />
<Button text="Test" disabled={withDisabledButtons} />
<Button text="Test" />
</Card>
</ThemeProvider>
)
}

describe('base/useRovingFocus:', () => {
/**
* Horizontal direction
*/
it('horizontal direction', () => {
const {container} = render(<RenderTestComponent />)
const rootElement = container.querySelector('#rootElement')
const buttons = rootElement.querySelectorAll('button')

// Focus button #0 on tab
userEvent.tab()
expect(buttons[0]).toBe(document.activeElement)

// Focus button #1 on arrow right
fireEvent.keyDown(rootElement, {key: 'ArrowRight'})
expect(buttons[1]).toBe(document.activeElement)

// Focus button #2 on arrow right
fireEvent.keyDown(rootElement, {key: 'ArrowRight'})
expect(buttons[2]).toBe(document.activeElement)

// Focus button #3 on arrow right
fireEvent.keyDown(rootElement, {key: 'ArrowRight'})
expect(buttons[3]).toBe(document.activeElement)

// Focus button #0 on arrow right
fireEvent.keyDown(rootElement, {key: 'ArrowRight'})
expect(buttons[0]).toBe(document.activeElement)

// Focus button #3 on arrow left
fireEvent.keyDown(rootElement, {key: 'ArrowLeft'})
expect(buttons[3]).toBe(document.activeElement)

// Focus button #2 on arrow left
fireEvent.keyDown(rootElement, {key: 'ArrowLeft'})
expect(buttons[2]).toBe(document.activeElement)
})

/**
* Vertical direction
*/
it('vertical direction', () => {
const {container} = render(<RenderTestComponent direction="vertical" />)
const rootElement = container.querySelector('#rootElement')
const buttons = rootElement.querySelectorAll('button')

// Focus button #0 on tab
userEvent.tab()
expect(buttons[0]).toBe(document.activeElement)

// Focus button #1 on arrow down
fireEvent.keyDown(rootElement, {key: 'ArrowDown'})
expect(buttons[1]).toBe(document.activeElement)

// Focus button #2 on arrow down
fireEvent.keyDown(rootElement, {key: 'ArrowDown'})
expect(buttons[2]).toBe(document.activeElement)

// Focus button #3 on arrow down
fireEvent.keyDown(rootElement, {key: 'ArrowDown'})
expect(buttons[3]).toBe(document.activeElement)

// Focus button #0 on arrow down
fireEvent.keyDown(rootElement, {key: 'ArrowDown'})
expect(buttons[0]).toBe(document.activeElement)

// Focus button #3 on arrow right
fireEvent.keyDown(rootElement, {key: 'ArrowUp'})
expect(buttons[3]).toBe(document.activeElement)

// Focus button #2 on arrow right
fireEvent.keyDown(rootElement, {key: 'ArrowUp'})
expect(buttons[2]).toBe(document.activeElement)
})

/**
* With disabled buttons
*/
it('with disabled buttons', () => {
const {container} = render(<RenderTestComponent withDisabledButtons />)
const rootElement = container.querySelector('#rootElement')
const buttons = rootElement.querySelectorAll('button')

// Focus button #1 on tab
userEvent.tab()
expect(buttons[1]).toBe(document.activeElement)

// Focus button #3 on arrow right (skips #2 because it is disabled)
fireEvent.keyDown(rootElement, {key: 'ArrowRight'})
expect(buttons[3]).toBe(document.activeElement)

// Focus button #1 on arrow right (skips #0 because it is disabled)
fireEvent.keyDown(rootElement, {key: 'ArrowRight'})
expect(buttons[1]).toBe(document.activeElement)
})

/**
* Without loop
*/
it('without loop', () => {
const {container} = render(<RenderTestComponent loop={false} />)
const rootElement = container.querySelector('#rootElement')
const buttons = rootElement.querySelectorAll('button')

// Focus button #0 on tab
userEvent.tab()
expect(buttons[0]).toBe(document.activeElement)

// Focus button #1 on arrow right
fireEvent.keyDown(rootElement, {key: 'ArrowRight'})
expect(buttons[1]).toBe(document.activeElement)

// Focus button #2 on arrow right
fireEvent.keyDown(rootElement, {key: 'ArrowRight'})
expect(buttons[2]).toBe(document.activeElement)

// Focus button #3 on arrow right
fireEvent.keyDown(rootElement, {key: 'ArrowRight'})
expect(buttons[3]).toBe(document.activeElement)

// Focus button #3 on arrow right (because loop is disabled, the focus stays on #3)
fireEvent.keyDown(rootElement, {key: 'ArrowRight'})
expect(buttons[3]).toBe(document.activeElement)
})

/**
* Initial focus last
*/
it('initial focus last', () => {
const {container} = render(<RenderTestComponent initialFocus="last" />)
const rootElement = container.querySelector('#rootElement')
const buttons = rootElement.querySelectorAll('button')

// Focus button #3 on tab (the last button)
userEvent.tab()
expect(buttons[3]).toBe(document.activeElement)

// Focus button #0 on arrow right
fireEvent.keyDown(rootElement, {key: 'ArrowRight'})
expect(buttons[0]).toBe(document.activeElement)
})
})
2 changes: 2 additions & 0 deletions packages/@sanity/base/src/components/rovingFocus/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './useRovingFocus'
export * from './types'
7 changes: 7 additions & 0 deletions packages/@sanity/base/src/components/rovingFocus/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export interface RovingFocusProps {
direction?: 'horizontal' | 'vertical'
initialFocus?: 'first' | 'last'
loop?: boolean
pause?: boolean
rootElement: HTMLElement | HTMLDivElement | null
}
170 changes: 170 additions & 0 deletions packages/@sanity/base/src/components/rovingFocus/useRovingFocus.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import {useCallback, useEffect, useState} from 'react'
import {RovingFocusProps} from './types'

const MUTATION_ATTRIBUTE_FILTER = ['aria-hidden', 'disabled', 'href']

const FOCUSABLE =
'a[href], button, input, textarea, select, details, [tabindex]:not([tabindex="-1"])'

function getFocusableElements(element: HTMLElement) {
return [...(element.querySelectorAll(FOCUSABLE) as any)].filter(
(el) => !el.hasAttribute('disabled') && el.getAttribute('aria-hidden') !== 'true'
) as HTMLElement[]
}

/** This hook handles focus with the keyboard arrows.
*
* - Roving focus definition [https://a11y-solutions.stevenwoodson.com/solutions/focus/roving-focus/]
* - Example usage:
*
* ```
function MyComponent() {
const [rootElement, setRootElement] = setRootElement(null)
useRovingFocus({
rootElement: rootElement,
})
return (
<div ref={setRootElement}>
<button>Button</button>
<button>Button</button>
<button>Button</button>
</div>
)
}
```
*/
export function useRovingFocus(props: RovingFocusProps): undefined {
const {direction = 'horizontal', initialFocus, loop = true, pause = false, rootElement} = props
const [focusedIndex, setFocusedIndex] = useState<number>(-1)
const [focusableElements, setFocusableElements] = useState<HTMLElement[]>([])

const focusableLen = focusableElements.length
const lastFocusableIndex = focusableLen - 1

/**
* Determine what keys to listen to depending on direction
*/
const nextKey = direction === 'horizontal' ? 'ArrowRight' : 'ArrowDown'
const prevKey = direction === 'horizontal' ? 'ArrowLeft' : 'ArrowUp'

/**
* Set focusable elements in state
*/
const handleSetElements = useCallback(() => {
if (rootElement) {
const els = getFocusableElements(rootElement)

setFocusableElements(els)
}
}, [rootElement])

/**
* Set focused index
*/
const handleFocus = useCallback((index: number) => {
setFocusedIndex(index)
}, [])

/**
* Handle increment/decrement of focusedIndex
*/
const handleKeyDown = useCallback(
(event) => {
if (pause) {
return
}

if (event.key === prevKey) {
event.preventDefault()
setFocusedIndex((prevIndex) => {
const next = (prevIndex + lastFocusableIndex) % focusableLen

if (!loop && next === lastFocusableIndex) {
return prevIndex
}

return next
})
}

if (event.key === nextKey) {
event.preventDefault()
setFocusedIndex((prevIndex) => {
const next = (prevIndex + 1) % focusableLen

if (!loop && next === 0) {
return prevIndex
}

return next
})
}
},
[focusableLen, loop, nextKey, pause, prevKey, lastFocusableIndex]
)

/**
* Set focusable elements on mount
*/
useEffect(() => {
handleSetElements()
}, [handleSetElements, initialFocus, direction])

/**
* Listen to DOM mutations to update focusableElements with latest state
*/
useEffect(() => {
const mo = new MutationObserver(handleSetElements)

if (rootElement) {
mo.observe(rootElement, {
childList: true,
subtree: true,
attributeFilter: MUTATION_ATTRIBUTE_FILTER,
})
}

return () => {
mo.disconnect()
}
}, [focusableElements, handleSetElements, rootElement])

/**
* Set focus on elements in focusableElements depending on focusedIndex
*/
useEffect(() => {
focusableElements.forEach((el, index) => {
if (index === focusedIndex) {
el.setAttribute('tabIndex', '0')
el.setAttribute('aria-selected', 'true')
el.focus()
el.onfocus = () => handleFocus(index)
el.onblur = () => handleFocus(-1)
} else {
el.setAttribute('tabIndex', '-1')
el.setAttribute('aria-selected', 'false')
el.onfocus = () => handleFocus(index)
}
})

if (focusedIndex === -1 && focusableElements) {
const initialIndex = initialFocus === 'last' ? lastFocusableIndex : 0
focusableElements[initialIndex]?.setAttribute('tabIndex', '0')
}
}, [focusableElements, focusedIndex, handleFocus, initialFocus, lastFocusableIndex])

/**
* Listen to key down events on rootElement
*/
useEffect(() => {
rootElement?.addEventListener('keydown', handleKeyDown)

return () => {
rootElement?.removeEventListener('keydown', handleKeyDown)
}
}, [handleKeyDown, rootElement])

return undefined
}

0 comments on commit ade3cd7

Please sign in to comment.