import React from 'react' import { GetInputPropsOptions } from 'downshift' import styled, { css, SimpleInterpolation } from 'styled-components' import { elem } from 'fp-ts/lib/Array' import { contramap, Eq, eqString } from 'fp-ts/lib/Eq' import { flow } from 'fp-ts/lib/function' import * as O from 'fp-ts/lib/Option' import { pipe } from 'fp-ts/lib/pipeable' import { Colors, getColor } from '@monorail/helpers/color' import { BorderRadius, borderRadius, FontSizes, FontWeights, } from '@monorail/helpers/exports' import { flexFlow } from '@monorail/helpers/flex' import { FormMultiSelectInput } from '@monorail/metaComponents/formMultiSelectInput/FormMultiSelectInput' import { FormMultiSelectInputProps, SuggestionInfo, } from '@monorail/metaComponents/formMultiSelectInput/FormMultiSelectInput.types' import { unsafeCoerceToArray } from '@monorail/sharedHelpers/fp-ts-ext/ReadonlyArray' import { trim } from '@monorail/sharedHelpers/strings' import { isNonEmptyString } from '@monorail/sharedHelpers/typeGuards' import { ButtonDisplay, ButtonSize, } from '@monorail/visualComponents/buttons/buttonTypes' import { IconButton } from '@monorail/visualComponents/buttons/IconButton' import { Divider } from '@monorail/visualComponents/divider/Divider' import { Icon } from '@monorail/visualComponents/icon/Icon' import { DisplayType } from '@monorail/visualComponents/inputs/inputTypes' import { Text } from '@monorail/visualComponents/typography/Text' const regExpChar = /[\\^$.*+?()[\]{}|]/g // from lodash escapeRegExp function const focusedBorder = css` border: 1px solid ${getColor(Colors.BrandLightBlue)}; ` export const containerCss = css` ${flexFlow('column')}; border-radius: 4px; border: 1px solid ${getColor(Colors.Gray12)}; width: 256px; position: relative; background-color: ${getColor(Colors.White)}; transition: 150ms; :focus { ${focusedBorder}; } :focus-within { ${focusedBorder}; } ` const SuggestibleInput = styled.input` order: 1; border: 0; padding: 4px 6px; border-radius: 4px; input { color: ${getColor(Colors.Gray89)}; } &:focus { outline: none; } ::placeholder { color: ${getColor(Colors.Gray54)}; font-style: italic; } ` const SelectedOptions = styled.ol<{ hasChildren?: boolean }>` ${flexFlow('row', 'wrap')}; order: 2; list-style: none; margin: 0; padding: ${props => (props.hasChildren ? '4px 0 0 0' : '0')}; overflow-x: hidden; overflow-y: auto; ` const SelectedOption = styled.li<{ fullWidth?: boolean }>` ${flexFlow('row')}; justify-content: space-between; align-items: center; border-radius: 4px; background-color: ${getColor(Colors.BrandLightBlue, 0.1)}; color: ${getColor(Colors.Gray89)}; margin: 0 4px 4px; padding-right: ${props => (props.fullWidth ? '6px' : '0')}; max-width: calc(100% - 8px); width: ${props => (props.fullWidth ? '100%' : 'auto')}; transition: 150ms; :focus { ${focusedBorder}; } ${Icon} { cursor: pointer; margin: 0; border-radius: 4px; height: 24px; width: 24px; padding: 4px 0 0 4px; } :hover { ${Icon} { color: ${getColor(Colors.BrandLightBlue)}; } } ` const SelectedOptionText = styled(Text)` white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding: 4px 8px; ` type Position = 'top' | 'bottom' const Suggestions = styled.aside<{ isOpen: boolean; position: Position }>` transform: ${props => props.position === 'top' ? 'translateY(-100%)' : 'none'}; background-color: ${getColor(Colors.White)}; box-shadow: 0 3px 5px -1px ${getColor(Colors.Black, 0.2)}, 0 6px 10px 0 ${getColor(Colors.Black, 0.14)}, 0 1px 18px 0 ${getColor(Colors.Gray12)}; left: 4px; list-style: none; margin: 0; max-height: 256px; max-width: 248px; overflow-x: hidden; overflow-y: auto; padding: 0 0 4px 0; pointer-events: ${props => (props.isOpen ? 'auto' : 'none')}; position: absolute; right: 4px; top: ${props => (props.position === 'top' ? 'auto' : '100%')}; visibility: ${props => (props.isOpen ? 'visible' : 'hidden')}; z-index: 1; ` export const TagInputItem = (props: { item: string handleClick: () => void }) => (
{props.item}
) const SuggestionList = styled.ol` list-style: none; margin: 0; padding: 0; ` const Suggestion = styled.li<{ isHighlighted: boolean }>` cursor: pointer; padding: 4px 16px; :hover { background-color: ${getColor(Colors.SelectionSecondaryOnHover, 0.54)}; } ${props => props.isHighlighted ? css` background-color: ${getColor(Colors.SelectionSecondaryOnHover, 0.54)}; ` : ``} ` type Tag = 'fullWidth' | 'text' const selectedOptionComponent = ( removeOption: (value: string) => void, tag?: Tag, removable = true, ) => { switch (tag) { case 'text': { return (value: string) => (
  • {value}
  • ) } case 'fullWidth': { return (value: string) => ( {value} {removable && ( removeOption(value)} icon="clear" /> )} ) } default: { return (value: string) => ( {value} {removable && ( removeOption(value)} cssOverrides={`margin: 4px;`} icon="clear" /> )} ) } } } export const renderSelectedOptions = (tag?: Tag, viewPlaceholder?: string) => ( selectedOptions: ReadonlyArray, removeOption: (value: string) => void, ): React.ReactElement => ( 0}> {selectedOptions.length > 0 ? unsafeCoerceToArray(selectedOptions).map( selectedOptionComponent(removeOption, tag, !Boolean(viewPlaceholder)), ) : viewPlaceholder} ) export const renderSuggestibleInput = (placeholder: string) => ( props: GetInputPropsOptions, ) => ( )} placeholder={placeholder} /> ) const getSearchValueRegex = (searchValue: string): RegExp => new RegExp(`^${searchValue.replace(regExpChar, '\\$&')}$`, 'ig') export const shouldShowNewSuggestion = ( searchValue: string, suggestions: ReadonlyArray, selectedOptions: ReadonlyArray, ) => { const searchValueRegex = getSearchValueRegex(searchValue) const hasPerfectMatch = suggestions.findIndex(s => searchValueRegex.test(s)) > -1 const hasBeenSelected = selectedOptions.findIndex(s => searchValueRegex.test(s)) > -1 return searchValue.length > 0 && !hasPerfectMatch && !hasBeenSelected } export const isAlreadySelected = ( searchValue: string, selectedOptions: ReadonlyArray, ) => { const searchValueRegex = getSearchValueRegex(searchValue) const hasBeenSelected = selectedOptions.findIndex(s => searchValueRegex.test(s)) > -1 return searchValue.length > 0 && hasBeenSelected } export const renderSuggestions = (params: { position: Position validateItem?: (item: string) => O.Option }) => ( suggestions: ReadonlyArray, info: SuggestionInfo, ): React.ReactElement => { const { isFocused, searchValue, isHighlighted, getSuggestionProps, selectedOptions, } = info const trimmedSearchValue = searchValue.replace(/\s+/g, ' ').trim() const { validateItem = O.some } = params const hasHighlightedSuggestion = suggestions.some(s => isHighlighted(s)) const showNewSuggestion = shouldShowNewSuggestion( trimmedSearchValue, suggestions, selectedOptions, ) const alreadySelected = isAlreadySelected(trimmedSearchValue, selectedOptions) return ( 0} position={params.position} > {suggestions.length > 0 ? 'Suggestions' : alreadySelected ? 'Value Already Selected' : 'No Matches'} {suggestions.map((s, i) => ( {trimmedSearchValue.length > 0 ? highlightSearchValue(s, trimmedSearchValue) : s} ))} {showNewSuggestion && pipe( trimmedSearchValue, validateItem, O.fold( () => <>, searchValue_ => ( <>
  • {searchValue_} (New Value) ), ), )}
    ) } export function highlightSearchValue(labelName: string, searchValue: string) { return findHighlightedSearchValues( labelName, normalize(searchValue), ).map(({ highlighted, value }, i) => highlighted ? {value} : value, ) } /** * Finds what parts of a given string matches the given search value. */ function findHighlightedSearchValues( value: string, searchValue: string, ): ReadonlyArray<{ highlighted: boolean; value: string }> { if (eqNormalizedString.equals(value, searchValue)) { return [{ highlighted: true, value }] } const regex = getSearchValueRegex(searchValue) const startParts: Array<{ highlighted: boolean; value: string }> = [] const indexedParts = Array.from(value.matchAll(regex)).reduce( (acc, match) => { const startIndex = match.index! const before = value.slice(acc.index, startIndex) const endIndex = startIndex + match[0].length const matchedText = value.slice(startIndex, endIndex) return { index: endIndex, parts: [ ...acc.parts, { highlighted: false, value: before || '' }, { highlighted: true, value: matchedText }, ], } }, { index: 0, parts: startParts }, ) const after = value.slice(indexedParts.index) return after ? [...indexedParts.parts, { highlighted: false, value: after }] : indexedParts.parts } export const normalize = (str: string): string => str.trim().toLocaleLowerCase() export const eqNormalizedString: Eq = contramap(normalize)(eqString) export const getSuggestions = (selectedLabels: ReadonlyArray) => ( searchValue: string, labels: ReadonlyArray, ): Array => labels.filter( l => includesSearchValue(l, searchValue) && !elem(eqNormalizedString)(l, unsafeCoerceToArray(selectedLabels)), ) const includesSearchValue = (value: string, searchValue: string) => normalize(value).includes(normalize(searchValue)) type GenericTagInputProps = Omit< FormMultiSelectInputProps, 'renderInput' > & { placeholder: string cssOverrides?: SimpleInterpolation position?: Position } export function GenericTagInput({ placeholder, cssOverrides = '', selectedOptions, options, eq, ...otherProps }: GenericTagInputProps) { return ( containerCss={containerCss.concat(cssOverrides)} defaultHighlightedIndex={0} renderInput={renderSuggestibleInput(placeholder)} eq={eq} selectedOptions={selectedOptions} options={options} {...otherProps} /> ) } type TagInputProps = Omit< GenericTagInputProps, | 'eq' | 'getSuggestedValues' | 'renderInput' | 'renderSelectedOptions' | 'renderSuggestions' | 'searchValueToItem' > & { placeholder: string tag?: Tag eq?: Eq showSuggestions?: boolean position?: Position searchValueToItem?: (searchValue: string) => O.Option /** Text to display when in View mode with no tags */ viewPlaceholder?: string } const defaultValidateItem = flow(trim, O.fromPredicate(isNonEmptyString)) export const TagInput = ({ tag, selectedOptions, eq = eqNormalizedString, showSuggestions = true, position = 'bottom', // @TODO - Pete Murphy 2020-09-30 - Condense `searchValueToItem` & // `validateItem` to a single prop, not clear why there ended up being two // props to do apparently same thing(?) searchValueToItem = defaultValidateItem, validateItem = flow(O.fromNullable, O.chain(defaultValidateItem)), viewPlaceholder, ...otherProps }: TagInputProps) => ( eq={eq} renderSelectedOptions={renderSelectedOptions( tag, otherProps.display === DisplayType.View ? viewPlaceholder : undefined, )} getSuggestedValues={getSuggestions(selectedOptions)} renderSuggestions={ showSuggestions ? renderSuggestions({ position, validateItem }) : () => null } selectedOptions={selectedOptions} searchValueToItem={searchValueToItem} validateItem={validateItem} {...otherProps} /> )