mirror of
https://github.com/fergalmoran/turnstone.git
synced 2025-12-22 09:49:56 +00:00
330 lines
8.8 KiB
JavaScript
330 lines
8.8 KiB
JavaScript
import React, { useState, useRef, useContext, useImperativeHandle, useEffect } from 'react'
|
|
import { StateContext } from '../context/state'
|
|
import Listbox from './listbox'
|
|
import Errorbox from './errorbox'
|
|
import { useDebounce } from 'use-debounce'
|
|
import useData from './hooks/useData'
|
|
import undef from '../utils/undef'
|
|
import isUndefined from '../utils/isUndefined'
|
|
import defaultStyles from './styles/container.styles.js'
|
|
import {
|
|
useItemsState,
|
|
useItemsError,
|
|
useQueryChange,
|
|
useHighlight,
|
|
useSelected
|
|
} from './hooks/containerEffects'
|
|
import {
|
|
setQuery,
|
|
setHighlighted,
|
|
highlightPrev,
|
|
highlightNext,
|
|
setSelected,
|
|
clear
|
|
} from '../actions/actions'
|
|
|
|
const Container = React.forwardRef((props, ref) => {
|
|
// Destructure props
|
|
const {
|
|
autoFocus,
|
|
cancelButton,
|
|
cancelButtonAriaLabel,
|
|
clearButton,
|
|
clearButtonAriaLabel,
|
|
debounceWait,
|
|
defaultListbox,
|
|
defaultListboxIsImmutable,
|
|
disabled,
|
|
enterKeyHint,
|
|
errorMessage,
|
|
id,
|
|
listbox,
|
|
listboxIsImmutable,
|
|
maxItems,
|
|
minQueryLength,
|
|
name,
|
|
noItemsMessage,
|
|
onBlur,
|
|
onChange,
|
|
onEnter,
|
|
onFocus,
|
|
onSelect,
|
|
onTab,
|
|
placeholder,
|
|
styles,
|
|
tabIndex,
|
|
text,
|
|
typeahead,
|
|
Cancel,
|
|
Clear
|
|
} = props
|
|
|
|
const listboxId = `${id}-listbox`
|
|
const errorboxId = `${id}-errorbox`
|
|
|
|
// Global state from context
|
|
const { state, dispatch } = useContext(StateContext)
|
|
|
|
// Component state
|
|
const [debouncedQuery] = useDebounce(state.query, debounceWait)
|
|
const [hasFocus, setHasFocus] = useState(false)
|
|
const [blockBlurHandler, setBlockBlurHandler] = useState(false)
|
|
const [autoSelect, setAutoSelect] = useState(!!text)
|
|
|
|
// DOM references
|
|
const queryInput = useRef(null)
|
|
const typeaheadInput = useRef(null)
|
|
|
|
// Calculated states
|
|
const hasTypeahead = typeahead && state.items.length > 1
|
|
const hasClearButton = clearButton && !!state.query
|
|
const hasCancelButton = cancelButton && hasFocus
|
|
const isExpanded = hasFocus && state.canShowListbox
|
|
const isErrorExpanded = !!props.errorMessage && state.itemsError
|
|
const containerClassname = hasFocus ? 'containerFocus' : 'container'
|
|
const containerStyles = styles[containerClassname] || styles.container
|
|
const defaultContainerStyles = styles[containerClassname]
|
|
? undef
|
|
: defaultStyles[containerClassname]
|
|
const inputClassName = hasFocus ? 'inputFocus' : 'input'
|
|
const inputStyles = styles[inputClassName] || styles.input
|
|
const queryDefaultStyle = hasTypeahead ? defaultStyles.query : defaultStyles.queryNoTypeahead
|
|
|
|
// Checks whether or not SWR data is to be treated as immutable
|
|
const isImmutable = (() => {
|
|
return listboxIsImmutable &&
|
|
!(
|
|
defaultListbox &&
|
|
!defaultListboxIsImmutable &&
|
|
debouncedQuery.length === 0
|
|
)
|
|
})()
|
|
|
|
// Hook to retrieve data using SWR
|
|
const swrResult = useData(
|
|
debouncedQuery ? debouncedQuery.toLowerCase() : '',
|
|
isImmutable,
|
|
listbox,
|
|
defaultListbox,
|
|
minQueryLength,
|
|
maxItems,
|
|
dispatch
|
|
)
|
|
|
|
// Store retrieved data in global state as state.items
|
|
useItemsState(swrResult.data)
|
|
|
|
// Autoselect matching value if we have initial text passed in a prop
|
|
useEffect(() => {
|
|
if(autoSelect && swrResult.data && swrResult.data[0]?.text === text) {
|
|
dispatch(setSelected(swrResult.data[0]))
|
|
setAutoSelect(false)
|
|
}
|
|
}, [autoSelect, swrResult.data, text, dispatch])
|
|
|
|
// Store retrieved error if there is one
|
|
useItemsError(swrResult.error)
|
|
|
|
// 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) => {
|
|
const highlightedIndex = state.highlighted && state.highlighted.index
|
|
const highlightedItem = !isUndefined(highlightedIndex)
|
|
? state.items[highlightedIndex]
|
|
: undef
|
|
const f = keyPressed.toLowerCase() === 'enter' ? onEnter : onTab
|
|
|
|
if(highlightedItem) dispatch(setSelected(highlightedIndex))
|
|
if (typeof f === 'function') f(queryInput.current.value, highlightedItem)
|
|
}
|
|
|
|
// Handle different keypresses and call the appropriate action creators
|
|
const checkKey = (evt) => {
|
|
switch (evt.keyCode) {
|
|
case 40: // Down arrow
|
|
dispatch(highlightNext())
|
|
break
|
|
case 38: // Up arrow
|
|
dispatch(highlightPrev())
|
|
break
|
|
case 13: // Enter
|
|
onTabOrEnter('enter')
|
|
break
|
|
case 9: // Tab
|
|
onTabOrEnter('tab')
|
|
break
|
|
case 27: // Esc
|
|
clearState()
|
|
break
|
|
}
|
|
}
|
|
|
|
const handleInput = () => {
|
|
dispatch(setQuery(queryInput.current.value))
|
|
}
|
|
|
|
const handleClearButton = () => {
|
|
setBlockBlurHandler(true)
|
|
clearState()
|
|
}
|
|
|
|
const handleCancelButton = () => {
|
|
clearState()
|
|
}
|
|
|
|
const clearState = () => {
|
|
// Immediately clearing both inputs prevents any slight
|
|
// visual timing delays with async dispatch
|
|
queryInput.current.value = ''
|
|
if(typeahead && typeaheadInput.current)
|
|
typeaheadInput.current.value = ''
|
|
dispatch(clear())
|
|
queryInput.current.focus()
|
|
}
|
|
|
|
const handleFocus = () => {
|
|
if(!hasFocus) {
|
|
setHasFocus(true)
|
|
if (state.items && state.items.length > 0) {
|
|
dispatch(setHighlighted(0))
|
|
}
|
|
if(typeof onFocus === 'function') onFocus()
|
|
}
|
|
}
|
|
|
|
const handleBlur = () => {
|
|
if(blockBlurHandler) {
|
|
queryInput.current.focus()
|
|
}
|
|
else {
|
|
setHasFocus(false)
|
|
if(typeof onBlur === 'function') onBlur()
|
|
}
|
|
setBlockBlurHandler(false)
|
|
}
|
|
|
|
// Expose methods to parent components
|
|
useImperativeHandle(ref, () => ({
|
|
focus: () => {
|
|
queryInput.current.focus()
|
|
},
|
|
blur: () => {
|
|
queryInput.current.blur()
|
|
},
|
|
select: () => {
|
|
queryInput.current.select()
|
|
},
|
|
clear: () => {
|
|
clearState()
|
|
},
|
|
query: (query) => {
|
|
if(typeof query === 'string') {
|
|
queryInput.current.value = query
|
|
queryInput.current.focus()
|
|
handleInput()
|
|
}
|
|
}
|
|
}))
|
|
|
|
return (
|
|
<React.Fragment>
|
|
<div
|
|
className={containerStyles}
|
|
style={defaultContainerStyles}
|
|
role='combobox'
|
|
aria-expanded={isExpanded}
|
|
aria-owns={listboxId}
|
|
aria-haspopup='listbox'>
|
|
<input
|
|
id={id}
|
|
name={name}
|
|
className={`${inputStyles || ''} ${styles.query || ''}`.trim()}
|
|
style={queryDefaultStyle}
|
|
disabled={disabled}
|
|
placeholder={placeholder}
|
|
type='text'
|
|
autoFocus={autoFocus}
|
|
autoComplete='off'
|
|
autoCorrect='off'
|
|
autoCapitalize='off'
|
|
spellCheck='false'
|
|
tabIndex={tabIndex}
|
|
enterKeyHint={enterKeyHint}
|
|
ref={queryInput}
|
|
onKeyDown={checkKey}
|
|
onInput={handleInput}
|
|
onFocus={handleFocus}
|
|
onBlur={handleBlur}
|
|
aria-autocomplete='both'
|
|
aria-controls={listboxId}
|
|
/>
|
|
|
|
{hasTypeahead && (
|
|
<input
|
|
className={`${inputStyles || ''} ${styles.typeahead || ''}`.trim()}
|
|
style={defaultStyles.typeahead}
|
|
disabled={disabled}
|
|
type='text'
|
|
autoComplete='off'
|
|
autoCorrect='off'
|
|
autoCapitalize='off'
|
|
spellCheck='false'
|
|
tabIndex='-1'
|
|
readOnly='readonly'
|
|
aria-hidden='true'
|
|
ref={typeaheadInput}
|
|
/>
|
|
)}
|
|
|
|
{hasClearButton && (
|
|
<button
|
|
className={styles.clearButton}
|
|
style={defaultStyles.clearButton}
|
|
onMouseDown={handleClearButton}
|
|
tabIndex={-1}
|
|
aria-label={clearButtonAriaLabel}>
|
|
<Clear />
|
|
</button>
|
|
)}
|
|
|
|
{hasCancelButton && (
|
|
<button
|
|
className={styles.cancelButton}
|
|
style={defaultStyles.cancelButton}
|
|
onMouseDown={handleCancelButton}
|
|
tabIndex={-1}
|
|
aria-label={cancelButtonAriaLabel}>
|
|
<Cancel />
|
|
</button>
|
|
)}
|
|
|
|
{isExpanded && (
|
|
<Listbox
|
|
id={listboxId}
|
|
items={state.items}
|
|
noItemsMessage={noItemsMessage}
|
|
styles={styles}
|
|
/>
|
|
)}
|
|
|
|
{isErrorExpanded && (
|
|
<Errorbox id={errorboxId} errorMessage={errorMessage} styles={styles} />
|
|
)}
|
|
</div>
|
|
</React.Fragment>
|
|
)
|
|
})
|
|
|
|
Container.displayName = 'Container'
|
|
|
|
export default Container
|