Skip to content

Commit 6a6d336

Browse files
Add support for Angular v20 bindings API
Co-authored-by: timdeschryver <[email protected]>
1 parent b815a18 commit 6a6d336

File tree

5 files changed

+126
-6
lines changed

5 files changed

+126
-6
lines changed
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { Component, computed, input, model, numberAttribute, output } from '@angular/core';
2+
import { FormsModule } from '@angular/forms';
3+
4+
@Component({
5+
selector: 'atl-bindings-api-example',
6+
template: `
7+
<div data-testid="input-value">{{ greetings() }} {{ name() }} of {{ age() }} years old</div>
8+
<div data-testid="computed-value">{{ greetingMessage() }}</div>
9+
<button data-testid="submit-button" (click)="submitName()">Submit</button>
10+
<button data-testid="increment-button" (click)="incrementAge()">Increment Age</button>
11+
<input type="text" data-testid="name-input" [(ngModel)]="name" />
12+
<div data-testid="current-age">Current age: {{ age() }}</div>
13+
`,
14+
standalone: true,
15+
imports: [FormsModule],
16+
})
17+
export class BindingsApiExampleComponent {
18+
greetings = input<string>('', {
19+
alias: 'greeting',
20+
});
21+
age = input.required<number, string>({ transform: numberAttribute });
22+
name = model.required<string>();
23+
submitValue = output<string>();
24+
ageChanged = output<number>();
25+
26+
greetingMessage = computed(() => `${this.greetings()} ${this.name()} of ${this.age()} years old`);
27+
28+
submitName() {
29+
this.submitValue.emit(this.name());
30+
}
31+
32+
incrementAge() {
33+
const newAge = this.age() + 1;
34+
this.ageChanged.emit(newAge);
35+
}
36+
}

projects/testing-library/src/lib/models.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
Provider,
88
Signal,
99
InputSignalWithTransform,
10+
Binding,
1011
} from '@angular/core';
1112
import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed } from '@angular/core/testing';
1213
import { Routes } from '@angular/router';
@@ -307,6 +308,26 @@ export interface RenderComponentOptions<ComponentType, Q extends Queries = typeo
307308
*/
308309
on?: OutputRefKeysWithCallback<ComponentType>;
309310

