Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
f921589
Revert Babel 8 upgrade — incompatible with @rollup/plugin-babel
bitifet Jun 23, 2026
71841c5
Sync docs/_data/package.json with root
bitifet Jun 23, 2026
7221a25
feat: add mask() method to input field components for masking library…
Copilot Apr 17, 2026
9d20d9e
docs: add field masking documentation with iMask.js examples
bitifet Apr 27, 2026
3f7637d
refactor: make input masking permanent, update docs and tests accordi…
bitifet Apr 27, 2026
b47e9cb
feat: add options serialization validation with descriptive error mes…
bitifet May 21, 2026
6a2cf48
test: merge general tests with options validation tests
bitifet May 21, 2026
0c11450
fix: prevent options serialization validation crash during construction
bitifet May 21, 2026
58bf845
fix: resolve string selectors in SmarkForm constructor; remove debug …
bitifet May 21, 2026
6da72e6
docs: fix masking examples — pin imask@6.6.3 CDN, fix form->myForm, f…
bitifet May 21, 2026
7d2e029
docs: rewrite field masking docs — fix UNKNOWN_TYPE, wrapper pattern,…
bitifet Jun 27, 2026
9710503
fix: review fixes — guard number.type null export, move CDN script ou…
bitifet Jun 27, 2026
cc03cbe
fix: simplify credit card mask — return IMask directly instead of def…
bitifet Jun 30, 2026
8ee19fc
fix: CC mask wrapper for null-export on incomplete, phone mask filter…
bitifet Jun 30, 2026
993c66e
feat: enhance CC example — placeholder factory-side, lazy:false with …
bitifet Jun 30, 2026
c816e49
feat: CC lazy switch on first char, price placeholder+decimal keyboar…
bitifet Jun 30, 2026
bf61d98
docs: convert Registering/Applying mask sections into sampletabs exam…
bitifet Jul 1, 2026
85e8711
fix: selected="js" (not "javascript"), restore Applying section, add …
bitifet Jul 1, 2026
bebf0eb
fix: async IIFE for 'Applying' example (clean HTML), restore min_items:0
bitifet Jul 1, 2026
e1b301c
chore: remove Price section, simplify Notes, restore let myForm, remo…
bitifet Jul 2, 2026
9711a32
feat: add Field Masking demo to showcase page
bitifet Jul 2, 2026
8079752
feat: add invalid-input blink feedback to masked fields, upgrade show…
bitifet Jul 2, 2026
651b34c
feat: invalid-field styling + resizable preview handle
bitifet Jul 2, 2026
63874d6
fix: move resize handle before edit-mode guard (was unreachable)
bitifet Jul 2, 2026
8a4934c
fix: Pointer Events for reliable drag stop, add :invalid CSS to baseCss
bitifet Jul 2, 2026
290b492
fix: move :invalid CSS to cssSource, use softer amber color
bitifet Jul 2, 2026
6b49574
fix: suppress blink on Backspace/Delete hitting mask separators
bitifet Jul 3, 2026
8835a92
docs: migrate showcase documentation to dedicated advanced_concepts p…
bitifet Jul 3, 2026
c3d4a66
fix: restore simple_list_hotkeys_with_context include, remove leftove…
bitifet Jul 3, 2026
2294e2a
docs: add documentation links to Three-Level Nesting intro, add CSS/J…
bitifet Jul 3, 2026
f71283e
docs: rename Three-Level → Multiple-Level Nesting, add Tab-flow + Alt…
bitifet Jul 4, 2026
5d8113c
refactor: split Advanced Concepts into Working with Forms + Advanced …
bitifet Jul 4, 2026
91ec140
feat: add playable sampletabs demos to hotkeys.md
bitifet Jul 4, 2026
e9c594a
fix: move captures before include, port coercion demos to value_coerc…
bitifet Jul 6, 2026
63121fa
chore: move coercion demos out of showcase into value_coercion.md
bitifet Jul 6, 2026
86dfcae
feat: smark_* naming convention, registerCustomAction, restructure co…
bitifet Jul 6, 2026
fa353d0
fix: don't extract on_* from pass-through, filter on_*/smark_* from s…
bitifet Jul 7, 2026
cf24d24
docs: on_* is pass-through not constructor-only — fix constructor page
bitifet Jul 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
68 changes: 68 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<script type="smark-mask">` elements. Masks are applied to fields using the `mask` property in `data-smark`. The mask instance is stored in `_maskInstance` and provides `unmaskedValue` for clean exports.

**Key API**:
- `SmarkForm.registerMask(name, factory)`: Registers a mask factory globally before form construction
- `<script type="smark-mask" data-name="...">`: Declarative mask registration — scanned by `_scanGlobalMasks()` in the constructor
- `data-smark mask` property: e.g., `<input data-smark='{"name":"card","mask":"digits"}'>`
- `export()`: Returns `_maskInstance.unmaskedValue` when available, else `nodeFld.value`
- `import()`: Dispatches `input` event when `_maskInstance` exists, so masks stay synchronized
- `smark_mask_throwOnMissing: false`: Suppresses error for unregistered or broken masks, falls back to unmasked input
- Singleton handling: delegates to inner field, `_maskInstance` lives on inner field
- Mixin-scoped masks: `<script type="smark-mask">` inside `<template>` is scoped, not global; requires `allowLocalMixinScripts: 'allow'`
- On error (mask not found or factory throws), original input type is restored; if `smark_mask_throwOnMissing` is `true` an error is thrown, otherwise a `console.warn` is emitted
- `MASK_APPLY_ERROR`: thrown when a mask factory throws; the original exception is available via `error.cause`

**Configuration file locations**:
- Tests: `test/declarative_mask.tests.js`, `test/mask.tests.js`
- Implementation: `src/main.js` (registry), `src/lib/mixin.js` (scoped mask filtering), `src/lib/component.js` (propagation), `src/types/input.type.js` (`_applyMask`, export/import integration)

### Playwright Test Runner

**What it does**: Runs end-to-end tests against the built distribution files and validates documentation examples.
Expand Down Expand Up @@ -389,6 +409,54 @@ When you discover a new pattern, fix a bug caused by an undocumented constraint,
3. **Correct inaccurate entries** whenever you verify that something documented is wrong.
4. You do not need a special PR description sentence to update `AGENTS/` files — just do it as part of your normal work.

## Documentation Guidelines

When writing or editing documentation pages in `docs/`, follow these conventions
to maintain consistency across the site:

### Page Structure

- **Front matter**: Always include `title`, `layout: chapter`, `permalink`, and
`nav_order`. The `title` should be a quoted string for multi-word titles
(e.g. `title: "Mixin Types"`).
- **`{% include links.md %}`**: Place immediately after the front matter, before
the chapter heading.
- **`{% include components/sampletabs_ctrl.md %}`**: Include after `links.md`
on pages that contain playable (sampletabs) examples.
- **Chapter heading**: Use `# {{ page.title }}` to render the title from front
matter. Do **not** hardcode the heading text.
- **Table of Contents**: Use a `<details class="chaptertoc">` block with a
`<!-- vim-markdown-toc GitLab -->` markup list inside a `{{ "..." |
markdownify }}` block. The TOC must list every second-level (`##`) heading
and its nested third-level (`###`) headings.

