From 375646b55d2db342a7ba200389084df3548790db Mon Sep 17 00:00:00 2001 From: Wes Souza Date: Tue, 26 Jul 2022 09:34:39 -0400 Subject: [PATCH] feat(slider): convert to TypeScript and export types --- src/Slider/Slider.js | 579 ---------------- .../{Slider.spec.js => Slider.spec.tsx} | 108 +-- .../{Slider.stories.js => Slider.stories.tsx} | 7 +- src/Slider/Slider.tsx | 638 ++++++++++++++++++ src/common/hooks/useIsFocusVisible.ts | 40 +- src/index.ts | 2 +- test/utils.tsx | 42 +- 7 files changed, 751 insertions(+), 665 deletions(-) delete mode 100644 src/Slider/Slider.js rename src/Slider/{Slider.spec.js => Slider.spec.tsx} (76%) rename src/Slider/{Slider.stories.js => Slider.stories.tsx} (96%) create mode 100644 src/Slider/Slider.tsx diff --git a/src/Slider/Slider.js b/src/Slider/Slider.js deleted file mode 100644 index 6b4db6a0..00000000 --- a/src/Slider/Slider.js +++ /dev/null @@ -1,579 +0,0 @@ -// 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'; - -import styled, { css } from 'styled-components'; -import { - createBoxStyles, - createBorderStyles, - createFlatBoxStyles, - createDisabledTextStyles, - createHatchedBackground -} from '../common'; -import { clamp, getSize, roundValueToStep } from '../common/utils'; -import useControlledOrUncontrolled from '../common/hooks/useControlledOrUncontrolled'; -import useForkRef from '../common/hooks/useForkRef'; -import { useIsFocusVisible } from '../common/hooks/useIsFocusVisible'; -import { StyledCutout } from '../Cutout/Cutout'; - -function percentToValue(percent, min, max) { - return (max - min) * percent + min; -} - -function trackFinger(event, touchId) { - if (touchId.current !== undefined && event.changedTouches) { - for (let i = 0; i < event.changedTouches.length; i += 1) { - const touch = event.changedTouches[i]; - if (touch.identifier === touchId.current) { - return { - x: touch.clientX, - y: touch.clientY - }; - } - } - return false; - } - return { - x: event.clientX, - y: event.clientY - }; -} -const useEnhancedEffect = - typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect; - -function useEventCallback(fn) { - const ref = React.useRef(fn); - useEnhancedEffect(() => { - ref.current = fn; - }); - return React.useCallback((...args) => (0, ref.current)(...args), []); -} -function ownerDocument(node) { - return (node && node.ownerDocument) || document; -} -function findClosest(values, currentValue) { - const { index: closestIndex } = values.reduce((acc, value, index) => { - const distance = Math.abs(currentValue - value); - - if (acc === null || distance < acc.distance || distance === acc.distance) { - return { - distance, - index - }; - } - - return acc; - }, null); - return closestIndex; -} - -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.materialText}; - `} - } - - ${({ vertical, size }) => - vertical - ? css` - height: ${size}; - margin-right: 1.5rem; - &:before { - left: -6px; - 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')}; -`; -const sharedGrooveStyles = () => css` - position: absolute; - ${({ vertical }) => - vertical - ? css` - bottom: 0; - left: 50%; - transform: translateX(-50%); - height: 100%; - width: 8px; - ` - : css` - left: 0; - top: 50%; - transform: translateY(-50%); - height: 8px; - width: 100%; - `} -`; -const StyledGroove = styled(StyledCutout)` - ${sharedGrooveStyles()} -`; -const StyledFlatGroove = styled(StyledCutout)` - ${sharedGrooveStyles()} - - border-left-color: ${({ theme }) => theme.flatLight}; - border-top-color: ${({ theme }) => theme.flatLight}; - border-right-color: ${({ theme }) => theme.canvas}; - border-bottom-color: ${({ theme }) => theme.canvas}; - &:before { - border-left-color: ${({ theme }) => theme.flatDark}; - border-top-color: ${({ theme }) => theme.flatDark}; - border-right-color: ${({ theme }) => theme.flatLight}; - border-bottom-color: ${({ theme }) => theme.flatLight}; - } -`; -const Thumb = styled.span` - position: relative; - ${({ vertical }) => - vertical - ? css` - width: 32px; - height: 18px; - right: 2px; - transform: translateY(-50%); - ` - : css` - height: 32px; - width: 18px; - top: 2px; - transform: translateX(-50%); - `} - ${({ variant }) => - variant === 'flat' - ? css` - ${createFlatBoxStyles()} - outline: 2px solid ${({ theme }) => theme.flatDark}; - background: ${({ theme }) => theme.flatLight}; - ` - : css` - ${createBoxStyles()} - ${createBorderStyles()} - `} - ${({ isDisabled, theme }) => - isDisabled && - createHatchedBackground({ - mainColor: theme.material, - secondaryColor: theme.borderLightest - })} -`; - -const tickHeight = 6; -const Tick = styled.span` - display: inline-block; - position: absolute; - - ${({ vertical }) => - vertical - ? css` - right: ${-tickHeight - 2}px; - bottom: 0px; - transform: translateY(1px); - width: ${tickHeight}px; - border-bottom: 2px solid ${({ theme }) => theme.materialText}; - ` - : css` - bottom: ${-tickHeight}px; - height: ${tickHeight}px; - transform: translateX(-1px); - border-left: 1px solid ${({ theme }) => theme.materialText}; - border-right: 1px solid ${({ theme }) => theme.materialText}; - `} - - color: ${({ theme }) => theme.materialText}; - ${({ isDisabled, theme }) => - isDisabled && - css` - ${createDisabledTextStyles()} - box-shadow: 1px 1px 0px ${theme.materialTextDisabledShadow}; - border-color: ${theme.materialTextDisabled}; - `} -`; -const Mark = styled.div` - position: absolute; - bottom: 0; - left: 0; - line-height: 1; - font-size: 0.875rem; - - ${({ vertical }) => - vertical - ? css` - transform: translate(${tickHeight + 2}px, ${tickHeight + 1}px); - ` - : css` - transform: translate(-0.5ch, calc(100% + 2px)); - `} -`; - -const Slider = React.forwardRef(function Slider(props, ref) { - const { - value, - defaultValue, - step, - min, - max, - size, - marks: marksProp, - onChange, - onChangeCommitted, - onMouseDown, - name, - orientation, - variant, - disabled, - ...otherProps - } = props; - const Groove = variant === 'flat' ? StyledFlatGroove : StyledGroove; - const vertical = orientation === 'vertical'; - 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 && step !== null - ? [...Array(Math.round((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(event, newValue); - } - if (onChangeCommitted) { - onChangeCommitted(event, newValue); - } - }); - - const getNewValue = React.useCallback( - finger => { - const { current: slider } = sliderRef; - const rect = slider.getBoundingClientRect(); - - let percent; - if (vertical) { - percent = (rect.bottom - finger.y) / rect.height; - } else { - percent = (finger.x - rect.left) / rect.width; - } - let newValue; - - newValue = percentToValue(percent, min, max); - if (step) { - newValue = roundValueToStep(newValue, step, min); - } else { - const marksValues = marks.map(mark => mark.value); - const closestIndex = findClosest(marksValues, newValue); - newValue = marksValues[closestIndex]; - } - newValue = clamp(newValue, min, max); - return newValue; - }, - [max, min, step] - ); - - const handleTouchMove = useEventCallback(event => { - const finger = trackFinger(event, touchId); - - if (!finger) { - return; - } - const newValue = getNewValue(finger); - - focusThumb(sliderRef); - setValueState(newValue); - setFocusVisible(true); - - if (onChange) { - onChange(event, newValue); - } - }); - const handleTouchEnd = useEventCallback(event => { - const finger = trackFinger(event, touchId); - - if (!finger) { - return; - } - - const newValue = getNewValue(finger); - - if (onChangeCommitted) { - onChangeCommitted(event, newValue); - } - - touchId.current = undefined; - - const doc = ownerDocument(sliderRef.current); - doc.removeEventListener('mousemove', handleTouchMove); - doc.removeEventListener('mouseup', handleTouchEnd); - doc.removeEventListener('touchmove', handleTouchMove); - doc.removeEventListener('touchend', handleTouchEnd); - }); - const handleMouseDown = useEventCallback(event => { - // TODO should we also pass event together with new value to callbacks? (same thing with other input components) - if (onMouseDown) { - onMouseDown(event); - } - event.preventDefault(); - const finger = trackFinger(event, touchId); - const newValue = getNewValue(finger); - focusThumb(sliderRef); - - setValueState(newValue); - setFocusVisible(true); - - if (onChange) { - onChange(event, newValue); - } - const doc = ownerDocument(sliderRef.current); - doc.addEventListener('mousemove', handleTouchMove); - doc.addEventListener('mouseup', handleTouchEnd); - }); - const handleTouchStart = useEventCallback(event => { - // Workaround as Safari has partial support for touchAction: 'none'. - event.preventDefault(); - const touch = event.changedTouches[0]; - if (touch != null) { - // A number that uniquely identifies the current finger in the touch session. - touchId.current = touch.identifier; - } - const finger = trackFinger(event, touchId); - const newValue = getNewValue(finger); - focusThumb(sliderRef); - - setValueState(newValue); - setFocusVisible(true); - - if (onChange) { - onChange(event, newValue); - } - - const doc = ownerDocument(sliderRef.current); - doc.addEventListener('touchmove', handleTouchMove); - doc.addEventListener('touchend', handleTouchEnd); - }); - React.useEffect(() => { - const { current: slider } = sliderRef; - slider.addEventListener('touchstart', handleTouchStart); - const doc = ownerDocument(slider); - - return () => { - slider.removeEventListener('touchstart', handleTouchStart); - doc.removeEventListener('mousemove', handleTouchMove); - doc.removeEventListener('mouseup', handleTouchEnd); - doc.removeEventListener('touchmove', handleTouchMove); - doc.removeEventListener('touchend', handleTouchEnd); - }; - }, [handleTouchEnd, handleTouchMove, handleTouchStart]); - - - return ( - - {/* should we keep the hidden input ? */} - - {marks && - marks.map(m => ( - - {m.label && ( - - {m.label} - - )} - - ))} - - - - ); -}); - -Slider.defaultProps = { - defaultValue: undefined, - value: undefined, - step: 1, - min: 0, - max: 100, - size: '100%', - onChange: null, - onChangeCommitted: null, - onMouseDown: null, - - name: null, - marks: false, - variant: 'default', - orientation: 'horizontal', - disabled: false -}; - -Slider.propTypes = { - value: propTypes.number, - defaultValue: propTypes.number, - - step: propTypes.number, - min: propTypes.number, - max: propTypes.number, - size: propTypes.oneOfType([propTypes.string, propTypes.number]), - onChange: propTypes.func, - onChangeCommitted: propTypes.func, - onMouseDown: propTypes.func, - - name: propTypes.string, - marks: propTypes.oneOfType([propTypes.bool, propTypes.array]), - variant: propTypes.oneOf(['default', 'flat']), - orientation: propTypes.oneOf(['horizontal', 'vertical']), - disabled: propTypes.bool -}; -export default Slider; diff --git a/src/Slider/Slider.spec.js b/src/Slider/Slider.spec.tsx similarity index 76% rename from src/Slider/Slider.spec.js rename to src/Slider/Slider.spec.tsx index ebee1e21..e377e47b 100644 --- a/src/Slider/Slider.spec.js +++ b/src/Slider/Slider.spec.tsx @@ -1,20 +1,15 @@ // Pretty much straight out copied from https://github.com/mui-org/material-ui 😂 -import React from 'react'; import { fireEvent } from '@testing-library/react'; import { renderWithTheme, Touch } from '../../test/utils'; -import Slider from './Slider'; +import { Slider } from './Slider'; -function createTouches(touches) { +function createTouches( + touches: { identifier: number; clientX?: number; clientY?: number }[] +) { return { - changedTouches: touches.map( - touch => - new Touch({ - target: document.body, - ...touch - }) - ) + changedTouches: touches.map(touch => new Touch(touch)) }; } @@ -31,14 +26,16 @@ describe('', () => { /> ); - fireEvent.mouseDown(container.firstChild); + const slider = container.firstElementChild as HTMLElement; + fireEvent.mouseDown(slider); fireEvent.mouseUp(document.body); expect(handleChange).toHaveBeenCalledTimes(1); expect(handleChangeCommitted).toHaveBeenCalledTimes(1); getByRole('slider').focus(); - fireEvent.keyDown(document.activeElement, { + const focusedSlider = document.activeElement as HTMLElement; + fireEvent.keyDown(focusedSlider, { key: 'Home' }); expect(handleChange).toHaveBeenCalledTimes(2); @@ -56,10 +53,8 @@ describe('', () => { /> ); - fireEvent.touchStart( - container.firstChild, - createTouches([{ identifier: 1 }]) - ); + const slider = container.firstElementChild as HTMLElement; + fireEvent.touchStart(slider, createTouches([{ identifier: 1 }])); expect(handleChange).toHaveBeenCalledTimes(1); expect(handleChangeCommitted).not.toHaveBeenCalled(); @@ -93,7 +88,8 @@ describe('', () => { const { container } = renderWithTheme( ); - fireEvent.mouseDown(container.firstChild); + const slider = container.firstElementChild as HTMLElement; + fireEvent.mouseDown(slider); expect(handleMouseDown).toHaveBeenCalledTimes(1); }); describe('prop: step', () => { @@ -105,28 +101,30 @@ describe('', () => { defaultValue={0} /> ); + const slider = container.firstElementChild as HTMLElement; // mocking containers size - container.firstChild.getBoundingClientRect = () => ({ - width: 100, - height: 20, - bottom: 20, - left: 0 - }); + slider.getBoundingClientRect = () => + ({ + width: 100, + height: 20, + bottom: 20, + left: 0 + } as DOMRect); const thumb = getByRole('slider'); fireEvent.touchStart( - container.firstChild, + slider, createTouches([{ identifier: 1, clientX: 22, clientY: 0 }]) ); expect(thumb).toHaveAttribute('aria-valuenow', '20'); thumb.focus(); - fireEvent.keyDown(document.activeElement, { + fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowUp' }); expect(thumb).toHaveAttribute('aria-valuenow', '30'); - fireEvent.keyDown(document.activeElement, { + fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'ArrowDown' }); expect(thumb).toHaveAttribute('aria-valuenow', '20'); @@ -143,11 +141,10 @@ describe('', () => { disabled /> ); + const slider = container.firstElementChild as HTMLElement; const thumb = getByRole('slider'); expect( - window - .getComputedStyle(container.firstChild, null) - .getPropertyValue('pointer-events') + window.getComputedStyle(slider, null).getPropertyValue('pointer-events') ).toBe('none'); expect(thumb).toHaveAttribute('aria-disabled', 'true'); }); @@ -159,27 +156,27 @@ describe('', () => { const thumb = getByRole('slider'); thumb.focus(); - fireEvent.keyDown(document.activeElement, { + fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Home' }); expect(thumb).toHaveAttribute('aria-valuenow', '0'); - fireEvent.keyDown(document.activeElement, { + fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'End' }); expect(thumb).toHaveAttribute('aria-valuenow', '100'); - fireEvent.keyDown(document.activeElement, { + fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'PageDown' }); expect(thumb).toHaveAttribute('aria-valuenow', '90'); - fireEvent.keyDown(document.activeElement, { + fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'Escape' }); expect(thumb).toHaveAttribute('aria-valuenow', '90'); - fireEvent.keyDown(document.activeElement, { + fireEvent.keyDown(document.activeElement as HTMLElement, { key: 'PageUp' }); expect(thumb).toHaveAttribute('aria-valuenow', '100'); @@ -199,10 +196,10 @@ describe('', () => { const thumb = getByRole('slider'); thumb.focus(); - fireEvent.keyDown(document.activeElement, moveRightEvent); + fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent); expect(thumb).toHaveAttribute('aria-valuenow', '6'); - fireEvent.keyDown(document.activeElement, moveLeftEvent); + fireEvent.keyDown(document.activeElement as HTMLElement, moveLeftEvent); expect(thumb).toHaveAttribute('aria-valuenow', '4'); expect(thumb.style.left).toBe('20%'); @@ -215,19 +212,19 @@ describe('', () => { const thumb = getByRole('slider'); thumb.focus(); - fireEvent.keyDown(document.activeElement, moveRightEvent); + fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent); expect(thumb).toHaveAttribute('aria-valuenow', '96'); - fireEvent.keyDown(document.activeElement, moveRightEvent); + fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent); expect(thumb).toHaveAttribute('aria-valuenow', '106'); - fireEvent.keyDown(document.activeElement, moveRightEvent); + fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent); expect(thumb).toHaveAttribute('aria-valuenow', '108'); - fireEvent.keyDown(document.activeElement, moveLeftEvent); + fireEvent.keyDown(document.activeElement as HTMLElement, moveLeftEvent); expect(thumb).toHaveAttribute('aria-valuenow', '96'); - fireEvent.keyDown(document.activeElement, moveLeftEvent); + fireEvent.keyDown(document.activeElement as HTMLElement, moveLeftEvent); expect(thumb).toHaveAttribute('aria-valuenow', '86'); }); @@ -238,13 +235,13 @@ describe('', () => { const thumb = getByRole('slider'); thumb.focus(); - fireEvent.keyDown(document.activeElement, moveLeftEvent); + fireEvent.keyDown(document.activeElement as HTMLElement, moveLeftEvent); expect(thumb).toHaveAttribute('aria-valuenow', '6'); - fireEvent.keyDown(document.activeElement, moveRightEvent); + fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent); expect(thumb).toHaveAttribute('aria-valuenow', '16'); - fireEvent.keyDown(document.activeElement, moveRightEvent); + fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent); expect(thumb).toHaveAttribute('aria-valuenow', '26'); }); @@ -255,7 +252,7 @@ describe('', () => { const thumb = getByRole('slider'); thumb.focus(); - fireEvent.keyDown(document.activeElement, moveRightEvent); + fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent); expect(thumb).toHaveAttribute('aria-valuenow', '0.3'); }); @@ -271,7 +268,7 @@ describe('', () => { const thumb = getByRole('slider'); thumb.focus(); - fireEvent.keyDown(document.activeElement, moveRightEvent); + fireEvent.keyDown(document.activeElement as HTMLElement, moveRightEvent); expect(thumb).toHaveAttribute('aria-valuenow', '3e-8'); }); @@ -287,7 +284,7 @@ describe('', () => { const thumb = getByRole('slider'); thumb.focus(); - fireEvent.keyDown(document.activeElement, moveLeftEvent); + fireEvent.keyDown(document.activeElement as HTMLElement, moveLeftEvent); expect(thumb).toHaveAttribute('aria-valuenow', '-3e-8'); }); }); @@ -314,16 +311,19 @@ describe('', () => { /> ); + const slider = container.firstElementChild as HTMLElement; + // mocking containers size - container.firstChild.getBoundingClientRect = () => ({ - width: 20, - height: 100, - bottom: 100, - left: 0 - }); + slider.getBoundingClientRect = () => + ({ + width: 20, + height: 100, + bottom: 100, + left: 0 + } as DOMRect); fireEvent.touchStart( - container.firstChild, + slider, createTouches([{ identifier: 1, clientX: 0, clientY: 20 }]) ); fireEvent.touchMove( diff --git a/src/Slider/Slider.stories.js b/src/Slider/Slider.stories.tsx similarity index 96% rename from src/Slider/Slider.stories.js rename to src/Slider/Slider.stories.tsx index 2090e585..caec2332 100644 --- a/src/Slider/Slider.stories.js +++ b/src/Slider/Slider.stories.tsx @@ -1,8 +1,7 @@ -import React from 'react'; +import { ComponentMeta } from '@storybook/react'; +import { Cutout, Slider } from 'react95'; import styled from 'styled-components'; -import { Slider, Cutout } from '..'; - const Wrapper = styled.div` background: ${({ theme }) => theme.material}; padding: 5rem; @@ -38,7 +37,7 @@ export default { title: 'Slider', component: Slider, decorators: [story => {story()}] -}; +} as ComponentMeta; export function Default() { return ( diff --git a/src/Slider/Slider.tsx b/src/Slider/Slider.tsx new file mode 100644 index 00000000..4fcfd70f --- /dev/null +++ b/src/Slider/Slider.tsx @@ -0,0 +1,638 @@ +// helper functions and event handling basically copied from Material UI (https://github.com/mui-org/material-ui) Slider component +import React, { + forwardRef, + useCallback, + useEffect, + useMemo, + useRef, + useState +} from 'react'; +import styled, { css } from 'styled-components'; + +import { + createBorderStyles, + createBoxStyles, + createDisabledTextStyles, + createFlatBoxStyles, + createHatchedBackground +} from '../common'; +import useControlledOrUncontrolled from '../common/hooks/useControlledOrUncontrolled'; +import useForkRef from '../common/hooks/useForkRef'; +import { useIsFocusVisible } from '../common/hooks/useIsFocusVisible'; +import { clamp, getSize, roundValueToStep } from '../common/utils'; +import { StyledCutout } from '../Cutout/Cutout'; +import { CommonStyledProps } from '../types'; + +type SliderProps = { + defaultValue?: number; + disabled?: boolean; + marks?: boolean | { label?: string; value: number }[]; + max?: number; + min?: number; + name?: string; + onChange?: ( + event: + | MouseEvent + | React.KeyboardEvent + | React.MouseEvent + | TouchEvent, + value: number + ) => void; + onChangeCommitted?: ( + event: MouseEvent | React.KeyboardEvent | TouchEvent, + value: number + ) => void; + onMouseDown?: (event: React.MouseEvent) => void; + orientation?: 'horizontal' | 'vertical'; + size?: string | number; + step?: number | null; + value?: number; + variant?: 'default' | 'flat'; +} & React.HTMLAttributes & + CommonStyledProps; + +function percentToValue(percent: number, min: number, max: number) { + return (max - min) * percent + min; +} + +function trackFinger( + event: MouseEvent | React.MouseEvent | TouchEvent, + touchId: number | undefined +) { + if (touchId !== undefined && 'changedTouches' in event) { + for (let i = 0; i < event.changedTouches.length; i += 1) { + const touch = event.changedTouches[i]; + if (touch.identifier === touchId) { + return { + x: touch.clientX, + y: touch.clientY + }; + } + } + + return false; + } + + if ('clientX' in event) { + return { + x: event.clientX, + y: event.clientY + }; + } + + return false; +} + +function ownerDocument(node?: Element) { + return (node && node.ownerDocument) || document; +} + +function findClosest(values: number[], currentValue: number) { + const { index: closestIndex } = + values.reduce<{ + distance: number; + index: number; + } | null>((acc, value, index) => { + const distance = Math.abs(currentValue - value); + + if ( + acc === null || + distance < acc.distance || + distance === acc.distance + ) { + return { + distance, + index + }; + } + + return acc; + }, null) ?? {}; + + return closestIndex ?? -1; +} + +type StyledSliderProps = Pick< + SliderProps, + 'orientation' | 'size' | 'variant' +> & { + $disabled?: boolean; + hasMarks?: boolean; + isFocused?: boolean; +}; + +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.materialText}; + `} + } + + ${({ orientation, size }) => + orientation === 'vertical' + ? css` + height: ${size}; + margin-right: 1.5rem; + &:before { + left: -6px; + 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: ${({ $disabled }) => ($disabled ? 'none' : 'auto')}; +`; +const sharedGrooveStyles = () => css` + position: absolute; + ${({ orientation }) => + orientation === 'vertical' + ? css` + bottom: 0; + left: 50%; + transform: translateX(-50%); + height: 100%; + width: 8px; + ` + : css` + left: 0; + top: 50%; + transform: translateY(-50%); + height: 8px; + width: 100%; + `} +`; +const StyledGroove = styled(StyledCutout)` + ${sharedGrooveStyles()} +`; +const StyledFlatGroove = styled(StyledCutout)` + ${sharedGrooveStyles()} + + border-left-color: ${({ theme }) => theme.flatLight}; + border-top-color: ${({ theme }) => theme.flatLight}; + border-right-color: ${({ theme }) => theme.canvas}; + border-bottom-color: ${({ theme }) => theme.canvas}; + &:before { + border-left-color: ${({ theme }) => theme.flatDark}; + border-top-color: ${({ theme }) => theme.flatDark}; + border-right-color: ${({ theme }) => theme.flatLight}; + border-bottom-color: ${({ theme }) => theme.flatLight}; + } +`; +const Thumb = styled.span` + position: relative; + ${({ orientation }) => + orientation === 'vertical' + ? css` + width: 32px; + height: 18px; + right: 2px; + transform: translateY(-50%); + ` + : css` + height: 32px; + width: 18px; + top: 2px; + transform: translateX(-50%); + `} + ${({ variant }) => + variant === 'flat' + ? css` + ${createFlatBoxStyles()} + outline: 2px solid ${({ theme }) => theme.flatDark}; + background: ${({ theme }) => theme.flatLight}; + ` + : css` + ${createBoxStyles()} + ${createBorderStyles()} + &:focus { + outline: none; + } + `} + ${({ $disabled, theme }) => + $disabled && + createHatchedBackground({ + mainColor: theme.material, + secondaryColor: theme.borderLightest + })} +`; + +const tickHeight = 6; +const Tick = styled.span` + display: inline-block; + position: absolute; + + ${({ orientation }) => + orientation === 'vertical' + ? css` + right: ${-tickHeight - 2}px; + bottom: 0px; + transform: translateY(1px); + width: ${tickHeight}px; + border-bottom: 2px solid ${({ theme }) => theme.materialText}; + ` + : css` + bottom: ${-tickHeight}px; + height: ${tickHeight}px; + transform: translateX(-1px); + border-left: 1px solid ${({ theme }) => theme.materialText}; + border-right: 1px solid ${({ theme }) => theme.materialText}; + `} + + color: ${({ theme }) => theme.materialText}; + ${({ $disabled, theme }) => + $disabled && + css` + ${createDisabledTextStyles()} + box-shadow: 1px 1px 0px ${theme.materialTextDisabledShadow}; + border-color: ${theme.materialTextDisabled}; + `} +`; +const Mark = styled.div` + position: absolute; + bottom: 0; + left: 0; + line-height: 1; + font-size: 0.875rem; + + ${({ orientation }) => + orientation === 'vertical' + ? css` + transform: translate(${tickHeight + 2}px, ${tickHeight + 1}px); + ` + : css` + transform: translate(-0.5ch, calc(100% + 2px)); + `} +`; + +const Slider = forwardRef(function Slider( + { + defaultValue, + disabled = false, + marks: marksProp = false, + max = 100, + min = 0, + name, + onChange, + onChangeCommitted, + onMouseDown, + orientation = 'horizontal', + size = '100%', + step = 1, + value, + variant = 'default', + ...otherProps + }, + ref +) { + const Groove = variant === 'flat' ? StyledFlatGroove : StyledGroove; + const vertical = orientation === 'vertical'; + const [valueDerived, setValueState] = useControlledOrUncontrolled({ + value, + defaultValue + }); + + const { + isFocusVisible, + onBlurVisible, + ref: focusVisibleRef + } = useIsFocusVisible(); + const [focusVisible, setFocusVisible] = useState(false); + const sliderRef = useRef(); + const thumbRef = useRef(null); + const handleFocusRef = useForkRef(focusVisibleRef, sliderRef); + const handleRef = useForkRef(ref, handleFocusRef); + + const handleFocus = useCallback( + (event: React.FocusEvent) => { + if (isFocusVisible(event)) { + setFocusVisible(true); + } + }, + [isFocusVisible] + ); + + const handleBlur = useCallback(() => { + if (focusVisible !== false) { + setFocusVisible(false); + onBlurVisible(); + } + }, [focusVisible, onBlurVisible]); + + const touchId = useRef(); + + const marks = useMemo( + () => + marksProp === true && Number.isFinite(step) + ? [...Array(Math.round((max - min) / (step as number)) + 1)].map( + (_, index) => ({ + label: undefined, + value: min + (step as number) * index + }) + ) + : Array.isArray(marksProp) + ? marksProp + : [], + [marksProp, max, min, step] + ); + + const handleKeyDown = useCallback( + (event: React.KeyboardEvent) => { + 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); + + onChange?.(event, newValue); + onChangeCommitted?.(event, newValue); + }, + [ + marks, + max, + min, + onChange, + onChangeCommitted, + setValueState, + step, + valueDerived + ] + ); + + const getNewValue = useCallback( + (finger: { x: number; y: number }) => { + if (!sliderRef.current) { + return 0; + } + const rect = sliderRef.current.getBoundingClientRect(); + + let percent; + if (vertical) { + percent = (rect.bottom - finger.y) / rect.height; + } else { + percent = (finger.x - rect.left) / rect.width; + } + let newValue; + + newValue = percentToValue(percent, min, max); + if (step) { + newValue = roundValueToStep(newValue, step, min); + } else { + const marksValues = marks.map(mark => mark.value); + const closestIndex = findClosest(marksValues, newValue); + newValue = marksValues[closestIndex]; + } + newValue = clamp(newValue, min, max); + return newValue; + }, + [marks, max, min, step, vertical] + ); + + const handleTouchMove = useCallback( + (event: MouseEvent | TouchEvent) => { + const finger = trackFinger(event, touchId.current); + + if (!finger) { + return; + } + const newValue = getNewValue(finger); + + thumbRef.current?.focus(); + setValueState(newValue); + setFocusVisible(true); + + onChange?.(event, newValue); + }, + [getNewValue, onChange, setValueState] + ); + + const handleTouchEnd = useCallback( + (event: MouseEvent | TouchEvent) => { + const finger = trackFinger(event, touchId.current); + + if (!finger) { + return; + } + + const newValue = getNewValue(finger); + + onChangeCommitted?.(event, newValue); + + touchId.current = undefined; + + const doc = ownerDocument(sliderRef.current); + doc.removeEventListener('mousemove', handleTouchMove); + doc.removeEventListener('mouseup', handleTouchEnd); + doc.removeEventListener('touchmove', handleTouchMove); + doc.removeEventListener('touchend', handleTouchEnd); + }, + [getNewValue, handleTouchMove, onChangeCommitted] + ); + + const handleMouseDown = useCallback( + (event: React.MouseEvent) => { + // TODO should we also pass event together with new value to callbacks? (same thing with other input components) + onMouseDown?.(event); + + event.preventDefault(); + thumbRef.current?.focus(); + setFocusVisible(true); + + const finger = trackFinger(event, touchId.current); + if (finger) { + const newValue = getNewValue(finger); + setValueState(newValue); + onChange?.(event, newValue); + } + + const doc = ownerDocument(sliderRef.current); + doc.addEventListener('mousemove', handleTouchMove); + doc.addEventListener('mouseup', handleTouchEnd); + }, + [ + getNewValue, + handleTouchEnd, + handleTouchMove, + onChange, + onMouseDown, + setValueState + ] + ); + + const handleTouchStart = useCallback( + (event: TouchEvent) => { + // Workaround as Safari has partial support for touchAction: 'none'. + event.preventDefault(); + const touch = event.changedTouches[0]; + if (touch != null) { + // A number that uniquely identifies the current finger in the touch session. + touchId.current = touch.identifier; + } + + thumbRef.current?.focus(); + setFocusVisible(true); + + const finger = trackFinger(event, touchId.current); + if (finger) { + const newValue = getNewValue(finger); + setValueState(newValue); + onChange?.(event, newValue); + } + + const doc = ownerDocument(sliderRef.current); + doc.addEventListener('touchmove', handleTouchMove); + doc.addEventListener('touchend', handleTouchEnd); + }, + [getNewValue, handleTouchEnd, handleTouchMove, onChange, setValueState] + ); + + useEffect(() => { + const { current: slider } = sliderRef; + slider?.addEventListener('touchstart', handleTouchStart); + const doc = ownerDocument(slider); + + return () => { + slider?.removeEventListener('touchstart', handleTouchStart); + doc.removeEventListener('mousemove', handleTouchMove); + doc.removeEventListener('mouseup', handleTouchEnd); + doc.removeEventListener('touchmove', handleTouchMove); + doc.removeEventListener('touchend', handleTouchEnd); + }; + }, [handleTouchEnd, handleTouchMove, handleTouchStart]); + + return ( + + {/* should we keep the hidden input ? */} + + {marks && + marks.map(m => ( + + {m.label && ( + + {m.label} + + )} + + ))} + + + + ); +}); + +export { Slider, SliderProps }; diff --git a/src/common/hooks/useIsFocusVisible.ts b/src/common/hooks/useIsFocusVisible.ts index b3a0af5e..f4192ade 100644 --- a/src/common/hooks/useIsFocusVisible.ts +++ b/src/common/hooks/useIsFocusVisible.ts @@ -5,9 +5,9 @@ import * as ReactDOM from 'react-dom'; let hadKeyboardEvent = true; let hadFocusVisibleRecently = false; -let hadFocusVisibleRecentlyTimeout = null; +let hadFocusVisibleRecentlyTimeout: number; -const inputTypesWhitelist = { +const inputTypesWhitelist: Record = { text: true, search: true, url: true, @@ -30,18 +30,22 @@ const inputTypesWhitelist = { * @param {Element} node * @return {boolean} */ -function focusTriggersKeyboardModality(node) { - const { type, tagName } = node; - - if (tagName === 'INPUT' && inputTypesWhitelist[type] && !node.readOnly) { - return true; - } +function focusTriggersKeyboardModality( + node: Element | HTMLElement | HTMLInputElement +) { + if ('type' in node) { + const { type, tagName } = node; + + if (tagName === 'INPUT' && inputTypesWhitelist[type] && !node.readOnly) { + return true; + } - if (tagName === 'TEXTAREA' && !node.readOnly) { - return true; + if (tagName === 'TEXTAREA' && !node.readOnly) { + return true; + } } - if (node.isContentEditable) { + if ('isContentEditable' in node && node.isContentEditable) { return true; } @@ -55,7 +59,7 @@ function focusTriggersKeyboardModality(node) { * then the modality is keyboard. Otherwise, the modality is not keyboard. * @param {KeyboardEvent} event */ -function handleKeyDown(event) { +function handleKeyDown(event: KeyboardEvent) { if (event.metaKey || event.altKey || event.ctrlKey) { return; } @@ -73,7 +77,7 @@ function handlePointerDown() { hadKeyboardEvent = false; } -function handleVisibilityChange() { +function handleVisibilityChange(this: Document) { if (this.visibilityState === 'hidden') { // If the tab becomes active again, the browser will handle calling focus // on the element (Safari actually calls it twice). @@ -85,7 +89,7 @@ function handleVisibilityChange() { } } -function prepare(doc) { +function prepare(doc: Document) { doc.addEventListener('keydown', handleKeyDown, true); doc.addEventListener('mousedown', handlePointerDown, true); doc.addEventListener('pointerdown', handlePointerDown, true); @@ -93,7 +97,7 @@ function prepare(doc) { doc.addEventListener('visibilitychange', handleVisibilityChange, true); } -export function teardown(doc) { +export function teardown(doc: Document) { doc.removeEventListener('keydown', handleKeyDown, true); doc.removeEventListener('mousedown', handlePointerDown, true); doc.removeEventListener('pointerdown', handlePointerDown, true); @@ -101,7 +105,7 @@ export function teardown(doc) { doc.removeEventListener('visibilitychange', handleVisibilityChange, true); } -function isFocusVisible(event) { +function isFocusVisible(event: React.FocusEvent) { const { target } = event; try { return target.matches(':focus-visible'); @@ -132,8 +136,8 @@ function handleBlurVisible() { }, 100); } -export function useIsFocusVisible() { - const ref = React.useCallback(instance => { +export function useIsFocusVisible() { + const ref = React.useCallback((instance: T) => { // eslint-disable-next-line react/no-find-dom-node const node = ReactDOM.findDOMNode(instance); if (node != null) { diff --git a/src/index.ts b/src/index.ts index 845ad31c..590d7e60 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,7 +24,7 @@ export * from './Panel/Panel'; export * from './Progress/Progress'; export * from './Radio/Radio'; export * from './Select/Select'; -export { default as Slider } from './Slider/Slider'; +export * from './Slider/Slider'; export { default as Tab } from './Tab/Tab'; export { default as TabBody } from './TabBody/TabBody'; export { default as Table } from './Table/Table'; diff --git a/test/utils.tsx b/test/utils.tsx index cbfb19f6..c44a405b 100644 --- a/test/utils.tsx +++ b/test/utils.tsx @@ -10,29 +10,53 @@ export const renderWithTheme = (component: React.ReactNode) => render({component}); export class Touch { - instance: any; - - constructor(instance: any) { - this.instance = instance; + #identifier: number; + + #clientX = 0; + + #clientY = 0; + + #pageX = 0; + + #pageY = 0; + + constructor({ + identifier, + clientX = 0, + clientY = 0, + pageX = 0, + pageY = 0 + }: { + identifier: number; + clientX?: number; + clientY?: number; + pageX?: number; + pageY?: number; + }) { + this.#identifier = identifier; + this.#clientX = clientX; + this.#clientY = clientY; + this.#pageX = pageX; + this.#pageY = pageY; } get identifier() { - return this.instance.identifier; + return this.#identifier; } get pageX() { - return this.instance.pageX; + return this.#pageX; } get pageY() { - return this.instance.pageY; + return this.#pageY; } get clientX() { - return this.instance.clientX; + return this.#clientX; } get clientY() { - return this.instance.clientY; + return this.#clientY; } }