diff --git a/goldens/public-api/core/index.md b/goldens/public-api/core/index.md index e2644aef90f..48542e3cc33 100644 --- a/goldens/public-api/core/index.md +++ b/goldens/public-api/core/index.md @@ -720,6 +720,13 @@ export interface Host { // @public export const Host: HostDecorator; +// @public +export class HostAttributeToken { + constructor(attributeName: string); + // (undocumented) + toString(): string; +} + // @public export interface HostBinding { hostPropertyName?: string; @@ -789,6 +796,19 @@ export function inject(token: ProviderToken, options: InjectOptions & { // @public (undocumented) export function inject(token: ProviderToken, options: InjectOptions): T | null; +// @public (undocumented) +export function inject(token: HostAttributeToken): string; + +// @public (undocumented) +export function inject(token: HostAttributeToken, options: { + optional: true; +}): string | null; + +// @public (undocumented) +export function inject(token: HostAttributeToken, options: { + optional: false; +}): string; + // @public export interface Injectable { providedIn?: Type | 'root' | 'platform' | 'any' | null; diff --git a/packages/core/src/di/host_attribute_token.ts b/packages/core/src/di/host_attribute_token.ts new file mode 100644 index 00000000000..f77deae0142 --- /dev/null +++ b/packages/core/src/di/host_attribute_token.ts @@ -0,0 +1,41 @@ +/*! + * @license + * Copyright Google LLC All Rights Reserved. + * + * Use of this source code is governed by an MIT-style license that can be + * found in the LICENSE file at https://angular.io/license + */ + +import {ɵɵinjectAttribute} from '../render3/instructions/di_attr'; + +/** + * Creates a token that can be used to inject static attributes of the host node. + * + * @usageNotes + * ### Injecting an attribute that is known to exist + * ```typescript + * @Directive() + * class MyDir { + * attr: string = inject(new HostAttributeToken('some-attr')); + * } + * ``` + * + * ### Optionally injecting an attribute + * ```typescript + * @Directive() + * class MyDir { + * attr: string | null = inject(new HostAttributeToken('some-attr'), {optional: true}); + * } + * ``` + * @publicApi + */ +export class HostAttributeToken { + constructor(private attributeName: string) {} + + /** @internal */ + __NG_ELEMENT_ID__ = () => ɵɵinjectAttribute(this.attributeName); + + toString(): string { + return `HostAttributeToken ${this.attributeName}`; + } +} diff --git a/packages/core/src/di/index.ts b/packages/core/src/di/index.ts index 953b0c8d51b..a70150a96a5 100644 --- a/packages/core/src/di/index.ts +++ b/packages/core/src/di/index.ts @@ -28,3 +28,4 @@ export {InjectOptions} from './interface/injector'; export {INJECTOR} from './injector_token'; export {ClassProvider, ModuleWithProviders, ClassSansProvider, ImportedNgModuleProviders, ConstructorProvider, EnvironmentProviders, ConstructorSansProvider, ExistingProvider, ExistingSansProvider, FactoryProvider, FactorySansProvider, Provider, StaticClassProvider, StaticClassSansProvider, StaticProvider, TypeProvider, ValueProvider, ValueSansProvider} from './interface/provider'; export {InjectionToken} from './injection_token'; +export {HostAttributeToken} from './host_attribute_token'; diff --git a/packages/core/src/di/injector_compatibility.ts b/packages/core/src/di/injector_compatibility.ts index 46aef910deb..906cae55ef9 100644 --- a/packages/core/src/di/injector_compatibility.ts +++ b/packages/core/src/di/injector_compatibility.ts @@ -18,6 +18,7 @@ import {getInjectImplementation, injectRootLimpMode} from './inject_switch'; import type {Injector} from './injector'; import {DecoratorFlags, InjectFlags, InjectOptions, InternalInjectFlags} from './interface/injector'; import {ProviderToken} from './provider_token'; +import type {HostAttributeToken} from './host_attribute_token'; const _THROW_IF_NOT_FOUND = {}; @@ -85,8 +86,14 @@ export function injectInjectorOnly(token: ProviderToken, flags = InjectFla */ export function ɵɵinject(token: ProviderToken): T; export function ɵɵinject(token: ProviderToken, flags?: InjectFlags): T|null; -export function ɵɵinject(token: ProviderToken, flags = InjectFlags.Default): T|null { - return (getInjectImplementation() || injectInjectorOnly)(resolveForwardRef(token), flags); +export function ɵɵinject(token: HostAttributeToken): string; +export function ɵɵinject(token: HostAttributeToken, flags?: InjectFlags): string|null; +export function ɵɵinject( + token: ProviderToken|HostAttributeToken, flags?: InjectFlags): string|null; +export function ɵɵinject( + token: ProviderToken|HostAttributeToken, flags = InjectFlags.Default): T|null { + return (getInjectImplementation() || injectInjectorOnly)( + resolveForwardRef(token as Type), flags); } /** @@ -153,6 +160,30 @@ export function inject(token: ProviderToken, options: InjectOptions&{optio * @publicApi */ export function inject(token: ProviderToken, options: InjectOptions): T|null; +/** + * @param token A token that represents a static attribute on the host node that should be injected. + * @returns Value of the attribute if it exists. + * @throws If called outside of a supported context or the attribute does not exist. + * + * @publicApi + */ +export function inject(token: HostAttributeToken): string; +/** + * @param token A token that represents a static attribute on the host node that should be injected. + * @returns Value of the attribute if it exists, otherwise `null`. + * @throws If called outside of a supported context. + * + * @publicApi + */ +export function inject(token: HostAttributeToken, options: {optional: true}): string|null; +/** + * @param token A token that represents a static attribute on the host node that should be injected. + * @returns Value of the attribute if it exists. + * @throws If called outside of a supported context or the attribute does not exist. + * + * @publicApi + */ +export function inject(token: HostAttributeToken, options: {optional: false}): string; /** * Injects a token from the currently active injector. * `inject` is only supported in an [injection context](/guide/dependency-injection-context). It can @@ -219,8 +250,11 @@ export function inject(token: ProviderToken, options: InjectOptions): T|nu * @publicApi */ export function inject( - token: ProviderToken, flags: InjectFlags|InjectOptions = InjectFlags.Default): T|null { - return ɵɵinject(token, convertToBitFlags(flags)); + token: ProviderToken|HostAttributeToken, + flags: InjectFlags|InjectOptions = InjectFlags.Default) { + // The `as any` here _shouldn't_ be necessary, but without it JSCompiler + // throws a disambiguation error due to the multiple signatures. + return ɵɵinject(token as any, convertToBitFlags(flags)); } // Converts object-based DI flags (`InjectOptions`) to bit flags (`InjectFlags`). diff --git a/packages/core/test/acceptance/di_spec.ts b/packages/core/test/acceptance/di_spec.ts index 8b94e5a7a9d..f3b1ad09c66 100644 --- a/packages/core/test/acceptance/di_spec.ts +++ b/packages/core/test/acceptance/di_spec.ts @@ -7,7 +7,7 @@ */ import {CommonModule} from '@angular/common'; -import {assertInInjectionContext, Attribute, ChangeDetectorRef, Component, ComponentRef, createEnvironmentInjector, createNgModule, Directive, ElementRef, ENVIRONMENT_INITIALIZER, EnvironmentInjector, EventEmitter, forwardRef, Host, HostBinding, ImportedNgModuleProviders, importProvidersFrom, ImportProvidersSource, inject, Inject, Injectable, InjectFlags, InjectionToken, InjectOptions, INJECTOR, Injector, Input, LOCALE_ID, makeEnvironmentProviders, ModuleWithProviders, NgModule, NgModuleRef, NgZone, Optional, Output, Pipe, PipeTransform, Provider, runInInjectionContext, Self, SkipSelf, TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation, ViewRef, ɵcreateInjector as createInjector, ɵDEFAULT_LOCALE_ID as DEFAULT_LOCALE_ID, ɵINJECTOR_SCOPE, ɵInternalEnvironmentProviders as InternalEnvironmentProviders} from '@angular/core'; +import {assertInInjectionContext, Attribute, ChangeDetectorRef, Component, ComponentRef, createEnvironmentInjector, createNgModule, Directive, ElementRef, ENVIRONMENT_INITIALIZER, EnvironmentInjector, EventEmitter, forwardRef, Host, HostAttributeToken, HostBinding, importProvidersFrom, ImportProvidersSource, inject, Inject, Injectable, InjectFlags, InjectionToken, InjectOptions, INJECTOR, Injector, Input, LOCALE_ID, makeEnvironmentProviders, ModuleWithProviders, NgModule, NgModuleRef, NgZone, Optional, Output, Pipe, PipeTransform, Provider, runInInjectionContext, Self, SkipSelf, TemplateRef, Type, ViewChild, ViewContainerRef, ViewEncapsulation, ViewRef, ɵcreateInjector as createInjector, ɵDEFAULT_LOCALE_ID as DEFAULT_LOCALE_ID, ɵINJECTOR_SCOPE, ɵInternalEnvironmentProviders as InternalEnvironmentProviders} from '@angular/core'; import {RuntimeError, RuntimeErrorCode} from '@angular/core/src/errors'; import {ViewRef as ViewRefInternal} from '@angular/core/src/render3/view_ref'; import {TestBed} from '@angular/core/testing'; @@ -4215,6 +4215,298 @@ describe('di', () => { }); }); + describe('HostAttributeToken', () => { + it('should inject an attribute on an element node', () => { + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = inject(new HostAttributeToken('some-attr')); + } + + @Component({ + standalone: true, + template: '
', + imports: [Dir], + }) + class TestCmp { + @ViewChild(Dir) dir!: Dir; + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + expect(fixture.componentInstance.dir.value).toBe('foo'); + }); + + it('should inject an attribute on ', () => { + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = inject(new HostAttributeToken('some-attr')); + } + + @Component({ + standalone: true, + template: '', + imports: [Dir], + }) + class TestCmp { + @ViewChild(Dir) dir!: Dir; + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + expect(fixture.componentInstance.dir.value).toBe('foo'); + }); + + it('should inject an attribute on ', () => { + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = inject(new HostAttributeToken('some-attr')); + } + + @Component({ + standalone: true, + template: '', + imports: [Dir], + }) + class TestCmp { + @ViewChild(Dir) dir!: Dir; + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + expect(fixture.componentInstance.dir.value).toBe('foo'); + }); + + it('should be able to inject different kinds of attributes', () => { + @Directive({selector: '[dir]', standalone: true}) + class Dir { + className = inject(new HostAttributeToken('class')); + inlineStyles = inject(new HostAttributeToken('style')); + value = inject(new HostAttributeToken('some-attr')); + } + + @Component({ + standalone: true, + template: ` +
+ `, + imports: [Dir], + }) + class TestCmp { + @ViewChild(Dir) dir!: Dir; + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + + const directive = fixture.componentInstance.dir; + expect(directive.className).toBe('hello there'); + expect(directive.inlineStyles).toMatch(/color:\s*red/); + expect(directive.inlineStyles).toMatch(/margin:\s*1px/); + expect(directive.value).toBe('foo'); + }); + + it('should throw a DI error when injecting a non-existent attribute', () => { + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = inject(new HostAttributeToken('some-attr')); + } + + @Component({ + standalone: true, + template: '
', + imports: [Dir], + }) + class TestCmp { + @ViewChild(Dir) dir!: Dir; + } + + expect(() => TestBed.createComponent(TestCmp)) + .toThrowError(/No provider for HostAttributeToken some-attr found/); + }); + + it('should not throw a DI error when injecting a non-existent attribute with optional: true', + () => { + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = inject(new HostAttributeToken('some-attr'), {optional: true}); + } + + @Component({ + standalone: true, + template: '
', + imports: [Dir], + }) + class TestCmp { + @ViewChild(Dir) dir!: Dir; + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + expect(fixture.componentInstance.dir.value).toBe(null); + }); + + it('should not inject attributes with namespace', () => { + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = inject(new HostAttributeToken('some-attr'), {optional: true}); + namespaceExists = inject(new HostAttributeToken('svg:exist'), {optional: true}); + other = inject(new HostAttributeToken('other'), {optional: true}); + } + + @Component({ + standalone: true, + template: ` +
+ `, + imports: [Dir], + }) + class TestCmp { + @ViewChild(Dir) dir!: Dir; + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + + const directive = fixture.componentInstance.dir; + expect(directive.value).toBe('foo'); + expect(directive.namespaceExists).toBe(null); + expect(directive.other).toBe('otherValue'); + }); + + it('should not inject attributes representing bindings and outputs', () => { + @Directive({selector: '[dir]', standalone: true}) + class Dir { + @Input() binding!: string; + @Output() output = new EventEmitter(); + + exists = inject(new HostAttributeToken('exists')); + bindingAttr = inject(new HostAttributeToken('binding'), {optional: true}); + outputAttr = inject(new HostAttributeToken('output'), {optional: true}); + other = inject(new HostAttributeToken('other')); + } + + @Component({ + standalone: true, + imports: [Dir], + template: ` +
` + }) + class TestCmp { + @ViewChild(Dir) dir!: Dir; + bindingValue = 'hello'; + noop() {} + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + + const directive = fixture.componentInstance.dir; + expect(directive.exists).toBe('existsValue'); + expect(directive.bindingAttr).toBe(null); + expect(directive.outputAttr).toBe(null); + expect(directive.other).toBe('otherValue'); + }); + + it('should not inject data-bound attributes', () => { + @Directive({selector: '[dir]', standalone: true}) + class Dir { + value = inject(new HostAttributeToken('title'), {optional: true}); + } + + @Component({ + standalone: true, + template: '
', + imports: [Dir], + }) + class TestCmp { + @ViewChild(Dir) dir!: Dir; + value = 123; + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + + expect(fixture.componentInstance.dir.value).toBe(null); + expect(fixture.nativeElement.querySelector('[dir]').getAttribute('title')).toBe('foo 123'); + }); + + it('should inject an attribute using @Inject', () => { + const TOKEN = new HostAttributeToken('some-attr'); + + @Directive({selector: '[dir]', standalone: true}) + class Dir { + constructor(@Inject(TOKEN) readonly value: string) {} + } + + @Component({ + standalone: true, + template: '
', + imports: [Dir], + }) + class TestCmp { + @ViewChild(Dir) dir!: Dir; + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + expect(fixture.componentInstance.dir.value).toBe('foo'); + }); + + it('should throw when injecting a non-existent attribute using @Inject', () => { + const TOKEN = new HostAttributeToken('some-attr'); + + @Directive({selector: '[dir]', standalone: true}) + class Dir { + constructor(@Inject(TOKEN) readonly value: string) {} + } + + @Component({ + standalone: true, + template: '
', + imports: [Dir], + }) + class TestCmp { + @ViewChild(Dir) dir!: Dir; + } + + expect(() => TestBed.createComponent(TestCmp)) + .toThrowError(/No provider for HostAttributeToken some-attr found/); + }); + + it('should not throw when injecting a non-existent attribute using @Inject @Optional', () => { + const TOKEN = new HostAttributeToken('some-attr'); + + @Directive({selector: '[dir]', standalone: true}) + class Dir { + constructor(@Inject(TOKEN) @Optional() readonly value: string|null) {} + } + + @Component({ + standalone: true, + template: '
', + imports: [Dir], + }) + class TestCmp { + @ViewChild(Dir) dir!: Dir; + } + + const fixture = TestBed.createComponent(TestCmp); + fixture.detectChanges(); + expect(fixture.componentInstance.dir.value).toBe(null); + }); + }); + it('should support dependencies in Pipes used inside ICUs', () => { @Injectable() class MyService { diff --git a/packages/core/test/bundling/defer/bundle.golden_symbols.json b/packages/core/test/bundling/defer/bundle.golden_symbols.json index 6afc841fb9c..4bb25d181d9 100644 --- a/packages/core/test/bundling/defer/bundle.golden_symbols.json +++ b/packages/core/test/bundling/defer/bundle.golden_symbols.json @@ -1367,6 +1367,9 @@ { "name": "init_hooks" }, + { + "name": "init_host_attribute_token" + }, { "name": "init_host_directives_feature" },