uNmAnNeR / imaskjs

vanilla javascript input mask
https://imask.js.org
MIT License
4.96k stars 258 forks source link

Lazy focus mode #1017

Open dicash opened 8 months ago

dicash commented 8 months ago

What problem you are trying to solve?

We're using imask as part of mui-like input component, it has placeholder displayed by default, and upon click moves it up and shows currency input.

https://github.com/uNmAnNeR/imaskjs/assets/136486874/379bdacd-4ca7-4a97-b6e9-61cfd8f07e2d

We'd like to use imask lazy=false to display dollar sign when input is focused, and use lazy=true when it's not.

https://github.com/uNmAnNeR/imaskjs/assets/136486874/13f50339-cce4-45a6-b6b8-bf46757d8c70

Describe the solution you'd like

We'd like to have lazy='focus' mode, that enables lazy mode only when the input is focused, and can display native placeholder otherwise.

Describe alternatives you've considered

We considered exposing imask instance via imperative handle to parent component with manual control of onfocus/onblur, but we preserve ref for external form validator so we cannot use it unfortunately.

giacomocerquone commented 7 months ago

You can actually share the ref to be used both for masking and form purposes. Can you share some code to understand more your use case please?

dicash commented 7 months ago

It's very complicated setup unfortunately with nextjs / SSR, so we are forced to use ref forwarded from parent component, so we don't have access to it...

giacomocerquone commented 7 months ago

It's very complicated setup unfortunately with nextjs / SSR, so we are forced to use ref forwarded from parent component, so we don't have access to it unfortunately...

I'm sorry. I don't want to say that there is always a way if I didn't see the code, but 99% of times it's easily doable. But If you can't even share a simple reproduction/example of what kind of structure you're talking about, this issue should be closed.

Ah, and btw, it hasn't got anything to do with SSR and/or Next.js, this is just plain react.

If I had to guess, you're using react-hook-form and need the component's ref, in that case a simple

const { onChange, onBlur, name, ref } = register('firstName'); 

<Input 
  ...
  ref={(node) => {
    ref.current = node;
    // use node however you want, with useImperativeHandle for example
  }} 
/>

Of course this can be done from input's parent also once you forward its ref as it seems you're already doing.

dicash commented 7 months ago

Ok here's snippet based on shadcn:

// input-currency
import * as React from 'react'
import { useId } from 'react'
import { Input, InputFieldProps, InputProps } from './input'
import { InputMask, InputMaskProps } from './input-mask'

const InputCurrency = React.forwardRef<
  HTMLInputElement,
  Omit<InputProps, 'value' | 'defaultValue'> &
    InputProps &
    InputFieldProps &
    InputMaskProps & {
      value?: string
      currency?: string
      defaultValue?: string
      lang?: 'en' | 'fr'
      max?: boolean
      min?: boolean
    }
>(
  (
    {
      className,
      type,
      icon,
      value,
      label,
      defaultValue,
      max,
      min,
      currency = 'CAD',
      ...props
    },
    parentRef
  ) => {
    let id = useId()
    id = props.id || `currency-${id}`

    const { mask, ...format } = {
      mask: '$ num',
      thousandsSeparator: ',',
      radix: '.',
      mapToRadix: [','],
    }

    return (
      <InputMask
        mask={mask}
        label={label}
        placeholder="$"
        ref={parentRef}
        lazy={false}
        blocks={{
          num: {
            mask: Number,
            expose: true,
            scale: 2,
            normalizeZeros: true,
            padFractionalZeros: true,
            autofix: true,
            max,
            min,
            ...format,
          },
        }}
        inputMode="decimal"
        defaultValue={defaultValue}
        icon={currency}
        {...props}
      />
    )
  }
)
// input-mask.tsx
import * as React from 'react'
import { Input, InputFieldProps, InputProps } from './input'
import IMask, { Definitions, InputMask as Mask, MaskedNumber } from 'imask'

