From d611141e34f3d234d26bd89aed1c6e137987394e Mon Sep 17 00:00:00 2001 From: tianwenjie Date: Fri, 15 Dec 2023 15:36:38 +0800 Subject: [PATCH] feat: add resizable directive feat: memory feat: re --- .changeset/fifty-hounds-stare.md | 5 + angular.json | 3 +- scripts/build.js | 11 +- src/table/index.ts | 1 + src/table/table-cell.directive.ts | 6 +- src/table/table-column-resizable.directive.ts | 253 ++++++++++++++++++ src/table/table-header-cell.directive.ts | 6 +- src/table/table-scroll.directive.ts | 28 +- src/table/table-scroll.scss | 1 + src/table/table.component.scss | 60 ++++- src/table/table.component.ts | 22 +- src/table/table.spec.ts | 14 +- stories/table/basic.component.ts | 5 +- stories/table/basic.stories.ts | 2 + stories/table/sticky-columns.component.ts | 30 ++- stories/table/sticky-columns.stories.ts | 2 + 16 files changed, 399 insertions(+), 50 deletions(-) create mode 100644 .changeset/fifty-hounds-stare.md create mode 100644 src/table/table-column-resizable.directive.ts diff --git a/.changeset/fifty-hounds-stare.md b/.changeset/fifty-hounds-stare.md new file mode 100644 index 000000000..c8aa5e03b --- /dev/null +++ b/.changeset/fifty-hounds-stare.md @@ -0,0 +1,5 @@ +--- +'@alauda/ui': minor +--- + +- feat: add resizable directive diff --git a/angular.json b/angular.json index eb14751f7..1a55ac474 100644 --- a/angular.json +++ b/angular.json @@ -74,7 +74,8 @@ "browserTarget": "storybook:build", "compodoc": true, "compodocArgs": ["-e", "json", "-d", "."], - "outputDir": "dist" + "outputDir": "dist", + "enableProdMode": false // FIXME: https://github.com/storybookjs/storybook/issues/23534 } } } diff --git a/scripts/build.js b/scripts/build.js index d34e644cf..4f80753fa 100644 --- a/scripts/build.js +++ b/scripts/build.js @@ -11,9 +11,10 @@ const watch = process.argv.includes('--watch'); const debugNgPackage = '../ng-package.debug.json'; -const dest = (isDebug ? require(debugNgPackage).dest : 'release') + '/theme'; +const releaseDest = isDebug ? require(debugNgPackage).dest : 'release'; function copyResources() { + const themeDest = path.resolve(releaseDest, 'theme'); gulp .src([ 'src/theme/_base-var.scss', @@ -22,12 +23,16 @@ function copyResources() { 'src/theme/_theme-preset.scss', 'src/theme/_mixin.scss', ]) - .pipe(gulp.dest(dest)); + .pipe(gulp.dest(themeDest)); + + gulp + .src(['src/resizable/resizable.scss']) + .pipe(gulp.dest(path.resolve(releaseDest, 'resizable'))); gulp .src('src/theme/style.scss') .pipe(sass().on('error', sass.logError)) - .pipe(gulp.dest(dest)); + .pipe(gulp.dest(themeDest)); } const packagr = ngPackagr diff --git a/src/table/index.ts b/src/table/index.ts index 0507009d6..e4adb068d 100644 --- a/src/table/index.ts +++ b/src/table/index.ts @@ -3,6 +3,7 @@ export * from './table.module'; export * from './table-cell.component'; export * from './table-cell.directive'; export * from './table-cell-def.directive'; +export * from './table-column-resizable.directive'; export * from './table-column-def.directive'; export * from './table-header-cell.directive'; export * from './table-header-cell-def.directive'; diff --git a/src/table/table-cell.directive.ts b/src/table/table-cell.directive.ts index f60fee04b..63675569c 100644 --- a/src/table/table-cell.directive.ts +++ b/src/table/table-cell.directive.ts @@ -1,9 +1,7 @@ import { CdkCell, CdkColumnDef } from '@angular/cdk/table'; import { Directive, ElementRef, Input } from '@angular/core'; -import { buildBem } from '../utils'; - -const bem = buildBem('aui-table'); +import { tableBem } from './table.component'; /** Cell template container that adds the right classes and role. */ @Directive({ @@ -23,7 +21,7 @@ export class TableCellDirective extends CdkCell { constructor(columnDef: CdkColumnDef, elementRef: ElementRef) { super(columnDef, elementRef); elementRef.nativeElement.classList.add( - bem.element(`column-${columnDef.cssClassFriendlyName}`), + tableBem.element(`column-${columnDef.cssClassFriendlyName}`), ); } } diff --git a/src/table/table-column-resizable.directive.ts b/src/table/table-column-resizable.directive.ts new file mode 100644 index 000000000..ff8470dc4 --- /dev/null +++ b/src/table/table-column-resizable.directive.ts @@ -0,0 +1,253 @@ +import { + AfterViewInit, + Directive, + ElementRef, + inject, + Input, + Renderer2, +} from '@angular/core'; +import { + fromEvent, + map, + merge, + Subscription, + switchMap, + take, + takeUntil, +} from 'rxjs'; + +import { buildBem } from '../utils'; + +import { + tableBem, + TableColumnDefDirective, + TableComponent, + // TableScrollWrapperDirective, +} from './index'; + +let tableColumnResizableID = 0; + +const resizableBem = buildBem('aui-table-column-resizable'); +const markLineWidth = 1; + +@Directive({ + selector: '[auiTableColumnResizable]', + standalone: true, +}) +export class TableColumnResizableDirective implements AfterViewInit { + @Input() + minWidth = '40px'; + + @Input() + maxWidth = '80%'; + + private readonly renderer2 = inject(Renderer2); + private readonly tableColumnDefDirective = inject(TableColumnDefDirective); + private readonly tableComponent = inject(TableComponent); + // private readonly tableScrollWrapperDirective = inject( + // TableScrollWrapperDirective, + // { + // optional: true, + // }, + // ); + + private readonly columnElement: HTMLElement = + inject(ElementRef).nativeElement; + private readonly containerElement: HTMLElement = + this.tableComponent.elementRef.nativeElement; + + private styleElement: HTMLStyleElement; + private hostAttr: string; + private resizeSubscription: Subscription; + + ngAfterViewInit() { + const resizeHandle = this.createResizeHandle(); + this.bindResizable(resizeHandle); + } + + ngOnDestroy() { + this.resizeSubscription?.unsubscribe(); + this.styleElement?.remove(); + if (this.hostAttr) { + this.containerElement.removeAttribute(this.hostAttr); + } + } + + private bindResizable(resizeHandle: HTMLElement) { + fromEvent(resizeHandle, 'mousedown') + .pipe( + switchMap(mouseDownEvent => { + mouseDownEvent.preventDefault(); + mouseDownEvent.stopPropagation(); + + this.renderer2.setStyle(resizeHandle, 'visibility', 'hidden'); + const resizeRange = this.getResizeRange(); + const initialMouseX = mouseDownEvent.clientX; + const columnWidth = this.getColumnWidth(); + const columnOffset = this.getColumnOffset(); + const resizeMarkLine = this.createResizeMarkLine( + columnOffset + columnWidth, + ); + const resizeOverlay = this.createResizeOverlay(); + + const mouseUp$ = fromEvent(document, 'mouseup').pipe( + take(1), + ); + const mouseMove$ = fromEvent(document, 'mousemove').pipe( + takeUntil(mouseUp$), + ); + + return merge( + mouseMove$.pipe( + map( + mouseMoveEvent => () => + resizeMarkLine.updateOffset( + columnOffset + + this.getWidthInRange( + resizeRange, + columnWidth + mouseMoveEvent.clientX - initialMouseX, + ), + ), + ), + ), + mouseUp$.pipe( + map(mouseUpEvent => () => { + this.renderer2.removeStyle(resizeHandle, 'visibility'); + resizeMarkLine.destroy(); + resizeOverlay.destroy(); + + this.renderWidthStyles( + this.getWidthInRange( + resizeRange, + columnWidth + mouseUpEvent.clientX - initialMouseX, + ), + ); + }), + ), + ); + }), + ) + .subscribe(exec => { + exec(); + }); + } + + private createResizeHandle() { + const resizeHandle: HTMLDivElement = this.renderer2.createElement('div'); + this.renderer2.addClass(resizeHandle, resizableBem.element('handle')); + this.renderer2.appendChild(this.columnElement, resizeHandle); + + return resizeHandle; + } + + private createResizeMarkLine(initialOffset: number) { + const markLine: HTMLElement = this.renderer2.createElement('div'); + this.renderer2.addClass(markLine, resizableBem.element('mark-line')); + this.renderer2.setStyle( + markLine, + 'left', + initialOffset - markLineWidth + 'px', + ); + if (this.isStickyLeftBorderColumn()) { + this.renderer2.addClass(markLine, 'inStickyBorderElemLeft'); + } + this.renderer2.appendChild(this.containerElement, markLine); + return { + element: markLine, + updateOffset: (offset: number) => { + this.renderer2.setStyle( + markLine, + 'left', + offset - markLineWidth + 'px', + ); + }, + destroy: () => { + this.renderer2.removeChild(this.containerElement, markLine); + }, + }; + } + + private createResizeOverlay() { + const resizeOverlay = this.renderer2.createElement('div'); + this.renderer2.addClass(resizeOverlay, resizableBem.element('overlay')); + this.renderer2.appendChild(this.containerElement, resizeOverlay); + return { + element: resizeOverlay, + destroy: () => { + this.renderer2.removeChild(this.containerElement, resizeOverlay); + }, + }; + } + + private getColumnWidth() { + return this.columnElement.clientWidth; + } + + private getColumnOffset() { + return ( + this.columnElement.getBoundingClientRect().left - + this.containerElement.getBoundingClientRect().left + ); + } + + private getWidthInRange( + [minWidth, maxWidth]: [number, number], + width: number, + ): number { + return Math.min(Math.max(width, minWidth), maxWidth); + } + + private getResizeRange(): [number, number] { + const minWidth = this.getActualWidth(this.minWidth); + const maxWidth = this.getActualWidth(this.maxWidth); + return [minWidth, maxWidth]; + } + + private getActualWidth(width: number | string): number { + if (typeof width === 'number') { + return width; + } else if (width.endsWith('px')) { + return parseInt(width.slice(0, -2)); + } else if (width.endsWith('%')) { + return ( + (this.containerElement.clientWidth * parseInt(width.slice(0, -1))) / 100 + ); + } + } + + private isStickyLeftBorderColumn() { + return this.columnElement.classList.contains( + 'aui-table-sticky-border-elem-left', + ); + } + + private renderWidthStyles(width: number) { + const className = tableBem.element( + `column-${this.tableColumnDefDirective.cssClassFriendlyName}`, + ); + if (!this.hostAttr) { + this.hostAttr = `table-resizable-${tableColumnResizableID++}`; + this.containerElement.setAttribute(this.hostAttr, ''); + } + const styleString = ` + [${this.hostAttr}] .${className} { + flex: none !important; + width: ${width}px !important; + min-width: ${width}px !important; + max-width: ${width}px !important; + } + `; + if (this.styleElement) { + this.styleElement.innerHTML = styleString; + } else { + this.styleElement = this.renderer2.createElement('style'); + this.styleElement.innerHTML = styleString; + this.renderer2.appendChild( + document.querySelector('head'), + this.styleElement, + ); + } + + this.tableComponent.updateStickyColumnStyles(); + } +} diff --git a/src/table/table-header-cell.directive.ts b/src/table/table-header-cell.directive.ts index 9a774cbdf..e9f26ef15 100644 --- a/src/table/table-header-cell.directive.ts +++ b/src/table/table-header-cell.directive.ts @@ -1,9 +1,7 @@ import { CdkColumnDef, CdkHeaderCell } from '@angular/cdk/table'; import { Directive, ElementRef } from '@angular/core'; -import { buildBem } from '../utils'; - -const bem = buildBem('aui-table'); +import { tableBem } from './table.component'; /** Header cell template container that adds the right classes and role. */ @Directive({ @@ -19,7 +17,7 @@ export class TableHeaderCellDirective extends CdkHeaderCell { constructor(columnDef: CdkColumnDef, elementRef: ElementRef) { super(columnDef, elementRef); elementRef.nativeElement.classList.add( - bem.element(`column-${columnDef.cssClassFriendlyName}`), + tableBem.element(`column-${columnDef.cssClassFriendlyName}`), ); } } diff --git a/src/table/table-scroll.directive.ts b/src/table/table-scroll.directive.ts index 44cee4159..ab0b2fd58 100644 --- a/src/table/table-scroll.directive.ts +++ b/src/table/table-scroll.directive.ts @@ -6,6 +6,7 @@ import { ElementRef, Host, HostBinding, + inject, Input, NgZone, OnDestroy, @@ -23,15 +24,13 @@ import { BehaviorSubject, } from 'rxjs'; -import { coerceAttrBoolean, observeResizeOn } from '../utils'; +import { buildBem, coerceAttrBoolean, observeResizeOn } from '../utils'; -import { TableComponent } from './table.component'; +import { tableBem, TableComponent } from './table.component'; -const CLASS_PREFIX = 'aui-table'; -const SHADOW_CLASS = `${CLASS_PREFIX}__scroll-shadow`; -const HAS_SCROLL_CLASS = `${SHADOW_CLASS}--has-scroll`; -const SCROLLING_CLASS = `${SHADOW_CLASS}--scrolling`; -const SCROLL_BEFORE_END_CLASS = `${SHADOW_CLASS}--before-end`; +const shadowClass = tableBem.element('scroll-shadow'); +const shadowBem = buildBem(shadowClass); +const scrollBeforeEndClass = shadowBem.modifier('before-end'); const HAS_TABLE_TOP_SHADOW = 'hasTableTopShadow'; const HAS_TABLE_BOTTOM_SHADOW = 'hasTableBottomShadow'; @@ -48,6 +47,8 @@ export class TableScrollWrapperDirective { @HostBinding('style.max-height') @Input() auiTableScrollWrapper = '100%'; + + elementRef = inject(ElementRef); } @Directive({ @@ -86,11 +87,8 @@ export class TableScrollableDirective super(el, scrollDispatcher, ngZone, dir); } - @HostBinding(`class.${SCROLL_BEFORE_END_CLASS}`) - SCROLL_BEFORE_END_CLASS = true; - - @HostBinding(`class.${SHADOW_CLASS}`) - SHADOW_CLASS = true; + @HostBinding('class') + className = `${scrollBeforeEndClass} ${shadowClass}`; get containerEl() { return this.el.nativeElement; @@ -168,19 +166,19 @@ export class TableScrollableDirective this.placeClassList( this.containerEl.classList, scrollDis > 0, - HAS_SCROLL_CLASS, + shadowBem.modifier('has-scroll'), ); const scrollLeft = this.containerEl.scrollLeft; this.placeClassList( this.containerEl.classList, scrollLeft > 0, - SCROLLING_CLASS, + shadowBem.modifier('scrolling'), ); this.placeClassList( this.containerEl.classList, scrollLeft < scrollDis, - SCROLL_BEFORE_END_CLASS, + scrollBeforeEndClass, ); } diff --git a/src/table/table-scroll.scss b/src/table/table-scroll.scss index 49e383bef..e6d129145 100644 --- a/src/table/table-scroll.scss +++ b/src/table/table-scroll.scss @@ -7,6 +7,7 @@ $stickyCssClass: 'aui-table-sticky'; // style for column shadow .aui-table__scroll-wrapper { + position: relative; display: flex; flex-direction: column; max-height: 100%; diff --git a/src/table/table.component.scss b/src/table/table.component.scss index 80d7a1ea8..565b2e817 100644 --- a/src/table/table.component.scss +++ b/src/table/table.component.scss @@ -2,6 +2,7 @@ @import '../theme/mixin'; .aui-table { + position: relative; display: block; padding: 0 $table-padding $table-padding; @include text-set(m, main); @@ -40,7 +41,7 @@ border-top-right-radius: use-var(border-radius-l); } - &:last-child { + &:last-of-type { border-bottom-width: 1px; min-height: $table-row-min-height; border-bottom-left-radius: use-var(border-radius-l); @@ -71,6 +72,7 @@ display: flex; align-items: center; flex: 1; + position: relative; } &__cell { @@ -135,3 +137,59 @@ } } } + +.aui-table-column-resizable { + &__handle { + display: block; + position: absolute; + top: 0; + bottom: 0; + right: 0; + width: 5px; + color: use-rgb(n-7); + cursor: col-resize; + + &:after { + content: ''; + display: block; + margin: $table-padding 0 $table-padding auto; + width: 1px; + height: calc(100% - #{$table-padding} * 2); + background-color: currentcolor; + } + + &:hover { + color: use-rgb(primary); + } + } + + &__mark-line { + display: block; + position: absolute; + top: 0; + bottom: 0; + width: 1px; + background-color: use-rgb(primary); + z-index: 9999; + cursor: col-resize; + } + + &__overlay { + display: block; + position: absolute; + inset: 0; + z-index: 9000; + cursor: col-resize; + } +} + +aui-table.aui-table__scroll-shadow--has-scroll { + aui-table-header-cell.aui-table-sticky-border-elem-left + .aui-table-column-resizable__handle { + transform: translateX(-20px); + } + + .aui-table-column-resizable__mark-line.inStickyBorderElemLeft { + transform: translateX(-20px); + } +} diff --git a/src/table/table.component.ts b/src/table/table.component.ts index dcda7fc28..9021e24f9 100644 --- a/src/table/table.component.ts +++ b/src/table/table.component.ts @@ -15,17 +15,24 @@ import { ChangeDetectionStrategy, Component, ContentChild, + ElementRef, + HostBinding, + inject, Input, OnDestroy, ViewChild, ViewEncapsulation, } from '@angular/core'; +import { buildBem } from '../utils'; + import { TablePlaceholderDefDirective, TablePlaceholderOutletDirective, } from './table-placeholder.directive'; +export const tableBem = buildBem('aui-table'); + @Component({ selector: 'aui-table', exportAs: 'auiTable', @@ -34,9 +41,6 @@ import { template: CDK_TABLE_TEMPLATE + '', - host: { - class: 'aui-table', - }, preserveWhitespaces: false, changeDetection: ChangeDetectionStrategy.OnPush, providers: [ @@ -69,6 +73,11 @@ export class TableComponent @ContentChild(TablePlaceholderDefDirective, { static: true }) _placeholderDef: TablePlaceholderDefDirective; + @HostBinding('class') + className = tableBem.block(); + + elementRef = inject(ElementRef); + // FIXME: workaround to override because it will break constructor if it is field, but why MatTable works? // @ts-ignore protected get stickyCssClass() { @@ -85,12 +94,13 @@ export class TableComponent private _createPlaceholder() { const footerRow = this._placeholderDef; - if (!this._placeholderDef) { + if (!footerRow) { return; } - const container = this._placeholderOutlet.viewContainer; - container.createEmbeddedView(footerRow.templateRef); + this._placeholderOutlet.viewContainer.createEmbeddedView( + footerRow.templateRef, + ); } private _clearPlaceholder() { diff --git a/src/table/table.spec.ts b/src/table/table.spec.ts index 6c21bdbb1..d83dcf65e 100644 --- a/src/table/table.spec.ts +++ b/src/table/table.spec.ts @@ -3,11 +3,7 @@ import { Component, ViewChild } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { BehaviorSubject, Observable } from 'rxjs'; -import { buildBem } from '../utils'; - -import { TableComponent, TableModule } from '.'; - -const bem = buildBem('aui-table'); +import { tableBem, TableComponent, TableModule } from '.'; describe('Table', () => { beforeEach(() => { @@ -138,20 +134,20 @@ function getElements(element: Element, query: string) { } function getHeaderRow(tableElement: Element): Element { - return tableElement.querySelector(`.${bem.element('header-row')}`); + return tableElement.querySelector(`.${tableBem.element('header-row')}`); } function getRows(tableElement: Element): Element[] { - return getElements(tableElement, `.${bem.element('row')}`); + return getElements(tableElement, `.${tableBem.element('row')}`); } function getCells(row: Element): Element[] { - return row ? getElements(row, `.${bem.element('cell')}`) : []; + return row ? getElements(row, `.${tableBem.element('cell')}`) : []; } function getHeaderCells(tableElement: Element): Element[] { return getElements( getHeaderRow(tableElement), - `.${bem.element('header-cell')}`, + `.${tableBem.element('header-cell')}`, ); } diff --git a/stories/table/basic.component.ts b/stories/table/basic.component.ts index 4f9cc026f..7acf8c5c7 100644 --- a/stories/table/basic.component.ts +++ b/stories/table/basic.component.ts @@ -6,7 +6,10 @@ import { DATA_SOURCE } from './data'; template: ` - + No. diff --git a/stories/table/basic.stories.ts b/stories/table/basic.stories.ts index cdc931bfd..97de5d8b6 100644 --- a/stories/table/basic.stories.ts +++ b/stories/table/basic.stories.ts @@ -9,6 +9,7 @@ import { ScrollingModule, SortModule, TableModule, + TableColumnResizableDirective, } from '@alauda/ui'; const meta: Meta = { @@ -24,6 +25,7 @@ const meta: Meta = { ScrollingModule, TableModule, ButtonModule, + TableColumnResizableDirective, ], }), ], diff --git a/stories/table/sticky-columns.component.ts b/stories/table/sticky-columns.component.ts index a3a0d1a1a..9582b7542 100644 --- a/stories/table/sticky-columns.component.ts +++ b/stories/table/sticky-columns.component.ts @@ -25,7 +25,10 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; auiTableColumnDef="no" [sticky]="true" > - + No. {{ @@ -36,7 +39,10 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; auiTableColumnDef="cell1" [sticky]="true" > - + header cell @@ -68,7 +74,10 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; - + header cell @@ -100,7 +109,10 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; - + header cell @@ -111,7 +123,10 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; auiTableColumnDef="cell10" stickyEnd > - + header cell @@ -126,7 +141,10 @@ import { ChangeDetectionStrategy, Component } from '@angular/core'; {{ item[11] }} - + header cell12 diff --git a/stories/table/sticky-columns.stories.ts b/stories/table/sticky-columns.stories.ts index ba303abb6..ca1445016 100644 --- a/stories/table/sticky-columns.stories.ts +++ b/stories/table/sticky-columns.stories.ts @@ -8,6 +8,7 @@ import { IconModule, ScrollingModule, SortModule, + TableColumnResizableDirective, TableModule, } from '@alauda/ui'; @@ -24,6 +25,7 @@ const meta: Meta = { ScrollingModule, TableModule, ButtonModule, + TableColumnResizableDirective, ], }), ],