Skip to content

Commit

Permalink
Merge pull request #680 from streamich/useSlider
Browse files Browse the repository at this point in the history
feat: useSlider
  • Loading branch information
streamich committed Jan 3, 2020
2 parents 57e1744 + 777865c commit 04acfe4
Show file tree
Hide file tree
Showing 6 changed files with 261 additions and 0 deletions.
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -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)
Expand Down
25 changes: 25 additions & 0 deletions 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 (
<div>
<div ref={ref} style={{ position: 'relative' }}>
<p style={{ textAlign: 'center', color: isSliding ? 'red' : 'green' }}>
{Math.round(state.value * 100)}%
</p>
<div style={{ position: 'absolute', left: pos }}>๐ŸŽš</div>
</div>
</div>
);
};
```
1 change: 1 addition & 0 deletions src/index.ts
Expand Up @@ -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';
Expand Down
50 changes: 50 additions & 0 deletions 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<HTMLElement>): 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;
141 changes: 141 additions & 0 deletions 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<HTMLElement>, options: Partial<Options> = {}): State => {
const isMounted = useMountedState();
const isSliding = useRef(false);
const frame = useRef(0);
const [state, setState] = useSetState<State>({
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;
43 changes: 43 additions & 0 deletions 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 (
<div>
<div ref={ref} style={{ position: 'relative', background: 'yellow', padding: 4 }}>
<p style={{ margin: 0, textAlign: 'center' }}>Slide me</p>
<div style={{ position: 'absolute', top: 0, left: (100 * state.value) + '%', transform: 'scale(2)' }}>
{state.isSliding ? '๐Ÿ‚' : '๐ŸŽฟ'}
</div>
</div>
<pre>{JSON.stringify(state, null, 2)}</pre>
</div>
);
};

const DemoVertical = () => {
const ref = React.useRef(null);
const state = useSlider(ref, { vertical: true });

return (
<div>
<div ref={ref} style={{ position: 'relative', background: 'yellow', padding: 4, width: 30, height: 400 }}>
<p style={{ margin: 0, textAlign: 'center' }}>Slide me</p>
<div style={{ position: 'absolute', left: 0, top: (100 * state.value) + '%', transform: 'scale(2)' }}>
{state.isSliding ? '๐Ÿ‚' : '๐ŸŽฟ'}
</div>
</div>
<pre>{JSON.stringify(state, null, 2)}</pre>
</div>
);
};

storiesOf('UI|useSlider', module)
.add('Docs', () => <ShowDocs md={require('../docs/useSlider.md')} />)
.add('Horizontal', () => <Demo />)
.add('Vertical', () => <DemoVertical />);

0 comments on commit 04acfe4

Please sign in to comment.