Skip to content

Commit ff98ccb

Browse files
kyubisationthePunderWoman
authored andcommitted
feat(router): support custom elements for RouterLink (angular#60290)
PR Close angular#60290
1 parent 1226eaa commit ff98ccb

File tree

2 files changed

+105
-4
lines changed

2 files changed

+105
-4
lines changed

packages/router/src/directives/router_link.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,12 @@ import {RuntimeErrorCode} from '../errors';
130130
* });
131131
* ```
132132
*
133+
* ### RouterLink compatible custom elements
134+
*
135+
* In order to make a custom element work with routerLink, the corresponding custom
136+
* element must implement the `href` attribute and must list `href` in the array of
137+
* the static property/getter `observedAttributes`.
138+
*
133139
* @ngModule RouterModule
134140
*
135141
* @publicApi
@@ -140,13 +146,15 @@ import {RuntimeErrorCode} from '../errors';
140146
export class RouterLink implements OnChanges, OnDestroy {
141147
/**
142148
* Represents an `href` attribute value applied to a host element,
143-
* when a host element is `<a>`. For other tags, the value is `null`.
149+
* when a host element is an `<a>`/`<area>` tag or a compatible custom element.
150+
* For other tags, the value is `null`.
144151
*/
145152
href: string | null = null;
146153

147154
/**
148155
* Represents the `target` attribute on a host element.
149-
* This is only used when the host element is an `<a>` tag.
156+
* This is only used when the host element is
157+
* an `<a>`/`<area>` tag or a compatible custom element.
150158
*/
151159
@HostBinding('attr.target') @Input() target?: string;
152160

@@ -196,7 +204,7 @@ export class RouterLink implements OnChanges, OnDestroy {
196204
*/
197205
@Input() relativeTo?: ActivatedRoute | null;
198206

199-
/** Whether a host element is an `<a>` tag. */
207+
/** Whether a host element is an `<a>`/`<area>` tag or a compatible custom element. */
200208
private isAnchorElement: boolean;
201209

202210
private subscription?: Subscription;
@@ -215,7 +223,20 @@ export class RouterLink implements OnChanges, OnDestroy {
215223
private locationStrategy?: LocationStrategy,
216224
) {
217225
const tagName = el.nativeElement.tagName?.toLowerCase();
218-
this.isAnchorElement = tagName === 'a' || tagName === 'area';
226+
this.isAnchorElement =
227+
tagName === 'a' ||
228+
tagName === 'area' ||
229+
!!(
230+
// Avoid breaking in an SSR context where customElements might not be defined.
231+
(
232+
typeof customElements === 'object' &&
233+
// observedAttributes is an optional static property/getter on a custom element.
234+
// The spec states that this must be an array of strings.
235+
(
236+
customElements.get(tagName) as {observedAttributes?: string[]} | undefined
237+
)?.observedAttributes?.includes?.('href')
238+
)
239+
);
219240

220241
if (this.isAnchorElement) {
221242
this.subscription = router.events.subscribe((s: Event) => {

packages/router/test/router_link_spec.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -207,6 +207,86 @@ describe('RouterLink', () => {
207207
});
208208
});
209209

210+
// Avoid executing in node environment because customElements is not defined.
211+
if (typeof customElements === 'object') {
212+
describe('on a custom element anchor', () => {
213+
/** Simple anchor element imitation. */
214+
class CustomAnchor extends HTMLElement {
215+
static get observedAttributes(): string[] {
216+
return ['href'];
217+
}
218+
219+
get href(): string {
220+
return this.getAttribute('href') ?? '';
221+
}
222+
set href(value: string) {
223+
this.setAttribute('href', value);
224+
}
225+
226+
constructor() {
227+
super();
228+
const shadow = this.attachShadow({mode: 'open'});
229+
shadow.innerHTML = '<a><slot></slot></a>';
230+
}
231+
232+
attributedChangedCallback(name: string, _oldValue: string | null, newValue: string | null) {
233+
if (name === 'href') {
234+
const anchor = this.shadowRoot!.querySelector('a')!;
235+
if (newValue === null) {
236+
anchor.removeAttribute('href');
237+
} else {
238+
anchor.setAttribute('href', newValue);
239+
}
240+
}
241+
}
242+
}
243+
244+
if (!customElements.get('custom-anchor')) {
245+
customElements.define('custom-anchor', CustomAnchor);
246+
}
247+
248+
@Component({
249+
template: `
250+
<custom-anchor [routerLink]="link()"></custom-anchor>
251+
`,
252+
standalone: false,
253+
})
254+
class LinkComponent {
255+
link = signal<string | null | undefined>('/');
256+
}
257+
let fixture: ComponentFixture<LinkComponent>;
258+
let link: HTMLAnchorElement;
259+
260+
beforeEach(async () => {
261+
TestBed.configureTestingModule({
262+
imports: [RouterModule.forRoot([])],
263+
declarations: [LinkComponent],
264+
});
265+
fixture = TestBed.createComponent(LinkComponent);
266+
await fixture.whenStable();
267+
link = fixture.debugElement.query(By.css('custom-anchor')).nativeElement;
268+
});
269+
270+
it('does not touch tabindex', async () => {
271+
expect(link.outerHTML).not.toContain('tabindex');
272+
});
273+
274+
it('null, removes href', async () => {
275+
expect(link.outerHTML).toContain('href');
276+
fixture.componentInstance.link.set(null);
277+
await fixture.whenStable();
278+
expect(link.outerHTML).not.toContain('href');
279+
});
280+
281+
it('undefined, removes href', async () => {
282+
expect(link.outerHTML).toContain('href');
283+
fixture.componentInstance.link.set(undefined);
284+
await fixture.whenStable();
285+
expect(link.outerHTML).not.toContain('href');
286+
});
287+
});
288+
}
289+
210290
it('can use a UrlTree as the input', async () => {
211291
@Component({
212292
template: '<a [routerLink]="urlTree">link</a>',

0 commit comments

Comments
 (0)