React customisable autocomplete component with typeahead and grouped results from multiple APIs.
Turnstone is a highly customisable, easy-to-use autocomplete search component for React.
View Demos | Demo #1 | Demo #2 | Demo #3 (Basic)
Play with Turnstone at CodeSandbox
onSelect
, onChange
, onTab
, onEnter
and more…$ npm install --save turnstone
import React from 'react'
import Turnstone from 'turnstone'
const App = () => {
const listbox = {
data: ['Peach', 'Pear', 'Pineapple', 'Plum', 'Pomegranate', 'Prune']
}
return (
<Turnstone listbox={listbox} />
)
}
import React, { useState } from 'react'
import Turnstone from 'turnstone'
const styles = {
input,
inputFocus,
query,
typeahead,
cancelButton,
clearButton,
listbox,
groupHeading,
item,
highlightedItem
}
const maxItems = 10
const listbox = [
{
id: 'cities',
name: 'Cities',
ratio: 8,
displayField: 'name',
data: (query) =>
fetch(`/api/cities?q=${encodeURIComponent(query)}&limit=${maxItems}`)
.then(response => response.json()),
searchType: 'startswith'
},
{
id: 'airports',
name: 'Airports',
ratio: 2,
displayField: 'name',
data: (query) =>
fetch(`/api/airports?q=${encodeURIComponent(query)}&limit=${maxItems}`)
.then(response => response.json()),
searchType: 'contains'
}
]
export default function Example() {
return (
<Turnstone
cancelButton={true}
debounceWait={250}
id="search"
listbox={listbox}
listboxIsImmutable={true}
matchText={true}
maxItems={maxItems}
name="search"
noItemsMessage="We found no places that match your search"
placeholder="Enter a city or airport"
styles={styles}
typeahead={true}
/>
)
}
This is an example of markup produced by the component, in this case with the
text New
entered into the search box.
<div class="container" role="combobox" aria-expanded="true" aria-owns="search-listbox" aria-haspopup="listbox">
<input type="text" id="search" name="search" class="input query" style="position:relative;z-index:1;background-color:transparent" placeholder="Enter a city or airport" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" aria-autocomplete="both" aria-controls="search-listbox">
<input type="text" class="input typeahead" style="position:absolute;z-index:0;top:0;left:0" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" tabindex="-1" readonly="" aria-hidden="true">
<button class="clearButton" tabindex="-1" aria-label="Clear contents" style="z-index: 2;">×</button>
<button class="cancelButton" tabindex="-1" aria-label="Cancel" style="z-index: 3;">Cancel</button>
<div id="search-listbox" class="listbox" role="listbox" style="position: absolute; z-index: 4;">
<div class="groupHeading">Cities</div>
<div class="highlightedItem" role="option" aria-selected="true" aria-label="New York City, New York, United States"><strong>New</strong> York City, New York, United States</div>
<div class="item" role="option" aria-selected="false" aria-label="New South Memphis, Tennessee, United States"><strong>New</strong> South Memphis, Tennessee, United States</div>
<div class="item" role="option" aria-selected="false" aria-label="New Kingston, Jamaica"><strong>New</strong> Kingston, Jamaica</div>
<div class="item" role="option" aria-selected="false" aria-label="Newcastle, South Africa"><strong>New</strong>castle, South Africa</div>
<div class="item" role="option" aria-selected="false" aria-label="New Orleans, Louisiana, United States"><strong>New</strong> Orleans, Louisiana, United States</div>
<div class="item" role="option" aria-selected="false" aria-label="New Delhi, India"><strong>New</strong> Delhi, India</div>
<div class="item" role="option" aria-selected="false" aria-label="Newcastle, Australia"><strong>New</strong>castle, Australia</div>
<div class="item" role="option" aria-selected="false" aria-label="Newport, Wales"><strong>New</strong>port, Wales</div>
<div class="groupHeading">Airports</div>
<div class="item" role="option" aria-selected="false" aria-label="John F Kennedy Intl (JFK), New York, United States"item>John F Kennedy Intl (JFK), <strong>New</strong> York, United States</div>
<div class="item" role="option" aria-selected="false" aria-label="Newark Liberty Intl (EWR), Newark, United States"><strong>New</strong>ark Liberty Intl (EWR), <strong>New</strong>ark, United States</div>
</div>
</div>
The following props can be supplied to the <Turnstone>
component:
autoFocus
boolean
false
true
the search input automatically receives focusdefaultListbox
prop is supplied, setting autoFocus
to true causes the default listbox to be automatically opened.cancelButton
boolean
false
true
a cancel button is rendered. The cancel button is displayed only when the search box receives focus. It is particularly useful for mobile screen sizes where a “back” button is requiredcancelButtonAriaLabel
string
"Cancel"
aria-label
attribute on the cancel button element.clearButton
boolean
false
true
a clear button is rendered whenever the user has entered at least one character into the search box..clearButton {
display: block;
width: 2rem;
right: 0px;
top: 0px;
bottom: 0px;
position: absolute;
color: #a8a8a8;
cursor: pointer;
border: none;
background: transparent;
padding:0;
}
clearButtonAriaLabel
string
"Clear contents"
aria-label
attribute on the clear button element.debounceWait
number
250
0
if you want no wait at all (e.g. if your listbox data is not fetched asynchronously)defaultListbox
array
or object
or function
undefined
[
{
name: 'Recent Searches',
displayField: 'name',
data: () => Promise.resolve(JSON.parse(localStorage.getItem('recent')) || []),
id: 'recent',
ratio: 1
},
{
name: 'Popular Cities',
displayField: 'name',
data: [
{ name: 'Paris, France', coords: '48.86425, 2.29416' },
{ name: 'Rome, Italy', coords: '41.89205, 12.49209' },
{ name: 'Orlando, Florida, United States', coords: '28.53781, -81.38592' },
{ name: 'London, England', coords: '51.50420, -0.12426' },
{ name: 'Barcelona, Spain', coords: '41.40629, 2.17555' },
{ name: 'New Orleans, Louisiana, United States', coords: '29.95465,-90.07507' },
{ name: 'Chicago, Illinois, United States', coords: '41.85003,-87.65005' },
{ name: 'Manchester, England', coords: '53.48095,-2.23743' }
],
id: 'popular',
ratio: 1
}
]
{
displayField: 'name',
data: () => fetch(`/api/cities/popular`).then(res => res.json()),
}
(query) => fetch(`/api/default-locations`)
.then(res => res.json())
.then(locations => {
const {recentSearches, popularCities} = locations
return [
{
name: 'Recent Searches',
displayField: 'name',
data: recentSearches,
id: 'recent',
ratio: 1
},
{
name: 'Popular Cities',
displayField: 'name',
data: popularCities,
id: 'popular',
ratio: 1
}
]
})
defaultListbox
and listbox
defaultListboxIsImmutable
boolean
true
true
the contents of the default listbox are considered to be immutable, i.e. they never change between queries.false
.disabled
boolean
false
true
the search box has an HTML disabled
attribute set and cannot be interacted with by the user.enterKeyHint
string
undefined
enterkeyhint
HTML attribute of the search box <input>
element."enter"
, "done"
, "go"
, "next"
, "previous"
, "search"
, "send"
errorMessage
string
undefined
id
string
"turnstone-7iq5g"
id
attribute applied to the container <div>
element.id
attribute of the listbox element e.g. "<id>-listbox"
and the corresponding aria-owns
attribute of the container element.id
as randomly generated ids cause discrepancies between server side and client side rendering.listbox
array
or object
or function
[
{
id: 'cities',
name: 'Cities',
ratio: 8,
displayField: 'name',
data: (query) =>
fetch(`/api/cities?q=${encodeURIComponent(query)}&limit=10`)
.then(res => res.json()),
searchType: 'startswith'
},
{
id: 'airports',
name: 'Airports',
ratio: 2,
displayField: 'name',
data: (query) =>
fetch(`/api/airports?q=${encodeURIComponent(query)}&limit=10`)
.then(res => res.json()),
searchType: 'contains'
}
]
Each object representing a group can include the following properties:
data
(function or array) required
If a function
Promise
that resolves to an array of items.query
argument which is a string containing the text entered into the search box. The function would then typically perform a fetch to an API endpoint for matching items and finally formats the data received as required.maxItems
prop, in case all of the other groups return zero matches.data
props supplied as functions.searchType
. The presumption is thatIf an array
searchType
(see below).displayField
(string or number or undefined)
displayField
must be a string or number.displayField
can be omitted.searchType
(string)
"startswith"
or "contains"
.data
prop is an array of items, Turnstone reduces the array down to items whose displayField
either starts with or contains the current query.searchType
is also used to match item text and wrap it in a <strong>
element, but only if the matchText
prop is set to true
. For startswith
, only text at the start of the displayField
is wrapped. For contains
, any matching text in the displayField
is wrapped.ratio
(number)
maxItems
prop governs the number of items that are displayed in total across all groups in the listbox. However, ratio
determines how many items are displayed within each group versus the other groups.maxItems
is set to 10
. For Group A we set ratio: 6
, for Group B ratio: 3
and for Group C ratio: 1
. Note that these three numbers add up to our total of 10
(note that they don’t have to and Turnstone will still calculate everything correctly, but it is much simpler if they do). This does not of course guarantee that we will see 6 items in Group A, 3 in Group B and 1 in Group C. There may not be enough matching items for this to be possible. So Turnstone will do its best to match the supplied ratio, but if it cannot it will make up the shortfall by including more items from other groups to match the total of 10 wherever possible. Only if across all the groups there are fewer items to display than 10 do we see fewer in the listbox.name
(string) required
id
(string)
Item
and GroupName
props and is useful for styling groups differently based on id
.{
displayField: 'name',
data: (query) =>
fetch(`/api/cities?q=${encodeURIComponent(query)}&limit=10`)
.then(res => res.json()),
searchType: 'startswith'
}
An object can only include the following fields
data
displayField
searchType
(query) => fetch(`/api/locations?q=${encodeURIComponent(query)}`)
.then(res => res.json())
.then(locations => {
const {cities, airports} = locations
return [
{
id: 'cities',
name: 'Cities',
ratio: 8,
displayField: 'name',
data: cities,
searchType: 'startswith'
},
{
id: 'airports',
name: 'Airports',
ratio: 2,
displayField: 'name',
data: airports,
searchType: 'contains'
}
]
})
listboxIsImmutable
boolean
true
true
the contents of the listbox are considered to be immutable, i.e. they never change between queries.false
.matchText
boolean
false
true
any text in listbox items that matches the user’s current search query is wrapped in a <strong>
element.searchType
for the item in question is startswith
only matching text at the start of the item text is wrapped. If the searchType
is contains
, any matching text in the item is wrapped.maxItems
number
10
ratio
setting in the listbox
and defaultListbox
props.minQueryLength
number
(must be greater than 0
)1
minQueryLength
is equalled or exceeded, no listbox is displayed.name
string
undefined
name
attribute applied to the search box.noItemsMessage
string
undefined
onBlur
function
undefined
blur
event triggers on the search box.onBlur
callbackonChange
function
undefined
change
event triggers on the search box.onChange
function:
query
(string) The current text value of the search boxonEnter
function
undefined
onEnter
function:
query
(string) The current text value of the search boxselectedItem
The item selected by the user. This is in the same format as received from listbox.data
.onFocus
function
undefined
focus
event triggers on the search box.onFocus
callbackonSelect
function
undefined
onSelect
function:
selectedItem
The item selected by the user. This is in the same format as received from listbox.data
.displayField
(string / number / undefined) The field in selectedItem
that contains the text displayed in the listbox. If selectedItem
is not an array or an object, displayField
is undefined
.undefined
arguments to indicate when an item is no longer selectedonTab
function
undefined
onTab
function:
query
(string) The current text value of the search boxselectedItem
The item selected by the user. This is in the same format as received from listbox.data
.placeholder
string
""
(empty string)placeholder
attribute applied to the search box.plugins
array
undefined
['plugin1', 'plugin2']
[
['plugin1', { option1: true, option2: 'foo' }],
'plugin2'
]
styles
object
undefined
class
attribute for the element.{
input: 'w-full h-12 border border-slate-300 py-2 pl-10 pr-7 text-xl outline-none rounded',
inputFocus: 'w-full h-12 border-x-0 border-t-0 border-b border-blue-300 py-2 pl-10 pr-7 text-xl outline-none sm:rounded sm:border',
query: 'text-slate-800 placeholder-slate-400',
typeahead: 'text-blue-300 border-white',
cancelButton: `absolute w-10 h-12 inset-y-0 left-0 items-center justify-center z-10 text-blue-400 inline-flex sm:hidden`,
clearButton: 'absolute inset-y-0 right-0 w-8 inline-flex items-center justify-center text-slate-400 hover:text-rose-400',
listbox: 'w-full bg-white sm:border sm:border-blue-300 sm:rounded text-left sm:mt-2 p-2 sm:drop-shadow-xl',
groupHeading: 'cursor-default mt-2 mb-0.5 px-1.5 uppercase text-sm text-rose-300',
item: 'cursor-pointer p-1.5 text-lg overflow-ellipsis overflow-hidden text-slate-700',
highlightedItem: 'cursor-pointer p-1.5 text-lg overflow-ellipsis overflow-hidden text-slate-700 rounded bg-blue-50'
}
container
The outer container <div>
that wraps all other elements. If not present, the style of the container is set to position: relative; text-align: left;
. If you specify your own styles, ensure that the value of position
allows for absolute positioning within this element.containerFocus
** Note that container
and containerFocus
are mutually exclusive. Only one or the other applies depending on whether the search box <input>
has focus. If the styling of the outer container is to change when the search box receives focus, specify styles for containerFocus
. If nothing is specified for containerFocus
the styles for container
are applied whether or not the search box has focus.input
Applies to the search box <input>
element as well as the typeahead <input>
. As the typeahead is positioned directly beneath the search box, these must be styled almost identically.inputFocus
Applies to the search box <input>
element as well as the typeahead <input>
, only when the search box has focus. Note that input
and inputFocus
are mutually exclusive. Only one or the other applies depending on whether the search box <input>
has focus. If nothing is specified for inputFocus
the styles for input
are applied whether or not the search box has focus.query
For styles applying only to the search box <input>
element and not the typeahead element beneath. A valid example is example text colour. Note that this element already has the following styles applied which cannot be overridden:
position: relative; z-index: 1; background-color: transparent;
position: relative;
typeahead
For styles applying only to the typeahead <input>
element and not the search box element above. A valid example is example text colour. Note that this element already has the following styles applied which cannot be overridden: position: absolute; z-index: 0; top: 0; left: 0;
.cancelButton
A <button>
element. This is only rendered when the search box has focus. Note that this element already has the following styles applied which cannot be overridden: z-index: 3
. You may wish only to display this at mobile screen widths.clearButton
A <button>
element. This is only rendered when the search box contains text. Note that this element already has the following styles applied which cannot be overridden: z-index: 2
.listbox
A <div>
element that contains group headings and selectable items/options. This is rendered only when the search box contains text that produces matching listbox items. This also contains the noItems
element when no items match the user’s search query.noItems
A <div>
element that contains a message when there are no items matching the user’s search query. This is rendered inside the listbox element.errorbox
A <div>
element that contains an error message <div>
. This is rendered in place of the listbox only when the search produces an error.errorMessage
A <div>
element that contains the error notification text.groupHeading
A <div>
containing the heading for a group in the listbox. The contents are text by default but can also be customised using the GroupName
prop.item
A <div>
containing a listbox item. The contents are text by default but can also be customised using the Item
prop.highlightedItem
Note that item
and highlightedItem
are mutually exclusive. An item <div>
has the highlightedItem
styling applied when it is highlighted either via a mouseover
event or via use of the up and down arrow keys.match
The <strong>
element that wraps any item text that matches the text entered into the search box. A common approach is to invert the styling so that the matched text is at a normal font weight and the remaining text is displayed in bold.tabIndex
number
undefined
tabindex
attribute applied to the search box.text
string
undefined
onSelect
to fire automatically if there is a matching resulttypeahead
string
true
true
shows typeahead text as the user enters text into the search box. This matches the currently highlighted item in the listbox, so long as the item starts with the search box text.The following custom components can also be supplied as props:
Cancel
() => 'Cancel'
<button>
element. It receives no props.Clear
() => '\u00d7'
<button>
element. It receives no props.Item
undefined
<div>
element.Item
component gives you huge flexibility to format and style listbox items however you like. They can be as rich as required, containing images, icons, multiple fields, etc.Item
component receives the following props when rendered:
appearsInDefaultListbox
(boolean) If true
indicates that the item appears in the default listbox rather than the listbox. This allows default listbox items to be styled completely differently to listbox items if required.groupId
(string) The id
of the group supplied in the listbox
or defaultListbox
prop.groupIndex
(number) The index of the group. Matches the order supplied in the listbox
or defaultListbox
prop. Zero-indexed.groupName
(string) The name
of the group supplied in the listbox
or defaultListbox
prop.index
(number) The index of the item within the listbox. Zero-indexed. This allows you to style, say, the first item differently to all the rest in the listbox.isHighlighted
(boolean) If true
indicates that the item is currently in a highlighted stateitem
The item in the same format as supplied by the listbox
or defaultListbox
prop.query
The text currently entered in the search box. This can be used to show matched text in the item.searchType
(string) Either "startswith"
or "contains"
. Indicates how the item was matched to the query.setSelected
(function) If executed, sets the selected item to whatever is passed to the function. This allows sub-items to be displayed within an item. For example, you may wish to provide selectable neighbourhoods or attractions within a city item. The function receives two arguments:
value
(object / array / string) The value of the item to selectdisplayField
(string / number / undefined) The key or index of the field inside value
that represents the text to display in the search box once selected. If the value
argument is a string, this must be set to undefined
.totalItems
The total number of items currently displayed inside the listbox.GroupName
undefined
<div>
element.GroupName
component receives the following props when rendered:
children
(string) The name
of the group supplied in the listbox
or defaultListbox
prop.id
(string) The id
of the group supplied in the listbox
or defaultListbox
prop.index
(number) The index of the group. Matches the order supplied in the listbox
or defaultListbox
prop. Zero-indexed.There are a number of methods accessible via a ref supplied to the Turnstone component.
For example:
import React, { useRef } from 'react'
import Turnstone from 'turnstone'
import data from './data'
const App = () => {
const listbox = { data }
const turnstoneRef = useRef()
const handleQuery = () => {
turnstoneRef.current?.query('new')
}
const handleClear = () => {
turnstoneRef.current?.clear()
}
return (
<>
<Turnstone ref={turnstoneRef} listbox={listbox} />
<button onClick={handleQuery}>Perform Query</button>
<button onClick={handleClear}>Clear Contents</button>
</>
)
}
The methods are as follows:
blur()
Removes keyboard focus from the search box.
clear()
Clears the contents of the search box
focus()
Sets keyboard focus on the search box.
query(<string>)
Sets the search box contents to the string argument supplied to the function.
select()
Selects the contents of the search box
$ npm run dev
$ npm test
(or $ npm run watch
)$ git tag vN.N.N
$ git push --tags
$ npm run build
$ npm publish
MIT © tomsouthall