|
1 | | -import { clsx } from 'clsx'; |
2 | | -import type { RefObject } from 'react'; |
3 | | -import { useRef } from 'react'; |
4 | | -import type { OptionRendererProps, ReactTagsAPI, TagRendererProps, TagSuggestion } from 'react-tag-autocomplete'; |
5 | | -import { ReactTags } from 'react-tag-autocomplete'; |
| 1 | +import type { TagsAutocompleteProps } from '@shlinkio/shlink-frontend-kit/tailwind'; |
| 2 | +import { TagsAutocomplete } from '@shlinkio/shlink-frontend-kit/tailwind'; |
6 | 3 | import type { FCWithDeps } from '../../container/utils'; |
7 | 4 | import { componentFactory, useDependencies } from '../../container/utils'; |
8 | 5 | import { useSetting } from '../../settings'; |
9 | 6 | import type { ColorGenerator } from '../../utils/services/ColorGenerator'; |
10 | | -import { normalizeTag } from './index'; |
11 | | -import { Tag } from './Tag'; |
12 | | -import { TagBullet } from './TagBullet'; |
13 | | - |
14 | | -let tagId = 1; |
15 | | - |
16 | | -const NOT_FOUND_TAG = 'Tag not found'; |
17 | | -const NEW_TAG = 'Add tag'; |
18 | | -const isSelectableOption = (tag: string) => tag !== NOT_FOUND_TAG; |
19 | | -const isNewOption = (tag: string) => tag === NEW_TAG; |
20 | | -const toTagObject = (tag: string): TagSuggestion => { |
21 | | - // react-tag-autocomplete uses the value to determine if a tag is already added, removing it in that case. |
22 | | - // Using a unique value ensures all tags are always considered new, together with a `Set` to ignore duplicates later. |
23 | | - tagId += 1; |
24 | | - return { label: tag, value: `${tag}${tagId}` }; |
25 | | -}; |
26 | | - |
27 | | -const buildTagRenderer = (colorGenerator: ColorGenerator) => ({ tag, onClick: deleteTag }: TagRendererProps) => ( |
28 | | - <Tag colorGenerator={colorGenerator} text={tag.label} className="react-tags__tag" onClose={deleteTag} /> |
29 | | -); |
30 | | -const buildOptionRenderer = (colorGenerator: ColorGenerator, api: RefObject<ReactTagsAPI | null>) => ( |
31 | | - { option, classNames: classes, ...rest }: OptionRendererProps, |
32 | | -) => { |
33 | | - const isSelectable = isSelectableOption(option.label); |
34 | | - const isNew = isNewOption(option.label); |
35 | | - |
36 | | - return ( |
37 | | - <div |
38 | | - className={clsx(classes.option, { |
39 | | - [classes.optionIsActive]: isSelectable && option.active, |
40 | | - 'react-tags__listbox-option--not-selectable': !isSelectable, |
41 | | - })} |
42 | | - {...rest} |
43 | | - > |
44 | | - {!isSelectable ? <i>{option.label}</i> : ( |
45 | | - <> |
46 | | - {!isNew && <TagBullet tag={`${option.label}`} colorGenerator={colorGenerator} />} |
47 | | - {!isNew ? option.label : <i>Add "{normalizeTag(api.current?.input.value ?? '')}"</i>} |
48 | | - </> |
49 | | - )} |
50 | | - </div> |
51 | | - ); |
52 | | -}; |
53 | 7 |
|
54 | 8 | export type TagsSelectorProps = { |
55 | | - tags: string[]; |
56 | | - selectedTags: string[]; |
57 | 9 | onChange: (tags: string[]) => void; |
58 | | - placeholder?: string; |
59 | | - /** If true, it won't allow adding new tags */ |
60 | | - immutable?: boolean; |
61 | | -}; |
| 10 | +} & Pick<TagsAutocompleteProps, 'tags' | 'selectedTags' | 'placeholder' | 'immutable' | 'containerClassName'>; |
62 | 11 |
|
63 | 12 | type TagsSelectorDeps = { |
64 | 13 | ColorGenerator: ColorGenerator; |
65 | 14 | }; |
66 | 15 |
|
67 | | -const TagsSelector: FCWithDeps<TagsSelectorProps, TagsSelectorDeps> = ( |
68 | | - { selectedTags, onChange, placeholder, tags, immutable = false }, |
69 | | -) => { |
| 16 | +const TagsSelector: FCWithDeps<TagsSelectorProps, TagsSelectorDeps> = ({ onChange, placeholder, ...rest }) => { |
70 | 17 | const { ColorGenerator: colorGenerator } = useDependencies(TagsSelector); |
71 | 18 | const shortUrlCreation = useSetting('shortUrlCreation'); |
72 | 19 | const searchMode = shortUrlCreation?.tagFilteringMode ?? 'startsWith'; |
73 | | - const apiRef = useRef<ReactTagsAPI>(null); |
74 | 20 |
|
75 | 21 | return ( |
76 | | - <ReactTags |
77 | | - ref={apiRef} |
78 | | - selected={selectedTags.map(toTagObject)} |
79 | | - suggestions={tags.filter((tag) => !selectedTags.includes(tag)).map(toTagObject)} |
80 | | - renderTag={buildTagRenderer(colorGenerator)} |
81 | | - // eslint-disable-next-line react-compiler/react-compiler |
82 | | - renderOption={buildOptionRenderer(colorGenerator, apiRef)} |
83 | | - activateFirstOption |
84 | | - allowNew={!immutable} |
85 | | - newOptionText={NEW_TAG} |
86 | | - noOptionsText={NOT_FOUND_TAG} |
87 | | - placeholderText={placeholder ?? 'Add tags to the URL'} |
88 | | - delimiterKeys={['Enter', ',']} |
89 | | - suggestionsTransform={ |
90 | | - (query, suggestions) => { |
91 | | - const searchTerm = query.toLowerCase().trim(); |
92 | | - return searchTerm.length < 1 ? [] : [...suggestions.filter( |
93 | | - ({ label }) => (searchMode === 'includes' ? label.includes(searchTerm) : label.startsWith(searchTerm)), |
94 | | - )].slice(0, 5); |
95 | | - } |
96 | | - } |
97 | | - onDelete={(removedTagIndex) => { |
98 | | - const tagsCopy = [...selectedTags]; |
99 | | - tagsCopy.splice(removedTagIndex, 1); |
100 | | - onChange(tagsCopy); |
101 | | - }} |
102 | | - onAdd={({ label: newTag }) => onChange( |
103 | | - // Use a Set to ignore duplicated tags. |
104 | | - // Split any of the new tags by comma, allowing to paste multiple comma-separated tags at once. |
105 | | - [...new Set([...selectedTags, ...newTag.split(',').map(normalizeTag)])], |
106 | | - )} |
| 22 | + <TagsAutocomplete |
| 23 | + {...rest} |
| 24 | + onTagsChange={onChange} |
| 25 | + getColorForTag={(tag) => colorGenerator.getColorForKey(tag)} |
| 26 | + size="lg" |
| 27 | + placeholder={placeholder ?? 'Add tags to the URL'} |
| 28 | + searchMode={searchMode} |
107 | 29 | /> |
108 | 30 | ); |
109 | 31 | }; |
|
0 commit comments