Skip to content

Commit d458f5d

Browse files
committed
fix(aria/combobox): multi selection
1 parent fc90cb8 commit d458f5d

File tree

8 files changed

+141
-117
lines changed

8 files changed

+141
-117
lines changed

src/aria/combobox/combobox.spec.ts

Lines changed: 0 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -539,15 +539,6 @@ describe('Combobox', () => {
539539
escape();
540540
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
541541
});
542-
543-
it('should clear selection on escape when closed', () => {
544-
focus();
545-
down();
546-
enter();
547-
expect(inputElement.value).toBe('Alabama');
548-
escape();
549-
expect(inputElement.value).toBe('');
550-
});
551542
});
552543

553544
// describe('with programmatic value changes', () => {
@@ -1096,18 +1087,6 @@ describe('Combobox', () => {
10961087
escape();
10971088
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
10981089
});
1099-
1100-
it('should clear selection on escape when closed', () => {
1101-
focus();
1102-
down();
1103-
right();
1104-
right();
1105-
enter();
1106-
expect(inputElement.value).toBe('December');
1107-
expect(inputElement.getAttribute('aria-expanded')).toBe('false');
1108-
escape();
1109-
expect(inputElement.value).toBe('');
1110-
});
11111090
});
11121091
});
11131092
});

src/aria/combobox/combobox.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export class Combobox<V> {
7171
/** Whether the combobox is focused. */
7272
readonly isFocused = signal(false);
7373

74-
/** Whether the listbox has received focus yet. */
74+
/** Whether the combobox has received focus yet. */
7575
private _hasBeenFocused = signal(false);
7676

7777
/** Whether the combobox is disabled. */

src/aria/private/behaviors/list-selection/list-selection.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,7 @@ export class ListSelection<T extends ListSelectionItem<V>, V> {
7171
}
7272

7373
/** Deselects the item at the current active index. */
74-
deselect(item?: T | null) {
74+
deselect(item?: ListSelectionItem<V>) {
7575
item = item ?? this.inputs.focusManager.inputs.activeItem();
7676

7777
if (item && !item.disabled() && item.selectable()) {
@@ -80,10 +80,10 @@ export class ListSelection<T extends ListSelectionItem<V>, V> {
8080
}
8181

8282
/** Toggles the item at the current active index. */
83-
toggle() {
84-
const item = this.inputs.focusManager.inputs.activeItem();
83+
toggle(item?: ListSelectionItem<V>) {
84+
item = item ?? this.inputs.focusManager.inputs.activeItem();
8585
if (item) {
86-
this.inputs.value().includes(item.value()) ? this.deselect() : this.select();
86+
this.inputs.value().includes(item.value()) ? this.deselect(item) : this.select(item);
8787
}
8888
}
8989

src/aria/private/behaviors/list/list.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -171,8 +171,8 @@ export class List<T extends ListItem<V>, V> {
171171
}
172172

173173
/** Toggles the currently active item in the list. */
174-
toggle() {
175-
this.selectionBehavior.toggle();
174+
toggle(item?: T) {
175+
this.selectionBehavior.toggle(item);
176176
}
177177

178178
/** Toggles the currently active item in the list, deselecting all other items. */

src/aria/private/combobox/combobox.spec.ts

Lines changed: 53 additions & 60 deletions
Original file line numberDiff line numberDiff line change
@@ -375,23 +375,23 @@ describe('Combobox with Listbox Pattern', () => {
375375

376376
it('should select and commit on click', () => {
377377
combobox.onPointerup(clickOption(listbox.inputs.items(), 0));
378-
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]);
378+
expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[0]);
379379
expect(listbox.inputs.value()).toEqual(['Apple']);
380380
expect(inputEl.value).toBe('Apple');
381381
});
382382

383383
it('should select and commit to input on Enter', () => {
384384
combobox.onKeydown(down());
385385
combobox.onKeydown(enter());
386-
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]);
386+
expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[0]);
387387
expect(listbox.inputs.value()).toEqual(['Apple']);
388388
expect(inputEl.value).toBe('Apple');
389389
});
390390

391391
it('should select on focusout if the input text exactly matches an item', () => {
392392
type('Apple');
393393
combobox.onFocusOut(new FocusEvent('focusout'));
394-
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]);
394+
expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[0]);
395395
expect(listbox.inputs.value()).toEqual(['Apple']);
396396
});
397397

