React: close other SubMenus when another SubMenu open - javascript

I want to close all other open SubMenu when the user opens a SubMenu.
Can anyone help me to find a solution?
My code:
Menu.tsx ->
const Menu: React.FC = ({ data }) => {
return (
<nav className={styles.headerMenu}>
{
data.map((item, index) => <MenuItem key={index} item={item} /> )
}
</nav>
)
}
MenuItem.tsx ->
const MenuItem: React.FC = ({ item }) => {
let [subMenuOpened, setSubMenuOpened] = useState<boolean>(false);
const switchMenu = (condition) => setSubMenuOpened(condition !== null ? condition : !subMenuOpened)
const SubMenu: React.FC = () => { /* Code to render submenu */ }
return (
<section onMouseEnter={()=> item.subMenu && switchMenu(true)} onMouseLeave={() => item.subMenu && switchMenu(false)}>
<a href={item.href}>{item.title}</a>
//Render SubMenu if item has submenu and it is open
{ (item.subMenu && subMenuOpened) && <SubMenu /> }
</section>
)
}
An example of what I mean

#Nikki9696 answered your question in a comment:
Generally, if a sibling needs to know about other sibling state, you
move the state up to the parent. So, you'd keep track of which submenu
was open in the Menu component, not the MenuItem, and close all but
the selected one there.
so I will show you an example of what they meant:
const Menu: React.FC = ({ data }) => {
const keys = data.map(function(item,key) { return { key:key, close: null, item: item}});
onOpen=(key)=>{
// this will close all menus except the current one
keys.forEach(x=>{
if (x.key !== key && x.close !== null)
x.close(); // Close should be set by child eg MenuItem.
})
}
return (
<nav className={styles.headerMenu}>
{
keys.map((item, index) => <MenuItem onOpen={onOpen} key={index} item={item} /> )
}
</nav>
)
}
const MenuItem: React.FC = ({ item, onOpen }:{item: any, onOpen:Function}) => {
let [subMenuOpened, setSubMenuOpened] = useState<boolean>(false);
item.close = ()=> { setSubMenuOpened(false); } /// this is so the parent will trigger close
const switchMenu = (condition) => setSubMenuOpened(condition !== null ? condition : !subMenuOpened)
useEffect(() =>{
if (subMenuOpened)
onOpen(item.key); // trigger parent
}, [subMenuOpened]);
const SubMenu: React.FC = () => { /* Code to render submenu */ }
return (
<section onMouseEnter={()=> item.item.subMenu && switchMenu(true)} onMouseLeave={() => item.subMenu && switchMenu(false)}>
<a href={item.item.href}>{item.item.title}</a>
//Render SubMenu if item has submenu and it is open
{ (item.item.subMenu && subMenuOpened) && <SubMenu /> }
</section>
)
}

Related

react-select arrow key navigation for submenu

I'm working on adding arrow key functionality to a react-select input dropdown with submenus. I am able to open the submenu, however I don't know how to be able focus an option from the submenu.
As you can see in the image, the submenu parent is selected. And I want to be able to focus the options on the right.
export const CustomOption = () => {
const [showNestedOptions, setShowNestedOptions] = useState(false);
const [subMenuOpen, setSubMenuOpen] = useState(null);
// this is used to give styling when an option is hovered over or chosen with arrow key
const [isFocusedState, setIsFocusedState] = useState(false);
// this basically opens the submenu on ArrowRight keyboard click and gives it isFocusedState(true) which is used to render the SELECTED submenu
const handleKeys = (e: any) => {
if (e.key === 'ArrowRight') {
!props.isMobile && setShowNestedOptions(true);
e.setIsFocusedState(true);
} else if (e.key === 'ArrowLeft') {
!props.isMobile && setShowNestedOptions(false);
e.setIsFocusedState(false);
}
};
useEffect(() => {
window.addEventListener('keydown', handleKeys);
return () => {
return window.removeEventListener('keydown', handleKeys);
};
}, []);
// this does the same but on mouseOver (hover)
const handleMouseOver = (e: any) => {
!props.isMobile && setShowNestedOptions(true);
setIsFocusedState(true);
};
const handleMouseLeave = (e: any) => {
!props.isMobile && setShowNestedOptions(false);
setIsFocusedState(false);
};
return (
<Box>
{props.data.nestedOptions ? (
<Box
onMouseLeave={handleMouseLeave}
onMouseOver={handleMouseOver}
onKeyDown={handleKeys}
onClick={() => setIsFocusedState(!isFocusedState)}
>
<MainOption
renderOption={props.renderOption}
option={props.data}
hasNestedOptions={true}
setSubMenuOpen={() => setSubMenuOpen(props.data.value)}
selectOption={selectOption}
isFocused={isFocusedState}
/>
{showNestedOptions && (
<Box>
{(isFocusedState || props.isFocused) &&
map(props.data.nestedOptions, (nestedOption, index: number) => {
const isFirst = index === 0;
const value = props.getOptionValue?.(nestedOption) || props.value;
const label = props.getOptionLabel?.(nestedOption) || props.label;
const nestedInnerProps = innerProps;
nestedInnerProps.onClick = (e: React.ChangeEvent<HTMLInputElement>) =>
selectOption(props.data.nestedOptions.find((o: SelectOption) => o.label === e.target.textContent));
const optionProps = {
...props,
data: { ...nestedOption, parentValue: subMenuOpen },
value: value,
label: label,
children: label,
innerProps: { ...nestedInnerProps, parentValue: subMenuOpen },
};
// here the submenu is rendered and opened
return (
<Box
key={index}
>
<Option {...optionProps} key={value} isFocused={false}>
<Center>
{label}
</Center>
</Option>
</Box>
);
})}
</Box>
)}
</Box>
) : (
// if there's no submenu, simply render the list of options
<Option {...props}>
<MainOption
isMobile={props.isMobile}
option={props.data}
getOptionValue={props.getOptionValue}
renderOption={props.renderOption}
wrapperOptionArg={props.wrapperOptionArg}
getOptionLabel={props.getOptionLabel}
/>
</Option>
)}
</Box>
);
};
I have tried to add onKeyDown to change the isFocused prop conidtionally, like so but somehow it only works on mouseOver and it sets the condition inappropriately ( for all options, or none at all )
return (
<Box
key={index}
>
<Option
{...optionProps}
key={value}
isFocused={props.data.nestedOptions[0] ? true : false}
<Center>
{label}
</Center>
</Option>
</Box>
Unfortunately there's not much information about this certain keyboard functionality that I was able to find online.
In short, how to focus the first element of a submenu when ArrowRight is already used to open the submenu?

React setting state to child component causing infinite loop

I have a page with some dropdown menu's used to search some content, the dropdown is a non-functional component. The page is a listsing page. Not important but gives some context.
I do some calculation on the listing page and update the state, then I pass this state into the Dropdown component. However, I'm getting an infinite loop and I'm not sure how to stop it or where I'm going wrong.
my listing page is here:
constructor(props){
super(props)
let industryList = this.createList(this.props.data.mainYaml.caseStudiesDropdowns[0].items)
let areaList = this.createList(this.props.data.mainYaml.caseStudiesDropdowns[1].items)
let techniqueList = this.createList(this.props.data.mainYaml.caseStudiesDropdowns[2].items)
this.state = {
industry: "All Industries",
area: "All Areas",
technique: [],
industries: industryList,
areas: areaList,
techniques: techniqueList
}
}
createList = (listItems) => {
let listArr = []
listItems.forEach((item) => {
let obj = {
name: item,
disabled: false
}
listArr.push(obj)
})
return listArr
}
filterCaseStudies = (caseStudies) => {
const filterIndustry = (caseStudies) => {
if (this.state.industry == "All Industries") {
return caseStudies
} else {
return caseStudies.filter((study) => study.node.industry == this.state.industry)
}
}
const filterArea = (caseStudies) => {
if (this.state.area == "All Areas") {
return caseStudies
} else {
return caseStudies.filter((study) => study.node.area == this.state.area)
}
}
const filterTechnique = (caseStudies) => {
if (this.state.technique.length === 0) {
return caseStudies
} else {
let matchedStudies = []
caseStudies.forEach((study) => {
let count = 0;
let techCount = study.node.technique.length - 1;
study.node.technique.forEach((item, i) => {
this.state.technique.forEach((selectedItems) => {
if (selectedItems == item) {
count++;
return
}
})
if (i == techCount && count > 0) {
study.node.count = count
matchedStudies.push(study)
}
})
})
matchedStudies.sort((a, b) => b.node.count - a.node.count);
return matchedStudies;
}
}
let industryMatches = filterIndustry(caseStudies)
let areaMatches = filterArea(industryMatches)
this.filterDropdowns(areaMatches)
let techniqueMatches = filterTechnique(areaMatches)
return techniqueMatches;
}
filterDropdowns = (filteredCaseStudies) => {
console.log(filteredCaseStudies)
let disabledIndustries = [];
let disabledAreas = [];
let disabledTechniques = [];
this.state.industries.forEach((industry) => {
let obj = {
name: industry.name
}
if (industry.name == "All Industries") {
console.log(industry.name)
obj.disabled = false;
disabledIndustries.push(obj);
} else {
obj.disabled = true;
filteredCaseStudies.forEach((study) => {
if (study.node.industry == industry.name) {
obj.disabled = false;
}
})
disabledIndustries.push(obj);
}
})
console.log(disabledIndustries)
this.setState({industries: disabledIndustries})
}
getCaseStudies = (caseStudies) => {
let filteredCaseStudies = this.filterCaseStudies(caseStudies)
if (filteredCaseStudies.length > 0) {
return filteredCaseStudies.map((study, i) => {
return (
<div key={i} className="col-lg-4 col-md-6 col-12 px-4 mb-5">
<CaseStudyListItem
data={study.node}
className="CaseStudyListItem--lg"
index={i}/>
</div>
)
})
} else {
return (
<div className="col-12 px-4 mb-5">
<h4>We're Sorry!</h4>
<p>We can't seem to find any case studies that match your search. Please try other search terms.</p>
</div>
)
}
}
dropdownChange = (selected, name) => {
this.setState({[name]: selected})
}
render () {
console.log(this.state)
return (
<Layout bodyClass="k-reverse-header">
<div className="CaseStudies">
<section className="CaseStudies__header k-bg--grey">
<div className="container-fluid">
<div className="d-flex k-row">
<div className="col-12 px-4">
<DropdownSelect className="CaseStudies__search-industry mb-4" data={this.props.data.mainYaml.caseStudiesDropdowns[0]} list={this.state.industries} selected={this.dropdownChange} />
<DropdownSelect className="CaseStudies__search-area mb-4" data={this.props.data.mainYaml.caseStudiesDropdowns[1]} list={this.state.areas} selected={this.dropdownChange} />
</div>
</div>
</div>
</section>
<section className="CaseStudies__list">
<div className="container-fluid">
<div className="d-flex flex-wrap k-row">
{this.getCaseStudies(this.props.data.allCaseStudiesYaml.edges)}
</div>
</div>
</section>
</div>
</Layout>
)
}
}
I believe the issue happens as I pass the state into the Dropdown component, it is also updated in the filterDropdowns function. The Dropdown component code is as follows.
const DropdownSelect = ({ data, className, list, selected}) => {
const [isActive, setActive] = useState(false);
const [activeItem, changeActiveItem] = useState(data.placeholder);
const ref = useRef();
useEffect(() => {
const checkIfClickedOutside = (e) => {
// If the menu is open and the clicked target is not within the menu,
// then close the menu
if (isActive && ref.current && !ref.current.contains(e.target)) {
setActive(false)
}
}
document.addEventListener("mousedown", checkIfClickedOutside)
return () => {
// Cleanup the event listener
document.removeEventListener("mousedown", checkIfClickedOutside)
}
}, [isActive])
const toggleClass = () => {
setActive(!isActive);
}
const buildDropdown = () => {
const splitArr = (arr, len) => {
let chunks = [], i = 0, n = arr.length;
while (i < n) {
chunks.push(arr.slice(i, i += len));
}
return chunks;
}
const buildList = (items) => {
return items.map((item, i) =>
<li
key={i}
className={`DropdownSelect__list-item ${activeItem == item.name ? "active" : ""} ${item.disabled ? "disabled" : ""}`}
onClick={() => itemClicked(item.name, selected, data.name)}
>
{item.name}
</li>
)
}
const itemClicked = (item, selected, search) => {
changeActiveItem(item)
selected(item, search)
}
const arrLen = list.length < 10 ? 3 : 4;
const listsArr = splitArr(list, arrLen);
return listsArr.map((list, i) =>
<ul key={i} className="DropdownSelect__list">
{buildList(list)}
</ul>
)
}
return (
<div className={`DropdownSelect ${className ? className : ''}`} ref={ref}>
<div
className={`DropdownSelect__button ${isActive ? "active" : ""}`}
onClick={toggleClass}
>
{activeItem == null ? data.placeholder : activeItem}
</div>
<div className={`DropdownSelect__list-wrapper ${isActive ? "active" : ""}`}>{buildDropdown}</div>
</div>
)
}
export default DropdownSelect
I feel like i could have all of my state in the listing page but then the Dropdown component is pointless as it wouldn't be self sufficient and usable elsewhere.
I guess I want to know how I break this loop but also what are my bad practices here? ie am I using state wrongly?
Any help greatly appreciated!
PS Here's the React error i get
Maximum update depth exceeded. This can happen when a component repeatedly calls setState inside componentWillUpdate or componentDidUpdate. React limits the number of nested updates to prevent infinite loops.
If I'm reading this right...
In your render, you have {this.getCaseStudies(this.props.data.allCaseStudiesYaml.edges)}. getCaseStudies calls filterCaseStudies, which calls filterDropdowns, which has a setState in it. When a setState occurs, the page re-renders, causing the page to go through all those function calls again, another setState occurs, the page re-renders again, forever, causing an infinite loop.
You'll have to re-write your code somewhat. You could possibly use that state to store the data in a different format, like an array, and map the data in your render?

