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 all 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 @@ -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;
streamich marked this conversation as resolved.
Show resolved Hide resolved
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 />);