tusen-ai / naive-ui

A Vue 3 Component Library. Fairly Complete. Theme Customizable. Uses TypeScript. Fast.
https://www.naiveui.com
MIT License
16.17k stars 1.67k forks source link

Input mask on form inputs #1385

Open thamibn opened 3 years ago

thamibn commented 3 years ago

This function solves the problem (这个功能解决的问题)

Avoids users to enter wrong data from the get go

Expected API (期望的 API)

This will allow us to mask inputs to specific format e.g (##)-(####)

Means a user will enter the data in that format.

Find below pure JavaScript example https://imask.js.org/

07akioni commented 3 years ago

I've done some experiments and find it's not easy to integrate it whether inside or outside the library.

The main problems are that they don't offer a DOM independant API and the handling of selection range issues of the input element.

However this is a somehow useful feature. I'll try to make it work later.

B3nsten commented 2 years ago

Might not be the optimal solution, but vue-the-mask offers a directive, that can easily be ported to Vue3 and is indeed working (at least for my purposes) with the existing NInput element. With a wrapper component and some tweaking, it's also possible to change between masked/unmasked value (while the displayed value is masked) and also get selection persistence.

./directives/mask.js

function event(name, detail) {
  const evt = new Event(name, { bubbles: true, cancelable: true })
  return evt
}

function dynamicMask(maskit, masks, tokens) {
  masks = masks.sort((a, b) => a.length - b.length)
  return function (value, mask, masked = true) {
    let i = 0
    while (i < masks.length) {
      const currentMask = masks[i]
      i++
      const nextMask = masks[i]
      if (!(nextMask && maskit(value, nextMask, true, tokens).length > currentMask.length)) {
        return maskit(value, currentMask, masked, tokens)
      }
    }
    return '' // empty masks
  }
}

function maskit(value, mask, masked = true, tokens) {
  value = value || ''
  mask = mask || ''
  let iMask = 0
  let iValue = 0
  let output = ''
  while (iMask < mask.length && iValue < value.length) {
    let cMask = mask[iMask]
    const masker = tokens[cMask]
    const cValue = value[iValue]
    if (masker && !masker.escape) {
      if (masker.pattern.test(cValue)) {
        output += masker.transform ? masker.transform(cValue) : cValue
        iMask++
      }
      iValue++
    } else {
      if (masker && masker.escape) {
        iMask++ // take the next mask char and treat it as char
        cMask = mask[iMask]
      }
      if (masked) output += cMask
      if (cValue === cMask) iValue++ // user typed the same char
      iMask++
    }
  }

  // fix mask that ends with a char: (#)
  let restOutput = ''
  while (iMask < mask.length && masked) {
    const cMask = mask[iMask]
    if (tokens[cMask]) {
      restOutput = ''
      break
    }
    restOutput += cMask
    iMask++
  }

  return output + restOutput
}

export function masker(value, mask, masked = true, tokens) {
  return Array.isArray(mask)
    ? dynamicMask(maskit, mask, tokens)(value, mask, masked, tokens)
    : maskit(value, mask, masked, tokens)
}

export const tokens = {
  '#': { pattern: /\d/ },
  X: { pattern: /[0-9a-zA-Z]/ },
  S: { pattern: /[a-zA-Z]/ },
  A: { pattern: /[a-zA-Z]/, transform: v => v.toLocaleUpperCase() },
  H: { pattern: /[0-9a-fA-F]/, transform: v => v.toLocaleUpperCase() },
  a: { pattern: /[a-zA-Z]/, transform: v => v.toLocaleLowerCase() },
  '!': { escape: true }
}

export default function (el, binding, vnode) {
  let config = binding.value
  if (Array.isArray(config) || typeof config === 'string') {
    config = {
      mask: config,
      tokens: tokens
    }
  } else return

  if (el.tagName.toLocaleUpperCase() !== 'INPUT') {
    const els = el.getElementsByTagName('input')
    if (els.length !== 1) {
      throw new Error("v-mask directive requires 1 input, found " + els.length)
    } else {
      el = els[0]
    }
  }

  let position = el.selectionEnd
  const digit = el.value[position - 1]
  const newDisplay = masker(el.value, config.mask, true, config.tokens)
  if (newDisplay !== el.value) {
    el.value = newDisplay
    el.dispatchEvent(event('input'))
    while (position < el.value.length && el.value.charAt(position - 1) !== digit) {
      position++
    }
    if (el === document.activeElement) {
      el.setSelectionRange(position, position)
      setTimeout(function () {
        el.setSelectionRange(position, position)
      }, 0)
    }
  }
}

./main.js

import mask from './directives/mask'
app.directive('mask', mask)

./components/MaskedInput.vue

<template>
  <n-input :value="display" v-mask="mask" @input="refresh" />
</template>

<script setup>
import { masker, tokens } from "@/directives/mask";

const emit = defineEmits(["update:value"]);

const props = defineProps({
  value: [String, Number],
  mask: {
    type: [String, Array],
    required: true,
  },
  masked: {
    type: Boolean,
    default: false,
  },
});
const { mask, masked, value } = toRefs(props);

const display = ref(value.value);
const lastValue = ref(null);

watch(
  () => value.value,
  (newValue) => {
    if (newValue !== lastValue.value) {
      display.value = newValue;
    }
  }
);

watch(
  () => masked.value,
  () => refresh(display.value)
);

const refresh = (value) => {
  display.value = value;
  const val = masker(value, mask.value, masked.value, tokens);
  if (val !== lastValue.value) {
    lastValue.value = val;
    emit("update:value", val);
  }
};
</script>

Use somewhere

<masked-input v-model:value="value" :mask="'##/##/####'" />
brunotourinho commented 1 year ago

Hey @B3nsten did you get it to work somehow? I'm in a project that demands masks, including date and currency.

B3nsten commented 1 year ago

Hey @B3nsten did you get it to work somehow? I'm in a project that demands masks, including date and currency.

The posted code was working. What's erroring for you?

brunotourinho commented 1 year ago

I'll give it a try, now! Have to adapt a bit because Im working with Nuxt 3!

brunotourinho commented 1 year ago

It worked ❤️! Now I'm missing currency mask. Any chance you have it already?

B3nsten commented 1 year ago

Well, depending on your needs you might get away with something like:

<masked-input v-model:value="value" :mask="['#.##', '##.##', '###.##']" />

You'd have to change a lot of things if you need negative numbers, want to handle the money prefix inside the input (I'd use a naive input group, tho), etc. This approach is not the best for handling variable input lengths and in this state is not suitable to handle anything but strings.

