Skip to content

Commit cabdb94

Browse files
milanmajchrakclaude
andcommitted
Port #1084 (part) to dtq-dev-9-base: accessible labels and per-component ids for the search boxes
Card X-02a, part B of three. Seven templates that the v9 upgrade took wholesale from vanilla 9.3, so the accessibility hunks of 5a92fdf were never applied. Every one of these inputs is either unnamed for a screen reader or named in hardcoded English regardless of the UI language. What changes: * a visually-hidden <label> is added for each control - two in eperson-search-box (the scope select and the query input), three in starts-with-date (year, month, free-text date), one each in group-search-box, members-list, subgroups-list, groups-registry and search-navbar; * aria-label="Search input" and aria-label="Search scope", which were hardcoded English strings even in the Czech UI, become bindings on the keys that already exist unreferenced in en.json5: labelPrefix + 'search.input', labelPrefix + 'search.scope' and messagePrefix + '.search.input'. No i18n key is added, removed or renamed; * the hardcoded id="query" is replaced by a per-component id in all five components that carry it in this set. formControlName and name stay "query"/"scope"; only the DOM id changes. Three fork mistakes are ported fixed rather than literally, each for a reason that is checkable on this branch: 1. class="sr-only" does not exist in Bootstrap 5 and 9-base defines it nowhere (git grep -nE '^[[:space:]]*\.sr-only' -- src => 0 hits, "bootstrap": "^5.3"). A literal port would render a visible label in the middle of every search form. The class used is visually-hidden, which is defined at src/styles/_global-styles.scss:280 and is position:absolute, so it adds nothing to the input-group layout. 2. <label for="startsWith + 'input'"> and <label for="'query' + '-input'"> are static attributes, not bindings: they emit those characters literally and pair with nothing. The ids here are static, so the labels use plain static for= values that match the real id. 3. The group edit page renders ds-members-list and ds-subgroups-list side by side (group-form.component.html:60 and :65) and both hardcoded id="query". Adding <label for="query"> without disambiguating first would have bound both labels to the first input. The fork also gave starts-with-date [id]="startsWith + 'input'". startsWith is mutated as the user types (starts-with-date.component.ts:123), so the id would change on every keystroke and the label association would break. A static id="startsWith-input" is used instead. Tests: 5a92fdf ships none. eperson-search-box.component.spec.ts gains cases asserting that each control's label resolves to that control (label.htmlFor === input.id, id non-empty) and that the accessible name is the translated key rather than the literal "Search input". Proven load-bearing with negative controls - see the PR description. Source: 5a92fdf (dtq-dev PR #1084), partial - the suggestion inputs from the same commit are in the sibling PR for card X-02a part C. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 8ac588e commit cabdb94

10 files changed

Lines changed: 79 additions & 12 deletions

File tree

src/app/access-control/group-registry/group-form/members-list/members-list.component.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,8 +84,9 @@ <h3 id="search" class="border-bottom pb-2">
8484
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
8585
<div class="flex-grow-1 me-3">
8686
<div class="mb-3 input-group me-3">
87-
<input type="text" name="query" id="query" formControlName="query"
88-
class="form-control" aria-label="Search input">
87+
<label for="members-list-query" class="visually-hidden">{{messagePrefix + '.search.input' | translate}}</label>
88+
<input type="text" name="query" id="members-list-query" formControlName="query"
89+
class="form-control" [attr.aria-label]="messagePrefix + '.search.input' | translate">
8990
<span class="input-group-append">
9091
<button type="submit" class="search-button btn btn-primary">
9192
<i class="fas fa-search"></i> {{ messagePrefix + '.search.button' | translate }}</button>

src/app/access-control/group-registry/group-form/members-list/members-list.component.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,19 @@ describe('MembersListComponent', () => {
199199
expect(comp).toBeDefined();
200200
}));
201201

