@rivinia/table 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,3 +1,273 @@
1
- # Temporary Holding Version
1
+ # @rivinia/table
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ [![npm version](https://img.shields.io/npm/v/@rivinia/table.svg)](https://www.npmjs.com/package/@rivinia/table)
4
+ [![license](https://img.shields.io/npm/l/@rivinia/table.svg)](https://www.npmjs.com/package/@rivinia/table)
5
+
6
+ A headless Angular table with typed columns, real row and column spans,
7
+ sorting, filtering, selection and paging — plus an optional ready-made grid.
8
+ The core owns the data and the geometry; it never renders a cell, never knows
9
+ what an "action" is, and never imports a stylesheet.
10
+
11
+ - Package version: **0.1.0**
12
+ - Requires: **Angular `^22.0.0`** (`@angular/core` and `@angular/common`).
13
+ - Entry points: `@rivinia/table` (headless controller + pure helpers) and
14
+ `@rivinia/table/ui` (the `rv-table` grid, div-based, `role="grid"`).
15
+ - Side effects: **none** (`sideEffects: false`).
16
+
17
+ ## Quick features
18
+
19
+ - **Headless first.** The controller is signals and pure functions — no DOM, no
20
+ browser needed to test the pipeline. The grid is a component you can throw
21
+ away and replace with your own markup.
22
+ - **Real spans, not CSS tricks.** `merge: 'equal'` for consecutive equal values,
23
+ `colSpan: n`, or a per-cell `span(ctx)` for both axes. The result is a resolved
24
+ matrix, so the keyboard, the ARIA and the pinned columns all follow the real
25
+ geometry.
26
+ - **One pipeline.** Filter → sort → page → selection, in that order, every step
27
+ a signal, so a page never recomputes more than it must.
28
+ - **Selection by id, never by index.** It survives sorting, filtering and
29
+ paging, and an action always receives the row it was rendered for.
30
+ `selectAllOnPage()` is page-scoped on purpose.
31
+ - **A shareable view.** `table.state` is the serialisable slice (sort, filters,
32
+ search, page, page size, selection) — put it in the URL and `setState()`
33
+ restores what the user sees.
34
+ - **The actions column is yours.** `display('actions')` reserves the column;
35
+ `<ng-template rvTableCell="actions">` fills it. Five icons, one link, or a
36
+ permission-driven set — the grid passes the row and gets out of the way.
37
+ - **Four bodies, not four screens.** Loading skeletons that keep the header
38
+ widths, an empty state, an error alert and the data, all inside one grid, all
39
+ live regions.
40
+ - **Right-to-left ready.** Every inset is logical, the header mirrors with the
41
+ body, labels ship in English and Arabic, and each value carries `dir="auto"`
42
+ so an English name stays intact in an Arabic table.
43
+ - **Themable end to end.** `--rv-table-*` beats `--rv-*` from `@rivinia/theme`,
44
+ which beats the built-in default; sizes are `rem`, so the grid follows the
45
+ browser's text size.
46
+ - **Zero runtime dependencies.** Angular peers only.
47
+
48
+ ## Install
49
+
50
+ ```bash
51
+ npm install @rivinia/table
52
+ ```
53
+
54
+ or
55
+
56
+ ```bash
57
+ pnpm add @rivinia/table
58
+ ```
59
+
60
+ or
61
+
62
+ ```bash
63
+ yarn add @rivinia/table
64
+ ```
65
+
66
+ or
67
+
68
+ ```bash
69
+ bun add @rivinia/table
70
+ ```
71
+
72
+ ## Why another table
73
+
74
+ Most Angular tables couple three things that change at different speeds: the
75
+ data pipeline, the DOM, and the styling. This one separates them. The pipeline
76
+ is signals and pure functions, so it is testable without a browser; the grid is
77
+ a component you can replace; and every colour, size and radius is a CSS custom
78
+ property read at the use site.
79
+
80
+ ## Quick start
81
+
82
+ ```ts
83
+ import { createTable, tableColumn } from '@rivinia/table';
84
+
85
+ @Component({ /* … */ })
86
+ export class InvoicesPage {
87
+ private readonly table = createTable<Invoice>(() => ({
88
+ // Columns are a FACTORY too: `options()` runs inside a computed, so a
89
+ // column list that reads a signal re-renders. A bare array is not accepted.
90
+ columns: () => [
91
+ tableColumn<Invoice>().accessor('number', { header: 'Invoice', width: '7rem' }),
92
+ tableColumn<Invoice>().accessor('client', { header: 'Client', flex: 1 }),
93
+ tableColumn<Invoice>().accessor('amount', {
94
+ header: 'Amount',
95
+ align: 'end',
96
+ format: (value) => new Intl.NumberFormat('en-EG').format(value),
97
+ }),
98
+ tableColumn<Invoice>().accessor('status', { header: 'Status' }),
99
+ ],
100
+ data: () => this.invoices(),
101
+ pageSize: 25,
102
+ selection: { mode: 'multi' },
103
+ }));
104
+
105
+ readonly rows = this.table.pageRows;
106
+ readonly columns = this.table.visibleColumns;
107
+ }
108
+ ```
109
+
110
+ `createTable()` must be called in an injection context: the controller is built
111
+ from signals and computeds, so it is either a field initialiser or inside
112
+ `inject()`-capable code.
113
+
114
+ ## Columns
115
+
116
+ A column is data, not a component. `tableColumn<T>()` has two builders:
117
+ `accessor(key, def?)` reads a field of the row, `display(id, def?)` has no field
118
+ and exists for a template or an actions column.
119
+
120
+ | Field | Meaning |
121
+ | --- | --- |
122
+ | `header` | Visible header text. Omit for an icon-only column. |
123
+ | `width` | A CSS length. Fixed columns take part in `table-layout: fixed`. |
124
+ | `flex` | `> 0` absorbs free space; `0` stays fixed. |
125
+ | `align` | `'start' \| 'center' \| 'end'` — logical, so RTL needs no override. |
126
+ | `sortable` | Adds the three-state sort affordance and `aria-sort`. |
127
+ | `format` | `(value, row, locale) => string` for display only. |
128
+ | `comparator` | Sort with something other than the default compare. |
129
+ | `pinned` | `'start' \| 'end'` — stays visible during horizontal scroll. |
130
+ | `textOverflow` | `'nowrap'` (default), `'wrap'`, `'clamp'`. |
131
+ | `lines` | Clamp height, default 2. |
132
+ | `hidden` | Removed from the visible set without losing its definition. |
133
+
134
+ ## Row and column spans
135
+
136
+ Spans are part of the core, not an add-on: pinning, keyboard navigation and
137
+ selection all need to know the grid's real geometry. Three ways to declare one,
138
+ in increasing power:
139
+
140
+ ```ts
141
+ // 1. Row span by a shared key: consecutive rows with an equal value merge.
142
+ tableColumn<Invoice>().accessor('department', { header: 'Department', merge: 'equal' })
143
+
144
+ // 2. Column span: this cell covers N columns to its end.
145
+ tableColumn<Invoice>().accessor('note', { header: 'Notes', colSpan: 2 })
146
+
147
+ // 3. Both axes, computed per cell.
148
+ tableColumn<Invoice>().display('summary', {
149
+ span: ({ rowIndex }) => (rowIndex === 0 ? { colSpan: 5 } : null),
150
+ })
151
+ ```
152
+
153
+ Rules, enforced at `createTable()` rather than at render time:
154
+
155
+ - `span` cannot be combined with `merge` or `colSpan` — the call throws.
156
+ - `merge` without an accessor warns and does not merge (there is nothing to
157
+ compare).
158
+ - `merge` and `colSpan` compose into one rectangle.
159
+ - Overlapping spans are shrunk so the anchor wins, and the overlap is reported.
160
+
161
+ `table.spans` is a resolved `SpanModel` with `at(row, column)`,
162
+ `owner(row, column)` and `move(row, column, dRow, dColumn)`. Every query is
163
+ O(1) against a dense matrix built once per `(rows, columns)` pair, so a
164
+ 10,000-row page does not re-walk your callbacks on scroll.
165
+
166
+ ## Selection
167
+
168
+ Ids, not array positions — so a selection survives sorting, filtering and
169
+ paging, and an action always receives the row it was rendered for. Turn it on
170
+ in the options (`'single'` or `'multi'`); a table without it renders none of the
171
+ affordance and every method is a no-op:
172
+
173
+ ```ts
174
+ createTable<Invoice>(() => ({
175
+ columns: () => this.columns,
176
+ data: () => this.invoices(),
177
+ selection: { mode: 'multi' },
178
+ }));
179
+ ```
180
+
181
+ ```ts
182
+ table.selection.toggle(row.id);
183
+ table.selection.isSelected(row.id);
184
+ table.selection.selectAllOnPage(); // page scope, never "everything by accident"
185
+ table.selection.clear();
186
+ ```
187
+
188
+ ## Paging and state
189
+
190
+ `page`, `pageSize`, `sorting`, `filters` and `globalSearch` are writable
191
+ signals; `rows` (filtered and sorted), `pageRows` (sliced) and `total` are
192
+ read-only computeds. `table.state` is the serialisable slice — put it in the URL
193
+ and the user can share what they see; `setState()` and `resetState()` restore it.
194
+
195
+ ## UI entry point
196
+
197
+ ```ts
198
+ import { RvTable } from '@rivinia/table/ui';
199
+ ```
200
+
201
+ ```html
202
+ <rv-table
203
+ aria-label="Invoices"
204
+ [columns]="columns()"
205
+ [rows]="rows()"
206
+ [spans]="table.spans()"
207
+ [totalRows]="table.total()"
208
+ [(sort)]="sort"
209
+ [(selected)]="selected"
210
+ [skeletonRows]="'auto'"
211
+ [loading]="loading()"
212
+ >
213
+ <!-- The actions column is YOUR template. Five buttons, one button, or a
214
+ link — the table only reserves the column and hands you the row. -->
215
+ <ng-template rvTableCell="actions" let-row>
216
+ <button type="button" (click)="approve(row)">Approve</button>
217
+ </ng-template>
218
+ </rv-table>
219
+ ```
220
+
221
+ The grid renders a single `role="grid"` container, a sticky header that shares
222
+ its column widths with the body, one roving `tabindex`, and cells that respect
223
+ `span.move()` for keyboard navigation. It ships no icons and no action
224
+ semantics.
225
+
226
+ Two details that are easy to miss:
227
+
228
+ - `aria-label` written on `<rv-table>` becomes the grid's accessible name; the
229
+ `[ariaLabel]` input is the reactive equivalent and wins over it.
230
+ - `[totalRows]` is what `aria-rowcount` reports when the page is smaller than
231
+ the data set. Without it the grid can only count what it renders, which on a
232
+ paged table is the page, not the table.
233
+ - A cell can be narrower than its content. The grid does not clip a cell by
234
+ default, because a floating menu inside a cell has to be able to leave it —
235
+ so an actions cell that does not fit paints over its neighbour. Size the
236
+ column (`width`) or let the content wrap; if your cells never float
237
+ anything, `overflow: clip` on `.rv-table-cell` is the strict fix.
238
+
239
+ ## Theming
240
+
241
+ Every visual value is a token read where it is used, so a value set on an
242
+ ancestor wins:
243
+
244
+ ```
245
+ --rv-table-<property> wins for the table only
246
+ --rv-<semantic> from @rivinia/theme
247
+ <built-in default> last
248
+ ```
249
+
250
+ Sizes are in `rem`, so the grid follows the browser's text size; hairlines stay
251
+ `1px` and focus rings `2px`.
252
+
253
+ ## Accessibility
254
+
255
+ - The grid is `role="grid"` with `aria-rowcount` / `aria-colcount`, `aria-sort`
256
+ on headers and `aria-selected` on rows.
257
+ - With `selectable`, each row draws a **decorative** box in its first cell and
258
+ the state lives in `aria-selected` — one announcement per row, not two, and
259
+ no second tab stop inside the cell. Clicking the row or pressing Space
260
+ toggles it.
261
+ - Merged cells are one cell: a screen reader hears the value once, not once per
262
+ covered position.
263
+ - Truncated text is available to assistive technology through
264
+ `aria-describedby`; a visual tooltip is the consumer's choice.
265
+ - Loading, empty and error states are live regions (`role="status"`), never
266
+ silent.
267
+
268
+ ## Status
269
+
270
+ `0.1.0` is not published yet. Client-mode sorting, filtering, paging, selection
271
+ and spans are the 0.1.0 scope; server mode (`TableRequest`, `reload()`) and
272
+ editing (`@rivinia/table/editing`) are planned, additive, and not part of this
273
+ version.