Turnstone

React customisable autocomplete component with typeahead and grouped result...

README

Turnstone - A React Search Component


Turnstone is a highly customisable, easy-to-use autocomplete search component for React.

- API
  - Props
  - Methods

Turnstone In Action




Turnstone in action

Features


- Lightweight React search box component
- Group search results from multiple APIs or other data sources with customisable headings
- Specify the maximum number of listbox options as well as weighted display ratios for each group
- Completely customise listbox options with your own React component. Add images, icons, additional sub-options, differing visual treatments by group or index and much more...
- Display typeahead autosuggest text beneath entered text
- Easily styled with various CSS methods including CSS Modules and Tailwind CSS
- Search input can be easily styled to attach to top of screen at mobile screen sizes with customisable cancel/back button to exit
- Multiple callbacks including: onSelect, onChange, onTab, onEnter and more...
- Built in WAI-ARIA accessibility
- Keyboard highlighting and selection using arrow, Tab and Enter keys
- Automated caching to reduce data fetches
- Debounce text entry to reduce data fetches
- Optional Clear button (customisable)
- Customisable placeholder text
- Add more functionality with plugins
- and much more...

Installation & Usage


  1. ``` sh
  2. $ npm install --save turnstone
  3. ```

Usage


Barebones unstyled example


  1. ``` js
  2. import React from 'react'
  3. import Turnstone from 'turnstone'

  4. const App = () => {
  5.   const listbox = {
  6.     data: ['Peach', 'Pear', 'Pineapple', 'Plum', 'Pomegranate', 'Prune']
  7.   }

  8.   return (
  9.     <Turnstone listbox={listbox} />
  10.   )
  11. }
  12. ```

Styled example with grouped results from two API sources


  1. ``` js
  2. import React, { useState } from 'react'
  3. import Turnstone from 'turnstone'

  4. const styles = {
  5.   input,
  6.   inputFocus,
  7.   query,
  8.   typeahead,
  9.   cancelButton,
  10.   clearButton,
  11.   listbox,
  12.   groupHeading,
  13.   item,
  14.   highlightedItem
  15. }

  16. const maxItems = 10

  17. const listbox = [
  18.   {
  19.     id: 'cities',
  20.     name: 'Cities',
  21.     ratio: 8,
  22.     displayField: 'name',
  23.     data: (query) =>
  24.       fetch(`/api/cities?q=${encodeURIComponent(query)}&limit=${maxItems}`)
  25.         .then(response => response.json()),
  26.     searchType: 'startswith'
  27.   },
  28.   {
  29.     id: 'airports',
  30.     name: 'Airports',
  31.     ratio: 2,
  32.     displayField: 'name',
  33.     data: (query) =>
  34.       fetch(`/api/airports?q=${encodeURIComponent(query)}&limit=${maxItems}`)
  35.         .then(response => response.json()),
  36.     searchType: 'contains'
  37.   }
  38. ]

  39. export default function Example() {
  40.   return (
  41.     <Turnstone
  42.       cancelButton={true}
  43.       debounceWait={250}
  44.       id="search"
  45.       listbox={listbox}
  46.       listboxIsImmutable={true}
  47.       matchText={true}
  48.       maxItems={maxItems}
  49.       name="search"
  50.       noItemsMessage="We found no places that match your search"
  51.       placeholder="Enter a city or airport"
  52.       styles={styles}
  53.       typeahead={true}
  54.     />
  55.   )
  56. }

  57. ```

Example Markup


