From f29a43e62c5766158cf635a4dd8b0637b501a5a7 Mon Sep 17 00:00:00 2001 From: MatusBeke Date: Thu, 16 Jul 2026 10:23:52 +0200 Subject: [PATCH 1/4] ZCU-PUB/Show embargo information in the bitstream in the Item View --- .../file-download-link.component.html | 10 ++++++++-- .../file-download-link.component.spec.ts | 4 ++++ .../file-download-link/file-download-link.component.ts | 2 ++ src/assets/i18n/cs.json5 | 5 +++-- src/assets/i18n/en.json5 | 4 ++++ 5 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/app/shared/file-download-link/file-download-link.component.html b/src/app/shared/file-download-link/file-download-link.component.html index 8ebe622a5ba..2a01d9dbdc5 100644 --- a/src/app/shared/file-download-link/file-download-link.component.html +++ b/src/app/shared/file-download-link/file-download-link.component.html @@ -1,5 +1,11 @@ - - + + diff --git a/src/app/shared/file-download-link/file-download-link.component.spec.ts b/src/app/shared/file-download-link/file-download-link.component.spec.ts index 39004b44c8b..b30e8c6a5c9 100644 --- a/src/app/shared/file-download-link/file-download-link.component.spec.ts +++ b/src/app/shared/file-download-link/file-download-link.component.spec.ts @@ -8,6 +8,7 @@ import { AuthorizationDataService } from '../../core/data/feature-authorization/ import { cold, getTestScheduler } from 'jasmine-marbles'; import { Item } from '../../core/shared/item.model'; import { RouterLinkDirectiveStub } from '../testing/router-link-directive.stub'; +import { TranslateModule } from '@ngx-translate/core'; describe('FileDownloadLinkComponent', () => { let component: FileDownloadLinkComponent; @@ -39,6 +40,9 @@ describe('FileDownloadLinkComponent', () => { function initTestbed() { TestBed.configureTestingModule({ + imports: [ + TranslateModule.forRoot(), + ], declarations: [FileDownloadLinkComponent, RouterLinkDirectiveStub], providers: [ {provide: AuthorizationDataService, useValue: authorizationService}, diff --git a/src/app/shared/file-download-link/file-download-link.component.ts b/src/app/shared/file-download-link/file-download-link.component.ts index 51d1c3a67cd..09680ca01c2 100644 --- a/src/app/shared/file-download-link/file-download-link.component.ts +++ b/src/app/shared/file-download-link/file-download-link.component.ts @@ -1,5 +1,6 @@ import { Component, Input, OnInit } from '@angular/core'; import { Bitstream } from '../../core/shared/bitstream.model'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; import { getBitstreamDownloadRoute } from '../../app-routing-paths'; import { AuthorizationDataService } from '../../core/data/feature-authorization/authorization-data.service'; import { FeatureID } from '../../core/data/feature-authorization/feature-id'; @@ -48,6 +49,7 @@ export class FileDownloadLinkComponent implements OnInit { constructor( private authorizationService: AuthorizationDataService, + public dsoNameService: DSONameService, ) { } diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index 52780715b6e..1783b30f785 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -2134,9 +2134,10 @@ "error.validation.metadata.qualifier.max-length": "Toto pole nesmí obsahovat více než 64 znaků", // "feed.description": "Syndication feed", "feed.description": "Synchronizační kanál", + // "file-download-link.download": "Download ", + "file-download-link.download": "Stáhnout", // "file-download-link.restricted": "Restricted bitstream", - // TODO New key - Add a translation - "file-download-link.restricted": "Restricted bitstream", + "file-download-link.restricted": "Omezený soubor", // "file-section.error.header": "Error obtaining files for this item", "file-section.error.header": "Chyba při získávání souborů pro tento záznam", // "footer.copyright": "copyright © 2002-{{ year }}", diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 1283f0656c9..31fb8e5c39d 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1701,6 +1701,10 @@ "feed.description": "RSS feed", + "file-download-link.download": "Download ", + + "file-download-link.restricted": "Restricted bitstream", + "file-section.error.header": "Error obtaining files for this item", "footer.copyright": "copyright © 2002-{{ year }}", From b8c555bc680fd5aa6e40531063a5b6da9a8e157a Mon Sep 17 00:00:00 2001 From: MatusBeke Date: Thu, 16 Jul 2026 12:48:26 +0200 Subject: [PATCH 2/4] Address Copilot review on #1378: fix a11y label wiring + add coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make the space between the translated "Download" prefix and the bitstream name explicit in the template instead of relying on a trailing space in the en.json5 source string (cs.json5 had none, producing "Stáhnoutname.pdf" with no separator). - aria-label on a plain is not reliably exposed by assistive tech; switch to the codebase's existing sr-only pattern (see view-mode-switch.component.html) and mark the decorative lock icon aria-hidden. - Add spec coverage asserting the download link's aria-label and the restricted-state sr-only text/aria-hidden icon. --- .../file-download-link.component.html | 4 +-- .../file-download-link.component.spec.ts | 28 +++++++++++++++++++ src/assets/i18n/cs.json5 | 2 +- src/assets/i18n/en.json5 | 2 +- 4 files changed, 32 insertions(+), 4 deletions(-) diff --git a/src/app/shared/file-download-link/file-download-link.component.html b/src/app/shared/file-download-link/file-download-link.component.html index 2a01d9dbdc5..adbdd9858dc 100644 --- a/src/app/shared/file-download-link/file-download-link.component.html +++ b/src/app/shared/file-download-link/file-download-link.component.html @@ -2,10 +2,10 @@ [queryParams]="(bitstreamPath$| async)?.queryParams" [target]="isBlank ? '_blank': '_self'" [ngClass]="cssClasses" - [attr.aria-label]="('file-download-link.download' | translate) + dsoNameService.getName(bitstream)" + [attr.aria-label]="('file-download-link.download' | translate) + ' ' + dsoNameService.getName(bitstream)" role="link" tabindex="0"> - + {{ 'file-download-link.restricted' | translate }} diff --git a/src/app/shared/file-download-link/file-download-link.component.spec.ts b/src/app/shared/file-download-link/file-download-link.component.spec.ts index b30e8c6a5c9..f4db5a42aed 100644 --- a/src/app/shared/file-download-link/file-download-link.component.spec.ts +++ b/src/app/shared/file-download-link/file-download-link.component.spec.ts @@ -9,6 +9,7 @@ import { cold, getTestScheduler } from 'jasmine-marbles'; import { Item } from '../../core/shared/item.model'; import { RouterLinkDirectiveStub } from '../testing/router-link-directive.stub'; import { TranslateModule } from '@ngx-translate/core'; +import { DSONameService } from '../../core/breadcrumbs/dso-name.service'; describe('FileDownloadLinkComponent', () => { let component: FileDownloadLinkComponent; @@ -16,6 +17,9 @@ describe('FileDownloadLinkComponent', () => { let scheduler; let authorizationService: AuthorizationDataService; + let dsoNameService: DSONameService; + + const bitstreamName = 'Test bitstream name'; let bitstream: Bitstream; let item: Item; @@ -24,6 +28,9 @@ describe('FileDownloadLinkComponent', () => { authorizationService = jasmine.createSpyObj('authorizationService', { isAuthorized: cold('-a', {a: true}) }); + dsoNameService = jasmine.createSpyObj('dsoNameService', { + getName: bitstreamName + }); bitstream = Object.assign(new Bitstream(), { uuid: 'bitstreamUuid', _links: { @@ -46,6 +53,7 @@ describe('FileDownloadLinkComponent', () => { declarations: [FileDownloadLinkComponent, RouterLinkDirectiveStub], providers: [ {provide: AuthorizationDataService, useValue: authorizationService}, + {provide: DSONameService, useValue: dsoNameService}, ] }) .compileComponents(); @@ -80,6 +88,18 @@ describe('FileDownloadLinkComponent', () => { const lock = fixture.debugElement.query(By.css('.fa-lock')); expect(lock).toBeNull(); }); + it('should set an accessible aria-label on the download link containing the bitstream name', () => { + scheduler.flush(); + fixture.detectChanges(); + const link = fixture.debugElement.query(By.css('a')); + expect(link.nativeElement.getAttribute('aria-label')).toContain(bitstreamName); + }); + it('should not show the restricted-bitstream text', () => { + scheduler.flush(); + fixture.detectChanges(); + const restricted = fixture.debugElement.query(By.css('.sr-only')); + expect(restricted).toBeNull(); + }); }); // describe('when the user has no download rights but has the right to request a copy', () => { // beforeEach(waitForAsync(() => { @@ -141,6 +161,14 @@ describe('FileDownloadLinkComponent', () => { const lock = fixture.debugElement.query(By.css('.fa-lock')).nativeElement; expect(lock).toBeTruthy(); }); + it('should mark the lock icon as decorative and expose the restricted state via a sr-only text node', () => { + scheduler.flush(); + fixture.detectChanges(); + const lock = fixture.debugElement.query(By.css('.fa-lock')).nativeElement; + expect(lock.getAttribute('aria-hidden')).toBe('true'); + const restricted = fixture.debugElement.query(By.css('.sr-only')); + expect(restricted.nativeElement.textContent.trim()).toBe('file-download-link.restricted'); + }); }); }); }); diff --git a/src/assets/i18n/cs.json5 b/src/assets/i18n/cs.json5 index 1783b30f785..294ab84ae75 100644 --- a/src/assets/i18n/cs.json5 +++ b/src/assets/i18n/cs.json5 @@ -2134,7 +2134,7 @@ "error.validation.metadata.qualifier.max-length": "Toto pole nesmí obsahovat více než 64 znaků", // "feed.description": "Syndication feed", "feed.description": "Synchronizační kanál", - // "file-download-link.download": "Download ", + // "file-download-link.download": "Download", "file-download-link.download": "Stáhnout", // "file-download-link.restricted": "Restricted bitstream", "file-download-link.restricted": "Omezený soubor", diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index 31fb8e5c39d..70d57ab6a16 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -1701,7 +1701,7 @@ "feed.description": "RSS feed", - "file-download-link.download": "Download ", + "file-download-link.download": "Download", "file-download-link.restricted": "Restricted bitstream", From 4b9c35d81a95678c9e7214a0f774e443e6ca4f68 Mon Sep 17 00:00:00 2001 From: MatusBeke Date: Thu, 16 Jul 2026 13:42:40 +0200 Subject: [PATCH 3/4] Adopt Copilot's .trim() suggestion for the download aria-label Defense-in-depth on top of the explicit-space fix: trims the translated prefix and the final concatenated string, so the label stays correctly spaced even if a translation (any of the ~30 locale files, not just cs) carries stray leading/trailing whitespace. --- .../shared/file-download-link/file-download-link.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/shared/file-download-link/file-download-link.component.html b/src/app/shared/file-download-link/file-download-link.component.html index adbdd9858dc..ce77aaf4476 100644 --- a/src/app/shared/file-download-link/file-download-link.component.html +++ b/src/app/shared/file-download-link/file-download-link.component.html @@ -2,7 +2,7 @@ [queryParams]="(bitstreamPath$| async)?.queryParams" [target]="isBlank ? '_blank': '_self'" [ngClass]="cssClasses" - [attr.aria-label]="('file-download-link.download' | translate) + ' ' + dsoNameService.getName(bitstream)" + [attr.aria-label]="(('file-download-link.download' | translate).trim() + ' ' + dsoNameService.getName(bitstream)).trim()" role="link" tabindex="0"> {{ 'file-download-link.restricted' | translate }} From 8cdd502565fb2e417130219e3650134d6a57f6fa Mon Sep 17 00:00:00 2001 From: MatusBeke Date: Mon, 20 Jul 2026 14:02:07 +0200 Subject: [PATCH 4/4] Address Copilot review: fold restricted state into the link's aria-label An element's aria-label overrides its accessible-name computation entirely, so the nested sr-only "Restricted bitstream" text was never actually announced by assistive tech - the lock icon's aria-hidden state was correct, but the restriction reason was silently dropped. Fixed by composing a single aria-label (via the new getDownloadLinkAriaLabel() method) that includes the restricted state when applicable, instead of relying on descendant sr-only content. Also evaluated Copilot's *ngVar suggestion to avoid the two separate canDownload$ | async subscriptions (aria-label + the lock icon's *ngIf) - reverted it after it broke the routerLink binding in tests: *ngVar destroys and recreates its entire templated content (including the anchor and its RouterLink) on every emission, which would cause the download link to flicker/reset once the authorization check resolves. Kept the two independent `| async` pipes instead, consistent with how bitstreamPath$ is already used twice in this same template. --- .../file-download-link.component.html | 4 ++-- .../file-download-link.component.spec.ts | 15 +++++++++------ .../file-download-link.component.ts | 12 ++++++++++++ 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/src/app/shared/file-download-link/file-download-link.component.html b/src/app/shared/file-download-link/file-download-link.component.html index ce77aaf4476..c9885d4401e 100644 --- a/src/app/shared/file-download-link/file-download-link.component.html +++ b/src/app/shared/file-download-link/file-download-link.component.html @@ -2,10 +2,10 @@ [queryParams]="(bitstreamPath$| async)?.queryParams" [target]="isBlank ? '_blank': '_self'" [ngClass]="cssClasses" - [attr.aria-label]="(('file-download-link.download' | translate).trim() + ' ' + dsoNameService.getName(bitstream)).trim()" + [attr.aria-label]="getDownloadLinkAriaLabel((canDownload$ | async), ('file-download-link.download' | translate), ('file-download-link.restricted' | translate))" role="link" tabindex="0"> - {{ 'file-download-link.restricted' | translate }} + diff --git a/src/app/shared/file-download-link/file-download-link.component.spec.ts b/src/app/shared/file-download-link/file-download-link.component.spec.ts index f4db5a42aed..61d44c42210 100644 --- a/src/app/shared/file-download-link/file-download-link.component.spec.ts +++ b/src/app/shared/file-download-link/file-download-link.component.spec.ts @@ -94,11 +94,11 @@ describe('FileDownloadLinkComponent', () => { const link = fixture.debugElement.query(By.css('a')); expect(link.nativeElement.getAttribute('aria-label')).toContain(bitstreamName); }); - it('should not show the restricted-bitstream text', () => { + it('should not include the restricted-bitstream text in the aria-label', () => { scheduler.flush(); fixture.detectChanges(); - const restricted = fixture.debugElement.query(By.css('.sr-only')); - expect(restricted).toBeNull(); + const link = fixture.debugElement.query(By.css('a')); + expect(link.nativeElement.getAttribute('aria-label')).not.toContain('file-download-link.restricted'); }); }); // describe('when the user has no download rights but has the right to request a copy', () => { @@ -161,13 +161,16 @@ describe('FileDownloadLinkComponent', () => { const lock = fixture.debugElement.query(By.css('.fa-lock')).nativeElement; expect(lock).toBeTruthy(); }); - it('should mark the lock icon as decorative and expose the restricted state via a sr-only text node', () => { + it('should mark the lock icon as decorative and expose the restricted state via the link\'s own aria-label', () => { scheduler.flush(); fixture.detectChanges(); const lock = fixture.debugElement.query(By.css('.fa-lock')).nativeElement; expect(lock.getAttribute('aria-hidden')).toBe('true'); - const restricted = fixture.debugElement.query(By.css('.sr-only')); - expect(restricted.nativeElement.textContent.trim()).toBe('file-download-link.restricted'); + // The restricted state must be part of the link's own aria-label: an element's + // aria-label overrides its accessible-name computation entirely, so a nested + // sr-only text node would never be announced (this is the bug the fix corrects). + const link = fixture.debugElement.query(By.css('a')); + expect(link.nativeElement.getAttribute('aria-label')).toContain('file-download-link.restricted'); }); }); }); diff --git a/src/app/shared/file-download-link/file-download-link.component.ts b/src/app/shared/file-download-link/file-download-link.component.ts index 09680ca01c2..48417c2a641 100644 --- a/src/app/shared/file-download-link/file-download-link.component.ts +++ b/src/app/shared/file-download-link/file-download-link.component.ts @@ -79,4 +79,16 @@ export class FileDownloadLinkComponent implements OnInit { queryParams: {} }; } + + /** + * Builds the download link's accessible name. When the bitstream is restricted, the + * restricted state is folded into this same string (rather than relying on a nested + * sr-only text node) because an element's own aria-label overrides its accessible name + * computation entirely - descendant text, including sr-only spans, is not announced + * alongside it. + */ + getDownloadLinkAriaLabel(canDownload: boolean | null, downloadLabel: string, restrictedLabel: string): string { + const download = `${downloadLabel.trim()} ${this.dsoNameService.getName(this.bitstream)}`.trim(); + return canDownload ? download : `${restrictedLabel.trim()}, ${download}`; + } }