From ff98ccb19391ed4e04528b82771c04ad67067d68 Mon Sep 17 00:00:00 2001 From: Lukas Spirig Date: Sat, 8 Mar 2025 23:07:59 +0100 Subject: [PATCH] feat(router): support custom elements for RouterLink (#60290) PR Close #60290 --- packages/router/src/directives/router_link.ts | 29 ++++++- packages/router/test/router_link_spec.ts | 80 +++++++++++++++++++ 2 files changed, 105 insertions(+), 4 deletions(-) diff --git a/packages/router/src/directives/router_link.ts b/packages/router/src/directives/router_link.ts index 9e4ceeb608e..5389015a083 100644 --- a/packages/router/src/directives/router_link.ts +++ b/packages/router/src/directives/router_link.ts @@ -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 ``. For other tags, the value is `null`. + * when a host element is an ``/`` 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 `` tag. + * This is only used when the host element is + * an ``/`` 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 `` tag. */ + /** Whether a host element is an ``/`` 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) => { diff --git a/packages/router/test/router_link_spec.ts b/packages/router/test/router_link_spec.ts index c5ecff62f55..69d0f657252 100644 --- a/packages/router/test/router_link_spec.ts +++ b/packages/router/test/router_link_spec.ts @@ -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 = ''; + } + + 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: ` + + `, + standalone: false, + }) + class LinkComponent { + link = signal('/'); + } + let fixture: ComponentFixture; + 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: 'link',