Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 20 additions & 3 deletions packages/compiler-cli/src/ngtsc/typecheck/src/host_bindings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,14 +387,31 @@ function createNodeFromListenerDecorator(

const callNode = new Call(span, nameSpan, receiver, argNodes, span);
const eventNameNode = args[0];
const [eventName, phase] = eventNameNode.text.split('.');
let type: ParsedEventType;
let eventName: string;
let phase: string | null;
let target: string | null;

if (eventNameNode.text.startsWith('@')) {
const parsedName = parser.parseAnimationEventName(eventNameNode.text);
type = ParsedEventType.Animation;
eventName = parsedName.eventName;
phase = parsedName.phase;
target = null;
} else {
const parsedName = parser.parseEventListenerName(eventNameNode.text);
type = ParsedEventType.Regular;
eventName = parsedName.eventName;
target = parsedName.target;
phase = null;
}

listeners.push(
new TmplAstBoundEvent(
eventName,
eventName.startsWith('@') ? ParsedEventType.Animation : ParsedEventType.Regular,
type,
callNode,
null,
target,
phase,
createSourceSpan(decorator),
createSourceSpan(decorator),
Expand Down
16 changes: 12 additions & 4 deletions packages/compiler-cli/src/ngtsc/typecheck/src/type_check_block.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1578,11 +1578,19 @@ class TcbUnclaimedOutputsOp extends TcbOp {
// `$event` depending on the event name. For unknown event names, TypeScript resorts to the
// base `Event` type.
const handler = tcbCreateEventHandler(output, this.tcb, this.scope, EventParamType.Infer);

if (elId === null) {
elId = this.scope.resolve(this.target);
let target: ts.Expression;
// Only check for `window` and `document` since in theory any target can be passed.
if (output.target === 'window' || output.target === 'document') {
target = ts.factory.createIdentifier(output.target);
} else if (elId === null) {
target = elId = this.scope.resolve(this.target);
} else {
target = elId;
}
const propertyAccess = ts.factory.createPropertyAccessExpression(elId, 'addEventListener');
const propertyAccess = ts.factory.createPropertyAccessExpression(
target,
'addEventListener',
);
addParseSpanInfo(propertyAccess, output.keySpan);
const call = ts.factory.createCallExpression(
/* expression */ propertyAccess,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -357,6 +357,20 @@ describe('type check blocks', () => {
);
});

it('should generate code for event targeting `window`', () => {
const block = tcb(`<button (window:scroll)="handle()"></button>`);
expect(block).toContain(
'window.addEventListener("scroll", ($event): any => { (this).handle(); });',
);
});

it('should generate code for event targeting `document`', () => {
const block = tcb(`<button (document:click)="handle()"></button>`);
expect(block).toContain(
'document.addEventListener("click", ($event): any => { (this).handle(); });',
);
});

it('should only generate directive declarations that have bindings or are referenced', () => {
const TEMPLATE = `
<div
Expand Down
24 changes: 24 additions & 0 deletions packages/compiler-cli/test/ngtsc/host_bindings_type_check_spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,30 @@ runInEachFileSystem(() => {
expect(getDiagnosticSourceCode(diags[0])).toBe('$event');
});

it('should check @HostListener with a target', () => {
env.write(
'test.ts',
`
import {Component, HostListener} from '@angular/core';

@Component({
template: '',
selector: 'button[foo]',
})
export class Comp {
@HostListener('document:click', ['$event']) handleEvent(event: KeyboardEvent) {}
}
`,
);

const diags = env.driveDiagnostics();
expect(diags.length).toBe(1);
expect((diags[0].messageText as ts.DiagnosticMessageChain).messageText).toBe(
`Argument of type 'MouseEvent' is not assignable to parameter of type 'KeyboardEvent'.`,
);
expect(getDiagnosticSourceCode(diags[0])).toBe('$event');
});

it('should check host animation event listeners', () => {
env.write(
'test.ts',
Expand Down
6 changes: 3 additions & 3 deletions packages/compiler/src/expression_parser/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -790,7 +790,7 @@ export class ParsedEvent {
// Animation events have a phase
constructor(
name: string,
targetOrPhase: string,
targetOrPhase: string | null,
type: ParsedEventType.TwoWay,
handler: ASTWithSource<NonNullAssert | PropertyRead | KeyedRead>,
sourceSpan: ParseSourceSpan,
Expand All @@ -800,7 +800,7 @@ export class ParsedEvent {

constructor(
name: string,
targetOrPhase: string,
targetOrPhase: string | null,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

OOC: was the value before just empty if there isn't a target or phase?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It was a null that was non-null-asserted to any.

type: ParsedEventType,
handler: ASTWithSource,
sourceSpan: ParseSourceSpan,
Expand All @@ -810,7 +810,7 @@ export class ParsedEvent {

constructor(
public name: string,
public targetOrPhase: string,
public targetOrPhase: string | null,
public type: ParsedEventType,
public handler: ASTWithSource,
public sourceSpan: ParseSourceSpan,
Expand Down
16 changes: 12 additions & 4 deletions packages/compiler/src/template_parser/binding_parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,16 @@ export class BindingParser {
return calcPossibleSecurityContexts(this._schemaRegistry, selector, prop, isAttribute);
}

parseEventListenerName(rawName: string): {eventName: string; target: string | null} {
const [target, eventName] = splitAtColon(rawName, [null, rawName]);
return {eventName: eventName!, target};
}

parseAnimationEventName(rawName: string): {eventName: string; phase: string | null} {
const matches = splitAtPeriod(rawName, [rawName, null]);
return {eventName: matches[0]!, phase: matches[1] === null ? null : matches[1].toLowerCase()};
}

private _parseAnimationEvent(
name: string,
expression: string,
Expand All @@ -668,9 +678,7 @@ export class BindingParser {
targetEvents: ParsedEvent[],
keySpan: ParseSourceSpan,
) {
const matches = splitAtPeriod(name, [name, '']);
const eventName = matches[0];
const phase = matches[1].toLowerCase();
const {eventName, phase} = this.parseAnimationEventName(name);
const ast = this._parseAction(expression, handlerSpan);
targetEvents.push(
new ParsedEvent(
Expand Down Expand Up @@ -713,7 +721,7 @@ export class BindingParser {
keySpan: ParseSourceSpan,
): void {
// long format: 'target: eventName'
const [target, eventName] = splitAtColon(name, [null!, name]);
const {eventName, target} = this.parseEventListenerName(name);
const prevErrorCount = this.errors.length;
const ast = this._parseAction(expression, handlerSpan);
const isValid = this.errors.length === prevErrorCount;
Expand Down
10 changes: 7 additions & 3 deletions packages/compiler/src/util.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,19 @@ export function dashCaseToCamelCase(input: string): string {
return input.replace(DASH_CASE_REGEXP, (...m: any[]) => m[1].toUpperCase());
}

export function splitAtColon(input: string, defaultValues: string[]): string[] {
export function splitAtColon(input: string, defaultValues: (string | null)[]): (string | null)[] {
return _splitAt(input, ':', defaultValues);
}

export function splitAtPeriod(input: string, defaultValues: string[]): string[] {
export function splitAtPeriod(input: string, defaultValues: (string | null)[]): (string | null)[] {
return _splitAt(input, '.', defaultValues);
}

function _splitAt(input: string, character: string, defaultValues: string[]): string[] {
function _splitAt(
input: string,
character: string,
defaultValues: (string | null)[],
): (string | null)[] {
const characterIndex = input.indexOf(character);
if (characterIndex == -1) return defaultValues;
return [input.slice(0, characterIndex).trim(), input.slice(characterIndex + 1).trim()];
Expand Down