Skip to content

Commit fd2636e

Browse files
committed
feat(forms): prototype signal forms support for select[multiple]
Add an initial Signal Forms implementation for <select multiple>. This prototype (for angular#67563) adds a dedicated runtime path for multi-select controls and updates Signal Forms type checking so <select multiple [formField]> is treated as string[] instead of string. The current scope is intentionally small: - support native <select multiple> - synchronize model -> DOM and DOM -> model - resync correctly when options change - preserve DOM option order in the emitted value Not included in this prototype: - compareWith - object-valued options / ngValue semantics - broader selection abstractions Also adds focused coverage for: - Signal Forms type-checking of <select multiple> - runtime synchronization for multi-select controls
1 parent c64ee96 commit fd2636e

5 files changed

Lines changed: 164 additions & 2 deletions

File tree

‎packages/compiler-cli/src/ngtsc/typecheck/test/type_check_block_spec.ts‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2923,6 +2923,14 @@ describe('type check blocks', () => {
29232923
expect(block).toContain('_t2.field = (((this).f));');
29242924
});
29252925

2926+
it('should generate a string array field for a multiple select', () => {
2927+
const block = tcb('<select multiple [formField]="f"></select>', [FieldMock]);
2928+
expect(block).toContain('var _t1 = null! as string[];');
2929+
expect(block).toContain('_t1 = ((this).f)().value();');
2930+
expect(block).toContain('var _t2 = null! as i0.FormField;');
2931+
expect(block).toContain('_t2.field = (((this).f));');
2932+
});
2933+
29262934
it('should generate a custom value control', () => {
29272935
const block = tcb('<custom-control [formField]="f"/>', [
29282936
FieldMock,

‎packages/compiler/src/typecheck/ops/signal_forms.ts‎

Lines changed: 18 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,11 +142,15 @@ export class TcbNativeFieldOp extends TcbOp {
142142
}
143143

144144
private getExpectedTypeFromDomNode(node: Element): string | null {
145-
if (node.name === 'textarea' || node.name === 'select') {
146-
// `<textarea>` and `<select>` are always strings.
145+
if (node.name === 'textarea') {
146+
// `<textarea>` is always a string.
147147
return 'string';
148148
}
149149

150+
if (node.name === 'select') {
151+
return hasMultipleSelect(node) ? 'string[]' : 'string';
152+
}
153+
150154
if (node.name !== 'input') {
151155
return this.getUnsupportedType();
152156
}
@@ -196,6 +200,18 @@ export class TcbNativeFieldOp extends TcbOp {
196200
}
197201
}
198202

203+
function hasMultipleSelect(node: Element): boolean {
204+
if (node.attributes.some((attr) => attr.name.toLowerCase() === 'multiple')) {
205+
return true;
206+
}
207+
208+
return node.inputs.some(
209+
(input) =>
210+
(input.type === BindingType.Property || input.type === BindingType.Attribute) &&
211+
input.name.toLowerCase() === 'multiple',
212+
);
213+
}
214+
199215
/**
200216
* A variation of the `TcbNativeFieldOp` with specific logic for radio buttons.
201217
*/
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
/**
2+
* @license
3+
* Copyright Google LLC All Rights Reserved.
4+
*
5+
* Use of this source code is governed by an MIT-style license that can be
6+
* found in the LICENSE file at https://angular.dev/license
7+
*/
8+
import {type ɵControlDirectiveHost as ControlDirectiveHost} from '@angular/core';
9+
import {
10+
bindingUpdated,
11+
CONTROL_BINDING_NAMES,
12+
createBindings,
13+
readFieldStateBindingValue,
14+
type ControlBindingKey,
15+
} from './bindings';
16+
import type {FormField} from './form_field';
17+
import {setNativeDomProperty} from './native';
18+
import {observeSelectMutations} from './select';
19+
20+
export function selectMultipleControlCreate(
21+
host: ControlDirectiveHost,
22+
parent: FormField<unknown>,
23+
): () => void {
24+
let updateMode = false;
25+
const select = parent.nativeFormElement as HTMLSelectElement;
26+
27+
host.listenToDom('input', () =>
28+
parent.state().controlValue.set(getSelectMultipleControlValue(select)),
29+
);
30+
host.listenToDom('blur', () => parent.state().markAsTouched());
31+
32+
parent.registerAsBinding();
33+
34+
observeSelectMutations(
35+
select,
36+
() => {
37+
if (!updateMode) {
38+
return;
39+
}
40+
setSelectMultipleControlValue(select, parent.state().controlValue());
41+
},
42+
parent.destroyRef,
43+
);
44+
45+
const bindings = createBindings<ControlBindingKey | 'controlValue'>();
46+
47+
return () => {
48+
const state = parent.state();
49+
const controlValue = state.controlValue();
50+
if (bindingUpdated(bindings, 'controlValue', controlValue)) {
51+
setSelectMultipleControlValue(select, controlValue);
52+
}
53+
54+
for (const name of CONTROL_BINDING_NAMES) {
55+
const value = readFieldStateBindingValue(state, name);
56+
if (bindingUpdated(bindings, name, value)) {
57+
host.setInputOnDirectives(name, value);
58+
if (parent.elementAcceptsNativeProperty(name)) {
59+
setNativeDomProperty(parent.renderer, select, name, value as string | number | undefined);
60+
}
61+
}
62+
}
63+
64+
updateMode = true;
65+
};
66+
}
67+
68+
function getSelectMultipleControlValue(select: HTMLSelectElement): string[] {
69+
const selected: string[] = [];
70+
const selectedOptions = select.selectedOptions;
71+
72+
if (selectedOptions !== undefined) {
73+
for (let i = 0; i < selectedOptions.length; i++) {
74+
selected.push(selectedOptions[i].value);
75+
}
76+
return selected;
77+
}
78+
79+
for (let i = 0; i < select.options.length; i++) {
80+
const option = select.options[i];
81+
if (option.selected) {
82+
selected.push(option.value);
83+
}
84+
}
85+
86+
return selected;
87+
}
88+
89+
function setSelectMultipleControlValue(select: HTMLSelectElement, value: unknown): void {
90+
const selectedValues = new Set(Array.isArray(value) ? value : []);
91+
for (let i = 0; i < select.options.length; i++) {
92+
const option = select.options[i];
93+
option.selected = selectedValues.has(option.value);
94+
}
95+
}

‎packages/forms/signals/src/directive/form_field.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {bindingUpdated, type ControlBindingKey, createBindings} from './bindings
4343
import {customControlCreate} from './control_custom';
4444
import {cvaControlCreate} from './control_cva';
4545
import {nativeControlCreate} from './control_native';
46+
import {selectMultipleControlCreate} from './control_select_multiple';
4647
import {
4748
isNativeFormElement,
4849
isNumericFormElement,
@@ -341,6 +342,12 @@ export class FormField<T> {
341342
this.ɵngControlUpdate = cvaControlCreate(host, this as FormField<unknown>);
342343
} else if (host.customControl) {
343344
this.ɵngControlUpdate = customControlCreate(host, this as FormField<unknown>);
345+
} else if (
346+
this.elementIsNativeFormElement &&
347+
this.nativeFormElement.tagName === 'SELECT' &&
348+
(this.nativeFormElement as HTMLSelectElement).multiple
349+
) {
350+
this.ɵngControlUpdate = selectMultipleControlCreate(host, this as FormField<unknown>);
344351
} else if (this.elementIsNativeFormElement) {
345352
this.ɵngControlUpdate = nativeControlCreate(
346353
host,

‎packages/forms/signals/test/web/form_field.spec.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3923,6 +3923,42 @@ describe('field directive', () => {
39233923
expect(cmp.f().value()).toBe('two');
39243924
});
39253925

3926+
it('synchronizes with a multiple select', () => {
3927+
@Component({
3928+
imports: [FormField],
3929+
template: `
3930+
<select #select multiple [formField]="$any(f)">
3931+
<option value="one">One</option>
3932+
<option value="two">Two</option>
3933+
<option value="three">Three</option>
3934+
</select>
3935+
`,
3936+
})
3937+
class TestCmp {
3938+
f = form(signal<string[]>(['two']));
3939+
select = viewChild.required<ElementRef<HTMLSelectElement>>('select');
3940+
}
3941+
3942+
const fix = act(() => TestBed.createComponent(TestCmp));
3943+
const select = fix.componentInstance.select().nativeElement;
3944+
const cmp = fix.componentInstance as TestCmp;
3945+
3946+
expect(Array.from(select.selectedOptions, (option) => option.value)).toEqual(['two']);
3947+
3948+
// Model -> View
3949+
act(() => cmp.f().value.set(['one', 'three']));
3950+
expect(Array.from(select.selectedOptions, (option) => option.value)).toEqual(['one', 'three']);
3951+
3952+
// View -> Model
3953+
act(() => {
3954+
for (const option of Array.from(select.options)) {
3955+
option.selected = option.value !== 'three';
3956+
}
3957+
select.dispatchEvent(new Event('input'));
3958+
});
3959+
expect(cmp.f().value()).toEqual(['one', 'two']);
3960+
});
3961+
39263962
it('synchronizes with a custom value control', () => {
39273963
@Component({
39283964
selector: 'my-input',

0 commit comments

Comments
 (0)