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/package.json b/package.json index 58170e2b0..8bfa937ae 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "JounQin (https://www.1stG.me)" ], "license": "MIT", + "packageManager": "yarn@1.22.21", "keywords": [ "alauda", "angular", 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/resizable/resizable.directive.ts b/src/resizable/resizable.directive.ts index 1cd519c89..ffe090100 100644 --- a/src/resizable/resizable.directive.ts +++ b/src/resizable/resizable.directive.ts @@ -1,45 +1,55 @@ -import { DOCUMENT } from '@angular/common'; import { AfterViewInit, Directive, ElementRef, EventEmitter, - Inject, Input, OnDestroy, + Optional, Output, Renderer2, } from '@angular/core'; -import { fromEvent, Subject, Subscription, takeUntil } from 'rxjs'; +import { fromEvent, Subject, takeUntil } from 'rxjs'; + +@Directive({ + selector: '[auiContainerForResizable]', + host: { + class: 'aui-container-for-resizable', + }, + standalone: true, +}) +export class ContainerForResizableDirective { + constructor(readonly el: ElementRef) {} +} /** * 使用此指令需要引入resizable.scss - * 因为参考线是基于absolute定位的,所以需要在预期的地方加上relative(不自动加是为了避免修改业务布局而导致使用者出现意外的异常) */ @Directive({ selector: '[auiResizable]', + host: { + class: 'aui-resizable', + }, standalone: true, }) export class ResizableDirective implements AfterViewInit, OnDestroy { @Input() - containerElement: Element; // 要插入参考线的容器元素 + minWidth: string | number; @Input() - minWidth: string; - - @Input() - maxWidth: string; + maxWidth: string | number; @Output() - resizeStartEvent = new EventEmitter(); + resizeStart = new EventEmitter(); @Output() - resizingEvent = new EventEmitter(); + resizing = new EventEmitter(); @Output() - resizeEndEvent = new EventEmitter(); + resizeEnd = new EventEmitter(); destroy$$ = new Subject(); + containerElement: Element; private readonly element: HTMLElement; private initialWidth: number; @@ -47,30 +57,30 @@ export class ResizableDirective implements AfterViewInit, OnDestroy { resizeHandle: HTMLElement; // 在指令作用的元素上插入引导分割线,hover到作用元素后出现,拖动事件的宿主 resizeOverlay: HTMLElement; // 在拖动时创建出一个透明的遮罩层,用以鼠标样式在拖动时总是拖动样式 resizeBar: HTMLElement; // 拖动时的参考线,该参考线会被插入到 containerElement - private mouseUpSubscription: Subscription; - private readonly document: Document; private handleHasUp: boolean; + private readonly BAR_WIDTH = 2; constructor( element: ElementRef, readonly renderer2: Renderer2, - @Inject(DOCUMENT) - private readonly doc: Document, + @Optional() + private readonly containerDirective: ContainerForResizableDirective, ) { this.element = element.nativeElement; - this.document = this.doc; + this.containerElement = + this.containerDirective?.el.nativeElement || this.element; } ngAfterViewInit() { this.createResizeHandle(); - this.binEvent(this.element, 'mouseover', () => { + this.binEvent(this.element, 'mouseover').subscribe(() => { this.handleHasUp = true; if (!this.resizeBar) { this.setResizeHandleVisible(true); } }); - this.binEvent(this.element, 'mouseout', () => { + this.binEvent(this.element, 'mouseout').subscribe(() => { this.handleHasUp = false; this.setResizeHandleVisible(false); }); @@ -84,38 +94,36 @@ export class ResizableDirective implements AfterViewInit, OnDestroy { private binEvent( target: HTMLElement | Document | Window, eventType: string, - callback: (e: E) => void, ) { - return fromEvent(target, eventType) - .pipe(takeUntil(this.destroy$$)) - .subscribe(callback); + return fromEvent(target, eventType).pipe(takeUntil(this.destroy$$)); } private setResizeHandleVisible(isVisible: boolean) { - this.resizeHandle && + if (this.resizeHandle) { this.renderer2.setStyle( this.resizeHandle, 'visibility', isVisible ? 'visible' : 'hidden', ); + } } private createResizeHandle() { - if (!this.resizeHandle) { - this.resizeHandle = this.renderer2.createElement('div'); - this.renderer2.addClass(this.resizeHandle, 'resize-handle'); + if (this.resizeHandle) { + return; + } - this.binEvent(this.resizeHandle, 'click', (e: Event) => - e.stopPropagation(), - ); - this.binEvent( - this.resizeHandle, - 'mousedown', - this.onMousedown.bind(this), - ); + this.resizeHandle = this.renderer2.createElement('div'); + this.renderer2.addClass(this.resizeHandle, 'resize-handle'); - this.renderer2.appendChild(this.element, this.resizeHandle); - } + this.binEvent(this.resizeHandle, 'click').subscribe(e => + e.stopPropagation(), + ); + this.binEvent(this.resizeHandle, 'mousedown').subscribe(e => { + this.onMousedown(e); + }); + + this.renderer2.appendChild(this.element, this.resizeHandle); } private createResizeOverlay() { @@ -130,7 +138,7 @@ export class ResizableDirective implements AfterViewInit, OnDestroy { this.renderer2.setStyle( this.resizeBar, 'left', - this.element.clientWidth + this.getOffset() + 'px', + this.element.clientWidth + this.getOffset() - this.BAR_WIDTH + 'px', ); this.renderer2.appendChild(this.containerElement, this.resizeBar); } @@ -143,42 +151,42 @@ export class ResizableDirective implements AfterViewInit, OnDestroy { this.setResizeHandleVisible(false); this.initialWidth = this.element.clientWidth; - this.resizeStartEvent.emit(this.initialWidth); + this.resizeStart.emit(this.initialWidth); this.mouseDownScreenX = event.clientX; this.createResizeOverlay(); this.createResizeBar(); - this.mouseUpSubscription = this.binEvent( + const mouseMoveSubscription = this.binEvent( document, - 'mouseup', - ev => this.onMouseup(ev), - ); + 'mousemove', + ).subscribe(e => { + this.move(e); + }); - this.binEvent(document, 'mousemove', this.move); + const mouseUpSubscription = this.binEvent( + document, + 'mouseup', + ).subscribe(ev => { + this.onMouseup(ev); + mouseUpSubscription.unsubscribe(); + mouseMoveSubscription.unsubscribe(); + }); } private onMouseup(event: MouseEvent): void { this.handleHasUp && this.setResizeHandleVisible(true); - const movementX = event.clientX - this.mouseDownScreenX; - const newWidth = this.initialWidth + movementX; - const finalWidth = this.getFinalWidth(newWidth); - - this.renderer2.removeChild(this.element, this.resizeOverlay); + this.renderer2.removeChild(this.containerElement, this.resizeOverlay); this.resizeOverlay = null; this.renderer2.removeChild(this.containerElement, this.resizeBar); this.resizeBar = null; - this.resizeEndEvent.emit(finalWidth); - - if (this.mouseUpSubscription && !this.mouseUpSubscription.closed) { - this._destroySubscription(); - } - - this.document.removeEventListener('mousemove', this.move); + const movementX = event.clientX - this.mouseDownScreenX; + const newWidth = this.initialWidth + movementX; + this.resizeEnd.emit(this.getFinalWidth(newWidth)); } - private readonly move = (event: MouseEvent) => { + private move(event: MouseEvent) { if (!this.resizeBar) { return; } @@ -189,14 +197,14 @@ export class ResizableDirective implements AfterViewInit, OnDestroy { this.renderer2.setStyle( this.resizeBar, 'left', - `${finalWidth + this.getOffset()}px`, + `${finalWidth + this.getOffset() - this.BAR_WIDTH}px`, ); - this.resizingEvent.emit(finalWidth); - }; + this.resizing.emit(finalWidth); + } private getFinalWidth(newWidth: number): number { // 不能超出边界 - const _max = this.containerElement.clientWidth - this.getOffset(); + const _max = this.containerElement.clientWidth + this.getOffset(); const minWidth = this.handleWidth(this.minWidth || this.getOffset()); const maxWidth = this.handleWidth(this.maxWidth || _max); @@ -206,29 +214,21 @@ export class ResizableDirective implements AfterViewInit, OnDestroy { private getOffset() { return ( this.element.getBoundingClientRect().left - - this.containerElement.getBoundingClientRect().left - - 2 // 2 是resizeBar的宽度 + this.containerElement.getBoundingClientRect().left ); } private handleWidth(width: string | number) { - if (!width) { - return; - } if (typeof width === 'number') { return width; } + if (!width) { + return; + } if (width.includes('%')) { const tableWidth = this.containerElement.clientWidth; return (tableWidth * Number.parseFloat(width)) / 100; } return Number.parseFloat(width.replace(/\D+/, '')); } - - private _destroySubscription() { - if (this.mouseUpSubscription) { - this.mouseUpSubscription.unsubscribe(); - this.mouseUpSubscription = null; - } - } } diff --git a/src/resizable/resizable.scss b/src/resizable/resizable.scss index 36bf0b6c7..db86a9b69 100644 --- a/src/resizable/resizable.scss +++ b/src/resizable/resizable.scss @@ -1,5 +1,10 @@ @import '../theme/var'; +.aui-resizable, +.aui-container-for-resizable { + position: relative; +} + .resize-handle { display: inline-block; position: absolute; diff --git a/src/table/table-cell.directive.ts b/src/table/table-cell.directive.ts index 768ab1dd7..63675569c 100644 --- a/src/table/table-cell.directive.ts +++ b/src/table/table-cell.directive.ts @@ -1,11 +1,7 @@ import { CdkCell, CdkColumnDef } from '@angular/cdk/table'; import { Directive, ElementRef, Input } from '@angular/core'; -import { buildBem } from '../utils'; - -import { TABLE_PREFIX_CLASSNAME } from './table.component'; - -const bem = buildBem(TABLE_PREFIX_CLASSNAME); +import { tableBem } from './table.component'; /** Cell template container that adds the right classes and role. */ @Directive({ @@ -25,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-col-resizable.directive.ts b/src/table/table-col-resizable.directive.ts index 353777cb5..b9087e70a 100644 --- a/src/table/table-col-resizable.directive.ts +++ b/src/table/table-col-resizable.directive.ts @@ -2,10 +2,10 @@ import { AfterViewInit, Directive, inject, Input } from '@angular/core'; import { takeUntil } from 'rxjs'; import { ResizableDirective } from '../resizable'; -import { buildBem, handlePixel } from '../utils'; +import { handlePixel } from '../utils'; import { - TABLE_PREFIX_CLASSNAME, + tableBem, TableColumnDefDirective, TableComponent, TableScrollWrapperDirective, @@ -41,10 +41,8 @@ export class TableColResizableDirective this.tableScrollWrapperDirective || this.tableComponent ).el.nativeElement; - const bem = buildBem(TABLE_PREFIX_CLASSNAME); - - this.resizeEndEvent.pipe(takeUntil(this.destroy$$)).subscribe(width => { - const className = bem.element( + this.resizeEnd.pipe(takeUntil(this.destroy$$)).subscribe(width => { + const className = tableBem.element( `column-${this.tableColumnDefDirective.cssClassFriendlyName}`, ); if (!this.hostAttr) { diff --git a/src/table/table-header-cell.directive.ts b/src/table/table-header-cell.directive.ts index 78b2097d9..e9f26ef15 100644 --- a/src/table/table-header-cell.directive.ts +++ b/src/table/table-header-cell.directive.ts @@ -1,11 +1,7 @@ import { CdkColumnDef, CdkHeaderCell } from '@angular/cdk/table'; import { Directive, ElementRef } from '@angular/core'; -import { buildBem } from '../utils'; - -import { TABLE_PREFIX_CLASSNAME } from './table.component'; - -const bem = buildBem(TABLE_PREFIX_CLASSNAME); +import { tableBem } from './table.component'; /** Header cell template container that adds the right classes and role. */ @Directive({ @@ -21,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 d8e6ddc46..8862a1bf1 100644 --- a/src/table/table-scroll.directive.ts +++ b/src/table/table-scroll.directive.ts @@ -24,14 +24,13 @@ import { BehaviorSubject, } from 'rxjs'; -import { coerceAttrBoolean, observeResizeOn } from '../utils'; +import { buildBem, coerceAttrBoolean, observeResizeOn } from '../utils'; -import { TABLE_PREFIX_CLASSNAME, TableComponent } from './table.component'; +import { tableBem, TableComponent } from './table.component'; -const SHADOW_CLASS = `${TABLE_PREFIX_CLASSNAME}__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'; @@ -88,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; @@ -170,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.component.scss b/src/table/table.component.scss index c8af5ceb8..612a12985 100644 --- a/src/table/table.component.scss +++ b/src/table/table.component.scss @@ -145,5 +145,9 @@ top: 50%; margin-top: -10px; z-index: initial; + + &:hover { + border-color: use-rgb(primary); + } } } diff --git a/src/table/table.component.ts b/src/table/table.component.ts index df37f0a1f..78b198d87 100644 --- a/src/table/table.component.ts +++ b/src/table/table.component.ts @@ -16,6 +16,7 @@ import { Component, ContentChild, ElementRef, + HostBinding, inject, Input, OnDestroy, @@ -23,12 +24,14 @@ import { ViewEncapsulation, } from '@angular/core'; +import { buildBem } from '../utils'; + import { TablePlaceholderDefDirective, TablePlaceholderOutletDirective, } from './table-placeholder.directive'; -export const TABLE_PREFIX_CLASSNAME = 'aui-table'; +export const tableBem = buildBem('aui-table'); @Component({ selector: 'aui-table', @@ -38,9 +41,6 @@ export const TABLE_PREFIX_CLASSNAME = 'aui-table'; template: CDK_TABLE_TEMPLATE + '', - host: { - class: TABLE_PREFIX_CLASSNAME, - }, preserveWhitespaces: false, changeDetection: ChangeDetectionStrategy.OnPush, providers: [ @@ -73,6 +73,9 @@ export class TableComponent @ContentChild(TablePlaceholderDefDirective, { static: true }) _placeholderDef: TablePlaceholderDefDirective; + @HostBinding('class') + className = tableBem.block(); + el = inject(ElementRef); // FIXME: workaround to override because it will break constructor if it is field, but why MatTable works? diff --git a/src/table/table.spec.ts b/src/table/table.spec.ts index 650a8ea56..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 { TABLE_PREFIX_CLASSNAME, TableComponent, TableModule } from '.'; - -const bem = buildBem(TABLE_PREFIX_CLASSNAME); +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/resizable/basic.component.ts b/stories/resizable/basic.component.ts new file mode 100644 index 000000000..002e67803 --- /dev/null +++ b/stories/resizable/basic.component.ts @@ -0,0 +1,77 @@ +import { + ChangeDetectionStrategy, + Component, + ElementRef, + QueryList, + ViewChildren, +} from '@angular/core'; + +import { ContainerForResizableDirective } from '../../src/resizable/resizable.directive'; + +import { ResizableDirective } from '@alauda/ui'; + +@Component({ + template: ` + 默认拖动容器为指令的宿主元素 +
+
+
+
+ + 但也可以通过 auiContainerForResizable 指定容器,例如在灰色 div 上加这个指令 +
+
+
+
+ 你会发现你可以在更大的范围内进行拖动,因为 container + 变大了,默认可拖动区域是限定在 container 里的。 + 如果你想自定义拖动区域,可以通过设定 minWidth 和 maxWidth + 完成,除了支持常规的 number 也就是 XXpx 这种,也支持 + XX%,百分比是相对于容器宽度的,例如下面设定最小60,最大80% +
+
+
+
+ + 是的,就这么简单,你要考虑的只有——怎么处理 resizeEnd 发出的拖动后的宽度。 + `, + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ResizableDirective, ContainerForResizableDirective], + styles: [ + ` + @import '../../src/resizable/resizable'; + `, + ], + standalone: true, +}) +export default class BasicComponent { + @ViewChildren('resizable') + resizable: QueryList; + + resizeEndHandle(width: number, idx: number) { + this.resizable.get(idx).nativeElement.style.width = `${width}px`; + } +} diff --git a/stories/resizable/basic.stories.ts b/stories/resizable/basic.stories.ts new file mode 100644 index 000000000..e812590a6 --- /dev/null +++ b/stories/resizable/basic.stories.ts @@ -0,0 +1,15 @@ +import { Meta, StoryObj } from '@storybook/angular'; + +import BasicComponent from './basic.component'; + +const meta: Meta = { + title: 'Example/Resizable', + component: BasicComponent, +}; + +export default meta; +type Story = StoryObj; + +export const Basic: Story = { + name: 'Basic', +}; diff --git a/stories/resizable/resizable.mdx b/stories/resizable/resizable.mdx new file mode 100644 index 000000000..42ef326be --- /dev/null +++ b/stories/resizable/resizable.mdx @@ -0,0 +1,37 @@ +import { Canvas, Meta } from '@storybook/blocks'; + +import * as basic from './basic.stories'; + + + +# Resizable + +Resizable 指令提供了一种通过左右拖动来调整宽度的能力 + +## 使用 + +需要在使用时手动引入 resizable.scss + +``` +@import 'node_modules/@alauda/ui/resizable/resizable'; +``` + + + +## Inputs + +| 名称 | 类型 | 默认值 | 描述 | +| -------- | ---------------- | ------ | --------------------------------------------- | +| minWidth | string \| number | - | 限定拖动后的最小宽度,为%时以容器的宽度为基数 | +| maxWidth | string \| number | - | 限定拖动后的最大宽度,为%时以容器的宽度为基数 | + +## Outputs + +| 名称 | 回调参数 | 描述 | +| ----------- | ------------- | ---------------------- | +| resizeStart | width: number | 开始调整宽度事件 | +| resizing | width: number | 正在进行宽度调整的事件 | +| resizeEnd | width: number | 调整宽度结束事件 |