Browse Source

Improve UX for maskedtextinput, taginput

Make UX as seamless as possible.
master
TheoryOfNekomata 1 year ago
parent
commit
b2cbbe66bd
5 changed files with 161 additions and 100 deletions
  1. +5
    -3
      categories/formatted/react/src/components/PhoneNumberInput/index.tsx
  2. +29
    -6
      categories/freeform/react/src/components/MaskedTextInput/index.tsx
  3. +99
    -75
      categories/multichoice/react/src/components/TagInput/index.tsx
  4. +28
    -15
      packages/react-utils/src/hooks/form.ts
  5. +0
    -1
      packages/react-utils/src/index.ts

+ 5
- 3
categories/formatted/react/src/components/PhoneNumberInput/index.tsx View File

@@ -80,7 +80,7 @@ export const PhoneNumberInput = React.forwardRef<
) => {
const { clientSide } = useClientSide({ clientSide: enhanced });
const [phoneNumber, setPhoneNumber] = React.useState<Value>(
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<PhoneNumberInputDerivedElement, Value>({
} = useProxyInput<Value, PhoneNumberInputDerivedElement>({
forwardedRef,
valueSetterFn: (v) => setPhoneNumber(v),
valueSetterFn: (v) => {
setPhoneNumber(v);
},
transformChangeHandlerArgs: (v) => (v ?? '') as unknown as Value,
});



+ 29
- 6
categories/freeform/react/src/components/MaskedTextInput/index.tsx View File

@@ -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<MaskedTextInputDerivedElement>(null);
const ref = forwardedRef ?? defaultRef;

const handleKeyUp: React.KeyboardEventHandler<MaskedTextInputDerivedElement> = 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 (
<div
@@ -127,7 +150,7 @@ export const MaskedTextInput = React.forwardRef<
<input
{...etcProps}
size={length}
ref={forwardedRef}
ref={typeof ref === 'function' ? defaultRef : ref}
disabled={disabled}
id={id}
aria-labelledby={labelId}


+ 99
- 75
categories/multichoice/react/src/components/TagInput/index.tsx View File

@@ -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,
};

+ 28
- 15
packages/react-utils/src/hooks/form.ts View File

@@ -7,43 +7,47 @@ interface HTMLElementWithValue extends HTMLElement {
value: ValueType;
}

export interface UseProxyInputOptions<Element extends HTMLElementWithValue, Value = ValueType> {
forwardedRef: React.Ref<Element>;
export interface UseProxyInputOptions<
ForwardedRef extends HTMLElementWithValue,
Value = ValueType,
> {
forwardedRef: React.Ref<ForwardedRef>;
valueSetterFn: (value: Value) => void;
transformChangeHandlerArgs?: (...e: unknown[]) => Value;
transformChangeHandlerArgs?: (value: Value) => ValueType;
}

export const useProxyInput = <
T extends HTMLElementWithValue = HTMLElementWithValue,
V = ValueType,
>(options: UseProxyInputOptions<T, V>) => {
TheValueType = ValueType,
ForwardedRefType extends HTMLElementWithValue = HTMLElementWithValue,
DefaultRefType extends HTMLElementWithValue = ForwardedRefType,
>(options: UseProxyInputOptions<ForwardedRefType, TheValueType>) => {
const {
forwardedRef,
valueSetterFn,
transformChangeHandlerArgs = (e) => e as V,
transformChangeHandlerArgs = (e) => e as ValueType,
} = options;
const defaultRef = React.useRef<T>(null);
const defaultRef = React.useRef<DefaultRefType>(null);

React.useEffect(() => {
const { current: currentRaw } = defaultRef;
const current = currentRaw as unknown as T;
const current = currentRaw as unknown as ForwardedRefType;
const forwardedRefProxy = new Proxy(current, {
get(target, prop, receiver) {
return Reflect.get(target, prop, receiver) as unknown;
},
set(target, prop, newValue, receiver) {
if (prop === 'value') {
const thisNewValue = newValue as V;
const thisNewValue = newValue as TheValueType;
const actualNewValue = (
Array.isArray(thisNewValue) ? thisNewValue.join('') : thisNewValue?.toString() ?? ''
) as V;
) as TheValueType;
valueSetterFn(actualNewValue);
current.value = actualNewValue as string;
return true;
}
return Reflect.set(target, prop, newValue, receiver);
},
}) as unknown as T;
}) as unknown as ForwardedRefType;

if (typeof forwardedRef === 'function') {
forwardedRef(forwardedRefProxy);
@@ -51,12 +55,21 @@ export const useProxyInput = <
}

if (typeof forwardedRef === 'object' && forwardedRef) {
const mutableForwardedRef = forwardedRef as React.MutableRefObject<T>;
const mutableForwardedRef = forwardedRef as React.MutableRefObject<ForwardedRefType>;
mutableForwardedRef.current = forwardedRefProxy;
}
});

const handleChange = React.useCallback((e: unknown) => {
// todo Handle input event

// do we need to expose this? or is defaultref enough?
// maybe we could attach the event listener to the defaultRef instead of exposing a handler (as
// long as target element exposes a ref)

// why do we have to do all this? because we want to proxy event handlers to fallback elements.
// fallback elements ensure server-side compatibility--with this, we can serialize every value
// that can be inputted in every custom control. (see graceful degradation)
const handleChange = React.useCallback((e: TheValueType) => {
if (!(typeof defaultRef === 'object' && defaultRef)) {
return;
}
@@ -65,7 +78,7 @@ export const useProxyInput = <
return;
}
const transformedValue = transformChangeHandlerArgs(e);
valueSetterFn(transformedValue);
valueSetterFn(e);
setTimeout(() => {
delegateTriggerEvent('change', input, transformedValue);
});


+ 0
- 1
packages/react-utils/src/index.ts View File

@@ -1,4 +1,3 @@
export * from './hooks/client-side';
export * from './hooks/form';
export * from './hooks/id';
export * from './event';

Loading…
Cancel
Save