Install Turnstone via npm
mainTo use Turnstone in your React project, install the package using npm:
$ npm install --save turnstonerepository·main·Indexed 41 days ago
https://github.com/tomsouthall/turnstoneA highly customizable, lightweight autocomplete search component for React (v2.2.0). It supports grouped results from multiple APIs, weighted display ratios, typeahead autosuggest, and built-in WAI-ARIA accessibility. Features include a flexible `listbox` prop for data fetching, custom styling via CSS classes, and a `useData` hook for managing search state.
To use Turnstone in your React project, install the package using npm:
$ npm install --save turnstoneThe styles prop accepts an object where keys represent Turnstone elements and values are CSS class strings (e.g., Tailwind classes).
Available keys:
container: The outer wrapper. Use containerFocus for styles when the input is focused.input / inputFocus: Styles for the search box. inputFocus is mutually exclusive with input.query: Styles for the search box input text only.typeahead: Styles for the typeahead input element.cancelButton / clearButton: Styles for the action buttons.listbox: The container for results.noItems: The container for the empty state message.errorbox / errorMessage: Styles for error states.groupHeading: The heading for a result group.item: The individual result item.highlightedItem: Styles for the item currently under hover or keyboard focus.match: The <strong> element wrapping matched text.Example using Tailwind CSS:
styles={{
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',
listbox: 'w-full bg-white sm:border sm:border-blue-300 sm:rounded text-left sm:mt-2 p-2 sm:drop-shadow-xl',
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'
}}{
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',
listbox: 'w-full bg-white sm:border sm:border-blue-300 sm:rounded text-left sm:mt-2 p-2 sm:drop-shadow-xl',
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'
}The listbox parameter passed to useData is an array of group objects. Each group defines how its specific data is retrieved and filtered.
data: Either an array of items or a function (query) => Promise<Array> that fetches data based on the query.displayField: The key in the data items used to extract the display text.searchType: Determines the filtering logic applied to the group:'startswith': Matches items where the display text starts with the query.'contains': Matches items where the display text includes the query.searchType is omitted or unrecognized.id: A unique identifier for the group.name: The name of the group.ratio: A numeric value used to determine how many items from this group are included when maxItems is reached (used in limitResults).When useData returns data, each item is transformed into a standardized object:
{
"value": "original_item_value",
"text": "display_text",
"groupIndex": 0,
"groupId": "group_id",
"groupName": "group_name",
"searchType": "startswith",
"displayField": "field_name",
"defaultListbox": true
}When maxItems is specified, useData uses a ratio-based algorithm to distribute the available slots among the provided groups.
ratio values across all groups (defaulting to 1 if ratio is not provided).ratioMultiplier based on maxItems / totalRatio.maxItems is met or all data is exhausted.For a simple, unstyled autocomplete search box, provide a listbox object containing a data array to the Turnstone component.
import React from 'react'
import Turnstone from 'turnstone'
const App = () => {
const listbox = {
data: ['Peach', 'Pear', 'Pineapple', 'Plum', 'Pomegranate', 'Prune']
}
return (
<Turnstone listbox={listbox} />
)
}Turnstone supports complex search configurations, including:
listbox. Each object defines a group with its own id, name, ratio (weighted display ratio), displayField, data (a function that fetches data based on the query), and searchType (startswith or contains).styles object to customize various internal elements like input, listbox, groupHeading, item, etc.debounceWait: Number of milliseconds to wait before fetching data.maxItems: Maximum number of items to display.typeahead: Boolean to enable/disable typeahead autosuggest text.cancelButton: Boolean to enable/disable the cancel button.noItemsMessage: String displayed when no results are found.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}
/>
)
}The <Turnstone> component accepts several props to control its behavior, appearance, and data fetching.
listbox (Required): Defines how results are populated. Can be an array of groups, a single object for ungrouped items, or a function that returns a promise resolving to an array of groups.defaultListbox: Displays items when the search box is focused but empty. Supports the same formats as listbox (array, object, or function).minQueryLength: Minimum characters required before fetching results (default: 1).debounceWait: Milliseconds to wait after typing before fetching (default: 250). Set to 0 for no wait.maxItems: Maximum total items displayed across all groups (default: 10).matchText: If true, wraps matching text in a <strong> element.typeahead: If true, shows typeahead text matching the highlighted item (default: true).autoFocus: Automatically focuses the input (default: false).cancelButton: Renders a cancel button when focused (default: false).clearButton: Renders a clear button when text is entered (default: false).disabled: Disables interaction (default: false).placeholder: Sets the HTML placeholder attribute.id: Sets the container id. Note: In Next.js, you must provide an explicit id to avoid hydration mismatches.errorMessage: Generic message shown if a fetch error occurs.noItemsMessage: Message shown when no results match the query.onChange(query: string): Called when the search text changes.onSelect(selectedItem: any, displayField: any): Called when an item is selected. Arguments are undefined when an item is deselected.onEnter(query: string, selectedItem: any): Called when Enter is pressed.onFocus() / onBlur() / onTab(query: string, selectedItem: any): Standard focus/tab event callbacks.The listbox prop is the primary way to provide data to Turnstone. You can supply it in three ways:
Use an array of objects to create categorized results. Each group object includes:
name (Required): The group heading.data (Required): An array of items or a function(query) that returns a Promise resolving to an array.displayField: The field in the item used for matching and display.searchType: Either "startswith" or "contains".ratio: A number used to distribute the maxItems total across groups.id: A unique identifier for the group.[
{
id: 'cities',
name: 'Cities',
ratio: 8,
displayField: 'name',
data: (query) => fetch(`/api/cities?q=${query}`).then(res => res.json()),
searchType: 'startswith'
},
{
id: 'airports',
name: 'Airports',
ratio: 2,
displayField: 'name',
data: ['JFK', 'LAX', 'LHR'],
searchType: 'contains'
}
]Use a single object for a simple, flat list of results.
{
displayField: 'name',
data: (query) => fetch(`/api/cities?q=${query}`).then(res => res.json()),
searchType: 'startswith'
}Use a function to return a promise that resolves to an array of group objects (as described in the Array section). This is useful for GraphQL or complex backend responses.
[
{
id: 'cities',
name: 'Cities',
ratio: 8,
displayField: 'name',
data: (query) => fetch(`/api/cities?q=${query}`).then(res => res.json()),
searchType: 'startswith'
},
{
id: 'airports',
name: 'Airports',
ratio: 2,
displayField: 'name',
data: ['JFK', 'LAX', 'LHR'],
searchType: 'contains'
}
]You can interact with the Turnstone component programmatically by using a ref.
Available methods:
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 provided string.select(): Selects the current contents of the search box.import React, { useRef } from 'react'
import Turnstone from 'turnstone'
const App = () => {
const turnstoneRef = useRef()
const handleQuery = () => {
turnstoneRef.current?.query('new')
}
const handleClear = () => {
turnstoneRef.current?.clear()
}
return (
<>
<Turnstone ref={turnstoneRef} listbox={{ data: [] }} />
<button onClick={handleQuery}>Perform Query</button>
<button onClick={handleClear}>Clear Contents</button>
</>
)
}import React, { useRef } from 'react'
import Turnstone from 'turnstone'
const App = () => {
const turnstoneRef = useRef()
const handleQuery = () => {
turnstoneRef.current?.query('new')
}
const handleClear = () => {
turnstoneRef.current?.clear()
}
return (
<>
<Turnstone ref={turnstoneRef} listbox={{ data: [] }} />
<button onClick={handleQuery}>Perform Query</button>
<button onClick={handleClear}>Clear Contents</button>
</>
)
}The useData hook manages search results and listbox state using SWR. It handles query-based fetching, data filtering, and result limiting based on provided listbox configurations.
query: The current search string.isImmutable: Boolean. If true, disables revalidation on focus, reconnect, or stale data.listbox: An array of group configurations defining how data is fetched and displayed.defaultListbox: The data/configuration to show when the query is empty.minQueryLength: The minimum number of characters required in the query before fetching begins (unless defaultListbox is used).maxItems: The maximum number of total items to return across all groups.Returns the object provided by useSWR, which includes data (the flattened array of processed items), error, and isLoading.
const { data, error, isLoading } = useData(
query,
false,
listbox,
defaultListbox,
3,
10
);The Turnstone component is the main entrypoint for the library. It provides a searchable input with a listbox for selecting items. It uses a StateContextProvider to manage internal state and supports a plugin system for extending functionality.
By default, it includes typeahead functionality, a debounce wait of 250ms, and a maximum of 10 items in the listbox. You can pass a ref to the component to access the underlying DOM element.
import Turnstone from 'turnstone';
// Basic usage
<Turnstone
listbox={[
{ name: 'Option 1', data: 'val1' },
{ name: 'Option 2', data: 'val2' }
]}
onSelect={(item) => console.log(item)}
/>The listbox prop (and defaultListbox) defines the selectable items. The data structure follows these rules:
name (string).'startswith', 'contains'.Example object structure:
{
"name": "Item Name",
"data": "item_value",
"searchType": "contains",
"displayField": "name"
}