Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 36 additions & 0 deletions projects/core/src/lib/core.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 ───
Expand Down
90 changes: 83 additions & 7 deletions projects/core/src/lib/core.component.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
import {
afterNextRender,
afterRenderEffect,
ChangeDetectionStrategy,
Component,
computed,
DestroyRef,
effect,
ElementRef,
inject,
input,
linkedSignal,
Expand Down Expand Up @@ -74,8 +77,12 @@ import { TableMeta } from './models/table-meta.interface';
})
export class CoreComponent implements OnDestroy {
private _destroyRef = inject(DestroyRef);
private _host = inject(ElementRef<HTMLElement>);
private _unsubscribe$ = new Subject<void>();

/** Initial page size used by `length: 'auto'` before the container is measured */
private static readonly DEFAULT_AUTO_ROWS = 10;

// ─── Inputs ───

readonly navigationKeys = input([
Expand Down Expand Up @@ -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<number | null>(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',
Expand Down Expand Up @@ -236,23 +251,27 @@ 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,
info: { numberOfRecords: sorted.length, pageTotal: 1 },
};
}

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),
},
};
});
Expand All @@ -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;
});
Expand Down Expand Up @@ -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) ───

Expand Down
5 changes: 4 additions & 1 deletion projects/core/src/lib/models/table-config.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,10 @@ export interface TableConfig<R = TableRow> {
[Property in keyof R]: TableColumn<R>;
};
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. <p>**Default:** `undefined`</p>*/
length?: number | 'auto';
};
rowClick?: boolean;
/** Toggle row active state on mouse enter/leave (hover) <p>**Default:** `false`</p>*/
Expand Down
5 changes: 5 additions & 0 deletions projects/core/src/lib/scss/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions projects/docs/src/app/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
6 changes: 6 additions & 0 deletions projects/docs/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<form [formGroup]="controls">
<div class="row gy-3">
<div class="form-group col-12 col-sm-auto">
<label for="height_input">Container height (px)</label>
<input id="height_input" formControlName="height" type="number" class="form-control" min="120" step="20" />
</div>
<div class="form-group col-12 col-sm-auto">
<label for="search_input">Search</label>
<input id="search_input" formControlName="search" type="text" class="form-control" />
</div>
</div>
</form>
<p class="text-body-secondary small mt-3 mb-1">
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.
</p>
<div
#resizeBox
class="border rounded my-2 p-3 d-flex flex-column"
style="resize: vertical; overflow: hidden; min-height: 200px"
[style.height.px]="containerHeight()"
>
<angular-generic-table
class="flex-grow-1 overflow-hidden"
style="min-height: 0"
[data]="data()"
[config]="tableConfig()"
[search]="searchValue()"
[loading]="loading()"
#table
>
<div class="table-loading gt-skeleton-loader"></div>
<div class="table-no-data alert alert-info mt-3">Table is empty</div>
</angular-generic-table>
<angular-generic-table-pagination [table]="table" class="border-top pt-2 mt-2"></angular-generic-table-pagination>
</div>
<docs-tabs [content]="SNIPPETS"></docs-tabs>
Original file line number Diff line number Diff line change
@@ -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<string | null>(null);
containerHeight = signal(420);
data = signal<any[]>([]);
tableConfig = signal<TableConfig>({});

// Wrapper element so drag-resizes can be synced back to the height control.
resizeBox = viewChild<ElementRef<HTMLElement>>('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' },
});
}
}
Loading