Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/components/core/selection/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './constants';
export * from './pseudo-checkbox/pseudo-checkbox';
export * from './pseudo-checkbox/pseudo-checkbox.module';
export * from './select-all';
131 changes: 131 additions & 0 deletions packages/components/core/selection/select-all.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { KbqSelectAllAdapter, shouldSelectSearchText, toggleSelectAll } from './select-all';

interface TestItem {
disabled: boolean;
selected: boolean;
}

const createAdapter = (items: TestItem[]): KbqSelectAllAdapter<TestItem> => ({
items,
isSelectable: (item) => !item.disabled,
isSelected: (item) => item.selected,
setSelected: (item, selected) => (item.selected = selected)
});

describe('toggleSelectAll', () => {
it('should select all selectable items when none are selected', () => {
const items: TestItem[] = [
{ disabled: false, selected: false },
{ disabled: false, selected: false }
];

const changed = toggleSelectAll(createAdapter(items));

expect(items.every((item) => item.selected)).toBe(true);
expect(changed.length).toBe(2);
});

it('should select the remaining items when only some are selected', () => {
const items: TestItem[] = [
{ disabled: false, selected: true },
{ disabled: false, selected: false }
];

const changed = toggleSelectAll(createAdapter(items));

expect(items.every((item) => item.selected)).toBe(true);
// only the previously-unselected item flips
expect(changed.length).toBe(1);
});

it('should deselect all items when every selectable item is already selected and allowDeselect is true', () => {
const items: TestItem[] = [
{ disabled: false, selected: true },
{ disabled: false, selected: true }
];

const changed = toggleSelectAll(createAdapter(items), { allowDeselect: true });

expect(items.every((item) => !item.selected)).toBe(true);
expect(changed.length).toBe(2);
});

it('should NOT deselect when all are selected and allowDeselect is false (default)', () => {
const items: TestItem[] = [
{ disabled: false, selected: true },
{ disabled: false, selected: true }
];

const changed = toggleSelectAll(createAdapter(items));

expect(items.every((item) => item.selected)).toBe(true);
expect(changed).toEqual([]);
});

it('should ignore non-selectable (disabled) items', () => {
const items: TestItem[] = [
{ disabled: true, selected: false },
{ disabled: false, selected: false }
];

toggleSelectAll(createAdapter(items));

expect(items[0].selected).toBe(false);
expect(items[1].selected).toBe(true);
});

it('should treat "all selected" based only on selectable items', () => {
// disabled item is unselected but must not force a "select all"
const items: TestItem[] = [
{ disabled: true, selected: false },
{ disabled: false, selected: true }
];

toggleSelectAll(createAdapter(items), { allowDeselect: true });

// every selectable item was already selected -> deselect them
expect(items[1].selected).toBe(false);
});

it('should be a no-op and return an empty array when there are no selectable items', () => {
const items: TestItem[] = [
{ disabled: true, selected: false },
{ disabled: true, selected: false }
];

const changed = toggleSelectAll(createAdapter(items));

expect(changed).toEqual([]);
expect(items.every((item) => !item.selected)).toBe(true);
});

it('should return an empty array when items is empty', () => {
expect(toggleSelectAll(createAdapter([]))).toEqual([]);
});
});

describe('shouldSelectSearchText', () => {
const createInput = (value: string, selectionStart: number | null, selectionEnd: number | null) =>
({ value, selectionStart, selectionEnd }) as HTMLInputElement;

it('should return false when there is no input', () => {
expect(shouldSelectSearchText(null)).toBe(false);
expect(shouldSelectSearchText(undefined)).toBe(false);
});

it('should return false when the input is empty', () => {
expect(shouldSelectSearchText(createInput('', 0, 0))).toBe(false);
});

it('should return true when nothing is selected (caret only)', () => {
expect(shouldSelectSearchText(createInput('text', 4, 4))).toBe(true);
});

it('should return true when the text is only partially selected', () => {
expect(shouldSelectSearchText(createInput('text', 0, 2))).toBe(true);
});

it('should return false when the whole value is already selected', () => {
expect(shouldSelectSearchText(createInput('text', 0, 4))).toBe(false);
});
});
82 changes: 82 additions & 0 deletions packages/components/core/selection/select-all.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
/**
* Adapter describing how a component reads and writes selection state for a set of items,
* so the shared select-all logic can operate regardless of how selection is stored
* (per-option setter, `SelectionModel` on data nodes, etc.).
*/
export interface KbqSelectAllAdapter<T> {
/** All candidate items, in render/model order. */
items: readonly T[];
/** Whether the item may participate (e.g. not disabled, and — for tree — `selectable()`). */
isSelectable: (item: T) => boolean;
/** Current selected state of the item. */
isSelected: (item: T) => boolean;
/** Applies the new selected state to the item. */
setSelected: (item: T, selected: boolean) => void;
}

/** Options for {@link toggleSelectAll}. */
export interface KbqToggleSelectAllOptions {
/**
* When `true`, a repeated toggle deselects everything once all selectable items are already
* selected. When `false` (default), the toggle only ever selects.
*/
allowDeselect?: boolean;
}

