Clickable ticker in React - javascript

I have literally tried for a few hours to replicate a clickable ticker, much like they have at the very top of this site: https://www.thebay.com/
I'm confused about what triggers useEffect and long story short, I can't come up with a solution that keeps the ticker moving AND also gives the option of clicking forward/backwards via arrows. Clicking the arrow should not permanently pause the ticker.
function Ticker() {
const [tickerDisplay, setTickerDisplay] = useState('Free In-store Pickup')
const [tickerIndex, setTickerIndex] = useState(0)
const [arrowClicked, setArrowClicked] = useState(false)
const notices = [
'Easy Returns within 30 Days of Purchase',
'Free Shipping on $99+ Orders',
'Free In-store Pickup',
]
const handleClick = (side) => {
setArrowClicked(true)
switch (side) {
case 'left':
setTickerIndex(
tickerIndex === 0 ? notices.length - 1 : tickerIndex - 1
)
break
case 'right':
setTickerIndex(
tickerIndex === notices.length - 1 ? 0 : tickerIndex + 1
)
break
default:
console.log('something went wrong')
break
}
}
useEffect(() => {
if (arrowClicked) {
setTickerDisplay(notices[tickerIndex])
setTickerIndex(
tickerIndex === notices.length - 1 ? 0 : tickerIndex + 1
)
setArrowClicked(false)
return
}
setTimeout(() => {
setTickerDisplay(notices[tickerIndex])
setTickerIndex(
tickerIndex === notices.length - 1 ? 0 : tickerIndex + 1
)
console.log('This will run every 6 seconds!')
}, 6000)
}, [tickerIndex, notices, tickerDisplay, arrowClicked])
return (
<IconContext.Provider value={{ className: 'ticker-icons-provider' }}>
<div className='ticker'>
<FaChevronLeft onClick={() => handleClick('left')} />
<div className='ticker_msg-wrapper'>{tickerDisplay}</div>
<FaChevronRight onClick={() => handleClick('right')} />
</div>
</IconContext.Provider>
)
}
export default Ticker
What is the best way to code this component?

