diff --git a/packages/components/tree-select/tree-select.component.spec.ts b/packages/components/tree-select/tree-select.component.spec.ts index 969b1344f..dc0c86d57 100644 --- a/packages/components/tree-select/tree-select.component.spec.ts +++ b/packages/components/tree-select/tree-select.component.spec.ts @@ -45,6 +45,7 @@ import { KbqPseudoCheckbox, KbqPseudoCheckboxModule, KbqPseudoCheckboxState, + LEFT_ARROW, RIGHT_ARROW, SPACE, ShowOnControlDirtyErrorStateMatcher, @@ -3124,6 +3125,84 @@ describe('KbqTreeSelect', () => { expect(document.activeElement).toBe(searchInput); })); + it('should keep the caret in the search field when LEFT_ARROW moves to the parent option', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + const searchInput: HTMLElement = overlayContainerElement.querySelector('.search-input')!; + const inputElementDebug = fixture.debugElement.query(By.css('.search-input')); + + inputElementDebug.nativeElement.value = 'core'; + inputElementDebug.triggerEventHandler('input', { target: inputElementDebug.nativeElement }); + tick(); + fixture.detectChanges(); + flush(); + + const select = fixture.componentInstance.select(); + const tree = select.tree()!; + + const pressPanelKey = (keyCode: number) => { + select.panelKeydownHandler(createKeyboardEvent('keydown', keyCode)); + fixture.detectChanges(); + flush(); + }; + + // the filter keeps the ancestors of the match: Documents > angular > src > core + expect( + fixture.debugElement + .queryAll(By.css('kbq-tree-option')) + .map((el) => el.nativeElement.textContent.trim()) + ).toEqual(['Documents', 'angular', 'src', 'core']); + + pressPanelKey(DOWN_ARROW); + pressPanelKey(DOWN_ARROW); + pressPanelKey(DOWN_ARROW); + + expect(tree.keyManager.activeItem!.value).toBe('core'); + expect(document.activeElement).toBe(searchInput); + + // `core` is a leaf, so LEFT_ARROW takes the move-to-parent branch — the highlight moves + // up, but the caret must stay in the search field + pressPanelKey(LEFT_ARROW); + + expect(tree.keyManager.activeItem!.value).toBe('src'); + expect(document.activeElement).toBe(searchInput); + })); + + it('should return the caret to the search field on RIGHT_ARROW', fakeAsync(() => { + trigger.click(); + fixture.detectChanges(); + flush(); + + const searchInput: HTMLElement = overlayContainerElement.querySelector('.search-input')!; + + const select = fixture.componentInstance.select(); + const tree = select.tree()!; + + const pressPanelKey = (keyCode: number) => { + select.panelKeydownHandler(createKeyboardEvent('keydown', keyCode)); + fixture.detectChanges(); + flush(); + }; + + pressPanelKey(DOWN_ARROW); + + const activeOption = tree.keyManager.activeItem!; + + // RIGHT_ARROW only expands and never moves the active item, so nothing takes the focus + // away from the search field on its own. Hand the focus to the option the way the key + // manager does, to prove the branch restores it rather than relying on it never moving. + activeOption.focus('keyboard'); + + expect(document.activeElement).not.toBe(searchInput); + + pressPanelKey(RIGHT_ARROW); + + expect(tree.keyManager.activeItem).toBe(activeOption); + expect(document.activeElement).toBe(searchInput); + })); + it('should show empty message', fakeAsync(() => { trigger.click(); fixture.detectChanges(); diff --git a/packages/components/tree-select/tree-select.component.ts b/packages/components/tree-select/tree-select.component.ts index 7235cea36..32d03c1d8 100644 --- a/packages/components/tree-select/tree-select.component.ts +++ b/packages/components/tree-select/tree-select.component.ts @@ -1294,7 +1294,17 @@ export class KbqTreeSelect this.close(); this.focus(); } else if (keyCode === LEFT_ARROW || keyCode === RIGHT_ARROW) { - return this.originalOnKeyDown.call(this.tree(), event); + this.originalOnKeyDown.call(tree, event); + + // LEFT_ARROW moves focus to the parent option when the active one is already collapsed, + // so the search field has to be given the caret back, the same way the other keys do below. + const search = this.search(); + + if (search && this.shouldShowSearch()) { + search.focus(); + } + + return; } else if (keyCode === HOME) { event.preventDefault(); diff --git a/packages/components/tree/tree-selection.component.spec.ts b/packages/components/tree/tree-selection.component.spec.ts index a9958bad7..8d6966f44 100644 --- a/packages/components/tree/tree-selection.component.spec.ts +++ b/packages/components/tree/tree-selection.component.spec.ts @@ -15,6 +15,7 @@ import { DOWN_ARROW, KbqOptionActionComponent, KbqOptionModule, + LEFT_ARROW, SPACE, TAB } from '@koobiq/components/core'; @@ -887,6 +888,256 @@ describe('KbqTreeSelection', () => { })); }); + describe('keyboard navigation with LEFT_ARROW', () => { + let fixture: ComponentFixture; + let component: KbqTreeAppDeepData; + + const pressKey = (keyCode: number) => { + component.tree.onKeyDown(createKeyboardEvent('keydown', keyCode)); + fixture.detectChanges(); + flush(); + }; + + const expandNode = (index: number) => { + (getNodes(treeElement)[index].querySelectorAll('kbq-tree-node-toggle')[0] as HTMLElement).click(); + fixture.detectChanges(); + flush(); + }; + + const getActiveValue = () => component.tree.keyManager.activeItem?.value; + + beforeEach(() => { + configureKbqTreeTestingModule(); + fixture = TestBed.createComponent(KbqTreeAppDeepData); + + component = fixture.componentInstance; + treeElement = fixture.nativeElement.querySelector('kbq-tree-selection'); + + fixture.detectChanges(); + }); + + it('should collapse an expanded option without moving the focus', fakeAsync(() => { + expandNode(1); + + // docs, src, assets, cdk, README, tests + expect(getNodes(treeElement).length).toBe(6); + + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + + expect(getActiveValue()).toBe('src'); + + pressKey(LEFT_ARROW); + + expect(getNodes(treeElement).length).toBe(3); + expect(component.treeControl.expansionModel.selected.length).toBe(0); + expect(getActiveValue()).toBe('src'); + })); + + it('should move the focus to the parent when the active option is a leaf', fakeAsync(() => { + expandNode(1); + + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + + expect(getActiveValue()).toBe('assets'); + + pressKey(LEFT_ARROW); + + expect(getActiveValue()).toBe('src'); + // the parent is only focused, not collapsed + expect(getNodes(treeElement).length).toBe(6); + })); + + it('should move the focus to the parent when the active option is already collapsed', fakeAsync(() => { + expandNode(1); + + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + + expect(getActiveValue()).toBe('cdk'); + + pressKey(LEFT_ARROW); + + expect(getActiveValue()).toBe('src'); + expect(getNodes(treeElement).length).toBe(6); + })); + + it('should collapse the whole tree with repeated presses', fakeAsync(() => { + expandNode(1); + expandNode(3); + + // docs, src, assets, cdk, a11y, keycodes, README, tests + expect(getNodes(treeElement).length).toBe(8); + expect(component.treeControl.expansionModel.selected.length).toBe(2); + + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + + expect(getActiveValue()).toBe('cdk'); + + // expanded -> collapse, the focus stays put + pressKey(LEFT_ARROW); + + expect(getNodes(treeElement).length).toBe(6); + expect(getActiveValue()).toBe('cdk'); + + // collapsed -> step up over the `assets` sibling to `src` + pressKey(LEFT_ARROW); + + expect(getActiveValue()).toBe('src'); + + // expanded -> collapse + pressKey(LEFT_ARROW); + + expect(getNodes(treeElement).length).toBe(3); + expect(component.treeControl.expansionModel.selected.length).toBe(0); + + // `src` is a root, so the tree stays fully collapsed + pressKey(LEFT_ARROW); + + expect(getNodes(treeElement).length).toBe(3); + expect(getActiveValue()).toBe('src'); + })); + + it('should move the focus to the parent when the toggle of an expanded option is disabled', fakeAsync(() => { + expandNode(1); + expandNode(3); + + // the toggle is disabled only after expanding — a disabled toggle cannot be clicked open + component.disabledToggles = ['cdk']; + fixture.detectChanges(); + + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + + // the option itself stays enabled, so DOWN_ARROW does not skip it + expect(getActiveValue()).toBe('cdk'); + + pressKey(LEFT_ARROW); + + // a disabled toggle makes the option non-expandable, so `cdk` is not collapsed + expect(getNodes(treeElement).length).toBe(8); + expect(component.treeControl.expansionModel.selected.length).toBe(2); + expect(getActiveValue()).toBe('src'); + })); + + it('should do nothing on a root-level option', fakeAsync(() => { + pressKey(DOWN_ARROW); + + expect(getActiveValue()).toBe('docs'); + + pressKey(LEFT_ARROW); + + expect(component.tree.keyManager.activeItemIndex).toBe(0); + expect(getNodes(treeElement).length).toBe(3); + })); + + it('should skip a disabled ancestor instead of landing on its sibling', fakeAsync(() => { + component.disabledNodes = ['cdk']; + fixture.detectChanges(); + + expandNode(1); + expandNode(3); + + expect(getNodes(treeElement).length).toBe(8); + + // DOWN_ARROW skips the disabled `cdk`, so the fourth press lands on `a11y` + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + + expect(getActiveValue()).toBe('a11y'); + + pressKey(LEFT_ARROW); + + // `cdk` cannot take the focus, so the walk narrows to its level and reaches `src` — + // without narrowing it would stop on `assets`, a sibling of the parent + expect(getActiveValue()).toBe('src'); + })); + + it('should keep narrowing the level across a chain of disabled ancestors', fakeAsync(() => { + component.disabledNodes = ['src', 'cdk']; + fixture.detectChanges(); + + expandNode(1); + expandNode(3); + + expect(getNodes(treeElement).length).toBe(8); + + // DOWN_ARROW skips both disabled ancestors, so the third press lands on `a11y` + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + + expect(getActiveValue()).toBe('a11y'); + + pressKey(LEFT_ARROW); + + // the walk narrows twice — over `cdk` to level 1, then over `src` to level 0 — so + // neither `assets` (a sibling of `cdk`) nor `docs` (a sibling of `src`) takes the focus + expect(getActiveValue()).toBe('a11y'); + })); + + it('should do nothing when every ancestor is disabled', fakeAsync(() => { + component.disabledNodes = ['src']; + fixture.detectChanges(); + + expandNode(1); + + // DOWN_ARROW skips the disabled `src`, so the second press lands on `assets` + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + + expect(getActiveValue()).toBe('assets'); + + pressKey(LEFT_ARROW); + + expect(getActiveValue()).toBe('assets'); + })); + + it('should not change the selection when moving to the parent', fakeAsync(() => { + expandNode(1); + + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + + expect(component.modelValue).toBe('assets'); + + pressKey(LEFT_ARROW); + + expect(getActiveValue()).toBe('src'); + expect(component.modelValue).toBe('assets'); + })); + + it('should emit navigationChange when moving to the parent', fakeAsync(() => { + expandNode(1); + + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + pressKey(DOWN_ARROW); + + const spy = jest.fn(); + const subscription = component.tree.navigationChange.subscribe(spy); + + pressKey(LEFT_ARROW); + + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].option.value).toBe('src'); + + subscription.unsubscribe(); + })); + }); + // todo need recover xdescribe('with when node template', () => { let fixture: ComponentFixture; @@ -1613,6 +1864,62 @@ class KbqTreeAppNonSelectableParents extends TreeParams { } } +// Unlike DEEP_DATA_OBJECT, `cdk` has a sibling before it, so a backwards ancestor scan that fails to +// narrow the level while stepping over a disabled `cdk` lands on `assets` instead of on `src`. +export const DEEP_DATA_OBJECT_WITH_SIBLINGS = { + docs: 'app', + src: { + assets: 'png', + cdk: { + a11y: 'ts', + keycodes: 'ts' + }, + README: 'md' + }, + tests: 'ts' +}; + +@Component({ + imports: [ + KbqTreeModule, + FormsModule + ], + template: ` + + + {{ node.name }} + + + + + + {{ node.name }} + + + ` +}) +class KbqTreeAppDeepData extends TreeParams { + modelValue: any = ''; + disabledNodes: string[] = []; + // disables only the toggle, leaving the option itself focusable — unlike `disabledNodes` + disabledToggles: string[] = []; + @ViewChild(KbqTreeSelection, { static: false }) tree: KbqTreeSelection; + + constructor() { + super(); + + this.dataSource.data = this.treeData = buildFileTree(DEEP_DATA_OBJECT_WITH_SIBLINGS, 0); + } +} + @Component({ imports: [ KbqTreeModule diff --git a/packages/components/tree/tree-selection.component.ts b/packages/components/tree/tree-selection.component.ts index 6fa82e00e..685d0d598 100644 --- a/packages/components/tree/tree-selection.component.ts +++ b/packages/components/tree/tree-selection.component.ts @@ -401,8 +401,14 @@ export class KbqTreeSelection this.keyManager.tabOut.next(); return; - } else if (keyCode === LEFT_ARROW && this.keyManager.activeItem?.isExpandable) { - this.treeControl.collapse(this.keyManager.activeItem.data as KbqTreeOption); + } else if (keyCode === LEFT_ARROW && this.keyManager.activeItem) { + const activeItem = this.keyManager.activeItem; + + if (activeItem.isExpandable && activeItem.isExpanded) { + this.treeControl.collapse(activeItem.data as KbqTreeOption); + } else { + this.setActiveParentOption(activeItem); + } return; } else if (keyCode === RIGHT_ARROW && this.keyManager.activeItem?.isExpandable) { @@ -746,6 +752,41 @@ export class KbqTreeSelection this.clipboard?.copy(this.keyManager.activeItem!.value); } + /** + * Moves focus to the closest enabled ancestor of the active option — ArrowLeft on a leaf, on an + * already-collapsed node, or on a node whose toggle is disabled. + * + * Ancestors are resolved against `renderedOptions` rather than `treeControl.dataNodes`: every + * ancestor of a visible option is itself visible, options are rendered depth-first, and these are + * the indices `keyManager` navigates. Scanning backwards, the first option with a smaller level is + * the parent. + * + * A disabled option cannot take focus (`KbqTreeOption.focus` refuses it), so the walk steps over a + * disabled ancestor and narrows `level` to that ancestor's level. Without narrowing, the scan would + * continue against the original level and land on a preceding sibling of the parent. + */ + private setActiveParentOption(activeOption: KbqTreeOption): void { + const options = this.renderedOptions.toArray(); + + let level = activeOption.level; + + for (let index = options.indexOf(activeOption) - 1; index >= 0; index--) { + const option = options[index]; + + if (option.level >= level) { + continue; + } + + if (!option.disabled) { + this.keyManager.setActiveItem(index); + + return; + } + + level = option.level; + } + } + private getHeight(): number { return this.elementRef.nativeElement.getClientRects()[0]?.height ?? 0; } diff --git a/packages/components/tree/tree.en.md b/packages/components/tree/tree.en.md index 7ea3de4a2..fb6858b2d 100644 --- a/packages/components/tree/tree.en.md +++ b/packages/components/tree/tree.en.md @@ -70,7 +70,7 @@ There are several variants for multiple item selection. [See in examples](/en/co | Tab | If focus is on the component preceding the tree in tab order → Move to the first tree row or the selected row
If focus is on any tree item and it has no additional actions → Move to the next component in tab order after the tree
If focus is on any tree item and it has additional actions → Move to the first additional action
If focus is on an item's additional action → Move to the next additional action (if one exists), otherwise move to the next component in tab order after the tree | | Shift + Tab | If focus is on any tree item → Move to the previous component in tab order before the tree
If focus is on the component following the tree in tab order → Move to the first tree row or the selected row | | | If focus is on a non-leaf item → Expand the nested items of that item
If focus is on a leaf item → Nothing happens | -| | If focus is on a non-leaf item → Collapse the nested items of that item
If focus is on a leaf item → Nothing happens | +| | If focus is on an expanded non-leaf item → Collapse the nested items of that item
If focus is on a collapsed non-leaf item → Move to its parent item
If focus is on a leaf item → Move to its parent item
If focus is on a top-level item → Nothing happens | | | If focus is on a tree item → Trigger the primary action of that item
If focus is on a tree item with a checkbox → Nothing happens
If focus is on an additional action → Apply that additional action | | Space | If focus is on a tree item with a checkbox → Select that tree item (and all nested items, if any)
If focus is on a tree item → Nothing happens
If focus is on an additional action → Apply that additional action | | | If focus is on a tree item → Move to the next (if it is the last item — nothing happens) / previous tree item (if it is the first item — nothing happens) | diff --git a/packages/components/tree/tree.ru.md b/packages/components/tree/tree.ru.md index ea930b162..5d304585b 100644 --- a/packages/components/tree/tree.ru.md +++ b/packages/components/tree/tree.ru.md @@ -70,7 +70,7 @@ | Tab | Если фокус установлен на предыдущем компоненте в таб-последовательности по отношению к дереву → Переход к первой строке дерева или к выделенной строке
Если фокус установлен на любом элементе дерева и у него нет доп. действий → Переход к следующему компоненту в таб-последовательности по отношению к дереву
Если фокус установлен на любом элементе дерева и у него есть доп. действия → Переход к первому доп. действию
Если фокус установлен на доп. действии элемента → Переход к следующему доп. действию (если оно есть), иначе переход к следующему компоненту в таб-последовательности по отношению к дереву | | Shift + Tab | Если фокус установлен на любом элементе дерева → Переход к предыдущему компоненту в таб-последовательности по отношению к дереву
Если фокус установлен на следующем компоненте в таб-последовательности по отношению к дереву → Переход к первой строке дерева или к выделенной строке | | | Если фокус установлен на нелистовом элементе → Раскрытие вложенных элементов этого элемента
Если фокус установлен на листовом элементе → Ничего не происходит | -| | Если фокус установлен на нелистовом элементе → Закрытие вложенных элементов этого элемента
Если фокус установлен на листовом элементе → Ничего не происходит | +| | Если фокус установлен на раскрытом нелистовом элементе → Закрытие вложенных элементов этого элемента
Если фокус установлен на закрытом нелистовом элементе → Переход к родительскому элементу
Если фокус установлен на листовом элементе → Переход к родительскому элементу
Если фокус установлен на элементе верхнего уровня → Ничего не происходит | | | Если фокус установлен на элементе дерева → Запуск основного действия этого элемента
Если фокус установлен на элементе дерева с чекбоксом → Ничего не происходит
Если фокус установлен на доп. действии → применение этого доп. действия | | Space | Если фокус установлен на элементе дерева с чекбоксом → Выбрать этот элемент дерева (и все вложенные, если есть)
Если фокус установлен на элементе дерева → Ничего не происходит
Если фокус установлен на доп. действии → применение этого доп. действия | | | Если фокус установлен на элементе дерева → Переход к следующему (если элемент последний — ничего не происходит) / предыдущему элементу дерева (если элемент первый — ничего не происходит) | diff --git a/tools/public_api_guard/components/tree-select.api.md b/tools/public_api_guard/components/tree-select.api.md index bef89764b..c7b1a44b6 100644 --- a/tools/public_api_guard/components/tree-select.api.md +++ b/tools/public_api_guard/components/tree-select.api.md @@ -181,7 +181,7 @@ export class KbqTreeSelect extends KbqAbstractSelect implements AfterContentInit }>; panelDoneAnimatingStream: Subject; // (undocumented) - panelKeydownHandler(event: KeyboardEvent): any; + panelKeydownHandler(event: KeyboardEvent): void; readonly panelMaxHeight: _angular_core.InputSignalWithTransform; protected readonly panelMaxHeightToken: _angular_core.Signal; readonly panelMaxWidth: _angular_core.InputSignalWithTransform;