Browse Source

Add x-mask plugin (#2849)

* Add mask plugin

* Warn about missing mask plugin
Caleb Porzio 3 năm trước cách đây
mục cha
commit
efa462bb92

+ 2 - 1
packages/alpinejs/src/alpine.js

@@ -3,7 +3,7 @@ import { mapAttributes, directive, setPrefix as prefix, prefix as prefixed } fro
 import { start, addRootSelector, addInitSelector, closestRoot, findClosest, initTree } from './lifecycle'
 import { mutateDom, deferMutations, flushAndStopDeferringMutations } from './mutation'
 import { mergeProxies, closestDataStack, addScopeToNode, scope as $data } from './scope'
-import { setEvaluator, evaluate, evaluateLater } from './evaluator'
+import { setEvaluator, evaluate, evaluateLater, dontAutoEvaluateFunctions } from './evaluator'
 import { transition } from './directives/x-transition'
 import { clone, skipDuringClone } from './clone'
 import { interceptor } from './interceptor'
@@ -25,6 +25,7 @@ let Alpine = {
     get raw() { return raw },
     version: ALPINE_VERSION,
     flushAndStopDeferringMutations,
+    dontAutoEvaluateFunctions,
     disableEffectScheduling,
     setReactivityEngine,
     closestDataStack,

+ 1 - 0
packages/alpinejs/src/directives/index.js

@@ -24,6 +24,7 @@ import './x-on'
 warnMissingPluginDirective('Collapse', 'collapse', 'collapse')
 warnMissingPluginDirective('Intersect', 'intersect', 'intersect')
 warnMissingPluginDirective('Focus', 'trap', 'focus')
+warnMissingPluginDirective('Mask', 'mask', 'mask')
 
 function warnMissingPluginDirective(name, directiveName, slug) {
     directive(directiveName, (el) => warn(`You can't use [x-${directiveName}] without first installing the "${name}" plugin here: https://alpine.dev/plugins/${slug}`, el))

+ 2 - 2
packages/alpinejs/src/directives/x-model.js

@@ -37,7 +37,7 @@ directive('model', (el, { modifiers, expression }, { effect, cleanup }) => {
     // Allow programmatic overiding of x-model.
     let evaluateSetModel = evaluateLater(el, `${expression} = __placeholder`)
     el._x_model = {
-        get() { 
+        get() {
             let result
             evaluate(value => result = value)
             return result
@@ -80,7 +80,7 @@ function generateAssignmentFunction(el, modifiers, expression) {
     return (event, currentValue) => {
         return mutateDom(() => {
             // Check for event.detail due to an issue where IE11 handles other events as a CustomEvent.
-            // Safari autofill triggers event as CustomEvent and assigns value to target 
+            // Safari autofill triggers event as CustomEvent and assigns value to target
             // so we return event.target.value instead of event.detail
             if (event instanceof CustomEvent && event.detail !== undefined) {
                 return event.detail || event.target.value

+ 14 - 2
packages/alpinejs/src/evaluator.js

@@ -2,6 +2,18 @@ import { closestDataStack, mergeProxies } from './scope'
 import { injectMagics } from './magics'
 import { tryCatch, handleError } from './utils/error'
 
+let shouldAutoEvaluateFunctions = true
+
+export function dontAutoEvaluateFunctions(callback) {
+    let cache = shouldAutoEvaluateFunctions
+
+    shouldAutoEvaluateFunctions = false
+
+    callback()
+
+    shouldAutoEvaluateFunctions = cache
+}
+
 export function evaluate(el, expression, extras = {}) {
     let result
 
@@ -90,7 +102,7 @@ function generateEvaluatorFromString(dataStack, expression, el) {
 
         let completeScope = mergeProxies([ scope, ...dataStack ])
 
-        if( typeof func === 'function' ) {
+        if (typeof func === 'function' ) {
             let promise = func(func, completeScope).catch((error) => handleError(error, el, expression))
 
             // Check if the function ran synchronously,
@@ -114,7 +126,7 @@ function generateEvaluatorFromString(dataStack, expression, el) {
 }
 
 export function runIfTypeOfFunction(receiver, value, scope, params, el) {
-    if (typeof value === 'function') {
+    if (shouldAutoEvaluateFunctions && typeof value === 'function') {
         let result = value.apply(scope, params)
 
         if (result instanceof Promise) {

+ 1 - 1
packages/docs/src/en/plugins/intersect.md

@@ -1,5 +1,5 @@
 ---
-order: 1
+order: 2
 title: Intersect
 description: An Alpine convenience wrapper for Intersection Observer that allows you to easily react when an element enters the viewport.
 graph_image: https://alpinejs.dev/social_intersect.jpg

+ 140 - 0
packages/docs/src/en/plugins/mask.md

@@ -0,0 +1,140 @@
+---
+order: 1
+title: Mask
+description: Automatically format text fields as users type
+graph_image: https://alpinejs.dev/social_mask.jpg
+---
+
+# Mask Plugin
+
+Alpine's Mask plugin allows you to automatically format a text input field as a user types.
+
+This is useful for many different types of inputs: phone numbers, credit cards, dollar amounts, account numbers, dates, etc.
+
+<a name="installation"></a>
+## Installation
+
+<div class=" relative" x-data="{ expanded: false }">
+<div x-show="expanded" x-collapse class="markdown">
+
+You can use this plugin by either including it from a `<script>` tag or installing it via NPM:
+
+### Via CDN
+
+You can include the CDN build of this plugin as a `<script>` tag, just make sure to include it BEFORE Alpine's core JS file.
+
+```alpine
+<!-- Alpine Plugins -->
+<script defer src="https://unpkg.com/@alpinejs/mask@3.x.x/dist/cdn.min.js"></script>
+
+<!-- Alpine Core -->
+<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>
+```
+
+### Via NPM
+
+You can install Mask from NPM for use inside your bundle like so:
+
+```shell
+npm install @alpinejs/mask
+```
+
+Then initialize it from your bundle:
+
+```js
+import Alpine from 'alpinejs'
+import mask from '@alpinejs/mask'
+
+Alpine.plugin(mask)
+
+...
+```
+
+</div>
+<button :aria-expanded="expanded" @click="expanded = ! expanded" class="text-aqua-600 font-medium underline">
+    <span x-text="expanded ? 'Hide' : 'Show more'">Show</span> <span x-text="expanded ? '↑' : '↓'">↓</span>
+</button>
+</div>
+
+<a name="x-mask"></a>
+## x-mask
+
+The primary API for using this plugin is the `x-mask` directive.
+
+Let's start by looking at the following simple example of a date field:
+
+```alpine
+<input x-mask="99/99/9999" placeholder="MM/DD/YYYY">
+```
+
+<!-- START_VERBATIM -->
+<div class="demo">
+    <input x-data x-mask="99/99/9999" placeholder="MM/DD/YYYY">
+</div>
+<!-- END_VERBATIM -->
+
+Notice how the text you type into the input field must adhere to the format provided by `x-mask`. In addition to enforcing numeric characters, the forward slashes `/` are also automatically added if a user doesn't type them first.
+
+The following wildcard characters are supported in masks:
+
+| Wildcard                   | Description                 |
+| -------------------------- | --------------------------- |
+| `*` | Any character |
+| `a` | Only alpha characters (a-z, A-Z) |
+| `9` | Only numeric characters (0-9) |
+
+<a name="mask-functions"></a>
+## Custom Mask Functions
+
+Sometimes simple mask literals (i.e. `(999) 999-9999`) are not sufficient. In these cases, `x-mask:function` allows you to dynamically generate masks on the fly based on user input.
+
+Here's an example of a credit card input that needs to change it's mask based on if the number starts with the numbers "34" or "37" (which means it's an Amex card and therefore has a different format).
+
+```alpine
+<input x-mask:function="
+    $input.startsWith('34') || $input.startsWith('37')
+        ? '9999 999999 99999' : '9999 9999 9999 9999'
+">
+```
+
+As you can see in the above example, every time a user types in the input, that value is passed to the function as `$input`. Based on the `$input`, a different mask is utilized in the field.
+
+Try it for yourself by typing a number that starts with "34" and one that doesn't.
+
+<!-- START_VERBATIM -->
+<div class="demo">
+    <input x-data x-mask:function="
+        $input.startsWith('34') || $input.startsWith('37')
+            ? '9999 999999 99999' : '9999 9999 9999 9999'
+    ">
+</div>
+<!-- END_VERBATIM -->
+
+<a name="money-inputs"></a>
+## Money Inputs
+
+Because writing your own custom mask function for money inputs is fairly complex, Alpine offers a prebuilt one and makes it available as `$money()`.
+
+Here is a fully functioning money input mask:
+
+```alpine
+<input x-mask:function="$money($input)">
+```
+
+<!-- START_VERBATIM -->
+<div class="demo" x-data>
+    <input type="text" x-mask:function="$money($input)" placeholder="0.00">
+</div>
+<!-- END_VERBATIM -->
+
+If you wish to swap the periods for commas and vice versa (as is required in certain currencies), you can do so using the second optional parameter:
+
+```alpine
+<input x-mask:function="$money($input, ',')">
+```
+
+<!-- START_VERBATIM -->
+<div class="demo" x-data>
+    <input type="text" x-mask:function="$money($input, ',')"  placeholder="0.00">
+</div>
+<!-- END_VERBATIM -->

+ 5 - 0
packages/mask/builds/cdn.js

@@ -0,0 +1,5 @@
+import mask from '../src/index.js'
+
+document.addEventListener('alpine:init', () => {
+    window.Alpine.plugin(mask)
+})

+ 5 - 0
packages/mask/builds/module.js

@@ -0,0 +1,5 @@
+import maskPlugin, { stripDown } from '../src/index.js'
+
+export default maskPlugin
+
+export { stripDown }

+ 9 - 0
packages/mask/package.json

@@ -0,0 +1,9 @@
+{
+    "name": "@alpinejs/mask",
+    "version": "3.9.6",
+    "description": "An Alpine plugin for input masking",
+    "author": "Caleb Porzio",
+    "license": "MIT",
+    "main": "dist/module.cjs.js",
+    "module": "dist/module.esm.js"
+}

+ 194 - 0
packages/mask/src/index.js

@@ -0,0 +1,194 @@
+
+export default function (Alpine) {
+    Alpine.directive('mask', (el, { value, expression }, { effect, evaluateLater }) => {
+        let templateFn = () => expression
+        let lastInputValue = ''
+
+        if (['function'].includes(value)) {
+            // This is an x-mask:function directive.
+
+            let evaluator = evaluateLater(expression)
+
+            effect(() => {
+                templateFn = input => {
+                    let result
+
+                    // We need to prevent "auto-evaluation" of functions like
+                    // x-on expressions do so that we can use them as mask functions.
+                    Alpine.dontAutoEvaluateFunctions(() => {
+                        evaluator(value => {
+                            result = typeof value === 'function' ? value(input) : value
+                        }, { scope: {
+                            // These are "magics" we'll make available to the x-mask:function:
+                            '$input': input,
+                            '$money': formatMoney.bind({ el }),
+                        }})
+                    })
+
+                    return result
+                }
+
+                // Run on initialize which serves a dual purpose:
+                // - Initializing the mask on the input if it has an initial value.
+                // - Running the template function to set up reactivity, so that
+                //   when a dependancy inside it changes, the input re-masks.
+                processInputValue(el)
+            })
+        } else {
+            processInputValue(el)
+        }
+
+        el.addEventListener('input', () => processInputValue(el))
+        el.addEventListener('blur', () => processInputValue(el))
+
+        function processInputValue (el) {
+            let input = el.value
+
+            let template = templateFn(input)
+
+            // If they hit backspace, don't process input.
+            if (lastInputValue.length - el.value.length === 1) {
+                return lastInputValue = el.value
+            }
+
+            // When an input element's value is set, it moves the cursor to the end
+            // therefore we need to track, estimate, and restore the cursor after
+            // a change was made.
+            restoreCursorPosition(el, template, () => {
+                lastInputValue = el.value = formatInput(input, template)
+            })
+        }
+
+        function formatInput(input, template) {
+            // Let empty inputs be empty inputs.
+            if (input === '') return ''
+
+            let strippedDownInput = stripDown(template, input)
+            let rebuiltInput = buildUp(template, strippedDownInput)
+
+            return rebuiltInput
+        }
+    })
+}
+
+export function restoreCursorPosition(el, template, callback) {
+    let cursorPosition = el.selectionStart
+    let unformattedValue = el.value
+
+    callback()
+
+    let beforeLeftOfCursorBeforeFormatting = unformattedValue.slice(0, cursorPosition)
+
+    let newPosition = buildUp(
+        template, stripDown(
+            template, beforeLeftOfCursorBeforeFormatting
+        )
+    ).length
+
+    el.setSelectionRange(newPosition, newPosition)
+}
+
+export function stripDown(template, input) {
+    let inputToBeStripped = input
+    let output = ''
+    let regexes = {
+        '9': /[0-9]/,
+        'a': /[a-zA-Z]/,
+        '*': /[a-zA-Z0-9]/,
+    }
+
+    let wildcardTemplate = ''
+
+    // Strip away non wildcard template characters.
+    for (let i = 0; i < template.length; i++) {
+        if (['9', 'a', '*'].includes(template[i])) {
+            wildcardTemplate += template[i]
+            continue;
+        }
+
+        for (let j = 0; j < inputToBeStripped.length; j++) {
+            if (inputToBeStripped[j] === template[i]) {
+                inputToBeStripped = inputToBeStripped.slice(0, j) + inputToBeStripped.slice(j+1)
+
+                break;
+            }
+        }
+    }
+
+    for (let i = 0; i < wildcardTemplate.length; i++) {
+        let found = false
+
+        for (let j = 0; j < inputToBeStripped.length; j++) {
+            if (regexes[wildcardTemplate[i]].test(inputToBeStripped[j])) {
+                output += inputToBeStripped[j]
+                inputToBeStripped = inputToBeStripped.slice(0, j) + inputToBeStripped.slice(j+1)
+
+                found = true
+                break;
+            }
+        }
+
+        if (! found) break;
+    }
+
+    return output
+}
+
+export function buildUp(template, input) {
+    let clean = Array.from(input)
+    let output = ''
+
+    for (let i = 0; i < template.length; i++) {
+        if (! ['9', 'a', '*'].includes(template[i])) {
+            output += template[i]
+            continue;
+        }
+
+        if (clean.length === 0) break;
+
+        output += clean.shift()
+    }
+
+    return output
+}
+
+function formatMoney(input, delimeter = '.', thousands) {
+    thousands = (delimeter === ',' && thousands === undefined)
+        ? '.' : ','
+
+    let addThousands = (input, thousands) => {
+        let output = ''
+        let counter = 0
+
+        for (let i = input.length - 1; i >= 0; i--) {
+            if (input[i] === thousands) continue;
+
+            if (counter === 3) {
+                output = input[i] + thousands + output
+                counter = 0
+            } else {
+                output = input[i] + output
+            }
+            counter++
+        }
+
+        return output
+    }
+
+    let nothousands = input.replaceAll(thousands, '')
+    let template = Array.from({ length: nothousands.split(delimeter)[0].length }).fill('9').join('')
+
+    template = addThousands(template, thousands)
+
+    if (input.includes(delimeter)) template += `${delimeter}99`
+
+    queueMicrotask(() => {
+        if (this.el.value.endsWith(delimeter)) return
+
+        if (this.el.value[this.el.selectionStart - 1] === delimeter) {
+            this.el.setSelectionRange(this.el.selectionStart - 1, this.el.selectionStart - 1)
+        }
+    })
+
+    return template
+}

+ 1 - 0
scripts/build.js

@@ -12,6 +12,7 @@ let brotliSize = require('brotli-size');
     'collapse',
     'morph',
     'focus',
+    'mask',
 ]).forEach(package => {
     if (! fs.existsSync(`./packages/${package}/dist`)) {
         fs.mkdirSync(`./packages/${package}/dist`, 0744);

+ 86 - 0
tests/cypress/integration/plugins/mask.spec.js

@@ -0,0 +1,86 @@
+import { haveValue, html, test } from '../../utils'
+
+test('x-mask',
+    [html`<input x-data x-mask="(999) 999-9999">`],
+    ({ get }) => {
+        // Type a phone number:
+        get('input').type('12').should(haveValue('(12'))
+        get('input').type('3').should(haveValue('(123) '))
+        get('input').type('4567890').should(haveValue('(123) 456-7890'))
+        // Clear it & paste formatted version in:
+        get('input').type('{selectAll}{backspace}')
+        get('input').invoke('val', '(123) 456-7890').trigger('blur')
+        get('input').should(haveValue('(123) 456-7890'))
+        // Clear it & paste un-formatted version in:
+        get('input').type('{selectAll}{backspace}')
+        get('input').invoke('val', '1234567890').trigger('blur')
+        get('input').should(haveValue('(123) 456-7890'))
+        // Make sure backspace works.
+        get('input').type('{backspace}').should(haveValue('(123) 456-789'))
+        get('input').type('{backspace}').should(haveValue('(123) 456-78'))
+        get('input').type('{backspace}').should(haveValue('(123) 456-7'))
+        get('input').type('{backspace}').should(haveValue('(123) 456-'))
+        get('input').type('{backspace}').should(haveValue('(123) 456'))
+        get('input').type('{backspace}').should(haveValue('(123) 45'))
+        // Make sure you can't type other characters.
+        get('input').type('a').should(haveValue('(123) 45'))
+        get('input').type('-').should(haveValue('(123) 45'))
+        // Put cursor in other places in the input and make sure you can type.
+        get('input').type('67890').should(haveValue('(123) 456-7890'))
+        get('input').type('{leftArrow}{leftArrow}{leftArrow}{leftArrow}{leftArrow}')
+        get('input').type('123456').should(haveValue('(123) 456-1234'))
+    },
+)
+
+test('x-mask with non wildcard alpha-numeric characters (b)',
+    [html`<input x-data x-mask="ba9*b">`],
+    ({ get }) => {
+        get('input').type('a').should(haveValue('ba'))
+        get('input').type('a').should(haveValue('ba'))
+        get('input').type('3').should(haveValue('ba3'))
+        get('input').type('z').should(haveValue('ba3zb'))
+        get('input').type('{backspace}{backspace}4').should(haveValue('ba34b'))
+    },
+)
+
+test('$money',
+    [html`<input x-data x-mask:function="$money">`],
+    ({ get }) => {
+        get('input').type('30.00').should(haveValue('30.00'))
+        get('input').type('5').should(haveValue('30.00'))
+        get('input').type('{backspace}').should(haveValue('30.0'))
+        get('input').type('5').should(haveValue('30.05'))
+        get('input').type('{selectAll}{backspace}').should(haveValue(''))
+        get('input').type('123').should(haveValue('123'))
+        get('input').type('4').should(haveValue('1,234'))
+        get('input').type('567').should(haveValue('1,234,567'))
+        get('input').type('.89').should(haveValue('1,234,567.89'))
+        get('input').type('{leftArrow}7').should(haveValue('1,234,567.87'))
+        get('input').type('{leftArrow}{leftArrow}{leftArrow}89').should(haveValue('123,456,789.87'))
+        get('input').type('{leftArrow}{leftArrow}{leftArrow}{leftArrow}12').should(haveValue('12,345,612,789.87'))
+        get('input').type('{leftArrow}3').should(haveValue('123,456,123,789.87'))
+        // Clear it & paste formatted version in:
+        get('input').type('{selectAll}{backspace}')
+        get('input').invoke('val', '123,456,132,789.87').trigger('blur')
+        get('input').should(haveValue('123,456,132,789.87'))
+        // Clear it & paste un-formatted version in:
+        get('input').type('{selectAll}{backspace}')
+        get('input').invoke('val', '123456132789.87').trigger('blur')
+        get('input').should(haveValue('123,456,132,789.87'))
+    },
+)
+
+test('$money swapping commas and periods',
+    [html`<input x-data x-mask:function="$money($input, ',')">`],
+    ({ get }) => {
+        get('input').type('30,00').should(haveValue('30,00'))
+        get('input').type('5').should(haveValue('30,00'))
+        get('input').type('{backspace}').should(haveValue('30,0'))
+        get('input').type('5').should(haveValue('30,05'))
+        get('input').type('{selectAll}{backspace}').should(haveValue(''))
+        get('input').type('123').should(haveValue('123'))
+        get('input').type('4').should(haveValue('1.234'))
+        get('input').type('567').should(haveValue('1.234.567'))
+        get('input').type(',89').should(haveValue('1.234.567,89'))
+    },
+)

+ 1 - 0
tests/cypress/spec.html

@@ -12,6 +12,7 @@
     <script src="/../../packages/focus/dist/cdn.js"></script>
     <script src="/../../packages/intersect/dist/cdn.js"></script>
     <script src="/../../packages/collapse/dist/cdn.js"></script>
+    <script src="/../../packages/mask/dist/cdn.js"></script>
     <script>
         let root = document.querySelector('#root')
 

+ 14 - 0
tests/jest/mask.spec.js

@@ -0,0 +1,14 @@
+let { stripDown } = require('../../packages/mask/dist/module.cjs')
+
+test('strip-down functionality', async () => {
+    expect(stripDown('(***) ***-****', '7162256108')).toEqual('7162256108')
+    expect(stripDown('(999) 999-9999', '7162256108')).toEqual('7162256108')
+    expect(stripDown('999) 999-9999', '7162256108')).toEqual('7162256108')
+    expect(stripDown('999 999-9999', '7162256108')).toEqual('7162256108')
+    expect(stripDown('999999-9999', '7162256108')).toEqual('7162256108')
+    expect(stripDown('9999999999', '7162256108')).toEqual('7162256108')
+    expect(stripDown('9999999999', '7162256108')).toEqual('7162256108')
+    expect(stripDown('(999) 999-9999', '716 2256108')).toEqual('7162256108')
+    expect(stripDown('(999) 999-9999', '(716) 2256108')).toEqual('7162256108')
+    expect(stripDown('(999) 999-9999', '(716) 2-25--6108')).toEqual('7162256108')
+})