Files
turnstone/src/lib/components/container.jsx
2022-02-19 22:20:39 +00:00

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>
)
}