### Playable Examples (Sampletabs)

- Prefer sampletabs (`{% include components/sampletabs_tpl.md %}`) over static
code blocks wherever possible — they let readers edit and run examples live.
- Each example needs three captures: `html`, `js`, and `notes`.
- Use `{% raw %}<!-- mask_foo_html {{{ -->{% endraw %}` / `{% raw %}<!-- }}} -->{% endraw %}` markers around each capture to delimit them in the source.
- Set `showEditor=true` and `tests=false` for documentation examples (tests are
defined in standalone test files under `test/`).
- Provide a meaningful `notes` capture with a "Try it!" call to action.

### Style & Vocabulary

- Use the active voice and present tense.
- Keep paragraphs short and focused. One idea per paragraph.
- Use descriptive link text (not "click here").
- Sidebar nav hierarchy follows `nav_order`.
- Cross-reference related chapters with `>` callout blocks and `See also:`
links.

### Liquid Variables

- Use `{{ page.title }}` for the chapter heading.
- Use `{{ "..." | relative_url }}` for relative links to other pages.
- Use `{{ "..." | markdownify }}` for the TOC block.
- Always include `{% include links.md %}` on every page to make link references
available.

## Agent Skills Directory (`skills/`)

The `skills/` directory contains installable skill specifications that can be
Expand Down
65 changes: 65 additions & 0 deletions AGENTS/Documentation-Examples.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,34 @@ Example usage in a `.md` doc file:
%}
```

### Required: `id="myForm$$"` Root Wrapper

**Every sampletabs example MUST use `id="myForm$$"` as the root wrapper element ID.** The template strips `$$` at render time (both in Liquid and JavaScript), so the runtime ID becomes `id="myForm"`.

The auto-generated `jsHead` calls `new SmarkForm(document.getElementById("myForm$$"))`. If the example provides a custom `jsHead`, it must use `document.getElementById("myForm$$")` — never a literal `#id` selector — as the SmarkForm constructor argument.

**Correct pattern (playable example inside a capture):**
```html
<div id="myForm$$">
<div data-smark='{"type":"form","name":"myFormName"}'>
<input data-smark='{"name":"fieldName","mask":"someMask"}'>
</div>
</div>
```
```javascript
SmarkForm.registerMask("someMask", (node) => { ... });
const myForm = new SmarkForm(document.getElementById("myForm$$"));
```

**Do NOT use:**
- Custom IDs like `id="payment"`, `id="product"` as the root — these break the playground editor's "demo" subform wrapping mechanism.
- `new SmarkForm("#customId")` — always use `document.getElementById("myForm$$")`.
- `data-smark` on the `id="myForm$$"` wrapper itself — the wrapper should be a plain DOM container. The `data-smark` belongs on the inner form element(s).

**CDN `<script>` tags** for external libraries (e.g. IMask) should be placed **before** the `id="myForm$$"` wrapper, not inside it. The test runner's `isFormRoot()` function now skips leading `<script>` tags when detecting the root wrapper, so `htmlSource` starting with `<script>` followed by `<div id="myForm$$">` is correctly recognised as having a root — no extra wrapper is added.

**Why this matters:** When `showEditor=true`, the playground wraps the example in a "demo" subform. The `smarkformBuildEditorHtml()` function in the controller extracts the wrapper (found by `id="myForm$$"`) and inserts the demo subform inside it. A custom ID breaks this extraction, causing incorrect export nesting (e.g. `{cardNumber: null}` instead of `{payment: {cardNumber: null}}`).

## Include Parameters

| Parameter | Default | Description |
Expand Down Expand Up @@ -319,3 +347,40 @@ button[data-smark*='"action":"reset"'] {
display: none !important;
}
```

## Critical Constraint: Preserve the SmarkForm-Based Playground Editor

**DO NOT** replace the playground editor's SmarkForm components (`demo` subform, `data-smark` buttons, `data-smark` editor textarea) with plain HTML elements + JS helpers. The SmarkForm-based editor is a deliberate design that demonstrates SmarkForm's power — the editor itself is a SmarkForm form.

The `demo` subform isolates the example's fields from the editor textarea:
- Export/Import buttons operate on `context:"demo"` (target the demo subform)
- The editor is a sibling `<textarea data-smark='{"name":"editor","type":"input"}'>`
- Reset targets the root form (no context), restoring demo values AND clearing editor

**Lesson learned**: Before making deep changes to the playground editor architecture, always ask the user. The old implementation is intentionally built this way.

### The `find()` Path Problem

Because the example's HTML is nested under a `demo` subform, example JS code using absolute paths like `find("/cardNumber")` will fail — the actual path is `/demo/cardNumber`. This is a known trade-off. Solutions should preserve the SmarkForm-based editor approach.

## Singleton + List Pattern in Co-located Examples

The `field_masking.md` Custom Mask example demonstrates a phone list with singleton-wrapped fields inside a list template:

```html
<template id="phoneTmpl">
<p style="display:flex;gap:8px;margin:4px 0;align-items:center">
<span data-smark='{"type":"input","name":"phone","mask":"digits"}'>
<input data-smark type="tel" placeholder="Phone number">
</span>
<button data-smark='{"type":"removebutton"}' title="Remove">✕</button>
</p>
</template>
```

Key details:
- **Singleton wrapper**: `<span data-smark='{"type":"input"…}'>` is not an INPUT tag, so SmarkForm treats it as a singleton — a form containing exactly one field. The inner `<input data-smark>` inherits options (including `mask`) from the singleton.
- **Mask on wrapper**: The `mask` property is set on the singleton wrapper, not on the inner `<input>`. It is inherited automatically.
- **`type:"input"` is safe for singletons**: Unlike `type:"number"`, the `input` type has no `validateInputType()` call, so there is no timing issue with mask application (the mask schedules `_applyMask()` via `onRendered`, which fires after render completes).
- **List with singleton items**: The `<template>` creates list items where each phone is a singleton field. The list's add/remove buttons work normally alongside masking.
- **Test with empty list**: The co-located test checks the list starts empty (`readField('/contacts/phones')` returns `[]`). Tests that interact with list items must use paths like `/contacts/phones/0/phone`.
18 changes: 18 additions & 0 deletions AGENTS/SmarkForm-Usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,24 @@ await component.import(data, {setDefault: false});
<button data-smark='{"action":"import"}'>Load Data</button>
```

