Skip to content

Commit e5dfb49

Browse files
authored
feat(module:cascader): support multiple selection (#8903)
* 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
1 parent 809155f commit e5dfb49

25 files changed

+1273
-574
lines changed

.github/CODEOWNERS

+2-2
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ components/tabs/** @hsuanxyz
2222
components/breadcrumb/** @simplejason
2323
components/empty/** @simplejason
2424
components/carousel/** @simplejason
25-
components/cascader/** @simplejason
25+
components/cascader/** @laffery
2626
components/descriptions/** @simplejason
2727
components/icon/** @simplejason
2828
components/message/** @simplejason
@@ -86,7 +86,7 @@ components/core/outlet/** @vthinkxie
8686
components/core/time/** @wenqi73
8787
components/core/trans-button/** @hsuanxyz
8888
components/core/transition-patch/** @vthinkxie
89-
components/core/tree/** @simplejason @hsuanxyz
89+
components/core/tree/** @simplejason @laffery
9090
components/core/wave/** @hsuanxyz
9191

9292
# Misc

components/cascader/cascader-li.component.ts components/cascader/cascader-option.component.ts

+55-22
Original file line numberDiff line numberDiff line change
@@ -14,36 +14,53 @@ import {
1414
OnInit,
1515
TemplateRef,
1616
ViewEncapsulation,
17-
numberAttribute
17+
numberAttribute,
18+
inject,
19+
Output,
20+
EventEmitter,
21+
booleanAttribute
1822
} from '@angular/core';
1923

2024
import { NzHighlightModule } from 'ng-zorro-antd/core/highlight';
2125
import { NzOutletModule } from 'ng-zorro-antd/core/outlet';
26+
import { NzTreeNode } from 'ng-zorro-antd/core/tree';
2227
import { NzIconModule } from 'ng-zorro-antd/icon';
2328

2429
import { NzCascaderOption } from './typings';
2530

2631
@Component({
27-
changeDetection: ChangeDetectionStrategy.OnPush,
28-
encapsulation: ViewEncapsulation.None,
32+
standalone: true,
2933
selector: '[nz-cascader-option]',
3034
exportAs: 'nzCascaderOption',
35+
imports: [NgTemplateOutlet, NzHighlightModule, NzIconModule, NzOutletModule],
3136
template: `
37+
@if (checkable) {
38+
<span
39+
class="ant-cascader-checkbox"
40+
[class.ant-cascader-checkbox-checked]="checked"
41+
[class.ant-cascader-checkbox-indeterminate]="halfChecked"
42+
[class.ant-cascader-checkbox-disabled]="disabled"
43+
(click)="onCheckboxClick($event)"
44+
>
45+
<span class="ant-cascader-checkbox-inner"></span>
46+
</span>
47+
}
48+
3249
@if (optionTemplate) {
3350
<ng-template
3451
[ngTemplateOutlet]="optionTemplate"
35-
[ngTemplateOutletContext]="{ $implicit: option, index: columnIndex }"
52+
[ngTemplateOutletContext]="{ $implicit: node.origin, index: columnIndex }"
3653
/>
3754
} @else {
3855
<div
3956
class="ant-cascader-menu-item-content"
40-
[innerHTML]="optionLabel | nzHighlight: highlightText : 'g' : 'ant-cascader-menu-item-keyword'"
57+
[innerHTML]="node.title | nzHighlight: highlightText : 'g' : 'ant-cascader-menu-item-keyword'"
4158
></div>
4259
}
4360
44-
@if (!option.isLeaf || option.children?.length || option.loading) {
61+
@if (!node.isLeaf || node.children?.length || node.isLoading) {
4562
<div class="ant-cascader-menu-item-expand-icon">
46-
@if (option.loading) {
63+
@if (node.isLoading) {
4764
<span nz-icon nzType="loading"></span>
4865
} @else {
4966
<ng-container *nzStringTemplateOutlet="expandIcon">
@@ -55,32 +72,31 @@ import { NzCascaderOption } from './typings';
5572
`,
5673
host: {
5774
class: 'ant-cascader-menu-item ant-cascader-menu-item-expanded',
58-
'[attr.title]': 'option.title || optionLabel',
75+
'[attr.title]': 'node.title',
5976
'[class.ant-cascader-menu-item-active]': 'activated',
60-
'[class.ant-cascader-menu-item-expand]': '!option.isLeaf',
61-
'[class.ant-cascader-menu-item-disabled]': 'option.disabled'
77+
'[class.ant-cascader-menu-item-expand]': '!node.isLeaf',
78+
'[class.ant-cascader-menu-item-disabled]': 'node.isDisabled'
6279
},
63-
imports: [NgTemplateOutlet, NzHighlightModule, NzIconModule, NzOutletModule],
64-
standalone: true
80+
changeDetection: ChangeDetectionStrategy.OnPush,
81+
encapsulation: ViewEncapsulation.None
6582
})
6683
export class NzCascaderOptionComponent implements OnInit {
6784
@Input() optionTemplate: TemplateRef<NzCascaderOption> | null = null;
68-
@Input() option!: NzCascaderOption;
85+
@Input() node!: NzTreeNode;
6986
@Input() activated = false;
7087
@Input() highlightText!: string;
7188
@Input() nzLabelProperty = 'label';
7289
@Input({ transform: numberAttribute }) columnIndex!: number;
7390
@Input() expandIcon: string | TemplateRef<void> = '';
7491
@Input() dir: Direction = 'ltr';
92+
@Input({ transform: booleanAttribute }) checkable?: boolean = false;
7593

76-
readonly nativeElement: HTMLElement;
94+
@Output() readonly check = new EventEmitter<void>();
95+
96+
public readonly nativeElement: HTMLElement = inject(ElementRef).nativeElement;
97+
98+
constructor(private cdr: ChangeDetectorRef) {}
7799

78-
constructor(
79-
private cdr: ChangeDetectorRef,
80-
elementRef: ElementRef
81-
) {
82-
this.nativeElement = elementRef.nativeElement;
83-
}
84100
ngOnInit(): void {
85101
if (this.expandIcon === '' && this.dir === 'rtl') {
86102
this.expandIcon = 'left';
@@ -89,11 +105,28 @@ export class NzCascaderOptionComponent implements OnInit {
89105
}
90106
}
91107

92-
get optionLabel(): string {
93-
return this.option[this.nzLabelProperty];
108+
get checked(): boolean {
109+
return this.node.isChecked;
110+
}
111+
112+
get halfChecked(): boolean {
113+
return this.node.isHalfChecked;
114+
}
115+
116+
get disabled(): boolean {
117+
return this.node.isDisabled || this.node.isDisableCheckbox;
94118
}
95119

96120
markForCheck(): void {
97121
this.cdr.markForCheck();
98122
}
123+
124+
onCheckboxClick(event: MouseEvent): void {
125+
event.preventDefault();
126+
event.stopPropagation();
127+
if (!this.checkable) {
128+
return;
129+
}
130+
this.check.emit();
131+
}
99132
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
/**
2+
* Use of this source code is governed by an MIT-style license that can be
3+
* found in the LICENSE file at https://github.com/NG-ZORRO/ng-zorro-antd/blob/master/LICENSE
4+
*/
5+
6+
import { Injectable } from '@angular/core';
7+
8+
import { NzTreeBaseService, NzTreeNode, NzTreeNodeKey } from 'ng-zorro-antd/core/tree';
9+
import { NzSafeAny } from 'ng-zorro-antd/core/types';
10+
import { arraysEqual, isNotNil } from 'ng-zorro-antd/core/util';
11+
12+
import { NzCascaderOption } from './typings';
13+
14+
interface InternalFieldNames {
15+
label: string;
16+
value: string;
17+
}
18+
19+
@Injectable()
20+
export class NzCascaderTreeService extends NzTreeBaseService {
21+
fieldNames: InternalFieldNames = {
22+
label: 'label',
23+
value: 'value'
24+
};
25+
missingNodeList: NzTreeNode[] = [];
26+
27+
override treeNodePostProcessor = (node: NzTreeNode): void => {
28+
node.key = this.getOptionValue(node);
29+
node.title = this.getOptionLabel(node);
30+
};
31+
32+
getOptionValue(node: NzTreeNode): NzSafeAny {
33+
return node.origin[this.fieldNames.value || 'value'];
34+
}
35+
36+
getOptionLabel(node: NzTreeNode): string {
37+
return node.origin[this.fieldNames.label || 'label'];
38+
}
39+
40+
get children(): NzTreeNode[] {
41+
return this.rootNodes;
42+
}
43+
44+
set children(value: Array<NzTreeNode | NzSafeAny>) {
45+
this.rootNodes = value.map(v => (v instanceof NzTreeNode ? v : new NzTreeNode(v, null)));
46+
}
47+
48+
constructor() {
49+
super();
50+
}
51+
52+
/**
53+
* Map list of nodes to list of option
54+
*/
55+
toOptions(nodes: NzTreeNode[]): NzCascaderOption[] {
56+
return nodes.map(node => node.origin);
57+
}
58+
59+
getAncestorNodeList(node: NzTreeNode | null): NzTreeNode[] {
60+
if (!node) {
61+
return [];
62+
}
63+
if (node.parentNode) {
64+
return [...this.getAncestorNodeList(node.parentNode), node];
65+
}
66+
return [node];
67+
}
68+
69+
/**
70+
* Render by nzCheckedKeys
71+
* When keys equals null, just render with checkStrictly
72+
*
73+
* @param paths
74+
* @param checkStrictly
75+
*/
76+
conductCheckPaths(paths: NzTreeNodeKey[][] | null, checkStrictly: boolean): void {
77+
this.checkedNodeList = [];
78+
this.halfCheckedNodeList = [];
79+
this.missingNodeList = [];
80+
const existsPathList: NzTreeNodeKey[][] = [];
81+
const calc = (nodes: NzTreeNode[]): void => {
82+
nodes.forEach(node => {
83+
if (paths === null) {
84+
// render tree if no default checked keys found
85+
node.isChecked = !!node.origin.checked;
86+
} else {
87+
// if node is in checked path
88+
const nodePath = this.getAncestorNodeList(node).map(n => this.getOptionValue(n));
89+
if (paths.some(keys => arraysEqual(nodePath, keys))) {
90+
node.isChecked = true;
91+
node.isHalfChecked = false;
92+
existsPathList.push(nodePath);
93+
} else {
94+
node.isChecked = false;
95+
node.isHalfChecked = false;
96+
}
97+
}
98+
if (node.children.length > 0) {
99+
calc(node.children);
100+
}
101+
});
102+
};
103+
calc(this.rootNodes);
104+
this.refreshCheckState(checkStrictly);
105+
this.missingNodeList = this.getMissingNodeList(paths, existsPathList);
106+
}
107+
108+
conductSelectedPaths(paths: NzTreeNodeKey[][], isMulti: boolean): void {
109+
this.selectedNodeList.forEach(node => (node.isSelected = false));
110+
this.selectedNodeList = [];
111+
this.missingNodeList = [];
112+
const existsPathList: NzTreeNodeKey[][] = [];
113+
const calc = (nodes: NzTreeNode[]): boolean =>
114+
nodes.every(node => {
115+
// if node is in selected path
116+
const nodePath = this.getAncestorNodeList(node).map(n => this.getOptionValue(n));
117+
if (paths.some(keys => arraysEqual(nodePath, keys))) {
118+
node.isSelected = true;
119+
this.setSelectedNodeList(node);
120+
existsPathList.push(nodePath);
121+
if (!isMulti) {
122+
// if not support multi select
123+
return false;
124+
}
125+
} else {
126+
node.isSelected = false;
127+
}
128+
if (node.children.length > 0) {
129+
// Recursion
130+
return calc(node.children);
131+
}
132+
return true;
133+
});
134+
calc(this.rootNodes);
135+
this.missingNodeList = this.getMissingNodeList(paths, existsPathList);
136+
}
137+
138+
private getMissingNodeList(paths: NzTreeNodeKey[][] | null, existsPathList: NzTreeNodeKey[][]): NzTreeNode[] {
139+
if (!paths) {
140+
return [];
141+
}
142+
return paths
143+
.filter(path => !existsPathList.some(keys => arraysEqual(path, keys)))
144+
.map(path => this.createMissingNode(path))
145+
.filter(isNotNil);
146+
}
147+
148+
private createMissingNode(path: NzTreeNodeKey[]): NzTreeNode | null {
149+
if (!path?.length) {
150+
return null;
151+
}
152+
153+
const createOption = (key: NzTreeNodeKey): NzSafeAny => {
154+
return {
155+
[this.fieldNames.value || 'value']: key,
156+
[this.fieldNames.label || 'label']: key
157+
};
158+
};
159+
160+
let node = new NzTreeNode(createOption(path[0]), null, this);
161+
162+
for (let i = 1; i < path.length; i++) {
163+
const childNode = new NzTreeNode(createOption(path[i]));
164+
node.addChildren([childNode]);
165+
node = childNode;
166+
}
167+
168+
return node;
169+
}
170+
}

0 commit comments

Comments
 (0)