diff --git a/README.md b/README.md index 88ff91e..e87a725 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,14 @@ -

+

Pro Angular: Table Component

-

+

+ An abstraction of Angular Material’s table that speeds up development time and gives you quick access to features such as type safe columns, row selection, copy on click, expandable rows, intent based sorting, and more! +

+
@@ -63,14 +66,15 @@ https://www.ProAngular.com/demos/pro-table ## Description -`@proangular/pro-table` is a **type-safe, Angular 20–first abstraction** over -Angular Material’s table. It’s designed for apps using **standalone components, -signals, and the new control-flow syntax** so you can wire up robust data grids -quickly without giving up control of your data model or rendering. The component -keeps Material’s performance and accessibility surface, while adding -strongly-typed columns, selection, copy-on-click, expandable detail rows, and a -clean sorting contract that **emits intent** instead of mutating your data for -you. +`@proangular/pro-table` is a **type-safe, Angular abstraction** over Angular +Material’s table. It’s designed for apps using **standalone components, signals, +and the new control-flow syntax** so you can wire up data grids quickly without +giving up control of your data model or rendering. + +The component keeps Material’s performance, accessibility, and theming surface, +while adding strongly-typed columns, selection, copy-on-click, expandable detail +rows, and a clean sorting contract that **emits intent** instead of mutating +data. ### Why it’s useful (technical) @@ -100,7 +104,7 @@ you. values. These affordances reduce the “glue code” you normally write around `MatTable`. -- **Built for Angular 20 patterns** +- **Built for Angular 20+ patterns** Uses **OnPush** change detection, `@if/@for/@let` in templates, and small reactive streams (`BehaviorSubject/ReplaySubject` + `shareReplay`) to keep updates efficient. The example shows **signals** + `effect()` integrating diff --git a/package-lock.json b/package-lock.json index 7ddebfa..26c0ce5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@proangular/pro-table", - "version": "20.3.2", + "version": "20.3.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@proangular/pro-table", - "version": "20.3.2", + "version": "20.3.3", "hasInstallScript": true, "license": "MIT", "devDependencies": { diff --git a/package.json b/package.json index edfe88b..3c9ef0e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@proangular/pro-table", - "version": "20.3.2", + "version": "20.3.3", "description": "A rich, dynamic, and versatile table component based on Angular Material.", "author": "Pro Angular ", "homepage": "https://www.proangular.com", diff --git a/src/app/public/table/table-animations.ts b/src/app/public/table/table-animations.ts deleted file mode 100644 index 5a15846..0000000 --- a/src/app/public/table/table-animations.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { - AUTO_STYLE, - animate, - state, - style, - transition, - trigger, -} from '@angular/animations'; - -const animationDuration = 100; - -export const TABLE_ANIMATIONS = [ - trigger('collapseAnimation', [ - state('expanded', style({ height: AUTO_STYLE, visibility: AUTO_STYLE })), - state('collapsed', style({ height: '0', visibility: 'hidden' })), - transition( - 'expanded => collapsed', - animate(`${animationDuration}ms ease-in`), - ), - transition( - 'collapsed => expanded', - animate(`${animationDuration}ms ease-out`), - ), - ]), - trigger('exitAnimation', [ - transition(':leave', [ - style({ transform: 'translateX(0)', opacity: 1 }), - animate( - `${animationDuration}ms`, - style({ transform: 'translateX(100%)', opacity: 0 }), - ), - ]), - ]), -]; diff --git a/src/app/public/table/table.component.html b/src/app/public/table/table.component.html index 6dfcab6..848b038 100644 --- a/src/app/public/table/table.component.html +++ b/src/app/public/table/table.component.html @@ -55,7 +55,7 @@ column.minWidthPx ? column.minWidthPx + 'px' : undefined " > - @let data = getData(dataSourceItem, column.key); + @let data = getData(dataSourceItem, column.key, placeholderEmptyData); @switch (getTypeOf(data)) { @case ('datetime') { @@ -107,7 +107,7 @@ } - +
- @if ( - expandableObject && isRowExpanded(rowObjectData, expandableObject) - ) { -
- -
- } +
+ @if ( + expandableObject && isRowExpanded(rowObjectData, expandableObject) + ) { +
+ +
+ } +
+ diff --git a/src/app/public/table/table.component.scss b/src/app/public/table/table.component.scss index 48ea610..9714b6b 100644 --- a/src/app/public/table/table.component.scss +++ b/src/app/public/table/table.component.scss @@ -61,14 +61,22 @@ table { > div.expandable-row-details { background-color: inherit; - display: flex; + display: grid; + grid-template-rows: 0fr; overflow: hidden; width: 100%; + transition: grid-template-rows 100ms ease-in; > div { + overflow: hidden; width: 100%; } } + + > div.expandable-row-details.open { + grid-template-rows: 1fr; + transition: grid-template-rows 100ms ease-out; + } } } @@ -109,3 +117,17 @@ p.loading { background: var(--mat-table-background-color); } } + +/* Animations */ + +@keyframes exp-leave-hold { + from { + opacity: 1; + } + to { + opacity: 1; + } +} +.exp-leave { + animation: exp-leave-hold 100ms linear both; +} diff --git a/src/app/public/table/table.component.ts b/src/app/public/table/table.component.ts index 42ebeb5..8a77e24 100644 --- a/src/app/public/table/table.component.ts +++ b/src/app/public/table/table.component.ts @@ -1,4 +1,3 @@ -import { DateTime } from 'luxon'; import { BehaviorSubject, ReplaySubject, @@ -16,7 +15,6 @@ import { ChangeDetectionStrategy, Component, DestroyRef, - ElementRef, EventEmitter, HostBinding, Input, @@ -37,19 +35,21 @@ import { MatTooltipModule } from '@angular/material/tooltip'; import { DateTimePipe } from '../pipes'; import { - DefinedPrimitive, - NestedKeysOfString, TableColumn, TableSortChangeEvent, TableTemplateReferenceExpandableObject, - TableTemplateReferenceObject, } from '../types'; import { - isNonEmptyPrimitive, + getData as _getData, + getDate as _getDate, + getTypeOf as _getTypeOf, + isNonEmptyObject as _isNonEmptyObject, + isTableTemplateRefExpandableObject as _isTableTemplateRefExpandableObject, + isTableTemplateRefObject as _isTableTemplateRefObject, + copyToClipboard, isNonEmptyString, isNonEmptyValue, } from '../utilities'; -import { TABLE_ANIMATIONS } from './table-animations'; /** * A custom table component wrapper for Angular Material's table component. @@ -70,16 +70,22 @@ import { TABLE_ANIMATIONS } from './table-animations'; templateUrl: './table.component.html', styleUrls: ['./table.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - animations: TABLE_ANIMATIONS, }) -export class TableComponent +export class TableComponent implements OnInit { private readonly clipboard = inject(Clipboard); private readonly destroyRef = inject(DestroyRef); - private readonly elementRef = inject(ElementRef); private readonly matSnackBar = inject(MatSnackBar); + protected readonly getData = _getData; + protected readonly getDate = _getDate; + protected readonly getTypeOf = _getTypeOf; + protected readonly isNonEmptyObject = _isNonEmptyObject; + protected readonly isTableTemplateRefExpandableObject = + _isTableTemplateRefExpandableObject; + protected readonly isTableTemplateRefObject = _isTableTemplateRefObject; + @Input({ required: false }) public set selectable(value: BooleanInput) { this.selectableSubject.next(coerceBooleanProperty(value)); } @@ -187,7 +193,9 @@ export class TableComponent matTableDataSource?.data.some((row) => { for (const key in row) { if (Object.prototype.hasOwnProperty.call(row, key)) { - if (this.isTableTemplateRefExpandableObject(row[key])) return true; + if (this.isTableTemplateRefExpandableObject(row[key])) { + return true; + } } } return false; @@ -200,65 +208,9 @@ export class TableComponent this.updateAllSelectedStatus(); } - public scrollTop({ offset = 0 }: { offset?: number } = {}): void { - const topOfTableWithOffset = - this.elementRef.nativeElement.getBoundingClientRect().top + - window.scrollY - - offset; - window.scrollTo({ top: topOfTableWithOffset, behavior: 'smooth' }); - } - @Input() public trackByFn: TrackByFunction = (_index, item) => item['id']; - protected getData( - dataSourceItem: T, - columnKey: NestedKeysOfString, - ): DefinedPrimitive | object | Date | DateTime { - const data = this.getNestedObjectData(dataSourceItem, columnKey); - if (data === 0) return '0'; - if (typeof data === 'boolean') return data ? 'True' : 'False'; - if (data === null) return this.placeholderEmptyData; - return data; - } - - protected getDate(value: unknown): Date | null { - return value instanceof Date ? value : null; - } - - protected getNestedObjectData( - data: T, - stringKeys: NestedKeysOfString, - ): DefinedPrimitive | object | Date | DateTime | null { - const keys = stringKeys.split('.'); - let value: unknown = data; - - for (const key of keys) { - if ( - value instanceof Object && - Object.prototype.hasOwnProperty.call(value, key) - ) { - value = value[key as keyof typeof value]; - } - } - - if (isNonEmptyPrimitive(value) || typeof value === 'object') { - return value; - } - - return null; - } - - protected getTypeOf(value: unknown): 'date' | 'datetime' | 'time' | string { - if (value instanceof Date) return 'date'; - if (value instanceof DateTime) return 'datetime'; - return typeof value; - } - - protected isNonEmptyObject(value: unknown): value is object { - return value instanceof Object && Object.keys(value).length > 0; - } - protected isRowExpanded( rowObjectData: T, expandableObject: TableTemplateReferenceExpandableObject | null, @@ -275,28 +227,6 @@ export class TableComponent return this.selectedKeys.has(this.trackByFn(0, row)); } - protected isTableTemplateRefObject( - value: unknown, - ): value is TableTemplateReferenceObject { - return ( - value instanceof Object && - 'context' in value && - 'templateRef' in value && - !('isExpanded' in value) - ); - } - - protected isTableTemplateRefExpandableObject( - value: unknown, - ): value is TableTemplateReferenceExpandableObject { - return ( - value instanceof Object && - 'context' in value && - 'templateRef' in value && - 'isExpanded' in value - ); - } - protected async masterToggle(): Promise { const data = (await firstValueFrom(this.dataChanges))?.data ?? []; const allSelected = data.every((row) => this.isSelected(row)); @@ -387,13 +317,9 @@ export class TableComponent private copyToClipboard(item: HTMLTableCellElement): void { const content = item.innerHTML.replace(/<[^>]*>/g, '').trim(); + const copied = copyToClipboard(content, this.clipboard); - if (!isNonEmptyString(content)) { - throw new Error('Failed to copy empty string to the clipboard!'); - } - - const ok = this.clipboard.copy(content); - if (!ok) { + if (!copied) { this.matSnackBar.open( `Failed to copy "${content}" to clipboard!`, 'Close', @@ -401,6 +327,7 @@ export class TableComponent ); return; } + this.matSnackBar.open(`Copied "${content}" to clipboard!`, 'Close', { duration: 3000, }); diff --git a/src/app/public/utilities/copy-to-clipboard.ts b/src/app/public/utilities/copy-to-clipboard.ts new file mode 100644 index 0000000..a4e7b9d --- /dev/null +++ b/src/app/public/utilities/copy-to-clipboard.ts @@ -0,0 +1,15 @@ +import { Clipboard } from '@angular/cdk/clipboard'; + +import { isNonEmptyString } from './type-checks'; + +export function copyToClipboard( + content: string, + clipboard: Clipboard, +): boolean { + if (!isNonEmptyString(content)) { + throw new Error('Failed to copy empty string to the clipboard!'); + } + + const success: boolean = clipboard.copy(content); + return success; +} diff --git a/src/app/public/utilities/get-data.ts b/src/app/public/utilities/get-data.ts new file mode 100644 index 0000000..821cb7b --- /dev/null +++ b/src/app/public/utilities/get-data.ts @@ -0,0 +1,25 @@ +import { DateTime } from 'luxon'; + +import { DefinedPrimitive, NestedKeysOfString } from '../types'; +import { getNestedObjectData } from './get-nested-object-data'; + +export function getData( + dataSourceItem: T, + columnKey: NestedKeysOfString, + placeholderEmptyData: string, +): DefinedPrimitive | object | Date | DateTime { + const data = getNestedObjectData(dataSourceItem, columnKey); + if (data === 0) { + return '0'; + } + + if (typeof data === 'boolean') { + return data ? 'True' : 'False'; + } + + if (data === null) { + return placeholderEmptyData; + } + + return data; +} diff --git a/src/app/public/utilities/get-date.ts b/src/app/public/utilities/get-date.ts new file mode 100644 index 0000000..ca075ec --- /dev/null +++ b/src/app/public/utilities/get-date.ts @@ -0,0 +1,3 @@ +export function getDate(value: unknown): Date | null { + return value instanceof Date ? value : null; +} diff --git a/src/app/public/utilities/get-nested-object-data.ts b/src/app/public/utilities/get-nested-object-data.ts new file mode 100644 index 0000000..1d2f3f9 --- /dev/null +++ b/src/app/public/utilities/get-nested-object-data.ts @@ -0,0 +1,27 @@ +import { DateTime } from 'luxon'; + +import { DefinedPrimitive, NestedKeysOfString } from '../types'; +import { isNonEmptyPrimitive } from './type-checks'; + +export function getNestedObjectData( + data: T, + stringKeys: NestedKeysOfString, +): DefinedPrimitive | object | Date | DateTime | null { + const keys = stringKeys.split('.'); + let value: unknown = data; + + for (const key of keys) { + if ( + value instanceof Object && + Object.prototype.hasOwnProperty.call(value, key) + ) { + value = value[key as keyof typeof value]; + } + } + + if (isNonEmptyPrimitive(value) || typeof value === 'object') { + return value; + } + + return null; +} diff --git a/src/app/public/utilities/get-type-of.ts b/src/app/public/utilities/get-type-of.ts new file mode 100644 index 0000000..3c7e273 --- /dev/null +++ b/src/app/public/utilities/get-type-of.ts @@ -0,0 +1,15 @@ +import { DateTime } from 'luxon'; + +export function getTypeOf( + value: unknown, +): 'date' | 'datetime' | 'time' | string { + if (value instanceof Date) { + return 'date'; + } + + if (value instanceof DateTime) { + return 'datetime'; + } + + return typeof value; +} diff --git a/src/app/public/utilities/index.ts b/src/app/public/utilities/index.ts index 3d771d6..85b0c9a 100644 --- a/src/app/public/utilities/index.ts +++ b/src/app/public/utilities/index.ts @@ -1,2 +1,9 @@ export * from './quick-sort'; export * from './type-checks'; +export { copyToClipboard } from './copy-to-clipboard'; +export { getData } from './get-data'; +export { getDate } from './get-date'; +export { getNestedObjectData } from './get-nested-object-data'; +export { getTypeOf } from './get-type-of'; +export { jsonSafeReplacer } from './json-safe-replacer'; +export { scrollTop } from './scroll-top'; diff --git a/src/app/public/utilities/json-safe-replacer.ts b/src/app/public/utilities/json-safe-replacer.ts new file mode 100644 index 0000000..6b08708 --- /dev/null +++ b/src/app/public/utilities/json-safe-replacer.ts @@ -0,0 +1,40 @@ +import { ElementRef, TemplateRef } from '@angular/core'; + +export const jsonSafeReplacer = (() => { + const seen = new WeakSet(); + const dropKeys = new Set([ + 'templateRef', + 'viewContainerRef', + '__ngContext__', + '_def', + 'blueprint', + ]); + + return function (_key: string, value: unknown): unknown { + if (dropKeys.has(_key)) { + return undefined; + } + + if (value instanceof TemplateRef) { + return '[TemplateRef]'; + } + + if (typeof ElementRef !== 'undefined' && value instanceof ElementRef) { + return '[ElementRef]'; + } + + if (typeof value === 'function') { + return `[Function ${value.name || 'anonymous'}]`; + } + + if (value && typeof value === 'object') { + if (seen.has(value)) { + return '[Circular]'; + } + + seen.add(value); + } + + return value; + }; +})(); diff --git a/src/app/public/utilities/quick-sort.ts b/src/app/public/utilities/quick-sort.ts index fcc06ff..2000d93 100644 --- a/src/app/public/utilities/quick-sort.ts +++ b/src/app/public/utilities/quick-sort.ts @@ -9,11 +9,19 @@ export function quickSort( const valueA = a[key] as T; const valueB = b[key] as T; - if (valueA === null || valueA === undefined) return 1; - if (valueB === null || valueB === undefined) return -1; + if (valueA === null || valueA === undefined) { + return 1; + } + if (valueB === null || valueB === undefined) { + return -1; + } - if (valueA < valueB) return direction === 'asc' ? -1 : 1; - if (valueA > valueB) return direction === 'asc' ? 1 : -1; + if (valueA < valueB) { + return direction === 'asc' ? -1 : 1; + } + if (valueA > valueB) { + return direction === 'asc' ? 1 : -1; + } return 0; }); } diff --git a/src/app/public/utilities/scroll-top.ts b/src/app/public/utilities/scroll-top.ts new file mode 100644 index 0000000..47a3d9f --- /dev/null +++ b/src/app/public/utilities/scroll-top.ts @@ -0,0 +1,12 @@ +import { ElementRef } from '@angular/core'; + +export function scrollTop( + elementRef: ElementRef, + { offset = 0 }: { offset?: number } = {}, +): void { + const topOfTableWithOffset = + elementRef.nativeElement.getBoundingClientRect().top + + window.scrollY - + offset; + window.scrollTo({ top: topOfTableWithOffset, behavior: 'smooth' }); +} diff --git a/src/app/public/utilities/type-checks.ts b/src/app/public/utilities/type-checks.ts index 6a5fd3c..3f5c435 100644 --- a/src/app/public/utilities/type-checks.ts +++ b/src/app/public/utilities/type-checks.ts @@ -1,7 +1,21 @@ -import { Primitive } from '../types'; +import { + Primitive, + TableTemplateReferenceExpandableObject, + TableTemplateReferenceObject, +} from '../types'; -export function isString(value: unknown): value is string { - return typeof value === 'string' || value instanceof String; +export function isNonEmptyObject(value: unknown): value is object { + return value instanceof Object && Object.keys(value).length > 0; +} + +export function isNonEmptyPrimitive( + value: unknown, +): value is NonNullable { + return isPrimitive(value) && value !== null && value !== undefined; +} + +export function isNonEmptyString(value: unknown): value is string { + return isString(value) && !isWhitespaceString(value); } export function isPrimitive(value: unknown): value is Primitive { @@ -16,14 +30,30 @@ export function isPrimitive(value: unknown): value is Primitive { ); } -export function isNonEmptyPrimitive( +export function isString(value: unknown): value is string { + return typeof value === 'string' || value instanceof String; +} + +export function isTableTemplateRefExpandableObject( value: unknown, -): value is NonNullable { - return isPrimitive(value) && value !== null && value !== undefined; +): value is TableTemplateReferenceExpandableObject { + return ( + value instanceof Object && + 'context' in value && + 'templateRef' in value && + 'isExpanded' in value + ); } -export function isNonEmptyString(value: unknown): value is string { - return isString(value) && !isWhitespaceString(value); +export function isTableTemplateRefObject( + value: unknown, +): value is TableTemplateReferenceObject { + return ( + value instanceof Object && + 'context' in value && + 'templateRef' in value && + !('isExpanded' in value) + ); } export function isNonEmptyValue(value: T): value is NonNullable { diff --git a/src/app/table-example/table-example.component.ts b/src/app/table-example/table-example.component.ts index f6a4c3a..d3712d1 100644 --- a/src/app/table-example/table-example.component.ts +++ b/src/app/table-example/table-example.component.ts @@ -14,7 +14,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { TableComponent } from '../public/table/table.component'; import { TableColumn, TableSortChangeEvent } from '../public/types'; -import { quickSort } from '../public/utilities'; +import { jsonSafeReplacer, quickSort } from '../public/utilities'; import { COLUMNS, COLUMNS_EXPANDABLE, @@ -72,11 +72,9 @@ export class TableExampleComponent implements OnInit { protected readonly sortableHeaders = signal(false); protected readonly stickyHeader = signal(false); - /** ----------------- Data ----------------- */ protected readonly columns = signal(COLUMNS); protected readonly data = signal(DATA); - /** ------------ Expandable Data ----------- */ @ViewChild('expendableRow', { static: true }) public readonly expendableRow!: TemplateRef; protected readonly columnsExpandable = signal(COLUMNS_EXPANDABLE); @@ -97,7 +95,9 @@ export class TableExampleComponent implements OnInit { } protected onRowSelect(rows: readonly CustomDataExpandable[]): void { - this.selectedRows.set(rows); + this.selectedRows.set( + rows.map(({ template, ...rest }) => rest as typeof rest), + ); } protected onSortChange( @@ -135,7 +135,11 @@ export class TableExampleComponent implements OnInit { } protected pretifyJson(value: unknown): string { - return JSON.stringify(value, undefined, 4); + try { + return JSON.stringify(value, jsonSafeReplacer, 2); + } catch { + return String(value); + } } private mapTemplateToData(item: CustomData): CustomDataExpandable {