From 4f77b2fcc92c7b11d9c2c22198c41523b85236f6 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Thu, 10 Sep 2026 13:02:54 +0100 Subject: [PATCH 1/2] feat(ui): book browse table on TanStack table v9 --- frontend/package.json | 2 +- ...-browse-column-width-preference.service.ts | 27 + .../browse/book-browse-table.component.html | 237 ++++++++ .../browse/book-browse-table.component.ts | 509 ++++++++++++++++++ .../book-table/book-table.component.spec.ts | 1 + .../book-table/book-table.component.ts | 17 +- frontend/src/i18n/en.json | 6 +- pnpm-lock.yaml | 46 +- 8 files changed, 823 insertions(+), 22 deletions(-) create mode 100644 frontend/src/app/features/book/browse/book-browse-column-width-preference.service.ts create mode 100644 frontend/src/app/features/book/browse/book-browse-table.component.html create mode 100644 frontend/src/app/features/book/browse/book-browse-table.component.ts diff --git a/frontend/package.json b/frontend/package.json index 5798f00bce..55e20a91e7 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -40,7 +40,7 @@ "@stomp/rx-stomp": "^2.4.0", "@stomp/stompjs": "^7.3.0", "@tanstack/angular-query-experimental": "5.102.8", - "@tanstack/angular-table": "^8.21.4", + "@tanstack/angular-table": "9.1.0", "@tanstack/angular-virtual": "^6.0.2", "chart.js": "^4.5.1", "chartjs-chart-matrix": "3.0.5", diff --git a/frontend/src/app/features/book/browse/book-browse-column-width-preference.service.ts b/frontend/src/app/features/book/browse/book-browse-column-width-preference.service.ts new file mode 100644 index 0000000000..607ece96fb --- /dev/null +++ b/frontend/src/app/features/book/browse/book-browse-column-width-preference.service.ts @@ -0,0 +1,27 @@ +import {Injectable, inject} from '@angular/core'; + +import {LocalStorageService} from '../../../shared/service/local-storage.service'; + +const STORAGE_KEY = 'browseTableColumnWidths'; + +@Injectable({providedIn: 'root'}) +export class BookBrowseColumnWidthPreferenceService { + private readonly localStorage = inject(LocalStorageService); + + load(): Record { + const saved = this.localStorage.get>(STORAGE_KEY); + if (saved == null || typeof saved !== 'object' || Array.isArray(saved)) { + return {}; + } + return Object.fromEntries( + Object.entries(saved).filter( + (entry): entry is [string, number] => + typeof entry[1] === 'number' && Number.isFinite(entry[1]) && entry[1] > 0, + ), + ); + } + + save(widths: Record): void { + this.localStorage.set(STORAGE_KEY, widths); + } +} diff --git a/frontend/src/app/features/book/browse/book-browse-table.component.html b/frontend/src/app/features/book/browse/book-browse-table.component.html new file mode 100644 index 0000000000..53e1efd328 --- /dev/null +++ b/frontend/src/app/features/book/browse/book-browse-table.component.html @@ -0,0 +1,237 @@ +@if (viewState() === 'empty') { + +} @else if (viewState() === 'initial-error') { +
+

{{ initialErrorMessage() }}

+ +
+} @else { +
+ + + + @for (header of headers(); track header.id) { + + } + + + + @for (virtualRow of rowVirtualizer.getVirtualItems(); track virtualRow.key) { + @let row = rows()[virtualRow.index]; + + @if (row) { + @let book = row.original; + @for (cell of row.getAllCells(); track cell.id) { + @let field = cell.column.id; + + } + } @else { + @for (header of headers(); track header.id) { + + } + } + @if (row && mobile() && selectionActive()) { + + } + + } + +
+ @if (header.column.id === 'select') { + + + + } @else if (header.column.getCanSort()) { + + } @else { + {{ headerLabelKeys()[header.column.id] | transloco }} + } + + @if (header.column.getCanResize()) { + + } +
+ @switch (field) { + @case ('select') { + + + + } + @case ('title') { +
+ + + {{ bookTitle(book) }} + + @if (!selectionActive()) { + + } +
+ } + @default { + @let value = cellText(cell); + @let links = cellLinks(book, field); + @if (links.length > 0) { + + + @if (links.length > 1) { + + } + + } @else { + + {{ value }} + + } + } + } +
+ +
+
+ +@if (nextPageError()) { +
+
+ {{ nextPageErrorMessage() }} + +
+
+} +} + + +
+ @for (link of overflowLinks(); track link.key + ':' + link.value) { + + {{ link.value }} + + } +
+
diff --git a/frontend/src/app/features/book/browse/book-browse-table.component.ts b/frontend/src/app/features/book/browse/book-browse-table.component.ts new file mode 100644 index 0000000000..6852ba492c --- /dev/null +++ b/frontend/src/app/features/book/browse/book-browse-table.component.ts @@ -0,0 +1,509 @@ +import {LocationStrategy, NgTemplateOutlet} from '@angular/common'; +import { + Component, + DestroyRef, + ElementRef, + computed, + contentChild, + effect, + inject, + input, + output, + signal, + viewChild, +} from '@angular/core'; +import {ActivatedRoute, Router} from '@angular/router'; +import {TranslocoPipe, TranslocoService} from '@jsverse/transloco'; +import { + type Cell, + type ColumnDef, + type ColumnSizingState, + type Header, + type SortingState, + columnResizingFeature, + columnSizingFeature, + functionalUpdate, + injectTable, + rowSortingFeature, + tableFeatures, +} from '@tanstack/angular-table'; +import {injectVirtualizer} from '@tanstack/angular-virtual'; +import {LucideArrowDown, LucideArrowUp, LucideEllipsisVertical} from '@lucide/angular'; + +import {CoverComponent} from '../../../shared/components/cover/cover.component'; +import {UrlHelperService} from '../../../shared/service/url-helper.service'; +import {contextMenuRequest, type ContextMenuRequest} from '../../../shared/ui/menu/app-menu.component'; +import {isPlainLeftClick} from '../../../shared/util/pointer-gestures'; +import {runOnNextTwoFrames} from '../../../shared/util/frames'; +import {RouteScrollPositionService} from '../../../shared/service/route-scroll-position.service'; +import {AppButtonComponent} from '../../../shared/ui/button/app-button.component'; +import {AppCheckboxComponent} from '../../../shared/ui/checkbox/app-checkbox.component'; +import {AppMenuComponent} from '../../../shared/ui/menu/app-menu.component'; +import {AppMenuItemComponent} from '../../../shared/ui/menu/app-menu-item.component'; +import {AppMenuTriggerDirective} from '../../../shared/ui/menu/app-menu-trigger.directive'; +import {AppTagComponent} from '../../../shared/ui/tag/app-tag.component'; +import {cn} from '../../../shared/ui/cn'; +import {bookCardCoverSrc} from '../components/cards/book-card.component'; +import {BrowseEmptyDef} from '../../../shared/browse/grid/grid.directives'; +import {sameRenderedRange, type BrowseRenderedRange, type BrowseStatus} from '../../../shared/browse/results'; +import {createBrowseSkeletonDelay} from '../../../shared/browse/skeleton-delay'; +import {type BrowseSelection} from '../../../shared/browse/selection'; +import {isBookQuerySortKey, type BookSortTerm} from '../data/book-query-params'; +import {type BookSummary} from '../data/book-response.models'; +import {bookTitle, type BookColumnValue} from './book-browse-fields'; +import { + BOOK_EMPTY_VALUE, + bookColumnKind, + bookColumnSizing, + bookColumnSortKey, + bookColumnValue, + formatBookValue, + type BookColumnOption, +} from './book-browse-columns'; +import {bookFacetLinks, type BookFacetLink} from './book-browse-facet-definitions'; +import {type BookSortOption} from './book-browse-sort'; +import {BookBrowseColumnWidthPreferenceService} from './book-browse-column-width-preference.service'; + +interface BookBrowseTableMenuRequest { + book: BookSummary; + request: ContextMenuRequest; +} + +const ROW_HEIGHT = 54; +const HEADER_HEIGHT = 42; +const RENDER_OVERSCAN = 10; +const SELECT_COLUMN_WIDTH = 44; +const TABLE_SKELETON_ROWS = 12; + +const features = tableFeatures({ + rowSortingFeature, + columnSizingFeature, + columnResizingFeature, +}); + +type BookBrowseHeader = Header; +type BookBrowseCell = Cell; + +const HEADER_CELL_CLASS = + 'relative box-border h-[var(--book-browse-head-h)] min-w-0 flex-none overflow-hidden bg-page px-3 text-left ' + + 'text-xs font-semibold leading-[var(--book-browse-head-h)] whitespace-nowrap text-text-secondary'; + +const SORT_BUTTON_CLASS = + '-mx-3 flex min-h-[var(--book-browse-head-h)] w-[calc(100%+1.5rem)] cursor-pointer items-center ' + + 'gap-1.5 border-0 bg-transparent px-3 font-[inherit] text-[inherit] hover:bg-surface-hover hover:text-text ' + + 'focus-visible:-outline-offset-2 focus-visible:outline-2 focus-visible:outline-primary'; + +const BODY_CELL_CLASS = + 'box-border flex h-[var(--book-browse-row-h)] min-w-0 flex-none items-center overflow-hidden border-b ' + + 'border-border/70 bg-page px-3 group-hover/row:bg-surface-hover ' + + 'group-data-[menu-open=true]/row:bg-surface-hover group-data-[selected=true]/row:bg-active-surface!'; + +@Component({ + selector: 'app-book-browse-table', + imports: [ + NgTemplateOutlet, + AppButtonComponent, + AppCheckboxComponent, + AppMenuComponent, + AppMenuItemComponent, + AppMenuTriggerDirective, + AppTagComponent, + CoverComponent, + TranslocoPipe, + LucideArrowDown, + LucideArrowUp, + LucideEllipsisVertical, + ], + templateUrl: './book-browse-table.component.html', + host: { + class: 'block max-h-full min-h-0 min-w-0 w-full', + '[class.flex-1]': 'mobile()', + }, +}) +export class BookBrowseTableComponent { + readonly books = input.required(); + readonly status = input.required(); + readonly hasNextPage = input.required(); + readonly pendingDeletionIds = input.required>(); + readonly visibleColumns = input.required(); + readonly sortTerms = input.required(); + readonly sortOptions = input.required(); + readonly mobile = input.required(); + readonly selection = input.required(); + readonly selectMode = input(false); + readonly openMenuBookId = input.required(); + readonly useSquareCovers = input.required(); + readonly nextPageError = input.required(); + readonly initialErrorMessage = input.required(); + readonly nextPageErrorMessage = input.required(); + + readonly renderedRange = signal(null, {equal: sameRenderedRange}); + readonly sortTermsChange = output(); + readonly facetRequested = output(); + readonly menuRequested = output(); + readonly detailRequested = output(); + readonly retryInitial = output(); + readonly retryNextPage = output(); + + readonly emptyDef = contentChild.required(BrowseEmptyDef); + + private readonly transloco = inject(TranslocoService); + private readonly urlHelper = inject(UrlHelperService); + private readonly router = inject(Router); + private readonly locationStrategy = inject(LocationStrategy); + private readonly route = inject(ActivatedRoute); + private readonly scrollPosition = inject(RouteScrollPositionService); + private readonly destroyRef = inject(DestroyRef); + private readonly columnWidthPreference = inject(BookBrowseColumnWidthPreferenceService); + + private readonly initialColumnSizing: ColumnSizingState = this.columnWidthPreference.load(); + private readonly selectColumnVisible = computed(() => this.selection() !== null); + private readonly sortOptionsById = computed(() => + new Map(this.sortOptions().map(option => [option.id, option]))); + private readonly sorting = computed(() => + this.sortTerms().map(term => ({id: term.key, desc: term.direction === 'desc'}))); + private readonly columnDefs = computed[]>(() => [ + ...(this.selectColumnVisible() + ? [{ + id: 'select', + size: SELECT_COLUMN_WIDTH, + minSize: SELECT_COLUMN_WIDTH, + maxSize: SELECT_COLUMN_WIDTH, + enableResizing: false, + enableSorting: false, + } satisfies ColumnDef] + : []), + ...this.visibleColumns().map(column => { + const sortKey = bookColumnSortKey(column.field); + const sortOption = sortKey ? this.sortOptionsById().get(sortKey) : undefined; + return { + id: column.field, + ...bookColumnSizing(column.field), + enableSorting: sortOption !== undefined, + sortDescFirst: sortOption?.defaultDirection === 'desc', + accessorFn: book => bookColumnValue(book, column.field), + } satisfies ColumnDef; + }), + ]); + + protected readonly table = injectTable(() => ({ + features, + data: this.books(), + columns: this.columnDefs(), + manualSorting: true, + enableSortingRemoval: false, + columnResizeMode: 'onChange', + initialState: {columnSizing: this.initialColumnSizing}, + state: { + sorting: this.sorting(), + }, + onSortingChange: updater => { + const options = this.sortOptionsById(); + const terms = functionalUpdate(updater, this.sorting()).flatMap((item): BookSortTerm[] => { + const direction = item.desc ? 'desc' : 'asc'; + return isBookQuerySortKey(item.id) && options.get(item.id)?.directions.includes(direction) + ? [{key: item.id, direction}] + : []; + }); + this.sortTermsChange.emit(terms); + }, + })); + + protected readonly headers = computed(() => this.table.getFlatHeaders()); + protected readonly ariaColumnCount = computed(() => this.headers().length); + protected readonly tableWidth = computed(() => this.table.getTotalSize()); + protected readonly isColumnResizing = computed(() => + Boolean(this.table.atoms.columnResizing.get().isResizingColumn), + ); + protected readonly columnSizeVars = computed>(() => + this.table.getAllLeafColumns().reduce>((styles, column) => { + styles[`--book-browse-col-${column.id}-size`] = `${column.getSize()}px`; + return styles; + }, {'--book-browse-row-h': `${ROW_HEIGHT}px`, '--book-browse-head-h': `${HEADER_HEIGHT}px`}), + ); + + protected readonly headerLabelKeys = computed>(() => + Object.fromEntries(this.visibleColumns().map(column => [column.field, column.labelKey]))); + protected readonly cellClasses = computed(() => + Object.fromEntries(this.headers().map(header => { + const id = header.column.id; + const numeric = this.isNumeric(id); + return [id, { + header: cn(HEADER_CELL_CLASS, numeric && 'text-right tabular-nums'), + sortButton: cn(SORT_BUTTON_CLASS, numeric && 'flex-row-reverse'), + body: cn(BODY_CELL_CLASS, numeric && 'justify-end text-right tabular-nums'), + }] as const; + })), + ); + protected readonly emptyValue = BOOK_EMPTY_VALUE; + protected readonly bookTitle = bookTitle; + + protected readonly rows = computed(() => this.table.getRowModel().rows); + private readonly hasRows = computed(() => this.rows().length > 0); + private readonly skeletonVisible = createBrowseSkeletonDelay(this.status, this.hasRows); + protected readonly viewState = computed<'empty' | 'initial-error' | 'table'>(() => { + if (this.hasRows()) { + return 'table'; + } + switch (this.status()) { + case 'error': + return 'initial-error'; + case 'success': + return 'empty'; + default: + return 'table'; + } + }); + protected readonly rowCount = computed(() => { + const rows = this.rows(); + if (rows.length > 0) { + return rows.length + (this.hasNextPage() ? 1 : 0); + } + return this.skeletonVisible() ? TABLE_SKELETON_ROWS : 0; + }); + protected readonly ariaRowCount = computed(() => this.rowCount() + 1); + + private readonly scrollElement = viewChild>('scrollElement'); + private readonly savedScrollTop = + this.scrollPosition.getPosition(this.scrollPosition.keyFor(this.route, 'table')) ?? 0; + private readonly savedScrollLeft = + this.scrollPosition.getPosition(this.scrollPosition.keyFor(this.route, 'table-x')) ?? 0; + private scrollRestored = false; + private readonly viewportHeight = signal(0); + private readonly overscanRows = computed(() => + Math.max(RENDER_OVERSCAN, Math.ceil(this.viewportHeight() / ROW_HEIGHT)), + ); + + protected readonly rowVirtualizer = injectVirtualizer(() => ({ + scrollElement: this.scrollElement(), + count: this.rowCount(), + estimateSize: () => ROW_HEIGHT, + overscan: this.isColumnResizing() ? 2 : this.overscanRows(), + getItemKey: index => this.rows()[index]?.original.id ?? `skeleton-${index}`, + initialOffset: () => this.savedScrollTop, + })); + + protected readonly paneClass = computed(() => cn( + 'book-browse-table-pane relative isolate box-border max-h-full overflow-auto overscroll-contain ' + + 'bg-page pb-[env(safe-area-inset-bottom)]', + this.mobile() + ? '-mx-4 h-full w-[calc(100%+2rem)]' + : 'rounded-xl border border-border', + )); + + private selectionShiftKey = false; + protected readonly selectionActive = computed(() => { + const selection = this.selection(); + return selection !== null && (selection.active() || this.selectMode()); + }); + protected readonly allSelected = computed(() => this.selection()?.allMatchingSelected() ?? false); + protected readonly someSelected = computed(() => { + const selection = this.selection(); + return selection !== null && selection.count() > 0 && !selection.allMatchingSelected(); + }); + + protected readonly overflowLinks = signal([]); + protected readonly overflowMenuLabel = signal(''); + + constructor() { + effect(() => { + const widths = this.table.atoms.columnSizing.get(); + if (!this.isColumnResizing()) { + this.columnWidthPreference.save(widths); + } + }); + + this.scrollPosition.trackRoute({ + scrollElement: this.scrollElement, + route: this.route, + destroyRef: this.destroyRef, + keySuffix: 'table', + beforeSave: () => { + const element = this.scrollElement()?.nativeElement; + if (element) { + this.scrollPosition.savePosition( + this.scrollPosition.keyFor(this.route, 'table-x'), + element.scrollLeft, + ); + } + }, + }); + + effect(onCleanup => { + const element = this.scrollElement()?.nativeElement; + if (!element) { + return; + } + const observer = new ResizeObserver(() => this.viewportHeight.set(element.clientHeight)); + observer.observe(element); + onCleanup(() => observer.disconnect()); + }); + + effect(() => { + const element = this.scrollElement()?.nativeElement; + if (this.scrollRestored || !element || this.rows().length === 0) { + return; + } + this.scrollRestored = true; + runOnNextTwoFrames(() => { + if (this.savedScrollTop > 0) { + this.rowVirtualizer.scrollToOffset(this.savedScrollTop); + } + if (this.savedScrollLeft > 0) { + element.scrollLeft = this.savedScrollLeft; + } + }); + }); + + effect(() => { + const renderedRows = this.rowVirtualizer.getVirtualItems(); + if (renderedRows.length === 0) { + return; + } + + const range = { + start: renderedRows[0].index, + end: renderedRows[renderedRows.length - 1].index, + }; + this.renderedRange.set(range); + }); + } + + scrollToTop(): void { + const element = this.scrollElement()?.nativeElement; + if (element) { + element.scrollTop = 0; + } + this.rowVirtualizer.scrollToOffset(0); + } + + protected columnWidth(field: string): string { + return `var(--book-browse-col-${field}-size)`; + } + + private isNumeric(field: string): boolean { + const kind = bookColumnKind(field); + return kind === 'number' || kind === 'rating' || kind === 'fileSize'; + } + + protected primaryAriaSort(header: BookBrowseHeader): 'ascending' | 'descending' | null { + if (header.column.getSortIndex() !== 0) { + return null; + } + const sorted = header.column.getIsSorted(); + if (!sorted) { + return null; + } + return sorted === 'asc' ? 'ascending' : 'descending'; + } + + protected startResize(header: BookBrowseHeader, event: MouseEvent | TouchEvent): void { + event.stopPropagation(); + header.getResizeHandler()(event); + } + + protected resetWidth(header: BookBrowseHeader, event: MouseEvent): void { + event.stopPropagation(); + header.column.resetSize(); + } + + protected authors(book: BookSummary): string[] { + return book.metadata?.authors ?? []; + } + + protected coverUrl(book: BookSummary): string | null { + return bookCardCoverSrc(book, book.primaryFile, this.urlHelper); + } + + protected squareCover(book: BookSummary): boolean { + return this.useSquareCovers() || book.primaryFile?.bookType === 'AUDIOBOOK'; + } + + protected cellText(cell: BookBrowseCell): string { + return formatBookValue( + bookColumnKind(cell.column.id), cell.getValue(), key => this.transloco.translate(key), + ); + } + + protected cellLinks(book: BookSummary, field: string): readonly BookFacetLink[] { + return bookFacetLinks(book, field); + } + + protected rememberSelectionPointer(event: MouseEvent): void { + event.stopPropagation(); + this.selectionShiftKey = event.shiftKey; + } + + protected stopPropagation(event: Event): void { + event.stopPropagation(); + } + + protected changeHeaderSelection(checked: boolean): void { + if (checked) { + this.selection()?.selectAll(); + } else { + this.selection()?.clear(); + } + } + + protected toggleRowSelection(book: BookSummary, index: number): void { + this.selection()?.toggle(book, index, this.selectionShiftKey); + this.selectionShiftKey = false; + } + + protected isSelected(book: BookSummary): boolean { + return this.selection()?.isSelected(book.id) ?? false; + } + + protected onRowContextMenu(book: BookSummary | undefined, event: MouseEvent): void { + if (!book || this.mobile()) { + return; + } + event.preventDefault(); + this.menuRequested.emit({book, request: contextMenuRequest(event)}); + } + + protected onRowMenu(book: BookSummary, event: MouseEvent): void { + event.preventDefault(); + event.stopPropagation(); + this.menuRequested.emit({book, request: contextMenuRequest(event)}); + } + + protected detailHref(book: BookSummary): string { + return this.locationStrategy.prepareExternalUrl( + this.router.serializeUrl(this.router.createUrlTree(['/book', book.id])), + ); + } + + protected onDetailClick(book: BookSummary, event: MouseEvent): void { + if (!isPlainLeftClick(event)) { + return; + } + event.preventDefault(); + this.detailRequested.emit(book); + } + + protected prepareOverflowMenu( + links: readonly BookFacetLink[], + field: string, + ): void { + this.overflowLinks.set(links.slice(1)); + this.overflowMenuLabel.set(this.moreLinksLabel(field, links.length - 1)); + } + + protected prepareOverflowMenuOnKey( + event: KeyboardEvent, + links: readonly BookFacetLink[], + field: string, + ): void { + if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp' && event.key !== 'Enter' && event.key !== ' ') { + return; + } + this.prepareOverflowMenu(links, field); + } + + protected moreLinksLabel(field: string, count: number): string { + const column = this.transloco.translate(this.headerLabelKeys()[field]); + return this.transloco.translate('browse.table.moreValues', {count, column}); + } +} diff --git a/frontend/src/app/features/book/components/book-browser/book-table/book-table.component.spec.ts b/frontend/src/app/features/book/components/book-browser/book-table/book-table.component.spec.ts index ded631d36e..4f0bc36b71 100644 --- a/frontend/src/app/features/book/components/book-browser/book-table/book-table.component.spec.ts +++ b/frontend/src/app/features/book/components/book-browser/book-table/book-table.component.spec.ts @@ -88,6 +88,7 @@ describe('BookTableComponent', () => { }); it('renders grid semantics and keeps resize handles out of the tab order', () => { + fixture = TestBed.createComponent(BookTableComponent); fixture.componentRef.setInput('books', [makeBook(1, 'Alpha')]); fixture.componentRef.setInput('visibleColumns', [ {field: 'title', header: 'Title'}, diff --git a/frontend/src/app/features/book/components/book-browser/book-table/book-table.component.ts b/frontend/src/app/features/book/components/book-browser/book-table/book-table.component.ts index 25fb614990..d6c5991b8f 100644 --- a/frontend/src/app/features/book/components/book-browser/book-table/book-table.component.ts +++ b/frontend/src/app/features/book/components/book-browser/book-table/book-table.component.ts @@ -5,7 +5,7 @@ import {FormsModule} from '@angular/forms'; import {ActivatedRoute} from '@angular/router'; import {TranslocoDirective, TranslocoService} from '@jsverse/transloco'; import {finalize} from 'rxjs'; -import {ColumnDef, ColumnSizingState, Header, createAngularTable, functionalUpdate, getCoreRowModel} from '@tanstack/angular-table'; +import {ColumnDef, ColumnSizingState, Header, columnResizingFeature, columnSizingFeature, functionalUpdate, injectTable, tableFeatures} from '@tanstack/angular-table'; import {injectVirtualizer} from '@tanstack/angular-virtual'; import {Checkbox} from '@openng/optimus-ui/checkbox'; import {Book, BookMetadata} from '../../../model/book.model'; @@ -51,6 +51,7 @@ const MAX_COLUMN_SIZES: Record = { categories: 360, }; const DEFAULT_MAX_COLUMN_SIZE = 280; +const features = tableFeatures({columnSizingFeature, columnResizingFeature}); const COVER_OVERLAY_POSITIONS: ConnectedPosition[] = [ {originX: 'end', originY: 'center', overlayX: 'start', overlayY: 'center', offsetX: 8}, {originX: 'start', originY: 'center', overlayX: 'end', overlayY: 'center', offsetX: -8}, @@ -101,7 +102,7 @@ export class BookTableComponent { protected readonly coverOverlayPositions = COVER_OVERLAY_POSITIONS; - private readonly columnDefs = computed[]>(() => [ + private readonly columnDefs = computed[]>(() => [ {id: 'select', header: '', size: 42, minSize: 42, maxSize: 42, enableResizing: false}, {id: 'lock', header: '', size: 42, minSize: 42, maxSize: 42, enableResizing: false}, {id: 'cover', header: '', size: 56, minSize: 56, maxSize: 56, enableResizing: false}, @@ -114,10 +115,10 @@ export class BookTableComponent { })), ]); - readonly table = createAngularTable(() => ({ + readonly table = injectTable(() => ({ + features, data: this.books(), columns: this.columnDefs(), - getCoreRowModel: getCoreRowModel(), getRowId: book => String(book.id), columnResizeMode: 'onChange', state: { @@ -131,7 +132,7 @@ export class BookTableComponent { readonly visibleCellIds = computed(() => ['select', 'lock', 'cover', ...this.visibleColumns().map(column => column.field)]); readonly ariaRowCount = computed(() => this.rowCount() + 1); readonly ariaColumnCount = computed(() => this.visibleCellIds().length); - readonly isColumnResizing = computed(() => Boolean(this.table.getState().columnSizingInfo.isResizingColumn)); + readonly isColumnResizing = computed(() => Boolean(this.table.atoms.columnResizing.get().isResizingColumn)); readonly rowCount = computed(() => Math.max(this.virtualRowCount(), this.books().length)); readonly columnSizeVars = computed>(() => { this.columnSizing(); @@ -143,7 +144,7 @@ export class BookTableComponent { return columnStyles; }, {}); - styles['--book-table-grid-template'] = this.table.getVisibleFlatColumns() + styles['--book-table-grid-template'] = this.table.getAllFlatColumns() .map(column => `calc(var(--book-table-col-${column.id}-size) * 1px)`) .join(' '); @@ -152,7 +153,7 @@ export class BookTableComponent { readonly tableWidth = computed(() => { this.columnSizing(); this.visibleColumns(); - return this.table.getVisibleFlatColumns().reduce((width, column) => width + column.getSize(), 0); + return this.table.getAllFlatColumns().reduce((width, column) => width + column.getSize(), 0); }); readonly allLoadedBooksSelected = computed(() => { const books = this.books(); @@ -232,7 +233,7 @@ export class BookTableComponent { return this.bookSelectionService.isBookSelected(book); } - startColumnResize(header: Header, event: MouseEvent | TouchEvent): void { + startColumnResize(header: Header, event: MouseEvent | TouchEvent): void { event.stopPropagation(); if (event.cancelable) { event.preventDefault(); diff --git a/frontend/src/i18n/en.json b/frontend/src/i18n/en.json index 09bcef7c35..10b25e95bb 100644 --- a/frontend/src/i18n/en.json +++ b/frontend/src/i18n/en.json @@ -5548,7 +5548,11 @@ "book": { "empty": "No books here yet.", "filteredEmpty": "No books match these filters.", - "loadError": "We couldn't load these books." + "loadError": "We couldn't load these books.", + "pageError": "Couldn't load more books." + }, + "table": { + "moreValues": "Show {{count}} more {{column}}" } } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 24c1d5c5f5..8ae0252c18 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -68,8 +68,8 @@ importers: specifier: 5.102.8 version: 5.102.8(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)) '@tanstack/angular-table': - specifier: ^8.21.4 - version: 8.21.4(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)) + specifier: 9.1.0 + version: 9.1.0(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)) '@tanstack/angular-virtual': specifier: ^6.0.2 version: 6.0.2(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)) @@ -2711,11 +2711,17 @@ packages: '@angular/common': '>=16.0.0' '@angular/core': '>=16.0.0' - '@tanstack/angular-table@8.21.4': - resolution: {integrity: sha512-djyuJFvIB/pBuBIqny8b7+5ozeXJSElaxYRZyyKLpYrXn4xQSysBT//X51eDT/mi3kS0spcjIHhoObjk3iCjcQ==} - engines: {node: '>=12'} + '@tanstack/angular-store@0.11.1': + resolution: {integrity: sha512-bQxr/YhTnF8BJi+uv+F4NNafrApmDk5n3ongglmZ0h+sOhwwyZLpLZF+Rt4852AEkkkPkRBvhb7zMSPPkHjqQg==} + peerDependencies: + '@angular/common': '>=19.0.0' + '@angular/core': '>=19.0.0' + + '@tanstack/angular-table@9.1.0': + resolution: {integrity: sha512-InWVmz4LRvftV3htYPtWjpgMkXZlviIYgsH6ijOoVGCejad9uRd/n4SXC5cn4t1ng5QIrw0ynnSoY69wGZlf8w==} + engines: {node: '>=20'} peerDependencies: - '@angular/core': '>=17' + '@angular/core': '>=19' '@tanstack/angular-virtual@6.0.2': resolution: {integrity: sha512-y/u2ZKAN5ob+ljwUoSK+r2j2piXw/ol63ykFbzZ8Hahj0dRU9xCJ0cJIDxTQ9zRoGbk0ZIiy3BK5w8Mso1daKA==} @@ -2728,9 +2734,12 @@ packages: '@tanstack/query-devtools@5.102.8': resolution: {integrity: sha512-ZgeMKuF5d/zOE+tgWm3cSbD6Zcbr6IugVsnbHzUcSismqLZDSUPBKM6ILUxExgwe6rPAOox2x5bA5T+PSOQG0Q==} - '@tanstack/table-core@8.21.3': - resolution: {integrity: sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==} - engines: {node: '>=12'} + '@tanstack/store@0.11.1': + resolution: {integrity: sha512-mzTOBhypOuDJAy/D8n2MfUZ1HFkXnmSETviRyhqEC8LUE7/IZQExOTxMANj3KjTofYTkFNpBY67qaVrT41YccA==} + + '@tanstack/table-core@9.1.0': + resolution: {integrity: sha512-tvnnqdWlvF/l6P65yS4ca4keW2VCC3e+OTT8/pUM7ZF5OQBJ7zTVy67T0lLpWBpkELrZN8sFJxY5DHF05fLUDQ==} + engines: {node: '>=20'} '@tanstack/virtual-core@3.17.7': resolution: {integrity: sha512-bp+v10y65sp2H7WpWfIMyxTNfl8ZVfxFTLRjPIFRryi6FV/J33z4IS53WO4pTk36KlvJ4iLiQz+oaydDC1xbcA==} @@ -7958,11 +7967,20 @@ snapshots: optionalDependencies: '@tanstack/query-devtools': 5.102.8 - '@tanstack/angular-table@8.21.4(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2))': + '@tanstack/angular-store@0.11.1(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2))': + dependencies: + '@angular/common': 22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2))(rxjs@7.8.2) + '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2) + '@tanstack/store': 0.11.1 + + '@tanstack/angular-table@9.1.0(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2))': dependencies: '@angular/core': 22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2) - '@tanstack/table-core': 8.21.3 + '@tanstack/angular-store': 0.11.1(@angular/common@22.1.3(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2))(rxjs@7.8.2))(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2)) + '@tanstack/table-core': 9.1.0 tslib: 2.8.1 + transitivePeerDependencies: + - '@angular/common' '@tanstack/angular-virtual@6.0.2(@angular/core@22.1.3(@angular/compiler@22.1.3)(rxjs@7.8.2))': dependencies: @@ -7974,7 +7992,11 @@ snapshots: '@tanstack/query-devtools@5.102.8': optional: true - '@tanstack/table-core@8.21.3': {} + '@tanstack/store@0.11.1': {} + + '@tanstack/table-core@9.1.0': + dependencies: + '@tanstack/store': 0.11.1 '@tanstack/virtual-core@3.17.7': {} From ef446f88533f63d9343c8ef0a3d12d203674a962 Mon Sep 17 00:00:00 2001 From: Alex <25013571+alexhb1@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:28:21 +0100 Subject: [PATCH 2/2] fix(ui): review follow-up --- .../src/app/features/book/browse/book-browse-table.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/features/book/browse/book-browse-table.component.ts b/frontend/src/app/features/book/browse/book-browse-table.component.ts index 6852ba492c..a870c55666 100644 --- a/frontend/src/app/features/book/browse/book-browse-table.component.ts +++ b/frontend/src/app/features/book/browse/book-browse-table.component.ts @@ -352,7 +352,7 @@ export class BookBrowseTableComponent { if (this.savedScrollLeft > 0) { element.scrollLeft = this.savedScrollLeft; } - }); + }, this.destroyRef); }); effect(() => {