Skip to content

Commit 1089098

Browse files
authored
fix(cdk-experimental/ui-patterns): listbox pointer event handler (#30843)
1 parent a7df65f commit 1089098

File tree

3 files changed

+220
-18
lines changed

3 files changed

+220
-18
lines changed

src/cdk-experimental/ui-patterns/behaviors/list-selection/list-selection.ts

+41-14
Original file line numberDiff line numberDiff line change
@@ -115,29 +115,56 @@ export class ListSelection<T extends ListSelectionItem<V>, V> {
115115
this._selectFromIndex(this.inputs.navigation.prevActiveIndex());
116116
}
117117

118-
/** Selects the items in the list starting at the given index. */
119-
private _selectFromIndex(index: number) {
120-
if (index === -1) {
121-
return;
122-
}
123-
124-
const upper = Math.max(this.inputs.navigation.inputs.activeIndex(), index);
125-
const lower = Math.min(this.inputs.navigation.inputs.activeIndex(), index);
126-
127-
for (let i = lower; i <= upper; i++) {
128-
this.select(this.inputs.items()[i]);
129-
}
130-
}
131-
132118
/** Sets the selection to only the current active item. */
133119
selectOne() {
134120
this.deselectAll();
135121
this.select();
136122
}
137123

124+
/** Toggles the items in the list starting at the last selected item. */
125+
toggleFromPrevSelectedItem() {
126+
const prevIndex = this.inputs.items().findIndex(i => this.previousValue() === i.value());
127+
const currIndex = this.inputs.navigation.inputs.activeIndex();
128+
const currValue = this.inputs.items()[currIndex].value();
129+
const items = this._getItemsFromIndex(prevIndex);
130+
131+
const operation = this.inputs.value().includes(currValue)
132+
? this.deselect.bind(this)
133+
: this.select.bind(this);
134+
135+
for (const item of items) {
136+
operation(item);
137+
}
138+
}
139+
138140
/** Sets the anchor to the current active index. */
139141
private _anchor() {
140142
const item = this.inputs.items()[this.inputs.navigation.inputs.activeIndex()];
141143
this.previousValue.set(item.value());
142144
}
145+
146+
/** Selects the items in the list starting at the given index. */
147+
private _selectFromIndex(index: number) {
148+
const items = this._getItemsFromIndex(index);
149+
150+
for (const item of items) {
151+
this.select(item);
152+
}
153+
}
154+
155+
/** Returns all items from the given index to the current active index. */
156+
private _getItemsFromIndex(index: number) {
157+
if (index === -1) {
158+
return [];
159+
}
160+
161+
const upper = Math.max(this.inputs.navigation.inputs.activeIndex(), index);
162+
const lower = Math.min(this.inputs.navigation.inputs.activeIndex(), index);
163+
164+
const items = [];
165+
for (let i = lower; i <= upper; i++) {
166+
items.push(this.inputs.items()[i]);
167+
}
168+
return items;
169+
}
143170
}

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

