diff --git a/AGENTS.md b/AGENTS.md index 99aab8ac..f0e750ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -49,6 +49,26 @@ For convenience, include this exact sentence in the PR description when you want ## Detailed Component Documentation +### Field Masking + +SmarkForm's declarative masking API allows integrating external masking libraries via `SmarkForm.registerMask(name, factory)` and ` +``` + +The `script[type="smark-mask"]` tag is inert — browsers don't execute it. +SmarkForm scans for these during initialization, evaluates the content, and +registers each named mask function internally. + +This works naturally inside **mixin templates** — a mixin that uses a masked +field can carry its own `\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 , node\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 , node\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 , node\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 , node\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 , node\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 , node\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 , node\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 , node\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 %} + +{% raw %} {% endraw %} +{% capture showcase_mask_js -%} +SmarkForm.registerMask("card", (node) => { + node.placeholder = "0000 0000 0000 0000"; + node.inputMode = "numeric"; + + let showLazy = true; + const imask = new IMask(node, { + mask: "0000 0000 0000 0000", + lazy: true, + }); + + let prevValue = node.value; + let lastKey = ""; + node.addEventListener("keydown", (e) => { lastKey = e.key; }); + node.addEventListener("input", () => { + const hasContent = imask.masked.unmaskedValue.length > 0; + if (hasContent && showLazy) { + showLazy = false; + imask.updateOptions({ + mask: "0000 0000 0000 0000", + lazy: false, + placeholderChar: "_", + }); + } else if (!hasContent && !showLazy) { + showLazy = true; + imask.updateOptions({ + mask: "0000 0000 0000 0000", + lazy: true, + }); + } + if (node.value === prevValue && node === document.activeElement) { + if (lastKey !== "Backspace" && lastKey !== "Delete") { + node.style.boxShadow = "0 0 0 2px #f80"; + setTimeout(() => node.style.boxShadow = "", 250); + } + } + prevValue = node.value; + const raw = imask.masked.unmaskedValue; + const showError = raw.length > 0 && !imask.masked.isComplete; + node.setCustomValidity(showError ? "Please enter the full 16-digit card number" : ""); + }); + + return { + get unmaskedValue() { + return imask.masked.isComplete ? imask.masked.unmaskedValue : null; + }, + set unmaskedValue(v) { imask.masked.unmaskedValue = v; }, + }; +}); -{% capture demoValue -%} -{ - "email": "alice@example.com" // Old data saved before upgrading to an array -} +const myForm = new SmarkForm(document.getElementById("myForm$$")); {%- endcapture %} +{% raw %} {% endraw %} -{% include components/sampletabs_tpl.md - formId="smart_value_coercion" - htmlSource=smart_value_coercion - cssSource=smart_value_coercion_css - notes=notes - selected="preview" - showEditor=true - demoValue=demoValue - tests=smart_value_coercion_tests -%} - - -#### Type coercion for scalar fields - -Fields with a specific HTML type automatically coerce values on both import and -export: - - * `` exports a JavaScript **number** (not a string), - and accepts string representations on import (e.g. `"28"` → `28`). - * `` exports an ISO 8601 string (`YYYY-MM-DD`), and - accepts compact strings (`YYYYMMDD`) and `Date` objects on import. - * `` exports `HH:MM:SS` and accepts `HH:MM` on import. - * Any field exports **`null`** when empty, to explicitly signal "unknown or - indifferent" rather than an empty string. - -Adding `{"encoding":"json"}` to any `` or ` -█

