Skip to content

Instantly share code, notes, and snippets.

@iki
Last active April 26, 2023 10:08
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save iki/fdfd01f2bdbbed5ec55e3f8e4d7abff4 to your computer and use it in GitHub Desktop.
Save iki/fdfd01f2bdbbed5ec55e3f8e4d7abff4 to your computer and use it in GitHub Desktop.
Select list items in React
// Select list items in React ~ https://gist.github.com/iki/fdfd01f2bdbbed5ec55e3f8e4d7abff4
// Solution for https://jsfiddle.net/mladylukas/9qmusLok/
// Implement a feature to allow item selection with the following requirements:
// 1. Clicking an item selects/unselects it.
// 2. Multiple items can be selected at a time.
// 3. Make sure to avoid unnecessary re-renders of each list item in the big list (performance).
// 4. Currently selected items should be visually highlighted.
// 5. Currently selected items' names should be shown at the top of the page.
// Testing: Use React Dev Tools / Profiler / Settings / Highlight updates when components render.
// TODO: Replace ReactDOM render() with ReactDOM 18 createRoot() (needs change below the line):
// ReactDOM.createRoot(document.getElementById('root')).render(<List items={items} />)
const { Fragment, memo, useRef, useState, useCallback } = React
const { CSSTransition, TransitionGroup } = ReactTransitionGroup
// Sort selected item names by color, size and fruit
// TODO: Generate item index when building items (needs change below the line)
const compareItems = (i1, i2) => getItemIndex(i1) - getItemIndex(i2)
const getItemIndex = (item) =>
item.index || (item.index = generateItemIndex(item))
const generateItemIndex = (item) => {
const [size, color, fruit] = item.name.split(' ', 3)
return (
(colors.indexOf(color) * sizes.length + sizes.indexOf(size)) *
fruits.length +
fruits.indexOf(fruit)
)
}
// Add new item or remove existing item from set and return the set
const toggleItem = (set, item) =>
set.has(item)
? console.log('-', item.name) || (set.delete(item) && set)
: console.log('+', item.name) || set.add(item)
const List = ({ items }) => {
const [selectedCount, setSelectedCount] = useState(0)
const selectedItems = useRef(new Set()).current
const isSelected = (item) => selectedItems.has(item)
const toggle = useCallback(
(item) => setSelectedCount(toggleItem(selectedItems, item).size),
[selectedItems, setSelectedCount]
)
return (
<Fragment>
<div className="Selected">
<span className="Selected__label">Selected:</span>
<SelectedItems
key={!selectedCount} // Replace element when switching between none/any to avoid transition
items={[...selectedItems].sort(compareItems)}
/>
</div>
<hr />
<ul className="List">
{items.map((item) => (
<ListItem
key={item.name}
item={item}
toggle={toggle}
selected={isSelected(item)}
/>
))}
</ul>
</Fragment>
)
}
const ListItem = memo(({ item, toggle, selected }) => (
<li
className={`List__item List__item--${item.color}${
selected ? ' List__item--selected' : ''
}`}
onClick={() => toggle(item)}
onKeyDown={() => toggle(item)}
>
{item.name}
</li>
))
const SelectedItems = ({ items }) => (
// FIXME: Transitioning items won't be memoized, needs change (and better UX) for large selections
<div className="Selected__items">
<TransitionGroup component={null}>
{items.map((item) => (
<SelectedItem key={item.name} item={item} />
))}
</TransitionGroup>
{!items.length && (
<span className="Selected__item Selected__item--none">none</span>
)}
<span className="Selected__item--space" />
</div>
)
const SelectedItem = ({ item, ...transitionProps }) => {
const ref = useRef()
return (
<CSSTransition
nodeRef={ref}
timeout={500}
classNames="Selected__item-"
{...transitionProps}
>
<span ref={ref} className={`Selected__item List__item--${item.color}`}>
{item.name}
</span>
</CSSTransition>
)
}
// ---------------------------------------
// Do NOT change anything below this line.
// ---------------------------------------
const sizes = ['tiny', 'small', 'medium', 'large', 'huge']
const colors = [
'navy',
'blue',
'aqua',
'teal',
'olive',
'green',
'lime',
'yellow',
'orange',
'red',
'maroon',
'fuchsia',
'purple',
'silver',
'gray',
'black',
]
const fruits = [
'apple',
'banana',
'watermelon',
'orange',
'peach',
'tangerine',
'pear',
'kiwi',
'mango',
'pineapple',
]
const items = sizes.reduce(
(items, size) => [
...items,
...fruits.reduce(
(acc, fruit) => [
...acc,
...colors.reduce(
(acc, color) => [
...acc,
{
name: `${size} ${color} ${fruit}`,
color,
},
],
[]
),
],
[]
),
],
[]
)
ReactDOM.render(<List items={items} />, document.getElementById('root'))
<html>
<head>
<link rel="stylesheet" href="style.css" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/solid.min.css"
/>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/18.2.0/umd/react.development.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/18.2.0/umd/react-dom.development.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-transition-group/4.4.5/react-transition-group.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/7.21.4/babel.min.js"></script>
<script src="app.jsx" type="text/babel"></script>
</head>
<body>
<div id="root"></div>
</body>
</html>
:root {
--item-height: 60px;
--selected-scale: 0.9;
--select-size: 16px;
--select-color: #00aaf2;
--select-pos-align: calc(var(--item-height) * (var(--selected-scale) - 1));
--select-pos-corner: calc(var(--select-size) / -2);
--select-pos: calc((var(--select-pos-align) + var(--select-pos-corner)) / 2);
}
body {
margin: 16px;
font-family: Tahoma, sans-serif;
}
.Selected {
display: flex;
}
.Selected__label {
color: #555;
margin: 0.1em 0.3em 0.3em 0;
}
.Selected__items {
display: flex;
flex-wrap: wrap;
}
.Selected__item {
color: white;
flex-grow: 1;
text-align: center;
white-space: nowrap;
margin-right: 0.3em;
margin-bottom: 0.2em;
padding: 0.1em 0.3em;
display: inline-block;
}
.Selected__item--none {
color: #aaa;
}
.Selected__item--space {
min-width: 20em;
flex-grow: 100;
}
.Selected__item--enter {
opacity: 0;
}
.Selected__item--enter-active {
opacity: 1;
transition: opacity 500ms ease;
}
.Selected__item--exit {
opacity: 1;
}
.Selected__item--exit-active {
opacity: 0;
transition: opacity 500ms ease-in;
}
hr {
margin: 0.2em 0 0.3em 0;
}
.List {
padding: 0;
display: grid;
list-style-type: none;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
grid-auto-rows: 60px;
grid-gap: 16px;
background-color: white;
}
.List__item {
display: flex;
align-items: center;
justify-content: center;
text-align: center;
height: calc(var(--item-height) * 0.9);
padding: calc(var(--item-height) * 0.05) 1em;
color: white;
text-shadow: 1px 1px rgba(0, 0, 0, 0.5);
box-shadow: 0px 1px 1px 0px rgba(0, 0, 0, 0.05);
cursor: pointer;
transition: transform 100ms ease-in;
}
.List__item--selected {
transform: scale(var(--selected-scale));
}
.List__item--selected::before {
content: '\f00c';
font-family: var(--fa-style-family-classic);
display: block;
position: absolute;
top: var(--select-pos);
left: var(--select-pos);
width: var(--select-size);
height: var(--select-size);
border-radius: 50%;
background-color: var(--select-color);
color: white;
text-align: center;
font-size: 11px;
line-height: 16px;
}
/* Taken from https://clrs.cc/ */
/* TODO: Fix contrast */
.List__item--navy {
background-color: #001f3f;
}
.List__item--blue {
background-color: #0074d9;
}
.List__item--aqua {
background-color: #7fdbff;
}
.List__item--teal {
background-color: #39cccc;
}
.List__item--olive {
background-color: #3d9970;
}
.List__item--green {
background-color: #2ecc40;
}
.List__item--lime {
background-color: #01ff70;
}
.List__item--yellow {
background-color: #ffdc00;
}
.List__item--orange {
background-color: #ff851b;
}
.List__item--red {
background-color: #ff4136;
}
.List__item--maroon {
background-color: #85144b;
}
.List__item--fuchsia {
background-color: #f012be;
}
.List__item--purple {
background-color: #b10dc9;
}
.List__item--black {
background-color: #111111;
}
.List__item--gray {
background-color: #aaaaaa;
}
.List__item--silver {
background-color: #dddddd;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment