Skip to main content

Select

Installation

$ yarn add @rexlabs/select

Usage

import {
// Hooks
useSelect,
useItems,
useValueList,
useFreeformDialog,

// Components
Select,
HoverContainer,
OptionContainer,
SimpleOption,
RecordOption,
SimpleToggleButton

// Types
NormalisedItem,
SelectProps,
OptionProps,
ToggleButtonProps,
FreeformConfig,
FreeformDialogProps,
UseItemsArgs,
UseValueListArgs
} from '@rexlabs/select';

Components

Select

The Select is an accessible combobox abstraction around downshift. It handles all of the view and custom behavioural logic as well as implementing some supplementary state for functionality not handled by downshift itself.

This component also implements the @rexlabs/form Field interface, which includes the onChange, onBlur and meta: { error } props.

Props

This component's API is a superset of the downshift useCombobox API and takes (almost) all of the props from this hook. This component does not support the defaultSelectedItem prop.

items (default: [])

The items to be displayed, these items can be anything. Whatever is passed here will be what is sent up to the form when an item is selected.

normaliser (default: (item) => ({ id: item, label: item }))
type normaliser = (item: Item) => NormalisedItem;

This function is used to normalise the items that are passed into the select into something that is usable by the select. The shape of the object that this function returns depends on the chosen Option.

searchable (default: true)
type searchable = boolean;

Whether the user can type into the input to filter down the items within the list.

deselectable (default: false)
type deselectable = boolean;

Whether an item is deselectable after it has been selected. When this is true the pressing the cross icon in the text input in the single select will remove both the input value and the value, when it is false it will only remove the input value.

helperText
type helperText = string;

This text will be displayed in the menu above the items

placeholder (default: searchable ? 'Type to search' : 'Select item')
type placeholder = string;

Text to display in the text input when it is empty

freeform
type freeform = {
label?: string;
handleAction: ({ inputValue: string; selectedItem: (item: Item )}) => void
}

An object with the properties label and handleAction. If defined, the freeform fixture will be appended to the end of the menu and when interacted with the handleAction function will be run.

filter (default: (item, { inputValue, itemToString }) => itemToString(item).toLowerCase().includes(inputValue.toLowerCase()))
type Helpers = {
inputValue: string;
itemToString: UseComboboxProps['itemToString'];
};
type filter = (item: NormalisedItem, helpers: Helpers) => boolean;

The filter will be run against every item that is passed to the select, if this function returns true then that item will be added to the menu.

Option (default: SimpleOption)

This component will be rendered in the menu once for each item. The option will be passed some important props:

  • item: The current item
  • index: The index of the item within the list
  • styles & className: There are some important styles that need to be applied to each option for the item virtualisation to work

Everything else is related to functionality or accessibility and should just be spread onto the option container.

This package exports some pre-built Option components (detailed below) to be used in specific circumstances to achieve some specific UI/UX concerns.

This component can be fully customised and some helper components are exported from this package to make this easier (HoverContainer & OptionContainer).

ToggleButton (default: SimpleToggleButton)

The button that is rendered that when interacted with will open the menu.

SimpleOption

This component can be passed to the Select components Option prop. If it is passed, the normaliser will have to return the SimpleItem type.

type SimpleItem = {
id: string;
label: string;
};

RecordOption

This component can be passed to the Select components Option prop. If it is passed, the normaliser will have to return the RecordItem type.

type RecordItem = {
label: string;
description?: string;
avatar?: ReactNode;
Icon?: ComponentType<IconProps>;
};

HoverContainer

This component gives custom select options an easy way to implement the correct highlight behaviour. The Option props (except for item) should be passed to this component.

OptionContainer

This component gives custom select options an easy way to implement consistent highlighting and selection behaviour. It implements the HoverContainer and the correct styling and components for the selected state of an item. The Option props should be passed to this component.

HighlightedLabel

This component implements text highlighting based on the input value that the items are currently being filtered by for the children passed to it. The children of this component are required to be a string

Data Flow

External Hooks

To manage the data layer and supplementary behaviours the select employs the concept of "external hooks". These are hooks that are kept inside this package as they relate specifically to select behaviour, however they are intended to be used by the implementor to add functionality to the base select.

All external hooks return an object containing one function, getSelectProps which is used to spread the props handled by the hook onto the select component. The reason a function is returned is so that it can be passed an onStateChange function which is then composed with the one returned by the getSelectProps function. This ensures that our external hook functionality itself can be composed, so if using multiple external hooks gether you will have to nest their calls to getSelectProps, e.g.

function CustomDropdown(props) {
// Incomplete examples for demonstration
const { getSelectProps: useItemsSelectProps } = useItems({ getItems });
const { getSelectProps: useFreeformDialogSelectProps } = useFreeformDialog({
Dialog
});

// The order doesn't matter
return <Select {...useItemsSelectProps(useFreeformDialogSelectProps())} />;
}

useItems

This hook deals with fetching data for the select.

import React, { useCallback } from 'react';

import { useItems, Select } from '@rexlabs/select';

export function CustomSelect({ id, ...props }) {
const [customHelperText, setCustomHelperText] = useState(null);

// Items will be re-fetched when this function
// changes so it must be memoised
const getItems = useCallback(
async (inputValue, { signal }) => {
const response = await fetch(
`myApi/endpoint/${id}?searchTerm=${inputValue}`,
{ signal }
);
return response.json();
},
[id]
);

// Same for suggested items
const getSuggestedItems = useCallback(
async ({ signal }) => {
const response = await fetch(`myApi/endpoint/${id}/suggested`, {
signal
});
return response.json();
},
[id]
);

const onStateChange = useCallback((changes) => {
const { inputValue } = changes;

if (inputValue === '') {
setCustomHelperText('Input is empty!');
} else {
// Otherwise use the default select implementation
setCustomHelperText(null);
}
}, []);

const { getSelectProps } = useItems({
getItems,
getSuggestedItems
});

return (
<Select
{...getSelectProps({
// Other unrelated select props can be passed
// and will just be sent straight through
searchable: false,
onStateChange
})}
// Opt out of helper text handled within the `useItems` hook
helperText={customHelperText}
/>
);
}

Args

minChars (default: 2)
type minChars = number;

The minimum characters that are required to perform a search.

debounce (default: 200)
type debounce = number;

The delay, in milliseconds, between the user pressing a key and the request being made

getItems
type getItems = (
inputValue: string,
{ signal: AbortSignal }
) => Item[] | Promise<Item[]>;

Function that takes the input value and helpers and returns an array of items or a promise that resolves to an array of items

The helpers contain an AbortSignal that will be cancelled when the request is discarded internally.

getSuggestedItems
type getSuggestedItems = ({ signal: AbortSignal }) => Item[] | Promise<Item[]>;

Function that takes the helpers and returns an array of items or a promise that resolves to an array of items

The helpers contain an AbortSignal that will be cancelled when the request is discarded internally.

useValueList

This hook also deals with fetching data for the select, however instead of throwing away the results it keeps an internal cache that will be cleared after a specified timeout. This hook will always fetch items when the select is opened for the first time (and whenever the cache is cleared). Items are shared between selects that implement the same key.

The idea behind this hook is to have a way to fetch items for a particular type of select only once and have the results shared between selects. This reduces the number of API calls that need to be made.

One caveat of this strategy is that the items can become stale if the time to live is too long, so this hook should mostly be used for data that is either static, or is very rarely updated.

Args

ttl (default: 300000)
type ttl = number;

The time to live for the items within the cache, items will be invalidated after this timeout.

cacheKey (default: uuid())
type cacheKey = string;

The cache key is used to connect multiple instances of this hook together so that they can share data. This way if you render two instances of the same selet on the page only one of them has to fetch the data.

getItems
type getItems = ({ signal: AbortSignal }) => Item[] | Promise<Item[]>;

Function that takes the helpers and returns an array of items or a promise that resolves to an array of items

The helpers contain an AbortSignal that will be cancelled when the request is discarded internally.

useFreeformDialog

This hook is a convenience for the pattern of launching a dialog from the freeform fixture.

Requires a <DialogProvider /> to be present in the application for this hook to work properly.

import { Dialog } from '@rexlabs/dialog';

function CustomDialog({
inputValue,
selectItem,
onClose
}: FreeformDialogProps<any>) {
return (
<Dialog title='Select freeform example' onClose={onClose}>
The select input value is {inputValue}
<button
onClick={() => {
selectItem({ id: inputValue, name: inputValue });
onClose?.();
}}
>
Select custom item
</button>
</Dialog>
);
}

function FreeformDialogSelect(props) {
const items = ['Item 1', 'Item 2', 'Item 3'];
const { getSelectProps } = useFreeformDialog({ Dialog });
return <Select {...props} {...getSelectProps()} items={items} />;
}

Args

Dialog

Custom dialog implementation that is passed the inputValue and the selectItem function as props.

Migration Instructions

Version 3.x -> 4.x

This major version is a complete rewrite and almost everything about how this component is used has changed. The API should be more streamlined and simpler to understand while still allowing for more customisation of core functionality through the use of custom hooks.

The Dropdown and Autocomplete components have been removed, there is now only a single main component export from this package, Select. This component, in conjunction with the external hooks, useItems and useValueList, can be used to implement all of the functionality that used to be handled by these two components.

const items = ['Item 1', 'Item 2', 'Item 3'];

// An equivalent `Dropdown`
function CustomDropdown(props) {
return <Select searchable={false} {...props} items={items} />;
}
async function getItems(inputValue) {
const data = await fetch(`some/api/endpoint?search=${inputValue}`)
.then((res) => res.json())
.then((data) => data);
return data;
}

function normaliser(item) {
return {
id: item.id,
label: item.name
};
}

// An equivalent `Autocomplete`
function CustomAutocomplete(props) {
const { getSelectProps } = useItems({
getItems
});

return <Select {...props} {...getSelectProps()} normaliser={normaliser} />;
}

The normaliser pattern has stayed mostly the same between versions, the difference is the shape that the select expects to be returned by default. The concept is the same, how the Option component is implemented directly impacts what the normaliser should return. The item prop given to the Option component is the item that is returned by the normaliser, so whatever properties it uses to display whatever it needs to is what needs to be returned.

One caveat to this is that the normaliser is now required to at least return an id property - this is used for equality comparisons to figure out if the item is the same for things like highlighting selected items. The actual property used can be changed, but must be unique among the items and then a custom getItemId function must be implemented to point to that new property.

// Old normaliser - default
function normaliser(item) {
return {
value: item.id,
label: item.name
};
}

// New normaliser - SimpleOption (default)
function normaliser(item) {
return {
id: item.id,
label: item.name
};
}

// New normaliser - RecordOption
function normaliser(item) {
return {
id: item.id,
label: item.name,
description: item.address, // Not required
avatar: <Avatar size='s' name={item.name} />, // Not required
Icon: PropertyIcon // Only shown if `avatar` is undefined
};
}

The denormaliser is no longer required, the value that is passed back to the form is always the original item, pre-normalisation.

async function getItems(inputValue) {
const data = await fetch(`some/api/endpoint?search=${inputValue}`)
.then((res) => res.json())
.then((data) => data);
return data;
}

async function getSuggestedItems() {
const data = await fetch('some/api/endpoint/suggested')
.then((res) => res.json())
.then((data) => data);
}

function normaliser(item) {
return {
id: item.id,
label: item.name
};
}

// `Autocomplete` with suggested items
function CustomAutocompleteWithSuggestedItems(props) {
const { getSelectProps } = useItems({
getItems,
getSuggestedItems
});

return <Select {...props} {...getSelectProps()} normaliser={normaliser} />;
}

Adding freeform values has changed, the shouldAddFreeformValue and freeformValueNormaliser props have been removed and instead the freeform fixture will be displayed when the freeform prop is defined.

const items = ['Item 1', 'Item 2', 'Item 3'];

function CustomDropdown(props) {
return (
<Select
{...props}
items={items}
freeform={{
label: 'Add',
handleAction: ({ inputValue, selectItem }) => {
// The argument passed to the `selectItem` function is
// run through the normaliser so the `freeformValueNormaliser`
// function is redundant in this case
selectItem(inputValue);
}
}}
/>
);
}

There is a common pattern when using these selects where instead of performing an action when clicking the freeform fixture, a dialog needs to open so a record can be created before being added as a value into the select. This has been made much easier with the addition of the useFreeformDialog hook (example above).

The filter API has changed, instead of returning the entire filtered list, this function now runs once for every element, returning true will allow the item to be shown.

// Old filter
function oldFilter(items, inputValue, selectedItems) {
// Return items that aren't already selected and match
// some part of the input value
return items.map(
(item) =>
// We have to know which property to use to compare strings here
item.label.contains(inputValue) &&
!selectedItems.find((selectedItem) => item === selectedItem)
);
}

function newFilter(item, { inputValue, selectedItems, itemToString }) {
// Return items that aren't already selected and match
// some part of the input value
return (
// Use the `itemToString` function so that we can define
// how the item becomes a string in one place
itemToString(item).contains(inputValue) &&
!selectedItems.find((selectedItem) => item === selectedItem)
);
}

Copyright © 2022 Rex Software All Rights Reserved.