How to pass a react-transition-group TransitionComponent to react-bootstrap Toast? - javascript

I have the below custom <CompleteToast/> component built using react-bootstrap. I want to apply custom CSS transitions, but don't understand how to achieve this. The docs say I should pass a custom react-transition-group TransitionComponent but don't expand on how or give an example.
I've tried looking at the default <ToastFade/> but don't understand Typescript. I've tried passing <ToastTransition/> as a reference but the children don't render (the reference works as "Exit animation complete!" is logged). Passing <ToastTransition children={...}/> causes errors.
What approach should I be taking here? Is this possible without TypeScript?
My instinct is that I need to figure out how ToastTransition can automatically inherit Toast's children.
CompleteToast.js
import PropTypes from 'prop-types';
import {Toast} from "react-bootstrap";
import {useContext, useEffect, useRef, useState} from "react";
import {IoCheckmarkCircleOutline} from "react-icons/all";
import {GlobalAppContext} from "../../App";
import ToastTransition from "../react-transition-group/ToastTransition";
const dividers = {
s: 1000,
m: 60000,
h: 3.6e+6,
d: 8.64e+7,
w: 6.048e+8,
mo: 2.628e+9,
y: 3.154e+10
}
const calculateTimeDiff = (timeInMs) => {
const diff = Date.now() - timeInMs;
switch (true) {
case (diff < dividers.s):
return "Just now";
case (diff < dividers.m):
return Math.floor(diff / dividers.s) + "s";
case (diff < dividers.h):
return Math.floor(diff / dividers.m) + "m";
case (diff < dividers.d):
return Math.floor(diff / dividers.h) + "h";
case (diff < dividers.w):
return Math.floor(diff / dividers.d) + "d";
case (diff < dividers.mo):
return Math.floor(diff / dividers.w) + "w";
case (diff < dividers.y):
return Math.floor(diff / dividers.mo) + "mo";
case (diff >= dividers.y):
return Math.floor(diff / dividers.y) + "y";
default:
return diff + "ms";
}
}
const timeUntilNext = (from, unit = "s") => {
let divider = dividers[unit];
return (Math.ceil(from / divider) * divider) - from;
}
function CompleteToast({show, title, timestamp, bodyText, headerClass, updateDeleteIds, id, deleteIds}) {
const [showState, setShowState] = useState(show);
const [timestampUpdated, setTimestampUpdated] = useState(null);
const [timestampState, setTimestampState] = useState(timestamp);
const [timestampText, setTimestampText] = useState("Just now");
const setToasts = useContext(GlobalAppContext)[0].setStateFunctions.toasts;
const shownOnce = useRef(false);
const deleteTimeout = useRef(null);
const hovering = useRef(false);
const close = () => {
if (!hovering.current) {
setShowState(false);
}
}
const setDeleteTimeout = () => {
if (shownOnce.current && !hovering.current) {
deleteTimeout.current = setTimeout(() => {
setToasts(prevState => prevState.filter(x => x.id !== id));
}, 2000)
}
}
useEffect(() => {
if (showState) {
setTimestampUpdated(Date.now());
setTimestampState(Date.now);
} else {
setDeleteTimeout();
}
}, [showState]);
useEffect(() => {
if (!showState) {
setShowState(true);
shownOnce.current = true;
}
}, []);
useEffect(() => {
if (showState) {
//timestamp has been updated and the toast is still showing - update the text to the current time difference
const timeDiff = calculateTimeDiff(timestampState);
setTimestampText(timeDiff);
setTimeout(() => {
//trigger new update to timestamp text on the next second
setTimestampUpdated(Date.now());
}, timeUntilNext(Date.now(), "s"));
}
}, [timestampUpdated]);
return (
<Toast style={{whiteSpace: "pre-wrap"}}
onClose={close}
onClick={close}
onMouseEnter={() => {
hovering.current = true;
if (deleteTimeout.current) {
clearTimeout(deleteTimeout.current);
deleteTimeout.current = null;
setShowState(true);
}
}}
onMouseLeave={() => {
hovering.current = false;
setTimeout(close, 3000);
}}
show={showState}
delay={4000}
autohide
id={id}
transition={ToastTransition}
className="cursor-pointer">
<Toast.Header className={headerClass} closeButton={false}>
<IoCheckmarkCircleOutline className={"smallIcon me-2"}/>
<p className="fs-5 my-0 me-auto">{title}</p>
<small>{timestampText}</small>
</Toast.Header>
<Toast.Body className={"position-relative"}>
{bodyText}
</Toast.Body>
</Toast>
);
}
CompleteToast.propTypes = {
show: PropTypes.bool,
handleClick: PropTypes.func,
buttonText: PropTypes.string,
buttonVariant: PropTypes.string,
bodyText: PropTypes.string,
headerClass: PropTypes.string,
title: PropTypes.string
};
CompleteToast.defaultProps = {
show: false,
timestamp: Date.now(),
timestampText: Date.now(),
title: "Success",
bodyText: "The operation was completed successfully",
headerClass: "bg-success text-white",
buttonVariant: "primary"
}
export default CompleteToast;
ToastTransition.js
import {Transition} from 'react-transition-group';
//copied from react-transition-group docks - aim is to getit working, then customise the actual transition
const duration = 300;
const defaultStyle = {
transition: `opacity ${duration}ms ease-out, maxHeight opacity ${duration}ms ease-out`,
opacity: 0,
maxHeight: 0,
}
const transitionStyles = {
entering: {opacity: 1, maxHeight: "200px"},
entered: {opacity: 1, maxHeight: "200px"},
exiting: {opacity: 0, maxHeight: 0},
exited: {opacity: 0, maxHeight: 0},
};
const ToastTransition = ({in: inProp, children}) => (
<Transition
in={inProp}
timeout={duration}
onExited={() => {
console.log("Exit animation complete!")
}}>
{state => (
<div style={{
...defaultStyle,
...transitionStyles[state]
}}>
{children}
</div>
)}
</Transition>
);
export default ToastTransition;

