feat(router): support custom elements for RouterLink (#60290)

PR Close #60290
This commit is contained in:
Lukas Spirig
2025-03-08 23:07:59 +01:00
committed by Jessica Janiuk
parent 1226eaad51
commit ff98ccb193
2 changed files with 105 additions and 4 deletions
+25 -4
View File
@@ -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) => {
+80
View File
@@ -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>',