Skip to content

feat: forms & validation — ngModel, forms, inputs, validators (spec 039) - #44

Merged
Mgrdich merged 11 commits into
masterfrom
forms
Jul 6, 2026
Merged

Mgrdich merged 11 commits into
masterfrom
forms

Conversation

@Mgrdich

@Mgrdich Mgrdich commented Jul 1, 2026

Copy link
Copy Markdown
Owner

Summary

Delivers the complete Forms & Validation layer (Phase 3 roadmap item, spec 039) — the largest remaining templating gap. A developer can now bind any form control to their data with two-way binding, group controls into forms that aggregate validity, validate with built-in and custom (sync + async) rules, and style controls via the full AngularJS state-class surface. Ships as a new ./forms subpath; all directives are core ng (registered on ngModule). Ran through the AWOS triad and marks the spec Completed with every acceptance criterion checked. Full suite green: 211 files, 4223 passed / 26 skipped; lint / format:check / typecheck / build all pass. EXCEPTION_HANDLER_CAUSES stays at 13 (forms errors reuse '$compile').

Built in 7 vertical slices:

  • ngModel + NgModelController: the $viewValue/$modelValue pipeline ($parsers/$formatters/$render/$setViewValue), model write-back via buildParentWriter (non-assignable → '$compile'), the model→view feedback guard, and ngChange.
  • FormController + form/ngForm: per-key validity aggregation, dirty/pristine/submitted propagation, named-form/named-control publishing, nested-form bubbling, submit → $setSubmitted + ngSubmit (native submit suppressed by default when the form has no action), and a nullFormCtrl so form-less ngModel works.
  • Typed input matrix: one input directive dispatching on type into an internal handler registry (parity) — number/range→Number, checkbox→boolean (+ ng-true-value/ng-false-value), radio→group value, date/datetime-local/time/month/week→Date (timezone-aware), plus no-model hidden/button/submit/reset and textarea.
  • select + ngOptions + ngList: single + multiple(→array) select, the full ngOptions grammar (label/value/group-by/disable-when/track-by over array + object collections), and ngList string↔array.
  • Validators: the $validators/$asyncValidators engine (sync-before-async, tri-state $setValidity, $pending + ng-pending, generation-counter stale-async cancellation) + built-ins (required/ngRequired, ngMinlength/ngMaxlength, pattern/ngPattern, min/max, email/number/url).
  • ngModelOptions: updateOn, debounce ($timeout-backed, $$phase-guarded, cancel-on-destroy), allowInvalid, getterSetter, timezone.
  • State classes (full parity): append-only classList toggling of ng-valid/ng-invalid, ng-dirty/ng-pristine, ng-touched/ng-untouched, ng-empty/ng-not-empty, per-rule ng-valid-<key>/ng-invalid-<key>, ng-pending, and ng-submitted — never stripping author classes. $animate deferred to Phase 4; toggles are synchronous.

New importable subpath

./forms — exports the NgModelController / FormController / NgModelOptions / SelectController and validator contract types; emits ESM + CJS + .d.ts, wired through tsconfig, vitest, package.json exports, rollup, and the root barrel.

Notes for reviewers

  • Two cross-spec behavior changes (documented in CLAUDE.md):
    • A <form> with no action now suppresses native submit by default via the form directive — this supersedes the spec-026 "ng-submit does NOT auto-preventDefault" note. The ngSubmit directive itself still doesn't preventDefault; the form directive owns suppression + $setSubmitted and does not double-run the ngSubmit expression. The spec-026 test was updated accordingly (+ an action-gated case).
    • directive-collector.ts now tie-breaks same-priority directives on directive name before collection index (AngularJS byPriority parity), so ngModel links before select and select's require: '?ngModel' resolves.
  • ngModelOptions resolution walks the $$ngControllers stash rather than ^^?ngModelOptions require (the same-element ngModel controller seam runs before the options controller stashes) — the one structural departure from upstream, noted in CLAUDE.md.
  • Three parity bugs surfaced by tests were fixed during the build (missing safeString in ngOptions; bare ngOptions value binding; the directive-sort tie-break above).