202+
it('should label its search input with an id of its own, not the generic id="query"', () => {
203+
const input: HTMLInputElement = fixture.nativeElement.querySelector('input[name="query"]');
204+
const label: HTMLLabelElement = fixture.nativeElement.querySelector('label[for="' + input.id + '"]');
205+
206+
expect(input.id).toBeTruthy();
207+
// ds-members-list and ds-subgroups-list render side by side on the group edit page,
208+
// so a shared id="query" makes one label point at the other component's input
209+
expect(input.id).not.toEqual('query');
210+
expect(label).toBeTruthy();
211+
expect(label.htmlFor).toEqual(input.id);
212+
expect(Array.from(label.classList)).toContain('visually-hidden');
213+
});
214+
202215
describe('current members list', () => {
203216
it('should show list of eperson members of current active group', () => {
204217
const epersonIdsFound = fixture.debugElement.queryAll(By.css('#ePeopleMembersOfGroup tr td:first-child'));

src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,9 @@ <h4 id="search" class="border-bottom pb-2">
6969
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
7070
<div class="flex-grow-1 me-3">
7171
<div class="mb-3 input-group me-3">
72-
<input type="text" name="query" id="query" formControlName="query"
73-
class="form-control" aria-label="Search input">
72+
<label for="subgroups-list-query" class="visually-hidden">{{messagePrefix + '.search.input' | translate}}</label>
73+
<input type="text" name="query" id="subgroups-list-query" formControlName="query"
74+
class="form-control" [attr.aria-label]="messagePrefix + '.search.input' | translate">
7475
<span class="input-group-append">
7576
<button type="submit" class="search-button btn btn-primary">
7677
<i class="fas fa-search"></i> {{ messagePrefix + '.search.button' | translate }}

src/app/access-control/group-registry/group-form/subgroup-list/subgroups-list.component.spec.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,19 @@ describe('SubgroupsListComponent', () => {
217217
expect(comp).toBeDefined();
218218
}));
219219

220+
it('should label its search input with an id of its own, not the generic id="query"', () => {
221+
const input: HTMLInputElement = fixture.nativeElement.querySelector('input[name="query"]');
222+
const label: HTMLLabelElement = fixture.nativeElement.querySelector('label[for="' + input.id + '"]');
223+
224+
expect(input.id).toBeTruthy();
225+
// ds-members-list and ds-subgroups-list render side by side on the group edit page,
226+
// so a shared id="query" makes one label point at the other component's input
227+
expect(input.id).not.toEqual('query');
228+
expect(label).toBeTruthy();
229+
expect(label.htmlFor).toEqual(input.id);
230+
expect(Array.from(label.classList)).toContain('visually-hidden');
231+
});
232+
220233
describe('current subgroup list', () => {
221234
it('should show list of subgroups of current active group', () => {
222235
const groupIdsFound = fixture.debugElement.queryAll(By.css('#subgroupsOfGroup tr td:first-child'));

src/app/access-control/group-registry/groups-registry.component.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@ <h2 id="search" class="border-bottom pb-2">{{messagePrefix + 'search.head' | tra
1616
<form [formGroup]="searchForm" (ngSubmit)="search(searchForm.value)" class="d-flex justify-content-between">
1717
<div class="flex-grow-1 me-3">
1818
<div class="mb-3 input-group">
19-
<input type="text" name="query" id="query" formControlName="query"
19+
<label for="groups-registry-query" class="visually-hidden">{{messagePrefix + 'search.placeholder' | translate}}</label>
20+
<input type="text" name="query" id="groups-registry-query" formControlName="query"
2021
class="form-control" [attr.aria-label]="messagePrefix + 'search.placeholder' | translate"
2122
[placeholder]="(messagePrefix + 'search.placeholder' | translate)" >
2223
<span class="input-group-append">

src/app/search-navbar/search-navbar.component.html

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
<div [title]="'nav.search' | translate" (dsClickOutside)="collapse()">
22
<div class="d-inline-block position-relative">
33
<form [formGroup]="searchForm" (ngSubmit)="onSubmit(searchForm.value)" autocomplete="on" class="d-flex">
4-
<input #searchInput [@toggleAnimation]="isExpanded" [attr.aria-label]="('nav.search' | translate)" name="query"
4+
<label for="search-navbar-query" class="visually-hidden">{{ 'nav.search' | translate }}</label>
5+
<input #searchInput id="search-navbar-query" [@toggleAnimation]="isExpanded" [attr.aria-label]="('nav.search' | translate)" name="query"
56
formControlName="query" type="text" placeholder="{{searchExpanded ? ('nav.search' | translate) : ''}}"
67
class="d-inline-block bg-transparent position-absolute form-control dropdown-menu-end p1"
78
[class.display]="searchExpanded ? 'inline-block' : 'none'"

src/app/shared/eperson-group-list/eperson-search-box/eperson-search-box.component.html

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,18 @@
22
[formGroup]="searchForm"
33
(ngSubmit)="submit(searchForm.value); $event.stopImmediatePropagation();" >
44
<div>
5-
<select name="scope" id="scope" formControlName="scope" class="form-select" aria-label="Search scope">
5+
<label for="eperson-search-scope" class="visually-hidden">{{labelPrefix + 'search.scope' | translate}}</label>
6+
<select name="scope" id="eperson-search-scope" formControlName="scope" class="form-select"
7+
[attr.aria-label]="labelPrefix + 'search.scope' | translate">
68
<option value="metadata">{{labelPrefix + 'search.scope.metadata' | translate}}</option>
79
<option value="email">{{labelPrefix + 'search.scope.email' | translate}}</option>
810
</select>
911
</div>
1012
<div class="flex-grow-1 me-3 ms-3">
1113
<div class="mb-3 input-group">
12-
<input type="text" name="query" id="query" formControlName="query"
13-
class="form-control" aria-label="Search input">
14+
<label for="eperson-search-query" class="visually-hidden">{{labelPrefix + 'search.input' | translate}}</label>
15+
<input type="text" name="query" id="eperson-search-query" formControlName="query"
16+
class="form-control" [attr.aria-label]="labelPrefix + 'search.input' | translate">
1417
<button type="submit" class="search-button btn btn-primary">
1518
<i class="fas fa-search"></i> {{ labelPrefix + 'search.button' | translate }}
1619
</button>

src/app/shared/eperson-group-list/eperson-search-box/eperson-search-box.component.spec.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,36 @@ describe('EpersonSearchBoxComponent test suite', () => {
9595
expect(comp.searchForm.controls.query.value).toBe('');
9696
});
9797

98+
it('should label the scope select and the query input, each resolving to its own control', () => {
99+
fixture.detectChanges();
100+
const element: HTMLElement = fixture.nativeElement;
101+
const labels: HTMLLabelElement[] = Array.from(element.querySelectorAll('label'));
102+
103+
expect(labels.length).toEqual(2);
104+
labels.forEach((label: HTMLLabelElement) => {
105+
expect(label.htmlFor).toBeTruthy();
106+
const control: HTMLElement = element.querySelector('[id="' + label.htmlFor + '"]');
107+
expect(control).toBeTruthy();
108+
expect(control.id).toEqual(label.htmlFor);
109+
// the label must not be rendered visibly (Bootstrap 5 dropped the old screen-reader class)
110+
expect(Array.from(label.classList)).toContain('visually-hidden');
111+
});
112+
});
113+
114+
it('should name both controls from the translated keys rather than hardcoded English', () => {
115+
fixture.detectChanges();
116+
const input: HTMLInputElement = fixture.nativeElement.querySelector('input[name="query"]');
117+
const select: HTMLSelectElement = fixture.nativeElement.querySelector('select[name="scope"]');
118+
119+
// TranslateModule.forRoot() with no catalogue echoes the key back, so the key is the observable value
120+
expect(input.getAttribute('aria-label')).toEqual('admin.access-control.epeople.search.input');
121+
expect(select.getAttribute('aria-label')).toEqual('admin.access-control.epeople.search.scope');
122+
// and the id is no longer the generic one that other components on the same page also used
123+
expect(input.id).toBeTruthy();
124+
expect(input.id).not.toEqual('query');
125+
expect(select.id).not.toEqual('scope');
126+
});
127+
98128
it('should emit new search event', () => {
99129
const data = {
100130
scope: 'metadata',

src/app/shared/eperson-group-list/group-search-box/group-search-box.component.html

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,9 @@
33
(ngSubmit)="submit(searchForm.value); $event.stopImmediatePropagation();" >
44
<div class="flex-grow-1 me-3">
55
<div class="mb-3 input-group">
6-
<input type="text" name="query" id="query" formControlName="query"
7-
class="form-control" aria-label="Search input">
6+
<label for="group-search-query" class="visually-hidden">{{labelPrefix + 'search.input' | translate}}</label>
7+
<input type="text" name="query" id="group-search-query" formControlName="query"
8+
class="form-control" [attr.aria-label]="labelPrefix + 'search.input' | translate">
89
<button type="submit" class="search-button btn btn-primary">
910
<i class="fas fa-search"></i> {{ labelPrefix + 'search.button' | translate }}
1011
</button>

src/app/shared/starts-with/date/starts-with-date.component.html

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
{{ 'browse.startsWith.jump' | translate }}
55
</span>
66
<div class="col-6 col-md-2">
7+
<label for="year-select" class="visually-hidden">{{ 'browse.startsWith.choose_year.label' | translate }}</label>
78
<select id="year-select" class="form-select" (change)="setStartsWithYearEvent($event)" [attr.aria-label]="'browse.startsWith.choose_year.label' |translate">
89
<option [value]="-1" [selected]="!startsWithYear">
910
{{ 'browse.startsWith.choose_year' | translate }}
@@ -18,6 +19,7 @@
1819
</select>
1920
</div>
2021
<div class="col-6 col-md-2">
22+
<label for="month-select" class="visually-hidden">{{ 'browse.startsWith.months.none.label' | translate }}</label>
2123
<select id="month-select" class="form-select" (change)="setStartsWithMonthEvent($event)" [attr.aria-label]="'browse.startsWith.months.none.label' |translate">
2224
@for (option of monthOptions; track option) {
2325
<option
@@ -30,7 +32,8 @@
3032
</div>
3133
<div class="col-12 col-md-6">
3234
<div class="mb-3 input-group pt-1 pt-md-0">
33-
<input class="form-control" placeholder="{{'browse.startsWith.type_date' | translate}}" [attr.aria-label]="'browse.startsWith.type_date.label' |translate" type="text" name="startsWith"
35+
<label for="startsWith-input" class="visually-hidden">{{ 'browse.startsWith.type_date.label' | translate }}</label>
36+
<input id="startsWith-input" class="form-control" placeholder="{{'browse.startsWith.type_date' | translate}}" [attr.aria-label]="'browse.startsWith.type_date.label' |translate" type="text" name="startsWith"
3437
formControlName="startsWith" [value]="getStartsWith() ? getStartsWith() : ''"/>
3538
<button class="btn btn-primary" type="submit" role="button" tabindex="0"><i class="fas fa-book-open"></i> {{ 'browse.startsWith.submit' | translate }}</button>
3639
</div>

0 commit comments

Comments
 (0)