@pleta/table
v0.2.0
Published
Angular data table with templates, selection, and controlled pagination.
Downloads
299
Readme
Pleta Table
PleTable renders external state and emits user intentions. It never fetches,
sorts or slices rows. Import it and its types/directives from @pleta/table.
It reuses PlePaginator, PleSkeleton and PleInProgress from @pleta/ui.
Build order: icons → ui → table, already handled by workspace scripts.
Public contracts
interface PleTableData<T> {
readonly rows: readonly T[];
readonly total: number;
}
type PleTablePagination =
| { readonly kind: 'page'; readonly index: number; readonly size: number }
| { readonly kind: 'offset'; readonly offset: number; readonly limit: number }
| { readonly kind: 'cursor'; readonly cursor: string | null; readonly limit: number }
| { readonly kind: 'continuation'; readonly token: string | null; readonly limit?: number };
type PleSortDirection = 'asc' | 'desc';
interface PleTableSort {
readonly column: string;
readonly direction: PleSortDirection;
}
interface PleTableQuery {
readonly pagination: PleTablePagination | null;
readonly sort: PleTableSort | null;
}The existing sort key column is retained; the application maps it to a backend
field. There is no second competing sort type.
| Binding | Default / purpose |
| ----------------------------- | ------------------------------------------------------------------ |
| data | PleTableData<T>; supplying it enables data/query mode |
| [(query)] / (queryChange) | { pagination: { kind: 'page', index: 0, size: 10 }, sort: null } |
| pagination | false; show existing paginator for kind: 'page' only |
| pageSizes | [10, 25, 50] |
| loading | false; external request state |
| error | unknown, default null; null/undefined mean no error |
| (retry) | void; does not change loading or clear error |
| skeletonRows | 5; positive finite integer, fractions rounded down |
| columnCount | 1; skeleton/colspan geometry when columns are omitted |
| columns | []; existing definitions for automatic cells/sort buttons |
| rowKey | (row: T) => string \| number; default object identity |
| caption | ''; caption text and scroll region name |
| label | 'Таблица'; fallback caption/region name |
Use unique stable row keys to preserve DOM when new objects represent the same rows. The component does not cache pages; the application retains successful data.
Remote pagination example
The consumer's api owns endpoints and response mapping:
readonly data = signal<PleTableData<User>>({ rows: [], total: 0 });
readonly query = signal<PleTableQuery>({
pagination: { kind: 'page', index: 0, size: 10 },
sort: null,
});
readonly loading = signal(false);
readonly error = signal<unknown>(null);
readonly userRowKey = (user: User) => user.id;
readonly columns: readonly PleTableColumn<User>[] = [
{ id: 'name', header: 'Name', field: 'name', sortable: true },
{ id: 'email', header: 'Email', field: 'email' },
];
private requestVersion = 0;
changeQuery(query: PleTableQuery): void {
this.query.set(query);
void this.reload();
}
async reload(): Promise<void> {
const version = ++this.requestVersion;
this.loading.set(true);
this.error.set(null);
try {
const result = await this.api.getUsers(this.query());
if (version === this.requestVersion) this.data.set(result);
} catch (error: unknown) {
if (version === this.requestVersion) this.error.set(error);
} finally {
if (version === this.requestVersion) this.loading.set(false);
}
}<ple-table
caption="Users"
[data]="data()"
[query]="query()"
[columns]="columns"
[rowKey]="userRowKey"
[loading]="loading()"
[error]="error()"
pagination
[skeletonRows]="10"
(queryChange)="changeQuery($event)"
(retry)="reload()"
/>Call reload from your initial-load flow. Query changes alone never set loading. Use lifecycle/cancellation handling from your data layer (Resource, RxJS, NgRx, GraphQL, etc.). This example ignores stale responses; disposal/cancellation belongs to the application.
Page indexes are zero-based. Resizing resets index to zero. Sorting cycles ascending → descending → none and emits one atomic query with pagination reset to its start: index/offset 0, cursor/token null. Rows are never reordered internally. After filters, deletions or total changes, the consumer clamps/resets its query.
For an external PlePaginator, leave pagination false and map its pageChange to
query.pagination in the consumer. Use null for no pagination. Offset/cursor/token
modes are accepted but have no built-in navigation or fetching; an external
controller can implement cursor navigation or infinite scroll later.
Multiple selection
Enable selection and provide a stable rowKey. Bind selected IDs using
[(selectedKeys)] (readonly (string | number)[], default []). A missing rowKey
with selection enabled throws a descriptive error: object identity is not reliable
across remote responses. Selection is disabled by default and is always multiple.
readonly selectedKeys = signal<readonly (string | number)[]>([]);
readonly userRowKey = (user: User) => user.id;
readonly selectionLabel = (user: User) => `Select ${user.name}`;<ple-table
caption="Users"
[data]="data()"
[columns]="columns"
[rowKey]="userRowKey"
selection
[(selectedKeys)]="selectedKeys"
[rowSelectionLabel]="selectionLabel"
/>The first column contains native checkboxes. The header checkbox selects/deselects only supplied rows (the current remote page), preserves other pages' keys, and is indeterminate for a partially selected page. It never selects an unseen dataset. Empty pages disable the header; loading disables all selection controls. Selection emits selectedKeysChange, independently of queryChange and requests.
Selection survives pagination, sorting, refresh, errors and disabling selection.
The application clears it after filters/deletions or bulk actions if appropriate:
selectedKeys.set([]). Keys must be unique and stable; string "1" differs from
number 1. The model stores keys rather than stale row objects.
rowSelectionLabel(row, index) customizes checkbox names; the default is
Выбрать строку N using the local row index. Native keyboard Space toggles selection.
Row clicks do not select, so links and actions keep their normal behavior.
The selection cell is added automatically before generated/custom head, row and
skeleton cells. Custom foot receives a leading empty cell. Do not add this column
to columns, columnCount or template colspans: they still describe data columns.
Empty/error/legacy spanning templates automatically include selection in colspan.
The row slot also receives let-selected="selected". Selection styling uses
--ple-table-selection-color and --ple-table-selected-background.
The /table-remote story has selection and external reset controls.
Initial loading and background refresh
| State | Rendering | | ------------------------------------ | ------------------------------------------------- | | Loading, empty rows | Decorative skeleton rows | | Loading, existing rows | Same row DOM plus PleInProgress | | Not loading, empty rows, no error | Empty slot/default text | | Not loading, existing rows, no error | Normal rows | | Not loading, empty rows, error | Error inside the body | | Not loading, existing rows, error | Existing rows and an error banner above the table |
Initial: start with { rows: [], total: 0 }, then set loading true → skeleton.
Refresh: keep the last data, set loading true → existing rows + progress; replace
data after success and clear loading. Do not clear rows when starting refresh.
During loading a stale error is hidden; only the application clears error input.
Default sort/paginator controls are disabled during loading; projected controls
are owned by the consumer.
Skeleton rows are aria-hidden. One polite initial-loading message and an indeterminate refresh progressbar avoid announcements per cell. The scroll region exposes aria-busy; refresh does not destroy rows or their local state.
Projected templates
TemplateRef inputs now cover the same slots and contexts: rowTemplate, headTemplate, footTemplate, captionContentTemplate, toolbarTemplate, skeletonTemplate, errorTemplate and progressTemplate. Explicit inputs win over their projected counterparts. Compatibility exceptions remain: pleTableEmpty wins over emptyTemplate; loadingTemplate overrides the entire initial body, including skeletonTemplate.
<ple-table [data]="data()" [rowTemplate]="row" [headTemplate]="head">
<ng-template pleTableRow let-item><td>Fallback: {{ item.name }}</td></ng-template>
</ple-table>
<ng-template #row let-item><td>{{ item.name }}</td></ng-template>
<ng-template #head><th scope="col">Name</th></ng-template>Legacy cell-content inputs also have projected counterparts: PleTableCell, PleTableHeader, PleTableLoading, PleTableFooter, PleTableCaptionExtra (selectors pleTableCell, pleTableHeader, pleTableLoading, pleTableFooter, pleTableCaptionExtra). They match cellTemplate, headerTemplate, loadingTemplate, footerTemplate and captionTemplate respectively. Input wins over projection. Contexts/wrappers stay unchanged; column-specific templates still win for generated cells/headers. Footer supplies spanning-cell contents, Foot supplies td/th. CaptionExtra adds to caption text; Caption/captionContentTemplate replace caption contents.
Import each used directive alongside PleTable from @pleta/table.
| Directive / selector | Context | Placement and content | | ----------------------------------- | -------------------------------------------- | ----------------------------------------- | | PleTableRow / pleTableRow | $implicit: row, index, first, last, selected | Body tr; supply td/th cells | | PleTableHead / pleTableHead | — | thead > tr; supply scoped th cells | | PleTableFoot / pleTableFoot | — | tfoot > tr; supply cells, not paginator | | PleTableCaption / pleTableCaption | — | Native caption | | PleTableToolbar / pleTableToolbar | — | Outside native table | | PleTableEmpty / pleTableEmpty | — | Spanning body cell | | PleTableSkeleton / pleTableSkeleton | index | Repeated decorative body tr; supply cells | | PleTableError / pleTableError | $implicit: unknown | Initial error cell or refresh banner | | PleTableProgress / pleTableProgress | — | Progress layer above the table |
<ple-table
label="Users"
[data]="data()"
[query]="query()"
[loading]="loading()"
[error]="error()"
[rowKey]="userRowKey"
[columnCount]="2"
pagination
(queryChange)="changeQuery($event)"
(retry)="reload()"
>
<ng-template pleTableCaption>Users</ng-template>
<ng-template pleTableToolbar>
<button type="button" (click)="reload()">Refresh</button>
</ng-template>
<ng-template pleTableHead>
<th scope="col">Name</th>
<th scope="col">Email</th>
</ng-template>
<ng-template pleTableRow let-user let-index="index">
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
</ng-template>
<ng-template pleTableFoot><td colspan="2">{{ data().total }} users</td></ng-template>
<ng-template pleTableEmpty>No users found</ng-template>
<ng-template pleTableSkeleton let-index="index">
<td><ple-skeleton /></td>
<td><ple-skeleton /></td>
</ng-template>
<ng-template pleTableError let-error>
<p>Failed to load users</p>
<button type="button" (click)="reload()">Retry</button>
</ng-template>
<ng-template pleTableProgress><ple-in-progress label="Updating users" /></ng-template>
</ple-table>Import PleSkeleton/PleInProgress from @pleta/ui for those overrides. A custom error owns
its retry control; the default control emits retry. A custom head owns sorting
and aria-sort; omit it and use columns to reuse built-in sort buttons.
Exported contexts: PleTableRowContext<T>, PleTableSkeletonContext. Bare row
templates do not infer their generic type from the parent table.
Do not put tr/thead/tbody wrappers in slot contents. Column count comes from columns.length or columnCount; explicit geometry needs no browser DOM inspection.
Encapsulation, theming and accessibility
Native table, caption, scoped generated headers and aria-sort are retained. The scroll region is keyboard focusable. Supply an appropriate caption or label even with a caption slot. Consumers own accessibility of custom content.
Table CSS variables: --ple-table-color, --ple-table-font, --ple-table-border,
--ple-table-radius, --ple-table-background, --ple-table-focus,
--ple-table-row-height (48px), --ple-table-row-border,
--ple-table-cell-padding (12px 16px), --ple-table-caption-padding,
--ple-table-head-background, --ple-table-hover-background,
--ple-table-foot-background, --ple-table-muted-color,
--ple-table-toolbar-gap, --ple-table-error-color.
Projected cells belong to the consumer's encapsulation scope. In its CSS use,
for example, td, th { padding: var(--ple-table-cell-padding, 12px 16px);
text-align: start; }. No global selectors or ng-deep are needed. Match custom
skeleton cell geometry to row content to minimize layout shift. Progress inherits
the UI component's --ple-in-progress-* variables.
PleInProgress uses signals, template and CSS animation, without browser globals, DOM measurements, random IDs, HTTP or Zone.js. Its markup is deterministic for identical server/client inputs. Actual SSR/hydration integration is not exercised by this workspace's browser-only Storybook test setup.
Compatibility and migration
Without data, existing rows/total/[(page)]/[(sort)]/paginated bindings are retained. With data, data/query/pagination take precedence: do not mix both state APIs. This deliberate compatibility path allows incremental migration; rendering, loading and templates remain one shared implementation.
Existing PleTableColumn<T> retains id, header, field?: keyof T, sortable,
cellTemplate and headerTemplate. It works with either data API. Template inputs:
| Input | Context | | -------------------------------------- | ----------------------------------------- | | cellTemplate / column.cellTemplate | $implicit: row, column, value, rowIndex | | headerTemplate / column.headerTemplate | $implicit: column | | captionTemplate | Extra content after caption text | | emptyTemplate | No context | | loadingTemplate | No context; spanning initial-loading body | | footerTemplate | $implicit: rows; spanning footer cell |
Old templates provide cell contents, unlike projected row/head/foot/skeleton templates, which provide cells. Column templates override common cell/header inputs. Projected row/head/caption/foot/empty slots override corresponding generated or legacy content. Legacy loadingTemplate overrides the entire initial skeleton body, including pleTableSkeleton and skeletonRows.
Intentional behavior change: loading with rows preserves them and shows progress; loadingTemplate only applies when rows are empty. This enables background refresh without losing row state. The sort key column and page shape are unchanged.
Demonstrations and non-goals
Storybook /table-remote demonstrates remote paging/sorting, skeleton, refresh,
initial/refresh errors, retry and projected slots. /table retains legacy cell
templates and local controlled derivation; /in-progress shows the UI indicator.
Future PleDataGrid work excluded here: expanded/tree rows, column resizing/reordering/pinning, grouping/aggregation, inline editing, virtualization, drag-and-drop, complex filters, saved views, column chooser, Excel-like navigation and an HTTP DataSource. No endpoint or state-manager dependencies are introduced.
Theme documentation
Storybook /theme, tab @pleta/table, lists all table tokens, defaults,
copyable CSS and a customization example. The old /theme-table route redirects
to this unified page. Table color defaults reference the shared UI palette;
component-specific --ple-table-* overrides remain supported.
Optionally import @pleta/ui/theme.css in global CSS. Set shared variables on
html[data-theme='dark'] with color-scheme: dark to supply your own dark theme.
There is no theme service or automatic theme switcher. Set table-only overrides
on ple-table or its container. These do not change the paginator, select,
buttons or progress indicator, which consume UI tokens. Projected cell styles
belong to the consuming component.
License
Pleta uses the proprietary Pleta Free Application Use License. Personal and commercial application use is free, including paid products, client projects, and SaaS. Application builds and distribution with bundled Pleta code are permitted. Modifying or forking the library itself, extracting its implementation, and republishing it as a standalone package or reusable library are not permitted except as stated in the license. Include the license in your application's legal notices. Third-party materials retain their own licenses. See the LICENSE file shipped with each package for the full terms.
