483

useMask

Hook to apply an input mask

statemediumtest coverage

Checkout

Enter your details to complete the payment of $49.00.

import { useField, useMask } from '@siberiacancode/reactuse';

import { cn } from '@/utils/lib';

interface Country {
  code: string;
  flag: string;
  mask: string;
  name: string;
}

const COUNTRIES = [
  { code: '7', name: 'Russia', flag: 'πŸ‡·πŸ‡Ί', mask: '+9 (999) 999-99-99' },
  { code: '1', name: 'USA', flag: 'πŸ‡ΊπŸ‡Έ', mask: '+9 (999) 999-9999' },
  { code: '44', name: 'UK', flag: 'πŸ‡¬πŸ‡§', mask: '+99 9999 999999' },
  { code: '77', name: 'Kazakhstan', flag: 'πŸ‡°πŸ‡Ώ', mask: '+99 (999) 999-99-99' },
  { code: '380', name: 'Ukraine', flag: 'πŸ‡ΊπŸ‡¦', mask: '+999 (99) 999-99-99' },
  { code: '998', name: 'Uzbekistan', flag: 'πŸ‡ΊπŸ‡Ώ', mask: '+999 (99) 999-99-99' }
] as const;

const DEFAULT_MASK = '999999999999999';
const INITIAL_DATE_VALUE = '05062026';

const SORTED_COUNTRIES = COUNTRIES.toSorted((a, b) => b.code.length - a.code.length);

const detectCountry = (rawValue: string): Country | undefined =>
  SORTED_COUNTRIES.find((country) => rawValue.startsWith(country.code));

const Demo = () => {
  const name = useField('');
  const cvv = useField('');

  const phoneMask = useMask(DEFAULT_MASK, {
    showMask: 'never',
    modify: (rawValue) => ({ mask: detectCountry(rawValue)?.mask ?? DEFAULT_MASK }),
    beforeMaskedChange: ({ nextState }) => ({
      ...nextState,
      selection: { start: nextState.value.length, end: nextState.value.length }
    })
  });

  const phone = phoneMask.watch();

  const cardNumber = useMask('9999 9999 9999 9999', {
    showMask: 'never'
  });
  const expiry = useMask('99/99', { showMask: 'never' });
  const paymentDate = useMask('99/99/9999', {
    showMask: 'never',
    initialValue: INITIAL_DATE_VALUE
  });

  const country = detectCountry(phone.rawValue);

  return (
    <section className='flex w-full max-w-md flex-col gap-4 p-4'>
      <div className='flex flex-col gap-1'>
        <h2 className='text-foreground text-base font-semibold'>Checkout</h2>
        <p className='text-muted-foreground text-xs'>
          Enter your details to complete the payment of $49.00.
        </p>
      </div>

      <div className='flex flex-col gap-1.5'>
        <label className='text-foreground text-xs font-medium'>Full name</label>
        <input
          className='border-border bg-card text-foreground rounded-md border px-3 py-2 text-sm outline-none'
          placeholder='John Carter'
          {...name.register()}
        />
      </div>

      <div className='flex flex-col gap-1.5'>
        <label className='text-foreground text-xs font-medium'>Phone number</label>
        <div className='relative'>
          {country && (
            <span className='pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-base'>
              {country.flag}
            </span>
          )}
          <input
            className={cn(
              'border-border bg-card text-foreground w-full rounded-md border py-2 pr-3 text-sm outline-none',
              country ? 'pl-10!' : 'pl-3'
            )}
            inputMode='tel'
            placeholder='Start typing with country code'
            {...phoneMask.register()}
          />
        </div>
        {country && <span className='text-muted-foreground px-1 text-[10px]'>{country.name}</span>}
      </div>

      <div className='flex flex-col gap-3'>
        <label className='text-foreground text-xs font-medium'>Card details</label>

        <input
          className='border-border bg-card text-foreground rounded-md border px-3 py-2 font-mono text-sm tracking-wider outline-none'
          inputMode='numeric'
          placeholder='1234 5678 9012 3456'
          {...cardNumber.register()}
        />

        <div className='flex gap-2'>
          <input
            className='border-border bg-card text-foreground w-full rounded-md border px-3 py-2 font-mono text-sm outline-none'
            inputMode='numeric'
            placeholder='MM/YY'
            {...expiry.register()}
          />
          <input
            className='border-border bg-card text-foreground w-full rounded-md border px-3 py-2 font-mono text-sm outline-none'
            inputMode='numeric'
            placeholder='DD/MM/YYYY'
            {...paymentDate.register()}
          />
          <input
            className='border-border bg-card text-foreground w-full rounded-md border px-3 py-2 font-mono text-sm outline-none'
            inputMode='numeric'
            maxLength={3}
            placeholder='CVV'
            type='password'
            {...cvv.register()}
          />
        </div>

        <div className='flex items-center justify-end'>
          <button data-size='sm' type='button'>
            Pay $49.00
          </button>
        </div>
      </div>
    </section>
  );
};

export default Demo;

Installation

pnpm add @siberiacancode/reactuse

Usage

const phoneMask = useMask('+7 (999) 999-99-99');

Type Declarations

import type {
  ChangeEventHandler,
  ClipboardEventHandler,
  FocusEventHandler,
  KeyboardEventHandler,
  MouseEventHandler,
  RefObject
} from 'react';

export type UseMaskPattern = string | Array<string | RegExp>;

