From 06866490bfc016ca07c1b1f7d9918776ac9abeb3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 21:59:54 +0000 Subject: [PATCH 1/4] Initial plan From 00b0a65c28b6cc793fb4e095456bebe0a577a98c Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:04:16 +0000 Subject: [PATCH 2/4] chore: initial plan for validation module Agent-Logs-Url: https://github.com/bitifet/SmarkForm/sessions/77e71182-d446-4a5e-aef8-01c231a49ee5 Co-authored-by: bitifet <1643647+bitifet@users.noreply.github.com> --- docs/_data/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/_data/package.json b/docs/_data/package.json index bc39807a..c6bc4d6c 100644 --- a/docs/_data/package.json +++ b/docs/_data/package.json @@ -55,7 +55,7 @@ "@rollup/plugin-terser": "^1.0.0", "concurrently": "^9.2.0", "jsonc": "^2.0.0", - "minimatch": "^10.2.4", + "minimatch": "^10.2.5", "parse5": "^8.0.0", "pug": "^3.0.4", "rollup": "^4.60.1", From f57a27a2a259ad996c26cb66c85af2a13f92ad07 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 22:18:05 +0000 Subject: [PATCH 3/4] feat: add validation plugin (createValidation), tests, and docs Agent-Logs-Url: https://github.com/bitifet/SmarkForm/sessions/77e71182-d446-4a5e-aef8-01c231a49ee5 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 +- docs/_advanced_concepts/validation.md | 395 +++++++++++++++++++ src/lib/validation/index.js | 280 ++++++++++++++ src/main.js | 2 + test/validation.tests.js | 536 ++++++++++++++++++++++++++ 8 files changed, 1217 insertions(+), 4 deletions(-) create mode 100644 docs/_advanced_concepts/validation.md create mode 100644 src/lib/validation/index.js create mode 100644 test/validation.tests.js diff --git a/dist/SmarkForm.esm.js b/dist/SmarkForm.esm.js index 24393bc5..026087a0 100644 --- a/dist/SmarkForm.esm.js +++ b/dist/SmarkForm.esm.js @@ -1 +1 @@ -function t(t,e,n){if("function"==typeof t?t===e:t.has(e))return arguments.length<3?e:n;throw new TypeError("Private element is not present on this object")}function e(t){if(Object(t)!==t)throw TypeError("right-hand side of 'in' should be an object, got "+(null!==t?typeof t:"null"));return t}function n(t,e){(function(t,e){if(e.has(t))throw new TypeError("Cannot initialize the same private elements twice on an object")})(t,e),e.add(t)}function r(t,e,n){return(e=l(e))in t?Object.defineProperty(t,e,{value:n,enumerable:!0,configurable:!0,writable:!0}):t[e]=n,t}function o(t,e){var n=Object.keys(t);if(Object.getOwnPropertySymbols){var r=Object.getOwnPropertySymbols(t);e&&(r=r.filter(function(e){return Object.getOwnPropertyDescriptor(t,e).enumerable})),n.push.apply(n,r)}return n}function i(t){for(var e=1;e=0;E--){var _;void 0!==(b=r(w[E],o,f,u,l,c,d,m,p))&&(i(l,b),0===l?_=b:1===l?(_=b.init,v=b.get||m.get,N=b.set||m.set,m={get:v,set:N}):m=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(t,e){return e};else if("function"!=typeof g){var T=g;g=function(t,e){for(var n=e,r=0;r3,v=m>=5,N=r;if(v?(g=t,0!=(m-=5)&&(h=i=i||[]),b&&!a&&(a=function(n){return e(n)===t}),N=a):(g=t.prototype,0!==m&&(h=o=o||[])),0!==m&&!b){var w=v?d:l,E=w.get(y)||0;if(!0===E||3===E&&4!==m||4===E&&3!==m)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&&m>2?w.set(y,m):w.set(y,!0)}c(s,g,f,y,m,v,b,h,N)}}return u(s,o),u(s,i),s}function u(t,e){e&&t.push(function(t){for(var n=0;n0){for(var r=[],o=e,a=e.name,s=n.length-1;s>=0;s--){var l={v:!1};try{var c=n[s](o,{kind:"class",name:a,addInitializer:t(r,l)})}finally{l.v=!0}void 0!==c&&(i(10,c),o=c)}return[o,function(){for(var t=0;tnull===t:e=>null===e||e.isSameNode(t);return[...t.querySelectorAll(e)].filter(t=>n(t.parentNode.closest(e)))}function u(t,e){let n=t.parentNode;const r=e>=0?1:-1;for(;n;){if(n.scrollHeight>n.clientHeight*r){var o=n.scrollHeight-n.clientHeight*r;if(e<=o*r)return void(n.scrollTop+=e);n.scrollTop=o,e-=o}n=n.parentNode}}function p(){return Math.random().toString(36).substring(2)}function f(t){try{return JSON.parse(t)}catch(t){}}function g(t){if(5===t.length&&":"===t[2]){const e=parseInt(t.substring(0,2),10),n=parseInt(t.substring(3,5),10);if(e>=0&&e<=23&&n>=0&&n<=59)return t+":00"}if(8===t.length&&":"===t[2]&&":"===t[5]){const e=parseInt(t.substring(0,2),10),n=parseInt(t.substring(3,5),10),r=parseInt(t.substring(6,8),10);if(e>=0&&e<=23&&n>=0&&n<=59&&r>=0&&r<=59)return t}if(6===t.length){const e=parseInt(t.substring(0,2),10),n=parseInt(t.substring(2,4),10),r=parseInt(t.substring(4,6),10);if(e>=0&&e<=23&&n>=0&&n<=59&&r>=0&&r<=59)return[t.substring(0,2),t.substring(2,4),t.substring(4,6)].join(":")}if(4===t.length){const e=parseInt(t.substring(0,2),10),n=parseInt(t.substring(2,4),10);if(e>=0&&e<=23&&n>=0&&n<=59)return[t.substring(0,2),t.substring(2,4),"00"].join(":")}return null}function h(t){if(15===t.length&&"T"===t[8]){const e=[t.substring(0,4),t.substring(4,6),t.substring(6,8)].join("-"),n=[t.substring(9,11),t.substring(11,13),t.substring(13,15)].join(":");return new Date("".concat(e,"T").concat(n))}if(13===t.length&&"T"===t[8]){const e=[t.substring(0,4),t.substring(4,6),t.substring(6,8)].join("-"),n=[t.substring(9,11),t.substring(11,13),"00"].join(":");return new Date("".concat(e,"T").concat(n))}if(19===t.length&&"-"===t[4]&&"-"===t[7]&&"T"===t[10]&&":"===t[13]&&":"===t[16])return new Date(t);if(16===t.length&&"-"===t[4]&&"-"===t[7]&&"T"===t[10]&&":"===t[13])return new Date(t+":00");return t.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})?$/)?new Date(t):NaN}function m(t,e,n,r){const o=t.tagName,i=t.getAttribute("type");if("INPUT"!=o||(i||e).toLowerCase()!=e){const t=new Error(r);throw t.code=n,t}i||(t.type=e)}const y=Symbol("Events"),b=Symbol("onEvents"),v=Symbol("allEvents"),N=/^on(?:Before|After)Action_/,w=/^onLocal_/,E=/^on_/,_=/^onAll_/,T={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 x(t,e,n){return t.has(e)||t.set(e,[]),t.get(e).push(n.bind(this)),this}async function A(t,e){const n=t?[t,...t.parents]:[],r=e?[e,...e.parents]:[],o=function(t,e){var n;const r=new Set(e);return null!==(n=t.find(t=>r.has(t)))&&void 0!==n?n:null}(n,r);for(const t of n){if(t===o)break;await t.emit("focusleave",{type:"focusleave",context:t})}for(const t of r){if(t===o)break;await t.emit("focusenter",{type:"focusenter",context:t})}}const S=Symbol("smarkform_legacy_prevent");var k={disEnhance(t){"form"!==t.targetNode.tagName.toLowerCase()||t.targetNode[S]||(t.targetNode[S]=!0,t.targetNode.addEventListener("submit",function(t){t.preventDefault()}))}};const O=["type"],I=new Map,L=new Set;function C(t){const e=document.createTreeWalker(t,NodeFilter.SHOW_ELEMENT);let n=e.currentNode;for(;n;)n.hasAttribute("id")&&(n.setAttribute("data-id",n.getAttribute("id")),n.removeAttribute("id")),n=e.nextNode()}async function P(t,e,n){const r=e.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;s?(d=new URL(s,document.baseURI).href,I.has(d)||I.set(d,fetch(d).then(t=>{if(!t.ok)throw Object.assign(new Error("Failed to fetch mixin source: ".concat(d)+" (HTTP ".concat(t.status,")")),{code:"MIXIN_FETCH_ERROR"});return t.text()}).then(t=>(new DOMParser).parseFromString(t,"text/html"))),c=await I.get(d)):(c=document,d=document.baseURI);const u="".concat(d,"#").concat(l),p=n._mixinChain||new Set;if(p.has(u))throw n.renderError("MIXIN_CIRCULAR_DEPENDENCY",'Circular mixin dependency detected: "'.concat(u,'"')+" is already being expanded in this rendering chain.");const g=c.getElementById(l);if(!g||"template"!==g.tagName.toLowerCase())throw n.renderError("MIXIN_TEMPLATE_NOT_FOUND","Mixin template #".concat(l," not found")+" in ".concat(s||"the current document","."));const h=[...g.content.childNodes].filter(t=>t.nodeType===Node.ELEMENT_NODE),m=h.filter(t=>"script"===t.tagName.toLowerCase()),y=h.filter(t=>"style"===t.tagName.toLowerCase()),b=h.filter(t=>{const e=t.tagName.toLowerCase();return"script"!==e&&"style"!==e});if(1!==b.length)throw n.renderError("MIXIN_TEMPLATE_INVALID_ROOT","Mixin template #".concat(l," must contain exactly one root")+" element (found ".concat(b.length,")."));const v=b[0],N=f(v.getAttribute("data-smark"))||{};if(void 0!==N.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 w=v.cloneNode(!0),E=[...t.children].filter(t=>t.hasAttribute("data-for"));E.length>0&&function(t,e){for(const n of e){const e=n.getAttribute("data-for"),r=t.querySelector('[id="'.concat(CSS.escape(e),'"]'));if(!r)continue;const o=n.cloneNode(!0);C(o),o.removeAttribute("data-for"),r.replaceWith(o)}}(w,E),function(t,e){if(t.querySelector("script"))throw e.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 + +``` + + +## Validation Providers + +### Provider signature + +A provider is any function — sync or async — with this signature: + +```javascript +async function myProvider({ root, data, reason, signal }) { + // `root` – the SmarkForm root instance + // `data` – current exported form data (plain JSON object) + // `reason` – why validation was triggered ('change', 'export', 'manual', …) + // `signal` – AbortSignal; abort if a newer validation run starts + + return { + issues: [ + // ... zero or more issue objects + ], + }; +} +``` + +Providers are called in order and their results are merged. A provider +that throws is silently skipped (with a console warning). + +### Issue shape + +| Property | Type | Required | Description | +|---|---|---|---| +| `level` | `"error"` \| `"warning"` | ✓ | Severity | +| `paths` | `string[]` | ✓ | SmarkForm paths (from `getPath()`) of the affected fields | +| `code` | `string` | ✓ | Machine-readable error code | +| `message` | `string` | ✓ | Human-readable message | +| `source` | `string` | ✓ | Name of the provider/system | +| `id` | `string` | | Stable ID (auto-generated if omitted) | +| `details` | `any` | | Optional extra data | + +The default `id` is generated as `${source}:${code}:${paths.join('|')}`. +Issues with the **same `id` across two consecutive runs** are considered +*persisting* (not new or solved). + +### Async providers + +Providers can be fully asynchronous — for example, to validate against a +backend API: + +```javascript +async function serverProvider({ data, signal }) { + const response = await fetch("/api/validate", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(data), + signal, // cancel if a newer run starts + }); + const result = await response.json(); + return { issues: result.issues }; +} +``` + + +## API Reference + +### `createValidation(root, options)` + +Creates and attaches a validation controller to a SmarkForm root instance. + +```javascript +const validation = SmarkForm.createValidation(root, options); +``` + +**Options:** + +| Option | Type | Default | Description | +|---|---|---|---| +| `providers` | `Function[]` | `[]` | Validation provider functions | +| `debounce` | `number` | `300` | Delay (ms) before running validation after a `change` event | +| `blockExportOnErrors` | `boolean` | `true` | Call `preventDefault()` on `BeforeAction_export` when errors exist | +| `applyA11y` | `boolean` | `true` | Apply `aria-invalid` ARIA attributes automatically | + +### `validation.validate(reason?)` + +Force an immediate validation run, bypassing the debounce timer. + +```javascript +const state = await validation.validate("manual"); +``` + +Returns a Promise that resolves to the current validation state object: + +```javascript +{ + issues: Issue[], // all current issues + hasErrors: boolean, + hasWarnings: boolean, + newIssues: Issue[], // appeared since last run + solvedIssues: Issue[], // gone since last run + persistingIssues: Issue[], // present in both runs +} +``` + +### `validation.getState()` + +Synchronously returns the latest known validation state snapshot (no new +provider calls are made). + +```javascript +const { issues, hasErrors, hasWarnings } = validation.getState(); +``` + +### `validation.destroy()` + +Detaches all internal listeners and cancels any pending work. The +`validation` object should not be used after this call. + +```javascript +validation.destroy(); +``` + + +## Events + +All validation events are emitted through SmarkForm's `.emit()` mechanism +on the root instance and can be registered with `.on()`, `.onLocal()`, or +`.onAll()`. + +### `ValidationStateChanged` + +Fired **after every** validation run (even when issues do not change). + +```javascript +myForm.on("ValidationStateChanged", (ev) => { + console.log("Validation complete:", ev.hasErrors, ev.issues); +}); +``` + +**Event properties:** + +| Property | Description | +|---|---| +| `issues` | All current issues | +| `hasErrors` | `true` if any error-level issue is active | +| `hasWarnings` | `true` if any warning-level issue is active | +| `newIssues` | Issues that appeared since the last run | +| `solvedIssues` | Issues that disappeared since the last run | +| `persistingIssues` | Issues present in both the previous and current run | +| `reason` | Why validation was triggered | + +### `ValidationIssuesChanged` + +Fired **only when the set of issues actually changes** (i.e. `newIssues` +or `solvedIssues` is non-empty). Use this event to update issue lists in +your UI without reacting to every no-change validation run. + +```javascript +myForm.on("ValidationIssuesChanged", (ev) => { + renderIssueList(ev.issues); +}); +``` + +Has the same properties as `ValidationStateChanged`. + +### `BeforeValidationA11yApply` + +Fired **before** the plugin applies (or removes) an `aria-invalid` attribute +on a field. Call `ev.preventDefault()` to suppress the default ARIA change. + +```javascript +myForm.on("BeforeValidationA11yApply", (ev) => { + // ev.path – the SmarkForm path of the field + // ev.issue – the issue object + // ev.action – 'set' or 'remove' + if (ev.path === "internalField") { + ev.preventDefault(); // never apply aria-invalid on this field + } +}); +``` + + +## ARIA Side Effects + +By default, when an **error-level** issue targets a field path, the plugin +sets `aria-invalid="true"` on the field's underlying `` (or container +element if no dedicated field node exists). When all errors for a path are +solved, `aria-invalid` is removed. + +**Warnings never set `aria-invalid`.** + +To disable ARIA side effects globally, pass `applyA11y: false`: + +```javascript +const validation = SmarkForm.createValidation(myForm, { + providers: [myProvider], + applyA11y: false, +}); +``` + +To suppress ARIA changes for specific fields, use the +`BeforeValidationA11yApply` event (see above). + + +## Blocking Export on Errors + +By default, if any **error-level** issues exist at the time `export` is +triggered, the plugin calls `ev.preventDefault()` on `BeforeAction_export`, +preventing the export from completing. + +```javascript +// The default — export is blocked when errors are present: +const validation = SmarkForm.createValidation(myForm, { + providers: [myProvider], + blockExportOnErrors: true, +}); + +// Allow export even with errors (e.g. "save draft" flows): +const validation = SmarkForm.createValidation(myForm, { + providers: [myProvider], + blockExportOnErrors: false, +}); +``` + +{: .hint } +> When `blockExportOnErrors` is `true`, the plugin runs a fresh, non-debounced +> validation immediately inside `BeforeAction_export` before deciding whether +> to block. This ensures the decision is always based on up-to-date data even +> if the user triggers export before the debounce timer fires. + + +## Cross-Field Validation Example + +The following provider validates that `endDate` is not earlier than +`startDate`, but only when both fields are filled: + +```javascript +function dateRangeProvider({ data }) { + const { startDate, endDate } = data; + + // Gate: only validate when both are filled + if (!startDate || !endDate) return { issues: [] }; + + if (new Date(endDate) < new Date(startDate)) { + return { + issues: [{ + level: "error", + paths: ["startDate", "endDate"], + code: "date_range_invalid", + message: "End date must not be earlier than start date.", + source: "dateRangeProvider", + }], + }; + } + + return { issues: [] }; +} + +const validation = SmarkForm.createValidation(myForm, { + providers: [dateRangeProvider], +}); + +myForm.on("ValidationIssuesChanged", (ev) => { + // Re-render your error messages whenever the issue set changes + displayErrors(ev.issues); +}); +``` + +This pattern makes it easy to wire in schema-based validators (e.g. Zod) +alongside custom cross-field rules: + +```javascript +import { z } from "zod"; + +const schema = z.object({ + name: z.string().min(1, "Name is required"), + email: z.string().email("Invalid email address"), +}); + +function zodProvider({ data }) { + const result = schema.safeParse(data); + if (result.success) return { issues: [] }; + + return { + issues: result.error.issues.map(issue => ({ + level: "error", + paths: [issue.path.join("/") || "/"], + code: issue.code, + message: issue.message, + source: "zod", + })), + }; +} +``` + + +## Custom Issue IDs + +By default the plugin generates a stable `id` from +`${source}:${code}:${paths.join('|')}`. Two issues with the same `id` in +consecutive runs are treated as the **same issue** (persisting), so the diff +correctly reports only genuinely new or solved issues. + +If your provider can produce multiple issues with the same `source`, `code`, +and `paths` (unlikely but possible), supply an explicit `id`: + +```javascript +return { + issues: [ + { id: "my-unique-id-1", level: "error", ... }, + { id: "my-unique-id-2", level: "error", ... }, + ], +}; +``` diff --git a/src/lib/validation/index.js b/src/lib/validation/index.js new file mode 100644 index 00000000..c7b8669c --- /dev/null +++ b/src/lib/validation/index.js @@ -0,0 +1,280 @@ +// lib/validation/index.js +// ======================= +// Minimal validation plugin for SmarkForm. +// +// Usage: +// import SmarkForm from 'smarkform'; +// const validation = SmarkForm.createValidation(myForm, { +// providers: [myProvider], +// debounce: 300, +// blockExportOnErrors: true, +// }); +// +// Provider signature: +// async (ctx) => ({ issues: [...] }) +// ctx = { root, data, reason, changedPaths?, signal? } +// +// Issue shape: +// { id?, level, paths, code, message, source, details? } +// Default id: `${source}:${code}:${paths.join('|')}` + +/** + * Normalize a raw issue object, filling in defaults and generating a stable id. + * @param {object} raw + * @returns {object} normalized issue + */ +function normalizeIssue(raw) { + const source = raw.source || 'unknown'; + const code = raw.code || 'unknown'; + const paths = ( + Array.isArray(raw.paths) ? raw.paths + : (raw.path ? [raw.path] : ['/']) + ); + const id = raw.id || `${source}:${code}:${paths.join('|')}`; + return { + id, + level: raw.level || 'error', + paths, + code, + message: raw.message || '', + source, + details: raw.details, + }; +}; + +/** + * Create a validation controller for a SmarkForm root instance. + * + * @param {object} root - SmarkForm root instance. + * @param {object} [options] + * @param {Array} [options.providers=[]] - Validation provider functions. + * @param {number} [options.debounce=300] - Debounce delay (ms) for change-triggered validation. + * @param {boolean}[options.blockExportOnErrors=true] - Prevent export when errors exist. + * @param {boolean}[options.applyA11y=true] - Apply aria-invalid side effects. + * @returns {{ validate, getState, destroy }} + */ +export function createValidation(root, options = {}) { + const { + providers = [], + debounce: debounceDelay = 300, + blockExportOnErrors = true, + applyA11y = true, + } = options; + + // --- internal state --- + let previousIssues = new Map(); // id -> normalized issue (last run) + let currentIssues = new Map(); // id -> normalized issue (latest) + let pendingDebounce = null; + let abortController = null; + let destroyed = false; + + // --- core validate --- + async function validate(reason) { + // Cancel any previous in-flight validation. + if (abortController) abortController.abort(); + abortController = new AbortController(); + const signal = abortController.signal; + + // Export current form data (silent so no extra events fire). + let data = null; + try { + data = await root.actions.export(null, { silent: true }); + } catch (_e) { + // Export may fail during rendering; continue with null. + } + if (signal.aborted) return null; + + // Run all providers and collect raw issues. + const rawIssues = []; + for (const provider of providers) { + try { + const result = await provider({ root, data, reason, signal }); + if (signal.aborted) return null; + if (result && Array.isArray(result.issues)) { + rawIssues.push(...result.issues); + } + } catch (e) { + if (e && e.name === 'AbortError') return null; + console.warn('[SmarkForm validation] Provider error:', e); + } + } + if (signal.aborted) return null; + + // Build new issues map. + const newIssuesMap = new Map(); + for (const raw of rawIssues) { + const issue = normalizeIssue(raw); + newIssuesMap.set(issue.id, issue); + } + + // Diff the new run against the current state (before this run). + // `currentIssues` holds what the last completed run produced. + const newIssues = []; + const solvedIssues = []; + const persistingIssues = []; + for (const [id, issue] of newIssuesMap) { + if (currentIssues.has(id)) { + persistingIssues.push(issue); + } else { + newIssues.push(issue); + } + } + for (const [id, issue] of currentIssues) { + if (!newIssuesMap.has(id)) solvedIssues.push(issue); + } + + // Advance state. + previousIssues = currentIssues; + currentIssues = newIssuesMap; + + const allIssues = [...currentIssues.values()]; + const hasErrors = allIssues.some(i => i.level === 'error'); + const hasWarnings = allIssues.some(i => i.level === 'warning'); + const hasChanges = newIssues.length > 0 || solvedIssues.length > 0; + + const state = { + issues: allIssues, + hasErrors, + hasWarnings, + newIssues, + solvedIssues, + persistingIssues, + }; + + // Emit ValidationStateChanged (always). + await root.emit('ValidationStateChanged', { ...state, reason }); + + // Emit ValidationIssuesChanged (only when issues changed). + if (hasChanges) { + await root.emit('ValidationIssuesChanged', { ...state, reason }); + } + + // Apply ARIA side effects (preventable). + if (applyA11y) { + await applyA11yEffects(state); + } + + return state; + }; + + // --- ARIA side effects --- + async function applyA11yEffects({ newIssues, solvedIssues }) { + // Set aria-invalid for new error paths. + for (const issue of newIssues) { + if (issue.level !== 'error') continue; + for (const path of issue.paths) { + const allowed = await root.emit('BeforeValidationA11yApply', { + issue, + path, + action: 'set', + }); + if (!allowed) continue; + const comp = root.find(path); + if (!comp) continue; + const node = comp.targetFieldNode || comp.targetNode; + if (node) node.setAttribute('aria-invalid', 'true'); + } + } + + // Remove aria-invalid for solved error paths (if no other error remains). + for (const issue of solvedIssues) { + if (issue.level !== 'error') continue; + for (const path of issue.paths) { + // Check whether another active error still targets this path. + const stillHasError = [...currentIssues.values()].some( + i => i.level === 'error' && i.paths.includes(path) + ); + if (stillHasError) continue; + const allowed = await root.emit('BeforeValidationA11yApply', { + issue, + path, + action: 'remove', + }); + if (!allowed) continue; + const comp = root.find(path); + if (!comp) continue; + const node = comp.targetFieldNode || comp.targetNode; + if (node) node.removeAttribute('aria-invalid'); + } + } + }; + + // --- debounced scheduling --- + function scheduleValidation(reason) { + if (pendingDebounce !== null) clearTimeout(pendingDebounce); + pendingDebounce = setTimeout(() => { + pendingDebounce = null; + if (!destroyed) validate(reason); + }, debounceDelay); + }; + + // --- event handlers --- + function onChangeHandler(/* ev */) { + if (destroyed) return; + scheduleValidation('change'); + }; + + async function onBeforeExportHandler(ev) { + if (destroyed || !blockExportOnErrors) return; + // Run validation immediately (not debounced) before deciding. + // Cancel any pending debounced run first. + if (pendingDebounce !== null) { + clearTimeout(pendingDebounce); + pendingDebounce = null; + } + const state = await validate('export'); + if (state && state.hasErrors) { + ev.preventDefault(); + } + }; + + // Attach listeners. + root.on('change', onChangeHandler); + root.on('BeforeAction_export', onBeforeExportHandler); + + // --- public API --- + return { + /** + * Force an immediate validation run (bypasses debounce). + * @param {string} [reason='manual'] + * @returns {Promise} validation state + */ + validate(reason) { + if (destroyed) return Promise.resolve(null); + if (pendingDebounce !== null) { + clearTimeout(pendingDebounce); + pendingDebounce = null; + } + return validate(reason || 'manual'); + }, + + /** + * Return the current validation state (synchronous snapshot). + * @returns {{ issues, hasErrors, hasWarnings }} + */ + getState() { + const issues = [...currentIssues.values()]; + return { + issues, + hasErrors: issues.some(i => i.level === 'error'), + hasWarnings: issues.some(i => i.level === 'warning'), + }; + }, + + /** + * Remove listeners and cancel pending work. + * The object is unusable after destroy(). + */ + destroy() { + destroyed = true; + if (pendingDebounce !== null) { + clearTimeout(pendingDebounce); + pendingDebounce = null; + } + if (abortController) { + abortController.abort(); + abortController = null; + } + }, + }; +}; diff --git a/src/main.js b/src/main.js index 7297bd50..124d5210 100644 --- a/src/main.js +++ b/src/main.js @@ -3,6 +3,7 @@ import {createType} from "./lib/component.js"; import {hotKeys_handler} from "./lib/hotkeys.js"; +import {createValidation} from "./lib/validation/index.js"; // Import core component types and event handlers: import {trigger, onTriggerClick} from "./types/trigger.type.js"; @@ -77,6 +78,7 @@ class SmarkForm extends form { }; SmarkForm.createType = createType; +SmarkForm.createValidation = createValidation; export default SmarkForm; diff --git a/test/validation.tests.js b/test/validation.tests.js new file mode 100644 index 00000000..4dfea0e8 --- /dev/null +++ b/test/validation.tests.js @@ -0,0 +1,536 @@ +// test/validation.tests.js +// ======================== +// Tests for the SmarkForm validation plugin (SmarkForm.createValidation). +// +// Covers: +// A) ValidationStateChanged fires on form change +// B) ValidationIssuesChanged fires only when issues change +// C) aria-invalid toggled for error paths +// D) Export is blocked when errors exist (blockExportOnErrors) +// E) Warnings do NOT set aria-invalid +// F) destroy() stops further validation activity + +import { test, expect } from '@playwright/test'; +import { renderPug } from '../src/lib/test/helpers.js'; + +// --------------------------------------------------------------------------- +// Shared minimal Pug template +// --------------------------------------------------------------------------- +const basicPug = (extraScript = '') => ` +doctype html +html + head + title= self.title + body + div#sfroot + input(data-smark name="startDate" type="text") + input(data-smark name="endDate" type="text") + script(src="../../dist/SmarkForm.umd.js") + script. + window.eventLog = []; + window.form = new SmarkForm(document.querySelector('#sfroot')); + form.rendered.then(() => { + ${extraScript} + }); +`; + +// Helper: render page and wait for SmarkForm +async function setupPage(page, title, src) { + const rendered = await renderPug({ title, src }); + await page.goto(rendered.url); + await page.waitForFunction(() => window.form !== undefined); + await page.evaluate(() => form.rendered); + return rendered.onClosed; +} + +// =========================================================================== +// A) ValidationStateChanged fires on form change +// =========================================================================== + +test.describe('ValidationStateChanged event', () => { + + test('fires when form value changes', async ({ page }) => { + const src = basicPug(` + window.stateChangedCount = 0; + window.validation = SmarkForm.createValidation(form, { + providers: [], + debounce: 0, + }); + form.on('ValidationStateChanged', () => { window.stateChangedCount++; }); + `); + let onClosed; + try { + onClosed = await setupPage(page, 'ValidationStateChanged fires', src); + await page.waitForFunction(() => window.validation !== undefined); + + // Trigger a change on the startDate field + await page.fill('input[name="startDate"]', '2024-01-01'); + await page.evaluate(() => document.querySelector('input[name="startDate"]').dispatchEvent(new Event('change', { bubbles: true }))); + + // Wait for the debounced validation to fire + await page.waitForFunction(() => window.stateChangedCount > 0, { timeout: 2000 }); + const count = await page.evaluate(() => window.stateChangedCount); + expect(count).toBeGreaterThan(0); + } finally { + if (onClosed) await onClosed(); + } + }); + + test('event carries issues, hasErrors, hasWarnings', async ({ page }) => { + const src = basicPug(` + window.lastState = null; + window.validation = SmarkForm.createValidation(form, { + debounce: 0, + providers: [async ({ data }) => ({ + issues: [{ + level: 'error', + paths: ['startDate'], + code: 'required', + message: 'Start date is required', + source: 'test', + }], + })], + }); + form.on('ValidationStateChanged', ev => { window.lastState = ev; }); + `); + let onClosed; + try { + onClosed = await setupPage(page, 'ValidationStateChanged payload', src); + await page.waitForFunction(() => window.validation !== undefined); + + // Trigger a manual validation + await page.evaluate(() => validation.validate('test')); + await page.waitForFunction(() => window.lastState !== null, { timeout: 2000 }); + + const state = await page.evaluate(() => ({ + hasErrors: window.lastState.hasErrors, + hasWarnings: window.lastState.hasWarnings, + issueCount: window.lastState.issues.length, + firstIssue: window.lastState.issues[0], + })); + expect(state.hasErrors).toBe(true); + expect(state.hasWarnings).toBe(false); + expect(state.issueCount).toBe(1); + expect(state.firstIssue.code).toBe('required'); + expect(state.firstIssue.source).toBe('test'); + } finally { + if (onClosed) await onClosed(); + } + }); +}); + +// =========================================================================== +// B) ValidationIssuesChanged fires only when issues change +// =========================================================================== + +test.describe('ValidationIssuesChanged event', () => { + + test('does not fire when issues remain unchanged', async ({ page }) => { + const src = basicPug(` + window.issuesChangedCount = 0; + window.stateChangedCount = 0; + let callCount = 0; + window.validation = SmarkForm.createValidation(form, { + debounce: 0, + providers: [async () => { + // Same issue every time + return { issues: [{ + level: 'error', paths: ['startDate'], + code: 'err', message: 'always', source: 'test', + }]}; + }], + }); + form.on('ValidationStateChanged', () => { window.stateChangedCount++; }); + form.on('ValidationIssuesChanged', () => { window.issuesChangedCount++; }); + `); + let onClosed; + try { + onClosed = await setupPage(page, 'ValidationIssuesChanged unchanged', src); + await page.waitForFunction(() => window.validation !== undefined); + + // First run - should emit both events + await page.evaluate(() => validation.validate('first')); + await page.waitForFunction(() => window.stateChangedCount >= 1, { timeout: 2000 }); + expect(await page.evaluate(() => window.issuesChangedCount)).toBe(1); + + // Second run with same issues - ValidationIssuesChanged should NOT fire again + await page.evaluate(() => validation.validate('second')); + await page.waitForFunction(() => window.stateChangedCount >= 2, { timeout: 2000 }); + expect(await page.evaluate(() => window.issuesChangedCount)).toBe(1); // unchanged + } finally { + if (onClosed) await onClosed(); + } + }); + + test('fires when issues appear and when they disappear', async ({ page }) => { + const src = basicPug(` + window.issuesChangedLog = []; + window.shouldFail = true; + window.validation = SmarkForm.createValidation(form, { + debounce: 0, + providers: [async () => { + if (window.shouldFail) { + return { issues: [{ + level: 'error', paths: ['startDate'], + code: 'err', message: 'error', source: 'test', + }]}; + } + return { issues: [] }; + }], + }); + form.on('ValidationIssuesChanged', ev => { + window.issuesChangedLog.push({ + newCount: ev.newIssues.length, + solvedCount: ev.solvedIssues.length, + }); + }); + `); + let onClosed; + try { + onClosed = await setupPage(page, 'ValidationIssuesChanged lifecycle', src); + await page.waitForFunction(() => window.validation !== undefined); + + // Run with error → IssuesChanged fires with newIssues + await page.evaluate(() => validation.validate('with-error')); + await page.waitForFunction(() => window.issuesChangedLog.length >= 1, { timeout: 2000 }); + + // Run without error → IssuesChanged fires with solvedIssues + await page.evaluate(() => { window.shouldFail = false; }); + await page.evaluate(() => validation.validate('without-error')); + await page.waitForFunction(() => window.issuesChangedLog.length >= 2, { timeout: 2000 }); + + const log = await page.evaluate(() => window.issuesChangedLog); + expect(log[0].newCount).toBe(1); + expect(log[0].solvedCount).toBe(0); + expect(log[1].newCount).toBe(0); + expect(log[1].solvedCount).toBe(1); + } finally { + if (onClosed) await onClosed(); + } + }); +}); + +// =========================================================================== +// C) aria-invalid toggled for error paths +// =========================================================================== + +test.describe('ARIA side effects', () => { + + test('sets aria-invalid on field with error', async ({ page }) => { + const src = basicPug(` + window.validation = SmarkForm.createValidation(form, { + debounce: 0, + providers: [async () => ({ + issues: [{ + level: 'error', paths: ['startDate'], + code: 'required', message: 'Required', source: 'test', + }], + })], + }); + `); + let onClosed; + try { + onClosed = await setupPage(page, 'aria-invalid set', src); + await page.waitForFunction(() => window.validation !== undefined); + + await page.evaluate(() => validation.validate('test')); + await page.waitForFunction( + () => document.querySelector('input[name="startDate"]').getAttribute('aria-invalid') === 'true', + { timeout: 2000 } + ); + const ariaInvalid = await page.locator('input[name="startDate"]').getAttribute('aria-invalid'); + expect(ariaInvalid).toBe('true'); + } finally { + if (onClosed) await onClosed(); + } + }); + + test('removes aria-invalid when error is solved', async ({ page }) => { + const src = basicPug(` + window.shouldFail = true; + window.validation = SmarkForm.createValidation(form, { + debounce: 0, + providers: [async () => { + if (window.shouldFail) { + return { issues: [{ + level: 'error', paths: ['startDate'], + code: 'required', message: 'Required', source: 'test', + }]}; + } + return { issues: [] }; + }], + }); + `); + let onClosed; + try { + onClosed = await setupPage(page, 'aria-invalid removed', src); + await page.waitForFunction(() => window.validation !== undefined); + + // First: introduce error + await page.evaluate(() => validation.validate('with-error')); + await page.waitForFunction( + () => document.querySelector('input[name="startDate"]').getAttribute('aria-invalid') === 'true', + { timeout: 2000 } + ); + + // Second: solve error + await page.evaluate(() => { window.shouldFail = false; }); + await page.evaluate(() => validation.validate('without-error')); + await page.waitForFunction( + () => !document.querySelector('input[name="startDate"]').hasAttribute('aria-invalid'), + { timeout: 2000 } + ); + const ariaInvalid = await page.locator('input[name="startDate"]').getAttribute('aria-invalid'); + expect(ariaInvalid).toBeNull(); + } finally { + if (onClosed) await onClosed(); + } + }); + + test('does NOT set aria-invalid for warnings', async ({ page }) => { + const src = basicPug(` + window.validation = SmarkForm.createValidation(form, { + debounce: 0, + providers: [async () => ({ + issues: [{ + level: 'warning', paths: ['startDate'], + code: 'soft-warning', message: 'Warning only', source: 'test', + }], + })], + }); + `); + let onClosed; + try { + onClosed = await setupPage(page, 'aria-invalid not for warnings', src); + await page.waitForFunction(() => window.validation !== undefined); + + // Wait for ValidationStateChanged to confirm validation ran + await page.evaluate(async () => { + await new Promise(resolve => { + form.on('ValidationStateChanged', resolve); + validation.validate('test'); + }); + }); + + const ariaInvalid = await page.locator('input[name="startDate"]').getAttribute('aria-invalid'); + expect(ariaInvalid).toBeNull(); + } finally { + if (onClosed) await onClosed(); + } + }); + + test('BeforeValidationA11yApply can prevent aria-invalid', async ({ page }) => { + const src = basicPug(` + window.validation = SmarkForm.createValidation(form, { + debounce: 0, + providers: [async () => ({ + issues: [{ + level: 'error', paths: ['startDate'], + code: 'err', message: 'Error', source: 'test', + }], + })], + }); + // Prevent all aria side effects + form.on('BeforeValidationA11yApply', ev => { ev.preventDefault(); }); + `); + let onClosed; + try { + onClosed = await setupPage(page, 'BeforeValidationA11yApply prevents', src); + await page.waitForFunction(() => window.validation !== undefined); + + // Wait for ValidationStateChanged to confirm validation ran + await page.evaluate(async () => { + await new Promise(resolve => { + form.on('ValidationStateChanged', resolve); + validation.validate('test'); + }); + }); + + const ariaInvalid = await page.locator('input[name="startDate"]').getAttribute('aria-invalid'); + expect(ariaInvalid).toBeNull(); + } finally { + if (onClosed) await onClosed(); + } + }); +}); + +// =========================================================================== +// D) Export is blocked when errors exist (blockExportOnErrors) +// =========================================================================== + +test.describe('Export blocking', () => { + + test('export is blocked when errors exist', async ({ page }) => { + const src = basicPug(` + window.exportResult = 'not-yet'; + window.validation = SmarkForm.createValidation(form, { + debounce: 0, + blockExportOnErrors: true, + providers: [async () => ({ + issues: [{ + level: 'error', paths: ['startDate'], + code: 'required', message: 'Required', source: 'test', + }], + })], + }); + `); + let onClosed; + try { + onClosed = await setupPage(page, 'export blocked on error', src); + await page.waitForFunction(() => window.validation !== undefined); + + // Try to export — should be blocked + const result = await page.evaluate(async () => { + const data = await form.actions.export(); + return data; // undefined if blocked + }); + expect(result).toBeUndefined(); + } finally { + if (onClosed) await onClosed(); + } + }); + + test('export succeeds when no errors', async ({ page }) => { + const src = basicPug(` + window.validation = SmarkForm.createValidation(form, { + debounce: 0, + blockExportOnErrors: true, + providers: [async () => ({ issues: [] })], + }); + `); + let onClosed; + try { + onClosed = await setupPage(page, 'export allowed no errors', src); + await page.waitForFunction(() => window.validation !== undefined); + + const result = await page.evaluate(async () => form.actions.export()); + expect(result).toBeTruthy(); + expect(typeof result).toBe('object'); + } finally { + if (onClosed) await onClosed(); + } + }); + + test('export is NOT blocked when blockExportOnErrors is false', async ({ page }) => { + const src = basicPug(` + window.validation = SmarkForm.createValidation(form, { + debounce: 0, + blockExportOnErrors: false, + providers: [async () => ({ + issues: [{ + level: 'error', paths: ['startDate'], + code: 'err', message: 'Error', source: 'test', + }], + })], + }); + `); + let onClosed; + try { + onClosed = await setupPage(page, 'export not blocked when option false', src); + await page.waitForFunction(() => window.validation !== undefined); + + const result = await page.evaluate(async () => form.actions.export()); + expect(result).toBeTruthy(); + } finally { + if (onClosed) await onClosed(); + } + }); +}); + +// =========================================================================== +// E) getState() returns current validation state synchronously +// =========================================================================== + +test.describe('getState()', () => { + + test('returns empty state before first validation', async ({ page }) => { + const src = basicPug(` + window.validation = SmarkForm.createValidation(form, { + debounce: 0, + providers: [], + }); + `); + let onClosed; + try { + onClosed = await setupPage(page, 'getState initial', src); + await page.waitForFunction(() => window.validation !== undefined); + + const state = await page.evaluate(() => validation.getState()); + expect(state.issues).toHaveLength(0); + expect(state.hasErrors).toBe(false); + expect(state.hasWarnings).toBe(false); + } finally { + if (onClosed) await onClosed(); + } + }); + + test('returns current issues after validation', async ({ page }) => { + const src = basicPug(` + window.validation = SmarkForm.createValidation(form, { + debounce: 0, + providers: [async () => ({ + issues: [{ + level: 'warning', paths: ['endDate'], + code: 'soft', message: 'Soft issue', source: 'test', + }], + })], + }); + `); + let onClosed; + try { + onClosed = await setupPage(page, 'getState after run', src); + await page.waitForFunction(() => window.validation !== undefined); + + await page.evaluate(async () => validation.validate('test')); + // getState is synchronous + const state = await page.evaluate(() => validation.getState()); + expect(state.issues).toHaveLength(1); + expect(state.hasWarnings).toBe(true); + expect(state.hasErrors).toBe(false); + } finally { + if (onClosed) await onClosed(); + } + }); +}); + +// =========================================================================== +// F) destroy() stops further validation activity +// =========================================================================== + +test.describe('destroy()', () => { + + test('stops validation after destroy', async ({ page }) => { + const src = basicPug(` + window.stateChangedCount = 0; + window.validation = SmarkForm.createValidation(form, { + debounce: 0, + providers: [], + }); + form.on('ValidationStateChanged', () => { window.stateChangedCount++; }); + `); + let onClosed; + try { + onClosed = await setupPage(page, 'destroy stops validation', src); + await page.waitForFunction(() => window.validation !== undefined); + + // Run once to confirm it works + await page.evaluate(async () => validation.validate('before-destroy')); + await page.waitForFunction(() => window.stateChangedCount >= 1, { timeout: 2000 }); + + const countBefore = await page.evaluate(() => window.stateChangedCount); + + // Destroy, then validate again — should be a no-op + await page.evaluate(() => { + validation.destroy(); + validation.validate('after-destroy'); + }); + + // Wait a bit to ensure no spurious validation fires + await page.waitForTimeout(200); + const countAfter = await page.evaluate(() => window.stateChangedCount); + expect(countAfter).toBe(countBefore); + } finally { + if (onClosed) await onClosed(); + } + }); +}); From 3687b116f14fb5dfe03e1abeb361bbc166857709 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 31 Mar 2026 23:22:27 +0000 Subject: [PATCH 4/4] docs: add interactive validation example with sampletabs to validation.md Agent-Logs-Url: https://github.com/bitifet/SmarkForm/sessions/26379dfe-783e-4717-b517-72ccb0056c93 Co-authored-by: bitifet <1643647+bitifet@users.noreply.github.com> --- docs/_advanced_concepts/validation.md | 136 ++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/docs/_advanced_concepts/validation.md b/docs/_advanced_concepts/validation.md index 28d0024b..8ad14c9d 100644 --- a/docs/_advanced_concepts/validation.md +++ b/docs/_advanced_concepts/validation.md @@ -39,6 +39,7 @@ nav_order: 7 * [Blocking Export on Errors](#blocking-export-on-errors) * [Cross-Field Validation Example](#cross-field-validation-example) * [Custom Issue IDs](#custom-issue-ids) +* [Complete Interactive Example](#complete-interactive-example) " | markdownify }} @@ -393,3 +394,138 @@ return { ], }; ``` + + +## Complete Interactive Example + +The playground below manages a list of **date periods** — the kind of entry you +might use to model opening seasons, billing cycles, or availability windows. +Two validation rules are enforced on every change: + +1. **Date order** — end date must not precede start date. +2. **No overlaps** — after sorting by start date, consecutive periods must not overlap. + +**Things to try:** + +- Set an **end date earlier than its start date** — the error appears immediately. +- Add a third period with dates that **overlap** an existing period. +- Fix all errors, then click **▶ Export** — the export is blocked while any error is active. +- Open the **JS tab** to see how the two providers and event handlers are wired together. + +{% raw %} {% endraw %} +{% capture validation_periods_html -%} +
+
    +
  • + From + — + +
  • +
