Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: useSlider #680

Merged
merged 10 commits into from Jan 3, 2020
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Expand Up @@ -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.
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>
);
};
```
25 changes: 25 additions & 0 deletions src/__stories__/useSlider.story.tsx
@@ -0,0 +1,25 @@
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 (
<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: state.pos, transform: 'scale(2)' }}>
streamich marked this conversation as resolved.
Show resolved Hide resolved
{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('Demo', () => <Demo />);
1 change: 1 addition & 0 deletions src/index.ts
Expand Up @@ -72,6 +72,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';
Expand Down
2 changes: 2 additions & 0 deletions 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;
Expand All @@ -8,6 +9,7 @@ export interface ContentRect {
left: number;
bottom: number;
}

const useMeasure = <T>(): [(instance: T) => void, ContentRect] => {
const [rect, set] = useState({
width: 0,
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;
150 changes: 150 additions & 0 deletions src/useSlider.ts
@@ -0,0 +1,150 @@
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 {
onScrub: (value: number) => void;
onScrubStart: () => void;
onScrubStop: () => void;
reverse: boolean;
styles: boolean | CSSProperties;
streamich marked this conversation as resolved.
Show resolved Hide resolved
}

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,
pos: 0,
length: 0,
});

const { width } = useMeasureDirty(ref);

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 = (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() && 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,
});

(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]);

useEffect(() => {
setState(prevState => ({
pos: Math.round(prevState.value * width),
length: width,
}));
}, [width]);
streamich marked this conversation as resolved.
Show resolved Hide resolved

return state;
};

export default useSlider;