Adding drag`n`drop(react-dnd) to Material-UI component [TreeView]

I have a question. I created a TreeView and tried to bind the drag'n'drop, everything works, the TreeItem can be moved. BUT. If you expand any TreeItem and try to drag it, then all its child TreeItems will move with it.
How to make only one TreeItem drag'n'drop, without its child TreeItems????
My guess is I need to access the inner component of the item tree. I also don't know how to do this.
My Code:
export const PathTreeItem = (props: PathTreeItemProps) => {
const [obj, setObj] = useState<TreeItemType[] | undefined>(undefined)
const [isCurrentRequest, setIsCurrentRequest] = useState(false)
const [{ isDragging }, drag] = useDrag({
item: { type: props.obj.description || 'asd' },
canDrag: true,
collect: (monitor: DragSourceMonitor) => ({
isDragging: monitor.isDragging(),
}),
})
const treeItemStyle = useMemo(
() => ({
opacity: isDragging ? 0.4 : 1,
}),
[isDragging]
)
useEffect(() => {
if (isCurrentRequest && props.obj.parameter_type === 'ABC') {
APIs.get(props.obj.name)
.then(res => {
setObj(res.data)
})
.catch(err => {=
console.error('Error ', err)
})
}
}, [isCurrentRequest])
const handleLabelCLick = useCallback(event => {
console.log(event)
setIsCurrentRequest(!isCurrentRequest)
}, [])
return (
<TreeItem
ref={drag}
style={treeItemStyle}
nodeId={props.index}
label={props.obj.description}
onLabelClick={handleLabelCLick}
>
{props.obj.parameter_type === 'ABC' ? (
obj ? (
obj.map((value, index) => (
<PathTreeItem
key={props.keyIndex * 100 + index}
keyIndex={index}
index={`${props.index}.${index}`}
obj={value}
/>
))
) : (
<div></div>
)
) : null}
</TreeItem>
)
}
I have solved that problem by not dragging the TreeItem itself, but a custom component attached to it as its label attribute. Unfortunately, the solution currently only works in Firefox, not in Chrome or Safari:
const CustomItem = () => {
return (
// custom item components (Box, Typography, etc.)
);
}
const DraggableCustomItem = () => {
const [{ isDragging }, drag] = useDrag({
collect: (monitor: DragSourceMonitor) => ({
isDragging: monitor.isDragging()
}),
type: 'CustomItem'
})
return (
<div ref={drag} style={{ opacity: isDragging ? 0.5 : 1}}>
<CustomItem/>
</div>
)
}
const TreeViewDraggableCustomItem = () => {
return (
<TreeView>
<TreeItem key = { '1' }
nodeId = { '1' }
label = { <DraggableCustomItem/> }>
</TreeView>
);
}
See also related SO question, example sandbox and github comment.