## `find()` Prefer Relative Paths

Always use **relative paths** (no leading `/`) in `find()` calls. Relative paths resolve from the nearest named ancestor, making code more portable when forms are restructured.

An absolute path like `find("/cardNumber")` searches from the root form's **direct children** only. If the form structure changes (e.g., nested under a wrapper subform), the absolute path breaks.

A relative multi-segment path like `find("demo/cardNumber")` navigates through child components step by step: `children["demo"]` → `children["cardNumber"]`. This is both more portable and equally explicit.

```javascript
// ❌ Absolute path — breaks if structure changes
const field = myForm.find("/cardNumber");

// ✅ Relative path — portable, explicit
const field = myForm.find("demo/cardNumber");
```

> **Note**: `find()` does **not** search recursively. Each path segment is a direct child lookup via `children[name]`. Single-segment relative paths like `find("cardNumber")` only find direct children of the nearest named ancestor, not descendants at arbitrary depth.

## `find()` Timing — Must Await `rendered`

The `find()` method looks up components in an internal map that is built **asynchronously during `render()`**. Before rendering is complete, **all `find()` calls return `null`** (or `undefined`).
Expand Down
190 changes: 190 additions & 0 deletions PROMPTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,3 +107,193 @@ Unlike most actions that usually enhance buttons, the "null" action will enhance

If the checkbox is checked (default), every field in the form gets disabled.


### Declarative Masking API

> Status: Draft

Implement a declarative masking system that lets users apply external masking
libraries to fields without writing `find()` + `.mask()` boilerplate after
`await rendered`.

#### Motivation

The current `mask(callback)` method works but requires:
- `await myForm.rendered`
- `myForm.find("path/to/field")`
- calling `.mask(callback)` on the result

This fails for list items added after initial render, because their mask is
never applied. A declarative approach ensures masks are applied at render time,
whether initial render or when a list item is cloned.

#### Proposed API — Two Approaches

**Approach 1 — `SmarkForm.registerMask()` (JavaScript API)**

```javascript
// Register before creating any SmarkForm instance
SmarkForm.registerMask("creditCard", (node) => {
return new IMask(node, { mask: "0000 0000 0000 0000" });
});

SmarkForm.registerMask("price", (node) => {
return new IMask(node, {
mask: Number,
scale: 2,
thousandsSeparator: " "
});
});
```

Then reference by name in `data-smark`:

```html
<input data-smark='{"name":"cardNumber","mask":"creditCard"}'>
```

**Approach 2 — `<script type="smark-mask">` (Declarative, HTML-centric)**

```html
<script type="smark-mask" data-name="creditCard">
(node) => new IMask(node, { mask: "0000 0000 0000 0000" });
</script>
```

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 `<script type="smark-mask">` in its `<template>`
element.

**Mixin scope**: Masks defined inside a mixin template are scoped to that
mixin's expansion — they don't pollute the global registry. When the mixin is
loaded, its masks are registered; when the mixin template is expanded for a
specific field, the mixin's masks take precedence over global ones. This lets
different mixins define a mask named "price" without conflict.

Implementation sketch: the mixin system already has its own options merging
pipeline (`getNodeOptions` in `component.js`). Mixin-scoped masks would be
stored alongside the mixin template and passed through the same merge logic,
so mixin-local masks override global masks during expansion but don't affect
other mixins or the global registry.

#### How Registration Works

Both approaches feed into the same internal registry:

```javascript
// Internal (component.js or a static registry)
SmarkForm._maskRegistry = {};

SmarkForm.registerMask = function(name, fn) {
SmarkForm._maskRegistry[name] = fn;
};
```

The `<script type="smark-mask">` scanner can be implemented inline in the
constructor or as a utility:

```javascript
for (const el of document.querySelectorAll('script[type="smark-mask"]')) {
const name = el.getAttribute("data-name");
const fn = new Function("return " + el.textContent)();
SmarkForm.registerMask(name, fn);
}
```

#### Integration with `render()` — Timing

When a component is rendered (including list items being cloned), the mask
is applied at the **end of `render()`**, after `targetFieldNode` has been
assigned. The `data-smark` options (including `mask`) are already available
in `me.options` from the constructor — no timing conflict.