This is an example of markup produced by the component, in this case with the
text New entered into the search box.

  1. ``` html
  2. <div class="container" role="combobox" aria-expanded="true" aria-owns="search-listbox" aria-haspopup="listbox">
  3.   <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">
  4.   <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">
  5.   <button class="clearButton" tabindex="-1" aria-label="Clear contents" style="z-index: 2;">×</button>
  6.   <button class="cancelButton" tabindex="-1" aria-label="Cancel" style="z-index: 3;">Cancel</button>
  7.   <div id="search-listbox" class="listbox" role="listbox" style="position: absolute; z-index: 4;">
  8.     <div class="groupHeading">Cities</div>
  9.     <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>
  10.     <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>
  11.     <div class="item" role="option" aria-selected="false" aria-label="New Kingston, Jamaica"><strong>New</strong> Kingston, Jamaica</div>
  12.     <div class="item" role="option" aria-selected="false" aria-label="Newcastle, South Africa"><strong>New</strong>castle, South Africa</div>
  13.     <div class="item" role="option" aria-selected="false" aria-label="New Orleans, Louisiana, United States"><strong>New</strong> Orleans, Louisiana, United States</div>
  14.     <div class="item" role="option" aria-selected="false" aria-label="New Delhi, India"><strong>New</strong> Delhi, India</div>
  15.     <div class="item" role="option" aria-selected="false" aria-label="Newcastle, Australia"><strong>New</strong>castle, Australia</div>
  16.     <div class="item" role="option" aria-selected="false" aria-label="Newport, Wales"><strong>New</strong>port, Wales</div>
  17.     <div class="groupHeading">Airports</div>
  18.     <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>
  19.     <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>
  20.   </div>
  21. </div>
  22. ```

API


Props


The following props can be supplied to the `` component:

autoFocus

- Type: boolean
- Default: false
- If true the search input automatically receives focus
- Note: If defaultListbox prop is supplied, setting autoFocus to true causes the default listbox to be automatically opened.

cancelButton

- Type: boolean
- Default: false
- If 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 required
in order to exit the focused state of the search box.

cancelButtonAriaLabel

- Type: string
- Default: "Cancel"
- The value of the aria-label attribute on the cancel button element.

clearButton

- Type: boolean
- Default: false
- If true a clear button is rendered whenever the user has entered at least one character into the search box.
- Clicking the clear button has the same effect as pressing the Esc key while entering text into the search box. The contents of the searchbox are cleared and focus is retained.
- Suggested styling for the clear button is to position it absolutely overlaying the right of the search box, for example:
  1. ```css
  2.   .clearButton {
  3.     display: block;
  4.     width: 2rem;
  5.     right: 0px;
  6.     top: 0px;
  7.     bottom: 0px;
  8.     position: absolute;
  9.     color: #a8a8a8;
  10.     cursor: pointer;
  11.     border: none;
  12.     background: transparent;
  13.     padding:0;
  14.   }
  15. ```

clearButtonAriaLabel

- Type: string
- Default: "Clear contents"
- The value of the aria-label attribute on the clear button element.

debounceWait

- Type: number
- Default: 250
- The wait time in milliseconds after the user finishes typing before the search query is sent to the fetch function.
- This reduces the number of API calls made by the fetch function
- Set to 0 if you want no wait at all (e.g. if your listbox data is not fetched asynchronously)

defaultListbox

- Type: array or object or function
- Default: undefined
- The default listbox is displayed when the search box has focus and is empty.
- Supply an array if you wish multiple groups of items to appear in the default listbox. Groups can
  be drawn from multiple sources. For example:
  1. ``` js
  2.   [
  3.     {
  4.       name: 'Recent Searches',
  5.       displayField: 'name',
  6.       data: () => Promise.resolve(JSON.parse(localStorage.getItem('recent')) || []),
  7.       id: 'recent',
  8.       ratio: 1
  9.     },
  10.     {
  11.       name: 'Popular Cities',
  12.       displayField: 'name',
  13.       data: [
  14.         { name: 'Paris, France', coords: '48.86425, 2.29416' },
  15.         { name: 'Rome, Italy', coords: '41.89205, 12.49209' },
  16.         { name: 'Orlando, Florida, United States', coords: '28.53781, -81.38592' },
  17.         { name: 'London, England', coords: '51.50420, -0.12426' },
  18.         { name: 'Barcelona, Spain', coords: '41.40629, 2.17555' },
  19.         { name: 'New Orleans, Louisiana, United States', coords: '29.95465,-90.07507' },
  20.         { name: 'Chicago, Illinois, United States', coords: '41.85003,-87.65005' },
  21.         { name: 'Manchester, England', coords: '53.48095,-2.23743' }
  22.       ],
  23.       id: 'popular',
  24.       ratio: 1
  25.     }
  26.   ]
  27. ```