/**
* Canonical "select all / deselect all" toggle shared by the multi-select components (`Ctrl`/`Cmd` + `A`).
*
* Considers only selectable items and selects them all. When `allowDeselect` is `true` and every selectable
* item is already selected, deselects them all instead; otherwise a repeated call is a no-op. No-op when
* there are no selectable items.
*
* @returns the items whose selected state actually flipped, in input order.
*/
export function toggleSelectAll<T>(adapter: KbqSelectAllAdapter<T>, options?: KbqToggleSelectAllOptions): T[] {
const selectable = adapter.items.filter((item) => adapter.isSelectable(item));

if (selectable.length === 0) {
return [];
}

const shouldSelect = !options?.allowDeselect || selectable.some((item) => !adapter.isSelected(item));
const changed = selectable.filter((item) => adapter.isSelected(item) !== shouldSelect);

selectable.forEach((item) => adapter.setSelected(item, shouldSelect));

return changed;
}

/** Event emitted by the `onSelectAll` outputs when the select-all toggle runs. */
export class KbqSelectAllEvent<T, S = unknown> {
constructor(
/** Component that emitted the event. */
public readonly source: S,
/** The selectable options affected by the toggle. */
public readonly options: T[],
/** `true` when all options were selected, `false` when all were deselected. */
public readonly selected: boolean
) {}
}

/**
* Whether `Ctrl`/`Cmd` + `A` should select the text of a search `<input>` rather than toggle options.
*
* Returns `true` only when the input has text that is not already fully selected (nothing selected,
* a partial selection, or just a caret). Returns `false` for an empty input (so select-all falls
* straight through to the options) and when the whole value is already selected (a second press
* then acts on the options).
*/
export function shouldSelectSearchText(input: HTMLInputElement | null | undefined): boolean {
if (!input) {
return false;
}

const length = input.value.length;

if (length === 0) {
return false;
}

return !(input.selectionStart === 0 && input.selectionEnd === length);
}
89 changes: 89 additions & 0 deletions packages/components/list/list-selection.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -489,6 +489,93 @@ describe('KbqListSelection without forms', () => {
expect(event.options.every((o) => !o.disabled)).toBe(true);
});

it('should deselect all options on a second Ctrl+A when selectAllToggle is enabled', () => {
const list: KbqListSelection = selectionList.componentInstance;
const enabledOptions = listOptions.filter(({ componentInstance: o }) => !o.disabled);

fixture.componentInstance.selectAllToggle = true;
fixture.detectChanges();

const pressCtrlA = () => {
const event = createKeyboardEvent('keydown', A);

Object.defineProperty(event, 'ctrlKey', { get: () => true });
list.onKeyDown(event);
fixture.detectChanges();
};

pressCtrlA();
expect(enabledOptions.every(({ componentInstance: o }) => o.selected)).toBe(true);

pressCtrlA();
expect(enabledOptions.every(({ componentInstance: o }) => !o.selected)).toBe(true);
});

it('should keep all options selected on a second Ctrl+A by default (selectAllToggle off)', () => {
const list: KbqListSelection = selectionList.componentInstance;
const enabledOptions = listOptions.filter(({ componentInstance: o }) => !o.disabled);

const pressCtrlA = () => {
const event = createKeyboardEvent('keydown', A);

Object.defineProperty(event, 'ctrlKey', { get: () => true });
list.onKeyDown(event);
fixture.detectChanges();
};

pressCtrlA();
pressCtrlA();

expect(enabledOptions.every(({ componentInstance: o }) => o.selected)).toBe(true);
});

it('should update the form-control value when Ctrl+A is pressed', () => {
const list: KbqListSelection = selectionList.componentInstance;
const onChangeSpy = jest.fn();

list.registerOnChange(onChangeSpy);

const selectAllEvent = createKeyboardEvent('keydown', A);

Object.defineProperty(selectAllEvent, 'ctrlKey', { get: () => true });

list.onKeyDown(selectAllEvent);
fixture.detectChanges();

expect(onChangeSpy).toHaveBeenCalled();

const enabledCount = listOptions.filter(({ componentInstance: o }) => !o.disabled).length;
const [reportedValue] = onChangeSpy.mock.calls[onChangeSpy.mock.calls.length - 1];

expect(reportedValue.length).toBe(enabledCount);
});

it('should invoke a custom selectAllHandler on Ctrl+A instead of the default', () => {
const list: KbqListSelection = selectionList.componentInstance;
const customHandler = jest.fn();

list.selectAllHandler = customHandler;

const selectAllEvent = createKeyboardEvent('keydown', A);

Object.defineProperty(selectAllEvent, 'ctrlKey', { get: () => true });

list.onKeyDown(selectAllEvent);
fixture.detectChanges();

expect(customHandler).toHaveBeenCalledTimes(1);
// default behaviour is bypassed -> nothing gets selected
expect(listOptions.every(({ componentInstance: o }) => !o.selected)).toBe(true);
});

it('should throw when selectAllHandler is set to a non-function', () => {
const list: KbqListSelection = selectionList.componentInstance;

expect(() => {
(list as unknown as { selectAllHandler: unknown }).selectAllHandler = 'not a function';
}).toThrow('`selectAllHandler` must be a function.');
});

it('should navigate to next page when PAGE_DOWN is pressed', () => {
const manager = selectionList.componentInstance.keyManager;

Expand Down Expand Up @@ -1136,6 +1223,7 @@ class SelectionListWithCustomComparator {
multiple="keyboard"
[autoSelect]="false"
[noUnselectLast]="false"
[selectAllToggle]="selectAllToggle"
(selectionChange)="onValueChange($event)"
>
<kbq-list-option checkboxPosition="before" disabled="true" [value]="'inbox'">
Expand All @@ -1151,6 +1239,7 @@ class SelectionListWithCustomComparator {
})
class SelectionListWithListOptions {
showLastOption: boolean = true;
selectAllToggle: boolean = false;

onValueChange(_change: KbqListSelectionChange) {}
}
Expand Down
Loading