@@ -402,26 +402,26 @@ describe('Combobox with Listbox Pattern', () => {
402402
type('Appl', {backspace: true});
403403
combobox.onInput(new InputEvent('input', {inputType: 'deleteContentBackward'}));
404404

405-
expect(listbox.getSelectedItem()).toBe(undefined);
405+
expect(listbox.getSelectedItems().length).toBe(0);
406406
expect(listbox.inputs.value()).toEqual([]);
407407
});
408408

409409
it('should not select on navigation', () => {
410410
combobox.onKeydown(down());
411-
expect(listbox.getSelectedItem()).toBe(undefined);
411+
expect(listbox.getSelectedItems().length).toBe(0);
412412
expect(listbox.inputs.value()).toEqual([]);
413413
});
414414

415415
it('should not select on input', () => {
416416
type('A');
417-
expect(listbox.getSelectedItem()).toBe(undefined);
417+
expect(listbox.getSelectedItems().length).toBe(0);
418418
expect(listbox.inputs.value()).toEqual([]);
419419
});
420420

421421
it('should not select on focusout if the input text does not match an item', () => {
422422
type('Appl');
423423
combobox.onFocusOut(new FocusEvent('focusout'));
424-
expect(listbox.getSelectedItem()).toBe(undefined);
424+
expect(listbox.getSelectedItems().length).toBe(0);
425425
expect(listbox.inputs.value()).toEqual([]);
426426
expect(inputEl.value).toBe('Appl');
427427
});
@@ -436,7 +436,7 @@ describe('Combobox with Listbox Pattern', () => {
436436

437437
it('should select and commit on click', () => {
438438
combobox.onPointerup(clickOption(listbox.inputs.items(), 3));
439-
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[3]);
439+
expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[3]);
440440
expect(listbox.inputs.value()).toEqual(['Blackberry']);
441441
expect(inputEl.value).toBe('Blackberry');
442442
});
@@ -446,20 +446,20 @@ describe('Combobox with Listbox Pattern', () => {
446446
combobox.onKeydown(down());
447447
combobox.onKeydown(down());
448448
combobox.onKeydown(enter());
449-
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]);
449+
expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[2]);
450450
expect(listbox.inputs.value()).toEqual(['Banana']);
451451
expect(inputEl.value).toBe('Banana');
452452
});
453453

454454
it('should select the first item on arrow down when collapsed', () => {
455455
combobox.onKeydown(down());
456-
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]);
456+
expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[0]);
457457
expect(listbox.inputs.value()).toEqual(['Apple']);
458458
});
459459

460460
it('should select the last item on arrow up when collapsed', () => {
461461
combobox.onKeydown(up());
462-
expect(listbox.getSelectedItem()).toBe(
462+
expect(listbox.getSelectedItems()[0]).toBe(
463463
listbox.inputs.items()[listbox.inputs.items().length - 1],
464464
);
465465
expect(listbox.inputs.value()).toEqual(['Cranberry']);
@@ -468,7 +468,7 @@ describe('Combobox with Listbox Pattern', () => {
468468
it('should select on navigation', () => {
469469
combobox.onKeydown(down());
470470
combobox.onKeydown(down());
471-
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]);
471+
expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[1]);
472472
expect(listbox.inputs.value()).toEqual(['Apricot']);
473473
});
474474

@@ -497,7 +497,7 @@ describe('Combobox with Listbox Pattern', () => {
497497

498498
it('should select and commit on click', () => {
499499
combobox.onPointerup(clickOption(listbox.inputs.items(), 3));
500-
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[3]);
500+
expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[3]);
501501
expect(listbox.inputs.value()).toEqual(['Blackberry']);
502502
expect(inputEl.value).toBe('Blackberry');
503503
});
@@ -507,20 +507,20 @@ describe('Combobox with Listbox Pattern', () => {
507507
combobox.onKeydown(down());
508508
combobox.onKeydown(down());
509509
combobox.onKeydown(enter());
510-
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]);
510+
expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[2]);
511511
expect(listbox.inputs.value()).toEqual(['Banana']);
512512
expect(inputEl.value).toBe('Banana');
513513
});
514514

