diff --git a/README.md b/README.md index e86e38c42e..64858f9f44 100644 --- a/README.md +++ b/README.md @@ -75,6 +75,7 @@ - [`useCss`](./docs/useCss.md) — dynamically adjusts CSS. - [`useDrop` and `useDropArea`](./docs/useDrop.md) — tracks file, link and copy-paste drops. - [`useFullscreen`](./docs/useFullscreen.md) — display an element or video full-screen. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/ui-usefullscreen--demo) + - [`useSlider`](./docs/useSlider.md) — provides slide behavior over any HTML element. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/ui-useslider--demo) - [`useSpeech`](./docs/useSpeech.md) — synthesizes speech from a text string. [![][img-demo]](https://codesandbox.io/s/n090mqz69m) - [`useVibrate`](./docs/useVibrate.md) — provide physical feedback using the [Vibration API](https://developer.mozilla.org/en-US/docs/Web/API/Vibration_API). [![][img-demo]](https://streamich.github.io/react-use/?path=/story/ui-usevibrate--demo) - [`useVideo`](./docs/useVideo.md) — plays video, tracks its state, and exposes playback controls. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/ui-usevideo--demo) diff --git a/docs/useSlider.md b/docs/useSlider.md new file mode 100644 index 0000000000..80d05207b4 --- /dev/null +++ b/docs/useSlider.md @@ -0,0 +1,25 @@ +# `useSlider` + +React UI hook that provides slide behavior over any HTML element. Supports both mouse and touch events. + +## Usage + +```jsx +import {useSlider} from 'react-use'; + +const Demo = () => { + const ref = React.useRef(null); + const {isSliding, value, pos, length} = useSlider(ref); + + return ( +
+
+

+ {Math.round(state.value * 100)}% +

+
🎚
+
+
+ ); +}; +``` diff --git a/src/index.ts b/src/index.ts index b5f89826bd..4a0aee4fa7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,6 +73,7 @@ export { default as useSessionStorage } from './useSessionStorage'; export { default as useSetState } from './useSetState'; export { default as useShallowCompareEffect } from './useShallowCompareEffect'; export { default as useSize } from './useSize'; +export { default as useSlider } from './useSlider'; export { default as useSpeech } from './useSpeech'; // not exported because of peer dependency // export { default as useSpring } from './useSpring'; diff --git a/src/useMeasureDirty.ts b/src/useMeasureDirty.ts new file mode 100644 index 0000000000..2f0702c2df --- /dev/null +++ b/src/useMeasureDirty.ts @@ -0,0 +1,50 @@ +import { useState, useEffect, useRef, RefObject } from 'react'; +import ResizeObserver from 'resize-observer-polyfill'; + +export interface ContentRect { + width: number; + height: number; + top: number; + right: number; + left: number; + bottom: number; +} + +const useMeasureDirty = (ref: RefObject): ContentRect => { + const frame = useRef(0); + const [rect, set] = useState({ + width: 0, + height: 0, + top: 0, + left: 0, + bottom: 0, + right: 0, + }); + + const [observer] = useState( + () => + new ResizeObserver(entries => { + const entry = entries[0]; + + if (entry) { + cancelAnimationFrame(frame.current); + + frame.current = requestAnimationFrame(() => { + set(entry.contentRect); + }); + } + }) + ); + + useEffect(() => { + observer.disconnect(); + + if (ref.current) { + observer.observe(ref.current); + } + }, [ref]); + + return rect; +}; + +export default useMeasureDirty; diff --git a/src/useSlider.ts b/src/useSlider.ts new file mode 100644 index 0000000000..f123cdc61f --- /dev/null +++ b/src/useSlider.ts @@ -0,0 +1,141 @@ +import { useEffect, useRef, RefObject, CSSProperties } from 'react'; + +import { isClient, off, on } from './util'; +import useMountedState from './useMountedState'; +import useSetState from './useSetState'; + +export interface State { + isSliding: boolean; + value: number; +} + +export interface Options { + onScrub: (value: number) => void; + onScrubStart: () => void; + onScrubStop: () => void; + reverse: boolean; + styles: boolean | CSSProperties; + vertical?: boolean; +} + +const noop = () => {}; + +const useSlider = (ref: RefObject, options: Partial = {}): State => { + const isMounted = useMountedState(); + const isSliding = useRef(false); + const frame = useRef(0); + const [state, setState] = useSetState({ + isSliding: false, + value: 0, + }); + + useEffect(() => { + if (isClient) { + const styles = options.styles === undefined ? true : options.styles; + const reverse = options.reverse === undefined ? false : options.reverse; + + if (ref.current && styles) { + ref.current.style.userSelect = 'none'; + } + + const startScrubbing = () => { + if (!isSliding.current && isMounted()) { + (options.onScrubStart || noop)(); + isSliding.current = true; + setState({ isSliding: true }); + bindEvents(); + } + }; + + const stopScrubbing = () => { + if (isSliding.current && isMounted()) { + (options.onScrubStop || noop)(); + isSliding.current = false; + setState({ isSliding: false }); + unbindEvents(); + } + }; + + const onMouseDown = (event: MouseEvent) => { + startScrubbing(); + onMouseMove(event); + }; + const onMouseMove = options.vertical + ? (event: MouseEvent) => onScrub(event.clientY) + : (event: MouseEvent) => onScrub(event.clientX); + + const onTouchStart = (event: TouchEvent) => { + startScrubbing(); + onTouchMove(event); + }; + const onTouchMove = options.vertical + ? (event: TouchEvent) => onScrub(event.changedTouches[0].clientY) + : (event: TouchEvent) => onScrub(event.changedTouches[0].clientX); + + const bindEvents = () => { + on(document, 'mousemove', onMouseMove); + on(document, 'mouseup', stopScrubbing); + + on(document, 'touchmove', onTouchMove); + on(document, 'touchend', stopScrubbing); + }; + + const unbindEvents = () => { + off(document, 'mousemove', onMouseMove); + off(document, 'mouseup', stopScrubbing); + + off(document, 'touchmove', onTouchMove); + off(document, 'touchend', stopScrubbing); + }; + + const onScrub = (clientXY: number) => { + cancelAnimationFrame(frame.current); + + frame.current = requestAnimationFrame(() => { + if (isMounted() && ref.current) { + const rect = ref.current.getBoundingClientRect(); + const pos = options.vertical ? rect.top : rect.left; + const length = options.vertical ? rect.height : rect.width; + + // Prevent returning 0 when element is hidden by CSS + if (!length) { + return; + } + + let value = (clientXY - pos) / length; + + if (value > 1) { + value = 1; + } else if (value < 0) { + value = 0; + } + + if (reverse) { + value = 1 - value; + } + + setState({ + value, + }); + + (options.onScrub || noop)(value); + } + }); + }; + + on(ref.current, 'mousedown', onMouseDown); + on(ref.current, 'touchstart', onTouchStart); + + return () => { + off(ref.current, 'mousedown', onMouseDown); + off(ref.current, 'touchstart', onTouchStart); + }; + } else { + return undefined; + } + }, [ref, options.vertical]); + + return state; +}; + +export default useSlider; diff --git a/stories/useSlider.story.tsx b/stories/useSlider.story.tsx new file mode 100644 index 0000000000..9626a8b43a --- /dev/null +++ b/stories/useSlider.story.tsx @@ -0,0 +1,43 @@ +import { storiesOf } from '@storybook/react'; +import * as React from 'react'; +import { useSlider } from '../src'; +import ShowDocs from './util/ShowDocs'; + +const Demo = () => { + const ref = React.useRef(null); + const state = useSlider(ref); + + return ( +
+
+

Slide me

+
+ {state.isSliding ? '🏂' : '🎿'} +
+
+
{JSON.stringify(state, null, 2)}
+
+ ); +}; + +const DemoVertical = () => { + const ref = React.useRef(null); + const state = useSlider(ref, { vertical: true }); + + return ( +
+
+

Slide me

+
+ {state.isSliding ? '🏂' : '🎿'} +
+
+
{JSON.stringify(state, null, 2)}
+
+ ); +}; + +storiesOf('UI|useSlider', module) + .add('Docs', () => ) + .add('Horizontal', () => ) + .add('Vertical', () => );