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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions packages/components/tree/control/flat-tree-control.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'));
Expand Down
33 changes: 31 additions & 2 deletions packages/components/tree/control/flat-tree-control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ export class FlatTreeControl<T> extends BaseTreeControl<T> {

private filters: FlatTreeControlFilter<T>[];

// Positions in `dataNodes`: a tree looks up every rendered row, and `indexOf` would make that rows × nodes.
private indexedNodes: T[] | null = null;
private nodeIndexes = new Map<T, number>();

/** Construct with flat tree data node functions getLevel, isExpandable, getValue and getViewValue. */
constructor(
public getLevel: (dataNode: T) => number,
Expand Down Expand Up @@ -52,7 +56,7 @@ export class FlatTreeControl<T> extends BaseTreeControl<T> {
* with correct levels.
*/
getDescendants(dataNode: T): T[] {
const startIndex = this.dataNodes.indexOf(dataNode);
const startIndex = this.indexOfNode(dataNode);
const results: T[] = [];

// Goes through flattened tree nodes in the `dataNodes` array, and get all descendants.
Expand Down Expand Up @@ -94,7 +98,7 @@ export class FlatTreeControl<T> extends BaseTreeControl<T> {
* an existing array; a fresh one is used when omitted.
*/
getParents(node: T, result: T[] = []): T[] {
const startIndex = this.dataNodes?.indexOf(node) ?? -1;
const startIndex = this.indexOfNode(node);

if (startIndex === -1) {
return result;
Expand Down Expand Up @@ -157,4 +161,29 @@ export class FlatTreeControl<T> extends BaseTreeControl<T> {
this.expansionModel.setSelection(...this.expandedItemsBeforeFiltration);
}
}

/** `dataNodes.indexOf(node)`, constant-time while the index matches the array. */
private indexOfNode(node: T): number {
const dataNodes = this.dataNodes;

if (!dataNodes) return -1;

const cached = this.indexedNodes === dataNodes ? this.nodeIndexes.get(node) : undefined;

// Checked against the array: `dataNodes` can be mutated in place.
if (cached !== undefined && dataNodes[cached] === node) return cached;

const index = dataNodes.indexOf(node);

// A node that is not there leaves the index as it is.
if (index === -1) return index;

this.indexedNodes = dataNodes;
this.nodeIndexes = new Map();
dataNodes.forEach((dataNode, position) => {
if (!this.nodeIndexes.has(dataNode)) this.nodeIndexes.set(dataNode, position);
});

return index;
}
}
38 changes: 32 additions & 6 deletions packages/components/tree/tree-option.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,10 @@ import {
InjectionToken,
input,
linkedSignal,
NgZone,
output,
QueryList,
Renderer2,
signal,
untracked,
viewChild,
Expand Down Expand Up @@ -73,6 +75,10 @@ export interface KbqTreeOptionParent<T = any> {
selectionModel: SelectionModel<T>;
unorderedOptions: QueryList<KbqTreeOption<T>>;
setSelectedOptionsByClick(option: KbqTreeOption<T>, 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. */
Expand Down Expand Up @@ -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)'
Expand All @@ -144,6 +149,8 @@ export class KbqTreeOption<T = any> extends KbqTreeNode<T> 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<T> & KbqTreeBase<T>;
Expand Down Expand Up @@ -384,6 +391,14 @@ export class KbqTreeOption<T = any> extends KbqTreeNode<T> 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 {
Expand Down Expand Up @@ -505,7 +520,10 @@ export class KbqTreeOption<T = any> extends KbqTreeNode<T> 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;
Expand Down Expand Up @@ -536,7 +554,10 @@ export class KbqTreeOption<T = any> extends KbqTreeNode<T> 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.
Expand Down Expand Up @@ -573,7 +594,8 @@ export class KbqTreeOption<T = any> extends KbqTreeNode<T> 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();
});
}
Expand All @@ -586,7 +608,8 @@ export class KbqTreeOption<T = any> extends KbqTreeNode<T> implements AfterConte

this.selectedState.set(false);

this.updateCheckboxState();
// Its own state only, as in `select`.
this.syncCheckboxState();
this.emitSelectionChangeEvent();
});
}
Expand Down Expand Up @@ -638,7 +661,10 @@ export class KbqTreeOption<T = any> extends KbqTreeNode<T> 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. */
Expand Down
Loading