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 {