Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,21 @@ For convenience, include this exact sentence in the PR description when you want

## Detailed Component Documentation

### Field Masking

The `mask(callback)` method (see `src/types/input.type.js`) enables integration with external masking libraries like iMask.js. It converts input types (`number`, `date`, `time`, `datetime-local`, `radio`, `color`) to `text` for masking while keeping exported data typed correctly. The mask instance is stored in `_maskInstance` and provides `unmaskedValue` for clean exports.

**Key methods**:
- `mask(callback)`: Applies mask, stores instance, converts input type to text
- `export()`: Returns `_maskInstance.unmaskedValue` when available, else `nodeFld.value`
- `import()`: Dispatches `input` event when `_maskInstance` exists, so masks stay synchronized
- Singleton handling: delegates to inner field, `_maskInstance` lives on inner field
- Masking is permanent: the field's input type is set to text and not restored; this is intentional and simplifies the implementation

**Configuration file locations**:
- Tests: `test/mask.tests.js`
- Implementation: `src/types/input.type.js` (lines 274-295 for mask method, lines 206-210 for export integration)

### Playwright Test Runner

**What it does**: Runs end-to-end tests against the built distribution files and validates documentation examples.
Expand Down
2 changes: 1 addition & 1 deletion dist/SmarkForm.esm.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/SmarkForm.esm.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/SmarkForm.umd.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/SmarkForm.umd.js.map

Large diffs are not rendered by default.

223 changes: 223 additions & 0 deletions docs/_advanced_concepts/field_masking.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,223 @@
---
title: Field Masking
layout: chapter
permalink: /advanced_concepts/field_masking
---

# Field Masking

{% include components/sampletabs_ctrl.md %}


SmarkForm's `mask()` method enables integration with external masking libraries to format user input while keeping exported data clean and typed. This is especially useful for fields like credit cards, phone numbers, and prices where visual formatting differs from the underlying data.

## How It Works

When you apply a mask, SmarkForm:

1. Converts the input type to `text` (so masking libraries can operate on it)
2. Stores the mask library instance in `_maskInstance`
3. Exports the **unmasked value** (raw data) instead of the formatted display
4. Dispatches `input` events when values are set programmatically so masks stay synchronized
5. Masking is permanent: once applied, the input type stays `text` for the lifecycle of the component

## Credit Card Example (Primary)

A credit card field demonstrates spaces between every 4 digits — a pattern familiar to most users.

{% raw %}<!-- mask_cc_html {{{ -->{% endraw %}
{% capture mask_cc_html -%}
<script src="https://cdn.jsdelivr.net/npm/imask@6.6.3"></script>
<div id="payment" data-smark='{"type":"form","name":"payment"}'>
<p>
<label>Card Number</label>
<input data-smark type="text" name="cardNumber" placeholder="0000 0000 0000 0000">
</p>
<p>
<button data-smark='{"action":"export"}'>💾 Save Payment</button>
</p>
</div>
{%- endcapture %}{% raw %}<!-- }}} -->{% endraw %}

{% raw %}<!-- mask_cc_js {{{ -->{% endraw %}
{% capture mask_cc_js -%}
const myForm = new SmarkForm("#payment");
myForm.rendered.then(async () => {
const cardField = myForm.find("/cardNumber");
cardField.mask(node => {
// Using iMask.js (loaded via CDN): https://cdn.jsdelivr.net/npm/imask
return new IMask(node, {
mask: "0000 0000 0000 0000"
});
});
});
{%- endcapture %}{% raw %}<!-- }}} -->{% endraw %}

{% raw %}<!-- mask_cc_notes {{{ -->{% endraw %}
{% capture mask_cc_notes -%}
**Try it!** Edit the code and see it live. You can change the mask or placeholder to experiment.
{%- endcapture %}{% raw %}<!-- }}} -->{% endraw %}

{% include components/sampletabs_tpl.md
formId="mask-cc"
htmlSource=mask_cc_html
jsHead=mask_cc_js
notes=mask_cc_notes
showEditor=true
tests=false
%}

## Price Example (Non-Text Field Type)

This shows how to use a `number` field type — SmarkForm converts it to `text` for masking but exports a proper JavaScript number.

{% raw %}<!-- mask_price_html {{{ -->{% endraw %}
{% capture mask_price_html -%}
<script src="https://cdn.jsdelivr.net/npm/imask@6.6.3"></script>
<div id="product" data-smark='{"type":"form","name":"product"}'>
<p>
<label>Unit Price</label>
<input data-smark type="number" name="price" step="0.01">
</p>
<p>
<button data-smark='{"action":"export"}'>💾 Save Price</button>
</p>
</div>
{%- endcapture %}{% raw %}<!-- }}} -->{% endraw %}

