diff --git a/packages/devtools/src/__tests__/forms-actions.test.ts b/packages/devtools/src/__tests__/forms-actions.test.ts index aceb2c06..9a84d181 100644 --- a/packages/devtools/src/__tests__/forms-actions.test.ts +++ b/packages/devtools/src/__tests__/forms-actions.test.ts @@ -160,6 +160,24 @@ Component({ `, })(Shirt); +class Trip { + model = signal({ terms: false, pace: 'slow', seat: 'aisle' }); + form = form(this.model); +} +Component({ + selector: 'trip-form', + imports: [FormField, FormRoot], + template: `
+ + + + +
`, +})(Trip); + async function render(type: new () => T) { const fixture = TestBed.createComponent(type); fixture.detectChanges(); @@ -411,6 +429,23 @@ describe('form actions on template-driven and Signal Forms', () => { ).toContain('ngModel'); }); + it('writes Signal Forms checkboxes, radios and selects through the page in user mode', async () => { + const fixture = await render(Trip); + const ctx = contextFor(fixture.nativeElement); + const model = fixture.componentInstance.model; + const write = (path: string, value: unknown) => + runFormAction(ctx, { action: 'set-value', formId: 'form-1', path, value, mode: 'user' }); + + expect((await write('terms', true)).ok).toBe(true); + expect(model().terms).toBe(true); + expect((await write('terms', false)).ok).toBe(true); + expect(model().terms).toBe(false); + expect((await write('pace', 'fast')).ok).toBe(true); + expect(model().pace).toBe('fast'); + expect((await write('seat', 'window')).ok).toBe(true); + expect(model().seat).toBe('window'); + }); + it('writes Signal Forms fields, refuses hidden ones and submits through the form root', async () => { const fixture = await render(Profile); const ctx = contextFor(fixture.nativeElement); diff --git a/packages/devtools/src/forms-actions.ts b/packages/devtools/src/forms-actions.ts index 71fb426c..c5e7a338 100644 --- a/packages/devtools/src/forms-actions.ts +++ b/packages/devtools/src/forms-actions.ts @@ -397,12 +397,19 @@ export function guardedFields( return overflow ? null : out; } +// A real click on a checkbox, radio or select fires input, then change. Signal Forms +// listens for input and the reactive and template-driven accessors listen for change. +function dispatchPick(element: Element): void { + element.dispatchEvent(new Event('input', { bubbles: true })); + element.dispatchEvent(new Event('change', { bubbles: true })); +} + function nativeWrite(element: Element, value: unknown): boolean { if (element instanceof HTMLInputElement) { if (element.type === 'file') return false; if (element.type === 'checkbox') { element.checked = !!value; - element.dispatchEvent(new Event('change', { bubbles: true })); + dispatchPick(element); return true; } if (element.type === 'radio') { @@ -414,7 +421,7 @@ function nativeWrite(element: Element, value: unknown): boolean { ) as HTMLInputElement | undefined; if (!target) return false; target.checked = true; - target.dispatchEvent(new Event('change', { bubbles: true })); + dispatchPick(target); return true; } element.value = value == null ? '' : String(value); @@ -467,7 +474,7 @@ function selectWrite( select.selectedIndex = index; expected = options[index].value; } - select.dispatchEvent(new Event('change', { bubbles: true })); + dispatchPick(select); return { expected }; } @@ -540,7 +547,14 @@ function writeValue( } if (viaDom && nativeWrite(element, value)) { if (mode === 'user') element.dispatchEvent(new Event('blur')); - return null; + const picked = + element instanceof HTMLInputElement && + (element.type === 'checkbox' || element.type === 'radio'); + if (!picked) return null; + const expected = element.type === 'checkbox' ? !!value : value; + const deferred = found.kind !== 'signal' && read(() => node['updateOn'], 'change') !== 'change'; + const stored = deferred ? node['_pendingValue'] : rawValue(found, node); + return sameOption(stored, expected) ? null : `holds ${label(stored)} after the write`; } if (found.kind === 'template' && leaf) { const dir = directivesOf(node);