src/components/Autocomplete.tsx

Summary

Maintainability
C
1 day
Test Coverage
import React, { useRef, useState, useEffect, forwardRef } from 'react'
import { CSSTransition, TransitionGroup } from 'react-transition-group'
import { position } from 'caret-pos'
import classnames from 'classnames'
import { useTagHistory } from '@/hooks/useTagHistory'
import { useTimeHistory } from '@/hooks/useTimeHistory'
import { t } from '@/services/i18n'
import { Parser } from '@/services/parser'
import { NODE_TYPE } from '@/models/node'
import { Task } from '@/models/task'
import { KEYCODE_ENTER } from '@/const'

import './Autocomplete.css'
import '@/css/fadeIn.css'
import '@/css/collapse.css'

const RegexpToken = /\s$/
const RegexpQuery = /\S+$/

type AutocompleteProps = {
  text: string
  editorRef: React.RefObject<HTMLTextAreaElement>
  onComplete: (value: string) => void
  onVisibleChange: (visible: boolean) => void
}

type CompleteItem = {
  action: () => void
  value: string
  name: string
}

const WINDOW_WIDTH = 450
const CURSOR_NONE = -1

export const Autocomplete = forwardRef<HTMLDivElement, AutocompleteProps>(
  (props: AutocompleteProps, ref: React.RefObject<HTMLDivElement>) => {
    const { tags } = useTagHistory()
    const { times } = useTimeHistory()
    const [cursor, setCursor] = useState(CURSOR_NONE)
    const tokenPosition = useRef(0)
    const editorRef = props.editorRef
    const node = Parser.parseLine(props.text)
    const nodeType = node ? node.type : null
    const isTask = nodeType === NODE_TYPE.TASK

    if (RegexpToken.test(props.text)) {
      let pos = position(editorRef.current)
      tokenPosition.current = pos.left
    }

    let containerStyle = { left: 0, top: 0 }
    if (editorRef && editorRef.current && ref && ref.current) {
      const editorRect = editorRef.current.getBoundingClientRect()
      const refRect = ref.current.getBoundingClientRect()
      containerStyle = {
        left: Math.min(
          tokenPosition.current - 12,
          WINDOW_WIDTH - refRect.width - editorRect.x + 20,
        ),
        top: editorRect.height,
      }
    }

    let items: CompleteItem[] = []
    if (isTask) {
      const task = node.data as Task
      if (
        task.estimatedTimes &&
        task.estimatedTimes.isEmpty() &&
        task.title != null &&
        task.title.length > 1
      ) {
        times.forEach((time) => {
          items.push({
            name: t('autocomplete_estimated'),
            value: '~/' + time,
            action: handleClick('~/' + time),
          })
        })
      }
      if (task.title != null && task.title.length > 1) {
        tags.forEach((tag) => {
          const found = task.tags.find((t) => t.name === tag.name)
          if (!found) {
            items.push({
              name: t('autocomplete_tag'),
              value: '#' + tag.name,
              action: handleClick('#' + tag.name),
            })
          }
        })
      }
    }

    let query = ''
    if (RegexpQuery.test(props.text)) {
      query = RegexpQuery.exec(props.text)[0]
      items = items.filter((item) => {
        return item.value.indexOf(query) >= 0
      })
    }

    const visible = items.length > 0 && tokenPosition.current > 0

    useEffect(() => {
      props.onVisibleChange(visible)
    }, [visible])

    useEffect(() => {
      setCursor(-1)
    }, [items.length])

    function handleClick(val: string) {
      return () => {
        const r = new RegExp(query + '$')
        props.onComplete(props.text.replace(r, val))
      }
    }

    function handleKeyDown(e: React.KeyboardEvent) {
      let newCursor: number
      if (e.keyCode === KEYCODE_ENTER) {
        if (cursor != null && cursor !== CURSOR_NONE) {
          handleClick(items[cursor].value)()
        }
      } else if (e.key === 'ArrowUp') {
        newCursor = cursor === 0 ? items.length - 1 : cursor - 1
      } else if (e.key === 'ArrowDown') {
        newCursor = (cursor + 1) % items.length
      }
      setCursor(newCursor)
    }

    return (
      <div
        className="autocomplete"
        style={containerStyle}
        onKeyDown={handleKeyDown}
        ref={ref}
      >
        {visible && (
          <TransitionGroup component="ul" className="autocomplete__menu">
            {items.map((item, idx) => (
              <CSSTransition
                key={item.name + item.value}
                timeout={200}
                classNames="collapse"
                unmountOnExit
              >
                <li
                  className={classnames('autocomplete__list', {
                    'autocomplete__list--focus': idx === cursor,
                  })}
                  key={item.name + item.value}
                  style={{ '--height': '28px' } as any}
                >
                  <button className="autocomplete__item" onClick={item.action}>
                    <span className="autocomplete__value">{item.value}</span>
                    <span className="autocomplete__name">{item.name}</span>
                  </button>
                </li>
              </CSSTransition>
            ))}
          </TransitionGroup>
        )}
      </div>
    )
  },
)