mirror of
https://github.com/angular/angular
synced 2026-05-24 09:28:37 +00:00
feat(core): add API to inject attributes on the host node (#54604)
Angular has the `@Attribute` decorator that allows for attributes to be injected from the host node, but we don't have an equivalent for the `inject` function. These changes introduce the new `HostAttributeToken` class that can be used to inject attributes similarly to `@Attribute`. It can be used as follows:
```typescript
import {HostAttributeToken, inject} from '@angular/core';
class MyDir {
someAttr = inject(new HostAttributeToken('some-attr'));
}
```
The new API works similarly to `@Attribute` with one key exception: it will throw a DI error when the attribute doesn't exist, instead of returning `null` like `@Attribute`. We made this change to align its behavior closer to other injection tokens.
PR Close #54604
This commit is contained in:
parent
4066e62db6
commit
331b16efd2
6 changed files with 396 additions and 5 deletions
|
|
@ -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<T>(token: ProviderToken<T>, options: InjectOptions & {
|
|||
// @public (undocumented)
|
||||
export function inject<T>(token: ProviderToken<T>, 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<any> | 'root' | 'platform' | 'any' | null;
|
||||
|
|
|
|||
41
packages/core/src/di/host_attribute_token.ts
Normal file
41
packages/core/src/di/host_attribute_token.ts
Normal file
|
|
@ -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}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<T>(token: ProviderToken<T>, flags = InjectFla
|
|||
*/
|
||||
export function ɵɵinject<T>(token: ProviderToken<T>): T;
|
||||
export function ɵɵinject<T>(token: ProviderToken<T>, flags?: InjectFlags): T|null;
|
||||
export function ɵɵinject<T>(token: ProviderToken<T>, 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<T>(
|
||||
token: ProviderToken<T>|HostAttributeToken, flags?: InjectFlags): string|null;
|
||||
export function ɵɵinject<T>(
|
||||
token: ProviderToken<T>|HostAttributeToken, flags = InjectFlags.Default): T|null {
|
||||
return (getInjectImplementation() || injectInjectorOnly)(
|
||||
resolveForwardRef(token as Type<T>), flags);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -153,6 +160,30 @@ export function inject<T>(token: ProviderToken<T>, options: InjectOptions&{optio
|
|||
* @publicApi
|
||||
*/
|
||||
export function inject<T>(token: ProviderToken<T>, 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<T>(token: ProviderToken<T>, options: InjectOptions): T|nu
|
|||
* @publicApi
|
||||
*/
|
||||
export function inject<T>(
|
||||
token: ProviderToken<T>, flags: InjectFlags|InjectOptions = InjectFlags.Default): T|null {
|
||||
return ɵɵinject(token, convertToBitFlags(flags));
|
||||
token: ProviderToken<T>|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`).
|
||||
|
|
|
|||
|
|
@ -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: '<div dir some-attr="foo" other="ignore"></div>',
|
||||
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 <ng-template>', () => {
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
class Dir {
|
||||
value = inject(new HostAttributeToken('some-attr'));
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: '<ng-template dir some-attr="foo" other="ignore"></ng-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 <ng-container>', () => {
|
||||
@Directive({selector: '[dir]', standalone: true})
|
||||
class Dir {
|
||||
value = inject(new HostAttributeToken('some-attr'));
|
||||
}
|
||||
|
||||
@Component({
|
||||
standalone: true,
|
||||
template: '<ng-container dir some-attr="foo" other="ignore"></ng-container>',
|
||||
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: `
|
||||
<div
|
||||
dir
|
||||
style="margin: 1px; color: red;"
|
||||
class="hello there"
|
||||
some-attr="foo"
|
||||
other="ignore"></div>
|
||||
`,
|
||||
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: '<div dir other="ignore"></div>',
|
||||
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: '<div dir other="ignore"></div>',
|
||||
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: `
|
||||
<div dir some-attr="foo" svg:exists="testExistValue" other="otherValue"></div>
|
||||
`,
|
||||
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: `
|
||||
<div
|
||||
dir
|
||||
exists="existsValue"
|
||||
[binding]="bindingValue"
|
||||
(output)="noop()"
|
||||
other="otherValue"
|
||||
ignore="ignoreValue"></div>`
|
||||
})
|
||||
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: '<div dir title="foo {{value}}" other="ignore"></div>',
|
||||
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: '<div dir some-attr="foo" other="ignore"></div>',
|
||||
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: '<div dir other="ignore"></div>',
|
||||
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: '<div dir other="ignore"></div>',
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -1367,6 +1367,9 @@
|
|||
{
|
||||
"name": "init_hooks"
|
||||
},
|
||||
{
|
||||
"name": "init_host_attribute_token"
|
||||
},
|
||||
{
|
||||
"name": "init_host_directives_feature"
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue