|
- import * as React from 'react';
- import { TextControl, tailwind } from '@tesseract-design/web-base';
- import { useClientSide, useFallbackId } from '@modal-sh/react-utils';
-
- const { tw } = tailwind;
-
- const MaskedTextInputDerivedElementComponent = 'input' as const;
-
- /**
- * Derived HTML element of the {@link MaskedTextInput} component.
- */
- export type MaskedTextInputDerivedElement = HTMLElementTagNameMap[
- typeof MaskedTextInputDerivedElementComponent
- ];
-
- type SelectionDirection = 'none' | 'forward' | 'backward';
-
- const AVAILABLE_AUTO_COMPLETE_VALUES = ['current-password', 'new-password'] as const;
-
- /**
- * Props of the {@link MaskedTextInput} component.
- */
- export interface MaskedTextInputProps extends Omit<React.HTMLProps<MaskedTextInputDerivedElement>, 'autoComplete' | 'size' | 'type' | 'label' | 'pattern'> {
- /**
- * 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?: TextControl.Size,
- /**
- * Should the component display a border?
- */
- border?: boolean,
- /**
- * Should the component occupy the whole width of its parent?
- */
- block?: boolean,
- /**
- * Style of the component.
- */
- variant?: TextControl.Variant,
- /**
- * Is the label hidden?
- */
- hiddenLabel?: boolean,
- /**
- * Visual length of the input.
- */
- length?: number,
- /**
- * Should the component be enhanced?
- */
- enhanced?: boolean,
- /**
- * Autocomplete value type of the component.
- */
- autoComplete?: typeof AVAILABLE_AUTO_COMPLETE_VALUES[number],
- }
-
- /**
- * Component for inputting textual values.
- */
- export const MaskedTextInput = React.forwardRef<
- MaskedTextInputDerivedElement,
- MaskedTextInputProps
- >((
- {
- label,
- hint,
- size = 'medium' as const,
- border = false as const,
- block = false as const,
- variant = 'default' as const,
- hiddenLabel = false as const,
- className,
- id: idProp,
- style,
- length,
- disabled,
- onKeyDown,
- onKeyUp,
- autoComplete,
- enhanced: enhancedProp = false as const,
- ...etcProps
- }: MaskedTextInputProps,
- forwardedRef,
- ) => {
- const { clientSide: enhanced } = useClientSide({ clientSide: enhancedProp });
- const labelId = React.useId();
- const id = useFallbackId(idProp);
- const [visible, setVisible] = React.useState(false);
- const [visibleViaKey, setVisibleViaKey] = React.useState(false);
- const defaultRef = React.useRef<MaskedTextInputDerivedElement>(null);
- const ref = forwardedRef ?? defaultRef;
-
- const handleKeyDown: React.KeyboardEventHandler<
- MaskedTextInputDerivedElement
- > = React.useCallback((e) => {
- if (e.ctrlKey && e.code === 'Space') {
- setVisibleViaKey(true);
- }
- onKeyDown?.(e);
- }, [onKeyDown]);
-
- const handleKeyUp: React.KeyboardEventHandler<
- MaskedTextInputDerivedElement
- > = React.useCallback((e) => {
- if (e.ctrlKey && e.code === 'Space') {
- setVisibleViaKey(false);
- setVisible((prev) => !prev);
- }
- onKeyUp?.(e);
- }, [onKeyUp]);
-
- const handleToggleVisible = React.useCallback(() => {
- const effectiveRef = typeof ref === 'object' ? ref : defaultRef;
- const current = effectiveRef.current as MaskedTextInputDerivedElement;
- const selectionStart = current.selectionStart as number;
- const selectionEnd = current.selectionEnd as number;
- const selectionDirection = current.selectionDirection as SelectionDirection;
-
- setVisible((prev) => !prev);
- setTimeout(() => {
- current.focus();
- current.setSelectionRange(selectionStart, selectionEnd, selectionDirection);
- });
- }, [ref, defaultRef]);
-
- // const preserveFocus: React.MouseEventHandler<HTMLButtonElement> = React.useCallback(() => {
- // const { current } = typeof ref === 'object' ? ref : defaultRef;
- // setVisibleViaKey(true);
- // setTimeout(() => {
- // current?.focus();
- // });
- // }, [ref, defaultRef]);
-
- // const handleToggleMouseUp: React.MouseEventHandler<HTMLButtonElement> = React.useCallback(() => {
- // const { current } = typeof ref === 'object' ? ref : defaultRef;
- // setVisibleViaKey(false);
- // setTimeout(() => {
- // current?.focus();
- // });
- // }, [ref, defaultRef]);
-
- React.useEffect(() => {
- if (typeof ref === 'function') {
- const defaultElement = defaultRef.current as MaskedTextInputDerivedElement;
- ref(defaultElement);
- }
- }, [defaultRef, ref]);
-
- return (
- <div
- className={tw(
- 'relative rounded ring-secondary/50 overflow-hidden group has-[:disabled]:opacity-50',
- 'focus-within:ring-4',
- {
- 'block': block,
- 'inline-block align-middle': !block,
- },
- className,
- )}
- style={style}
- data-testid="base"
- >
- {label && (
- <>
- <label
- data-testid="label"
- id={labelId}
- htmlFor={id}
- className={tw(
- 'absolute z-[1] w-full top-0.5 left-0 pointer-events-none pl-1 text-xxs font-bold group-focus-within:text-secondary text-primary leading-none bg-negative select-none',
- {
- 'sr-only': hiddenLabel,
- },
- {
- 'pr-1': !enhanced,
- },
- {
- 'pr-10': enhanced && size === 'small',
- 'pr-12': enhanced && size === 'medium',
- 'pr-16': enhanced && size === 'large',
- },
- )}
- >
- <span className="block w-full whitespace-nowrap h-[1.1em] overflow-hidden text-ellipsis">
- {label}
- </span>
- </label>
- {' '}
- </>
- )}
- <MaskedTextInputDerivedElementComponent
- {...etcProps}
- size={length}
- ref={typeof ref === 'function' ? defaultRef : ref}
- disabled={disabled}
- id={id}
- aria-labelledby={labelId}
- type={!visible ? 'password' : 'text'}
- data-testid="input"
- autoComplete={autoComplete ?? 'off'}
- onKeyUp={enhanced ? handleKeyUp : undefined}
- onKeyDown={enhanced ? handleKeyDown : undefined}
- className={tw(
- 'bg-negative rounded-inherit w-full peer block font-inherit',
- 'focus:outline-0',
- 'disabled:opacity-50 disabled:cursor-not-allowed',
- {
- 'text-xxs': size === 'small',
- 'text-xs': size === 'medium',
- },
- {
- 'pl-4': variant === 'default',
- 'pl-1.5': variant === 'alternate',
- },
- {
- 'pt-4': variant === 'alternate',
- },
- {
- 'pr-4': variant === 'default' && !enhanced,
- 'pr-1.5': variant === 'alternate' && !enhanced,
- },
- {
- 'pr-10': enhanced && size === 'small',
- 'pr-12': enhanced && size === 'medium',
- 'pr-16': enhanced && size === 'large',
- },
- {
- 'h-10': size === 'small',
- 'h-12': size === 'medium',
- 'h-16': size === 'large',
- },
- )}
- />
- {hint && (
- <div
- data-testid="hint"
- className={tw(
- 'absolute left-0 px-1 pointer-events-none text-xxs leading-none w-full bg-negative select-none',
- {
- 'bottom-0 pl-4 pb-1': variant === 'default',
- 'top-0.5': variant === 'alternate',
- },
- {
- 'pt-2': variant === 'alternate' && size === 'small',
- 'pt-3': variant === 'alternate' && size !== 'small',
- },
- {
- 'pr-4': !enhanced && variant === 'default',
- 'pr-1': !enhanced && variant === 'alternate',
- },
- {
- 'pr-10': enhanced && size === 'small',
- 'pr-12': enhanced && size === 'medium',
- 'pr-16': enhanced && size === 'large',
- },
- )}
- >
- <div
- className="opacity-50 whitespace-nowrap w-full h-[1.1em] overflow-hidden text-ellipsis"
- >
- {hint}
- </div>
- </div>
- )}
- {enhanced && (
- <button
- disabled={disabled}
- type="button"
- data-testid="indicator"
- tabIndex={-1}
- className={tw(
- 'text-center z-[1] focus:outline-0 flex items-center justify-center aspect-square absolute bottom-0 right-0 select-none',
- {
- 'text-primary group-focus-within:text-secondary group-focus-within:active:text-tertiary': !visibleViaKey,
- 'text-tertiary': visibleViaKey,
- },
- {
- 'w-10': size === 'small',
- 'w-12': size === 'medium',
- 'w-16': size === 'large',
- },
- )}
- onClick={handleToggleVisible}
- title={visible ? 'Hide' : 'Show'}
- >
- <span className="sr-only">
- {visible ? 'Hide' : 'Show'}
- </span>
- <svg
- className="w-6 h-6 fill-none stroke-current stroke-2 linejoin-round linecap-round"
- viewBox="0 0 24 24"
- role="presentation"
- >
- {!visible && (
- <>
- <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
- <circle cx="12" cy="12" r="3" />
- </>
- )}
- {visible && (
- <>
- <path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24" />
- <line x1="1" y1="1" x2="23" y2="23" />
- </>
- )}
- </svg>
- </button>
- )}
- {border && (
- <span
- data-testid="border"
- className="absolute z-[1] inset-0 rounded-inherit border-2 border-primary pointer-events-none group-focus-within:border-secondary"
- />
- )}
- </div>
- );
- });
-
- MaskedTextInput.displayName = 'MaskedTextInput';
-
- MaskedTextInput.defaultProps = {
- label: undefined,
- hint: undefined,
- size: 'medium' as const,
- border: false as const,
- block: false as const,
- variant: 'default' as const,
- hiddenLabel: false as const,
- length: undefined,
- enhanced: false as const,
- autoComplete: undefined,
- };
|