This repository has been archived by the owner on Feb 7, 2024. It is now read-only.
-
Notifications
You must be signed in to change notification settings - Fork 7
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
Showing
9 changed files
with
316 additions
and
17 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
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,106 @@ | ||
import React, { ReactNode, createContext, useEffect, useState } from 'react' | ||
|
||
import { KEY, KEYS_TRIGGERING_KEYBOARD_NAVIGATION } from '_utils/keycodes' | ||
|
||
// A React hook based on: https://github.com/WICG/focus-visible | ||
|
||
const pointerMoveEventList = [ | ||
'mousedown', | ||
'mouseup', | ||
'pointermove', | ||
'pointerdown', | ||
'pointerup', | ||
'touchmove', | ||
'touchstart', | ||
'touchend', | ||
] | ||
|
||
const pointerDownEventList = ['mousedown', 'pointerdown', 'touchstart'] | ||
|
||
type FocusVisibleProviderProps = { | ||
children: ReactNode | ||
} | ||
|
||
export const FocusVisibleContext = createContext(false) | ||
|
||
export const FocusVisibleProvider = ({ children }: FocusVisibleProviderProps) => { | ||
/* When the provider first loads, assume the user is in pointer modality. */ | ||
const [hadKeyboardEvent, setHadKeyboardEvent] = useState(false) | ||
|
||
useEffect(() => { | ||
let lastClientX: Number | ||
let lastClientY: Number | ||
|
||
const onPointerDown = () => { | ||
setHadKeyboardEvent(false) | ||
} | ||
|
||
const onInitialPointerMove = () => { | ||
setHadKeyboardEvent(false) | ||
} | ||
|
||
const getLastMouseMove = (e: MouseEvent) => { | ||
// Ensure the mouse has actually moved (Safari) | ||
// https://transitory.technology/mouse-move-in-safari/ | ||
if (lastClientX === e.clientX && lastClientY === e.clientY) { | ||
return | ||
} | ||
|
||
lastClientX = e.clientX | ||
lastClientY = e.clientY | ||
} | ||
|
||
const addInitialPointerMoveListeners = () => { | ||
document.addEventListener('mousemove', (event: MouseEvent) => { | ||
getLastMouseMove(event) | ||
onInitialPointerMove | ||
}) | ||
|
||
pointerMoveEventList.forEach(e => document.addEventListener(e, onInitialPointerMove)) | ||
} | ||
|
||
const removeInitialPointerMoveListeners = () => { | ||
document.removeEventListener('mousemove', (event: MouseEvent) => { | ||
getLastMouseMove(event) | ||
onInitialPointerMove | ||
}) | ||
|
||
pointerMoveEventList.forEach(e => document.removeEventListener(e, onInitialPointerMove)) | ||
} | ||
|
||
const onKeyDown = (e: KeyboardEvent) => { | ||
const element = e.target as HTMLInputElement | ||
const isTypingArea = | ||
element.tagName === 'TEXTAREA' || (element.tagName === 'INPUT' && element.type === 'text') | ||
// Remove Spacebar and Enter keys in case of text editing | ||
const keysList = isTypingArea | ||
? KEYS_TRIGGERING_KEYBOARD_NAVIGATION.filter( | ||
key => key !== KEY.SPACEBAR && key !== KEY.ENTER, | ||
) | ||
: KEYS_TRIGGERING_KEYBOARD_NAVIGATION | ||
|
||
if (keysList.includes(e.key)) { | ||
setHadKeyboardEvent(true) | ||
} | ||
} | ||
|
||
// For some kinds of state, we are interested in changes at the global | ||
// scope only. Global pointer input and global key presses change | ||
// should affect the state at every scope. | ||
document.addEventListener('keydown', onKeyDown, true) | ||
pointerDownEventList.forEach(e => document.addEventListener(e, onPointerDown, true)) | ||
addInitialPointerMoveListeners() | ||
|
||
return () => { | ||
document.removeEventListener('keydown', onKeyDown, true) | ||
pointerDownEventList.forEach(e => document.removeEventListener(e, onPointerDown, true)) | ||
removeInitialPointerMoveListeners() | ||
} | ||
}, []) | ||
|
||
return ( | ||
<FocusVisibleContext.Provider value={hadKeyboardEvent}>{children}</FocusVisibleContext.Provider> | ||
) | ||
} | ||
|
||
export default FocusVisibleProvider |
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,60 @@ | ||
import React, { useContext } from 'react' | ||
import { act } from 'react-dom/test-utils' | ||
import { mount, ReactWrapper } from 'enzyme' | ||
|
||
import FocusVisibleProvider, { FocusVisibleContext } from '.' | ||
import { KEY } from '_utils/keycodes' | ||
|
||
let focusVisibleContext = null | ||
let parentNode: HTMLElement | ||
let wrapper: ReactWrapper | ||
const ChildComponent = () => { | ||
focusVisibleContext = useContext(FocusVisibleContext) | ||
return null | ||
} | ||
|
||
describe('FocusVisibleProvider', () => { | ||
beforeEach(() => { | ||
parentNode = document.createElement('div') | ||
document.body.appendChild(parentNode) | ||
wrapper = mount( | ||
<FocusVisibleProvider> | ||
<ChildComponent /> | ||
</FocusVisibleProvider>, | ||
{ | ||
attachTo: parentNode, | ||
}, | ||
) | ||
}) | ||
afterEach(() => { | ||
document.body.removeChild(parentNode) | ||
parentNode = null | ||
focusVisibleContext = null | ||
}) | ||
|
||
it('Should provide "false" value for keyboard navigation context on mounting', () => { | ||
expect(focusVisibleContext).toEqual(false) | ||
}) | ||
|
||
it('Should update the context value by switching from keyboard to pointer interaction', () => { | ||
act(() => { | ||
parentNode.dispatchEvent(new KeyboardEvent('keydown', { key: KEY.TAB })) | ||
}) | ||
wrapper.update() | ||
expect(focusVisibleContext).toEqual(true) | ||
|
||
act(() => { | ||
parentNode.dispatchEvent(new MouseEvent('mousedown')) | ||
}) | ||
wrapper.update() | ||
expect(focusVisibleContext).toEqual(false) | ||
}) | ||
|
||
it('Should provide "false" value when using not whitelisted key', () => { | ||
act(() => { | ||
parentNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt' })) | ||
}) | ||
wrapper.update() | ||
expect(focusVisibleContext).toEqual(false) | ||
}) | ||
}) |
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,23 @@ | ||
import { useState, useContext, useCallback } from 'react' | ||
import { FocusVisibleContext } from '_utils/focusVisibleProvider' | ||
|
||
export const useFocusVisible = () => { | ||
const [isFocused, setIsFocused] = useState(false) | ||
const hadKeyboardEvent = useContext(FocusVisibleContext) | ||
|
||
const onFocus = useCallback(() => { | ||
setIsFocused(true) | ||
}, [isFocused]) | ||
|
||
const onBlur = useCallback(() => { | ||
setIsFocused(false) | ||
}, [isFocused]) | ||
|
||
return { | ||
focusVisible: hadKeyboardEvent && isFocused, | ||
onFocus, | ||
onBlur, | ||
} | ||
} | ||
|
||
export default useFocusVisible |
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,56 @@ | ||
import React from 'react' | ||
import { act } from 'react-dom/test-utils' | ||
import { mount, ReactWrapper } from 'enzyme' | ||
|
||
import { KEY } from '_utils/keycodes' | ||
import { useFocusVisible } from './useFocusVisible' | ||
import FocusVisibleProvider from '.' | ||
|
||
let parentNode: HTMLElement | ||
let wrapper: ReactWrapper | ||
const ButtonComponent = () => { | ||
const { focusVisible, onFocus, onBlur } = useFocusVisible() | ||
return ( | ||
<button className={focusVisible ? 'focus-visible' : null} onFocus={onFocus} onBlur={onBlur}> | ||
Test | ||
</button> | ||
) | ||
} | ||
|
||
describe('useFocusVisible', () => { | ||
beforeEach(() => { | ||
parentNode = document.createElement('div') | ||
document.body.appendChild(parentNode) | ||
wrapper = mount( | ||
<FocusVisibleProvider> | ||
<ButtonComponent /> | ||
</FocusVisibleProvider>, | ||
{ | ||
attachTo: parentNode, | ||
}, | ||
) | ||
}) | ||
afterEach(() => { | ||
document.body.removeChild(parentNode) | ||
parentNode = null | ||
}) | ||
it('Should have `focusVisible` truthy value only when focused + keyboard nav used', () => { | ||
expect(wrapper.find('button').prop('className')).toEqual(null) | ||
act(() => { | ||
parentNode.dispatchEvent(new KeyboardEvent('keydown', { key: KEY.TAB })) | ||
}) | ||
wrapper.update() | ||
expect(wrapper.find('button').prop('className')).toEqual(null) | ||
|
||
wrapper.find('button').simulate('focus') | ||
expect(wrapper.find('button').prop('className')).toEqual('focus-visible') | ||
}) | ||
it('Should have `focusVisible` falsy value when use not whitelisted key', () => { | ||
wrapper.find('button').simulate('focus') | ||
act(() => { | ||
parentNode.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt' })) | ||
}) | ||
wrapper.update() | ||
expect(wrapper.find('button').prop('className')).toEqual(null) | ||
}) | ||
}) |
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
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
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
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 |
---|---|---|
@@ -1,21 +1,32 @@ | ||
import React from 'react' | ||
import React, { Component } from 'react' | ||
import { FocusVisibleProvider } from '_utils/focusVisibleProvider' | ||
|
||
import { storiesOf } from '@storybook/react' | ||
import { withKnobs, text } from '@storybook/addon-knobs' | ||
import { action } from '@storybook/addon-actions' | ||
import Section from 'layout/section/baseSection' | ||
import Why from 'why' | ||
|
||
class LayoutExample extends Component { | ||
render() { | ||
return ( | ||
<div> | ||
<FocusVisibleProvider> | ||
<Section> | ||
<Why | ||
onClick={action('clicked')} | ||
title={text('Title', 'Why this is a text that is so long ? (new window)')} | ||
> | ||
{text('Text', 'Why this is a text that is so long ?')} | ||
</Why> | ||
</Section> | ||
</FocusVisibleProvider> | ||
</div> | ||
) | ||
} | ||
} | ||
|
||
const stories = storiesOf('Widgets|Why', module) | ||
stories.addDecorator(withKnobs) | ||
|
||
stories.add('default', () => ( | ||
<Section> | ||
<Why | ||
onClick={action('clicked')} | ||
title={text('Title', 'Why this is a text that is so long ? (new window)')} | ||
> | ||
{text('Text', 'Why this is a text that is so long ?')} | ||
</Why> | ||
</Section> | ||
)) | ||
stories.add('default', () => <LayoutExample />) |