- Supply an object if you wish an ungrouped set of items to appear in the default listbox. For example:
  1. ``` js
  2.   {
  3.     displayField: 'name',
  4.     data: () => fetch(`/api/cities/popular`).then(res => res.json()),
  5.   }
  6. ```
- Supply a function if you wish to dynamically build your listbox contents. One example might be
  where you have a data source that already groups results such as a GraphQL query. The function must return a promise which resolves to an array structured exactly as detailed above (see "supply an array..."). For example:
  1. ``` js
  2.   (query) => fetch(`/api/default-locations`)
  3.     .then(res => res.json())
  4.     .then(locations => {
  5.       const {recentSearches, popularCities} = locations
  6.       return [
  7.         {
  8.           name: 'Recent Searches',
  9.           displayField: 'name',
  10.           data: recentSearches,
  11.           id: 'recent',
  12.           ratio: 1
  13.         },
  14.         {
  15.           name: 'Popular Cities',
  16.           displayField: 'name',
  17.           data: popularCities,
  18.           id: 'popular',
  19.           ratio: 1
  20.         }
  21.       ]
  22.     })
  23. ```
- See the listbox prop for details on the data structure of groups as these are the same for bothdefaultListbox and listbox

defaultListboxIsImmutable

- Type: boolean
- Default: true
- If true the contents of the default listbox are considered to be immutable, i.e. they never change between queries.
- If the same query can return different results, this must be set to false.

disabled

- Type: boolean
- Default: false
- If true the search box has an HTML disabled attribute set and cannot be interacted with by the user.

enterKeyHint

- Type: string
- Default: undefined
- If provided, sets the `enterkeyhint` HTML attribute of the search box `` element.
- Accepted values: "enter", "done", "go", "next", "previous", "search", "send"

errorMessage

- Type: string
- Default: undefined
- If provided, this is a generic message displayed in the listbox if any error is thrown when fetching results.
- If not provided then no listbox is displayed if an error occurs

id

- Recommended
- Type: string
- Default: A randomly generated string e.g. "turnstone-7iq5g"
- This is the HTML `id` attribute applied to the container `
` element.- It is also used to set the `id` attribute of the listbox element e.g. `"-listbox"` and the corresponding `aria-owns` attribute of the container element.
- It is recommended to always provide an id.
- Note: If you use Next.js, you must provide an explicit id as randomly generated ids cause discrepancies between server side and client side rendering.

listbox

- Required
- Type: array or object or function
- Specifies how listbox results are populated in response to a user's query entered into the search box.
- Supplying an array
  Supply an array if you wish multiple groups of items to appear in the default listbox. Groups can
  be drawn from multiple sources. For example:
  1. ``` js
  2.   [
  3.     {
  4.       id: 'cities',
  5.       name: 'Cities',
  6.       ratio: 8,
  7.       displayField: 'name',
  8.       data: (query) =>
  9.         fetch(`/api/cities?q=${encodeURIComponent(query)}&limit=10`)
  10.           .then(res => res.json()),
  11.       searchType: 'startswith'
  12.     },
  13.     {
  14.       id: 'airports',
  15.       name: 'Airports',
  16.       ratio: 2,
  17.       displayField: 'name',
  18.       data: (query) =>
  19.         fetch(`/api/airports?q=${encodeURIComponent(query)}&limit=10`)
  20.           .then(res => res.json()),
  21.       searchType: 'contains'
  22.     }
  23.   ]
  24. ```
  Each object representing a group can include the following properties:
  - data (function or array) required
    If a function
    - If supplied as a function, the return value must be a Promise that resolves to an array of items.
    - The array returned by the function is made up of items each representing an item that can potentially appear in the listbox. Items can be objects, arrays or strings.
    - The function receives a 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.
    - If possible, the function should return enough items to satisfy the maxItems prop, in case all of the other groups return zero matches.
    - See the example above for data props supplied as functions.
    - The array returned will not be filtered according to the searchType. The presumption is that
    the function will return an array that is already correctly filtered.

    If an array
    - Instead of a function, an array of items, matching and non-matching can be supplied and Turnstone filters this down to items that match the query.
    - Items can be objects, arrays or strings.
    - The contents of the array will be filtered down to items matching the user's query based on the searchType (see below).
  - displayField (string or number or undefined)
    - This indicates the field within each item in the data array that contains the text to be displayed in the listbox and the text that will be matched to the user's query.
    - If the item is an object or array, displayField must be a string or number.
    - If the item is a string, displayField can be omitted.
  - searchType (string)
    - Must be either "startswith" or "contains".
    - If the data prop is an array of items, Turnstone reduces the array down to items whose displayField either starts with or contains the current query.