I'm an idiot - it's as simple as calling the children prop which is 'special' and is passed automatically.
It was visually rendering because I needed to manually set a show class on the <CompleteToast/> component, i.e.:
className="cursor-pointer show"
The react-transition-group component handles the actual showing/hiding it seems (this is inconsistent with the default behaviour, but as far as I can see it's not cuasing issues so far).
Working ToastTransition.js (need to tweak CSS)
import {Transition} from 'react-transition-group';
const duration = 500;
const defaultStyle = {
transition: `opacity ${duration}ms ease-in-out, max-height ${duration}ms ease-out`,
opacity: 0,
maxHeight: 0
}
const transitionStyles = {
entering: {opacity: 1, maxHeight: "200px"},
entered: {opacity: 1, maxHeight: "200px"},
exiting: {opacity: 0, maxHeight: 0},
exited: {opacity: 0, maxHeight: 0},
};
const ToastTransition = ({in: inProp, children}) => {
return (
<Transition
in={inProp}
timeout={duration}
onExited={() => {
children.props.onExited();
}}>
{(state) => (
<div style={{
...defaultStyle,
...transitionStyles[state]
}}>
{children}
</div>
)}
</Transition>
)
}
export default ToastTransition;

Related

Javascript Spinner not Spinning to the Correct Position

I'm trying to modify a Spinner component by adding a new method Spinner.moveToPosition so that it will spin to a specific symbol, as opposed to Spinner.forceUpdateHandler which spins it to a random symbol.
Here's my CodePen: https://codepen.io/gameveloster/pen/qBxJPVK
Original CodePen: https://codepen.io/antibland/pen/ypagZd
However, when I added a "SPIN!" button where clicking the button will get them to spin to positions 0, 0 and 1 so that the left two spinners will display the same image
const handleClick = () => {
// The first and second symbols should be the same after spinning, but they are not
_child1?.current?.moveToPosition(0);
_child2?.current?.moveToPosition(0);
_child3?.current?.moveToPosition(1);
};
they end up not doing that:
What might be the issue here? Thank you!
Spinner.js
import React from "react";
class Spinner extends React.Component {
constructor(props) {
super(props);
this.forceUpdateHandler = this.forceUpdateHandler.bind(this);
}
moveToPosition(position) {
if (this.timer) {
clearInterval(this.timer);
}
this.setState({
position: position * Spinner.iconHeight,
timeRemaining: this.props.timer,
});
this.timer = setInterval(() => {
this.tick();
}, 100);
}
forceUpdateHandler() {
this.reset();
}
reset() {
if (this.timer) {
clearInterval(this.timer);
}
this.start = this.setStartPosition();
this.setState({
position: this.start,
timeRemaining: this.props.timer,
});
this.timer = setInterval(() => {
this.tick();
}, 100);
}
state = {
position: 0,
lastPosition: null,
};
static iconHeight = 188;
multiplier = Math.floor(Math.random() * (4 - 1) + 1);
start = this.setStartPosition();
speed = Spinner.iconHeight * this.multiplier;
setStartPosition() {
return Math.floor(Math.random() * 9) * Spinner.iconHeight * -1;
}
moveBackground() {
this.setState({
position: this.state.position - this.speed,
timeRemaining: this.state.timeRemaining - 100,
});
}
getSymbolFromPosition() {
let { position } = this.state;
const totalSymbols = 9;
const maxPosition = Spinner.iconHeight * (totalSymbols - 1) * -1;
let moved = (this.props.timer / 100) * this.multiplier;
let startPosition = this.start;
let currentPosition = startPosition;
for (let i = 0; i < moved; i++) {
currentPosition -= Spinner.iconHeight;
if (currentPosition < maxPosition) {
currentPosition = 0;
}
}
this.props.onFinish(currentPosition);
}
tick() {
if (this.state.timeRemaining <= 0) {
clearInterval(this.timer);
this.getSymbolFromPosition();
} else {
this.moveBackground();
}
}
componentDidMount() {
clearInterval(this.timer);
this.setState({
position: this.start,
timeRemaining: this.props.timer,
});
this.timer = setInterval(() => {
this.tick();
}, 100);
}
render() {
let { position, current } = this.state;
return (
<div
style={{ backgroundPosition: "0px " + position + "px" }}
className={`icons`}
/>
);
}
}
export default Spinner;
App.tsx
import React, { useRef } from "react";
import "./App.css";
import Spinner from "./Spinner.js";
const App = () => {
const [matches, setMatches] = React.useState<number[]>([]);
const _child1 = useRef<Spinner | null>(null);
const _child2 = useRef<Spinner | null>(null);
const _child3 = useRef<Spinner | null>(null);
const handleClick = () => {
// The first and second symbols should be the same after spinning, but they are not
_child1?.current?.moveToPosition(0);
_child2?.current?.moveToPosition(0);
_child3?.current?.moveToPosition(1);
};
const finishHandler = (value: number) => {
setMatches([...matches, value]);
if (matches.length === 3) {
console.log("Done");
emptyArray();
}
};
const emptyArray = () => {
setMatches([]);
};
return (
<div>
<div className={`spinner-container`}>
<Spinner onFinish={finishHandler} ref={_child1} timer="1000" />
<Spinner onFinish={finishHandler} ref={_child2} timer="1400" />
<Spinner onFinish={finishHandler} ref={_child3} timer="2200" />
<div className="gradient-fade"></div>
</div>
<button onClick={handleClick}>SPIN!!!</button>
</div>
);
};
export default App;

Assigning ScrollTo value cause unexpected flickering/blinking on iOS devices

We recently worked on an auto-scrolling while freely swipeable component using React.js. The implementation idea is inspired by this article
And we've made something like this in React:
import React, { Component } from "react";
import PropTypes from "prop-types";
import "./AutoScroller.css";
const NUM_OF_CLONES = 10;
const AUTO_SCROLL_OFFSET = 1; // min offset of scrollTo is 1
const AUTO_SCROLL_INTERVAL = 32; // 1000 ms / 30 fps
export default class AutoScroller extends Component {
static propTypes = {
contents: PropTypes.array.isRequired,
itemWidth: PropTypes.number.isRequired,
numsOfItemsPerScreen: PropTypes.number.isRequired
};
constructor(props) {
super(props);
this.autoScrollerRef = React.createRef();
this.currentPosition = 0;
this.autoScrollTimer = null;
this.scrollingTimer = null;
/* boolean status */
this.isTouch = false;
this.isScrolling = false;
}
componentDidMount() {
this.startAutoScroll();
this.autoScrollerRef.current.addEventListener(
"touchstart",
this.touchStartHandler
);
this.autoScrollerRef.current.addEventListener(
"touchend",
this.touchEndHandler
);
this.autoScrollerRef.current.addEventListener("scroll", this.scrollHandler);
this.autoScrollerRef.current.addEventListener(
"contextmenu",
this.contextMenuHandler
);
}
componentWillUnmount() {
this.clearAutoScroll();
this.clearScrollingTimer();
this.autoScrollerRef.current.removeEventListener(
"touchstart",
this.touchStartHandler
);
this.autoScrollerRef.current.removeEventListener(
"touchend",
this.touchEndHandler
);
this.autoScrollerRef.current.removeEventListener(
"scroll",
this.scrollHandler
);
this.autoScrollerRef.current.removeEventListener(
"contextmenu",
this.contextMenuHandler
);
}
touchStartHandler = () => {
this.isTouch = true;
this.clearAutoScroll();
};
touchEndHandler = () => {
this.isTouch = false;
if (!this.isScrolling) {
this.currentPosition = this.autoScrollerRef.current.scrollLeft;
this.startAutoScroll();
}
};
scrollHandler = () => {
const {
contents: { length },
itemWidth
} = this.props;
this.isScrolling = true;
this.currentPosition = this.autoScrollerRef.current.scrollLeft;
const maxOffset = length * itemWidth;
if (this.currentPosition > maxOffset) {
const offset = this.currentPosition - maxOffset;
this.autoScrollerRef.current.scrollTo(offset, 0);
this.currentPosition = offset;
} else if (this.currentPosition <= 0) {
const offset = this.currentPosition + maxOffset;
this.autoScrollerRef.current.scrollTo(offset, 0);
this.currentPosition = offset;
}
/***
* note: there will be only one timer, and the timer is only created by the very last scroll
* only when the scroll event is not triggered anymore, the timer starts to get executed.
*/
if (this.scrollingTimer) {
clearTimeout(this.scrollingTimer);
}
this.scrollingTimer = setTimeout(() => {
this.isScrolling = false;
/***
* note: resume auto-scroll when the momentum scroll (after finger leaves) stalls the scroll
*/
if (!this.isTouch) {
this.startAutoScroll();
}
}, 300);
};
contextMenuHandler = (event) => {
event.preventDefault();
};
startAutoScroll = () => {
if (!this.autoScrollTimer) {
this.autoScrollTimer = setInterval(this.autoScroll, AUTO_SCROLL_INTERVAL);
}
};
clearAutoScroll = () => {
if (this.autoScrollTimer) {
clearInterval(this.autoScrollTimer);
this.autoScrollTimer = null;
}
};
clearScrollingTimer = () => {
if (this.scrollingTimer) {
clearTimeout(this.scrollingTimer);
this.scrollingTimer = null;
}
};
autoScroll = () => {
const {
contents: { length },
itemWidth,
numsOfItemsPerScreen
} = this.props;
if (this.currentPosition < 0) {
this.currentPosition = 0;
}
if (length > numsOfItemsPerScreen) {
const position = this.currentPosition + AUTO_SCROLL_OFFSET;
this.autoScrollerRef.current.scrollTo(position, 0);
const maxOffset = length * itemWidth;
if (this.currentPosition > maxOffset) {
const offset = this.currentPosition - maxOffset;
this.autoScrollerRef.current.scrollTo(offset, 0);
this.currentPosition = offset;
} else {
this.currentPosition = position;
}
}
};
getWrappedData = () => {
const { contents } = this.props;
const { length } = contents;
const numberOfClones = length < NUM_OF_CLONES ? length : NUM_OF_CLONES;
return [...contents, ...contents.slice(0, numberOfClones)];
};
render() {
const { itemGap, lineHeight } = this.props;
return (
<div className="auto-scroller" ref={this.autoScrollerRef}>
<ul>
{this.getWrappedData().map((content, index) => (
<Item
key={`auto-scroller-item-${index}`}
content={content}
itemGap={itemGap}
lineHeight={lineHeight}
/>
))}
</ul>
</div>
);
}
}
class Item extends Component {
static propTypes = {
content: PropTypes.object.isRequired,
itemGap: PropTypes.number,
lineHeight: PropTypes.number
};
render() {
const { content, itemGap = 10 } = this.props;
return (
<li
className="auto-scroller__item"
style={{ paddingRight: `${itemGap}px` }}
>
<div className="auto-scroller__item__content">
<img draggable={false} src={content.imgUrl} />
<div className="auto-scroller__item__content__title">
{content.title}
</div>
</div>
</li>
);
}
}
You can test with the demo from PlayCode (source code).
Just open the link with Safari on the iPhone.
What I observed was every time when it was on the boundary cases, the image started to flicker.
Further, if you swipe it with your finger forth and back on that point, the whole UI started to flicker. (see this screen recording) However, we didn't spot this glitch on Android devices.
Any possible solutions are welcome. Does anyone encounter something like this before?
removing overflow-y: hidden; and overflow-x: auto; from autoscroller.css
solved it on my end.
another solution would be to add z-index: 1; and scroll-behavior: smooth; to .auto-scroller
let me know if it worked!

How can I add a fade when the image is changed?

This is an image that changes every 5 seconds, but I think it's distracting, because it doesn't have any fade. How can I make it so that there is a short transition time like you can do in CSS?
import React, { useEffect, useState } from "react";
import aa from '../imgs/aa.JPG'
import aa2 from '../imgs/aa2.JPG'
import aa3 from '../imgs/aa3.JPG'
import aa4 from '../imgs/aa4.JPG'
import gg from '../imgs/gg.jpeg'
import gg2 from '../imgs/gg2.jpeg'
import gg3 from '../imgs/gg3.jpeg'
import gg4 from '../imgs/gg4.jpeg'
import './AnimatedGalery.css'
const images = [aa, aa2, aa3, aa4, gg, gg2, gg3, gg4];
export default function () {
let [currentIndex, setCurrentIndex] = useState(0);
useEffect(() => {
const intervalId = setInterval(() => {
if(currentIndex == images.length - 1) {
setCurrentIndex(currentIndex = 0);
}
else {
setCurrentIndex(currentIndex = currentIndex + 1);
}
}, 5000)
return () => clearInterval(intervalId);
}, [])
return (
<div>
<img src={images[currentIndex]} />
</div>
)
}
Have a look at React Spring https://aleclarson.github.io/react-spring/v9/
Here is a quick sandbox showing a demo of what it sounds like you're after.
https://codesandbox.io/s/affectionate-nightingale-r7yjm?file=/src/App.tsx
Essentially, React Spring's useTransition hook will play spring-based animations when the data provided to the hook changes.
import { animated, useTransition } from "#react-spring/web";
import * as React from "react";
const imageUrls = [
"https://images.unsplash.com/photo-1462396240927-52058a6a84ec?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=966&q=80",
"https://images.unsplash.com/photo-1495314736024-fa5e4b37b979?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=1352&q=80",
"https://images.unsplash.com/photo-1612004687343-617e7c8f68d8?ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&ixlib=rb-1.2.1&auto=format&fit=crop&w=634&q=80"
];
export default function App() {
const [index, setIndex] = React.useState(0);
const [imageUrl, setImageUrl] = React.useState(imageUrls[index]);
React.useEffect(() => {
const timeoutId = window.setTimeout(() => {
setIndex((s) => {
let newIndex = 0;
if (s < imageUrls.length - 1) newIndex = s + 1;
setImageUrl(imageUrls[newIndex]);
return newIndex;
});
}, 2500);
return () => clearTimeout(timeoutId);
}, [index]);
const transition = useTransition(imageUrl, {
from: { opacity: 0, transform: "translateY(-1rem) scale(0.75)" },
enter: { opacity: 1, transform: "translateY(-0rem) scale(1)" },
leave: { opacity: 0, transform: "translateY(1rem) scale(1.25)" }
});
const fragment = transition((style, item) => (
<animated.div style={{ ...style, position: "absolute" }}>
<img
src={item}
alt=""
style={{ width: 200, height: 200, objectFit: "cover" }}
/>
</animated.div>
));
return (
<div
style={{
display: "flex",
width: "100vw",
height: "100vh",
alignItems: "center",
justifyContent: "center"
}}
>
{fragment}
</div>
);
}

React Masonry Layout has bugs rendering images with fade-in effect

Code is here: https://codesandbox.io/s/gatsby-starter-default-ry8sm
You can try demo: https://ry8sm.sse.codesandbox.io/
Every picture is an Enlarger component which will zoom in when you click on it. And they are designed to show up sequentially by fading in. I use Ref to track every Enlarger and here is the code snippet for it.
import Img from "react-image-enlarger"
class Enlarger extends React.Component {
state = { zoomed: false, opacity: 0 }
toggleOpacity = o => {
this.setState({ opacity: o })
}
render() {
const { index, orderIndex, src, enlargedSrc, onLoad } = this.props
return (
<div style={{ margin: "0.25rem" }} onLoad={onLoad}>
<Img
style={{
opacity: this.state.opacity,
transition: "opacity 0.5s cubic-bezier(0.25,0.46,0.45,0.94)",
transitionDelay: `${orderIndex * 0.07}s`,
}}
zoomed={this.state.zoomed}
src={src}
enlargedSrc={enlargedSrc}
onClick={() => {
this.setState({ zoomed: true })
}}
onRequestClose={() => {
this.setState({ zoomed: false })
}}
/>
</div>
)
}
}
export default Enlarger
And I have a Masonry component which will achieve the Masonry layout
import React, { Component } from "react"
import imagesLoaded from "imagesloaded"
import PropTypes from "prop-types"
import TransitionGroup from "react-transition-group/TransitionGroup"
class MasonryGrid extends Component {
componentDidMount() {
window.onload = this.resizeAllGridItems()
window.addEventListener("resize", this.resizeAllGridItems)
let allItems = document.getElementsByClassName("masonry-grid--item")
for (let x = 0; x < allItems.length; x++) {
imagesLoaded(allItems[x], this.resizeInstance)
}
}
resizeAllGridItems = () => {
let allItems = document.getElementsByClassName("masonry-grid--item")
for (let x = 0; x < allItems.length; x++) {
this.resizeGridItem(allItems[x])
}
}
resizeGridItem = item => {
let grid = document.getElementsByClassName("masonry-grid")[0]
let rowHeight = parseInt(
window.getComputedStyle(grid).getPropertyValue("grid-auto-rows")
)
let rowGap = parseInt(
window.getComputedStyle(grid).getPropertyValue("grid-row-gap")
)
let rowSpan = Math.ceil(
(item.querySelector(".content").getBoundingClientRect().height + rowGap) /
(rowHeight + rowGap)
)
item.style.gridRowEnd = "span " + rowSpan
}
resizeInstance = instance => {
let item = instance.elements[0]
this.resizeGridItem(item)
}
render() {
const MasonryGrid = {
display: "grid",
gridGap: `${this.props.gridGap}`,
gridTemplateColumns: `repeat(auto-fill, minmax(${
this.props.itemWidth
}px, 1fr))`,
gridAutoRows: "10px",
}
return (
<TransitionGroup>
<div className="masonry-grid" style={MasonryGrid}>
{this.props.children.length >= 1 &&
this.props.children.map((item, index) => {
return (
<div className="masonry-grid--item" key={index}>
<div className="content">{item}</div>
</div>
)
})}
</div>
</TransitionGroup>
)
}
}
MasonryGrid.defaultProps = {
itemWidth: 250,
gridGap: "6px 10px",
}
MasonryGrid.propTypes = {
itemWidth: PropTypes.number,
gridGap: PropTypes.string,
}
export default MasonryGrid
The problem is, if you look at the demo, when you click on tab project1, you will see the pictures show up on top of each other and doesn't spread well as intended. But once you resize the browser a little bit, they becomes normal and form the Masonry layout I wanted. I suspect it has something to do with the fade-in effect I implemented but I don't know how to fix it.

Is there a way to build range slider with dat.gui.js in ThreeJs?

I want to have a range slider in my design, although I have a number slider but would like to have where I can select a range of number to be passed like in below picture.
Is there any option rather than making changes in the dat.gui.js file and adding a feature for range slider?
No, that is not possible. The creation of the controllers is based solely on the value-type which is handled here. You will have to either hack it, make a pull-request or just go with two sliders (min/max).
I have implemented a dat-gui range slider lookalike in React (with material-ui). Perhaps this will help someone trying to create their own, so I thought I'd post it here. Here's what the slider looks like
import React, { useEffect, useRef, useCallback } from 'react';
import cx from 'classnames';
import { makeStyles, Theme, createStyles } from '#material-ui/core';
import clamp from 'lodash/clamp';
import useEventCallback from 'utils/useEventCallback';
function asc(a: number, b: number) {
return a - b;
}
function findClosest(values: any, currentValue: number) {
const { index: closestIndex } = values.reduce((acc: { distance: number; } | null, value: number, index: number) => {
const distance = Math.abs(currentValue - value);
if (acc === null || distance < acc.distance || distance === acc.distance) {
return {
distance,
index,
};
}
return acc;
}, null);
return closestIndex;
}
function valueToPercent(value: number, min: number, max: number) {
return ((value - min) * 100) / (max - min);
}
function percentToValue(percent: number, min: number, max: number) {
return (max - min) * percent + min;
}
function getDecimalPrecision(num: number) {
// This handles the case when num is very small (0.00000001), js will turn this into 1e-8.
// When num is bigger than 1 or less than -1 it won't get converted to this notation so it's fine.
if (Math.abs(num) < 1) {
const parts = num.toExponential().split('e-');
const matissaDecimalPart = parts[0].split('.')[1];
return (
(matissaDecimalPart ? matissaDecimalPart.length : 0) +
parseInt(parts[1], 10)
);
}
const decimalPart = num.toString().split('.')[1];
return decimalPart ? decimalPart.length : 0;
}
function roundValueToStep(value: number, step: number) {
const nearest = Math.round(value / step) * step;
return Number(nearest.toFixed(getDecimalPrecision(step)));
}
function setValueIndex({ values, source, newValue, index }: any) {
// Performance shortcut
if (values[index] === newValue) {
return source;
}
const output = [...values];
output[index] = newValue;
return output;
}
const axisProps = {
offset: (percent: number) => ({ left: `${percent}%` }),
leap: (percent: number) => ({ width: `${percent}%` }),
};
const trackMouse = (event: React.MouseEvent) => ({ x: event.clientX, y: event.clientY, });
const useStyles = makeStyles((theme: Theme) =>
createStyles({
root: {
width: '100%',
boxSizing: 'border-box',
display: 'inline-block',
cursor: 'ew-resize',
touchAction: 'none',
border: '3px solid #1a1a1a',
},
slider: {
display: 'block',
position: 'relative',
backgroundColor: '#141414',
backgroundImage: `linear-gradient(90deg, ${theme.palette.primary.light}, ${theme.palette.primary.light})`, //#2fa1d6, #2fa1d6)",
backgroundRepeat: 'no-repeat',
height: '14px',
},
})
)
interface Props {
values: number[];
min: number;
max: number;
step: number;
className?: string;
color?: string;
defaultValue?: number[];
disabled?: boolean;
onChange?: (event: React.MouseEvent, value: any) => void;
onChangeCommitted?: (event: React.MouseEvent, value: any) => void;
onMouseDown?: (event: React.MouseEvent) => void;
}
export default function RangeSlider({
className,
color = 'primary',
defaultValue,
disabled = false,
max,
min,
onChange,
onChangeCommitted,
onMouseDown,
step,
values: valuesProp,
...other
}: Props) {
const classes = useStyles();
const sliderRef = useRef<any>();
const previousIndex = useRef<any>();
let values = [...valuesProp].sort(asc);
values = values.map((value: number) => clamp(value, min, max));
const getNewValue = useCallback(
({ mouse, move = false, values: values2, source }) => {
const { current: slider } = sliderRef;
const { width, left } = slider.getBoundingClientRect();
const percent = (mouse.x - left) / width;
let newValue;
newValue = percentToValue(percent, min, max);
newValue = roundValueToStep(newValue, step);
newValue = clamp(newValue, min, max);
let activeIndex = 0;
if (!move) {
activeIndex = findClosest(values2, newValue);
} else {
activeIndex = previousIndex.current;
}
const previousValue = newValue;
newValue = setValueIndex({
values: values2,
source,
newValue,
index: activeIndex,
}).sort(asc);
activeIndex = newValue.indexOf(previousValue);
previousIndex.current = activeIndex;
return { newValue, activeIndex };
},
[max, min, step]
);
const handleMouseMove = useEventCallback((event: React.MouseEvent) => {
const mouse = trackMouse(event);
const { newValue } = getNewValue({
mouse,
move: true,
values,
source: valuesProp,
});
if (onChange) {
onChange(event, newValue);
}
});
const handleMouseEnter = useEventCallback((event: React.MouseEvent) => {
// If the slider was being interacted with but the mouse went off the window
// and then re-entered while unclicked then end the interaction.
if (event.buttons === 0) {
handleMouseEnd(event);
}
});
const handleMouseEnd = useEventCallback((event: React.MouseEvent) => {
const mouse = trackMouse(event);
const { newValue } = getNewValue({
mouse,
values,
source: valuesProp,
});
if (onChangeCommitted) {
onChangeCommitted(event, newValue);
}
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseEnd);
window.removeEventListener('mouseenter', handleMouseEnter);
});
useEffect(() => {
return () => {
window.removeEventListener('mousemove', handleMouseMove);
window.removeEventListener('mouseup', handleMouseEnd);
window.removeEventListener('mouseenter', handleMouseEnter);
};
}, [disabled, handleMouseEnter, handleMouseEnd, handleMouseMove]);
const handleMouseDown = useEventCallback((event: React.MouseEvent) => {
if (onMouseDown) {
onMouseDown(event);
}
if (disabled) {
return;
}
event.preventDefault();
const mouse = trackMouse(event);
const { newValue } = getNewValue({
mouse,
values,
source: valuesProp,
});
if (onChange) {
onChange(event, newValue);
}
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseenter', handleMouseEnter);
window.addEventListener('mouseup', handleMouseEnd);
});
const sliderOffset = valueToPercent(values[0], min, max);
const sliderLeap = valueToPercent(values[values.length - 1], min, max) - sliderOffset;
const widthBackground = axisProps.leap(sliderLeap).width;
const sliderStyle = {
...axisProps.offset(sliderOffset),
...axisProps.leap(sliderLeap),
backgroundSize: `${widthBackground}% 100%`,
};
return (
<span
ref={sliderRef}
className={cx(classes.root, className)}
onMouseDown={handleMouseDown}
{...other}
>
<span className={classes.slider} style={sliderStyle} />
{values.map((value, index) => {
const percent = valueToPercent(value, min, max);
const style = axisProps.offset(percent);
return (
<span
key={index}
role="slider"
style={style}
data-index={index}
/>
);
})}
</span>
);
}
RangeSlider.defaultProps = {
min: 0,
max: 100,
step: 1
}

Categories