🎯 Implementation Complete! This library has been extracted from the monorepo and is ready for Git submodule distribution. Features: - Standardized SCSS imports (no relative paths) - Optimized public-api.ts exports - Independent Angular library structure - Ready for consumer integration as submodule 🚀 Generated with Claude Code (https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
246 lines
8.0 KiB
TypeScript
246 lines
8.0 KiB
TypeScript
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ContentChild, TemplateRef } from '@angular/core';
|
|
import { CommonModule } from '@angular/common';
|
|
|
|
export type TableVariant = 'default' | 'striped' | 'bordered' | 'minimal';
|
|
export type TableSize = 'compact' | 'default' | 'comfortable';
|
|
export type TableColumnAlign = 'left' | 'center' | 'right';
|
|
|
|
export interface TableColumn {
|
|
key: string;
|
|
label: string;
|
|
sortable?: boolean;
|
|
align?: TableColumnAlign;
|
|
width?: string;
|
|
sticky?: boolean;
|
|
}
|
|
|
|
export interface TableSortEvent {
|
|
column: string;
|
|
direction: 'asc' | 'desc' | null;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'ui-table',
|
|
standalone: true,
|
|
imports: [CommonModule],
|
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
|
template: `
|
|
<div [class]="tableContainerClasses">
|
|
<table [class]="tableClasses">
|
|
<!-- Table Header -->
|
|
@if (showHeader && columns.length > 0) {
|
|
<thead class="ui-table__header">
|
|
<tr class="ui-table__header-row">
|
|
@for (column of columns; track column.key) {
|
|
<th
|
|
[class]="getHeaderCellClasses(column)"
|
|
[style.width]="column.width"
|
|
[attr.aria-sort]="getSortAttribute(column.key)"
|
|
(click)="handleSort(column)"
|
|
>
|
|
<div class="ui-table__header-content">
|
|
<span class="ui-table__header-text">{{ column.label }}</span>
|
|
@if (column.sortable && sortColumn === column.key) {
|
|
<span class="ui-table__sort-indicator" [attr.data-direction]="sortDirection">
|
|
@if (sortDirection === 'asc') {
|
|
↑
|
|
} @else if (sortDirection === 'desc') {
|
|
↓
|
|
}
|
|
</span>
|
|
}
|
|
</div>
|
|
</th>
|
|
}
|
|
</tr>
|
|
</thead>
|
|
}
|
|
|
|
<!-- Table Body -->
|
|
<tbody class="ui-table__body">
|
|
@if (data.length === 0 && showEmptyState) {
|
|
<tr class="ui-table__empty-row">
|
|
<td [attr.colspan]="columns.length" class="ui-table__empty-cell">
|
|
<div class="ui-table__empty-content">
|
|
@if (emptyTemplate) {
|
|
<ng-container [ngTemplateOutlet]="emptyTemplate"></ng-container>
|
|
} @else {
|
|
<div class="ui-table__empty-text">{{ emptyMessage }}</div>
|
|
}
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
} @else {
|
|
@for (row of data; track trackByFn ? trackByFn($index, row) : $index; let rowIndex = $index) {
|
|
<tr
|
|
[class]="getRowClasses(row, rowIndex)"
|
|
(click)="handleRowClick(row, rowIndex)"
|
|
[attr.aria-rowindex]="rowIndex + 1"
|
|
>
|
|
@for (column of columns; track column.key) {
|
|
<td [class]="getBodyCellClasses(column, row)">
|
|
@if (getCellTemplate(column.key)) {
|
|
<ng-container
|
|
[ngTemplateOutlet]="getCellTemplate(column.key)!"
|
|
[ngTemplateOutletContext]="{
|
|
$implicit: getCellValue(row, column.key),
|
|
row: row,
|
|
column: column,
|
|
index: rowIndex
|
|
}"
|
|
></ng-container>
|
|
} @else {
|
|
<span class="ui-table__cell-content">
|
|
{{ getCellValue(row, column.key) }}
|
|
</span>
|
|
}
|
|
</td>
|
|
}
|
|
</tr>
|
|
}
|
|
}
|
|
</tbody>
|
|
</table>
|
|
|
|
<!-- Loading Overlay -->
|
|
@if (loading) {
|
|
<div class="ui-table__loading-overlay">
|
|
<div class="ui-table__loading-spinner"></div>
|
|
<div class="ui-table__loading-text">{{ loadingMessage }}</div>
|
|
</div>
|
|
}
|
|
</div>
|
|
`,
|
|
styleUrl: './table.component.scss'
|
|
})
|
|
export class TableComponent {
|
|
@Input() data: any[] = [];
|
|
@Input() columns: TableColumn[] = [];
|
|
@Input() variant: TableVariant = 'default';
|
|
@Input() size: TableSize = 'default';
|
|
@Input() showHeader: boolean = true;
|
|
@Input() showEmptyState: boolean = true;
|
|
@Input() emptyMessage: string = 'No data available';
|
|
@Input() loading: boolean = false;
|
|
@Input() loadingMessage: string = 'Loading...';
|
|
@Input() hoverable: boolean = true;
|
|
@Input() selectable: boolean = false;
|
|
@Input() stickyHeader: boolean = false;
|
|
@Input() maxHeight: string = '';
|
|
@Input() sortColumn: string = '';
|
|
@Input() sortDirection: 'asc' | 'desc' | null = null;
|
|
@Input() trackByFn?: (index: number, item: any) => any;
|
|
@Input() cellTemplates: Record<string, TemplateRef<any>> = {};
|
|
@Input() class: string = '';
|
|
|
|
@Output() rowClick = new EventEmitter<{row: any, index: number}>();
|
|
@Output() sort = new EventEmitter<TableSortEvent>();
|
|
@Output() selectionChange = new EventEmitter<any[]>();
|
|
|
|
@ContentChild('emptyTemplate') emptyTemplate?: TemplateRef<any>;
|
|
|
|
selectedRows: Set<any> = new Set();
|
|
|
|
get tableContainerClasses(): string {
|
|
return [
|
|
'ui-table-container',
|
|
this.stickyHeader ? 'ui-table-container--sticky-header' : '',
|
|
this.maxHeight ? 'ui-table-container--max-height' : '',
|
|
this.loading ? 'ui-table-container--loading' : '',
|
|
this.class
|
|
].filter(Boolean).join(' ');
|
|
}
|
|
|
|
get tableClasses(): string {
|
|
return [
|
|
'ui-table',
|
|
`ui-table--${this.variant}`,
|
|
`ui-table--${this.size}`,
|
|
this.hoverable ? 'ui-table--hoverable' : '',
|
|
this.selectable ? 'ui-table--selectable' : '',
|
|
].filter(Boolean).join(' ');
|
|
}
|
|
|
|
getHeaderCellClasses(column: TableColumn): string {
|
|
return [
|
|
'ui-table__header-cell',
|
|
`ui-table__header-cell--${column.align || 'left'}`,
|
|
column.sortable ? 'ui-table__header-cell--sortable' : '',
|
|
column.sticky ? 'ui-table__header-cell--sticky' : '',
|
|
this.sortColumn === column.key ? 'ui-table__header-cell--sorted' : ''
|
|
].filter(Boolean).join(' ');
|
|
}
|
|
|
|
getBodyCellClasses(column: TableColumn, row: any): string {
|
|
return [
|
|
'ui-table__body-cell',
|
|
`ui-table__body-cell--${column.align || 'left'}`,
|
|
column.sticky ? 'ui-table__body-cell--sticky' : ''
|
|
].filter(Boolean).join(' ');
|
|
}
|
|
|
|
getRowClasses(row: any, index: number): string {
|
|
return [
|
|
'ui-table__body-row',
|
|
this.selectedRows.has(row) ? 'ui-table__body-row--selected' : '',
|
|
index % 2 === 1 && this.variant === 'striped' ? 'ui-table__body-row--striped' : ''
|
|
].filter(Boolean).join(' ');
|
|
}
|
|
|
|
handleSort(column: TableColumn): void {
|
|
if (!column.sortable) return;
|
|
|
|
let newDirection: 'asc' | 'desc' | null;
|
|
|
|
if (this.sortColumn === column.key) {
|
|
// Toggle through: asc -> desc -> null -> asc
|
|
if (this.sortDirection === 'asc') {
|
|
newDirection = 'desc';
|
|
} else if (this.sortDirection === 'desc') {
|
|
newDirection = null;
|
|
} else {
|
|
newDirection = 'asc';
|
|
}
|
|
} else {
|
|
newDirection = 'asc';
|
|
}
|
|
|
|
this.sort.emit({
|
|
column: column.key,
|
|
direction: newDirection
|
|
});
|
|
}
|
|
|
|
handleRowClick(row: any, index: number): void {
|
|
if (this.selectable) {
|
|
this.toggleRowSelection(row);
|
|
}
|
|
|
|
this.rowClick.emit({ row, index });
|
|
}
|
|
|
|
toggleRowSelection(row: any): void {
|
|
if (this.selectedRows.has(row)) {
|
|
this.selectedRows.delete(row);
|
|
} else {
|
|
this.selectedRows.add(row);
|
|
}
|
|
|
|
this.selectionChange.emit(Array.from(this.selectedRows));
|
|
}
|
|
|
|
getCellValue(row: any, key: string): any {
|
|
return key.split('.').reduce((obj, prop) => obj?.[prop], row) ?? '';
|
|
}
|
|
|
|
getCellTemplate(columnKey: string): TemplateRef<any> | null {
|
|
return this.cellTemplates[columnKey] || null;
|
|
}
|
|
|
|
getSortAttribute(columnKey: string): string | null {
|
|
if (this.sortColumn === columnKey) {
|
|
return this.sortDirection === 'asc' ? 'ascending' : 'descending';
|
|
}
|
|
return null;
|
|
}
|
|
} |