Skip to content

Commit

Permalink
fix(multi-select): add select all to keyboard nav (#950)
Browse files Browse the repository at this point in the history
  • Loading branch information
[email protected] authored and GitHub Enterprise committed Jun 28, 2023
1 parent ebe1381 commit f101dbf
Show file tree
Hide file tree
Showing 8 changed files with 338 additions and 14 deletions.
2 changes: 2 additions & 0 deletions projects/ng-aquila/src/dropdown/dropdown.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { NX_DROPDOWN_SCROLL_STRATEGY_PROVIDER, NxDropdownComponent, NxDropdownIn
import { NxDropdownGroupComponent } from './group/dropdown-group';
import { NxDropdownItemComponent } from './item/dropdown-item';
import { NxMultiSelectComponent } from './multi-select/multi-select.component';
import { NxMultiSelectAllComponent } from './multi-select/multi-select-all.component';
import { NxMultiSelectOptionComponent } from './multi-select/multi-select-option.component';

@NgModule({
Expand All @@ -39,6 +40,7 @@ import { NxMultiSelectOptionComponent } from './multi-select/multi-select-option
NxDropdownClosedLabelDirective,
NxMultiSelectComponent,
NxMultiSelectOptionComponent,
NxMultiSelectAllComponent,
],
providers: [NxDropdownIntl, NX_DROPDOWN_SCROLL_STRATEGY_PROVIDER],
exports: [NxDropdownComponent, NxDropdownItemComponent, NxDropdownGroupComponent, NxDropdownClosedLabelDirective, NxMultiSelectComponent],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
<nx-checkbox [indeterminate]="indeterminate" [class.is-active]="active" [class.is-selected]="selected" [checked]="selected">
{{ label }}
</nx-checkbox>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
@import '../../shared-styles/index';

.nx-checkbox {
&.is-active {
@include focus-style;
border-radius: 4px;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { Highlightable, LiveAnnouncer } from '@angular/cdk/a11y';
import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ElementRef, EventEmitter, HostListener, Input, Output } from '@angular/core';

let nextId = 0;

/**
* A single option witin the multi select.
*
* @docs-private
*/
@Component({
selector: 'nx-multi-select-all',
styleUrls: ['./multi-select-all.component.scss'],
templateUrl: './multi-select-all.component.html',
host: {
role: 'option',
'[id]': 'id',
'[attr.aria-selected]': 'selected || null',
'[attr.aria-disabled]': 'disabled || null',
},
changeDetection: ChangeDetectionStrategy.OnPush,
})
export class NxMultiSelectAllComponent<T> implements Highlightable {
private _active = false;

id = `nx-multi-select-all-${nextId++}`;

/**
* Value of this option.
*/
@Input() value?: T;

/**
* Label of this option.
*/
@Input() label = '';

/**
* Indeterminate
*/
@Input() indeterminate = false;

/**
* Whether this option is selected.
*/
@Input() selected = false;

/**
* Whether thisoption is disabled.
*/
@Input() disabled = false;

/**
* Emits an event when this option is selected or unselected by the user.
*/
@Output() readonly selectedAllChange = new EventEmitter<boolean>();

/**
* Sets this option active highlighting it to the user.
*/
set active(value: boolean) {
this._active = value;
this._cdr.markForCheck();
}

get active() {
return this._active;
}

constructor(private readonly _cdr: ChangeDetectorRef, readonly elementRef: ElementRef, private liveAnnouncer: LiveAnnouncer) {}

setActiveStyles(): void {
this.active = true;
}

setInactiveStyles(): void {
this.active = false;
}

@HostListener('click', ['$event'])
_onClick(e: Event) {
e.preventDefault();
e.stopPropagation();
this._onSelect();
}

_onSelect() {
if (!this.disabled) {
this.selected = !this.selected;
this.selectedAllChange.emit(this.selected);
this.liveAnnouncer.announce(`${this.label} ${this.selected ? 'selected' : 'unselected'}`);
}
}

/**
* Selects this option as if the user clicked on it.
*/
selectViaInteraction() {
this._onSelect();
this._cdr.markForCheck();
}
}
193 changes: 193 additions & 0 deletions projects/ng-aquila/src/dropdown/multi-select/multi-select-all.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,193 @@
import { LiveAnnouncer } from '@angular/cdk/a11y';
import { ComponentHarness, HarnessLoader, parallel } from '@angular/cdk/testing';
import { TestbedHarnessEnvironment } from '@angular/cdk/testing/testbed';
import { Component, Directive, Type, ViewChild } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';

import { NxDropdownModule } from '../dropdown.module';
import { NxMultiSelectAllComponent } from './multi-select-all.component';

/** @docs-private */
export class MultiSelectAllHarness extends ComponentHarness {
static hostSelector = 'nx-multi-select-all';

getLabel = this.locatorFor('.nx-checkbox__label');

getCheckbox = this.locatorFor('.nx-checkbox');

getCheckIcon = this.locatorForOptional('nx-icon');

async getLabelText() {
const label = await this.getLabel();
return label.text();
}

async isSelected() {
const checkbox = await this.getCheckbox();
return checkbox.hasClass('is-selected');
}

async isActive() {
const checkbox = await this.getCheckbox();
return checkbox.hasClass('is-active');
}

async isOutline() {
const host = await this.host();
return host.hasClass('is-outline');
}

async click() {
const option = await this.host();
await option.click();
}

async getId() {
const host = await this.host();
return host.getAttribute('id');
}
}

describe('NxMultiSelectAllComponent', () => {
let fixture: ComponentFixture<MultiSelectAllTest>;
let testInstance: MultiSelectAllTest;
let multiSelectAllInstance: NxMultiSelectAllComponent<any>;
let loader: HarnessLoader;
let multiSelectAllHarness: MultiSelectAllHarness;
let liveAnnouncer: LiveAnnouncer;

async function configureTestingModule(declarations: any[]) {
return TestBed.configureTestingModule({
imports: [NxDropdownModule],
declarations,
providers: [LiveAnnouncer],
}).compileComponents();
}

function createTestComponent(component: Type<MultiSelectAllTest>) {
fixture = TestBed.createComponent(component);
fixture.detectChanges();
testInstance = fixture.componentInstance;
multiSelectAllInstance = testInstance.multiSelectOption;
liveAnnouncer = TestBed.inject(LiveAnnouncer);

loader = TestbedHarnessEnvironment.loader(fixture);
}

beforeEach(async () => {
await configureTestingModule([BasicMultiSelectAllComponent, NxMultiSelectAllComponent]);
createTestComponent(BasicMultiSelectAllComponent);
multiSelectAllHarness = await loader.getHarness(MultiSelectAllHarness);
});

describe('select all button', () => {
it('has the label', async () => {
const label = await multiSelectAllHarness.getLabelText();
expect(label).toBe('Select All');
});

it('is not active', async () => {
expect(await multiSelectAllHarness.isActive()).toBeFalse();
});

it('shows no check icon', async () => {
const icon = await multiSelectAllHarness.getCheckIcon();
expect(icon).toBeNull();
});

it('is not selected', async () => {
expect(await multiSelectAllHarness.isSelected()).toBeFalse();
});

it('has the aria attributes', async () => {
const option = await multiSelectAllHarness.host();

const [role, id, ariaSelected, ariaDisabled] = await parallel(() => [
option.getAttribute('role'),
option.getAttribute('id'),
option.getAttribute('aria-selected'),
option.getAttribute('aria-disabled'),
]);

expect(role).toBe('option');
expect(id).toMatch(/^nx-multi-select-all-\d+$/);
expect(ariaSelected).toBeNull();
expect(ariaDisabled).toBeNull();
});

describe('when selected by click', () => {
beforeEach(async () => {
await multiSelectAllHarness.click();
});

it('is selected', async () => {
expect(await multiSelectAllHarness.isSelected()).toBeTrue();
});

it('shows check icon', async () => {
const icon = await multiSelectAllHarness.getCheckIcon();
expect(icon).not.toBeNull();
});

it('has the aria attributes', async () => {
const option = await multiSelectAllHarness.host();
const ariaSelected = await option.getAttribute('aria-selected');

expect(ariaSelected).toBe('true');
});

it('triggers the selectedChange event', () => {
expect(testInstance.onSelectAll).toHaveBeenCalled();
});
});

describe('when set active', () => {
beforeEach(() => {
multiSelectAllInstance.setActiveStyles();
fixture.detectChanges();
});

it('is active', async () => {
expect(await multiSelectAllHarness.isActive()).toBeTrue();
});

describe('and set inactive', () => {
beforeEach(() => {
multiSelectAllInstance.setInactiveStyles();
fixture.detectChanges();
});

it('is active', async () => {
expect(await multiSelectAllHarness.isActive()).toBeFalse();
});
});
});
});

describe('accessibility', () => {
it('read out select state', async () => {
createTestComponent(BasicMultiSelectAllComponent);
const announceSpy = spyOn(liveAnnouncer, 'announce');
await multiSelectAllHarness.click();

expect(announceSpy).toHaveBeenCalledWith('Select All selected');
});
});
});

@Directive()
abstract class MultiSelectAllTest {
@ViewChild(NxMultiSelectAllComponent) multiSelectOption!: NxMultiSelectAllComponent<any>;

selected = false;
label = 'Select All';
onSelectAll = jasmine.createSpy('onSelectAll');
indeterminate = false;
}

@Component({
template: `
<nx-multi-select-all [selected]="selected" [label]="label" [indeterminate]="indeterminate" (selectedAllChange)="onSelectAll()"> </nx-multi-select-all>
`,
})
class BasicMultiSelectAllComponent extends MultiSelectAllTest {}
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,16 @@
<div *ngIf="!_isOutline && _overlayLabel" class="panel-header" #panelHeader>
{{ _overlayLabel }}
</div>

<div class="item actions select-all" *ngIf="_isOutline && !disableSelectAll">
<nx-checkbox [ngModel]="_allSelected" (ngModelChange)="_onSelectAll()" [indeterminate]="_someSelected">
{{ _intl.selectAll }}
</nx-checkbox>
<nx-multi-select-all
(selectedAllChange)="_onSelectAll()"
[label]="_intl.selectAll"
[indeterminate]="_someSelected"
#selectAllCheckbox
></nx-multi-select-all>
</div>

<div *ngIf="filter" class="filter-wrapper">
<nx-formfield class="filter" appearance="auto">
<input
Expand Down Expand Up @@ -91,6 +96,7 @@
[selected]="this.selectedItems.has(option)"
[disabled]="this._isDisabled(option)"
[appearance]="_appearance"
#option
></nx-multi-select-option>
</div>
</div>
Expand Down
Loading

0 comments on commit f101dbf

Please sign in to comment.