diff --git a/src/assets/styles/_tables.scss b/src/assets/styles/_tables.scss index 746631d..1579698 100644 --- a/src/assets/styles/_tables.scss +++ b/src/assets/styles/_tables.scss @@ -18,3 +18,37 @@ padding-left: 10px; } } + +.header-item { + background-color: $btn-bg; + height: 50px; + padding: 0 24px; + display: flex; + align-items: center; + z-index: 1; + border-bottom: 1px solid $separator; + box-sizing: border-box; + + > *:not(:last-child) { + margin-right: 10px; + } + + .actions { + display: flex; + align-items: center; + justify-content: flex-end; + + > *:not(:last-child) { + margin-right: 16px; + } + } + + &.selection-enabled { + padding-left: 10px; + } +} + +.scrollbar-placeholder { + width: 11px; + padding: 0 !important; +} diff --git a/src/index.ts b/src/index.ts index 3ba9aa9..7935743 100644 --- a/src/index.ts +++ b/src/index.ts @@ -8,6 +8,7 @@ export * from './lib/utils/types/utility-types'; export * from './lib/utils/types/tooltip-positions.type'; export * from './lib/utils/decorators/bind.decorator'; export * from './lib/utils/decorators/required.decorator'; +export * from './lib/utils/decorators/debounce.decorator'; export * from './lib/buttons/circle-button/circle-button.type'; export * from './lib/buttons/circle-button/circle-button.component'; export * from './lib/filtering/filter-utils'; @@ -25,3 +26,4 @@ export * from './lib/tables/entities.service'; export * from './lib/tables/listing-component.directive'; export * from './lib/tables/models/table-column-config.model'; export * from './lib/tables/table-column-name/table-column-name.component'; +export * from './lib/tables/table-header/table-header.component'; diff --git a/src/lib/common-ui.module.ts b/src/lib/common-ui.module.ts index a71045b..2c7bdb6 100644 --- a/src/lib/common-ui.module.ts +++ b/src/lib/common-ui.module.ts @@ -12,6 +12,9 @@ import { SortByPipe } from './sorting/sort-by.pipe'; import { HumanizePipe } from './utils/pipes/humanize.pipe'; import { TableColumnNameComponent } from './tables/table-column-name/table-column-name.component'; import { QuickFiltersComponent } from './filtering/quick-filters/quick-filters.component'; +import { TableHeaderComponent } from './tables/table-header/table-header.component'; +import { TranslateModule } from '@ngx-translate/core'; +import { SyncWidthDirective } from './tables/sync-width.directive'; const buttons = [IconButtonComponent, ChevronButtonComponent, CircleButtonComponent]; @@ -19,14 +22,16 @@ const inputs = [RoundCheckboxComponent]; const matModules = [MatIconModule, MatButtonModule, MatTooltipModule]; -const components = [...buttons, ...inputs, TableColumnNameComponent, QuickFiltersComponent]; +const modules = [...matModules, TranslateModule]; -const pipes = [SortByPipe, HumanizePipe]; +const components = [...buttons, ...inputs, TableColumnNameComponent, QuickFiltersComponent, TableHeaderComponent]; + +const utils = [SortByPipe, HumanizePipe, SyncWidthDirective]; @NgModule({ - declarations: [...components, ...pipes], - imports: [CommonModule, ...matModules], - exports: [...components, ...pipes, ...matModules] + declarations: [...components, ...utils], + imports: [CommonModule, ...modules], + exports: [...components, ...utils, ...modules] }) export class CommonUiModule { constructor(private readonly _iconRegistry: MatIconRegistry, private readonly _sanitizer: DomSanitizer) { diff --git a/src/lib/filtering/quick-filters/quick-filters.component.ts b/src/lib/filtering/quick-filters/quick-filters.component.ts index 977956e..fb3ec7e 100644 --- a/src/lib/filtering/quick-filters/quick-filters.component.ts +++ b/src/lib/filtering/quick-filters/quick-filters.component.ts @@ -10,7 +10,7 @@ import { FilterService } from '../filter.service'; changeDetection: ChangeDetectionStrategy.OnPush }) export class QuickFiltersComponent { - @Input() @Required() quickFilters!: Set; + @Input() @Required() quickFilters!: readonly NestedFilter[]; constructor(readonly filterService: FilterService) {} } diff --git a/src/lib/tables/listing-component.directive.ts b/src/lib/tables/listing-component.directive.ts index be2410f..5fdb8b1 100644 --- a/src/lib/tables/listing-component.directive.ts +++ b/src/lib/tables/listing-component.directive.ts @@ -1,4 +1,4 @@ -import { Directive, Injector, OnDestroy, Provider } from '@angular/core'; +import { Directive, Injector, OnDestroy } from '@angular/core'; import { combineLatest, Observable } from 'rxjs'; import { distinctUntilChanged, map, switchMap } from 'rxjs/operators'; import { FilterService } from '../filtering/filter.service'; @@ -11,7 +11,7 @@ import { KeysOf } from '../utils/types/utility-types'; import { TableColumnConfig } from './models/table-column-config.model'; import { EntitiesService } from './entities.service'; -export const DefaultListingServices = new Set().add(FilterService).add(SearchService).add(EntitiesService).add(SortingService); +export const DefaultListingServices = [FilterService, SearchService, EntitiesService, SortingService] as const; @Directive() export abstract class ListingComponent extends AutoUnsubscribe implements OnDestroy { @@ -23,7 +23,7 @@ export abstract class ListingComponent extends AutoUnsubscribe readonly noMatch$ = this._noMatch$; readonly sortedDisplayedEntities$ = this._sortedDisplayedEntities$; - abstract readonly tableColumnConfigs: TableColumnConfig[]; + abstract readonly tableColumnConfigs: readonly TableColumnConfig[]; /** * Key used in the *trackBy* function with **ngFor* or **cdkVirtualFor* * and in the default sorting and as the search field diff --git a/src/lib/tables/sync-width.directive.ts b/src/lib/tables/sync-width.directive.ts new file mode 100644 index 0000000..caaeeec --- /dev/null +++ b/src/lib/tables/sync-width.directive.ts @@ -0,0 +1,71 @@ +import { Directive, ElementRef, HostListener, Input, OnDestroy } from '@angular/core'; + +@Directive({ + selector: '[iqserSyncWidth]' +}) +export class SyncWidthDirective implements OnDestroy { + @Input() iqserSyncWidth!: string; + private readonly _interval: number; + + constructor(private readonly _elementRef: ElementRef) { + this._interval = setInterval(() => { + this._matchWidth(); + }, 1000); + } + + ngOnDestroy(): void { + clearInterval(this._interval); + } + + private _matchWidth() { + const headerItems = this._elementRef.nativeElement.children; + const tableRows = this._elementRef.nativeElement.parentElement.parentElement.getElementsByClassName(this.iqserSyncWidth); + + if (!tableRows || !tableRows.length) { + return; + } + + this._elementRef.nativeElement.setAttribute('synced', true); + + const { tableRow, length } = this._sampleRow(tableRows); + if (!tableRow) return; + + const hasExtraColumns = headerItems.length !== length ? 1 : 0; + + for (let idx = 0; idx < length - hasExtraColumns - 1; ++idx) { + if (headerItems[idx]) { + headerItems[idx].style.width = `${tableRow.children[idx].getBoundingClientRect().width}px`; + headerItems[idx].style.minWidth = `${tableRow.children[idx].getBoundingClientRect().width}px`; + } + } + + for (let idx = length - hasExtraColumns - 1; idx < headerItems.length; ++idx) { + if (headerItems[idx]) { + headerItems[idx].style.minWidth = `0`; + } + } + } + + @HostListener('window:resize') + onResize() { + this._matchWidth(); + } + + private _sampleRow(tableRows: HTMLCollectionOf): { + tableRow?: Element; + length: number; + } { + let length = 0; + let tableRow: Element | undefined; + + for (let idx = 0; idx < tableRows.length; ++idx) { + const row = tableRows.item(idx); + if (row && row.children.length > length) { + length = row.children.length; + tableRow = row; + } + } + + return { tableRow, length }; + } +} diff --git a/src/lib/tables/table-header/table-header.component.html b/src/lib/tables/table-header/table-header.component.html new file mode 100644 index 0000000..eebdbd5 --- /dev/null +++ b/src/lib/tables/table-header/table-header.component.html @@ -0,0 +1,42 @@ +
+ + + + {{ tableHeaderLabel | translate: { length: (entitiesService.displayedLength$ | async) } }} + + + + + + + + +
+ +
+
+ + + +
+ +
+
diff --git a/src/lib/tables/table-header/table-header.component.scss b/src/lib/tables/table-header/table-header.component.scss new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/tables/table-header/table-header.component.ts b/src/lib/tables/table-header/table-header.component.ts new file mode 100644 index 0000000..84e4c46 --- /dev/null +++ b/src/lib/tables/table-header/table-header.component.ts @@ -0,0 +1,23 @@ +import { ChangeDetectionStrategy, Component, Input, TemplateRef } from '@angular/core'; +import { Required } from '../../utils/decorators/required.decorator'; +import { FilterService } from '../../filtering/filter.service'; +import { TableColumnConfig } from '../models/table-column-config.model'; +import { EntitiesService } from '../entities.service'; + +@Component({ + selector: 'iqser-table-header', + templateUrl: './table-header.component.html', + styleUrls: ['./table-header.component.scss'], + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class TableHeaderComponent { + @Input() @Required() tableHeaderLabel!: string; + @Input() @Required() tableColumnConfigs!: readonly TableColumnConfig[]; + @Input() hasEmptyColumn = false; + @Input() selectionEnabled = false; + @Input() bulkActions?: TemplateRef; + + readonly quickFilters$ = this.filterService.getFilterModels$('quickFilters'); + + constructor(readonly entitiesService: EntitiesService, readonly filterService: FilterService) {} +} diff --git a/src/lib/utils/decorators/debounce.decorator.ts b/src/lib/utils/decorators/debounce.decorator.ts new file mode 100644 index 0000000..c6d1fdc --- /dev/null +++ b/src/lib/utils/decorators/debounce.decorator.ts @@ -0,0 +1,14 @@ +export function Debounce(delay = 300): MethodDecorator { + return function (target: Object, propertyKey: PropertyKey, descriptor: PropertyDescriptor) { + let timeout: number | undefined; + + const original = descriptor.value; + + descriptor.value = function (...args: unknown[]) { + clearTimeout(timeout); + timeout = setTimeout(() => original.apply(this, args), delay); + }; + + return descriptor; + }; +}