
Security News
Attackers Are Hunting High-Impact Node.js Maintainers in a Coordinated Social Engineering Campaign
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.
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:
autoFocusbooleanfalsetrue the search input automatically receives focusdefaultListbox prop is supplied, setting autoFocus to true causes the default listbox to be automatically opened.cancelButtonbooleanfalsetrue 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 required
in order to exit the focused state of the search box.cancelButtonAriaLabelstring"Cancel"aria-label attribute on the cancel button element.clearButtonbooleanfalsetrue 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;
}
clearButtonAriaLabelstring"Clear contents"aria-label attribute on the clear button element.debounceWaitnumber2500 if you want no wait at all (e.g. if your listbox data is not fetched asynchronously)defaultListboxarray or object or functionundefined[
{
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 listboxdefaultListboxIsImmutablebooleantruetrue the contents of the default listbox are considered to be immutable, i.e. they never change between queries.false.disabledbooleanfalsetrue the search box has an HTML disabled attribute set and cannot be interacted with by the user.enterKeyHintstringundefinedenterkeyhint HTML attribute of the search box <input> element."enter", "done", "go", "next", "previous", "search", "send"errorMessagestringundefinedidstring"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.listboxarray 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 that
the function will return an array that is already correctly filtered.If 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
datadisplayFieldsearchType
See above for explanations of each field.(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'
}
]
})
listboxIsImmutablebooleantruetrue the contents of the listbox are considered to be immutable, i.e. they never change between queries.false.matchTextbooleanfalsetrue 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.maxItemsnumber10ratio setting in the listbox and defaultListbox props.minQueryLengthnumber (must be greater than 0)1minQueryLength is equalled or exceeded, no listbox is displayed.namestringundefinedname attribute applied to the search box.noItemsMessagestringundefinedonBlurfunctionundefinedblur event triggers on the search box.onBlur callbackonChangefunctionundefinedchange event triggers on the search box.onChange function:
query (string) The current text value of the search boxonEnterfunctionundefinedonEnter 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.onFocusfunctionundefinedfocus event triggers on the search box.onFocus callbackonSelectfunctionundefinedonSelect 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 selectedonTabfunctionundefinedonTab 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.placeholderstring"" (empty string)placeholder attribute applied to the search box.pluginsarrayundefined['plugin1', 'plugin2']
[
['plugin1', { option1: true, option2: 'foo' }],
'plugin2'
]
stylesobjectundefinedclass 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.tabIndexnumberundefinedtabindex attribute applied to the search box.textstringundefinedonSelect to fire automatically if there is a matching resulttypeaheadstringtruetrue 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.Itemundefined<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.GroupNameundefined<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 publishMIT © tomsouthall
FAQs
React customisable autocomplete component with typeahead and grouped results from multiple APIs.
We found that turnstone demonstrated a not healthy version release cadence and project activity because the last version was released a year ago. It has 1 open source maintainer collaborating on the project.
Did you know?

Socket for GitHub automatically highlights issues in each pull request and monitors the health of all your open source dependencies. Discover the contents of your packages and block harmful activity before you install or update your dependencies.

Security News
Multiple high-impact npm maintainers confirm they have been targeted in the same social engineering campaign that compromised Axios.

Security News
Axios compromise traced to social engineering, showing how attacks on maintainers can bypass controls and expose the broader software supply chain.

Security News
Node.js has paused its bug bounty program after funding ended, removing payouts for vulnerability reports but keeping its security process unchanged.