From 5f03da27fa70bcaad2d01aefb876c1d60e9f36c6 Mon Sep 17 00:00:00 2001 From: Haoxin Yang <1810849666@qq.com> Date: Fri, 12 Apr 2024 15:26:50 +0800 Subject: [PATCH 1/2] feat: add cascader component --- src/cascader/cascader.component.html | 102 ++++++ src/cascader/cascader.component.scss | 105 ++++++ src/cascader/cascader.component.ts | 331 +++++++++++++++++++ src/cascader/cascader.module.ts | 18 + src/cascader/cascader.types.ts | 13 + src/cascader/index.ts | 4 + src/cascader/option-placeholder.component.ts | 15 + src/cascader/option/option.component.scss | 38 +++ src/cascader/option/option.component.ts | 64 ++++ src/cascader/utils.ts | 60 ++++ src/index.ts | 1 + 11 files changed, 751 insertions(+) create mode 100644 src/cascader/cascader.component.html create mode 100644 src/cascader/cascader.component.scss create mode 100644 src/cascader/cascader.component.ts create mode 100644 src/cascader/cascader.module.ts create mode 100644 src/cascader/cascader.types.ts create mode 100644 src/cascader/index.ts create mode 100644 src/cascader/option-placeholder.component.ts create mode 100644 src/cascader/option/option.component.scss create mode 100644 src/cascader/option/option.component.ts create mode 100644 src/cascader/utils.ts diff --git a/src/cascader/cascader.component.html b/src/cascader/cascader.component.html new file mode 100644 index 000000000..147317660 --- /dev/null +++ b/src/cascader/cascader.component.html @@ -0,0 +1,102 @@ +
+ + + + + + +
+
+ + {{ $selectedOptions()?.at(-1)?.label }} + + + + / + {{ option.label }} + + +
+
+
+
+ + +
+
+
+ +
+
+ +
+ +
+
+
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..a21ac0864 --- /dev/null +++ b/src/cascader/utils.ts @@ -0,0 +1,60 @@ +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> = [], + ) { + 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( + _: number, + options: Array | SearchedCascaderOption>, +) { + return options.map(option => option.value).join(','); +} + +export function trackByOption(_: number, option: CascaderOption) { + return [option.value, option.children?.map(o => o.value).join(',')].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'; From b33e74eb0770a02ca697603ce40c6151c762cc7f Mon Sep 17 00:00:00 2001 From: Haoxin Yang <1810849666@qq.com> Date: Mon, 22 Apr 2024 10:18:26 +0800 Subject: [PATCH 2/2] fix: trackFn and disbaled option in search and unexpected tooltip close --- src/cascader/cascader.component.html | 2 +- src/cascader/utils.ts | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/cascader/cascader.component.html b/src/cascader/cascader.component.html index 147317660..fd88d8468 100644 --- a/src/cascader/cascader.component.html +++ b/src/cascader/cascader.component.html @@ -86,7 +86,7 @@ diff --git a/src/cascader/utils.ts b/src/cascader/utils.ts index a21ac0864..010e1acd8 100644 --- a/src/cascader/utils.ts +++ b/src/cascader/utils.ts @@ -20,6 +20,9 @@ export function searchCascadeOptions( node: CascaderOption, path: Array> = [], ) { + if (node.disabled) { + return; + } const newPath = path.concat(node); const pathIncludesFilterString = newPath.some(p => @@ -49,12 +52,12 @@ export function searchCascadeOptions( } export function trackByOptions( - _: number, + columnIndex: number, options: Array | SearchedCascaderOption>, ) { - return options.map(option => option.value).join(','); + return `${columnIndex}~${options?.map(o => o.label).join(',')}`; } export function trackByOption(_: number, option: CascaderOption) { - return [option.value, option.children?.map(o => o.value).join(',')].join('~'); + return `${option.label}~${option.children?.map(o => o.label).join(',')}`; }