311+
/**
312+
* @description
313+
* An array of bindings to apply to the component using Angular v20+'s native bindings API.
314+
* This provides a more direct way to bind inputs and outputs compared to the `inputs` and `on` options.
315+
*
316+
* @default
317+
* []
318+
*
319+
* @example
320+
* import { inputBinding, outputBinding } from '@angular/core';
321+
*
322+
* await render(AppComponent, {
323+
* bindings: [
324+
* inputBinding('value', () => 'test value'),
325+
* outputBinding('click', (event) => console.log(event))
326+
* ]
327+
* })
328+
*/
329+
bindings?: Binding[];
330+
310331
/**
311332
* @description
312333
* A collection of providers to inject dependencies of the component.

projects/testing-library/src/lib/testing-library.ts

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import {
1111
SimpleChanges,
1212
Type,
1313
isStandalone,
14+
Binding,
1415
} from '@angular/core';
1516
import { ComponentFixture, DeferBlockBehavior, DeferBlockState, TestBed, tick } from '@angular/core/testing';
1617
import { NavigationExtras, Router } from '@angular/router';
@@ -69,6 +70,7 @@ export async function render<SutType, WrapperType = SutType>(
6970
componentOutputs = {},
7071
inputs: newInputs = {},
7172
on = {},
73+
bindings = [],
7274
componentProviders = [],
7375
childComponentOverrides = [],
7476
componentImports,
@@ -192,11 +194,19 @@ export async function render<SutType, WrapperType = SutType>(
192194
outputs: Partial<SutType>,
193195
subscribeTo: OutputRefKeysWithCallback<SutType>,
194196
): Promise<ComponentFixture<SutType>> => {
195-
const createdFixture: ComponentFixture<SutType> = await createComponent(componentContainer);
196-
setComponentProperties(createdFixture, properties);
197-
setComponentInputs(createdFixture, inputs);
198-
setComponentOutputs(createdFixture, outputs);
199-
subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo);
197+
const createdFixture: ComponentFixture<SutType> = await createComponent(componentContainer, bindings);
198+
199+
// Only use traditional input/output setting if no bindings are provided
200+
// When bindings are used, they handle inputs and outputs natively
201+
if (!bindings || bindings.length === 0) {
202+
setComponentProperties(createdFixture, properties);
203+
setComponentInputs(createdFixture, inputs);
204+
setComponentOutputs(createdFixture, outputs);
205+
subscribedOutputs = subscribeToComponentOutputs(createdFixture, subscribeTo);
206+
} else {
207+
// With bindings, we still need to handle componentProperties for non-input properties
208+
setComponentProperties(createdFixture, properties);
209+
}
200210

201211
if (removeAngularAttributes) {
202212
createdFixture.nativeElement.removeAttribute('ng-version');
@@ -335,9 +345,18 @@ export async function render<SutType, WrapperType = SutType>(
335345
};
336346
}
337347

338-
async function createComponent<SutType>(component: Type<SutType>): Promise<ComponentFixture<SutType>> {
348+
async function createComponent<SutType>(
349+
component: Type<SutType>,
350+
bindings?: Binding[],
351+
): Promise<ComponentFixture<SutType>> {
339352
/* Make sure angular application is initialized before creating component */
340353
await TestBed.inject(ApplicationInitStatus).donePromise;
354+
355+
// Use the new bindings API if available and bindings are provided
356+
if (bindings && bindings.length > 0) {
357+
return TestBed.createComponent(component, { bindings });
358+
}
359+
341360
return TestBed.createComponent(component);
342361
}
343362

projects/testing-library/src/public_api.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,6 @@
55
export * from './lib/models';
66
export * from './lib/config';
77
export * from './lib/testing-library';
8+
9+
// Re-export Angular's binding functions for convenience
10+
export { inputBinding, outputBinding, twoWayBinding, type Binding } from '@angular/core';
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
import { Component, input, output } from '@angular/core';
2+
import { render, screen, inputBinding, outputBinding } from '../src/public_api';
3+
4+
describe('ATL Bindings API Support', () => {
5+
@Component({
6+
selector: 'atl-bindings-test',
7+
template: `
8+
<div data-testid="value">{{ value() }}</div>
9+
<div data-testid="greeting">{{ greeting() }}</div>
10+
<button data-testid="emit-button" (click)="clicked.emit('clicked: ' + value())">Click me</button>
11+
`,
12+
standalone: true,
13+
})
14+
class BindingsTestComponent {
15+
value = input<string>('default');
16+
greeting = input<string>('hello', { alias: 'greet' });
17+
clicked = output<string>();
18+
}
19+
20+
it('should support inputBinding for regular inputs', async () => {
21+
await render(BindingsTestComponent, {
22+
bindings: [inputBinding('value', () => 'test-value'), inputBinding('greet', () => 'hi there')],
23+
});
24+
25+
expect(screen.getByTestId('value')).toHaveTextContent('test-value');
26+
expect(screen.getByTestId('greeting')).toHaveTextContent('hi there');
27+
});
28+
29+
it('should support outputBinding for outputs', async () => {
30+
const clickHandler = jest.fn();
31+
32+
await render(BindingsTestComponent, {
33+
bindings: [inputBinding('value', () => 'bound-value'), outputBinding('clicked', clickHandler)],
34+
});
35+
36+
const button = screen.getByTestId('emit-button');
37+
button.click();
38+
39+
expect(clickHandler).toHaveBeenCalledWith('clicked: bound-value');
40+
});
41+
});

0 commit comments

Comments
 (0)