export interface UseMaskOptions {
  /** Clear value on blur when mask is incomplete */
  autoClear?: boolean;
  /** Initial raw value */
  initialValue?: string;
  /** Mask pattern string or array of string literals and RegExp objects */
  mask: UseMaskPattern;
  /** Defines when mask slots are displayed */
  showMask?: UseMaskShow;
  /** Character displayed in unfilled slot */
  slot?: string;
  /** Override or extend the default token map */
  tokens?: Record<string, RegExp>;
  /** Escape hatch for advanced cursor/value manipulation */
  beforeMaskedChange?: (states: {
    previousState: MaskState;
    currentState: MaskState;
    nextState: MaskState;
  }) => MaskState;
  /** Called before masking on each keystroke, can return overrides for mask options */
  modify?: (
    value: string
  ) => Partial<Pick<UseMaskOptions, 'mask' | 'showMask' | 'slot' | 'tokens'>>;
  /** Called on every change with raw and masked values */
  onChangeRaw?: (rawValue: string, maskedValue: string) => void;
  /** Called when all required mask slots are filled */
  onFilled?: (maskedValue: string, rawValue: string) => void;
  /** Transform each character before validation and insertion */
  transform?: (char: string) => string;
}

export interface MaskState {
  selection: { start: number; end: number } | null;
  value: string;
}

export interface UseMaskRegisterParams {
  /** The blur event handler */
  onBlur?: FocusEventHandler<HTMLInputElement>;
  /** The change event handler */
  onChange?: ChangeEventHandler<HTMLInputElement>;
  /** The focus event handler */
  onFocus?: FocusEventHandler<HTMLInputElement>;
  /** The key down event handler */
  onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
  /** The mouse down event handler */
  onMouseDown?: MouseEventHandler<HTMLInputElement>;
  /** The mouse up event handler */
  onMouseUp?: MouseEventHandler<HTMLInputElement>;
  /** The paste event handler */
  onPaste?: ClipboardEventHandler<HTMLInputElement>;
}

export type UseMaskShow = 'always' | 'filled' | 'focus' | 'never';

export type UseMaskGetValueType = 'display' | 'masked' | 'raw';

export interface UseMaskGetValueMap {
  display: string;
  masked: string;
  raw: string;
}

export interface UseMaskValue {
  /** Current value displayed in the input */
  displayValue: string;
  /** Whether all required mask slots are filled */
  filled: boolean;
  /** Current masked value without placeholder slots */
  maskedValue: string;
  /** Current raw unmasked value */
  rawValue: string;
  /** Current value displayed in the input */
  value: string;
}

export interface UseMaskReturn {
  /** The input ref */
  ref: RefObject<HTMLInputElement | null>;
  /** Get current mask value */
  getValue: <Type extends UseMaskGetValueType = 'raw'>(type?: Type) => UseMaskGetValueMap[Type];
  /** Register the masked input */
  register: (params?: UseMaskRegisterParams) => {
    onBlur?: FocusEventHandler<HTMLInputElement>;
    onChange?: ChangeEventHandler<HTMLInputElement>;
    onFocus?: FocusEventHandler<HTMLInputElement>;
    onKeyDown?: KeyboardEventHandler<HTMLInputElement>;
    onMouseDown?: MouseEventHandler<HTMLInputElement>;
    onMouseUp?: MouseEventHandler<HTMLInputElement>;
    onPaste?: ClipboardEventHandler<HTMLInputElement>;
    ref: (node: HTMLInputElement | null | undefined) => void;
  };
  /** Clear the input value and reset state */
  reset: () => void;
  /** Set the raw input value */
  setValue: (value: string) => void;
  /** The watch function */
  watch: () => UseMaskValue;
}

export type UseMaskReturnValue = UseMaskReturn;

interface MaskLiteralSlot {
  char: string;
  optional?: boolean;
  type: 'literal';
}

interface MaskTokenSlot {
  char: string;
  optional?: boolean;
  pattern: RegExp;
  type: 'token';
}

export type MaskSlot = MaskLiteralSlot | MaskTokenSlot;

interface UndoState {
  rawValue: string;
  selectionStart: number;
}

API

Parameters

NameTypeDefaultNote
maskUseMaskPattern-Mask pattern string or array of literals and RegExp tokens
optionsOmit<UseMaskOptions, 'mask'>-The hook options when mask is passed as the first argument
options.autoClearbooleanfalseClear value on blur when mask is incomplete
options.initialValuestring""Initial raw value
options.modify(value: string) => Partial<Pick<UseMaskOptions, 'mask' | 'showMask' | 'slot' | 'tokens'>>-Called before masking and can return dynamic mask option overrides
options.showMaskUseMaskShow"focus"Defines when placeholder slots are displayed
options.slotstring | null""Character displayed in unfilled slots
options.tokensRecord<string, RegExp>-Override or extend the default token map
options.beforeMaskedStateChange(states: { previousState: MaskState; currentState: MaskState; nextState: MaskState }) => MaskState-Escape hatch for advanced cursor/value manipulation
options.onChangeRaw(rawValue: string, maskedValue: string) => void-Called on every change with raw and display values
options.onFilled(maskedValue: string, rawValue: string) => void-Called when all required mask slots are filled
options.transform(char: string) => string-Transform each character before validation and insertion

Returns

UseMaskReturn

Contributors

ddebabin

Last updated on