1import { Component, computed, input, output, signal } from "@angular/core"
2import { FormsModule } from "@angular/forms"
3import { CommonModule } from "@angular/common"
4
5interface Column<T> {
6 key: keyof T & string
7 label: string
8 sortable?: boolean
9}
10
11type SortDirection = "asc" | "desc" | null
12
13@Component({
14 selector: "app-data-table",
15 standalone: true,
16 imports: [CommonModule, FormsModule],
17 template: `
18 <div class="table-container">
19 <input
20 type="text"
21 [ngModel]="filterText()"
22 (ngModelChange)="filterText.set($event)"
23 placeholder="Search..."
24 class="search-input"
25 />
26
27 <table>
28 <thead>
29 <tr>
30 @for (col of columns(); track col.key) {
31 <th
32 [class.sortable]="col.sortable"
33 (click)="col.sortable && toggleSort(col.key)"
34 >
35 {{ col.label }}
36 @if (sortColumn() === col.key) {
37 <span>{{ sortDirection() === "asc" ? "^" : "v" }}</span>
38 }
39 </th>
40 }
41 </tr>
42 </thead>
43 <tbody>
44 @for (row of displayedRows(); track trackByFn()(row)) {
45 <tr (click)="rowClick.emit(row)">
46 @for (col of columns(); track col.key) {
47 <td>{{ row[col.key] }}</td>
48 }
49 </tr>
50 } @empty {
51 <tr>
52 <td [attr.colspan]="columns().length">No results found</td>
53 </tr>
54 }
55 </tbody>
56 </table>
57
58 <div class="pagination">
59 <button [disabled]="currentPage() <= 1" (click)="prevPage()">
60 Previous
61 </button>
62 <span>Page {{ currentPage() }} of {{ totalPages() }}</span>
63 <button
64 [disabled]="currentPage() >= totalPages()"
65 (click)="nextPage()"
66 >
67 Next
68 </button>
69 </div>
70 </div>
71 `,
72})
73export class DataTableComponent<T extends Record<string, unknown>> {
74 columns = input.required<Column<T>[]>()
75 data = input.required<T[]>()
76 pageSize = input(10)
77 trackByFn = input<(item: T) => unknown>(() => (item: T) => item)
78
79 rowClick = output<T>()
80
81 filterText = signal("")
82 sortColumn = signal<string | null>(null)
83 sortDirection = signal<SortDirection>(null)
84 currentPage = signal(1)
85
86 private filteredRows = computed(() => {
87 const text = this.filterText().toLowerCase()
88 if (!text) return this.data()
89 return this.data().filter((row) =>
90 Object.values(row).some((val) =>
91 String(val).toLowerCase().includes(text)
92 )
93 )
94 })
95
96 private sortedRows = computed(() => {
97 const rows = [...this.filteredRows()]
98 const col = this.sortColumn()
99 const dir = this.sortDirection()
100 if (!col || !dir) return rows
101
102 return rows.sort((a, b) => {
103 const aVal = a[col] as string
104 const bVal = b[col] as string
105 const cmp = String(aVal).localeCompare(String(bVal))
106 return dir === "asc" ? cmp : -cmp
107 })
108 })
109
110 totalPages = computed(() =>
111 Math.max(1, Math.ceil(this.sortedRows().length / this.pageSize()))
112 )
113
114 displayedRows = computed(() => {
115 const start = (this.currentPage() - 1) * this.pageSize()
116 return this.sortedRows().slice(start, start + this.pageSize())
117 })
118
119 toggleSort(key: string): void {
120 if (this.sortColumn() === key) {
121 const next =
122 this.sortDirection() === "asc"
123 ? "desc"
124 : this.sortDirection() === "desc"
125 ? null
126 : "asc"
127 this.sortDirection.set(next)
128 if (!next) this.sortColumn.set(null)
129 } else {
130 this.sortColumn.set(key)
131 this.sortDirection.set("asc")
132 }
133 this.currentPage.set(1)
134 }
135
136 prevPage(): void {
137 this.currentPage.update((p) => Math.max(1, p - 1))
138 }
139
140 nextPage(): void {
141 this.currentPage.update((p) => Math.min(this.totalPages(), p + 1))
142 }
143}