mirror of
https://github.com/angular/angular.git
synced 2026-09-14 13:54:52 +08:00
feat(router): support custom elements for RouterLink (#60290)
PR Close #60290
This commit is contained in:
committed by
Jessica Janiuk
parent
1226eaad51
commit
ff98ccb193
@@ -130,6 +130,12 @@ import {RuntimeErrorCode} from '../errors';
|
||||
* });
|
||||
* ```
|
||||
*
|
||||
* ### RouterLink compatible custom elements
|
||||
*
|
||||
* In order to make a custom element work with routerLink, the corresponding custom
|
||||
* element must implement the `href` attribute and must list `href` in the array of
|
||||
* the static property/getter `observedAttributes`.
|
||||
*
|
||||
* @ngModule RouterModule
|
||||
*
|
||||
* @publicApi
|
||||
@@ -140,13 +146,15 @@ import {RuntimeErrorCode} from '../errors';
|
||||
export class RouterLink implements OnChanges, OnDestroy {
|
||||
/**
|
||||
* Represents an `href` attribute value applied to a host element,
|
||||
* when a host element is `<a>`. For other tags, the value is `null`.
|
||||
* when a host element is an `<a>`/`<area>` tag or a compatible custom element.
|
||||
* For other tags, the value is `null`.
|
||||
*/
|
||||
href: string | null = null;
|
||||
|
||||
/**
|
||||
* Represents the `target` attribute on a host element.
|
||||
* This is only used when the host element is an `<a>` tag.
|
||||
* This is only used when the host element is
|
||||
* an `<a>`/`<area>` tag or a compatible custom element.
|
||||
*/
|
||||
@HostBinding('attr.target') @Input() target?: string;
|
||||
|
||||
@@ -196,7 +204,7 @@ export class RouterLink implements OnChanges, OnDestroy {
|
||||
*/
|
||||
@Input() relativeTo?: ActivatedRoute | null;
|
||||
|
||||
/** Whether a host element is an `<a>` tag. */
|
||||
/** Whether a host element is an `<a>`/`<area>` tag or a compatible custom element. */
|
||||
private isAnchorElement: boolean;
|
||||
|
||||
private subscription?: Subscription;
|
||||
@@ -215,7 +223,20 @@ export class RouterLink implements OnChanges, OnDestroy {
|
||||
private locationStrategy?: LocationStrategy,
|
||||
) {
|
||||
const tagName = el.nativeElement.tagName?.toLowerCase();
|
||||
this.isAnchorElement = tagName === 'a' || tagName === 'area';
|
||||
this.isAnchorElement =
|
||||
tagName === 'a' ||
|
||||
tagName === 'area' ||
|
||||
!!(
|
||||
// Avoid breaking in an SSR context where customElements might not be defined.
|
||||
(
|
||||
typeof customElements === 'object' &&
|
||||
// observedAttributes is an optional static property/getter on a custom element.
|
||||
// The spec states that this must be an array of strings.
|
||||
(
|
||||
customElements.get(tagName) as {observedAttributes?: string[]} | undefined
|
||||
)?.observedAttributes?.includes?.('href')
|
||||
)
|
||||
);
|
||||
|
||||
if (this.isAnchorElement) {
|
||||
this.subscription = router.events.subscribe((s: Event) => {
|
||||
|
||||
@@ -207,6 +207,86 @@ describe('RouterLink', () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Avoid executing in node environment because customElements is not defined.
|
||||
if (typeof customElements === 'object') {
|
||||
describe('on a custom element anchor', () => {
|
||||
/** Simple anchor element imitation. */
|
||||
class CustomAnchor extends HTMLElement {
|
||||
static get observedAttributes(): string[] {
|
||||
return ['href'];
|
||||
}
|
||||
|
||||
get href(): string {
|
||||
return this.getAttribute('href') ?? '';
|
||||
}
|
||||
set href(value: string) {
|
||||
this.setAttribute('href', value);
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
const shadow = this.attachShadow({mode: 'open'});
|
||||
shadow.innerHTML = '<a><slot></slot></a>';
|
||||
}
|
||||
|
||||
attributedChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
|
||||
if (name === 'href') {
|
||||
const anchor = this.shadowRoot!.querySelector('a')!;
|
||||
if (newValue === null) {
|
||||
anchor.removeAttribute('href');
|
||||
} else {
|
||||
anchor.setAttribute('href', newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!customElements.get('custom-anchor')) {
|
||||
customElements.define('custom-anchor', CustomAnchor);
|
||||
}
|
||||
|
||||
@Component({
|
||||
template: `
|
||||
<custom-anchor [routerLink]="link()"></custom-anchor>
|
||||
`,
|
||||
standalone: false,
|
||||
})
|
||||
class LinkComponent {
|
||||
link = signal<string | null | undefined>('/');
|
||||
}
|
||||
let fixture: ComponentFixture<LinkComponent>;
|
||||
let link: HTMLAnchorElement;
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [RouterModule.forRoot([])],
|
||||
declarations: [LinkComponent],
|
||||
});
|
||||
fixture = TestBed.createComponent(LinkComponent);
|
||||
await fixture.whenStable();
|
||||
link = fixture.debugElement.query(By.css('custom-anchor')).nativeElement;
|
||||
});
|
||||
|
||||
it('does not touch tabindex', async () => {
|
||||
expect(link.outerHTML).not.toContain('tabindex');
|
||||
});
|
||||
|
||||
it('null, removes href', async () => {
|
||||
expect(link.outerHTML).toContain('href');
|
||||
fixture.componentInstance.link.set(null);
|
||||
await fixture.whenStable();
|
||||
expect(link.outerHTML).not.toContain('href');
|
||||
});
|
||||
|
||||
it('undefined, removes href', async () => {
|
||||
expect(link.outerHTML).toContain('href');
|
||||
fixture.componentInstance.link.set(undefined);
|
||||
await fixture.whenStable();
|
||||
expect(link.outerHTML).not.toContain('href');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
it('can use a UrlTree as the input', async () => {
|
||||
@Component({
|
||||
template: '<a [routerLink]="urlTree">link</a>',
|
||||
|
||||
Reference in New Issue
Block a user