Skip to content

Commit 7c49399

Browse files
authored
feat(cdk-experimental/listbox): multi-select and active descendant support (#19929)
1 parent 657ee35 commit 7c49399

File tree

3 files changed

+520
-33
lines changed

3 files changed

+520
-33
lines changed

src/cdk-experimental/listbox/BUILD.bazel

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ ng_module(
1111
module_name = "@angular/cdk-experimental/listbox",
1212
deps = [
1313
"//src/cdk/a11y",
14+
"//src/cdk/collections",
1415
"//src/cdk/keycodes",
1516
],
1617
)

src/cdk-experimental/listbox/listbox.spec.ts

Lines changed: 298 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -308,21 +308,271 @@ describe('CdkOption', () => {
308308
expect(listboxInstance._listKeyManager.activeItem).toEqual(optionInstances[2]);
309309
expect(listboxInstance._listKeyManager.activeItemIndex).toBe(2);
310310
});
311+
312+
it('should update selected option on click event', () => {
313+
let selectedOptions = optionInstances.filter(option => option.selected);
314+
315+
expect(selectedOptions.length).toBe(0);
316+
expect(optionElements[0].hasAttribute('aria-selected')).toBeFalse();
317+
expect(optionInstances[0].selected).toBeFalse();
318+
expect(fixture.componentInstance.changedOption).toBeUndefined();
319+
320+
dispatchMouseEvent(optionElements[0], 'click');
321+
fixture.detectChanges();
322+
323+
selectedOptions = optionInstances.filter(option => option.selected);
324+
expect(selectedOptions.length).toBe(1);
325+
expect(optionElements[0].getAttribute('aria-selected')).toBe('true');
326+
expect(optionInstances[0].selected).toBeTrue();
327+
expect(fixture.componentInstance.changedOption).toBeDefined();
328+
expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[0].id);
329+
});
330+
});
331+
332+
describe('with multiple selection', () => {
333+
let fixture: ComponentFixture<ListboxMultiselect>;
334+
335+
let testComponent: ListboxMultiselect;
336+
337+
let listbox: DebugElement;
338+
let listboxInstance: CdkListbox;
339+
340+
let options: DebugElement[];
341+
let optionInstances: CdkOption[];
342+
let optionElements: HTMLElement[];
343+
344+
beforeEach(async(() => {
345+
TestBed.configureTestingModule({
346+
imports: [CdkListboxModule],
347+
declarations: [ListboxMultiselect],
348+
}).compileComponents();
349+
}));
350+
351+
beforeEach(async(() => {
352+
fixture = TestBed.createComponent(ListboxMultiselect);
353+
fixture.detectChanges();
354+
355+
testComponent = fixture.debugElement.componentInstance;
356+
357+
listbox = fixture.debugElement.query(By.directive(CdkListbox));
358+
listboxInstance = listbox.injector.get<CdkListbox>(CdkListbox);
359+
360+
options = fixture.debugElement.queryAll(By.directive(CdkOption));
361+
optionInstances = options.map(o => o.injector.get<CdkOption>(CdkOption));
362+
optionElements = options.map(o => o.nativeElement);
363+
}));
364+
365+
it('should select all options using the select all method', () => {
366+
let selectedOptions = optionInstances.filter(option => option.selected);
367+
testComponent.isMultiselectable = true;
368+
fixture.detectChanges();
369+
370+
expect(selectedOptions.length).toBe(0);
371+
expect(optionElements[0].hasAttribute('aria-selected')).toBeFalse();
372+
expect(optionInstances[0].selected).toBeFalse();
373+
expect(fixture.componentInstance.changedOption).toBeUndefined();
374+
375+
listboxInstance.setAllSelected(true);
376+
fixture.detectChanges();
377+
378+
selectedOptions = optionInstances.filter(option => option.selected);
379+
expect(selectedOptions.length).toBe(4);
380+
381+
for (const option of optionElements) {
382+
expect(option.getAttribute('aria-selected')).toBe('true');
383+
}
384+
385+
expect(fixture.componentInstance.changedOption).toBeDefined();
386+
expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[3].id);
387+
});
388+
389+
it('should deselect previously selected when multiple is false', () => {
390+
let selectedOptions = optionInstances.filter(option => option.selected);
391+
392+
expect(selectedOptions.length).toBe(0);
393+
expect(optionElements[0].hasAttribute('aria-selected')).toBeFalse();
394+
expect(optionInstances[0].selected).toBeFalse();
395+
expect(fixture.componentInstance.changedOption).toBeUndefined();
396+
397+
dispatchMouseEvent(optionElements[0], 'click');
398+
fixture.detectChanges();
399+
400+
selectedOptions = optionInstances.filter(option => option.selected);
401+
expect(selectedOptions.length).toBe(1);
402+
expect(optionElements[0].getAttribute('aria-selected')).toBe('true');
403+
expect(optionInstances[0].selected).toBeTrue();
404+
expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[0].id);
405+
406+
dispatchMouseEvent(optionElements[2], 'click');
407+
fixture.detectChanges();
408+
409+
selectedOptions = optionInstances.filter(option => option.selected);
410+
expect(selectedOptions.length).toBe(1);
411+
expect(optionElements[0].hasAttribute('aria-selected')).toBeFalse();
412+
expect(optionInstances[0].selected).toBeFalse();
413+
expect(optionElements[2].getAttribute('aria-selected')).toBe('true');
414+
expect(optionInstances[2].selected).toBeTrue();
415+
416+
/** Expect first option to be most recently changed because it was deselected. */
417+
expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[0].id);
418+
});
419+
420+
it('should allow multiple selection when multiple is true', () => {
421+
let selectedOptions = optionInstances.filter(option => option.selected);
422+
testComponent.isMultiselectable = true;
423+
424+
expect(selectedOptions.length).toBe(0);
425+
expect(fixture.componentInstance.changedOption).toBeUndefined();
426+
427+
dispatchMouseEvent(optionElements[0], 'click');
428+
fixture.detectChanges();
429+
430+
selectedOptions = optionInstances.filter(option => option.selected);
431+
expect(selectedOptions.length).toBe(1);
432+
expect(optionElements[0].getAttribute('aria-selected')).toBe('true');
433+
expect(optionInstances[0].selected).toBeTrue();
434+
expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[0].id);
435+
436+
dispatchMouseEvent(optionElements[2], 'click');
437+
fixture.detectChanges();
438+
439+
selectedOptions = optionInstances.filter(option => option.selected);
440+
expect(selectedOptions.length).toBe(2);
441+
expect(optionElements[0].getAttribute('aria-selected')).toBe('true');
442+
expect(optionInstances[0].selected).toBeTrue();
443+
expect(optionElements[2].getAttribute('aria-selected')).toBe('true');
444+
expect(optionInstances[2].selected).toBeTrue();
445+
expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[2].id);
446+
});
447+
448+
it('should deselect all options when multiple switches to false', () => {
449+
let selectedOptions = optionInstances.filter(option => option.selected);
450+
testComponent.isMultiselectable = true;
451+
452+
expect(selectedOptions.length).toBe(0);
453+
expect(fixture.componentInstance.changedOption).toBeUndefined();
454+
455+
dispatchMouseEvent(optionElements[0], 'click');
456+
fixture.detectChanges();
457+
458+
selectedOptions = optionInstances.filter(option => option.selected);
459+
expect(selectedOptions.length).toBe(1);
460+
expect(optionElements[0].getAttribute('aria-selected')).toBe('true');
461+
expect(optionInstances[0].selected).toBeTrue();
462+
expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[0].id);
463+
464+
testComponent.isMultiselectable = false;
465+
fixture.detectChanges();
466+
467+
selectedOptions = optionInstances.filter(option => option.selected);
468+
expect(selectedOptions.length).toBe(0);
469+
expect(optionElements[0].hasAttribute('aria-selected')).toBeFalse();
470+
expect(optionInstances[0].selected).toBeFalse();
471+
expect(fixture.componentInstance.changedOption.id).toBe(optionInstances[0].id);
472+
});
311473
});
312474

