From ccfafebb229b5d49770b73758dda1d8a73cf3968 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Thu, 2 Jul 2026 14:12:24 +0300 Subject: [PATCH 1/5] feat(list, select, tree, tree-select): unify Ctrl/Cmd+A select-all across (#DS-3102) --- packages/components/core/selection/index.ts | 1 + .../core/selection/select-all.spec.ts | 131 ++++++++++++++++ .../components/core/selection/select-all.ts | 82 ++++++++++ .../list/list-selection.component.spec.ts | 86 ++++++++++ .../list/list-selection.component.ts | 49 +++++- .../select/select.component.spec.ts | 147 +++++++++++++++++- .../components/select/select.component.ts | 56 +++++-- .../tree-select/tree-select.component.spec.ts | 138 +++++++++++++++- .../tree-select/tree-select.component.ts | 32 +++- .../tree/tree-selection.component.spec.ts | 40 +++++ .../tree/tree-selection.component.ts | 59 +++++-- .../select-search/select-search-example.ts | 2 +- tools/public_api_guard/components/core.api.md | 30 ++++ tools/public_api_guard/components/list.api.md | 7 +- .../public_api_guard/components/select.api.md | 7 +- .../components/tree-select.api.md | 7 +- tools/public_api_guard/components/tree.api.md | 9 +- 17 files changed, 846 insertions(+), 37 deletions(-) create mode 100644 packages/components/core/selection/select-all.spec.ts create mode 100644 packages/components/core/selection/select-all.ts diff --git a/packages/components/core/selection/index.ts b/packages/components/core/selection/index.ts index 8086fe875f..d0b6eeeda1 100644 --- a/packages/components/core/selection/index.ts +++ b/packages/components/core/selection/index.ts @@ -1,3 +1,4 @@ export * from './constants'; export * from './pseudo-checkbox/pseudo-checkbox'; export * from './pseudo-checkbox/pseudo-checkbox.module'; +export * from './select-all'; diff --git a/packages/components/core/selection/select-all.spec.ts b/packages/components/core/selection/select-all.spec.ts new file mode 100644 index 0000000000..d658cf54d5 --- /dev/null +++ b/packages/components/core/selection/select-all.spec.ts @@ -0,0 +1,131 @@ +import { KbqSelectAllAdapter, shouldSelectSearchText, toggleSelectAll } from './select-all'; + +interface TestItem { + disabled: boolean; + selected: boolean; +} + +const createAdapter = (items: TestItem[]): KbqSelectAllAdapter => ({ + 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); + }); +}); diff --git a/packages/components/core/selection/select-all.ts b/packages/components/core/selection/select-all.ts new file mode 100644 index 0000000000..d909c64282 --- /dev/null +++ b/packages/components/core/selection/select-all.ts @@ -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 { + /** 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(adapter: KbqSelectAllAdapter, 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 { + constructor( + /** Component that emitted the event. */ + public source: unknown, + /** The selectable options affected by the toggle. */ + public options: T[], + /** `true` when all options were selected, `false` when all were deselected. */ + public selected: boolean + ) {} +} + +/** + * Whether `Ctrl`/`Cmd` + `A` should select the text of a search `` 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); +} diff --git a/packages/components/list/list-selection.component.spec.ts b/packages/components/list/list-selection.component.spec.ts index 4cbef43e53..41fd066fe8 100644 --- a/packages/components/list/list-selection.component.spec.ts +++ b/packages/components/list/list-selection.component.spec.ts @@ -489,6 +489,92 @@ 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); + + list.selectAllToggle = true; + + 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; diff --git a/packages/components/list/list-selection.component.ts b/packages/components/list/list-selection.component.ts index 981aa3cecc..8d01c5a9e2 100644 --- a/packages/components/list/list-selection.component.ts +++ b/packages/components/list/list-selection.component.ts @@ -58,6 +58,7 @@ import { RIGHT_ARROW, SPACE, TAB, + toggleSelectAll, UP_ARROW } from '@koobiq/components/core'; import { KbqDropdownTrigger } from '@koobiq/components/dropdown'; @@ -165,6 +166,9 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest private _noUnselectLast: boolean = true; + /** When `true`, a repeated Ctrl/Cmd+A deselects all options. Off by default (Ctrl+A only selects). */ + @Input({ transform: booleanAttribute }) selectAllToggle: boolean = false; + multipleMode: MultipleMode | null; get multiple(): boolean { @@ -494,8 +498,7 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest } if (this.multiple && isSelectAll(event)) { - this.selectAllOptions(); - event.preventDefault(); + this.selectAllHandler(event, this); return; } else if (isCopy(event)) { @@ -634,12 +637,46 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest // View to model callback that should be called whenever the selected options change. private onChange: (value: any) => void = (_: any) => {}; - private selectAllOptions() { - const optionsToSelect = this.options.filter((option) => !option.disabled); + /** + * Function for handling the combination Ctrl + A (select all). By default, the internal handler is used, + * which toggles the selection of all non-disabled options. + */ + @Input() + get selectAllHandler() { + return this._selectAllHandler; + } - optionsToSelect.forEach((option) => option.setSelected(true)); + set selectAllHandler(fn: (event: KeyboardEvent, list: KbqListSelection) => void) { + if (typeof fn !== 'function') { + throw Error('`selectAllHandler` must be a function.'); + } - this.onSelectAll.emit(new KbqListSelectAllEvent(this, optionsToSelect)); + this._selectAllHandler = fn; + } + + private _selectAllHandler(event: KeyboardEvent, list: KbqListSelection): void { + event.preventDefault(); + + const options = list.options.toArray(); + + toggleSelectAll( + { + items: options, + isSelectable: (option) => !option.disabled, + isSelected: (option) => option.selected, + setSelected: (option, selected) => option.setSelected(selected) + }, + { allowDeselect: list.selectAllToggle } + ); + + list.reportValueChange(); + + list.onSelectAll.emit( + new KbqListSelectAllEvent( + list, + options.filter((option) => !option.disabled) + ) + ); } private copyActiveOption(event: KeyboardEvent) { diff --git a/packages/components/select/select.component.spec.ts b/packages/components/select/select.component.spec.ts index 82ca69ba37..58fe49f3ff 100644 --- a/packages/components/select/select.component.spec.ts +++ b/packages/components/select/select.component.spec.ts @@ -516,6 +516,34 @@ class SelectWithSearch implements OnInit { } } +@Component({ + selector: 'multiple-select-with-search', + imports: [ + KbqSelectModule, + KbqInputModule, + ReactiveFormsModule + ], + template: ` + + + + + + + @for (option of options; track option) { + {{ option }} + } + + + ` +}) +class MultipleSelectWithSearch { + readonly select = viewChild.required(KbqSelect); + + searchCtrl: UntypedFormControl = new UntypedFormControl(); + options: string[] = ['One', 'Two', 'Three', 'Four']; +} + @Component({ selector: 'custom-select-accessor', imports: [ @@ -3704,6 +3732,82 @@ describe('KbqSelect', () => { })); }); + describe('Ctrl+A with search (multiple)', () => { + let fixture: ComponentFixture; + let testInstance: MultipleSelectWithSearch; + + beforeEach(fakeAsync(() => { + configureKbqSelectTestingModule([MultipleSelectWithSearch]); + fixture = TestBed.createComponent(MultipleSelectWithSearch); + testInstance = fixture.componentInstance; + fixture.detectChanges(); + + testInstance.select().open(); + fixture.detectChanges(); + flush(); + })); + + const getSearchInput = (): HTMLInputElement => + fixture.debugElement.query(By.css('.search-input')).nativeElement; + + const pressCtrlA = (target: HTMLElement) => { + const event = createKeyboardEvent('keydown', A); + + Object.defineProperty(event, 'ctrlKey', { get: () => true }); + dispatchEvent(target, event); + fixture.detectChanges(); + }; + + const allOptionsSelected = (): boolean => + testInstance + .select() + .options.toArray() + .every((o) => o.selected); + const anyOptionSelected = (): boolean => + testInstance + .select() + .options.toArray() + .some((o) => o.selected); + + it('should select all options when the search field is empty', () => { + const input = getSearchInput(); + + input.value = ''; + + pressCtrlA(input); + + expect(allOptionsSelected()).toBe(true); + }); + + it('should select the search text (not options) when the text is only partially selected', () => { + const input = getSearchInput(); + const onSelectAll = jest.fn(); + + testInstance.select().onSelectAll.subscribe(onSelectAll); + + input.value = 'Th'; + input.setSelectionRange(0, 1); + + pressCtrlA(input); + + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe(input.value.length); + expect(anyOptionSelected()).toBe(false); + expect(onSelectAll).not.toHaveBeenCalled(); + }); + + it('should select all options when the search text is already fully selected', () => { + const input = getSearchInput(); + + input.value = 'Th'; + input.setSelectionRange(0, input.value.length); + + pressCtrlA(input); + + expect(allOptionsSelected()).toBe(true); + }); + }); + describe('with search', () => { beforeEach(() => { configureKbqSelectTestingModule([SelectWithSearch]); @@ -5210,6 +5314,24 @@ describe('KbqSelect', () => { ]); }); + it('should select all options when pressing cmd + a on macOS (metaKey)', () => { + const selectElement = fixture.nativeElement.querySelector('kbq-select'); + const options = fixture.componentInstance.options(); + + expect(options.every((option) => option.selected)).toBe(false); + + fixture.componentInstance.select().open(); + fixture.detectChanges(); + + const event = createKeyboardEvent('keydown', A, selectElement); + + Object.defineProperty(event, 'metaKey', { get: () => true }); + dispatchEvent(selectElement, event); + fixture.detectChanges(); + + expect(options.every((option) => option.selected)).toBe(true); + }); + it('should skip disabled options when using ctrl + a', () => { const selectElement = fixture.nativeElement.querySelector('kbq-select'); const options = fixture.componentInstance.options(); @@ -5270,10 +5392,12 @@ describe('KbqSelect', () => { ]); }); - it('should deselect all options with ctrl + a if all options are selected', () => { + it('should deselect all options with ctrl + a if all options are selected and selectAllToggle is enabled', () => { const selectElement = fixture.nativeElement.querySelector('kbq-select'); const options = fixture.componentInstance.options(); + fixture.componentInstance.select().selectAllToggle = true; + options.forEach((option) => option.select()); fixture.detectChanges(); @@ -5302,6 +5426,27 @@ describe('KbqSelect', () => { expect(testInstance.control.value).toEqual([]); }); + it('should keep all options selected with ctrl + a by default (selectAllToggle off)', () => { + const selectElement = fixture.nativeElement.querySelector('kbq-select'); + const options = fixture.componentInstance.options(); + + options.forEach((option) => option.select()); + fixture.detectChanges(); + + expect(options.every((option) => option.selected)).toBe(true); + + fixture.componentInstance.select().open(); + fixture.detectChanges(); + + const event = createKeyboardEvent('keydown', A, selectElement); + + Object.defineProperty(event, 'ctrlKey', { get: () => true }); + dispatchEvent(selectElement, event); + fixture.detectChanges(); + + expect(options.every((option) => option.selected)).toBe(true); + }); + it('should allow providing custom tag content', fakeAsync(() => { const fixtureCustomizedContent = TestBed.createComponent(MultiSelectWithCustomizedTagContent); const componentInstance = fixtureCustomizedContent.componentInstance; diff --git a/packages/components/select/select.component.ts b/packages/components/select/select.component.ts index fb483d6c4f..f777a47084 100644 --- a/packages/components/select/select.component.ts +++ b/packages/components/select/select.component.ts @@ -43,7 +43,6 @@ import { import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { ControlValueAccessor, FormGroupDirective, NgControl, NgForm, UntypedFormControl } from '@angular/forms'; import { - A, ActiveDescendantKeyManager, BACKSPACE, CanUpdateErrorState, @@ -66,6 +65,7 @@ import { KbqOption, KbqOptionBase, KbqOptionSelectionChange, + KbqSelectAllEvent, KbqSelectFooter, KbqSelectMatcher, KbqSelectSearch, @@ -83,8 +83,12 @@ import { getKbqSelectDynamicMultipleError, getKbqSelectNonArrayValueError, getKbqSelectNonFunctionValueError, + isInput, + isSelectAll, isUndefined, - kbqSelectAnimations + kbqSelectAnimations, + shouldSelectSearchText, + toggleSelectAll } from '@koobiq/components/core'; import { KbqCleaner, KbqFormField, KbqFormFieldControl } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; @@ -468,6 +472,12 @@ export class KbqSelect /** Event emitted when the selected value has been changed by the user. */ readonly selectionChange = output(); + /** + * Event emitted when all options are selected or deselected via the Ctrl/Cmd + A shortcut. + * Not emitted when a custom `selectAllHandler` is supplied — the handler owns the behaviour then. + */ + readonly onSelectAll = output>(); + /** * Event that emits whenever the raw value of the select changes. This is here primarily * to facilitate the two-way binding for the `value` input. @@ -601,6 +611,9 @@ export class KbqSelect */ readonly virtualOptionFactory = input<(value: any) => KbqVirtualOption>(); + /** When `true`, a repeated Ctrl/Cmd+A deselects all options. Off by default (Ctrl+A only selects). */ + @Input({ transform: booleanAttribute }) selectAllToggle: boolean = false; + /** * Function for handling the Ctrl + A (select all) keyboard combination. * By default, the internal handler selects all options. @@ -1583,7 +1596,7 @@ export class KbqSelect } else if ((keyCode === ENTER || keyCode === SPACE) && this.keyManager.activeItem) { event.preventDefault(); this.keyManager.activeItem.selectViaInteraction(); - } else if (this.multiSelection && keyCode === A && event.ctrlKey) { + } else if (this.multiSelection && isSelectAll(event)) { this.selectAllHandler(event, this); } else { const previouslyFocusedIndex = this.keyManager.activeItemIndex; @@ -1862,17 +1875,38 @@ export class KbqSelect /** Function for handling the combination Ctrl + A (select all). By default, the internal handler is used. */ private _selectAllHandler(event: KeyboardEvent, select: KbqSelect): void { + const searchInput = isInput(event) ? (event.target as HTMLInputElement) : null; + + if (shouldSelectSearchText(searchInput)) { + searchInput!.select(); + event.preventDefault(); + + return; + } + event.preventDefault(); - const hasDeselectedOptions = select.options.some((option) => !option.selected); + const options = select.options.toArray(); - select.options.forEach((option) => { - if (hasDeselectedOptions && !option.disabled) { - option.select(); - } else { - option.deselect(); - } - }); + const changed = toggleSelectAll( + { + items: options, + isSelectable: (option) => !option.disabled, + isSelected: (option) => option.selected, + setSelected: (option, selected) => (selected ? option.select() : option.deselect()) + }, + { allowDeselect: select.selectAllToggle } + ); + + const selected = changed.length > 0 && changed[0].selected; + + select.onSelectAll.emit( + new KbqSelectAllEvent( + select, + options.filter((option) => !option.disabled), + selected + ) + ); } /** diff --git a/packages/components/tree-select/tree-select.component.spec.ts b/packages/components/tree-select/tree-select.component.spec.ts index 155308189a..d0bd84c6c5 100644 --- a/packages/components/tree-select/tree-select.component.spec.ts +++ b/packages/components/tree-select/tree-select.component.spec.ts @@ -525,6 +525,51 @@ class SelectWithSearch implements OnInit { } } +@Component({ + selector: 'multiple-select-with-search', + imports: [ + KbqTreeModule, + KbqInputModule, + KbqTreeSelectModule, + ReactiveFormsModule + ], + template: ` + + + + + + + + + {{ treeControl.getViewValue(node) }} + + + + + ` +}) +class MultipleTreeSelectWithSearch implements OnInit { + control = new UntypedFormControl(); + + treeControl = new FlatTreeControl(getLevel, isExpandable, getValue, getValue); + treeFlattener = new KbqTreeFlattener(transformer, getLevel, isExpandable, getChildren); + + dataSource: KbqTreeFlatDataSource; + searchControl: UntypedFormControl = new UntypedFormControl(); + + readonly select = viewChild.required(KbqTreeSelect); + + constructor() { + this.dataSource = new KbqTreeFlatDataSource(this.treeControl, this.treeFlattener); + this.dataSource.data = buildFileTree(TREE_DATA, 0); + } + + ngOnInit(): void { + this.searchControl.valueChanges.subscribe((value) => this.treeControl.filterNodes(value)); + } +} + @Component({ selector: 'select-with-change-event', imports: [ @@ -2846,6 +2891,77 @@ describe('KbqTreeSelect', () => { })); }); + describe('Ctrl+A with search (multiple)', () => { + let fixture: ComponentFixture; + let testInstance: MultipleTreeSelectWithSearch; + let trigger: HTMLElement; + + beforeEach(fakeAsync(() => { + configureKbqTreeSelectTestingModule([MultipleTreeSelectWithSearch]); + fixture = TestBed.createComponent(MultipleTreeSelectWithSearch); + testInstance = fixture.componentInstance; + fixture.detectChanges(); + + trigger = fixture.debugElement.query(By.css('.kbq-select__trigger')).nativeElement; + trigger.click(); + fixture.detectChanges(); + tick(); + flush(); + })); + + const getSearchInput = (): HTMLInputElement => + fixture.debugElement.query(By.css('.search-input')).nativeElement; + + const pressCtrlA = (target: HTMLElement) => { + const event = createKeyboardEvent('keydown', A); + + Object.defineProperty(event, 'ctrlKey', { get: () => true }); + dispatchEvent(target, event); + fixture.detectChanges(); + tick(0); + }; + + const selectedCount = (): number => testInstance.control.value?.length ?? 0; + + it('should select all options when the search field is empty', fakeAsync(() => { + const input = getSearchInput(); + + input.value = ''; + + pressCtrlA(input); + + expect(selectedCount()).toBeGreaterThan(0); + })); + + it('should select the search text (not options) when the text is only partially selected', fakeAsync(() => { + const input = getSearchInput(); + const onSelectAll = jest.fn(); + + testInstance.select().onSelectAll.subscribe(onSelectAll); + + input.value = 'src'; + input.setSelectionRange(0, 1); + + pressCtrlA(input); + + expect(input.selectionStart).toBe(0); + expect(input.selectionEnd).toBe(input.value.length); + expect(selectedCount()).toBe(0); + expect(onSelectAll).not.toHaveBeenCalled(); + })); + + it('should select all options when the search text is already fully selected', fakeAsync(() => { + const input = getSearchInput(); + + input.value = 'src'; + input.setSelectionRange(0, input.value.length); + + pressCtrlA(input); + + expect(selectedCount()).toBeGreaterThan(0); + })); + }); + describe('with search', () => { let fixture: ComponentFixture; let trigger: HTMLElement; @@ -4198,9 +4314,10 @@ describe('KbqTreeSelect', () => { ]); })); - it('should deselect all options with CTRL + A if all options are selected', () => { + it('should deselect all options with CTRL + A if all options are selected and selectAllToggle is enabled', () => { const selectElement = fixture.nativeElement.querySelector('kbq-tree-select'); + fixture.componentInstance.select().selectAllToggle = true; fixture.componentInstance.select().open(); fixture.detectChanges(); @@ -4216,6 +4333,25 @@ describe('KbqTreeSelect', () => { expect(testInstance.control.getRawValue()).toEqual([]); }); + + it('should keep all options selected with CTRL + A by default (selectAllToggle off)', () => { + const selectElement = fixture.nativeElement.querySelector('kbq-tree-select'); + + fixture.componentInstance.select().open(); + fixture.detectChanges(); + + const event = createKeyboardEvent('keydown', A, selectElement); + + Object.defineProperty(event, 'ctrlKey', { get: () => true }); + dispatchEvent(selectElement, event); + + expect(testInstance.control.getRawValue().length).toEqual(25); + + dispatchEvent(selectElement, event); + fixture.detectChanges(); + + expect(testInstance.control.getRawValue().length).toEqual(25); + }); }); describe('with parent selection', () => { diff --git a/packages/components/tree-select/tree-select.component.ts b/packages/components/tree-select/tree-select.component.ts index ec2cd0f4dd..5cdf6dd827 100644 --- a/packages/components/tree-select/tree-select.component.ts +++ b/packages/components/tree-select/tree-select.component.ts @@ -54,6 +54,7 @@ import { KbqAbstractSelect, KbqComponentColors, KbqLocaleService, + KbqSelectAllEvent, KbqSelectMatcher, KbqSelectSearch, KbqSelectTrigger, @@ -69,9 +70,11 @@ import { getKbqSelectDynamicMultipleError, getKbqSelectNonArrayValueError, hasModifierKey, + isInput, isSelectAll, isUndefined, - kbqSelectAnimations + kbqSelectAnimations, + shouldSelectSearchText } from '@koobiq/components/core'; import { KbqCleaner, KbqFormField, KbqFormFieldControl } from '@koobiq/components/form-field'; import { KbqIconModule } from '@koobiq/components/icon'; @@ -311,6 +314,12 @@ export class KbqTreeSelect /** Event emitted when the selected value has been changed by the user. */ readonly selectionChange = output(); + /** + * Event emitted when all options are selected or deselected via the Ctrl/Cmd + A shortcut. + * Not emitted when a custom `selectAllHandler` is supplied — the handler owns the behaviour then. + */ + readonly onSelectAll = output>(); + /** * Event that emits whenever the raw value of the select changes. This is here primarily * to facilitate the two-way binding for the `value` input. @@ -443,6 +452,9 @@ export class KbqTreeSelect private _autoSelect: boolean = true; + /** When `true`, a repeated Ctrl/Cmd+A deselects all options. Off by default (Ctrl+A only selects). */ + @Input({ transform: booleanAttribute }) selectAllToggle: boolean = false; + get value(): any { return this.tree()!.getSelectedValues(); } @@ -537,9 +549,25 @@ export class KbqTreeSelect /** Function for handling the combination Ctrl + A (select all). By default, the internal handler is used. */ private _selectAllHandler(event: KeyboardEvent, select: KbqTreeSelect): void { + const searchInput = isInput(event) ? (event.target as HTMLInputElement) : null; + + if (shouldSelectSearchText(searchInput)) { + searchInput!.select(); + event.preventDefault(); + + return; + } + event.preventDefault(); - select.tree()!.selectAllOptions(); + const tree = select.tree()!; + + tree.selectAllOptions(select.selectAllToggle); + + const options = tree.renderedOptions.filter((option) => !option.disabled && option.selectable()); + const selected = options.some((option) => tree.selectionModel.isSelected(option.data)); + + select.onSelectAll.emit(new KbqSelectAllEvent(select, options, selected)); } /** Whether the select is focused. */ diff --git a/packages/components/tree/tree-selection.component.spec.ts b/packages/components/tree/tree-selection.component.spec.ts index cd0cd5543f..7fa36d9537 100644 --- a/packages/components/tree/tree-selection.component.spec.ts +++ b/packages/components/tree/tree-selection.component.spec.ts @@ -620,6 +620,9 @@ describe('KbqTreeSelection', () => { })); it('should exclude non-selectable options on CTRL + A', fakeAsync(() => { + // selectAllToggle enables deselect on the second press asserted below + component.tree.selectAllToggle = true; + const selectAllKeyEvent = createKeyboardEvent('keydown', A); Object.defineProperty(selectAllKeyEvent, 'ctrlKey', { get: () => true }); @@ -640,6 +643,41 @@ describe('KbqTreeSelection', () => { expect(component.modelValue.length).toBe(0); })); + it('should keep all options selected on a second CTRL + A by default (selectAllToggle off)', fakeAsync(() => { + const selectAllKeyEvent = createKeyboardEvent('keydown', A); + + Object.defineProperty(selectAllKeyEvent, 'ctrlKey', { get: () => true }); + + component.tree.onKeyDown(selectAllKeyEvent); + fixture.detectChanges(); + + const selectedAfterFirst = component.modelValue.length; + + expect(selectedAfterFirst).toBeGreaterThan(0); + + component.tree.onKeyDown(selectAllKeyEvent); + fixture.detectChanges(); + + expect(component.modelValue.length).toBe(selectedAfterFirst); + })); + + it('should invoke a custom selectAllHandler on CTRL + A instead of the default', fakeAsync(() => { + const customHandler = jest.fn(); + + component.tree.selectAllHandler = customHandler; + + const selectAllKeyEvent = createKeyboardEvent('keydown', A); + + Object.defineProperty(selectAllKeyEvent, 'ctrlKey', { get: () => true }); + + component.tree.onKeyDown(selectAllKeyEvent); + fixture.detectChanges(); + + expect(customHandler).toHaveBeenCalledTimes(1); + // default behaviour is bypassed -> nothing gets selected + expect(component.modelValue.length).toBe(0); + })); + it('should not select non-selectable option via setSelectedOptionsByClick', () => { const option = component.tree.renderedOptions.toArray()[2]; @@ -1062,6 +1100,8 @@ describe('KbqTreeSelection', () => { })); it('should deselect all visible options and values', fakeAsync(() => { + component.tree.selectAllToggle = true; + component.tree.onKeyDown(selectAllKeyEvent); fixture.detectChanges(); diff --git a/packages/components/tree/tree-selection.component.ts b/packages/components/tree/tree-selection.component.ts index 6b12669003..a864d0d357 100644 --- a/packages/components/tree/tree-selection.component.ts +++ b/packages/components/tree/tree-selection.component.ts @@ -5,6 +5,7 @@ import { SelectionModel } from '@angular/cdk/collections'; import { AfterContentInit, AfterViewInit, + booleanAttribute, ChangeDetectionStrategy, Component, ContentChildren, @@ -44,6 +45,7 @@ import { RIGHT_ARROW, SPACE, TAB, + toggleSelectAll, UP_ARROW } from '@koobiq/components/core'; import { merge, Observable, Subscription } from 'rxjs'; @@ -213,6 +215,9 @@ export class KbqTreeSelection private _noUnselectLast: boolean = true; + /** When `true`, a repeated Ctrl/Cmd+A deselects all options. Off by default (Ctrl+A only selects). */ + @Input({ transform: booleanAttribute }) selectAllToggle: boolean = false; + // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @Input() @@ -375,8 +380,7 @@ export class KbqTreeSelection } if (this.multiple && isSelectAll(event)) { - this.selectAllOptions(); - event.preventDefault(); + this.selectAllHandler(event, this); return; } else if (isCopy(event)) { @@ -544,24 +548,59 @@ export class KbqTreeSelection this.selectionChange.emit(new KbqTreeSelectionChange(this, option, [option])); } - selectAllOptions(): void { + /** + * Function for handling the combination Ctrl + A (select all). By default, the internal handler is used, + * which toggles the selection of all non-disabled, selectable options. + */ + @Input() + get selectAllHandler() { + return this._selectAllHandler; + } + + set selectAllHandler(fn: (event: KeyboardEvent, tree: KbqTreeSelection) => void) { + if (typeof fn !== 'function') { + throw Error('`selectAllHandler` must be a function.'); + } + + this._selectAllHandler = fn; + } + + private _selectAllHandler(event: KeyboardEvent, tree: KbqTreeSelection): void { + event.preventDefault(); + + tree.selectAllOptions(); + } + + selectAllOptions(allowDeselect: boolean = this.selectAllToggle): void { const nonSelectableDataNodes = this.renderedOptions .filter((option) => option.disabled || !option.selectable()) .map((option) => option.data); + // Selection is applied at the data-node level (incl. collapsed/non-rendered nodes), + // while the emitted events carry the selectable rendered options. const dataNodes = this.treeControl.dataNodes.filter( (node) => !this.treeControl.isDisabled(node) && !nonSelectableDataNodes.includes(node) ); const selectableOptions = this.renderedOptions.filter((option) => !option.disabled && option.selectable()); - let changedOptions: KbqTreeOption[] = selectableOptions; + const shouldSelect = !allowDeselect || dataNodes.some((node) => !this.selectionModel.isSelected(node)); + + toggleSelectAll( + { + items: dataNodes, + isSelectable: () => true, + isSelected: (node) => this.selectionModel.isSelected(node), + setSelected: (node, selected) => + selected ? this.selectionModel.select(node) : this.selectionModel.deselect(node) + }, + { allowDeselect } + ); - if (dataNodes.length === this.selectionModel.selected.length) { - this.selectionModel.clear(); - } else { - this.selectionModel.select(...dataNodes); - changedOptions = selectableOptions.filter((option) => !option.selected); - } + // `option.selected` is cached until change detection, so this still reflects the pre-toggle + // state — on select it yields the newly-selected options, matching the previous behaviour. + const changedOptions = shouldSelect + ? selectableOptions.filter((option) => !option.selected) + : selectableOptions; this.selectionChange.emit(new KbqTreeSelectionChange(this, changedOptions[0], changedOptions)); this.onSelectAll.emit(new KbqTreeSelectAllEvent(this, selectableOptions)); diff --git a/packages/docs-examples/components/select/select-search/select-search-example.ts b/packages/docs-examples/components/select/select-search/select-search-example.ts index 4e4b2fb381..8aa5b787f6 100644 --- a/packages/docs-examples/components/select/select-search/select-search-example.ts +++ b/packages/docs-examples/components/select/select-search/select-search-example.ts @@ -22,7 +22,7 @@ import { map, startWith } from 'rxjs/operators'; ], template: ` - + diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 22cbb13a91..567cfb3b07 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -3243,6 +3243,25 @@ export class KbqRoundDecimalPipe implements PipeTransform { static ɵprov: i0.ɵɵInjectableDeclaration; } +// @public +export interface KbqSelectAllAdapter { + isSelectable: (item: T) => boolean; + isSelected: (item: T) => boolean; + items: readonly T[]; + setSelected: (item: T, selected: boolean) => void; +} + +// @public +export class KbqSelectAllEvent { + constructor( + source: unknown, + options: T[], + selected: boolean); + options: T[]; + selected: boolean; + source: unknown; +} + // @public export const kbqSelectAnimations: { readonly transformPanel: AnimationTriggerMetadata; @@ -3408,6 +3427,11 @@ export interface KbqTitleTextRef { textElement?: ElementRef; } +// @public +export interface KbqToggleSelectAllOptions { + allowDeselect?: boolean; +} + // @public (undocumented) export interface KbqUnitSystem { // (undocumented) @@ -4437,6 +4461,9 @@ export const SEVEN = 55; // @public (undocumented) export const SHIFT = 16; +// @public +export function shouldSelectSearchText(input: HTMLInputElement | null | undefined): boolean; + // @public export class ShowOnControlDirtyErrorStateMatcher implements ErrorStateMatcher { // (undocumented) @@ -4758,6 +4785,9 @@ export const tkTMLocaleData: { }; }; +// @public +export function toggleSelectAll(adapter: KbqSelectAllAdapter, options?: KbqToggleSelectAllOptions): T[]; + // @public (undocumented) export const TOP_LEFT_POSITION_PRIORITY: ConnectionPositionPair[]; diff --git a/tools/public_api_guard/components/list.api.md b/tools/public_api_guard/components/list.api.md index 4c5aad6161..4d45179e59 100644 --- a/tools/public_api_guard/components/list.api.md +++ b/tools/public_api_guard/components/list.api.md @@ -207,6 +207,8 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest // (undocumented) static ngAcceptInputType_disabled: unknown; // (undocumented) + static ngAcceptInputType_selectAllToggle: unknown; + // (undocumented) ngAfterContentInit(): void; // (undocumented) ngAfterViewInit(): void; @@ -240,6 +242,9 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest selectActiveOptions(): void; // (undocumented) selectAll(): void; + get selectAllHandler(): (event: KeyboardEvent, list: KbqListSelection) => void; + set selectAllHandler(fn: (event: KeyboardEvent, list: KbqListSelection) => void); + selectAllToggle: boolean; // (undocumented) readonly selectionChange: i0.OutputEmitterRef; // (undocumented) @@ -268,7 +273,7 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest // (undocumented) writeValue(values: string[]): void; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } diff --git a/tools/public_api_guard/components/select.api.md b/tools/public_api_guard/components/select.api.md index baea536a2a..66deeda08e 100644 --- a/tools/public_api_guard/components/select.api.md +++ b/tools/public_api_guard/components/select.api.md @@ -40,6 +40,7 @@ import { KbqOptgroup } from '@koobiq/components/core'; import { KbqOption } from '@koobiq/components/core'; import { KbqOptionBase } from '@koobiq/components/core'; import { KbqOptionSelectionChange } from '@koobiq/components/core'; +import { KbqSelectAllEvent } from '@koobiq/components/core'; import { KbqSelectMatcher } from '@koobiq/components/core'; import { KbqSelectSearch } from '@koobiq/components/core'; import { KbqSelectSearchEmptyResult } from '@koobiq/components/core'; @@ -161,6 +162,8 @@ export class KbqSelect extends KbqAbstractSelect implements AfterContentInit, On // (undocumented) static ngAcceptInputType_panelMinWidth: unknown; // (undocumented) + static ngAcceptInputType_selectAllToggle: unknown; + // (undocumented) static ngAcceptInputType_tabIndex: unknown; ngAfterContentInit(): void; // (undocumented) @@ -176,6 +179,7 @@ export class KbqSelect extends KbqAbstractSelect implements AfterContentInit, On onContainerClick(): void; onFocus(): void; onRemoveMatcherItem(option: KbqOptionBase, $event: any): void; + readonly onSelectAll: _angular_core.OutputEmitterRef>; onTouched: () => void; open(): void; readonly openedChange: EventEmitter; @@ -220,6 +224,7 @@ export class KbqSelect extends KbqAbstractSelect implements AfterContentInit, On get searchMinOptionsThreshold(): number | undefined; get selectAllHandler(): (event: KeyboardEvent, select: KbqSelect) => void; set selectAllHandler(fn: (event: KeyboardEvent, select: KbqSelect) => void); + selectAllToggle: boolean; get selected(): KbqOptionBase | KbqOptionBase[]; readonly selectionChange: _angular_core.OutputEmitterRef; selectionModel: SelectionModel; @@ -249,7 +254,7 @@ export class KbqSelect extends KbqAbstractSelect implements AfterContentInit, On withVirtualScroll: boolean; writeValue(value: any): void; // (undocumented) - static ɵcmp: _angular_core.ɵɵComponentDeclaration; + static ɵcmp: _angular_core.ɵɵComponentDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } diff --git a/tools/public_api_guard/components/tree-select.api.md b/tools/public_api_guard/components/tree-select.api.md index 5d8e19846c..a27350085c 100644 --- a/tools/public_api_guard/components/tree-select.api.md +++ b/tools/public_api_guard/components/tree-select.api.md @@ -32,6 +32,7 @@ import { KbqAbstractSelect } from '@koobiq/components/core'; import { KbqCleaner } from '@koobiq/components/form-field'; import { KbqComponentColors } from '@koobiq/components/core'; import { KbqFormFieldControl } from '@koobiq/components/form-field'; +import { KbqSelectAllEvent } from '@koobiq/components/core'; import { KbqSelectMatcher } from '@koobiq/components/core'; import { KbqSelectSearch } from '@koobiq/components/core'; import { KbqSelectTrigger } from '@koobiq/components/core'; @@ -134,6 +135,8 @@ export class KbqTreeSelect extends KbqAbstractSelect implements AfterContentInit // (undocumented) static ngAcceptInputType_multiple: unknown; // (undocumented) + static ngAcceptInputType_selectAllToggle: unknown; + // (undocumented) ngAfterContentInit(): void; // (undocumented) ngAfterViewInit(): void; @@ -153,6 +156,7 @@ export class KbqTreeSelect extends KbqAbstractSelect implements AfterContentInit // (undocumented) onFocus(): void; onRemoveSelectedOption(selectedOption: any, $event: any): void; + readonly onSelectAll: _angular_core.OutputEmitterRef>; onTouched: () => void; // (undocumented) open(): void; @@ -199,6 +203,7 @@ export class KbqTreeSelect extends KbqAbstractSelect implements AfterContentInit get searchMinOptionsThreshold(): number | undefined; get selectAllHandler(): (event: KeyboardEvent, select: KbqTreeSelect) => void; set selectAllHandler(fn: (event: KeyboardEvent, select: KbqTreeSelect) => void); + selectAllToggle: boolean; // (undocumented) get selected(): any; // (undocumented) @@ -236,7 +241,7 @@ export class KbqTreeSelect extends KbqAbstractSelect implements AfterContentInit readonly valueChange: _angular_core.OutputEmitterRef; writeValue(value: any): void; // (undocumented) - static ɵcmp: _angular_core.ɵɵComponentDeclaration; + static ɵcmp: _angular_core.ɵɵComponentDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } diff --git a/tools/public_api_guard/components/tree.api.md b/tools/public_api_guard/components/tree.api.md index a9343c6955..e44d6045e1 100644 --- a/tools/public_api_guard/components/tree.api.md +++ b/tools/public_api_guard/components/tree.api.md @@ -575,6 +575,8 @@ export class KbqTreeSelection extends KbqTreeBase implements ControlValueAc // (undocumented) readonly navigationChange: i0.OutputEmitterRef>; // (undocumented) + static ngAcceptInputType_selectAllToggle: unknown; + // (undocumented) ngAfterContentInit(): void; // (undocumented) ngAfterViewInit(): void; @@ -611,8 +613,11 @@ export class KbqTreeSelection extends KbqTreeBase implements ControlValueAc resetFocusedItemOnBlur: boolean; // (undocumented) selectActiveOptions(): void; + get selectAllHandler(): (event: KeyboardEvent, tree: KbqTreeSelection) => void; + set selectAllHandler(fn: (event: KeyboardEvent, tree: KbqTreeSelection) => void); // (undocumented) - selectAllOptions(): void; + selectAllOptions(allowDeselect?: boolean): void; + selectAllToggle: boolean; // (undocumented) readonly selectionChange: EventEmitter>; // Warning: (ae-forgotten-export) The symbol "SelectionModelOption" needs to be exported by the entry point index.d.ts @@ -648,7 +653,7 @@ export class KbqTreeSelection extends KbqTreeBase implements ControlValueAc // (undocumented) writeValue(value: any): void; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } From 03a50e22a68d50c9d5a0072fb413c1f96a054313 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Thu, 2 Jul 2026 18:57:49 +0300 Subject: [PATCH 2/5] fix: after review use signals --- .../components/list/list-selection.component.spec.ts | 5 ++++- packages/components/list/list-selection.component.ts | 4 ++-- packages/components/select/select.component.spec.ts | 12 ++++++++++-- packages/components/select/select.component.ts | 4 ++-- .../tree-select/tree-select.component.spec.ts | 11 +++++++++-- .../components/tree-select/tree-select.component.ts | 4 ++-- .../components/tree/tree-selection.component.spec.ts | 8 ++++++-- packages/components/tree/tree-selection.component.ts | 5 +++-- tools/public_api_guard/components/list.api.md | 6 ++---- tools/public_api_guard/components/select.api.md | 6 ++---- tools/public_api_guard/components/tree-select.api.md | 6 ++---- tools/public_api_guard/components/tree.api.md | 6 ++---- 12 files changed, 46 insertions(+), 31 deletions(-) diff --git a/packages/components/list/list-selection.component.spec.ts b/packages/components/list/list-selection.component.spec.ts index 41fd066fe8..b1a1f5a0cb 100644 --- a/packages/components/list/list-selection.component.spec.ts +++ b/packages/components/list/list-selection.component.spec.ts @@ -493,7 +493,8 @@ describe('KbqListSelection without forms', () => { const list: KbqListSelection = selectionList.componentInstance; const enabledOptions = listOptions.filter(({ componentInstance: o }) => !o.disabled); - list.selectAllToggle = true; + fixture.componentInstance.selectAllToggle = true; + fixture.detectChanges(); const pressCtrlA = () => { const event = createKeyboardEvent('keydown', A); @@ -1222,6 +1223,7 @@ class SelectionListWithCustomComparator { multiple="keyboard" [autoSelect]="false" [noUnselectLast]="false" + [selectAllToggle]="selectAllToggle" (selectionChange)="onValueChange($event)" > @@ -1237,6 +1239,7 @@ class SelectionListWithCustomComparator { }) class SelectionListWithListOptions { showLastOption: boolean = true; + selectAllToggle: boolean = false; onValueChange(_change: KbqListSelectionChange) {} } diff --git a/packages/components/list/list-selection.component.ts b/packages/components/list/list-selection.component.ts index 8d01c5a9e2..327a5069e8 100644 --- a/packages/components/list/list-selection.component.ts +++ b/packages/components/list/list-selection.component.ts @@ -167,7 +167,7 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest private _noUnselectLast: boolean = true; /** When `true`, a repeated Ctrl/Cmd+A deselects all options. Off by default (Ctrl+A only selects). */ - @Input({ transform: booleanAttribute }) selectAllToggle: boolean = false; + readonly selectAllToggle = input(false, { transform: booleanAttribute }); multipleMode: MultipleMode | null; @@ -666,7 +666,7 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest isSelected: (option) => option.selected, setSelected: (option, selected) => option.setSelected(selected) }, - { allowDeselect: list.selectAllToggle } + { allowDeselect: list.selectAllToggle() } ); list.reportValueChange(); diff --git a/packages/components/select/select.component.spec.ts b/packages/components/select/select.component.spec.ts index 58fe49f3ff..9ae016a614 100644 --- a/packages/components/select/select.component.spec.ts +++ b/packages/components/select/select.component.spec.ts @@ -685,7 +685,13 @@ class BasicSelectOnPushPreselected { ], template: ` - + @for (food of foods; track food) { {{ food.viewValue }} @@ -708,6 +714,7 @@ class BasicSelectOnPushPreselected { ` }) class MultiSelect { + selectAllToggle: boolean = false; foods: any[] = [ { value: 'steak-0', viewValue: 'Steak' }, { value: 'pizza-1', viewValue: 'Pizza' }, @@ -5396,7 +5403,8 @@ describe('KbqSelect', () => { const selectElement = fixture.nativeElement.querySelector('kbq-select'); const options = fixture.componentInstance.options(); - fixture.componentInstance.select().selectAllToggle = true; + fixture.componentInstance.selectAllToggle = true; + fixture.detectChanges(); options.forEach((option) => option.select()); fixture.detectChanges(); diff --git a/packages/components/select/select.component.ts b/packages/components/select/select.component.ts index f777a47084..1876474d34 100644 --- a/packages/components/select/select.component.ts +++ b/packages/components/select/select.component.ts @@ -612,7 +612,7 @@ export class KbqSelect readonly virtualOptionFactory = input<(value: any) => KbqVirtualOption>(); /** When `true`, a repeated Ctrl/Cmd+A deselects all options. Off by default (Ctrl+A only selects). */ - @Input({ transform: booleanAttribute }) selectAllToggle: boolean = false; + readonly selectAllToggle = input(false, { transform: booleanAttribute }); /** * Function for handling the Ctrl + A (select all) keyboard combination. @@ -1895,7 +1895,7 @@ export class KbqSelect isSelected: (option) => option.selected, setSelected: (option, selected) => (selected ? option.select() : option.deselect()) }, - { allowDeselect: select.selectAllToggle } + { allowDeselect: select.selectAllToggle() } ); const selected = changed.length > 0 && changed[0].selected; diff --git a/packages/components/tree-select/tree-select.component.spec.ts b/packages/components/tree-select/tree-select.component.spec.ts index d0bd84c6c5..c02aa15024 100644 --- a/packages/components/tree-select/tree-select.component.spec.ts +++ b/packages/components/tree-select/tree-select.component.spec.ts @@ -793,7 +793,12 @@ class BasicSelectOnPushPreselected { ], template: ` - + {{ treeControl.getViewValue(node) }} @@ -810,6 +815,7 @@ class BasicSelectOnPushPreselected { }) class MultiSelect { control = new UntypedFormControl(); + selectAllToggle: boolean = false; treeControl = new FlatTreeControl(getLevel, isExpandable, getValue, getValue); treeFlattener = new KbqTreeFlattener(transformer, getLevel, isExpandable, getChildren); @@ -4317,7 +4323,8 @@ describe('KbqTreeSelect', () => { it('should deselect all options with CTRL + A if all options are selected and selectAllToggle is enabled', () => { const selectElement = fixture.nativeElement.querySelector('kbq-tree-select'); - fixture.componentInstance.select().selectAllToggle = true; + fixture.componentInstance.selectAllToggle = true; + fixture.detectChanges(); fixture.componentInstance.select().open(); fixture.detectChanges(); diff --git a/packages/components/tree-select/tree-select.component.ts b/packages/components/tree-select/tree-select.component.ts index 5cdf6dd827..f88554cce8 100644 --- a/packages/components/tree-select/tree-select.component.ts +++ b/packages/components/tree-select/tree-select.component.ts @@ -453,7 +453,7 @@ export class KbqTreeSelect private _autoSelect: boolean = true; /** When `true`, a repeated Ctrl/Cmd+A deselects all options. Off by default (Ctrl+A only selects). */ - @Input({ transform: booleanAttribute }) selectAllToggle: boolean = false; + readonly selectAllToggle = input(false, { transform: booleanAttribute }); get value(): any { return this.tree()!.getSelectedValues(); @@ -562,7 +562,7 @@ export class KbqTreeSelect const tree = select.tree()!; - tree.selectAllOptions(select.selectAllToggle); + tree.selectAllOptions(select.selectAllToggle()); const options = tree.renderedOptions.filter((option) => !option.disabled && option.selectable()); const selected = options.some((option) => tree.selectionModel.isSelected(option.data)); diff --git a/packages/components/tree/tree-selection.component.spec.ts b/packages/components/tree/tree-selection.component.spec.ts index 7fa36d9537..661dd831b8 100644 --- a/packages/components/tree/tree-selection.component.spec.ts +++ b/packages/components/tree/tree-selection.component.spec.ts @@ -621,7 +621,8 @@ describe('KbqTreeSelection', () => { it('should exclude non-selectable options on CTRL + A', fakeAsync(() => { // selectAllToggle enables deselect on the second press asserted below - component.tree.selectAllToggle = true; + component.selectAllToggle = true; + fixture.detectChanges(); const selectAllKeyEvent = createKeyboardEvent('keydown', A); @@ -1100,7 +1101,8 @@ describe('KbqTreeSelection', () => { })); it('should deselect all visible options and values', fakeAsync(() => { - component.tree.selectAllToggle = true; + component.selectAllToggle = true; + fixture.detectChanges(); component.tree.onKeyDown(selectAllKeyEvent); fixture.detectChanges(); @@ -1359,6 +1361,7 @@ class TreeSelectionFocusStates extends TreeParams {} template: ` ; diff --git a/packages/components/tree/tree-selection.component.ts b/packages/components/tree/tree-selection.component.ts index a864d0d357..02b641ecbb 100644 --- a/packages/components/tree/tree-selection.component.ts +++ b/packages/components/tree/tree-selection.component.ts @@ -15,6 +15,7 @@ import { HostAttributeToken, inject, Input, + input, IterableDiffer, OnDestroy, Output, @@ -216,7 +217,7 @@ export class KbqTreeSelection private _noUnselectLast: boolean = true; /** When `true`, a repeated Ctrl/Cmd+A deselects all options. Off by default (Ctrl+A only selects). */ - @Input({ transform: booleanAttribute }) selectAllToggle: boolean = false; + readonly selectAllToggle = input(false, { transform: booleanAttribute }); // TODO: Skipped for migration because: // Accessor inputs cannot be migrated as they are too complex. @@ -571,7 +572,7 @@ export class KbqTreeSelection tree.selectAllOptions(); } - selectAllOptions(allowDeselect: boolean = this.selectAllToggle): void { + selectAllOptions(allowDeselect: boolean = this.selectAllToggle()): void { const nonSelectableDataNodes = this.renderedOptions .filter((option) => option.disabled || !option.selectable()) .map((option) => option.data); diff --git a/tools/public_api_guard/components/list.api.md b/tools/public_api_guard/components/list.api.md index 4d45179e59..59322dd933 100644 --- a/tools/public_api_guard/components/list.api.md +++ b/tools/public_api_guard/components/list.api.md @@ -207,8 +207,6 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest // (undocumented) static ngAcceptInputType_disabled: unknown; // (undocumented) - static ngAcceptInputType_selectAllToggle: unknown; - // (undocumented) ngAfterContentInit(): void; // (undocumented) ngAfterViewInit(): void; @@ -244,7 +242,7 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest selectAll(): void; get selectAllHandler(): (event: KeyboardEvent, list: KbqListSelection) => void; set selectAllHandler(fn: (event: KeyboardEvent, list: KbqListSelection) => void); - selectAllToggle: boolean; + readonly selectAllToggle: i0.InputSignalWithTransform; // (undocumented) readonly selectionChange: i0.OutputEmitterRef; // (undocumented) @@ -273,7 +271,7 @@ export class KbqListSelection implements AfterContentInit, AfterViewInit, OnDest // (undocumented) writeValue(values: string[]): void; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } diff --git a/tools/public_api_guard/components/select.api.md b/tools/public_api_guard/components/select.api.md index 66deeda08e..8116bbd6ea 100644 --- a/tools/public_api_guard/components/select.api.md +++ b/tools/public_api_guard/components/select.api.md @@ -162,8 +162,6 @@ export class KbqSelect extends KbqAbstractSelect implements AfterContentInit, On // (undocumented) static ngAcceptInputType_panelMinWidth: unknown; // (undocumented) - static ngAcceptInputType_selectAllToggle: unknown; - // (undocumented) static ngAcceptInputType_tabIndex: unknown; ngAfterContentInit(): void; // (undocumented) @@ -224,7 +222,7 @@ export class KbqSelect extends KbqAbstractSelect implements AfterContentInit, On get searchMinOptionsThreshold(): number | undefined; get selectAllHandler(): (event: KeyboardEvent, select: KbqSelect) => void; set selectAllHandler(fn: (event: KeyboardEvent, select: KbqSelect) => void); - selectAllToggle: boolean; + readonly selectAllToggle: _angular_core.InputSignalWithTransform; get selected(): KbqOptionBase | KbqOptionBase[]; readonly selectionChange: _angular_core.OutputEmitterRef; selectionModel: SelectionModel; @@ -254,7 +252,7 @@ export class KbqSelect extends KbqAbstractSelect implements AfterContentInit, On withVirtualScroll: boolean; writeValue(value: any): void; // (undocumented) - static ɵcmp: _angular_core.ɵɵComponentDeclaration; + static ɵcmp: _angular_core.ɵɵComponentDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } diff --git a/tools/public_api_guard/components/tree-select.api.md b/tools/public_api_guard/components/tree-select.api.md index a27350085c..e5c547efeb 100644 --- a/tools/public_api_guard/components/tree-select.api.md +++ b/tools/public_api_guard/components/tree-select.api.md @@ -135,8 +135,6 @@ export class KbqTreeSelect extends KbqAbstractSelect implements AfterContentInit // (undocumented) static ngAcceptInputType_multiple: unknown; // (undocumented) - static ngAcceptInputType_selectAllToggle: unknown; - // (undocumented) ngAfterContentInit(): void; // (undocumented) ngAfterViewInit(): void; @@ -203,7 +201,7 @@ export class KbqTreeSelect extends KbqAbstractSelect implements AfterContentInit get searchMinOptionsThreshold(): number | undefined; get selectAllHandler(): (event: KeyboardEvent, select: KbqTreeSelect) => void; set selectAllHandler(fn: (event: KeyboardEvent, select: KbqTreeSelect) => void); - selectAllToggle: boolean; + readonly selectAllToggle: _angular_core.InputSignalWithTransform; // (undocumented) get selected(): any; // (undocumented) @@ -241,7 +239,7 @@ export class KbqTreeSelect extends KbqAbstractSelect implements AfterContentInit readonly valueChange: _angular_core.OutputEmitterRef; writeValue(value: any): void; // (undocumented) - static ɵcmp: _angular_core.ɵɵComponentDeclaration; + static ɵcmp: _angular_core.ɵɵComponentDeclaration; // (undocumented) static ɵfac: _angular_core.ɵɵFactoryDeclaration; } diff --git a/tools/public_api_guard/components/tree.api.md b/tools/public_api_guard/components/tree.api.md index e44d6045e1..0aec89e862 100644 --- a/tools/public_api_guard/components/tree.api.md +++ b/tools/public_api_guard/components/tree.api.md @@ -575,8 +575,6 @@ export class KbqTreeSelection extends KbqTreeBase implements ControlValueAc // (undocumented) readonly navigationChange: i0.OutputEmitterRef>; // (undocumented) - static ngAcceptInputType_selectAllToggle: unknown; - // (undocumented) ngAfterContentInit(): void; // (undocumented) ngAfterViewInit(): void; @@ -617,7 +615,7 @@ export class KbqTreeSelection extends KbqTreeBase implements ControlValueAc set selectAllHandler(fn: (event: KeyboardEvent, tree: KbqTreeSelection) => void); // (undocumented) selectAllOptions(allowDeselect?: boolean): void; - selectAllToggle: boolean; + readonly selectAllToggle: i0.InputSignalWithTransform; // (undocumented) readonly selectionChange: EventEmitter>; // Warning: (ae-forgotten-export) The symbol "SelectionModelOption" needs to be exported by the entry point index.d.ts @@ -653,7 +651,7 @@ export class KbqTreeSelection extends KbqTreeBase implements ControlValueAc // (undocumented) writeValue(value: any): void; // (undocumented) - static ɵcmp: i0.ɵɵComponentDeclaration; + static ɵcmp: i0.ɵɵComponentDeclaration; // (undocumented) static ɵfac: i0.ɵɵFactoryDeclaration; } From f5159574e5781f0f596017c7f599ff4e81e46218 Mon Sep 17 00:00:00 2001 From: lskramarov Date: Thu, 2 Jul 2026 19:28:57 +0300 Subject: [PATCH 3/5] fix: after review --- .../select/select.component.spec.ts | 25 +++++++++++++++++++ .../components/select/select.component.ts | 16 ++++++------ .../tree-select/tree-select.component.spec.ts | 24 ++++++++++++++++++ .../tree-select/tree-select.component.ts | 4 ++- .../tree/tree-selection.component.spec.ts | 17 +++++++++++++ .../tree/tree-selection.component.ts | 19 ++++++++------ 6 files changed, 87 insertions(+), 18 deletions(-) diff --git a/packages/components/select/select.component.spec.ts b/packages/components/select/select.component.spec.ts index 9ae016a614..5c378abf92 100644 --- a/packages/components/select/select.component.spec.ts +++ b/packages/components/select/select.component.spec.ts @@ -5455,6 +5455,31 @@ describe('KbqSelect', () => { expect(options.every((option) => option.selected)).toBe(true); }); + it('should emit onSelectAll with selected=true on a no-op ctrl + a when everything is already selected', () => { + const selectElement = fixture.nativeElement.querySelector('kbq-select'); + const options = fixture.componentInstance.options(); + const onSelectAll = jest.fn(); + + fixture.componentInstance.select().onSelectAll.subscribe(onSelectAll); + + options.forEach((option) => option.select()); + fixture.detectChanges(); + + fixture.componentInstance.select().open(); + fixture.detectChanges(); + + const event = createKeyboardEvent('keydown', A, selectElement); + + Object.defineProperty(event, 'ctrlKey', { get: () => true }); + dispatchEvent(selectElement, event); + fixture.detectChanges(); + + expect(onSelectAll).toHaveBeenCalledTimes(1); + // reflects the post-shortcut state (everything is still selected), not a stale false + expect(onSelectAll.mock.calls[0][0].selected).toBe(true); + expect(options.every((option) => option.selected)).toBe(true); + }); + it('should allow providing custom tag content', fakeAsync(() => { const fixtureCustomizedContent = TestBed.createComponent(MultiSelectWithCustomizedTagContent); const componentInstance = fixtureCustomizedContent.componentInstance; diff --git a/packages/components/select/select.component.ts b/packages/components/select/select.component.ts index 1876474d34..594dd4d35f 100644 --- a/packages/components/select/select.component.ts +++ b/packages/components/select/select.component.ts @@ -1887,8 +1887,9 @@ export class KbqSelect event.preventDefault(); const options = select.options.toArray(); + const selectableOptions = options.filter((option) => !option.disabled); - const changed = toggleSelectAll( + toggleSelectAll( { items: options, isSelectable: (option) => !option.disabled, @@ -1898,15 +1899,12 @@ export class KbqSelect { allowDeselect: select.selectAllToggle() } ); - const selected = changed.length > 0 && changed[0].selected; + // `selected` per the KbqSelectAllEvent contract: `true` only when every selectable option is + // now selected, `false` otherwise (deselected or partial). Reading the live option state keeps + // this correct even on a no-op (Ctrl+A while everything is already selected, selectAllToggle off). + const selected = selectableOptions.length > 0 && selectableOptions.every((option) => option.selected); - select.onSelectAll.emit( - new KbqSelectAllEvent( - select, - options.filter((option) => !option.disabled), - selected - ) - ); + select.onSelectAll.emit(new KbqSelectAllEvent(select, selectableOptions, selected)); } /** diff --git a/packages/components/tree-select/tree-select.component.spec.ts b/packages/components/tree-select/tree-select.component.spec.ts index c02aa15024..dcbd0367ee 100644 --- a/packages/components/tree-select/tree-select.component.spec.ts +++ b/packages/components/tree-select/tree-select.component.spec.ts @@ -4359,6 +4359,30 @@ describe('KbqTreeSelect', () => { expect(testInstance.control.getRawValue().length).toEqual(25); }); + + it('should emit onSelectAll with selected=true on a no-op CTRL + A when everything is already selected', () => { + const selectElement = fixture.nativeElement.querySelector('kbq-tree-select'); + const onSelectAll = jest.fn(); + + fixture.componentInstance.select().onSelectAll.subscribe(onSelectAll); + fixture.componentInstance.select().open(); + fixture.detectChanges(); + + const event = createKeyboardEvent('keydown', A, selectElement); + + Object.defineProperty(event, 'ctrlKey', { get: () => true }); + + // first press selects everything + dispatchEvent(selectElement, event); + fixture.detectChanges(); + + // second press is a no-op (all already selected, selectAllToggle off) — still "all selected" + dispatchEvent(selectElement, event); + fixture.detectChanges(); + + expect(onSelectAll).toHaveBeenCalledTimes(2); + expect(onSelectAll.mock.calls[1][0].selected).toBe(true); + }); }); describe('with parent selection', () => { diff --git a/packages/components/tree-select/tree-select.component.ts b/packages/components/tree-select/tree-select.component.ts index f88554cce8..78b448f456 100644 --- a/packages/components/tree-select/tree-select.component.ts +++ b/packages/components/tree-select/tree-select.component.ts @@ -565,7 +565,9 @@ export class KbqTreeSelect tree.selectAllOptions(select.selectAllToggle()); const options = tree.renderedOptions.filter((option) => !option.disabled && option.selectable()); - const selected = options.some((option) => tree.selectionModel.isSelected(option.data)); + // `selected` per the KbqSelectAllEvent contract: `true` only when every selectable option is + // now selected, `false` otherwise (deselected or partial); guarded for the empty-options case. + const selected = options.length > 0 && options.every((option) => tree.selectionModel.isSelected(option.data)); select.onSelectAll.emit(new KbqSelectAllEvent(select, options, selected)); } diff --git a/packages/components/tree/tree-selection.component.spec.ts b/packages/components/tree/tree-selection.component.spec.ts index 661dd831b8..b1dbe362c1 100644 --- a/packages/components/tree/tree-selection.component.spec.ts +++ b/packages/components/tree/tree-selection.component.spec.ts @@ -1119,6 +1119,23 @@ describe('KbqTreeSelection', () => { expect(component.savedSelectAllEvent!.options.length).toBe(5); expect(component.modelValue.length).toBe(0); })); + + it('should not emit selectionChange with an undefined option on a no-op CTRL + A (default)', fakeAsync(() => { + // first press selects everything (default: selectAllToggle off) + component.tree.onKeyDown(selectAllKeyEvent); + fixture.detectChanges(); + + const onSelectionChange = jest.spyOn(component, 'onSelectionChange'); + + component.savedSelectionChangeEvent = undefined; + + // second press is a no-op: everything is already selected and allowDeselect is off + component.tree.onKeyDown(selectAllKeyEvent); + fixture.detectChanges(); + + expect(onSelectionChange).not.toHaveBeenCalled(); + expect(component.savedSelectionChangeEvent).toBeUndefined(); + })); }); }); }); diff --git a/packages/components/tree/tree-selection.component.ts b/packages/components/tree/tree-selection.component.ts index 02b641ecbb..a6e7c093a0 100644 --- a/packages/components/tree/tree-selection.component.ts +++ b/packages/components/tree/tree-selection.component.ts @@ -584,9 +584,10 @@ export class KbqTreeSelection ); const selectableOptions = this.renderedOptions.filter((option) => !option.disabled && option.selectable()); - const shouldSelect = !allowDeselect || dataNodes.some((node) => !this.selectionModel.isSelected(node)); - toggleSelectAll( + // `toggleSelectAll` returns the data nodes whose selection actually flipped — the source of + // truth, unlike the cached `option.selected` which lags until change detection. + const changed = toggleSelectAll( { items: dataNodes, isSelectable: () => true, @@ -597,13 +598,15 @@ export class KbqTreeSelection { allowDeselect } ); - // `option.selected` is cached until change detection, so this still reflects the pre-toggle - // state — on select it yields the newly-selected options, matching the previous behaviour. - const changedOptions = shouldSelect - ? selectableOptions.filter((option) => !option.selected) - : selectableOptions; + const changedData = new Set(changed); + const changedOptions = selectableOptions.filter((option) => changedData.has(option.data)); + + // Skip `selectionChange` on a no-op (e.g. Ctrl+A while everything is already selected and + // `allowDeselect` is off) so `KbqTreeSelectionChange.option` is never `undefined`. + if (changedOptions.length > 0) { + this.selectionChange.emit(new KbqTreeSelectionChange(this, changedOptions[0], changedOptions)); + } - this.selectionChange.emit(new KbqTreeSelectionChange(this, changedOptions[0], changedOptions)); this.onSelectAll.emit(new KbqTreeSelectAllEvent(this, selectableOptions)); } From 4a620519e201a95fa56c2e9990b62a051b47ee6d Mon Sep 17 00:00:00 2001 From: lskramarov Date: Fri, 3 Jul 2026 12:29:43 +0300 Subject: [PATCH 4/5] fix: example --- .../components/select/select-search/select-search-example.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/docs-examples/components/select/select-search/select-search-example.ts b/packages/docs-examples/components/select/select-search/select-search-example.ts index 8aa5b787f6..92c2bc4027 100644 --- a/packages/docs-examples/components/select/select-search/select-search-example.ts +++ b/packages/docs-examples/components/select/select-search/select-search-example.ts @@ -22,7 +22,7 @@ import { map, startWith } from 'rxjs/operators'; ], template: ` - + From 9e022472f947702602dd4868f386446c426d4e8d Mon Sep 17 00:00:00 2001 From: lskramarov Date: Fri, 3 Jul 2026 12:46:04 +0300 Subject: [PATCH 5/5] fix: types for KbqSelectAllEvent --- packages/components/core/selection/select-all.ts | 8 ++++---- packages/components/select/select.component.ts | 2 +- .../components/tree-select/tree-select.component.ts | 2 +- tools/public_api_guard/components/core.api.md | 10 +++++----- tools/public_api_guard/components/select.api.md | 2 +- tools/public_api_guard/components/tree-select.api.md | 2 +- 6 files changed, 13 insertions(+), 13 deletions(-) diff --git a/packages/components/core/selection/select-all.ts b/packages/components/core/selection/select-all.ts index d909c64282..a895a46338 100644 --- a/packages/components/core/selection/select-all.ts +++ b/packages/components/core/selection/select-all.ts @@ -48,14 +48,14 @@ export function toggleSelectAll(adapter: KbqSelectAllAdapter, options?: Kb } /** Event emitted by the `onSelectAll` outputs when the select-all toggle runs. */ -export class KbqSelectAllEvent { +export class KbqSelectAllEvent { constructor( /** Component that emitted the event. */ - public source: unknown, + public readonly source: S, /** The selectable options affected by the toggle. */ - public options: T[], + public readonly options: T[], /** `true` when all options were selected, `false` when all were deselected. */ - public selected: boolean + public readonly selected: boolean ) {} } diff --git a/packages/components/select/select.component.ts b/packages/components/select/select.component.ts index 594dd4d35f..775f059f1d 100644 --- a/packages/components/select/select.component.ts +++ b/packages/components/select/select.component.ts @@ -476,7 +476,7 @@ export class KbqSelect * Event emitted when all options are selected or deselected via the Ctrl/Cmd + A shortcut. * Not emitted when a custom `selectAllHandler` is supplied — the handler owns the behaviour then. */ - readonly onSelectAll = output>(); + readonly onSelectAll = output>(); /** * Event that emits whenever the raw value of the select changes. This is here primarily diff --git a/packages/components/tree-select/tree-select.component.ts b/packages/components/tree-select/tree-select.component.ts index 78b448f456..44466e9e35 100644 --- a/packages/components/tree-select/tree-select.component.ts +++ b/packages/components/tree-select/tree-select.component.ts @@ -318,7 +318,7 @@ export class KbqTreeSelect * Event emitted when all options are selected or deselected via the Ctrl/Cmd + A shortcut. * Not emitted when a custom `selectAllHandler` is supplied — the handler owns the behaviour then. */ - readonly onSelectAll = output>(); + readonly onSelectAll = output>(); /** * Event that emits whenever the raw value of the select changes. This is here primarily diff --git a/tools/public_api_guard/components/core.api.md b/tools/public_api_guard/components/core.api.md index 567cfb3b07..c7a40ddf66 100644 --- a/tools/public_api_guard/components/core.api.md +++ b/tools/public_api_guard/components/core.api.md @@ -3252,14 +3252,14 @@ export interface KbqSelectAllAdapter { } // @public -export class KbqSelectAllEvent { +export class KbqSelectAllEvent { constructor( - source: unknown, + source: S, options: T[], selected: boolean); - options: T[]; - selected: boolean; - source: unknown; + readonly options: T[]; + readonly selected: boolean; + readonly source: S; } // @public diff --git a/tools/public_api_guard/components/select.api.md b/tools/public_api_guard/components/select.api.md index 8116bbd6ea..afd0737ed2 100644 --- a/tools/public_api_guard/components/select.api.md +++ b/tools/public_api_guard/components/select.api.md @@ -177,7 +177,7 @@ export class KbqSelect extends KbqAbstractSelect implements AfterContentInit, On onContainerClick(): void; onFocus(): void; onRemoveMatcherItem(option: KbqOptionBase, $event: any): void; - readonly onSelectAll: _angular_core.OutputEmitterRef>; + readonly onSelectAll: _angular_core.OutputEmitterRef>; onTouched: () => void; open(): void; readonly openedChange: EventEmitter; diff --git a/tools/public_api_guard/components/tree-select.api.md b/tools/public_api_guard/components/tree-select.api.md index e5c547efeb..762f625ff5 100644 --- a/tools/public_api_guard/components/tree-select.api.md +++ b/tools/public_api_guard/components/tree-select.api.md @@ -154,7 +154,7 @@ export class KbqTreeSelect extends KbqAbstractSelect implements AfterContentInit // (undocumented) onFocus(): void; onRemoveSelectedOption(selectedOption: any, $event: any): void; - readonly onSelectAll: _angular_core.OutputEmitterRef>; + readonly onSelectAll: _angular_core.OutputEmitterRef>; onTouched: () => void; // (undocumented) open(): void;