diff --git a/docs/todo.md b/docs/todo.md index fe8ef25..27c74d2 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -15,6 +15,7 @@ # Formatted +- [ ] `SuggestionInput` - [ ] `EmailInput` - [ ] `TelInput` - [ ] `UrlInput` diff --git a/src/modules/option/components/TagInput/index.tsx b/src/modules/option/components/TagInput/index.tsx new file mode 100644 index 0000000..8c816f3 --- /dev/null +++ b/src/modules/option/components/TagInput/index.tsx @@ -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, '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((( + { + 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(() => { + const theDefaultValue = !Array.isArray(defaultValue) ? [defaultValue.toString(), ''] : [...defaultValue, '']; + return theDefaultValue.filter(v => v.length > 0); + }); + const defaultRef = React.useRef(null); + const effectiveRef = forwardedRef ?? defaultRef; + + const styleArgs = React.useMemo(() => ({ + 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 = (e) => { + const target = e.target as HTMLInputElement; + setViewValue(target.value.split(separator)) + + if (onInput) { + onInput(e) + } + } + + const handleFocus: React.FocusEventHandler = (e) => { + setFocused(true); + if (onFocus) { + onFocus(e); + } + } + + const handleBlur: React.FocusEventHandler = (e) => { + setFocused(false); + if (onBlur) { + onBlur(e); + } + } + + const handleSelect: React.ReactEventHandler = (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 = (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 ( +
+ +
+
+ {tags.map(v => ( +
+ +
+ ))} + { + inputText.lastIndexOf(separator) < 0 + && ( + <> + { + inputText.slice(0, selectionStart - tags.join(separator).length - separator?.length) + } +
+ { + inputText.slice(selectionStart - tags.join(separator).length - separator?.length, selectionEnd - tags.join(separator).length - separator?.length) + } +
+ { + inputText.slice(selectionEnd - tags.join(separator).length - separator?.length) + } + + ) + } + { + inputText.lastIndexOf(separator) >= 0 + && ( + <> + { + inputText.slice(0, selectionStart - tags.join(separator).length) + } +
+ { + inputText.slice(selectionStart - tags.join(separator).length - 1, selectionEnd - tags.join(separator).length - 1) + } +
+ { + inputText.slice(selectionEnd - tags.join(separator).length - 1) + } + + ) + } +
+
+ { + border && ( + + ) + } + { + label && !hiddenLabel && ( +
+ {label} +
+ ) + } + {hint && ( +
+
+ {hint} +
+
+ )} + {indicator && ( +
+ {indicator} +
+ )} +
+ ); +})); + +TagInput.displayName = 'TagInput'; diff --git a/src/modules/option/index.ts b/src/modules/option/index.ts index 9082200..32e630e 100644 --- a/src/modules/option/index.ts +++ b/src/modules/option/index.ts @@ -2,6 +2,7 @@ export * from './components/DropdownSelect'; export * from './components/MenuSelect'; export * from './components/RadioButton'; export * from './components/RadioTickBox'; +export * from './components/TagInput'; export * from './components/ToggleButton'; export * from './components/ToggleSwitch'; export * from './components/ToggleTickBox'; diff --git a/src/modules/option/web-option-react.test.ts b/src/modules/option/web-option-react.test.ts index 2d851b7..0883799 100644 --- a/src/modules/option/web-option-react.test.ts +++ b/src/modules/option/web-option-react.test.ts @@ -6,6 +6,7 @@ describe('web-option-react', () => { 'MenuSelect', 'RadioButton', 'RadioTickBox', + 'TagInput', 'ToggleButton', 'ToggleSwitch', 'ToggleTickBox', diff --git a/src/pages/categories/option/index.tsx b/src/pages/categories/option/index.tsx index 8295f4e..b131df2 100644 --- a/src/pages/categories/option/index.tsx +++ b/src/pages/categories/option/index.tsx @@ -872,6 +872,203 @@ const OptionPage: NextPage = ({ +
+
+

+ TagInput +

+
+
+

+ Default +

+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+

Alternate

+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+
+
+

@@ -1211,16 +1408,6 @@ const OptionPage: NextPage = ({

-
-
-

- TagList -

-
- TODO -
-
-