475+
describe('with aria active descendant', () => {
476+
let fixture: ComponentFixture<ListboxActiveDescendant>;
477+
478+
let testComponent: ListboxActiveDescendant;
479+
480+
let listbox: DebugElement;
481+
let listboxInstance: CdkListbox;
482+
let listboxElement: HTMLElement;
483+
484+
let options: DebugElement[];
485+
let optionInstances: CdkOption[];
486+
let optionElements: HTMLElement[];
487+
488+
beforeEach(async(() => {
489+
TestBed.configureTestingModule({
490+
imports: [CdkListboxModule],
491+
declarations: [ListboxActiveDescendant],
492+
}).compileComponents();
493+
}));
494+
495+
beforeEach(async(() => {
496+
fixture = TestBed.createComponent(ListboxActiveDescendant);
497+
fixture.detectChanges();
498+
499+
testComponent = fixture.debugElement.componentInstance;
500+
501+
listbox = fixture.debugElement.query(By.directive(CdkListbox));
502+
listboxInstance = listbox.injector.get<CdkListbox>(CdkListbox);
503+
listboxElement = listbox.nativeElement;
504+
505+
options = fixture.debugElement.queryAll(By.directive(CdkOption));
506+
optionInstances = options.map(o => o.injector.get<CdkOption>(CdkOption));
507+
optionElements = options.map(o => o.nativeElement);
508+
}));
509+
510+
it('should update aria active descendant when enabled', () => {
511+
expect(listboxElement.hasAttribute('aria-activedescendant')).toBeFalse();
512+
513+
listboxInstance.setActiveOption(optionInstances[0]);
514+
fixture.detectChanges();
515+
516+
expect(listboxElement.hasAttribute('aria-activedescendant')).toBeTrue();
517+
expect(listboxElement.getAttribute('aria-activedescendant')).toBe(optionElements[0].id);
518+
519+
listboxInstance.setActiveOption(optionInstances[2]);
520+
fixture.detectChanges();
521+
522+
expect(listboxElement.hasAttribute('aria-activedescendant')).toBeTrue();
523+
expect(listboxElement.getAttribute('aria-activedescendant')).toBe(optionElements[2].id);
524+
});
525+
526+
it('should update aria active descendant via arrow keys', () => {
527+
expect(listboxElement.hasAttribute('aria-activedescendant')).toBeFalse();
528+
529+
dispatchKeyboardEvent(listboxElement, 'keydown', DOWN_ARROW);
530+
fixture.detectChanges();
531+
532+
expect(listboxElement.hasAttribute('aria-activedescendant')).toBeTrue();
533+
expect(listboxElement.getAttribute('aria-activedescendant')).toBe(optionElements[0].id);
534+
535+
dispatchKeyboardEvent(listboxElement, 'keydown', DOWN_ARROW);
536+
fixture.detectChanges();
537+
538+
expect(listboxElement.hasAttribute('aria-activedescendant')).toBeTrue();
539+
expect(listboxElement.getAttribute('aria-activedescendant')).toBe(optionElements[1].id);
540+
});
541+
542+
it('should place focus on options and not set active descendant', () => {
543+
testComponent.isActiveDescendant = false;
544+
fixture.detectChanges();
545+
546+
expect(listboxElement.hasAttribute('aria-activedescendant')).toBeFalse();
547+
548+
dispatchKeyboardEvent(listboxElement, 'keydown', DOWN_ARROW);
549+
fixture.detectChanges();
550+
551+
expect(listboxElement.hasAttribute('aria-activedescendant')).toBeFalse();
552+
expect(document.activeElement).toEqual(optionElements[0]);
553+
dispatchKeyboardEvent(listboxElement, 'keydown', DOWN_ARROW);
554+
fixture.detectChanges();
555+
556+
expect(listboxElement.hasAttribute('aria-activedescendant')).toBeFalse();
557+
expect(document.activeElement).toEqual(optionElements[1]);
558+
559+
});
560+
});
313561
});
314562

315563
@Component({
316564
template: `
317565
<div cdkListbox
318-
[disabled]="isListboxDisabled"
319-
(selectionChange)="onSelectionChange($event)">
566+
[disabled]="isListboxDisabled"
567+
(selectionChange)="onSelectionChange($event)">
320568
<div cdkOption
321569
[disabled]="isPurpleDisabled">
322-
Purple</div>
570+
Purple
571+
</div>
323572
<div cdkOption
324573
[disabled]="isSolarDisabled">
325-
Solar</div>
574+
Solar
575+
</div>
326576
<div cdkOption>Arc</div>
327577
<div cdkOption>Stasis</div>
328578
</div>`
@@ -337,3 +587,47 @@ class ListboxWithOptions {
337587
this.changedOption = event.option;
338588
}
339589
}
590+
591+
@Component({
592+
template: `
593+
<div cdkListbox
594+
[multiple]="isMultiselectable"
595+
(selectionChange)="onSelectionChange($event)">
596+
<div cdkOption>Purple</div>
597+
<div cdkOption>Solar</div>
598+
<div cdkOption>Arc</div>
599+
<div cdkOption>Stasis</div>
600+
</div>`
601+
})
602+
class ListboxMultiselect {
603+
changedOption: CdkOption;
604+
isMultiselectable: boolean = false;
605+
606+
onSelectionChange(event: ListboxSelectionChangeEvent) {
607+
this.changedOption = event.option;
608+
}
609+
}
610+
611+
@Component({
612+
template: `
613+
<div cdkListbox
614+
[useActiveDescendant]="isActiveDescendant">
615+
<div cdkOption>Purple</div>
616+
<div cdkOption>Solar</div>
617+
<div cdkOption>Arc</div>
618+
<div cdkOption>Stasis</div>
619+
</div>`
620+
})
621+
class ListboxActiveDescendant {
622+
changedOption: CdkOption;
623+
isActiveDescendant: boolean = true;
624+
focusedOption: string;
625+
626+
onSelectionChange(event: ListboxSelectionChangeEvent) {
627+
this.changedOption = event.option;
628+
}
629+
630+
onFocus(option: string) {
631+
this.focusedOption = option;
632+
}
633+
}

0 commit comments

Comments
 (0)