I'm building a multiple selection in React with a react-bootstrap ListGroup.
Despite having the active property of an item set to false though, the last clicked item still gets active in its class list.
I've been considering getting a reference to the list item and manipulate the class list, but that is too dirty.
I tried changing the variant attribute of the ListGroup.Item, but the active class overrides that. I'd prefer to not modify the css class definition.
I prefer using the onSelect handler of the ListGroup instead of using the ToggleButton's onClick event from a usability perspective.
Have you tried using a ListGroup to manipulate multiple selection?
Here is stripped version of the code:
import { useState } from "react";
import "./styles.css";
import ListGroup from "react-bootstrap/ListGroup";
import Container from "react-bootstrap/Container";
import Row from "react-bootstrap/Row";
import Col from "react-bootstrap/Col";
import ToggleButton from "react-bootstrap/ToggleButton";
import "bootstrap/dist/css/bootstrap.min.css";
import { cloneDeep } from "lodash";
export default function App() {
const allItems = [
{ id: 1, title: "A" },
{ id: 2, title: "B" },
{ id: 3, title: "C" },
{ id: 4, title: "D" },
{ id: 5, title: "E" }
];
const [selectedItems, setSelectedItems] = useState([
{ id: 2, title: "B" },
{ id: 4, title: "D" }
]);
function toggleSelection(eventKey, e) {
const itemId = parseInt(eventKey?.replace(/^itemSelect_/, ""), 10);
const newSelectedItems = cloneDeep(selectedItems);
const indexInSelection = selectedItems.findIndex(
(sitm) => sitm.id === itemId
);
if (indexInSelection >= 0) {
newSelectedItems.splice(indexInSelection, 1);
} else {
const newItem = allItems.find((itm) => itm.id === itemId);
newSelectedItems.push(newItem);
}
setSelectedItems(newSelectedItems);
}
return (
<div className="App">
<ListGroup onSelect={toggleSelection}>
{allItems.map((itm) => {
return (
<ListGroup.Item
eventKey={`itemSelect_${itm.id}`}
key={`itemSelect${itm.id}`}
active={
selectedItems.find((sitm) => sitm.id === itm.id) !== undefined
}
>
<Container>
<Row>
<Col sm="2">
{" "}
<ToggleButton
className="mx-1"
type="checkbox"
size="sm"
key={`check${itm.id}`}
checked={
selectedItems.find((sitm) => sitm.id === itm.id) !==
undefined
}
value={itm.id}
></ToggleButton>
</Col>
<Col sm="10">{itm.title}</Col>
</Row>
</Container>
</ListGroup.Item>
);
})}
</ListGroup>
</div>
);
}
And a live preview is available at Code Sandbox
Thanks in advance for your help!
It's something to do with eventKey and onSelect - ListGroup and ListGroup.Item have some internal implementations on various user actions as you can see in docs. Try this implementation instead. I removed onSelect on ListGroup and added onClick event on ListGroup.Item where we don't need eventKey anymore and pass itm.id directly.
Related
I am trying to create a list of buttons with values that are stored in a state and user is only allowed to use 1 item (I dont want to use radio input because I want to have more control over styling it).
import React from "react";
import { useEffect, useState } from "react";
import "./styles.css";
const items = [
{ id: 1, text: "Easy and Fast" },
{ id: 2, text: "Easy and Cheap" },
{ id: 3, text: "Cheap and Fast" }
];
const App = () => {
const [task, setTask] = useState([]);
const clickTask = (item) => {
setTask([...task, item.id]);
console.log(task);
// how can I make sure only 1 item is added to task
// and remove the other items
// only one option is selectable all the time
};
const chosenTask = (item) => {
if (task.find((v) => v.id === item.id)) {
return true;
}
return false;
};
return (
<div className="App">
{items.map((item) => (
<li key={item.id}>
<label>
<button
type="button"
className={chosenTask(item) ? "chosen" : ""}
onClick={() => clickTask(item)}
onChange={() => clickTask(item)}
/>
<span>{item.text}</span>
</label>
</li>
))}
</div>
);
};
export default App;
https://codesandbox.io/s/react-fiddle-forked-cvhivt?file=/src/App.js
I am trying to only allow 1 item to be added to the state at all the time, but I dont know how to do this?
Example output is to have Easy and Fast in task state and is selected. If user click on Easy and Cheap, select that one and store in task state and remove Easy and Fast. Only 1 item can be in the task state.
import React from "react";
import { useEffect, useState } from "react";
import "./styles.css";
const items = [
{ id: 1, text: "Easy and Fast" },
{ id: 2, text: "Easy and Cheap" },
{ id: 3, text: "Cheap and Fast" }
];
const App = () => {
const [task, setTask] = useState();
const clickTask = (item) => {
setTask(item);
console.log(task);
// how can I make sure only 1 item is added to task
// and remove the other items
// only one option is selectable all the time
};
return (
<div className="App">
{items.map((item) => (
<li key={item.id}>
<label>
<button
type="button"
className={item.id === task?.id ? "chosen" : ""}
onClick={() => clickTask(item)}
onChange={() => clickTask(item)}
/>
<span>{item.text}</span>
</label>
</li>
))}
</div>
);
};
export default App;
Is this what you wanted to do?
Think of your array as a configuration structure. If you add in active props initialised to false, and then pass that into the component you can initialise state with it.
For each task (button) you pass down the id, and active state, along with the text and the handler, and then let the handler in the parent extract the id from the clicked button, and update your state: as you map over the previous state set each task's active prop to true/false depending on whether its id matches the clicked button's id.
For each button you can style it based on whether the active prop is true or false.
If you then need to find the active task use find to locate it in the state tasks array.
const { useState } = React;
function Tasks({ config }) {
const [ tasks, setTasks ] = useState(config);
function handleClick(e) {
const { id } = e.target.dataset;
setTasks(prev => {
// task.id === +id will return either true or false
return prev.map(task => {
return { ...task, active: task.id === +id };
});
});
}
// Find the active task, and return its text
function findSelectedItem() {
const found = tasks.find(task => task.active)
if (found) return found.text;
return 'No active task';
}
return (
<section>
{tasks.map(task => {
return (
<Task
key={task.id}
taskid={task.id}
active={task.active}
text={task.text}
handleClick={handleClick}
/>
);
})};
<p>Selected task is: {findSelectedItem()}</p>
</section>
);
}
function Task(props) {
const {
text,
taskid,
active,
handleClick
} = props;
// Create a style string using a joined array
// to be used by the button
const buttonStyle = [
'taskButton',
active && 'active'
].join(' ');
return (
<button
data-id={taskid}
className={buttonStyle}
type="button"
onClick={handleClick}
>{text}
</button>
);
}
const taskConfig = [
{ id: 1, text: 'Easy and Fast', active: false },
{ id: 2, text: 'Easy and Cheap', active: false },
{ id: 3, text: 'Cheap and Fast', active: false }
];
ReactDOM.render(
<Tasks config={taskConfig} />,
document.getElementById('react')
);
.taskButton { background-color: palegreen; padding: 0.25em 0.4em; }
.taskButton:not(:first-child) { margin-left: 0.25em; }
.taskButton:hover { background-color: lightgreen; cursor: pointer; }
.taskButton.active { background-color: skyblue; }
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.production.min.js"></script>
<div id="react"></div>
On every click react rerender every item. How to avoid it? I want react to only render items that have changed. I tried using react memo and usecallback but it didn't help. I can't understand what is the reason. What are the ways to solve this problem? Thank you.
App.ts
import React, { useState } from "react";
import "./styles.css";
import ListItem from "./ListItem";
const items = [
{ id: 1, text: "items1" },
{ id: 2, text: "items2" },
{ id: 3, text: "items3" },
{ id: 4, text: "items4" },
{ id: 5, text: "items5" },
{ id: 6, text: "items6" },
{ id: 7, text: "items7" }
];
export default function App() {
const [activeIndex, setActiveIndex] = useState(1);
const onClick = (newActiveIndex: number) => {
setActiveIndex(newActiveIndex);
};
return (
<div className="App">
{items.map(({ id, text }) => (
<ListItem
key={id}
id={id}
text={text}
activeId={activeIndex}
onClick={onClick}
/>
))}
</div>
);
}
Item.ts
import React from "react";
interface ListItemProps {
id: number;
activeId: number;
text: string;
onClick: (newActiveIndex: number) => void;
}
const ListItem: React.FC<ListItemProps> = ({ id, activeId, text, onClick }) => {
const isActive = activeId === id;
const style = {
marginRight: "42px",
marginTop: "24px",
color: isActive ? "green" : "red"
};
console.log(`update id: ${id}`);
return (
<div>
<span style={style}>id: {id}</span>
<span style={style}>is active: {isActive ? "active" : "inactive"}</span>
<span style={style}>text: {text}</span>
<button onClick={() => onClick(id)}>setActive</button>
</div>
);
};
export default ListItem;
activeIndex changes with each click, so all of the components have new prop values and will need to re-render.
Instead of passing the activeIndex to every component every time:
const ListItem: React.FC<ListItemProps> = ({ id, activeId, text, onClick }) => {
const isActive = activeId === id;
Pass an isActive to each component:
const ListItem: React.FC<ListItemProps> = ({ id, isActive, text, onClick }) => {
Effectively moving the calculation of the bool from the component to the consuming code:
<ListItem
key={id}
id={id}
text={text}
isActive={activeIndex === id}
onClick={onClick}
/>
Then memoization (coupled with useCallback for the onClick handler) should work because most components (the ones which aren't changing their "active" state) won't receive new props and won't need to re-render.
Even with memoization it won't work, because your onClick function changes your components state, and your children components depend on activeIndex value, so every time you click you change the components state, and when state changes, the component re-renders itself and it's children, if your children didn't depend on your state, they wouldn't re-render (if you use memoization).
Plus to David's answer i would return ListItem in React.memo React.memo(ListItem)
I actually have a drag and drop list from BaseWeb https://baseweb.design/components/dnd-list/,
And instead of havings strings as the exemple shows, i'm having components for a blog, like a text section, some inputs, etc... I use this list to reorder my components easily, but i got a problem, if i want to click to go in my text input, I drag my component, and don't get in.
I'm using React-Quill for the text editor
Here's my code for the list:
initialState={{
items: componentsArray
}}
onChange={({oldIndex, newIndex}) =>
setComponentsArray(newIndex === -1 ?
arrayRemove(componentsArray, oldIndex) :
arrayMove(componentsArray, oldIndex, newIndex))
}
className=""
overrides={{
DragHandle: <FontAwesomeIcon icon={Icons.faLeftRight} />,
}}
/>
Try cancel onmousedown BaseWeb's handler for elements you need to free of drag.
import React, { useState, useEffect, useRef } from 'react';
import { List, arrayMove } from 'baseui/dnd-list';
import { StatefulSelect } from 'baseui/select';
import { v4 as uuidv4 } from 'uuid';
const RulesTab = () => {
const [items, setItems] = useState([{ id: uuidv4() }, { id: uuidv4() }]);
const dndRootRef = useRef(null);
useEffect(() => {
// override base-web's mousedown event handler
dndRootRef.current.addEventListener('mousedown', (el) => {
let isFreeOfDrag = false;
let currentEl = el.target;
// check if the element you clicked is inside the "free-of-drag block"
do {
if (currentEl.getAttribute('test-id') === 'free-of-drag') {
isFreeOfDrag = true;
break;
} else {
currentEl = currentEl.parentElement;
}
} while (currentEl);
// invoke el.stopPropagation(); if the element you clicked is inside the "free-of-drag block"
if (isFreeOfDrag) {
el.stopPropagation();
}
});
}, []);
return (
<List
items={items.map(({ id }, index) => (
<div style={{ display: 'flex' }} test-id="free-of-drag" key={id}>
<StatefulSelect
options={[
{ label: 'AliceBlue', id: '#F0F8FF' },
{ label: 'AntiqueWhite', id: '#FAEBD7' },
]}
placeholder="Select color"
/>
</div>
))}
onChange={({ oldIndex, newIndex }, el) => setItems(arrayMove(items, oldIndex, newIndex))}
overrides={{
Root: {
props: {
ref: dndRootRef,
},
},
}}
/>
);
};
I want to click on an option in the menu. This options should then display all the items associated with that option in the child component. I know I am going wrong in two places. Firstly, I am going wrong in the onClick function. Secondly, I am not sure how to display all the items of ONLY the option (Eg.Brand, Holiday destination, Film) that is clicked in the child component. Any help would be greatly appreciated.
horizantalscroll.js
import React from 'react';
import ScrollMenu from 'react-horizontal-scrolling-menu';
import './hrizontalscroll.css';
import Items from './items';
// list of items
// One item component
// selected prop will be passed
const MenuItem = ({ text, selected }) => {
return (
<div
className="menu-item"
>
{text}
</div>
);
};
onclick(){
this.onClick(name)}
}
// All items component
// Important! add unique key
export const Menu = (list) => list.map(el => {
const { name } = el;
return (
<MenuItem
text={name}
key={name}
onclick={this.onclick.name}
/>
);
});
const Arrow = ({ text, className }) => {
return (
<div
className={className}
>{text}</div>
);
};
const ArrowLeft = Arrow({ text: '<', className: 'arrow-prev' });
const ArrowRight = Arrow({ text: '>', className: 'arrow-next' });
class HorizantScroller extends React.Component {
state = {
selected: 0,
statelist: [
{name: "Brands",
items: ["1", "2", "3"]
},
{name: "Films",
items: ["f1", "f2", "f3"]
},
{name: "Holiday Destination",
items: ["f1", "f2", "f3"]
}
]
};
onSelect = key => {
this.setState({ selected: key });
}
render() {
const { selected } = this.state;
// Create menu from items
const menu = Menu(this.state.statelist, selected);
const {statelist} = this.state;
return (
<div className="HorizantScroller">
<ScrollMenu
data={menu}
arrowLeft={ArrowLeft}
arrowRight={ArrowRight}
selected={selected}
onSelect={this.onSelect}
/>
<items items={items}/>
</div>
);
}
}
export default HorizantScroller;
items.js
import React, { Component } from "react";
import HorizontalScroller from "horizontalscroll.js";
class Items extends React.Component{
render() {
return (
<div>
{this.statelist.items.map({items, name}) =>
name === this.statelist.name && <div>{items}</div>
}
</div>
);
}
}
export default Items;
The answer is in passing to your Items component the correct array it needs to show. You are already creating a state variable for what's selected. So it would be along the lines of:
<Items items={items[selected]}/>
I'm building a TreeView with the Treeview component from Material UI: https://material-ui.com/components/tree-view/
I have created the component below which fetches data when a node is expanded. Furthermore, the tree is build so each node that have children also is a tree of MyTreeItem, but I have one question:
When I reach a point where there are no more children, I want to remove/hide the expand/collapse icon. How can i achieve this?
import ReactDOM from "react-dom";
import React from "react";
import TreeView from "#material-ui/lab/TreeView";
import ExpandMoreIcon from "#material-ui/icons/ExpandMore";
import ChevronRightIcon from "#material-ui/icons/ChevronRight";
import TreeItem from "#material-ui/lab/TreeItem";
const { useState, useCallback } = React;
export default function MyTreeItem(props) {
const [childNodes, setChildNodes] = useState(null);
const [expanded, setExpanded] = React.useState([]);
function fetchChildNodes(id) {
return new Promise(resolve => {
setTimeout(() => {
resolve({
children: [
{
id: "2",
name: "Calendar"
},
{
id: "3",
name: "Settings"
},
{
id: "4",
name: "Music"
}
]
});
}, 1000);
});
}
const handleChange = (event, nodes) => {
const expandingNodes = nodes.filter(x => !expanded.includes(x));
setExpanded(nodes);
if (expandingNodes[0]) {
const childId = expandingNodes[0];
fetchChildNodes(childId).then(
result =>
result.children
? setChildNodes(
result.children.map(node => (
<MyTreeItem key={node.uuid} {...node} action={props.action} />
))
)
: console.log("No children") // How do I remeove the expand/collapse icon?
);
}
};
return (
<TreeView
defaultCollapseIcon={<ExpandMoreIcon />}
defaultExpandIcon={<ChevronRightIcon />}
expanded={expanded}
onNodeToggle={handleChange}
>
{/*The node below should act as the root node for now */}
<TreeItem nodeId={props.id} label={props.name}>
{childNodes || [<div key="stub" />]}
</TreeItem>
</TreeView>
);
}
const rootElement = document.getElementById("root");
ReactDOM.render(<MyTreeItem id="1" name="Applications" />, rootElement);
You can execute setChildNodes(null) when there's no children (i.e. replace that with console.log("No children")) and remove the stub so that the icon is not shown when childNodes is null:
<TreeItem nodeId={props.id} label={props.name}>
{childNodes}
</TreeItem>
This is done automatically by the TreeItem component. As long as it does not have any children, it will not have a collapse/expand icon.
In your case, icon is always displayed because of [<div key="stub" />]. You should decide dynamically when add it or not to enable icon.