Skip to content
This repository has been archived by the owner on Feb 7, 2024. It is now read-only.

Commit

Permalink
Add FocusVisibleProvider
Browse files Browse the repository at this point in the history
  • Loading branch information
nickoola committed Apr 23, 2020
1 parent ce708e9 commit ea2481b
Show file tree
Hide file tree
Showing 9 changed files with 316 additions and 17 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Unreleased

- **[NEW]** Add `FocusVisibleProvider` and `useFocusVisible` utils to polyfill :focus-visible css pseudo class.
- [...]

# v29.2.0 (23/04/2020)
Expand Down
106 changes: 106 additions & 0 deletions src/_utils/focusVisibleProvider/index.tsx
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
60 changes: 60 additions & 0 deletions src/_utils/focusVisibleProvider/index.unit.tsx
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)
})
})
23 changes: 23 additions & 0 deletions src/_utils/focusVisibleProvider/useFocusVisible.tsx
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
56 changes: 56 additions & 0 deletions src/_utils/focusVisibleProvider/useFocusVisible.unit.tsx
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)
})
})
21 changes: 21 additions & 0 deletions src/_utils/keycodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,25 @@ export const KEYCODES = {
SPACEBAR: 32,
}

export const KEY = {
ESCAPE: 'Escape',
ENTER: 'Enter',
SPACEBAR: ' ',
TAB: 'Tab',
ARROWDOWN: 'ArrowDown',
ARROWLEFT: 'ArrowLeft',
ARROWRIGHT: 'ArrowRight',
ARROWUP: 'ArrowUp',
}

export const KEYS_TRIGGERING_KEYBOARD_NAVIGATION = [
KEY.TAB,
KEY.ARROWDOWN,
KEY.ARROWLEFT,
KEY.ARROWRIGHT,
KEY.ARROWUP,
KEY.ENTER,
KEY.SPACEBAR,
]

export default KEYCODES
29 changes: 23 additions & 6 deletions src/why/Why.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import React from 'react'
import cc from 'classcat'
import { useFocusVisible } from '_utils/focusVisibleProvider/useFocusVisible'

import QuestionIcon from 'icon/questionIcon'

Expand All @@ -10,11 +11,27 @@ export interface WhyProps {
readonly onClick?: () => void
}

const Why = ({ className, children, title, onClick }: WhyProps) => (
<button type="button" className={cc(['kirk-why', className])} title={title} onClick={onClick}>
<QuestionIcon />
<span>{children}</span>
</button>
)
const Why = ({ className, children, title, onClick }: WhyProps) => {
const { focusVisible, onFocus, onBlur } = useFocusVisible()
return (
<button
type="button"
className={cc([
'kirk-why',
{
'focus-visible': focusVisible,
},
className,
])}
title={title}
onClick={onClick}
onFocus={onFocus}
onBlur={onBlur}
>
<QuestionIcon />
<span>{children}</span>
</button>
)
}

export default Why
4 changes: 4 additions & 0 deletions src/why/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ const StyledWhy = styled(Why)`
background-color: ${color.lightBackground};
}
:focus:not(.focus-visible) {
outline: none;
}
/* Reset hover styles on devices not supporting hover state (e.g. touch devices). */
@media (hover: none), (hover: on-demand) {
&:hover {
Expand Down
33 changes: 22 additions & 11 deletions src/why/story.tsx
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 />)

0 comments on commit ea2481b

Please sign in to comment.