React accordion get collapsible height

I'm trying to get height of the collapsible but it always same value which is uncollapsed div's height.
Is there a way to get actual content height of div inside the accordion ?
When I print current ref's height it always returns a fixed value. But if I click the same collapsible it returns correct value so I guess it takes uncollapsed div height of collapsible.
Accordion.js
const Accordion = ({ children }) => {
const [ selected, select ] = useState(null);
const [ currentState, changeCurrentState ] = useState(true);
const onCollapsibleSelected = (selectedItem, selectedState, ref) => {
select(selectedItem);
changeCurrentState(!selectedState);
// Always same - because accordion is not expanded yet
console.log(ref.current.offsetHeight)
};
const collapsibleChildren = children.map((item, index) => {
let collapsible;
if (item && item.props) {
const hidden = selected && selected === index ? currentState : true;
collapsible = (
<Collapsible { ...item.props }
onSelect={ onCollapsibleSelected }
key={ index }
index={ index }
collapsed={ hidden }
/>
);
}
return collapsible;
});
return (
<Fragment>
{ collapsibleChildren }
</Fragment>
);
};
Accordion.displayName = 'Accordion';
Accordion.propTypes = {
children: PropTypes.any
};
export default Accordion;
.. index.js
const Collapsible = ({ content, title, onSelect, collapsed, index }) => {
const collapsibleRef = useRef();
const trackCollapsibleBlockClick = () => {
const event = collapsed ? OPEN_COLLAPSIBLE_BLOCK: CLOSE_COLLAPSIBLE_BLOCK;
trackEvent({ ... event, name: title });
};
const onTitleClicked = () => {
trackCollapsibleBlockClick();
onSelect(index, collapsed, collapsibleRef);
};
return (
<CollapsibleStyle ref={ collapsibleRef } onClick={ onTitleClicked }>
<Title collapsed={ collapsed }>{ title }</Title>
<Content hidden={ collapsed }>{ content }</Content>
</CollapsibleStyle>
);
};
Collapsible.displayName = 'Collapsible';
Collapsible.propTypes = {
content: PropTypes.array,
title: PropTypes.string,
onSelect: PropTypes.func,
collapsed: PropTypes.bool,
index: PropTypes.number
};
export default Collapsible;
CollapsibleStyle.js
import styled from 'styled-components';
export default styled.div`
&& {
background-color: ${ ({ theme: { collapsible } }) => collapsible.backgroundColor };
border-radius: ${ ({ theme: { collapsible } }) => collapsible.borderRadius };
margin-bottom: ${ ({ theme: { collapsible } }) => collapsible.marginBottom };
padding: ${ ({ theme: { collapsible } }) => collapsible.paddingVertical }
${ ({ theme: { experience: { wrapper } } }) => wrapper.padding.md };
position: relative;
cursor: pointer;
}
`;
I can actually see the correct height of the child when I print ref.current.children. But when I try to access like ref.current.children[1].clientHeight it returns wrong value.
Solved as below. Using useEffect as it produces lifecycle events. I check if the collapsible item is changed by collapsed check.
useEffect(() => {
handleCollapsibleViewPort();
}, [collapsed]);
const handleCollapsibleViewPort = () => {
if(uncollapsedHeight !== 0 && collapsibleRef.current.clientHeight > uncollapsedHeight ) {
scrollElementToTop(collapsibleRef.current.getBoundingClientRect().top);
}
}

