Click Down/Up keys for scrolling inside ul element- React - javascript

I got a problem trying to make my own Autocomplete component in react..
I created a component which render the results and set max-height on their wrapper,
I used an onKeyDown event on the input element to track down/up key press. Right now I use it to mark the active item... but when the max-height I set is too small and there is a scroll in the side when the "active-item" go off the div's height limit the scroll doesn't go down with it... How can I fix it?
import React, { useState, useEffect } from "react"
const Autocomplete = ({ options }) => {
const [activeOption, setActiveOption] = useState(4)
const [filteredOptions, setFilteredOptions] = useState([])
const [showOptions, setShowOptions] = useState(false)
const [userInput, setUserInput] = useState("")
useEffect(() => {
setShowOptions(userInput)
setFilteredOptions([
...options.filter(
option => option.toLowerCase().indexOf(userInput.toLowerCase()) > -1
)
])
setActiveOption(0)
}, [userInput])
const handleKeyDown = e => {
if (e.key === "ArrowDown") {
if (activeOption === filteredOptions.length - 1) return
setActiveOption(activeOption + 1)
}
if (e.key === "ArrowUp") {
if (activeOption === 0) return
setActiveOption(activeOption - 1)
}
}
return (
<>
<div className="search">
<input
type="text"
className="search-box"
value={userInput}
onChange={e => setUserInput(e.target.value)}
onKeyDown={handleKeyDown}
/>
<ul className="options">
{showOptions &&
filteredOptions.map((option, i) => (
<li className={activeOption === i ? `option-active` : ``} key={i}>
{option}
</li>
))}
</ul>
</div>
</>
)
}
export default Autocomplete
function App() {
return (
<div className="App">
<Autocomplete
options={[
"Alligator",
"Bask",
"Crocodilian",
"Death Roll",
"Eggs",
"Jaws",
"Reptile",
"Solitary",
"Tail",
"Wetlands"
]}
/>
</div>
)
}
.option-active {
font-weight: bold;
background: cornflowerblue;
}
.options {
height: 100px;
overflow: overlay;
}
Here's a picture to explain better my problem:
when second item is active:
when the sixth is active:
As you can see the scroll stays the same and doesn't go down with the li element...
Thanks by heart!

