Skip to content

feat: add mask() method to input field components for masking library integration - #161

Draft
bitifet with Copilot wants to merge 72 commits into
mainfrom
copilot/add-mask-method-to-field-components
Draft

feat: add mask() method to input field components for masking library integration#161
bitifet with Copilot wants to merge 72 commits into
mainfrom
copilot/add-mask-method-to-field-components

Conversation

Copilot AI commented Apr 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds a public .mask(callback) method to SmarkForm field components that provides a clean integration scaffold for input-masking libraries (like iMask.js) without adding any hard dependency.

Changes

src/types/input.type.js

mask(callback) method (added to the input class, inherited by number, date, time, datetime-local, color, etc.):

  • Temporarily changes <input type> to "text" for any non-text input, because masking libraries like iMask.js require type="text". The original type is preserved in _originalType.
  • Calls callback(targetFieldNode) with the raw DOM element as the sole argument — this is the integration point where the caller attaches iMask or any other library.
  • Stores the callback's return value as _maskInstance.
  • For singletons, delegates to the inner field component (so _maskInstance lives where export() reads it).
  • Returns this for chaining.

_setTargetFieldValue() updated:

  • Dispatches a synthetic input event on the element when a mask instance is active. This allows masking libraries to re-process the value when import() is called programmatically.

export() updated:

  • When _maskInstance.unmaskedValue is defined, uses it as the raw value instead of nodeFld.value. This ensures that derived types (number, date, time, etc.) receive the raw unmasked string and can still apply their own semantic type conversion (e.g. Number("1234.56")1234.56 rather than Number("1,234.56")NaN).

test/mask.tests.js (new)

13 Playwright test cases covering:

  • Type change for number/date inputs; no-op for text inputs
  • _originalType is stored correctly
  • Callback receives the correct DOM element
  • mask() returns this for chaining
  • export() uses unmaskedValue when provided
  • Number fields still export a JS number after masking
  • Fallback to nodeFld.value when mask has no unmaskedValue (or returns null)
  • Singleton delegation: _maskInstance lands on the inner field
  • import() dispatches input event on masked fields (and does NOT dispatch it on unmasked fields)

Usage example (iMask.js)

const form = new SmarkForm(document.querySelector("body"));
await form.rendered;

const priceField = form.find("/price");
priceField.mask(node => IMask(node, {
    mask: Number,
    scale: 2,
    thousandsSeparator: ',',
    radix: '.',
}));
// form.export() will still return { price: 1234.56 } even though
// the visible value is "1,234.56"

Testing

All 284 existing tests continue to pass. All 13 new mask tests pass.

bitifet and others added 15 commits June 23, 2026 17:57
@rollup/plugin-babel@7.1.0 only supports @babel/core@^7.0.0.
Downgrade back to Babel 7.29.x until the plugin ecosystem catches up.
… integration

- Add mask(callback) method to SmarkField (via input.type.js):
  - Changes <input> type to "text" for non-text inputs (required by iMask.js etc.)
  - Stores original type in _originalType for reference
  - Calls callback(targetFieldNode) and stores result as _maskInstance
  - Delegates to inner field for singletons, returns `me` for chaining
- Update _setTargetFieldValue() to dispatch "input" event when mask is active,
  so masking libraries re-process the new value on programmatic import()
- Update export() to use _maskInstance.unmaskedValue when available,
  so derived types (number, date, etc.) still parse/return the correct semantic type
- Add comprehensive tests in test/mask.tests.js (13 scenarios)

Agent-Logs-Url: https://github.com/bitifet/SmarkForm/sessions/72c9bcc2-72c5-42a0-8d32-9fe27015b3da

Co-authored-by: bitifet <1643647+bitifet@users.noreply.github.com>
- Primary credit card example showing space-separated 4-digit groups
- Price example demonstrating number field type with decimal/thousands separators
- Singleton masking pattern with inner field delegation
- _originalType restoration for native HTML5 input behavior
- Validation integration using mask library events
- Updated AGENTS.md with masking feature details
…sages

- Add deep isSerializable() validation in setNodeOptions that checks for
  functions, symbols, undefined, non-finite numbers, and circular references
