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);