Skip to content

Commit 5462862

Browse files
authored
feat(list, select, tree, tree-select): unify Ctrl/Cmd+A select-all across (#DS-3102) (#1690)
1 parent f342ec6 commit 5462862

17 files changed

Lines changed: 932 additions & 39 deletions
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './constants';
22
export * from './pseudo-checkbox/pseudo-checkbox';
33
export * from './pseudo-checkbox/pseudo-checkbox.module';
4+
export * from './select-all';
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { KbqSelectAllAdapter, shouldSelectSearchText, toggleSelectAll } from './select-all';
2+
3+
interface TestItem {
4+
disabled: boolean;
5+
selected: boolean;
6+
}
7+
8+
const createAdapter = (items: TestItem[]): KbqSelectAllAdapter<TestItem> => ({
9+
items,
10+
isSelectable: (item) => !item.disabled,
11+
isSelected: (item) => item.selected,
12+
setSelected: (item, selected) => (item.selected = selected)
13+
});
14+
15+
describe('toggleSelectAll', () => {
16+
it('should select all selectable items when none are selected', () => {
17+
const items: TestItem[] = [
18+
{ disabled: false, selected: false },
19+
{ disabled: false, selected: false }
20+
];
21+
22+
const changed = toggleSelectAll(createAdapter(items));
23+
24+
expect(items.every((item) => item.selected)).toBe(true);
25+
expect(changed.length).toBe(2);
26+
});
27+
28+
it('should select the remaining items when only some are selected', () => {
29+
const items: TestItem[] = [
30+
{ disabled: false, selected: true },
31+
{ disabled: false, selected: false }
32+
];
33+
34+
const changed = toggleSelectAll(createAdapter(items));
35+
36+
expect(items.every((item) => item.selected)).toBe(true);
37+
// only the previously-unselected item flips
38+
expect(changed.length).toBe(1);
39+
});
40+
41+
it('should deselect all items when every selectable item is already selected and allowDeselect is true', () => {
42+
const items: TestItem[] = [
43+
{ disabled: false, selected: true },
44+
{ disabled: false, selected: true }
45+
];
46+
47+
const changed = toggleSelectAll(createAdapter(items), { allowDeselect: true });
48+
49+
expect(items.every((item) => !item.selected)).toBe(true);
50+
expect(changed.length).toBe(2);
51+
});
52+
53+
it('should NOT deselect when all are selected and allowDeselect is false (default)', () => {
54+
const items: TestItem[] = [
55+
{ disabled: false, selected: true },
56+
{ disabled: false, selected: true }
57+
];
58+
59+
const changed = toggleSelectAll(createAdapter(items));
60+
61+
expect(items.every((item) => item.selected)).toBe(true);
62+
expect(changed).toEqual([]);
63+
});
64+
65+
it('should ignore non-selectable (disabled) items', () => {
66+
const items: TestItem[] = [
67+
{ disabled: true, selected: false },
68+
{ disabled: false, selected: false }
69+
];
70+
71+
toggleSelectAll(createAdapter(items));
72+
73+
expect(items[0].selected).toBe(false);
74+
expect(items[1].selected).toBe(true);
75+
});
76+
77+
it('should treat "all selected" based only on selectable items', () => {
78+
// disabled item is unselected but must not force a "select all"
79+
const items: TestItem[] = [
80+
{ disabled: true, selected: false },
81+
{ disabled: false, selected: true }
82+
];
83+
84+
toggleSelectAll(createAdapter(items), { allowDeselect: true });
85+
86+
// every selectable item was already selected -> deselect them
87+
expect(items[1].selected).toBe(false);
88+
});
89+
90+
it('should be a no-op and return an empty array when there are no selectable items', () => {
91+
const items: TestItem[] = [
92+
{ disabled: true, selected: false },
93+
{ disabled: true, selected: false }
94+
];
95+
96+
const changed = toggleSelectAll(createAdapter(items));
97+
98+
expect(changed).toEqual([]);
99+
expect(items.every((item) => !item.selected)).toBe(true);
100+
});
101+
102+
it('should return an empty array when items is empty', () => {
103+
expect(toggleSelectAll(createAdapter([]))).toEqual([]);
104+
});
105+
});
106+
107+
describe('shouldSelectSearchText', () => {
108+
const createInput = (value: string, selectionStart: number | null, selectionEnd: number | null) =>
109+
({ value, selectionStart, selectionEnd }) as HTMLInputElement;
110+
111+
it('should return false when there is no input', () => {
112+
expect(shouldSelectSearchText(null)).toBe(false);
113+
expect(shouldSelectSearchText(undefined)).toBe(false);
114+
});
115+
116+
it('should return false when the input is empty', () => {
117+
expect(shouldSelectSearchText(createInput('', 0, 0))).toBe(false);
118+
});
119+
120+
it('should return true when nothing is selected (caret only)', () => {
121+
expect(shouldSelectSearchText(createInput('text', 4, 4))).toBe(true);
122+
});
123+
124+
it('should return true when the text is only partially selected', () => {
125+
expect(shouldSelectSearchText(createInput('text', 0, 2))).toBe(true);
126+
});
127+
128+
it('should return false when the whole value is already selected', () => {
129+
expect(shouldSelectSearchText(createInput('text', 0, 4))).toBe(false);
130+
});
131+
});
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* Adapter describing how a component reads and writes selection state for a set of items,
3+
* so the shared select-all logic can operate regardless of how selection is stored
4+
* (per-option setter, `SelectionModel` on data nodes, etc.).
5+
*/
6+
export interface KbqSelectAllAdapter<T> {
7+
/** All candidate items, in render/model order. */
8+
items: readonly T[];
9+
/** Whether the item may participate (e.g. not disabled, and — for tree — `selectable()`). */
10+
isSelectable: (item: T) => boolean;
11+
/** Current selected state of the item. */
12+
isSelected: (item: T) => boolean;
13+
/** Applies the new selected state to the item. */
14+
setSelected: (item: T, selected: boolean) => void;
15+
}
16+
17+
/** Options for {@link toggleSelectAll}. */
18+
export interface KbqToggleSelectAllOptions {
19+
/**
20+
* When `true`, a repeated toggle deselects everything once all selectable items are already
21+
* selected. When `false` (default), the toggle only ever selects.
22+
*/
23+
allowDeselect?: boolean;
24+
}
25+
26+
/**
27+
* Canonical "select all / deselect all" toggle shared by the multi-select components (`Ctrl`/`Cmd` + `A`).
28+
*
29+
* Considers only selectable items and selects them all. When `allowDeselect` is `true` and every selectable
30+
* item is already selected, deselects them all instead; otherwise a repeated call is a no-op. No-op when
31+
* there are no selectable items.
32+
*
33+
* @returns the items whose selected state actually flipped, in input order.
34+
*/
35+
export function toggleSelectAll<T>(adapter: KbqSelectAllAdapter<T>, options?: KbqToggleSelectAllOptions): T[] {
36+
const selectable = adapter.items.filter((item) => adapter.isSelectable(item));
37+
38+
if (selectable.length === 0) {
39+
return [];
40+
}
41+
42+
const shouldSelect = !options?.allowDeselect || selectable.some((item) => !adapter.isSelected(item));
43+
const changed = selectable.filter((item) => adapter.isSelected(item) !== shouldSelect);
44+
45+
selectable.forEach((item) => adapter.setSelected(item, shouldSelect));
46+
47+
return changed;
48+
}
49+
50+
/** Event emitted by the `onSelectAll` outputs when the select-all toggle runs. */
51+
export class KbqSelectAllEvent<T, S = unknown> {
52+
constructor(
53+
/** Component that emitted the event. */
54+
public readonly source: S,
55+
/** The selectable options affected by the toggle. */
56+
public readonly options: T[],
57+
/** `true` when all options were selected, `false` when all were deselected. */
58+
public readonly selected: boolean
59+
) {}
60+
}
61+
62+
/**
63+
* Whether `Ctrl`/`Cmd` + `A` should select the text of a search `<input>` rather than toggle options.
64+
*
65+
* Returns `true` only when the input has text that is not already fully selected (nothing selected,
66+
* a partial selection, or just a caret). Returns `false` for an empty input (so select-all falls
67+
* straight through to the options) and when the whole value is already selected (a second press
68+
* then acts on the options).
69+
*/
70+
export function shouldSelectSearchText(input: HTMLInputElement | null | undefined): boolean {
71+
if (!input) {
72+
return false;
73+
}
74+
75+
const length = input.value.length;
76+
77+
if (length === 0) {
78+
return false;
79+
}
80+
81+
return !(input.selectionStart === 0 && input.selectionEnd === length);
82+
}

packages/components/list/list-selection.component.spec.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -489,6 +489,93 @@ describe('KbqListSelection without forms', () => {
489489
expect(event.options.every((o) => !o.disabled)).toBe(true);
490490
});
491491

492+
it('should deselect all options on a second Ctrl+A when selectAllToggle is enabled', () => {
493+
const list: KbqListSelection = selectionList.componentInstance;
494+
const enabledOptions = listOptions.filter(({ componentInstance: o }) => !o.disabled);
495+
496+
fixture.componentInstance.selectAllToggle = true;
497+
fixture.detectChanges();
498+
499+
const pressCtrlA = () => {
500+
const event = createKeyboardEvent('keydown', A);
501+
502+
Object.defineProperty(event, 'ctrlKey', { get: () => true });
503+
list.onKeyDown(event);
504+
fixture.detectChanges();
505+
};
506+
507+
pressCtrlA();
508+
expect(enabledOptions.every(({ componentInstance: o }) => o.selected)).toBe(true);
509+
510+
pressCtrlA();
511+
expect(enabledOptions.every(({ componentInstance: o }) => !o.selected)).toBe(true);
512+
});
513+
514+
it('should keep all options selected on a second Ctrl+A by default (selectAllToggle off)', () => {
515+
const list: KbqListSelection = selectionList.componentInstance;
516+
const enabledOptions = listOptions.filter(({ componentInstance: o }) => !o.disabled);
517+
518+
const pressCtrlA = () => {
519+
const event = createKeyboardEvent('keydown', A);
520+
521+
Object.defineProperty(event, 'ctrlKey', { get: () => true });
522+
list.onKeyDown(event);
523+
fixture.detectChanges();
524+
};
525+
526+
pressCtrlA();
527+
pressCtrlA();
528+
529+
expect(enabledOptions.every(({ componentInstance: o }) => o.selected)).toBe(true);
530+
});
531+
532+
it('should update the form-control value when Ctrl+A is pressed', () => {
533+
const list: KbqListSelection = selectionList.componentInstance;
534+
const onChangeSpy = jest.fn();
535+
536+
list.registerOnChange(onChangeSpy);
537+
538+
const selectAllEvent = createKeyboardEvent('keydown', A);
539+
540+
Object.defineProperty(selectAllEvent, 'ctrlKey', { get: () => true });
541+
542+
list.onKeyDown(selectAllEvent);
543+
fixture.detectChanges();
544+
545+
expect(onChangeSpy).toHaveBeenCalled();
546+
547+
const enabledCount = listOptions.filter(({ componentInstance: o }) => !o.disabled).length;
548+
const [reportedValue] = onChangeSpy.mock.calls[onChangeSpy.mock.calls.length - 1];
549+
550+
expect(reportedValue.length).toBe(enabledCount);
551+
});
552+
553+
it('should invoke a custom selectAllHandler on Ctrl+A instead of the default', () => {
554+
const list: KbqListSelection = selectionList.componentInstance;
555+
const customHandler = jest.fn();
556+
557+
list.selectAllHandler = customHandler;
558+
559+
const selectAllEvent = createKeyboardEvent('keydown', A);
560+
561+
Object.defineProperty(selectAllEvent, 'ctrlKey', { get: () => true });
562+
563+
list.onKeyDown(selectAllEvent);
564+
fixture.detectChanges();
565+
566+
expect(customHandler).toHaveBeenCalledTimes(1);
567+
// default behaviour is bypassed -> nothing gets selected
568+
expect(listOptions.every(({ componentInstance: o }) => !o.selected)).toBe(true);
569+
});
570+
571+
it('should throw when selectAllHandler is set to a non-function', () => {
572+
const list: KbqListSelection = selectionList.componentInstance;
573+
574+
expect(() => {
575+
(list as unknown as { selectAllHandler: unknown }).selectAllHandler = 'not a function';
576+
}).toThrow('`selectAllHandler` must be a function.');
577+
});
578+
492579
it('should navigate to next page when PAGE_DOWN is pressed', () => {
493580
const manager = selectionList.componentInstance.keyManager;
494581

@@ -1136,6 +1223,7 @@ class SelectionListWithCustomComparator {
11361223
multiple="keyboard"
11371224
[autoSelect]="false"
11381225
[noUnselectLast]="false"
1226+
[selectAllToggle]="selectAllToggle"
11391227
(selectionChange)="onValueChange($event)"
11401228
>
11411229
<kbq-list-option checkboxPosition="before" disabled="true" [value]="'inbox'">
@@ -1151,6 +1239,7 @@ class SelectionListWithCustomComparator {
11511239
})
11521240
class SelectionListWithListOptions {
11531241
showLastOption: boolean = true;
1242+
selectAllToggle: boolean = false;
11541243

11551244
onValueChange(_change: KbqListSelectionChange) {}
11561245
}

0 commit comments

Comments
 (0)