Skip to content
3 changes: 3 additions & 0 deletions cypress/e2e/collection-edit.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ describe('Edit Collection > Delete page', () => {
// <ds-delete-collection> tag must be loaded
cy.get('ds-delete-collection').should('be.visible');

// Wait for inner content to render before running axe
cy.get('ds-delete-collection h1#header', { timeout: 30000 }).should('be.visible');

// Analyze for accessibility issues
testA11y('ds-delete-collection');
});
Expand Down
3 changes: 3 additions & 0 deletions cypress/e2e/community-edit.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,9 @@ describe('Edit Community > Delete page', () => {
// <ds-delete-community> tag must be loaded
cy.get('ds-delete-community').should('be.visible');

// Wait for inner content to render before running axe
cy.get('ds-delete-community h1#header', { timeout: 30000 }).should('be.visible');

// Analyze for accessibility issues
testA11y('ds-delete-community');
});
Expand Down
3 changes: 3 additions & 0 deletions cypress/e2e/item-edit.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ describe('Edit Item > Status tab', () => {
// <ds-item-status> tag must be loaded
cy.get('ds-item-status').should('be.visible');

// Wait for the actual status content to render before running axe
cy.get('ds-item-status .status-label', { timeout: 30000 }).should('be.visible');

// Analyze for accessibility issues
testA11y('ds-item-status');
});
Expand Down
6 changes: 6 additions & 0 deletions cypress/e2e/item-page.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ describe('Item Page', () => {
// <ds-full-item-page> tag must be loaded
cy.get('ds-full-item-page').should('be.visible');

// Wait for the inner content (item-page) to actually render — the host
// element gets its size from padding/header even before the item details
// resolve, so visibility alone isn't enough for axe to find any nodes.
cy.get('ds-full-item-page .item-page', { timeout: 30000 }).should('exist');
cy.get('ds-full-item-page ds-item-page-title-field', { timeout: 30000 }).should('be.visible');

// Analyze <ds-full-item-page> for accessibility issues
testA11y('ds-full-item-page');
});
Expand Down
24 changes: 24 additions & 0 deletions cypress/support/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,5 +40,29 @@ export const testA11y = (context?: any, options?: Options) => {
{ id: 'color-contrast', enabled: false },
],
});
// When the include context is a CSS selector string, axe-core re-resolves
// that selector at axe.run time via document.querySelectorAll. Between
// Cypress commands (injectAxe + configureAxe take a few ms each) Angular
// may re-render and the host element can briefly disappear from the
// document, causing axe to throw
// "No elements found for include in page Context".
//
// Fix: wait for the host to exist with rendered content, then resolve the
// selector to a live DOM Element here and pass that Element reference (not
// the selector string) to cy.checkA11y. axe-core uses the Element directly
// and does not re-query the document by selector, eliminating the race.
if (typeof context === 'string') {
cy.get(context, { timeout: 30000 }).should('exist');
cy.get(`${context} *`, { timeout: 30000 }).should('exist');
cy.get(context).then(($el) => {
// Pass ALL matched elements (not just the first) so that selectors which
// resolve to multiple nodes preserve the original string-context coverage.
// axe-core accepts an Array of Elements as Context.
const elements = $el.toArray();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
cy.checkA11y(elements as any, options, terminalLog);
});
return;
}
cy.checkA11y(context, options, terminalLog);
};
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
@if (linkType !== linkTypes.None) {
<a [target]="(linkType === linkTypes.ExternalLink) ? '_blank' : '_self'"
[attr.rel]="(linkType === linkTypes.ExternalLink) ? 'noopener noreferrer' : null"
[routerLink]="[itemPageRoute]" class="lead item-list-title dont-break-out" tabindex="-1">
[routerLink]="[itemPageRoute]" class="lead item-list-title dont-break-out" tabindex="-1"
[attr.aria-label]="dsoNameService.getName(dso)">
<ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="true">
</ds-thumbnail>
</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
@if (linkType !== linkTypes.None) {
<a [target]="(linkType === linkTypes.ExternalLink) ? '_blank' : '_self'"
[attr.rel]="(linkType === linkTypes.ExternalLink) ? 'noopener noreferrer' : null"
[routerLink]="[itemPageRoute]" class="lead item-list-title dont-break-out" tabindex="-1">
[routerLink]="[itemPageRoute]" class="lead item-list-title dont-break-out" tabindex="-1"
[attr.aria-label]="dsoNameService.getName(dso)">
<ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="true">
</ds-thumbnail>
</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
<div class="col-3 col-md-2">
@if (linkType !== linkTypes.None) {
<a [target]="(linkType === linkTypes.ExternalLink) ? '_blank' : '_self'" [attr.rel]="(linkType === linkTypes.ExternalLink) ? 'noopener noreferrer' : null"
[routerLink]="[itemPageRoute]" class="lead item-list-title dont-break-out" tabindex="-1">
[routerLink]="[itemPageRoute]" class="lead item-list-title dont-break-out" tabindex="-1"
[attr.aria-label]="dsoNameService.getName(dso)">
<ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="true">
</ds-thumbnail>
</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,8 @@
@if (linkType !== linkTypes.None) {
<a [target]="(linkType === linkTypes.ExternalLink) ? '_blank' : '_self'"
[attr.rel]="(linkType === linkTypes.ExternalLink) ? 'noopener noreferrer' : null"
[routerLink]="[itemPageRoute]" class="dont-break-out" tabindex="-1">
[routerLink]="[itemPageRoute]" class="dont-break-out" tabindex="-1"
[attr.aria-label]="dsoNameService.getName(dso)">
<ds-thumbnail [thumbnail]="dso?.thumbnail | async"
[defaultImage]="'assets/images/project-placeholder.svg'"
[alt]="'thumbnail.project.alt'"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,11 @@ <h1 class="border-bottom">{{'item.edit.head' | translate}}</h1>
<span [ngbTooltip]="'item.edit.tabs.disabled.tooltip' | translate">
@if ((page.enabled | async) !== true) {
<button
class="nav-link disabled">
class="nav-link disabled"
role="tab"
aria-disabled="true"
[attr.aria-selected]="false"
tabindex="-1">
{{'item.edit.tabs.' + page.page + '.head' | translate}}
</button>
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@
<div class="col-3 col-md-2">
@if (linkType !== linkTypes.None) {
<a [target]="(linkType === linkTypes.ExternalLink) ? '_blank' : '_self'" [attr.rel]="(linkType === linkTypes.ExternalLink) ? 'noopener noreferrer' : null"
[routerLink]="[itemPageRoute]" class="dont-break-out" tabindex="-1">
[routerLink]="[itemPageRoute]" class="dont-break-out" tabindex="-1"
[attr.aria-label]="dsoNameService.getName(dso)">
<ds-thumbnail [thumbnail]="dso?.thumbnail | async" [limitWidth]="true">
</ds-thumbnail>
</a>
Expand Down
15 changes: 14 additions & 1 deletion src/app/shared/utils/metadata-link.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,12 @@ export class MetadataLinkService {

constructor(private configService: ConfigurationDataService) {
this.resolvers$ = this.loadResolvers();
// Eagerly trigger the resolver config fetch as soon as the service is
// instantiated (singleton, providedIn: 'root'). Without this, resolvers$
// is only subscribed to lazily by template `async` pipes. Combined with
// refCount: false on shareReplay this warms the replay buffer for the
// lifetime of the service so that the 5 HTTP calls happen only once.
this.resolvers$.subscribe();
}

/**
Expand Down Expand Up @@ -88,7 +94,14 @@ export class MetadataLinkService {
openPolicyFinder: fetch('openPolicyFinder'),
jcr: fetch('jcr'),
}).pipe(
shareReplay({ bufferSize: 1, refCount: true }),
// refCount: false — once the resolver config is loaded, cache it for
// the lifetime of the service (singleton). With refCount: true the
// inner subscription would be torn down whenever subscriber count
// dropped to zero (e.g. during view churn in the @for over metadata
// rows on /full item page), causing the 5 HTTP calls to repeat and
// the metadata table to re-render. The values are immutable per
// backend deployment, so a permanent cache is safe.
shareReplay({ bufferSize: 1, refCount: false }),
);
}

Expand Down
Loading