skyderby/skyderby

View on GitHub
app/javascript/components/TokenizedSearchField/index.tsx

Summary

Maintainability
A
1 hr
Test Coverage
import React, { useRef, useState } from 'react'

import useClickOutside from 'hooks/useClickOutside'
import IconTimes from 'icons/times.svg'
import Token from './Token'
import ValueSelect from './ValueSelect'
import TypeSelect from './TypeSelect'
import { Mode, TokenTuple, ValueKey, isAllowedValueKey, allowedValueKeys } from './types'

import styles from './styles.module.scss'
import { ValueType } from 'react-select'

type TokenizedSearchFieldProps = {
  initialValues?: TokenTuple[]
  onChange: (tokens: TokenTuple[]) => unknown
  exclude?: typeof allowedValueKeys[number]
}

const TokenizedSearchField = ({
  initialValues = [],
  onChange,
  exclude
}: TokenizedSearchFieldProps) => {
  const containerRef = useRef<HTMLDivElement>(null)
  const [tokens, setTokens] = useState(initialValues)
  const [mode, setMode] = useState<Mode>('idle')
  const [currentType, setCurrentType] = useState<ValueKey>()

  useClickOutside(containerRef, () => {
    setMode('idle')
  })

  const fireOnChange = (tokens: TokenTuple[]) => onChange?.(tokens)

  const handleBlur = (evt: React.MouseEvent | React.FocusEvent) => {
    if (evt.target !== evt.currentTarget) return

    if (mode === 'idle') {
      setMode('selectType')
    } else {
      setMode('idle')
    }
  }

  const handleTypeSelect = (option: ValueType<{ value: string }, false>) => {
    if (!option) {
      setMode('idle')
      return
    }

    if (!isAllowedValueKey(option.value)) {
      console.warn(
        `Expected value key to be one of [${allowedValueKeys}], but got ${option.value}`
      )
      return
    }

    setCurrentType(option.value)
    setMode('selectValue')
  }

  const handleValueSelect = (newToken: TokenTuple) => {
    const newSetOfTokens = [...tokens, newToken]

    setTokens(newSetOfTokens)
    fireOnChange(newSetOfTokens)

    setMode('idle')
  }

  const deleteAll = () => {
    setTokens([])
    fireOnChange([])
  }

  const deleteByIdx = (deletedIdx: number) => {
    const newSetOfTokens = tokens.filter((_el, idx) => idx !== deletedIdx)

    setTokens(newSetOfTokens)
    fireOnChange(newSetOfTokens)
  }

  return (
    <div className={styles.container} ref={containerRef} aria-label="Search field">
      <ul className={styles.tokensList} onClick={handleBlur}>
        {tokens.map(([type, value], idx) => (
          <Token key={idx} type={type} value={value} onDelete={() => deleteByIdx(idx)} />
        ))}

        <li>
          {['idle', 'selectType'].includes(mode) && (
            <TypeSelect
              aria-label="Select filter criteria"
              onChange={handleTypeSelect}
              exclude={exclude}
            />
          )}

          {mode === 'selectValue' && (
            <ValueSelect
              type={currentType}
              onChange={handleValueSelect}
              onBlur={handleBlur}
            />
          )}
        </li>
      </ul>

      {tokens.length > 0 && (
        <button className={styles.clearButton} title="Clear all" onClick={deleteAll}>
          <IconTimes />
        </button>
      )}
    </div>
  )
}

export default TokenizedSearchField