brunotourinho commented 1 year ago

Thanks a bunch! I've been working on this feature for a week! You saved me!

rom-rzic commented 1 year ago

Hello. There is an easier way, it is not necessary to create a new MaskedInput component, you can use the package https://github.com/beholdr/maska , and to set the mask, use as <n-input placeholder="Phone number *" v-model:value="form.phone" :input-props="{ vMaska: true, 'data-mask': '+# ### ###-##-##' }"/>

golddeitys commented 1 year ago

@rom-rzic does it work? Tried but nothing happened

rassimjhan commented 1 year ago

Hello. There is an easier way, it is not necessary to create a new MaskedInput component, you can use the package https://github.com/beholdr/maska , and to set the mask, use as <n-input placeholder="Phone number *" v-model:value="form.phone" :input-props="{ vMaska: true, 'data-mask': '+# ### ###-##-##' }"/>

Hello! Could you show how register vMaska in component? I have tried your example but in doesn't work. I register maska in script tag import { vMaska } from "maska"

rassimjhan commented 1 year ago

Hello. There is an easier way, it is not necessary to create a new MaskedInput component, you can use the package https://github.com/beholdr/maska , and to set the mask, use as <n-input placeholder="Phone number *" v-model:value="form.phone" :input-props="{ vMaska: true, 'data-mask': '+# ### ###-##-##' }"/>

Hello! Could you show how register vMaska in component? I have tried your example but in doesn't work. I register maska in script tag import { vMaska } from "maska"

I found the solution!

 <n-input
   v-model:value="formData.user.phone"
   placeholder="+7 700 777 77 77"
   v-maska
   :input-props="{
     'data-maska': '+# ### ### ## ##',
   }"
/>

The reason why @rom-rzic example didn't work because 'data-mask' and he didn't set v-maska directive in n-input also you don't have to set vMaska: true in input-props. You have to use 'data-maska' and don't forget to set v-maska directive!

Welcome 🎉   

rassimjhan commented 1 year ago

@rom-rzic does it work? Tried but nothing happened

Solution https://github.com/tusen-ai/naive-ui/issues/1385#issuecomment-1728124131

gglazewskigran commented 12 months ago

someone got it working for n-input pair?