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 10, 2020
1 parent 9fd9433 commit 037989c
Show file tree
Hide file tree
Showing 5 changed files with 228 additions and 17 deletions.
34 changes: 34 additions & 0 deletions src/_utils/focusVisible/FocusVisibleConsumer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import React from 'react'
import { FocusVisibleContext } from '_utils/focusVisible/FocusVisibleProvider'

export const FocusVisibleConsumer = () => {
const [isFocused, setIsFocused] = React.useState(false)
const { hadKeyboardEvent, isInitialized } = React.useContext(FocusVisibleContext)

const onFocus = React.useCallback(() => {
if (!isFocused) setIsFocused(true)
}, [isFocused])

const onBlur = React.useCallback(() => {
if (isFocused) setIsFocused(false)
}, [isFocused])

let focusVisible: boolean
if (isInitialized) {
focusVisible = hadKeyboardEvent && isFocused
} else {
// Fallback to focused when the `FocusVisibleProvider` is not used.
focusVisible = isFocused
}

return React.useMemo(
() => ({
focusVisible,
onFocus,
onBlur,
}),
[focusVisible, onBlur, onFocus],
)
}

export default FocusVisibleConsumer
131 changes: 131 additions & 0 deletions src/_utils/focusVisible/FocusVisibleProvider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import React, { useEffect, useState, ReactNode } from 'react'

// A React hook based on: https://github.com/WICG/focus-visible

interface FocusVisibleProviderProps {
children: ReactNode
}

export const FocusVisibleContext = React.createContext({
hadKeyboardEvent: true,
isInitialized: false,
})

export const FocusVisibleProvider = ({ children }: FocusVisibleProviderProps) => {
const [hadKeyboardEvent, setHadKeyboardEvent] = useState(true)

const onPointerDown = () => {
setHadKeyboardEvent(false)
}

/**
* When the polfyill first loads, assume the user is in keyboard modality.
* If any event is received from a pointing device (e.g. mouse, pointer,
* touch), turn off keyboard modality.
* This accounts for situations where focus enters the page from the URL bar.
* @param {PointerEvent} e
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const onInitialPointerMove = (e: any) => {
// Work around a Safari quirk that fires a mousemove on <html> whenever the
// window blurs, even if you're tabbing out of the page. ¯\_(ツ)_/¯
if (e.target.nodeName && e.target.nodeName.toLowerCase() === 'html') {
return
}

setHadKeyboardEvent(false)
// eslint-disable-next-line no-use-before-define
removeInitialPointerMoveListeners()
}

/**
* Add a group of listeners to detect usage of any pointing devices.
* These listeners will be added when the polyfill first loads, and anytime
* the window is blurred, so that they are active when the window regains
* focus.
*/
const addInitialPointerMoveListeners = () => {
document.addEventListener('mousemove', onInitialPointerMove)
document.addEventListener('mousedown', onInitialPointerMove)
document.addEventListener('mouseup', onInitialPointerMove)
document.addEventListener('pointermove', onInitialPointerMove)
document.addEventListener('pointerdown', onInitialPointerMove)
document.addEventListener('pointerup', onInitialPointerMove)
document.addEventListener('touchmove', onInitialPointerMove)
document.addEventListener('touchstart', onInitialPointerMove)
document.addEventListener('touchend', onInitialPointerMove)
}

const removeInitialPointerMoveListeners = () => {
document.removeEventListener('mousemove', onInitialPointerMove)
document.removeEventListener('mousedown', onInitialPointerMove)
document.removeEventListener('mouseup', onInitialPointerMove)
document.removeEventListener('pointermove', onInitialPointerMove)
document.removeEventListener('pointerdown', onInitialPointerMove)
document.removeEventListener('pointerup', onInitialPointerMove)
document.removeEventListener('touchmove', onInitialPointerMove)
document.removeEventListener('touchstart', onInitialPointerMove)
document.removeEventListener('touchend', onInitialPointerMove)
}

/**
* If the most recent user interaction was via the keyboard;
* and the key press did not include a meta, alt/option, or control key;
* then the modality is keyboard. Otherwise, the modality is not keyboard.
* Apply `focus-visible` to any current active element and keep track
* of our keyboard modality state with `hadKeyboardEvent`.
* @param {KeyboardEvent} e
*/
const onKeyDown = (e: KeyboardEvent) => {
if (e.metaKey || e.altKey || e.ctrlKey) {
return
}

setHadKeyboardEvent(true)
}
/**
* If the user changes tabs, keep track of whether or not the previously
* focused element had .focus-visible.
*/
const onVisibilityChange = () => {
if (document.visibilityState === 'hidden') {
// If the tab becomes active again, the browser will handle calling focus
// on the element (Safari actually calls it twice).
// If this tab change caused a blur on an element with focus-visible,
// re-apply the class when the user switches back to the tab.
setHadKeyboardEvent(true)
addInitialPointerMoveListeners()
}
}

useEffect(() => {
// For some kinds of state, we are interested in changes at the global scope
// only. For example, global pointer input, global key presses and global
// visibility change should affect the state at every scope:
document.addEventListener('keydown', onKeyDown, true)
document.addEventListener('mousedown', onPointerDown, true)
document.addEventListener('pointerdown', onPointerDown, true)
document.addEventListener('touchstart', onPointerDown, true)
document.addEventListener('visibilitychange', onVisibilityChange, true)

addInitialPointerMoveListeners()

return () => {
document.removeEventListener('keydown', onKeyDown, true)
document.removeEventListener('mousedown', onPointerDown, true)
document.removeEventListener('pointerdown', onPointerDown, true)
document.removeEventListener('touchstart', onPointerDown, true)
document.removeEventListener('visibilitychange', onVisibilityChange, true)

removeInitialPointerMoveListeners()
}
}, [setHadKeyboardEvent])

return (
<FocusVisibleContext.Provider value={{ hadKeyboardEvent, isInitialized: true }}>
{children}
</FocusVisibleContext.Provider>
)
}

export default FocusVisibleProvider
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 { FocusVisibleConsumer } from '_utils/focusVisible/FocusVisibleConsumer'

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 } = FocusVisibleConsumer()
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
8 changes: 8 additions & 0 deletions src/why/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@ const StyledWhy = styled(Why)`
background-color: ${color.lightBackground};
}
:focus:not(.focus-visible) {
outline: none;
}
.focus-visible {
outline: 1;
}
/* Reset hover styles on devices not supporting hover state (e.g. touch devices). */
@media (hover: none), (hover: on-demand) {
&:hover {
Expand Down
43 changes: 32 additions & 11 deletions src/why/story.tsx
Original file line number Diff line number Diff line change
@@ -1,21 +1,42 @@
import React from 'react'
import React, { Component } from 'react'
import { FocusVisibleProvider } from '_utils/focusVisible/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>
<ul>
<li>
<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>
</li>
<li>
<button type="button">Focus provider - No consumer</button>
</li>
</ul>
</Section>
</FocusVisibleProvider>
<Section>
<button type="button">No focus provider</button>
</Section>
</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 037989c

Please sign in to comment.