Test plan

  • pnpm lint — clean
  • pnpm format:check — clean
  • pnpm typecheck — clean
  • pnpm test — 4223 passing / 26 skipped (211 files); forms 156/156
  • pnpm build — succeeds (dual ESM/CJS + .d.ts; ./forms emits all three)

Docs

  • src/forms/README.md — new: controllers, pipeline, state classes, input-type matrix, validators, ngModelOptions, worked example.
  • CLAUDE.md — ./forms module row, 8 non-obvious invariants, 12 "where to look" rows.
  • context/product/roadmap.md — Forms & Validation block marked shipped.

🤖 Generated with Claude Code

Mgrdich and others added 11 commits June 30, 2026 14:09
Add the AWOS spec triad for the Forms & Validation roadmap item:
functional spec, technical considerations, and the vertically-sliced
task list (7 runnable slices). No source changes yet.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…nput/textarea (spec 039 slice 1)

Scaffold the ./forms subpath (@Forms alias, package.json exports, rollup
entry, root barrel). Implement NgModelController (the $viewValue/$modelValue
pipeline, $parsers/$formatters/$render/$setViewValue, state tracking, per-key
$setValidity, $isEmpty, model->view feedback guard), the ngModel directive
(model write-back via buildParentWriter, $formatters watch), the single input
directive + text handler + textarea, ngChange, and append-only state-class
toggling. Registered on ngModule.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add FormController (child add/remove, per-key validity aggregation,
dirty/pristine/submitted propagation, $$renameControl) and the shared
form/ngForm directive factory (named-form scope publish, named-control
publish onto the form, submit -> $setSubmitted + ngSubmit with
preventDefault when no action, nested-form bubbling via ?^^form). Wire
ngModel to register with the enclosing form (nullFormCtrl fallback keeps
form-less ngModel working) and deregister on $destroy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… (spec 039 slice 3)

Add the inputType handlers: number/range (numeric parse, invalid keeps bad
values out of the model, range clamp), checkbox (boolean + ng-true-value/
ng-false-value), radio (group-shared model), and the date/datetime-local/
time/month/week family (Date model, per-type parse/format in input-date.ts,
timezone-parameterized for the Slice 6 handoff), plus no-model hidden/button/
submit/reset and baseline string handlers. Single input directive dispatches
on type.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Add the select directive + SelectController (single value, multiple->array,
unknown-option handling), ngOptions (grammar parse in ng-options-parse.ts;
label/value/group-by/disable-when/track-by over array + object collections,
regenerated on $watchCollection), and ngList ($parser/$formatter with
configurable/regex delimiter).

Fixes uncovered during Slice 4:
- ng-options.ts: add missing safeString helper (ngOptions generated zero
  options due to a ReferenceError swallowed by the digest).
- ng-options-parse.ts: bare `expr for item in items` binds the item, not the
  label expression, as the option value (AngularJS parity).
- directive-collector.ts: same-priority sort now tie-breaks on directive name
  before index (AngularJS byPriority parity), so ngModel links before select
  and select's `require: ?ngModel` resolves.
- ng-event-directives.test: a <form> now suppresses native submit by default
  via the form directive (forms spec 039 §2.3) when it has no action; updated
  the spec-026 expectations + added the action-gated case.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…slice 5)

Extend NgModelController with the validation engine: $validators/
$asyncValidators maps, $validate(), $$runValidators (extracted to
validation.ts) — sync-first, async only if all sync pass, tri-state
$setValidity (true/false/undefined-pending), $pending + ng-pending, and a
generation counter cancelling stale async passes. Invalid values are kept out
of the scope model by default (the $$allowInvalid seam defaults false for the
Slice 6 ngModelOptions handoff). Add built-in validator directives
(required/ngRequired, ngMinlength/ngMaxlength, pattern/ngPattern, min/max) in
validators.ts and the email/url/number/date type validators in
input-validators.ts. Model->view watch gains the ngModelWatch divergence guard.

