From c0ceb14e0855777b19f74ddae30522c4f3d2f007 Mon Sep 17 00:00:00 2001 From: Ward Oosterlijnck Date: Mon, 14 Oct 2019 22:58:12 +1100 Subject: [PATCH 1/8] useSlider --- docs/useSlider.md | 0 src/__stories__/useSlider.story.tsx | 22 +++++ src/index.ts | 1 + src/useSlider.ts | 137 ++++++++++++++++++++++++++++ 4 files changed, 160 insertions(+) create mode 100644 docs/useSlider.md create mode 100644 src/__stories__/useSlider.story.tsx create mode 100644 src/useSlider.ts diff --git a/docs/useSlider.md b/docs/useSlider.md new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/__stories__/useSlider.story.tsx b/src/__stories__/useSlider.story.tsx new file mode 100644 index 0000000000..3369b31319 --- /dev/null +++ b/src/__stories__/useSlider.story.tsx @@ -0,0 +1,22 @@ +import { storiesOf } from '@storybook/react'; +import * as React from 'react'; +import { useSlider } from '..'; +import ShowDocs from './util/ShowDocs'; + +const Demo = () => { + const ref = React.useRef(null); + const state = useSlider(ref); + + return ( +
+
+
🎚
+
+
{JSON.stringify(state, null, 2)}
+
+ ); +}; + +storiesOf('UI|useSlider', module) + .add('Docs', () => ) + .add('Demo', () => ); diff --git a/src/index.ts b/src/index.ts index 65872134d6..d3e0e1900a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -70,6 +70,7 @@ export { default as useScrolling } from './useScrolling'; export { default as useSessionStorage } from './useSessionStorage'; export { default as useSetState } from './useSetState'; 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/useSlider.ts b/src/useSlider.ts new file mode 100644 index 0000000000..a518f82dfd --- /dev/null +++ b/src/useSlider.ts @@ -0,0 +1,137 @@ +import { useEffect, useRef, RefObject } from 'react'; + +import { isClient, off, on } from './util'; +import useMountedState from './useMountedState'; +import useSetState from './useSetState'; + +export interface State { + isSliding: boolean; + value: number; + pos: number; + length: number; +} + +const REVERSE = false; +// const ONSCRUB = undefined; +// const ONSCRUBSTART = undefined; +// const ONSCRUBSTOP = undefined +// const NOOP = () => {}; + +export interface Options { + onScrub: (event: Event) => void; + reverse: boolean; +} + +const useSlider = (ref: RefObject): State => { + const isMounted = useMountedState(); + const isSliding = useRef(false); + const frame = useRef(0); + const [state, setState] = useSetState({ + isSliding: false, + value: 0, + pos: 0, + length: 0, + }); + + useEffect(() => { + if (isClient) { + const startScrubbing = () => { + if (!isSliding.current && isMounted()) { + // (ONSCRUBSTART || NOOP)(); + isSliding.current = true; + setState({ isSliding: true }); + bindEvents(); + } + }; + + const stopScrubbing = () => { + if (isSliding.current && isMounted()) { + // (ONSCRUBSTOP || NOOP)(); + isSliding.current = false; + setState({ isSliding: false }); + unbindEvents(); + } + }; + + const onMouseDown = (event: MouseEvent) => { + startScrubbing(); + onMouseMove(event); + }; + const onMouseMove = (event: MouseEvent) => onScrub(event.clientX); + + const onTouchStart = (event: TouchEvent) => { + startScrubbing(); + onTouchMove(event); + }; + const onTouchMove = (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 = (clientX: number) => { + cancelAnimationFrame(frame.current); + + frame.current = requestAnimationFrame(() => { + if (!isMounted()) { + return; + } + + if (ref.current) { + const { left: pos, width: length } = ref.current.getBoundingClientRect(); + + // Prevent returning 0 when element is hidden by CSS + if (!length) { + return; + } + + let value = (clientX - pos) / length; + + if (value > 1) { + value = 1; + } else if (value < 0) { + value = 0; + } + + if (REVERSE) { + value = 1 - value; + } + + setState({ + value, + pos: clientX - pos, + length, + }); + // (ONSCRUB || NOOP)(value); + } + }); + }; + + on(document, 'mousedown', onMouseDown); + on(document, 'touchstart', onTouchStart); + + return () => { + off(document, 'mousedown', onMouseDown); + off(document, 'touchstart', onTouchStart); + }; + } else { + return undefined; + } + }, [ref]); + + return state; +}; + +export default useSlider; From da8a853ba3655ae702afdf4f599d2359b2cce0e6 Mon Sep 17 00:00:00 2001 From: Ward Oosterlijnck Date: Tue, 15 Oct 2019 08:57:57 +1100 Subject: [PATCH 2/8] useSlider options --- src/useSlider.ts | 33 ++++++++++++++++++++------------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/src/useSlider.ts b/src/useSlider.ts index a518f82dfd..6b799cb34a 100644 --- a/src/useSlider.ts +++ b/src/useSlider.ts @@ -1,4 +1,4 @@ -import { useEffect, useRef, RefObject } from 'react'; +import { useEffect, useRef, RefObject, CSSProperties } from 'react'; import { isClient, off, on } from './util'; import useMountedState from './useMountedState'; @@ -11,18 +11,17 @@ export interface State { length: number; } -const REVERSE = false; -// const ONSCRUB = undefined; -// const ONSCRUBSTART = undefined; -// const ONSCRUBSTOP = undefined -// const NOOP = () => {}; - export interface Options { - onScrub: (event: Event) => void; + onScrub: (value: number) => void; + onScrubStart: () => void; + onScrubStop: () => void; reverse: boolean; + styles: boolean | CSSProperties; } -const useSlider = (ref: RefObject): State => { +const noop = () => {}; + +const useSlider = (ref: RefObject, options: Partial = {}): State => { const isMounted = useMountedState(); const isSliding = useRef(false); const frame = useRef(0); @@ -35,9 +34,16 @@ const useSlider = (ref: RefObject): State => { 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()) { - // (ONSCRUBSTART || NOOP)(); + (options.onScrubStart || noop)(); isSliding.current = true; setState({ isSliding: true }); bindEvents(); @@ -46,7 +52,7 @@ const useSlider = (ref: RefObject): State => { const stopScrubbing = () => { if (isSliding.current && isMounted()) { - // (ONSCRUBSTOP || NOOP)(); + (options.onScrubStop || noop)(); isSliding.current = false; setState({ isSliding: false }); unbindEvents(); @@ -105,7 +111,7 @@ const useSlider = (ref: RefObject): State => { value = 0; } - if (REVERSE) { + if (reverse) { value = 1 - value; } @@ -114,7 +120,8 @@ const useSlider = (ref: RefObject): State => { pos: clientX - pos, length, }); - // (ONSCRUB || NOOP)(value); + + (options.onScrub || noop)(value); } }); }; From c0843555ef8f8117db5c986692d899ccfaef700a Mon Sep 17 00:00:00 2001 From: Ward Oosterlijnck Date: Tue, 15 Oct 2019 18:26:37 +1100 Subject: [PATCH 3/8] useSlider docs --- README.md | 1 + docs/useSlider.md | 25 +++++++++++++++++++++++++ src/__stories__/useSlider.story.tsx | 7 +++++-- 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index ebe6c734db..25b7b13302 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,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) - [`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) - [`useWait`](./docs/useWait.md) — complex waiting management for UIs. diff --git a/docs/useSlider.md b/docs/useSlider.md index e69de29bb2..80d05207b4 100644 --- a/docs/useSlider.md +++ 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/__stories__/useSlider.story.tsx b/src/__stories__/useSlider.story.tsx index 3369b31319..b902951ed2 100644 --- a/src/__stories__/useSlider.story.tsx +++ b/src/__stories__/useSlider.story.tsx @@ -9,8 +9,11 @@ const Demo = () => { return (
-
-
🎚
+
+

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

+
🎚
{JSON.stringify(state, null, 2)}
From e657c2f8f6c0a2c6f801f689d6afadfc56e02d21 Mon Sep 17 00:00:00 2001 From: Ward Oosterlijnck Date: Wed, 16 Oct 2019 18:34:38 +1100 Subject: [PATCH 4/8] handle resize --- src/useMeasure.ts | 2 ++ src/useMeasureDirty.ts | 50 ++++++++++++++++++++++++++++++++++++++++++ src/useSlider.ts | 24 ++++++++++++-------- 3 files changed, 67 insertions(+), 9 deletions(-) create mode 100644 src/useMeasureDirty.ts diff --git a/src/useMeasure.ts b/src/useMeasure.ts index 8e0d82bf2e..1b61c3cb56 100644 --- a/src/useMeasure.ts +++ b/src/useMeasure.ts @@ -1,5 +1,6 @@ import { useCallback, useState } from 'react'; import ResizeObserver from 'resize-observer-polyfill'; + export interface ContentRect { width: number; height: number; @@ -8,6 +9,7 @@ export interface ContentRect { left: number; bottom: number; } + const useMeasure = (): [(instance: T) => void, ContentRect] => { const [rect, set] = useState({ width: 0, 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 index 6b799cb34a..15875fe0f2 100644 --- a/src/useSlider.ts +++ b/src/useSlider.ts @@ -3,6 +3,7 @@ import { useEffect, useRef, RefObject, CSSProperties } from 'react'; import { isClient, off, on } from './util'; import useMountedState from './useMountedState'; import useSetState from './useSetState'; +import useMeasureDirty from './useMeasureDirty'; export interface State { isSliding: boolean; @@ -32,6 +33,8 @@ const useSlider = (ref: RefObject, options: Partial = {}): length: 0, }); + const { width } = useMeasureDirty(ref); + useEffect(() => { if (isClient) { const styles = options.styles === undefined ? true : options.styles; @@ -91,11 +94,7 @@ const useSlider = (ref: RefObject, options: Partial = {}): cancelAnimationFrame(frame.current); frame.current = requestAnimationFrame(() => { - if (!isMounted()) { - return; - } - - if (ref.current) { + if (isMounted() && ref.current) { const { left: pos, width: length } = ref.current.getBoundingClientRect(); // Prevent returning 0 when element is hidden by CSS @@ -126,18 +125,25 @@ const useSlider = (ref: RefObject, options: Partial = {}): }); }; - on(document, 'mousedown', onMouseDown); - on(document, 'touchstart', onTouchStart); + on(ref.current, 'mousedown', onMouseDown); + on(ref.current, 'touchstart', onTouchStart); return () => { - off(document, 'mousedown', onMouseDown); - off(document, 'touchstart', onTouchStart); + off(ref.current, 'mousedown', onMouseDown); + off(ref.current, 'touchstart', onTouchStart); }; } else { return undefined; } }, [ref]); + useEffect(() => { + setState(prevState => ({ + pos: Math.round(prevState.value * width), + length: width, + })); + }, [width]); + return state; }; From 2d97cdfadab56c54741e7871d717d911d60c728f Mon Sep 17 00:00:00 2001 From: Ward Oosterlijnck Date: Thu, 17 Oct 2019 00:28:30 +1100 Subject: [PATCH 5/8] More fun story --- src/__stories__/useSlider.story.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/__stories__/useSlider.story.tsx b/src/__stories__/useSlider.story.tsx index b902951ed2..fffd3ac60b 100644 --- a/src/__stories__/useSlider.story.tsx +++ b/src/__stories__/useSlider.story.tsx @@ -9,11 +9,11 @@ const Demo = () => { return (
-
-

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

-
🎚
+
+

Slide me

+
+ {state.isSliding ? '🏂' : '🎿'} +
{JSON.stringify(state, null, 2)}
From 4633aebbb434b0e050c7cf8131369fe98f4fa9fd Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 3 Jan 2020 19:42:15 +0100 Subject: [PATCH 6/8] =?UTF-8?q?test:=20=F0=9F=92=8D=20fix=20useSlider()=20?= =?UTF-8?q?story?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- stories/useSlider.story.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/stories/useSlider.story.tsx b/stories/useSlider.story.tsx index fffd3ac60b..46726e9a9a 100644 --- a/stories/useSlider.story.tsx +++ b/stories/useSlider.story.tsx @@ -1,6 +1,6 @@ import { storiesOf } from '@storybook/react'; import * as React from 'react'; -import { useSlider } from '..'; +import { useSlider } from '../src'; import ShowDocs from './util/ShowDocs'; const Demo = () => { @@ -21,5 +21,5 @@ const Demo = () => { }; storiesOf('UI|useSlider', module) - .add('Docs', () => ) + .add('Docs', () => ) .add('Demo', () => ); From 1980d46199b866396fe4b8ce12528188c9958876 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 3 Jan 2020 19:46:16 +0100 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20=F0=9F=92=A1=20don't=20use=20us?= =?UTF-8?q?eMeassure=20in=20useSlider=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/useSlider.ts | 16 ---------------- stories/useSlider.story.tsx | 2 +- 2 files changed, 1 insertion(+), 17 deletions(-) diff --git a/src/useSlider.ts b/src/useSlider.ts index 15875fe0f2..a7548bceca 100644 --- a/src/useSlider.ts +++ b/src/useSlider.ts @@ -3,13 +3,10 @@ import { useEffect, useRef, RefObject, CSSProperties } from 'react'; import { isClient, off, on } from './util'; import useMountedState from './useMountedState'; import useSetState from './useSetState'; -import useMeasureDirty from './useMeasureDirty'; export interface State { isSliding: boolean; value: number; - pos: number; - length: number; } export interface Options { @@ -29,12 +26,8 @@ const useSlider = (ref: RefObject, options: Partial = {}): const [state, setState] = useSetState({ isSliding: false, value: 0, - pos: 0, - length: 0, }); - const { width } = useMeasureDirty(ref); - useEffect(() => { if (isClient) { const styles = options.styles === undefined ? true : options.styles; @@ -116,8 +109,6 @@ const useSlider = (ref: RefObject, options: Partial = {}): setState({ value, - pos: clientX - pos, - length, }); (options.onScrub || noop)(value); @@ -137,13 +128,6 @@ const useSlider = (ref: RefObject, options: Partial = {}): } }, [ref]); - useEffect(() => { - setState(prevState => ({ - pos: Math.round(prevState.value * width), - length: width, - })); - }, [width]); - return state; }; diff --git a/stories/useSlider.story.tsx b/stories/useSlider.story.tsx index 46726e9a9a..7f5934dab7 100644 --- a/stories/useSlider.story.tsx +++ b/stories/useSlider.story.tsx @@ -11,7 +11,7 @@ const Demo = () => {

Slide me

-
+
{state.isSliding ? '🏂' : '🎿'}
From 777865c3ac6772fbda2bc0a6f58cde3eff7dec43 Mon Sep 17 00:00:00 2001 From: streamich Date: Fri, 3 Jan 2020 19:53:12 +0100 Subject: [PATCH 8/8] =?UTF-8?q?feat:=20=F0=9F=8E=B8=20add=20[vertical]=20f?= =?UTF-8?q?lag=20to=20useSlider()=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/useSlider.ts | 19 +++++++++++++------ stories/useSlider.story.tsx | 20 +++++++++++++++++++- 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/useSlider.ts b/src/useSlider.ts index a7548bceca..f123cdc61f 100644 --- a/src/useSlider.ts +++ b/src/useSlider.ts @@ -15,6 +15,7 @@ export interface Options { onScrubStop: () => void; reverse: boolean; styles: boolean | CSSProperties; + vertical?: boolean; } const noop = () => {}; @@ -59,13 +60,17 @@ const useSlider = (ref: RefObject, options: Partial = {}): startScrubbing(); onMouseMove(event); }; - const onMouseMove = (event: MouseEvent) => onScrub(event.clientX); + const onMouseMove = options.vertical + ? (event: MouseEvent) => onScrub(event.clientY) + : (event: MouseEvent) => onScrub(event.clientX); const onTouchStart = (event: TouchEvent) => { startScrubbing(); onTouchMove(event); }; - const onTouchMove = (event: TouchEvent) => onScrub(event.changedTouches[0].clientX); + const onTouchMove = options.vertical + ? (event: TouchEvent) => onScrub(event.changedTouches[0].clientY) + : (event: TouchEvent) => onScrub(event.changedTouches[0].clientX); const bindEvents = () => { on(document, 'mousemove', onMouseMove); @@ -83,19 +88,21 @@ const useSlider = (ref: RefObject, options: Partial = {}): off(document, 'touchend', stopScrubbing); }; - const onScrub = (clientX: number) => { + const onScrub = (clientXY: number) => { cancelAnimationFrame(frame.current); frame.current = requestAnimationFrame(() => { if (isMounted() && ref.current) { - const { left: pos, width: length } = ref.current.getBoundingClientRect(); + 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 = (clientX - pos) / length; + let value = (clientXY - pos) / length; if (value > 1) { value = 1; @@ -126,7 +133,7 @@ const useSlider = (ref: RefObject, options: Partial = {}): } else { return undefined; } - }, [ref]); + }, [ref, options.vertical]); return state; }; diff --git a/stories/useSlider.story.tsx b/stories/useSlider.story.tsx index 7f5934dab7..9626a8b43a 100644 --- a/stories/useSlider.story.tsx +++ b/stories/useSlider.story.tsx @@ -20,6 +20,24 @@ const Demo = () => { ); }; +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('Demo', () => ); + .add('Horizontal', () => ) + .add('Vertical', () => );