-{%- endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture type_coercion_tests -%} -export default async ({ page, expect, id, root, readField, writeField }) => { - await expect(root).toBeVisible(); - - // Number coercion: string input → number output - await writeField('age', '35'); - expect( - await readField('age'), - 'Number field coerces string to number on export' - ).toStrictEqual(35); - - // Empty number field exports null - await writeField('age', null); - expect( - await readField('age'), - 'Number field exports null when empty' - ).toStrictEqual(null); - - // Date normalization: compact string → ISO format - await writeField('dob', '20000101'); - expect( - await readField('dob'), - 'Date field normalizes compact strings to ISO format' - ).toStrictEqual('2000-01-01'); - - // Empty date field exports null - await writeField('dob', null); - expect( - await readField('dob'), - 'Date field exports null when empty' - ).toStrictEqual(null); - - // JSON encoding: object round-trips through textarea - const metaObj = { subscribed: true, tier: 'premium' }; - await writeField('metadata', metaObj); - expect( - await readField('metadata'), - 'JSON-encoded textarea round-trips objects correctly' - ).toEqual(metaObj); - - // JSON encoding: null clears the textarea - await writeField('metadata', null); - expect( - await readField('metadata'), - 'JSON-encoded textarea exports null when cleared' - ).toStrictEqual(null); -}; +{% raw %} {% endraw %} +{% capture showcase_mask_notes -%} +A fully-worked credit card field: IMask with lazy switching (placeholder → underscore fill), `inputMode: "numeric"` for mobile keyboards, orange blink on rejected keystrokes, and a wrapper that returns `null` for incomplete numbers so they are never exported. +> **Want to learn more?** See +> [Field Masking]({{ "/working_with_forms/field_masking" | relative_url }}). {%- endcapture %} -{% raw %} {% endraw %} +{% raw %} {% endraw %} {% capture demoValue -%} -{ - "name": "Alice", - "age": "28", // String instead of number, will be coerced to a number - "dob": "19960315", // Correctly parsed as date. - "metadata": { // Will be exported/imported as JSON - // If invalid exports null (catch that from validation) - "subscribed": true, - "tier": "premium" - } +{"payment": {"cardNumber": 4111111111111111}} +{%- endcapture %} + +{% raw %} {% endraw %} +{% capture showcase_mask_css -%} +input:invalid { + outline: 1px solid #d4c070; + outline-offset: -1px; } {%- endcapture %} +{% raw %} {% endraw %} {% include components/sampletabs_tpl.md - formId="type_coercion" - htmlSource=type_coercion - notes=notes + formId="field_masking" + htmlSource=showcase_mask_html + jsHead=showcase_mask_js + cssSource=showcase_mask_css + notes=showcase_mask_notes selected="preview" showEditor=true demoValue=demoValue - tests=type_coercion_tests + tests=false %} +> **Want to learn more about masking?** See the full reference in +> [Field Masking]({{ "/working_with_forms/field_masking" | relative_url }}). + ### Dynamic Dropdown Options diff --git a/docs/_advanced_concepts/animations.md b/docs/_advanced_concepts/animations.md new file mode 100644 index 00000000..8a247775 --- /dev/null +++ b/docs/_advanced_concepts/animations.md @@ -0,0 +1,163 @@ +--- +title: "Animations" +layout: chapter +permalink: /advanced_concepts/animations +nav_order: 5 + +--- + +{% include links.md %} +{% include components/sampletabs_ctrl.md %} + +# {{ page.title }} + +SmarkForm is markup-agnostic and deliberately provides no built-in animation +engine — transitions are a design concern that belongs to your CSS. + +The technique is straightforward: use SmarkForm's lifecycle events to add and +remove CSS classes on list items, and let CSS `transition` do the rest. + +{% raw %}{% endraw %} +{% capture anim_example_html -%} +
+ + + +
+{%- endcapture %}{% raw %}{% endraw %} + +{% raw %}{% endraw %} +{% capture anim_example_css -%} +{{""}}#myForm$$ ul { list-style: none; padding: 0; } +{{""}}#myForm$$ li.anim-in { + transform: translateX(-100%); + opacity: 0; + transition: transform 200ms ease, opacity 200ms ease; +} +{{""}}#myForm$$ li.anim-visible { + transform: translateX(0); + opacity: 1; +} +{{""}}#myForm$$ li.anim-out { + transform: translateX(100%); + opacity: 0; + transition: transform 150ms ease, opacity 150ms ease; +} +{%- endcapture %}{% raw %}{% endraw %} + +{% raw %}{% endraw %} +{% capture anim_example_js -%} +const delay = ms => new Promise(r => setTimeout(r, ms)); + +myForm.onAll("afterRender", async (ev) => { + if (ev.context.parent?.options.type !== "list") return; + ev.context.targetNode.classList.add("anim-in"); + await delay(1); + ev.context.targetNode.classList.add("anim-visible"); +}); + +myForm.onAll("beforeUnrender", async (ev) => { + if (ev.context.parent?.options.type !== "list") return; + ev.context.targetNode.classList.add("anim-out"); + await delay(150); +}); +{%- endcapture %}{% raw %}{% endraw %} + +{% raw %}{% endraw %} +{% capture anim_example_notes -%} +Add or remove items to see the slide-in/slide-out animation. The `afterRender` handler adds the CSS class that triggers the entry transition; the `beforeUnrender` handler keeps the element visible while the exit animation plays. +{%- endcapture %}{% raw %}{% endraw %} + +{% include components/sampletabs_tpl.md + formId="anim-example" + htmlSource=anim_example_html + cssSource=anim_example_css + jsSource=anim_example_js + notes=anim_example_notes + selected="preview" + tests=false +%} + +## Lifecycle Events for Entry/Exit Animations + +- **`afterRender`** — fires after a new item's DOM node has been inserted. + Add an initial CSS class that hides or offsets the element, then — after a + minimal delay to let the browser paint the initial state — add a second class + that transitions it to its final visible position. + +- **`beforeUnrender`** — fires before an item is removed from the DOM. + Remove the "visible" class and return a `Promise` that resolves after the + transition duration. SmarkForm awaits that promise, so the element stays in + the document long enough for the exit animation to complete. + +## Global vs. Per-List Application + +`myForm.onAll()` listens on *all* components in the form. By adding a guard +that checks `ev.context.parent?.options.type !== "list"`, you can skip anything +that is not a direct child of a list — subforms, labels, buttons, etc. + +The result is that **any list added anywhere in the form hierarchy** is +automatically animated without further wiring. + +## Why Add the Class via JavaScript? + +If the animation class were baked into the HTML template, every item would +start hidden even when JavaScript is unavailable. Adding it through the +`afterRender` handler ensures the animation only kicks in when JS is active, +so the form **degrades gracefully** without it. + +## The 1ms Delay in `afterRender` + +CSS transitions only fire when a property **changes** after the element is +already in the document. If both the hidden class and the visible class were +added in the same microtask, the browser would never observe the initial +hidden state and the transition would not play. + +`await delay(1)` yields control for one event-loop tick, giving the rendering +engine a chance to paint the initial state before the visible class is applied. + +## The `await` in `beforeUnrender` + +SmarkForm awaits the return value of `beforeUnrender` handlers before detaching +the element from the DOM. By returning a promise that resolves after the CSS +`transition-duration`, you keep the element visible just long enough for the +exit animation to finish. + +```css +.animated_item { + transform: translateX(-100%); + opacity: 0; + transition: transform 200ms ease-out, opacity 200ms ease-out; +} + +.animated_item.ongoing { + transform: translateX(0); + opacity: 1; + transition: transform 200ms ease-in, opacity 200ms ease-in; +} +``` + +```javascript +const delay = ms => new Promise(resolve => setTimeout(resolve, ms)); + +myForm.onAll("afterRender", async function(ev) { + if (ev.context.parent?.options.type !== "list") return; + const item = ev.context.targetNode; + item.classList.add("animated_item"); + await delay(1); + item.classList.add("ongoing"); +}); + +myForm.onAll("beforeUnrender", async function(ev) { + if (ev.context.parent?.options.type !== "list") return; + const item = ev.context.targetNode; + item.classList.remove("ongoing"); + await delay(150); +}); +``` + +> **See also:** [Events](events) for the full lifecycle event reference. diff --git a/docs/_advanced_concepts/error_codes.md b/docs/_advanced_concepts/error_codes.md index 4767e0c7..e729e7e3 100644 --- a/docs/_advanced_concepts/error_codes.md +++ b/docs/_advanced_concepts/error_codes.md @@ -2,7 +2,7 @@ title: Error Codes Reference layout: chapter permalink: /advanced_concepts/error_codes -nav_order: 8 +nav_order: 4 --- @@ -355,10 +355,10 @@ A mixin type reference does not contain a `#` fragment. ### `MIXIN_EXTERNAL_FETCH_BLOCKED` -The mixin type reference includes a URL part but `allowExternalMixins` is +The mixin type reference includes a URL part but `smark_mixin_allowExternal` is `"block"` (the default). No network request was made. -**Fix:** Set `allowExternalMixins: "same-origin"` or `"allow"` on the root +**Fix:** Set `smark_mixin_allowExternal: "same-origin"` or `"allow"` on the root SmarkForm instance to permit external template loading. For fine-grained control, pass a per-origin object such as `{ 'https://trusted.example.com': 'allow', '*': 'block' }`. See @@ -368,11 +368,11 @@ control, pass a per-origin object such as ### `MIXIN_CROSS_ORIGIN_FETCH_BLOCKED` -The mixin type reference points to a cross-origin URL but `allowExternalMixins` +The mixin type reference points to a cross-origin URL but `smark_mixin_allowExternal` is `"same-origin"`. **Fix:** Either move the template to the same origin, set -`allowExternalMixins: "allow"` if you trust all remote origins, or use a +`smark_mixin_allowExternal: "allow"` if you trust all remote origins, or use a per-origin policy object to allow only specific trusted origins: `{ 'https://trusted-cdn.example.com': 'allow', '*': 'block' }`. @@ -432,9 +432,9 @@ name on the placeholder element instead. ### `MIXIN_SCRIPT_LOCAL_BLOCKED` The mixin template (loaded from a local in-page ` +
+
+ +
+ +
+ + + +`; + + // We expect a MASK_NOT_FOUND error for the "global" field because + // "secret" is scoped to the mixin, not registered globally. + const errors = []; + page.on('console', msg => { + if (msg.type() === 'error') errors.push(msg.text()); + }); + + let onClosed; + try { + const rendered = await renderHtml(html, 'script_tag_no_global'); + onClosed = rendered.onClosed; + await page.goto(rendered.url); + await page.waitForFunction(() => typeof window.myForm !== 'undefined'); + await page.waitForTimeout(1500); + + // A MASK_NOT_FOUND error should fire for the "global" field. + const maskErr = errors.find(e => e.includes('not found')); + expect(maskErr).toBeTruthy(); + } finally { + if (onClosed) await onClosed(); + } + }); + + // ────────────────────────────────────────────────────────────────────────── + // Mixin-scoped masks + // ────────────────────────────────────────────────────────────────────────── + test('mixin-scoped mask is available to fields inside the mixin', async ({ page }) => { + // The +
+ +
+ +
+
+
+ + + +`; + + let onClosed; + try { + const rendered = await renderHtml(html, 'mixin_scoped'); + onClosed = rendered.onClosed; + await page.goto(rendered.url); + await page.waitForFunction(() => typeof window.myForm !== 'undefined'); + await page.waitForFunction(() => window.myForm.renderedSync === true); + + const result = await page.evaluate(() => { + const field = myForm.find('used/inner'); + if (!field) return { found: false, hasInstance: false, type: null }; + return { + found: true, + hasInstance: !!field._maskInstance, + type: field.targetFieldNode.getAttribute('type'), + }; + }); + expect(result.found).toBe(true); + expect(result.hasInstance).toBe(true); + expect(result.type).toBe('text'); + } finally { + if (onClosed) await onClosed(); + } + }); + + test('mixin-local mask overrides global mask with same name', async ({ page }) => { + const html = ` + +Mixin Override + + + +
+
+ +
+ + + +`; + + let onClosed; + try { + const rendered = await renderHtml(html, 'mixin_override'); + onClosed = rendered.onClosed; + await page.goto(rendered.url); + await page.waitForFunction(() => typeof window.myForm !== 'undefined'); + await page.waitForFunction(() => window.myForm.renderedSync === true); + + const result = await page.evaluate(() => { + const innerField = myForm.find('used/inner'); + const plainField = myForm.find('plain'); + if (!innerField || !plainField) { + return { innerVal: null, plainVal: null, innerFound: !!innerField, plainFound: !!plainField }; + } + return { + innerVal: innerField._maskInstance.unmaskedValue, + plainVal: plainField._maskInstance.unmaskedValue, + }; + }); + expect(result.innerVal).toBe('LOCAL'); + expect(result.plainVal).toBe('GLOBAL'); + } finally { + if (onClosed) await onClosed(); + } + }); + +}); diff --git a/test/doc/WRITING_TESTS.md b/test/doc/WRITING_TESTS.md index c0937318..c526bd07 100644 --- a/test/doc/WRITING_TESTS.md +++ b/test/doc/WRITING_TESTS.md @@ -139,7 +139,7 @@ these options into the SmarkForm constructor so the example renders correctly: formId="my_mixin_example" htmlSource=my_mixin_example_html demoValue='{"foo":"bar"}' - smarkformOptions='{"allowLocalMixinScripts":"allow"}' + smarkformOptions='{"smark_mixin_allowLocalScripts":"allow"}' tests=false %} ``` @@ -148,10 +148,10 @@ Supported option keys (all optional): | Option | Allowed values | Default | Purpose | |---|---|---|---| -| `allowLocalMixinScripts` | `"block"` / `"noscript"` / `"allow"` / per-origin object | `"block"` | Allow `