- No matter whether data is a function or an array, `searchType` is also used to match item text and wrap it in a `` 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)
    - The 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.
    - For example, let's say there are 3 groups and 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.
    - So it is good to see the ratios as an ideal to be filled wherever the number of results make it possible. This provides a better user experience by showing as many results as possible across all groups. An alternative approach of setting a limit for each group individually would not allow this, nor would it allow us to control the total number of items in the listbox and therefore its size.
  - name (string) required
    - The name of the group
  - id (string)
    - A unique identifier for the group.
    - This is passed to the Item and GroupName props and is useful for styling groups differently based on id.
- Supplying an object
  Supply an object if you wish an ungrouped set of items to appear in the default listbox. For example:
  1. ``` js
  2.   {
  3.     displayField: 'name',
  4.     data: (query) =>
  5.       fetch(`/api/cities?q=${encodeURIComponent(query)}&limit=10`)
  6.         .then(res => res.json()),
  7.     searchType: 'startswith'
  8.   }
  9. ```
  An object can only include the following fields
    - data
    - displayField
    - searchType
  See above for explanations of each field.
- Supplying a function
  Supplying a function is useful if you wish to dynamically build your listbox contents based
  on the user's query. One example might be where you have a data source that already groups results such as a GraphQL query.
  The function receives a single string argument representing the user's query entered into the search box
  The function must return a promise which resolves to an array structured exactly as detailed above in "Supplying an array". For example:
  1. ``` js
  2.   (query) => fetch(`/api/locations?q=${encodeURIComponent(query)}`)
  3.     .then(res => res.json())
  4.     .then(locations => {
  5.       const {cities, airports} = locations

  6.       return [
  7.         {
  8.           id: 'cities',
  9.           name: 'Cities',
  10.           ratio: 8,
  11.           displayField: 'name',
  12.           data: cities,
  13.           searchType: 'startswith'
  14.         },
  15.         {
  16.           id: 'airports',
  17.           name: 'Airports',
  18.           ratio: 2,
  19.           displayField: 'name',
  20.           data: airports,
  21.           searchType: 'contains'
  22.         }
  23.       ]
  24.     })
  25. ```


listboxIsImmutable

- Type: boolean
- Default: true
- If true the contents of the listbox are considered to be immutable, i.e. they never change between queries.
- If the same query can return different results, this must be set to false.

matchText

- Type: boolean
- Default: false
- If `true` any text in listbox items that matches the user's current search query is wrapped in a `` element.
- Note that if the 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

- Type: number
- Default: 10
- The maximum number of items permitted to be displayed in the listbox and default listbox.
- Note: If there are several groups of items in the listbox, this determines how many items are displayed in total, not the number to be displayed within each group. To control the number of items displayed per groupo, use the ratio setting in the listbox and defaultListbox props.

minQueryLength

- Type: number (must be greater than 0)
- Default: 1
- Indicates the minimum number of characters that the user must enter into the search box before results are fetched and a populated listbox is displayed.
- Until minQueryLength is equalled or exceeded, no listbox is displayed.

name

- Type: string
- Default: undefined
- The HTML name attribute applied to the search box.

noItemsMessage

- Type: string
- Default: undefined
- If provided, this is a generic message displayed in the listbox if in the event that no items match the user's search query.
- If not provided then no listbox is displayed in the case of no matching items.

