@datakable/react 1.0.1 → 1.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
@@ -16,6 +16,7 @@ React `>=18`. The modules of `<DataTable />` and `useDataTable` carry a `"use cl
16
16
  so the Next.js app router treats them as client code even when a server component imports them
17
17
  from here; the rest (core's `createTable`, …) stays usable on the server. Like core,
18
18
  it needs a bundler that replaces `process.env.NODE_ENV` (see [core](https://github.com/Ink01101011/datakable/tree/main/packages/core)).
19
+ In Node.js (SSR, tests) it needs Node `>=18.18`. The types need TypeScript `>=5.4`.
19
20
 
20
21
  ## useDataTable
21
22
 
@@ -109,8 +110,8 @@ for the features installed (this package installs none by default). Pass `table=
109
110
  to render a table you set up yourself, `components` to replace the search input, pager or states,
110
111
  and style it through the `data-*` hooks (`data-status`, `data-initial-load`, `data-sort`,
111
112
  `data-invalid`), or import the optional `@datakable/react/styles.css` (zero-specificity rules,
112
- themed through `--dk-*` custom properties). A `cell` that throws renders `⚠` in that cell
113
- (`DK_CALLBACK_FAILED`); so does a component it returns that throws while rendering — each such cell
113
+ themed through `--dk-*` custom properties). A `cell` that throws renders `⚠` in that cell (named
114
+ "Could not display this value" for screen readers, `DK_CALLBACK_FAILED`); so does a component it returns that throws while rendering — each such cell
114
115
  has its own error boundary, and the other cells keep rendering (`DK_RENDER_FAILED`). A component
115
116
  that throws elsewhere in the body shows the Error state with a retry.
116
117
 
@@ -29,7 +29,7 @@ var import_react = require("react");
29
29
  var import_render_codes = require("./render-codes.cjs");
30
30
  var import_use_data_table = require("./use-data-table.cjs");
31
31
  var import_jsx_runtime = require("react/jsx-runtime");
32
- (0, import_core.keepDiagnostics)(import_render_codes.renderDiagnostics, import_core.boundaryDiagnostics, import_core.slotDiagnostics);
32
+ (0, import_core.keepDiagnostics)(import_render_codes.renderDiagnostics, import_core.boundaryDiagnostics, import_core.slotDiagnostics, import_core.pipelineDiagnostics);
33
33
  (0, import_render_codes.registerRenderOperations)();
34
34
  var useIsomorphicLayoutEffect = typeof window === "undefined" ? import_react.useEffect : import_react.useLayoutEffect;
35
35
  function DataTable(props) {
@@ -44,6 +44,7 @@ function ZeroConfig(props) {
44
44
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DataTableView, { table: view, components, caption });
45
45
  }
46
46
  var WARN = "\u26A0";
47
+ var FAILED = /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { role: "img", "aria-label": "Could not display this value", children: WARN });
47
48
  var BUSY = ["validating", "pending", "searching", "fetching"];
48
49
  var LOADING = ["validating", "searching", "fetching"];
49
50
  function hasAsyncSource(table) {
@@ -76,7 +77,7 @@ var tableOf = (view) => Object.hasOwn(view, "rowModel") ? Object.getPrototypeOf(
76
77
  function report(table, failures) {
77
78
  const bySlot = /* @__PURE__ */ new Map();
78
79
  for (const f of failures) {
79
- const k = `${f.slot}\0${f.expected === void 0 ? "throw" : "return"}`;
80
+ const k = `${f.slot}\0${f.lost ? "" : f.expected === void 0 ? "throw" : "return"}`;
80
81
  const list = bySlot.get(k);
81
82
  if (list) list.push(f);
82
83
  else bySlot.set(k, [f]);
@@ -84,9 +85,17 @@ function report(table, failures) {
84
85
  for (const list of bySlot.values()) {
85
86
  const first = list[0];
86
87
  const rows = list.slice(0, 3).map((f) => f.row);
87
- const fallback = "those cells rendered \u26A0";
88
+ const fallback = "cells-warn";
88
89
  try {
89
- if (first.expected === void 0) {
90
+ if (first.lost !== void 0) {
91
+ (0, import_core.emitTableDiagnostic)(table, "DK_UNRENDERABLE_VALUE", {
92
+ column: first.column,
93
+ index: first.index,
94
+ count: list.length,
95
+ rows,
96
+ valueType: first.lost
97
+ });
98
+ } else if (first.expected === void 0) {
90
99
  const e = first.error;
91
100
  (0, import_core.emitTableDiagnostic)(
92
101
  table,
@@ -116,6 +125,10 @@ function report(table, failures) {
116
125
  }
117
126
  }
118
127
  }
128
+ var lost;
129
+ var onLost = (v) => {
130
+ lost = v;
131
+ };
119
132
  function cellContent(col, row, failures, table) {
120
133
  const def = col.def;
121
134
  const value = row.getValue(col.id);
@@ -147,15 +160,33 @@ function cellContent(col, row, failures, table) {
147
160
  bad(failures, col, row, slot, "a string", out);
148
161
  return (0, import_core.defaultFormat)(value);
149
162
  }
150
- return (0, import_core.defaultFormat)(value);
163
+ const text = (0, import_core.defaultFormat)(value, onLost);
164
+ if (lost !== void 0) {
165
+ const { id: column, index } = col;
166
+ const type = Array.isArray(lost) ? "array" : typeof lost;
167
+ failures.push({ slot: `columns[${index}]`, column, index, row: row.index, lost: type });
168
+ lost = void 0;
169
+ }
170
+ return text;
151
171
  } catch (error) {
152
172
  failures.push({ slot: `columns[${col.index}].${slot}`, column: col.id, row: row.index, error });
153
- return WARN;
173
+ return FAILED;
154
174
  }
155
175
  }
156
176
  function guard(out, table, col, row, slot) {
157
177
  if (typeof out !== "object" || out === null) return out;
158
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(CellBoundary, { table, col, slot, row: row.index, original: row.original, children: out });
178
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
179
+ Boundary,
180
+ {
181
+ table,
182
+ col,
183
+ slot,
184
+ row: row.index,
185
+ resetKey: row.original,
186
+ fallback: failedCell,
187
+ children: out
188
+ }
189
+ );
159
190
  }
160
191
  function bad(failures, col, row, slot, expected, out) {
161
192
  failures.push({
@@ -165,7 +196,31 @@ function bad(failures, col, row, slot, expected, out) {
165
196
  expected,
166
197
  received: (0, import_core.describeReturn)(out)
167
198
  });
168
- return WARN;
199
+ return FAILED;
200
+ }
201
+ var HIDDEN = {
202
+ position: "absolute",
203
+ width: 1,
204
+ height: 1,
205
+ overflow: "hidden",
206
+ clipPath: "inset(50%)",
207
+ whiteSpace: "nowrap"
208
+ };
209
+ var serverPaged = (view) => view.getOptions().features?.some(
210
+ (f) => f?.stage === "paginate" && f.mode === "server"
211
+ ) === true;
212
+ function pages(view, page, count, server) {
213
+ if (server) {
214
+ return {
215
+ ...page,
216
+ pageCount: view.getPageCount?.() ?? 0,
217
+ canPrev: view.canPrevPage?.() ?? false,
218
+ canNext: view.canNextPage?.() ?? false
219
+ };
220
+ }
221
+ const pageCount = Math.ceil(count / page.pageSize);
222
+ const index = Math.min(page.pageIndex, Math.max(pageCount - 1, 0));
223
+ return { ...page, pageCount, canPrev: index > 0, canNext: index + 1 < pageCount };
169
224
  }
170
225
  var ariaSort = (dir) => dir === "asc" ? "ascending" : dir === "desc" ? "descending" : "none";
171
226
  function DataTableView(props) {
@@ -186,14 +241,45 @@ function DataTableView(props) {
186
241
  if (failures.length > 0) report(table, failures);
187
242
  });
188
243
  const sortable = typeof view.toggleSort === "function";
244
+ const sortBy = state.sorting?.by;
189
245
  const searchState = state.search;
190
246
  const paged = typeof view.getPageCount === "function";
191
247
  const loading = isInitialLoad && (LOADING.includes(status) || status === "pending" && hasAsyncSource(view));
192
248
  const failed = isInitialLoad && status === "error";
193
249
  const span = Math.max(columns.length, 1);
194
- const retryLoad = () => void view.refetch();
195
- const Search = parts.SearchInput ?? DefaultSearchInput;
196
- const Pager = parts.Pagination ?? DefaultPagination;
250
+ const tableEl = (0, import_react.useRef)(null);
251
+ const keepFocus = () => {
252
+ const t = tableEl.current;
253
+ const from = document.activeElement?.closest("[data-dk-state],[data-dk-refresh]");
254
+ if (t && from && t.parentElement?.contains(from)) {
255
+ t.tabIndex = -1;
256
+ t.focus();
257
+ }
258
+ };
259
+ const retryLoad = () => {
260
+ keepFocus();
261
+ void view.refetch();
262
+ };
263
+ const server = paged && serverPaged(view);
264
+ const pager = paged ? pages(view, state.pagination, rowModel.filteredCount, server) : null;
265
+ const n = rowModel.filteredCount;
266
+ const count = `${n} row${n === 1 ? "" : "s"}`;
267
+ const page = pager ? `${pager.pageIndex + 1} of ${Math.max(pager.pageCount, 1)}` : "";
268
+ let said = "";
269
+ if (searchState?.status === "pending" || searchState?.status === "searching") {
270
+ said = "Searching rows\u2026";
271
+ } else if (status === "idle" && !loading) {
272
+ said = server && n ? `Page ${page}` : page ? `${count}, page ${page}` : count;
273
+ }
274
+ const liveEl = (0, import_react.useRef)(null);
275
+ const saidBefore = (0, import_react.useRef)(null);
276
+ (0, import_react.useEffect)(() => {
277
+ const el = liveEl.current;
278
+ if (el && saidBefore.current !== null && saidBefore.current !== said) el.textContent = said;
279
+ saidBefore.current = said;
280
+ });
281
+ const cap = props.caption;
282
+ const named = typeof cap === "string" || typeof cap === "number" ? `${cap}` : "";
197
283
  const Empty = parts.Empty ?? DefaultEmpty;
198
284
  const Loading = parts.Loading ?? DefaultLoading;
199
285
  const ErrorState = parts.Error ?? DefaultError;
@@ -232,6 +318,7 @@ function DataTableView(props) {
232
318
  );
233
319
  });
234
320
  }
321
+ if (typeof window === "undefined" && failures.length > 0) report(table, failures);
235
322
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
236
323
  "div",
237
324
  {
@@ -240,22 +327,21 @@ function DataTableView(props) {
240
327
  "data-initial-load": isInitialLoad ? "true" : "false",
241
328
  "aria-busy": BUSY.includes(status) ? "true" : void 0,
242
329
  children: [
243
- searchState && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { "data-dk-search": "", "data-status": searchState.status, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
244
- Search,
245
- {
246
- value: searchState.input,
247
- status: searchState.status,
248
- onChange: (q) => void view.setSearch?.(q),
249
- onSubmit: () => void view.flushSearch?.(),
250
- onClear: () => view.clearSearch?.()
251
- }
252
- ) }),
330
+ searchState && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { "data-dk-search": "", "data-status": searchState.status, children: part(table, "SearchInput", parts.SearchInput, DefaultSearchInput, {
331
+ value: searchState.input,
332
+ status: searchState.status,
333
+ onChange: (q) => void view.setSearch?.(q),
334
+ onSubmit: () => void view.flushSearch?.(),
335
+ onClear: () => view.clearSearch?.(),
336
+ label: named ? `Search ${named}` : "Search"
337
+ }) }),
253
338
  !loading && !failed && status !== "idle" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(RefreshIndicator, { status, retry: retryLoad }),
254
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("table", { children: [
339
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("table", { ref: tableEl, children: [
255
340
  props.caption != null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("caption", { children: props.caption }),
256
341
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("thead", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("tr", { children: columns.map((col) => {
257
342
  const canSort = sortable && col.def.sortable !== false;
258
- const dir = canSort ? view.getSortDirection?.(col.id) ?? false : false;
343
+ const e = canSort ? sortBy?.find((x) => x.id === col.id) : void 0;
344
+ const dir = e ? e.desc ? "desc" : "asc" : false;
259
345
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
260
346
  "th",
261
347
  {
@@ -266,7 +352,7 @@ function DataTableView(props) {
266
352
  "button",
267
353
  {
268
354
  type: "button",
269
- onClick: (e) => view.toggleSort?.(col.id, { multi: e.shiftKey }),
355
+ onClick: (e2) => view.toggleSort?.(col.id, { multi: e2.shiftKey }),
270
356
  children: [
271
357
  col.header,
272
358
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "data-dk-sort-indicator": "", "aria-hidden": "true", children: dir === "asc" ? " \u25B2" : dir === "desc" ? " \u25BC" : "" })
@@ -278,77 +364,74 @@ function DataTableView(props) {
278
364
  );
279
365
  }) }) }),
280
366
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)("tbody", { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
281
- BodyBoundary,
367
+ Boundary,
282
368
  {
283
369
  table,
284
370
  resetKey: rowModel,
285
- fallback: (retry) => stateRow(span, "error", /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ErrorState, { status, reason: "render", retry })),
371
+ fallback: (retry) => stateRow(
372
+ span,
373
+ "error",
374
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
375
+ ErrorState,
376
+ {
377
+ status,
378
+ reason: "render",
379
+ retry: () => {
380
+ keepFocus();
381
+ retry();
382
+ }
383
+ }
384
+ )
385
+ ),
286
386
  children: body
287
387
  }
288
388
  ) })
289
389
  ] }),
290
- paged && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
291
- Pager,
292
- {
293
- pageIndex: state.pagination.pageIndex,
294
- pageSize: state.pagination.pageSize,
295
- pageCount: view.getPageCount?.() ?? 0,
296
- canPrev: view.canPrevPage?.() ?? false,
297
- canNext: view.canNextPage?.() ?? false,
298
- prev: () => view.prevPage?.(),
299
- next: () => view.nextPage?.(),
300
- setPageIndex: (i) => view.setPageIndex?.(i),
301
- setPageSize: (n) => view.setPageSize?.(n)
302
- }
303
- )
390
+ pager && part(table, "Pagination", parts.Pagination, DefaultPagination, {
391
+ ...pager,
392
+ prev: () => view.prevPage?.(),
393
+ next: () => view.nextPage?.(),
394
+ setPageIndex: (i) => view.setPageIndex?.(i),
395
+ setPageSize: (n2) => view.setPageSize?.(n2),
396
+ label: named ? `${named} pagination` : "Pagination"
397
+ }),
398
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { "aria-live": "polite", style: HIDDEN, ref: liveEl })
304
399
  ]
305
400
  }
