Skip to content

Commit af2a131

Browse files
ivanwonderatscott
authored andcommitted
feat(language-service): support completions for animation (#44630)
Support completions for animation. PR Close #44630
1 parent 73424de commit af2a131

File tree

4 files changed

+201
-7
lines changed

4 files changed

+201
-7
lines changed

packages/language-service/src/attribute_completions.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,3 +621,16 @@ function getStructuralAttributes(meta: TypeCheckableDirectiveMeta): string[] {
621621

622622
return structuralAttributes;
623623
}
624+
625+
export function buildAnimationCompletionEntries(
626+
animations: string[], replacementSpan: ts.TextSpan,
627+
kind: DisplayInfoKind): ts.CompletionEntry[] {
628+
return animations.map(animation => {
629+
return {
630+
kind: unsafeCastDisplayInfoKindToScriptElementKind(kind),
631+
name: animation,
632+
sortText: animation,
633+
replacementSpan,
634+
};
635+
});
636+
}

packages/language-service/src/completions.ts

Lines changed: 85 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,16 +6,16 @@
66
* found in the LICENSE file at https://angular.io/license
77
*/
88

9-
import {AST, ASTWithSource, BindingPipe, Call, EmptyExpr, ImplicitReceiver, LiteralPrimitive, ParseSourceSpan, PropertyRead, PropertyWrite, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstText, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
9+
import {AST, ASTWithSource, BindingPipe, BindingType, Call, EmptyExpr, ImplicitReceiver, LiteralPrimitive, ParsedEventType, ParseSourceSpan, PropertyRead, PropertyWrite, SafePropertyRead, TmplAstBoundAttribute, TmplAstBoundEvent, TmplAstElement, TmplAstNode, TmplAstReference, TmplAstTemplate, TmplAstText, TmplAstTextAttribute, TmplAstVariable} from '@angular/compiler';
1010
import {NgCompiler} from '@angular/compiler-cli/src/ngtsc/core';
1111
import {CompletionKind, DirectiveInScope, SymbolKind, TemplateDeclarationSymbol} from '@angular/compiler-cli/src/ngtsc/typecheck/api';
1212
import {BoundEvent, TextAttribute} from '@angular/compiler/src/render3/r3_ast';
1313
import ts from 'typescript';
1414

15-
import {addAttributeCompletionEntries, AttributeCompletionKind, buildAttributeCompletionTable, getAttributeCompletionSymbol} from './attribute_completions';
15+
import {addAttributeCompletionEntries, AttributeCompletionKind, buildAnimationCompletionEntries, buildAttributeCompletionTable, getAttributeCompletionSymbol} from './attribute_completions';
1616
import {DisplayInfo, DisplayInfoKind, getDirectiveDisplayInfo, getSymbolDisplayInfo, getTsSymbolDisplayInfo, unsafeCastDisplayInfoKindToScriptElementKind} from './display_parts';
1717
import {TargetContext, TargetNodeKind, TemplateTarget} from './template_target';
18-
import {filterAliasImports, isBoundEventWithSyntheticHandler} from './utils';
18+
import {filterAliasImports, isBoundEventWithSyntheticHandler, isWithin} from './utils';
1919

2020
type PropertyExpressionCompletionBuilder =
2121
CompletionBuilder<PropertyRead|PropertyWrite|EmptyExpr|SafePropertyRead|TmplAstBoundEvent>;
@@ -27,6 +27,8 @@ type PipeCompletionBuilder = CompletionBuilder<BindingPipe>;
2727

2828
type LiteralCompletionBuilder = CompletionBuilder<LiteralPrimitive|TextAttribute>;
2929

30+
type ElementAnimationCompletionBuilder = CompletionBuilder<TmplAstBoundAttribute|TmplAstBoundEvent>;
31+
3032
export enum CompletionNodeContext {
3133
None,
3234
ElementTag,
@@ -36,6 +38,8 @@ export enum CompletionNodeContext {
3638
TwoWayBinding,
3739
}
3840

41+
const ANIMATION_PHASES = ['start', 'done'];
42+
3943
/**
4044
* Performs autocompletion operations on a given node in the template.
4145
*
@@ -70,7 +74,11 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
7074
} else if (this.isElementTagCompletion()) {
7175
return this.getElementTagCompletion();
7276
} else if (this.isElementAttributeCompletion()) {
73-
return this.getElementAttributeCompletions(options);
77+
if (this.isAnimationCompletion()) {
78+
return this.getAnimationCompletions();
79+
} else {
80+
return this.getElementAttributeCompletions(options);
81+
}
7482
} else if (this.isPipeCompletion()) {
7583
return this.getPipeCompletions();
7684
} else if (this.isLiteralCompletion()) {
@@ -531,6 +539,68 @@ export class CompletionBuilder<N extends TmplAstNode|AST> {
531539
return directive?.tsSymbol;
532540
}
533541

542+
private isAnimationCompletion(): this is ElementAnimationCompletionBuilder {
543+
return (this.node instanceof TmplAstBoundAttribute &&
544+
this.node.type === BindingType.Animation) ||
545+
(this.node instanceof TmplAstBoundEvent && this.node.type === ParsedEventType.Animation);
546+
}
547+
548+
private getAnimationCompletions(this: ElementAnimationCompletionBuilder):
549+
ts.WithMetadata<ts.CompletionInfo>|undefined {
550+
if (this.node instanceof TmplAstBoundAttribute) {
551+
const animations = this.compiler.getTemplateTypeChecker()
552+
.getDirectiveMetadata(this.component)
553+
?.animationTriggerNames?.staticTriggerNames;
554+
const replacementSpan = makeReplacementSpanFromParseSourceSpan(this.node.keySpan);
555+
556+
if (animations === undefined) {
557+
return undefined;
558+
}
559+
560+
const entries = buildAnimationCompletionEntries(
561+
[...animations, '.disabled'], replacementSpan, DisplayInfoKind.ATTRIBUTE);
562+
return {
563+
entries,
564+
isGlobalCompletion: false,
565+
isMemberCompletion: false,
566+
isNewIdentifierLocation: true,
567+
};
568+
} else {
569+
const animationNameSpan = buildAnimationNameSpan(this.node);
570+
const phaseSpan = buildAnimationPhaseSpan(this.node);
571+
if (isWithin(this.position, animationNameSpan)) {
572+
const animations = this.compiler.getTemplateTypeChecker()
573+
.getDirectiveMetadata(this.component)
574+
?.animationTriggerNames?.staticTriggerNames;
575+
const replacementSpan = makeReplacementSpanFromParseSourceSpan(animationNameSpan);
576+
577+
if (animations === undefined) {
578+
return undefined;
579+
}
580+
581+
const entries =
582+
buildAnimationCompletionEntries(animations, replacementSpan, DisplayInfoKind.EVENT);
583+
return {
584+
entries,
585+
isGlobalCompletion: false,
586+
isMemberCompletion: false,
587+
isNewIdentifierLocation: true,
588+
};
589+
}
590+
if (phaseSpan !== null && isWithin(this.position, phaseSpan)) {
591+
const replacementSpan = makeReplacementSpanFromParseSourceSpan(phaseSpan);
592+
const entries = buildAnimationCompletionEntries(
593+
ANIMATION_PHASES, replacementSpan, DisplayInfoKind.EVENT);
594+
return {
595+
entries,
596+
isGlobalCompletion: false,
597+
isMemberCompletion: false,
598+
isNewIdentifierLocation: true,
599+
};
600+
}
601+
}
602+
}
603+
534604
private isElementAttributeCompletion(): this is ElementAttributeCompletionBuilder {
535605
return (this.nodeContext === CompletionNodeContext.ElementAttributeKey ||
536606
this.nodeContext === CompletionNodeContext.TwoWayBinding) &&
@@ -884,3 +954,14 @@ function nodeContextFromTarget(target: TargetContext): CompletionNodeContext {
884954
return CompletionNodeContext.None;
885955
}
886956
}
957+
958+
function buildAnimationNameSpan(node: TmplAstBoundEvent): ParseSourceSpan {
959+
return new ParseSourceSpan(node.keySpan.start, node.keySpan.start.moveBy(node.name.length));
960+
}
961+
962+
function buildAnimationPhaseSpan(node: TmplAstBoundEvent): ParseSourceSpan|null {
963+
if (node.phase !== null) {
964+
return new ParseSourceSpan(node.keySpan.end.moveBy(-node.phase.length), node.keySpan.end);
965+
}
966+
return null;
967+
}

packages/language-service/src/template_target.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,8 +370,10 @@ class TemplateTargetVisitor implements t.Visitor {
370370
}
371371

372372
visitBoundAttribute(attribute: t.BoundAttribute) {
373-
const visitor = new ExpressionVisitor(this.position);
374-
visitor.visit(attribute.value, this.path);
373+
if (attribute.valueSpan !== undefined) {
374+
const visitor = new ExpressionVisitor(this.position);
375+
visitor.visit(attribute.value, this.path);
376+
}
375377
}
376378

377379
visitBoundEvent(event: t.BoundEvent) {

packages/language-service/test/completions_spec.ts

Lines changed: 99 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,14 @@ const UNION_TYPE_PIPE = {
141141
`
142142
};
143143

144+
const ANIMATION_TRIGGER_FUNCTION = `
145+
function trigger(name: string) {
146+
return {name};
147+
}
148+
`;
149+
150+
const ANIMATION_METADATA = `animations: [trigger('animationName')],`;
151+
144152
describe('completions', () => {
145153
beforeEach(() => {
146154
initMockFileSystem('Native');
@@ -682,6 +690,92 @@ describe('completions', () => {
682690
});
683691
});
684692

693+
describe('animations', () => {
694+
it('should return animation names for the property binding', () => {
695+
const {templateFile} =
696+
setup(`<input [@my]>`, '', {}, ANIMATION_TRIGGER_FUNCTION, ANIMATION_METADATA);
697+
templateFile.moveCursorToText('[@my¦]');
698+
699+
const completions = templateFile.getCompletionsAtPosition();
700+
expectContain(
701+
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ATTRIBUTE),
702+
['animationName']);
703+
expectReplacementText(completions, templateFile.contents, 'my');
704+
});
705+
706+
it('should return animation names when the property binding animation name is empty',
707+
() => {
708+
const {templateFile} =
709+
setup(`<input [@]>`, '', {}, ANIMATION_TRIGGER_FUNCTION, ANIMATION_METADATA);
710+
templateFile.moveCursorToText('[@¦]');
711+
712+
const completions = templateFile.getCompletionsAtPosition();
713+
expectContain(
714+
completions,
715+
unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ATTRIBUTE),
716+
['animationName']);
717+
});
718+
719+
it('should return the special animation control binding called @.disabled ', () => {
720+
const {templateFile} =
721+
setup(`<input [@.dis]>`, '', {}, ANIMATION_TRIGGER_FUNCTION, ANIMATION_METADATA);
722+
templateFile.moveCursorToText('[@.dis¦]');
723+
724+
const completions = templateFile.getCompletionsAtPosition();
725+
expectContain(
726+
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.ATTRIBUTE),
727+
['.disabled']);
728+
expectReplacementText(completions, templateFile.contents, '.dis');
729+
});
730+
731+
it('should return animation names for the event binding', () => {
732+
const {templateFile} =
733+
setup(`<input (@my)>`, '', {}, ANIMATION_TRIGGER_FUNCTION, ANIMATION_METADATA);
734+
templateFile.moveCursorToText('(@my¦)');
735+
736+
const completions = templateFile.getCompletionsAtPosition();
737+
expectContain(
738+
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
739+
['animationName']);
740+
expectReplacementText(completions, templateFile.contents, 'my');
741+
});
742+
743+
it('should return animation names when the event binding animation name is empty', () => {
744+
const {templateFile} =
745+
setup(`<input (@)>`, '', {}, ANIMATION_TRIGGER_FUNCTION, ANIMATION_METADATA);
746+
templateFile.moveCursorToText('(@¦)');
747+
748+
const completions = templateFile.getCompletionsAtPosition();
749+
expectContain(
750+
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
751+
['animationName']);
752+
});
753+
754+
it('should return the animation phase for the event binding', () => {
755+
const {templateFile} =
756+
setup(`<input (@my.do)>`, '', {}, ANIMATION_TRIGGER_FUNCTION, ANIMATION_METADATA);
757+
templateFile.moveCursorToText('(@my.do¦)');
758+
759+
const completions = templateFile.getCompletionsAtPosition();
760+
expectContain(
761+
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
762+
['done']);
763+
expectReplacementText(completions, templateFile.contents, 'do');
764+
});
765+
766+
it('should return the animation phase when the event binding animation phase is empty',
767+
() => {
768+
const {templateFile} =
769+
setup(`<input (@my.)>`, '', {}, ANIMATION_TRIGGER_FUNCTION, ANIMATION_METADATA);
770+
templateFile.moveCursorToText('(@my.¦)');
771+
772+
const completions = templateFile.getCompletionsAtPosition();
773+
expectContain(
774+
completions, unsafeCastDisplayInfoKindToScriptElementKind(DisplayInfoKind.EVENT),
775+
['done']);
776+
});
777+
});
778+
685779
it('should return input completions for a partial attribute', () => {
686780
const {templateFile} = setup(`<input my>`, '', DIR_WITH_SELECTED_INPUT);
687781
templateFile.moveCursorToText('my¦>');
@@ -1178,7 +1272,8 @@ function toText(displayParts?: ts.SymbolDisplayPart[]): string {
11781272
}
11791273

11801274
function setup(
1181-
template: string, classContents: string, otherDeclarations: {[name: string]: string} = {}): {
1275+
template: string, classContents: string, otherDeclarations: {[name: string]: string} = {},
1276+
functionDeclarations: string = '', componentMetadata: string = ''): {
11821277
templateFile: OpenBuffer,
11831278
} {
11841279
const decls = ['AppCmp', ...Object.keys(otherDeclarations)];
@@ -1190,9 +1285,12 @@ function setup(
11901285
'test.ts': `
11911286
import {Component, Directive, NgModule, Pipe, TemplateRef} from '@angular/core';
11921287
1288+
${functionDeclarations}
1289+
11931290
@Component({
11941291
templateUrl: './test.html',
11951292
selector: 'app-cmp',
1293+
${componentMetadata}
11961294
})
11971295
export class AppCmp {
11981296
${classContents}

0 commit comments

Comments
 (0)