From 116c325251938f1e7fa5ae537c533b232c13312e Mon Sep 17 00:00:00 2001 From: Mgrdich Date: Tue, 30 Jun 2026 14:09:38 -0400 Subject: [PATCH 01/11] docs: forms & validation spec triad (spec 039) 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) --- .../functional-spec.md | 135 ++++++++++++++++ .../spec/039-forms-and-validation/tasks.md | 61 +++++++ .../technical-considerations.md | 152 ++++++++++++++++++ 3 files changed, 348 insertions(+) create mode 100644 context/spec/039-forms-and-validation/functional-spec.md create mode 100644 context/spec/039-forms-and-validation/tasks.md create mode 100644 context/spec/039-forms-and-validation/technical-considerations.md diff --git a/context/spec/039-forms-and-validation/functional-spec.md b/context/spec/039-forms-and-validation/functional-spec.md new file mode 100644 index 0000000..ec2a6a8 --- /dev/null +++ b/context/spec/039-forms-and-validation/functional-spec.md @@ -0,0 +1,135 @@ +# Functional Specification: Forms & Validation + +- **Roadmap Item:** Forms & Validation — `ngModel` two-way binding, form-element directives, `ngModel` helpers, Form & control state tracking, built-in validators, custom validators. +- **Status:** Draft +- **Author:** Mgrdich + +--- + +## 1. Overview and Rationale (The "Why") + +Today a developer using this framework can render data into the page (interpolation, `ng-bind`), react to events (`ng-click` and friends), and structure the DOM (`ng-if`, `ng-repeat`). What they **cannot** do is capture input from the user and feed it back into their data — there is no two-way data binding, no form controls, and no validation. This is the single largest remaining gap before the framework can power a real interactive application (the TodoMVC / form-validation demos on the roadmap depend on it). + +This change delivers the complete forms layer: a developer can bind any form control to a piece of their data so that typing in a field updates the data and changing the data updates the field; group controls into forms that report their own validity; validate input with both built-in rules and their own custom rules (synchronous and asynchronous); and style controls based on their state (touched, dirty, valid, pending, …) using the same state-class conventions AngularJS established. + +**Success is measured by:** a developer can build a non-trivial validated form — text, numeric, date, checkbox, radio, and dropdown controls bound to their data, with required/length/pattern/format rules plus a custom async rule — and the form behaves identically to the same markup running on AngularJS 1.x (same model values, same validity, same state classes, same timing). + +--- + +## 2. Functional Requirements (The "What") + +### 2.1 Two-way binding with `ng-model` + +- **As a** developer, **I want to** bind a form control to a property of my data with `ng-model="…"`, **so that** user input and my data stay in sync automatically. + - **Acceptance Criteria:** + - [ ] Given ``, when the user types "Ada", then the bound property becomes "Ada" without any extra wiring. + - [ ] Given the bound property is changed in code, when the next update cycle runs, then the control on screen shows the new value. + - [ ] The binding target may be a nested path (`a.b.c`); intermediate objects are created as needed when the user first types. + - [ ] When `ng-model` points at an expression that cannot be assigned to (e.g. a literal or a function call), the developer sees a clear error explaining the field is not assignable. + +- **The value pipeline:** each control exposes a controller (the "model controller") with two ordered transformation lists — one that runs when the user's input travels **toward** the data, and one that runs when the data travels **toward** the screen — plus a render step and a programmatic "the user changed the value" entry point. + - **Acceptance Criteria:** + - [ ] A developer can register a transformation that converts the on-screen text into the stored value (e.g. trim whitespace) and see it applied on every keystroke commit. + - [ ] A developer can register a transformation that converts the stored value into what is shown (e.g. format a number) and see it applied whenever the data changes. + - [ ] The on-screen value and the stored value are exposed separately so a developer can read either. + - [ ] Calling the "value changed" entry point with a new on-screen value runs the toward-data transformations, runs validation, and (when valid, or when configured to allow invalid) writes the result to the data. + +### 2.2 Control state tracking & state CSS classes + +- **As a** developer, **I want** each control and form to track whether it has been visited, changed, and whether it is valid, **so that** I can show validation feedback at the right moment. + - **Acceptance Criteria:** + - [ ] A fresh control reports itself as untouched, pristine, and (absent failing rules) valid; the form reflects the same. + - [ ] After the user focuses then leaves a control, it reports itself touched. + - [ ] After the user changes a control's value, it reports itself dirty (and no longer pristine). + - [ ] A developer can reset a control/form back to pristine programmatically. + - [ ] **State classes (full parity), toggled synchronously:** the control element carries `ng-valid`/`ng-invalid`, `ng-dirty`/`ng-pristine`, `ng-touched`/`ng-untouched`, `ng-empty`/`ng-not-empty`, and `ng-pending` while async validation is running. + - [ ] **Per-rule classes:** for each named rule, the element carries `ng-valid-` or `ng-invalid-` (e.g. `ng-invalid-required`, `ng-valid-maxlength`); rule names with separators are dasherized consistently with AngularJS. + - [ ] A form element additionally carries `ng-submitted` once a submit has been attempted. + - [ ] Classes only ever added by the framework are removed when their condition flips; classes the developer put on the element themselves are never removed. + +### 2.3 Forms and the form controller + +- **As a** developer, **I want** to group controls inside a `
` (or `ng-form` for nested groups) that aggregates their state, **so that** I can enable/disable a submit button on overall validity. + - **Acceptance Criteria:** + - [ ] A `` automatically becomes a form group without extra attributes; `ng-form` provides the same for nesting a group inside another form. + - [ ] A form is invalid if any control inside it is invalid, and valid only when all are valid; the form is dirty if any control is dirty. + - [ ] A named form (``) and named controls (``) are reachable by name so a developer can read their state in expressions (e.g. show a message when `myForm.email` is invalid and touched). + - [ ] Nested forms contribute their validity up to the parent form; removing a control or sub-form from the page removes its contribution. + - [ ] Submitting the form marks it submitted and runs any `ng-submit` handler; by default the browser's native submit/navigation is suppressed. + - [ ] A developer can programmatically mark the form submitted, reset it to pristine, and reset which controls are considered "submitted". + +### 2.4 Form-element directives & input types + +- **As a** developer, **I want** every standard form control to work with `ng-model`, **so that** I can bind text, numbers, dates, choices, and free text. + - **Acceptance Criteria (typed model values — full parity):** + - [ ] `', $compile, $rootScope); + const node = el as unknown as HTMLTextAreaElement; + + ($rootScope as unknown as { notes: string }).notes = 'hi'; + $rootScope.$digest(); + expect(node.value).toBe('hi'); + + node.value = 'edited'; + node.dispatchEvent(new Event('input')); + expect(($rootScope as unknown as { notes: string }).notes).toBe('edited'); + }); +}); + +function readController(el: HTMLElement): NgModelControllerImpl { + // The controller is stashed on $$ngControllers under 'ngModel'. + const map = (el as unknown as { $$ngControllers?: Map }).$$ngControllers; + const ctrl = map?.get('ngModel'); + if (!(ctrl instanceof NgModelControllerImpl)) { + throw new Error('ngModel controller not found on element'); + } + return ctrl; +} diff --git a/src/forms/forms-register.ts b/src/forms/forms-register.ts new file mode 100644 index 0000000..9f9fcac --- /dev/null +++ b/src/forms/forms-register.ts @@ -0,0 +1,36 @@ +/** + * Forms directive registration (spec 039 Slice 1 / + * technical-considerations §2.8). + * + * Like every other built-in directive batch, the forms directives are + * core `ng` directives — registered on `ngModule` via a + * `.config(['$compileProvider', …])` block (DI-only). An app reaching + * `'ng'` in its deps chain gets `ngModel` / `input` / `textarea` / + * `ngChange` for free; the directive factories themselves stay file-local + * (not exported from `@compiler/index` or the root barrel), matching the + * `ngTransclude` / event-directive precedent. + * + * This module exposes a single {@link registerForms} helper that takes the + * `$compileProvider` and registers the four directives. `ngModule` + * (`src/core/ng-module.ts`) imports + invokes it from its config block. + */ + +import type { $CompileProvider } from '@compiler/compile-provider'; + +import { inputDirective, textareaDirective } from './input'; +import { ngChangeDirective, ngModelDirective, NG_CHANGE_NAME, NG_MODEL_NAME } from './ng-model'; + +/** + * Register the Slice-1 forms directives on a `$compileProvider`. + * + * - `ngModel` — two-way binding + the published {@link NgModelController}. + * - `input` — single directive dispatching on `type` (default → text). + * - `textarea` — delegates to the `text` handler. + * - `ngChange` — fires on committed view change. + */ +export function registerForms($compileProvider: $CompileProvider): void { + $compileProvider.directive(NG_MODEL_NAME, ngModelDirective); + $compileProvider.directive('input', inputDirective); + $compileProvider.directive('textarea', textareaDirective); + $compileProvider.directive(NG_CHANGE_NAME, ngChangeDirective); +} diff --git a/src/forms/index.ts b/src/forms/index.ts new file mode 100644 index 0000000..cd66ce3 --- /dev/null +++ b/src/forms/index.ts @@ -0,0 +1,17 @@ +/** + * Public barrel for the `@forms` module — forms & validation (spec 039). + * + * The directives themselves are DI-only core `ng` directives registered + * on `ngModule` via `forms-register.ts` (the `ngTransclude` / event- + * directive precedent), so the public surface here is the CONTRACT TYPES + * consumers type against — `NgModelController` and its transform-list + * types — plus the `registerForms` wiring helper that `ngModule` invokes. + * + * Slice 1 exposes the `NgModelController` contract; later slices add + * `FormController`, `NgModelOptions`, validator maps, etc. + */ + +export type { NgModelController, ModelParser, ModelFormatter } from './ng-model-controller'; +export { NgModelControllerImpl } from './ng-model-controller'; +export { NgModelNonAssignableError } from './ng-model'; +export { registerForms } from './forms-register'; diff --git a/src/forms/input-types.ts b/src/forms/input-types.ts new file mode 100644 index 0000000..37ba305 --- /dev/null +++ b/src/forms/input-types.ts @@ -0,0 +1,137 @@ +/** + * Input-type handler registry (spec 039 Slice 1 / FS §2.4, + * technical-considerations §2.4). + * + * One `input` directive dispatches on `attrs.type` into the handlers + * registered here (AngularJS parity — NOT one directive per type). + * Slice 1 ships the baseline `text` handler; Slice 3 fills in + * number/range/checkbox/radio/date/etc., and Slice 5 wires the + * `email`/`number`/`url` type validators through these handlers. + * + * A handler receives the linked `scope`, the control `element`, the + * shared `attrs`, and the published {@link NgModelController}. It is + * responsible for: + * + * - installing `$render` (writes `$viewValue` to the DOM control), and + * - registering the native DOM event listeners that call + * `$setViewValue` on user input. + * + * **`$$phase`-guarded dispatch.** A native event firing while a digest is + * already in flight (e.g. inside another `$apply`) must NOT call + * `scope.$apply` (which throws `'$digest already in progress'`). The + * shared {@link applyDuringEvent} helper dispatches through + * `scope.$evalAsync` in that case and `scope.$apply` otherwise — the + * established event-directive pattern. The runner is wrapped in a + * `try/catch` that routes through `$exceptionHandler` with cause + * `'$compile'` because the project's `$apply` is `try/finally`-only. + */ + +import type { Scope } from '@core/index'; + +import type { Attributes } from '@compiler/directive-types'; +import { invokeExceptionHandler, type ExceptionHandler } from '@exception-handler/index'; + +import type { NgModelControllerImpl } from './ng-model-controller'; + +/** + * Context handed to every input-type handler. + */ +export interface InputTypeContext { + scope: Scope; + element: HTMLInputElement | HTMLTextAreaElement; + attrs: Attributes; + ctrl: NgModelControllerImpl; + exceptionHandler: ExceptionHandler; +} + +/** + * An input-type handler installs `$render` + the native event listeners + * for one (group of) `type` value(s). + */ +export type InputTypeHandler = (ctx: InputTypeContext) => void; + +/** + * Dispatch a runner through the `$$phase`-guarded `$apply` / `$evalAsync` + * seam, routing any throw via `$exceptionHandler('$compile')`. Mirrors the + * spec-026 event-directive workaround (the project's `$apply` is + * `try/finally`, not `try/catch`). + */ +export function applyDuringEvent(scope: Scope, exceptionHandler: ExceptionHandler, run: () => void): void { + try { + if (scope.$$phase !== null) { + scope.$evalAsync(run); + } else { + scope.$apply(run); + } + } catch (err) { + invokeExceptionHandler(exceptionHandler, err, '$compile'); + } +} + +/** + * Coerce a view value to the string a text control should display. + * `undefined` / `null` render as `''` (never the literal words); a value + * that is already a string passes through; numbers / booleans use their + * primitive string form. Any other shape (an object, etc.) is treated as + * empty rather than producing `'[object Object]'` — a text control should + * not be fed a non-primitive model, and showing the brace-noise would be + * worse than blank. + */ +function stringifyView(value: unknown): string { + if (value === undefined || value === null) { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') { + return String(value); + } + return ''; +} + +/** + * Baseline string handler used by `text` / `search` / `tel` / `password` + * / `email` / `url` inputs and by `textarea`. Renders the model's + * formatted string into `element.value` and commits user input on the + * native `input` and `change` events. + * + * The `$render` writer coerces `undefined` / `null` to `''` so the control + * never shows the literal words; the listener reads `element.value` + * verbatim (parsers — e.g. trim, type validators — run inside + * `$setViewValue`). + */ +export const textInputType: InputTypeHandler = ({ scope, element, ctrl, exceptionHandler }) => { + ctrl.$render = () => { + element.value = stringifyView(ctrl.$viewValue); + }; + + const listener = () => { + applyDuringEvent(scope, exceptionHandler, () => { + ctrl.$setViewValue(element.value); + }); + }; + + element.addEventListener('input', listener); + element.addEventListener('change', listener); + + scope.$on('$destroy', () => { + element.removeEventListener('input', listener); + element.removeEventListener('change', listener); + }); +}; + +/** + * The Slice-1 type registry. Every recognized `type` maps to the + * baseline string handler today; later slices register the typed + * handlers. Unknown / absent types fall back to `text` (AngularJS + * parity) at the dispatch site in `input.ts`. + */ +export const inputTypeHandlers: Record = { + text: textInputType, + search: textInputType, + tel: textInputType, + url: textInputType, + email: textInputType, + password: textInputType, +}; diff --git a/src/forms/input.ts b/src/forms/input.ts new file mode 100644 index 0000000..ee5e867 --- /dev/null +++ b/src/forms/input.ts @@ -0,0 +1,91 @@ +/** + * `input` + `textarea` directives (spec 039 Slice 1 / FS §2.4, + * technical-considerations §2.4). + * + * A SINGLE `input` directive (`restrict: 'E'`, `require: '?ngModel'`) + * matches every `` and dispatches on `attrs.type` into the + * internal {@link inputTypeHandlers} registry (AngularJS parity — not one + * directive per type). When the element carries no `ng-model` the link is + * a no-op (the optional `?ngModel` require yields `null`), so a plain + * `` without a model is untouched. + * + * `textarea` is a thin directive delegating to the `text` handler — a + * `', $compile, $rootScope); - const node = el as unknown as HTMLTextAreaElement; + const node = asTextarea(el); ($rootScope as unknown as { notes: string }).notes = 'hi'; $rootScope.$digest(); @@ -269,7 +266,7 @@ function readController(el: HTMLElement): NgModelControllerImpl { describe('model watch — parse-error preservation (PR-audit regression)', () => { it('does not clear $error.parse when the model is set to the same rejected text', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = readController(el); // A parser that rejects any value containing digits. diff --git a/src/forms/__tests__/ng-options.test.ts b/src/forms/__tests__/ng-options.test.ts index ead26ac..204a315 100644 --- a/src/forms/__tests__/ng-options.test.ts +++ b/src/forms/__tests__/ng-options.test.ts @@ -11,6 +11,7 @@ import { afterEach, describe, expect, it } from 'vitest'; +import { asOption, asSelect } from '@compiler/__tests__/dom-guards'; import type { CompileService } from '@compiler/directive-types'; import type { Scope } from '@core/index'; import { bootstrapInjector } from '@bootstrap/index'; @@ -37,7 +38,7 @@ afterEach(() => { function compile(html: string, $compile: CompileService, scope: Scope): HTMLSelectElement { const el = document.createElement('div'); el.innerHTML = html; - const child = el.firstElementChild as HTMLSelectElement; + const child = asSelect(el.firstElementChild); $compile(child)(scope); scope.$digest(); return child; @@ -94,7 +95,7 @@ describe('ngOptions over an array — label + value (FS §2.5)', () => { ); // Index 0 is the unknown option (model unset) — Alan is at index 2. - (select.options[2] as HTMLOptionElement).selected = true; + asOption(select.options[2]).selected = true; fireChange(select); expect(model($rootScope, 'chosen')).toBe(alan); }); @@ -113,7 +114,7 @@ describe('ngOptions over an array — label + value (FS §2.5)', () => { expect(optionLabels(select)).toEqual(['', 'Ada', 'Alan']); // Index 0 is the unknown option (model unset) — Ada is at index 1. - (select.options[1] as HTMLOptionElement).selected = true; + asOption(select.options[1]).selected = true; fireChange(select); expect(model($rootScope, 'chosen')).toBe(1); }); @@ -154,7 +155,10 @@ describe('ngOptions group by — optgroups (FS §2.5)', () => { const groups = Array.from(select.querySelectorAll('optgroup')); expect(groups.map((g) => g.label)).toEqual(['Analysis', 'Computing']); - const computing = groups[1] as HTMLOptGroupElement; + const computing = groups[1]; + if (computing === undefined) { + throw new Error('expected a second '); + } expect(Array.from(computing.querySelectorAll('option')).map((o) => o.textContent)).toEqual(['Alan', 'Grace']); }); }); @@ -201,7 +205,7 @@ describe('ngOptions track by — stable identity (FS §2.5)', () => { ); // Index 0 is the unknown option (model unset) — Ada is at index 1. - (select.options[1] as HTMLOptionElement).selected = true; + asOption(select.options[1]).selected = true; fireChange(select); const chosen = model($rootScope, 'chosen') as { id: number }; expect(chosen.id).toBe(1); @@ -236,7 +240,7 @@ describe('ngOptions over an object — (key, value) iteration (FS §2.5)', () => ); // Index 0 is the unknown option (model unset) — Green is at index 2. - (select.options[2] as HTMLOptionElement).selected = true; + asOption(select.options[2]).selected = true; fireChange(select); expect(model($rootScope, 'chosen')).toBe('Green'); }); diff --git a/src/forms/__tests__/parity.test.ts b/src/forms/__tests__/parity.test.ts index a06f169..bad2b24 100644 --- a/src/forms/__tests__/parity.test.ts +++ b/src/forms/__tests__/parity.test.ts @@ -21,6 +21,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { asHtmlElement, asInput } from '@compiler/__tests__/dom-guards'; import type { CompileService } from '@compiler/directive-types'; import type { Scope } from '@core/index'; import { bootstrapInjector } from '@bootstrap/index'; @@ -48,16 +49,12 @@ afterEach(() => { function compile(html: string, $compile: CompileService, scope: Scope): HTMLElement { const el = document.createElement('div'); el.innerHTML = html; - const child = el.firstElementChild as HTMLElement; + const child = asHtmlElement(el.firstElementChild); $compile(child)(scope); scope.$digest(); return child; } -function input(el: HTMLElement): HTMLInputElement { - return el as HTMLInputElement; -} - function fireInput(el: HTMLInputElement, value: string): void { el.value = value; el.dispatchEvent(new Event('input')); @@ -100,7 +97,7 @@ describe('$setDirty / $setPristine propagation (parity)', () => { $rootScope, ); const outerCtrl = formOf(outer); - const el = input(outer.querySelector('input') as HTMLElement); + const el = asInput(outer.querySelector('input')); // Fresh: both forms pristine. expect(outerCtrl.$pristine).toBe(true); @@ -123,19 +120,21 @@ describe('$setDirty / $setPristine propagation (parity)', () => { $rootScope, ); const outerCtrl = formOf(outer); - const [elA, elB] = Array.from(outer.querySelectorAll('input')).map((n) => input(n as HTMLElement)); + const inputs = outer.querySelectorAll('input'); + const elA = asInput(inputs[0]); + const elB = asInput(inputs[1]); - fireInput(elA as HTMLInputElement, 'x'); - fireInput(elB as HTMLInputElement, 'y'); + fireInput(elA, 'x'); + fireInput(elB, 'y'); expect(outerCtrl.$dirty).toBe(true); - expect(ctrlOf(elA as HTMLElement).$dirty).toBe(true); - expect(ctrlOf(elB as HTMLElement).$dirty).toBe(true); + expect(ctrlOf(elA).$dirty).toBe(true); + expect(ctrlOf(elB).$dirty).toBe(true); // Reset the outer form → every control + the nested form returns pristine. outerCtrl.$setPristine(); expect(outerCtrl.$pristine).toBe(true); - expect(ctrlOf(elA as HTMLElement).$pristine).toBe(true); - expect(ctrlOf(elB as HTMLElement).$pristine).toBe(true); + expect(ctrlOf(elA).$pristine).toBe(true); + expect(ctrlOf(elB).$pristine).toBe(true); const innerCtrl = scopeVal($rootScope, 'inner') as FormControllerImpl; expect(innerCtrl.$pristine).toBe(true); }); @@ -161,7 +160,7 @@ describe('$setDirty / $setPristine propagation (parity)', () => { describe('$rollbackViewValue (parity)', () => { it('reverts an uncommitted view value to the last committed one and re-renders', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); // Commit an initial value on blur. @@ -188,7 +187,7 @@ describe('$rollbackViewValue (parity)', () => { describe('$parsers / $formatters ordering (parity)', () => { it('$parsers run in REGISTRATION order (view → model)', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); const order: string[] = []; @@ -209,7 +208,7 @@ describe('$parsers / $formatters ordering (parity)', () => { it('$formatters run in REVERSE registration order (model → view)', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); const order: string[] = []; @@ -232,7 +231,7 @@ describe('$parsers / $formatters ordering (parity)', () => { it('a $parser returning undefined short-circuits the chain and fails the parse key', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); let secondRan = false; @@ -259,7 +258,7 @@ describe('$parsers / $formatters ordering (parity)', () => { describe('$isEmpty override (parity)', () => { it('a custom $isEmpty drives ng-empty and required', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); // Override: only the literal string "empty" counts as empty. @@ -286,7 +285,7 @@ describe('$$renameControl (parity)', () => { const { $compile, $rootScope } = boot(); const form = compile('', $compile, $rootScope); const formCtrl = formOf(form); - const ctrl = ctrlOf(input(form.querySelector('input') as HTMLElement)); + const ctrl = ctrlOf(asInput(form.querySelector('input'))); expect(ctrl.$name).toBe('a'); formCtrl.$$renameControl(ctrl, 'renamed'); @@ -303,7 +302,7 @@ describe('$$renameControl (parity)', () => { const { $compile, $rootScope } = boot(); const form = compile('
', $compile, $rootScope); const formCtrl = formOf(form); - const ctrl = ctrlOf(input(form.querySelector('input') as HTMLElement)); + const ctrl = ctrlOf(asInput(form.querySelector('input'))); // Simulate a newer control having taken over the `a` slot. const newer = { $name: 'a' }; @@ -323,7 +322,7 @@ describe('$$renameControl (parity)', () => { describe('$commitViewValue / view-change listeners (parity)', () => { it('re-committing the same view value does not re-fire $viewChangeListeners', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); const spy = vi.fn(); ctrl.$viewChangeListeners.push(spy); @@ -339,7 +338,7 @@ describe('$commitViewValue / view-change listeners (parity)', () => { it('a programmatic model change re-renders WITHOUT firing view-change listeners', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); const spy = vi.fn(); ctrl.$viewChangeListeners.push(spy); @@ -360,7 +359,7 @@ describe('$commitViewValue / view-change listeners (parity)', () => { describe('programmatic control state (parity)', () => { it('$setTouched / $setUntouched toggle the touched classes', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); expect(ctrl.$untouched).toBe(true); @@ -378,7 +377,7 @@ describe('programmatic control state (parity)', () => { it('$setDirty / $setPristine toggle the dirty classes on the control', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); expect(ctrl.$pristine).toBe(true); diff --git a/src/forms/__tests__/select.test.ts b/src/forms/__tests__/select.test.ts index 8bb5c27..d4b96ce 100644 --- a/src/forms/__tests__/select.test.ts +++ b/src/forms/__tests__/select.test.ts @@ -11,6 +11,7 @@ import { afterEach, describe, expect, it } from 'vitest'; +import { asOption, asSelect } from '@compiler/__tests__/dom-guards'; import type { CompileService } from '@compiler/directive-types'; import type { Scope } from '@core/index'; import { bootstrapInjector } from '@bootstrap/index'; @@ -37,7 +38,7 @@ afterEach(() => { function compile(html: string, $compile: CompileService, scope: Scope): HTMLSelectElement { const el = document.createElement('div'); el.innerHTML = html; - const child = el.firstElementChild as HTMLSelectElement; + const child = asSelect(el.firstElementChild); $compile(child)(scope); scope.$digest(); return child; @@ -146,8 +147,8 @@ describe('select[multiple] — binds an array of chosen values (FS §2.4)', () = const { $compile, $rootScope } = boot(); const select = compile(html, $compile, $rootScope); - (select.options[0] as HTMLOptionElement).selected = true; - (select.options[2] as HTMLOptionElement).selected = true; + asOption(select.options[0]).selected = true; + asOption(select.options[2]).selected = true; fireChange(select); expect(model($rootScope, 'colors')).toEqual(['red', 'blue']); @@ -160,20 +161,20 @@ describe('select[multiple] — binds an array of chosen values (FS §2.4)', () = setModel($rootScope, 'colors', ['green', 'blue']); $rootScope.$digest(); - expect((select.options[0] as HTMLOptionElement).selected).toBe(false); - expect((select.options[1] as HTMLOptionElement).selected).toBe(true); - expect((select.options[2] as HTMLOptionElement).selected).toBe(true); + expect(asOption(select.options[0]).selected).toBe(false); + expect(asOption(select.options[1]).selected).toBe(true); + expect(asOption(select.options[2]).selected).toBe(true); }); it('deselecting all options writes an empty array', () => { const { $compile, $rootScope } = boot(); const select = compile(html, $compile, $rootScope); - (select.options[0] as HTMLOptionElement).selected = true; + asOption(select.options[0]).selected = true; fireChange(select); expect(model($rootScope, 'colors')).toEqual(['red']); - (select.options[0] as HTMLOptionElement).selected = false; + asOption(select.options[0]).selected = false; fireChange(select); expect(model($rootScope, 'colors')).toEqual([]); }); @@ -183,11 +184,11 @@ describe('select[multiple] — binds an array of chosen values (FS §2.4)', () = const select = compile(html, $compile, $rootScope); // Fresh multiple select with no selection → ng-empty. - (select.options[0] as HTMLOptionElement).selected = true; + asOption(select.options[0]).selected = true; fireChange(select); expect(select.classList.contains('ng-not-empty')).toBe(true); - (select.options[0] as HTMLOptionElement).selected = false; + asOption(select.options[0]).selected = false; fireChange(select); expect(select.classList.contains('ng-empty')).toBe(true); }); diff --git a/src/forms/__tests__/validators.test.ts b/src/forms/__tests__/validators.test.ts index d57863c..f09255f 100644 --- a/src/forms/__tests__/validators.test.ts +++ b/src/forms/__tests__/validators.test.ts @@ -13,6 +13,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import type { QDeferred, QService } from '@async/q-types'; +import { asHtmlElement, asInput } from '@compiler/__tests__/dom-guards'; import type { CompileService } from '@compiler/directive-types'; import type { Scope } from '@core/index'; import { bootstrapInjector } from '@bootstrap/index'; @@ -41,16 +42,12 @@ afterEach(() => { function compile(html: string, $compile: CompileService, scope: Scope): HTMLElement { const el = document.createElement('div'); el.innerHTML = html; - const child = el.firstElementChild as HTMLElement; + const child = asHtmlElement(el.firstElementChild); $compile(child)(scope); scope.$digest(); return child; } -function input(el: HTMLElement): HTMLInputElement { - return el as HTMLInputElement; -} - function fireInput(el: HTMLInputElement, value: string): void { el.value = value; el.dispatchEvent(new Event('input')); @@ -78,7 +75,7 @@ describe('required (FS §2.6)', () => { it('flips control + form validity and toggles ng-invalid-required', () => { const { $compile, $rootScope } = boot(); const form = compile('
', $compile, $rootScope); - const el = input(form.querySelector('input') as HTMLElement); + const el = asInput(form.querySelector('input')); // Fresh empty control fails `required`. const ctrl = ctrlOf(el); @@ -102,7 +99,7 @@ describe('required (FS §2.6)', () => { it('conditional ng-required toggles as its expression changes', () => { const { $compile, $rootScope } = boot(); ($rootScope as unknown as Record).need = false; - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); // need=false → not required → empty is valid. @@ -124,7 +121,7 @@ describe('required (FS §2.6)', () => { describe('ng-minlength / ng-maxlength (FS §2.6)', () => { it('minlength fails when too short, passes when long enough', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); fireInput(el, 'ab'); @@ -139,7 +136,7 @@ describe('ng-minlength / ng-maxlength (FS §2.6)', () => { it('maxlength fails when too long', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); fireInput(el, 'abc'); @@ -152,7 +149,7 @@ describe('ng-minlength / ng-maxlength (FS §2.6)', () => { it('re-validates when the ng-minlength expression changes', () => { const { $compile, $rootScope } = boot(); ($rootScope as unknown as Record).n = 2; - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); fireInput(el, 'ab'); // length 2 passes with min 2 @@ -168,7 +165,7 @@ describe('ng-minlength / ng-maxlength (FS §2.6)', () => { describe('pattern / ng-pattern (FS §2.6)', () => { it('ng-pattern with a regex literal fails on mismatch', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); fireInput(el, 'abc'); @@ -183,7 +180,7 @@ describe('pattern / ng-pattern (FS §2.6)', () => { it('ng-pattern from a scope RegExp re-validates when it changes', () => { const { $compile, $rootScope } = boot(); ($rootScope as unknown as Record).re = /^a+$/; - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); fireInput(el, 'aaa'); @@ -196,7 +193,7 @@ describe('pattern / ng-pattern (FS §2.6)', () => { describe('email / url type validators (FS §2.6)', () => { it('email fails on a malformed address, passes on a valid one', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); fireInput(el, 'not-an-email'); @@ -211,7 +208,7 @@ describe('email / url type validators (FS §2.6)', () => { it('url fails on a malformed URL', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); fireInput(el, 'nope'); @@ -224,7 +221,7 @@ describe('email / url type validators (FS §2.6)', () => { describe('number min / max validators (FS §2.6)', () => { it('min fails below the bound, max fails above', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); fireInput(el, '3'); @@ -249,7 +246,7 @@ describe('number min / max validators (FS §2.6)', () => { describe('custom $validators (FS §2.7)', () => { it('a custom sync rule flips validity under its key on every change', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); // "no digits allowed" @@ -273,7 +270,7 @@ describe('custom $validators (FS §2.7)', () => { describe('custom $asyncValidators + $pending (FS §2.7)', () => { it('reports pending + ng-pending until settle; model written only on resolve', () => { const { $compile, $rootScope, $q } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); let deferred: QDeferred | undefined; @@ -303,7 +300,7 @@ describe('custom $asyncValidators + $pending (FS §2.7)', () => { it('a rejecting async rule marks the control invalid under its key', () => { const { $compile, $rootScope, $q } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); let deferred: QDeferred | undefined; @@ -324,7 +321,7 @@ describe('custom $asyncValidators + $pending (FS §2.7)', () => { it('async runs ONLY after all sync validators pass', () => { const { $compile, $rootScope, $q } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); let asyncCalls = 0; @@ -345,7 +342,7 @@ describe('custom $asyncValidators + $pending (FS §2.7)', () => { it('a stale async pass is cancelled by newer input (no stale validity write)', () => { const { $compile, $rootScope, $q } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); const deferreds: QDeferred[] = []; @@ -382,7 +379,7 @@ describe('custom $asyncValidators + $pending (FS §2.7)', () => { describe('$validate (FS §2.7)', () => { it('re-runs validators against the current value on demand', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); fireInput(el, 'abc'); @@ -406,7 +403,7 @@ describe('$validate (FS §2.7)', () => { describe('min/max validate the parsed model value (parity)', () => { it('a NaN-parsing view value ("." → NaN) is treated as empty — min/max pass', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); // NUMBER_RE accepts "." (parses to NaN); NaN is EMPTY for min/max — @@ -418,7 +415,7 @@ describe('min/max validate the parsed model value (parity)', () => { it('still fails/passes min against real numbers', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); fireInput(el, '3'); @@ -437,7 +434,7 @@ describe('min/max validate the parsed model value (parity)', () => { describe('native minlength/maxlength attributes (parity)', () => { it(' (no ng- prefix) validates', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); fireInput(el, 'ab'); @@ -449,7 +446,7 @@ describe('native minlength/maxlength attributes (parity)', () => { it(' (no ng- prefix) validates', () => { const { $compile, $rootScope } = boot(); - const el = input(compile('', $compile, $rootScope)); + const el = asInput(compile('', $compile, $rootScope)); const ctrl = ctrlOf(el); ctrl.$setViewValue('abc'); @@ -475,7 +472,7 @@ describe('form-level $pending + ng-pending (parity)', () => { it('a control with an outstanding async rule marks the enclosing form pending', () => { const { $compile, $rootScope, $q } = boot(); const form = compile('
', $compile, $rootScope); - const el = input(form.querySelector('input') as HTMLElement); + const el = asInput(form.querySelector('input')); const ctrl = ctrlOf(el); const formCtrl = model($rootScope, 'f') as FormLike; @@ -504,7 +501,7 @@ describe('form-level $pending + ng-pending (parity)', () => { it('a rejected async rule moves the form from pending to invalid', () => { const { $compile, $rootScope, $q } = boot(); const form = compile('
', $compile, $rootScope); - const el = input(form.querySelector('input') as HTMLElement); + const el = asInput(form.querySelector('input')); const ctrl = ctrlOf(el); const formCtrl = model($rootScope, 'f') as FormLike & { $error: Record }; @@ -534,7 +531,7 @@ describe('form-level $pending + ng-pending (parity)', () => { ); ($rootScope as unknown as Record)['show'] = true; $rootScope.$digest(); - const el = input(form.querySelector('input') as HTMLElement); + const el = asInput(form.querySelector('input')); const ctrl = ctrlOf(el); const formCtrl = model($rootScope, 'f') as FormLike; diff --git a/src/forms/input-types.ts b/src/forms/input-types.ts index ee22b69..09ab94f 100644 --- a/src/forms/input-types.ts +++ b/src/forms/input-types.ts @@ -29,7 +29,7 @@ * `'$compile'` because the project's `$apply` is `try/finally`-only. */ -import type { Scope } from '@core/index'; +import { asInstanceOf, type Scope } from '@core/index'; import type { Attributes } from '@compiler/directive-types'; import { invokeExceptionHandler, type ExceptionHandler } from '@exception-handler/index'; @@ -117,10 +117,11 @@ function toStringView(value: unknown): string { * Read the narrowed `HTMLInputElement` view of the control. The date / * number / checkbox / radio handlers only ever run for an `` (a * `