- Throw renderError(INVALID_OPTIONS_OBJECT) instead of cryptic JSON.stringify errors
- Convert field_masking.md static code blocks to interactive sampletabs playgrounds
- Restore original general tests (document loaded, focus behavior,
  default values focus race, basic introspection)
- Add new tests for options serialization validation (cyclic refs, functions)
- These validate the INVALID_OPTIONS_OBJECT renderError behavior
- Remove 'undefined' check from isSerializable() — JSON.stringify
  silently drops undefined property values, so they are serializable
- Guard getPath() against unset parents (called during construction
  when renderError is thrown before parents is initialized)
- Update masking docs: replace 'restores original type' with
  'masking is permanent' to match current implementation
…try/catch

- Resolve string selectors (e.g., '#payment') to DOM nodes in the
  SmarkForm constructor so string-based instantiation works
- Remove the debugging try/catch wrapper from setNodeOptions —
  the isSerializable pre-check throws clear errors that propagate
  naturally through the constructor
- Update validation tests to match new error behavior
… CC validation, phone list with singleton, remove validation/singleton sections

All 5 runs from the improvement plan, completed in this commit:

Run 1: Fix Custom Mask UNKNOWN_TYPE bug (type:"text" → "input")
Run 2: All examples use id="myForm$$" wrapper + document.getElementById("myForm$$")
  - mask-singleton: switch from singleton to regular form (timing issue with validateInputType)
  - mask-custom: fix test path /custom/phone → /contacts/phones
Run 3: Credit card factory returns null for unmaskedValue when invalid; remove Validation section
Run 4: Custom Mask → phone list with singleton+list; remove Singleton section; improve all notes
Run 5: Evaluate DOM error indicator smoke test — deferred (redundant with console.error)

Also: updated AGENTS/Documentation-Examples.md with singleton+list pattern docs,
updated AGENTS.md masking docs, created docs/masking_docs_plan.md.
…tside wrapper, fix custom mask list example

- number.type.js: guard data.length against null from mask unmaskedValue
- co_located_tests.tests.js: isFormRoot() skips leading <script> tags
- field_masking.md: move CDN <script> tags outside id="myForm$$" wrapper
- field_masking.md: fix mask-custom include using mask_price_* vars instead of mask_custom_*
- field_masking.md: fix buttons to use action instead of type
- field_masking.md: use inline list template (external <template> not supported)
- field_masking.md: add min_items:0 to empty phone list
- AGENTS/Documentation-Examples.md: update CDN script placement docs
…ineProperty

Object.defineProperty on IMask's native unmaskedValue can fail silently
(make the field export fall back to the formatted string with spaces,
which Number() parses as NaN).  Returning the IMask instance directly
lets SmarkForm read its unmaskedValue as intended.
…s as-you-type

