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]);
+}