diff --git a/CHANGELOG.md b/CHANGELOG.md
index d0007445ab..c07dac1691 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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)
diff --git a/src/_utils/focusVisibleProvider/index.tsx b/src/_utils/focusVisibleProvider/index.tsx
new file mode 100644
index 0000000000..819028542c
--- /dev/null
+++ b/src/_utils/focusVisibleProvider/index.tsx
@@ -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 (
+ {children}
+ )
+}
+
+export default FocusVisibleProvider
diff --git a/src/_utils/focusVisibleProvider/index.unit.tsx b/src/_utils/focusVisibleProvider/index.unit.tsx
new file mode 100644
index 0000000000..0c78224a17
--- /dev/null
+++ b/src/_utils/focusVisibleProvider/index.unit.tsx
@@ -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(
+
+
+ ,
+ )
+ })
+ 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)
+ })
+})
diff --git a/src/_utils/focusVisibleProvider/useFocusVisible.tsx b/src/_utils/focusVisibleProvider/useFocusVisible.tsx
new file mode 100644
index 0000000000..334377edb5
--- /dev/null
+++ b/src/_utils/focusVisibleProvider/useFocusVisible.tsx
@@ -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
diff --git a/src/_utils/focusVisibleProvider/useFocusVisible.unit.tsx b/src/_utils/focusVisibleProvider/useFocusVisible.unit.tsx
new file mode 100644
index 0000000000..f81ad75110
--- /dev/null
+++ b/src/_utils/focusVisibleProvider/useFocusVisible.unit.tsx
@@ -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 (
+
+ )
+}
+
+describe('useFocusVisible', () => {
+ beforeEach(() => {
+ wrapper = mount(
+
+
+ ,
+ )
+ })
+ 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)
+ })
+})
diff --git a/src/_utils/keycodes.ts b/src/_utils/keycodes.ts
index 3deb80a103..49d2d0e466 100644
--- a/src/_utils/keycodes.ts
+++ b/src/_utils/keycodes.ts
@@ -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
diff --git a/src/why/Why.tsx b/src/why/Why.tsx
index ca33641bf3..91e2a09c3a 100644
--- a/src/why/Why.tsx
+++ b/src/why/Why.tsx
@@ -1,5 +1,6 @@
import React from 'react'
import cc from 'classcat'
+import { useFocusVisible } from '_utils/focusVisibleProvider/useFocusVisible'
import QuestionIcon from 'icon/questionIcon'
@@ -10,11 +11,27 @@ export interface WhyProps {
readonly onClick?: () => void
}
-const Why = ({ className, children, title, onClick }: WhyProps) => (
-
-)
+const Why = ({ className, children, title, onClick }: WhyProps) => {
+ const { focusVisible, onFocus, onBlur } = useFocusVisible()
+ return (
+
+ )
+}
export default Why
diff --git a/src/why/index.tsx b/src/why/index.tsx
index 368da50ce2..05d8879b89 100644
--- a/src/why/index.tsx
+++ b/src/why/index.tsx
@@ -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 {
diff --git a/src/why/story.tsx b/src/why/story.tsx
index 15506f091c..8371560aca 100644
--- a/src/why/story.tsx
+++ b/src/why/story.tsx
@@ -1,4 +1,5 @@
-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'
@@ -6,16 +7,26 @@ import { action } from '@storybook/addon-actions'
import Section from 'layout/section/baseSection'
import Why from 'why'
+class LayoutExample extends Component {
+ render() {
+ return (
+
+
+
+
+ {text('Text', 'Why this is a text that is so long ?')}
+
+
+
+
+ )
+ }
+}
+
const stories = storiesOf('Widgets|Why', module)
stories.addDecorator(withKnobs)
-stories.add('default', () => (
-
-
- {text('Text', 'Why this is a text that is so long ?')}
-
-
-))
+stories.add('default', () => )