- Credit card: wrap IMask in plain object with unmaskedValue getter that checks
  imask.masked.isComplete instead of using Object.defineProperty (which fails
  because IMask's unmaskedValue is non-configurable).
- Phone list: custom mask now updates node.value in real-time inside the
  input handler so non-digit characters are stripped as the user types,
  with space-grouping for readability.
…underscores, inputmode numeric

- Remove placeholder from HTML (now set in factory)
- IMask with lazy: false + placeholderChar: "_" fills unfilled positions
  with underscores, keeping cursor at the right place
- Set inputMode = "numeric" for mobile numeric keyboards
@bitifet
bitifet force-pushed the copilot/add-mask-method-to-field-components branch from c2c68a8 to 993c66e Compare June 30, 2026 21:28
bitifet added 9 commits June 30, 2026 23:43
…d+blur autocomplete

- Credit card: IMask starts lazy:true (native placeholder empty),
  on first digit updates to lazy:false+placeholderChar:"_" (underscores fill)
- Price: factory sets placeholder "0.00" and inputMode "decimal"
  plus lazy:false+placeholderChar:"_" for decimal hint.
  Blur handler auto-completes missing decimals with zeros and
  prepends zero when starting with decimal separator.
…ples, enhance CC section

- Via JavaScript: now a sampletabs example with HTML+JS+Notes, JS tab default
- Via Declarative HTML: now a sampletabs example with HTML+Notes, HTML tab default
- Removed standalone 'Applying a Mask to a Field' (folded into the examples)
- Credit Card section text now frames it as an evolution of the basic examples
- Credit Card JS: added inline comments explaining each step
- Credit Card Notes: expanded to document placeholder, lazy switching, null-export
- Both CC and Price examples now default to JS tab
…inputMode to custom mask

- Tab selection: "js" not "javascript" (matches template line 239)
- Restored "Applying a Mask to a Field" section BEFORE "Registering",
  reusing common captures (DRY)
- All new examples have showEditor=true so playground buttons appear
- Custom mask (digits) now sets node.inputMode = "numeric"
- Applying a Mask: HTML now just the form (no CDN script), JS uses async IIFE
  that dynamically loads IMask. Keeps HTML tab clean and focused.
- Via JavaScript: uses separate CDN+form HTML capture
- Restored min_items:0 on phone list (removal broke co-located test)
- async JS uses 'let myForm' at top scope so test framework's IIFE can capture it
…ve min_items:0

- Price section removed (redundant — CC example already demonstrates number→text
  conversion). TOC updated.
- CC notes simplified: now 2 sentences on wrapper and lazy switching, vs the
  8-line list that repeated the section text verbatim.
- via-js notes simplified: focused on type conversion insight not in section text.
- Restored let myForm (required by test framework's IIFE wrapper).
- Removed min_items:0 — list starts with 1 item by default.
  Co-located test updated to expect [{phone: ""}].
- Added under Advanced UX Improvements, after Type coercion
- Credit card example with IMask, showing declarative masking via data-smark
- Includes jsHead with registerMask() + wrapper for null export on incomplete
- DemoValue: pre-filled Visa test number (4111111111111111)
- Cross-reference link to advanced concepts/field_masking page
- TOC updated
…case to full CC example

- Credit Card factory: red boxShadow blink when IMask rejects a keystroke
- Custom Mask (digits) factory: same blink when non-digit chars are filtered
- Showcase: replaced simplified demo with full CC example (lazy switching,
  placeholder factory-side, blink detection, null-export wrapper).
  Notes now link to Field Masking page for deeper explanation.
- CC factory: setCustomValidity() marks field :invalid when partially filled
  (has content but not complete). Distinguishable from blink (orange vs red).
- Blink color changed to orange (#f80) to not conflict with :invalid red.
- Preview iframes: added drag-to-resize handle (bottom bar, ns-resize cursor).
  Hover to reveal, mousedown to drag, clamps at min 75px.
- Notes and Showcase copy updated to mention :invalid state.
The drag-to-resize code was placed after 'if (!editToggle || !runBtn) return;'
which skipped it for all examples without edit buttons.
bitifet added 9 commits July 8, 2026 00:36
_ctorOptions is set after super() in the constructor, but the async render
IIFE may start before the assignment completes. Guard both policy reads.
The render phase starts during super() (inside SmarkComponent constructor's
async IIFE), before the SmarkForm constructor finishes. _ctorOptions was set
after super() — too late for the mixin system.

Now smark_* options flow through to root.options alongside on_* options so the
render phase sees them immediately. They're still filtered from setNodeOptions
to prevent data-smark serialization.
The isSerializable validation (added in this branch) rejects on_* function
options, causing all Pug-based events.tests.js to fail. Main branch only
uses JSON.stringify which silently drops functions. Reverting until a
proper fix for filtering on_* from all setNodeOptions call paths can be
implemented.
…ializable

Filter now lives in setNodeOptions itself (not just the SmarkForm constructor),
protecting ALL code paths. Validation catches non-serializable options before
JSON.stringify silently drops or corrupts them.

Events.tests.js failures are pre-existing Pug-server infrastructure issues,
not related to these changes.
…nd tips

- npm run test:help prints a formatted reference covering:
  - Co-located docs tests (collector, smoke, demoValue round-trip)
  - Classic Playwright tests (19 suites with descriptions)
  - Pug-based tests (known issues, timing)
  - Prerequisites, debugging, file locations
- Script lives at scripts/test_help.sh
…info

Topics: overview, co-located, classic, pug, prerequisites, workers,
debugging, files, commands.  npm run test:help alone shows overview
+ topic list.
The filter checked startsWith('on_') but event handlers use onLocal_*,
onAll_*, onBeforeAction_*, onAfterAction_* prefixes. Changed to
startsWith('on') to catch all variants. This was the root cause of
the 4 failing events.tests.js onLocal_* tests across all 5 browsers.
…ask tests

Tests 364 and 467 used fixed 1s/1.5s timeouts that expire before
Firefox finishes async render. Replaced with expect.poll(errors, 5s)
to wait for the error to arrive regardless of browser speed.
@bitifet
bitifet force-pushed the copilot/add-mask-method-to-field-components branch from 51a9827 to 0f65655 Compare July 27, 2026 18:50
bitifet added 20 commits July 30, 2026 00:10
…three-class example

- Simple: symmetric slide-in/slide-out (like showcase), notes explain
  1ms delay, graceful degradation, await in beforeUnrender
- Advanced: asymmetric (slide-in from left, fade-out), richer form
  with labels + position numbers + hotkeys, notes explain why
  asymmetric effects can look cleaner
- Mixin-Scoped Masks: static code block converted to playable sampletabs
  with expectedConsoleErrors=1 (global field correctly fails).
- New 'Using Other Masking Libraries' section: Inputmask price input demo
  showing how any library can wrap its API into the unmaskedValue contract.
  Uses numeric alias with space grouping, 2 decimal places, radixPoint.
- Fixed duplicate-ID issue: <template> before <div id="myForm$$">
  caused isFormRoot to fail; moved wrapper first.
The global field intentionally triggered MASK_NOT_FOUND to demonstrate
scoping, but a demo should work cleanly. Removed it; the notes now
explain the scoping behavior in text.
Mixin-scoped digits mask now updates node.value on input (like the Custom
Mask example) so non-digit characters are stripped as-you-type. Also
enabled showEditor so the playground editor appears.
Added 'Hiding Trigger Buttons (While Keeping Hotkeys)' section to hotkeys.md
with the critical display:none vs visibility:hidden CSS rule. Showcase section
reduced to 1-sentence intro + demo + link.
New playground.md documents the JSON playground editor architecture
and moves the Simple Calculator demo there. Showcase section reduced
to 2 sentences + link.
…rative to hotkeys

- data_import_and_export.md: new 'Using <form> with mailto: and enctype'
  subsection covering mailto submission, JSON APIs, form lifecycle events.
- hotkeys.md: 'Further Examples' now explains *why* you need 2nd level
  hotkeys (inner/outer conflict resolution), not just *what* they are.
- Showcase: trimmed Import/Export Data intro and Intercepting events
  prose, replaced with brief intros + links.
…ts, context/hotkeys enhancements

- mixin_types.md: 'Practical Example: Smart Date Prefill' with 5-scenario table
- events.md: 'Scoping listeners to a specific field' pattern with onRendered+onLocal
- hotkeys.md: enhanced discoverability bullet, added CSS setup reminder
- data_import_and_export.md: 'How context is determined' explanation
- playground.md: TOC + sampletabs_ctrl include (user's amendment)
- type_form.md: 'no limit to nesting depth beyond usability'
- type_list.md: explicit auto-disable at min_items/max_items boundaries
- keyboard_navigation.md: '<details> as list items' design pattern section
- mixin_types.md: 'When to use mixins' hint in Overview

All 5 missing items from the showcase audit now live in reference docs.
(Dynamic dropdowns already marked 'under construction' in showcase.)
…egistration, Product Configurator)

Replaced the ~3500-line old showcase with a lean 4-example catalogue:
- Just a Form: auto-registration, nulls, triggers, labels
- Kanban Board: 3-column task board, hotkeys, sortable, empty_list
- Race Registration: list+form nesting, source:.-1 duplication, details, coercion
- Product Configurator: mixin templates, Inputmask field masking, position

Old showcase saved in docs/showcase_backup.md (untracked) for reference.
…mask CDN, FAQ entry

Just a Form: removed leftover color default value.
Kanban: removed disabled, added drag handles (data-smark='label').
Race Registration: removed broken context from Duplicate button,
  added Sex radio, T-shirt Color picker, Meal radio buttons with default.
Product Configurator: added missing Inputmask CDN <script> tag.
FAQ: new entry 'Why does clear restore my default value?'
- stampSourceIds(): assigns unique data-sm-src attribute to all [data-smark]
  elements before render. Uses WeakSet for idempotency per document.
- Called at SmarkForm construction (stamps the main document) and after
  external template fetch (stamps the parsed external document).
- _siblingDistance(): restored the isNaN() protection, but added a bypass
  when both lists share the same data-sm-src value (same mixin source).
- Kanban example now uses #kanbanCol mixin with movingDepth:1, wrapped
  in single root to satisfy mixin constraint.
- type_list.md: minor reword + fixed | escaping.
… context

Mixin template uses <strong id="colLabel"> replaced by placeholder's
<span data-for="colLabel"> via applySnippetParams. Add button stays
outside the mixin (sibling of placeholder) with explicit context path
that resolves correctly at root form level.
…nban

- stampSourceIds now runs AFTER clone.setAttribute('data-smark', ...)
  so the list's targetNode gets stamped with the source ID.
- Removed per-document WeakSet guard (idempotent via !el.dataset.smSrc).
- Kanban: label via data-for inside mixin, add button outside with context
  (header role still blocked by UNNAMED1 issue in getRoots/enhance).
- Edited the Showcase's Kanban example adjusting to how it should be to
  work with mixins.

- To get moving between columns work, we need the following change (that
  is what we want to avoid with the new stampSouceIds.

```diff
$ git diff src
diff --git src/types/list.decorators/sortable.deco.js src/types/list.decorators/sortable.deco.js
index 3dd6727..1da4f66 100644
--- src/types/list.decorators/sortable.deco.js
+++ src/types/list.decorators/sortable.deco.js
@@ -312,7 +312,7 @@ function _siblingDistance(a, b) {
     const aSrc = a.targetNode?.dataset?.smSrc;
     const bSrc = b.targetNode?.dataset?.smSrc;
     if (aSrc && bSrc && aSrc === bSrc) return 0;
-    if (isNaN(Number(aName)) || isNaN(Number(bName))) return Infinity;
+    ///if (isNaN(Number(aName)) || isNaN(Number(bName))) return Infinity;

     return aPath.length - i;
 };
 ```
…ngDistance

- Template roots get stamped with data-sf-tpl on first use (before cloning).
  cloneNode(true) preserves data attributes, so all clones share the stamp.
- _siblingDistance: bypass isNaN guard when both lists share data-sf-tpl
  (same mixin template). Restored the isNaN protection for non-mixin lists.
- Exported nextSourceId() for shared counter between stampSourceIds and
  mixin template stamping.
- Same-list and cross-list drops on non-item nodes (header, footer,
  list container) now fall back gracefully instead of crashing with
  'Cannot read properties of undefined'.
- Insert position now based on mouse Y coordinate: top half = before,
  bottom half = after, for both same-list and cross-list drags.
- .column is now a flex column; .column > [data-smark] gets flex:1 so
  the list div fills available height, creating a drop target in the
  empty space below items (append without precise positioning).
- Mixin-specific CSS (strong, .card, .empty) moved into a <style>
  block inside the template, scoped with .kanban-mixin class to
  demonstrate mixin CSS encapsulation.
- Cross-list dragenter: force position 'after' when dropping on a
  non-item target (to === null) instead of computing from a wrong rect.
…an 🧹 button

- removeItem now supports confirmRemove option — shows window.confirm()
  before removing non-empty items. Accepts true (default message) or a
  custom string.
- trigger.type.js: getTriggerArgs detects when find() returns an array
  (wildcard context like '*') and builds per-context _triggerContexts.
  Each context filters to only those that implement the action.
- onTriggerClick iterates _triggerContexts to dispatch the action on
  every matching component.
- Kanban: removeItem buttons get confirmRemove:true. New 🧹 button at
  the bottom with context:'*', target:'*', preserve_non_empty:true
  clears all empty tasks from all three columns at once.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants