Skip to content

Commit 75231e8

Browse files
committed
feat(ui): book browse table on TanStack table v9
1 parent bc3239f commit 75231e8

8 files changed

Lines changed: 822 additions & 22 deletions

File tree

frontend/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
"@stomp/rx-stomp": "^2.4.0",
4141
"@stomp/stompjs": "^7.3.0",
4242
"@tanstack/angular-query-experimental": "5.102.8",
43-
"@tanstack/angular-table": "^8.21.4",
43+
"@tanstack/angular-table": "9.1.0",
4444
"@tanstack/angular-virtual": "^6.0.2",
4545
"chart.js": "^4.5.1",
4646
"chartjs-chart-matrix": "3.0.5",
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import {Injectable, inject} from '@angular/core';
2+
3+
import {LocalStorageService} from '../../../shared/service/local-storage.service';
4+
5+
const STORAGE_KEY = 'browseTableColumnWidths';
6+
7+
@Injectable({providedIn: 'root'})
8+
export class BookBrowseColumnWidthPreferenceService {
9+
private readonly localStorage = inject(LocalStorageService);
10+
11+
load(): Record<string, number> {
12+
const saved = this.localStorage.get<Record<string, unknown>>(STORAGE_KEY);
13+
if (saved == null || typeof saved !== 'object' || Array.isArray(saved)) {
14+
return {};
15+
}
16+
return Object.fromEntries(
17+
Object.entries(saved).filter(
18+
(entry): entry is [string, number] =>
19+
typeof entry[1] === 'number' && Number.isFinite(entry[1]) && entry[1] > 0,
20+
),
21+
);
22+
}
23+
24+
save(widths: Record<string, number>): void {
25+
this.localStorage.set(STORAGE_KEY, widths);
26+
}
27+
}
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
@if (viewState() === 'empty') {
2+
<ng-container [ngTemplateOutlet]="emptyDef().templateRef" />
3+
} @else if (viewState() === 'initial-error') {
4+
<div class="flex min-h-64 flex-col items-center justify-center gap-3">
5+
<p class="m-0 text-sm text-text-muted">{{ initialErrorMessage() }}</p>
6+
<app-button
7+
variant="soft"
8+
size="sm"
9+
[label]="'common.retry' | transloco"
10+
(clicked)="retryInitial.emit()" />
11+
</div>
12+
} @else {
13+
<div
14+
#scrollElement
15+
[class]="paneClass()">
16+
<table
17+
role="table"
18+
class="grid min-w-full border-separate border-spacing-0 text-sm text-text"
19+
[style]="columnSizeVars()"
20+
[style.width.px]="tableWidth()"
21+
[attr.aria-rowcount]="ariaRowCount()"
22+
[attr.aria-colcount]="ariaColumnCount()">
23+
<thead role="rowgroup" class="sticky top-0 z-30 grid bg-page shadow-[0_1px_0_color-mix(in_srgb,currentColor_12%,transparent)]">
24+
<tr role="row" class="flex min-w-full" [style.width.px]="tableWidth()">
25+
@for (header of headers(); track header.id) {
26+
<th
27+
role="columnheader"
28+
scope="col"
29+
[class]="cellClasses()[header.column.id].header"
30+
[style.width]="columnWidth(header.column.id)"
31+
[attr.aria-sort]="primaryAriaSort(header)">
32+
@if (header.column.id === 'select') {
33+
<span class="grid h-full w-full place-items-center" (pointerdown)="stopPropagation($event)">
34+
<app-checkbox
35+
size="sm"
36+
[ariaLabel]="'shared.ui.bulkActions.selectAll' | transloco"
37+
[checked]="allSelected()"
38+
[indeterminate]="someSelected()"
39+
(checkedChange)="changeHeaderSelection($event)" />
40+
</span>
41+
} @else if (header.column.getCanSort()) {
42+
<button
43+
type="button"
44+
[class]="cellClasses()[header.column.id].sortButton"
45+
(click)="header.column.getToggleSortingHandler()?.($event)">
46+
<span>{{ headerLabelKeys()[header.column.id] | transloco }}</span>
47+
@if (header.column.getIsSorted(); as direction) {
48+
@if (direction === 'asc') {
49+
<svg lucideArrowUp class="size-3.5 shrink-0 text-primary" aria-hidden="true"></svg>
50+
} @else {
51+
<svg lucideArrowDown class="size-3.5 shrink-0 text-primary" aria-hidden="true"></svg>
52+
}
53+
@if (sortTerms().length > 1) {
54+
<app-tag
55+
color="primary"
56+
size="sm"
57+
styleClass="size-4 justify-center p-0! text-[10px]!"
58+
aria-hidden="true">
59+
{{ header.column.getSortIndex() + 1 }}
60+
</app-tag>
61+
}
62+
}
63+
</button>
64+
} @else {
65+
<span>{{ headerLabelKeys()[header.column.id] | transloco }}</span>
66+
}
67+
68+
@if (header.column.getCanResize()) {
69+
<span
70+
class="absolute -right-1.5 top-0 z-10 h-full w-3 cursor-col-resize touch-none after:absolute after:inset-y-1/4 after:left-1/2 after:w-px after:-translate-x-1/2 after:bg-current/20 after:content-['']"
71+
aria-hidden="true"
72+
(mousedown)="startResize(header, $event)"
73+
(touchstart)="startResize(header, $event)"
74+
(dblclick)="resetWidth(header, $event)"></span>
75+
}
76+
</th>
77+
}
78+
</tr>
79+
</thead>
80+
<tbody role="rowgroup" class="relative grid" [style.height.px]="rowVirtualizer.getTotalSize()">
81+
@for (virtualRow of rowVirtualizer.getVirtualItems(); track virtualRow.key) {
82+
@let row = rows()[virtualRow.index];
83+
<tr
84+
role="row"
85+
class="group/row absolute left-0 top-0 flex min-w-full cursor-default"
86+
[class.opacity-50]="row ? pendingDeletionIds().has(row.original.id) : false"
87+
[attr.data-selected]="row && isSelected(row.original) ? 'true' : null"
88+
[attr.data-menu-open]="row && row.original.id === openMenuBookId() ? 'true' : null"
89+
(contextmenu)="onRowContextMenu(row?.original, $event)"
90+
[style.width.px]="tableWidth()"
91+
[style.height.px]="virtualRow.size"
92+
[style.transform]="'translateY(' + virtualRow.start + 'px)'"
93+
[attr.aria-rowindex]="virtualRow.index + 2">
94+
@if (row) {
95+
@let book = row.original;
96+
@for (cell of row.getAllCells(); track cell.id) {
97+
@let field = cell.column.id;
98+
<td
99+
role="cell"
100+
[class]="cellClasses()[field].body"
101+
[style.width]="columnWidth(field)">
102+
@switch (field) {
103+
@case ('select') {
104+
<span
105+
class="grid h-full w-full place-items-center opacity-0 group-hover/row:opacity-100 group-has-[:focus-visible]/row:opacity-100 group-data-[selected=true]/row:opacity-100"
106+
[class.opacity-100]="selectionActive()"
107+
(pointerdown)="rememberSelectionPointer($event)">
108+
<app-checkbox
109+
size="sm"
110+
[ariaLabel]="'book.selectBook' | transloco: {title: bookTitle(book)}"
111+
[checked]="isSelected(book)"
112+
(checkedChange)="toggleRowSelection(book, virtualRow.index)" />
113+
</span>
114+
}
115+
@case ('title') {
116+
<div class="flex min-w-0 items-center gap-2.5">
117+
<span
118+
class="block w-[26px] flex-none overflow-hidden rounded-[0.2rem] outline -outline-offset-1 outline-black/10 dark:outline-white/10 [&_app-cover]:h-full"
119+
[style.height.px]="squareCover(book) ? 26 : 38"
120+
aria-hidden="true">
121+
<app-cover
122+
[src]="coverUrl(book)"
123+
[title]="bookTitle(book)"
124+
[authors]="authors(book)"
125+
size="sm"
126+
loading="lazy" />
127+
</span>
128+
<a
129+
class="min-w-0 overflow-hidden text-ellipsis whitespace-nowrap font-medium text-text no-underline hover:text-primary-text hover:underline hover:underline-offset-[0.15em] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary"
130+
[attr.href]="detailHref(book)"
131+
(click)="onDetailClick(book, $event)">
132+
{{ bookTitle(book) }}
133+
</a>
134+
@if (!selectionActive()) {
135+
<span class="ml-auto hidden flex-none group-hover/row:block group-has-[:focus-visible]/row:block group-data-[menu-open=true]/row:block pointer-coarse:block">
136+
<app-button
137+
variant="ghost"
138+
size="sm"
139+
iconOnly
140+
ariaHasPopup="menu"
141+
[ariaExpanded]="book.id === openMenuBookId()"
142+
[ariaLabel]="'book.card.aria.bookMenu' | transloco: {title: bookTitle(book)}"
143+
(clicked)="onRowMenu(book, $event)">
144+
<svg lucideEllipsisVertical aria-hidden="true"></svg>
145+
</app-button>
146+
</span>
147+
}
148+
</div>
149+
}
150+
@default {
151+
@let value = cellText(cell);
152+
@let links = cellLinks(book, field);
153+
@if (links.length > 0) {
154+
<span class="flex min-w-0 max-w-full items-center gap-2" [title]="value">
155+
<button
156+
type="button"
157+
class="min-h-10 min-w-0 cursor-pointer overflow-hidden text-ellipsis whitespace-nowrap border-0 bg-transparent p-0 font-[inherit] text-primary-text hover:underline hover:underline-offset-[0.15em] focus-visible:rounded-[0.2rem] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary"
158+
(click)="facetRequested.emit(links[0])">
159+
{{ links[0].value }}
160+
</button>
161+
@if (links.length > 1) {
162+
<button
163+
type="button"
164+
class="relative h-6 flex-none cursor-pointer rounded-[0.2rem] border-0 bg-transparent px-0.5 font-[inherit] tabular-nums text-primary-text hover:underline hover:underline-offset-[0.15em] focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-primary after:absolute after:inset-x-0 after:top-1/2 after:h-10 after:-translate-y-1/2 after:content-['']"
165+
[attr.aria-label]="moreLinksLabel(field, links.length - 1)"
166+
[appMenuTriggerFor]="overflowMenu"
167+
(click)="prepareOverflowMenu(links, field)"
168+
(keydown)="prepareOverflowMenuOnKey($event, links, field)">
169+
+{{ links.length - 1 }}
170+
</button>
171+
}
172+
</span>
173+
} @else {
174+
<span
175+
class="overflow-hidden text-ellipsis whitespace-nowrap"
176+
[class.text-text-muted]="value === emptyValue"
177+
[title]="value">
178+
{{ value }}
179+
</span>
180+
}
181+
}
182+
}
183+
</td>
184+
}
185+
} @else {
186+
@for (header of headers(); track header.id) {
187+
<td
188+
role="cell"
189+
[class]="cellClasses()[header.column.id].body"
190+
[style.width]="columnWidth(header.column.id)">
191+
<span
192+
class="h-3 w-[min(80%,11rem)] rounded-full bg-skeleton-base animate-skeleton motion-reduce:animate-none"
193+
aria-hidden="true"></span>
194+
</td>
195+
}
196+
}
197+
@if (row && mobile() && selectionActive()) {
198+
<td aria-hidden="true" class="absolute inset-0 z-20 block p-0">
199+
<button
200+
type="button"
201+
tabindex="-1"
202+
class="block h-full w-full cursor-default border-0 bg-transparent p-0"
203+
(click)="toggleRowSelection(row.original, virtualRow.index)"></button>
204+
</td>
205+
}
206+
</tr>
207+
}
208+
</tbody>
209+
</table>
210+
</div>
211+
212+
@if (nextPageError()) {
213+
<div class="z-10 -mt-12 flex justify-center">
214+
<div class="flex items-center gap-3 rounded-lg border border-border bg-card px-4 py-2 shadow-pop">
215+
<span class="text-sm text-text-muted">{{ nextPageErrorMessage() }}</span>
216+
<app-button
217+
variant="ghost"
218+
size="sm"
219+
[label]="'common.retry' | transloco"
220+
(clicked)="retryNextPage.emit()" />
221+
</div>
222+
</div>
223+
}
224+
}
225+
226+
<app-menu
227+
#overflowMenu
228+
menuClass="w-max max-w-[min(24rem,calc(100vw-1rem))]"
229+
[ariaLabel]="overflowMenuLabel()">
230+
<div class="max-h-80 overflow-y-auto overscroll-contain">
231+
@for (link of overflowLinks(); track link.key + ':' + link.value) {
232+
<app-menu-item [value]="link.key + ':' + link.value" [searchLabel]="link.value" (selected)="facetRequested.emit(link)">
233+
{{ link.value }}
234+
</app-menu-item>
235+
}
236+
</div>
237+
</app-menu>

0 commit comments

Comments
 (0)