EXCEPTION_HANDLER_CAUSES stays 13 (validator errors reuse '$compile').

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…mezone (spec 039 slice 6)

Add the ngModelOptions directive + resolved-options helper and thread it
through the pipeline: updateOn (configurable commit events + buffering via
$$updateEvents), debounce (number or per-event map, $timeout-backed,
supersede-reset, cancel on $destroy, $$phase-guarded), allowInvalid (flips the
Slice 5 $$allowInvalid seam), getterSetter (model expression as read/write fn),
and timezone (threaded into the date input parse/format). Options inherit from
an ancestor ngModelOptions via a $$ngControllers stash walk (parity note: a
structural departure from AngularJS's ^^?ngModelOptions require wiring — the
same-element ngModel controller seam runs before the options controller stashes).

118 forms tests; src/compiler green; EXCEPTION_HANDLER_CAUSES stays 13.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…p (spec 039 slice 7)

Add forms-types.test.ts (25 expectTypeOf contract tests) and parity.test.ts
(13 upstream ngModelSpec/formSpec edge-case vectors — all green against the
shipped runtime, no bugs found). Add src/forms/README.md. Update CLAUDE.md
(./forms module row, 8 invariants incl. the ng-submit/form suppression parity
change, the directive-collector byPriority name tie-break, ngModelOptions
stash-walk resolution, and EXCEPTION_HANDLER_CAUSES still 13; 12 where-to-look
rows). Tick the Forms & Validation roadmap block (spec 039 — shipped).

156 forms tests; src/compiler green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
All acceptance criteria verified against the 156 forms tests + full green
regression (4223 tests). Set functional-spec + technical-considerations Status
to Completed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Findings from a four-way adversarial audit of PR #44, all verified and
fixed with regression tests (19 new tests; full suite 4242 passing):

- transclude-fn: invoke cloneAttachFn BEFORE linking the clone
  (AngularJS publicLinkFn parity). Structural directives insert the
  clone inside that callback, so require '^'/'^^' and the
  $$ngControllers stash walks now resolve real DOM ancestors — an
  <input ng-model> inside ng-if previously never registered with its
  enclosing <form>.
- form: tri-state FormController.$setValidity — a control's in-flight
  async rule now populates the form's $pending[key] set + ng-pending
  (undefined was coerced to valid at the ngModel bubble site);
  $removeControl clears pending sets; $$renameControl re-slots the
  named control on the form instance (identity-guarded), and the
  ngModel $destroy unpublish is identity-guarded too.
- input: numeric min/max validate the PARSED modelValue (a '.' view
  value parses to NaN → empty → passes, upstream parity); radio
  watches its ng-value expression so a data-driven value re-renders,
  and commits under the 'change' debounce trigger; native
  minlength/maxlength attribute spellings now register (the factories
  already read them).
- ngOptions: track-by matching is by track-by key evaluated on the
  candidate model value (fresh non-identical objects select), the
  multiple-select hook path is actually reachable (writeMultiValue now
  routes through ngOptionsHooks), an author-supplied empty option
  survives regeneration (null model selects it / reads back null), and
  a mismatched model shows the synthetic unknown option ('?').
- select: interpolated option value="{{…}}" re-keys on change.
- ngModelOptions: a lone updateOn: '*' resets to the handler's default
  commit events instead of making the control uncommittable.
- ngModel: the model watch re-runs validators only inside the render
  branch (upstream ngModelWatch parity) so a live parse error is not
  cleared when the formatted value matches the on-screen text.
- ngList: joins with the RAW attribute value (upstream
  value.join(ngList)); default is ', '.
- validation: the $q.all rejection branch finalizes as failed instead
  of leaving the control permanently ng-pending.
- barrel: root index re-exports the full ./forms surface
  (SelectController(Impl), validators, model-options helpers, …).
- docs: CLAUDE.md invariants (tri-state form validity, cloneAttach
  ordering) + forms README (ngList join, native length names);
  ng-options-parse regex-group comment corrected.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ceOf util + shared test dom-guards (PR-44 audit)

