Merge pull request #1222 from atmire/w2p-79730_Fix-search-sidebar-a11y-issues

Fix search sidebar a11y issues
This commit is contained in:
Tim Donohue
2021-06-25 16:56:09 -05:00
committed by GitHub
20 changed files with 296 additions and 61 deletions

View File

@@ -162,6 +162,7 @@ import { UsageReport } from './statistics/models/usage-report.model';
import { RootDataService } from './data/root-data.service';
import { Root } from './data/root.model';
import { SearchConfig } from './shared/search/search-filters/search-config.model';
import { SequenceService } from './shared/sequence.service';
/**
* When not in production, endpoint responses can be mocked for testing purposes
@@ -282,7 +283,8 @@ const PROVIDERS = [
FilteredDiscoveryPageResponseParsingService,
{ provide: NativeWindowService, useFactory: NativeWindowFactory },
VocabularyService,
VocabularyTreeviewService
VocabularyTreeviewService,
SequenceService,
];
/**

View File

@@ -0,0 +1,22 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { SequenceService } from './sequence.service';
let service: SequenceService;
describe('SequenceService', () => {
beforeEach(() => {
service = new SequenceService();
});
it('should return sequential numbers on next(), starting with 1', () => {
const NUMBERS = [1,2,3,4,5];
const sequence = NUMBERS.map(() => service.next());
expect(sequence).toEqual(NUMBERS);
});
});

View File

@@ -0,0 +1,24 @@
/**
* The contents of this file are subject to the license and copyright
* detailed in the LICENSE and NOTICE files at the root of the source
* tree and available online at
*
* http://www.dspace.org/license/
*/
import { Injectable } from '@angular/core';
@Injectable()
/**
* Provides unique sequential numbers
*/
export class SequenceService {
private value: number;
constructor() {
this.value = 0;
}
public next(): number {
return ++this.value;
}
}

View File

@@ -3,13 +3,27 @@
(keydown.arrowdown)="shiftFocusDown($event)"
(keydown.arrowup)="shiftFocusUp($event)" (keydown.esc)="close()"
(dsClickOutside)="close();">
<input #inputField type="text" [(ngModel)]="value" [name]="name"
class="form-control suggestion_input"
[ngClass]="{'is-invalid': !valid}"
[dsDebounce]="debounceTime" (onDebounce)="find($event)"
[placeholder]="placeholder"
[ngModelOptions]="{standalone: true}" autocomplete="off"/>
<input type="submit" class="d-none"/>
<div class="form-group mb-0">
<label *ngIf="label; else searchInput">
<span class="font-weight-bold">
{{label}}
</span>
<ng-container *ngTemplateOutlet="searchInput"></ng-container>
</label>
</div>
<ng-template #searchInput>
<input #inputField type="text" [(ngModel)]="value" [name]="name"
class="form-control suggestion_input"
[ngClass]="{'is-invalid': !valid}"
[dsDebounce]="debounceTime" (onDebounce)="find($event)"
[placeholder]="placeholder"
[ngModelOptions]="{standalone: true}" autocomplete="off"
/>
</ng-template>
<label class="d-none">
<input type="submit"/>
<span>{{'search.filters.search.submit' | translate}}</span>
</label>
<div class="autocomplete dropdown-menu" [ngClass]="{'show': (show | async) && isNotEmpty(suggestions)}">
<div class="dropdown-list">
<div *ngFor="let suggestionOption of suggestions">

View File

@@ -53,6 +53,11 @@ export class InputSuggestionsComponent implements ControlValueAccessor, OnChange
*/
@Input() valid = true;
/**
* Label for the input field. Used for screen readers.
*/
@Input() label? = '';
/**
* Output for when the form is submitted
*/

View File

