diff --git a/shlink-web-component/src/short-urls/ShortUrlForm.tsx b/shlink-web-component/src/short-urls/ShortUrlForm.tsx index 16e2fead..cc98bf7e 100644 --- a/shlink-web-component/src/short-urls/ShortUrlForm.tsx +++ b/shlink-web-component/src/short-urls/ShortUrlForm.tsx @@ -4,13 +4,14 @@ import { faDesktop } from '@fortawesome/free-solid-svg-icons'; import { Checkbox, SimpleCard } from '@shlinkio/shlink-frontend-kit'; import classNames from 'classnames'; import { parseISO } from 'date-fns'; -import { isEmpty, pipe, replace, trim } from 'ramda'; +import { isEmpty } from 'ramda'; import type { ChangeEvent, FC } from 'react'; import { useEffect, useState } from 'react'; import { Button, FormGroup, Input, Row } from 'reactstrap'; import type { InputType } from 'reactstrap/types/lib/Input'; import type { ShlinkCreateShortUrlData, ShlinkDeviceLongUrls, ShlinkEditShortUrlData } from '../api-contract'; import type { DomainSelectorProps } from '../domains/DomainSelector'; +import { normalizeTag } from '../tags/helpers'; import type { TagsSelectorProps } from '../tags/helpers/TagsSelector'; import { IconInput } from '../utils/components/IconInput'; import type { DateTimeInputProps } from '../utils/dates/DateTimeInput'; @@ -35,7 +36,6 @@ export interface ShortUrlFormProps Promise; } -const normalizeTag = pipe(trim, replace(/ /g, '-')); const toDate = (date?: string | Date): Date | undefined => (typeof date === 'string' ? parseISO(date) : date); const isCreationData = (data: ShlinkCreateShortUrlData | ShlinkEditShortUrlData): data is ShlinkCreateShortUrlData => diff --git a/shlink-web-component/src/tags/helpers/TagsSelector.tsx b/shlink-web-component/src/tags/helpers/TagsSelector.tsx index 0ab01e26..527fc46a 100644 --- a/shlink-web-component/src/tags/helpers/TagsSelector.tsx +++ b/shlink-web-component/src/tags/helpers/TagsSelector.tsx @@ -1,9 +1,11 @@ -import { useEffect } from 'react'; -import type { OptionRendererProps, TagRendererProps, TagSuggestion } from 'react-tag-autocomplete'; +import resolveClasses from 'classnames'; +import { useEffect, useRef } from 'react'; +import type { OptionRendererProps, ReactTagsAPI, TagRendererProps, TagSuggestion } from 'react-tag-autocomplete'; import { ReactTags } from 'react-tag-autocomplete'; import type { ColorGenerator } from '../../utils/services/ColorGenerator'; import { useSetting } from '../../utils/settings'; import type { TagsList } from '../reducers/tagsList'; +import { normalizeTag } from './index'; import { Tag } from './Tag'; import { TagBullet } from './TagBullet'; @@ -19,6 +21,10 @@ interface TagsSelectorConnectProps extends TagsSelectorProps { tagsList: TagsList; } +const NOT_FOUND_TAG = 'Tag not found'; +const NEW_TAG = 'Add tag'; +const isSelectableOption = (tag: string) => tag !== NOT_FOUND_TAG; +const isNewOption = (tag: string) => tag === NEW_TAG; const toTagSuggestion = (tag: string): TagSuggestion => ({ label: tag, value: tag }); export const TagsSelector = (colorGenerator: ColorGenerator) => ( @@ -30,33 +36,51 @@ export const TagsSelector = (colorGenerator: ColorGenerator) => ( const shortUrlCreation = useSetting('shortUrlCreation'); const searchMode = shortUrlCreation?.tagFilteringMode ?? 'startsWith'; + const apiRef = useRef(null); const ReactTagsTag = ({ tag, onClick: deleteTag }: TagRendererProps) => ( ); - const ReactTagsSuggestion = ({ option }: OptionRendererProps) => ( - <> - - {option.label} - - ); + const ReactTagsSuggestion = ({ option, classNames }: OptionRendererProps) => { + const isSelectable = isSelectableOption(option.label); + const isNew = isNewOption(option.label); + + return ( +
+ {!isSelectable ? {option.label} : ( + <> + {!isNew && } + {!isNew ? option.label : Add "{normalizeTag(apiRef.current?.input.value ?? '')}"} + + )} +
+ ); + }; return ( !selectedTags.includes(tag)).map(toTagSuggestion)} renderTag={ReactTagsTag} renderOption={ReactTagsSuggestion} + activateFirstOption allowNew={allowNew} - // addOnBlur TODO Implement manually + newOptionText={NEW_TAG} + noOptionsText={NOT_FOUND_TAG} placeholderText={placeholder ?? 'Add tags to the URL'} delimiterKeys={['Enter', 'Tab', ',']} suggestionsTransform={ (query, suggestions) => { const searchTerm = query.toLowerCase().trim(); - return searchTerm.length < 1 ? [] : suggestions.filter( + return searchTerm.length < 1 ? [] : [...suggestions.filter( ({ label }) => (searchMode === 'includes' ? label.includes(searchTerm) : label.startsWith(searchTerm)), - ); + )].slice(0, 5); } } onDelete={(removedTagIndex) => { @@ -67,7 +91,7 @@ export const TagsSelector = (colorGenerator: ColorGenerator) => ( onAdd={({ label: newTag }) => onChange( // * Avoid duplicated tags (thanks to the Set), // * Split any of the new tags by comma, allowing to paste multiple comma-separated tags at once. - [...new Set([...selectedTags, ...newTag.toLowerCase().split(',')])], + [...new Set([...selectedTags, ...newTag.split(',')])], )} /> ); diff --git a/shlink-web-component/src/tags/helpers/index.ts b/shlink-web-component/src/tags/helpers/index.ts new file mode 100644 index 00000000..6c273699 --- /dev/null +++ b/shlink-web-component/src/tags/helpers/index.ts @@ -0,0 +1,3 @@ +const ONE_OR_MORE_SPACES_REGEX = /\s+/g; + +export const normalizeTag = (tag: string) => tag.trim().toLowerCase().replace(ONE_OR_MORE_SPACES_REGEX, '-'); diff --git a/shlink-web-component/src/tags/react-tag-autocomplete.scss b/shlink-web-component/src/tags/react-tag-autocomplete.scss index 7c4baf5c..c2cf9632 100644 --- a/shlink-web-component/src/tags/react-tag-autocomplete.scss +++ b/shlink-web-component/src/tags/react-tag-autocomplete.scss @@ -1,5 +1,6 @@ @import '../../../node_modules/@shlinkio/shlink-frontend-kit/dist/base'; +// Main wrapper .react-tags { position: relative; padding: 5px 0 0 6px; @@ -26,45 +27,35 @@ background-color: var(--input-color); } -.react-tags.is-focused { +// Mimic bootstrap input focus ring +.react-tags.is-active { box-shadow: 0 0 0 .2rem rgb(70 150 229 / 25%); } +.react-tags__label { + display: none; +} + .react-tags__tag { font-size: 100%; } -.react-tags__selected { +.react-tags__list { display: inline; vertical-align: 2px; + padding: 0; + list-style-type: none; } -.react-tags__selected-tag { +.react-tags__list-item { display: inline-block; - box-sizing: border-box; - margin: 0 6px 6px 0; - padding: 6px 8px; - border: 1px solid var(--input-border-color); - border-radius: .25rem; - background: #f1f1f1; - - /* match the font styles */ - font-size: inherit; - line-height: inherit; +} +.react-tags__list-item:not(:last-child) { + margin-right: 3px; } -.react-tags__selected-tag:after { - content: '\2715'; - color: #aaaaaa; - margin-left: 8px; -} - -.react-tags__selected-tag:hover, -.react-tags__selected-tag:focus { - border-color: var(--input-border-color); -} - -.react-tags__search { +// The block to search +.react-tags__combobox { display: inline-block; /* match tag layout */ @@ -76,13 +67,13 @@ } @media screen and (min-width: $smMin) { - .react-tags__search { + .react-tags__combobox { /* this will become the offsetParent for suggestions */ position: relative; } } -.react-tags__search-input { +.react-tags__combobox-input { font-size: 1.25rem; line-height: inherit; color: var(--input-text-color); @@ -98,62 +89,52 @@ outline: none; } -.react-tags__search-input::placeholder { +.react-tags__combobox-input::placeholder { color: #6c757d; } -.react-tags__search-input::-ms-clear { +.react-tags__combobox-input::-ms-clear { display: none; } -.react-tags__suggestions { +.react-tags__listbox { position: absolute; top: 100%; left: 0; width: 100%; z-index: 10; -} - -@media screen and (min-width: $smMin) { - .react-tags__suggestions { - width: 240px; - } -} - -.react-tags__suggestions ul { margin: 4px -1px; padding: 0; - list-style: none; background: var(--primary-color); border: 1px solid var(--border-color); border-radius: .25rem; box-shadow: 0 2px 6px rgb(0 0 0 / .2); } -.react-tags__suggestions li { +@media screen and (min-width: $smMin) { + .react-tags__listbox { + width: 240px; + } +} + +.react-tags__listbox .react-tags__listbox-option { padding: 8px 10px; } -.react-tags__suggestions li:not(:last-child) { +.react-tags__listbox .react-tags__listbox-option:not(:last-child) { border-bottom: 1px solid var(--border-color); } -.react-tags__suggestions li mark { - text-decoration: underline; - background: none; - font-weight: 600; -} - -.react-tags__suggestions li:hover { +.react-tags__listbox .react-tags__listbox-option:hover:not(.react-tags__listbox-option--not-selectable) { cursor: pointer; background-color: var(--active-color); } -.react-tags__suggestions li.is-active { +.react-tags__listbox .react-tags__listbox-option.is-active { background-color: var(--active-color); } -.react-tags__suggestions li.is-disabled { +.react-tags__listbox .react-tags__listbox-option.is-disabled { opacity: .5; cursor: auto; }