ReactJS: Adding multiple input fields of different types on click

I've created a React app for a school project that can add multiple types of input fields to a view by clicking a button (sort of like Wordpress Gutenberg).
Currently, I can add one of each type of item onto the view. However, if I click the button again, it erases the current text that was added. I'd like the ability to click the button to add as many fields as I'd like on click.
Also, the items are only added into the view in the order they were created meaning, even if I choose photo first and I click headline after, it (headline) will appear at the top of the list above the initial item.
I've had a look at these solutions (which were pretty good) but they didn't provide what I need.
Dynamically adding Input form field issue reactjs
and "update delete list elements using unique key": https://www.youtube.com/watch?v=tJYBMSuOX3s
which was closer to what I needed to do.
Apologies in advance for the length of the code,(there are two other related components for text input and an editform). I'm sure there is a much more simple way to do this. I haven't been able to find an npm package or solution to this specific problem online and am open to a simpler solution.
Edit.jsx
export default class Edit extends React.Component {
state = {
texts: {
hl: '',
shl: '',
txt: '',
photo: []
},
coms: {
hl: false,
shl: false,
txt: false,
photo: null
},
labels: {
// Replace with icons
hl: 'Headline',
shl: 'Sub',
txt: 'Text Area',
photo: 'Photo'
},
selectedItem: '',
}
componentDidMount() {
const saveData = localStorage.getItem('saveData') === 'true';
const user = saveData ? localStorage.getItem('user') : '';
this.setState({ user, saveData });
}
createPage = async () => {
await this.props.postPage(this.state.texts)
}
// add options
addOptions = (item) => {
const { coms } = this.state
coms[item] = !coms[item]
this.setState({ coms: coms })
}
// ADD TEXT
addTxt = () => {
this.setState({ texts: [...this.state.texts, ""] })
}
enableAllButtons = () => {
this.setState({ selectedItem: '' })
}
handleChange = (e, index) => {
this.state.texts[index] = e.target.value
//set the changed state.
this.setState({ texts: this.state.texts })
}
setDisable = (selectedItem) => {
this.setState({ selectedItem })
}
handleRemove = () => {
// this.state.texts.splice(index, 1)
this.setState({ texts: this.state.texts })
}
handleSubmit = (e) => {
console.log(this.state, 'all text')
}
handleChange = (e, item) => {
let { texts } = this.state
texts[item] = e.target.value
//set the changed state.
this.setState({ texts })
console.log(texts)
}
render() {
const { coms, labels, selectedItem, texts } = this.state
let buttons = Object.keys(coms)
let showItems = Object.keys(coms).filter(key => coms[key] === true)
return (
<div>
<InnerHeader />
{/* Make a route for edit here */}
<Route path='/edit/form' render={() => (
<EditForm
texts={texts}
coms={coms}
labels={labels}
addOptions={this.addOptions}
setDisable={this.setDisable}
selectedItem={selectedItem}
showItems={showItems}
handleChange={this.handleChange}
enableAllButtons={this.enableAllButtons}
/>
)} />
{/* Make route for preview */}
<Route path='/edit/preview' render={(props) => (
<Preview
{...props}
createPage={this.createPage}
/>
)}
/>
</div>
)
}
}
AddText.jsx:
export default class AddText extends Component {
state = {
}
// ADD TEXT
addTxt(item) {
const {
addOptions } = this.props
addOptions(item)
}
render() {
const { coms, labels } = this.props
const { selectedItem } = this.props
let buttons = Object.keys(coms)
console.log('here', selectedItem)
return (
<div>
<Card>
<Card.Body>
{
buttons.map((item, index) => <button
value={(selectedItem === "") ? false : (selectedItem === item) ? false : true} key={index} onClick={() => this.addTxt(item)}>
{labels[item]}
</button>
)
}
</Card.Body>
</Card>
</div>
)
}
}
EditForm.jsx
export default function EditForm(props) {
return (
<div>
<div className='some-page-wrapper-sm'>
<div className="dash-card-sm">
<button><Link to={{
pathname: '/edit/preview',
item: props.texts
}}>preview</Link></button>
<br />
<br />
<AddText
coms={props.coms}
labels={props.labels}
addOptions={props.addOptions}
setDisable={props.setDisable}
selectedItem={props.selectedItem}
/>
<div>
{
props.showItems.map((item, index) => {
return (
<InputFieldComponent
// setDisable={props.setDisable}
onChangeText={(e) => props.handleChange(e, item)}
enableAllButtons={props.enableAllButtons}
key={index}
item={item}
labels={props.labels}
texts={props.texts}
/>
)
})
}
</div>
</div>
</div>
</div>
)
}
InputFieldComponent.jsx
export default class InputFieldComponent extends React.Component {
setWrapperRef = (node) => {
this.wrapperRef = node;
}
render() {
const { labels, item, onChangeText, texts } = this.props
return (
<div>
<textarea
className="txt-box"
ref={this.setWrapperRef}
onChange={onChangeText}
placeholder={labels[item]}
value={texts[item]} />
</div>
)
}
}

Categories