306
401
  );
307
402
  }
403
+ function part(table, name, Custom, Default, props) {
404
+ if (!Custom) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Default, { ...props });
405
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
406
+ Boundary,
407
+ {
408
+ table,
409
+ slot: `components.${name}`,
410
+ resetKey: Custom,
411
+ fallback: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Default, { ...props }),
412
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Custom, { ...props })
413
+ }
414
+ );
415
+ }
308
416
  function stateRow(span, kind, content) {
309
417
  return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("tr", { "data-dk-state": kind, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("td", { colSpan: span, children: content }) });
310
418
  }
311
- var BodyBoundary = class extends import_react.Component {
312
- constructor() {
313
- super(...arguments);
314
- this.state = { failed: false, key: this.props.resetKey };
315
- this.retry = () => this.setState({ failed: false });
316
- }
317
- static getDerivedStateFromError() {
318
- return { failed: true };
319
- }
320
- static getDerivedStateFromProps(props, state) {
321
- return props.resetKey === state.key ? null : { failed: false, key: props.resetKey };
322
- }
323
- componentDidCatch(error) {
324
- try {
325
- (0, import_core.emitTableDiagnostic)(
326
- this.props.table,
327
- "DK_RENDER_FAILED",
328
- { op: "render:table", ...error instanceof Error ? { errorName: error.name } : {} },
329
- { cause: error }
330
- );
331
- } catch {
332
- }
333
- }
334
- render() {
335
- return this.state.failed ? this.props.fallback(this.retry) : this.props.children;
336
- }
337
- };
338
- var CellBoundary = class extends import_react.Component {
419
+ var Boundary = class extends import_react.Component {
339
420
  constructor() {
340
421
  super(...arguments);
341
422
  this.state = {
342
423
  failed: false,
343
- original: this.props.original,
344
- def: this.props.col.def
424
+ key: this.props.resetKey,
425
+ def: this.props.col?.def
345
426
  };
427
+ this.retry = () => this.setState({ failed: false });
346
428
  }
347
429
  static getDerivedStateFromError() {
348
430
  return { failed: true };
349
431
  }
350
432
  static getDerivedStateFromProps(props, state) {
351
- return props.original === state.original && props.col.def === state.def ? null : { failed: false, original: props.original, def: props.col.def };
433
+ const def = props.col?.def;
434
+ return props.resetKey === state.key && def === state.def ? null : { failed: false, key: props.resetKey, def };
352
435
  }
353
436
  componentDidCatch(error) {
354
437
  const { table, col, slot, row } = this.props;
@@ -357,10 +440,9 @@ var CellBoundary = class extends import_react.Component {
357
440
  table,
358
441
  "DK_RENDER_FAILED",
359
442
  {
360
- op: "render:cell",
361
- slot: `columns[${col.index}].${slot}`,
362
- column: col.id,
363
- row,
443
+ op: col ? "render:cell" : "render:table",
444
+ ...slot ? { slot: col ? `columns[${col.index}].${slot}` : slot } : {},
445
+ ...col ? { column: col.id, row } : {},
364
446
  ...error instanceof Error ? { errorName: error.name } : {}
365
447
  },
366
448
  { cause: error }
@@ -369,16 +451,24 @@ var CellBoundary = class extends import_react.Component {
369
451
  }
370
452
  }
371
453
  render() {
372
- return this.state.failed ? WARN : this.props.children;
454
+ return this.state.failed ? this.props.fallback(this.retry) : this.props.children;
373
455
  }
374
456
  };
375
- function DefaultSearchInput({ value, status, onChange, onSubmit, onClear }) {
457
+ var failedCell = () => FAILED;
458
+ function DefaultSearchInput({
459
+ value,
460
+ status,
461
+ onChange,
462
+ onSubmit,
463
+ onClear,
464
+ label
465
+ }) {
376
466
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_jsx_runtime.Fragment, { children: [
377
467
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
378
468
  "input",
379
469
  {
380
470
  type: "search",
381
- "aria-label": "Search",
471
+ "aria-label": label,
382
472
  value,
383
473
  onChange: (e) => onChange(e.target.value),
384
474
  onKeyDown: (e) => {
@@ -387,19 +477,38 @@ function DefaultSearchInput({ value, status, onChange, onSubmit, onClear }) {
387
477
  }
388
478
  }
389
479
  ),
390
- (status === "pending" || status === "searching") && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { role: "status", "data-dk-spinner": "", "aria-label": "Searching" })
480
+ (status === "pending" || status === "searching") && // Decorative: the live region says "Searching…" (an empty status announces nothing).
481
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "data-dk-spinner": "", "aria-hidden": "true" })
391
482
  ] });