515515
it('should select the first item on arrow down when collapsed', () => {
516516
combobox.onKeydown(down());
517-
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[0]);
517+
expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[0]);
518518
expect(listbox.inputs.value()).toEqual(['Apple']);
519519
});
520520

521521
it('should select the last item on arrow up when collapsed', () => {
522522
combobox.onKeydown(up());
523-
expect(listbox.getSelectedItem()).toBe(
523+
expect(listbox.getSelectedItems()[0]).toBe(
524524
listbox.inputs.items()[listbox.inputs.items().length - 1],
525525
);
526526
expect(listbox.inputs.value()).toEqual(['Cranberry']);
@@ -529,7 +529,7 @@ describe('Combobox with Listbox Pattern', () => {
529529
it('should select on navigation', () => {
530530
combobox.onKeydown(down());
531531
combobox.onKeydown(down());
532-
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[1]);
532+
expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[1]);
533533
expect(listbox.inputs.value()).toEqual(['Apricot']);
534534
});
535535

@@ -580,31 +580,36 @@ describe('Combobox with Listbox Pattern', () => {
580580
});
581581

582582
describe('Readonly mode', () => {
583-
it('should select and close on selection', () => {
584-
const {combobox, listbox, inputEl} = getPatterns({readonly: true});
585-
combobox.onPointerup(clickOption(listbox.inputs.items(), 2));
586-
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]);
587-
expect(listbox.inputs.value()).toEqual(['Banana']);
588-
expect(inputEl.value).toBe('Banana');
589-
expect(combobox.expanded()).toBe(false);
590-
});
583+
describe('with single-select', () => {
584+
it('should select and close on selection', () => {
585+
const {combobox, listbox, inputEl} = getPatterns({readonly: true});
586+
combobox.onPointerup(clickOption(listbox.inputs.items(), 2));
587+
expect(listbox.getSelectedItems()[0]).toBe(listbox.inputs.items()[2]);
588+
expect(listbox.inputs.value()).toEqual(['Banana']);
589+
expect(inputEl.value).toBe('Banana');
590+
expect(combobox.expanded()).toBe(false);
591+
});
591592

592-
it('should close on escape', () => {
593-
const {combobox} = getPatterns({readonly: true});
594-
combobox.onKeydown(down());
595-
expect(combobox.expanded()).toBe(true);
596-
combobox.onKeydown(escape());
597-
expect(combobox.expanded()).toBe(false);
593+
it('should close on escape', () => {
594+
const {combobox} = getPatterns({readonly: true});
595+
combobox.onKeydown(down());
596+
expect(combobox.expanded()).toBe(true);
597+
combobox.onKeydown(escape());
598+
expect(combobox.expanded()).toBe(false);
599+
});
598600
});
599601

600-
it('should clear selection on escape when already closed', () => {
601-
const {combobox, listbox} = getPatterns({readonly: true});
602-
combobox.onPointerup(clickOption(listbox.inputs.items(), 2));
603-
expect(listbox.getSelectedItem()).toBe(listbox.inputs.items()[2]);
604-
expect(listbox.inputs.value()).toEqual(['Banana']);
605-
combobox.onKeydown(escape());
606-
expect(listbox.getSelectedItem()).toBe(undefined);
607-
expect(listbox.inputs.value()).toEqual([]);
602+
describe('with multi-select', () => {
603+
it('should allow users to select multiple options', () => {
604+
const {combobox, listbox, inputEl} = getPatterns({readonly: true});
605+
(listbox.inputs.multi as WritableSignal<boolean>).set(true);
606+
607+
combobox.onPointerup(clickOption(listbox.inputs.items(), 1));
608+
combobox.onPointerup(clickOption(listbox.inputs.items(), 2));
609+
610+
expect(listbox.inputs.value()).toEqual(['Apricot', 'Banana']);
611+
expect(inputEl.value).toBe('Apricot, Banana');
612+
});
608613
});
609614
});
610615
});
@@ -742,7 +747,7 @@ describe('Combobox with Tree Pattern', () => {
742747
it('should select and commit to input on Enter', () => {
743748
combobox.onKeydown(down());
744749
combobox.onKeydown(enter());
745-
expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[0]);
750+
expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[0]);
746751
expect(tree.inputs.value()).toEqual(['Fruit']);
747752
expect(inputEl.value).toBe('Fruit');
748753
});
@@ -760,26 +765,26 @@ describe('Combobox with Tree Pattern', () => {
760765

761766
type('Appl', {backspace: true});
762767

763-
expect(tree.getSelectedItem()).toBe(undefined);
768+
expect(tree.getSelectedItems().length).toBe(0);
764769
expect(tree.inputs.value()).toEqual([]);
765770
});
766771

767772
it('should not select on navigation', () => {
768773
combobox.onKeydown(down());
769-
expect(tree.getSelectedItem()).toBe(undefined);
774+
expect(tree.getSelectedItems().length).toBe(0);
770775
expect(tree.inputs.value()).toEqual([]);
771776
});
772777

773778
it('should not select on input', () => {
774779
type('A');
775-
expect(tree.getSelectedItem()).toBe(undefined);
780+
expect(tree.getSelectedItems().length).toBe(0);
776781
expect(tree.inputs.value()).toEqual([]);
777782
});
778783

779784
it('should not select on focusout if the input text does not match an item', () => {
780785
type('Appl');
781786
combobox.onFocusOut(new FocusEvent('focusout'));
782-
expect(tree.getSelectedItem()).toBe(undefined);
787+
expect(tree.getSelectedItems().length).toBe(0);
783788
expect(tree.inputs.value()).toEqual([]);
784789
expect(inputEl.value).toBe('Appl');
785790
});
@@ -794,7 +799,7 @@ describe('Combobox with Tree Pattern', () => {
794799

795800
it('should select and commit on click', () => {
796801
combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 2));
797-
expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[2]);
802+
expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[2]);
798803
expect(tree.inputs.value()).toEqual(['Banana']);
799804
expect(inputEl.value).toBe('Banana');
800805
});
@@ -810,7 +815,7 @@ describe('Combobox with Tree Pattern', () => {
810815

811816
it('should select the first item on arrow down when collapsed', () => {
812817
combobox.onKeydown(down());
813-
expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[0]);
818+
expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[0]);
814819
expect(tree.inputs.value()).toEqual(['Fruit']);
815820
});
816821

@@ -851,7 +856,7 @@ describe('Combobox with Tree Pattern', () => {
851856

852857
it('should select and commit on click', () => {
853858
combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 2));
854-
expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[2]);
859+
expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[2]);
855860
expect(tree.inputs.value()).toEqual(['Banana']);
856861
expect(inputEl.value).toBe('Banana');
857862
});
@@ -867,7 +872,7 @@ describe('Combobox with Tree Pattern', () => {
867872

868873
it('should select the first item on arrow down when collapsed', () => {
869874
combobox.onKeydown(down());
870-
expect(tree.getSelectedItem()).toBe(tree.inputs.allItems()[0]);
875+
expect(tree.getSelectedItems()[0]).toBe(tree.inputs.allItems()[0]);
871876
expect(tree.inputs.value()).toEqual(['Fruit']);
872877
});
873878

@@ -935,17 +940,5 @@ describe('Combobox with Tree Pattern', () => {
935940
combobox.onKeydown(escape());
936941
expect(combobox.expanded()).toBe(false);
937942
});
938-
939-
it('should clear selection on escape when already closed', () => {
940-
const {combobox, tree, inputEl} = getPatterns({readonly: true});
941-
combobox.onPointerup(clickInput(inputEl));
942-
combobox.onPointerup(clickTreeItem(tree.inputs.allItems(), 0));
943-
expect(tree.inputs.value()).toEqual(['Fruit']);
944-
expect(inputEl.value).toBe('Fruit');
945-
expect(combobox.expanded()).toBe(false);
946-
combobox.onKeydown(escape());
947-
expect(tree.inputs.value()).toEqual([]);
948-
expect(inputEl.value).toBe('');
949-
});
950943
});
951944
});

0 commit comments

Comments
 (0)