export const radixOperator = (language?: string) => {
  const value = new Intl.NumberFormat(language).format(0.11)
  const radix = value[1]
  return radix
}

export const thousandsSeparator = (language?: string) => {
  const value = new Intl.NumberFormat(language).format(1000)
  const thousandSeparator = value[1]
  return thousandSeparator
}

export type InputMaskProps = {
  icon?: React.ReactNode
  mask?: string | NumberConstructor
  blocks?: {}
  definitions?: Definitions
  displayChar?: string
  overwrite?: 'shift'
  lazy?: boolean
  normalizeZeros?: boolean
  padFractionalZeros?: boolean
  autofix?: boolean
  scale?: number
  thousandsSeparator?: string
  radix?: string
  min?: string | number
  max?: string | number
  defaultValue?: string
  onComplete?: (value: string, unmaskedValue: string) => void
  onAccept?: (value: string, unmaskedValue: string) => void
}

const InputMask = React.forwardRef<HTMLInputElement, InputProps & InputFieldProps & InputMaskProps>(
  (
    {
      mask,
      displayChar,
      overwrite,
      lazy = true,
      blocks,
      definitions,
      onComplete,
      onAccept,
      scale,
      thousandsSeparator = '',
      radix = ',',
      min,
      max,
      defaultValue,
      normalizeZeros,
      autofix,
      padFractionalZeros,
      ...props
    },
    parentRef
  ) => {
    const masked = React.useRef<Mask<any>>()
    const [input, setInput] = React.useState<HTMLInputElement | null>()

    // NOTE: we can't use ref as dependency since it may trigger effect twice, which erases value.
    // see https://legacy.reactjs.org/docs/hooks-faq.html#how-can-i-measure-a-dom-node
    React.useLayoutEffect(() => {
      if (!input) return

      masked.current = IMask<any>(input, {
        mask: mask,
        definitions,
        displayChar,
        overwrite,
        lazy,
        blocks,
        scale,
        thousandsSeparator,
        radix,
        min,
        max,
        normalizeZeros,
        padFractionalZeros,
      })

      masked.current.value = (defaultValue ?? '') as string

      // eg. when country changes - we have to update proper phone
      if (masked.current.masked.isComplete)
        onComplete?.(masked.current.value, masked.current.unmaskedValue)

      return () => masked.current?.destroy()
    }, [input, mask])

    // we need to have an effect since onAccessCallback changes independently and more frequently than ref.current
    React.useLayoutEffect(() => {
      if (!masked.current) return
      const { current } = masked
      const onCompleteCallback = () => onComplete?.(current.value, current.unmaskedValue)
      const onAcceptCallback = () => onAccept?.(current.value, current.unmaskedValue)
      current.on('accept', onAcceptCallback)
      current.on('complete', onCompleteCallback)

      return () => {
        current.off('complete', onCompleteCallback)
        current.off('accept', onAcceptCallback)
      }
    }, [onComplete, onAccept, mask, input])

    React.useImperativeHandle(parentRef, () => input as HTMLInputElement)

    return <Input ref={(node) => setInput(node)} {...props} />
  }
)

InputMask.displayName = 'InputMask'

export { InputMask }
// input.tsx
import * as React from 'react'
import clsx from 'clsx'
import { useId } from 'react'
import { IconWarning } from './icon'

export interface InputProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'value'> {
  value?: string | null
}

const Input = React.forwardRef<
  HTMLInputElement,
  InputFieldProps & InputProps & { icon?: React.ReactNode; rounded?: boolean }
