Skip to main content

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:

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({
disabled: false,
choices: [
{ value: 'chocolate', label: 'Chocolate' },
{ value: 'strawberry', label: 'Strawberry' },
{ value: 'vanilla', label: 'Vanilla' },
],
}),
// ...and add a title
title: 'Flavor',
},
]}
/>
)
}
Flavor
1
2
3
rows

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',
},
]}
/>
)
}
Flavor
1
Select...
2
Select...
3
Select...
rows

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,
}),
}}
/>
)
}
Flavor
1
Select...
2
Select...
3
Select...
rows

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' },
]}
/>
)
}
Flavor
1
Select...
2
Select...
3
Select...
4
Select...
5
Select...
6
Select...
rows

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}
/>
)
}
Flavor
1
2
3
rows

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!

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}
/*...*/
/>
)
}
Flavor
1
2
3
rows

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',
},
]}
/>
)
}
Flavor
1
2
3
rows

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',
},
]}
/>
)
}
Flavor
1
2
3
rows

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)
}}
/>
)
}
Flavor
1
2
3
rows

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',
},
]}
/>
)
}
Flavor
1
2
3
rows

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>
)
}
Flavor
1
2
3
rows

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}
/>
)
}
Flavor
Quantity
1
2
3
rows