diff --git a/src/cascader/cascader.component.html b/src/cascader/cascader.component.html
new file mode 100644
index 000000000..fd88d8468
--- /dev/null
+++ b/src/cascader/cascader.component.html
@@ -0,0 +1,102 @@
+
+
+
+
+
diff --git a/src/cascader/cascader.component.scss b/src/cascader/cascader.component.scss
new file mode 100644
index 000000000..2b15facef
--- /dev/null
+++ b/src/cascader/cascader.component.scss
@@ -0,0 +1,105 @@
+@import '../theme/var';
+@import '../theme/mixin';
+
+.aui-cascader {
+ display: inline-block;
+ position: relative;
+ width: 100%;
+
+ @include input-field-indicator;
+
+ &__label-container.aui-input {
+ position: absolute;
+ top: 0;
+ left: 0;
+ display: inline-flex;
+ align-items: center;
+ pointer-events: none;
+ background-color: transparent;
+ border-color: transparent;
+
+ &[readonly] {
+ pointer-events: auto;
+ }
+ }
+
+ &.isFilterable.isOpened &__label-container.aui-input {
+ color: use-rgb(n-4);
+ }
+
+ &__label {
+ width: 100%;
+ @include text-overflow;
+ }
+
+ &__input-inaction {
+ background-color: use-rgb(main-bg);
+ border-color: use-rgb(n-7) !important;
+ cursor: text;
+
+ &:focus {
+ border-color: use-rgb(primary);
+ }
+ }
+
+ &__input[disabled] {
+ background-color: use-rgb(n-8);
+ border-color: use-rgb(n-7);
+ }
+}
+
+aui-cascader.ng-invalid.ng-dirty,
+.ng-submitted aui-cascader.ng-invalid {
+ .aui-input {
+ @include input-error;
+ }
+}
+
+.aui-cascader-option-container {
+ padding: 8px 0;
+ max-width: 90vw;
+ border-radius: use-var(border-radius-m);
+ background-color: use-rgb(popper-bg);
+ @include popper-shadow;
+
+ &__content {
+ display: flex;
+ flex-direction: row;
+ max-height: calc(#{use-var(inline-height-m)} * 10);
+ position: relative;
+ overflow: auto;
+
+ @include scroll-bar;
+ }
+
+ &__search-content {
+ max-height: calc(#{use-var(inline-height-m)} * 10);
+ position: relative;
+ overflow: auto;
+
+ @include scroll-bar;
+ }
+
+ &__placeholder {
+ color: use-rgb(n-4);
+ font-size: use-var(font-size-m);
+ text-align: center;
+ display: inline-block;
+ position: relative;
+ width: 100%;
+ }
+
+ &__content &__column {
+ min-width: 92px;
+ max-width: 246px;
+ }
+
+ &__search-content &__column {
+ min-width: 0;
+ max-width: 90vw;
+ }
+
+ &__column + &__column {
+ border-left: 1px solid use-rgb(border);
+ }
+}
diff --git a/src/cascader/cascader.component.ts b/src/cascader/cascader.component.ts
new file mode 100644
index 000000000..b11130ed1
--- /dev/null
+++ b/src/cascader/cascader.component.ts
@@ -0,0 +1,331 @@
+import { NgIf, NgTemplateOutlet, NgFor } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ElementRef,
+ EventEmitter,
+ Input,
+ Output,
+ ViewChild,
+ ViewEncapsulation,
+ computed,
+ forwardRef,
+ signal,
+} from '@angular/core';
+import { toSignal } from '@angular/core/rxjs-interop';
+import { NG_VALUE_ACCESSOR } from '@angular/forms';
+import { Observable, from } from 'rxjs';
+
+import { CommonFormControl } from '../form';
+import { IconComponent } from '../icon';
+import {
+ InputComponent,
+ InputSuffixDirective,
+ InputGroupComponent,
+} from '../input';
+import { ComponentSize } from '../internal/types';
+import { coerceAttrBoolean } from '../internal/utils';
+import { TooltipDirective } from '../tooltip';
+
+import { CascaderOption, SearchedCascaderOption } from './cascader.types';
+import { CascaderOptionComponent } from './option/option.component';
+import {
+ dropRestItems,
+ isParentOption,
+ searchCascadeOptions,
+ trackByOption,
+ trackByOptions,
+} from './utils';
+
+@Component({
+ selector: 'aui-cascader',
+ templateUrl: 'cascader.component.html',
+ styleUrls: ['cascader.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
+ preserveWhitespaces: false,
+ providers: [
+ {
+ provide: NG_VALUE_ACCESSOR,
+ useExisting: forwardRef(() => CascaderComponent),
+ multi: true,
+ },
+ ],
+ standalone: true,
+ imports: [
+ CascaderOptionComponent,
+ TooltipDirective,
+ InputGroupComponent,
+ InputComponent,
+ InputSuffixDirective,
+ IconComponent,
+ NgIf,
+ NgTemplateOutlet,
+ NgFor,
+ ],
+})
+export class CascaderComponent extends CommonFormControl {
+ @Input()
+ get size() {
+ return this.$$size();
+ }
+
+ set size(val) {
+ if (!val || this.$$size() === val) {
+ return;
+ }
+ this.$$size.set(val);
+ }
+
+ @Input({ transform: coerceAttrBoolean })
+ filterable = true;
+
+ @Input({ transform: coerceAttrBoolean })
+ clearable: boolean;
+
+ @Input({ transform: coerceAttrBoolean })
+ onlyShowLastLabel = false;
+
+ @Input({ transform: coerceAttrBoolean })
+ changeOnSelect = false;
+
+ @Input()
+ loading = false;
+
+ @Input()
+ placeholder = '';
+
+ @Input()
+ loadData: (
+ selectedOption: CascaderOption,
+ ) =>
+ | PromiseLike>>
+ | Observable>>;
+
+ @Input()
+ get options() {
+ return this.$$options();
+ }
+
+ set options(val) {
+ this.$$options.set(val);
+ this.$$columns.set([val]);
+ }
+
+ @ViewChild('inputRef', { static: true })
+ inputRef: InputComponent;
+
+ @ViewChild('tooltipRef', { static: true })
+ private readonly tooltipRef: TooltipDirective;
+
+ @ViewChild('selectRef', { static: true })
+ protected selectRef: ElementRef;
+
+ @Output()
+ filterChange = new EventEmitter();
+
+ get rootClass() {
+ return `aui-cascader aui-cascader--${this.$$size()}`;
+ }
+
+ get containerClass() {
+ return `aui-cascader-option-container aui-cascader-option-container--${this.$$size()}`;
+ }
+
+ get opened() {
+ return this.tooltipRef.isCreated;
+ }
+
+ get inaction() {
+ return !(this.filterable && this.opened);
+ }
+
+ get filterString() {
+ return this.$$filterString();
+ }
+
+ set filterString(val) {
+ if (val !== this.$$filterString()) {
+ this.$$filterString.set(val);
+ this.filterChange.emit(val);
+ }
+ }
+
+ trackByOptions = trackByOptions;
+ trackByOption = trackByOption;
+
+ $$size = signal(ComponentSize.Medium);
+ $$filterString = signal('');
+ $$options = signal>>([]);
+ $$columns = signal>>>([]);
+
+ $model = toSignal(this.model$);
+
+ $selectedOptions = computed(() => {
+ const selectedOptions: Array> = [];
+ let currentColumnOptions = this.$$options();
+ this.$model()?.forEach(value => {
+ const currentColumnOption = currentColumnOptions?.find(
+ option => option.value === value,
+ );
+ selectedOptions.push(currentColumnOption);
+ currentColumnOptions = currentColumnOption.children;
+ });
+ return selectedOptions;
+ });
+
+ $searchedOptions = computed(() =>
+ this.$inSearching()
+ ? searchCascadeOptions(this.$$options(), this.$$filterString())
+ : [],
+ );
+
+ $optionsVisible = computed(
+ () =>
+ this.$$options().length > 0 &&
+ (!this.$$filterString() || this.$searchedOptions().length > 0),
+ );
+
+ $hasSelected = computed(() => this.$selectedOptions().length > 0);
+ $inSearching = computed(() => !!this.$$filterString());
+
+ $containerWidth = computed(() =>
+ this.$$options().length === 0 || !!this.$$filterString()
+ ? this.selectRef.nativeElement.offsetWidth + 'px'
+ : null,
+ );
+
+ $OptionsContentClass = computed(() =>
+ this.$inSearching()
+ ? 'aui-cascader-option-container__search-content'
+ : 'aui-cascader-option-container__content',
+ );
+
+ $visibleColumns = computed(() =>
+ this.$searchedOptions().length > 0
+ ? [this.$searchedOptions()]
+ : this.$$columns(),
+ );
+
+ activatedOptions: Array> = [];
+
+ clearValue(event: Event) {
+ this.emitValue(null);
+ event.stopPropagation();
+ event.preventDefault();
+ }
+
+ closeOption() {
+ this.tooltipRef.hide();
+ }
+
+ onOptionsVisibleChange(visible: boolean) {
+ if (!visible) {
+ this.filterString = '';
+ this.inputRef.elementRef.nativeElement.value = '';
+ this.activatedOptions = [];
+ this.$$columns.set([]);
+ return;
+ }
+
+ this.activatedOptions = [...this.$selectedOptions()];
+ this.$$columns.set(
+ [this.options].concat(
+ this.activatedOptions.map(option => option.children).filter(Boolean),
+ ),
+ );
+ }
+
+ onOptionClick(
+ option: CascaderOption | SearchedCascaderOption,
+ columnIndex: number,
+ ) {
+ if (option.disabled) {
+ return;
+ }
+ this.$inSearching()
+ ? this.onClickSearchedOption(option as SearchedCascaderOption)
+ : this.onClickOption(option as CascaderOption, columnIndex);
+ }
+
+ isOptionActivated(
+ option: CascaderOption | SearchedCascaderOption,
+ columnIndex: number,
+ ) {
+ return this.$inSearching()
+ ? this.checkSearchedOptionActivated(
+ option as SearchedCascaderOption,
+ )
+ : this.checkOptionActivated(option as CascaderOption, columnIndex);
+ }
+
+ onInput(event: Event) {
+ this.filterString = (event.target as HTMLInputElement).value;
+ }
+
+ private checkOptionActivated(option: CascaderOption, columnIndex: number) {
+ return this.activatedOptions[columnIndex] === option;
+ }
+
+ private checkSearchedOptionActivated(
+ searchedOption: SearchedCascaderOption,
+ ) {
+ return (
+ searchedOption.path.length === this.activatedOptions.length &&
+ searchedOption.path.every(
+ (option, index) => option === this.activatedOptions[index],
+ )
+ );
+ }
+
+ private onClickOption(option: CascaderOption, columnIndex: number) {
+ if (this.checkOptionActivated(option, columnIndex)) {
+ return;
+ }
+ this.activatedOptions[columnIndex] = option;
+
+ const isParent = isParentOption(option);
+ const performChange = !isParent || this.changeOnSelect;
+
+ if (isParent && option.children?.length > 0) {
+ this.setColumnsData(option, columnIndex);
+ } else if (isParent && !!this.loadData) {
+ // 需要懒加载 children 的节点需要显式的声明 isLeaf: false
+ option.loading = true;
+ from(this.loadData(option)).subscribe(childrenOptions => {
+ option.loading = false;
+ option.children = childrenOptions;
+ this.setColumnsData(option, columnIndex);
+ });
+ } else {
+ this.activatedOptions = dropRestItems(this.activatedOptions, columnIndex);
+ this.closeOption();
+ }
+
+ if (performChange) {
+ this.emitValue(
+ this.activatedOptions.map(activatedOption => activatedOption.value),
+ );
+ }
+ }
+
+ private onClickSearchedOption(
+ searchedOption: SearchedCascaderOption,
+ ) {
+ if (this.checkSearchedOptionActivated(searchedOption)) {
+ return;
+ }
+ this.activatedOptions = [...searchedOption.path];
+ this.closeOption();
+ this.emitValue(
+ this.activatedOptions.map(activatedOption => activatedOption.value),
+ );
+ }
+
+ private setColumnsData(option: CascaderOption, columnIndex: number) {
+ this.$$columns.update(columns => {
+ columns[columnIndex + 1] = option.children;
+ return dropRestItems(columns, columnIndex + 1);
+ });
+ }
+}
diff --git a/src/cascader/cascader.module.ts b/src/cascader/cascader.module.ts
new file mode 100644
index 000000000..96ac82f13
--- /dev/null
+++ b/src/cascader/cascader.module.ts
@@ -0,0 +1,18 @@
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+
+import { IconModule } from '../icon';
+
+import { CascaderComponent } from './cascader.component';
+import { CascaderOptionComponent } from './option/option.component';
+
+@NgModule({
+ imports: [
+ CommonModule,
+ IconModule,
+ CascaderComponent,
+ CascaderOptionComponent,
+ ],
+ exports: [CascaderComponent, CascaderOptionComponent],
+})
+export class CascaderModule {}
diff --git a/src/cascader/cascader.types.ts b/src/cascader/cascader.types.ts
new file mode 100644
index 000000000..ea7882f77
--- /dev/null
+++ b/src/cascader/cascader.types.ts
@@ -0,0 +1,13 @@
+export interface CascaderOption {
+ label: string;
+ value: T;
+ disabled?: boolean;
+ loading?: boolean;
+ isLeaf?: boolean;
+ children?: Array>;
+}
+
+export interface SearchedCascaderOption
+ extends CascaderOption {
+ path: Array>;
+}
diff --git a/src/cascader/index.ts b/src/cascader/index.ts
new file mode 100644
index 000000000..faf6721fd
--- /dev/null
+++ b/src/cascader/index.ts
@@ -0,0 +1,4 @@
+export * from './cascader.component';
+export * from './cascader.module';
+export * from './cascader.types';
+export * from './option/option.component';
diff --git a/src/cascader/option-placeholder.component.ts b/src/cascader/option-placeholder.component.ts
new file mode 100644
index 000000000..09e291d50
--- /dev/null
+++ b/src/cascader/option-placeholder.component.ts
@@ -0,0 +1,15 @@
+import {
+ ChangeDetectionStrategy,
+ Component,
+ ViewEncapsulation,
+} from '@angular/core';
+
+@Component({
+ selector: 'aui-cascader-option-placeholder',
+ template: ``,
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
+ preserveWhitespaces: false,
+ standalone: true,
+})
+export class CascaderOptionPlaceholderComponent {}
diff --git a/src/cascader/option/option.component.scss b/src/cascader/option/option.component.scss
new file mode 100644
index 000000000..91f7c694d
--- /dev/null
+++ b/src/cascader/option/option.component.scss
@@ -0,0 +1,38 @@
+@import '../../theme/var';
+@import '../../theme/mixin';
+
+.aui-cascader-option {
+ cursor: pointer;
+ padding: 4px 12px;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ &__icon {
+ margin-left: 24px;
+ display: flex;
+ align-items: center;
+ }
+
+ &__label {
+ @include text-overflow;
+ }
+
+ &.isDisabled {
+ color: use-rgb(n-6);
+ cursor: not-allowed;
+ }
+
+ &:hover,
+ &.isActivated {
+ background-color: use-rgb(p-6);
+ }
+
+ &.isActivated {
+ color: use-rgb(primary);
+ }
+
+ &.isActivated &__icon {
+ color: use-rgb(n-1);
+ }
+}
diff --git a/src/cascader/option/option.component.ts b/src/cascader/option/option.component.ts
new file mode 100644
index 000000000..9f28663de
--- /dev/null
+++ b/src/cascader/option/option.component.ts
@@ -0,0 +1,64 @@
+import { NgIf, NgTemplateOutlet, NgFor, AsyncPipe } from '@angular/common';
+import {
+ ChangeDetectionStrategy,
+ Component,
+ HostBinding,
+ Input,
+ ViewEncapsulation,
+ computed,
+ signal,
+} from '@angular/core';
+
+import { IconComponent } from '../../icon';
+import { Bem, buildBem } from '../../internal/utils';
+import { CascaderOption } from '../cascader.types';
+import { isParentOption } from '../utils';
+
+@Component({
+ selector: 'aui-cascader-option',
+ template: `
+
+ {{ option.label }}
+
+
+ `,
+ styleUrls: ['option.component.scss'],
+ changeDetection: ChangeDetectionStrategy.OnPush,
+ encapsulation: ViewEncapsulation.None,
+ preserveWhitespaces: false,
+ standalone: true,
+ imports: [NgIf, NgTemplateOutlet, NgFor, AsyncPipe, IconComponent],
+})
+export class CascaderOptionComponent {
+ bem: Bem = buildBem('aui-cascader-option');
+
+ @Input()
+ get option() {
+ return this.$$option();
+ }
+
+ set option(val) {
+ this.$$option.set(val);
+ }
+
+ @Input() activated = false;
+
+ @HostBinding('class')
+ className = this.bem.block();
+
+ @HostBinding('class.isDisabled') get isDisabled() {
+ return this.option?.disabled;
+ }
+
+ @HostBinding('class.isActivated') get isActivated() {
+ return this.activated;
+ }
+
+ $$option = signal>(null);
+
+ $isParent = computed(() => isParentOption(this.$$option()));
+}
diff --git a/src/cascader/utils.ts b/src/cascader/utils.ts
new file mode 100644
index 000000000..010e1acd8
--- /dev/null
+++ b/src/cascader/utils.ts
@@ -0,0 +1,63 @@
+import { CascaderOption, SearchedCascaderOption } from './cascader.types';
+
+export function isParentOption(option: CascaderOption): boolean {
+ return (
+ (option.children && !!option.children.length) || option.isLeaf === false
+ );
+}
+
+export function dropRestItems(arr: T[], index: number) {
+ return arr.slice(0, index + 1);
+}
+
+export function searchCascadeOptions(
+ root: Array>,
+ filterString: string,
+) {
+ const results: Array> = [];
+
+ function search(
+ node: CascaderOption,
+ path: Array> = [],
+ ) {
+ if (node.disabled) {
+ return;
+ }
+ const newPath = path.concat(node);
+
+ const pathIncludesFilterString = newPath.some(p =>
+ p.label.includes(filterString),
+ );
+
+ if (!isParentOption(node)) {
+ if (pathIncludesFilterString) {
+ const result = {
+ label: newPath.map(p => p.label).join(' / '),
+ value: newPath.map(p => p.value),
+ path: newPath,
+ };
+ results.push(result);
+ }
+ return;
+ }
+
+ // 如果当前节点不是叶子节点,继续深度优先遍历其子节点
+ node.children.forEach(child => {
+ search(child, newPath);
+ });
+ }
+
+ root.forEach(node => search(node));
+ return results;
+}
+
+export function trackByOptions(
+ columnIndex: number,
+ options: Array | SearchedCascaderOption>,
+) {
+ return `${columnIndex}~${options?.map(o => o.label).join(',')}`;
+}
+
+export function trackByOption(_: number, option: CascaderOption) {
+ return `${option.label}~${option.children?.map(o => o.label).join(',')}`;
+}
diff --git a/src/index.ts b/src/index.ts
index dece82d2d..59086fea2 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -5,6 +5,7 @@ export * from './back-top';
export * from './breadcrumb';
export * from './button';
export * from './card';
+export * from './cascader';
export * from './checkbox';
export * from './color-picker';
export * from './date-picker';