```javascript
// At the end of input.type.js render(), after targetFieldNode is set
if (me.options.mask && typeof me.options.mask === "string") {
const factory = SmarkForm._maskRegistry[me.options.mask];
if (factory) {
const input = me.targetFieldNode;
if (input) {
const type = input.getAttribute("type");
if (type && type !== "text" && type !== "textarea") {
input.setAttribute("type", "text");
}
me._maskInstance = factory(input);
}
} else if (SmarkForm._maskConfig.throwOnMissing) {
throw new Error(`Mask "${me.options.mask}" not found in registry`);
} else {
console.warn(`Mask "${me.options.mask}" not found in registry`);
}
}
```

The constructor flow: `me.options = options` (has `mask` from `data-smark`).
Then `render()` runs → assigns `targetFieldNode` → applies mask.

#### The `.mask()` Method Is Removed

Since this is a feature branch (not yet public API), the old `.mask()` method
is **removed**. All masking is done declaratively via `data-smark` or the
registry API. This avoids user confusion — the old method didn't work for list
items added after render, and keeping it would create two inconsistent ways to
mask a field.

Only the declarative approach (`{"mask":"name"}` in `data-smark`) and the
`registerMask()` API remain.

For edge cases that need custom programmatic logic, users can register a named
mask via `SmarkForm.registerMask()` and reference it in `data-smark`.

#### `export()` / `import()` Behavior

The existing integration with `export()` (returning `_maskInstance.unmaskedValue`
when available) and `import()` (dispatching `input` event) remains unchanged.

#### List Item Masking — Automatic

When a list clones a new item, the item's `data-smark` is processed as part of
`render()`. If any field in the cloned item has `{"mask":"creditCard"}`, the
mask function is looked up from the registry and applied — no manual
intervention needed.

This is the key advantage over the current `.mask()` approach.

#### Singleton Handling

Same as current: singleton components delegate mask to their inner field. The
`mask` property on a singleton is treated identically — the inner field's
render applies the mask.

#### HTML Input Type

When a mask is applied, the input's `type` is changed to `"text"` (if not
already). Original type is not restored — masking is permanent.

#### Error Handling

Configurable via `SmarkForm.maskConfig` (or similar global option):

```javascript
SmarkForm.maskConfig = {
throwOnMissing: true // default: throw; set to false to only warn
};
```

When `throwOnMissing` is `true` (default), referencing an unregistered mask
name throws an error — catching it during development. When `false`, a
console warning is emitted and the field is left unmasked.

#### Implementation Steps (Draft)

1. Add `SmarkForm._maskRegistry = {}` and `SmarkForm.maskConfig` in `main.js`
2. Add `SmarkForm.registerMask(name, fn)` static method
3. In the constructor or init phase, scan for `<script type="smark-mask">` tags and register them
4. In `input.type.js` at the end of `render()`, check `me.options.mask` and apply
5. Modify `export()` (already checks `_maskInstance`) and `import()` (dispatch `input` event) — reuse existing logic
6. Mixin system: when expanding a mixin template, its local `<script type="smark-mask">` definitions are merged into the expansion's mask scope, taking precedence over the global registry during that mixin's lifetime
7. Remove the old `.mask()` method from `input.type.js`
2 changes: 1 addition & 1 deletion dist/SmarkForm.esm.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/SmarkForm.esm.js.map

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/SmarkForm.umd.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion dist/SmarkForm.umd.js.map

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions docs/_about/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,7 @@ component explicitly with the `"context"` property and a relative path:

See [Quick Start — Actions and Triggers](
{{ "/getting_started/quick_start" | relative_url }}#actions-and-triggers)
and [Form Traversing]({{ "/advanced_concepts/form_traversing" | relative_url }})
and [Form Traversing]({{ "/working_with_forms/form_traversing" | relative_url }})
for full details.


Expand Down Expand Up @@ -950,7 +950,7 @@ can remove a phone when focus is inside the phones list, and remove a whole
user when focus is at the user level. SmarkForm picks the right trigger
automatically based on where the keyboard focus is.

See [Hotkeys]({{ "/advanced_concepts/hotkeys" | relative_url }}) for full details and examples.
See [Hotkeys]({{ "/working_with_forms/hotkeys" | relative_url }}) for full details and examples.

### What if I want to reach an outer action with the same hotkey?

Expand Down
Loading