+157-1
Original file line numberDiff line numberDiff line change
@@ -46,13 +46,15 @@ describe('Listbox Pattern', () => {
4646

4747
function getOptions(listbox: TestListbox, values: string[]): TestOption[] {
4848
return values.map((value, index) => {
49+
const element = document.createElement('div');
50+
element.role = 'option';
4951
return new OptionPattern({
5052
value: signal(value),
5153
id: signal(`option-${index}`),
5254
disabled: signal(false),
5355
searchTerm: signal(value),
5456
listbox: signal(listbox),
55-
element: signal({focus: () => {}} as HTMLElement),
57+
element: signal(element),
5658
});
5759
});
5860
}
@@ -439,4 +441,158 @@ describe('Listbox Pattern', () => {
439441
});
440442
});
441443
});
444+
445+
describe('Pointer Events', () => {
446+
function click(options: WritableSignal<TestOption[]>, index: number, mods?: ModifierKeys) {
447+
return {
448+
target: options()[index].element(),
449+
shiftKey: mods?.shift,
450+
ctrlKey: mods?.control,
451+
} as unknown as PointerEvent;
452+
}
453+
454+
describe('follows focus & single select', () => {
455+
it('should select a single option on click', () => {
456+
const {listbox, options} = getDefaultPatterns({
457+
multi: signal(false),
458+
selectionMode: signal('follow'),
459+
});
460+
listbox.onPointerdown(click(options, 0));
461+
expect(listbox.inputs.value()).toEqual(['Apple']);
462+
});
463+
});
464+
465+
describe('explicit focus & single select', () => {
466+
it('should select an unselected option on click', () => {
467+
const {listbox, options} = getDefaultPatterns({
468+
multi: signal(false),
469+
selectionMode: signal('explicit'),
470+
});
471+
listbox.onPointerdown(click(options, 0));
472+
expect(listbox.inputs.value()).toEqual(['Apple']);
473+
});
474+
475+
it('should deselect a selected option on click', () => {
476+
const {listbox, options} = getDefaultPatterns({
477+
multi: signal(false),
478+
value: signal(['Apple']),
479+
selectionMode: signal('explicit'),
480+
});
481+
listbox.onPointerdown(click(options, 0));
482+
expect(listbox.inputs.value()).toEqual([]);
483+
});
484+
});
485+
486+
describe('explicit focus & multi select', () => {
487+
it('should select an unselected option on click', () => {
488+
const {listbox, options} = getDefaultPatterns({
489+
multi: signal(true),
490+
selectionMode: signal('explicit'),
491+
});
492+
listbox.onPointerdown(click(options, 0));
493+
expect(listbox.inputs.value()).toEqual(['Apple']);
494+
});
495+
496+
it('should deselect a selected option on click', () => {
497+
const {listbox, options} = getDefaultPatterns({
498+
multi: signal(true),
499+
value: signal(['Apple']),
500+
selectionMode: signal('explicit'),
501+
});
502+
listbox.onPointerdown(click(options, 0));
503+
expect(listbox.inputs.value()).toEqual([]);
504+
});
505+
506+
it('should select options from anchor on shift + click', () => {
507+
const {listbox, options} = getDefaultPatterns({
508+
multi: signal(true),
509+
selectionMode: signal('explicit'),
510+
});
511+
listbox.onPointerdown(click(options, 2));
512+
listbox.onPointerdown(click(options, 5, {shift: true}));
513+
expect(listbox.inputs.value()).toEqual(['Banana', 'Blackberry', 'Blueberry', 'Cantaloupe']);
514+
});
515+
516+
it('should deselect options from anchor on shift + click', () => {
517+
const {listbox, options} = getDefaultPatterns({
518+
multi: signal(true),
519+
selectionMode: signal('explicit'),
520+
});
521+
listbox.onPointerdown(click(options, 2));
522+
listbox.onPointerdown(click(options, 5));
523+
listbox.onPointerdown(click(options, 2, {shift: true}));
524+
expect(listbox.inputs.value()).toEqual([]);
525+
});
526+
});
527+
528+
describe('follows focus & multi select', () => {
529+
it('should select a single option on click', () => {
530+
const {listbox, options} = getDefaultPatterns({
531+
multi: signal(true),
532+
selectionMode: signal('follow'),
533+
});
534+
listbox.onPointerdown(click(options, 0));
535+
expect(listbox.inputs.value()).toEqual(['Apple']);
536+
listbox.onPointerdown(click(options, 1));
537+
expect(listbox.inputs.value()).toEqual(['Apricot']);
538+
listbox.onPointerdown(click(options, 2));
539+
expect(listbox.inputs.value()).toEqual(['Banana']);
540+
});
541+
542+
it('should select an unselected option on ctrl + click', () => {
543+
const {listbox, options} = getDefaultPatterns({
544+
multi: signal(true),
545+
selectionMode: signal('follow'),
546+
});
547+
listbox.onPointerdown(click(options, 0));
548+
expect(listbox.inputs.value()).toEqual(['Apple']);
549+
listbox.onPointerdown(click(options, 1, {control: true}));
550+
expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot']);
551+
listbox.onPointerdown(click(options, 2, {control: true}));
552+
expect(listbox.inputs.value()).toEqual(['Apple', 'Apricot', 'Banana']);
553+
});
554+
555+
it('should deselect a selected option on ctrl + click', () => {
556+
const {listbox, options} = getDefaultPatterns({
557+
multi: signal(true),
558+
selectionMode: signal('follow'),
559+
});
560+
listbox.onPointerdown(click(options, 0));
561+
expect(listbox.inputs.value()).toEqual(['Apple']);
562+
listbox.onPointerdown(click(options, 0, {control: true}));
563+
expect(listbox.inputs.value()).toEqual([]);
564+
});
565+
566+
it('should select options from anchor on shift + click', () => {
567+
const {listbox, options} = getDefaultPatterns({
568+
multi: signal(true),
569+
selectionMode: signal('follow'),
570+
});
571+
listbox.onPointerdown(click(options, 2));
572+
listbox.onPointerdown(click(options, 5, {shift: true}));
573+
expect(listbox.inputs.value()).toEqual(['Banana', 'Blackberry', 'Blueberry', 'Cantaloupe']);
574+
});
575+
576+
it('should deselect options from anchor on shift + click', () => {
577+
const {listbox, options} = getDefaultPatterns({
578+
multi: signal(true),
579+
selectionMode: signal('follow'),
580+
});
581+
listbox.onPointerdown(click(options, 2));
582+
listbox.onPointerdown(click(options, 5, {control: true}));
583+
listbox.onPointerdown(click(options, 2, {shift: true}));
584+
expect(listbox.inputs.value()).toEqual([]);
585+
});
586+
});
587+
588+
it('should only navigate when readonly', () => {
589+
const {listbox, options} = getDefaultPatterns({readonly: signal(true)});
590+
listbox.onPointerdown(click(options, 0));
591+
expect(listbox.inputs.value()).toEqual([]);
592+
listbox.onPointerdown(click(options, 1));
593+
expect(listbox.inputs.value()).toEqual([]);
594+
listbox.onPointerdown(click(options, 2));
595+
expect(listbox.inputs.value()).toEqual([]);
596+
});
597+
});
442598
});