{% raw %}<!-- mask_price_js {{{ -->{% endraw %}
{% capture mask_price_js -%}
const myForm = new SmarkForm("#product");
myForm.rendered.then(async () => {
const priceField = myForm.find("/price");
priceField.mask(node => {
const imask = new IMask(node, {
mask: Number,
scale: 2, // 2 decimal places
thousandsSeparator: " " // Add space separator every 3 digits
});
// Export returns a proper JavaScript number, not a formatted string
return imask;
});
});
{%- endcapture %}{% raw %}<!-- }}} -->{% endraw %}

{% raw %}<!-- mask_price_notes {{{ -->{% endraw %}
{% capture mask_price_notes -%}
**Try it!** Change the mask options (for example, scale for decimals) and see the export value change accordingly.
{%- endcapture %}{% raw %}<!-- }}} -->{% endraw %}

{% include components/sampletabs_tpl.md
formId="mask-price"
htmlSource=mask_price_html
jsHead=mask_price_js
notes=mask_price_notes
showEditor=true
tests=false
%}

## Singleton (Single Field) Masking

Masking works on any field within a form, including single-field forms. Apply the mask to the child field just like any other.

{% raw %}<!-- mask_singleton_html {{{ -->{% endraw %}
{% capture mask_singleton_html -%}
<script src="https://cdn.jsdelivr.net/npm/imask@6.6.3"></script>
<div id="singleton" data-smark='{"type":"form","name":"quantity"}'>
<p>
<label>Quantity</label>
<input data-smark type="number" name="quantity" value="0">
</p>
</div>
{%- endcapture %}{% raw %}<!-- }}} -->{% endraw %}

{% raw %}<!-- mask_singleton_js {{{ -->{% endraw %}
{% capture mask_singleton_js -%}
const myForm = new SmarkForm("#singleton");
myForm.rendered.then(async () => {
const quantityField = myForm.find("/quantity");
quantityField.mask(node => new IMask(node, { mask: "0[.]00" }));
});
{%- endcapture %}{% raw %}<!-- }}} -->{% endraw %}

{% raw %}<!-- mask_singleton_notes {{{ -->{% endraw %}
{% capture mask_singleton_notes -%}
**Try it!** Try different masks and see the quantity field format change.
{%- endcapture %}{% raw %}<!-- }}} -->{% endraw %}

{% include components/sampletabs_tpl.md
formId="mask-singleton"
htmlSource=mask_singleton_html
jsHead=mask_singleton_js
notes=mask_singleton_notes
showEditor=true
tests=false
%}

## Validation

Many masking libraries include built-in validation. You can integrate it by checking validity before accepting values:

{% raw %}<!-- mask_validation_html {{{ -->{% endraw %}
{% capture mask_validation_html -%}
<script src="https://cdn.jsdelivr.net/npm/imask@6.6.3"></script>
<div id="pricevalidation" data-smark='{"type":"form","name":"validateprice"}' style="max-width:350px">
<p>
<label>Price</label>
<input data-smark type="number" name="price" step="0.01">
</p>
<p>
<button data-smark='{"action":"export"}'>💾 Check</button>
</p>
</div>
{%- endcapture %}{% raw %}<!-- }}} -->{% endraw %}

{% raw %}<!-- mask_validation_js {{{ -->{% endraw %}
{% capture mask_validation_js -%}
const myForm = new SmarkForm("#pricevalidation");
myForm.rendered.then(async () => {
const priceField = myForm.find("/price");
const imask = new IMask(priceField.targetFieldNode, {
mask: Number,
scale: 2,
thousandsSeparator: " "
});
priceField._maskInstance = imask;
priceField.targetFieldNode.addEventListener("accept", (e) => {
const input = e.target;
input.setCustomValidity(imask.masked.isValid ? "" : "Please enter a valid price");
});
});
{%- endcapture %}{% raw %}<!-- }}} -->{% endraw %}

{% raw %}<!-- mask_validation_notes {{{ -->{% endraw %}
{% capture mask_validation_notes -%}
**Try it!** The playground below highlights invalid price input using a custom validation message (try entering a non-numeric value).
{%- endcapture %}{% raw %}<!-- }}} -->{% endraw %}

{% include components/sampletabs_tpl.md
formId="mask-validation"
htmlSource=mask_validation_html
jsHead=mask_validation_js
notes=mask_validation_notes
showEditor=true
tests=false
%}

## Masking is Permanent

When a mask converts an input to `text`, that change is permanent for the lifetime of the field instance. SmarkForm no longer tracks or restores the original type, as masking is treated as an intentional, final state. Native HTML5 behaviors are replaced by the mask's behavior, which is by design.

