Implementing a select column
The goal here is to implement a simple select that can serve as a base for a prod ready component of your application. It will be easily extensible, and re-usable anywhere in your app. The end result will look like this:
function App() {
// `data` is a simple array of `string | null`, in a real world example
// we would probably have multiple columns and use objects instead
// See section => Using with multiple columns
const [ data, setData ] = useState(['chocolate', 'strawberry', null])
return (
<DataSheetGrid
value={data}
onChange={setData}
columns={[
{
// We use our custom select column 🎉
...selectColumn({
choices: [
{ value: 'chocolate', label: 'Chocolate' },
{ value: 'strawberry', label: 'Strawberry' },
{ value: 'vanilla', label: 'Vanilla' },
],
}),
// ...and add a title
title: 'Flavor',
},
]}
/>
)
}
Setup
We will be using the popular react-select library. First we need to install it:
npm i react-select
# Using Typescript?
npm i --save-dev @types/react-select
The rest of this tutorial will be using Typescript, if your are using plain JS simply strip away any type annotation and you should be good to go.
Now we can simply use the Select
component in a column using the component prop:
import Select from 'react-select'
function App() {
const [ data, setData ] = useState(['chocolate', 'strawberry', null])
return (
<DataSheetGrid
value={data}
onChange={setData}
columns={[
{
component: Select
title: 'Flavor',
},
]}
/>
)
}
The result is not great but at least it works, we simply need to do a little styling. To do so we need to customize the
props and behavior of the Select
component by wraping it in a component of our own:
import Select from 'react-select'
const SelectComponent = () => {
return <Select />
}
function App() {
const [ data, setData ] = useState(['chocolate', 'strawberry', null])
return (
<DataSheetGrid
value={data}
onChange={setData}
columns={[
{
component: SelectComponent,
title: 'Flavor',
},
]}
/>
)
}
We now have a solid base to start styling!
Styling
According to react-select doc we can use the styles
prop to style any part of the
select component. So far we only need to style 3 elements:
- container: make it take the full width and height of the cell
- control: make it take the full height of the container and remove any border
- indicatorSeparator: just hide it using opacity, it's easier on the eye
const SelectComponent = () => {
return (
<Select
styles={{
container: (provided) => ({
...provided,
flex: 1, // full width
alignSelf: 'stretch', // full height
}),
control: (provided) => ({
...provided,
height: '100%',
border: 'none',
boxShadow: 'none',
background: 'none',
}),
indicatorSeparator: (provided) => ({
...provided,
opacity: 0,
}),
}}
/>
)
}
Interacting with our select does not feel right, but it is already looking a lot better. To fix this issue
we need to prevent the mouse cursor from interacting with the select unless the cell is focused. We can use the focus prop to set
the CSS property pointerEvents
to none
when the cell is not focused:
import { CellProps } from 'react-datasheet-grid'
const SelectComponent = ({ focus }: CellProps) => {
return (
<Select
styles={{
container: (provided) => ({
...provided,
flex: 1,
alignSelf: 'stretch',
pointerEvents: focus ? undefined : 'none',
}),
/*...*/
}}
/>
)
}
If we add 10 rows to our datasheet grid we see that the "Select..." placeholder and the caret look very repetitive. It is recommended to only show placeholders on active cells to avoid this effect. We can use the active prop to do so:
import { CellProps } from 'react-datasheet-grid'
const SelectComponent = ({ active }: CellProps) => {
return (
<Select
styles={{
/*...*/
indicatorsContainer: (provided) => ({
...provided,
opacity: active ? 1 : 0,
}),
placeholder: (provided) => ({
...provided,
opacity: active ? 1 : 0,
}),
}}
/>
)
}
Finally we can add some options just for fun:
const SelectComponent = () => {
return (
<Select
/*...*/
options={[
{ value: 'chocolate', label: 'Chocolate' },
{ value: 'strawberry', label: 'Strawberry' },
{ value: 'vanilla', label: 'Vanilla' },
]}
/>
)
}
The end result is a placeholder that is only visible when the cell is active, and a component you can only interact with while the cell is focused.
Fixing the menu
It now takes 3 clicks to open the menu: click once to select the cell, click again to focus, and click
again to open the menu. We can fix that by using the focus
prop of our component to control the menuIsOpen
prop
of the select.
const SelectComponent = ({ focus }: CellProps) => {
return (
<Select
/*...*/
menuIsOpen={focus}
/>
)
}
We have another issue: if you try to open the select of the last row you have to scroll to see the menu because it is contained within the scrollable area of the grid.
The solution is to use portals. Thankfully react-select has this feature built-in and we simply need to add a prop:
const SelectComponent = () => {
return (
<Select
/*...*/
menuPortalTarget={document.body}
/>
)
}
The menu is now displayed correctly and automatically opens when the cell is focused. You can use the return and escape keys to open and close the menu!
Fixing the search
When you start typing the menu open because the cell gains focus, but nothing else happens, you have to manually click on the input to start searching.
We have the same issue when we close the menu by pressing Esc, the input is till in focus with a blinking cursor.
To fix this issue we simply need to manually focus and blur the input when we gain or loose focus.
We can use a ref
of the select element along with useLayoutEffect
to achieve that:
import React, { useLayoutEffect, useRef } from 'react'
const SelectComponent = ({ focus }: CellProps) => {
const ref = useRef<Select>(null)
// This function will be called only when `focus` changes
useLayoutEffect(() => {
if (focus) {
ref.current?.focus()
} else {
ref.current?.blur()
}
}, [focus])
return (
<Select
ref={ref}
/*...*/
/>
)
}
Keeping focus
Now that the menu renders in a portal, any click in the menu will be considered to be outside of the grid and react-datasheet-grid will blur the cell, closing the menu. Luckily for us react-select prevent this from happening by blocking the click event on the menu, as you can see it stays open when you select a choice.
But for the sake of example we are going to still "fix" this issue that might occur if you use another library or decide to implement your own.
We use the keepFocus property of the column to keep focus even when we click outside
of the grid. We now have to call stopEditing ourself when the user clicks
outside of the select. Notice the nextRow
property that allows us to stay on the same cell instead of going to the next.
const SelectComponent = ({ stopEditing }: CellProps) => {
/*...*/
return (
<Select
/*...*/
onMenuClose={() => stopEditing({ nextRow: false })}
/>
)
}
function App() {
const [ data, setData ] = useState(['chocolate', 'strawberry', null])
return (
<DataSheetGrid
value={data}
onChange={setData}
columns={[
{
component: SelectComponent,
keepFocus: true,
title: 'Flavor',
},
]}
/>
)
}
Fixing the up and down keys
The last UX issue we face is that using the up and down keys to select a choice moves the active cell up or down, and as a result closes the select. To fix this we simply need to disable keys when the cell is focused using the disableKeys option of the column:
function App() {
const [ data, setData ] = useState(['chocolate', 'strawberry', null])
return (
<DataSheetGrid
value={data}
onChange={setData}
columns={[
{
component: SelectComponent,
disableKeys: true,
title: 'Flavor',
},
]}
/>
)
}
Now we can freely use the up and down arrows, but the Enter
key is also ignored when we try to select a value.
We will fix this in next section.
Binding data
Until this point the underlying data (our state) was not modified. It looks like the select is working just fine because it keeps an inner state to show the value that has just been selected, but two points can give it away:
- The default values we specified in
useState
are not shown: the two first rows should be Chocolate and Strawberry - If you add 20 rows, scroll down and scroll back up the selected value is gone. This is because the rows that are not in view are deleted and created back when the user scrolls back to them, resetting the inner state of the select component
Wee need to bind the right values to our select component:
// For now we just took the choices outside of the component because we
// need to refer to it multiple times (this is not the final design, but it will do for now)
const choices = [
{ value: 'chocolate', label: 'Chocolate' },
{ value: 'strawberry', label: 'Strawberry' },
{ value: 'vanilla', label: 'Vanilla' },
]
const SelectComponent = ({ rowData }: CellProps) => {
/*...*/
return (
<Select
/*...*/
value={
// We cannot just pass the rowData as value
// We have to pass the entire choice object { label, value }
choices.find(({ value }) => value === rowData) ?? null
}
options={choices}
/>
)
}
We also need to bind the onChange
callback of the select to update the value and close the select. We use the
setRowData and stopEditing
to achieve that:
const SelectComponent = ({ setRowData, stopEditing }: CellProps) => {
/*...*/
return (
<Select
/*...*/
onChange={({ value }) => {
setRowData(value)
// We don't just do `stopEditing()` because it is triggered too early by react-select
setTimeout(stopEditing, 0)
}}
/>
)
}
Handling copy pasting
Handling copy pasting is very straight forward, we just need to implement three functions:
- deleteValue: will be called when cutting or deleting a cell. We want return null as a deleted value.
- copyValue: will be called when the cell is copied. Here we decided to return the label of the choice, not the underlying value.
- pasteValue: will be called to handle pasting. Because we chose to copy the label, we have to transform it back to a value on pasting.
function App() {
const [ data, setData ] = useState(['chocolate', 'strawberry', null])
return (
<DataSheetGrid
value={data}
onChange={setData}
columns={[
{
component: SelectComponent,
disableKeys: true,
deleteValue: () => null,
copyValue: ({ rowData }) =>
choices.find((choice) => choice.value === rowData)?.label,
pasteValue: ({ value }) =>
choices.find((choice) => choice.label === value)?.value ?? null,
title: 'Flavor',
},
]}
/>
)
}
Making the column generic
Now we need to make our column re-usable. To make a column re-usable we often expose a function that takes params and returns a column object:
type SelectOptions = {
choices: Choice[]
// Let's add more options!
disabled?: boolean
}
const selectColumn = (
// We receive the options as a parameter to create the column object
options: SelectOptions
): Column<string | null, SelectOptions> => ({
component: SelectComponent,
// We pass the options to the cells using the `columnData`property
columnData: options,
// We set other column properties so we don't have to do it manually everytime we use the column
disableKeys: true,
keepFocus: true,
// We can also use the options to customise some properties
disabled: options.disabled,
deleteValue: () => null,
copyValue: ({ rowData }) =>
options.choices.find((choice) => choice.value === rowData)?.label,
pasteValue: ({ value }) =>
options.choices.find((choice) => choice.label === value)?.value ?? null,
})
We also have to update the SelectComponent
to get the options from columnData
:
const SelectComponent = React.memo(
({ columnData, rowData }: CellProps<string | null, SelectOptions>) => {
/*...*/
return (
<Select
/*...*/
isDisabled={columnData.disabled}
value={
columnData.choices.find(({ value }) => value === rowData) ?? null
}
options={columnData.choices}
/>
)
}
)
Now our column is truly re-usable:
function App() {
return (
<DataSheetGrid
value={data}
onChange={setData}
columns={[
selectColumn({ choices: [/*..*/], disabled: true })
]}
/>
)
}
We can also extend the column to add a title, a custom min-width, or override any property:
function App() {
return (
<DataSheetGrid
value={data}
onChange={setData}
columns={[
{
...selectColumn({ choices: [/*..*/], disabled: true }),
title: 'Flavor',
minWidth: 150,
}
]}
/>
)
}
Final result
import React, { useLayoutEffect, useRef, useState } from 'react'
import { DataSheetGrid, CellProps, Column } from 'react-datasheet-grid'
import Select, { GroupBase, SelectInstance } from 'react-select'
type Choice = {
label: string
value: string
}
type SelectOptions = {
choices: Choice[]
disabled?: boolean
}
const SelectComponent = React.memo(
({
active,
rowData,
setRowData,
focus,
stopEditing,
columnData,
}: CellProps<string | null, SelectOptions>) => {
const ref = useRef<SelectInstance<Choice, false, GroupBase<Choice>>>(null)
useLayoutEffect(() => {
if (focus) {
ref.current?.focus()
} else {
ref.current?.blur()
}
}, [focus])
return (
<Select
ref={ref}
styles={{
container: (provided) => ({
...provided,
flex: 1,
alignSelf: 'stretch',
pointerEvents: focus ? undefined : 'none',
}),
control: (provided) => ({
...provided,
height: '100%',
border: 'none',
boxShadow: 'none',
background: 'none',
}),
indicatorSeparator: (provided) => ({
...provided,
opacity: 0,
}),
indicatorsContainer: (provided) => ({
...provided,
opacity: active ? 1 : 0,
}),
placeholder: (provided) => ({
...provided,
opacity: active ? 1 : 0,
}),
}}
isDisabled={columnData.disabled}
value={
columnData.choices.find(({ value }) => value === rowData) ?? null
}
menuPortalTarget={document.body}
menuIsOpen={focus}
onChange={(choice) => {
if (choice === null) return;
setRowData(choice.value);
setTimeout(stopEditing, 0);
}}
onMenuClose={() => stopEditing({ nextRow: false })}
options={columnData.choices}
/>
)
}
)
const selectColumn = (
options: SelectOptions
): Column<string | null, SelectOptions> => ({
component: SelectComponent,
columnData: options,
disableKeys: true,
keepFocus: true,
disabled: options.disabled,
deleteValue: () => null,
copyValue: ({ rowData }) =>
options.choices.find((choice) => choice.value === rowData)?.label ?? null,
pasteValue: ({ value }) =>
options.choices.find((choice) => choice.label === value)?.value ?? null,
})
function App() {
const [data, setData] = useState<Array<string | null>>([
'chocolate',
'strawberry',
null,
])
return (
<div style={{ marginBottom: 20 }}>
<DataSheetGrid
value={data}
onChange={setData}
columns={[
{
...selectColumn({
choices: [
{ value: 'chocolate', label: 'Chocolate' },
{ value: 'strawberry', label: 'Strawberry' },
{ value: 'vanilla', label: 'Vanilla' },
],
}),
title: 'Flavor',
},
]}
/>
</div>
)
}
Using with multiple columns
So far we have been using our select column on rows that are strings or null, that works well if we have only one column.
Of course in a real world situation you probably want to have multiple columns and use objects instead. Fortunately
their is no extra work needed to make it work, simply use keyColumn
:
import {
DataSheetGrid,
Column,
keyColumn,
intColumn,
} from 'react-datasheet-grid'
type Row = {
flavor: string | null
quantity: number | null
}
function App() {
const [data, setData] = useState<Row[]>([
{ flavor: 'chocolate', quantity: 3 },
{ flavor: 'strawberry', quantity: 5 },
{ flavor: null, quantity: null },
])
const columns: Column<Row>[] = [
{
...keyColumn('flavor', selectColumn({ choices: [/*...*/] })),
title: 'Flavor',
},
{
...keyColumn('quantity', intColumn),
title: 'Quantity',
},
]
return (
<DataSheetGrid
value={data}
onChange={setData}
columns={columns}
/>
)
}