diff --git a/catalog/index.js b/catalog/index.js index 4fdc5feca..c0bb84a6e 100644 --- a/catalog/index.js +++ b/catalog/index.js @@ -67,10 +67,11 @@ import { theme, } from '../index'; import { - Modal as V6Modal, Button as V6Button, SegmentedButtonGroup, Dropdown as V6Dropdown, + Modal as V6Modal, + Slider as V6Slider, } from '../index-v6'; import { GroupSelector, LargeGroupSelector } from '../components/group-selector'; import { ShareDialog } from '../components/share-dialog'; @@ -904,10 +905,7 @@ const pages = [ path: '/slider/variations', title: 'Slider Variations', content: pageLoader(() => import('./slider/variations.md')), - imports: { - Slider, - DocgenTable, - }, + imports: { Slider, DocgenTable }, }, { path: 'slider/documentation', @@ -915,6 +913,18 @@ const pages = [ content: pageLoader(() => import('./slider/documentation.md')), imports: { Slider, DocgenTable }, }, + { + path: '/slider/variations-v6', + title: 'v6 Slider Variations', + content: pageLoader(() => import('./slider/variations-v6.md')), + imports: { Slider: V6Slider, DocgenTable }, + }, + { + path: 'slider/documentation-v6', + title: 'v6 Slider Documentation', + content: pageLoader(() => import('./slider/documentation-v6.md')), + imports: { Slider: V6Slider, DocgenTable }, + }, ], }, { diff --git a/catalog/slider/documentation-v6.md b/catalog/slider/documentation-v6.md new file mode 100644 index 000000000..0684e9c60 --- /dev/null +++ b/catalog/slider/documentation-v6.md @@ -0,0 +1,11 @@ +For the next major version of Styled UI, the Slider component has been rebuilt to use Styled System primitives. + +You can opt-in to the new API now by importing `{ Slider } from '@faithlife/styled-ui/v6'`. When v6 is released, the `/v6` entrypoint will continue to be supported with a deprecation warning until v7 is released. + +This documentation is automatically generated from JSDoc comments. + +```react +noSource: true +--- + +``` diff --git a/catalog/slider/variations-v6.md b/catalog/slider/variations-v6.md new file mode 100644 index 000000000..e8bfb459c --- /dev/null +++ b/catalog/slider/variations-v6.md @@ -0,0 +1,108 @@ +For the next major version of Styled UI, the Slider component has been rebuilt to use Styled System primitives. + +You can opt-in to the new API now by importing `{ Slider } from '@faithlife/styled-ui/v6'`. When v6 is released, the `/v6` entrypoint will continue to be supported with a deprecation warning until v7 is released. + +## Slider + +```react +showSource: true +state: {} +--- +
+ + +
+``` + +### With minValue / maxValue + +```react +showSource: true +state: {} +--- +
+ + + +
+``` + +### With labels + +```react +showSource: true +state: {} +--- +
+ + + +
+``` + +### Callback props: onStop vs onSlide + +If your slider will be making external API calls, you may wish to call that only on the `onStop` callback, when the user has finished sliding. +If you want to have incremental updates while the slider is moving, such as to keep multiple sliders in sync, you may want to use the `onSlide` callback prop. + +```react +showSource: true +state: { value: 1 } +--- +
+ onStop: + + onSlide: + +
+``` + +### hideAvailableStops + +For sliders with many stops, consider using the `hideAvailableStops` option. + +```react +showSource: true +state: { value: 50, labels: [0, 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, 22, 24, 26, 28, 30, 32, 34, 36, 38, 40, 42, 44, 46, 48, 50, 52, 54, 56, 58, 60, 62, 64, 66, 68, 70, 72, 74, 76, 78, 80, 82, 84, 86, 88, 90, 92, 94, 96, 98, 100] } +--- +
+
Opacity: {state.value * 2}
+ + + Faithlife campus +
+``` + +### Note on background colors + +The slider component expects a white background to create the sections of inactive track that cover the blue gradient. If your slider is not on a white background, use the `backgroundColor` (or `bg`) prop to indicate the correct background color. + +```react +showSource: true +state: {} +--- +
+ + + +
+``` diff --git a/components/slider/component.jsx b/components/slider/component.jsx index 59c1036bc..fdb33d582 100644 --- a/components/slider/component.jsx +++ b/components/slider/component.jsx @@ -34,16 +34,12 @@ export class Slider extends PureComponent { stopCount: PropTypes.number.isRequired, /** Useful for sliders with many stops */ hideAvailableStops: PropTypes.bool, - /** Style overrides */ - styleOverrides: PropTypes.shape({ - backgroundColor: PropTypes.string, - }), + ...Box.propTypes, }; static defaultProps = { hideAvailableStops: false, labels: [], - styleOverrides: {}, }; state = { @@ -267,7 +263,6 @@ export class Slider extends PureComponent { onStop, stopCount, hideAvailableStops, - styleOverrides, disabled, ...props } = this.props; @@ -308,7 +303,7 @@ export class Slider extends PureComponent { 0} key={index} - styleOverrides={styleOverrides} /> ))} @@ -357,12 +351,11 @@ export class Slider extends PureComponent { const TrackPart = ({ children, left, right, ...props }) => ( {children} diff --git a/components/slider/index.js b/components/slider/index.js index 5aace8d3a..517afe7ac 100644 --- a/components/slider/index.js +++ b/components/slider/index.js @@ -1 +1,2 @@ +export { Slider as LegacySlider } from './legacy-component'; export { Slider } from './component'; diff --git a/components/slider/legacy-component.jsx b/components/slider/legacy-component.jsx new file mode 100644 index 000000000..59c1036bc --- /dev/null +++ b/components/slider/legacy-component.jsx @@ -0,0 +1,402 @@ +import React, { createRef, PureComponent, useRef } from 'react'; +import PropTypes from 'prop-types'; +import debounce from 'lodash.debounce'; +import throttle from 'lodash.throttle'; +import memoize from 'memoize-one'; +import { Box } from '../Box'; +import { Popover } from '../popover-v6'; +import * as Styled from './styled'; + +function range(from, to) { + return [...Array(to - from).keys()].map(num => num + from); +} + +// Derived from https://github.com/Faithlife/react-util/ +function createDerivedValue(getDependencies, calculateValue) { + const calculateMemoizedValue = memoize(calculateValue); + return () => calculateMemoizedValue(...getDependencies()); +} + +export class Slider extends PureComponent { + static propTypes = { + disabled: PropTypes.bool, + value: PropTypes.number.isRequired, + /** Array of numbers or strings to be used as tooltip labels */ + labels: PropTypes.arrayOf(PropTypes.oneOfType([PropTypes.string, PropTypes.number])), + /** 0-based index */ + minValue: PropTypes.number, + /** 0-based index, should be less than stopCount if specified */ + maxValue: PropTypes.number, + /** Triggered every time the slider moves */ + onSlide: PropTypes.func, + /** Triggered when the slider stops moving */ + onStop: PropTypes.func, + stopCount: PropTypes.number.isRequired, + /** Useful for sliders with many stops */ + hideAvailableStops: PropTypes.bool, + /** Style overrides */ + styleOverrides: PropTypes.shape({ + backgroundColor: PropTypes.string, + }), + }; + + static defaultProps = { + hideAvailableStops: false, + labels: [], + styleOverrides: {}, + }; + + state = { + value: this.props.value, + isHovered: false, + isSliding: false, + }; + + _slider = createRef(); + _timeout = null; + + UNSAFE_componentWillReceiveProps(nextProps) { + if (nextProps.value !== this.state.value) { + this.setState({ value: nextProps.value }); + } + } + + componentWillUnmount() { + if (this._timeout) { + clearTimeout(this._timeout); + } + } + + calculateValue = clientX => { + if (!(this._slider && this._slider.current)) { + return null; + } + + const rect = this._slider.current.getBoundingClientRect(); + const width = rect.right - rect.left; + const pos = clientX - rect.left; + const clampedX = Math.min(Math.max(0, pos), width); + + const newValue = Math.round((clampedX / width) * (this.props.stopCount - 1)); + const { minValue, maxValue } = this.props; + return newValue > maxValue ? maxValue : newValue < minValue ? minValue : newValue; + }; + + getStops = createDerivedValue(() => [this.props.stopCount], stopCount => range(0, stopCount)); + + handleTouchStart = event => { + if (this.props.disabled) { + return; + } + + event.preventDefault(); + document.addEventListener('touchmove', this.handleTouchMove); + document.addEventListener('touchend', this.handleTouchEnd); + this.setState({ isHovered: true, isSliding: true }); + this.handleTouchMove(event); + }; + + handleTouchMove = event => { + if (this.props.disabled) { + return; + } + + event.preventDefault(); + const value = this.calculateValue(event.touches[0].clientX); + if (this.state.value !== value) { + if (this.props.onSlide) { + this.props.onSlide(value); + } + this.setState({ value }); + } + }; + + handleTouchEnd = event => { + if (this.props.disabled) { + return; + } + + event.preventDefault(); + document.removeEventListener('touchmove', this.handleTouchMove); + document.removeEventListener('touchend', this.handleTouchEnd); + + if (this.props.onStop) { + this.props.onStop(this.state.value); + } + + this.setState({ isSliding: false }); + this.handleTogglePopover(false, 250); + }; + + handleMouseDown = event => { + if (this.props.disabled) { + return; + } + + if (event.button !== 0 || this.state.isSliding) { + return; + } + event.preventDefault(); + document.addEventListener('mousemove', this.handleMouseMove); + document.addEventListener('mouseup', this.handleMouseUp); + this.setState({ isHovered: true, isSliding: true }); + this.handleMouseMove(event); + }; + + handleMouseMove = event => { + if (this.props.disabled) { + return; + } + + event.preventDefault(); + const value = this.calculateValue(event.clientX); + if (this.state.value !== value) { + if (this.props.onSlide) { + this.props.onSlide(value); + } + this.setState({ value, isHovered: true }); + } + }; + + handleMouseUp = event => { + if (this.props.disabled) { + return; + } + + event.preventDefault(); + document.removeEventListener('mousemove', this.handleMouseMove); + document.removeEventListener('mouseup', this.handleMouseUp); + + if (this.props.onStop) { + this.props.onStop(this.state.value); + } + + this.setState({ isSliding: false }); + this.handleTogglePopover(false); + }; + + handleKeyDown = event => { + if (event.key === 'Tab' || event.key === 'Escape') { + return; + } + + event.preventDefault(); + event.persist(); + this.handleThrottledKeyDown(event); + }; + + handleDebouncedKeyInput = debounce(() => { + this.handleTogglePopover(false, 150); + if (this.props.onStop) { + this.props.onStop(this.state.value); + } + }, 250); + + handleThrottledKeyDown = throttle(event => { + const { disabled, minValue, maxValue, stopCount } = this.props; + const { value: currentValue } = this.state; + + if (disabled) { + this.setState({ isHovered: true }, () => { + this.handleTogglePopover(false, 400); + }); + return; + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowRight') { + const newValue = currentValue + 1; + const value = + newValue > maxValue ? maxValue : newValue > stopCount - 1 ? stopCount - 1 : newValue; + return this.setState({ value, isHovered: true }, () => { + this.handleDebouncedKeyInput(); + if (this.props.onSlide) { + this.props.onSlide(value); + } + }); + } else if (event.key === 'ArrowUp' || event.key === 'ArrowLeft') { + const newValue = currentValue - 1; + const value = newValue < minValue ? minValue : newValue < 0 ? 0 : newValue; + return this.setState({ value, isHovered: true }, () => { + this.handleDebouncedKeyInput(); + if (this.props.onSlide) { + this.props.onSlide(value); + } + }); + } + + return false; + }, 50); + + handleMouseEnter = event => { + event.preventDefault(); + this.handleTogglePopover(true); + }; + + handleMouseLeave = event => { + event.preventDefault(); + this.handleTogglePopover(false); + }; + + handleTogglePopover = (isOpen, delay = 0) => { + if (!isOpen) { + if (this._timeout) { + clearTimeout(this._timeout); + } + this._timeout = setTimeout(() => { + this.setState({ isHovered: false }); + this._timeout = null; + }, delay || 0); + } else { + if (this._timeout) { + clearTimeout(this._timeout); + } + this._timeout = setTimeout(() => { + this.setState({ isHovered: true }); + this._timeout = null; + }, delay || 0); + } + }; + + render() { + const { + value, + labels, + minValue, + maxValue, + onSlide, + onStop, + stopCount, + hideAvailableStops, + styleOverrides, + disabled, + ...props + } = this.props; + + const { isHovered, isSliding, value: pendingValue } = this.state; + const stops = this.getStops(); + + const activeStopIndex = Math.min(Math.round(pendingValue), stopCount - 1); + + return ( + event.preventDefault()} + onKeyDown={this.handleKeyDown} + onMouseDown={this.handleMouseDown} + onTouchStart={this.handleTouchStart} + tabIndex="0" + ref={this._slider} + position="relative" + width="100%" + minHeight="28px" + css={` + cursor: ${({ disabled }) => (disabled ? 'normal' : 'pointer')}; + touch-action: none; + + &:focus { + box-shadow: 0 0 0 0.2rem rgba(30, 145, 214, 0.5); + outline: none; + } + `} + {...props} + > + + + + + {!hideAvailableStops || minValue ? ( + + {stops.map(index => ( + (minValue ?? 0) && !(index >= maxValue) && !(index === stopCount - 1)) + } + minimumAvailable={index === minValue && minValue > 0} + key={index} + styleOverrides={styleOverrides} + /> + ))} + + ) : null} + + + + + ); + } +} + +const TrackPart = ({ children, left, right, ...props }) => ( + + {children} + +); + +function getPercentage(index, max) { + return (index / max) * 100; +} + +const Thumb = React.memo( + ({ isSliding, isHovered, isAtTrackStart, isAtTrackEnd, position, label, disabled }) => { + const ref = useRef(); + const isPopupOpen = !!(isHovered && (label || label === 0)); + return ( + + + {isPopupOpen && ( + + {`${label}`} + + )} + + ); + }, +); diff --git a/index-v6.js b/index-v6.js index a3ed80262..1909362fe 100644 --- a/index-v6.js +++ b/index-v6.js @@ -1,4 +1,5 @@ -export { Modal } from './components/modal'; export { Button, SegmentedButtonGroup } from './components/button'; -export { usePopover, Popover } from './components/popover-v6'; export { Dropdown } from './components/dropdown'; +export { Modal } from './components/modal'; +export { usePopover, Popover } from './components/popover-v6'; +export { Slider } from './components/slider'; diff --git a/index.js b/index.js index fac215d5b..5eb33ee31 100644 --- a/index.js +++ b/index.js @@ -32,8 +32,8 @@ export { Tooltip, } from './components/popover'; export { Radio } from './components/radio'; -export { Slider } from './components/slider'; export { SimpleToast } from './components/simple-toast'; +export { LegacySlider as Slider } from './components/slider'; export { TabManager, Tab,