## Complete Example with CDN

> <b>Try the playgrounds above to see the key features. For production usage with your own CDN, use:</b>

```html
<script src="https://cdn.jsdelivr.net/npm/imask@6.6.3"></script>
<script src="https://cdn.jsdelivr.net/npm/smarkform/dist/SmarkForm.umd.js"></script>
<!-- Basic HTML from examples above -->
```

```javascript
// See above for field setup! Only IMask and SmarkForm loading differs for CDN usage.
```
4 changes: 2 additions & 2 deletions docs/_data/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,9 +56,9 @@
"concurrently": "^9.2.0",
"jsonc": "^2.0.0",
"minimatch": "^10.2.5",
"parse5": "^8.0.0",
"parse5": "^8.0.1",
"pug": "^3.0.4",
"rollup": "^4.60.1",
"rollup": "^4.60.2",
"rollup-plugin-cleanup": "^3.2.1",
"rollup-plugin-copy": "^3.5.0",
"rollup-plugin-del": "^1.0.1",
Expand Down
30 changes: 29 additions & 1 deletion src/lib/component.js
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,34 @@ export class SmarkComponent {
};//}}}
setNodeOptions(node, options) {//{{{
const me = this;
function isSerializable(value, path = "", visited = new WeakSet()) {
if (value === null || typeof value !== "object") {
if (typeof value === "function") {
throw new Error(`Function found at ${path}`);
}
if (typeof value === "symbol") {
throw new Error(`Symbol found at ${path}`);
}
if (typeof value === "number" && (!Number.isFinite(value))) {
throw new Error(`Non-finite number found at ${path}`);
}
return;
}
if (visited.has(value)) {
return;
}
visited.add(value);
if (Array.isArray(value)) {
value.forEach((item, i) => {
isSerializable(item, `${path}[${i}]`, visited);
});
} else {
for (const key of Object.keys(value)) {
isSerializable(value[key], path ? `${path}.${key}` : key, visited);
}
}
}
isSerializable(options);
node.dataset[me.property_name] = JSON.stringify(options);
};//}}}
async enhance(node, defaultOptions) {//{{{
Expand Down Expand Up @@ -329,7 +357,7 @@ export class SmarkComponent {
};//}}}
getPath() {//{{{
const me = this;
const ancestors = [...me.parents].map(p=>p.name).reverse();
const ancestors = me.parents ? [...me.parents].map(p=>p.name).reverse() : [];
if (me.name) ancestors.push(me.name); // Compute parent path inside labels (or singletons?).
return ancestors.join("/") || "/";
};//}}}
Expand Down
8 changes: 8 additions & 0 deletions src/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,14 @@ class SmarkForm extends form {
...formOptions
} = {}
) {
// Resolve string selectors to DOM nodes:
if (typeof targetNode === "string") {
const resolved = document.querySelector(targetNode);
if (!resolved) throw new Error(
`SmarkForm: selector "${targetNode}" did not match any element`
);
targetNode = resolved;
}
const options = {
...formOptions,
name: "",
Expand Down
29 changes: 29 additions & 0 deletions src/types/input.type.js
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,9 @@ export class input extends form {
const me = this;
if (me.isSingleton) return; // (Only for real field)
me.targetFieldNode.value = value;
if (me._maskInstance != null) {
me.targetFieldNode.dispatchEvent(new Event("input", {bubbles: true}));
};
};//}}}
async render() {//{{{
const me = this;
Expand Down Expand Up @@ -200,6 +203,11 @@ export class input extends form {
// Keep fallback working when encoding is json and value attribute is not set.
// (and don't expetct <opton> inner text to be JSON)
retv = JSON.stringify(nodeFld.options[nodeFld.selectedIndex].text);
} else if (
me._maskInstance != null
&& me._maskInstance.unmaskedValue !== undefined
) {
retv = me._maskInstance.unmaskedValue;
} else {
retv = nodeFld.value;
};
Expand Down Expand Up @@ -263,4 +271,25 @@ export class input extends form {
return ! value.trim().length;
// Native input's value type is always a string.
};//}}}
mask(callback) {//{{{
const me = this;
// For singletons, delegate to the inner field so _maskInstance lives
// where export() reads it from.
if (me.isSingleton) {
me.children[""].mask(callback);
return me;
};
const fld = me.targetFieldNode;
if (
fld
&& fld.tagName === "INPUT"
) {
const currentType = (fld.getAttribute("type") || "text").toLowerCase();
if (currentType !== "text") {
fld.setAttribute("type", "text");
};
};
me._maskInstance = callback(fld) ?? null;
return me;
};//}}}
};
Loading