@@ -8,15 +8,18 @@
</ng-container>
<div class="clearfix toggle-more-filters">
<a class="float-left" *ngIf="!(isLastPage$ | async)"
(click)="showMore()">{{"search.filters.filter.show-more"
| translate}}</a>
(click)="showMore()" href="javascript:void(0);">
{{"search.filters.filter.show-more" | translate}}
</a>
<a class="float-right" *ngIf="(currentPage | async) > 1"
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less"
| translate}}</a>
(click)="showFirstPageOnly()" href="javascript:void(0);">
{{"search.filters.filter.show-less" | translate}}
</a>
</div>
</div>
<ds-filter-input-suggestions [suggestions]="(filterSearchResults | async)"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder' | translate"
[label]="'search.filters.filter.' + filterConfig.name + '.label' | translate"
[action]="currentUrl"
[name]="filterConfig.paramName"
[(ngModel)]="filter"

View File

@@ -8,11 +8,13 @@
</ng-container>
<div class="clearfix toggle-more-filters">
<a class="float-left" *ngIf="!(isLastPage$ | async)"
(click)="showMore()">{{"search.filters.filter.show-more"
| translate}}</a>
(click)="showMore()" href="javascript:void(0);">
{{"search.filters.filter.show-more" | translate}}
</a>
<a class="float-right" *ngIf="(currentPage | async) > 1"
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less"
| translate}}</a>
(click)="showFirstPageOnly()" href="javascript:void(0);">
{{"search.filters.filter.show-less" | translate}}
</a>
</div>
</div>

View File

@@ -1,10 +1,13 @@
<a *ngIf="isVisible | async" class="d-flex flex-row"
[tabIndex]="-1"
[routerLink]="[searchLink]"
[queryParams]="addQueryParams" queryParamsHandling="merge">
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
<span class="filter-value px-1">
<label class="mb-0">
<input type="checkbox" [checked]="false" class="my-1 align-self-stretch"/>
<span class="filter-value px-1">
{{ 'search.filters.' + filterConfig.name + '.' + filterValue.value | translate: {default: filterValue.value} }}
</span>
</span>
</label>
<span class="float-right filter-value-count ml-auto">
<span class="badge badge-secondary badge-pill">{{filterValue.count}}</span>
</span>

View File

@@ -1,8 +1,11 @@
<a class="d-flex flex-row"
[tabIndex]="-1"
[routerLink]="[searchLink]"
[queryParams]="removeQueryParams" queryParamsHandling="merge">
<label class="mb-0">
<input type="checkbox" [checked]="true" class="my-1 align-self-stretch"/>
<span class="filter-value pl-1 text-capitalize">
{{ 'search.filters.' + filterConfig.name + '.' + selectedValue.value | translate: {default: selectedValue.label} }}
</span>
</label>
</a>

View File

@@ -1,17 +1,21 @@
<div class="facet-filter d-block mb-3 p-3" *ngIf="active$ | async">
<div (click)="toggle()" class="filter-name">
<div class="facet-filter d-block mb-3 p-3" *ngIf="active$ | async"
[id]="regionId" [attr.aria-labelledby]="toggleId" [ngClass]="{ 'focus': focusBox }" role="region">
<button (click)="toggle()" (focusin)="focusBox = true" (focusout)="focusBox = false"
class="filter-name d-flex" [attr.aria-controls]="regionId" [id]="toggleId"
[attr.aria-expanded]="false"
[attr.aria-label]="((collapsed$ | async) ? 'search.filters.filter.expand' : 'search.filters.filter.collapse') | translate"
>
<h5 class="d-inline-block mb-0">
{{'search.filters.filter.' + filter.name + '.head'| translate}}
</h5>
<span class="filter-toggle fas float-right"
<span class="filter-toggle flex-grow-1 fas p-auto"
[ngClass]="(collapsed$ | async) ? 'fa-plus' : 'fa-minus'"
[title]="((collapsed$ | async) ? 'search.filters.filter.expand' : 'search.filters.filter.collapse') | translate"
[attr.aria-label]="((collapsed$ | async) ? 'search.filters.filter.expand' : 'search.filters.filter.collapse') | translate">
[title]="((collapsed$ | async) ? 'search.filters.filter.expand' : 'search.filters.filter.collapse') | translate">
</span>
</div>
</button>
<div [@slide]="(collapsed$ | async) ? 'collapsed' : 'expanded'"
(@slide.start)="startSlide($event)" (@slide.done)="finishSlide($event)"
class="search-filter-wrapper" [ngClass]="{'closed' : closed}">
class="search-filter-wrapper" [ngClass]="{ 'closed' : closed, 'notab': notab }">
<ds-search-facet-filter-wrapper
[filterConfig]="filter"
[inPlaceSearch]="inPlaceSearch">

