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

Implement focus visible provider #659

Merged
merged 1 commit into from
Apr 27, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
- **[BREAKING CHANGE]** All `className` prop types have been changed to `string` instead of `Classcat.Class`
- **[FIX]** Define `CardsSection`'s justify-content to be centered only in large screens
- **[UPDATE]** Add focus trap & handling overflow on `searchForm` section components
- **[UPDATE]** Add `searchForm` prop to `HeroSection`.
- **[NEW]** Add `FocusVisibleProvider` and `useFocusVisible` utils to polyfill :focus-visible css pseudo class.
- [...]

# v29.4.0 (24/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 { KEYS, 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 !== KEYS.SPACEBAR && key !== KEYS.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
52 changes: 52 additions & 0 deletions src/_utils/focusVisibleProvider/index.unit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import React, { useContext } from 'react'
import { act } from 'react-dom/test-utils'
import { mount, ReactWrapper } from 'enzyme'

import FocusVisibleProvider, { FocusVisibleContext } from '.'
import { KEYS } from '_utils/keycodes'

let focusVisibleContext = null
let wrapper: ReactWrapper
const ChildComponent = () => {
focusVisibleContext = useContext(FocusVisibleContext)
return null
}

describe('FocusVisibleProvider', () => {
beforeEach(() => {
wrapper = mount(
<FocusVisibleProvider>
<ChildComponent />
</FocusVisibleProvider>,
)
})
afterEach(() => {
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(() => {
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: KEYS.TAB }))
})
wrapper.update()
expect(focusVisibleContext).toEqual(true)

act(() => {
document.body.dispatchEvent(new MouseEvent('mousedown'))
})
wrapper.update()
expect(focusVisibleContext).toEqual(false)
})

it('Should provide "false" value when using not whitelisted key', () => {
act(() => {
document.body.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
46 changes: 46 additions & 0 deletions src/_utils/focusVisibleProvider/useFocusVisible.unit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import React from 'react'
import { act } from 'react-dom/test-utils'
import { mount, ReactWrapper } from 'enzyme'

import { KEYS } from '_utils/keycodes'
import { useFocusVisible } from './useFocusVisible'
import FocusVisibleProvider from '.'

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(() => {
wrapper = mount(
<FocusVisibleProvider>
<ButtonComponent />
</FocusVisibleProvider>,
)
})
it('Should have `focusVisible` truthy value only when focused + keyboard nav used', () => {
expect(wrapper.find('button').prop('className')).toEqual(null)
act(() => {
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: KEYS.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(() => {
document.body.dispatchEvent(new KeyboardEvent('keydown', { key: 'Alt' }))
})
wrapper.update()
expect(wrapper.find('button').prop('className')).toEqual(null)
})
})
17 changes: 17 additions & 0 deletions src/_utils/keycodes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,23 @@ export const KEYCODES = {

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

export const KEYS_TRIGGERING_KEYBOARD_NAVIGATION = [
KEYS.TAB,
KEYS.ARROWDOWN,
KEYS.ARROWLEFT,
KEYS.ARROWRIGHT,
KEYS.ARROWUP,
KEYS.ENTER,
KEYS.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()
simonrelet marked this conversation as resolved.
Show resolved Hide resolved
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 />)