diff --git a/AGENTS.md b/AGENTS.md index 99aab8ac..9d8a0a28 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 // Stamp with a unique template ID so clones from the same template\n // share it — used by cross-list drag-and-drop.\n if (! templateRoot.dataset.sfTpl) {\n templateRoot.dataset.sfTpl = nextSourceId();\n }\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 // Stamp with a unique template ID so clones from the same template\n // share it — used by cross-list drag-and-drop.\n if (! templateRoot.dataset.sfTpl) {\n templateRoot.dataset.sfTpl = nextSourceId();\n }\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 %} +
+ +
+ The mixin template includes its own <style> block. Styles are scoped with + a .kanban-mixin class on the template root so they only affect the mixin's + content — demonstrating encapsulated component styling. -{% raw %} {% endraw %} -{% capture nested_schedule_table -%} -
-

🗓️ Periods:

-
-
🔒 Out of Service
-
+ See Mixin Types.
-
-{{ period_item_tpl }} -{{ schedule_row_tpl }} -{%- endcapture %} -{% raw %} {% endraw %} -{% raw %} {% endraw %} -{% capture nested_schedule_notes -%} +
+ +
+ CSS flex layout makes each column's list root fill the available height, + so dropping a task anywhere in the empty area below the last card works + as an "append" — no need to precisely target the last item. -👉 **Two templates, one example:** - * `#scheduleRow` (inner) — the time-interval list template. - * `#periodItem` (outer) — wraps the whole period fieldset and uses - `#scheduleRow` inside it, demonstrating mixin composition. + See List Type. +
+
-👉 **Smart date prefill (the ` -{% raw %} {% endraw %} -{% capture nested_forms_with_load_save -%}
-{{ nested_forms }} -
- - -
-
{% -endcapture %} -{% raw %} {% endraw %} + + + +

💡 Try the Inputmask price field: type digits, see space grouping and 2 decimal places

+
-{% raw %} {% endraw %} -{% capture submit_form_example -%} -
-

- - -

-

- - -

-

- - -

-

- -

-
{% -endcapture %} -{% raw %} {% endraw %} + +{%- endcapture %}{% raw %}{% endraw %} + +{% raw %} {% endraw %} +{% capture configurator_js -%} +SmarkForm.registerMask("price", (node) => { + node.inputMode = "decimal"; + Inputmask({ + alias: "numeric", + groupSeparator: " ", + radixPoint: ".", + digits: 2, + digitsOptional: false, + placeholder: "0", + allowMinus: false, + }).mask(node); + return { + get unmaskedValue() { return node.inputmask?.unmaskedvalue() ?? node.value; }, + set unmaskedValue(v) { node.inputmask?.setValue(v); }, + }; +}); + +const myForm = new SmarkForm(document.getElementById("myForm$$")); +{%- endcapture %}{% raw %}{% endraw %} -{% raw %} {% endraw %} -{% capture submit_form_example_notes -%} -👉 Clicking **📧 Send Email** opens the user's email client with: +{% raw %} {% endraw %} +{% capture configurator_css -%} +{{""}}#myForm$$ { max-width: 500px; font-size: 0.95em; } +{{""}}#myForm$$ ul { list-style: none; padding: 0; display: flex; flex-direction: column; gap: 0.5em; } +{{""}}#myForm$$ .hint { font-size: 0.82em; color: #888; margin-top: 1em; } +{{""}}#myForm$$ button[data-hotkey]::after { content: "Ctrl+" attr(data-hotkey); position: absolute; top: -1.4em; left: 0; font-size: 0.65em; background: #333; color: #fff; padding: 1px 4px; border-radius: 3px; white-space: nowrap; } +{{""}}#myForm$$ button { position: relative; } +{%- endcapture %}{% raw %}{% endraw %} - * **To:** `test@example.com` - * **Subject:** `Contact Form Submission` - * **Body:** the Text-encoded form fields. +{% raw %} {% endraw %} +{% capture configurator_notes -%} +👉 **Mixin templates.** #carModel is defined once via <template> and reused for every car entry. Each instance gets its own identity, data, and options. -✏️ **To use a real address:** - * Head to the `🗒️ HTML` tab and check the 📝 checkbox. - * Edit the email in the `action` attribute of the `
` element. - * Click the `▶️ Run` button to reload the `👁️ Preview` tab with the updated code. - * Fill the form and click the **📧 Send Email** button. +👉 **Field masking.** The price field uses [Inputmask](https://github.com/RobinHerbots/Inputmask) (loaded via CDN) — SmarkForm.registerMask() wraps Inputmask's API into SmarkForm's unmaskedValue contract. Type digits to see automatic space grouping and 2-decimal formatting. -🌐 **To submit to an HTTP endpoint** instead, point `action` at your server URL -and propperly adjust the `method` attribute. +👉 **Sortable list.** Drag car models to reorder them. exportEmpties:false skips empty entries. -📦 **For JSON APIs**, additionally set `enctype="application/json"` — SmarkForm -will send the data as a JSON payload via `fetch()`. +👉 **Hotkeys.** Ctrl++ adds a model, Ctrl+- removes. -{: .warning :} -> `enctype="application/json"` is **not** compatible with `mailto:` actions. -> Use the default (URL-encoded) encoding for `mailto:`. +> See Mixin Types, +> Field Masking. +{%- endcapture %}{% raw %}{% endraw %} -{: .info :} -> You can also intercept or extend the submission via *SmarkForm* events: -> `BeforeAction_submit` (fired before sending — you can `preventDefault()` to -> cancel) and `AfterAction_submit` (fired after the data has been sent). +{% capture demoValue -%} +{"cars":[{"model":"Model S","color":"Midnight Blue","price":"34900.00"},{"model":"Model X","color":"Pearl White","price":"42500.00"}]} {%- endcapture %} -{% raw %} {% endraw %} {% include components/sampletabs_tpl.md - formId="submit_form" - htmlSource=submit_form_example - notes=submit_form_example_notes + formId="configurator" + htmlSource=configurator_html + cssSource=configurator_css + jsHead=configurator_js + notes=configurator_notes + smarkformOptions='{"smark_mixin_allowLocalScripts":"allow"}' selected="preview" + showEditor=true + demoValue=demoValue tests=false %} -### A note on context of the triggers +### Features demonstrated -As we have seen in the previous examples: +
- * We can use the *export* and *import* actions to export/import data from/to - any *context*: The whole form, any of its subforms or even a single field. +
+ +
+ The car model card is defined in a <template id="carModel"> with its own + <style> block. The .car-card class scopes styles to the mixin's content + so they don't leak to other parts of the page. - * That *context* is, by default, determined by the place where the - *trigger* is placed in the DOM tree, but it can be explicitly set by the - *context* property of the *trigger* component. + See Mixin Types. +
+
- * We can use the *target* property to set the destination/source of that - data or intercept the *afterAction_export* and *beforeAction_import* events - to programatically handle the data. +
+ +
+ The price field uses [Inputmask](https://github.com/RobinHerbots/Inputmask) + loaded from a CDN. SmarkForm.registerMask("price", …) wraps Inputmask in + SmarkForm's mask factory API. Typing 34900 produces 3 4900.00 on screen. + See Field Masking. +
+
-{: .info :} -> For the sake of simplicity, from now on, we'll stick to the layout of the -> very first example (`⬇️ Export`, `⬆️ Import` and `❌ Clear` buttons -> targetting the "editor" textarea) that doesn't need any additional JS code. -> -> That part of the layout will also be omitted in the HTML source since we've -> already know how it works. +
+ +
+ The mask factory returns an unmaskedValue getter/setter. When SmarkForm + exports the form, it reads unmaskedValue — the price "3 4900.00" becomes + 34900.00 in JSON, ready for server processing. + See Field Masking — unmaskedValue. +
+
-👌 If you want a clearer example on how the context affect the triggers, take a -look to the following example: +
+ +
+ Car models can be reordered by dragging. sortable:true on the list + enables drag-and-drop within the list; combined with position action + for automatic numbering. -{% raw %} {% endraw %} -{% capture notes -%} -👉 Notice that **all *Import* and *Export* buttons (triggers) are handled -by the same event handlers** (for "BeforeAction_import" and -"AfterAction_export", respectively). + See List Type — sortable. +
+
-👉 **All *Import* and *Export* buttons (triggers) belong to different -*SmarkForm* fields** determined by **(1)** where they are placed in the DOM and -**(2)** the relative path from that place pointed by the *context* property. +
+ +
+ The price field sets inputMode:"decimal" via the mask factory — mobile + devices show a numeric keypad instead of the full keyboard. -ℹ️ Different field types may import/export different data types (*forms* -import/export JSON while regular *inputs* import/export text --or number--). - -{%- endcapture %}{% raw %} {% endraw %} - - -{% raw %} {% endraw %} -{% capture context_comparsion_example -%} -
-
-

- - -

-

- - -

- - - - - - - - - - - - - - - - - - - - - -
Name field:Surname field:Whole Form:
-
-
- -
-
{% -endcapture %} -{% raw %} {% endraw %} - - -{% include components/sampletabs_tpl.md - formId="context_comparsion" - htmlSource=context_comparsion_example - notes=notes - selected="preview" - tests=false -%} - - -🚀 As you can see, the same actions can be applied to different parts of the -form just by placing the triggers in the right place or explicitly setting the -right path to the desired *context*. - -👉 You can *import*, *export* or *clear* either the whole form or any of its -fields. Try exporting / exporting / clearing the whole form or individual -fields with the help of the "JSON data viewer / editor". - - -## Advanced UX Improvements - -Finally, we'll showcase some advanced user experience improvements that SmarkForm offers, such as smart auto-enabling/disabling of controls and non-breaking unobtrusive keyboard navigation among others. - -### Auto enabling or disabling of actions - -As you may have already noticed, SmarkForm automatically enables or disables -actions based on the current state of the form. For example, if a list has -reached its maximum number of items specified by the *max_items* option, the -"Add Item" button will be disabled until an item is removed. - -The same happen with the "Remove Item" button when the list has reached its -minimum number of items specified by *min_items*. - -Let's recall our [Singleton List Example](#singleton_list_example) with just -slight modifications: - - 1. Keep the *min_items* to its default value of 1, so that the list cannot be empty. - 2. Add a little CSS to make the disabled buttons more evident. - -{% raw %} {% endraw %} -{% capture simple_list_autodisable -%} -
- - - - Phones: -
    -
  • - - - - -
  • -
-
{%- endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture simple_list_autodisable_css -%} -/* Hide list bullets */ -{{""}}#myForm$$ ul li { - list-style-type: none !important; -} -/* Make disabled buttons more evident: */ -{{""}}#myForm$$ :disabled { - opacity: 0.4; -}{% -endcapture %} -{% raw %} {% endraw %} - -{% capture demoValue -%} -{ - "phones": [ - "+1 555 867 5309", - "+1 555 234 5678" - ] -}{%- endcapture %} - -{% include components/sampletabs_tpl.md - formId="simple_list_autodisable" - htmlSource=simple_list_autodisable - height=30 - cssSource=simple_list_autodisable_css - selected="preview" - demoValue=demoValue - showEditor=true - tests=false -%} - -👉 Notice that the `🧹` and `➖` buttons get disabled then the list has only -one item (at the beginning or after removing enough items to reach *min_items*' -value) and the same happens with the `➕` button when the list reaches its -*max_items* limit. - - -### Context-Driven Keyboard Shortcuts (Hot Keys) - -All *SmarkForm* triggers can be assigned a *hotkey* property to -make them accessible via keyboard shortcuts. - -To trigger an action using a keyboard shortcut the user only needs to press the -*Ctrl* key and the key defined in the *hotkey* property of the trigger. - -In the following example you can use the `Ctrl`+`+` and `Ctrl`+`-` combinations -to add or remove phone numbers from the list, respectively. - -{% raw %} {% endraw %} -{% capture simple_list_hotkeys -%} -█ -█ -█ -█Phones: -█
    -█
  • -█ -█ -█ -█ -█
  • -█
{% -endcapture %} - -{% capture simple_list_hotkeys_html -%} -
-{{ simple_list_hotkeys }} -
{% -endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture simple_list_hotkeys_css -%} -{{ hotkeys_reveal_css }} -{{ simple_list_autodisable_css }} -{%- endcapture %} -{% raw %} {% endraw %} - -{% capture demoValue -%} -{ - "phones": [ - "+1 555 867 5309", - "+1 555 234 5678" - ] -} -{%- endcapture %} - -{% include components/sampletabs_tpl.md - formId="simple_list_hotkeys" - htmlSource=simple_list_hotkeys_html - height=30 - cssSource=simple_list_hotkeys_css - selected="preview" - demoValue=demoValue - showEditor=true - tests=false -%} - -### Reveal of hot keys - -If you tinkered a bit with the previous example, you may have noticed that as -soon as you press the `Ctrl` key, the related hot keys are revealed beside -corresponding buttons. - -🚀 This means that **the user does not need to know every hotkeys in advance**, -but can discover them on the fly by pressing the `Ctrl` key. - -For instance I bet you already discovered that you can use the `Ctrl`+`Delete` -combination to activate the `🧹` button and remove all unused phone number -fields in the list. - -{: .warning :} -> For this to work, **a little CSS setup is needed** to define how the hint -> will look like. -> -> {: .info :} -> > Hotkey hints are dynamically revealed/unrevealied by setting/removing the -> > `data-hotkey` attribute in the trigger's DOM node. -> -> {: .hint :} -> > Check the *CSS* tab of the example above to see an example of how to style -> > the hot keys hints. - - - -### Hotkeys and context - -In *SmarkForm*, hotkeys are context-aware, meaning that the same hotkey can -trigger different actions depending on the context in which the focus is. - -If you dug a bit into the HTML source of the previous example, you may have -noticed that the outer `➕` and `➖` buttons have the *hotkey* property set as -well but, unlike the `🧹` button, they are not announced when pressing the -`Ctrl` key. - -The reason behind this is that the value of their *hotkey* property is the same -of their inner counterparts and hotkeys are discovered from the inner focused -field to the outside, **giving preference to the innermost ones in case of -conflict**. - -Let's see the same example with a few additional fields outside the list: - -If you focus one of them and press the `Ctrl` key, you'll see that nothing -happens. But if you navigate to any phone number in the list (for instance by -repeatedly pressing the `Tab` key) and press the `Ctrl` key, you'll see that -now the hotkeys we defined are available again. - -{% raw %} {% endraw %} -{% capture simple_list_hotkeys_with_context -%} -█

-█ -█ -█

-█

-█ -█ -█

-{{ simple_list_hotkeys }}{% -endcapture %} - -{% capture simple_list_hotkeys_with_context_html -%} -
-{{ simple_list_hotkeys_with_context | replace: "█", " " }} -
{% -endcapture %} -{% raw %} {% endraw %} - - -{% capture demoValue -%} -{ - "name": "John", - "surname": "Doe", - "phones": [ - "+1 555 867 5309", - "+1 555 234 5678" - ] -} -{%- endcapture %} - -{% include components/sampletabs_tpl.md - formId="simple_list_hotkeys_with_context" - htmlSource=simple_list_hotkeys_with_context_html - height=50 - cssSource=simple_list_hotkeys_css - selected="preview" - demoValue=demoValue - showEditor=true - tests=false -%} - - -### Collapsible sections - -HTML's native `
` and `` elements provide a simple, accessible -way to create collapsible content — no JavaScript or special SmarkForm -properties needed. SmarkForm fields placed inside a `
` element work -exactly as they would anywhere else; the browser handles the show/hide toggle -natively. - -A particularly useful pattern is to use `
` elements as **list items**, -placing an identifying field inside the ``. This way, even when an item -is collapsed, its key information remains visible so the user can quickly scan -the list. - -The following example shows a contact list where each item is a `
` -element. The `` contains the contact's name so it is always visible, -while the full details (email and phone) are revealed on expansion: - -{% raw %} {% endraw %} -{% capture collapsible_sections -%} -
-
-
- - - -
- - - -
-
+ See Input Type.
- - -
{%- endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture collapsible_sections_css -%} -{{""}}#myForm$$ { - font-family: sans-serif; -} -{{""}}#myForm$$ details { - border: 1px solid #ccc; - border-radius: 4px; - margin-bottom: 6px; - padding: 4px 8px; -} -{{""}}#myForm$$ summary { - display: flex; - align-items: center; - gap: 6px; - cursor: pointer; - list-style: none; - user-select: none; -} -{{""}}#myForm$$ summary::before { - content: "▶"; - font-size: .75em; - transition: transform .15s; - flex-shrink: 0; -} -{{""}}#myForm$$ details[open] > summary::before { - transform: rotate(90deg); -} -{{""}}#myForm$$ summary input { - flex: 1; - min-width: 0; - font-weight: bold; - border: none; - background: transparent; - outline: none; -} -{{""}}#myForm$$ .contact-details { - display: flex; - flex-wrap: wrap; - gap: 6px; - padding: 6px 0 2px 1.2em; -} -{{""}}#myForm$$ .contact-details input { - flex: 1; - min-width: 120px; -} -{{""}}#myForm$$ .contact-details button { - margin-left: auto; -} -{{""}}#myForm$$ textarea[name="notes"] { - display: block; - width: 100%; - min-height: 60px; - margin-top: 8px; - padding: 6px 8px; - border: 1px solid #ccc; - border-radius: 4px; - font-family: inherit; - box-sizing: border-box; - resize: vertical; -} -{{ hotkeys_reveal_css }} -{%- endcapture %} -{% raw %} {% endraw %} - -{% capture demoValue -%} -{ - "contacts": [ - {"fullname":"Alice Smith","email":"alice@example.com","phone":"+1 555 100 0001"}, - {"fullname":"Bob Jones","email":"bob@example.com","phone":"+1 555 100 0002"}, - {"fullname":"Carol White","email":"carol@example.com","phone":"+1 555 100 0003"} - ], - "notes": "" -}{%- endcapture %} - -{% raw %} {% endraw %} -{% capture collapsible_sections_tests -%} -export default async ({ page, expect, id, root, readField, writeField }) => { - await expect(root).toBeVisible(); - - const formSel = `#myForm-${id}`; - const addBtn = root.getByRole('button', { name: '➕ Add contact' }); - - // Set up: ensure we have 3 contacts for navigation tests. - // The form starts with 1 contact (min_items=1); add 2 more. - await addBtn.click(); - await page.waitForTimeout(50); - await addBtn.click(); - await page.waitForTimeout(50); - - // ── Issue 1: Space key in input must NOT toggle
────── - { - // Open the first contact's
- await page.evaluate(s => { - document.querySelector(`${s} details`).open = true; - }, formSel); - - const firstSummaryInput = root.locator('details').nth(0).locator('summary input[name="fullname"]'); - await firstSummaryInput.focus(); - await page.waitForTimeout(30); // > IME_FOCUS_AGE_MS - - // Press Space — must type a space, NOT close the
- await page.keyboard.press(' '); - - const stillOpen = await page.evaluate( - s => document.querySelector(`${s} details`).open, formSel - ); - expect(stillOpen, 'Space key in summary input must not toggle
').toBe(true); - } - - // ── Shift+Space: toggle
but prevent space character from being typed - { - // Open the first contact's
- await page.evaluate(s => { - document.querySelector(`${s} details`).open = true; - }, formSel); - - const firstSummaryInput = root.locator('details').nth(0).locator('summary input[name="fullname"]'); - const valueBefore = await firstSummaryInput.inputValue(); - await firstSummaryInput.focus(); - await page.waitForTimeout(30); - - // Shift+Space — must toggle the
but NOT type a space - await page.keyboard.press('Shift+ '); - - const nowClosed = await page.evaluate( - s => !document.querySelector(`${s} details`).open, formSel - ); - expect(nowClosed, 'Shift+Space must toggle (close) the
').toBe(true); - - const valueAfter = await firstSummaryInput.inputValue(); - expect(valueAfter, 'Shift+Space must not type a space into the input').toBe(valueBefore); - } - - // ── Shift+Space from body field: fold + refocus to summary ─────────────── - { - // Open the first contact's
- await page.evaluate(s => { - document.querySelector(`${s} details`).open = true; - }, formSel); - - // Focus a field that is inside the body (not ) - const firstBodyInput = root.locator('details').nth(0).locator('input[name="email"]'); - await firstBodyInput.focus(); - await page.waitForTimeout(30); - - // Shift+Space from the body — must fold (close) the
- await page.keyboard.press('Shift+ '); - - const nowClosed = await page.evaluate( - s => !document.querySelector(`${s} details`).open, formSel - ); - expect(nowClosed, 'Shift+Space from body must fold (close) the
').toBe(true); - - // Focus must have moved to a field inside (not lost) - const focusInSummary = await page.evaluate(s => { - const active = document.activeElement; - if (!active) return false; - return !!active.closest('summary'); - }, formSel); - expect(focusInSummary, 'Shift+Space from body must refocus a field in ').toBe(true); - } - - // ── Issue 2: Enter in closed input → navigate to next item ───── - { - // Close all contacts - await page.evaluate(s => { - for (const d of document.querySelectorAll(`${s} details`)) d.open = false; - }, formSel); - - const firstFullname = root.locator('details').nth(0).locator('summary input[name="fullname"]'); - await firstFullname.focus(); - await page.waitForTimeout(30); - - // Enter must skip the hidden email/phone of contact[0] and go to - // the fullname (summary input) of contact[1] - await page.keyboard.press('Enter'); - - const focusedInSecond = await page.evaluate(s => { - const active = document.activeElement; - if (!active) return false; - if (!active.closest('summary')) return false; - const details = active.closest('details'); - const allDetails = [...document.querySelectorAll(`${s} details`)]; - return allDetails.indexOf(details) === 1; - }, formSel); - - expect( - focusedInSecond, - 'Enter in closed summary input must skip hidden fields and go to next item\'s summary field' - ).toBe(true); - } - - // ── Alt+Enter: unfold closed
and navigate into hidden fields ──── - { - // Close all contacts - await page.evaluate(s => { - for (const d of document.querySelectorAll(`${s} details`)) d.open = false; - }, formSel); - - const firstFullname = root.locator('details').nth(0).locator('summary input[name="fullname"]'); - await firstFullname.focus(); - await page.waitForTimeout(30); - - // Alt+Enter must open the first contact's
and navigate - // to the first hidden field (email) inside it - await page.keyboard.press('Alt+Enter'); - - const focusedOnEmail = await page.evaluate(s => { - const active = document.activeElement; - if (!active) return false; - return active.name === 'email' - && active.closest('details') === document.querySelector(`${s} details`); - }, formSel); - - expect( - focusedOnEmail, - 'Alt+Enter in closed summary must unfold the details and navigate to the first hidden field' - ).toBe(true); - - const firstDetailOpen = await page.evaluate( - s => document.querySelector(`${s} details`).open, formSel - ); - expect(firstDetailOpen, 'Alt+Enter must open the closed
').toBe(true); - } - - // ── Issue 3: Enter from last field of last form-type list item ──────────── - { - // Open all contacts so the phone fields are reachable - await page.evaluate(s => { - for (const d of document.querySelectorAll(`${s} details`)) d.open = true; - }, formSel); - - const lastPhone = root.locator('details').last().locator('input[name="phone"]'); - await lastPhone.focus(); - await page.waitForTimeout(30); - - // Enter must cross the list boundary and land on the notes textarea - await page.keyboard.press('Enter'); - - const focusedOnNotes = await page.evaluate( - s => document.activeElement === document.querySelector(`${s} [name="notes"]`), - formSel - ); - - expect( - focusedOnNotes, - 'Enter from last field of last form-type list item must navigate to field after the list' - ).toBe(true); - } - - // ── Backward navigation: Shift+Enter from a field after a list/form ────── - // All contacts are still open from the previous test. - { - const notesFld = root.locator('[name="notes"]'); - await notesFld.focus(); - await page.waitForTimeout(30); - - // Shift+Enter from the textarea (no Ctrl needed for backward navigation) - await page.keyboard.press('Shift+Enter'); - - // Should land on 'phone' of the last contact (last field of last item), - // NOT on 'fullname' (which would happen if focus always dives to first child). - const focusedOnLastPhone = await page.evaluate(s => { - const active = document.activeElement; - if (!active) return false; - const details = document.querySelectorAll(`${s} details`); - const lastDetail = details[details.length - 1]; - const phone = lastDetail?.querySelector('input[name="phone"]'); - return active === phone; - }, formSel); - - expect( - focusedOnLastPhone, - 'Shift+Enter from field after list must land on last field (phone) of last list item' - ).toBe(true); - } - - // ── Backward navigation: Shift+Enter from first field of a list item ───── - { - // Start on 'fullname' of contact[2] (last contact, first field) - const lastFullname = root.locator('details').last().locator('summary input[name="fullname"]'); - await lastFullname.focus(); - await page.waitForTimeout(30); - - // Shift+Enter should cross the list item boundary and land on 'phone' - // of the previous item (contact[1]) — the last field of that item. - await page.keyboard.press('Shift+Enter'); - - const focusedOnPrevPhone = await page.evaluate(s => { - const active = document.activeElement; - if (!active) return false; - const details = document.querySelectorAll(`${s} details`); - const secondDetail = details[1]; // contact[1] - const phone = secondDetail?.querySelector('input[name="phone"]'); - return active === phone; - }, formSel); - - expect( - focusedOnPrevPhone, - 'Shift+Enter from first field of a list item must land on last field of previous item' - ).toBe(true); - } - - // ── Backward navigation into CLOSED (folded) items ─────────────────────── - // Shift+Enter must SKIP closed items (symmetric with forward navigation). - // Alt+Shift+Enter must open a closed item and land on its last field. - - // Close all contacts first. - await page.evaluate(s => { - document.querySelectorAll(`${s} details`).forEach(d => d.open = false); - }, formSel); - await page.waitForTimeout(30); - - { - // Plain Shift+Enter from notes must SKIP hidden fields and land on - // the last visible field of the last contact (fullname, in ). - const notesFld = root.locator('[name="notes"]'); - await notesFld.focus(); - await page.waitForTimeout(30); - - await page.keyboard.press('Shift+Enter'); - await page.waitForTimeout(50); - - const result = await page.evaluate(s => { - const active = document.activeElement; - const details = document.querySelectorAll(`${s} details`); - const lastDetail = details[details.length - 1]; - return { - isOpen: lastDetail?.open, - focusedFullname: active === lastDetail?.querySelector('summary input[name="fullname"]'), - focusedPhone: active === lastDetail?.querySelector('input[name="phone"]'), - }; - }, formSel); - - expect( - result.isOpen, - 'Plain Shift+Enter must NOT open a closed
' - ).toBe(false); - expect( - result.focusedFullname, - 'Plain Shift+Enter into a closed item must land on its last VISIBLE field (fullname in summary)' - ).toBe(true); - expect( - result.focusedPhone, - 'Plain Shift+Enter into a closed item must NOT land on the hidden phone field' - ).toBe(false); - } - - // Close all again, then test Alt+Shift+Enter (should open and enter). - await page.evaluate(s => { - document.querySelectorAll(`${s} details`).forEach(d => d.open = false); - }, formSel); - await page.waitForTimeout(30); - - { - // Alt+Shift+Enter from notes must OPEN the last contact and land on its - // last field (phone). - const notesFld = root.locator('[name="notes"]'); - await notesFld.focus(); - await page.waitForTimeout(30); - - await page.keyboard.press('Alt+Shift+Enter'); - await page.waitForTimeout(50); - - const result = await page.evaluate(s => { - const active = document.activeElement; - const details = document.querySelectorAll(`${s} details`); - const lastDetail = details[details.length - 1]; - return { - isOpen: lastDetail?.open, - focusedPhone: active === lastDetail?.querySelector('input[name="phone"]'), - }; - }, formSel); - - expect( - result.isOpen, - 'Alt+Shift+Enter into a closed item must open its
' - ).toBe(true); - expect( - result.focusedPhone, - 'Alt+Shift+Enter into a closed item must land on its last field (phone)' - ).toBe(true); - } - - // Close all again, then test crossing item boundary with plain Shift+Enter. - await page.evaluate(s => { - document.querySelectorAll(`${s} details`).forEach(d => d.open = false); - }, formSel); - await page.waitForTimeout(30); - - { - // Open last contact just enough to focus its fullname (in ). - // Plain Shift+Enter must cross the item boundary and land on the last - // VISIBLE field of the middle contact (fullname in ), NOT open it. - const lastDetails = root.locator('details').last(); - await page.evaluate(s => { - const details = document.querySelectorAll(`${s} details`); - details[details.length - 1].open = true; - }, formSel); - await page.waitForTimeout(30); - - const lastFullname = lastDetails.locator('summary input[name="fullname"]'); - await lastFullname.focus(); - await page.waitForTimeout(30); - - await page.keyboard.press('Shift+Enter'); - await page.waitForTimeout(50); - - const result = await page.evaluate(s => { - const active = document.activeElement; - const details = document.querySelectorAll(`${s} details`); - const middleDetail = details[1]; // contact[1] - return { - isOpen: middleDetail?.open, - focusedFullname: active === middleDetail?.querySelector('summary input[name="fullname"]'), - focusedPhone: active === middleDetail?.querySelector('input[name="phone"]'), - }; - }, formSel); - - expect( - result.isOpen, - 'Plain Shift+Enter crossing to a closed previous item must NOT open it' - ).toBe(false); - expect( - result.focusedFullname, - 'Plain Shift+Enter crossing to a closed previous item must land on its last visible field (fullname)' - ).toBe(true); - expect( - result.focusedPhone, - 'Plain Shift+Enter crossing to a closed previous item must NOT focus hidden phone' - ).toBe(false); - } - - // Close all again, then test crossing boundary with Alt+Shift+Enter. - await page.evaluate(s => { - document.querySelectorAll(`${s} details`).forEach(d => d.open = false); - }, formSel); - await page.waitForTimeout(30); - - { - // Open last contact, focus fullname. Alt+Shift+Enter must open the - // middle contact and land on its last field (phone). - const lastDetails = root.locator('details').last(); - await page.evaluate(s => { - const details = document.querySelectorAll(`${s} details`); - details[details.length - 1].open = true; - }, formSel); - await page.waitForTimeout(30); - - const lastFullname = lastDetails.locator('summary input[name="fullname"]'); - await lastFullname.focus(); - await page.waitForTimeout(30); - - await page.keyboard.press('Alt+Shift+Enter'); - await page.waitForTimeout(50); - - const result = await page.evaluate(s => { - const active = document.activeElement; - const details = document.querySelectorAll(`${s} details`); - const middleDetail = details[1]; // contact[1] - return { - isOpen: middleDetail?.open, - focusedPhone: active === middleDetail?.querySelector('input[name="phone"]'), - }; - }, formSel); - - expect( - result.isOpen, - 'Alt+Shift+Enter crossing to a closed previous item must open it' - ).toBe(true); - expect( - result.focusedPhone, - 'Alt+Shift+Enter crossing to a closed previous item must land on its last field (phone)' - ).toBe(true); - } -}; -{%- endcapture %} -{% raw %} {% endraw %} - -{% include components/sampletabs_tpl.md - formId="collapsible_sections" - htmlSource=collapsible_sections - height=65 - cssSource=collapsible_sections_css - selected="preview" - demoValue=demoValue - showEditor=true - tests=collapsible_sections_tests -%} - -👉 Notice that clicking a contact's name row (or the arrow at the left) -toggles it. The name input inside `` is always visible and editable -regardless of the collapsed/expanded state. - -{: .info :} -> The same technique works for any form section, not just list items. Wrap a -> `
` or `
` in a `
` element and -> add a `` heading — no SmarkForm-specific options needed. The -> `open` attribute on `
` controls the initial state: include it to -> start expanded (the default), or omit it to start collapsed. - - -### Smooth navigation - -As you may have already noticed in the preceding examples, *SmarkForm* provides -an intuitive interface to facilitate users effortlessly discover how to -fluently fill all the data in the form without bothering with the interface. - -👉 Notice you can navigate smoothly between form fields by typing `Enter` -(forward) and `Shift`+`Enter` (backward). - -So, when you finish filling a field, you can just press `Enter` to -move to the next one. - -This is not only more convenient than `Tab` and `Shift`+`Tab`. More than that: -**it skips controls providing a more fluid experience** when you are just -filling data in. - -{: .info :} -> In case of a textarea, use `Ctrl`+`Enter` instead, since `Enter` alone is -> used to insert a new line in the text. - -Take a look to the `📝 Notes` tab of the previous example for more interesting -insights and tips. - -👉 Last but not least, if you still prefer using `Tab` and `Shift`+`Tab`, in the -previous example you may have noticed that you can navigate through the outer -`🧹`, `➕` and `➖` buttons using the `Tab` key, but you cannot navigate to the -inner `➖` and `➕` buttons in every list item. - -This is automatically handled by *SmarkForm* to improve User Experience: - - * Passing through all `➖` and `➕` buttons in every list item would - have made it hard to navigate through the list. - - * *SmarkForm* detects that they have a *hotkey* defined and take them out of - the navigation flow since the user only needs to press the `Ctrl` key to - discover a handy alternative to activate them from the keyboard. - - * The outer ones, by contrast, are always kept in the navigation flow since - they are outside of their actual context and their functionality may be - required before having chance to bring the focus inside their context. - - Put in other words: otherwise, with *min_items* set to 0, it would be - impossible to create the first item without resorting to the mouse. - - -### 2nd level hotkeys - -Let's recall the previous example with few personal data and a list of phones -and wrap it in a list to build a simple phonebook. - -As we've learned, we can use "+" and "-" hotkeys to add or remove entries in -our phonebook without causing any conflict. When the user presses the `Ctrl` -key the proper hotkeys are revealed depending on the context of the current -focus. - -🤔 But now let's say you filled in the last phone number in the current entry -and you want to add a new contact to the phonebook without turning to the -mouse. **You cannot reach the outer `➕` button to add a new contact because -its hotkey is the same as the inner `➕` button to add a new phone number.** - -🚀 For this kind of situations, *SmarkForm* provides a *2nd level hotkey -access*: - -👉 Just combine the `Alt` key with the `Ctrl` key and the hotkeys in -their nearest level will be automatically inhibited allowing those in the next -higher level to reveal. - -Try it in the following example: - -{% raw %} {% endraw %} -{% capture 2nd_level_hotkeys_html -%} -
-█
-█
-█ -█ [➖] -█ -█ Contact -█ N -█ -█ {{ - simple_list_hotkeys_with_context | replace: "█", "█ " -}}█
-█
-█

-█ Total entries: -█ M -█

-█ - -
{%- endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture 2nd_level_hotkeys_tests -%} -export default async ({ page, expect, id, root }) => { - await expect(root).toBeVisible(); - - // Check that both inputs exist - const nameFld = page.locator('input[name="name"]'); - const surnameFld = page.locator('input[name="surname"]'); - const addPhoneBtn = page.locator('button[title="Add phone number"]'); - const editorFld = page.locator('textarea[data-smark]'); - - await expect(nameFld).toBeVisible(); - - // Fill name and surname fields: - await nameFld.fill('John'); - await surnameFld.fill('Doe'); - - // Add a phone field to the list (it will get ghe focus) - await addPhoneBtn.click(); - - // Fill in - await page.keyboard.type('1234567890'); - - // Use Shift+Enter to navigate back to the first phone filed - await page.keyboard.down('Shift'); - await page.keyboard.press('Enter'); - await page.keyboard.up('Shift'); - - // Fill in the first phone field - await page.keyboard.type('0987654321'); - - - // Check the propper hotkey hints got revealed - // ------------------------------------------- - - // Get locators: - const removeEmptyBtn = page.getByRole('button', { name: '🧹' }).nth(0); - const removeLastBtn = page.getByRole('button', { name: '➖' }).nth(0); - const appendItemBtn = page.getByRole('button', { name: '➕' }).nth(0); - const removeItemBtn1 = page.getByRole('button', { name: '➖' }).nth(1); - const addItemBtn1 = page.getByRole('button', { name: '➕' }).nth(1); - const removeItemBtn2 = page.getByRole('button', { name: '➖' }).nth(2); - const addItemBtn2 = page.getByRole('button', { name: '➕' }).nth(2); - const addContactBtn = page.getByRole('button', { name: '➕ Add Contact' }) - - // Function to read the hotkey hint content (if displayed) - async function readHotkeyHint(locator) { - const box = await locator.boundingBox(); - const x = box.x + box.width / 2; - const y = box.y + box.height / 2; - return await page.evaluate(({x, y}) => { - const element = document.elementFromPoint(x, y); - const beforeStyle = window.getComputedStyle(element, '::before'); - if (beforeStyle.display === 'none' || beforeStyle.content === '') return null; - return element.getAttribute('data-hotkey') || null; - }, {x, y}); - } - - // Reveal 1st level hotkey hints by pressing and holding Control - await page.keyboard.down('Control'); - - // Check 1st level hotkey hints - expect(await readHotkeyHint(removeEmptyBtn)).toBe('Delete'); - expect(await readHotkeyHint(removeLastBtn)).toBe(null); - expect(await readHotkeyHint(appendItemBtn)).toBe(null); - expect(await readHotkeyHint(addItemBtn1)).toBe('+'); - expect(await readHotkeyHint(addItemBtn2)).toBe(null); - expect(await readHotkeyHint(removeItemBtn1)).toBe('-'); - expect(await readHotkeyHint(removeItemBtn2)).toBe(null); - expect(await readHotkeyHint(addContactBtn)).toBe(null); - - // Reveal 2nd level hotkey hints by also pressing Alt - await page.keyboard.down('Alt'); - - expect(await readHotkeyHint(removeEmptyBtn)).toBe(null); - expect(await readHotkeyHint(removeLastBtn)).toBe(null); - expect(await readHotkeyHint(appendItemBtn)).toBe(null); - expect(await readHotkeyHint(addItemBtn1)).toBe(null); - expect(await readHotkeyHint(addItemBtn2)).toBe(null); - expect(await readHotkeyHint(removeItemBtn1)).toBe(null); - expect(await readHotkeyHint(removeItemBtn2)).toBe(null); - expect(await readHotkeyHint(addContactBtn)).toBe('+'); - - // Return to 1st level hotkeys by releasing Alt - await page.keyboard.up('Alt'); - - // Check hotkey hints reverted to 1st level - expect(await readHotkeyHint(removeEmptyBtn)).toBe('Delete'); - expect(await readHotkeyHint(removeLastBtn)).toBe(null); - expect(await readHotkeyHint(appendItemBtn)).toBe(null); - expect(await readHotkeyHint(addItemBtn1)).toBe('+'); - expect(await readHotkeyHint(addItemBtn2)).toBe(null); - expect(await readHotkeyHint(removeItemBtn1)).toBe('-'); - expect(await readHotkeyHint(removeItemBtn2)).toBe(null); - expect(await readHotkeyHint(addContactBtn)).toBe(null); - - // (Here Control key is sitll hold) - - // Use 'Control' + '+' to add another phone field in between - await page.keyboard.press('+'); - - // Release 'Control' key (end hotkeys functionality) - await page.keyboard.up('Control'); - - // Check all hotkey revealing are gone - expect(await readHotkeyHint(removeEmptyBtn)).toBe(null); - expect(await readHotkeyHint(removeLastBtn)).toBe(null); - expect(await readHotkeyHint(appendItemBtn)).toBe(null); - expect(await readHotkeyHint(addItemBtn1)).toBe(null); - expect(await readHotkeyHint(addItemBtn2)).toBe(null); - expect(await readHotkeyHint(removeItemBtn1)).toBe(null); - expect(await readHotkeyHint(removeItemBtn2)).toBe(null); - expect(await readHotkeyHint(addContactBtn)).toBe(null); - - // Fill in the phone number - await page.keyboard.type('1122334455'); - - // Add another phone field to the end of the list (it will get ghe focus) - await addPhoneBtn.click(); - - // Fil in - await page.keyboard.type('6677889900'); - - // Export the data - const data = await page.evaluate(async() => { - return await myForm.export(); - }); - - // Verify the exported data - const expectedData = { - phonelist: [ - { - name: 'John', - surname: 'Doe', - phones: [ - '0987654321', - '1122334455', - '1234567890', - '6677889900' - ] - } - ] - }; - expect(data).toEqual(expectedData); - -}; -{%- endcapture %} -{% raw %} {% endraw %} - - -{% include components/sampletabs_tpl.md - formId="2nd_level_hotkeys" - htmlSource=2nd_level_hotkeys_html - cssSource=simple_list_hotkeys_css - selected="preview" - showEditor=true - tests=2nd_level_hotkeys_tests -%} - - -### Hidden actions - - -As we already learned, *SmarkForm* hotkeys are defined over trigger components -so, to define a hotkey to perform some action, we need to place a trigger -component that calls that action somewhere in the form. - -{: .info :} -> This aligns well with the *SmarkForm* philosophy of providing a consistent -> functionality no matter the device or input method used. For instance, if you -> use a touch device, you will hardly use the keyboard, let alone a hotkey. But -> you will always be able to tap the button to perform the action. - -Nevertheless there are exceptions where hotkeys can be convenient but flooding -the form with triggers for, maybe non essential, actions would make the form -cluttered more than needed. - -👉 This is the case of the `➖` and `➕` buttons surrounding every phone number -field in the previous examples which allowed to cherry pick the position where -to remove or add a new phone: For small devices would be enough with the -general `➖` and `➕` buttons that removes or adds a phone number from/to the -end of the list. - -💡 In this scenario **we can use CSS to hide the triggers** while keeping them -accessible through their hotkeys. - -{: .warning :} -> Keep in mind that if, [like in our examples](#reveal-of-hot-keys), you use a -> `::before` (or `::after`) pseudo-element to show the hotkey hint, you -> shouldn't use a property that completely removes it from the DOM, like -> `display: none;`, since it will also prevent the `::before` or `::after` -> pseudo-element from appearing too. -> -> {: .hint :} -> > Better use `visibility: hidden;` or `opacity: 0;` to hide the button -> > and `width: 0px;` and/or `height: 0px;` as needed to prevent them from -> > taking space in the layout. - - -{% raw %} {% endraw %} -{% capture hidden_actions_css -%} - -{{""}}#myForm$$ li.row button[data-smark] { - visibility: hidden; - width: 0px; - pointer-events: none; -} -{{""}}#myForm$$ li.row button[data-smark]::before { - visibility: visible; -} -{{ simple_list_hotkeys_css }} -{%- endcapture %} -{% raw %} {% endraw %} - - -{% include components/sampletabs_tpl.md - formId="hidden_actions" - htmlSource=2nd_level_hotkeys_html - cssSource=hidden_actions_css - selected="preview" - tests=false -%} - -This is just a simple trick and not any new *SmarkForm* feature, but it is -worth to mention it here since it helps to build smoother and cleaner forms. - -If you try to fill the former example you'll notice that, when hitting the -`Ctrl` key, the "+" and "-" hotkey hints are shown beside the position of the, -now hidden, `➕` and `➖` buttons. - -...And, at the same time, the ones still visible in the outer context will -allow touch device users to add or remove phone numbers even only to/from the -end of the list. - - -### Animations - -*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. - -* **`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. - -🚀 Because both handlers filter by `ev.context.parent?.options.type`, a single -pair of listeners covers every list in the form — including nested ones — with -no per-list wiring required. - -{% raw %} {% endraw %} -{% capture animations_css -%} -.animated_item { - transform: translateX(-100%); /* Start off-screen to the left */ - opacity: 0; /* Optional: Start invisible for smoother effect */ - /* Transition for removal effect */ - transition: - transform 200ms ease-out, - opacity 200ms ease-out; -} - -.animated_item.ongoing { - transform: translateX(0); /* End at original position */ - opacity: 1; /* Optional: Fully visible */ - transition: - transform 200ms ease-in, - opacity 200ms ease-in; -} - -{{ hidden_actions_css }}{% -endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture animations_js -%} -const delay = ms=>new Promise(resolve=>setTimeout(resolve, ms)); -{{""}}myForm.onAll("afterRender", async function(ev) { - if (ev.context.parent?.options.type !== "list") return; /* Only for list items */ - const item = ev.context.targetNode; - item.classList.add("animated_item"); - await delay(1); /* Important: Allow DOM to update */ - item.classList.add("ongoing"); -}); -{{""}}myForm.onAll("beforeUnrender", async function(ev) { - if (ev.context.parent?.options.type !== "list") return; /* Only for list items */ - const item = ev.context.targetNode; - item.classList.remove("ongoing"); - /* Await for transition to be finished before item removal: */ - await delay(150); -}); -{%- endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture animations_notes -%} -**Why add `animated_item` via JavaScript instead of directly in the HTML?** - -If the class were baked into the 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. - ---- - -**Why the 1 ms delay in `afterRender`?** - -CSS transitions only fire when a property *changes* after the element is already -in the document. If both `animated_item` and `ongoing` were added in the same -task, the browser would never observe the initial hidden state and the transition -would not play. The `await delay(1)` yields control for one event-loop tick, -giving the rendering engine a chance to paint the initial state before `ongoing` -is applied. - ---- - -**Why `await delay(150)` in `beforeUnrender`?** - -*SmarkForm* awaits the return value of `beforeUnrender` handlers before -detaching the element from the DOM. By returning a promise that resolves after -150 ms (matching the CSS `transition-duration`), we keep the element visible -just long enough for the exit animation to finish. - ---- - -**Applying this globally vs. per-list** - -`myForm.onAll()` listens on *all* components in the form. The guard -`ev.context.parent?.options.type !== "list"` skips 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. -{%- endcapture %} -{% raw %} {% endraw %} - - -{% include components/sampletabs_tpl.md - formId="animations" - htmlSource=2nd_level_hotkeys_html - cssSource=animations_css - jsSource=animations_js - selected="preview" - notes=animations_notes - tests=false -%} - -The `afterRender` handler adds `animated_item` via JavaScript rather than -embedding it directly in the HTML template. This ensures the animation class is -only present when JavaScript is active, so the form degrades gracefully if JS is -disabled. - -The `beforeUnrender` handler does the reverse: it removes `ongoing` and returns -a `Promise` delayed by 150 ms — matching the CSS transition duration — so -*SmarkForm* holds the element in the DOM while the exit animation plays out. - - -### Smart value coercion - -*SmarkForm* automatically normalises imported values to match the expected type -and shape of each field. This keeps your forms resilient to data-model changes -and ensures that what you save is always clean and well-typed. - -#### Scalar-to-array list coercion - -When a *list* field receives a non-array value — a plain string, a number, or -an object — it automatically wraps it in a single-item array. This is -particularly useful for **model migrations**: if a field that used to hold a -single `email` string is upgraded to accept a list of `emails`, old saved data -continues to work without any transformation step. - -{% raw %} {% endraw %} -{% capture notes -%} -👉 **Scalar-to-array coercion**: If you import a plain string instead of an -array, *SmarkForm* automatically places it in a single-item list. - - * Click **⬇️ Export**, change `["alice@example.com"]` to just - `"alice@example.com"` in the JSON playground editor below, then click - **⬆️ Import** — the single email is placed in the list automatically. - * This mirrors the upgrade from a single-value field (e.g. `"email"`) to a - list field (e.g. `"emails": [...] `). - -👉 **Empty items are not exported** by default (controlled by `exportEmpties`). - - * Click **➕** to add a blank item, then **⬇️ Export** — the blank row will - be absent from the output, keeping saved data clean. - * Set `"exportEmpties": true` on the list to keep blank slots in the data - (useful in draft-save workflows where you want to preserve the user's - position in the list). - -{%- endcapture %}{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture smart_value_coercion -%} -
-█ -█ -█Emails: -█
    -█
  • (No emails on record)
  • -█
  • -█
-
{%- endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture smart_value_coercion_css -%} -{{""}}#myForm$$ ul { - list-style: none; - padding-left: 0; -}{% -endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture smart_value_coercion_tests -%} -export default async ({ page, expect, id, root, readField, writeField }) => { - await expect(root).toBeVisible(); - - // Scalar-to-array coercion - await writeField('email', 'bob@example.com'); - expect( - await readField('email'), - 'Scalar string is coerced to a single-item array' - ).toEqual(['bob@example.com']); - - // Array import works as expected - await writeField('email', ['carol@example.com', 'dave@example.com']); - expect( - await readField('email'), - 'Array import works normally' - ).toEqual(['carol@example.com', 'dave@example.com']); - - // exportEmpties = false: blank items are not exported - await page.evaluate(() => myForm.find('/email').addItem()); - expect( - await readField('email'), - 'Blank item is not exported (exportEmpties defaults to false)' - ).toEqual(['carol@example.com', 'dave@example.com']); - - // Filling the new blank item makes it appear in the export - const inputs = page.locator(`#myForm-${id} input[type=email]`); - await inputs.last().fill('eve@example.com'); - expect( - await readField('email'), - 'Filled item IS exported' - ).toEqual(['carol@example.com', 'dave@example.com', 'eve@example.com']); -}; - -{%- endcapture %} -{% raw %} {% endraw %} - -{% capture demoValue -%} -{ - "email": "alice@example.com" // Old data saved before upgrading to an array -} -{%- endcapture %} - -{% 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); -}; - -{%- endcapture %} -{% 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" - } -} -{%- endcapture %} - -{% include components/sampletabs_tpl.md - formId="type_coercion" - htmlSource=type_coercion - notes=notes - selected="preview" - showEditor=true - demoValue=demoValue - tests=type_coercion_tests -%} - - - -### Dynamic Dropdown Options - -{: .warning :} -> Section still under construction... - -In this example, we'll illustrate how to create dropdown menus with dynamic options. This is particularly useful for forms that need to load options based on user input or external data sources. - - -{% include components/sampletabs_tpl.md - formId="dynamic_dropdown" - htmlSource=dynamic_dropdown - selected="preview" - tests=false -%} - - - - -## Random Examples - -Here are some random examples to showcase the flexibility of SmarkForm and how -it can be used to create various types of forms or even more complex interfaces -with different functionalities. - -### Simple Calculator - -The following example implements a simple calculator with just single input -field and several buttons triggering the *import* action over that field with -the *data* property accordingly set. - -It leverages the *singleton* pattern to avoid specifying the context for every -button. Then a very simple JavaScript code makes the rest... - -{% raw %} {% endraw %} -{% capture calculator -%} -
-
- - -
- - - - - - - - - - - - - - - - - - - - -
-
- -
{%- endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture calculatorStyles_css -%} -{{""}}#myForm$$ .calculator { - background-color: #333; - border-radius: 10px; - padding: 20px; - box-shadow: 0 4px 10px rgba(0, 0, 0, 0.3); - width: 300px; -} -{{""}}#myForm$$ .display { - width: 100%; - box-sizing: border-box; - background-color: #fff; - border: 1px solid #ccc; - border-radius: 5px; - padding: 10px; - margin-bottom: 10px; - font-size: 24px; - text-align: right; - overflow: hidden; - white-space: nowrap; - text-overflow: ellipsis; -} -{{""}}#myForm$$ .display:invalid { - background-color: #fcc; -} -{{""}}#myForm$$ .buttons { - display: grid; - grid-template-columns: repeat(4, 1fr); - gap: 5px; -} -{{""}}#myForm$$ button { - padding: 15px; - font-size: 18px; - border: none; - border-radius: 5px; - cursor: pointer; - background-color: #555; - color: white; - transition: background-color 0.2s; -} -{{""}}#myForm$$ button:hover { - background-color: #777; -} -{{""}}#myForm$$ .operator { - background-color: #f9a825; -} -{{""}}#myForm$$ .operator:hover { - background-color: #ffb300; -} -{{""}}#myForm$$ .equals { - background-color: #4caf50; -} -{{""}}#myForm$$ .equals:hover { - background-color: #66bb6a; -} -{{""}}#myForm$$ .clear { - background-color: #d32f2f; -} -{{""}}#myForm$$ .clear:hover { - background-color: #ef5350; -} -{{ hotkeys_reveal_css }} -{%- endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture calculator_css -%} -{{ calculatorStyles_css }} -{{ hotkeys_reveal_css }} -{%- endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture calculator_js -%} - -const invalidChars = /[^0-9+\-*\/().]+/g; - -myForm.on("BeforeAction_import", async (ev)=>{ - const prevValue = await ev.context.export(); - const key = ev.data; - switch (key) { - case "C": - ev.data = "0"; /* Clear display */ - break; - case "Del": - ev.data = prevValue.slice(0, -1) || "0"; /* Remove last character */ - break; - case "=": - try { - /* Evaluate expression */ - const sanitized = prevValue.replace(invalidChars, ''); - ev.data = eval(sanitized); - } catch (e) { - alert("Invalid expression"); - ev.preventDefault(); /* Keep existing data */ - } - break; - default: - if (prevValue.trim() === "0") { - ev.data = key; /* Replace 0 with new input */ - } else { - ev.data = prevValue + key; /* Append to existing value */ - }; - }; -});{% -endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture notes -%} -👉 The code in this example is listening to all *import* actions in the whole -form. - -This isn't an issue for this simple example. But if we had other fields in -the form (unless they were intended to be additional calculators) would be -affected too. - -In that case, we could have attached the listener directly to the *display* -field like this: - -```javascript -myForm.onRendered(()=>{ - /* Now display field is rendered */ - const display = myForm.find("/display"); - display.onLocal("BeforeAction_import", async (ev)=>{ - /* ... */ - }); -}); -``` - -👉 Using `.on()`or `.onLocal()` here is indifferent since inputs have no -children. - -...But in case of forms (or lists of forms) using `.on()` would have lead to -intercept every "BeforeAcction_import" event in it **or its children** while -.onLocal() will only intercept those triggered by the form itself. Not from -any of its descendants. - -{%- endcapture %}{% raw %} {% endraw %} - -{% include components/sampletabs_tpl.md - formId="calculator" - htmlSource=calculator - jsSource=calculator_js - cssSource=calculator_css - notes=notes - selected="preview" - tests=false -%} - -{: .hint :} -> Notice that this calculator has *the power superpower* for free: -> -> Expressions like `2**10` are valid, so you can calculate any power. - -👉 A single event handler over the *onAfterAction_import* does all the magic by -intercepting the new value and appending it to the current one except for the -few special cases like `C`, `Del` and `=` where the value is handled -accordingly. - -Check the *JS* tab to see the little JavaScript code that does the job. - -Don't miss the *Notes* tab too for some additional insights. - - -👌 The best thing is that you can either use the calculator buttons or directly -type in the input field: Every time you use a button, the *import* action will -bring the focus back to the input field so you can continue typing. - - -### Calculator (UX improved) - -The UX feeling of the previous example isn't perfect since it was intended to -be a very simple implementation. - -Let's handle the keydown event too and notice the so little effort is needed to -reach a perfect UX. - -{% raw %} {% endraw %} -{% capture supercalculator -%} -
-
- - -
- - - - - - - - - - - - - - - - - - - - -
-
- -
{%- endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture supercalculator_js -%} - -var invalidChars = /[^0-9+\-*\/().]+/g; - -function updateDisplay(prevValue, key) { - switch (key.toLowerCase()) { - case "c": - case "delete": - return "0"; /* Clear display */ - break; - case "backspace": - return prevValue.slice(0, -1) || "0"; /* Remove last character */ - break; - case "=": - case "enter": /* Keyboard enter key */ - try { - /* Evaluate expression */ - const sanitized = prevValue.replaceAll(invalidChars, ''); - return eval(sanitized); - } catch (e) { - return "Error!"; - } - break; - default: - if (!! key.match(invalidChars)) { - return prevValue; /* Keep existing data */ - }; - if (prevValue.replace(/[0\s]+/, "") === "") { - return key; /* Replace 0 with new input */ - }; - return prevValue + key; /* Append to existing value */ - }; -}; - -myForm.on("BeforeAction_import", async (ev)=>{ - const prevValue = await ev.context.export(); - const key = ev.data; - ev.data = updateDisplay(prevValue, key); -}); - -myForm.on("keydown", async (ev)=>{ - ev.preventDefault(); - const prevValue = await ev.context.export(); - const key = ev.originalEvent.key; - const data = updateDisplay(prevValue, key); - await ev.context.import(data); -});{% -endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture supercalculator_css -%} -{{""}}#myForm$$ .calculator input.display { - caret-color: transparent; /* Hide caret */ -} -{{ calculatorStyles_css }} -{%- endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture notes -%} - -🕵️ It's off-topic but worth to mention the trick of doing `!! -key.match(invalidChars))` instead of `invalidChars.test(key)` is not arbitrary -since *invalidChars* is a regex with the global flag set, which makes it -suitable for 'String.replaceAll()'. - -With `test()`, the internal *lastIndex* property won't be reset making it to -fail after first usage. - -The `!!` bit is just stylistic to note we want to evaluate the result of -`.match()` as a boolean. - -{%- endcapture %}{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture supercalculator_tests -%} -export default async ({ page, expect, id, root, readField, writeField }) => { - await expect(root).toBeVisible(); - - // Enter first number "123": click button "1", press "2" (bare key, no - // modifier), then click "3" - await root.getByRole('button', { name: '1' }).click(); - await page.keyboard.press('2'); - await root.getByRole('button', { name: '3' }).click(); - expect(await readField('display')).toBe('123'); - - // Enter the operator: click the + button - await root.getByRole('button', { name: '+' }).click(); - expect(await readField('display')).toBe('123+'); - - // Enter second number "45": press "4" (bare key, no modifier), then click "5" - await page.keyboard.press('4'); - await root.getByRole('button', { name: '5' }).click(); - expect(await readField('display')).toBe('123+45'); - - // Evaluate by pressing Enter (keyboard without any modifier) - await page.keyboard.press('Enter'); - - // Export the form and verify the result in the display field - const data = await page.evaluate(async () => { - return await myForm.export(); - }); - expect(String(data.display)).toBe('168'); -}; -{%- endcapture %}{% raw %} {% endraw %} - -{% include components/sampletabs_tpl.md - formId="supercalculator" - htmlSource=supercalculator - jsSource=supercalculator_js - cssSource=supercalculator_css - notes=notes - selected="preview" - tests=supercalculator_tests -%} - -In this example we no longer need to define hotkeys since we are directly -listening to all keydown events. - -If you check the *JS* tab you'll see that we extracted the key processing logic -to a function called `updateDisplay()` that receives thwo arguments -(*prevValue* and *key*) to calculate the new value of the display. - -It returns null for invalid keystrokes and can report the "Error!" condition -directly to the display (like a real calculator) since it will be cleared with -the next keystroke (no matter which event it comes from). - -Then the *BeforeAction_import* event handler just calls that function and sets -the *ev.data* property with its result. - -The *keydown* event handler does call the `updateDisplay()` function but: - - * It takes the key from the original keydown event. - * Calls the `.preventDefault()` method to avoid the keystroke effectively - reaching the display. - * Programmatically triggers the *import* action over the display with the - new value calculated. - -{: .hint :} -> Since now all keyboard strokes are processed by to `updateDisplay()` -> function, this allows us to define handy aliases which will feel more natural -> in a PC keyboard for some keys like: -> -> * `Enter` as an alias for `=` -> * `Delete` as an alias for `C` -> -> In the case of the formerly named `Del` key, we just renamed it to -> `Backspace` to match the real key since `Del` was just a random name to void -> using en Emojii (←) as a key name. - -We also added a little CSS rule to hide the caret in the input field since the -display will no longer be directly editable. - - -### Team Event Planner - -This is the same demo shown on the [🔗 landing page]({{ "/" | relative_url }}) — a -compact form that showcases several SmarkForm features at once: a nested -subform, a sortable variable-length list, context-driven hotkeys, and date/time -coercion. +
-Use the JSON editor below to inspect the exported data as you interact with the -form, or import your own JSON to pre-populate it. +
+ +
+ The 🧹 button with preserve_non_empty:true and + target:"*" removes all empty car models at once. exportEmpties:false + on the list skips empty entries when exporting the form. -{% raw %} {% endraw %} -{% capture event_planner_html -%} -
-
-

- - -

-

- - -

-

- - -

-
- 👤 Organizer -

- - -

-

- - -

-
-
- - - 👥 Attendees: -
    -
  • -
    - - - N ☰ - - - - - -
    - - -
    -
    -
  • -
-
-

💡 Hold Ctrl to reveal shortcuts

+ See List Type.
-
{% -endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture event_planner_css -%} -{{""}}#myForm$$ .ep { - display: flex; - flex-direction: column; - gap: 0.35em; - max-width: 460px; - font-size: 0.95em; -} -{{""}}#myForm$$ .ep p { - display: flex; - align-items: center; - gap: 0.5em; - margin: 0; -} -{{""}}#myForm$$ .ep label { - font-weight: 500; - white-space: nowrap; -} -{{""}}#myForm$$ .ep label:not(.bullet) { - min-width: 4.5em; -} -{{""}}#myForm$$ .ep input { - padding: 0.3em 0.5em; - border: 1px solid #ccc !important; - border-radius: 4px; -} -{{""}}#myForm$$ .ep input[type="text"], -{{""}}#myForm$$ .ep input[type="email"] { - flex: 1; -} -{{""}}#myForm$$ .ep fieldset { - border: 1px solid #ddd !important; - border-radius: 6px; - padding: 0.4em 0.8em 0.6em; - margin: 0; - display: flex; - flex-direction: column; - gap: 0.3em; -} -{{""}}#myForm$$ .ep fieldset legend { - font-weight: bold; - padding: 0 0.3em; -} -{{""}}#myForm$$ .ep-list ul { - list-style: none !important; - padding: 0 !important; - margin: 0.2em 0 0; - display: flex; - flex-direction: column; - gap: 0.25em; -} -{{""}}#myForm$$ .ep-list ul li { - display: flex; - align-items: flex-start; - gap: 0.3em; -} -{{""}}#myForm$$ .ep-list ul li details { - width: 100%; - border: 1px solid transparent; - border-radius: 4px; - transition: border-color 0.15s; -} -{{""}}#myForm$$ .ep-list ul li details[open] { - border-color: #ccc; - padding-bottom: 4px; -} -{{""}}#myForm$$ .ep-list ul li summary { - display: flex; - align-items: center; - gap: 0.4em; - cursor: default; - user-select: none; - padding: 0.1em 0.2em; - list-style: none; -} -{{""}}#myForm$$ .ep-attendee { - display: flex; - flex-wrap: wrap; - gap: 0.4em; - padding: 0.3em 0.4em 0.1em 1.5em; -} -{{""}}#myForm$$ .ep-attendee input { - flex: 1; - min-width: 120px; -} -{{""}}#myForm$$ .ep-hint { - font-size: 0.82em; - color: #888; - margin: 0.15em 0 0; -} -{{""}}#myForm$$ .ep-hint kbd { - background: rgba(165, 165, 165, .25); - border: 1px solid #ccc; - border-radius: 3px; - padding: 1px 4px; -} -/* Hotkey hints revealed on Ctrl press */ -{{""}}#myForm$$ [data-hotkey]{position:relative} -{{""}}#myForm$$ [data-hotkey]::after{ - content:"Ctrl+" attr(data-hotkey); - position:absolute; top:-1.6em; left:0; - font-size:0.7em; - background:#333; color:#fff; - padding:1px 4px; border-radius:3px; - white-space:nowrap; -} -/* Attendee list item entry/exit animations */ -{{""}}#myForm$$ .ep-list ul li.animated_item { - transform: translateX(-100%); - opacity: 0; - transition: transform 200ms ease, opacity 200ms ease; -} -{{""}}#myForm$$ .ep-list ul li.animated_item.ongoing { - transform: translateX(0); - opacity: 1; -}{% -endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture event_planner_js %}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); -});{%- endcapture %} -{% raw %} {% endraw %} - -{% raw %} {% endraw %} -{% capture notes -%} -👉 This demo highlights several SmarkForm features at once: - - * **Foldable rows**: Each attendee row uses a native `
`/`` - element. Click the ▶ triangle (or anywhere on the row header outside the - name field and action buttons) to expand or collapse the extra fields. A - `` inside the `` acts as the - SmarkForm label, making it the drag handle for reordering (shown as `☰`). - Because the `` itself is not the label, the native disclosure - triangle and fold/unfold on click are preserved without extra CSS - workarounds. - * **Nested subform**: The `organizer` fieldset is a subform — its fields are - grouped and exported as a nested object. - * **Sortable list**: Attendees can be dragged to reorder them. The list uses - `exportEmpties: false` so empty slots are not exported. - * **Context-driven hotkeys**: The `➕`/`➖` buttons inside each list item - carry `-`/`+` hotkeys, active only when focus is within that item. The `🧹` - button uses `Delete` as a context-wide hotkey. - * **Date & time coercion**: The `date` and `time` inputs use SmarkForm's - built-in type coercion — values are normalised to ISO date/time format on - import/export. - * **Label components**: `data-smark='label'` on non-`
-For more detailed information and documentation, please refer to the other sections of this manual. If you have any questions or need further assistance, don't hesitate to reach out to the SmarkForm community. +
-Happy form building! + +--- +{: .hint } +> **Want the full feature set?** Each example above demonstrates a curated +> subset of SmarkForm capabilities. See the individual reference pages for +> the complete API and advanced patterns. diff --git a/docs/_advanced_concepts/animations.md b/docs/_advanced_concepts/animations.md new file mode 100644 index 00000000..1a542152 --- /dev/null +++ b/docs/_advanced_concepts/animations.md @@ -0,0 +1,218 @@ +--- +title: "Animations" +layout: chapter +permalink: /advanced_concepts/animations +nav_order: 3 + +--- + +{% 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. + +## Simple Example — Two-Class Symmetric Animation + +The simplest approach uses two CSS classes: a **base class** that defines the +off-screen position and the `transition`, and a **visible class** that overrides +the position to the final on-screen state. + +Entry animation: add the base class → wait 1 ms for the browser to paint → +add the visible class. Exit animation: remove the visible class → the base +class takes over → the `transition` plays in reverse. + +{% raw %}{% endraw %} +{% capture anim_simple_html -%} +
+ + +
    +
  • + +
  • +
+
+{%- endcapture %}{% raw %}{% endraw %} + +{% raw %}{% endraw %} +{% capture anim_simple_css -%} +{{""}}#myForm$$ ul { list-style: none; padding: 0; } +{{""}}#myForm$$ li.anim-base { + transform: translateX(-100%); + opacity: 0; + transition: transform 200ms ease-out, opacity 200ms ease-out; +} +{{""}}#myForm$$ li.anim-base.anim-visible { + transform: translateX(0); + opacity: 1; + transition: transform 200ms ease-in, opacity 200ms ease-in; +} +{%- endcapture %}{% raw %}{% endraw %} + +{% raw %}{% endraw %} +{% capture anim_simple_js -%} +const delay = ms => new Promise(r => setTimeout(r, ms)); + +myForm.onAll("afterRender", async (ev) => { + if (ev.context.parent?.options.type !== "list") return; + const item = ev.context.targetNode; + item.classList.add("anim-base"); + await delay(1); // Important: let the browser paint the initial state + item.classList.add("anim-visible"); +}); + +myForm.onAll("beforeUnrender", async (ev) => { + if (ev.context.parent?.options.type !== "list") return; + const item = ev.context.targetNode; + item.classList.remove("anim-visible"); + await delay(200); +}); +{%- endcapture %}{% raw %}{% endraw %} + +{% raw %}{% endraw %} +{% capture anim_simple_notes -%} +Add items to see them slide in from the left; remove items to see them slide back out. + +**Why the 1 ms delay in `afterRender`?** + +CSS transitions only fire when a property *changes* after the element is already in the document. If both `anim-base` and `anim-visible` were added in the same microtask, the browser would never observe the initial hidden state and the transition would not play. The `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. + +**Why add `anim-base` via JavaScript instead of in the HTML?** + +If the class were baked into the template, every item would start hidden even when JavaScript is unavailable. Adding it through `afterRender` ensures the animation only kicks in when JS is active, so the form **degrades gracefully** without it. + +**Why `await` in `beforeUnrender`?** + +SmarkForm awaits the return value of `beforeUnrender` handlers before detaching the element from the DOM. By resolving the promise after the CSS `transition-duration`, we keep the element visible just long enough for the exit animation to finish. +{%- endcapture %}{% raw %}{% endraw %} + +{% include components/sampletabs_tpl.md + formId="anim-simple" + htmlSource=anim_simple_html + cssSource=anim_simple_css + jsSource=anim_simple_js + notes=anim_simple_notes + selected="preview" + tests=false +%} + +## Advanced Example — Asymmetric Entry and Exit + +The two-class approach always plays the same transition in reverse for exit. +Sometimes you want **different** entry and exit effects — for example, sliding +in from the left but fading out on removal. + +This needs a third class for the exit state. The technique is the same: +`afterRender` handles entry, `beforeUnrender` handles exit. Both use a 1 ms +delay before applying the final state so the browser has time to register the +starting position. + +{% raw %}{% endraw %} +{% capture anim_adv_html -%} +
+ + +
    +
  • + + +
  • +
+
+{%- endcapture %}{% raw %}{% endraw %} + +{% raw %}{% endraw %} +{% capture anim_adv_css -%} +{{""}}#myForm$$ ul { list-style: none; padding: 0; } +{{""}}#myForm$$ li { + display: flex; align-items: center; gap: 0.4em; + margin: 0.2em 0; +} +{{""}}#myForm$$ li label { flex: 1; } +{{""}}#myForm$$ li.adv-in { + transform: translateX(-100%); + transition: transform 300ms ease-out; +} +{{""}}#myForm$$ li.adv-visible { + transform: translateX(0); +} +{{""}}#myForm$$ li.adv-out { + opacity: 0; + transition: opacity 250ms ease-in; +} +{%- endcapture %}{% raw %}{% endraw %} + +{% raw %}{% endraw %} +{% capture anim_adv_js -%} +const delay = ms => new Promise(r => setTimeout(r, ms)); + +myForm.onAll("afterRender", async (ev) => { + if (ev.context.parent?.options.type !== "list") return; + const item = ev.context.targetNode; + item.classList.add("adv-in"); + await delay(1); + item.classList.add("adv-visible"); +}); + +myForm.onAll("beforeUnrender", async (ev) => { + if (ev.context.parent?.options.type !== "list") return; + const item = ev.context.targetNode; + item.classList.add("adv-out"); + await delay(250); +}); +{%- endcapture %}{% raw %}{% endraw %} + +{% raw %}{% endraw %} +{% capture anim_adv_notes -%} +Entry slides in from the left (`translateX` only, 300 ms). Exit fades out (`opacity` only, 250 ms) — the two effects are independent. + +**Why separate effects instead of symmetric?** + +A symmetric effect (slide right for exit) puts the slowest part of the `ease-in` curve near the off-screen position, where the element is already nearly invisible. An `opacity`-only exit is simpler and looks cleaner. You can also compose both (`transform` + `opacity`) for a combined effect — the key is that the three-class approach lets you pick any combination. + +**Why the 1 ms delay in exit too?** + +When adding `adv-out`, the element already has `adv-visible` overriding it to `translateX(0)`. The `adv-out` class changes `opacity` from 1 to 0. Adding the class and `await` in the same tick would batch the style change — the 1 ms delay lets the browser register the new `opacity` before we start waiting for the animation to finish. +{%- endcapture %}{% raw %}{% endraw %} + +{% include components/sampletabs_tpl.md + formId="anim-adv" + htmlSource=anim_adv_html + cssSource=anim_adv_css + jsSource=anim_adv_js + notes=anim_adv_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 (or add an exit 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. + +> **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..c0a0734c 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: 5 --- @@ -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'); + + // Render error fires asynchronously — poll until it appears + await expect.poll(() => errors.length, { timeout: 5000 }).toBeGreaterThan(0); + 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 `