-
Notifications
You must be signed in to change notification settings - Fork 395
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
1 parent
44be99f
commit ade3cd7
Showing
5 changed files
with
358 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
178 changes: 178 additions & 0 deletions
178
packages/@sanity/base/src/components/rovingFocus/__tests__/useRovingFocus.test.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
}) | ||
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
export * from './useRovingFocus' | ||
export * from './types' |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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
170
packages/@sanity/base/src/components/rovingFocus/useRovingFocus.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} |