mirror of
https://github.com/fergalmoran/turnstone.git
synced 2025-12-22 09:49:56 +00:00
257 lines
6.5 KiB
JavaScript
257 lines
6.5 KiB
JavaScript
import React, { useState, useMemo, useRef, useContext } from 'react'
|
|
import { StateContext } from '../context/state'
|
|
import Items from './items'
|
|
import { useDebounce } from 'use-debounce'
|
|
import useData from './hooks/useData'
|
|
import undef from '../utils/undef'
|
|
import isUndefined from '../utils/isUndefined'
|
|
import defaultStyles from './styles/input.styles.js'
|
|
import {
|
|
useItemsState,
|
|
useAutoFocus,
|
|
useQueryChange,
|
|
useHighlight,
|
|
useSelected
|
|
} from './hooks/containerEffects'
|
|
import {
|
|
setQuery,
|
|
setHighlighted,
|
|
highlightPrev,
|
|
highlightNext,
|
|
setSelected
|
|
} from '../actions/actions'
|
|
|
|
export default function Container(props) {
|
|
// Destructure props
|
|
const {
|
|
autoFocus,
|
|
clearButton,
|
|
clearButtonAriaLabel,
|
|
clearButtonText,
|
|
debounceWait,
|
|
defaultItemGroups,
|
|
defaultItemGroupsAreImmutable,
|
|
displayField,
|
|
data,
|
|
dataSearchType,
|
|
id,
|
|
isDisabled,
|
|
itemGroupsAreImmutable,
|
|
maxItems,
|
|
minQueryLength,
|
|
name,
|
|
noItemsMessage,
|
|
onChange,
|
|
onSelect,
|
|
onEnter,
|
|
onTab,
|
|
placeholder,
|
|
tabIndex
|
|
} = props
|
|
|
|
// Destructure itemGroups prop
|
|
const {
|
|
itemGroups = [
|
|
{
|
|
name: '',
|
|
data,
|
|
dataSearchType,
|
|
displayField,
|
|
ratio: maxItems
|
|
}
|
|
]
|
|
} = props
|
|
|
|
const dropdownId = `${id}-dropdown`
|
|
|
|
// Global state from context
|
|
const { state, dispatch } = useContext(StateContext)
|
|
const { customStyles } = state
|
|
|
|
// Component state
|
|
const [debouncedQuery] = useDebounce(state.query, debounceWait)
|
|
const [hasFocus, setHasFocus] = useState(false)
|
|
|
|
// DOM references
|
|
const queryInput = useRef(null)
|
|
const typeaheadInput = useRef(null)
|
|
|
|
// Checks whether or not SWR data is to be treated as immutable
|
|
const isImmutable = (() => {
|
|
return itemGroupsAreImmutable &&
|
|
!(
|
|
defaultItemGroups &&
|
|
!defaultItemGroupsAreImmutable &&
|
|
debouncedQuery.length === 0
|
|
)
|
|
})()
|
|
|
|
// Hook to retrieve data using SWR
|
|
const swrData = useData(
|
|
debouncedQuery.toLowerCase(),
|
|
isImmutable,
|
|
itemGroups,
|
|
defaultItemGroups,
|
|
minQueryLength,
|
|
maxItems,
|
|
dispatch
|
|
).data
|
|
|
|
// Store retrieved data in global state as state.items
|
|
useItemsState(swrData)
|
|
|
|
// Autofocus on render if prop is true
|
|
useAutoFocus(queryInput, autoFocus)
|
|
|
|
// As soon as the query state changes (ignoring debounce) update the
|
|
// typeahead value and the query value and fire onChange
|
|
useQueryChange(state.query, queryInput, typeaheadInput, onChange)
|
|
|
|
// When the highlighted item changes, make sure the typeahead matches and format
|
|
// the query text to match the case of the typeahead text
|
|
useHighlight(state.highlighted, hasFocus, queryInput, typeaheadInput)
|
|
|
|
// When an item is selected alter the query to match and fire applicable events
|
|
useSelected(state.selected, queryInput, typeaheadInput, onSelect)
|
|
|
|
const onTabOrEnter = (keyPressed) => {
|
|
// keyPressed must be 'enter' or 'tab'
|
|
const highlightedIndex = state.highlighted && state.highlighted.index
|
|
const highlightedItem = !isUndefined(highlightedIndex)
|
|
? state.items[highlightedIndex]
|
|
: undef
|
|
const f = keyPressed.toLowerCase() === 'enter' ? onEnter : onTab
|
|
dispatch(setSelected(highlightedIndex))
|
|
if (typeof f === 'function') f(queryInput.current.value, highlightedItem)
|
|
}
|
|
|
|
const hasClearButton = () => {
|
|
return clearButton && !!state.query
|
|
}
|
|
|
|
const isExpanded = useMemo(() => {
|
|
if (hasFocus && !state.query && defaultItemGroups) return true
|
|
if (state.query.length < minQueryLength) return false
|
|
return hasFocus && !!state.query
|
|
}, [hasFocus, state.query, defaultItemGroups, minQueryLength])
|
|
|
|
// Handle different keypresses and call the appropriate action creators
|
|
const checkKey = (evt) => {
|
|
switch (evt.keyCode) {
|
|
case 40: // Down arrow
|
|
evt.preventDefault()
|
|
dispatch(highlightNext())
|
|
break
|
|
case 38: // Up arrow
|
|
evt.preventDefault()
|
|
dispatch(highlightPrev())
|
|
break
|
|
case 13: // Enter
|
|
evt.preventDefault()
|
|
onTabOrEnter('enter')
|
|
break
|
|
case 9: // Tab
|
|
evt.preventDefault()
|
|
onTabOrEnter('tab')
|
|
break
|
|
case 27: // Esc
|
|
evt.preventDefault()
|
|
clearState()
|
|
break
|
|
}
|
|
}
|
|
|
|
const handleInput = () => {
|
|
dispatch(setQuery(queryInput.current.value))
|
|
}
|
|
|
|
const handleClearButton = (evt) => {
|
|
evt.preventDefault()
|
|
clearState()
|
|
}
|
|
|
|
const clearState = () => {
|
|
dispatch(setQuery(''))
|
|
queryInput.current.focus()
|
|
}
|
|
|
|
const handleFocus = () => {
|
|
setHasFocus(true) //TODO: make hasFocus part of global state?
|
|
|
|
if (state.items && state.items.length > 0) {
|
|
dispatch(setHighlighted(0))
|
|
}
|
|
}
|
|
|
|
const handleBlur = () => {
|
|
setHasFocus(false)
|
|
}
|
|
|
|
return (
|
|
<React.Fragment>
|
|
<div
|
|
className={customStyles.queryContainer}
|
|
style={defaultStyles.queryContainer}
|
|
role='combobox'
|
|
aria-expanded={isExpanded}
|
|
aria-owns={dropdownId}
|
|
aria-haspopup='listbox'>
|
|
<input
|
|
id={id}
|
|
name={name}
|
|
className={customStyles.query}
|
|
style={defaultStyles.query}
|
|
disabled={isDisabled}
|
|
placeholder={placeholder}
|
|
type='text'
|
|
autoComplete='off'
|
|
autoCorrect='off'
|
|
autoCapitalize='off'
|
|
spellCheck='false'
|
|
tabIndex={tabIndex}
|
|
ref={queryInput}
|
|
onKeyDown={checkKey}
|
|
onInput={handleInput}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
aria-autocomplete='both'
|
|
aria-controls={dropdownId}
|
|
/>
|
|
|
|
<input
|
|
className={customStyles.typeahead}
|
|
style={defaultStyles.typeahead}
|
|
disabled={isDisabled}
|
|
type='text'
|
|
autoComplete='off'
|
|
autoCorrect='off'
|
|
autoCapitalize='off'
|
|
spellCheck='false'
|
|
tabIndex='-1'
|
|
readOnly='readonly'
|
|
aria-hidden='true'
|
|
ref={typeaheadInput}
|
|
/>
|
|
|
|
{hasClearButton() && (
|
|
<div
|
|
className={customStyles.clearButton}
|
|
style={defaultStyles.clearButton}
|
|
onMouseDown={handleClearButton}
|
|
tabIndex={-1}
|
|
role='button'
|
|
aria-label={clearButtonAriaLabel}>{clearButtonText}</div>
|
|
)}
|
|
|
|
{isExpanded && (
|
|
<Items
|
|
id={dropdownId}
|
|
items={state.items}
|
|
noItemsMessage={noItemsMessage}
|
|
/>
|
|
)}
|
|
</div>
|
|
</React.Fragment>
|
|
)
|
|
}
|