I think you could maintain an array of element refs, in order to call scrollIntoView method of that element, when active option changes.
Untested code:
// Use a ref container (we won't use `current`)
const elmRefs = useRef()
// Instead we build a custom ref object in each key of the ref for each option
useEffect(() => {
options.forEach(opt => elmRefs[opt] = {current: null}
}, [options])
// Effect that scrolls active element when it changes
useLayoutEffect(() => {
// This is what makes element visible
elmRefs[options[activeOption]].current.scrollIntoView()
}, [options, activeOption])
// In the "render" section, connect each option to elmRefs
<li className={activeOption === i ? `option-active` : ``} ref={elmRefs[option]} key={i}>
{option}
</li>
Let me know what you think!
Edit:
If the elmRefs need to be initialized imediately, you could do:
// Outside component
const initRefs = options => Object.fromEntries(options.map(o => [o, {current: null}]))
// Then in the component, replace useRef by:
const elmRefs = useRef(initRef(options))
And replace elmRefs by elmRefs.current in the rest of the code...

Related

React: setState to a single component from multiple mapped instances of the same component

I have a hover state that changes div background color and adds a <p> tag to a mapped component:
const [isHover, setIsHover] = useState(false)
Here is the mapped component where I set the state:
const AddSectionButton = ({
isHover,
setIsHover,
sections,
setSections,
nextSectionId,
setNextSectionId,
sectionTitle,
setSectionTitle,
sectionId,
}) => {
return (
<AddSectionDiv
onMouseEnter={() => {
setIsHover(!isHover);
}}
onMouseLeave={() => {
setIsHover(!isHover);
}}
style={isHover && { backgroundColor: "#A4AAE0" }}
>
{isHover && <p>Add Section</p>}
</AddSectionDiv>
);
};
Whenever I hover to a single mapped component, the rest of the mapped components trigger the hover effect as well.
How do I set the state to only the hovered component and not affect the rest?
I thought about using a key, where as you can see in my mapped component, I passed a sectionId prop which contains the key, but I'm confused as to how I should use it.
You can, and should, use a key or any value/property that uniquely identifies the element being hovered.
In the parent use an initially null isHover state.
const [isHover, setIsHover] = useState(null);
And in the children set or clear the isHover state by their id. And check if the current isHover value matches the current sectionId value.
const AddSectionButton = ({
isHover,
setIsHover,
sections,
setSections,
nextSectionId,
setNextSectionId,
sectionTitle,
setSectionTitle,
sectionId,
}) => {
return (
<AddSectionDiv
onMouseEnter={() => {
setIsHover(sectionId);
}}
onMouseLeave={() => {
setIsHover(null);
}}
style={isHover === sectionId && { backgroundColor: "#A4AAE0" }}
>
{isHover === sectionId && <p>Add Section</p>}
</AddSectionDiv>
);
};
Consider moving/implementing this isHover state internally to each component, the parent component likely doesn't need to concern itself with the hover status of any of its children. Do this and your original logic is fine.
const AddSectionButton = ({
sections,
setSections,
nextSectionId,
setNextSectionId,
sectionTitle,
setSectionTitle,
sectionId,
}) => {
const [isHover, setIsHover] = useState(false);
return (
<AddSectionDiv
onMouseEnter={() => {
setIsHover(true);
}}
onMouseLeave={() => {
setIsHover(false);
}}
style={isHover && { backgroundColor: "#A4AAE0" }}
>
{isHover && <p>Add Section</p>}
</AddSectionDiv>
);
};

React list, show active item onClick. Without re-rendering the list

I want to show different style for the stock item which is clicked, but when i set activeItem to idx clicked, react re-renders the whole list again. Is there any way to show only one active item without re-rendering the list
import React, { useState,memo} from "react";
const StocksList=({stocksData})=>{
const [activeItem, setActiveItem] = useState(0);
const Item=({stock,idx})=>{
return (
<li
className={activeItem===idx ? "active-stock-item" : "stock-item" }
onClick={()=>{
setActiveItem(idx);
}}
>
{`${idx+1}. ${stock["Name"]}`}
</li>
)
}
return(
stocksData.map((stock,idx) => <Item key={stock._id} {...{stock,idx}} />)
)
}
export default memo(StocksList)
Set a key on each rendered item.
stocksData.map((stock,idx) => <Item key={idx} {...{stock,idx}} />)
As a side-note, it's recommended not to use the index of the object as the key. If stock has an ID or some other unique identifier, use that.
Edit
Based on your comments, here's a working version that'll update select the active item:
import React, { useState,memo} from "react";
const stocksData = [
{
_id: 0,
name: "Zero"
},
{
_id: 1,
name: "One"
}
];
const Item=({ _id, name, active, onClick })=>{
function onSelfClick() {
onClick(_id);
}
return (
<li
className={active ? "active-stock-item" : "stock-item" }
onClick={onSelfClick}
>
{`${ _id +1 }. ${name}. ${active}`}
</li>
)
}
export default function StocksList() {
const [activeItem, setActiveItem] = useState(stocksData[0]._id);
function onClick(id) {
setActiveItem(id)
}
return (
stocksData.map(stock => <Item key={stock._id} active={activeItem === stock._id} onClick={onClick} {...stock} />)
)
}

React accordion, set dynamic height when DOM's children change

I have an Accordion component which his children can change his height dynamically (by API response), I try this code but not working because the height changes only if I close and re-open the accordion. The useEffect not triggering when children DOM change. Can anyone help me? Thanks
export const VerticalAccordion = (props) => {
const accordionContainerRef = useRef<HTMLDivElement>(null);
const [contentHeight, setContentHeight] = useState<number | undefined>(0);
const [animationClass, setAnimationClass] = useState<'animated'>();
const [overflow, setOverflow] = useState<'visible' | 'hidden'>('visible');
const [isOpen, setIsOpen] = useState<boolean>(true);
const {title, children} = props;
useEffect(() =>{
if(accordionContainerRef.current){
const height = isOpen ? accordionContainerRef.current.scrollHeight: 0;
setContentHeight(height);
if(isOpen){
// delay need for animation
setTimeout(() => setOverflow('visible'),700);
return;
}
return setOverflow('hidden')
}
}, [isOpen, accordionContainerRef, children]);
const onAccordionClick = () => {
setAnimationClass('animated');
setIsOpen(prevState => !prevState)
};
return (
<div className={'accordion'}>
<div className={`header`}>
<div className={`header-title`}>{title}</div>
<MyIcon onClick={() => onAccordionClick()}
customClass={`header-arrow`}
path={menuDown}
size={20}/>
</div>
<div ref={accordionContainerRef}
style={{ height: `${contentHeight}px`, overflow}}
className={`data` + (animationClass ? ` data--${animationClass}` : '')}>
{children}
</div>
</div>
)
}
Even though the working around what I have found is not a robust one, but it is working completely fine for me. If someone stumbles upon this same issue might find this useful.
const [activeState, setActiveState] = useState("");
const [activeHeight, setActiveHeight] = useState("0px");
const contentRef = useRef(null)
const toogleActive = () => {
setActiveState(activeState === "" ? "active" : "");
setActiveHeight(activeState === "active" ? "0px" :`${contentRef.current.scrollHeight + 100}px`)
}
return (
<div className={styles.accordion_section}>
<button className={styles.accordion} onClick={toogleActive}>
<p className={styles.accordion_title}>{title}</p>
</button>
<div ref={contentRef}
style={{ maxHeight: `${activeHeight}` }}
className={styles.accordion_content}>
<div>
{content}
</div>
</div>
</div>
)
I have hardcoded some extra space so that while the dynamic response is accepted the Child DOM is shown. In the CSS module file, I have kept the overflow as auto, earlier it was hidden.
.accordion_content {
background-color: white;
overflow: auto;
max-height: max-content;
}
As a result, the Child DOM is appearing dynamically and the user can scroll inside the Accordion if the Child DOM needs larger space.
If you want to use state hooks it is better to use context, so that the hook will be shared between the child and parent. This link might give you an idea of how does context works.
Here is how I am solving a similar problem. I needed an accordion that
Could be opened/closed depending on external variable
Could be opened/closed by clicking on the accordion title
Would resize when the contents of the accordion changes height
Here's the accordion in its parent component. Also, the useState hook for opening/closing the accordion from outside the accordion (as necessary).
const [isOpen, setOpen] = useState(false)
// some code...
<Accordion title={"Settings"} setOpen={setOpen} open={isOpen}>
{children}
</Accordion>
Here's the accordion component. I pass the setOpen hook from the parent to the accordion. I use useEffect to update the accordion's properties when either open or the children change.
import React, { useEffect, useState, useRef } from "react"
import styled from "styled-components"
const AccordionTitle = styled.span`
cursor: pointer;
`
const AccordionContent = styled.div`
height: ${({ height }) => height}px;
opacity: ${({ height }) => (height > 0 ? 1 : 0)};
overflow: hidden;
transition: 0.5s;
`
export const Accordion = ({ title, setOpen, open, children }) => {
const content = useRef(null)
const [height, setHeight] = useState(0)
const [direction, setDirection] = useState("right")
useEffect(() => {
if (open) {
setHeight(content.current.scrollHeight)
setDirection("down")
} else {
setHeight(0)
setDirection("right")
}
}, [open, children])
return (
<>
<h3>
<AccordionTitle onClick={(e) => setOpen((prev) => !prev)}>
{title}
<i className={`arrow-accordion ${direction}`}></i>
</AccordionTitle>
</h3>
<AccordionContent height={height} ref={content}>
{children}
</AccordionContent>
</>
)
}

Triggering useState

I have a component and render it conditionally with different props.
{activeNavItem === 'Concept Art' ? (
<Gallary
images={conceptArtImages}
sectionRef={sectionRef}
/>
) : (
<Gallary
images={mattePaintingImages}
sectionRef={sectionRef}
/>
)}
This component has useState(false) and useEffect hooks. useEffect determines when screen position reaches the dom element and it triggers useState to true: elementPosition < screenPosition. Then my state triggers class on dom element: state ? 'animationClass' : ''.
const Gallary = ({ images, sectionRef }) => {
const [isViewed, setIsViewed] = useState(false);
useEffect(() => {
const section = sectionRef.current;
const onScroll = () => {
const screenPosition = window.innerHeight / 2;
const sectionPosition = section.getBoundingClientRect().top;
console.log(screenPosition);
if (sectionPosition < screenPosition) setIsViewed(true);
};
onScroll();
window.addEventListener('scroll', onScroll);
return () => {
window.removeEventListener('scroll', onScroll);
};
}, [sectionRef]);
return (
<ul className="section-gallary__list">
{images.map((art, index) => (
<li
key={index}
className={`section-gallary__item ${isViewed ? 'animation--view' : ''}`}>
<img className="section-gallary__img" src={art} alt="concept art" />
</li>
))}
</ul>
);
};
Problem: it works on my first render. But when I toggle component with different props, my state iniatially is true and I haven't animation.
I notice that if I have two components(ComponentA, ComponentB) instead of one(ComponentA) it works fine.
try setting isViewed to false when your component is not in view like this:
if (sectionPosition < screenPosition && !isViewed){
setIsViewed(true);
}
else{
if(isViewed)
setIsViewed(false);
}
and you can do it like this:
if (sectionPosition < screenPosition && !isViewed){
setIsViewed(state=>!state);
}
else{
if(isViewed)
setIsViewed(state=>!state);
}
plus no need to render same component multiple times, you can change props only:
<Gallary
images={activeNavItem === 'ConceptArt'?conceptArtImages:mattePaintingImages}
sectionRef={sectionRef}
/>

React.js Autocomplete from scratch

As part of a technical test, I've been asked to write an autocomplete input in React. I've done this but I'd now like to add the functionality of navigating up and down the rendered list with the arrow keys. I've done some extensive Googling and found nothing React specific apart from npm packages.
To be clear, I'm looking for something like this but for React: https://www.w3schools.com/howto/howto_js_autocomplete.asp
All I basically need is the arrow button functionality, I've got everything else working fine.
Cheers
Here's an example that I tried but couldn't get working.
export default class Example extends Component {
constructor(props) {
super(props)
this.handleKeyDown = this.handleKeyDown.bind(this)
this.state = {
cursor: 0,
result: []
}
}
handleKeyDown(e) {
const { cursor, result } = this.state
// arrow up/down button should select next/previous list element
if (e.keyCode === 38 && cursor > 0) {
this.setState( prevState => ({
cursor: prevState.cursor - 1
}))
} else if (e.keyCode === 40 && cursor < result.length - 1) {
this.setState( prevState => ({
cursor: prevState.cursor + 1
}))
}
}
render() {
const { cursor } = this.state
return (
<Container>
<Input onKeyDown={ this.handleKeyDown }/>
<List>
{
result.map((item, i) => (
<List.Item
key={ item._id }
className={cursor === i ? 'active' : null}
>
<span>{ item.title }</span>
</List.Item>
))
}
</List>
</Container>
)
}
}
And here is my code:
class Search extends Component {
constructor(props) {
super(props);
this.state = {
location: '',
searchName: '',
showSearch: false,
cursor: 0
};
}
handleKeyPress = e => {
const { cursor, searchName } = this.state;
// arrow up/down button should select next/previous list element
if (e.keyCode === 38 && cursor > 0) {
this.setState(prevState => ({
cursor: prevState.cursor - 1
}));
} else if (e.keyCode === 40 && cursor < searchName.length - 1) {
this.setState(prevState => ({
cursor: prevState.cursor + 1
}));
}
};
render() {
const { searchName, location } = this.state;
return (
<div className="Search">
<h1>Where are you going?</h1>
<form id="search-form" onSubmit={this.handleSubmit}>
<label htmlFor="location">Pick-up Location</label>
<input
type="text"
id="location"
value={location}
placeholder="city, airport, station, region, district..."
onChange={this.handleChange}
onKeyUp={this.handleKeyUp}
onKeyDown={this.handleKeyPress}
/>
{this.state.showSearch ? (
<Suggestions searchName={searchName} />
) : null}
<button value="submit" type="submit" id="search-button">
Search
</button>
</form>
</div>
);
}
Code that renders the list from the restful API:
.then(res =>
this.setState({
searchName: res.data.results.docs.map(array => (
<a href="#">
<div
key={array.ufi}
className="locations"
>
{array.name}
</div>
</a>
))
})
);
Since you are defining a function as handleKeyDown(e) {...} the context this is not pointing to the context of class instance, the context will be supplied by onKeyDown (and I suppose it's window as this)
So, you have 2 ways to go:
declare your function as handleKeyDown = (e) => {...}
bind handleKeyDown context to component instance like onKeyDown={this.handleKeyDown.bind(this)}
Also, don't forget that you may want a mod items.length counter, meaning when your press down and the last item is already selected, it would go to the first item.
Your api usage with storing markdown into the state is just a terrible thing to do. You don't have access to these strings anymore, instead save it as a plain array. Pass it where you need it, and use it to create jsx there.
Also, you don't use cursor from your state at all.

Categories