View File

@@ -1,10 +1,36 @@
:host .facet-filter {
border: 1px solid var(--bs-light);
cursor: pointer;
.search-filter-wrapper.closed {
overflow: hidden;
border: 1px solid var(--bs-light);
cursor: pointer;
line-height: 0;
.search-filter-wrapper {
line-height: var(--bs-line-height-base);
&.closed {
overflow: hidden;
}
.filter-toggle {
line-height: var(--bs-line-height-base);
&.notab {
visibility: hidden;
}
}
.filter-toggle {
line-height: var(--bs-line-height-base);
text-align: right;
position: relative;
top: -0.125rem; // Fix weird outline shape in Chrome
}
> button {
appearance: none;
border: 0;
padding: 0;
background: transparent;
width: 100%;
outline: none !important;
}
&.focus {
outline: none;
box-shadow: var(--bs-input-btn-focus-box-shadow);
}
}

View File

@@ -12,6 +12,7 @@ import { SearchFilterConfig } from '../../search-filter-config.model';
import { FilterType } from '../../filter-type.model';
import { SearchConfigurationServiceStub } from '../../../testing/search-configuration-service.stub';
import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component';
import { SequenceService } from '../../../../core/shared/sequence.service';
describe('SearchFilterComponent', () => {
let comp: SearchFilterComponent;
@@ -50,12 +51,15 @@ describe('SearchFilterComponent', () => {
};
let filterService;
let sequenceService;
const mockResults = observableOf(['test', 'data']);
const searchServiceStub = {
getFacetValuesFor: (filter) => mockResults
};
beforeEach(waitForAsync(() => {
sequenceService = jasmine.createSpyObj('sequenceService', { next: 17 });
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot(), RouterTestingModule.withRoutes([]), NoopAnimationsModule],
declarations: [SearchFilterComponent],
@@ -65,7 +69,8 @@ describe('SearchFilterComponent', () => {
provide: SearchFilterService,
useValue: mockFilterService
},
{ provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() }
{ provide: SEARCH_CONFIG_SERVICE, useValue: new SearchConfigurationServiceStub() },
{ provide: SequenceService, useValue: sequenceService },
],
schemas: [NO_ERRORS_SCHEMA]
}).overrideComponent(SearchFilterComponent, {
@@ -81,6 +86,12 @@ describe('SearchFilterComponent', () => {
filterService = (comp as any).filterService;
});
it('should generate unique IDs', () => {
expect(sequenceService.next).toHaveBeenCalled();
expect(comp.toggleId).toContain('17');
expect(comp.regionId).toContain('17');
});
describe('when the toggle method is triggered', () => {
beforeEach(() => {
spyOn(filterService, 'toggle');

View File

@@ -10,6 +10,7 @@ import { isNotEmpty } from '../../../empty.util';
import { SearchService } from '../../../../core/shared/search/search.service';
import { SearchConfigurationService } from '../../../../core/shared/search/search-configuration.service';
import { SEARCH_CONFIG_SERVICE } from '../../../../+my-dspace-page/my-dspace-page.component';
import { SequenceService } from '../../../../core/shared/sequence.service';
@Component({
selector: 'ds-search-filter',
@@ -37,6 +38,16 @@ export class SearchFilterComponent implements OnInit {
*/
closed: boolean;
/**
* True when the filter controls should be hidden & removed from the tablist
*/
notab: boolean;
/**
* True when the filter toggle button is focused
*/
focusBox = false;
/**
* Emits true when the filter is currently collapsed in the store
*/
@@ -52,10 +63,15 @@ export class SearchFilterComponent implements OnInit {
*/
active$: Observable<boolean>;
private readonly sequenceId: number;
constructor(
private filterService: SearchFilterService,
private searchService: SearchService,
@Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService) {
@Inject(SEARCH_CONFIG_SERVICE) private searchConfigService: SearchConfigurationService,
private sequenceService: SequenceService,
) {
this.sequenceId = this.sequenceService.next();
}
/**
@@ -112,6 +128,9 @@ export class SearchFilterComponent implements OnInit {
if (event.fromState === 'collapsed') {
this.closed = false;
}
if (event.toState === 'collapsed') {
this.notab = true;
}
}
/**
@@ -122,6 +141,17 @@ export class SearchFilterComponent implements OnInit {
if (event.toState === 'collapsed') {
this.closed = true;
}
if (event.fromState === 'collapsed') {
this.notab = false;
}
}
get regionId(): string {
return `search-filter-region-${this.sequenceId}`;
}
get toggleId(): string {
return `search-filter-toggle-${this.sequenceId}`;
}
/**

View File

@@ -8,15 +8,18 @@
</ng-container>
<div class="clearfix toggle-more-filters">
<a class="float-left" *ngIf="!(isLastPage$ | async)"
(click)="showMore()">{{"search.filters.filter.show-more"
| translate}}</a>
(click)="showMore()" href="javascript:void(0);">
{{"search.filters.filter.show-more" | translate}}
</a>
<a class="float-right" *ngIf="(currentPage | async) > 1"
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less"
| translate}}</a>
(click)="showFirstPageOnly()" href="javascript:void(0);">
{{"search.filters.filter.show-less" | translate}}
</a>
</div>
</div>
<ds-filter-input-suggestions [suggestions]="(filterSearchResults | async)"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder' | translate"
[label]="'search.filters.filter.' + filterConfig.name + '.label' | translate"
[action]="currentUrl"
[name]="filterConfig.paramName"
[(ngModel)]="filter"

View File

@@ -2,25 +2,42 @@
<div class="filters py-2">
<form #form="ngForm" (ngSubmit)="onSubmit()" class="add-filter row"
[action]="currentUrl">
<div class="col-6">
<input type="text" [(ngModel)]="range[0]" [name]="filterConfig.paramName + '.min'"
class="form-control" (blur)="onSubmit()"
aria-label="Mininum value"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.min.placeholder'| translate"/>
<div class="col-6 form-group mb-0">
<label>
<span class="font-weight-bold">
{{'search.filters.filter.' + filterConfig.name + '.min.label' | translate}}
</span>
<input type="text" [(ngModel)]="range[0]" [name]="filterConfig.paramName + '.min'"
class="form-control" (blur)="onSubmit()"
aria-label="Mininum value"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.min.placeholder' | translate"
/>
</label>
</div>
<div class="col-6">
<input type="text" [(ngModel)]="range[1]" [name]="filterConfig.paramName + '.max'"
class="form-control" (blur)="onSubmit()"
aria-label="Maximum value"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.max.placeholder'| translate"/>
<label>
<span class="font-weight-bold">
{{'search.filters.filter.' + filterConfig.name + '.max.label' | translate}}
</span>
<input type="text" [(ngModel)]="range[1]" [name]="filterConfig.paramName + '.max'"
class="form-control" (blur)="onSubmit()"
aria-label="Maximum value"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.max.placeholder' | translate"
/>
</label>
</div>
<input type="submit" class="d-none"/>
<label class="d-none">
<input type="submit" class="d-none"/>
<span>{{'search.filters.search.submit' | translate}}</span>
</label>
</form>
<ng-container *ngIf="shouldShowSlider()">
<nouislider [connect]="true" [min]="min" [max]="max" [step]="1"
[(ngModel)]="range" (change)="onSubmit()" ngDefaultControl></nouislider>
[dsDebounce]="250" (onDebounce)="onSubmit()"
(keydown)="startKeyboardControl()" (keyup)="stopKeyboardControl()"
[(ngModel)]="range" ngDefaultControl>
</nouislider>
</ng-container>
<ng-container *ngFor="let page of (filterValues$ | async)?.payload">
<div [@facetLoad]="animationState">

View File

@@ -21,6 +21,7 @@
}
&:focus {
outline: none;
box-shadow: var(--bs-input-btn-focus-box-shadow);
}
}

View File

@@ -68,6 +68,12 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple
*/
sub: Subscription;
/**
* Whether the sider is being controlled by the keyboard.
* Supresses any changes until the key is released.
*/
keyboardControl: boolean;
constructor(protected searchService: SearchService,
protected filterService: SearchFilterService,
protected router: Router,
@@ -104,6 +110,10 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple
* Submits new custom range values to the range filter from the widget
*/
onSubmit() {
if (this.keyboardControl) {
return; // don't submit if a key is being held down
}
const newMin = this.range[0] !== this.min ? [this.range[0]] : null;
const newMax = this.range[1] !== this.max ? [this.range[1]] : null;
this.router.navigate(this.getSearchLinkParts(), {
@@ -117,6 +127,14 @@ export class SearchRangeFilterComponent extends SearchFacetFilterComponent imple
this.filter = '';
}
startKeyboardControl(): void {
this.keyboardControl = true;
}
stopKeyboardControl(): void {
this.keyboardControl = false;
}
/**
* TODO when upgrading nouislider, verify that this check is still needed.
* Prevents AoT bug

View File

@@ -8,15 +8,18 @@
</ng-container>
<div class="clearfix toggle-more-filters">
<a class="float-left" *ngIf="!(isLastPage$ | async)"
(click)="showMore()">{{"search.filters.filter.show-more"
| translate}}</a>
(click)="showMore()" href="javascript:void(0);">
{{"search.filters.filter.show-more" | translate}}
</a>
<a class="float-right" *ngIf="(currentPage | async) > 1"
(click)="showFirstPageOnly()">{{"search.filters.filter.show-less"
| translate}}</a>
(click)="showFirstPageOnly()" href="javascript:void(0);">
{{"search.filters.filter.show-less" | translate}}
</a>
</div>
</div>
<ds-filter-input-suggestions [suggestions]="(filterSearchResults | async)"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder'| translate"
[placeholder]="'search.filters.filter.' + filterConfig.name + '.placeholder' | translate"
[label]="'search.filters.filter.' + filterConfig.name + '.label' | translate"
[action]="currentUrl"
[name]="filterConfig.paramName"
[(ngModel)]="filter"

View File

@@ -2882,38 +2882,56 @@
"search.filters.filter.author.placeholder": "Author name",
"search.filters.filter.author.label": "Search author name",
"search.filters.filter.birthDate.head": "Birth Date",
"search.filters.filter.birthDate.placeholder": "Birth Date",
"search.filters.filter.birthDate.label": "Search birth date",
"search.filters.filter.collapse": "Collapse filter",
"search.filters.filter.creativeDatePublished.head": "Date Published",
"search.filters.filter.creativeDatePublished.placeholder": "Date Published",
"search.filters.filter.creativeDatePublished.label": "Search date published",
"search.filters.filter.creativeWorkEditor.head": "Editor",
"search.filters.filter.creativeWorkEditor.placeholder": "Editor",
"search.filters.filter.creativeWorkEditor.label": "Search editor",
"search.filters.filter.creativeWorkKeywords.head": "Subject",
"search.filters.filter.creativeWorkKeywords.placeholder": "Subject",
"search.filters.filter.creativeWorkKeywords.label": "Search subject",
"search.filters.filter.creativeWorkPublisher.head": "Publisher",
"search.filters.filter.creativeWorkPublisher.placeholder": "Publisher",
"search.filters.filter.creativeWorkPublisher.label": "Search publisher",
"search.filters.filter.dateIssued.head": "Date",
"search.filters.filter.dateIssued.max.placeholder": "Minimum Date",
"search.filters.filter.dateIssued.max.placeholder": "Maximum Date",
"search.filters.filter.dateIssued.min.placeholder": "Maximum Date",
"search.filters.filter.dateIssued.max.label": "End",
"search.filters.filter.dateIssued.min.placeholder": "Minimum Date",
"search.filters.filter.dateIssued.min.label": "Start",
"search.filters.filter.dateSubmitted.head": "Date submitted",
"search.filters.filter.dateSubmitted.placeholder": "Date submitted",
"search.filters.filter.dateSubmitted.label": "Search date submitted",
"search.filters.filter.discoverable.head": "Private",
"search.filters.filter.withdrawn.head": "Withdrawn",
@@ -2922,6 +2940,8 @@
"search.filters.filter.entityType.placeholder": "Item Type",
"search.filters.filter.entityType.label": "Search item type",
"search.filters.filter.expand": "Expand filter",
"search.filters.filter.has_content_in_original_bundle.head": "Has files",
@@ -2930,38 +2950,56 @@
"search.filters.filter.itemtype.placeholder": "Type",
"search.filters.filter.itemtype.label": "Search type",
"search.filters.filter.jobTitle.head": "Job Title",
"search.filters.filter.jobTitle.placeholder": "Job Title",
"search.filters.filter.jobTitle.label": "Search job title",
"search.filters.filter.knowsLanguage.head": "Known language",
"search.filters.filter.knowsLanguage.placeholder": "Known language",
"search.filters.filter.knowsLanguage.label": "Search known language",
"search.filters.filter.namedresourcetype.head": "Status",
"search.filters.filter.namedresourcetype.placeholder": "Status",
"search.filters.filter.namedresourcetype.label": "Search status",
"search.filters.filter.objectpeople.head": "People",
"search.filters.filter.objectpeople.placeholder": "People",
"search.filters.filter.objectpeople.label": "Search people",
"search.filters.filter.organizationAddressCountry.head": "Country",
"search.filters.filter.organizationAddressCountry.placeholder": "Country",
"search.filters.filter.organizationAddressCountry.label": "Search country",
"search.filters.filter.organizationAddressLocality.head": "City",
"search.filters.filter.organizationAddressLocality.placeholder": "City",
"search.filters.filter.organizationAddressLocality.label": "Search city",
"search.filters.filter.organizationFoundingDate.head": "Date Founded",
"search.filters.filter.organizationFoundingDate.placeholder": "Date Founded",
"search.filters.filter.organizationFoundingDate.label": "Search date founded",
"search.filters.filter.scope.head": "Scope",
"search.filters.filter.scope.placeholder": "Scope filter",
"search.filters.filter.scope.label": "Search scope filter",
"search.filters.filter.show-less": "Collapse",
"search.filters.filter.show-more": "Show more",
@@ -2970,10 +3008,14 @@
"search.filters.filter.subject.placeholder": "Subject",
"search.filters.filter.subject.label": "Search subject",
"search.filters.filter.submitter.head": "Submitter",
"search.filters.filter.submitter.placeholder": "Submitter",
"search.filters.filter.submitter.label": "Search submitter",
"search.filters.entityType.JournalIssue": "Journal Issue",
@@ -2999,6 +3041,8 @@
"search.filters.reset": "Reset filters",
"search.filters.search.submit": "Submit",
"search.form.search": "Search",