The forms suite (and two forms src sites) narrowed querySelector /
firstElementChild / indexed-collection reads with bare `as HTML*Element`
casts, silencing both the null/undefined case and the wrong-element case.

- @core gains `asInstanceOf(value, ctor)` — the throwing companion to
  the boolean is* guards (compiler's node-guards.ts precedent), any-free
  via an `abstract new (...args: never[])` constructor signature.
- New shared test helper `@compiler/__tests__/dom-guards` (the
  test-helpers.ts precedent): asHtmlElement / asInput / asTextarea /
  asForm / asSelect / asOption wrappers.
- All eight forms test files now guard instead of cast; the src-side
  narrows in input.ts / input-types.ts / select.ts / ng-options.ts go
  through asInstanceOf, so a violated tag invariant fails loudly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Mgrdich
Mgrdich merged commit 41de18f into master Jul 6, 2026
2 checks passed
@Mgrdich
Mgrdich deleted the forms branch July 6, 2026 18:02
Mgrdich added a commit that referenced this pull request Jul 6, 2026
…row routing, doc consistency

Post-merge cleanup on top of spec 039 (PR #44):

- Extract the `$$phase`-guarded `$apply`/`$evalAsync` dispatch into a
  single shared `src/compiler/apply-phase-guarded.ts` helper. The
  spec-026 event directives, forms `applyDuringEvent` (input-types),
  `form` submit, and `select`'s `applyChange` now all route through it
  with their own cause token instead of duplicating the try/catch.
- Fix `select`: `applyChange` previously lacked the try/catch, so a
  throwing `$parser` during the native change commit escaped the
  listener without reaching `$exceptionHandler`. It now injects
  `$exceptionHandler` and routes via `'$compile'`. Adds a regression
  test in select.test.ts.
- De-duplicate `stashController` — remove the local copy in compile.ts
  and import the shared one from element-slots.ts.
- Rename runControllerSeam-widening.test.ts → kebab-case
  run-controller-seam-widening.test.ts (file-naming convention).
- Add inline justifications to the eslint-disable comments in
  core/utils.ts (isFunction, TypedArray copy cast, dynamic delete).
- Doc consistency: update the stale "EXCEPTION_HANDLER_CAUSES stays at
  10" comments across compiler/controller/bootstrap error files (the
  tuple is now 13 after spec 037) and refresh CLAUDE.md + roadmap.md.

typecheck + lint clean; 4243 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mgrdich added a commit that referenced this pull request Jul 6, 2026
…row routing, doc consistency (#45)

Post-merge cleanup on top of spec 039 (PR #44):

- Extract the `$$phase`-guarded `$apply`/`$evalAsync` dispatch into a
  single shared `src/compiler/apply-phase-guarded.ts` helper. The
  spec-026 event directives, forms `applyDuringEvent` (input-types),
  `form` submit, and `select`'s `applyChange` now all route through it
  with their own cause token instead of duplicating the try/catch.
- Fix `select`: `applyChange` previously lacked the try/catch, so a
  throwing `$parser` during the native change commit escaped the
  listener without reaching `$exceptionHandler`. It now injects
  `$exceptionHandler` and routes via `'$compile'`. Adds a regression
  test in select.test.ts.
- De-duplicate `stashController` — remove the local copy in compile.ts
  and import the shared one from element-slots.ts.
- Rename runControllerSeam-widening.test.ts → kebab-case
  run-controller-seam-widening.test.ts (file-naming convention).
- Add inline justifications to the eslint-disable comments in
  core/utils.ts (isFunction, TypedArray copy cast, dynamic delete).
- Doc consistency: update the stale "EXCEPTION_HANDLER_CAUSES stays at
  10" comments across compiler/controller/bootstrap error files (the
  tuple is now 13 after spec 037) and refresh CLAUDE.md + roadmap.md.

typecheck + lint clean; 4243 tests pass.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.

1 participant