onBlur

- Type: function
- Default: undefined
- If provided, this callback function is executed whenever the blur event triggers on the search box.
- There are no arguments passed to the onBlur callback

onChange

- Type: function
- Default: undefined
- If provided, this callback function is executed whenever the change event triggers on the search box.
- The following arguments are passed to the onChange function:
  1. query (string) The current text value of the search box

onEnter

- Type: function
- Default: undefined
- If provided, this callback function is executed whenever the Enter button is pressed while the search box has focus.
- The following arguments are passed to the onEnter function:
  1. query (string) The current text value of the search box
  1. selectedItem The item selected by the user. This is in the same format as received from listbox.data.

onFocus

- Type: function
- Default: undefined
- If provided, this callback function is executed whenever the focus event triggers on the search box.
- There are no arguments passed to the onFocus callback

onSelect

- Type: function
- Default: undefined
- If provided, this callback function is executed whenever a listbox item is selected.
- The following arguments are passed to the onSelect function:
  1. selectedItem The item selected by the user. This is in the same format as received from listbox.data.
  1. 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.
- This function is also called with undefined arguments to indicate when an item is no longer selected

onTab

- Type: function
- Default: undefined
- If provided, this callback function is executed whenever the Tab button is pressed while the search box has focus.
- The following arguments are passed to the onTab function:
  1. query (string) The current text value of the search box
  1. selectedItem The item selected by the user. This is in the same format as received from listbox.data.

placeholder

- Type: string
- Default: "" (empty string)
- The HTML placeholder attribute applied to the search box.

plugins

- Type: array
- Default: undefined
- A series of Turnstone plugins to add extra functionality such as the Recent Searches plugin.
- Include each plugin as an array entry.
  1. ``` js
  2.   ['plugin1', 'plugin2']
  3. ```
- If there are options to specify alongside a plugin, then make the array entry an array with the first item being the plugin name and the second an options object.
  1. ``` js
  2.   [
  3.     ['plugin1', { option1: true, option2: 'foo' }],
  4.     'plugin2'
  5.   ]
  6. ```

styles

- Type: object
- Default: undefined
- An object whose keys represent elements rendered by Turnstone. Each corresponding value is a string representing the class attribute for the element.
- Just as in the HTML class attribute, the string for each element can contain one or multiple classnames. For example, if you use Tailwind, this could look like the following example:
  1. ``` js
  2.   {
  3.     input: 'w-full h-12 border border-slate-300 py-2 pl-10 pr-7 text-xl outline-none rounded',
  4.     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',
  5.     query: 'text-slate-800 placeholder-slate-400',
  6.     typeahead: 'text-blue-300 border-white',
  7.     cancelButton: `absolute w-10 h-12 inset-y-0 left-0 items-center justify-center z-10 text-blue-400 inline-flex sm:hidden`,
  8.     clearButton: 'absolute inset-y-0 right-0 w-8 inline-flex items-center justify-center text-slate-400 hover:text-rose-400',
  9.     listbox: 'w-full bg-white sm:border sm:border-blue-300 sm:rounded text-left sm:mt-2 p-2 sm:drop-shadow-xl',
  10.     groupHeading: 'cursor-default mt-2 mb-0.5 px-1.5 uppercase text-sm text-rose-300',
  11.     item: 'cursor-pointer p-1.5 text-lg overflow-ellipsis overflow-hidden text-slate-700',
  12.     highlightedItem: 'cursor-pointer p-1.5 text-lg overflow-ellipsis overflow-hidden text-slate-700 rounded bg-blue-50'
  13.   }
  14. ```
- The available elements are as follows:
- **`container`** The outer container `
` 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 `` 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 `` element as well as the typeahead ``. As the typeahead is positioned directly beneath the search box, these must be styled almost identically. - **`inputFocus`** Applies to the search box `` element as well as the typeahead ``, 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 `` 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 `` 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:
    - When typeahead is visible position: relative; z-index: 1; background-color: transparent;
    - When typeahead is not visible position: relative;
- **`typeahead`** For styles applying *only* to the typeahead `` 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 `