From b2cbbe66bd52ddfcaac156c793cb8b06a1b38512 Mon Sep 17 00:00:00 2001 From: TheoryOfNekomata Date: Sat, 22 Jul 2023 18:47:13 +0800 Subject: [PATCH] Improve UX for maskedtextinput, taginput Make UX as seamless as possible. --- .../src/components/PhoneNumberInput/index.tsx | 8 +- .../src/components/MaskedTextInput/index.tsx | 35 +++- .../react/src/components/TagInput/index.tsx | 174 ++++++++++-------- packages/react-utils/src/hooks/form.ts | 43 +++-- packages/react-utils/src/index.ts | 1 - 5 files changed, 161 insertions(+), 100 deletions(-) diff --git a/categories/formatted/react/src/components/PhoneNumberInput/index.tsx b/categories/formatted/react/src/components/PhoneNumberInput/index.tsx index cecaecb..964f39f 100644 --- a/categories/formatted/react/src/components/PhoneNumberInput/index.tsx +++ b/categories/formatted/react/src/components/PhoneNumberInput/index.tsx @@ -80,7 +80,7 @@ export const PhoneNumberInput = React.forwardRef< ) => { const { clientSide } = useClientSide({ clientSide: enhanced }); const [phoneNumber, setPhoneNumber] = React.useState( - value?.toString() ?? defaultValue?.toString() ?? '' + value?.toString() ?? defaultValue?.toString() ?? '', ); const labelId = React.useId(); const defaultId = React.useId(); @@ -88,9 +88,11 @@ export const PhoneNumberInput = React.forwardRef< const { defaultRef, handleChange: handlePhoneInputChange, - } = useProxyInput({ + } = useProxyInput({ forwardedRef, - valueSetterFn: (v) => setPhoneNumber(v), + valueSetterFn: (v) => { + setPhoneNumber(v); + }, transformChangeHandlerArgs: (v) => (v ?? '') as unknown as Value, }); diff --git a/categories/freeform/react/src/components/MaskedTextInput/index.tsx b/categories/freeform/react/src/components/MaskedTextInput/index.tsx index ef60d21..b5dd000 100644 --- a/categories/freeform/react/src/components/MaskedTextInput/index.tsx +++ b/categories/freeform/react/src/components/MaskedTextInput/index.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { TextControl } from '@tesseract-design/web-base'; import clsx from 'clsx'; -import { useClientSide } from '@modal-sh/react-utils'; +import { useClientSide, useFallbackId } from '@modal-sh/react-utils'; export type MaskedTextInputDerivedElement = HTMLInputElement; @@ -67,11 +67,14 @@ export const MaskedTextInput = React.forwardRef< ) => { const { clientSide: indicator } = useClientSide({ clientSide: true, initial: false }); const labelId = React.useId(); - const defaultId = React.useId(); - const id = idProp ?? defaultId; + const id = useFallbackId(idProp); const [visible, setVisible] = React.useState(false); + const defaultRef = React.useRef(null); + const ref = forwardedRef ?? defaultRef; - const handleKeyUp: React.KeyboardEventHandler = React.useCallback((e) => { + const handleKeyUp: React.KeyboardEventHandler< + MaskedTextInputDerivedElement + > = React.useCallback((e) => { if (e.ctrlKey && e.code === 'Space') { setVisible((prev) => !prev); } @@ -79,8 +82,28 @@ export const MaskedTextInput = React.forwardRef< }, [onKeyUp]); const handleToggleVisible = React.useCallback(() => { + const { current } = typeof ref === 'object' ? ref : defaultRef; + let selectionStart = 0; + let selectionEnd = 0; + let selectionDirection: 'none' | 'forward' | 'backward' | undefined = 'none' as const; + if (current) { + selectionStart = current.selectionStart ?? 0; + selectionEnd = current.selectionEnd ?? 0; + selectionDirection = current.selectionDirection ?? 'none' as const; + } setVisible((prev) => !prev); - }, []); + setTimeout(() => { + current?.focus(); + current?.setSelectionRange(selectionStart, selectionEnd, selectionDirection); + }); + }, [ref, defaultRef]); + + React.useEffect(() => { + if (typeof ref === 'function') { + const defaultElement = defaultRef.current as MaskedTextInputDerivedElement; + ref(defaultElement); + } + }, [defaultRef, ref]); return (
, 'size' | 'type' | 'style' | 'label' | 'list'> { /** @@ -60,116 +68,124 @@ export interface TagInputProps extends Omit { 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( 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(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(() => { + 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 = (e) => { + const handleFocus: React.FocusEventHandler = (e) => { if (!clientSide) { onFocus?.(e); } }; - const handleBlur: React.FocusEventHandler = (e) => { + const handleBlur: React.FocusEventHandler = (e) => { if (!clientSide) { onBlur?.(e); } @@ -367,19 +388,20 @@ export const TagInput = React.forwardRef( {' '} )} -