diff --git a/AGENTS.md b/AGENTS.md index 1c22f2cf..a82e370c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/dist/SmarkForm.esm.js b/dist/SmarkForm.esm.js index 6caf50e0..a9c0870f 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 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 +
+

+ + +

+

+ +

+
+{%- endcapture %}{% raw %}{% endraw %} + +{% raw %}{% 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 %}{% 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 %}{% endraw %} +{% capture mask_price_html -%} + +
+

+ + +

+

+ +

+
+{%- endcapture %}{% raw %}{% endraw %} + +{% raw %}{% 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 %}{% 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 %}{% endraw %} +{% capture mask_singleton_html -%} + +
+

+ + +

+
+{%- endcapture %}{% raw %}{% endraw %} + +{% raw %}{% 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 %}{% 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 %}{% endraw %} +{% capture mask_validation_html -%} + +
+

+ + +

+

+ +

+
+{%- endcapture %}{% raw %}{% endraw %} + +{% raw %}{% 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 %}{% 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 + +> Try the playgrounds above to see the key features. For production usage with your own CDN, use: + +```html + + + +``` + +```javascript +// See above for field setup! Only IMask and SmarkForm loading differs for CDN usage. +``` 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", diff --git a/src/lib/component.js b/src/lib/component.js index 7e21d735..275cbad8 100644 --- a/src/lib/component.js +++ b/src/lib/component.js @@ -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) {//{{{ @@ -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("/") || "/"; };//}}} diff --git a/src/main.js b/src/main.js index 7297bd50..9b4d7192 100644 --- a/src/main.js +++ b/src/main.js @@ -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: "", diff --git a/src/types/input.type.js b/src/types/input.type.js index 21f95ab6..f7bb13b6 100644 --- a/src/types/input.type.js +++ b/src/types/input.type.js @@ -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; @@ -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 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; }; @@ -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; + };//}}} }; diff --git a/test/general.tests.js b/test/general.tests.js index 527b66eb..828aa600 100644 --- a/test/general.tests.js +++ b/test/general.tests.js @@ -312,3 +312,53 @@ block mainForm }); +test.describe('options validation', () => { + test('throws on cyclic options', 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; + try { + comp.setNodeOptions(node, comp.options); + return {error: false}; + } catch (e) { + return {error: true, name: e.name, message: e.message}; + } + }); + expect(result.error).toBe(true); + expect(result.name).toBe("TypeError"); + expect(result.message).toContain("circular"); + } finally { + await onClosed(); + } + }); + + test('throws on options with 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}; + } + }); + expect(result.error).toBe(true); + expect(result.message).toContain("Function found at"); + } finally { + await onClosed(); + } + }); +}); diff --git a/test/mask.tests.js b/test/mask.tests.js new file mode 100644 index 00000000..5614bc53 --- /dev/null +++ b/test/mask.tests.js @@ -0,0 +1,379 @@ +import { test, expect } from '@playwright/test'; +import {renderPug} from '../src/lib/test/helpers.js'; + +const pugSrc = (// {{{ +`extends layout.pug +block mainForm + .section + // Plain text field - type must NOT be changed + p + label Text Field + input( + data-smark + name="textField" + type="text" + ) + + // Number field - type must be changed to "text" for masking + p + label Number Field + input( + data-smark={ + type: "number", + name: "numberField" + } + type="number" + ) + + // Date field - type must be changed to "text" for masking + p + label Date Field + input( + data-smark={ + type: "date", + name: "dateField" + } + type="date" + ) + + // Singleton number (mask called on outer singleton component) + div(data-smark={ + type: "number", + name: "singletonNumber" + }) + p + label Singleton Number + input(data-smark type="number") +`);// }}} + +test.describe('mask() method', () => { + const test_title = 'mask() method'; + + test('changes input type to "text" for number fields', async ({ page }) => {//{{{ + let onClosed; + try { + const rendered = await renderPug({ title: test_title, src: pugSrc }); + onClosed = rendered.onClosed; + await page.goto(rendered.url); + + const result = await page.evaluate(async () => { + const field = form.find("/numberField"); + await field.import(42); // ensure rendered + const typeBefore = field.targetFieldNode.getAttribute("type"); + field.mask(() => null); + const typeAfter = field.targetFieldNode.getAttribute("type"); + return { typeBefore, typeAfter /* masking is now permanent; _originalType removed */ }; + }); + + expect(result.typeBefore).toBe("number"); + expect(result.typeAfter).toBe("text"); + } finally { + if (onClosed) await onClosed(); + } + });//}}} + + test('changes input type to "text" for date fields', async ({ page }) => {//{{{ + let onClosed; + try { + const rendered = await renderPug({ title: test_title, src: pugSrc }); + onClosed = rendered.onClosed; + await page.goto(rendered.url); + + const result = await page.evaluate(async () => { + const field = form.find("/dateField"); + await field.import("2024-01-15"); + const typeBefore = field.targetFieldNode.getAttribute("type"); + field.mask(() => null); + const typeAfter = field.targetFieldNode.getAttribute("type"); + return { typeBefore, typeAfter /* masking is now permanent; _originalType removed */ }; + }); + + expect(result.typeBefore).toBe("date"); + expect(result.typeAfter).toBe("text"); + } finally { + if (onClosed) await onClosed(); + } + });//}}} + + test('does not change type for plain text fields', async ({ page }) => {//{{{ + let onClosed; + try { + const rendered = await renderPug({ title: test_title, src: pugSrc }); + onClosed = rendered.onClosed; + await page.goto(rendered.url); + + const result = await page.evaluate(async () => { + const field = form.find("/textField"); + await field.import("hello"); + const typeBefore = field.targetFieldNode.getAttribute("type"); + field.mask(() => null); + const typeAfter = field.targetFieldNode.getAttribute("type"); + return { typeBefore, typeAfter /* masking is now permanent; _originalType removed */ }; + }); + + expect(result.typeBefore).toBe("text"); + expect(result.typeAfter).toBe("text"); + } finally { + if (onClosed) await onClosed(); + } + });//}}} + + test('calls callback with the targetFieldNode', async ({ page }) => {//{{{ + let onClosed; + try { + const rendered = await renderPug({ title: test_title, src: pugSrc }); + onClosed = rendered.onClosed; + await page.goto(rendered.url); + + const result = await page.evaluate(async () => { + const field = form.find("/numberField"); + await field.import(99); + let receivedNode = null; + field.mask(node => { + receivedNode = node; + return null; + }); + return { + isSameNode: receivedNode === field.targetFieldNode, + tagName: receivedNode?.tagName, + }; + }); + + expect(result.isSameNode).toBe(true); + expect(result.tagName).toBe("INPUT"); + } finally { + if (onClosed) await onClosed(); + } + });//}}} + + test('returns the component itself for chaining', async ({ page }) => {//{{{ + let onClosed; + try { + const rendered = await renderPug({ title: test_title, src: pugSrc }); + onClosed = rendered.onClosed; + await page.goto(rendered.url); + + const result = await page.evaluate(async () => { + const field = form.find("/textField"); + await field.import("hi"); + const returned = field.mask(() => null); + return returned === field; + }); + + expect(result).toBe(true); + } finally { + if (onClosed) await onClosed(); + } + });//}}} + + test('export() uses _maskInstance.unmaskedValue when provided', async ({ page }) => {//{{{ + let onClosed; + try { + const rendered = await renderPug({ title: test_title, src: pugSrc }); + onClosed = rendered.onClosed; + await page.goto(rendered.url); + + const result = await page.evaluate(async () => { + const field = form.find("/textField"); + await field.import("original"); + // Apply mock mask: displays formatted value but tracks unmasked separately + const mockMask = { unmaskedValue: "raw_value" }; + field.mask(node => { + node.value = "formatted_value"; // mask changes display + return mockMask; + }); + // export() should return unmaskedValue, not the formatted display value + const exported = await field.export(); + return { exported, displayValue: field.targetFieldNode.value }; + }); + + expect(result.exported).toBe("raw_value"); + expect(result.displayValue).toBe("formatted_value"); + } finally { + if (onClosed) await onClosed(); + } + });//}}} + + test('number field export() still returns a JS number when mask provides unmaskedValue', async ({ page }) => {//{{{ + let onClosed; + try { + const rendered = await renderPug({ title: test_title, src: pugSrc }); + onClosed = rendered.onClosed; + await page.goto(rendered.url); + + const result = await page.evaluate(async () => { + const field = form.find("/numberField"); + await field.import(1234.56); + // Mock a number mask: display "1,234.56", unmasked "1234.56" + const mockMask = { unmaskedValue: "1234.56" }; + field.mask(node => { + node.value = "1,234.56"; // formatted display + return mockMask; + }); + const exported = await field.export(); + return { exported, type: typeof exported }; + }); + + expect(result.exported).toBe(1234.56); + expect(result.type).toBe("number"); + } finally { + if (onClosed) await onClosed(); + } + });//}}} + + test('export() falls back to nodeFld.value when no unmaskedValue', async ({ page }) => {//{{{ + let onClosed; + try { + const rendered = await renderPug({ title: test_title, src: pugSrc }); + onClosed = rendered.onClosed; + await page.goto(rendered.url); + + const result = await page.evaluate(async () => { + const field = form.find("/textField"); + await field.import("hello"); + // mask returns an object WITHOUT unmaskedValue + field.mask(() => ({ someOtherProp: true })); + const exported = await field.export(); + return exported; + }); + + expect(result).toBe("hello"); + } finally { + if (onClosed) await onClosed(); + } + });//}}} + + test('export() falls back to nodeFld.value when callback returns null', async ({ page }) => {//{{{ + let onClosed; + try { + const rendered = await renderPug({ title: test_title, src: pugSrc }); + onClosed = rendered.onClosed; + await page.goto(rendered.url); + + const result = await page.evaluate(async () => { + const field = form.find("/textField"); + await field.import("world"); + field.mask(() => null); + const exported = await field.export(); + return exported; + }); + + expect(result).toBe("world"); + } finally { + if (onClosed) await onClosed(); + } + });//}}} + + test('singleton: mask() delegates to inner field', async ({ page }) => {//{{{ + let onClosed; + try { + const rendered = await renderPug({ title: test_title, src: pugSrc }); + onClosed = rendered.onClosed; + await page.goto(rendered.url); + + const result = await page.evaluate(async () => { + const singleton = form.find("/singletonNumber"); + await singleton.import(100); + const returned = singleton.mask(() => null); + return { + // mask() returns the outer singleton component + returnedIsSingleton: returned === singleton, + // _maskInstance is on the inner field, not the singleton itself + innerHasMask: singleton.children[""]._maskInstance !== undefined, + outerHasNoMask: singleton._maskInstance === undefined, + // type is changed on the actual input element + inputType: singleton.targetFieldNode.getAttribute("type"), + }; + }); + + expect(result.returnedIsSingleton).toBe(true); + expect(result.innerHasMask).toBe(true); + expect(result.outerHasNoMask).toBe(true); + expect(result.inputType).toBe("text"); + } finally { + if (onClosed) await onClosed(); + } + });//}}} + + test('singleton: export() still returns correct number type after masking', async ({ page }) => {//{{{ + let onClosed; + try { + const rendered = await renderPug({ title: test_title, src: pugSrc }); + onClosed = rendered.onClosed; + await page.goto(rendered.url); + + const result = await page.evaluate(async () => { + const singleton = form.find("/singletonNumber"); + await singleton.import(42); + const mockMask = { unmaskedValue: "42" }; + singleton.mask(node => { + node.value = "42.00"; // formatted display + return mockMask; + }); + const exported = await singleton.export(); + return { exported, type: typeof exported }; + }); + + expect(result.exported).toBe(42); + expect(result.type).toBe("number"); + } finally { + if (onClosed) await onClosed(); + } + });//}}} + + test('import() dispatches input event on masked field', async ({ page }) => {//{{{ + let onClosed; + try { + const rendered = await renderPug({ title: test_title, src: pugSrc }); + onClosed = rendered.onClosed; + await page.goto(rendered.url); + + const result = await page.evaluate(async () => { + const field = form.find("/textField"); + await field.import("initial"); + let inputEventCount = 0; + const mockMask = { unmaskedValue: "" }; + field.mask(node => { + node.addEventListener("input", () => { + inputEventCount++; + mockMask.unmaskedValue = node.value; + }); + return mockMask; + }); + await field.import("updated"); + return { inputEventCount, unmaskedValue: mockMask.unmaskedValue }; + }); + + expect(result.inputEventCount).toBeGreaterThanOrEqual(1); + expect(result.unmaskedValue).toBe("updated"); + } finally { + if (onClosed) await onClosed(); + } + });//}}} + + test('import() does NOT dispatch extra input event on unmasked field', async ({ page }) => {//{{{ + let onClosed; + try { + const rendered = await renderPug({ title: test_title, src: pugSrc }); + onClosed = rendered.onClosed; + await page.goto(rendered.url); + + const result = await page.evaluate(async () => { + const field = form.find("/textField"); + await field.import("initial"); + let inputEventCount = 0; + field.targetFieldNode.addEventListener("input", () => { + inputEventCount++; + }); + // No mask applied + await field.import("no mask"); + return { inputEventCount }; + }); + + expect(result.inputEventCount).toBe(0); + } finally { + if (onClosed) await onClosed(); + } + });//}}} + +});