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', () => )