|
|
@@ -1,7 +1,7 @@ |
|
|
|
import * as React from 'react'; |
|
|
|
import { TagsInput } from 'react-tag-input-component'; |
|
|
|
import clsx from 'clsx'; |
|
|
|
import { useClientSide, delegateTriggerEvent } from '@modal-sh/react-utils'; |
|
|
|
import { useClientSide, useProxyInput } from '@modal-sh/react-utils'; |
|
|
|
import { TextControl } from '@tesseract-design/web-base'; |
|
|
|
import plugin from 'tailwindcss/plugin'; |
|
|
|
|
|
|
@@ -11,9 +11,17 @@ const TAG_INPUT_SEPARATOR_MAP = { |
|
|
|
'semicolon': ';', |
|
|
|
} as const; |
|
|
|
|
|
|
|
const TAG_INPUT_VALUE_SEPARATOR_MAP = { |
|
|
|
'comma': ',', |
|
|
|
'newline': '\n', |
|
|
|
'semicolon': ';', |
|
|
|
} as const; |
|
|
|
|
|
|
|
export type TagInputSeparator = keyof typeof TAG_INPUT_SEPARATOR_MAP |
|
|
|
|
|
|
|
export type TagInputDerivedElement = HTMLTextAreaElement; |
|
|
|
export type TagInputDerivedElement = HTMLTextAreaElement | HTMLInputElement; |
|
|
|
|
|
|
|
export type TagInputProxiedElement = HTMLTextAreaElement & HTMLInputElement; |
|
|
|
|
|
|
|
export interface TagInputProps extends Omit<React.HTMLProps<TagInputDerivedElement>, 'size' | 'type' | 'style' | 'label' | 'list'> { |
|
|
|
/** |
|
|
@@ -60,116 +68,124 @@ export interface TagInputProps extends Omit<React.HTMLProps<TagInputDerivedEleme |
|
|
|
* Should the last tag be editable when removed? |
|
|
|
*/ |
|
|
|
editOnRemove?: boolean, |
|
|
|
/** |
|
|
|
* Fallback element for non-enhanced mode. |
|
|
|
*/ |
|
|
|
fallbackElement?: 'textarea' | 'input', |
|
|
|
/** |
|
|
|
* Separator used on the value of the input. |
|
|
|
*/ |
|
|
|
valueSeparator?: TagInputSeparator, |
|
|
|
} |
|
|
|
|
|
|
|
export const tagInputPlugin = plugin(({ addComponents }) => { |
|
|
|
addComponents({ |
|
|
|
'.tag-input': { |
|
|
|
'& textarea + div > input': { |
|
|
|
'& label + * + div > input': { |
|
|
|
'flex': 'auto', |
|
|
|
}, |
|
|
|
'&[data-size="small"] textarea + div > input': { |
|
|
|
'&[data-size="small"] label + * + div > input': { |
|
|
|
'font-size': '0.625rem', |
|
|
|
}, |
|
|
|
'&[data-size="medium"] textarea + div > input': { |
|
|
|
'&[data-size="medium"] label + * + div > input': { |
|
|
|
'font-size': '0.75rem', |
|
|
|
}, |
|
|
|
'&[data-variant="default"] textarea + div': { |
|
|
|
'&[data-variant="default"] label + * + div': { |
|
|
|
'padding-left': '1rem', |
|
|
|
'padding-right': '1rem', |
|
|
|
}, |
|
|
|
|
|
|
|
'&[data-variant="alternate"] textarea + div': { |
|
|
|
'&[data-variant="alternate"] label + * + div': { |
|
|
|
'padding-left': '0.375rem', |
|
|
|
'padding-right': '0.375rem', |
|
|
|
}, |
|
|
|
|
|
|
|
'&[data-size="small"][data-variant="default"] textarea + div': { |
|
|
|
'&[data-size="small"][data-variant="default"] label + * + div': { |
|
|
|
'padding-top': '0.625rem', |
|
|
|
'padding-bottom': '0.875rem', |
|
|
|
}, |
|
|
|
|
|
|
|
'&[data-size="medium"][data-variant="default"] textarea + div': { |
|
|
|
'&[data-size="medium"][data-variant="default"] label + * + div': { |
|
|
|
'padding-top': '0.75rem', |
|
|
|
'padding-bottom': '1rem', |
|
|
|
}, |
|
|
|
|
|
|
|
'&[data-size="large"][data-variant="default"] textarea + div': { |
|
|
|
'&[data-size="large"][data-variant="default"] label + * + div': { |
|
|
|
'padding-top': '1rem', |
|
|
|
'padding-bottom': '1.25rem', |
|
|
|
}, |
|
|
|
|
|
|
|
'&[data-size="small"][data-variant="alternate"] textarea + div': { |
|
|
|
'&[data-size="small"][data-variant="alternate"] label + * + div': { |
|
|
|
'padding-top': '1.375rem', |
|
|
|
}, |
|
|
|
|
|
|
|
'&[data-size="medium"][data-variant="alternate"] textarea + div': { |
|
|
|
'&[data-size="medium"][data-variant="alternate"] label + * + div': { |
|
|
|
'padding-top': '1.5rem', |
|
|
|
}, |
|
|
|
|
|
|
|
'&[data-size="large"][data-variant="alternate"] textarea + div': { |
|
|
|
'&[data-size="large"][data-variant="alternate"] label + * + div': { |
|
|
|
'padding-top': '1.75rem', |
|
|
|
}, |
|
|
|
|
|
|
|
'&[data-size="small"] textarea + div': { |
|
|
|
'&[data-size="small"] label + * + div': { |
|
|
|
'gap': '0.25rem', |
|
|
|
'min-height': '2.5rem', |
|
|
|
}, |
|
|
|
|
|
|
|
'&[data-size="small"].tag-input-indicator textarea + div': { |
|
|
|
'&[data-size="small"].tag-input-indicator label + * + div': { |
|
|
|
'padding-right': '2.5rem', |
|
|
|
}, |
|
|
|
|
|
|
|
'&[data-size="medium"] textarea + div': { |
|
|
|
'&[data-size="medium"] label + * + div': { |
|
|
|
'gap': '0.375rem', |
|
|
|
'min-height': '3rem', |
|
|
|
}, |
|
|
|
|
|
|
|
'&[data-size="medium"].tag-input-indicator textarea + div': { |
|
|
|
'&[data-size="medium"].tag-input-indicator label + * + div': { |
|
|
|
'padding-right': '3rem', |
|
|
|
}, |
|
|
|
|
|
|
|
'&[data-size="large"] textarea + div': { |
|
|
|
'&[data-size="large"] label + * + div': { |
|
|
|
'gap': '0.375rem', |
|
|
|
'min-height': '4rem', |
|
|
|
}, |
|
|
|
|
|
|
|
'&[data-size="large"].tag-input-indicator textarea + div': { |
|
|
|
'&[data-size="large"].tag-input-indicator label + * + div': { |
|
|
|
'padding-right': '4rem', |
|
|
|
}, |
|
|
|
|
|
|
|
'& textarea + div > span': { |
|
|
|
'& label + * + div > span': { |
|
|
|
'padding': '0.125rem', |
|
|
|
'border-radius': '0.25rem', |
|
|
|
'line-height': '1', |
|
|
|
'background-color': 'rgb(var(--color-positive) / 25%)', |
|
|
|
}, |
|
|
|
|
|
|
|
'& textarea + div > span:focus-within': { |
|
|
|
'& label + * + div > span:focus-within': { |
|
|
|
'background-color': 'rgb(var(--color-secondary) / 25%)', |
|
|
|
}, |
|
|
|
|
|
|
|
'& textarea + div > span span': { |
|
|
|
'& label + * + div > span span': { |
|
|
|
'pointer-events': 'none', |
|
|
|
}, |
|
|
|
|
|
|
|
'& textarea + div > span button': { |
|
|
|
'& label + * + div > span button': { |
|
|
|
'color': 'rgb(var(--color-primary))', |
|
|
|
'padding': '0', |
|
|
|
'width': '1rem', |
|
|
|
'margin-left': '0.25rem', |
|
|
|
}, |
|
|
|
|
|
|
|
'& textarea + div > span button:focus': { |
|
|
|
'& label + * + div > span button:focus': { |
|
|
|
'outline': 'none', |
|
|
|
'color': 'rgb(var(--color-secondary))', |
|
|
|
}, |
|
|
|
|
|
|
|
'& textarea + div > span button:focus:-moz-focusring': { |
|
|
|
'& label + * + div > span button:focus:-moz-focusring': { |
|
|
|
'display': 'none', |
|
|
|
}, |
|
|
|
|
|
|
|
'& textarea + div > span button:hover': { |
|
|
|
'& label + * + div > span button:hover': { |
|
|
|
'color': 'rgb(var(--color-primary))', |
|
|
|
}, |
|
|
|
}, |
|
|
@@ -187,68 +203,73 @@ export const TagInput = React.forwardRef<TagInputDerivedElement, TagInputProps>( |
|
|
|
hint, |
|
|
|
indicator, |
|
|
|
size = 'medium' as const, |
|
|
|
border = false, |
|
|
|
block = false, |
|
|
|
border = false as const, |
|
|
|
block = false as const, |
|
|
|
variant = 'default' as const, |
|
|
|
hiddenLabel = false, |
|
|
|
hiddenLabel = false as const, |
|
|
|
className, |
|
|
|
enhanced: enhancedProp = false, |
|
|
|
enhanced: enhancedProp = false as const, |
|
|
|
separator = ['newline'], |
|
|
|
valueSeparator = separator?.[0] ?? 'newline', |
|
|
|
defaultValue, |
|
|
|
value, |
|
|
|
disabled, |
|
|
|
id: idProp, |
|
|
|
onFocus, |
|
|
|
onBlur, |
|
|
|
editOnRemove = false, |
|
|
|
editOnRemove = false as const, |
|
|
|
placeholder, |
|
|
|
fallbackElement: FallbackElement = 'textarea' as const, |
|
|
|
...etcProps |
|
|
|
}, |
|
|
|
forwardedRef, |
|
|
|
) => { |
|
|
|
const EffectiveFallbackElement = valueSeparator === 'newline' ? 'textarea' : FallbackElement; |
|
|
|
const { clientSide } = useClientSide({ clientSide: enhancedProp }); |
|
|
|
const defaultRef = React.useRef<TagInputDerivedElement>(null); |
|
|
|
const ref = forwardedRef ?? defaultRef; |
|
|
|
const labelId = React.useId(); |
|
|
|
const defaultId = React.useId(); |
|
|
|
const id = idProp ?? defaultId; |
|
|
|
const tags = React.useMemo(() => { |
|
|
|
if (defaultValue === undefined) { |
|
|
|
const [tags, setTags] = React.useState<string[]>(() => { |
|
|
|
const effectiveValue = value ?? defaultValue; |
|
|
|
if (effectiveValue === undefined) { |
|
|
|
return []; |
|
|
|
} |
|
|
|
if (typeof defaultValue === 'string') { |
|
|
|
return separator.reduce((theDefaultValues, s) => { |
|
|
|
if (s === 'newline') { |
|
|
|
return theDefaultValues.join('\n').split('\n'); |
|
|
|
} |
|
|
|
return theDefaultValues.join(s).split(s); |
|
|
|
}, [defaultValue]); |
|
|
|
} |
|
|
|
if (typeof defaultValue === 'number') { |
|
|
|
return [defaultValue.toString()]; |
|
|
|
} |
|
|
|
return defaultValue as string[]; |
|
|
|
}, [defaultValue, separator]); |
|
|
|
|
|
|
|
const handleTagsInputChange = (newTags: string[]) => { |
|
|
|
if (!(typeof ref === 'object' && ref)) { |
|
|
|
return; |
|
|
|
if (typeof effectiveValue === 'string') { |
|
|
|
return effectiveValue.split(TAG_INPUT_VALUE_SEPARATOR_MAP[valueSeparator]); |
|
|
|
} |
|
|
|
const { current: input } = ref; |
|
|
|
if (!input) { |
|
|
|
return; |
|
|
|
if (typeof effectiveValue === 'number') { |
|
|
|
return [effectiveValue.toString()]; |
|
|
|
} |
|
|
|
setTimeout(() => { |
|
|
|
delegateTriggerEvent('change', input, newTags.map(((t) => t.trim())).join('\n')); |
|
|
|
}); |
|
|
|
}; |
|
|
|
return effectiveValue as string[]; |
|
|
|
}); |
|
|
|
const { |
|
|
|
defaultRef, |
|
|
|
handleChange: handleTagsInputChange, |
|
|
|
} = useProxyInput< |
|
|
|
string[], |
|
|
|
TagInputDerivedElement, |
|
|
|
TagInputProxiedElement |
|
|
|
>({ |
|
|
|
forwardedRef, |
|
|
|
valueSetterFn: (v) => { |
|
|
|
setTags(v); |
|
|
|
}, |
|
|
|
transformChangeHandlerArgs: (newTags) => { |
|
|
|
const thisNewTags = newTags as string[]; |
|
|
|
return ( |
|
|
|
thisNewTags.map((t) => t.trim()).join(TAG_INPUT_VALUE_SEPARATOR_MAP[valueSeparator]) |
|
|
|
); |
|
|
|
}, |
|
|
|
}); |
|
|
|
const ref = forwardedRef ?? defaultRef; |
|
|
|
const labelId = React.useId(); |
|
|
|
const defaultId = React.useId(); |
|
|
|
const id = idProp ?? defaultId; |
|
|
|
|
|
|
|
const handleFocus: React.FocusEventHandler<HTMLTextAreaElement> = (e) => { |
|
|
|
const handleFocus: React.FocusEventHandler<TagInputDerivedElement> = (e) => { |
|
|
|
if (!clientSide) { |
|
|
|
onFocus?.(e); |
|
|
|
} |
|
|
|
}; |
|
|
|
|
|
|
|
const handleBlur: React.FocusEventHandler<HTMLTextAreaElement> = (e) => { |
|
|
|
const handleBlur: React.FocusEventHandler<TagInputDerivedElement> = (e) => { |
|
|
|
if (!clientSide) { |
|
|
|
onBlur?.(e); |
|
|
|
} |
|
|
@@ -367,19 +388,20 @@ export const TagInput = React.forwardRef<TagInputDerivedElement, TagInputProps>( |
|
|
|
{' '} |
|
|
|
</> |
|
|
|
)} |
|
|
|
<textarea |
|
|
|
<EffectiveFallbackElement |
|
|
|
{...etcProps} |
|
|
|
placeholder={placeholder} |
|
|
|
disabled={disabled} |
|
|
|
ref={ref} |
|
|
|
ref={defaultRef} |
|
|
|
id={id} |
|
|
|
aria-labelledby={labelId} |
|
|
|
data-testid="input" |
|
|
|
defaultValue={defaultValue} |
|
|
|
value={value} |
|
|
|
onFocus={handleFocus} |
|
|
|
onBlur={handleBlur} |
|
|
|
style={{ |
|
|
|
height: clientSide ? undefined : 0, |
|
|
|
height: 0, |
|
|
|
}} |
|
|
|
tabIndex={clientSide ? -1 : undefined} |
|
|
|
className={clsx( |
|
|
@@ -417,7 +439,7 @@ export const TagInput = React.forwardRef<TagInputDerivedElement, TagInputProps>( |
|
|
|
'pr-12': indicator && size === 'medium', |
|
|
|
'pr-16': indicator && size === 'large', |
|
|
|
}, |
|
|
|
!clientSide && { |
|
|
|
{ |
|
|
|
'min-h-10': size === 'small', |
|
|
|
'min-h-12': size === 'medium', |
|
|
|
'min-h-16': size === 'large', |
|
|
@@ -504,12 +526,14 @@ TagInput.defaultProps = { |
|
|
|
label: undefined, |
|
|
|
hint: undefined, |
|
|
|
indicator: undefined, |
|
|
|
size: 'medium', |
|
|
|
variant: 'default', |
|
|
|
size: 'medium' as const, |
|
|
|
variant: 'default' as const, |
|
|
|
separator: ['newline'], |
|
|
|
border: false, |
|
|
|
block: false, |
|
|
|
hiddenLabel: false, |
|
|
|
enhanced: false, |
|
|
|
editOnRemove: false, |
|
|
|
border: false as const, |
|
|
|
block: false as const, |
|
|
|
hiddenLabel: false as const, |
|
|
|
enhanced: false as const, |
|
|
|
editOnRemove: false as const, |
|
|
|
fallbackElement: 'textarea' as const, |
|
|
|
valueSeparator: 'newline' as const, |
|
|
|
}; |