From c464cd5d8fd94edb4bab40e14db3cce968b4c464 Mon Sep 17 00:00:00 2001 From: Wei Li Date: Fri, 18 Jun 2021 09:54:21 +0800 Subject: [PATCH 01/10] feat: table column sticky (#129) * feat: table column sticky * refactor: workaround for custom stickyCssClass Co-authored-by: JounQin --- src/table/public-api.ts | 1 + src/table/table-scroll-wrapper.directive.ts | 59 ++++++++++++ src/table/table.component.scss | 67 +++++++++++++ src/table/table.component.ts | 11 ++- src/table/table.module.ts | 3 + stories/table/index.ts | 8 ++ .../sticky-columns-demo.component.html | 94 +++++++++++++++++++ .../sticky-columns-demo.component.scss | 6 ++ .../sticky-columns-demo.component.ts | 16 ++++ 9 files changed, 263 insertions(+), 2 deletions(-) create mode 100644 src/table/table-scroll-wrapper.directive.ts create mode 100644 stories/table/sticky-columns/sticky-columns-demo.component.html create mode 100644 stories/table/sticky-columns/sticky-columns-demo.component.scss create mode 100644 stories/table/sticky-columns/sticky-columns-demo.component.ts diff --git a/src/table/public-api.ts b/src/table/public-api.ts index 46cc20c85..2628243ef 100644 --- a/src/table/public-api.ts +++ b/src/table/public-api.ts @@ -9,3 +9,4 @@ export * from './table-header-row-def.directive'; export * from './table-cell-def.directive'; export * from './table-header-cell-def.directive'; export * from './table-column-def.directive'; +export * from './table-scroll-wrapper.directive'; diff --git a/src/table/table-scroll-wrapper.directive.ts b/src/table/table-scroll-wrapper.directive.ts new file mode 100644 index 000000000..12cb00b32 --- /dev/null +++ b/src/table/table-scroll-wrapper.directive.ts @@ -0,0 +1,59 @@ +import { + AfterViewChecked, + AfterViewInit, + Directive, + ElementRef, + OnDestroy, +} from '@angular/core'; +import { fromEvent, Subject } from 'rxjs'; +import { debounceTime, takeUntil } from 'rxjs/operators'; + +const CLASS_PREFIX = 'aui-table'; +const WRAPPER_CLASS = `${CLASS_PREFIX}__scroll-wrapper`; +const HAS_SCROLL_CLASS = `${WRAPPER_CLASS}--has-scroll`; +const SCROLLING_CLASS = `${WRAPPER_CLASS}--scrolling`; +const SCROLL_BEFORE_END_CLASS = `${WRAPPER_CLASS}--before-end`; + +@Directive({ + selector: '[auiTableScrollWrapper]', + host: { + class: `${WRAPPER_CLASS} ${SCROLL_BEFORE_END_CLASS}`, + }, +}) +export class TableScrollWrapperDirective + implements AfterViewChecked, AfterViewInit, OnDestroy { + scrollDis = 0; + destroy$$ = new Subject(); + constructor(private readonly el: ElementRef) {} + + ngAfterViewChecked() { + this.scrollDis = + this.el.nativeElement.scrollWidth - this.el.nativeElement.offsetWidth; + if (this.scrollDis > 0) { + this.el.nativeElement.classList.add(HAS_SCROLL_CLASS); + } + } + + ngAfterViewInit() { + fromEvent(this.el.nativeElement, 'scroll') + .pipe(debounceTime(100), takeUntil(this.destroy$$)) + .subscribe(() => { + const scrollLeft = this.el.nativeElement.scrollLeft; + if (scrollLeft > 0) { + this.el.nativeElement.classList.add(SCROLLING_CLASS); + } else { + this.el.nativeElement.classList.remove(SCROLLING_CLASS); + } + if (scrollLeft === this.scrollDis) { + this.el.nativeElement.classList.remove(SCROLL_BEFORE_END_CLASS); + } else { + this.el.nativeElement.classList.add(SCROLL_BEFORE_END_CLASS); + } + }); + } + + ngOnDestroy() { + this.destroy$$.next(); + this.destroy$$.complete(); + } +} diff --git a/src/table/table.component.scss b/src/table/table.component.scss index 48d2ad757..710443836 100644 --- a/src/table/table.component.scss +++ b/src/table/table.component.scss @@ -1,8 +1,75 @@ @import '../theme/var'; +$stickyCssClass: 'aui-table-sticky'; + .aui-table { font-size: $table-font-size-base; + &__scroll-wrapper { + overflow: auto; + .aui-table__row { + padding: 0; + border: none; + } + .aui-table__header-cell { + background-color: $table-header-row-bg; + border: $table-border-width solid $table-header-row-border-color; + border-width: $table-border-width 0; + } + .aui-table__cell { + padding: $table-row-vertical-padding $table-cell-horizontal-padding; + border-bottom: $table-border-width solid $table-row-border-color; + background: $table-row-bg; + } + &--has-scroll { + .#{$stickyCssClass} { + &:after { + position: absolute; + top: 0; + bottom: -1px; + width: 30px; + transform: translateX(-100%); + // stylelint-disable-next-line plugin/no-low-performance-animation-properties + transition: box-shadow 0.3s; + content: ''; + pointer-events: none; + } + &-border-elem-left { + &:after { + right: -50px; + } + } + &-border-elem-right { + overflow: visible; + &:after { + left: 0; + } + } + } + } + &--scrolling { + .#{$stickyCssClass} { + &-border-elem-left { + &:after { + // stylelint-disable-next-line plugin/no-low-performance-animation-properties + box-shadow: inset 10px 0 8px -8px rgb(0 0 0 / 15%); + } + } + } + } + + &--before-end { + .#{$stickyCssClass} { + &-border-elem-right { + &:after { + // stylelint-disable-next-line plugin/no-low-performance-animation-properties + box-shadow: inset -10px 0 8px -8px rgb(0 0 0 / 15%); + } + } + } + } + } + &__row, &__header-row { display: flex; diff --git a/src/table/table.component.ts b/src/table/table.component.ts index 52c3a0c4b..715501ae5 100644 --- a/src/table/table.component.ts +++ b/src/table/table.component.ts @@ -42,6 +42,13 @@ import { ], }) export class TableComponent extends CdkTable { - // FIXME: disable override because it will break constructor, but why MatTable works? - // protected stickyCssClass = 'aui-table-sticky'; + // FIXME: workaround to override because it will break constructor if it is field, but why MatTable works? + // @ts-expect-error + protected get stickyCssClass() { + return 'aui-table-sticky'; + } + + protected set stickyCssClass(_stickyCssClass: string) { + // + } } diff --git a/src/table/table.module.ts b/src/table/table.module.ts index 5ee2ffdd3..d80222008 100644 --- a/src/table/table.module.ts +++ b/src/table/table.module.ts @@ -11,6 +11,7 @@ import { TableHeaderRowDefDirective } from './table-header-row-def.directive'; import { TableHeaderRowComponent } from './table-header-row.component'; import { TableRowDefDirective } from './table-row-def.directive'; import { TableRowComponent } from './table-row.component'; +import { TableScrollWrapperDirective } from './table-scroll-wrapper.directive'; import { TableComponent } from './table.component'; @NgModule({ @@ -26,6 +27,7 @@ import { TableComponent } from './table.component'; TableCellDefDirective, TableHeaderCellDefDirective, TableColumnDefDirective, + TableScrollWrapperDirective, ], exports: [ TableComponent, @@ -38,6 +40,7 @@ import { TableComponent } from './table.component'; TableCellDefDirective, TableHeaderCellDefDirective, TableColumnDefDirective, + TableScrollWrapperDirective, ], }) export class TableModule {} diff --git a/stories/table/index.ts b/stories/table/index.ts index 9ced23a7f..17860088f 100644 --- a/stories/table/index.ts +++ b/stories/table/index.ts @@ -5,6 +5,7 @@ import { storiesOf } from '@storybook/angular'; import { ExpandDemoComponent } from './expand-demo/expand-demo.component'; import { SortDemoComponent } from './sort-demo/sort-demo.component'; +import { StickyColumnsDemoComponent } from './sticky-columns/sticky-columns-demo.component'; export const DATA_SOURCE: Element[] = [ { id: 1, name: 'element1', displayName: 'Element One', value: 5 }, @@ -80,6 +81,13 @@ storiesOf('Table', module) declarations: [ExpandDemoComponent], }, component: ExpandDemoComponent, + })) + .add('sticky-columns', () => ({ + moduleMetadata: { + imports: [TableModule], + declarations: [StickyColumnsDemoComponent], + }, + component: StickyColumnsDemoComponent, })); export interface Element { diff --git a/stories/table/sticky-columns/sticky-columns-demo.component.html b/stories/table/sticky-columns/sticky-columns-demo.component.html new file mode 100644 index 000000000..b8b65f01b --- /dev/null +++ b/stories/table/sticky-columns/sticky-columns-demo.component.html @@ -0,0 +1,94 @@ +
+ + + + No. + + {{ item[0] }} + + + + header cell + + + {{ item[1] }} + + + + + header cell + + + {{ item[2] }} + + + + + header cell + + + {{ item[3] }} + + + + + header cell + + + {{ item[4] }} + + + + + header cell + + + {{ item[5] }} + + + + + header cell + + + {{ item[6] }} + + + + + header cell + + + {{ item[7] }} + + + + + header cell + + + {{ item[8] }} + + + + + header cell + + + {{ item[9] }} + + + + + header cell + + + {{ item[10] }} + + + + + +
diff --git a/stories/table/sticky-columns/sticky-columns-demo.component.scss b/stories/table/sticky-columns/sticky-columns-demo.component.scss new file mode 100644 index 000000000..a5c63c0c3 --- /dev/null +++ b/stories/table/sticky-columns/sticky-columns-demo.component.scss @@ -0,0 +1,6 @@ +.aui-table__scroll-wrapper { + .aui-table__header-cell, + .aui-table__cell { + flex: 1 0 100px; + } +} diff --git a/stories/table/sticky-columns/sticky-columns-demo.component.ts b/stories/table/sticky-columns/sticky-columns-demo.component.ts new file mode 100644 index 000000000..b286f7624 --- /dev/null +++ b/stories/table/sticky-columns/sticky-columns-demo.component.ts @@ -0,0 +1,16 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; + +@Component({ + templateUrl: 'sticky-columns-demo.component.html', + styleUrls: ['sticky-columns-demo.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class StickyColumnsDemoComponent { + columns = ['no'].concat( + Array.from({ length: 10 }).map((_, index) => `cell${index + 1}`), + ); + + dataSource = Array.from({ length: 20 }).map((_, i) => + ['1'].concat(Array.from({ length: 10 }).map((_, j) => `cell${i}${j}`)), + ); +} From 2ced08fb53c5c05578ef24b1646045b5c2c6094d Mon Sep 17 00:00:00 2001 From: JounQin Date: Fri, 18 Jun 2021 11:40:39 +0800 Subject: [PATCH 02/10] feat: support observable active status for tab component (#130) --- package.json | 1 + src/tabs/public-api.ts | 1 + src/tabs/tab-context.service.ts | 32 +++++++++++++++ src/tabs/tab-group.component.spec.ts | 16 ++++---- src/tabs/tab-group.component.ts | 4 +- src/tabs/tab-header.component.ts | 4 +- src/tabs/tab.component.ts | 12 +++--- stories/tabs/active-test.component.ts | 38 ++++++++++++++++++ stories/tabs/tabs.stories.mdx | 56 +++++++++++++++++++++++++-- 9 files changed, 142 insertions(+), 22 deletions(-) create mode 100644 src/tabs/tab-context.service.ts create mode 100644 stories/tabs/active-test.component.ts diff --git a/package.json b/package.json index fb870abc9..363362660 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "build": "ng-packagr && yarn build:copy", "build:copy": "node scripts/copy-resources", "debug": "ng-packagr -p ng-package.debug.json && yarn build:copy --debug", + "dev": "yarn start", "lint": "run-p lint:*", "lint:es": "cross-env PARSER_NO_WATCH=true eslint . --cache --ext js,md,mdx,ts -f friendly", "lint:style": "stylelint --cache '{src,stories}/**/*.{html,scss,ts}'", diff --git a/src/tabs/public-api.ts b/src/tabs/public-api.ts index 141423c41..aabd4685f 100644 --- a/src/tabs/public-api.ts +++ b/src/tabs/public-api.ts @@ -6,3 +6,4 @@ export * from './tab-header.component'; export * from './tab-directives'; export * from './tab.component'; export * from './tab-header-active-indicator.component'; +export * from './tab-context.service'; diff --git a/src/tabs/tab-context.service.ts b/src/tabs/tab-context.service.ts new file mode 100644 index 000000000..99e0e501c --- /dev/null +++ b/src/tabs/tab-context.service.ts @@ -0,0 +1,32 @@ +import { Injectable, Optional, SkipSelf } from '@angular/core'; +import { BehaviorSubject, Observable, combineLatest } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; + +@Injectable() +export class TabContextService { + private readonly active$$ = new BehaviorSubject(false); + + /** + * Whether the tab is currently active. + */ + get isActive() { + return this.active$$.value; + } + + set isActive(isActive: boolean) { + this.active$$.next(isActive); + } + + readonly active$: Observable = (this._parent + ? combineLatest([this._parent.active$, this.active$$]).pipe( + map(([a, b]) => a && b), + ) + : this.active$$ + ).pipe(distinctUntilChanged()); + + constructor( + @Optional() + @SkipSelf() + private readonly _parent: TabContextService, + ) {} +} diff --git a/src/tabs/tab-group.component.spec.ts b/src/tabs/tab-group.component.spec.ts index 7b9097102..46fa39791 100644 --- a/src/tabs/tab-group.component.spec.ts +++ b/src/tabs/tab-group.component.spec.ts @@ -258,16 +258,16 @@ describe('TabGroupComponent', () => { it('should set the isActive flag on each of the tabs', () => { fixture.detectChanges(); const tabs = fixture.componentInstance.tabs.toArray(); - expect(tabs[0].isActive).toBe(true); - expect(tabs[1].isActive).toBe(false); - expect(tabs[2].isActive).toBe(false); - expect(tabs[3].isActive).toBe(false); + expect(tabs[0].tabContext.isActive).toBe(true); + expect(tabs[1].tabContext.isActive).toBe(false); + expect(tabs[2].tabContext.isActive).toBe(false); + expect(tabs[3].tabContext.isActive).toBe(false); fixture.componentInstance.selectedIndex = 2; fixture.detectChanges(); - expect(tabs[0].isActive).toBe(false); - expect(tabs[1].isActive).toBe(false); - expect(tabs[2].isActive).toBe(true); - expect(tabs[3].isActive).toBe(false); + expect(tabs[0].tabContext.isActive).toBe(false); + expect(tabs[1].tabContext.isActive).toBe(false); + expect(tabs[2].tabContext.isActive).toBe(true); + expect(tabs[3].tabContext.isActive).toBe(false); }); }); }); diff --git a/src/tabs/tab-group.component.ts b/src/tabs/tab-group.component.ts index 5d86bda33..3a9f8cd9c 100644 --- a/src/tabs/tab-group.component.ts +++ b/src/tabs/tab-group.component.ts @@ -190,7 +190,7 @@ export class TabGroupComponent // Setup the position for each tab and optionally setup an origin on the next selected tab. this._tabs.forEach((tab: TabComponent, index: number) => { tab.position = index - indexToSelect; - tab.isActive = index === indexToSelect; + tab.tabContext.isActive = index === indexToSelect; // If there is already a selected tab, then set up an origin for the next selected tab // if it doesn't have one already. if (this._selectedIndex != null && tab.position === 0 && !tab.origin) { @@ -226,7 +226,7 @@ export class TabGroupComponent // explicit change that selects a different tab. if (indexToSelect === this._selectedIndex) { for (const [i, tab] of tabs.entries()) { - if (tab.isActive) { + if (tab.tabContext.isActive) { // Assign both to the `_indexToSelect` and `_selectedIndex` so we don't fire a changed // event, otherwise the consumer may end up in an infinite loop in some edge cases like // adding a tab within the `selectedIndexChange` event. diff --git a/src/tabs/tab-header.component.ts b/src/tabs/tab-header.component.ts index c15d3add1..545801376 100644 --- a/src/tabs/tab-header.component.ts +++ b/src/tabs/tab-header.component.ts @@ -111,11 +111,11 @@ export class TabHeaderComponent /** Event emitted when the option is selected. */ @Output() - readonly selectFocusedIndex = new EventEmitter(); + readonly selectFocusedIndex = new EventEmitter(); /** Event emitted when a label is focused. */ @Output() - readonly indexFocused = new EventEmitter(); + readonly indexFocused = new EventEmitter(); @ContentChild(TabHeaderAddonDirective, { static: false }) _headerAddon: TabHeaderAddonDirective; diff --git a/src/tabs/tab.component.ts b/src/tabs/tab.component.ts index 02058a6df..4dd429466 100644 --- a/src/tabs/tab.component.ts +++ b/src/tabs/tab.component.ts @@ -20,6 +20,7 @@ import { Subject } from 'rxjs'; import { coerceAttrBoolean } from '../utils'; +import { TabContextService } from './tab-context.service'; import { TabContentDirective, TabLabelDirective } from './tab-directives'; @Component({ @@ -29,6 +30,7 @@ import { TabContentDirective, TabLabelDirective } from './tab-directives'; encapsulation: ViewEncapsulation.None, changeDetection: ChangeDetectionStrategy.OnPush, preserveWhitespaces: false, + providers: [TabContextService], }) export class TabComponent implements AfterContentInit, OnDestroy, OnChanges { /** Plain text label for the tab, used when there is no template label. */ @@ -70,11 +72,6 @@ export class TabComponent implements AfterContentInit, OnDestroy, OnChanges { return this._contentPortal; } - /** - * Whether the tab is currently active. - */ - isActive = false; - /** * The relatively indexed position where 0 represents the center, negative is left, and positive * represents the right. @@ -95,7 +92,10 @@ export class TabComponent implements AfterContentInit, OnDestroy, OnChanges { private _disabled = false; - constructor(private readonly _viewContainerRef: ViewContainerRef) {} + constructor( + private readonly _viewContainerRef: ViewContainerRef, + public readonly tabContext: TabContextService, + ) {} ngAfterContentInit(): void { this._contentPortal = new TemplatePortal( diff --git a/stories/tabs/active-test.component.ts b/stories/tabs/active-test.component.ts new file mode 100644 index 000000000..754a56bf3 --- /dev/null +++ b/stories/tabs/active-test.component.ts @@ -0,0 +1,38 @@ +import { TabComponent, TabContextService } from '@alauda/ui'; +import { + ChangeDetectionStrategy, + Component, + OnDestroy, + OnInit, +} from '@angular/core'; +import { Subject } from 'rxjs'; +import { takeUntil } from 'rxjs/operators'; + +@Component({ + // tslint:disable-next-line component-selector + selector: 'tabs-active-test', + template: ` `, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ActiveTestComponent implements OnInit, OnDestroy { + private readonly destroy$$ = new Subject(); + + constructor( + // it is only used to get `textLabel` for storybook demo + private readonly tabComponent: TabComponent, + private readonly tabContext: TabContextService, + ) {} + + ngOnInit() { + this.tabContext.active$ + .pipe(takeUntil(this.destroy$$)) + .subscribe(active => { + console.log(this.tabComponent.textLabel, active); + }); + } + + ngOnDestroy() { + this.destroy$$.next(); + this.destroy$$.complete(); + } +} diff --git a/stories/tabs/tabs.stories.mdx b/stories/tabs/tabs.stories.mdx index 3f78eff35..9c121ef11 100644 --- a/stories/tabs/tabs.stories.mdx +++ b/stories/tabs/tabs.stories.mdx @@ -1,6 +1,7 @@ import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; import { moduleMetadata } from '@storybook/angular'; +import { ActiveTestComponent } from './active-test.component'; import { LazyTestComponent } from './lazy-test.component'; import { @@ -18,7 +19,7 @@ import { decorators={[ moduleMetadata({ imports: [ButtonModule, IconModule, FormModule, RadioModule, TabsModule], - declarations: [LazyTestComponent], + declarations: [ActiveTestComponent, LazyTestComponent], }), ]} /> @@ -204,13 +205,13 @@ import { - + Content 1 - + Content 2 - + Content 3 @@ -223,6 +224,53 @@ import { +## Tab Active 状态 + + + + {{ + template: /* HTML */ ` + + + + 是 + 否 + + + + + Content 1 + + + + + + + Content 2-1 + + + + + Content 2-2 + + + + + + + `, + props: { + lazy: true, + }, + }} + + + ## TabGroupComponent Inputs | 名称 | 类型 | 默认值 | 描述 | From e1c92c6880e5555282ec9087d64bff23c3c3b9dc Mon Sep 17 00:00:00 2001 From: JounQin Date: Mon, 21 Jun 2021 10:43:00 +0800 Subject: [PATCH 03/10] fix: only get first value from observable for beforeAction (#131) --- src/dialog/confirm-dialog/confirm-dialog.component.ts | 3 ++- src/dialog/dialog.service.spec.ts | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/dialog/confirm-dialog/confirm-dialog.component.ts b/src/dialog/confirm-dialog/confirm-dialog.component.ts index 66003ba70..374fe6604 100644 --- a/src/dialog/confirm-dialog/confirm-dialog.component.ts +++ b/src/dialog/confirm-dialog/confirm-dialog.component.ts @@ -5,6 +5,7 @@ import { ViewEncapsulation, } from '@angular/core'; import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; import { Bem, buildBem } from '../../utils'; import { DialogRef } from '../dialog-ref'; @@ -95,7 +96,7 @@ export class ConfirmDialogComponent { const result = (beforeAction as CustomBeforeAction)(); if (result instanceof Observable) { - return result.toPromise(); + return result.pipe(first()).toPromise(); } return result; diff --git a/src/dialog/dialog.service.spec.ts b/src/dialog/dialog.service.spec.ts index b9a2ee4e4..9f29dad10 100644 --- a/src/dialog/dialog.service.spec.ts +++ b/src/dialog/dialog.service.spec.ts @@ -161,7 +161,7 @@ describe('DialogService', () => { dialogService .confirm({ title: '', - beforeConfirm: () => timer(100), + beforeConfirm: () => timer(100, 100), }) // eslint-disable-next-line sonarjs/no-identical-functions .then(() => { From c8dece2f7318530dfd263263cabce32dd36e35ca Mon Sep 17 00:00:00 2001 From: zangguodong <237974119@qq.com> Date: Tue, 22 Jun 2021 11:43:24 +0800 Subject: [PATCH 04/10] feat: date picker & time picker (#124) Co-authored-by: Guodong --- package.json | 4 +- src/date-picker/calendar/constant.ts | 37 +++ .../calendar/date-picker-panel/component.ts | 165 ++++++++++ .../calendar/date-picker-panel/style.scss | 8 + .../calendar/date-picker-panel/template.html | 61 ++++ src/date-picker/calendar/footer/component.ts | 29 ++ src/date-picker/calendar/footer/style.scss | 9 + src/date-picker/calendar/footer/template.html | 18 ++ src/date-picker/calendar/header/component.ts | 127 ++++++++ src/date-picker/calendar/header/style.scss | 39 +++ src/date-picker/calendar/header/template.html | 105 +++++++ .../calendar/panel/picker-panel.style.scss | 127 ++++++++ .../calendar/panel/picker-panel.template.html | 48 +++ .../calendar/panel/picker-panel.ts | 202 ++++++++++++ .../calendar/range-picker-panel/component.ts | 263 ++++++++++++++++ .../calendar/range-picker-panel/style.scss | 51 +++ .../calendar/range-picker-panel/template.html | 105 +++++++ src/date-picker/calendar/util.ts | 192 ++++++++++++ src/date-picker/date-picker.module.ts | 49 +++ src/date-picker/date-picker.type.ts | 27 ++ .../date-picker/date-picker.component.ts | 115 +++++++ .../date-picker/date-picker.template.html | 40 +++ src/date-picker/public-api.ts | 10 + .../range-picker/range-picker.component.ts | 92 ++++++ .../range-picker/range-picker.style.scss | 8 + .../range-picker/range-picker.template.html | 37 +++ src/date-picker/trigger/trigger.component.ts | 89 ++++++ src/date-picker/trigger/trigger.style.scss | 72 +++++ src/date-picker/trigger/trigger.template.html | 64 ++++ src/i18n/i18n.module.ts | 9 + src/i18n/i18n.pipe.ts | 14 + src/i18n/i18n.service.ts | 44 +++ src/i18n/i18n.type.ts | 13 + src/i18n/language/en.ts | 24 ++ src/i18n/language/zh.ts | 24 ++ src/i18n/public-api.ts | 6 + src/icon/icons.ts | 2 +- src/public-api.ts | 3 + src/theme/_mixin.scss | 5 + src/theme/_var.scss | 13 + src/time-picker/component.ts | 174 +++++++++++ src/time-picker/constant.ts | 5 + src/time-picker/panel/panel.component.ts | 291 ++++++++++++++++++ src/time-picker/panel/panel.style.scss | 82 +++++ src/time-picker/panel/panel.template.html | 101 ++++++ src/time-picker/public-api.ts | 5 + src/time-picker/style.scss | 53 ++++ src/time-picker/template.html | 53 ++++ src/time-picker/time-picker.module.ts | 34 ++ src/time-picker/time-picker.type.ts | 25 ++ stories/date-picker/date-picker.stories.mdx | 237 ++++++++++++++ stories/time-picker/time-picker.stories.mdx | 84 +++++ tsconfig.lib.json | 7 + yarn.lock | 5 + 54 files changed, 3504 insertions(+), 2 deletions(-) create mode 100644 src/date-picker/calendar/constant.ts create mode 100644 src/date-picker/calendar/date-picker-panel/component.ts create mode 100644 src/date-picker/calendar/date-picker-panel/style.scss create mode 100644 src/date-picker/calendar/date-picker-panel/template.html create mode 100644 src/date-picker/calendar/footer/component.ts create mode 100644 src/date-picker/calendar/footer/style.scss create mode 100644 src/date-picker/calendar/footer/template.html create mode 100644 src/date-picker/calendar/header/component.ts create mode 100644 src/date-picker/calendar/header/style.scss create mode 100644 src/date-picker/calendar/header/template.html create mode 100644 src/date-picker/calendar/panel/picker-panel.style.scss create mode 100644 src/date-picker/calendar/panel/picker-panel.template.html create mode 100644 src/date-picker/calendar/panel/picker-panel.ts create mode 100644 src/date-picker/calendar/range-picker-panel/component.ts create mode 100644 src/date-picker/calendar/range-picker-panel/style.scss create mode 100644 src/date-picker/calendar/range-picker-panel/template.html create mode 100644 src/date-picker/calendar/util.ts create mode 100644 src/date-picker/date-picker.module.ts create mode 100644 src/date-picker/date-picker.type.ts create mode 100644 src/date-picker/date-picker/date-picker.component.ts create mode 100644 src/date-picker/date-picker/date-picker.template.html create mode 100644 src/date-picker/public-api.ts create mode 100644 src/date-picker/range-picker/range-picker.component.ts create mode 100644 src/date-picker/range-picker/range-picker.style.scss create mode 100644 src/date-picker/range-picker/range-picker.template.html create mode 100644 src/date-picker/trigger/trigger.component.ts create mode 100644 src/date-picker/trigger/trigger.style.scss create mode 100644 src/date-picker/trigger/trigger.template.html create mode 100644 src/i18n/i18n.module.ts create mode 100644 src/i18n/i18n.pipe.ts create mode 100644 src/i18n/i18n.service.ts create mode 100644 src/i18n/i18n.type.ts create mode 100644 src/i18n/language/en.ts create mode 100644 src/i18n/language/zh.ts create mode 100644 src/i18n/public-api.ts create mode 100644 src/time-picker/component.ts create mode 100644 src/time-picker/constant.ts create mode 100644 src/time-picker/panel/panel.component.ts create mode 100644 src/time-picker/panel/panel.style.scss create mode 100644 src/time-picker/panel/panel.template.html create mode 100644 src/time-picker/public-api.ts create mode 100644 src/time-picker/style.scss create mode 100644 src/time-picker/template.html create mode 100644 src/time-picker/time-picker.module.ts create mode 100644 src/time-picker/time-picker.type.ts create mode 100644 stories/date-picker/date-picker.stories.mdx create mode 100644 stories/time-picker/time-picker.stories.mdx create mode 100644 tsconfig.lib.json diff --git a/package.json b/package.json index 363362660..a75f0c030 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,7 @@ "ui" ], "scripts": { - "build": "ng-packagr && yarn build:copy", + "build": "ng-packagr -c tsconfig.lib.json && yarn build:copy", "build:copy": "node scripts/copy-resources", "debug": "ng-packagr -p ng-package.debug.json && yarn build:copy --debug", "dev": "yarn start", @@ -44,6 +44,7 @@ "@angular/forms": "^10.0.0 || ^11.0.0", "@angular/platform-browser": "^10.0.0 || ^11.0.0", "clipboard-polyfill": "^3.0.0", + "dayjs": "^1.0.0", "rxjs": "^6.0.0" }, "devDependencies": { @@ -73,6 +74,7 @@ "@types/node": "^14.14.31", "@types/resize-observer-browser": "^0.1.5", "clipboard-polyfill": "^3.0.2", + "dayjs": "^1.10.4", "gulp": "^4.0.2", "gulp-dart-sass": "^1.0.2", "jest": "^26.6.3", diff --git a/src/date-picker/calendar/constant.ts b/src/date-picker/calendar/constant.ts new file mode 100644 index 000000000..c4e3d8d46 --- /dev/null +++ b/src/date-picker/calendar/constant.ts @@ -0,0 +1,37 @@ +import { DateNavRange } from '../date-picker.type'; + +export const DATE = 'date'; +export const DAY = 'day'; +export const MONTH = 'month'; +export const YEAR = 'year'; +export const HOUR = 'hour'; +export const MINUTE = 'minute'; +export const SECOND = 'second'; + +// 根据日期类型,分为年,月,日 +export enum DatePickerType { + Day = 'date', + Month = 'month', + Year = 'year', +} + +export const MONTH_PANEL_COLUMN_COUNT = 3; +export const MONTH_PANEL_ROW_COUNT = 4; + +export const YEAR_PANEL_COLUMN_COUNT = 3; +export const YEAR_PANEL_ROW_COUNT = 4; + +export const DAY_PANEL_COLUMN_COUNT = 7; +export const DAY_PANEL_ROW_COUNT = 6; + +export const DATE_TYPES = [ + DatePickerType.Year, + DatePickerType.Month, + DatePickerType.Day, +]; + +export const DATE_NAV_RANGES = [ + DateNavRange.Decade, + DateNavRange.Year, + DateNavRange.Month, +]; diff --git a/src/date-picker/calendar/date-picker-panel/component.ts b/src/date-picker/calendar/date-picker-panel/component.ts new file mode 100644 index 000000000..69e8626f1 --- /dev/null +++ b/src/date-picker/calendar/date-picker-panel/component.ts @@ -0,0 +1,165 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, + TemplateRef, + ViewEncapsulation, + forwardRef, +} from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import dayjs, { ConfigType, Dayjs } from 'dayjs'; + +import { CommonFormControl } from '../../../form/common-form'; +import { TimePickerModel } from '../../../time-picker/time-picker.type'; +import { DateNavRange, DisabledTimeFn } from '../../date-picker.type'; +import { DatePickerType } from '../constant'; +import { + getNavRangeByType, + getTimePickerModel, + getTypeByNavRange, + nextNavRangeType, + updateDate, + updateDateByTimeModel, +} from '../util'; + +@Component({ + selector: 'aui-date-picker-panel', + templateUrl: './template.html', + styleUrls: ['./style.scss'], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DatePickerPanelComponent), + multi: true, + }, + ], +}) +export class DatePickerPanelComponent extends CommonFormControl { + @Input() + clearable = true; + + @Input() + clearText: string; + + @Input() + set type(type: DatePickerType) { + this.navRange = getNavRangeByType(type); + this._type = type; + } + + get type() { + return this._type; + } + + private _type: DatePickerType; + + @Input() + showTime = false; + + @Input() + disabledDate: (date: Dayjs, type: DateNavRange) => boolean = () => false; + + @Input() + disabledTime: DisabledTimeFn = () => null; + + @Input() + weekStartDay = 0; + + @Input() + showFooter = true; + + @Input() + footerTemplate: TemplateRef; + + @Input() + extraFooter: TemplateRef; + + @Input() + minDate: Dayjs; + + @Input() + maxDate: Dayjs; + + @Output() + confirm = new EventEmitter(); + + @Output() + clear = new EventEmitter(); + + private _cacheSelectedDate: Dayjs; + private _cacheDisabledTimeFn: ReturnType; + + getDisabledTimeFn( + selectedDate: Dayjs, + type: keyof ReturnType, + ) { + if (selectedDate !== this._cacheSelectedDate) { + this._cacheDisabledTimeFn = this.disabledTime(selectedDate); + this._cacheSelectedDate = selectedDate; + } + return this._cacheDisabledTimeFn?.[type]; + } + + navRange: DateNavRange; + + get currentNavType() { + return getTypeByNavRange(this.navRange); + } + + anchor: Dayjs; + + selectedDate: Dayjs; + + selectedTime: TimePickerModel; + + DateNavRange = DateNavRange; + DatePickerType = DatePickerType; + + writeValue(obj: Dayjs) { + super.writeValue(obj); + this.selectedDate = obj; + this.selectedTime = getTimePickerModel(obj); + this.anchor = obj || dayjs(); + this.cdr.markForCheck(); + } + + panelValueChange(value: Dayjs) { + this.selectedDate = updateDate( + this.selectedDate || dayjs(), + value, + this.currentNavType, + ); + this.anchor = this.selectedDate; + this.selectedDate = updateDateByTimeModel( + this.selectedDate, + this.selectedTime, + ); + if (this.type === this.currentNavType) { + this.emitValueChange(this.selectedDate); + } + const nextNavRange = nextNavRangeType( + this.navRange, + getNavRangeByType(this.type), + ); + if (this.navRange !== nextNavRange) { + this.navRange = nextNavRange; + } + } + + confirmValue(value?: ConfigType, closeAfterConfirm = true) { + this.emitValueChange(value ? dayjs(value) : this.selectedDate); + closeAfterConfirm && this.confirm.next(); + } + + timeDateChange(time: TimePickerModel) { + if (!this.selectedDate) { + return; + } + this.selectedDate = updateDateByTimeModel(this.selectedDate, time); + this.emitValueChange(this.selectedDate); + } +} diff --git a/src/date-picker/calendar/date-picker-panel/style.scss b/src/date-picker/calendar/date-picker-panel/style.scss new file mode 100644 index 000000000..762905853 --- /dev/null +++ b/src/date-picker/calendar/date-picker-panel/style.scss @@ -0,0 +1,8 @@ +@import '../../../theme/pattern'; + +.aui-date-picker-panel__wrapper { + display: flex; + flex-direction: column; + font-size: $font-size-regular; + font-weight: $font-weight-normal; +} diff --git a/src/date-picker/calendar/date-picker-panel/template.html b/src/date-picker/calendar/date-picker-panel/template.html new file mode 100644 index 000000000..de70440d7 --- /dev/null +++ b/src/date-picker/calendar/date-picker-panel/template.html @@ -0,0 +1,61 @@ +
+ + + + + + + + + + + + + +
diff --git a/src/date-picker/calendar/footer/component.ts b/src/date-picker/calendar/footer/component.ts new file mode 100644 index 000000000..d02000188 --- /dev/null +++ b/src/date-picker/calendar/footer/component.ts @@ -0,0 +1,29 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, + ViewEncapsulation, +} from '@angular/core'; + +@Component({ + selector: 'aui-calendar-footer', + templateUrl: './template.html', + styleUrls: ['./style.scss'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CalendarFooterComponent { + @Input() + clearable = true; + + @Input() + clearText: string; + + @Output() + confirm = new EventEmitter(); + + @Output() + clear = new EventEmitter(); +} diff --git a/src/date-picker/calendar/footer/style.scss b/src/date-picker/calendar/footer/style.scss new file mode 100644 index 000000000..49c72e860 --- /dev/null +++ b/src/date-picker/calendar/footer/style.scss @@ -0,0 +1,9 @@ +@import '../../../theme/mixin'; + +.aui-calendar-footer__wrapper { + display: flex; + flex-direction: row; + justify-content: space-between; + border-top: $border-width $border-style #e8e8e8; + padding: 8px 0 0; +} diff --git a/src/date-picker/calendar/footer/template.html b/src/date-picker/calendar/footer/template.html new file mode 100644 index 000000000..5b9f7be63 --- /dev/null +++ b/src/date-picker/calendar/footer/template.html @@ -0,0 +1,18 @@ + diff --git a/src/date-picker/calendar/header/component.ts b/src/date-picker/calendar/header/component.ts new file mode 100644 index 000000000..4d7b132ec --- /dev/null +++ b/src/date-picker/calendar/header/component.ts @@ -0,0 +1,127 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, + ViewEncapsulation, +} from '@angular/core'; +import dayjs, { ConfigType, Dayjs } from 'dayjs'; + +import { buildBem } from '../../../utils'; +import { + CalendarHeaderRange, + DateNavRange, + Side, +} from '../../date-picker.type'; +import { MONTH, YEAR } from '../constant'; +import { calcRangeValue } from '../util'; + +const bem = buildBem('aui-calendar-header'); + +@Component({ + selector: 'aui-calendar-header', + templateUrl: './template.html', + styleUrls: ['./style.scss'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CalendarHeaderComponent { + @Input() + dateNavRange = DateNavRange.Month; + + @Input() + anchor = dayjs(); + + @Input() + maxAvail?: ConfigType; + + private get _maxAvail() { + return this.maxAvail ? dayjs(this.maxAvail) : null; + } + + @Input() + minAvail?: ConfigType; + + private get _minAvail() { + return this.minAvail ? dayjs(this.minAvail) : null; + } + + @Output() + navRangeChange = new EventEmitter(); + + @Output() + anchorChange = new EventEmitter(); + + get headerRange(): CalendarHeaderRange { + return calcRangeValue(this.dateNavRange, this.anchor); + } + + bem = bem; + + DateNavRange = DateNavRange; + + // maxAvail > current date :right btn hide + // minAvail > current date :left btn hide + shouldShowNav(type: DateNavRange, side: Side) { + const availValue = (side === Side.Left + ? this._minAvail + : this._maxAvail + )?.clone(); + if (!availValue) { + return true; + } + // 对于年的判别,2014-5-1至2015-6-1日,仍当展示按钮 + const constrainDate = [DateNavRange.Month, DateNavRange.Year].includes(type) + ? availValue.add(side === Side.Left ? 1 : -1, type as 'month' | 'year') + : availValue; + return ( + this.compareNavValue(type, constrainDate, this.anchor) === + (side === Side.Left ? -1 : 1) + ); + } + + // @return isBetween|isEqual:0, isBefore:-1,isAfter:1 + compareNavValue(type: DateNavRange, constrain: Dayjs, anchor: Dayjs) { + const range = calcRangeValue(type, anchor); + const constrainValue = constrain; + if (type === DateNavRange.Decade) { + if (constrainValue.isBetween(range.start, range.end, YEAR)) { + return 0; + } + return constrainValue.isBefore(range.start) ? -1 : 1; + } + return constrainValue.isSame(range.start, MONTH) + ? 0 + : constrainValue.isBefore(range.start, MONTH) + ? -1 + : 1; + } + + navHead(range: DateNavRange, value: number) { + let anchor: Dayjs; + switch (range) { + case DateNavRange.Month: { + anchor = this.anchor.add(value, MONTH); + break; + } + case DateNavRange.Year: { + anchor = this.anchor.add(value, YEAR); + break; + } + case DateNavRange.Decade: + anchor = this.anchor.add(value, YEAR); + break; + } + this.anchorChange.next(anchor); + } + + clickNav = (range: DateNavRange) => { + if (![DateNavRange.Month, DateNavRange.Year].includes(range)) { + return; + } + this.navRangeChange.next( + range === DateNavRange.Month ? DateNavRange.Year : DateNavRange.Decade, + ); + }; +} diff --git a/src/date-picker/calendar/header/style.scss b/src/date-picker/calendar/header/style.scss new file mode 100644 index 000000000..6fc7ac9f1 --- /dev/null +++ b/src/date-picker/calendar/header/style.scss @@ -0,0 +1,39 @@ +@import '../../../theme/var'; +$block: aui-calendar-header; + +.#{$block} { + &__container { + height: 24px; + display: flex; + align-items: center; + justify-content: space-between; + } + &__nav-action { + width: 28px; + a { + color: #969799; + font-size: $font-size-regular; + &:hover { + color: $color-primary; + } + } + } + &__nav-content { + flex: 1; + display: flex; + justify-content: center; + align-items: center; + font-size: 16px; + color: $color-text-main; + font-weight: $font-weight-bold; + a { + color: $color-text-main; + &:hover { + color: #006eff; + } + } + a + a { + margin-left: 5px; + } + } +} diff --git a/src/date-picker/calendar/header/template.html b/src/date-picker/calendar/header/template.html new file mode 100644 index 000000000..f41878007 --- /dev/null +++ b/src/date-picker/calendar/header/template.html @@ -0,0 +1,105 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/date-picker/calendar/panel/picker-panel.style.scss b/src/date-picker/calendar/panel/picker-panel.style.scss new file mode 100644 index 000000000..370b7e6bd --- /dev/null +++ b/src/date-picker/calendar/panel/picker-panel.style.scss @@ -0,0 +1,127 @@ +// stylelint-disable plugin/no-low-performance-animation-properties +@import '../../../theme/mixin'; + +$block: aui-picker-panel; +$in-range-bgcolor: #edf4ff; +$left-in-range-shadow: -6px 0 0 0 $in-range-bgcolor; +$right-in-range-shadow: 6px 0 0 0 $in-range-bgcolor; + +$left-disabled-shadow: -6px 0 0 0 $color-disabled-bg; +$right-disabled-shadow: 6px 0 0 0 $color-disabled-bg; + +$left-mask-shadow: -12px 0 0 0 white; + +.#{$block} { + &__container { + height: min-content; + box-sizing: border-box; + justify-content: stretch; + display: flex; + flex-direction: column; + margin: 12px 9px; + width: $range-picker-header-width; + align-items: center; + &.month-panel-container, + &.year-panel-container { + button { + width: 56px; + height: 28px !important; + } + } + &.day-panel-container { + height: min-content; + padding: 0; + margin: 0; + button { + width: $date-picker-cell-size; + height: $date-picker-cell-size !important; + } + + .#{$block}__row + .#{$block}__row { + margin-top: $date-picker-row-gap; + } + } + } + + &__row + &__row { + margin-top: $date-picker-seprator-gap; + } + + &__row { + flex: 1; + display: flex; + align-items: center; + font-size: $font-size-regular; + font-weight: normal; + + &.week-header { + span { + display: inline-block; + margin: 0; + width: 36px; + text-align: center; + } + + color: $color-text-help; + margin-bottom: 8px; + } + } + + &__cell { + display: inline-flex; + position: relative; + align-items: center; + justify-content: center; + margin-inline: 6px; + + button { + box-sizing: content-box; + // Use trackby to enhance performence, which reuses button and may cause size change with animation.So overwrite transition + transition: color $animation-duration $animation-interpolation, + background-color $animation-duration $animation-interpolation; + } + + &.in-range { + background-color: $in-range-bgcolor; + box-shadow: $left-in-range-shadow, $right-in-range-shadow; + } + + &.is-range-end { + box-shadow: $left-in-range-shadow; + } + + &.is-range-start { + box-shadow: $right-in-range-shadow; + } + + &.is-disabled { + background-color: $color-disabled-bg; + box-shadow: $left-disabled-shadow, $right-disabled-shadow; + } + + &.is-disabled + &:not(.is-disabled):not(.is-range-start) { + box-shadow: $left-mask-shadow; + } + + &.is-disabled + &:not(.is-disabled).is-range-start { + box-shadow: $left-mask-shadow, $right-in-range-shadow; + } + + &:not(.is-disabled) + &.is-disabled { + box-shadow: $right-disabled-shadow; + } + + &.is-today button { + border-color: #1890ff; + } + + &.is-active button { + background-color: $color-primary; + color: #fff; + } + &.is-background button { + background-color: initial !important; + color: rgba(0, 0, 0, 0.25); + } + } +} diff --git a/src/date-picker/calendar/panel/picker-panel.template.html b/src/date-picker/calendar/panel/picker-panel.template.html new file mode 100644 index 000000000..6057eb6c9 --- /dev/null +++ b/src/date-picker/calendar/panel/picker-panel.template.html @@ -0,0 +1,48 @@ +
+
+ + {{ item | auiI18n }} + +
+
+ + +
+
diff --git a/src/date-picker/calendar/panel/picker-panel.ts b/src/date-picker/calendar/panel/picker-panel.ts new file mode 100644 index 000000000..e63294aa7 --- /dev/null +++ b/src/date-picker/calendar/panel/picker-panel.ts @@ -0,0 +1,202 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + EventEmitter, + Input, + OnChanges, + Output, + ViewEncapsulation, +} from '@angular/core'; +import dayjs, { Dayjs } from 'dayjs'; +import isBetween from 'dayjs/plugin/isBetween'; + +import { buildBem } from '../../../utils'; +import { DateNavRange } from '../../date-picker.type'; +import { + DAY, + DAY_PANEL_COLUMN_COUNT, + DAY_PANEL_ROW_COUNT, + DatePickerType, + MONTH, + MONTH_PANEL_COLUMN_COUNT, + MONTH_PANEL_ROW_COUNT, + YEAR, + YEAR_PANEL_COLUMN_COUNT, + YEAR_PANEL_ROW_COUNT, +} from '../constant'; +import { + DateCell, + calcRangeValue, + composeDisabledDateFn, + formatDate, + getDatePanelIterable, + getNavRangeByType, + getTypeByNavRange, + sortDates, +} from '../util'; + +dayjs.extend(isBetween); + +const bem = buildBem('aui-picker-panel'); + +@Component({ + selector: 'aui-picker-panel', + templateUrl: './picker-panel.template.html', + styleUrls: ['./picker-panel.style.scss'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class PickerPanelComponent implements OnChanges { + @Input() + navRange!: DateNavRange; + + @Input() + type!: DatePickerType; + + @Input() + anchor = dayjs(); + + @Input() + matchDates: Dayjs[]; + + @Input() + disabledDate: (date: Dayjs, navRange: DateNavRange) => boolean = () => false; + + @Input() + set weekStartDay(day: number) { + const normalized = Math.max(Math.min(Math.round(day), 6), 0); + this.weekDefs = [ + ...this.weekDefs.slice(normalized), + ...this.weekDefs.slice(0, normalized), + ]; + this._weekStartDay = normalized; + } + + get weekStartDay() { + return this._weekStartDay; + } + + @Input() + minDate: Dayjs; + + @Input() + maxDate: Dayjs; + + get disabledDateFn() { + return composeDisabledDateFn( + date => this.minDate && date.isBefore(this.minDate), + date => this.maxDate && date.isAfter(this.maxDate), + this.disabledDate, + ); + } + + private _weekStartDay = 0; + + panelData: DateCell[][]; + + @Output() + select = new EventEmitter(); + + @Output() + hovered = new EventEmitter(); + + bem = bem; + DateNavRange = DateNavRange; + + weekDefs = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + ]; + + constructor(private readonly cdr: ChangeDetectorRef) {} + + ngOnChanges() { + // 如果 dateValue 是一个数组,必然是同一个面板上,否则应当给与不同的面板不同的dateValue值 + const headerRange = calcRangeValue(this.navRange, this.anchor); + this.renderPanelData(headerRange.start, this.navRange); + } + + // 根据当前数据,计算渲染表格 + // eslint-disable-next-line sonarjs/cognitive-complexity + renderPanelData(date: Dayjs, navRange: DateNavRange) { + const value = []; + let colCounts = 0; + let rowCounts = 0; + if (!this.panelData) { + this.panelData = [[]]; + } + const [...values] = getDatePanelIterable(date, navRange, this.weekStartDay); + if (navRange === DateNavRange.Month) { + colCounts = DAY_PANEL_COLUMN_COUNT; + rowCounts = DAY_PANEL_ROW_COUNT; + } else if (navRange === DateNavRange.Decade) { + colCounts = YEAR_PANEL_COLUMN_COUNT; + rowCounts = YEAR_PANEL_ROW_COUNT; + } else if (navRange === DateNavRange.Year) { + colCounts = MONTH_PANEL_COLUMN_COUNT; + rowCounts = MONTH_PANEL_ROW_COUNT; + } + for (let k = 0; k < rowCounts; k++) { + const row = []; + for (let i = 0; i < colCounts; i++) { + const cell = formatDate(navRange, values[k * colCounts + i], date); + cell.isDisabled = this.disabledDateFn(cell.value, navRange); + const sortedDates = sortDates(this.matchDates); + if (sortedDates?.length === 2 && navRange === DateNavRange.Month) { + if ( + cell.value.isBetween(sortedDates[0], sortedDates[1]) && + !cell.isBackground + ) { + cell.inRange = true; + } + cell.isRangeStart = + cell.value.isSame(sortedDates[0], DAY) && !cell.isBackground; + cell.isRangeEnd = + cell.value.isSame(sortedDates[1], DAY) && !cell.isBackground; + if (cell.isRangeStart && cell.isRangeEnd) { + cell.isRangeStart = false; + cell.isRangeEnd = false; + } + } + row.push(cell); + } + value.push(row); + } + this.panelData = value.slice(); + this.cdr.markForCheck(); + } + + match(value: Dayjs) { + if ( + !this.matchDates?.length || + getNavRangeByType(this.type) !== this.navRange + ) { + return false; + } + return this.matchDates?.some(dateValue => + this.navRange === DateNavRange.Decade + ? value.isSame(dateValue, YEAR) + : this.navRange === DateNavRange.Year + ? value.isSame(dateValue, MONTH) + : value.isSame(dateValue, DAY), + ); + } + + matchToday(value: Dayjs) { + return value.isSame(dayjs(), getTypeByNavRange(this.navRange)); + } + + selectValue(value: Dayjs) { + this.select.next(value); + } + + trackByFn() { + return this.navRange; + } +} diff --git a/src/date-picker/calendar/range-picker-panel/component.ts b/src/date-picker/calendar/range-picker-panel/component.ts new file mode 100644 index 000000000..bfe679e78 --- /dev/null +++ b/src/date-picker/calendar/range-picker-panel/component.ts @@ -0,0 +1,263 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, + ViewEncapsulation, + forwardRef, +} from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import dayjs, { Dayjs } from 'dayjs'; + +import { CommonFormControl } from '../../../form/common-form'; +import { TimePickerModel } from '../../../time-picker/time-picker.type'; +import { buildBem } from '../../../utils'; +import { DateNavRange, DisabledTimeFn, Side } from '../../date-picker.type'; +import { DatePickerType, MONTH, YEAR } from '../constant'; +import { + composeDisabledDateFn, + getTimePickerModel, + getTypeByNavRange, + maxDate, + minDate, + nextNavRangeType, + sortDates, + updateDate, + updateDateByTimeModel, +} from '../util'; + +const bem = buildBem('aui-date-range-picker-panel'); + +@Component({ + selector: 'aui-date-range-picker-panel', + templateUrl: './template.html', + styleUrls: ['./style.scss'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DateRangePickerPanelComponent), + multi: true, + }, + ], +}) +export class DateRangePickerPanelComponent extends CommonFormControl { + @Input() + clearable = true; + + @Input() + clearText: string; + + @Input() + showTime = true; + + @Input() + showFooter = true; + + @Input() + disabledDate: (date: Dayjs, navRange: DateNavRange) => boolean = () => false; + + @Input() + disabledTime: { left: DisabledTimeFn; right: DisabledTimeFn } = { + left: () => null, + right: () => null, + }; + + @Input() + weekStartDay = 0; + + @Input() + minDate: Dayjs; + + @Input() + maxDate: Dayjs; + + @Output() + clear = new EventEmitter(); + + @Output() + confirm = new EventEmitter(); + + bem = bem; + Side = Side; + DatePickerType = DatePickerType; + leftDateRange = DateNavRange.Month; + rightDateRange = DateNavRange.Month; + + FOOTER_DATE_FORMAT = 'YYYY-MM-dd'; + + leftAnchor = dayjs(); + rightAnchor = dayjs().add(1, MONTH); + + get maxHeaderAvail() { + return minDate(this.rightAnchor, this.maxDate); + } + + get minHeaderAvail() { + return maxDate(this.minDate, this.leftAnchor); + } + + // 用于存放 range data 序列,数量为 2 时清除 + rangeValue: Dayjs[] = []; + + // 用于组装匹配日期序列 + matchValues: Dayjs[]; + + startTime: TimePickerModel; + endTime: TimePickerModel; + + writeValue(obj: Dayjs[]) { + super.writeValue(obj); + this.rangeValue = obj || []; + this.matchValues = [...this.rangeValue]; + this.startTime = getTimePickerModel(obj?.[0]); + this.endTime = getTimePickerModel(obj?.[1]); + if (obj?.length === 2) { + this.reorder(sortDates(obj)); + } + this.cdr.markForCheck(); + } + + calendarRangeChange(type: DateNavRange, side: Side) { + if (side === Side.Left) { + this.leftDateRange = type; + } else { + this.rightDateRange = type; + } + } + + // range 组件范围受控 + getDateDisabledFn(side: Side, constrainValue: Dayjs) { + return composeDisabledDateFn((date: Dayjs, navRange: DateNavRange) => { + if (navRange === DateNavRange.Month) { + return false; + } + if (navRange === DateNavRange.Decade) { + return date[side === Side.Left ? 'isAfter' : 'isBefore']( + constrainValue, + YEAR, + ); + } + return !date[side === Side.Left ? 'isBefore' : 'isAfter']( + constrainValue, + MONTH, + ); + }, this.disabledDate); + } + + private getDisabledTimeCachedFn(side: Side) { + let cacheSelectedDate: Dayjs; + let cacheDisabledTimeFn: ReturnType; + return (value: Dayjs, key: keyof ReturnType) => { + if (value !== cacheSelectedDate) { + cacheDisabledTimeFn = (this.disabledTime?.[side] || (() => null))( + value, + ); + cacheSelectedDate = value; + } + return cacheDisabledTimeFn?.[key]; + }; + } + + leftDisabledTimeFn = this.getDisabledTimeCachedFn(Side.Left); + rightDisabledTimeFn = this.getDisabledTimeCachedFn(Side.Right); + + selectPickerPanel(value: Dayjs, side: Side) { + const navRange = + side === Side.Left ? this.leftDateRange : this.rightDateRange; + const type = getTypeByNavRange(navRange); + const dateValue = updateDate( + side === Side.Left ? this.leftAnchor : this.rightAnchor, + value, + type, + ); + const nextRange = nextNavRangeType(navRange, DateNavRange.Month); + + if (side === Side.Left) { + this.leftAnchor = dateValue; + this.leftDateRange = nextRange; + } else { + this.rightAnchor = dateValue; + this.rightDateRange = nextRange; + } + + if (navRange !== DateNavRange.Month) { + return; + } + + if (this.leftAnchor.isSame(this.rightAnchor, MONTH)) { + this.rightAnchor = this.rightAnchor.add(1, MONTH); + } + + this.rangeValue = + this.rangeValue.length === 2 + ? [dateValue] + : sortDates([...this.rangeValue, dateValue]); + this.matchValues = [...this.rangeValue]; + this.syncTime(); + if (this.rangeValue.length === 2) { + this.reorder(this.rangeValue); + this.emitValueChange(this.rangeValue); + } + } + + reorder(sortedDate: Dayjs[]) { + if (!sortedDate[0].isSame(sortedDate[1], MONTH)) { + this.leftAnchor = updateDate( + this.leftAnchor, + sortedDate[0], + DatePickerType.Day, + ); + this.rightAnchor = updateDate( + this.leftAnchor, + sortedDate[1], + DatePickerType.Day, + ); + } + } + + hoverItem(date: Dayjs) { + if (this.rangeValue.length === 1) { + this.matchValues[1] = date; + } + } + + confirmValue() { + this.emitValueChange(this.rangeValue); + this.confirm.next(); + } + + timeChange(time: TimePickerModel) { + if (!time) { + return; + } + this.syncTime(); + if (!this.rangeValue?.length) { + const date = updateDateByTimeModel(dayjs(), time); + this.rangeValue = [date, date]; + this.matchValues = [...this.rangeValue]; + this.startTime = this.endTime = time; + } + this.emitValueChange(this.rangeValue); + } + + syncTime() { + if (this.showTime && !!this.rangeValue?.length) { + const startDate = this.rangeValue[0] + ? updateDateByTimeModel(this.rangeValue[0], this.startTime) + : null; + const endDate = this.rangeValue[1] + ? updateDateByTimeModel(this.rangeValue[1], this.endTime) + : null; + if (!this.startTime && startDate) { + this.startTime = getTimePickerModel(this.rangeValue[0]); + } + if (!this.endTime && endDate) { + this.endTime = getTimePickerModel(this.rangeValue[1]); + } + this.rangeValue = [startDate, endDate].filter(i => !!i); + } + } +} diff --git a/src/date-picker/calendar/range-picker-panel/style.scss b/src/date-picker/calendar/range-picker-panel/style.scss new file mode 100644 index 000000000..3e3b26ab2 --- /dev/null +++ b/src/date-picker/calendar/range-picker-panel/style.scss @@ -0,0 +1,51 @@ +@import '../../../theme/var'; + +$block: aui-date-range-picker-panel; + +.#{$block} { + &__container { + display: flex; + flex-direction: column; + width: $range-picker-body-width; + font-size: $font-size-regular; + + .placeholder { + color: $color-text-placeholder; + &.separator { + margin-inline: 12px; + } + } + .date-value { + color: $color-text-main; + } + .date-holder { + display: inline-block; + margin-right: 8px; + width: 82px; + } + } + &__header { + display: flex; + justify-content: space-between; + aui-calendar-header { + flex: 0 0 $range-picker-header-width; + } + } + + &__body { + display: flex; + justify-content: space-between; + margin: 16px 0; + } + &__footer { + .time-picker { + width: 88px; + display: inline-block; + } + } + &__footer-content { + display: flex; + align-items: center; + height: 100%; + } +} diff --git a/src/date-picker/calendar/range-picker-panel/template.html b/src/date-picker/calendar/range-picker-panel/template.html new file mode 100644 index 000000000..df1030182 --- /dev/null +++ b/src/date-picker/calendar/range-picker-panel/template.html @@ -0,0 +1,105 @@ +
+
+ + +
+
+ + +
+ + +
+ + + + {{ 'to' | auiI18n }} + + +
+
+
+ + + {{ placeholder }} + {{ + value | date: FOOTER_DATE_FORMAT + }} + diff --git a/src/date-picker/calendar/util.ts b/src/date-picker/calendar/util.ts new file mode 100644 index 000000000..ee4265798 --- /dev/null +++ b/src/date-picker/calendar/util.ts @@ -0,0 +1,192 @@ +import dayjs, { Dayjs } from 'dayjs'; + +import { TimePickerModel } from '../../time-picker/time-picker.type'; +import { DateNavRange, DisabledDateFn } from '../date-picker.type'; + +import { + DATE_NAV_RANGES, + DATE_TYPES, + DAY, + DAY_PANEL_COLUMN_COUNT, + DAY_PANEL_ROW_COUNT, + DatePickerType, + MONTH, + MONTH_PANEL_COLUMN_COUNT, + MONTH_PANEL_ROW_COUNT, + YEAR, + YEAR_PANEL_COLUMN_COUNT, + YEAR_PANEL_ROW_COUNT, +} from './constant'; + +export class DateCell { + isBackground?: boolean; // 面板中非本月日期 + inRange?: boolean; // 在范围组件的范围内 + isRangeStart?: boolean; // 范围组件的范围开始日期 + isRangeEnd?: boolean; // 范围组件的范围结束日期 + isDisabled?: boolean; + + constructor(public value: Dayjs, public label: string | number) {} +} + +export function getDatePanelIterable( + date: Dayjs, + navRange: DateNavRange, + weekStartDay: number, +): Iterable & Iterator { + let firstData: Dayjs; + let counts = 0; + date = date.clone(); + if (navRange === DateNavRange.Month) { + firstData = date.date(-1).day(weekStartDay); + if (firstData.isAfter(date)) { + firstData = firstData.subtract(1, 'week'); + } + counts = DAY_PANEL_COLUMN_COUNT * DAY_PANEL_ROW_COUNT; + } else if (navRange === DateNavRange.Year) { + firstData = date.month(0); + counts = MONTH_PANEL_COLUMN_COUNT * MONTH_PANEL_ROW_COUNT; + } else if (navRange === DateNavRange.Decade) { + firstData = date.subtract(1, YEAR); + counts = YEAR_PANEL_COLUMN_COUNT * YEAR_PANEL_ROW_COUNT; + } + let i = 0; + return { + [Symbol.iterator]() { + return this; + }, + next: () => { + if (i < counts) { + return { + value: firstData.add( + i++, + navRange === DateNavRange.Month + ? DAY + : navRange === DateNavRange.Year + ? MONTH + : YEAR, + ), + done: false, + }; + } + return { + done: true, + value: null, + }; + }, + }; +} + +export function formatDate(type: DateNavRange, dateValue: Dayjs, date: Dayjs) { + if (!dateValue) { + return null; + } + const label = + type === DateNavRange.Decade + ? dateValue.year() + : type === DateNavRange.Year + ? dateValue.month() + 1 + : dateValue.date(); + const cell = new DateCell(dateValue, label); + if (type === DateNavRange.Month && date.month() !== dateValue.month()) { + cell.isBackground = true; + } + return cell; +} + +export function nextDatePickerType( + current: DatePickerType, + guard?: DatePickerType, +) { + const guardIndex = guard ? DATE_TYPES.indexOf(guard) : DATE_TYPES.length - 1; + const currentIndex = DATE_TYPES.indexOf(current); + if (currentIndex < guardIndex) { + return DATE_TYPES[currentIndex + 1]; + } + return current; +} + +export function nextNavRangeType(current: DateNavRange, guard?: DateNavRange) { + const guardIndex = guard + ? DATE_NAV_RANGES.indexOf(guard) + : DATE_NAV_RANGES.length - 1; + const currentIndex = DATE_NAV_RANGES.indexOf(current); + if (currentIndex < guardIndex) { + return DATE_NAV_RANGES[currentIndex + 1]; + } + return current; +} + +export function updateDate( + sourceValue: Dayjs, + newValue: Dayjs, + type: DatePickerType, +) { + const updateTypes = DATE_TYPES.slice(0, DATE_TYPES.indexOf(type) + 1); + return updateTypes.reduce( + (acc, curr) => acc.set(curr, newValue.get(curr)), + sourceValue.clone(), + ); +} + +export function calcRangeValue(type: DateNavRange, date: Dayjs) { + date = date.clone(); + return [DateNavRange.Month, DatePickerType.Year].includes(type) + ? { + start: date, + } + : { + start: date.subtract(date.year() % 10, YEAR), + end: date.add(9 - (date.year() % 10), YEAR), + }; +} + +export function sortDates(dates: Dayjs[]) { + return [...(dates || [])].sort((a, b) => + a.isBefore(b) ? -1 : a.isSame(b) ? 0 : 1, + ); +} + +export function getNavRangeByType(type: DatePickerType) { + return { + [DatePickerType.Day]: DateNavRange.Month, + [DatePickerType.Month]: DateNavRange.Year, + [DatePickerType.Year]: DateNavRange.Decade, + }[type]; +} + +export function getTypeByNavRange(navRange: DateNavRange) { + return { + [DateNavRange.Month]: DatePickerType.Day, + [DateNavRange.Year]: DatePickerType.Month, + [DateNavRange.Decade]: DatePickerType.Year, + }[navRange]; +} + +export function getTimePickerModel(date: Dayjs): TimePickerModel { + return date + ? { + second: date.second(), + minute: date.minute(), + hour: date.hour(), + } + : null; +} + +export function updateDateByTimeModel(date: Dayjs, time: TimePickerModel) { + return time + ? date.hour(time.hour).minute(time.minute).second(time.second) + : date; +} + +export const composeDisabledDateFn = (...fns: DisabledDateFn[]) => ( + date: Dayjs, + navRange: DateNavRange, +) => !fns.filter(i => !!i).every(fn => !fn(date, navRange)); + +export function minDate(a: Dayjs, b: Dayjs, unit: dayjs.UnitType = 'date') { + return !a ? b : !b ? a : a.isBefore(b, unit) ? a : b; +} + +export function maxDate(a: Dayjs, b: Dayjs, unit: dayjs.UnitType = 'date') { + return !a ? b : !b ? a : a.isAfter(b, unit) ? a : b; +} diff --git a/src/date-picker/date-picker.module.ts b/src/date-picker/date-picker.module.ts new file mode 100644 index 000000000..9d20db8a6 --- /dev/null +++ b/src/date-picker/date-picker.module.ts @@ -0,0 +1,49 @@ +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { IconModule } from '../icon/icon.module'; +import { + ButtonModule, + I18nModule, + InputModule, + TimePickerModule, + TooltipModule, +} from '../public-api'; + +import { DatePickerPanelComponent } from './calendar/date-picker-panel/component'; +import { CalendarFooterComponent } from './calendar/footer/component'; +import { CalendarHeaderComponent } from './calendar/header/component'; +import { PickerPanelComponent } from './calendar/panel/picker-panel'; +import { DateRangePickerPanelComponent } from './calendar/range-picker-panel/component'; +import { DatePickerComponent } from './date-picker/date-picker.component'; +import { RangePickerComponent } from './range-picker/range-picker.component'; +import { DatePickerTriggerComponent } from './trigger/trigger.component'; + +const COMPONENTS = [ + CalendarHeaderComponent, + CalendarFooterComponent, + DateRangePickerPanelComponent, + PickerPanelComponent, + RangePickerComponent, + DatePickerPanelComponent, + DatePickerComponent, + DatePickerTriggerComponent, +]; + +@NgModule({ + imports: [ + CommonModule, + InputModule, + ButtonModule, + TooltipModule, + ReactiveFormsModule, + FormsModule, + TimePickerModule, + IconModule, + I18nModule, + ], + declarations: [...COMPONENTS], + exports: [...COMPONENTS], +}) +export class DatePickerModule {} diff --git a/src/date-picker/date-picker.type.ts b/src/date-picker/date-picker.type.ts new file mode 100644 index 000000000..fd442cb6f --- /dev/null +++ b/src/date-picker/date-picker.type.ts @@ -0,0 +1,27 @@ +import { ConfigType, Dayjs } from 'dayjs'; + +export enum Side { + Left = 'left', + Right = 'right', +} + +export type DisabledTimeFn = ( + date?: ConfigType, +) => { + hours: () => number[]; + minutes: (hour?: number) => number[]; + seconds: (hour?: number, minute?: number) => number[]; +}; + +export interface CalendarHeaderRange { + start: Dayjs; + end?: Dayjs; +} + +export enum DateNavRange { + Decade = 'decade', + Year = 'year', + Month = 'month', +} + +export type DisabledDateFn = (date: Dayjs, type: DateNavRange) => boolean; diff --git a/src/date-picker/date-picker/date-picker.component.ts b/src/date-picker/date-picker/date-picker.component.ts new file mode 100644 index 000000000..7d43a68d3 --- /dev/null +++ b/src/date-picker/date-picker/date-picker.component.ts @@ -0,0 +1,115 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + OnInit, + Output, + TemplateRef, + ViewEncapsulation, + forwardRef, +} from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import dayjs, { ConfigType, Dayjs } from 'dayjs'; + +import { CommonFormControl } from '../../form/common-form'; +import { ComponentSize } from '../../types'; +import { DatePickerType } from '../calendar/constant'; +import { DateNavRange, DisabledTimeFn } from '../date-picker.type'; + +@Component({ + selector: 'aui-date-picker', + templateUrl: './date-picker.template.html', + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => DatePickerComponent), + multi: true, + }, + ], +}) +export class DatePickerComponent + extends CommonFormControl + implements OnInit { + @Input() + clearable = true; + + @Input() + clearText: string; + + @Input() + format: string; + + @Input() + showTime = false; + + @Input() + type = DatePickerType.Day; + + @Input() + size = ComponentSize.Medium; + + @Input() + disabledDate: (date: Dayjs, type: DateNavRange) => boolean = () => false; + + @Input() + disabledTime: DisabledTimeFn = () => null; + + @Input() + minDate: Dayjs; + + @Input() + maxDate: Dayjs; + + @Input() + weekStartDay = 0; + + @Input() + showFooter = true; + + @Input() + footerTemplate: TemplateRef; + + @Input() + extraFooter: TemplateRef; + + @Output() + openChange = new EventEmitter(); + + value: Dayjs; + DatePickerType = DatePickerType; + + ngOnInit() { + if (!this.format) { + this.format = this.getDefaultFormat(this.type); + this.cdr.markForCheck(); + } + } + + valueIn(obj: ConfigType) { + return obj ? dayjs(obj) : null; + } + + writeValue(obj: Dayjs) { + super.writeValue(obj); + this.value = obj; + this.cdr.markForCheck(); + } + + private getDefaultFormat(type = DatePickerType.Day) { + return type === DatePickerType.Year + ? 'YYYY' + : type === DatePickerType.Month + ? 'YYYY-MM' + : this.showTime + ? 'YYYY-MM-DD HH:mm:ss' + : 'YYYY-MM-DD'; + } + + clearValue() { + this.value = null; + this.emitValueChange(null); + } +} diff --git a/src/date-picker/date-picker/date-picker.template.html b/src/date-picker/date-picker/date-picker.template.html new file mode 100644 index 000000000..f0cdf7728 --- /dev/null +++ b/src/date-picker/date-picker/date-picker.template.html @@ -0,0 +1,40 @@ + + + + + diff --git a/src/date-picker/public-api.ts b/src/date-picker/public-api.ts new file mode 100644 index 000000000..bcfe01b84 --- /dev/null +++ b/src/date-picker/public-api.ts @@ -0,0 +1,10 @@ +export * from './date-picker.module'; +export * from './range-picker/range-picker.component'; +export * from './date-picker.type'; +export * from './calendar/footer/component'; +export * from './calendar/header/component'; +export * from './calendar/panel/picker-panel'; +export * from './calendar/range-picker-panel/component'; +export * from './calendar/date-picker-panel/component'; +export * from './calendar/constant'; +export * from './trigger/trigger.component'; diff --git a/src/date-picker/range-picker/range-picker.component.ts b/src/date-picker/range-picker/range-picker.component.ts new file mode 100644 index 000000000..eba9e741d --- /dev/null +++ b/src/date-picker/range-picker/range-picker.component.ts @@ -0,0 +1,92 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, + ViewEncapsulation, + forwardRef, +} from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import dayjs, { ConfigType, Dayjs } from 'dayjs'; + +import { CommonFormControl } from '../../form/common-form'; +import { ComponentSize } from '../../types'; +import { DisabledDateFn, DisabledTimeFn } from '../date-picker.type'; + +@Component({ + selector: 'aui-range-picker', + templateUrl: './range-picker.template.html', + // provide tooltip class + // tslint:disable-next-line: validate-decorators + encapsulation: ViewEncapsulation.Emulated, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => RangePickerComponent), + multi: true, + }, + ], + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['./range-picker.style.scss'], +}) +export class RangePickerComponent extends CommonFormControl< + ConfigType[], + Dayjs[] +> { + @Input() + clearable = true; + + @Input() + clearText: string; + + @Input() + format = 'YYYY-MM-DD'; + + @Input() + showFooter = true; + + @Input() + showTime = true; + + @Input() + disabledDate: DisabledDateFn = () => false; + + @Input() + minDate: Dayjs; + + @Input() + maxDate: Dayjs; + + @Input() + disabledTime: { left: DisabledTimeFn; right: DisabledTimeFn } = { + left: () => null, + right: () => null, + }; + + @Input() + weekStartDay = 0; + + @Input() + size: ComponentSize; + + @Output() + openChange = new EventEmitter(); + + value: [Dayjs, Dayjs]; + + valueIn(obj: [ConfigType, ConfigType]) { + return obj?.map(i => dayjs(i)); + } + + writeValue(obj: [Dayjs, Dayjs]) { + super.writeValue(obj); + this.value = obj; + this.cdr.markForCheck(); + } + + clearValue() { + this.value = null; + this.emitValueChange(null); + } +} diff --git a/src/date-picker/range-picker/range-picker.style.scss b/src/date-picker/range-picker/range-picker.style.scss new file mode 100644 index 000000000..c8986dc58 --- /dev/null +++ b/src/date-picker/range-picker/range-picker.style.scss @@ -0,0 +1,8 @@ +::ng-deep { + .aui-tooltip.range-picker-wrapper { + max-width: none; + } +} +aui-date-picker-trigger { + display: inline-block; +} diff --git a/src/date-picker/range-picker/range-picker.template.html b/src/date-picker/range-picker/range-picker.template.html new file mode 100644 index 000000000..4c7df162a --- /dev/null +++ b/src/date-picker/range-picker/range-picker.template.html @@ -0,0 +1,37 @@ + + + + + diff --git a/src/date-picker/trigger/trigger.component.ts b/src/date-picker/trigger/trigger.component.ts new file mode 100644 index 000000000..f1fec21d1 --- /dev/null +++ b/src/date-picker/trigger/trigger.component.ts @@ -0,0 +1,89 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, + ViewChild, + ViewEncapsulation, +} from '@angular/core'; +import { Dayjs } from 'dayjs'; + +import { InputComponent } from '../../input/input.component'; +import { ComponentSize } from '../../types'; +import { buildBem } from '../../utils'; + +const bem = buildBem('aui-date-picker-trigger'); + +@Component({ + selector: 'aui-date-picker-trigger', + templateUrl: './trigger.template.html', + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + styleUrls: ['./trigger.style.scss'], +}) +export class DatePickerTriggerComponent { + @Input() + format = 'YYYY-MM-DD'; + + @Input() + size: ComponentSize; + + @Input() + isRange: boolean; + + @Input() + value: Dayjs | Dayjs[]; + + @Input() + clearable = true; + + @Input() + placeholder: string; + + @Input() + startPlaceholder: string; + + @Input() + endPlaceholder: string; + + @Input() + disabled = false; + + @Output() + blur = new EventEmitter(); + + @Output() + clear = new EventEmitter(); + + @ViewChild('focusRef', { static: false }) + focusInputRef: InputComponent; + + centerFocus = false; + leftFocus = false; + rightFocus = false; + hovered = false; + bem = bem; + + get isFocus() { + return this.isRange ? this.leftFocus || this.rightFocus : this.centerFocus; + } + + get hasValue() { + return !this.isRange + ? !!this.value + : (this.value as Dayjs[])?.[0] || (this.value as Dayjs[])?.[1]; + } + + get showClear() { + return this.clearable && this.hasValue && this.hovered; + } + + constructor() { + this.focusInput = this.focusInput.bind(this); + } + + focusInput() { + this.focusInputRef.elementRef.nativeElement.focus(); + } +} diff --git a/src/date-picker/trigger/trigger.style.scss b/src/date-picker/trigger/trigger.style.scss new file mode 100644 index 000000000..6fc47e2bb --- /dev/null +++ b/src/date-picker/trigger/trigger.style.scss @@ -0,0 +1,72 @@ +@import '../../theme/var'; +@import '../../theme/mixin'; + +$block: aui-date-picker-trigger; +aui-range-picker, +aui-date-picker { + &.ng-invalid { + &.ng-submitted, + &.ng-dirty { + .#{$block}__container { + border-color: $color-error !important; + } + } + } +} + +.#{$block} { + &__container { + display: flex; + flex-direction: row; + align-items: center; + box-sizing: border-box; + width: 200px; + border: $input-border-width $input-border-style $input-border-color; + position: relative; + cursor: pointer; + input[readonly] { + cursor: pointer; + border: none; + @include view-readonly; + } + &.is-focus, + &:hover { + border-color: $color-primary; + } + &.is-range { + width: 260px; + } + &.is-disabled { + input[readonly] { + background: transparent; + cursor: not-allowed; + } + + background: $color-disabled-bg; + border: $input-border-width $input-border-style $input-border-color !important; + &:hover { + cursor: not-allowed; + } + } + } + + &__content { + flex: 1; + display: flex; + align-items: center; + .placeholder { + color: $color-text-placeholder; + } + } + + &__addon { + color: $color-text-help; + margin-right: 8px; + font-size: $date-picker-icon-size; + width: $date-picker-icon-size; + height: $date-picker-icon-size; + .clear { + @include clear-button; + } + } +} diff --git a/src/date-picker/trigger/trigger.template.html b/src/date-picker/trigger/trigger.template.html new file mode 100644 index 000000000..935ad2559 --- /dev/null +++ b/src/date-picker/trigger/trigger.template.html @@ -0,0 +1,64 @@ +
+ + + + {{ + 'to' | auiI18n + }} + + + + + + + + + + + + +
diff --git a/src/i18n/i18n.module.ts b/src/i18n/i18n.module.ts new file mode 100644 index 000000000..11741c5df --- /dev/null +++ b/src/i18n/i18n.module.ts @@ -0,0 +1,9 @@ +import { NgModule } from '@angular/core'; + +import { I18nPipe } from './i18n.pipe'; + +@NgModule({ + declarations: [I18nPipe], + exports: [I18nPipe], +}) +export class I18nModule {} diff --git a/src/i18n/i18n.pipe.ts b/src/i18n/i18n.pipe.ts new file mode 100644 index 000000000..8937e07d7 --- /dev/null +++ b/src/i18n/i18n.pipe.ts @@ -0,0 +1,14 @@ +import { Pipe, PipeTransform } from '@angular/core'; + +import { I18nService } from './i18n.service'; +import { StringMap } from './i18n.type'; + +@Pipe({ + name: 'auiI18n', +}) +export class I18nPipe implements PipeTransform { + constructor(private readonly i18n: I18nService) {} + transform(value: any, data?: StringMap) { + return this.i18n.translate(value, data); + } +} diff --git a/src/i18n/i18n.service.ts b/src/i18n/i18n.service.ts new file mode 100644 index 000000000..03d32f70b --- /dev/null +++ b/src/i18n/i18n.service.ts @@ -0,0 +1,44 @@ +import { Inject, Injectable, isDevMode } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; +import { pluck } from 'rxjs/operators'; + +import { I18NInterface, I18NInterfaceToken, StringMap } from './i18n.type'; + +@Injectable({ + providedIn: 'root', +}) +export class I18nService { + private readonly i18nChange$$ = new BehaviorSubject( + this._i18n, + ); + + localeChange$ = this.i18nChange$$.asObservable().pipe(pluck('locale')); + + constructor(@Inject(I18NInterfaceToken) private _i18n: I18NInterface) {} + + setI18n(i18n: I18NInterface) { + this._i18n = i18n; + this.i18nChange$$.next(i18n); + } + + get i18n() { + return this._i18n; + } + + translate(key: string, data?: StringMap, ignoreNonExist = false) { + let content = this._i18n.translation[key]; + if (content == null) { + if (isDevMode() && !ignoreNonExist) { + console.warn(`No exist translate key for ${key}`); + } + return key; + } + if (data) { + content = content.replaceAll( + /{{([^{}]+)}}/, + (_matched, $0: string) => data[$0.trim()], + ); + } + return content; + } +} diff --git a/src/i18n/i18n.type.ts b/src/i18n/i18n.type.ts new file mode 100644 index 000000000..2fddf3a0d --- /dev/null +++ b/src/i18n/i18n.type.ts @@ -0,0 +1,13 @@ +import { InjectionToken } from '@angular/core'; + +import { en } from './language/en'; + +export interface I18NInterface { + locale: string; + translation: Record; +} +export type StringMap = Record; + +export const I18NInterfaceToken = new InjectionToken('i18n token', { + factory: () => en, +}); diff --git a/src/i18n/language/en.ts b/src/i18n/language/en.ts new file mode 100644 index 000000000..86fa5bca4 --- /dev/null +++ b/src/i18n/language/en.ts @@ -0,0 +1,24 @@ +import { I18NInterface } from '../i18n.type'; + +export const en: I18NInterface = { + locale: 'en', + translation: { + start_date: 'Start Date', + end_date: 'End Date', + select_date: 'Select Date', + select_time: 'Select time', + clear: 'clear', + confirm: 'confirm', + to: 'To', + year_suffix: '', + month_suffix: '', + monday: 'Mon', + tuesday: 'Tues', + wednesday: 'Wed', + thursday: 'Thur', + friday: 'Fri', + saturday: 'Sat', + sunday: 'Sun', + now: 'Now', + }, +}; diff --git a/src/i18n/language/zh.ts b/src/i18n/language/zh.ts new file mode 100644 index 000000000..2bd4db15e --- /dev/null +++ b/src/i18n/language/zh.ts @@ -0,0 +1,24 @@ +import { I18NInterface } from '../i18n.type'; + +export const zh: I18NInterface = { + locale: 'zh', + translation: { + start_date: '开始日期', + end_date: '结束日期', + select_time: '选择时间', + select_date: '选择日期', + clear: '清除', + confirm: '确定', + to: '至', + year_suffix: '年', + month_suffix: '月', + monday: '一', + tuesday: '二', + wednesday: '三', + thursday: '四', + friday: '五', + saturday: '六', + sunday: '日', + now: '此刻', + }, +}; diff --git a/src/i18n/public-api.ts b/src/i18n/public-api.ts new file mode 100644 index 000000000..14120b988 --- /dev/null +++ b/src/i18n/public-api.ts @@ -0,0 +1,6 @@ +export * from './i18n.module'; +export * from './i18n.pipe'; +export * from './i18n.type'; +export * from './i18n.service'; +export * from './language/en'; +export * from './language/zh'; diff --git a/src/icon/icons.ts b/src/icon/icons.ts index 421391e66..05f8315ca 100644 --- a/src/icon/icons.ts +++ b/src/icon/icons.ts @@ -1,2 +1,2 @@ export const auiIcons = - ''; + ''; diff --git a/src/public-api.ts b/src/public-api.ts index 6514716f9..e347fef33 100644 --- a/src/public-api.ts +++ b/src/public-api.ts @@ -33,3 +33,6 @@ export * from './page/public-api'; export * from './scrolling/public-api'; export * from './theme/public-api'; export * from './anchor/public-api'; +export * from './i18n/public-api'; +export * from './time-picker/public-api'; +export * from './date-picker/public-api'; diff --git a/src/theme/_mixin.scss b/src/theme/_mixin.scss index 34ed72908..a2e1c390d 100644 --- a/src/theme/_mixin.scss +++ b/src/theme/_mixin.scss @@ -65,3 +65,8 @@ border-radius: 50%; background-color: $color; } + +@mixin view-readonly { + background-color: $input-bg-color; + border-color: $input-border-color; +} diff --git a/src/theme/_var.scss b/src/theme/_var.scss index c9c62d246..768777c0c 100644 --- a/src/theme/_var.scss +++ b/src/theme/_var.scss @@ -525,3 +525,16 @@ $accordion-item-header-color: #666; $back-top-box-size: 36px; $back-top-position-offset: 10px; $back-top-border-radius: 2px; + +// timepicker +$time-picker-icon-size: $font-size-large; +$time-picker-body-small-width: 278px; + +// datepicker +$date-picker-icon-size: $font-size-large; +$date-picker-body-size: 216px; +$date-picker-seprator-gap: 20px; +$date-picker-row-gap: 8px; +$date-picker-cell-size: 22px; +$range-picker-body-width: 552px; +$range-picker-header-width: 275px; diff --git a/src/time-picker/component.ts b/src/time-picker/component.ts new file mode 100644 index 000000000..015778ca7 --- /dev/null +++ b/src/time-picker/component.ts @@ -0,0 +1,174 @@ +import { + ChangeDetectionStrategy, + Component, + EventEmitter, + Input, + Output, + TemplateRef, + ViewChild, + ViewEncapsulation, + forwardRef, +} from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import dayjs, { Dayjs } from 'dayjs'; +import customParseFormat from 'dayjs/plugin/customParseFormat'; + +import { updateDateByTimeModel } from '../date-picker/calendar/util'; +import { CommonFormControl } from '../form/common-form'; +import { + TimePickerDataLike, + TimePickerModel, + TooltipDirective, + isTimePickerModel, +} from '../public-api'; +import { ComponentSize } from '../types'; +import { buildBem } from '../utils'; + +dayjs.extend(customParseFormat); + +const bem = buildBem('aui-time-picker'); + +@Component({ + selector: 'aui-time-picker', + templateUrl: './template.html', + styleUrls: ['./style.scss'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimePickerComponent), + multi: true, + }, + ], +}) +export class TimePickerComponent extends CommonFormControl< + TimePickerDataLike, + TimePickerModel +> { + @Input() + format = 'HH:mm:ss'; + + @Input() + size: ComponentSize = ComponentSize.Medium; + + @Input() + placeholder = ''; + + @Input() + clearable = true; + + @Input() + showIcon = true; + + @Input() + disableHours: () => number[]; + + @Input() + disableMinutes: (hour?: number) => number[]; + + @Input() + disableSeconds: (hour?: number, minute?: number) => number[]; + + @Input() + hourStep = 1; + + @Input() + minuteStep = 1; + + @Input() + secondStep = 1; + + @Input() + footerTemplate: TemplateRef; + + @Input() + tooltipPosition = 'bottom start'; + + @Output() + openChange = new EventEmitter(); + + @ViewChild('tooltip', { static: false }) + tooltip: TooltipDirective; + + timeValue: Dayjs = null; + timeFormatValue = ''; + focused = false; + hovered = false; + bem = bem; + + writeValue(value: TimePickerDataLike) { + super.writeValue(value); + if (!value) { + return this.setValue(null); + } + let result: Dayjs; + if (isTimePickerModel(value)) { + result = updateDateByTimeModel(dayjs(), value); + } else { + result = dayjs(value); + this.submit(false, result); + } + this.setValue(result); + } + + setValue(value: Dayjs) { + this.timeValue = value; + this.timeFormatValue = value ? dayjs(value).format(this.format) : null; + this.cdr.markForCheck(); + } + + changeFromPanel(value: Dayjs) { + // sync input value + this.timeFormatValue = value?.format(this.format); + this.cdr.markForCheck(); + } + + changeFromInput(source: string) { + // sync panel value + if (!source) { + this.timeValue = null; + } else { + const afterParsed = dayjs(source, this.format); + if (afterParsed.isValid()) { + this.timeValue = afterParsed; + } + } + } + + blur() { + if (this.onTouched) { + this.onTouched(); + } + // dateFormatValue may need reset + this.setValue(this.timeValue); + this.openChange.next(false); + } + + openTooltip() { + if (this.tooltip.isCreated) { + return; + } + this.tooltip.createTooltip(); + this.openChange.next(true); + } + + clear() { + this.setValue(null); + this.submit(); + } + + submit(close = true, value?: Dayjs) { + close && this.tooltip.disposeTooltip(); + const refer = value ?? this.timeValue; + this.emitValue( + refer + ? { + second: refer.second(), + minute: refer.minute(), + hour: refer.hour(), + } + : null, + ); + } +} diff --git a/src/time-picker/constant.ts b/src/time-picker/constant.ts new file mode 100644 index 000000000..88c74f79f --- /dev/null +++ b/src/time-picker/constant.ts @@ -0,0 +1,5 @@ +export const HOUR_ITEMS = Array.from({ length: 24 }).map((_, index) => index); +export const MINUTE_ITEMS = Array.from({ length: 60 }).map((_, index) => index); +export const SECOND_ITEMS = Array.from({ length: 60 }).map((_, index) => index); + +export const CONTROL_ITEM_HEIGHT = 28; diff --git a/src/time-picker/panel/panel.component.ts b/src/time-picker/panel/panel.component.ts new file mode 100644 index 000000000..be0c8bf07 --- /dev/null +++ b/src/time-picker/panel/panel.component.ts @@ -0,0 +1,291 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + Output, + SimpleChanges, + TemplateRef, + ViewChild, + ViewEncapsulation, + forwardRef, +} from '@angular/core'; +import { NG_VALUE_ACCESSOR } from '@angular/forms'; +import dayjs, { Dayjs } from 'dayjs'; + +import { CommonFormControl } from '../../form/common-form'; +import { ComponentSize } from '../../types'; +import { buildBem } from '../../utils'; +import { + CONTROL_ITEM_HEIGHT, + HOUR_ITEMS, + MINUTE_ITEMS, + SECOND_ITEMS, +} from '../constant'; +import { TimePickerControlType } from '../time-picker.type'; + +const TIME_PICKER_COLUMN_WIDTH = 80; +const bem = buildBem('aui-time-picker-panel'); + +@Component({ + selector: 'aui-time-picker-panel', + templateUrl: './panel.template.html', + styleUrls: ['./panel.style.scss'], + encapsulation: ViewEncapsulation.None, + changeDetection: ChangeDetectionStrategy.OnPush, + providers: [ + { + provide: NG_VALUE_ACCESSOR, + useExisting: forwardRef(() => TimePickerPanelComponent), + multi: true, + }, + ], + host: { + mousedown: '$event.preventDefault()', + }, +}) +export class TimePickerPanelComponent + extends CommonFormControl + implements OnChanges { + @Input() + set format(value: string) { + if (value != null) { + this._format = value; + const charSet = new Set(value); + this.hourEnabled = charSet.has('H') || charSet.has('h'); + this.minuteEnabled = charSet.has('m'); + this.secondEnabled = charSet.has('s'); + this.enabledColumns = [ + this.hourEnabled, + this.minuteEnabled, + this.secondEnabled, + ].filter(i => i).length; + } + } + + get format(): string { + return this._format; + } + + private _format: string; + + get totalWidth() { + return this.enabledColumns * TIME_PICKER_COLUMN_WIDTH; + } + + @Input() + hourStep = 1; + + @Input() + minuteStep = 1; + + @Input() + secondStep = 1; + + @Input() + footerTemplate: TemplateRef; + + @Output() + confirm = new EventEmitter(); + + @Input() + disableHours: () => number[]; + + @Input() + disableMinutes: (hour?: number) => number[]; + + @Input() + disableSeconds: (hour?: number, minute?: number) => number[]; + + hourEnabled: boolean; + minuteEnabled: boolean; + secondEnabled: boolean; + + TimePickerControlType = TimePickerControlType; + HOUR_ITEM_CONFIG = HOUR_ITEMS; + MINUTE_ITEM_CONFIG = MINUTE_ITEMS; + SECOND_ITEM_CONFIG = SECOND_ITEMS; + + CONTROL_ITEM_HEIGHT = CONTROL_ITEM_HEIGHT; + ComponentSize = ComponentSize; + bem = bem; + + @ViewChild('hourRef', { static: false }) + hourRef: ElementRef; + + @ViewChild('minuteRef', { static: false }) + minuteRef: ElementRef; + + @ViewChild('secondRef', { static: false }) + secondRef: ElementRef; + + enabledColumns = 0; + + // 用于控制初次滚动,是否展示滚动动画效果 + firstScrolled = false; + + constructor(protected cdr: ChangeDetectorRef) { + super(cdr); + this.value$.subscribe(_ => { + this.cdr.markForCheck(); + }); + } + + isDisabled(value: number, type: TimePickerControlType) { + const currentValue = this.snapshot.value; + if (this.disabled) { + return true; + } + if (type === TimePickerControlType.Hour) { + return (!this.disableHours ? [] : this.disableHours()).includes(value); + } + if (type === TimePickerControlType.Minute) { + return (!this.disableMinutes + ? [] + : this.disableMinutes(currentValue?.hour()) + ).includes(value); + } + if (type === TimePickerControlType.Second) { + return (!this.disableSeconds + ? [] + : this.disableSeconds(currentValue?.hour(), currentValue?.minute()) + ).includes(value); + } + } + + ngOnChanges({ hourStep, minuteStep, secondStep }: SimpleChanges): void { + if (hourStep?.currentValue > 0) { + this.HOUR_ITEM_CONFIG = HOUR_ITEMS.filter( + i => i % hourStep.currentValue === 0, + ); + } + if (minuteStep?.currentValue > 0) { + this.MINUTE_ITEM_CONFIG = MINUTE_ITEMS.filter( + i => i % minuteStep.currentValue === 0, + ); + } + if (secondStep?.currentValue > 0) { + this.SECOND_ITEM_CONFIG = SECOND_ITEMS.filter( + i => i % secondStep.currentValue === 0, + ); + } + this.cdr.markForCheck(); + } + + writeValue(value: Dayjs) { + super.writeValue(value); + this.syncScrollOffset(!this.firstScrolled ? 0 : 120, value); + } + + selectValue(value: number, type: TimePickerControlType): void { + if (this.isDisabled(value, type)) { + return; + } + this.firstScrolled = true; + const result = this.syncValue(value, type, this.snapshot.value); + this.syncScrollOffset(120, result); + } + + syncValue(value: number, type: TimePickerControlType, currentValue: Dayjs) { + if (!type) { + return; + } + const result = (currentValue || dayjs()).set(type, value); + this.emitValue(result); + return result; + } + + syncScrollOffset(duration: number, value: Dayjs) { + const empty = !value; + const config = [ + { + ref: this.hourRef, + enabled: this.hourEnabled, + step: this.hourStep, + type: 'hour', + }, + { + ref: this.minuteRef, + enabled: this.minuteEnabled, + step: this.minuteStep, + type: 'minute', + }, + { + ref: this.secondRef, + enabled: this.secondEnabled, + step: this.secondStep, + type: 'second', + }, + ] as const; + + config.forEach(cf => { + if (cf.enabled && cf.ref) { + this.scrollByValue( + cf.ref.nativeElement, + empty ? 0 : value.get(cf.type), + cf.step, + empty ? 0 : duration, + ); + } + }); + if (!empty) { + this.firstScrolled = true; + } + } + + private scrollByValue( + element: HTMLElement, + value: number, + divideBy: number, + duration: number, + ) { + const targetOffset = (value * CONTROL_ITEM_HEIGHT) / divideBy; + const currentTop = element.scrollTop; + if (currentTop === targetOffset) { + return; + } + if (!duration) { + element.scrollTop = targetOffset; + } else { + const difference = targetOffset - currentTop; + // 每个tick滚动距离 + const perTick = (difference / duration) * 10; + + requestAnimationFrame(() => { + element.scrollTop = currentTop + perTick; + if (element.scrollTop === targetOffset) { + return; + } + this.scrollByValue(element, value, divideBy, duration - 10); + }); + } + } + + matchValue( + value: number, + type: TimePickerControlType, + currDate: Dayjs, + ): boolean { + return currDate?.get(type) === value; + } + + getControlTypeConfig(type: TimePickerControlType): number[] { + return { + [TimePickerControlType.Hour]: this.HOUR_ITEM_CONFIG, + [TimePickerControlType.Minute]: this.MINUTE_ITEM_CONFIG, + [TimePickerControlType.Second]: this.SECOND_ITEM_CONFIG, + }[type]; + } + + selectNow() { + this.firstScrolled = true; + this.emitValue(dayjs()); + } + + trackBy(_index: number, content: number) { + return content; + } +} diff --git a/src/time-picker/panel/panel.style.scss b/src/time-picker/panel/panel.style.scss new file mode 100644 index 000000000..b70dcadf1 --- /dev/null +++ b/src/time-picker/panel/panel.style.scss @@ -0,0 +1,82 @@ +@import '../../theme/var'; +@import '../../theme/pattern'; + +$block: aui-time-picker-panel; +$cell_height: 28px; +$total_height: $cell_height * 9; + +.#{$block} { + &__wrapper { + @include shadow-8; + + display: flex; + flex-direction: column; + font-size: $font-size-regular; + font-weight: $font-weight-normal; + ul, + li { + border: none; + } + } + &__content { + display: flex; + flex-direction: row; + height: $total_height; + border-bottom: $border-width $border-style $default-border-color; + } + &__footer { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + padding: 9px; + } + &__column { + overflow: hidden; + flex: 1; + text-align: center; + &:hover { + overflow-y: auto; + } + + align-self: stretch; + ul { + min-height: $total_height; + list-style: none; + padding: 0; + margin: 0; + } + &-strut { + height: $total_height; + width: 1px; + visibility: hidden; + } + } + &__column + .#{$block}__column { + border-left: 1px solid #e8e8e8; + } + &__cell { + flex: none; + height: $cell_height; + line-height: $cell_height; + width: 100%; + + &.selected { + background-color: #e6f1ff; + color: $color-primary; + font-weight: $font-weight-bold; + } + + &.disabled { + cursor: not-allowed !important; + background-color: $color-disabled-bg; + color: $color-info; + } + + &:not(.disabled):not(.selected):hover { + background-color: $color-primary-ultra-light; + color: $color-primary; + cursor: pointer; + } + } +} diff --git a/src/time-picker/panel/panel.template.html b/src/time-picker/panel/panel.template.html new file mode 100644 index 000000000..9fe0f02db --- /dev/null +++ b/src/time-picker/panel/panel.template.html @@ -0,0 +1,101 @@ +
+
+
+
    +
  • + {{ item | number: '2.0-0' }} +
  • +
+
+
+
+
    +
  • + {{ item | number: '2.0-0' }} +
  • +
+
+
+
+
    +
  • + {{ item | number: '2.0-0' }} +
  • +
+
+
+
+ + +
+ + +
+
+ + + +
diff --git a/src/time-picker/public-api.ts b/src/time-picker/public-api.ts new file mode 100644 index 000000000..1d1168266 --- /dev/null +++ b/src/time-picker/public-api.ts @@ -0,0 +1,5 @@ +export * from './time-picker.module'; +export * from './component'; +export * from './time-picker.type'; +export * from './constant'; +export * from './panel/panel.component'; diff --git a/src/time-picker/style.scss b/src/time-picker/style.scss new file mode 100644 index 000000000..bd16f7bce --- /dev/null +++ b/src/time-picker/style.scss @@ -0,0 +1,53 @@ +@import '../theme/mixin'; + +$block: aui-time-picker; + +::ng-deep { + .aui-input-group__addon.aui-input-group__addon--after { + background-color: initial; + border: none; + } +} + +#{$block} { + &.ng-invalid { + &.ng-submitted, + &.ng-dirty { + .#{$block}__container { + border-color: $color-error !important; + } + } + } +} + +.#{$block} { + &__container { + display: inline-flex; + border: $border-style $border-width $default-border-color; + &:hover, + &.focus { + border-color: $hover-border-color; + } + > .aui-input { + border: none; + &[readonly] { + background-color: initial; + cursor: pointer; + } + &.disabled { + cursor: not-allowed; + } + } + } + &__icon { + color: $color-text-help; + font-size: $time-picker-icon-size; + width: $time-picker-icon-size; + height: $time-picker-icon-size; + align-self: center; + margin-right: 6px; + .clear { + @include clear-button; + } + } +} diff --git a/src/time-picker/template.html b/src/time-picker/template.html new file mode 100644 index 000000000..4e7252e9f --- /dev/null +++ b/src/time-picker/template.html @@ -0,0 +1,53 @@ +
+ + + + + + + +
+ + + + diff --git a/src/time-picker/time-picker.module.ts b/src/time-picker/time-picker.module.ts new file mode 100644 index 000000000..8bc1280d1 --- /dev/null +++ b/src/time-picker/time-picker.module.ts @@ -0,0 +1,34 @@ +import { OverlayModule } from '@angular/cdk/overlay'; +import { PortalModule } from '@angular/cdk/portal'; +import { CommonModule } from '@angular/common'; +import { NgModule } from '@angular/core'; +import { FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { + ButtonModule, + I18nModule, + IconModule, + InputModule, + TooltipModule, +} from '../public-api'; + +import { TimePickerComponent } from './component'; +import { TimePickerPanelComponent } from './panel/panel.component'; + +@NgModule({ + imports: [ + FormsModule, + ReactiveFormsModule, + PortalModule, + OverlayModule, + CommonModule, + InputModule, + TooltipModule, + IconModule, + ButtonModule, + I18nModule, + ], + declarations: [TimePickerComponent, TimePickerPanelComponent], + exports: [TimePickerComponent, TimePickerPanelComponent], +}) +export class TimePickerModule {} diff --git a/src/time-picker/time-picker.type.ts b/src/time-picker/time-picker.type.ts new file mode 100644 index 000000000..0c6e22b1e --- /dev/null +++ b/src/time-picker/time-picker.type.ts @@ -0,0 +1,25 @@ +import { ConfigType } from 'dayjs'; + +import { _isNumberValue } from '../utils'; + +export enum TimePickerControlType { + Hour = 'hour', + Minute = 'minute', + Second = 'second', +} + +export interface TimePickerModel { + hour: number; + minute: number; + second: number; +} + +export type TimePickerDataLike = ConfigType | TimePickerModel; + +export function isTimePickerModel(item: any): item is TimePickerModel { + return ( + _isNumberValue(item?.second) && + _isNumberValue(item?.minute) && + _isNumberValue(item?.hour) + ); +} diff --git a/stories/date-picker/date-picker.stories.mdx b/stories/date-picker/date-picker.stories.mdx new file mode 100644 index 000000000..6a08bd047 --- /dev/null +++ b/stories/date-picker/date-picker.stories.mdx @@ -0,0 +1,237 @@ +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { moduleMetadata } from '@storybook/angular'; +import dayjs from 'dayjs'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; + +import { DatePickerModule, DatePickerType, DateNavRange } from '@alauda/ui'; + + + +# DatePicker + +日期选择组件 + +## 基本 + + + + {{ + template: /* HTML */ ` + showTime: true; weekStartDay: 2 + + Form value1: {{time?.toDate()}} +
+
+ showTime: false + + Form value2: {{time2?.toDate()}} +
+
+ disable until today + + Form value3: {{time3?.toDate()}} +
+
+ disabled + +
+
+ With extra footer + + +
+ +
+
+ `, + props: { + log: function (x) { + console.log(x); + }, + now: dayjs(), + time: dayjs().add(7, 'day'), + time2: null, + time3: null, + time4: null, + minDate: dayjs().add(3, 'day'), + maxDate: dayjs().add(10, 'day'), + control: new FormControl({ value: null, disabled: true }), + DatePickerType: DatePickerType, + disableDateFn: function (date, navRange) { + return date.isBefore( + dayjs(), + navRange === DateNavRange.Month + ? 'date' + : navRange === DateNavRange.Year + ? 'month' + : 'year', + ); + }, + disabledTimeFn: function (date) { + if (date?.isSame(dayjs(), 'date')) { + return { + hours: () => [1, 2, 3, 4], + minutes: () => [4, 12], + }; + } + return null; + }, + }, + }} +
+
+ + + + {{ + template: /* HTML */ ` + showTime: true + + Form value: {{range|json}} +
+
+ showTime: false + + Form value: {{range2|json}} +
+
+ disable some day + +
+
+ disabled + + `, + props: { + range: [dayjs(), dayjs()], + range2: null, + range3: null, + minDate: dayjs().add(3, 'day'), + maxDate: dayjs().add(10, 'day'), + disableDateFn: function (date, navRange) { + return ( + (navRange === DateNavRange.Month && + date.isBefore(dayjs(), 'date')) || + date.isSame(dayjs().add(1, 'month').add(2, 'date'), 'date') + ); + }, + disabledTimeFn: function () { + return { + left: date => + date?.isSame(dayjs(), 'date') + ? { + hours: () => [1, 2, 3, 4], + minutes: () => [4, 12], + } + : null, + }; + }, + control: new FormControl({ value: null, disabled: true }), + }, + }} +
+
+ +## DatePicker Inputs + +| 名称 | 类型 | 默认值 | 描述 | +| -------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -------------------- | ------------------------------------ | +| clearable | boolean | true | 是否显示清除按钮 | +| clearText | string | 'clear' | 清除按钮文案 | +| format | string | 'YYYY-MM-DD' | 日期显示格式 | +| showTime | boolean | false | 是否显示选择时间 | +| type | DatePickerType | DatePickerType.Day | 指定日期组件类型,为选择日,月,或年 | +| size | ComponentSize | ComponentSize.Medium | 触发器大小 | +| weekStartDay | number | 0 | 面板的第一个日期为周几 | +| disabledDate | (date: Dayjs, type: DateNavRange) => boolean | () => false | 禁用某些日期 | +| disabledTime | (date: Dayjs) => {hours: () => number[];minutes: (hour?: number) => number[];seconds: (hour?: number, minute?: number) => number[];} | () => null | 禁用某些时间 | +| extraFooter | TemplateRef | null | 额外的 footer | +| footerTemplate | TemplateRef | null | 自定义 footer 内容区 | +| minDate | Dayjs | null | 最小日期 | +| maxDate | Dayjs | null | 最大日期 | + +### DatePicker 表单输入数据格式 + +`Dayjs` + +### DatePicker 表单输出数据格式 + +`Dayjs` + +## RangePicker Inputs + +| 名称 | 类型 | 默认值 | 描述 | +| ------------ | -------------------------------------------- | -------------------- | ---------------------- | +| clearable | boolean | true | 是否显示清除按钮 | +| clearText | string | 'clear' | 清除按钮文案 | +| format | string | 'YYYY-MM-DD' | 日期显示格式 | +| showTime | boolean | false | 是否显示选择时间 | +| size | ComponentSize | ComponentSize.Medium | 触发器大小 | +| weekStartDay | number | 0 | 面板的第一个日期为周几 | +| disabledDate | (date: Dayjs, type: DateNavRange) => boolean | () => false | 禁用某些日期 | +| disabledTime | {left: DisabledTimeFn,right:DisabledTimeFn} | null | 禁用某些时间 | +| minDate | Dayjs | null | 最小日期 | +| maxDate | Dayjs | null | 最大日期 | + +### RangePicker 表单输入数据格式 + +`[DayJs,Dayjs]` + +### RangePicker 表单输出数据格式 + +`[DayJs,Dayjs]` diff --git a/stories/time-picker/time-picker.stories.mdx b/stories/time-picker/time-picker.stories.mdx new file mode 100644 index 000000000..f149ef3e6 --- /dev/null +++ b/stories/time-picker/time-picker.stories.mdx @@ -0,0 +1,84 @@ +import { Canvas, Meta, Story } from '@storybook/addon-docs/blocks'; +import { moduleMetadata } from '@storybook/angular'; + +import { ComponentSize, TimePickerModule } from '@alauda/ui'; + + + +# TimePicker + +时间选择器 + +## 基本 + + + + {{ + template: /* HTML */ ` + initData :{{timeClone|json}} +
+ +
+ Form value: {{time|json}} +
+
+ initData: {{time2|json}} +
+ +
+ Form value: {{time2|json}} + `, + props: { + time: Date.now(), + timeClone: Date.now(), + time2: { hour: 2, minute: 10, second: 3 }, + csize: ComponentSize, + }, + }} +
+
+ +## TimePicker Inputs + +| 名称 | 类型 | 默认值 | 描述 | +| -------------- | -------------------------------------------- | -------------------- | -------------------------------- | +| format | string | 'HH:mm:ss' | 日期格式 | +| size | ComponentSize | ComponentSize.Medium | input 大小 | +| placeholder | string | - | 自定义 placeholder | +| clearable | boolean | true | 可否清除 | +| showIcon | boolean | true | 是否显示图标 | +| hourStep | number | 1 | 小时步长 | +| minuteStep | number | 1 | 分钟步长 | +| secondStep | number | 1 | 秒步长 | +| footerTemplate | TemplateRef | null | footer 模板,不传显示默认 footer | +| disableHours | () => number[] | ()=>[] | 禁用的小时 | +| disableMinutes | (hour?: number) => number[] | ()=>[] | 禁用的分钟 | +| disableSeconds | (hour?: number, minute?: number) => number[] | ()=>[] | 禁用的秒钟 | + +## TimePicker Outputs + +| 名称 | 类型 | 默认值 | 描述 | +| ---------- | ------- | ------ | --------------------- | +| openChange | boolean | - | 打开或关闭事件 output | + +### TimePicker 表单输入数据格式 + +`Dayjs.ConfigType | TimePickerModel` + +### TimePicker 表单输出数据格式 + +`TimePickerModel` diff --git a/tsconfig.lib.json b/tsconfig.lib.json new file mode 100644 index 000000000..006ec5401 --- /dev/null +++ b/tsconfig.lib.json @@ -0,0 +1,7 @@ +{ + "extends": "@1stg/tsconfig/angular", + "angularCompilerOptions": { + "baseUrl": ".", + "enableIvy": false + } +} diff --git a/yarn.lock b/yarn.lock index 95b358904..1fa8a4b3e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7049,6 +7049,11 @@ dateformat@^3.0.0: resolved "https://registry.yarnpkg.com/dateformat/-/dateformat-3.0.3.tgz#a6e37499a4d9a9cf85ef5872044d62901c9889ae" integrity sha512-jyCETtSl3VMZMWeRo7iY1FL19ges1t55hMo5yaam4Jrsm5EPL89UQkoQRyiI+Yf4k8r2ZpdngkV8hr1lIdjb3Q== +dayjs@^1.10.4: + version "1.10.5" + resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.10.5.tgz#5600df4548fc2453b3f163ebb2abbe965ccfb986" + integrity sha512-BUFis41ikLz+65iH6LHQCDm4YPMj5r1YFLdupPIyM4SGcXMmtiLQ7U37i+hGS8urIuqe7I/ou3IS1jVc4nbN4g== + debug@2.6.9, debug@^2.2.0, debug@^2.3.3, debug@^2.6.0, debug@^2.6.9: version "2.6.9" resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" From 6ca5d934874dc1d4569bb81652eee953367cf9a2 Mon Sep 17 00:00:00 2001 From: JounQin Date: Wed, 23 Jun 2021 09:54:37 +0800 Subject: [PATCH 05/10] ci: publish beta version automatically for PR (#133) * feat: auto release beta by pr * ci: publish beta is only available on non-forked PR Co-authored-by: zangguodong --- .github/workflows/publish-beta.yml | 19 +++++++++++++++++++ .github/workflows/publish.yml | 24 ++++++++++-------------- package.json | 2 +- scripts/npm-tag.js | 7 +++++++ scripts/publish-version.js | 26 ++++++++++++++++++++++++++ scripts/release.sh | 23 +++++++++++++++++++++++ 6 files changed, 86 insertions(+), 15 deletions(-) create mode 100644 .github/workflows/publish-beta.yml create mode 100644 scripts/npm-tag.js create mode 100644 scripts/publish-version.js create mode 100644 scripts/release.sh diff --git a/.github/workflows/publish-beta.yml b/.github/workflows/publish-beta.yml new file mode 100644 index 000000000..3c237fc05 --- /dev/null +++ b/.github/workflows/publish-beta.yml @@ -0,0 +1,19 @@ +name: Publish beta + +on: + pull_request: + branches: [master] + +jobs: + publish_beta: + name: Publish beta + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install dependencies + run: yarn --frozen-lockfile + - name: Release beta + run: sh scripts/release.sh + env: + PUBLISH_VERSION: beta + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 1ea757b61..a12ceae04 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -13,23 +13,19 @@ jobs: name: Publish package runs-on: ubuntu-latest steps: - - uses: actions/checkout@v1 + - uses: actions/checkout@v2 + with: + fetch-depth: 0 - name: Install dependencies - run: yarn install - - name: Update package version - run: | - git config --local user.email "action@github.com" - git config --local user.name "GitHub Action" - yarn release $VERSION - env: - VERSION: ${{ github.event.inputs.version }} - - name: Build package - run: yarn build - - name: Publish + run: yarn --frozen-lockfile + - name: Set git info run: | - npm set //registry.npmjs.org/:_authToken "$NPM_TOKEN" - npm publish release + git config --local user.email "actions@github.com" + git config --local user.name "GitHub Actions" + - name: Release + run: sh scripts/release.sh env: + PUBLISH_VERSION: ${{ github.event.inputs.version }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} - name: Push changes uses: ad-m/github-push-action@master diff --git a/package.json b/package.json index a75f0c030..23cbda845 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "lint:ts": "tslint -p . -t stylish", "lint:tsc": "tsc -p tsconfig.base.json --noEmit", "postinstall": "yarn-deduplicate --strategy fewer || exit 0", - "release": "standard-version --no-verify --release-as", + "release": "standard-version --no-verify", "start": "yarn storybook", "storybook": "start-storybook -c .storybook -p 9001", "storybook:build": "build-storybook -c .storybook -o dist", diff --git a/scripts/npm-tag.js b/scripts/npm-tag.js new file mode 100644 index 000000000..b65e59fb6 --- /dev/null +++ b/scripts/npm-tag.js @@ -0,0 +1,7 @@ +const version = process.env.PUBLISH_VERSION; + +if (version === 'beta' || /-beta([.-_]?\d*)$/.test(version)) { + process.stdout.write('beta'); +} else { + process.stdout.write(version); +} diff --git a/scripts/publish-version.js b/scripts/publish-version.js new file mode 100644 index 000000000..940fcfe62 --- /dev/null +++ b/scripts/publish-version.js @@ -0,0 +1,26 @@ +const { execSync } = require('child_process'); + +// eslint-disable-next-line node/no-extraneous-require +const semver = require('semver'); + +let version = process.env.PUBLISH_VERSION || 'patch'; + +if (version.startsWith('v')) { + version = version.slice(1); +} else if (version === 'beta') { + const distTags = JSON.parse( + execSync('yarn info @alauda/ui dist-tags --json').toString(), + ); + + const { beta, latest } = distTags.data || distTags; + + if (semver.gt(beta, latest)) { + version = beta.endsWith('-beta') + ? beta + '.0' + : beta.replace(/(-beta\.)(\d+)$/, (_, $0, $1) => $0 + (+$1 + 1)); + } else { + version = semver.minVersion(`>${latest}`) + '-beta'; + } +} + +process.stdout.write(version); diff --git a/scripts/release.sh b/scripts/release.sh new file mode 100644 index 000000000..b727237cd --- /dev/null +++ b/scripts/release.sh @@ -0,0 +1,23 @@ +#!/bin/sh + +PUBLISH_VERSION=$(node scripts/publish-version) +NPM_TAG=$(node scripts/npm-tag) + +if [ "$NPM_TOKEN" = "" ]; then + echo "NPM_TOKEN is not available on PR from forked repository!" + echo "If you're a member of Alauda, just checkout a new branch instead." + exit 0 +fi + +if [ "$NPM_TAG" = "beta" ]; then + SRC_PKG=package.json + TMP=$(mktemp) + jq ".version = \"$PUBLISH_VERSION\"" "$SRC_PKG" >"$TMP" + mv -f "$TMP" "$SRC_PKG" +else + yarn release --release-as "$PUBLISH_VERSION" +fi + +yarn build +npm set //registry.npmjs.org/:_authToken "$NPM_TOKEN" +npm publish release --tag "$NPM_TAG" From 8453e519cedac8bc00533bb974dfc17f9f3df583 Mon Sep 17 00:00:00 2001 From: JounQin Date: Thu, 24 Jun 2021 11:12:01 +0800 Subject: [PATCH 06/10] fix: detect textarea value changes for autosize (#134) --- src/input/autosize.directive.ts | 52 ++++++++++++++++++--------------- stories/input/index.ts | 30 ++++++++++++------- 2 files changed, 48 insertions(+), 34 deletions(-) diff --git a/src/input/autosize.directive.ts b/src/input/autosize.directive.ts index 6319df54e..e3c0f3e32 100644 --- a/src/input/autosize.directive.ts +++ b/src/input/autosize.directive.ts @@ -2,9 +2,12 @@ import { AfterViewInit, Directive, ElementRef, - HostListener, Input, + OnDestroy, } from '@angular/core'; +import { NgControl } from '@angular/forms'; +import { Subject } from 'rxjs'; +import { startWith, takeUntil } from 'rxjs/operators'; import { calcTextareaHeight } from './utils'; @@ -22,23 +25,15 @@ export interface AutoSizeValue { * Directive to automatically resize a textarea to fit its content. */ @Directive({ - // tslint:disable-next-line: directive-selector - selector: 'textarea[autosize]', + selector: + // tslint:disable-next-line: directive-selector + 'textarea[autosize][ngModel],textarea[autosize][formControl],textarea[autosize][formControlName]', exportAs: 'TextareaAutosize', }) -export class AutosizeDirective implements AfterViewInit { +export class AutosizeDirective implements AfterViewInit, OnDestroy { private _autoSize: AutoSizeValue = DEFAULT_VALUE; - private textareaCalcStyle: { - minHeight?: string; - maxHeight?: string; - height?: string; - } = {}; - - @HostListener('input') - onTextareaInput() { - this.resizeTextarea(); - } + private readonly destroy$$ = new Subject(); @Input('autosize') get autoSize() { @@ -53,21 +48,32 @@ export class AutosizeDirective implements AfterViewInit { this.resizeTextarea(); } - constructor(protected elementRef: ElementRef) {} + constructor( + private readonly elRef: ElementRef, + private readonly ngControl: NgControl, + ) {} resizeTextarea() { + const el = this.elRef.nativeElement; const autoSize = this._autoSize; - this.textareaCalcStyle = calcTextareaHeight( - this.elementRef.nativeElement, - autoSize.minRows, - autoSize.maxRows, + Object.assign( + el.style, + calcTextareaHeight( + el, + autoSize.minRows ?? DEFAULT_VALUE.minRows, + autoSize.maxRows || DEFAULT_VALUE.maxRows, // 0 is unacceptable + ), ); - this.elementRef.nativeElement.style.minHeight = this.textareaCalcStyle.minHeight; - this.elementRef.nativeElement.style.maxHeight = this.textareaCalcStyle.maxHeight; - this.elementRef.nativeElement.style.height = this.textareaCalcStyle.height; } ngAfterViewInit() { - this.resizeTextarea(); + this.ngControl.valueChanges + .pipe(startWith(null as void), takeUntil(this.destroy$$)) + .subscribe(() => this.resizeTextarea()); + } + + ngOnDestroy() { + this.destroy$$.next(); + this.destroy$$.complete(); } } diff --git a/stories/input/index.ts b/stories/input/index.ts index 1cb1b5c77..846848e92 100644 --- a/stories/input/index.ts +++ b/stories/input/index.ts @@ -88,17 +88,6 @@ storiesOf('Input', module)

Textarea with autosize (size:default = medium):

- - `, })) @@ -341,4 +330,23 @@ storiesOf('Input', module) controls, }, }; + }) + .add('autosize', () => { + const minRows = number('minRows', 0); + const maxRows = number('maxRows', 0); + const value = text('value', 'Hello world!'); + return { + moduleMetadata: { + imports: [FormsModule, InputModule], + }, + template: /* HTML */ ``, + props: { + minRows, + maxRows, + value, + }, + }; }); From 0ad29cf045753d03bc1dc72805e9cb3169a005ad Mon Sep 17 00:00:00 2001 From: Lihua Tang Date: Mon, 28 Jun 2021 13:08:16 +0800 Subject: [PATCH 07/10] feat: fixed size table virtual scroll (#123) Co-authored-by: Lihua Tang --- ...ixed-size-table-virtual-scroll-strategy.ts | 107 +++++++++++++ ...xed-size-table-virtual-scroll.directive.ts | 149 ++++++++++++++++++ src/scrolling/public-api.ts | 2 + src/scrolling/scrolling.module.ts | 2 + ...ed-size-virtual-scroll-demo.component.html | 38 +++++ ...ixed-size-virtual-scroll-demo.component.ts | 12 ++ stories/table/index.ts | 15 +- 7 files changed, 324 insertions(+), 1 deletion(-) create mode 100644 src/scrolling/fixed-size-table-virtual-scroll-strategy.ts create mode 100644 src/scrolling/fixed-size-table-virtual-scroll.directive.ts create mode 100644 stories/table/fixed-size-virtual-scroll-demo/fixed-size-virtual-scroll-demo.component.html create mode 100644 stories/table/fixed-size-virtual-scroll-demo/fixed-size-virtual-scroll-demo.component.ts diff --git a/src/scrolling/fixed-size-table-virtual-scroll-strategy.ts b/src/scrolling/fixed-size-table-virtual-scroll-strategy.ts new file mode 100644 index 000000000..977eb2557 --- /dev/null +++ b/src/scrolling/fixed-size-table-virtual-scroll-strategy.ts @@ -0,0 +1,107 @@ +import { ListRange } from '@angular/cdk/collections'; +import { + CdkVirtualScrollViewport, + VirtualScrollStrategy, +} from '@angular/cdk/scrolling'; +import { Injectable } from '@angular/core'; +import { BehaviorSubject, Subject } from 'rxjs'; +import { distinctUntilChanged } from 'rxjs/operators'; + +@Injectable() +export class FixedSizeTableVirtualScrollStrategy + implements VirtualScrollStrategy { + private _rowHeight = 42; + private _headerHeight = 42; + private _bufferSize = 10; + private readonly _indexChange = new Subject(); + viewport: CdkVirtualScrollViewport; + + scrolledIndexChange = this._indexChange.pipe(distinctUntilChanged()); + stickyChange = new Subject(); + + renderedRangeStream = new BehaviorSubject({ start: 0, end: 0 }); + + get dataLength(): number { + return this._dataLength; + } + + set dataLength(value: number) { + if (value !== this._dataLength) { + this._dataLength = value; + this.onDataLengthChanged(); + } + } + + private _dataLength = 0; + + attach(viewport: CdkVirtualScrollViewport): void { + this.viewport = viewport; + this.viewport.renderedRangeStream.subscribe(this.renderedRangeStream); + this.onDataLengthChanged(); + } + + detach(): void { + this._indexChange.complete(); + this.renderedRangeStream.complete(); + this.stickyChange.complete(); + } + + onContentScrolled(): void { + this._updateContent(); + } + + onDataLengthChanged(): void { + if (this.viewport) { + this.viewport.setTotalContentSize(this._rowHeight * this.dataLength); + } + this._updateContent(); + } + + onContentRendered(): void { + // no-op + } + + onRenderedOffsetChanged(): void { + // no-op + } + + scrollToIndex(index: number, behavior: ScrollBehavior): void { + if (!this.viewport || !this._rowHeight) { + return; + } + this.viewport.scrollToOffset( + (index - 1) * this._rowHeight + this._headerHeight, + behavior, + ); + } + + setConfig(rowHeight: number, headerHeight: number, bufferSize: number) { + this._rowHeight = rowHeight; + this._headerHeight = headerHeight; + this._bufferSize = bufferSize; + this.onDataLengthChanged(); + } + + private _updateContent() { + if (!this.viewport) { + return; + } + const newIndex = + Math.round( + (this.viewport.measureScrollOffset() - this._headerHeight) / + this._rowHeight, + ) + 1; + const start = Math.max(0, newIndex - this._bufferSize); + const end = Math.min( + this._dataLength, + newIndex + + Math.ceil(this.viewport.getViewportSize() / this._rowHeight) + + this._bufferSize, + ); + const renderedOffset = start * this._rowHeight; + this.viewport.setRenderedContentOffset(renderedOffset); + this.viewport.setRenderedRange({ start, end }); + this.stickyChange.next(renderedOffset); + this._indexChange.next(newIndex); + } +} diff --git a/src/scrolling/fixed-size-table-virtual-scroll.directive.ts b/src/scrolling/fixed-size-table-virtual-scroll.directive.ts new file mode 100644 index 000000000..2c5cbd630 --- /dev/null +++ b/src/scrolling/fixed-size-table-virtual-scroll.directive.ts @@ -0,0 +1,149 @@ +import { VIRTUAL_SCROLL_STRATEGY } from '@angular/cdk/scrolling'; +import { CdkHeaderRowDef } from '@angular/cdk/table'; +import { + AfterContentInit, + ContentChild, + Directive, + Input, + OnChanges, + OnDestroy, + forwardRef, + SimpleChanges, +} from '@angular/core'; +import { Subject } from 'rxjs'; +import { filter, map, takeUntil, tap } from 'rxjs/operators'; + +import { TableComponent } from '../table/public-api'; + +import { FixedSizeTableVirtualScrollStrategy } from './fixed-size-table-virtual-scroll-strategy'; + +export function _tableVirtualScrollDirectiveStrategyFactory( + tableDir: FixedSizeTableVirtualScrollDirective, +) { + return tableDir.scrollStrategy; +} + +const stickyHeaderSelector = '.aui-table__header-row.aui-table-sticky'; + +const defaults = { + rowHeight: 42, + headerHeight: 42, + buffer: 10, +}; + +@Directive({ + // tslint:disable-next-line: directive-selector + selector: 'aui-virtual-scroll-viewport[fixedSize]', + exportAs: 'viewPort', + providers: [ + { + provide: VIRTUAL_SCROLL_STRATEGY, + useFactory: _tableVirtualScrollDirectiveStrategyFactory, + deps: [forwardRef(() => FixedSizeTableVirtualScrollDirective)], + }, + ], +}) +export class FixedSizeTableVirtualScrollDirective + implements AfterContentInit, OnChanges, OnDestroy { + private readonly onDestroy$ = new Subject(); + + @Input() + rowHeight: number = defaults.rowHeight; + + @Input() + headerHeight: number = defaults.headerHeight; + + @Input() + buffer: number = defaults.buffer; + + @Input() + dataSource: readonly unknown[]; + + @ContentChild(TableComponent, { static: false }) + table: TableComponent; + + scrollStrategy = new FixedSizeTableVirtualScrollStrategy(); + + private stickyPositions: Map; + + ngAfterContentInit() { + this.scrollStrategy.stickyChange + .pipe( + filter(() => this.isStickyEnabled()), + tap(() => { + if (!this.stickyPositions) { + this.initStickyPositions(); + } + }), + takeUntil(this.onDestroy$), + ) + .subscribe(stickyOffset => { + this.setSticky(stickyOffset); + }); + this.scrollStrategy.renderedRangeStream + .pipe( + map(({ start, end }) => + typeof start !== 'number' || typeof end !== 'number' + ? this.dataSource + : this.dataSource?.slice(start, end), + ), + takeUntil(this.onDestroy$), + ) + .subscribe(data => { + this.table.dataSource = data; + }); + } + + ngOnChanges({ dataSource, rowHeight, headerHeight, buffer }: SimpleChanges) { + if (dataSource) { + this.scrollStrategy.dataLength = this.dataSource?.length; + } + if (rowHeight || headerHeight || buffer) { + this.scrollStrategy.setConfig( + this.rowHeight, + this.headerHeight, + this.buffer, + ); + } + } + + ngOnDestroy() { + this.onDestroy$.next(); + this.onDestroy$.complete(); + } + + private isStickyEnabled(): boolean { + return ( + !!this.scrollStrategy.viewport && + // @ts-expect-error + (this.table._headerRowDefs as CdkHeaderRowDef[]) + .map(def => def.sticky) + .reduce((prev, curr) => prev && curr, true) + ); + } + + private initStickyPositions() { + this.stickyPositions = new Map(); + this.scrollStrategy.viewport.elementRef.nativeElement + .querySelectorAll(stickyHeaderSelector) + .forEach(el => { + const parent = el.parentElement; + if (!this.stickyPositions.has(parent)) { + this.stickyPositions.set(parent, parent.offsetTop); + } + }); + } + + private setSticky(offset: number) { + this.scrollStrategy.viewport.elementRef.nativeElement + .querySelectorAll(stickyHeaderSelector) + .forEach((el: HTMLElement) => { + const parent = el.parentElement; + let baseOffset = 0; + if (this.stickyPositions.has(parent)) { + baseOffset = this.stickyPositions.get(parent); + } + el.style.top = `${baseOffset - offset}px`; + }); + } +} diff --git a/src/scrolling/public-api.ts b/src/scrolling/public-api.ts index f14178ab7..355fed73c 100644 --- a/src/scrolling/public-api.ts +++ b/src/scrolling/public-api.ts @@ -1,3 +1,5 @@ +export * from './fixed-size-table-virtual-scroll-strategy'; +export * from './fixed-size-table-virtual-scroll.directive'; export * from './fixed-size-virtual-scroll.directive'; export * from './scrolling.module'; export * from './virtual-scroll-viewport.component'; diff --git a/src/scrolling/scrolling.module.ts b/src/scrolling/scrolling.module.ts index f5f1c3182..bf96dfad6 100644 --- a/src/scrolling/scrolling.module.ts +++ b/src/scrolling/scrolling.module.ts @@ -1,11 +1,13 @@ import { NgModule } from '@angular/core'; +import { FixedSizeTableVirtualScrollDirective } from './fixed-size-table-virtual-scroll.directive'; import { FixedSizeVirtualScrollDirective } from './fixed-size-virtual-scroll.directive'; import { VirtualForOfDirective } from './virtual-for-of.directive'; import { VirtualScrollViewportComponent } from './virtual-scroll-viewport.component'; const EXPORTABLE_COMPONENTS = [VirtualScrollViewportComponent]; const EXPORTABLE_DIRECTIVES = [ + FixedSizeTableVirtualScrollDirective, FixedSizeVirtualScrollDirective, VirtualForOfDirective, ]; diff --git a/stories/table/fixed-size-virtual-scroll-demo/fixed-size-virtual-scroll-demo.component.html b/stories/table/fixed-size-virtual-scroll-demo/fixed-size-virtual-scroll-demo.component.html new file mode 100644 index 000000000..777809705 --- /dev/null +++ b/stories/table/fixed-size-virtual-scroll-demo/fixed-size-virtual-scroll-demo.component.html @@ -0,0 +1,38 @@ + + + + No. + + {{ item.id }} + + + + Name + + element{{ item.id }} + + + + + Value + + Value + + + + Detail + + Detail info + + + + + diff --git a/stories/table/fixed-size-virtual-scroll-demo/fixed-size-virtual-scroll-demo.component.ts b/stories/table/fixed-size-virtual-scroll-demo/fixed-size-virtual-scroll-demo.component.ts new file mode 100644 index 000000000..220852646 --- /dev/null +++ b/stories/table/fixed-size-virtual-scroll-demo/fixed-size-virtual-scroll-demo.component.ts @@ -0,0 +1,12 @@ +import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { BehaviorSubject } from 'rxjs'; + +@Component({ + templateUrl: 'fixed-size-virtual-scroll-demo.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class FixedSizeVirtualScrollDemoComponent { + dataSource$$ = new BehaviorSubject( + Array.from({ length: 50000 }).map((_, i) => ({ id: i + 1 })), + ); +} diff --git a/stories/table/index.ts b/stories/table/index.ts index 17860088f..8b7e3c6be 100644 --- a/stories/table/index.ts +++ b/stories/table/index.ts @@ -1,9 +1,15 @@ -import { IconModule, SortModule, TableModule } from '@alauda/ui'; +import { + IconModule, + ScrollingModule, + SortModule, + TableModule, +} from '@alauda/ui'; import { BrowserAnimationsModule } from '@angular/platform-browser/animations'; import { boolean, object, withKnobs } from '@storybook/addon-knobs'; import { storiesOf } from '@storybook/angular'; import { ExpandDemoComponent } from './expand-demo/expand-demo.component'; +import { FixedSizeVirtualScrollDemoComponent } from './fixed-size-virtual-scroll-demo/fixed-size-virtual-scroll-demo.component'; import { SortDemoComponent } from './sort-demo/sort-demo.component'; import { StickyColumnsDemoComponent } from './sticky-columns/sticky-columns-demo.component'; @@ -88,6 +94,13 @@ storiesOf('Table', module) declarations: [StickyColumnsDemoComponent], }, component: StickyColumnsDemoComponent, + })) + .add('virtual scroll', () => ({ + moduleMetadata: { + imports: [TableModule, ScrollingModule], + declarations: [FixedSizeVirtualScrollDemoComponent], + }, + component: FixedSizeVirtualScrollDemoComponent, })); export interface Element { From 34ff1d7ff9329326fb66a16f0ff78295e779453f Mon Sep 17 00:00:00 2001 From: JounQin Date: Tue, 29 Jun 2021 12:34:44 +0800 Subject: [PATCH 08/10] ci: should target `latest` tag by default (#135) --- scripts/npm-tag.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/npm-tag.js b/scripts/npm-tag.js index b65e59fb6..0bf08ad06 100644 --- a/scripts/npm-tag.js +++ b/scripts/npm-tag.js @@ -3,5 +3,5 @@ const version = process.env.PUBLISH_VERSION; if (version === 'beta' || /-beta([.-_]?\d*)$/.test(version)) { process.stdout.write('beta'); } else { - process.stdout.write(version); + process.stdout.write('latest'); } From 04b6e393c6b737109b23cea072c6e3dd422d16c3 Mon Sep 17 00:00:00 2001 From: JounQin Date: Tue, 29 Jun 2021 12:55:42 +0800 Subject: [PATCH 09/10] ci: change git info (#136) --- .github/workflows/publish.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index a12ceae04..f39056125 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -20,8 +20,8 @@ jobs: run: yarn --frozen-lockfile - name: Set git info run: | - git config --local user.email "actions@github.com" - git config --local user.name "GitHub Actions" + git config --local user.email "41898282+github-actions[bot]@users.noreply.github.com" + git config --local user.name "github-actions[bot]" - name: Release run: sh scripts/release.sh env: From 7408bbbc33c05f68aeeb80066e9519972f0b10cb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 29 Jun 2021 05:04:54 +0000 Subject: [PATCH 10/10] chore(release): 5.7.2 --- CHANGELOG.md | 16 ++++++++++++++++ package.json | 2 +- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 16c9a027d..a49733664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,22 @@ All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. +### [5.7.2](https://github.com/alauda/alauda-ui/compare/v5.6.8...v5.7.2) (2021-06-29) + + +### Features + +* date picker & time picker ([#124](https://github.com/alauda/alauda-ui/issues/124)) ([c8dece2](https://github.com/alauda/alauda-ui/commit/c8dece2f7318530dfd263263cabce32dd36e35ca)) +* fixed size table virtual scroll ([#123](https://github.com/alauda/alauda-ui/issues/123)) ([0ad29cf](https://github.com/alauda/alauda-ui/commit/0ad29cf045753d03bc1dc72805e9cb3169a005ad)) +* support observable active status for tab component ([#130](https://github.com/alauda/alauda-ui/issues/130)) ([2ced08f](https://github.com/alauda/alauda-ui/commit/2ced08fb53c5c05578ef24b1646045b5c2c6094d)) +* table column sticky ([#129](https://github.com/alauda/alauda-ui/issues/129)) ([c464cd5](https://github.com/alauda/alauda-ui/commit/c464cd5d8fd94edb4bab40e14db3cce968b4c464)) + + +### Bug Fixes + +* detect textarea value changes for autosize ([#134](https://github.com/alauda/alauda-ui/issues/134)) ([8453e51](https://github.com/alauda/alauda-ui/commit/8453e519cedac8bc00533bb974dfc17f9f3df583)) +* only get first value from observable for beforeAction ([#131](https://github.com/alauda/alauda-ui/issues/131)) ([e1c92c6](https://github.com/alauda/alauda-ui/commit/e1c92c6880e5555282ec9087d64bff23c3c3b9dc)) + ### [5.6.8](https://github.com/alauda/alauda-ui/compare/v5.6.7...v5.6.8) (2021-05-13) diff --git a/package.json b/package.json index 23cbda845..df8815d20 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@alauda/ui", - "version": "5.6.8", + "version": "5.7.2", "description": "Angular UI components by Alauda Frontend Team.", "repository": "git+https://github.com/alauda/alauda-ui.git", "author": "Alauda Frontend",