diff --git a/src/components/Slider/Slider.js b/src/components/Slider/Slider.js index 4466d039..98517e20 100644 --- a/src/components/Slider/Slider.js +++ b/src/components/Slider/Slider.js @@ -1,3 +1,4 @@ +// helper functions and event handling basically copied from Material UI (https://github.com/mui-org/material-ui) Slider component import React, { useRef } from 'react'; import propTypes from 'prop-types'; @@ -10,9 +11,10 @@ import { createHatchedBackground } from '../common'; import useControlledOrUncontrolled from '../common/hooks/useControlledOrUncontrolled'; +import useForkRef from '../common/hooks/useForkRef'; +import { useIsFocusVisible } from '../common/hooks/focusVisible'; import Cutout from '../Cutout/Cutout'; -// helper functions and event handling basically copied from Material UI (https://github.com/mui-org/material-ui) Slider component function trackFinger(event, touchId) { if (touchId.current !== undefined && event.changedTouches) { for (let i = 0; i < event.changedTouches.length; i += 1) { @@ -82,19 +84,51 @@ function roundValueToStep(value, step, min) { const nearest = Math.round((value - min) / step) * step + min; return Number(nearest.toFixed(getDecimalPrecision(step))); } +function focusThumb(sliderRef) { + if (!sliderRef.current.contains(document.activeElement)) { + sliderRef.current.querySelector(`#swag`).focus(); + } +} const Wrapper = styled.div` display: inline-block; position: relative; touch-action: none; + &:before { + content: ''; + display: inline-block; + position: absolute; + top: -2px; + left: -15px; + width: calc(100% + 30px); + height: ${({ hasMarks }) => (hasMarks ? '41px' : '39px')}; + ${({ isFocused, theme }) => + isFocused && + ` + outline: 2px dotted ${theme.text}; + `} + } + ${({ vertical, size }) => vertical ? css` height: ${size}; margin-right: 1.5rem; + &:before { + left: -2px; + top: -15px; + height: calc(100% + 30px); + width: ${({ hasMarks }) => (hasMarks ? '41px' : '39px')}; + } ` : css` width: ${size}; margin-bottom: 1.5rem; + &:before { + top: -2px; + left: -15px; + width: calc(100% + 30px); + height: ${({ hasMarks }) => (hasMarks ? '41px' : '39px')}; + } `} pointer-events: ${({ isDisabled }) => (isDisabled ? 'none' : 'auto')}; @@ -220,38 +254,124 @@ const Mark = styled.div` `} `; -const Slider = ({ - value, - defaultValue, - step, - min, - max, - size, - marks: marksProp, - onChange, - onChangeCommitted, - onMouseDown, - name, - vertical, - variant, - disabled, - ...otherProps -}) => { +const Slider = React.forwardRef(function Slider(props, ref) { + const { + value, + defaultValue, + step, + min, + max, + size, + marks: marksProp, + onChange, + onChangeCommitted, + onMouseDown, + name, + vertical, + variant, + disabled, + ...otherProps + } = props; const Groove = variant === 'flat' ? StyledFlatGroove : StyledGroove; + const [valueDerived, setValueState] = useControlledOrUncontrolled({ value, defaultValue }); + const { + isFocusVisible, + onBlurVisible, + ref: focusVisibleRef + } = useIsFocusVisible(); + const [focusVisible, setFocusVisible] = React.useState(false); const sliderRef = useRef(); + const handleFocusRef = useForkRef(focusVisibleRef, sliderRef); + const handleRef = useForkRef(ref, handleFocusRef); + + const handleFocus = useEventCallback(event => { + if (isFocusVisible(event)) { + setFocusVisible(true); + } + }); + const handleBlur = useEventCallback(() => { + if (focusVisible !== false) { + setFocusVisible(false); + onBlurVisible(); + } + }); + const touchId = React.useRef(); const marks = - marksProp === true - ? Array(1 + (max - min) / step) - .fill({ label: null }) - .map((mark, i) => ({ ...mark, value: i * step })) - : marksProp; + marksProp === true && step !== null + ? [...Array(Math.floor((max - min) / step) + 1)].map((_, index) => ({ + value: min + step * index + })) + : marksProp || []; + + const handleKeyDown = useEventCallback(event => { + const tenPercents = (max - min) / 10; + const marksValues = marks.map(mark => mark.value); + const marksIndex = marksValues.indexOf(valueDerived); + let newValue; + + switch (event.key) { + case 'Home': + newValue = min; + break; + case 'End': + newValue = max; + break; + case 'PageUp': + if (step) { + newValue = valueDerived + tenPercents; + } + break; + case 'PageDown': + if (step) { + newValue = valueDerived - tenPercents; + } + break; + case 'ArrowRight': + case 'ArrowUp': + if (step) { + newValue = valueDerived + step; + } else { + newValue = + marksValues[marksIndex + 1] || marksValues[marksValues.length - 1]; + } + break; + case 'ArrowLeft': + case 'ArrowDown': + if (step) { + newValue = valueDerived - step; + } else { + newValue = marksValues[marksIndex - 1] || marksValues[0]; + } + break; + default: + return; + } + + // Prevent scroll of the page + event.preventDefault(); + if (step) { + newValue = roundValueToStep(newValue, step, min); + } + + newValue = clamp(newValue, min, max); + + setValueState(newValue); + setFocusVisible(true); + + if (onChange) { + onChange(newValue); + } + if (onChangeCommitted) { + onChangeCommitted(newValue); + } + }); const getNewValue = React.useCallback( finger => { @@ -288,7 +408,9 @@ const Slider = ({ } const newValue = getNewValue(finger); + focusThumb(sliderRef); setValueState(newValue); + setFocusVisible(true); if (onChange) { onChange(newValue); @@ -302,6 +424,7 @@ const Slider = ({ } const newValue = getNewValue(finger); + if (onChangeCommitted) { onChangeCommitted(newValue); } @@ -322,8 +445,11 @@ const Slider = ({ event.preventDefault(); const finger = trackFinger(event, touchId); const newValue = getNewValue(finger); + focusThumb(sliderRef); setValueState(newValue); + setFocusVisible(true); + if (onChange) { onChange(newValue); } @@ -341,7 +467,10 @@ const Slider = ({ } const finger = trackFinger(event, touchId); const newValue = getNewValue(finger); + focusThumb(sliderRef); + setValueState(newValue); + setFocusVisible(true); if (onChange) { onChange(newValue); @@ -371,7 +500,9 @@ const Slider = ({ vertical={vertical} size={size} onMouseDown={handleMouseDown} - ref={sliderRef} + ref={handleRef} + isFocused={focusVisible} + hasMarks={marks.length} {...otherProps} > {/* should we keep the hidden input ? */} @@ -403,10 +534,12 @@ const Slider = ({ ); -}; +}); Slider.defaultProps = { defaultValue: undefined, diff --git a/src/components/Slider/Slider.spec.js b/src/components/Slider/Slider.spec.js index 945da7e7..333c47b5 100644 --- a/src/components/Slider/Slider.spec.js +++ b/src/components/Slider/Slider.spec.js @@ -1,3 +1,5 @@ +// Pretty much straight out copied from https://github.com/mui-org/material-ui 😂 + import React from 'react'; import { fireEvent } from '@testing-library/react'; @@ -21,7 +23,7 @@ describe('', () => { const handleChange = jest.fn(); const handleChangeCommitted = jest.fn(); - const { container } = renderWithTheme( + const { container, getByRole } = renderWithTheme( ', () => { expect(handleChange).toHaveBeenCalledTimes(1); expect(handleChangeCommitted).toHaveBeenCalledTimes(1); + + getByRole('slider').focus(); + fireEvent.keyDown(document.activeElement, { + key: 'Home' + }); + expect(handleChange).toHaveBeenCalledTimes(2); + expect(handleChangeCommitted).toHaveBeenCalledTimes(2); }); it('should only listen to changes from the same touchpoint', () => { @@ -110,6 +119,17 @@ describe('', () => { createTouches([{ identifier: 1, clientX: 22, clientY: 0 }]) ); expect(thumb).toHaveAttribute('aria-valuenow', '20'); + + thumb.focus(); + fireEvent.keyDown(document.activeElement, { + key: 'ArrowUp' + }); + expect(thumb).toHaveAttribute('aria-valuenow', '30'); + + fireEvent.keyDown(document.activeElement, { + key: 'ArrowDown' + }); + expect(thumb).toHaveAttribute('aria-valuenow', '20'); }); }); @@ -133,6 +153,143 @@ describe('', () => { }); }); + describe('keyboard', () => { + it('should handle all the keys', () => { + const { getByRole } = renderWithTheme(); + const thumb = getByRole('slider'); + thumb.focus(); + + fireEvent.keyDown(document.activeElement, { + key: 'Home' + }); + expect(thumb).toHaveAttribute('aria-valuenow', '0'); + + fireEvent.keyDown(document.activeElement, { + key: 'End' + }); + expect(thumb).toHaveAttribute('aria-valuenow', '100'); + + fireEvent.keyDown(document.activeElement, { + key: 'PageDown' + }); + expect(thumb).toHaveAttribute('aria-valuenow', '90'); + + fireEvent.keyDown(document.activeElement, { + key: 'Escape' + }); + expect(thumb).toHaveAttribute('aria-valuenow', '90'); + + fireEvent.keyDown(document.activeElement, { + key: 'PageUp' + }); + expect(thumb).toHaveAttribute('aria-valuenow', '100'); + }); + + const moveLeftEvent = { + key: 'ArrowLeft' + }; + const moveRightEvent = { + key: 'ArrowRight' + }; + + it('should use min as the step origin', () => { + const { getByRole } = renderWithTheme( + + ); + const thumb = getByRole('slider'); + thumb.focus(); + + fireEvent.keyDown(document.activeElement, moveRightEvent); + expect(thumb).toHaveAttribute('aria-valuenow', '250'); + + fireEvent.keyDown(document.activeElement, moveLeftEvent); + expect(thumb).toHaveAttribute('aria-valuenow', '150'); + }); + + it('should reach right edge value', () => { + const { getByRole } = renderWithTheme( + + ); + const thumb = getByRole('slider'); + thumb.focus(); + + fireEvent.keyDown(document.activeElement, moveRightEvent); + expect(thumb).toHaveAttribute('aria-valuenow', '96'); + + fireEvent.keyDown(document.activeElement, moveRightEvent); + expect(thumb).toHaveAttribute('aria-valuenow', '106'); + + fireEvent.keyDown(document.activeElement, moveRightEvent); + expect(thumb).toHaveAttribute('aria-valuenow', '108'); + + fireEvent.keyDown(document.activeElement, moveLeftEvent); + expect(thumb).toHaveAttribute('aria-valuenow', '96'); + + fireEvent.keyDown(document.activeElement, moveLeftEvent); + expect(thumb).toHaveAttribute('aria-valuenow', '86'); + }); + + it('should reach left edge value', () => { + const { getByRole } = renderWithTheme( + + ); + const thumb = getByRole('slider'); + thumb.focus(); + + fireEvent.keyDown(document.activeElement, moveLeftEvent); + expect(thumb).toHaveAttribute('aria-valuenow', '6'); + + fireEvent.keyDown(document.activeElement, moveRightEvent); + expect(thumb).toHaveAttribute('aria-valuenow', '16'); + + fireEvent.keyDown(document.activeElement, moveRightEvent); + expect(thumb).toHaveAttribute('aria-valuenow', '26'); + }); + + it('should round value to step precision', () => { + const { getByRole } = renderWithTheme( + + ); + const thumb = getByRole('slider'); + thumb.focus(); + + fireEvent.keyDown(document.activeElement, moveRightEvent); + expect(thumb).toHaveAttribute('aria-valuenow', '0.3'); + }); + + it('should not fail to round value to step precision when step is very small', () => { + const { getByRole } = renderWithTheme( + + ); + const thumb = getByRole('slider'); + thumb.focus(); + + fireEvent.keyDown(document.activeElement, moveRightEvent); + expect(thumb).toHaveAttribute('aria-valuenow', '3e-8'); + }); + + it('should not fail to round value to step precision when step is very small and negative', () => { + const { getByRole } = renderWithTheme( + + ); + const thumb = getByRole('slider'); + thumb.focus(); + + fireEvent.keyDown(document.activeElement, moveLeftEvent); + expect(thumb).toHaveAttribute('aria-valuenow', '-3e-8'); + }); + }); + describe('prop: vertical', () => { it('should render with aria-orientation attribute set to "vertical" ', () => { const { getByRole } = renderWithTheme(); diff --git a/src/components/common/hooks/focusVisible.js b/src/components/common/hooks/focusVisible.js new file mode 100644 index 00000000..b3a0af5e --- /dev/null +++ b/src/components/common/hooks/focusVisible.js @@ -0,0 +1,145 @@ +// Straight out copied from https://github.com/mui-org/material-ui 😂 +// based on https://github.com/WICG/focus-visible/blob/v4.1.5/src/focus-visible.js +import * as React from 'react'; +import * as ReactDOM from 'react-dom'; + +let hadKeyboardEvent = true; +let hadFocusVisibleRecently = false; +let hadFocusVisibleRecentlyTimeout = null; + +const inputTypesWhitelist = { + text: true, + search: true, + url: true, + tel: true, + email: true, + password: true, + number: true, + date: true, + month: true, + week: true, + time: true, + datetime: true, + 'datetime-local': true +}; + +/** + * Computes whether the given element should automatically trigger the + * `focus-visible` class being added, i.e. whether it should always match + * `:focus-visible` when focused. + * @param {Element} node + * @return {boolean} + */ +function focusTriggersKeyboardModality(node) { + const { type, tagName } = node; + + if (tagName === 'INPUT' && inputTypesWhitelist[type] && !node.readOnly) { + return true; + } + + if (tagName === 'TEXTAREA' && !node.readOnly) { + return true; + } + + if (node.isContentEditable) { + return true; + } + + return false; +} + +/** + * Keep track of our keyboard modality state with `hadKeyboardEvent`. + * 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. + * @param {KeyboardEvent} event + */ +function handleKeyDown(event) { + if (event.metaKey || event.altKey || event.ctrlKey) { + return; + } + hadKeyboardEvent = true; +} + +/** + * If at any point a user clicks with a pointing device, ensure that we change + * the modality away from keyboard. + * This avoids the situation where a user presses a key on an already focused + * element, and then clicks on a different element, focusing it with a + * pointing device, while we still think we're in keyboard modality. + */ +function handlePointerDown() { + hadKeyboardEvent = false; +} + +function handleVisibilityChange() { + if (this.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. + if (hadFocusVisibleRecently) { + hadKeyboardEvent = true; + } + } +} + +function prepare(doc) { + doc.addEventListener('keydown', handleKeyDown, true); + doc.addEventListener('mousedown', handlePointerDown, true); + doc.addEventListener('pointerdown', handlePointerDown, true); + doc.addEventListener('touchstart', handlePointerDown, true); + doc.addEventListener('visibilitychange', handleVisibilityChange, true); +} + +export function teardown(doc) { + doc.removeEventListener('keydown', handleKeyDown, true); + doc.removeEventListener('mousedown', handlePointerDown, true); + doc.removeEventListener('pointerdown', handlePointerDown, true); + doc.removeEventListener('touchstart', handlePointerDown, true); + doc.removeEventListener('visibilitychange', handleVisibilityChange, true); +} + +function isFocusVisible(event) { + const { target } = event; + try { + return target.matches(':focus-visible'); + } catch (error) { + // browsers not implementing :focus-visible will throw a SyntaxError + // we use our own heuristic for those browsers + // rethrow might be better if it's not the expected error but do we really + // want to crash if focus-visible malfunctioned? + } + + // no need for validFocusTarget check. the user does that by attaching it to + // focusable events only + return hadKeyboardEvent || focusTriggersKeyboardModality(target); +} + +/** + * Should be called if a blur event is fired on a focus-visible element + */ +function handleBlurVisible() { + // To detect a tab/window switch, we look for a blur event followed + // rapidly by a visibility change. + // If we don't see a visibility change within 100ms, it's probably a + // regular focus change. + hadFocusVisibleRecently = true; + window.clearTimeout(hadFocusVisibleRecentlyTimeout); + hadFocusVisibleRecentlyTimeout = window.setTimeout(() => { + hadFocusVisibleRecently = false; + }, 100); +} + +export function useIsFocusVisible() { + const ref = React.useCallback(instance => { + // eslint-disable-next-line react/no-find-dom-node + const node = ReactDOM.findDOMNode(instance); + if (node != null) { + prepare(node.ownerDocument); + } + }, []); + + return { isFocusVisible, onBlurVisible: handleBlurVisible, ref }; +} diff --git a/src/components/common/hooks/useForkRef.js b/src/components/common/hooks/useForkRef.js new file mode 100644 index 00000000..41c77065 --- /dev/null +++ b/src/components/common/hooks/useForkRef.js @@ -0,0 +1,28 @@ +// Straight out copied from https://github.com/mui-org/material-ui 😂 +import * as React from 'react'; + +function setRef(ref, value) { + if (typeof ref === 'function') { + ref(value); + } else if (ref) { + // eslint-disable-next-line no-param-reassign + ref.current = value; + } +} + +export default function useForkRef(refA, refB) { + /** + * This will create a new function if the ref props change and are defined. + * This means react will call the old forkRef with `null` and the new forkRef + * with the ref. Cleanup naturally emerges from this behavior + */ + return React.useMemo(() => { + if (refA == null && refB == null) { + return null; + } + return refValue => { + setRef(refA, refValue); + setRef(refB, refValue); + }; + }, [refA, refB]); +}