From c61dda467458d67046188d9d7702fef6ca31bbe4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Apr 2026 23:20:18 +0000 Subject: [PATCH 1/8] feat: add mask() method to input field components for masking library integration - Add mask(callback) method to SmarkField (via input.type.js): - Changes type to "text" for non-text inputs (required by iMask.js etc.) - Stores original type in _originalType for reference - Calls callback(targetFieldNode) and stores result as _maskInstance - Delegates to inner field for singletons, returns `me` for chaining - Update _setTargetFieldValue() to dispatch "input" event when mask is active, so masking libraries re-process the new value on programmatic import() - Update export() to use _maskInstance.unmaskedValue when available, so derived types (number, date, etc.) still parse/return the correct semantic type - Add comprehensive tests in test/mask.tests.js (13 scenarios) Agent-Logs-Url: https://github.com/bitifet/SmarkForm/sessions/72c9bcc2-72c5-42a0-8d32-9fe27015b3da Co-authored-by: bitifet <1643647+bitifet@users.noreply.github.com> --- dist/SmarkForm.esm.js | 2 +- dist/SmarkForm.esm.js.map | 2 +- dist/SmarkForm.umd.js | 2 +- dist/SmarkForm.umd.js.map | 2 +- src/types/input.type.js | 30 +++ test/mask.tests.js | 382 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 416 insertions(+), 4 deletions(-) create mode 100644 test/mask.tests.js diff --git a/dist/SmarkForm.esm.js b/dist/SmarkForm.esm.js index 8da9ef2f..3b97e194 100644 --- a/dist/SmarkForm.esm.js +++ b/dist/SmarkForm.esm.js @@ -1 +1 @@ -function e(e,t,n){if("function"==typeof e?e===t:e.has(t))return arguments.length<3?t:n;throw new TypeError("Private element is not present on this object")}function t(e){if(Object(e)!==e)throw TypeError("right-hand side of 'in' should be an object, got "+(null!==e?typeof e:"null"));return e}function n(e,t){(function(e,t){if(t.has(e))throw new TypeError("Cannot initialize the same private elements twice on an object")})(e,t),t.add(e)}function r(e,t,n){return(t=l(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function o(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),n.push.apply(n,r)}return n}function i(e){for(var t=1;t=0;E--){var _;void 0!==(b=r(w[E],o,f,u,l,c,d,h,p))&&(i(l,b),0===l?_=b:1===l?(_=b.init,v=b.get||h.get,N=b.set||h.set,h={get:v,set:N}):h=b,void 0!==_&&(void 0===g?g=_:"function"==typeof g?g=[g,_]:g.push(_)))}if(0===l||1===l){if(void 0===g)g=function(e,t){return t};else if("function"!=typeof g){var S=g;g=function(e,t){for(var n=t,r=0;r3,v=h>=5,N=r;if(v?(g=e,0!=(h-=5)&&(m=i=i||[]),b&&!a&&(a=function(n){return t(n)===e}),N=a):(g=e.prototype,0!==h&&(m=o=o||[])),0!==h&&!b){var w=v?d:l,E=w.get(y)||0;if(!0===E||3===E&&4!==h||4===E&&3!==h)throw Error("Attempted to decorate a public method/accessor that has the same name as a previously decorated public method/accessor. This is not currently supported by the decorators plugin. Property name was: "+y);!E&&h>2?w.set(y,h):w.set(y,!0)}c(s,g,f,y,h,v,b,m,N)}}return u(s,o),u(s,i),s}function u(e,t){t&&e.push(function(e){for(var n=0;n0){for(var r=[],o=t,a=t.name,s=n.length-1;s>=0;s--){var l={v:!1};try{var c=n[s](o,{kind:"class",name:a,addInitializer:e(r,l)})}finally{l.v=!0}void 0!==c&&(i(10,c),o=c)}return[o,function(){for(var e=0;enull===e:t=>null===t||t.isSameNode(e);return[...e.querySelectorAll(t)].filter(e=>n(e.parentNode.closest(t)))}function u(e,t){let n=e.parentNode;const r=t>=0?1:-1;for(;n;){if(n.scrollHeight>n.clientHeight*r){var o=n.scrollHeight-n.clientHeight*r;if(t<=o*r)return void(n.scrollTop+=t);n.scrollTop=o,t-=o}n=n.parentNode}}function p(){return Math.random().toString(36).substring(2)}function f(e){try{return JSON.parse(e)}catch(e){}}function g(e){if(5===e.length&&":"===e[2]){const t=parseInt(e.substring(0,2),10),n=parseInt(e.substring(3,5),10);if(t>=0&&t<=23&&n>=0&&n<=59)return e+":00"}if(8===e.length&&":"===e[2]&&":"===e[5]){const t=parseInt(e.substring(0,2),10),n=parseInt(e.substring(3,5),10),r=parseInt(e.substring(6,8),10);if(t>=0&&t<=23&&n>=0&&n<=59&&r>=0&&r<=59)return e}if(6===e.length){const t=parseInt(e.substring(0,2),10),n=parseInt(e.substring(2,4),10),r=parseInt(e.substring(4,6),10);if(t>=0&&t<=23&&n>=0&&n<=59&&r>=0&&r<=59)return[e.substring(0,2),e.substring(2,4),e.substring(4,6)].join(":")}if(4===e.length){const t=parseInt(e.substring(0,2),10),n=parseInt(e.substring(2,4),10);if(t>=0&&t<=23&&n>=0&&n<=59)return[e.substring(0,2),e.substring(2,4),"00"].join(":")}return null}function m(e){if(15===e.length&&"T"===e[8]){const t=[e.substring(0,4),e.substring(4,6),e.substring(6,8)].join("-"),n=[e.substring(9,11),e.substring(11,13),e.substring(13,15)].join(":");return new Date("".concat(t,"T").concat(n))}if(13===e.length&&"T"===e[8]){const t=[e.substring(0,4),e.substring(4,6),e.substring(6,8)].join("-"),n=[e.substring(9,11),e.substring(11,13),"00"].join(":");return new Date("".concat(t,"T").concat(n))}if(19===e.length&&"-"===e[4]&&"-"===e[7]&&"T"===e[10]&&":"===e[13]&&":"===e[16])return new Date(e);if(16===e.length&&"-"===e[4]&&"-"===e[7]&&"T"===e[10]&&":"===e[13])return new Date(e+":00");return e.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/)?new Date(e):NaN}function h(e){let t=null==e?void 0:e.parentElement;for(;t;){if("DETAILS"===t.tagName&&!t.open){const n=t.querySelector(":scope > summary");if(!n||!n.contains(e))return!0}t=t.parentElement}return!1}function y(e,t,n,r){const o=e.tagName,i=e.getAttribute("type");if("INPUT"!=o||(i||t).toLowerCase()!=t){const e=new Error(r);throw e.code=n,e}i||(e.type=t)}const b=Symbol("Events"),v=Symbol("onEvents"),N=Symbol("allEvents"),w=/^on(?:Before|After)Action_/,E=/^onLocal_/,_=/^on_/,S=/^onAll_/,x={keydown:{bubbles:!0},keyup:{bubbles:!0},keypress:{bubbles:!0},beforeinput:{bubbles:!0},input:{bubbles:!0},change:{bubbles:!0},focus:{bubbles:!1},blur:{bubbles:!1},focusin:{bubbles:!0},focusout:{bubbles:!0},click:{bubbles:!0},dblclick:{bubbles:!0},contextmenu:{bubbles:!0},mousedown:{bubbles:!0},mouseup:{bubbles:!0},mousemove:{bubbles:!0},mouseenter:{bubbles:!1},mouseleave:{bubbles:!1},mouseover:{bubbles:!0},mouseout:{bubbles:!0},focusenter:{bubbles:!0,synthetic:!0},focusleave:{bubbles:!0,synthetic:!0}};function T(e,t,n){return e.has(t)||e.set(t,[]),e.get(t).push(n.bind(this)),this}async function A(e,t){const n=e?[e,...e.parents]:[],r=t?[t,...t.parents]:[],o=function(e,t){var n;const r=new Set(t);return null!==(n=e.find(e=>r.has(e)))&&void 0!==n?n:null}(n,r);for(const e of n){if(e===o)break;await e.emit("focusleave",{type:"focusleave",context:e})}for(const e of r){if(e===o)break;await e.emit("focusenter",{type:"focusenter",context:e})}}const k=Symbol("smarkform_legacy_prevent");var I={disEnhance(e){"form"!==e.targetNode.tagName.toLowerCase()||e.targetNode[k]||(e.targetNode[k]=!0,e.targetNode.addEventListener("submit",function(e){e.preventDefault()}))}};const O=["type"],L=new Map,C=new Set;function D(e){const t=document.createTreeWalker(e,NodeFilter.SHOW_ELEMENT);let n=t.currentNode;for(;n;)n.hasAttribute("id")&&(n.setAttribute("data-id",n.getAttribute("id")),n.removeAttribute("id")),n=t.nextNode()}function P(e){try{return new URL(e).origin!==location.origin}catch(e){return!1}}async function F(e,t,n){const r=t.type,o=r.indexOf("#"),s=r.slice(0,o),l=r.slice(o+1);if(!l)throw n.renderError("MIXIN_TYPE_MISSING_FRAGMENT",'Mixin type reference "'.concat(r,'" must include a non-empty')+" #templateId fragment.");let c,d;if(s){var u;d=new URL(s,document.baseURI).href;const e=null!==(u=n.root.options.allowExternalMixins)&&void 0!==u?u:"block";if("block"===e)throw n.renderError("MIXIN_EXTERNAL_FETCH_BLOCKED",'Mixin type "'.concat(r,'" references an external URL but')+' allowExternalMixins is "block" (the default). Set allowExternalMixins to "same-origin" or "allow" on the root SmarkForm instance to permit external mixin loading.');if("same-origin"===e&&P(d))throw n.renderError("MIXIN_CROSS_ORIGIN_FETCH_BLOCKED",'Mixin type "'.concat(r,'" references a cross-origin URL')+" (".concat(new URL(d).origin,") but allowExternalMixins")+' is "same-origin". Set allowExternalMixins to "allow" to permit cross-origin mixin loading.');L.has(d)||L.set(d,fetch(d).then(e=>{if(!e.ok)throw Object.assign(new Error("Failed to fetch mixin source: ".concat(d)+" (HTTP ".concat(e.status,")")),{code:"MIXIN_FETCH_ERROR"});return e.text()}).then(e=>(new DOMParser).parseFromString(e,"text/html"))),c=await L.get(d)}else c=document,d=document.baseURI;const p="".concat(d,"#").concat(l),g=n._mixinChain||new Set;if(g.has(p))throw n.renderError("MIXIN_CIRCULAR_DEPENDENCY",'Circular mixin dependency detected: "'.concat(p,'"')+" is already being expanded in this rendering chain.");const m=c.getElementById(l);if(!m||"template"!==m.tagName.toLowerCase())throw n.renderError("MIXIN_TEMPLATE_NOT_FOUND","Mixin template #".concat(l," not found")+" in ".concat(s||"the current document","."));const h=[...m.content.childNodes].filter(e=>e.nodeType===Node.ELEMENT_NODE),y=h.filter(e=>"script"===e.tagName.toLowerCase()),b=h.filter(e=>"style"===e.tagName.toLowerCase()),v=h.filter(e=>{const t=e.tagName.toLowerCase();return"script"!==t&&"style"!==t});if(1!==v.length)throw n.renderError("MIXIN_TEMPLATE_INVALID_ROOT","Mixin template #".concat(l," must contain exactly one root")+" element (found ".concat(v.length,")."));const N=v[0],w=f(N.getAttribute("data-smark"))||{};if(void 0!==w.name)throw n.renderError("MIXIN_TEMPLATE_ROOT_HAS_NAME","Mixin template #".concat(l," root element must not specify")+' a "name" in its data-smark options. The name must be set on the placeholder (usage site).');const E=N.cloneNode(!0),_=[...e.children].filter(e=>e.hasAttribute("data-for"));_.length>0&&function(e,t){for(const n of t){const t=n.getAttribute("data-for"),r=e.querySelector('[id="'.concat(CSS.escape(t),'"]'));if(!r)continue;const o=n.cloneNode(!0);D(o),o.removeAttribute("data-for"),r.replaceWith(o)}}(E,_),function(e,t){if(e.querySelector("script"))throw t.renderError("MIXIN_NESTED_SCRIPT_DISALLOWED","Mixin template root subtree must not contain \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested + + +
+ + + + +``` \ No newline at end of file diff --git a/docs/_data/package.json b/docs/_data/package.json index 7b30daf9..5f16f6e1 100644 --- a/docs/_data/package.json +++ b/docs/_data/package.json @@ -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", From 253d165bf1f8b07506a47d16653c63f579d4e90d Mon Sep 17 00:00:00 2001 From: Joan Miquel Torres Date: Mon, 27 Apr 2026 18:19:30 +0200 Subject: [PATCH 3/8] refactor: make input masking permanent, update docs and tests accordingly --- AGENTS.md | 2 +- dist/SmarkForm.esm.js | 2 +- dist/SmarkForm.esm.js.map | 2 +- dist/SmarkForm.umd.js | 2 +- dist/SmarkForm.umd.js.map | 2 +- docs/_advanced_concepts/field_masking.md | 13 ++----------- src/types/input.type.js | 1 - test/mask.tests.js | 9 +++------ 8 files changed, 10 insertions(+), 23 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 92a76ac2..a82e370c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -57,7 +57,7 @@ The `mask(callback)` method (see `src/types/input.type.js`) enables integration - `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 -- `_originalType` restoration: original input type is restored when field is destroyed +- 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` diff --git a/dist/SmarkForm.esm.js b/dist/SmarkForm.esm.js index 3be6ba90..bae72ad2 100644 --- a/dist/SmarkForm.esm.js +++ b/dist/SmarkForm.esm.js @@ -1 +1 @@ -function e(e,t,n){if("function"==typeof e?e===t:e.has(t))return arguments.length<3?t:n;throw new TypeError("Private element is not present on this object")}function t(e){if(Object(e)!==e)throw TypeError("right-hand side of 'in' should be an object, got "+(null!==e?typeof e:"null"));return e}function n(e,t){(function(e,t){if(t.has(e))throw new TypeError("Cannot initialize the same private elements twice on an object")})(e,t),t.add(e)}function r(e,t,n){return(t=l(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function o(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),n.push.apply(n,r)}return n}function i(e){for(var t=1;t=0;E--){var _;void 0!==(b=r(w[E],o,f,u,l,c,d,h,p))&&(i(l,b),0===l?_=b:1===l?(_=b.init,v=b.get||h.get,N=b.set||h.set,h={get:v,set:N}):h=b,void 0!==_&&(void 0===g?g=_:"function"==typeof g?g=[g,_]:g.push(_)))}if(0===l||1===l){if(void 0===g)g=function(e,t){return t};else if("function"!=typeof g){var S=g;g=function(e,t){for(var n=t,r=0;r3,v=h>=5,N=r;if(v?(g=e,0!=(h-=5)&&(m=i=i||[]),b&&!a&&(a=function(n){return t(n)===e}),N=a):(g=e.prototype,0!==h&&(m=o=o||[])),0!==h&&!b){var w=v?d:l,E=w.get(y)||0;if(!0===E||3===E&&4!==h||4===E&&3!==h)throw Error("Attempted to decorate a public method/accessor that has the same name as a previously decorated public method/accessor. This is not currently supported by the decorators plugin. Property name was: "+y);!E&&h>2?w.set(y,h):w.set(y,!0)}c(s,g,f,y,h,v,b,m,N)}}return u(s,o),u(s,i),s}function u(e,t){t&&e.push(function(e){for(var n=0;n0){for(var r=[],o=t,a=t.name,s=n.length-1;s>=0;s--){var l={v:!1};try{var c=n[s](o,{kind:"class",name:a,addInitializer:e(r,l)})}finally{l.v=!0}void 0!==c&&(i(10,c),o=c)}return[o,function(){for(var e=0;e=0;t--)r.push(e.children[t]);for(;r.length;){const e=r.pop();if(e.matches(t))n.push(e);else for(let t=e.children.length-1;t>=0;t--)r.push(e.children[t])}return n}function u(e,t){let n=e.parentNode;const r=t>=0?1:-1;for(;n;){if(n.scrollHeight>n.clientHeight*r){var o=n.scrollHeight-n.clientHeight*r;if(t<=o*r)return void(n.scrollTop+=t);n.scrollTop=o,t-=o}n=n.parentNode}}function p(){return Math.random().toString(36).substring(2)}function f(e){try{return JSON.parse(e)}catch(e){}}function g(e){if(5===e.length&&":"===e[2]){const t=parseInt(e.substring(0,2),10),n=parseInt(e.substring(3,5),10);if(t>=0&&t<=23&&n>=0&&n<=59)return e+":00"}if(8===e.length&&":"===e[2]&&":"===e[5]){const t=parseInt(e.substring(0,2),10),n=parseInt(e.substring(3,5),10),r=parseInt(e.substring(6,8),10);if(t>=0&&t<=23&&n>=0&&n<=59&&r>=0&&r<=59)return e}if(6===e.length){const t=parseInt(e.substring(0,2),10),n=parseInt(e.substring(2,4),10),r=parseInt(e.substring(4,6),10);if(t>=0&&t<=23&&n>=0&&n<=59&&r>=0&&r<=59)return[e.substring(0,2),e.substring(2,4),e.substring(4,6)].join(":")}if(4===e.length){const t=parseInt(e.substring(0,2),10),n=parseInt(e.substring(2,4),10);if(t>=0&&t<=23&&n>=0&&n<=59)return[e.substring(0,2),e.substring(2,4),"00"].join(":")}return null}function m(e){if(15===e.length&&"T"===e[8]){const t=[e.substring(0,4),e.substring(4,6),e.substring(6,8)].join("-"),n=[e.substring(9,11),e.substring(11,13),e.substring(13,15)].join(":");return new Date("".concat(t,"T").concat(n))}if(13===e.length&&"T"===e[8]){const t=[e.substring(0,4),e.substring(4,6),e.substring(6,8)].join("-"),n=[e.substring(9,11),e.substring(11,13),"00"].join(":");return new Date("".concat(t,"T").concat(n))}if(19===e.length&&"-"===e[4]&&"-"===e[7]&&"T"===e[10]&&":"===e[13]&&":"===e[16])return new Date(e);if(16===e.length&&"-"===e[4]&&"-"===e[7]&&"T"===e[10]&&":"===e[13])return new Date(e+":00");return e.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/)?new Date(e):NaN}function h(e){let t=null==e?void 0:e.parentElement;for(;t;){if("DETAILS"===t.tagName&&!t.open){const n=t.querySelector(":scope > summary");if(!n||!n.contains(e))return!0}t=t.parentElement}return!1}function y(e,t,n,r){const o=e.tagName,i=e.getAttribute("type");if("INPUT"!=o||(i||t).toLowerCase()!=t){const e=new Error(r);throw e.code=n,e}i||(e.type=t)}const b=Symbol("Events"),v=Symbol("onEvents"),N=Symbol("allEvents"),w=/^on(?:Before|After)Action_/,E=/^onLocal_/,_=/^on_/,S=/^onAll_/,x={keydown:{bubbles:!0},keyup:{bubbles:!0},keypress:{bubbles:!0},beforeinput:{bubbles:!0},input:{bubbles:!0},change:{bubbles:!0},focus:{bubbles:!1},blur:{bubbles:!1},focusin:{bubbles:!0},focusout:{bubbles:!0},click:{bubbles:!0},dblclick:{bubbles:!0},contextmenu:{bubbles:!0},mousedown:{bubbles:!0},mouseup:{bubbles:!0},mousemove:{bubbles:!0},mouseenter:{bubbles:!1},mouseleave:{bubbles:!1},mouseover:{bubbles:!0},mouseout:{bubbles:!0},focusenter:{bubbles:!0,synthetic:!0},focusleave:{bubbles:!0,synthetic:!0}};function T(e,t,n){return e.has(t)||e.set(t,[]),e.get(t).push(n.bind(this)),this}async function k(e,t){const n=e?[e,...e.parents]:[],r=t?[t,...t.parents]:[],o=function(e,t){var n;const r=new Set(t);return null!==(n=e.find(e=>r.has(e)))&&void 0!==n?n:null}(n,r);for(const e of n){if(e===o)break;await e.emit("focusleave",{type:"focusleave",context:e})}for(const e of r){if(e===o)break;await e.emit("focusenter",{type:"focusenter",context:e})}}const A=Symbol("smarkform_legacy_prevent");var I={disEnhance(e){"form"!==e.targetNode.tagName.toLowerCase()||e.targetNode[A]||(e.targetNode[A]=!0,e.targetNode.addEventListener("submit",function(e){e.preventDefault()}))}};const O=["type"],L=new Map,C=new Set;function D(e){const t=document.createTreeWalker(e,NodeFilter.SHOW_ELEMENT);let n=t.currentNode;for(;n;)n.hasAttribute("id")&&(n.setAttribute("data-id",n.getAttribute("id")),n.removeAttribute("id")),n=t.nextNode()}function P(e){try{return new URL(e).origin!==location.origin}catch(e){return!1}}async function F(e,t,n){const r=t.type,o=r.indexOf("#"),s=r.slice(0,o),l=r.slice(o+1);if(!l)throw n.renderError("MIXIN_TYPE_MISSING_FRAGMENT",'Mixin type reference "'.concat(r,'" must include a non-empty')+" #templateId fragment.");let c,d;if(s){var u;d=new URL(s,document.baseURI).href;const e=null!==(u=n.root.options.allowExternalMixins)&&void 0!==u?u:"block";if("block"===e)throw n.renderError("MIXIN_EXTERNAL_FETCH_BLOCKED",'Mixin type "'.concat(r,'" references an external URL but')+' allowExternalMixins is "block" (the default). Set allowExternalMixins to "same-origin" or "allow" on the root SmarkForm instance to permit external mixin loading.');if("same-origin"===e&&P(d))throw n.renderError("MIXIN_CROSS_ORIGIN_FETCH_BLOCKED",'Mixin type "'.concat(r,'" references a cross-origin URL')+" (".concat(new URL(d).origin,") but allowExternalMixins")+' is "same-origin". Set allowExternalMixins to "allow" to permit cross-origin mixin loading.');L.has(d)||L.set(d,fetch(d).then(e=>{if(!e.ok)throw Object.assign(new Error("Failed to fetch mixin source: ".concat(d)+" (HTTP ".concat(e.status,")")),{code:"MIXIN_FETCH_ERROR"});return e.text()}).then(e=>(new DOMParser).parseFromString(e,"text/html"))),c=await L.get(d)}else c=document,d=document.baseURI;const p="".concat(d,"#").concat(l),g=n._mixinChain||new Set;if(g.has(p))throw n.renderError("MIXIN_CIRCULAR_DEPENDENCY",'Circular mixin dependency detected: "'.concat(p,'"')+" is already being expanded in this rendering chain.");const m=c.getElementById(l);if(!m||"template"!==m.tagName.toLowerCase())throw n.renderError("MIXIN_TEMPLATE_NOT_FOUND","Mixin template #".concat(l," not found")+" in ".concat(s||"the current document","."));const h=[...m.content.childNodes].filter(e=>e.nodeType===Node.ELEMENT_NODE),y=h.filter(e=>"script"===e.tagName.toLowerCase()),b=h.filter(e=>"style"===e.tagName.toLowerCase()),v=h.filter(e=>{const t=e.tagName.toLowerCase();return"script"!==t&&"style"!==t});if(1!==v.length)throw n.renderError("MIXIN_TEMPLATE_INVALID_ROOT","Mixin template #".concat(l," must contain exactly one root")+" element (found ".concat(v.length,")."));const N=v[0],w=f(N.getAttribute("data-smark"))||{};if(void 0!==w.name)throw n.renderError("MIXIN_TEMPLATE_ROOT_HAS_NAME","Mixin template #".concat(l," root element must not specify")+' a "name" in its data-smark options. The name must be set on the placeholder (usage site).');const E=N.cloneNode(!0),_=[...e.children].filter(e=>e.hasAttribute("data-for"));_.length>0&&function(e,t){for(const n of t){const t=n.getAttribute("data-for"),r=e.querySelector('[id="'.concat(CSS.escape(t),'"]'));if(!r)continue;const o=n.cloneNode(!0);D(o),o.removeAttribute("data-for"),r.replaceWith(o)}}(E,_),function(e,t){if(e.querySelector("script"))throw t.renderError("MIXIN_NESTED_SCRIPT_DISALLOWED","Mixin template root subtree must not contain \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested +

@@ -32,30 +37,46 @@ A credit card field demonstrates spaces between every 4 digits — a pattern fam

-``` - -```javascript -const form = new SmarkForm('#payment'); -await form.rendered; - -const cardField = form.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', - blocks: { - '0000': {mask: IMask.Number} - } - }); +{%- endcapture %}{% raw %}{% endraw %} + +{% raw %}{% endraw %} +{% capture mask_cc_js -%} +const form = new SmarkForm("#payment"); +form.rendered.then(async () => { + const cardField = form.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", + blocks: { + "0000": {mask: IMask.Number} + } + }); + }); }); -``` +{%- endcapture %}{% raw %}{% endraw %} + +{% raw %}{% 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 +%} ## 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. -```html -
+{% raw %}{% endraw %} +{% capture mask_price_html -%} + +

@@ -64,75 +85,125 @@ This shows how to use a `number` field type — SmarkForm converts it to `text`

-``` - -```javascript -const form = new SmarkForm('#product'); -await form.rendered; - -const priceField = form.find('/price'); -priceField.mask(node => { - const imask = new IMask(node, { - mask: Number, - scale: 100, // 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 %}{% endraw %} +{% capture mask_price_js -%} +const form = new SmarkForm("#product"); +form.rendered.then(async () => { + const priceField = form.find("/price"); + priceField.mask(node => { + const imask = new IMask(node, { + mask: Number, + scale: 100, // 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 %}{% 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 +%} ## Singleton Masking When masking is applied to a singleton component (e.g., a number field wrapped in a type converter), the method delegates to the inner field. The `_maskInstance` lives on the inner field while the outer component remains chainable. -```html -
+{% raw %}{% endraw %} +{% capture mask_singleton_html -%} + +
-``` +{%- endcapture %}{% raw %}{% endraw %} + +{% raw %}{% endraw %} +{% capture mask_singleton_js -%} +const form = new SmarkForm("#singleton"); +form.rendered.then(async () => { + // Mask on the singleton — returns the outer component for chaining + const quantityField = form.find("/quantity"); + quantityField.mask(node => new IMask(node, { mask: "0[.]00" })); + // The inner field has the _maskInstance + const innerField = quantityField.children[""]; + console.log("Inner field has mask:", innerField._maskInstance !== undefined); + console.log("Outer field has mask:", quantityField._maskInstance === undefined); +}); +{%- endcapture %}{% raw %}{% endraw %} -```javascript -const form = new SmarkForm('div'); -await form.rendered; - -// Mask on the singleton — returns the outer component for chaining -const quantityField = form.find('/quantity'); -quantityField.mask(node => new IMask(node, { - mask: '0[.]00' -})); - -// The inner field has the _maskInstance -const innerField = quantityField.children['']; -console.log(innerField._maskInstance !== undefined); // true -console.log(quantityField._maskInstance === undefined); // true -``` +{% raw %}{% endraw %} +{% capture mask_singleton_notes -%} +**Try it!** This playground demonstrates singleton masking: edit the JS to check the instance locations and try different masks. +{%- 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 +%} ## Validation Many masking libraries include built-in validation. You can integrate it by checking validity before accepting values: -```javascript -priceField.mask(node => { - const imask = new IMask(node, { - mask: Number, - scale: 100, - thousandsSeparator: ' ' - }); - - // Listen for validation changes - node.addEventListener('accept', (e) => { - const input = e.target; - input.setCustomValidity(imask.isValid ? '' : 'Please enter a valid price'); - }); - - return imask; +{% raw %}{% endraw %} +{% capture mask_validation_html -%} + +
+

+ + +

+

+ +

+
+{%- endcapture %}{% raw %}{% endraw %} + +{% raw %}{% endraw %} +{% capture mask_validation_js -%} +const form = new SmarkForm("#pricevalidation"); +form.rendered.then(async () => { + const priceField = form.find("/price"); + const imask = new IMask(priceField.targetFieldNode, { + mask: Number, + scale: 100, + 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 %}{% 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 +%} ## Masking is Permanent @@ -140,36 +211,14 @@ When a mask converts an input to `text`, that change is permanent for the lifeti ## Complete Example with CDN +> Try the playgrounds above to see the key features. For production usage with your own CDN, use: + ```html - - - - - - -
- - - - -``` \ No newline at end of file + + + +``` + +```javascript +// See above for field setup! Only IMask and SmarkForm loading differs for CDN usage. +``` diff --git a/src/lib/component.js b/src/lib/component.js index 7e21d735..a80e2ce8 100644 --- a/src/lib/component.js +++ b/src/lib/component.js @@ -238,7 +238,51 @@ export class SmarkComponent { };//}}} setNodeOptions(node, options) {//{{{ const me = this; - node.dataset[me.property_name] = JSON.stringify(options); + // Check for non-serializable values before attempting JSON.stringify + function isSerializable(value, path = "", visited = new WeakSet()) { + // Primitive types + 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 === "undefined") { + throw new Error(`Undefined found at ${path}`); + } + if (typeof value === "number" && (!Number.isFinite(value))) { + throw new Error(`Non-finite number found at ${path}`); + } + return; + } + // Objects: detect circular references + if (visited.has(value)) { + return; // already visited, skip to avoid infinite recursion + } + visited.add(value); + if (Array.isArray(value)) { + value.forEach((item, i) => { + isSerializable(item, `${path}[${i}]`, visited); + }); + } else { + // plain object + for (const key of Object.keys(value)) { + isSerializable(value[key], path ? `${path}.${key}` : key, visited); + } + } + } + try { + isSerializable(options); + node.dataset[me.property_name] = JSON.stringify(options); + } catch (err) { + console.log("Error stringifying options for node", node, "with options", options); + console.log({ me }); + throw me.renderError( + "INVALID_OPTIONS_OBJECT", + `Cannot serialize options for data-${me.property_name} on node: encountered unserializable properties. Original error: ${err && err.message ? err.message : err}` + ); + } };//}}} async enhance(node, defaultOptions) {//{{{ const me = this; From 7e548d33059c8502e2f489f82d7297c819a8f8e0 Mon Sep 17 00:00:00 2001 From: Joan Miquel Torres Date: Thu, 21 May 2026 23:07:34 +0200 Subject: [PATCH 5/8] test: merge general tests with options validation tests - Restore original general tests (document loaded, focus behavior, default values focus race, basic introspection) - Add new tests for options serialization validation (cyclic refs, functions) - These validate the INVALID_OPTIONS_OBJECT renderError behavior --- test/general.tests.js | 59 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 59 insertions(+) diff --git a/test/general.tests.js b/test/general.tests.js index 527b66eb..fb558c6f 100644 --- a/test/general.tests.js +++ b/test/general.tests.js @@ -312,3 +312,62 @@ block mainForm }); +test.describe('options validation', () => { + test('throws renderError for unserializable options (cyclic)', async ({ page }) => { + const pug = `div#cyclic-form(data-smark='{"type":"form","name":"c"}')\nscript(src=\"../../dist/SmarkForm.umd.js\")`; + const { url, onClosed } = await renderPug({ title: 'cyclic options', src: pug }); + try { + await page.goto(url); + const result = await page.evaluate(() => { + const SmarkForm = window.SmarkForm; + const node = document.getElementById('cyclic-form'); + const comp = new SmarkForm(node); + comp.options.badCyclic = comp.options; // introduce cyclic reference + try { + comp.setNodeOptions(node, comp.options); + return {error: false}; + } catch (e) { + return { + error: true, + name: e.name, + message: e.message, + code: e.code, + }; + } + }); + expect(result.error).toBe(true); + expect(result.code === "INVALID_OPTIONS_OBJECT" || result.name === "renderError").toBe(true); + } finally { + await onClosed(); + } + }); + + test('throws renderError for unserializable options (function property)', async ({ page }) => { + const pug = `div#func-form(data-smark='{"type":"form","name":"f"}')\nscript(src=\"../../dist/SmarkForm.umd.js\")`; + const { url, onClosed } = await renderPug({ title: 'function option', src: pug }); + try { + await page.goto(url); + const result = await page.evaluate(() => { + const SmarkForm = window.SmarkForm; + const node = document.getElementById('func-form'); + const comp = new SmarkForm(node); + comp.options.badFn = () => 42; + try { + comp.setNodeOptions(node, comp.options); + return {error: false}; + } catch (e) { + return { + error: true, + name: e.name, + message: e.message, + code: e.code, + }; + } + }); + expect(result.error).toBe(true); + expect(result.code === "INVALID_OPTIONS_OBJECT" || result.name === "renderError").toBe(true); + } finally { + await onClosed(); + } + }); +}); From 41f2a6ca050ce181dd1257bafd7b2bd7a284b725 Mon Sep 17 00:00:00 2001 From: Joan Miquel Torres Date: Fri, 22 May 2026 00:14:28 +0200 Subject: [PATCH 6/8] fix: prevent options serialization validation crash during construction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove 'undefined' check from isSerializable() — JSON.stringify silently drops undefined property values, so they are serializable - Guard getPath() against unset parents (called during construction when renderError is thrown before parents is initialized) - Update masking docs: replace 'restores original type' with 'masking is permanent' to match current implementation --- dist/SmarkForm.esm.js | 2 +- dist/SmarkForm.esm.js.map | 2 +- dist/SmarkForm.umd.js | 2 +- dist/SmarkForm.umd.js.map | 2 +- docs/_advanced_concepts/field_masking.md | 4 ++-- src/lib/component.js | 5 +---- 6 files changed, 7 insertions(+), 10 deletions(-) diff --git a/dist/SmarkForm.esm.js b/dist/SmarkForm.esm.js index 8b28f4d6..3f20f7df 100644 --- a/dist/SmarkForm.esm.js +++ b/dist/SmarkForm.esm.js @@ -1 +1 @@ -function e(e,t,n){if("function"==typeof e?e===t:e.has(t))return arguments.length<3?t:n;throw new TypeError("Private element is not present on this object")}function t(e){if(Object(e)!==e)throw TypeError("right-hand side of 'in' should be an object, got "+(null!==e?typeof e:"null"));return e}function n(e,t){(function(e,t){if(t.has(e))throw new TypeError("Cannot initialize the same private elements twice on an object")})(e,t),t.add(e)}function r(e,t,n){return(t=l(t))in e?Object.defineProperty(e,t,{value:n,enumerable:!0,configurable:!0,writable:!0}):e[t]=n,e}function o(e,t){var n=Object.keys(e);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(e);t&&(r=r.filter(function(t){return Object.getOwnPropertyDescriptor(e,t).enumerable})),n.push.apply(n,r)}return n}function i(e){for(var t=1;t=0;E--){var _;void 0!==(b=r(w[E],o,p,u,l,c,d,h,f))&&(i(l,b),0===l?_=b:1===l?(_=b.init,v=b.get||h.get,N=b.set||h.set,h={get:v,set:N}):h=b,void 0!==_&&(void 0===g?g=_:"function"==typeof g?g=[g,_]:g.push(_)))}if(0===l||1===l){if(void 0===g)g=function(e,t){return t};else if("function"!=typeof g){var S=g;g=function(e,t){for(var n=t,r=0;r3,v=h>=5,N=r;if(v?(g=e,0!=(h-=5)&&(m=i=i||[]),b&&!a&&(a=function(n){return t(n)===e}),N=a):(g=e.prototype,0!==h&&(m=o=o||[])),0!==h&&!b){var w=v?d:l,E=w.get(y)||0;if(!0===E||3===E&&4!==h||4===E&&3!==h)throw Error("Attempted to decorate a public method/accessor that has the same name as a previously decorated public method/accessor. This is not currently supported by the decorators plugin. Property name was: "+y);!E&&h>2?w.set(y,h):w.set(y,!0)}c(s,g,p,y,h,v,b,m,N)}}return u(s,o),u(s,i),s}function u(e,t){t&&e.push(function(e){for(var n=0;n0){for(var r=[],o=t,a=t.name,s=n.length-1;s>=0;s--){var l={v:!1};try{var c=n[s](o,{kind:"class",name:a,addInitializer:e(r,l)})}finally{l.v=!0}void 0!==c&&(i(10,c),o=c)}return[o,function(){for(var e=0;e=0;t--)r.push(e.children[t]);for(;r.length;){const e=r.pop();if(e.matches(t))n.push(e);else for(let t=e.children.length-1;t>=0;t--)r.push(e.children[t])}return n}function u(e,t){let n=e.parentNode;const r=t>=0?1:-1;for(;n;){if(n.scrollHeight>n.clientHeight*r){var o=n.scrollHeight-n.clientHeight*r;if(t<=o*r)return void(n.scrollTop+=t);n.scrollTop=o,t-=o}n=n.parentNode}}function f(){return Math.random().toString(36).substring(2)}function p(e){try{return JSON.parse(e)}catch(e){}}function g(e){if(5===e.length&&":"===e[2]){const t=parseInt(e.substring(0,2),10),n=parseInt(e.substring(3,5),10);if(t>=0&&t<=23&&n>=0&&n<=59)return e+":00"}if(8===e.length&&":"===e[2]&&":"===e[5]){const t=parseInt(e.substring(0,2),10),n=parseInt(e.substring(3,5),10),r=parseInt(e.substring(6,8),10);if(t>=0&&t<=23&&n>=0&&n<=59&&r>=0&&r<=59)return e}if(6===e.length){const t=parseInt(e.substring(0,2),10),n=parseInt(e.substring(2,4),10),r=parseInt(e.substring(4,6),10);if(t>=0&&t<=23&&n>=0&&n<=59&&r>=0&&r<=59)return[e.substring(0,2),e.substring(2,4),e.substring(4,6)].join(":")}if(4===e.length){const t=parseInt(e.substring(0,2),10),n=parseInt(e.substring(2,4),10);if(t>=0&&t<=23&&n>=0&&n<=59)return[e.substring(0,2),e.substring(2,4),"00"].join(":")}return null}function m(e){if(15===e.length&&"T"===e[8]){const t=[e.substring(0,4),e.substring(4,6),e.substring(6,8)].join("-"),n=[e.substring(9,11),e.substring(11,13),e.substring(13,15)].join(":");return new Date("".concat(t,"T").concat(n))}if(13===e.length&&"T"===e[8]){const t=[e.substring(0,4),e.substring(4,6),e.substring(6,8)].join("-"),n=[e.substring(9,11),e.substring(11,13),"00"].join(":");return new Date("".concat(t,"T").concat(n))}if(19===e.length&&"-"===e[4]&&"-"===e[7]&&"T"===e[10]&&":"===e[13]&&":"===e[16])return new Date(e);if(16===e.length&&"-"===e[4]&&"-"===e[7]&&"T"===e[10]&&":"===e[13])return new Date(e+":00");return e.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/)?new Date(e):NaN}function h(e){let t=null==e?void 0:e.parentElement;for(;t;){if("DETAILS"===t.tagName&&!t.open){const n=t.querySelector(":scope > summary");if(!n||!n.contains(e))return!0}t=t.parentElement}return!1}function y(e,t,n,r){const o=e.tagName,i=e.getAttribute("type");if("INPUT"!=o||(i||t).toLowerCase()!=t){const e=new Error(r);throw e.code=n,e}i||(e.type=t)}const b=Symbol("Events"),v=Symbol("onEvents"),N=Symbol("allEvents"),w=/^on(?:Before|After)Action_/,E=/^onLocal_/,_=/^on_/,S=/^onAll_/,x={keydown:{bubbles:!0},keyup:{bubbles:!0},keypress:{bubbles:!0},beforeinput:{bubbles:!0},input:{bubbles:!0},change:{bubbles:!0},focus:{bubbles:!1},blur:{bubbles:!1},focusin:{bubbles:!0},focusout:{bubbles:!0},click:{bubbles:!0},dblclick:{bubbles:!0},contextmenu:{bubbles:!0},mousedown:{bubbles:!0},mouseup:{bubbles:!0},mousemove:{bubbles:!0},mouseenter:{bubbles:!1},mouseleave:{bubbles:!1},mouseover:{bubbles:!0},mouseout:{bubbles:!0},focusenter:{bubbles:!0,synthetic:!0},focusleave:{bubbles:!0,synthetic:!0}};function T(e,t,n){return e.has(t)||e.set(t,[]),e.get(t).push(n.bind(this)),this}async function k(e,t){const n=e?[e,...e.parents]:[],r=t?[t,...t.parents]:[],o=function(e,t){var n;const r=new Set(t);return null!==(n=e.find(e=>r.has(e)))&&void 0!==n?n:null}(n,r);for(const e of n){if(e===o)break;await e.emit("focusleave",{type:"focusleave",context:e})}for(const e of r){if(e===o)break;await e.emit("focusenter",{type:"focusenter",context:e})}}const A=Symbol("smarkform_legacy_prevent");var I={disEnhance(e){"form"!==e.targetNode.tagName.toLowerCase()||e.targetNode[A]||(e.targetNode[A]=!0,e.targetNode.addEventListener("submit",function(e){e.preventDefault()}))}};const O=["type"],L=new Map,C=new Set;function D(e){const t=document.createTreeWalker(e,NodeFilter.SHOW_ELEMENT);let n=t.currentNode;for(;n;)n.hasAttribute("id")&&(n.setAttribute("data-id",n.getAttribute("id")),n.removeAttribute("id")),n=t.nextNode()}function P(e){try{return new URL(e).origin!==location.origin}catch(e){return!1}}async function F(e,t,n){const r=t.type,o=r.indexOf("#"),s=r.slice(0,o),l=r.slice(o+1);if(!l)throw n.renderError("MIXIN_TYPE_MISSING_FRAGMENT",'Mixin type reference "'.concat(r,'" must include a non-empty')+" #templateId fragment.");let c,d;if(s){var u;d=new URL(s,document.baseURI).href;const e=null!==(u=n.root.options.allowExternalMixins)&&void 0!==u?u:"block";if("block"===e)throw n.renderError("MIXIN_EXTERNAL_FETCH_BLOCKED",'Mixin type "'.concat(r,'" references an external URL but')+' allowExternalMixins is "block" (the default). Set allowExternalMixins to "same-origin" or "allow" on the root SmarkForm instance to permit external mixin loading.');if("same-origin"===e&&P(d))throw n.renderError("MIXIN_CROSS_ORIGIN_FETCH_BLOCKED",'Mixin type "'.concat(r,'" references a cross-origin URL')+" (".concat(new URL(d).origin,") but allowExternalMixins")+' is "same-origin". Set allowExternalMixins to "allow" to permit cross-origin mixin loading.');L.has(d)||L.set(d,fetch(d).then(e=>{if(!e.ok)throw Object.assign(new Error("Failed to fetch mixin source: ".concat(d)+" (HTTP ".concat(e.status,")")),{code:"MIXIN_FETCH_ERROR"});return e.text()}).then(e=>(new DOMParser).parseFromString(e,"text/html"))),c=await L.get(d)}else c=document,d=document.baseURI;const f="".concat(d,"#").concat(l),g=n._mixinChain||new Set;if(g.has(f))throw n.renderError("MIXIN_CIRCULAR_DEPENDENCY",'Circular mixin dependency detected: "'.concat(f,'"')+" is already being expanded in this rendering chain.");const m=c.getElementById(l);if(!m||"template"!==m.tagName.toLowerCase())throw n.renderError("MIXIN_TEMPLATE_NOT_FOUND","Mixin template #".concat(l," not found")+" in ".concat(s||"the current document","."));const h=[...m.content.childNodes].filter(e=>e.nodeType===Node.ELEMENT_NODE),y=h.filter(e=>"script"===e.tagName.toLowerCase()),b=h.filter(e=>"style"===e.tagName.toLowerCase()),v=h.filter(e=>{const t=e.tagName.toLowerCase();return"script"!==t&&"style"!==t});if(1!==v.length)throw n.renderError("MIXIN_TEMPLATE_INVALID_ROOT","Mixin template #".concat(l," must contain exactly one root")+" element (found ".concat(v.length,")."));const N=v[0],w=p(N.getAttribute("data-smark"))||{};if(void 0!==w.name)throw n.renderError("MIXIN_TEMPLATE_ROOT_HAS_NAME","Mixin template #".concat(l," root element must not specify")+' a "name" in its data-smark options. The name must be set on the placeholder (usage site).');const E=N.cloneNode(!0),_=[...e.children].filter(e=>e.hasAttribute("data-for"));_.length>0&&function(e,t){for(const n of t){const t=n.getAttribute("data-for"),r=e.querySelector('[id="'.concat(CSS.escape(t),'"]'));if(!r)continue;const o=n.cloneNode(!0);D(o),o.removeAttribute("data-for"),r.replaceWith(o)}}(E,_),function(e,t){if(e.querySelector("script"))throw t.renderError("MIXIN_NESTED_SCRIPT_DISALLOWED","Mixin template root subtree must not contain \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested \n // \n // is valid.\n const templateContentEls = [...template.content.childNodes]\n .filter(n => n.nodeType === Node.ELEMENT_NODE);\n const topLevelScripts = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'script');\n const topLevelStyles = templateContentEls\n .filter(n => n.tagName.toLowerCase() === 'style');\n const rootElements = templateContentEls\n .filter(n => {\n const tag = n.tagName.toLowerCase();\n return tag !== 'script' && tag !== 'style';\n });\n if (rootElements.length !== 1) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_INVALID_ROOT'\n , `Mixin template #${templateId} must contain exactly one root`\n + ` element (found ${rootElements.length}).`\n );\n }\n\n const templateRoot = rootElements[0];\n\n // Validate: template root must not set \"name\" in data-smark:\n const templateRootOptions = parseJSON(\n templateRoot.getAttribute('data-smark')\n ) || {};\n if (templateRootOptions.name !== undefined) {\n throw component.renderError(\n 'MIXIN_TEMPLATE_ROOT_HAS_NAME'\n , `Mixin template #${templateId} root element must not specify`\n + ' a \"name\" in its data-smark options.'\n + ' The name must be set on the placeholder (usage site).'\n );\n }\n\n // Deep-clone the template root:\n const clone = templateRoot.cloneNode(true);\n\n // Collect snippet parameter nodes: direct children of the placeholder\n // that carry a `data-for` attribute, referencing elements inside the clone\n // by their id. These are consumed (not rendered as children).\n const params = [...node.children].filter(el => el.hasAttribute('data-for'));\n\n // Apply snippet parameter substitutions on the clone:\n if (params.length > 0) {\n applySnippetParams(clone, params);\n }\n\n // Enforce no nested +

@@ -41,16 +41,13 @@ A credit card field demonstrates spaces between every 4 digits — a pattern fam {% raw %}{% endraw %} {% capture mask_cc_js -%} -const form = new SmarkForm("#payment"); -form.rendered.then(async () => { - const cardField = form.find("/cardNumber"); +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", - blocks: { - "0000": {mask: IMask.Number} - } + mask: "0000 0000 0000 0000" }); }); }); @@ -67,6 +64,7 @@ form.rendered.then(async () => { jsHead=mask_cc_js notes=mask_cc_notes showEditor=true + tests=false %} ## Price Example (Non-Text Field Type) @@ -75,7 +73,7 @@ This shows how to use a `number` field type — SmarkForm converts it to `text` {% raw %}{% endraw %} {% capture mask_price_html -%} - +

@@ -89,13 +87,13 @@ This shows how to use a `number` field type — SmarkForm converts it to `text` {% raw %}{% endraw %} {% capture mask_price_js -%} -const form = new SmarkForm("#product"); -form.rendered.then(async () => { - const priceField = form.find("/price"); +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: 100, // 2 decimal places + scale: 2, // 2 decimal places thousandsSeparator: " " // Add space separator every 3 digits }); // Export returns a proper JavaScript number, not a formatted string @@ -115,37 +113,36 @@ form.rendered.then(async () => { jsHead=mask_price_js notes=mask_price_notes showEditor=true + tests=false %} -## Singleton Masking +## Singleton (Single Field) Masking -When masking is applied to a singleton component (e.g., a number field wrapped in a type converter), the method delegates to the inner field. The `_maskInstance` lives on the inner field while the outer component remains chainable. +Masking works on any field within a form, including single-field forms. Apply the mask to the child field just like any other. {% raw %}{% endraw %} {% capture mask_singleton_html -%} - -

- + +
+

+ + +

{%- endcapture %}{% raw %}{% endraw %} {% raw %}{% endraw %} {% capture mask_singleton_js -%} -const form = new SmarkForm("#singleton"); -form.rendered.then(async () => { - // Mask on the singleton — returns the outer component for chaining - const quantityField = form.find("/quantity"); +const myForm = new SmarkForm("#singleton"); +myForm.rendered.then(async () => { + const quantityField = myForm.find("/quantity"); quantityField.mask(node => new IMask(node, { mask: "0[.]00" })); - // The inner field has the _maskInstance - const innerField = quantityField.children[""]; - console.log("Inner field has mask:", innerField._maskInstance !== undefined); - console.log("Outer field has mask:", quantityField._maskInstance === undefined); }); {%- endcapture %}{% raw %}{% endraw %} {% raw %}{% endraw %} {% capture mask_singleton_notes -%} -**Try it!** This playground demonstrates singleton masking: edit the JS to check the instance locations and try different masks. +**Try it!** Try different masks and see the quantity field format change. {%- endcapture %}{% raw %}{% endraw %} {% include components/sampletabs_tpl.md @@ -154,6 +151,7 @@ form.rendered.then(async () => { jsHead=mask_singleton_js notes=mask_singleton_notes showEditor=true + tests=false %} ## Validation @@ -162,7 +160,7 @@ Many masking libraries include built-in validation. You can integrate it by chec {% raw %}{% endraw %} {% capture mask_validation_html -%} - +

@@ -176,12 +174,12 @@ Many masking libraries include built-in validation. You can integrate it by chec {% raw %}{% endraw %} {% capture mask_validation_js -%} -const form = new SmarkForm("#pricevalidation"); -form.rendered.then(async () => { - const priceField = form.find("/price"); +const myForm = new SmarkForm("#pricevalidation"); +myForm.rendered.then(async () => { + const priceField = myForm.find("/price"); const imask = new IMask(priceField.targetFieldNode, { mask: Number, - scale: 100, + scale: 2, thousandsSeparator: " " }); priceField._maskInstance = imask; @@ -203,6 +201,7 @@ form.rendered.then(async () => { jsHead=mask_validation_js notes=mask_validation_notes showEditor=true + tests=false %} ## Masking is Permanent @@ -214,7 +213,7 @@ When a mask converts an input to `text`, that change is permanent for the lifeti > Try the playgrounds above to see the key features. For production usage with your own CDN, use: ```html - + ```