[Back] /**
* Code based on Tagger, copyright (c) 2018-2022 Jakub T. Jankiewicz <https://jcubic.pl/me>
* Released under the MIT license.
*/
import React, { useRef, useState } from 'react'
import { handleUnknownError } from '../../utils/errors'
import { SuggestionList } from './SuggestionList'
import { TagList } from './TagList'
import type { InputHTMLAttributes, KeyboardEventHandler } from 'react'
const COMPLETION_MIN_LENGTH = 2
const SPECIAL_CHARS_RE = /(?<specialChar>[-\\^$[\]()+{}?*.|])/g
const escapeRegex = (str: string) =>
str.replace(SPECIAL_CHARS_RE, '\\$1')
const isNotEmpty = (value: string | null | undefined): value is string =>
!['', '""', "''", '``', undefined, null].includes(value)
const isTagSelected = (selected: string[], tag: string) => {
if (!selected.includes(tag)) {
return false
}
const re = new RegExp(`^${escapeRegex(tag)}`)
return 1 === selected.filter(test_tag => re.test(test_tag)).length
}
export type TagEditorCompletions = string[] | ((value?: string) => Promise<string[]>) | undefined
const buildCompletions = (completions: TagEditorCompletions, value: string | undefined): Promise<string[] | undefined> => {
if (!completions) {
return Promise.resolve(undefined)
} else if ('function' === typeof completions) {
return completions(value)
} else {
return Promise.resolve(completions)
}
}
export interface TagEditorProps extends Omit<InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
id: string
tags: string[]
onChange: (tags: string[]) => void
tagLimit?: number
completions?: TagEditorCompletions
addOnBlur?: boolean
allowSpaces?: boolean
allowDuplicates?: boolean
completionMinLength?: number
}
// eslint-disable-next-line max-lines-per-function
export const TagEditor: React.FC<TagEditorProps> = ({
id,
tags,
onChange,
tagLimit,
completions,
addOnBlur = false,
allowSpaces = true,
allowDuplicates = false,
completionMinLength = COMPLETION_MIN_LENGTH,
...inputProps
}) => {
const inputRef = useRef<HTMLInputElement>(null)
const [inputValue, setInputValue] = useState<string>('')
const [completionOpen, setCompletionOpen] = useState(false)
const [completionList, setCompletionList] = useState<string[]>([])
const [lastCompletionList, setLastCompletionList] = useState<string[]>()
const isTagLimit = () => Boolean(tagLimit && 0 < tagLimit && tags.length >= tagLimit)
const addTag = (tag: string = inputValue.trim()) => {
const isTagValid = isNotEmpty(tag) && !isTagLimit() && (allowDuplicates || !tags.includes(tag))
if (isTagValid) {
onChange([...tags, tag])
}
setInputValue('')
return isTagValid
}
const removeTag = (tag?: string) =>
onChange(tag ? tags.filter(item => item !== tag) : tags.slice(0, -1))
const triggerCompletion = (openList = inputValue.length >= completionMinLength) => {
if (openList) {
buildCompletions(completions, inputValue)
.then(list => {
setLastCompletionList(completionList)
if (list?.length) {
setCompletionList(allowDuplicates ? list : list.filter(item => !tags.includes(item)))
}
})
.catch(handleUnknownError)
}
setCompletionOpen(openList)
}
const keyboardHandler: KeyboardEventHandler<HTMLInputElement> = event => {
const { key, ctrlKey, metaKey } = event
switch (key) {
case 'Enter':
case ',':
event.preventDefault()
addTag()
break
case 'Backspace':
if (!inputValue) {
event.preventDefault()
removeTag()
}
break
case ' ':
if (ctrlKey || metaKey) {
event.preventDefault()
triggerCompletion(true)
} else if (!allowSpaces) {
event.preventDefault()
addTag()
}
break
case 'Tab':
if (!isTagLimit()) {
event.preventDefault()
}
break
}
}
const inputHandler = () => {
if (completionOpen && lastCompletionList && isTagSelected(lastCompletionList, inputValue.trim())) {
if (addTag()) {
setCompletionOpen(false)
}
} else {
triggerCompletion()
}
}
return (
<div className="tagger">
<ul onClick={() => inputRef.current?.focus()}>
<TagList tags={tags} onRemove={removeTag} />
<li className="tagger-new">
<input
{...inputProps}
id={id}
type="text"
ref={inputRef}
value={inputValue}
list={`tagger-completion-${completionOpen ? '' : '-disabled'}${id}`}
onBlur={() => addOnBlur ? addTag() : undefined}
onChange={event => setInputValue(event.target.value)}
onKeyDown={keyboardHandler}
onInput={inputHandler}
/>
<SuggestionList
id={`tagger-completion-${id}`}
suggestions={completionList.filter(suggestion => !tags.includes(suggestion))}
onSelect={suggestion => {
addTag(suggestion)
setCompletionList([])
setCompletionOpen(false)
}}
/>
</li>
</ul>
</div>
)
}