>(({ className, type, icon, invalid, before, rounded, ...props }, parentRef) => {
  let id = useId()
  id = props.id || `input-${id}`
  return (
    <InputField
      {...{
        id,
        ...props,
        invalid,
        before,
        icon: (
          <>
            {icon}
            {invalid ? <IconWarning size="lg" className="text-danger-600" /> : null}
          </>
        ),
      }}
    >
      <input
        id={id}
        type={type || 'text'}
        className={clsx(
          'peer flex h-14 w-full border bg-white pl-4 py-2 text-base file:border-0 file:bg-transparent file:text-base file:font-medium placeholder-neutral-400 focus-visible:outline-none focus-visible:ring-4 focus-visible:ring-offset-0 focus-visible:border disabled:cursor-not-allowed  border-neutral-400 disabled:border-neutral-300 disabled:text-neutral-400',
          invalid && icon ? 'pr-20' : invalid || icon ? 'pr-12' : 'pr-4',
          before ? '!pl-12' : '',
          props.label ? 'pt-6' : '',
          rounded ? 'rounded-full' : 'rounded-md',
          invalid
            ? '!border-danger-600 focus-visible:ring-danger-200'
            : 'focus-visible:ring-primary-300 focus-visible:border-primary-500',
          className
        )}
        ref={parentRef}
        placeholder=" "
        {...props}
        value={props.value === null ? (props.defaultValue == null ? '' : undefined) : props.value}
      />
    </InputField>
  )
})

Input.displayName = 'Input'

export interface InputFieldProps {
  id?: string
  className?: string
  label?: React.ReactNode
  info?: React.ReactNode
  icon?: React.ReactNode
  before?: React.ReactNode
  invalid?: boolean
  disabled?: boolean
  placeholder?: string
  children?: React.ReactNode
  value?: string | number | null | undefined
}

// takes input element and wraps with error, support, icon etc.
export const InputField = ({
  id,
  className,
  info,
  invalid,
  label,
  icon,
  children,
  disabled,
  placeholder,
  before,
  ...props
}: InputFieldProps) => {
  return (
    <div className={clsx('relative w-full items-center gap-1.5 group', className)}>
      {before ? (
        <div className="absolute left-4 h-14 top-0 flex flex-row gap-2 items-center text-neutral-600 z-10">
          {before}
        </div>
      ) : null}
      {children}
      {label ? (
        <label
          className={clsx(
            'absolute left-4 right-8 transition-all top-2 truncate max-w-full cursor-text',
            !id && 'pointer-events-none',
            invalid
              ? '!text-danger-600 peer-focus:text-danger-600'
              : disabled
              ? '!text-neutral-400'
              : 'text-neutral-600 peer-focus:text-primary-500',
            // NOTE: we suppose if props have value, then input is controllable and we handle label differently (see select vs text/input)
            'value' in props
              ? props.value == null
                ? placeholder
                  ? 'text-xs'
                  : 'top-4 text-base'
                : 'text-xs peer-focus:top-2 peer-focus:text-xs'
              : !placeholder
              ? 'peer-focus:top-2 peer-focus:text-xs text-xs peer-placeholder-shown:top-4 peer-placeholder-shown:text-base'
              : 'text-xs'
          )}
          htmlFor={id}
        >
          {label}
        </label>
      ) : null}
      {icon ? (
        <div className="absolute right-4 h-14 top-0 flex flex-row gap-2 items-center text-neutral-600">
          {icon}
        </div>
      ) : null}
      {info ? (
        <p
          className={clsx(
            'text-xs mt-1',
            disabled ? 'text-neutral-400' : invalid ? 'text-danger-600' : 'text-neutral-600'
          )}
        >
          {info}
        </p>
      ) : null}
    </div>
  )
}

export { Input }

I will try to shave off code to get more minimal sample.

giacomocerquone commented 6 months ago

Ok here's snippet based on shadcn: ... I will try to shave off code to get more minimal sample.

I've got your sample. I need to create a small reproduction, removing a lot of stuff. Btw other than being noisy, unless you've stripped some stuff, it also contains useless pieces such as the empty useImperativeHandle.

I'll update you 🙂

mrambold commented 1 month ago

@giacomocerquone Has there been any progress on this? I have the same requirements as @dicash: Would it be possible to make the lazy prop react to changes, so that the mask is only shown, when the input has focus?

Here is a very simple example of the scenario: https://stackblitz.com/edit/vue3-vite-starter-as5sug?file=src%2FApp.vue