392
483
  }
393
484
  function DefaultPagination(p) {
394
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("nav", { "aria-label": "Pagination", "data-dk-pagination": "", children: [
395
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", "aria-label": "Previous page", disabled: !p.canPrev, onClick: p.prev, children: "\u2039" }),
485
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("nav", { "aria-label": p.label, "data-dk-pagination": "", children: [
486
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
487
+ "button",
488
+ {
489
+ type: "button",
490
+ "aria-label": "Previous page",
491
+ "aria-disabled": !p.canPrev || void 0,
492
+ onClick: p.canPrev ? p.prev : void 0,
493
+ children: "\u2039"
494
+ }
495
+ ),
396
496
  /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { "data-dk-page": "", children: [
397
497
  "Page ",
398
498
  p.pageIndex + 1,
399
499
  " of ",
400
500
  Math.max(p.pageCount, 1)
401
501
  ] }),
402
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("button", { type: "button", "aria-label": "Next page", disabled: !p.canNext, onClick: p.next, children: "\u203A" })
502
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
503
+ "button",
504
+ {
505
+ type: "button",
506
+ "aria-label": "Next page",
507
+ "aria-disabled": !p.canNext || void 0,
508
+ onClick: p.canNext ? p.next : void 0,
509
+ children: "\u203A"
510
+ }
511
+ )
403
512
  ] });
404
513
  }
405
514
  var DefaultEmpty = ({ filtered }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_jsx_runtime.Fragment, { children: filtered ? "No matching rows" : "No rows" });