diff --git a/packages/components/tree/control/flat-tree-control.spec.ts b/packages/components/tree/control/flat-tree-control.spec.ts index b883e3cf15..1c10880083 100644 --- a/packages/components/tree/control/flat-tree-control.spec.ts +++ b/packages/components/tree/control/flat-tree-control.spec.ts @@ -192,6 +192,48 @@ describe('FlatTreeControl', () => { }); }); + describe('position of a node', () => { + const names = (found: FlatNode[]): string[] => found.map((node) => node.name); + + it('should search dataNodes once for any number of lookups', () => { + const indexOf = vi.spyOn(nodes, 'indexOf'); + + nodes.forEach((node) => { + control.getDescendants(node); + control.getParents(node); + }); + + expect(indexOf).toHaveBeenCalledTimes(1); + }); + + it('should follow dataNodes changed in place', () => { + const notes: FlatNode = { name: 'notes', level: 2, expandable: false }; + + expect(names(control.getDescendants(byName(nodes, 'documents')))).toEqual(['specs', 'draft', 'readme']); + + nodes.splice(1, 0, { name: 'archive', level: 1, expandable: false }); + nodes.splice(3, 0, notes); + + expect(names(control.getDescendants(byName(nodes, 'documents')))).toEqual([ + 'notes', + 'specs', + 'draft', + 'readme' + ]); + expect(names(control.getParents(notes))).toEqual(['root', 'documents']); + expect(names(control.getParents(byName(nodes, 'draft')))).toEqual(['root', 'documents', 'specs']); + }); + + it('should follow a replaced dataNodes array', () => { + expect(names(control.getDescendants(byName(nodes, 'documents')))).toEqual(['specs', 'draft', 'readme']); + + control.dataNodes = nodes.filter((node) => node.name !== 'specs' && node.name !== 'draft'); + + expect(names(control.getDescendants(byName(nodes, 'documents')))).toEqual(['readme']); + expect(control.getParents(byName(nodes, 'draft'))).toEqual([]); + }); + }); + describe('hasValue', () => { it('should find the node carrying the value', () => { expect(control.hasValue('specs')).toBe(byName(nodes, 'specs')); diff --git a/packages/components/tree/control/flat-tree-control.ts b/packages/components/tree/control/flat-tree-control.ts index bb4bd0d882..1e23914ac7 100644 --- a/packages/components/tree/control/flat-tree-control.ts +++ b/packages/components/tree/control/flat-tree-control.ts @@ -17,6 +17,10 @@ export class FlatTreeControl extends BaseTreeControl { private filters: FlatTreeControlFilter[]; + // Positions in each `dataNodes` array: a tree looks up every rendered row, and `indexOf` would make that + // rows × nodes. Keyed weakly by the array, so replacing `dataNodes` releases the old nodes. + private readonly nodeIndexes = new WeakMap>(); + /** Construct with flat tree data node functions getLevel, isExpandable, getValue and getViewValue. */ constructor( public getLevel: (dataNode: T) => number, @@ -165,20 +169,37 @@ export class FlatTreeControl extends BaseTreeControl { } /** - * Position of the node in `dataNodes`. With `trackBy` a node rebuilt as a new object since is found by its key, - * as the expansion methods accept it too. + * Position of the node in `dataNodes`, constant-time while the index matches the array. With `trackBy` a node + * rebuilt as a new object since is found by its key, as the expansion methods accept it too. */ private indexOfNode(node: T): number { const { dataNodes, trackBy } = this; if (!dataNodes) return -1; + const cached = this.nodeIndexes.get(dataNodes)?.get(node); + + // Checked against the array: `dataNodes` can be mutated in place. + if (cached !== undefined && dataNodes[cached] === node) return cached; + const index = dataNodes.indexOf(node); - if (index !== -1 || !trackBy) return index; + // A node that is not there leaves the index as it is. + if (index === -1) { + if (!trackBy) return index; + + const key = trackBy(node); - const key = trackBy(node); + return dataNodes.findIndex((candidate) => trackBy(candidate) === key); + } + + const positions = new Map(); + + dataNodes.forEach((dataNode, position) => { + if (!positions.has(dataNode)) positions.set(dataNode, position); + }); + this.nodeIndexes.set(dataNodes, positions); - return dataNodes.findIndex((candidate) => trackBy(candidate) === key); + return index; } } diff --git a/packages/components/tree/tree-option.component.ts b/packages/components/tree/tree-option.component.ts index 2d810cdebb..8ea6a0e422 100644 --- a/packages/components/tree/tree-option.component.ts +++ b/packages/components/tree/tree-option.component.ts @@ -19,8 +19,10 @@ import { InjectionToken, input, linkedSignal, + NgZone, output, QueryList, + Renderer2, signal, untracked, viewChild, @@ -73,6 +75,10 @@ export interface KbqTreeOptionParent { selectionModel: SelectionModel; unorderedOptions: QueryList>; setSelectedOptionsByClick(option: KbqTreeOption, shiftKey: boolean, ctrlKey: boolean): void; + /** Called when an option takes focus. */ + reportOptionFocus?(event: KbqTreeOptionEvent): void; + /** Called when an option loses focus. */ + reportOptionBlur?(event: KbqTreeOptionEvent): void; } /** Injection token used to provide the parent component to options. */ @@ -133,7 +139,6 @@ const samePredicates = (a: TreeOptionPredicates, b: TreeOptionPredicates): boole '[attr.aria-checked]': 'ariaChecked', '[attr.aria-selected]': 'ariaSelected', '(focusin)': 'focus()', - '(mouseenter)': 'onMouseenter()', '(blur)': 'blur()', '(click)': 'selectViaInteraction($event)', '(keydown)': 'onKeydown($event)' @@ -144,6 +149,8 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte private changeDetectorRef = inject(ChangeDetectorRef); private readonly environmentInjector = inject(EnvironmentInjector); private readonly destroyRef = inject(DestroyRef); + private readonly ngZone = inject(NgZone); + private readonly renderer = inject(Renderer2); // Intersected with the rendering base because `KbqTreeNode` resolves its level and expansion state // through it; the option itself only ever touches the `KbqTreeOptionParent` half. override tree: KbqTreeOptionParent & KbqTreeBase; @@ -384,6 +391,14 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte super(); this.tree = tree; + + // Not a host listener: one would check the views on every row the pointer crosses, and only a tree in a select + // reacts to it. + const unlisten = this.ngZone.runOutsideAngular(() => + this.renderer.listen(this.elementRef.nativeElement, 'mouseenter', () => this.onMouseenter()) + ); + + this.destroyRef.onDestroy(unlisten); } ngDoCheck(): void { @@ -505,7 +520,10 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte kbqFocusAndReveal(this.elementRef.nativeElement, focusOrigin === 'mouse'); if (!this.hasFocus) { - this.focusEvents.next({ option: this }); + const event: KbqTreeOptionEvent = { option: this }; + + this.focusEvents.next(event); + this.tree.reportOptionFocus?.(event); Promise.resolve().then(() => { this.hasFocus = true; @@ -536,7 +554,10 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte this.hasFocus = false; - this.blurEvents.next({ option: this }); + const event: KbqTreeOptionEvent = { option: this }; + + this.blurEvents.next(event); + this.tree.reportOptionBlur?.(event); }, // The environment injector, not the option's: an option removed while focused is blurred as its view is // destroyed, and the tree still has to hear about it to move focus. @@ -573,7 +594,8 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte this.focus(); } - this.updateCheckboxState(); + // Its own state only: an ancestor's reads the selection model, which this leaves as it is. + this.syncCheckboxState(); this.emitSelectionChangeEvent(); }); } @@ -586,7 +608,8 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte this.selectedState.set(false); - this.updateCheckboxState(); + // Its own state only, as in `select`. + this.syncCheckboxState(); this.emitSelectionChangeEvent(); }); } @@ -628,8 +651,9 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte markForCheck() { this.changeDetectorRef.markForCheck(); - // Its own state only: after a selection, data or mode change the tree calls this on every option. - if (this.showCheckbox) { + // Its own state only: after a selection, data or mode change the tree calls this on every option. A projected + // checkbox shows the state while the tree's own is hidden. + if (this.showCheckbox || this.externalPseudoCheckbox) { this.syncCheckboxState(); } } @@ -638,7 +662,10 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte protected onMouseenter() { if (this.disabled || !this.tree.inSelect) return; - this.focus('mouse'); + // Focusing the option it already holds would only run change detection. + if (this.hasFocus && _getFocusedElementPierceShadowDom() === this.elementRef.nativeElement) return; + + this.ngZone.run(() => this.focus('mouse')); } /** Recomputes the option's own checkbox state, leaving its ancestors as they are. */ @@ -646,8 +673,8 @@ export class KbqTreeOption extends KbqTreeNode implements AfterConte if (this.selectAllRow()) return; if (this.checkboxThirdState() && this.isExpandable) { - // One descendant walk, not two: `getDescendants` is a linear scan of the whole data set, and - // the roll-up runs this method for every ancestor. + // One descendant walk, not two: each walk covers the whole subtree, and the roll-up runs this method for + // every ancestor. const descendants = this.tree.treeControl.getDescendants(this.data); const selectedCount = descendants.filter((child) => this.tree.selectionModel.isSelected(child)).length; diff --git a/packages/components/tree/tree-selection.component.spec.ts b/packages/components/tree/tree-selection.component.spec.ts index 49c483ff13..61e7adc556 100644 --- a/packages/components/tree/tree-selection.component.spec.ts +++ b/packages/components/tree/tree-selection.component.spec.ts @@ -9,6 +9,7 @@ import { effect, EventEmitter, InputSignal, + NgZone, signal, Type, viewChild, @@ -34,6 +35,7 @@ import { KbqMultipleInput, KbqOptionActionComponent, KbqOptionModule, + KbqPseudoCheckbox, KbqStateSavingService, KbqStateStore, LEFT_ARROW, @@ -2287,6 +2289,35 @@ describe('KbqTreeSelection', () => { }); }); + describe('tri-state checkbox projected while the tree hides its own', () => { + it('should keep the projected checkbox of an ancestor in step with the selection', async () => { + vi.useFakeTimers(); + configureKbqTreeTestingModule(); + + const fixture = TestBed.createComponent(TreeWithProjectedThirdStateCheckbox); + const { tree, treeControl } = fixture.componentInstance; + const settle = async () => { + fixture.detectChanges(); + await vi.advanceTimersByTimeAsync(0); + fixture.detectChanges(); + }; + + await settle(); + treeControl.expand(treeControl.hasValue('Pictures')!); + await settle(); + + tree().selectionModel.select(treeControl.hasValue('Sun')!); + await settle(); + + const pictures = getNodeByText(fixture.nativeElement, 'Pictures'); + + expect(tree().renderedOptions.find((option) => option.value === 'Pictures')!.checkboxState).toBe( + 'indeterminate' + ); + expect(pictures.querySelector('kbq-pseudo-checkbox')!.classList).toContain('kbq-indeterminate'); + }); + }); + describe('tri-state checkbox over replaced data', () => { let fixture: ComponentFixture; let component: TreeWithTrackedThirdStateCheckbox; @@ -2389,16 +2420,41 @@ describe('KbqTreeSelection', () => { expect(root.checkboxState).toBe('indeterminate'); }); - it('should not roll every newly rendered row up through its ancestors', async () => { + it('should roll no rendered row up through its ancestors, selected ones included', async () => { vi.useFakeTimers(); - const rollUps = vi.spyOn(component.treeControl, 'getParents'); + const { tree, treeControl } = component; - component.treeControl.expandAll(); + tree().selectionModel.select(treeControl.hasValue(`node-${DEEP_CHAIN_DEPTH}`)!); await settle(); - expect(getNodes(treeElement).length).toBe(component.treeControl.dataNodes.length); + const rollUps = vi.spyOn(treeControl, 'getParents'); + + treeControl.expandAll(); + await settle(); + + expect(getNodes(treeElement).length).toBe(treeControl.dataNodes.length); expect(rollUps).not.toHaveBeenCalled(); + expect(optionFor(`node-${DEEP_CHAIN_DEPTH}`).selected).toBe(true); + expect(optionFor('node-0').checkboxState).toBe('indeterminate'); + }); + + it('should sweep the rows once for the selection changes of one task', async () => { + vi.useFakeTimers(); + + const { tree, treeControl } = component; + + treeControl.expandAll(); + await settle(); + + const sweeps = vi.spyOn(optionFor('node-0'), 'markForCheck'); + + ['leaf-1', 'leaf-2', 'leaf-3'].forEach((name) => + tree().selectionModel.select(treeControl.hasValue(name)!) + ); + await settle(); + + expect(sweeps).toHaveBeenCalledTimes(1); }); }); @@ -3057,22 +3113,17 @@ describe('KbqTreeSelection', () => { expect(treeElement.querySelectorAll('.kbq-tree-option').length).toBe(0); }); - it('should drop the per-option focus subscriptions', async () => { - vi.useFakeTimers(); - + it('should complete optionFocusChanges and optionBlurChanges', () => { const tree = component.tree(); + const focusCompleted = vi.fn(); + const blurCompleted = vi.fn(); - tree.onKeyDown(createKeyboardEvent('keydown', DOWN_ARROW)); - fixture.detectChanges(); - await vi.runOnlyPendingTimersAsync(); - - expect(tree['optionFocusSubscription']).not.toBeNull(); - expect(tree['optionBlurSubscription']).not.toBeNull(); - + tree.optionFocusChanges.subscribe({ complete: focusCompleted }); + tree.optionBlurChanges.subscribe({ complete: blurCompleted }); fixture.destroy(); - expect(tree['optionFocusSubscription']).toBeNull(); - expect(tree['optionBlurSubscription']).toBeNull(); + expect(focusCompleted).toHaveBeenCalledTimes(1); + expect(blurCompleted).toHaveBeenCalledTimes(1); }); it('should not restore the tab index after the tree is gone', () => { @@ -3929,6 +3980,37 @@ class TreeWithThirdStateCheckbox extends TreeParams { readonly tree = viewChild.required(KbqTreeSelection); } +@Component({ + imports: [KbqTreeModule, KbqPseudoCheckbox], + template: ` + + + + {{ node.name }} + + + + + + {{ node.name }} + + + ` +}) +class TreeWithProjectedThirdStateCheckbox extends TreeParams { + readonly tree = viewChild.required(KbqTreeSelection); +} + const DEEP_CHAIN_DEPTH = 12; /** @@ -5747,4 +5829,149 @@ describe('KbqTreeSelection rendering a change of the data', () => { expect(selectAllCheckbox().classList).toContain('kbq-indeterminate'); }); + + it('should leave every view unchecked when the pointer enters a row of a tree outside a select', () => { + const row = getNodeByText(fixture.nativeElement, 'Pictures'); + const checks = countChecks(() => dispatchFakeEvent(row, 'mouseenter')); + + expect(checks.declaringView).toBe(0); + expect(checks.rows).toEqual(checks.rows.map(() => 0)); + }); + + it('should leave the zone alone when the pointer enters the row a tree in a select has focused', async () => { + const row = getNodeByText(fixture.nativeElement, 'Pictures'); + + fixture.componentInstance.tree().inSelect = true; + row.focus(); + await settle(); + + const run = vi.spyOn(TestBed.inject(NgZone), 'run'); + + dispatchFakeEvent(row, 'mouseenter'); + + expect(run).not.toHaveBeenCalled(); + + dispatchFakeEvent(getNodeByText(fixture.nativeElement, 'Documents'), 'mouseenter'); + + expect(run).toHaveBeenCalled(); + }); + + it('should hand the focus of a row the change brings in to the keyboard before the rows are swept', async () => { + fixture.componentInstance.selectAll = false; + await settle(); + + const { tree, treeControl } = fixture.componentInstance; + + treeControl.expand(node('Documents')); + TestBed.tick(); + dispatchFakeEvent(getNodeByText(fixture.nativeElement, 'angular'), 'focusin'); + + expect(tree().keyManager.activeItem!.value).toBe('angular'); + }); + + it('should report through optionFocusChanges the focus of rows rendered after subscribing', async () => { + const { tree, treeControl } = fixture.componentInstance; + const focused: string[] = []; + const subscription = tree().optionFocusChanges.subscribe(({ option }) => focused.push(option.value)); + + treeControl.expand(node('Documents')); + await settle(); + getNodeByText(fixture.nativeElement, 'angular').focus(); + subscription.unsubscribe(); + + expect(focused).toEqual(['angular']); + }); +}); + +@Component({ + imports: [KbqTreeModule], + template: ` + + {{ node.name }} + + ` +}) +class TreeWithSharedValues extends TreeParams { + readonly tree = viewChild.required>(KbqTreeSelection); + + constructor() { + super(); + + // Rows share values (`jpg`, `ts`, `pdf`, `app`): only the node tells such rows apart. + this.treeControl.getValue = (node: FileFlatNode) => node.type ?? node.name; + } +} + +describe('KbqTreeSelection with rows that share a value', () => { + let fixture: ComponentFixture; + + const settle = async (): Promise => { + fixture.detectChanges(); + await new Promise((resolve) => setTimeout(resolve)); + await fixture.whenStable(); + }; + + const folder = (name: string): FileFlatNode => fixture.componentInstance.treeControl.hasValue(name)!; + + /** The rows the keyboard moves through, in its order. */ + const keyboardRows = (): HTMLElement[] => + fixture.componentInstance.tree().renderedOptions.map((option) => option.getHostElement()); + + const selectedRows = (): string[] => + getNodes(fixture.nativeElement) + .filter((row) => row.classList.contains('kbq-selected')) + .map((row) => row.textContent!.trim()); + + beforeEach(() => { + fixture = TestBed.createComponent(TreeWithSharedValues); + fixture.detectChanges(); + }); + + it('should mark only the selected node among the rows that share its value', async () => { + const { tree, treeControl } = fixture.componentInstance; + + treeControl.expand(folder('Pictures')); + treeControl.expand(folder('Documents')); + await settle(); + + dispatchFakeEvent(getNodeByText(fixture.nativeElement, 'Woods'), 'click'); + await settle(); + + expect(tree().selectionModel.selected.map(({ name }) => name)).toEqual(['Woods']); + expect(selectedRows()).toEqual(['Woods']); + }); + + it('should mark no row while nothing is selected, rows without a value included', async () => { + fixture = TestBed.createComponent(TreeWithSharedValues); + // Folders carry no value. + fixture.componentInstance.treeControl.getValue = (node: FileFlatNode) => node.type; + await settle(); + + expect(getNodes(fixture.nativeElement).length).toBeGreaterThan(0); + expect(selectedRows()).toEqual([]); + }); + + it('should follow the rows on screen across expanding, collapsing and new data, with values shared by rows', () => { + const { dataSource, treeControl } = fixture.componentInstance; + + treeControl.expand(folder('Pictures')); + treeControl.expand(folder('Documents')); + TestBed.tick(); + + expect(getNodes(fixture.nativeElement).length).toBe(11); + expect(keyboardRows()).toEqual(getNodes(fixture.nativeElement)); + + treeControl.collapse(folder('Pictures')); + TestBed.tick(); + + expect(getNodes(fixture.nativeElement).length).toBe(8); + expect(keyboardRows()).toEqual(getNodes(fixture.nativeElement)); + + dataSource.data = buildFileTree(DATA_OBJECT, 0); + treeControl.expand(folder('Downloads')); + TestBed.tick(); + + expect(getNodes(fixture.nativeElement).length).toBe(8); + expect(keyboardRows()).toEqual(getNodes(fixture.nativeElement)); + }); }); diff --git a/packages/components/tree/tree-selection.component.ts b/packages/components/tree/tree-selection.component.ts index 4acc612a3e..56ee265fc7 100644 --- a/packages/components/tree/tree-selection.component.ts +++ b/packages/components/tree/tree-selection.component.ts @@ -68,9 +68,9 @@ import { UP_ARROW } from '@koobiq/components/core'; import { KBQ_FORM_FIELD } from '@koobiq/components/form-field'; -import { merge, Observable, Subscription } from 'rxjs'; +import { Observable, Subject, Subscription } from 'rxjs'; import { AsyncScheduler } from 'rxjs/internal/scheduler/AsyncScheduler'; -import { delay } from 'rxjs/operators'; +import { delay, filter, map } from 'rxjs/operators'; import { FlatTreeControl } from './control/flat-tree-control'; import { KbqTreeNodeOutlet } from './outlet'; import { KbqTreeNodePadding } from './padding.directive'; @@ -336,12 +336,19 @@ export class KbqTreeSelection this.autoSelectState.set(coerceBooleanProperty(value)); } + // Options report here, so a subscriber also hears the rows rendered after it subscribed. + private readonly optionFocusEvents = new Subject(); + + private readonly optionBlurEvents = new Subject(); + + /** Emits when an option of the tree takes focus. */ get optionFocusChanges(): Observable { - return merge(...this.renderedOptions.map((option) => option.onFocus)); + return this.optionFocusEvents.asObservable(); } + /** Emits when an option of the tree loses focus. */ get optionBlurChanges(): Observable { - return merge(...this.renderedOptions.map((option) => option.onBlur)); + return this.optionBlurEvents.asObservable(); } /** @@ -570,10 +577,6 @@ export class KbqTreeSelection return this.sortedNodes.length; } - private optionFocusSubscription: Subscription | null; - - private optionBlurSubscription: Subscription | null; - private selectionModelSubscription: Subscription | null = null; /** Whether a value report is queued for the end of the current tick. Cleared by `writeValue` to cancel it. */ @@ -865,20 +868,30 @@ export class KbqTreeSelection this.bindSelectionModel(); + this.listenToOptionsFocus(); + + // A render and the selection changes of one task each notify, each with its own timer: the first to fire sweeps + // for all of them and the rest are dropped. `auditTime` would tie the later ones to the first one's timer. + let sweeps = 0; + this.renderedOptions.changes - .pipe(delay(0, this.scheduler), takeUntilDestroyed(this.destroyRef)) - .subscribe((options) => { - this.resetOptions(); + .pipe( + map(() => sweeps), + delay(0, this.scheduler), + filter((sweepsWhenNotified) => sweepsWhenNotified === sweeps), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe(() => { + sweeps++; // Check to see if we need to update our tab index this.updateTabIndex(); this.syncSelectionModelToDataNodes(); - const selectedValues = this.multiple ? this.getSelectedValues() : [this.getSelectedValues()]; - - options.forEach((option) => { - if (selectedValues.includes(option.value)) { + this.renderedOptions.forEach((option) => { + // By node rather than by value: rows may share a value. + if (this.selectionModel.isSelected(option.data)) { option.select(false); } else { option.deselect(); @@ -925,7 +938,8 @@ export class KbqTreeSelection ngOnDestroy(): void { super.ngOnDestroy(); - this.dropSubscriptions(); + this.optionFocusEvents.complete(); + this.optionBlurEvents.complete(); this.focusMonitor.stopMonitoring(this.elementRef); } @@ -1107,6 +1121,16 @@ export class KbqTreeSelection } } + /** @docs-private */ + reportOptionFocus(event: KbqTreeOptionEvent): void { + this.optionFocusEvents.next(event); + } + + /** @docs-private */ + reportOptionBlur(event: KbqTreeOptionEvent): void { + this.optionBlurEvents.next(event); + } + /** @docs-private */ setSelectedOptionsByClick(option: KbqTreeOption, shiftKey: boolean, ctrlKey: boolean): void { if (option.disabled || !option.selectable()) { @@ -1596,12 +1620,20 @@ export class KbqTreeSelection orderedOptions.push(selectAllOption); } + // Indexed once per render, as a search per node is quadratic in the rows. The first option wins in both maps. + const optionsByNode = new Map>(); + const optionsByValue = new Map>(); + + this.unorderedOptions.forEach((option) => { + if (!optionsByNode.has(option.data)) optionsByNode.set(option.data, option); + + if (!optionsByValue.has(option.value)) optionsByValue.set(option.value, option); + }); + this.sortedNodes.forEach((node) => { // By node first: nodes do not have to carry distinct values, and a value match would hand every node with // the same value the first option holding it. - const found = - this.unorderedOptions.find((option) => option.data === node) ?? - this.unorderedOptions.find((option) => option.value === this.treeControl.getValue(node)); + const found = optionsByNode.get(node) ?? optionsByValue.get(this.treeControl.getValue(node)); if (found) { orderedOptions.push(found); @@ -1626,25 +1658,9 @@ export class KbqTreeSelection return array; } - private resetOptions() { - this.dropSubscriptions(); - this.listenToOptionsFocus(); - } - - private dropSubscriptions() { - if (this.optionFocusSubscription) { - this.optionFocusSubscription.unsubscribe(); - this.optionFocusSubscription = null; - } - - if (this.optionBlurSubscription) { - this.optionBlurSubscription.unsubscribe(); - this.optionBlurSubscription = null; - } - } - private listenToOptionsFocus(): void { - this.optionFocusSubscription = this.optionFocusChanges.subscribe((event) => { + // Both streams complete with the tree. + this.optionFocusEvents.subscribe((event) => { const index: number = this.renderedOptions.toArray().indexOf(event.option as KbqTreeOption); this.renderedOptions.filter((option) => option.hasFocus).forEach((option) => (option.hasFocus = false)); @@ -1659,7 +1675,7 @@ export class KbqTreeSelection // set, because `KbqTreeOption.hasFocus` is cleared synchronously but only raised a microtask later, so // `hasFocusedOption()` reports nothing focused for the whole move. Only a blur of the option the // key manager still points at is the focus actually leaving the options. - this.optionBlurSubscription = this.optionBlurChanges.subscribe(({ option }) => { + this.optionBlurEvents.subscribe(({ option }) => { if (option === this.keyManager.activeItem && !this.hasFocusedOption() && this.resetFocusedItemOnBlur) { this.keyManager.setActiveItem(-1); } diff --git a/tools/check-api-docs/baseline.json b/tools/check-api-docs/baseline.json index e05f766401..60b050d218 100644 --- a/tools/check-api-docs/baseline.json +++ b/tools/check-api-docs/baseline.json @@ -44,7 +44,7 @@ "components-toast": 50, "components-toggle": 16, "components-tooltip": 22, - "components-tree": 157, + "components-tree": 155, "components-tree-select": 28, "components-username": 1 } diff --git a/tools/public_api_guard/components/tree.api.md b/tools/public_api_guard/components/tree.api.md index eae3af8a99..58d0bd19b8 100644 --- a/tools/public_api_guard/components/tree.api.md +++ b/tools/public_api_guard/components/tree.api.md @@ -546,6 +546,8 @@ export interface KbqTreeOptionParent { inSelect: boolean; multiple: boolean; optionShouldHoldFocusOnBlur: boolean; + reportOptionBlur?(event: KbqTreeOptionEvent): void; + reportOptionFocus?(event: KbqTreeOptionEvent): void; selectAllState: KbqPseudoCheckboxState; // (undocumented) selectionModel: SelectionModel; @@ -628,9 +630,7 @@ export class KbqTreeSelection extends KbqTreeBase, T, K>>; onTouched: () => void; - // (undocumented) get optionBlurChanges(): Observable; - // (undocumented) get optionFocusChanges(): Observable; // (undocumented) optionShouldHoldFocusOnBlur: boolean; @@ -642,6 +642,8 @@ export class KbqTreeSelection extends KbqTreeBase>; renderNodeChanges(data: T[] | ReadonlyArray, dataDiffer?: IterableDiffer, viewContainer?: ViewContainerRef, parentData?: T): void; + reportOptionBlur(event: KbqTreeOptionEvent): void; + reportOptionFocus(event: KbqTreeOptionEvent): void; // (undocumented) resetFocusedItemOnBlur: boolean; protected get resolvedAriaLabel(): string | null;