Skip to content

Commit

Permalink
feat(module:cascader): support multiple selection (#8903)
Browse files Browse the repository at this point in the history
* feat(module:cascader): support multiple selection

* feat(module:cascader): support multiple selection

* feat(module:cascader): support multiple selection

* feat(module:cascader): support multiple selection - disabled

* feat(module:cascader): support multiple selection - showSearch

* feat(module:cascader): support multiple selection - showSearch

* feat(module:cascader): support multiple selection - default value

* feat(module:cascader): support multiple selection - load data

* feat(module:cascader): support multiple selection - lazy load

* feat(module:cascader): support multiple selection - search mode

* feat(module:cascader): update docs and remove useless code

* refactor(module:cascader): remove cdkOverlayOrigin wrapper

* refactor(module:cascader): remove nzSelect

* feat(module:cascader): click leaf node set check state in multiple mode

* fix(module:cascader): hide selected item when searching in single mode

* fix(module:cascader): quit searching when check in multiple mode
  • Loading branch information
Laffery authored Dec 4, 2024
1 parent 809155f commit e5dfb49
Show file tree
Hide file tree
Showing 25 changed files with 1,273 additions and 574 deletions.
4 changes: 2 additions & 2 deletions .github/CODEOWNERS
Validating CODEOWNERS rules …
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ components/tabs/** @hsuanxyz
components/breadcrumb/** @simplejason
components/empty/** @simplejason
components/carousel/** @simplejason
components/cascader/** @simplejason
components/cascader/** @laffery
components/descriptions/** @simplejason
components/icon/** @simplejason
components/message/** @simplejason
Expand Down Expand Up @@ -86,7 +86,7 @@ components/core/outlet/** @vthinkxie
components/core/time/** @wenqi73
components/core/trans-button/** @hsuanxyz
components/core/transition-patch/** @vthinkxie
components/core/tree/** @simplejason @hsuanxyz
components/core/tree/** @simplejason @laffery
components/core/wave/** @hsuanxyz

# Misc
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,36 +14,53 @@ import {
OnInit,
TemplateRef,
ViewEncapsulation,
numberAttribute
numberAttribute,
inject,
Output,
EventEmitter,
booleanAttribute
} from '@angular/core';

import { NzHighlightModule } from 'ng-zorro-antd/core/highlight';
import { NzOutletModule } from 'ng-zorro-antd/core/outlet';
import { NzTreeNode } from 'ng-zorro-antd/core/tree';
import { NzIconModule } from 'ng-zorro-antd/icon';

import { NzCascaderOption } from './typings';

@Component({
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
standalone: true,
selector: '[nz-cascader-option]',
exportAs: 'nzCascaderOption',
imports: [NgTemplateOutlet, NzHighlightModule, NzIconModule, NzOutletModule],
template: `
@if (checkable) {
<span
class="ant-cascader-checkbox"
[class.ant-cascader-checkbox-checked]="checked"
[class.ant-cascader-checkbox-indeterminate]="halfChecked"
[class.ant-cascader-checkbox-disabled]="disabled"
(click)="onCheckboxClick($event)"
>
<span class="ant-cascader-checkbox-inner"></span>
</span>
}
@if (optionTemplate) {
<ng-template
[ngTemplateOutlet]="optionTemplate"
[ngTemplateOutletContext]="{ $implicit: option, index: columnIndex }"
[ngTemplateOutletContext]="{ $implicit: node.origin, index: columnIndex }"
/>
} @else {
<div
class="ant-cascader-menu-item-content"
[innerHTML]="optionLabel | nzHighlight: highlightText : 'g' : 'ant-cascader-menu-item-keyword'"
[innerHTML]="node.title | nzHighlight: highlightText : 'g' : 'ant-cascader-menu-item-keyword'"
></div>
}
@if (!option.isLeaf || option.children?.length || option.loading) {
@if (!node.isLeaf || node.children?.length || node.isLoading) {
<div class="ant-cascader-menu-item-expand-icon">
@if (option.loading) {
@if (node.isLoading) {
<span nz-icon nzType="loading"></span>
} @else {
<ng-container *nzStringTemplateOutlet="expandIcon">
Expand All @@ -55,32 +72,31 @@ import { NzCascaderOption } from './typings';
`,
host: {
class: 'ant-cascader-menu-item ant-cascader-menu-item-expanded',
'[attr.title]': 'option.title || optionLabel',
'[attr.title]': 'node.title',
'[class.ant-cascader-menu-item-active]': 'activated',
'[class.ant-cascader-menu-item-expand]': '!option.isLeaf',
'[class.ant-cascader-menu-item-disabled]': 'option.disabled'
'[class.ant-cascader-menu-item-expand]': '!node.isLeaf',
'[class.ant-cascader-menu-item-disabled]': 'node.isDisabled'
},
imports: [NgTemplateOutlet, NzHighlightModule, NzIconModule, NzOutletModule],
standalone: true
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None
})
export class NzCascaderOptionComponent implements OnInit {
@Input() optionTemplate: TemplateRef<NzCascaderOption> | null = null;
@Input() option!: NzCascaderOption;
@Input() node!: NzTreeNode;
@Input() activated = false;
@Input() highlightText!: string;
@Input() nzLabelProperty = 'label';
@Input({ transform: numberAttribute }) columnIndex!: number;
@Input() expandIcon: string | TemplateRef<void> = '';
@Input() dir: Direction = 'ltr';
@Input({ transform: booleanAttribute }) checkable?: boolean = false;

readonly nativeElement: HTMLElement;
@Output() readonly check = new EventEmitter<void>();

public readonly nativeElement: HTMLElement = inject(ElementRef).nativeElement;

constructor(private cdr: ChangeDetectorRef) {}

constructor(
private cdr: ChangeDetectorRef,
elementRef: ElementRef
) {
this.nativeElement = elementRef.nativeElement;
}
ngOnInit(): void {
if (this.expandIcon === '' && this.dir === 'rtl') {
this.expandIcon = 'left';
Expand All @@ -89,11 +105,28 @@ export class NzCascaderOptionComponent implements OnInit {
}
}

get optionLabel(): string {
return this.option[this.nzLabelProperty];
get checked(): boolean {
return this.node.isChecked;
}

get halfChecked(): boolean {
return this.node.isHalfChecked;
}

get disabled(): boolean {
return this.node.isDisabled || this.node.isDisableCheckbox;
}

markForCheck(): void {
this.cdr.markForCheck();
}

onCheckboxClick(event: MouseEvent): void {
event.preventDefault();
event.stopPropagation();
if (!this.checkable) {
return;
}
this.check.emit();
}
}
170 changes: 170 additions & 0 deletions components/cascader/cascader-tree.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
/**
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/LICENSE
*/

import { Injectable } from '@angular/core';

import { NzTreeBaseService, NzTreeNode, NzTreeNodeKey } from 'ng-zorro-antd/core/tree';
import { NzSafeAny } from 'ng-zorro-antd/core/types';
import { arraysEqual, isNotNil } from 'ng-zorro-antd/core/util';

import { NzCascaderOption } from './typings';

interface InternalFieldNames {
label: string;
value: string;
}

@Injectable()
export class NzCascaderTreeService extends NzTreeBaseService {
fieldNames: InternalFieldNames = {
label: 'label',
value: 'value'
};
missingNodeList: NzTreeNode[] = [];

override treeNodePostProcessor = (node: NzTreeNode): void => {
node.key = this.getOptionValue(node);
node.title = this.getOptionLabel(node);
};

getOptionValue(node: NzTreeNode): NzSafeAny {
return node.origin[this.fieldNames.value || 'value'];
}

getOptionLabel(node: NzTreeNode): string {
return node.origin[this.fieldNames.label || 'label'];
}

get children(): NzTreeNode[] {
return this.rootNodes;
}

set children(value: Array<NzTreeNode | NzSafeAny>) {
this.rootNodes = value.map(v => (v instanceof NzTreeNode ? v : new NzTreeNode(v, null)));
}

constructor() {
super();
}

/**
* Map list of nodes to list of option
*/
toOptions(nodes: NzTreeNode[]): NzCascaderOption[] {
return nodes.map(node => node.origin);
}

getAncestorNodeList(node: NzTreeNode | null): NzTreeNode[] {
if (!node) {
return [];
}
if (node.parentNode) {
return [...this.getAncestorNodeList(node.parentNode), node];
}
return [node];
}

/**
* Render by nzCheckedKeys
* When keys equals null, just render with checkStrictly
*
* @param paths
* @param checkStrictly
*/
conductCheckPaths(paths: NzTreeNodeKey[][] | null, checkStrictly: boolean): void {
this.checkedNodeList = [];
this.halfCheckedNodeList = [];
this.missingNodeList = [];
const existsPathList: NzTreeNodeKey[][] = [];
const calc = (nodes: NzTreeNode[]): void => {
nodes.forEach(node => {
if (paths === null) {
// render tree if no default checked keys found
node.isChecked = !!node.origin.checked;
} else {
// if node is in checked path
const nodePath = this.getAncestorNodeList(node).map(n => this.getOptionValue(n));
if (paths.some(keys => arraysEqual(nodePath, keys))) {
node.isChecked = true;
node.isHalfChecked = false;
existsPathList.push(nodePath);
} else {
node.isChecked = false;
node.isHalfChecked = false;
}
}
if (node.children.length > 0) {
calc(node.children);
}
});
};
calc(this.rootNodes);
this.refreshCheckState(checkStrictly);
this.missingNodeList = this.getMissingNodeList(paths, existsPathList);
}

conductSelectedPaths(paths: NzTreeNodeKey[][], isMulti: boolean): void {
this.selectedNodeList.forEach(node => (node.isSelected = false));
this.selectedNodeList = [];
this.missingNodeList = [];
const existsPathList: NzTreeNodeKey[][] = [];
const calc = (nodes: NzTreeNode[]): boolean =>
nodes.every(node => {
// if node is in selected path
const nodePath = this.getAncestorNodeList(node).map(n => this.getOptionValue(n));
if (paths.some(keys => arraysEqual(nodePath, keys))) {
node.isSelected = true;
this.setSelectedNodeList(node);
existsPathList.push(nodePath);
if (!isMulti) {
// if not support multi select
return false;
}
} else {
node.isSelected = false;
}
if (node.children.length > 0) {
// Recursion
return calc(node.children);
}
return true;
});
calc(this.rootNodes);
this.missingNodeList = this.getMissingNodeList(paths, existsPathList);
}

private getMissingNodeList(paths: NzTreeNodeKey[][] | null, existsPathList: NzTreeNodeKey[][]): NzTreeNode[] {
if (!paths) {
return [];
}
return paths
.filter(path => !existsPathList.some(keys => arraysEqual(path, keys)))
.map(path => this.createMissingNode(path))
.filter(isNotNil);
}

private createMissingNode(path: NzTreeNodeKey[]): NzTreeNode | null {
if (!path?.length) {
return null;
}

const createOption = (key: NzTreeNodeKey): NzSafeAny => {
return {
[this.fieldNames.value || 'value']: key,
[this.fieldNames.label || 'label']: key
};
};

let node = new NzTreeNode(createOption(path[0]), null, this);

for (let i = 1; i < path.length; i++) {
const childNode = new NzTreeNode(createOption(path[i]));
node.addChildren([childNode]);
node = childNode;
}

return node;
}
}
Loading

0 comments on commit e5dfb49

Please sign in to comment.