diff --git a/package.json b/package.json index 0acffd5..1b6c81d 100644 --- a/package.json +++ b/package.json @@ -3,6 +3,7 @@ "version": "0.0.0", "scripts": { "ng": "ng", + "prestart": "node scripts/generate-sources.mjs", "start": "ng serve --host=0.0.0.0", "build": "ng build core --configuration production", "postbuild": "npx copyfiles projects/core/src/lib/scss/**/* dist/core/scss -f", diff --git a/projects/core/src/lib/core.component.spec.ts b/projects/core/src/lib/core.component.spec.ts index faa5dd8..3d6a2e4 100644 --- a/projects/core/src/lib/core.component.spec.ts +++ b/projects/core/src/lib/core.component.spec.ts @@ -302,6 +302,42 @@ describe('CoreComponent', () => { expect(component.boundedPaginationIndex()).toBeLessThanOrEqual(1); }); + + it('should fall back to the default page size for auto mode before measuring', () => { + // jsdom reports zero element heights, so measurement no-ops and the + // default initial page size is used. + const data = Array.from({ length: 25 }, (_, i) => ({ name: `Item ${i}` })); + fixture.componentRef.setInput('data', data); + fixture.componentRef.setInput('config', { + columns: { name: {} }, + pagination: { length: 'auto' }, + }); + fixture.detectChanges(); + + const rows = el.querySelectorAll('tbody tr'); + expect(rows.length).toBe(10); + expect(component.tableInfo?.pageSize).toBe(10); + expect(component.tableInfo?.pageTotal).toBe(3); + }); + + it('should re-chunk auto mode according to the measured page size', () => { + const data = Array.from({ length: 25 }, (_, i) => ({ name: `Item ${i}` })); + fixture.componentRef.setInput('data', data); + fixture.componentRef.setInput('config', { + columns: { name: {} }, + pagination: { length: 'auto' }, + }); + fixture.detectChanges(); + + // Simulate a measurement result (DOM measurement is unavailable in jsdom). + (component as any).autoPageSize.set(4); + fixture.detectChanges(); + + const rows = el.querySelectorAll('tbody tr'); + expect(rows.length).toBe(4); + expect(component.tableInfo?.pageSize).toBe(4); + expect(component.tableInfo?.pageTotal).toBe(7); + }); }); // ─── Search ─── diff --git a/projects/core/src/lib/core.component.ts b/projects/core/src/lib/core.component.ts index 6d2f84f..1e2680a 100644 --- a/projects/core/src/lib/core.component.ts +++ b/projects/core/src/lib/core.component.ts @@ -1,9 +1,12 @@ import { + afterNextRender, + afterRenderEffect, ChangeDetectionStrategy, Component, computed, DestroyRef, effect, + ElementRef, inject, input, linkedSignal, @@ -74,8 +77,12 @@ import { TableMeta } from './models/table-meta.interface'; }) export class CoreComponent implements OnDestroy { private _destroyRef = inject(DestroyRef); + private _host = inject(ElementRef); private _unsubscribe$ = new Subject(); + /** Initial page size used by `length: 'auto'` before the container is measured */ + private static readonly DEFAULT_AUTO_ROWS = 10; + // ─── Inputs ─── readonly navigationKeys = input([ @@ -124,8 +131,16 @@ export class CoreComponent implements OnDestroy { this.paginationIndexInput() ); + /** Page size measured from the container height, used when `length: 'auto'` */ + private autoPageSize = signal(null); + // ─── Computed Signals ─── + /** Whether pagination is in auto (fit-to-container) mode */ + protected isAutoPagination = computed( + () => this.config().pagination?.length === 'auto' + ); + protected customClasses = computed(() => ({ selectedRow: 'gt-selected', activeRow: 'gt-active', @@ -236,7 +251,8 @@ export class CoreComponent implements OnDestroy { }; } - if (!config.pagination || config.pagination.length === 0) { + const pag = config.pagination; + if (!pag || pag.length === 0) { return { data: [sorted], config, @@ -244,15 +260,18 @@ export class CoreComponent implements OnDestroy { }; } + const pageSize = + pag.length === 'auto' + ? (this.autoPageSize() ?? CoreComponent.DEFAULT_AUTO_ROWS) + : +(pag.length || 0); + return { - data: chunk(sorted, +(config.pagination.length || 0)), + data: chunk(sorted, pageSize), config, info: { numberOfRecords: sorted.length, - pageSize: +(config.pagination.length || 0), - pageTotal: Math.ceil( - sorted.length / +(config.pagination.length || 0) - ), + pageSize, + pageTotal: Math.ceil(sorted.length / pageSize), }, }; }); @@ -274,8 +293,11 @@ export class CoreComponent implements OnDestroy { readonly boundedPaginationIndex = computed(() => { const page = this.currentPaginationIndex(); const info = this.tableInfoSignal(); + const configLength = this.config()?.pagination?.length; const pageSize = - info.pageSize ?? this.config()?.pagination?.length ?? info.numberOfRecords; + info.pageSize ?? + (typeof configLength === 'number' ? configLength : undefined) ?? + info.numberOfRecords; const lastPage = Math.ceil(info.numberOfRecords / pageSize) - 1; return +page < 0 ? 0 : +page > lastPage ? lastPage : +page; }); @@ -321,6 +343,60 @@ export class CoreComponent implements OnDestroy { this.rowActiveOutput.emit(event); }); + // ─── Auto pagination (fit rows to container height) ─── + + private _resizeObserver?: ResizeObserver; + + // Observe container/table size changes to recompute the auto page size + private _setupAutoPagination = afterNextRender(() => { + if (typeof ResizeObserver === 'undefined') { + return; + } + const host = this._host.nativeElement; + this._resizeObserver = new ResizeObserver(() => { + if (this.isAutoPagination()) { + this._measureAutoPageSize(); + } + }); + this._resizeObserver.observe(host); + const table = host.querySelector('table'); + if (table) { + this._resizeObserver.observe(table); + } + this._destroyRef.onDestroy(() => this._resizeObserver?.disconnect()); + }); + + // Re-measure after render when data/config changes (e.g. async data load) + private _autoPaginationEffect = afterRenderEffect(() => { + this.processedData(); + if (this.isAutoPagination()) { + this._measureAutoPageSize(); + } + }); + + /** Measure available height and derive how many rows fit (auto pagination) */ + private _measureAutoPageSize(): void { + const host = this._host.nativeElement; + const table = host.querySelector('table'); + if (!table) { + return; + } + const sampleRow = table.querySelector('tbody tr') as HTMLElement | null; + if (!sampleRow) { + return; + } + const rowH = sampleRow.offsetHeight; + const available = host.clientHeight; + // Skip when not measurable (SSR/jsdom, hidden, or unconstrained container) + if (rowH <= 0 || available <= 0) { + return; + } + const headH = table.tHead?.offsetHeight ?? 0; + const footH = table.tFoot?.offsetHeight ?? 0; + const fit = Math.max(1, Math.floor((available - headH - footH) / rowH)); + this.autoPageSize.set(fit); + } + // ─── Public Observable Getters (backward compat, lazy-cached) ─── diff --git a/projects/core/src/lib/models/table-config.interface.ts b/projects/core/src/lib/models/table-config.interface.ts index 5bfc1ff..2a253d2 100644 --- a/projects/core/src/lib/models/table-config.interface.ts +++ b/projects/core/src/lib/models/table-config.interface.ts @@ -20,7 +20,10 @@ export interface TableConfig { [Property in keyof R]: TableColumn; }; pagination?: { - length?: number; + /** Rows per page. Use a number for a fixed page size, or `'auto'` to display + * as many rows as fit the available height. For `'auto'` to work the table + * must be placed inside a height-constrained container/wrapper.

**Default:** `undefined`

*/ + length?: number | 'auto'; }; rowClick?: boolean; /** Toggle row active state on mouse enter/leave (hover)

**Default:** `false`

*/ diff --git a/projects/core/src/lib/scss/index.scss b/projects/core/src/lib/scss/index.scss index 0e7bef6..970aa7a 100644 --- a/projects/core/src/lib/scss/index.scss +++ b/projects/core/src/lib/scss/index.scss @@ -65,6 +65,11 @@ $skeleton-height: var(--gt-skeleton-height, 169px) !default; } @mixin default-style { + // Custom elements default to inline; make the host a block so its height can + // be constrained by a wrapper (required for `pagination: { length: 'auto' }`). + angular-generic-table { + display: block; + } #{$style-selector} { thead tr th { // sort button diff --git a/projects/docs/src/app/app.component.ts b/projects/docs/src/app/app.component.ts index 0c94aaf..937a38a 100644 --- a/projects/docs/src/app/app.component.ts +++ b/projects/docs/src/app/app.component.ts @@ -62,6 +62,7 @@ export class AppComponent { { path: '/advanced', label: 'Advanced' }, { path: '/sorting', label: 'Sorting' }, { path: '/pagination', label: 'Pagination' }, + { path: '/auto-pagination', label: 'Auto pagination' }, { path: '/lazy-loading', label: 'Server-side pagination' }, { path: '/row-hover-click', label: 'Row hover & click' }, { path: '/row-select', label: 'Row selection' }, diff --git a/projects/docs/src/app/app.routes.ts b/projects/docs/src/app/app.routes.ts index 69c4449..bcd94fa 100644 --- a/projects/docs/src/app/app.routes.ts +++ b/projects/docs/src/app/app.routes.ts @@ -36,6 +36,12 @@ export const routes: Routes = [ data: { title: 'Pagination' }, loadComponent: () => import('./examples/pagination/pagination.component').then((m) => m.PaginationComponent), }, + { + path: 'auto-pagination', + data: { title: 'Auto pagination' }, + loadComponent: () => + import('./examples/auto-pagination/auto-pagination.component').then((m) => m.AutoPaginationComponent), + }, { path: 'lazy-loading', data: { title: 'Server-side pagination' }, diff --git a/projects/docs/src/app/examples/auto-pagination/auto-pagination.component.html b/projects/docs/src/app/examples/auto-pagination/auto-pagination.component.html new file mode 100644 index 0000000..e397f7a --- /dev/null +++ b/projects/docs/src/app/examples/auto-pagination/auto-pagination.component.html @@ -0,0 +1,37 @@ +
+
+
+ + +
+
+ + +
+
+
+

+ The table fills the height-constrained wrapper below — change the height (or drag the bottom-right handle to + resize it like a text area) to see the number of rows adapt. +

+
+ +
+
Table is empty
+
+ +
+ diff --git a/projects/docs/src/app/examples/auto-pagination/auto-pagination.component.ts b/projects/docs/src/app/examples/auto-pagination/auto-pagination.component.ts new file mode 100644 index 0000000..d5ec99b --- /dev/null +++ b/projects/docs/src/app/examples/auto-pagination/auto-pagination.component.ts @@ -0,0 +1,94 @@ +import { + Component, + DestroyRef, + ElementRef, + OnInit, + afterNextRender, + inject, + signal, + viewChild, +} from '@angular/core'; +import { ReactiveFormsModule, FormBuilder } from '@angular/forms'; +import { HttpClient } from '@angular/common/http'; +import { DatePipe, formatDate } from '@angular/common'; +import { CoreComponent, PaginationComponent as GtPaginationComponent, TableConfig } from '@angular-generic-table/core'; +import { TabsComponent } from '../../components/tabs/tabs.component'; +import { SOURCE_TABS } from './_source'; + +@Component({ + selector: 'docs-auto-pagination', + templateUrl: './auto-pagination.component.html', + imports: [CoreComponent, GtPaginationComponent, ReactiveFormsModule, TabsComponent], +}) +export class AutoPaginationComponent implements OnInit { + private fb = inject(FormBuilder); + private http = inject(HttpClient); + private destroyRef = inject(DestroyRef); + + // Container height drives how many rows the table shows in `length: 'auto'` mode. + controls = this.fb.group({ + height: [420], + search: [''], + }); + + loading = signal(true); + searchValue = signal(null); + containerHeight = signal(420); + data = signal([]); + tableConfig = signal({}); + + // Wrapper element so drag-resizes can be synced back to the height control. + resizeBox = viewChild>('resizeBox'); + + SNIPPETS = SOURCE_TABS; + + constructor() { + // Keep the height control in sync when the user drags the resize handle. + afterNextRender(() => { + const el = this.resizeBox()?.nativeElement; + if (!el || typeof ResizeObserver === 'undefined') { + return; + } + const observer = new ResizeObserver(() => { + const height = Math.round(el.getBoundingClientRect().height); + if (height > 0 && height !== this.containerHeight()) { + this.containerHeight.set(height); + this.controls.get('height')?.setValue(height, { emitEvent: false }); + } + }); + observer.observe(el); + this.destroyRef.onDestroy(() => observer.disconnect()); + }); + } + + ngOnInit(): void { + this.http.get<{ data: any[] }>('https://private-730c61-generictable.apiary-mock.com/data').subscribe((res) => { + this.data.set(res.data); + this.loading.set(false); + }); + + this.controls.get('height')?.valueChanges.subscribe((height) => { + this.containerHeight.set(+(height || 0)); + }); + this.controls.get('search')?.valueChanges.subscribe((value) => { + this.searchValue.set(value); + }); + + this.tableConfig.set({ + class: 'table text-nowrap', + columns: { + first_name: { sortable: true }, + last_name: { sortable: true }, + gender: { sortable: true }, + birthday: { + sortable: true, + class: 'text-end justify-content-end', + search: (row, column) => formatDate(row[column], 'longDate', 'en'), + transform: { pipe: DatePipe, args: ['longDate'] }, + }, + }, + // 'auto' fits as many rows as the container height allows. + pagination: { length: 'auto' }, + }); + } +}