src/cdk-experimental/ui-patterns/listbox/listbox.ts

+22-3
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ interface SelectOptions {
2626
selectAll?: boolean;
2727
selectFromAnchor?: boolean;
2828
selectFromActive?: boolean;
29+
toggleFromAnchor?: boolean;
2930
}
3031

3132
/** Represents the required inputs for a listbox. */
@@ -176,13 +177,28 @@ export class ListboxPattern<V> {
176177
return manager.on(e => this.goto(e));
177178
}
178179

179-
if (this.inputs.multi()) {
180+
if (!this.multi() && this.followFocus()) {
181+
return manager.on(e => this.goto(e, {selectOne: true}));
182+
}
183+
184+
if (!this.multi() && !this.followFocus()) {
185+
return manager.on(e => this.goto(e, {toggle: true}));
186+
}
187+
188+
if (this.multi() && this.followFocus()) {
189+
return manager
190+
.on(e => this.goto(e, {selectOne: true}))
191+
.on(Modifier.Ctrl, e => this.goto(e, {toggle: true}))
192+
.on(Modifier.Shift, e => this.goto(e, {toggleFromAnchor: true}));
193+
}
194+
195+
if (this.multi() && !this.followFocus()) {
180196
return manager
181197
.on(e => this.goto(e, {toggle: true}))
182-
.on(Modifier.Shift, e => this.goto(e, {selectFromActive: true}));
198+
.on(Modifier.Shift, e => this.goto(e, {toggleFromAnchor: true}));
183199
}
184200

185-
return manager.on(e => this.goto(e, {toggleOne: true}));
201+
return manager;
186202
});
187203

188204
constructor(readonly inputs: ListboxInputs<V>) {
@@ -279,6 +295,9 @@ export class ListboxPattern<V> {
279295
if (opts?.selectFromActive) {
280296
this.selection.selectFromActive();
281297
}
298+
if (opts?.toggleFromAnchor) {
299+
this.selection.toggleFromPrevSelectedItem();
300+
}
282301
}
283302

284303
private _getItem(e: PointerEvent) {

0 commit comments

Comments
 (0)