|
|
@@ -0,0 +1,327 @@ |
|
|
|
import * as React from 'react'; |
|
|
|
import * as TextControlBase from '@tesseract-design/web-base-textcontrol'; |
|
|
|
import * as BadgeBase from '@tesseract-design/web-base-badge'; |
|
|
|
|
|
|
|
export type TagInputProps = Omit<React.HTMLProps<HTMLInputElement>, 'size' | 'style'> & { |
|
|
|
/** |
|
|
|
* Short textual description indicating the nature of the component's value. |
|
|
|
*/ |
|
|
|
label?: React.ReactNode, |
|
|
|
/** |
|
|
|
* Short textual description as guidelines for valid input values. |
|
|
|
*/ |
|
|
|
hint?: React.ReactNode, |
|
|
|
/** |
|
|
|
* Size of the component. |
|
|
|
*/ |
|
|
|
size?: TextControlBase.TextControlSize, |
|
|
|
/** |
|
|
|
* Additional description, usually graphical, indicating the nature of the component's value. |
|
|
|
*/ |
|
|
|
indicator?: React.ReactNode, |
|
|
|
/** |
|
|
|
* Should the component display a border? |
|
|
|
*/ |
|
|
|
border?: boolean, |
|
|
|
/** |
|
|
|
* Should the component occupy the whole width of its parent? |
|
|
|
*/ |
|
|
|
block?: boolean, |
|
|
|
/** |
|
|
|
* Style of the component. |
|
|
|
*/ |
|
|
|
style?: TextControlBase.TextControlStyle, |
|
|
|
/** |
|
|
|
* Is the label hidden? |
|
|
|
*/ |
|
|
|
hiddenLabel?: boolean, |
|
|
|
enhanced?: boolean, |
|
|
|
separator?: string, |
|
|
|
} |
|
|
|
|
|
|
|
export const TagInput = React.forwardRef<HTMLInputElement, TagInputProps>((( |
|
|
|
{ |
|
|
|
label = '', |
|
|
|
hint = '', |
|
|
|
indicator = null, |
|
|
|
size = TextControlBase.TextControlSize.MEDIUM, |
|
|
|
border = false, |
|
|
|
block = false, |
|
|
|
style = TextControlBase.TextControlStyle.DEFAULT, |
|
|
|
hiddenLabel = false, |
|
|
|
onInput, |
|
|
|
enhanced = false, |
|
|
|
defaultValue = '', |
|
|
|
separator = ',', |
|
|
|
onFocus, |
|
|
|
onBlur, |
|
|
|
onSelect, |
|
|
|
className: _className, |
|
|
|
placeholder: _placeholder, |
|
|
|
as: _as, |
|
|
|
...etcProps |
|
|
|
}: TagInputProps, |
|
|
|
forwardedRef, |
|
|
|
) => { |
|
|
|
const [hydrated, setHydrated] = React.useState(false); |
|
|
|
const [focused, setFocused] = React.useState(false); |
|
|
|
const [selectionStart, setSelectionStart] = React.useState(0); |
|
|
|
const [selectionEnd, setSelectionEnd] = React.useState(0); |
|
|
|
const [viewValue, setViewValue] = React.useState<string[]>(() => { |
|
|
|
const theDefaultValue = !Array.isArray(defaultValue) ? [defaultValue.toString(), ''] : [...defaultValue, '']; |
|
|
|
return theDefaultValue.filter(v => v.length > 0); |
|
|
|
}); |
|
|
|
const defaultRef = React.useRef<HTMLInputElement>(null); |
|
|
|
const effectiveRef = forwardedRef ?? defaultRef; |
|
|
|
|
|
|
|
const styleArgs = React.useMemo<TextControlBase.TextControlBaseArgs>(() => ({ |
|
|
|
block, |
|
|
|
border, |
|
|
|
size, |
|
|
|
indicator: Boolean(indicator), |
|
|
|
style, |
|
|
|
resizable: true, |
|
|
|
predefinedValues: false, |
|
|
|
}), [block, border, size, indicator, style]); |
|
|
|
|
|
|
|
const renderEnhanced = React.useMemo(() => enhanced && hydrated, [enhanced, hydrated]); |
|
|
|
|
|
|
|
const handleInput: React.FormEventHandler<HTMLInputElement> = (e) => { |
|
|
|
const target = e.target as HTMLInputElement; |
|
|
|
setViewValue(target.value.split(separator)) |
|
|
|
|
|
|
|
if (onInput) { |
|
|
|
onInput(e) |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
const handleFocus: React.FocusEventHandler<HTMLInputElement> = (e) => { |
|
|
|
setFocused(true); |
|
|
|
if (onFocus) { |
|
|
|
onFocus(e); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
const handleBlur: React.FocusEventHandler<HTMLInputElement> = (e) => { |
|
|
|
setFocused(false); |
|
|
|
if (onBlur) { |
|
|
|
onBlur(e); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
const handleSelect: React.ReactEventHandler<HTMLInputElement> = (e) => { |
|
|
|
const target = e.target as HTMLInputElement; |
|
|
|
const newSelectionStart = target.selectionStart ?? 0; |
|
|
|
const newSelectionEnd = target.selectionEnd ?? 0; |
|
|
|
const newDirection = Math.sign(newSelectionStart - selectionStart) > 0 ? 'forward' : 'backward' |
|
|
|
setSelectionStart(newSelectionStart); |
|
|
|
setSelectionEnd(newSelectionEnd); |
|
|
|
|
|
|
|
console.log(newDirection); |
|
|
|
|
|
|
|
const lastSeparatorIndex = target.value.lastIndexOf(separator); |
|
|
|
const separatorStartRaw = newSelectionStart > lastSeparatorIndex ? newSelectionStart : target.value.slice(0, newSelectionEnd).lastIndexOf(separator) + separator?.length; |
|
|
|
const separatorEndRaw = newSelectionEnd > lastSeparatorIndex ? newSelectionEnd : target.value.slice(0, newSelectionEnd).length + target.value.slice(newSelectionEnd).indexOf(separator); |
|
|
|
|
|
|
|
let separatorStart = 0; |
|
|
|
let separatorEnd; |
|
|
|
if (lastSeparatorIndex > -1) { |
|
|
|
separatorStart = separatorStartRaw > -1 ? separatorStartRaw : 0; |
|
|
|
} |
|
|
|
separatorEnd = separatorEndRaw; |
|
|
|
if (newSelectionStart <= target.value.lastIndexOf(separator)) { |
|
|
|
if (newSelectionStart === newSelectionEnd && newSelectionStart === separatorStart && newDirection === 'backward') { |
|
|
|
target.selectionStart = newSelectionStart - separator?.length; |
|
|
|
target.selectionEnd = newSelectionStart - separator?.length; |
|
|
|
target.selectionDirection = newDirection; |
|
|
|
} else if (newSelectionStart === newSelectionEnd && newSelectionEnd === separatorEnd && newDirection === 'forward') { |
|
|
|
target.selectionStart = newSelectionEnd + separator?.length; |
|
|
|
target.selectionEnd = newSelectionEnd + separator?.length; |
|
|
|
target.selectionDirection = newDirection; |
|
|
|
} else { |
|
|
|
target.selectionStart = separatorStart; |
|
|
|
target.selectionEnd = separatorEnd; |
|
|
|
target.selectionDirection = 'backward'; |
|
|
|
} |
|
|
|
} |
|
|
|
if (onSelect) { |
|
|
|
onSelect(e); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
const focusOnInput: React.MouseEventHandler<HTMLDivElement> = (e) => { |
|
|
|
e.preventDefault(); |
|
|
|
if (typeof effectiveRef === 'function') { |
|
|
|
return; |
|
|
|
} |
|
|
|
if (effectiveRef !== null && effectiveRef.current) { |
|
|
|
effectiveRef.current.focus(); |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
const tags = React.useMemo(() => viewValue.slice(0, -1), [viewValue]) |
|
|
|
const inputText = React.useMemo(() => viewValue.slice(-1)[0] ?? '', [viewValue]) |
|
|
|
|
|
|
|
React.useEffect(() => { |
|
|
|
setHydrated(true) |
|
|
|
}, []); |
|
|
|
|
|
|
|
return ( |
|
|
|
<div |
|
|
|
className={TextControlBase.Root(styleArgs)} |
|
|
|
> |
|
|
|
<input |
|
|
|
{...etcProps} |
|
|
|
className={TextControlBase.Input(styleArgs)} |
|
|
|
ref={effectiveRef} |
|
|
|
aria-label={label} |
|
|
|
style={{ |
|
|
|
height: TextControlBase.MIN_HEIGHTS[size], |
|
|
|
// position: renderEnhanced ? 'absolute' : undefined, |
|
|
|
// left: renderEnhanced ? -999999 : undefined, |
|
|
|
}} |
|
|
|
data-testid="input" |
|
|
|
onInput={handleInput} |
|
|
|
onFocus={handleFocus} |
|
|
|
onBlur={handleBlur} |
|
|
|
onSelect={handleSelect} |
|
|
|
/> |
|
|
|
<div |
|
|
|
className={TextControlBase.Input(styleArgs)} |
|
|
|
onClick={focusOnInput} |
|
|
|
style={{ |
|
|
|
cursor: 'text', |
|
|
|
}} |
|
|
|
> |
|
|
|
<div |
|
|
|
style={{ |
|
|
|
margin: '-0.125rem', |
|
|
|
}} |
|
|
|
> |
|
|
|
{tags.map(v => ( |
|
|
|
<div |
|
|
|
style={{ |
|
|
|
padding: '0.125rem', |
|
|
|
display: 'inline-block', |
|
|
|
}} |
|
|
|
key={v} |
|
|
|
> |
|
|
|
<button |
|
|
|
className={BadgeBase.Root({ rounded: false })} |
|
|
|
style={{ |
|
|
|
border: 0, |
|
|
|
font: 'inherit', |
|
|
|
lineHeight: 0, |
|
|
|
paddingTop: 0, |
|
|
|
paddingBottom: 0, |
|
|
|
color: 'inherit', |
|
|
|
backgroundColor: 'transparent', |
|
|
|
}} |
|
|
|
> |
|
|
|
<div |
|
|
|
className={BadgeBase.Content()} |
|
|
|
> |
|
|
|
{v} |
|
|
|
{' '} |
|
|
|
× |
|
|
|
</div> |
|
|
|
</button> |
|
|
|
</div> |
|
|
|
))} |
|
|
|
{ |
|
|
|
inputText.lastIndexOf(separator) < 0 |
|
|
|
&& ( |
|
|
|
<> |
|
|
|
{ |
|
|
|
inputText.slice(0, selectionStart - tags.join(separator).length - separator?.length) |
|
|
|
} |
|
|
|
<div |
|
|
|
style={{ |
|
|
|
display: 'inline-block', |
|
|
|
verticalAlign: 'middle', |
|
|
|
backgroundColor: focused ? 'Highlight' : undefined, |
|
|
|
color: focused ? 'HighlightText' : undefined, |
|
|
|
height: '1.25em', |
|
|
|
minWidth: 1, |
|
|
|
}} |
|
|
|
> |
|
|
|
{ |
|
|
|
inputText.slice(selectionStart - tags.join(separator).length - separator?.length, selectionEnd - tags.join(separator).length - separator?.length) |
|
|
|
} |
|
|
|
</div> |
|
|
|
{ |
|
|
|
inputText.slice(selectionEnd - tags.join(separator).length - separator?.length) |
|
|
|
} |
|
|
|
</> |
|
|
|
) |
|
|
|
} |
|
|
|
{ |
|
|
|
inputText.lastIndexOf(separator) >= 0 |
|
|
|
&& ( |
|
|
|
<> |
|
|
|
{ |
|
|
|
inputText.slice(0, selectionStart - tags.join(separator).length) |
|
|
|
} |
|
|
|
<div |
|
|
|
style={{ |
|
|
|
display: 'inline-block', |
|
|
|
verticalAlign: 'middle', |
|
|
|
backgroundColor: focused ? 'Highlight' : undefined, |
|
|
|
color: focused ? 'HighlightText' : undefined, |
|
|
|
height: '1.25em', |
|
|
|
minWidth: 1, |
|
|
|
}} |
|
|
|
> |
|
|
|
{ |
|
|
|
inputText.slice(selectionStart - tags.join(separator).length - 1, selectionEnd - tags.join(separator).length - 1) |
|
|
|
} |
|
|
|
</div> |
|
|
|
{ |
|
|
|
inputText.slice(selectionEnd - tags.join(separator).length - 1) |
|
|
|
} |
|
|
|
</> |
|
|
|
) |
|
|
|
} |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
{ |
|
|
|
border && ( |
|
|
|
<span |
|
|
|
data-testid="border" |
|
|
|
/> |
|
|
|
) |
|
|
|
} |
|
|
|
{ |
|
|
|
label && !hiddenLabel && ( |
|
|
|
<div |
|
|
|
data-testid="label" |
|
|
|
className={TextControlBase.LabelWrapper(styleArgs)} |
|
|
|
> |
|
|
|
{label} |
|
|
|
</div> |
|
|
|
) |
|
|
|
} |
|
|
|
{hint && ( |
|
|
|
<div |
|
|
|
className={TextControlBase.HintWrapper(styleArgs)} |
|
|
|
data-testid="hint" |
|
|
|
> |
|
|
|
<div |
|
|
|
className={TextControlBase.Hint()} |
|
|
|
> |
|
|
|
{hint} |
|
|
|
</div> |
|
|
|
</div> |
|
|
|
)} |
|
|
|
{indicator && ( |
|
|
|
<div |
|
|
|
className={TextControlBase.IndicatorWrapper(styleArgs)} |
|
|
|
> |
|
|
|
{indicator} |
|
|
|
</div> |
|
|
|
)} |
|
|
|
</div> |
|
|
|
); |
|
|
|
})); |
|
|
|
|
|
|
|
TagInput.displayName = 'TagInput'; |