This is not a work of art and probably some things could've been done better.
Hope that suits you.
const { useRef, useState, useEffect } = React;
const getItems = () => Promise.resolve(['All of our questions are now open', 'Answers extended: 72 hours after questions open', 'Post a question or get an answer', 'Free badges on 20k points'])
const Ticker = ({onPrevious, onNext, items, currentIndex}) => {
const ref = useRef(null);
const [size, setSize] = useState({
width: 0,
widthPx: '0px',
height: 0,
heightPx: '0px'
})
useEffect(() => {
if(ref && ref.current) {
const {width, height} = ref.current.getBoundingClientRect();
setSize({
width,
widthPx: `${width}px`,
height,
height: `${height}px`
})
}
}, [ref]);
const calculateStyleForItem = (index) => {
return {
width: size.width,
transform: `translateX(${0}px)`
}
}
const calculateStyleForContainer = () => {
return {
width: `${size.width * (items.length + 1)}px`,
transform: `translateX(${-currentIndex * size.width + 2 * size.width}px)`
}
}
return <div ref={ref} className="ticker">
<div style={{width: size.widthPx, height: size.heightPx}} className="ticker__foreground">
<div onClick={onPrevious} className="arrow">{'<'}</div>
<div onClick={onNext} className="arrow">{'>'}</div>
</div>
<div>
<div style={calculateStyleForContainer()} className="ticker__values">
{items.map((value, index) => <div key={index} style={calculateStyleForItem(index)}className="ticker__value">{value}</div>)}
</div>
</div>
</div>
}
const App = () => {
const [items, setItems] = useState([]);
const [currentIndex, setCurrentIndex] = useState(0);
const [clicked, setClicked] = useState(false);
useEffect(() => {
let isUnmounted = false;
getItems()
.then(items => {
if(isUnmounted) {
return
}
setItems(items);
})
return () => {
isUnmounted = true;
}
}, [])
useEffect(() => {
if(!items.length) {
return () => {
}
}
let handle = null;
const loop = () => {
if(!clicked) {
onNext(null);
}
setClicked(false);
handle = setTimeout(loop, 2000);
}
handle = setTimeout(loop, 2000);
return () => {
clearTimeout(handle);
}
}, [items, clicked])
const onPrevious = () => {
setClicked(true);
setCurrentIndex(index => (index - 1) > -1 ? index - 1 : items.length - 1)
}
const onNext = (programmatically) => {
if(programmatically) {
setClicked(programmatically);
}
setCurrentIndex(index => (index + 1) % items.length)
}
return <div>
{items.length ? <Ticker onPrevious={onPrevious} onNext={onNext} currentIndex={currentIndex} items={items}/> : 'Loading'}
</div>
}
ReactDOM.render(
<App />,
document.getElementById('root')
);
html, body {
box-sizing: border-box;
margin: 0;
}
.ticker {
display: flex;
justify-content: center;
align-items: center;
background: black;
font-size: 1rem;
color: white;
font-weight: bold;
padding: 1rem;
overflow: hidden;
}
.ticker__foreground {
position: absolute;
z-index: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.ticker__values {
transition: all .3s ease-in;
}
.ticker__value {
text-align: center;
display: inline-block;
vertical-align: middle;
float: none;
}
.arrow {
font-size: 1.5rem;
cursor: pointer;
padding: 1rem;
}
<script src="https://unpkg.com/react/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/babel-standalone#6/babel.min.js"></script>
<div id="root"></div>

Related

Correct way to map JSON to Table by Fetch API method (react js)

I'm trying to map some json elements to a Table (Customers.jsx using ) but can't see to figure out the correct way. How to insert my Fetch Method into Customers.jsx correctly? especially map renderBody part and the bodyData={/The JsonData/}
Fetch method
componentDidMount() {
this.refershList();
}
async refershList() {
const cookies = new Cookies();
await fetch('https://xxxxxxxxxxxxxxxxx/Customers', {
headers: { Authorization: `Bearer ${cookies.get('userToken')}` }
})
.then(response => response.json())
.then(data => {
this.setState({ deps: data });
});
}
Customers.jsx
import React from 'react'
import Table from '../components/table/Table'
const customerTableHead = [
'',
'name',
'email',
'phone',
'total orders',
'total spend',
'location'
]
const renderHead = (item, index) => <th key={index}>{item}</th>
const renderBody = (item, index) => (
<tr key={index}>
<td>{item.id}</td>
<td>{item.name}</td>
<td>{item.email}</td>
<td>{item.phone}</td>
<td>{item.total_orders}</td>
<td>{item.total_spend}</td>
<td>{item.location}</td>
</tr>
)
const Customers = () => {
return (
<div>
<h2 className="page-header">
customers
</h2>
<div className="row">
<div className="col-12">
<div className="card">
<div className="card__body">
<Table
limit='10'
headData={customerTableHead}
renderHead={(item, index) => renderHead(item, index)}
bodyData={/*The JsonData*/}
renderBody={(item, index) => renderBody(item, index)}
/>
</div>
</div>
</div>
</div>
</div>
)
}
export default Customers
API JSON data
[
{
"id":1,
"name":"Brittan Rois",
"email":"brois0#unicef.org",
"location":"Bator",
"phone":"+62 745 807 7685",
"total_spend":"$557248.44",
"total_orders":24011
},
{
"id":2,
"name":"Matthew Junifer",
"email":"mjunifer1#buzzfeed.com",
"location":"Bromma",
"phone":"+46 993 722 3008",
"total_spend":"$599864.94",
"total_orders":60195
},
{
"id":3,
"name":"Finlay Baylay",
"email":"fbaylay2#purevolume.com",
"location":"Atalaia",
"phone":"+55 232 355 3569",
"total_spend":"$171337.47",
"total_orders":96328
},
{
"id":4,
"name":"Beryle Monelli",
"email":"bmonelli3#amazonaws.com",
"location":"Martingança",
"phone":"+351 734 876 8127",
"total_spend":"$335862.78",
"total_orders":78768
}
]
Table.jsx
import React, {useState} from 'react'
import './table.css'
const Table = props => {
const initDataShow = props.limit && props.bodyData ? props.bodyData.slice(0, Number(props.limit)) : props.bodyData
const [dataShow, setDataShow] = useState(initDataShow)
let pages = 1
let range = []
if (props.limit !== undefined) {
let page = Math.floor(props.bodyData.length / Number(props.limit))
pages = props.bodyData.length % Number(props.limit) === 0 ? page : page + 1
range = [...Array(pages).keys()]
}
const [currPage, setCurrPage] = useState(0)
const selectPage = page => {
const start = Number(props.limit) * page
const end = start + Number(props.limit)
setDataShow(props.bodyData.slice(start, end))
setCurrPage(page)
}
return (
<div>
<div className="table-wrapper">
<table>
{
props.headData && props.renderHead ? (
<thead>
<tr>
{
props.headData.map((item, index) => props.renderHead(item, index))
}
</tr>
</thead>
) : null
}
{
props.bodyData && props.renderBody ? (
<tbody>
{
dataShow.map((item, index) => props.renderBody(item, index))
}
</tbody>
) : null
}
</table>
</div>
{
pages > 1 ? (
<div className="table__pagination">
{
range.map((item, index) => (
<div key={index} className={`table__pagination-item ${currPage === index ? 'active' : ''}`} onClick={() => selectPage(index)}>
{item + 1}
</div>
))
}
</div>
) : null
}
</div>
)
}
export default Table
Table.css
.table-wrapper {
overflow-y: auto;
}
table {
width: 100%;
min-width: 400px;
border-spacing: 0;
}
thead {
background-color: var(--second-bg);
}
tr {
text-align: left;
}
th,
td {
text-transform: capitalize;
padding: 15px 10px;
}
tbody > tr:hover {
background-color: var(--main-color);
color: var(--txt-white);
}
.table__pagination {
display: flex;
width: 100%;
justify-content: flex-end;
align-items: center;
margin-top: 20px;
}
.table__pagination-item ~ .table__pagination-item {
margin-left: 10px;
}
.table__pagination-item {
width: 30px;
height: 30px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.table__pagination-item.active,
.table__pagination-item.active:hover {
background-color: var(--main-color);
color: var(--txt-white);
font-weight: 600;
}
.table__pagination-item:hover {
color: var(--txt-white);
background-color: var(--second-color);
}
To handle this case you can use the useEffect hook inside the component. This hook lets you execute a function when the component mounts and whenever any variable defined in its dependencies array changes. Like this:
const Customers = () => {
//...
const [deps, setDeps] = useState();
refreshList() {
const cookies = new Cookies();
fetch('https://xxxxxxxxxxxxxxxxx/Customers', {
headers: { Authorization: `Bearer ${cookies.get('userToken')}` }
}).then(response => response.json()).
then(data => {
setDeps(data);
});
}
//This effect will call refreshList() when the component is mounted
useEffect(() => {
refreshList();
}, []); //The empty array tells that this will be executed only when the component mounts
//...
}
UPDATE: This is how you may use it in your component itself
//...
const Customers = () => {
const [data, setData] = useState();
refreshList() {
const cookies = new Cookies();
fetch('https://xxxxxxxxxxxxxxxxx/Customers', {
headers: { Authorization: `Bearer ${cookies.get('userToken')}` }
}).then(response => response.json()).
then(data => {
setData(data);
});
}
useEffect(() => {
refreshList();
}, []);
return (
<div>
<h2 className="page-header">
customers
</h2>
<div className="row">
<div className="col-12">
<div className="card">
<div className="card__body">
<Table
limit='10'
headData={customerTableHead}
renderHead={(item, index) => renderHead(item, index)}
bodyData={data}
renderBody={(item, index) => renderBody(item, index)}
/>
</div>
</div>
</div>
</div>
</div>
)
}

How to prevent tooltip going out of screen reactjs

Here a small demo. There are a few block; hovering on each block appears a tooltip(orange rect). It doesn't work correctly.
Tooltip should be displayed from left or right side. To get sizes of tooltip need to display it. Coords to display tooltip can be calculated only after tooltip is displayed
Codesandbox https://codesandbox.io/s/react-ref-65jj6?file=/src/index.js:88-231
const { useState, useEffect, useCallback } = React;
function App() {
return (
<div>
<HoveredBlock index={1} />
<HoveredBlock index={2} blockStyle={{ marginLeft: "5%" }} />
<HoveredBlock index={3} blockStyle={{ marginLeft: "50%" }} />
</div>
);
}
function calcCoords(blockRect, hoverRect) {
const docWidth = document.documentElement.clientWidth;
const isLeft = blockRect.right + hoverRect.width > docWidth;
const coords = {};
if (!isLeft) {
coords.x = blockRect.right;
coords.y = blockRect.top;
coords.type = "right";
} else {
coords.x = blockRect.left - 5 - hoverRect.width;
coords.y = blockRect.top;
coords.type = "left";
}
return coords;
}
function HoveredBlock({ index, blockStyle }) {
const [blockRect, setBlockRect] = useState();
const [hoverRect, setHoverRect] = useState();
const [showHover, setShowHover] = useState(false);
const [coords, setCoords] = useState();
const blockRef = useCallback((node) => {
if (node) {
setBlockRect(node.getBoundingClientRect());
}
}, []);
const hoverRef = useCallback(
(node) => {
if (showHover && node) {
setHoverRect(node.getBoundingClientRect());
}
},
[showHover]
);
useEffect(() => {
if (showHover && hoverRect) {
const coords = calcCoords(blockRect, hoverRect);
setCoords(coords);
}
}, [hoverRect]);
const isHidden = !showHover || !coords ? 'hidden' : '';
return (
<div>
<div
ref={blockRef}
className="block"
style={blockStyle}
onMouseEnter={() => setShowHover(true)}
onMouseLeave={() => setShowHover(false)}
>
{index}
</div>
<div
ref={hoverRef}
className={'hover-block' + isHidden}
style={{
left: coords && coords.x,
top: coords && coords.y
}}
/>
</div>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
rootElement
);
.block {
width: 100px;
height: 100px;
background-color: aquamarine;
margin-left: 82%;
}
.hover-block {
position: fixed;
width: 100px;
height: 100px;
background-color: coral;
}
.hidden {
display: none;
}
<script src="https://unpkg.com/react#17/umd/react.development.js" crossorigin></script>
<script src="https://unpkg.com/react-dom#17/umd/react-dom.development.js" crossorigin></script>
<div id="root"></div>
I solved it. I changed the way how to hide element: visibility:hidded instead of display:none
export default function HoveredBlock({ blockStyle }) {
const [blockRect, setBlockRect] = useState();
const [hoverRect, setHoverRect] = useState();
const [showHover, setShowHover] = useState(false);
const [coords, setCoords] = useState();
const blockRef = useCallback((node) => {
if (node) {
setBlockRect(node.getBoundingClientRect());
}
}, []);
const hoverRef = useCallback((node) => {
if (node) {
setHoverRect(node.getBoundingClientRect());
}
}, []);
useEffect(() => {
if (showHover) {
console.log({ blockRect, hoverRect });
const coords = calcCoords(blockRect, hoverRect);
setCoords(coords);
}
}, [showHover, blockRect, hoverRect]);
return (
<>
<div
ref={blockRef}
className="block"
style={blockStyle}
onMouseEnter={() => setShowHover(true)}
onMouseLeave={() => setShowHover(false)}
/>
<div
ref={hoverRef}
className={cx("hover-block", {
hidden: !showHover || !coords
})}
style={{
left: coords && coords.x,
top: coords && coords.y
}}
></div>
</>
);
}
.block {
width: 100px;
height: 100px;
background-color: aquamarine;
margin-left: 20%;
}
.hover-block {
position: fixed;
width: 100px;
height: 100px;
background-color: coral;
}
.hidden {
visibility: hidden;
}

Method not getting correct useState value despite updating state in React

I am working on a site where I am trying to display paginated student information. For example I have 12 students I have pagination items 1-4 as I display 3 students at a time.
I am running into a problem when I call my update pagination function. The counter in this method is always 0 despite me incrementing it correctly. Any ideas or pointers would be greatly appreciated.
import React, {useState, useEffect} from 'react';
function App() {
let students = [
{'name': 'Harry'},
{'name': 'Hermoine'},
{'name': 'Ron'},
{'name': 'Ginny'},
{'name': 'Snape'},
{'name': 'Bellatrix'},
{'name': 'Albus'},
{'name': 'Dudley'},
{'name': 'Petunia'},
{'name': 'Hagrid'},
{'name': 'Lee'},
{'name': 'James'}
];
let [loaded, setLoaded] = useState(false);
let [pagination, setPagination] = useState([]);
let [limit, setLimit] = useState(3); // how many students are visible at a time
let [pages, setPages] = useState(Math.round(students.length/limit)); // amount of pages based on total students
let [currentPage, setCurrentPage] = useState(1); // the current page
let [pageCount, setPageCount] = useState(0); // counter used to increment/decrement pages in view
function updatePagination() {
let tmpArray = [];
for(let i = pageCount; i < pages; i ++){
tmpArray.push(i+1);
}
// 1-3, 4-6 etc...
setPagination(tmpArray.slice(pageCount, pageCount + limit)); // this pageCount is always 0 despite me setting it on handleNext method
}
function handleNext(){
setCurrentPage(currentPage + 1);
if(pageCount + limit === currentPage){
if(currentPage <= pages){
setPageCount(pageCount + limit);
}
}
updatePagination();
}
useEffect(() => {
updatePagination();
setLoaded(true);
}, []);
return (
<main className={styles.app}>
<div className={styles.student}>
<h1>Three student cards</h1>
</div>
<ol className={styles.pagination}>
<div className={styles.paginationContainer}>
<button className={currentPage === 0 ? styles.prev + ' ' + styles.disabled : styles.prev}>prev</button>
{loaded && pagination.map((item, index) => {
return (
<li key={index} className={styles.paginationItem}>
<button className={currentPage === item ? styles.active : null}>{item}</button>
</li>
)
})}
<button onClick={() => {
handleNext()
}} className={currentPage === pages ? styles.next + ' ' + styles.disabled : styles.next}>next</button>
</div>
</ol>
{loaded &&
<div className={styles.info}>
<p>Page Count: {pageCount}</p>
<p>Current Page: {currentPage}</p>
<p>Limit: {limit}</p>
<p>Pages: {pages}</p>
<p>Total Students: {students.length}</p>
<p>Pagination: {pagination}</p>
</div>
}
</main>
);
}
export default App;
Following are the problems in your code:
Inside handleNext() function, you are using currentPage immediately after calling setCurrentPage(...) function BUT the state is updated asynchronously. So currentPage will be the old value instead of the updated value.
Another problem is that if the user clicks the next button three times, then the conditon pageCount + limit === currentPage will be true and pageCount will be set to pageCount + limit which is 3. This means that the loop inside handleNext() function for (let i = pageCount; i < pages; i++) {...} will only execute once. So tmpArray will contain only one element, i.e. 4.
Also, since calling handleNext() function updates the currentPage depending on its previous value, you should update the state by passing the function to setCurrentPage() function
setCurrentPage((page) => page + 1);
Similarly to go to previous page:
setCurrentPage((page) => page - 1);
This will make sure that state is updated correctly.
Edit:
It wasn't clear before why you were using pageCount but the demo you posted in a comment made it clear what you are trying to achieve.
Problem you mentioned in your question is that value of pageCount is always zero. Looking at your code, i think you don't need pageCount at all.
To achieve the desired functionality, you need to take following steps:
To populate the pagination array, you can make use of the useEffect hook that executes whenever students array or limit changes.
useEffect(() => {
// set pagination
let arr = new Array(Math.ceil(students.length / limit))
.fill()
.map((_, idx) => idx + 1);
setPagination(arr);
setLoaded(true);
}, [students, limit]);
To display limited number of students at a time, create a function that slices the students array depending on the currentPage and limit.
const getPaginatedStudents = () => {
const startIndex = currentPage * limit - limit;
const endIndex = startIndex + limit;
return students.slice(startIndex, endIndex);
};
To display a limited number of pages equal to the limit, you don't need pageCount. You can create a function that slices the pagination array depending on the currentPage and limit. This function is similar to the one created in step 2 but the difference lies in how startIndex is calculated.
const getPaginationGroup = () => {
let start = Math.floor((currentPage - 1) / limit) * limit;
let end = start + limit;
return pagination.slice(start, end);
};
Create functions that will change the currentPage
function goToNextPage() {
setCurrentPage((page) => page + 1);
}
function goToPreviousPage() {
setCurrentPage((page) => page - 1);
}
function changePage(event) {
const pageNumber = Number(event.target.textContent);
setCurrentPage(pageNumber);
}
That's all you need to get the desired functionality.
Demo
Following code snippet shows an example:
const studentsData = [
{ name: "Harry" },
{ name: "Hermoine" },
{ name: "Ron" },
{ name: "Ginny" },
{ name: "Snape" },
{ name: "Bellatrix" },
{ name: "Albus" },
{ name: "Dudley" },
{ name: "Petunia" },
{ name: "Hagrid" },
{ name: "Lee" },
{ name: "James" },
{ name: "Lily" },
{ name: "Remus" },
{ name: "Voldemort" },
{ name: "Dobby" },
{ name: "Lucius" },
{ name: "Sirius" }
];
function Student({ name }) {
return (
<div className="student">
<h3>{name}</h3>
</div>
);
}
function App() {
let [students] = React.useState(studentsData);
let [pagination, setPagination] = React.useState([]);
let [loaded, setLoaded] = React.useState(false);
let [limit] = React.useState(3);
let [pages] = React.useState(Math.round(students.length / limit));
let [currentPage, setCurrentPage] = React.useState(1);
function goToNextPage() {
setCurrentPage((page) => page + 1);
}
function goToPreviousPage() {
setCurrentPage((page) => page - 1);
}
function changePage(event) {
const pageNumber = Number(event.target.textContent);
setCurrentPage(pageNumber);
}
React.useEffect(() => {
// set pagination
let arr = new Array(Math.ceil(students.length / limit))
.fill()
.map((_, idx) => idx + 1);
setPagination(arr);
setLoaded(true);
}, [students, limit]);
const getPaginatedStudents = () => {
const startIndex = currentPage * limit - limit;
const endIndex = startIndex + limit;
return students.slice(startIndex, endIndex);
};
const getPaginationGroup = () => {
let start = Math.floor((currentPage - 1) / limit) * limit;
let end = start + limit;
return pagination.slice(start, end);
};
return (
<main>
<h1>Students</h1>
{loaded && (
<div className="studentsContainer">
{getPaginatedStudents().map((s) => (
<Student key={s.name} {...s} />
))}
</div>
)}
<ol className="pagination">
<div className="paginationContainer">
<button
onClick={goToPreviousPage}
className={currentPage === 1 ? "prev disabled" : "prev"}
>
prev
</button>
{loaded &&
getPaginationGroup().map((item, index) => {
return (
<li key={index} className="paginationItem">
<button
onClick={changePage}
className={currentPage === item ? "active" : null}
>
{item}
</button>
</li>
);
})}
<button
onClick={goToNextPage}
className={currentPage === pages ? "next disabled" : "next"}
>
next
</button>
</div>
</ol>
</main>
);
}
ReactDOM.render(<App/>, document.getElementById('root'));
h1 { margin: 0; }
.studentsContainer {
display: flex;
background: #efefef;
padding: 15px 10px;
margin: 5px 0;
}
.student {
flex-grow: 1;
box-shadow: 0 0 2px rgba(0, 0, 0, 0.4);
text-align: center;
max-width: 450px;
margin: 0 5px;
}
.pagination {
position: relative;
padding: 0;
}
.paginationContainer {
align-items: center;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(40px, auto));
grid-column-gap: .65rem;
justify-content: center;
justify-items: center;
max-width: 100%;
width: 100%;
position: relative;
}
.paginationItem {
height: 40px;
width: 40px;
user-select: none;
list-style: none;
}
.prev, .next {
user-select: none;
cursor: pointer;
background: transparent;
border: none;
outline: none;
}
.prev.disabled, .next.disabled {
pointer-events: none;
opacity: .5;
}
button {
padding: .65rem;
display: block;
height: 100%;
width: 100%;
border-radius: 50%;
text-align: center;
border: 1px solid #ccc;
outline: none;
cursor: pointer;
}
button.active {
background: rgba(black, .25);
border: none;
color: #777;
pointer-events: none;
}
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.13.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.13.1/umd/react-dom.production.min.js"></script>
<div id="root"></div>
You can also view this demo on codesandbox.

Cancelling requestAnimationRequest in a React component using hooks doesn't work

I am working on a progress bar (Eventually..) and I want to stop the animation (calling cancelAnimationRequest) when reaching a certain value (10, 100, ..., N) and reset it to 0.
However, with my current code, it resets to 0 but keeps running indefinitely. I think I might have something wrong in this part of the code:
setCount((prevCount) => {
console.log('requestRef.current', requestRef.current, prevCount);
if (prevCount < 10) return prevCount + deltaTime * 0.001;
// Trying to cancel the animation here and reset to 0:
cancelAnimationFrame(requestRef.current);
return 0;
});
This is the whole example:
const Counter = () => {
const [count, setCount] = React.useState(0);
// Use useRef for mutable variables that we want to persist
// without triggering a re-render on their change:
const requestRef = React.useRef();
const previousTimeRef = React.useRef();
const animate = (time) => {
if (previousTimeRef.current != undefined) {
const deltaTime = time - previousTimeRef.current;
// Pass on a function to the setter of the state
// to make sure we always have the latest state:
setCount((prevCount) => {
console.log('requestRef.current', requestRef.current, prevCount);
if (prevCount < 10) return prevCount + deltaTime * 0.001;
// Trying to cancel the animation here and reset to 0:
cancelAnimationFrame(requestRef.current);
return 0;
});
}
previousTimeRef.current = time;
requestRef.current = requestAnimationFrame(animate);
}
React.useEffect(() => {
requestRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestRef.current);
}, []);
return <div>{ Math.round(count) }</div>;
}
ReactDOM.render(<Counter />, document.getElementById('app'));
html {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
body {
font-size: 60px;
font-weight: 700;
font-family: 'Roboto Mono', monospace;
color: #5D9199;
background-color: #A3E3ED;
}
.as-console-wrapper {
max-height: 66px !important;
}
<script src="https://unpkg.com/react#16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom#16.7.0-alpha.0/umd/react-dom.development.js"></script>
<div id="app"></div>
Code pen: https://codepen.io/fr-nevin/pen/RwrLmPd
The main problem with your code is that you are trying to cancel an update that has already been executed. Instead, you can just avoid requesting that last update that you don't need. You can see the problem and a simple solution for that below:
const Counter = () => {
const [count, setCount] = React.useState(0);
const requestRef = React.useRef();
const previousTimeRef = React.useRef(0);
const animate = React.useCallback((time) => {
console.log(' RUN:', requestRef.current);
setCount((prevCount) => {
const deltaTime = time - previousTimeRef.current;
const nextCount = prevCount + deltaTime * 0.001;
// We add 1 to the limit value to make sure the last valid value is
// also displayed for one whole "frame":
if (nextCount >= 11) {
console.log(' CANCEL:', requestRef.current, '(this won\'t work as inteneded)');
// This won't work:
// cancelAnimationFrame(requestRef.current);
// Instead, let's use this Ref to avoid calling `requestAnimationFrame` again:
requestRef.current = null;
}
return nextCount >= 11 ? 0 : nextCount;
});
// If we have already reached the limit value, don't call `requestAnimationFrame` again:
if (requestRef.current !== null) {
previousTimeRef.current = time;
requestRef.current = requestAnimationFrame(animate);
console.log('- SCHEDULE:', requestRef.current);
}
}, []);
React.useEffect(() => {
requestRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestRef.current);
}, []);
// This floors the value:
// See https://stackoverflow.com/questions/7487977/using-bitwise-or-0-to-floor-a-number.
return (<div>{ count | 0 } / 10</div>);
};
ReactDOM.render(<Counter />, document.getElementById('app'));
html {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
body {
font-size: 60px;
font-weight: 700;
font-family: 'Roboto Mono', monospace;
color: #5D9199;
background-color: #A3E3ED;
}
.as-console-wrapper {
max-height: 66px !important;
}
<script src="https://unpkg.com/react#16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom#16.7.0-alpha.0/umd/react-dom.development.js"></script>
<div id="app"></div>
In any case, you are also updating the state many more times than actually needed, which you can avoid by using refs and the timestamp (time) provided by requestAnimationFrame to keep track of the current and next/target counter values. You are still going to call the requestAnimationFrame update function the same number of times, but you will only update the state (setCount(...)) once you know the change is going to be reflected in the UI.
const Counter = ({ max = 10, rate = 0.001, location }) => {
const limit = max + 1;
const [count, setCount] = React.useState(0);
const t0Ref = React.useRef(Date.now());
const requestRef = React.useRef();
const targetValueRef = React.useRef(1);
const animate = React.useCallback(() => {
// No need to keep track of the previous time, store initial time instead. Note we can't
// use the time param provided by requestAnimationFrame to the callback, as that one won't
// be reset when the `location` changes:
const time = Date.now() - t0Ref.current;
const nextValue = time * rate;
if (nextValue >= limit) {
console.log('Reset to 0');
setCount(0);
return;
}
const targetValue = targetValueRef.current;
if (nextValue >= targetValue) {
console.log(`Update ${ targetValue - 1 } -> ${ nextValue | 0 }`);
setCount(targetValue);
targetValueRef.current = targetValue + 1;
}
requestRef.current = requestAnimationFrame(animate);
}, []);
React.useEffect(() => {
requestRef.current = requestAnimationFrame(animate);
return () => cancelAnimationFrame(requestRef.current);
}, []);
React.useEffect(() => {
// Reset counter if `location` changes, but there's no need to call `cancelAnimationFrame` .
setCount(0);
t0Ref.current = Date.now();
targetValueRef.current = 1;
}, [location]);
return (<div className="counter">{ count } / { max }</div>);
};
const App = () => {
const [fakeLocation, setFakeLocation] = React.useState('/');
const handleButtonClicked = React.useCallback(() => {
setFakeLocation(`/${ Math.random().toString(36).slice(2) }`);
}, []);
return (<div>
<span className="location">Fake Location: { fakeLocation }</span>
<Counter max={ 10 } location={ fakeLocation } />
<button className="button" onClick={ handleButtonClicked }>Update Parent</button>
</div>);
};
ReactDOM.render(<App />, document.getElementById('app'));
html {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
body {
font-family: 'Roboto Mono', monospace;
color: #5D9199;
background-color: #A3E3ED;
}
.location {
font-size: 16px;
}
.counter {
font-size: 60px;
font-weight: 700;
}
.button {
border: 2px solid #5D9199;
padding: 8px;
margin: 0;
font-family: 'Roboto Mono', monospace;
color: #5D9199;
background: transparent;
outline: none;
}
.as-console-wrapper {
max-height: 66px !important;
}
<script src="https://unpkg.com/react#16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom#16.7.0-alpha.0/umd/react-dom.development.js"></script>
<div id="app"></div>

show average rating in read mode and just total or half value in write mode

I am trying to create star rating where the functionality has to be following:
In read mode, the stars are shown as per average (should support 100%
i.e 5 or 96% i.e 4.6) in write mode, the user can only rate 1, 1.5, 2,
2.5 etc not 2.6
The read mode is working as expected but is having problem with write mode.
The problem in write mode is I cannot update the rating with non-decimal value from 1 to 5 and also half value like 1.5, 2.5, 3.5 etc. On hovering how do i decide if my mouse pointer is in the full star or half of star? Can anyone look at this, please?
I have created a sandbox for showing the demo
Here it is
https://codesandbox.io/s/9l6kmnw7vw
The code is as follow
UPDATED CODE
// #flow
import React from "react";
import styled, { css } from "styled-components";
const StyledIcon = styled.i`
display: inline-block;
width: 12px;
overflow: hidden;
direction: ${props => props.direction && props.direction};
${props => props.css && css(...props.css)};
`;
const StyledRating = styled.div`
unicode-bidi: bidi-override;
font-size: 25px;
height: 25px;
width: 125px;
margin: 0 auto;
position: relative;
padding: 0;
text-shadow: 0px 1px 0 #a2a2a2;
color: grey;
`;
const TopStyledRating = styled.div`
padding: 0;
position: absolute;
z-index: 1;
display: block;
top: 0;
left: 0;
overflow: hidden;
${props => props.css && css(...props.css)};
width: ${props => props.width && props.width};
`;
const BottomStyledRating = styled.div`
padding: 0;
display: block;
z-index: 0;
`;
class Rating extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
rating: this.props.rating || null,
// eslint-disable-next-line
temp_rating: null
};
}
handleMouseover(rating) {
console.log("rating", rating);
this.setState(prev => ({
rating,
// eslint-disable-next-line
temp_rating: prev.rating
}));
}
handleMouseout() {
this.setState(prev => ({
rating: prev.temp_rating
}));
}
rate(rating) {
this.setState({
rating,
// eslint-disable-next-line
temp_rating: rating
});
}
calculateWidth = value => {
const { total } = this.props;
const { rating } = this.state;
return Math.floor((rating / total) * 100).toFixed(2) + "%";
};
render() {
const { disabled, isReadonly } = this.props;
const { rating } = this.state;
const topStars = [];
const bottomStars = [];
const writableStars = [];
console.log("rating", rating);
// eslint-disable-next-line
if (isReadonly) {
for (let i = 0; i < 5; i++) {
topStars.push(<span>★</span>);
}
for (let i = 0; i < 5; i++) {
bottomStars.push(<span>★</span>);
}
} else {
// eslint-disable-next-line
for (let i = 0; i < 10; i++) {
let klass = "star_border";
if (rating >= i && rating !== null) {
klass = "star";
}
writableStars.push(
<StyledIcon
direction={i % 2 === 0 ? "ltr" : "rtl"}
className="material-icons"
css={this.props.css}
onMouseOver={() => !disabled && this.handleMouseover(i)}
onFocus={() => !disabled && this.handleMouseover(i)}
onClick={() => !disabled && this.rate(i)}
onMouseOut={() => !disabled && this.handleMouseout()}
onBlur={() => !disabled && this.handleMouseout()}
>
{klass}
</StyledIcon>
);
}
}
return (
<React.Fragment>
{isReadonly ? (
<StyledRating>
<TopStyledRating
css={this.props.css}
width={this.calculateWidth(this.props.rating)}
>
{topStars}
</TopStyledRating>
<BottomStyledRating>{bottomStars}</BottomStyledRating>
</StyledRating>
) : (
<React.Fragment>
{rating}
{writableStars}
</React.Fragment>
)}
</React.Fragment>
);
}
}
Rating.defaultProps = {
css: "",
disabled: false
};
export default Rating;
Now the writable stars is separately done to show the stars status when hovering and clicking but when I am supplying rating as 5 it is filling the third stars instead of 5th.
I think your current problem seems to be with where your mouse event is set, as you are handling it on the individual stars, they disappear, and trigger a mouseout event, causing this constant switch in visibility.
I would rather set the detection of the rating on the outer div, and then track where the mouse is in relation to the div, and set the width of the writable stars according to that.
I tried to make a sample from scratch, that shows how you could handle the changes from the outer div. I am sure the formula I used can be simplified still, but okay, this was just to demonstrate how it can work.
const { Component } = React;
const getRating = x => (parseInt(x / 20) * 20 + (x % 20 >= 13 ? 20 : x % 20 >= 7 ? 10 : 0));
class Rating extends Component {
constructor() {
super();
this.state = {
appliedRating: '86%'
};
this.setParentElement = this.setParentElement.bind( this );
this.handleMouseOver = this.handleMouseOver.bind( this );
this.applyRating = this.applyRating.bind( this );
this.reset = this.reset.bind( this );
this.stopReset = this.stopReset.bind( this );
}
stopReset() {
clearTimeout( this.resetTimeout );
}
setParentElement(e) {
this.parentElement = e;
}
handleMouseOver(e) {
this.stopReset();
if (e.currentTarget !== this.parentElement) {
return;
}
const targetRating = getRating(e.clientX - this.parentElement.offsetLeft);
if (this.state.setRating !== targetRating) {
this.setState({
setRating: targetRating
});
}
}
applyRating(e) {
this.setState({
currentRating: this.state.setRating
});
}
reset(e) {
this.resetTimeout = setTimeout(() => this.setState( { setRating: null } ), 50 );
}
renderStars( width, ...classes ) {
return (
<div
onMouseEnter={this.stopReset}
className={ ['flex-rating', ...classes].join(' ')}
style={{width}}>
<span onMouseEnter={this.stopReset} className="star">★</span>
<span onMouseEnter={this.stopReset} className="star">★</span>
<span onMouseEnter={this.stopReset} className="star">★</span>
<span onMouseEnter={this.stopReset} className="star">★</span>
<span onMouseEnter={this.stopReset} className="star">★</span>
</div>
);
}
renderFixed() {
return this.renderStars('100%', 'fixed');
}
renderReadOnlyRating() {
const { appliedRating } = this.state;
return this.renderStars( appliedRating, 'readonly' );
}
renderWriteRating() {
let { setRating, currentRating } = this.state;
if (setRating === 0) {
setRating = '0%';
}
if (currentRating === undefined) {
currentRating = '100%';
}
return this.renderStars( setRating || currentRating, 'writable' );
}
render() {
return (
<div>
<div
ref={ this.setParentElement }
className="rating"
onMouseMove={ this.handleMouseOver }
onMouseOut={ this.reset }
onClick={ this.applyRating }>
{ this.renderFixed() }
{ this.renderReadOnlyRating() }
{ this.renderWriteRating() }
</div>
<div>Current rating: { ( ( this.state.currentRating || 0 ) / 20) }</div>
</div>
);
}
}
ReactDOM.render( <Rating />, document.getElementById('container') );
body { margin: 50px; }
.rating {
font-family: 'Courier new';
font-size: 16px;
position: relative;
display: inline-block;
width: 100px;
height: 25px;
align-items: flex-start;
justify-content: center;
align-content: center;
background-color: white;
}
.flex-rating {
position: absolute;
top: 0;
left: 0;
display: flex;
height: 100%;
overflow: hidden;
cursor: pointer;
}
.fixed {
color: black;
font-size: 1.1em;
font-weight: bold;
}
.readonly {
color: silver;
font-weight: bold;
}
.writable {
color: blue;
background-color: rgba(100, 100, 100, .5);
}
.star {
text-align: center;
width: 20px;
max-width: 20px;
min-width: 20px;
}
<script id="react" src="https://cdnjs.cloudflare.com/ajax/libs/react/15.6.2/react.js"></script>
<script id="react-dom" src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/15.6.2/react-dom.js"></script>
<div id="container"></div>

Categories