A tiny, zero dependency, shuttle (a.k.a list shuttle, dual listbox, etc.) implementation in React using hooks.
A Shuttle, or list shuttle, is two containers that allow you to move items from a "source" to a "target". It's pretty rare in the wild, but great for business applications.
TODO: add animated gif
Other implementations are great but they generally force you to massage your data into a model and are restrictive. Hooks allow you to send data to react accessible shuttle so it can internally manipulate things without sacrificing your ability to control rendering of the shuttle items, controls, etc.
npm i react-accessible-shuttle
# add peer dependencies
npm i react react-dom
react-accessible-shuttle
is a controlled component, but is flexible and adapts to your needs. Since you have complete control over the rendering process, you can render anything you want no matter how simple or complex your state data is. Here's an example using an array of strings:
import React from 'react';
import ReactDOM from 'react-dom';
import { Shuttle, useShuttleState } from 'react-accessible-shuttle';
import 'react-accessible-shuttle/css/shuttle.css';
function App() {
const shuttle = useShuttleState({
source: ['a', 'b', 'c'],
target: ['d', 'e', 'f'],
});
return (
<Shuttle {...shuttle}>
<Shuttle.Container>
{({ source, selected }, getItemProps) =>
source.map((item, index) => (
<Shuttle.Item
{...getItemProps(index)}
key={item}
value={item}
selected={selected.source.has(index)}>
{item}
</Shuttle.Item>
))
}
</Shuttle.Container>
<Shuttle.Controls />
<Shuttle.Container>
{({ target, selected }, getItemProps) =>
target.map((item, index) => (
<Shuttle.Item
{...getItemProps(index)}
key={item}
value={item}
selected={selected.target.has(index)}>
{item}
</Shuttle.Item>
))
}
</Shuttle.Container>
</Shuttle>
);
}
ReactDOM.render(<App />, document.getElementById('app'));
react-accessible-shuttle
is powered by React hooks which allows the nitty-gritty internal details of the component to be handled for you, but while giving you the flexibility to control everything if you need it.
You can also use react-accessible-shuttle via CDN -- it even works with legacy browsers like IE 11 -- without transpiling.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width" />
<!-- Shuttle Dependency -->
<link
rel="stylesheet"
href="https://unpkg.com/react-accessible-shuttle/css/shuttle.css"
/>
<title>React Accessible Shuttle</title>
</head>
<body>
<div id="root"></div>
<!-- Peer Dependencies -->
<script src="https://unpkg.com/react/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom/umd/react-dom.development.js"></script>
<!-- Shuttle Dependency -->
<script src="https://unpkg.com/react-accessible-shuttle/dist-browser/index.js"></script>
<!-- Usage -->
<script>
function App() {
const shuttle = ReactShuttle.useShuttleState({
source: [1, 2, 3],
target: [4, 5, 6],
});
return React.createElement(ReactShuttle, shuttle, [
React.createElement(ReactShuttle.Container, null, function(
state,
getItemProps
) {
return state.source.map(function(item, index) {
const props = {
key: index,
value: item,
};
Object.assign(props, getItemProps(index));
return React.createElement(ReactShuttle.Item, props, item);
});
}),
React.createElement(ReactShuttle.Controls, null, null),
React.createElement(ReactShuttle.Container, null, function (
state,
getItemProps
) {
return state.target.map(function (item, index) {
const props = {
key: index,
value: item,
};
Object.assign(props, getItemProps(index));
return React.createElement(ReactShuttle.Item, props, item);
});
}),
]);
}
ReactDOM.render(React.createElement(App), document.getElementById('root'));
</script>
</body>
</html>
If you're new to hooks, the example might seem verbose; however, we can easily abstract react-accessible-shuttle to take in a model and render on your behalf.
Note: React 16.9 is a peer dependency of react-accessible-shuttle which means we can use hooks! However, if, for some reason, you find yourself stubbing 16.9 APIs so you can use newer stuff without upgrading, then you could possibly make things work 😲
Not on the hooks train yet? No worries. react-accessible-shuttle
depends in React 16.8.0+ so if you have that, then you can use without hooks (i.e. in a class
component) with a some extra effort 😃 (although we should really use hooks because they make our lives much easier).
Here are the things that need to be done:
- Pass
selected
anddisabled
to state (useShuttleState
generates these automatically for us) - Override
Shuttle.Controls
and manually constructsetState
calls. See ShuttleControls.tsx for code you can copy and paste or the example below.
If you're new to state reducing, this might seem mind-bending, but remember that we're using this.setState
to pass information to a function that returns our modified state.
import React from 'react';
import { Shuttle, useShuttleState } from 'react-accessible-shuttle';
class App extends React.Component {
state = {
source: ['a', 'b', 'c'],
target: ['d', 'e', 'f'],
// you MUST provide these when using
// class components
selections: {
source: new Set(),
target: new Set(),
},
disabled: {
source: new Set(),
target: new Set(),
},
};
this.moveAllFromSource = () => {
this.setState({
action: 'MOVE_ALL',
from: 'source',
to: 'target',
});
};
this.moveSelectedFromSource = () => {
this.setState({
action: 'MOVE_SELECTIONS',
from: 'source',
to: 'target',
});
};
this.moveSelectedFromTarget = () => {
this.setState({
action: 'MOVE_SELECTIONS',
from: 'target',
to: 'source',
});
};
this.moveAllFromTarget = () => {
this.setState({
action: 'MOVE_ALL',
from: 'target',
to: 'source',
});
};
render() {
return (
<Shuttle shuttleState={this.state} setShuttleState={this.setState}>
<Shuttle.Container>
{/* ... */}
</Shuttle.Container>
<Shuttle.Controls>
{() => (
<>
<button onClick={this.moveAllFromSource}>{'\u00BB'}</button>
<button onClick={this.moveSelectedFromSource}>{'\u203A'}</button>
<button onClick={this.moveSelectedFromTarget}>{'\u2039'}</button>
<button onClick={this.moveAllFromTarget}>{'\u00AB'}</button>
</>
)}
</Shuttle.Controls>
<Shuttle.Container>
{/* ... */}
</Shuttle.Container>
</Shuttle>
);
}
}
ReactDOM.render(<App />, document.getElementById('app'));
At a high level, react-accessible-shuttle uses state reducing to keep the code maintainable, while offering you the ability to override, extend, and enhance functionality without needing to create a PR for a new feature 😄
useShuttleState
is the entry point. This pure function takes in your data and outputs shuttleState
and setShuttleState
that are generated from React.useReducer
. These are passed down to Shuttle
and off we go.
If you're new to hooks, but familiar with Redux, then the concepts are the same. react-accessible-shuttle exposes each reducer function as a separate module, modifying the state as needed. react-accessible-shuttle uses a composeReducers
redux-style function to combine all reducers. Like Redux, all reducers are executed when setShuttleState
is called.
If you're brand new to state reducing, fear not! Reducer functions are just pure functions that take in state
+ some arguments and return the modified/unmodified state. Our extra arguments tell us useful information like what kind of action we're getting, additional information that helps us modify the state, debugging info, etc. How does this help? Read on!
useShuttleState
takes in four arguments:
- state
- initialSelections - optional
- disabled - optional
- reducers - optional
We can pass custom reducers to enhance functionality pretty easily. Suppose if a container has no selection, but when clicked we want to select the first (0-ith) item in the array. Using state reducing, we can achieve this easily without bloating the Shuttle API:
import React from 'react';
import { Shuttle, useShuttleState } from 'react-accessible-shuttle';
function App() {
const shuttle = useShuttleState(
{
source: ['a', 'b', 'c'],
target: ['d', 'e', 'f'],
},
null,
null,
{
selectFirstItem: (state: any, action: { [key: string]: any } = {}) => {
if (action.type === 'SELECT_FIRST_ITEM') {
if (action.container !== 'source' && action.container !== 'target') {
throw new Error('Missing container from SELECT_FIRST_ITEM reducer');
}
if (!state[action.container].length) {
console.warn(
`Cannot apply selectFirstItem when ${action.container} is empty`
);
return { ...state };
}
if (!state.selected[action.container].size) {
state.selected[action.container].add(0);
}
return { ...state };
}
return { ...state };
},
}
);
return (
<Shuttle {...shuttle}>
<Shuttle.Container
onClick={() => {
shuttle.setShuttleState({
type: 'SELECT_FIRST_ITEM',
container: 'source',
});
}}>
{({ source, selected }, getItemProps) =>
source.map((item, index) => (
<Shuttle.Item
{...getItemProps(index)}
key={item}
value={item}
selected={selected.source.has(index)}>
{item}
</Shuttle.Item>
))
}
</Shuttle.Container>
<Shuttle.Controls />
<Shuttle.Container
onClick={() => {
shuttle.setShuttleState({
type: 'SELECT_FIRST_ITEM',
container: 'target',
});
}}>
{/* ... */}
</Shuttle.Container>
</Shuttle>
);
}
ReactDOM.render(<App />, document.getElementById('app'));
When I filter items selections are not maintained
react-accessible-shuttle
depends on being able to resolve the index of the item based on the data-index
attribute on Shuttle.Items. If you're child render function in Shuttle.Container
looks like this:
<Shuttle.Container>
{({ source, selected }, getItemProps) =>
source
.filter(item => item.includes(sourceFilter))
.map((item, index) => (
<Shuttle.Item
{...getItemProps(index)}
key={item}
value={item}
selected={selected.source.has(index) && source[index] === item}>
{item}
</Shuttle.Item>
))
}
</Shuttle.Container>
Then you will have issues. selected
contains a set of integers. This mapping breaks when you use filter because data-index
changes. See the with-search example in codesandbox for an example.