+
+ + +
+
    +
    
    +
    {%- endcapture %} + +{% capture validation_periods_css -%} +.periods-list { list-style: none; padding: 0; margin: 0; } +.period-row { display: flex; align-items: center; gap: .5em; padding: .35em 0; flex-wrap: wrap; } +{{""}}#myForm$$ input[aria-invalid="true"] { border: 2px solid #c33; background: #fff0f0; border-radius: 3px; outline: none; } +.sf-controls { display: flex; gap: .5em; margin-top: .5em; } +.sf-msgs { list-style: none; padding: 0; margin: .8em 0 0; } +.sf-msgs:empty { display: none; } +.sf-issue { padding: .3em .6em; margin-bottom: .25em; border-radius: 4px; font-size: .9em; } +.sf-issue.error { background: #fde8e8; border-left: 3px solid #c33; color: #700; } +.sf-issue.warning { background: #fef3e2; border-left: 3px solid #c80; color: #640; } +.sf-out { background: #f4f4f4; padding: .5em; margin-top: .6em; font-size: .8em; border-radius: 4px; white-space: pre-wrap; } +.sf-out:empty { display: none; }{%- endcapture %} + +{% capture validation_periods_js -%} +// Provider 1: end_date must not precede start_date +function checkDateOrder({ data }) { + const issues = []; + (data.periods || []).forEach((p, i) => { + if (p.start_date && p.end_date && p.end_date < p.start_date) { + issues.push({ + level: 'error', + paths: ['periods/' + i + '/start_date', 'periods/' + i + '/end_date'], + code: 'end_before_start', + message: 'Period ' + (i + 1) + ': end date is before start date.', + source: 'periods', + }); + } + }); + return { issues }; +} + +// Provider 2: consecutive periods (sorted by start date) must not overlap +function checkNoOverlap({ data }) { + const issues = []; + const complete = (data.periods || []) + .map((p, i) => ({ i, s: p.start_date, e: p.end_date })) + .filter(p => p.s && p.e && p.s <= p.e) + .sort((a, b) => (a.s > b.s ? 1 : -1)); + for (let j = 1; j < complete.length; j++) { + const prev = complete[j - 1], curr = complete[j]; + if (curr.s <= prev.e) { + issues.push({ + level: 'error', + paths: ['periods/' + prev.i + '/end_date', 'periods/' + curr.i + '/start_date'], + code: 'overlap', + message: 'Periods ' + (prev.i + 1) + ' and ' + (curr.i + 1) + ' overlap.', + source: 'periods', + }); + } + } + return { issues }; +} + +const validation = SmarkForm.createValidation(myForm, { + providers: [checkDateOrder, checkNoOverlap], + debounce: 300, +}); + +// Keep the message list in sync with the current validation state +const msgsEl = document.getElementById('msgs'); +const outEl = document.getElementById('out'); +myForm.on('ValidationStateChanged', (ev) => { + msgsEl.innerHTML = ev.issues + .map(iss => '
  • ' + iss.message + '
  • ') + .join(''); +}); + +// Show exported JSON only when the export succeeds (i.e. no errors) +myForm.on('AfterAction_export', (ev) => { + if (ev.data) outEl.textContent = JSON.stringify(ev.data, null, 2); +}); +{%- endcapture %} + +{% capture validation_periods_demoValue -%} +{ + "periods": [ + { "start_date": "2025-01-01", "end_date": "2025-06-30" }, + { "start_date": "2025-07-01", "end_date": "2025-12-31" } + ] +}{%- endcapture %} +{% raw %} {% endraw %} + +{% include components/sampletabs_tpl.md + formId="validation_periods" + htmlSource=validation_periods_html + cssSource=validation_periods_css + jsSource=validation_periods_js + demoValue=validation_periods_demoValue + selected="preview" + showEditor=true + height=55 + tests=false +%} + +{: .hint } +> The JS tab uses `ValidationStateChanged` to re-render the full message list on +> every validation run. In production you may prefer `ValidationIssuesChanged`, +> which fires only when the set of issues actually changes, so DOM updates happen +> only when necessary.