1/*
2 * RapidContext <https://www.rapidcontext.com/>
3 * Copyright (c) 2007-2026 Per Cederberg. All rights reserved.
4 *
5 * This program is free software: you can redistribute it and/or
6 * modify it under the terms of the BSD license.
7 *
8 * This program is distributed in the hope that it will be useful,
9 * but WITHOUT ANY WARRANTY; without even the implied warranty of
10 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
11 *
12 * See the RapidContext LICENSE for more details.
13 */
14
15// Namespace initialization
16if (typeof(RapidContext) == "undefined") {
17 RapidContext = {};
18}
19RapidContext.Widget = RapidContext.Widget ?? { Classes: {} };
20
21/**
22 * Creates a new data table widget.
23 *
24 * @constructor
25 * @param {Object} attrs the widget and node attributes
26 * @param {string} [attrs.select] the row selection mode ('none', 'one',
27 * 'multiple' or 'auto'), defaults to 'one'
28 * @param {string} [attrs.key] the unique key identifier column field,
29 * defaults to null
30 * @param {boolean} [attrs.hidden] the hidden widget flag, defaults to false
31 * @param {...TableColumn} [child] the child table columns
32 *
33 * @return {Widget} the widget DOM node
34 *
35 * @class The table widget class. Used to provide a sortable and scrolling
36 * data table, using an outer `<div>` HTML element around a `<table>`. The
37 * `Table` widget can only have `TableColumn` child nodes, each providing a
38 * visible data column in the table.
39 * @extends RapidContext.Widget
40 *
41 * @example <caption>JavaScript</caption>
42 * let attrs1 = { field: "id", title: "Identifier", key: true, type: "number" };
43 * let attrs2 = { field: "name", title: "Name", maxLength: 50, sort: "asc" };
44 * let attrs3 = { field: "modified", title: "Last Modified", type: "datetime" };
45 * let col1 = RapidContext.Widget.TableColumn(attrs1);
46 * let col2 = RapidContext.Widget.TableColumn(attrs2);
47 * let col3 = RapidContext.Widget.TableColumn(attrs3);
48 * let exampleTable = RapidContext.Widget.Table({}, col1, col2, col3);
49 *
50 * @example <caption>User Interface XML</caption>
51 * <Table id="exampleTable" w="50%" h="100%">
52 * <TableColumn field="id" title="Identifier" key="true" type="number" />
53 * <TableColumn field="name" title="Name" maxLength="50" sort="asc" />
54 * <TableColumn field="modified" title="Last Modified" type="datetime" />
55 * </Table>
56 */
57RapidContext.Widget.Table = function (attrs/*, ...*/) {
58 const thead = RapidContext.UI.THEAD({}, document.createElement("tr"));
59 const tbody = RapidContext.UI.TBODY();
60 const table = RapidContext.UI.TABLE({ "class": "widgetTable" }, thead, tbody);
61 const o = RapidContext.UI.DIV({}, table);
62 RapidContext.Widget._widgetMixin(o, RapidContext.Widget.Table);
63 o.classList.add("widgetTable");
64 o._data = [];
65 o._rows = [];
66 o._keyField = null;
67 o._selected = [];
68 o._selectMode = "one";
69 o._mouseX = 0;
70 o._mouseY = 0;
71 o.setAttrs(attrs);
72 o.addAll(Array.from(arguments).slice(1));
73 o.on("mousedown", o._handleMouseDown);
74 o.on("click", o._handleClick);
75 return o;
76};
77
78// Register widget class
79RapidContext.Widget.Classes.Table = RapidContext.Widget.Table;
80
81/**
82 * Emitted when the table data is cleared.
83 *
84 * @name RapidContext.Widget.Table#onclear
85 * @event
86 */
87
88/**
89 * Emitted when the table selection changes.
90 *
91 * @name RapidContext.Widget.Table#onselect
92 * @event
93 */
94
95/**
96 * Returns the widget container DOM node.
97 *
98 * @return {Node} the container DOM node
99 */
100RapidContext.Widget.Table.prototype._containerNode = function () {
101 const table = this.firstChild;
102 const thead = table.firstChild;
103 const tr = thead.firstChild;
104 return tr;
105};
106
107/**
108 * Handles the mouse down event to stop text selection in some cases.
109 *
110 * @param {Event} evt the DOM Event object
111 */
112RapidContext.Widget.Table.prototype._handleMouseDown = function (evt) {
113 if (evt.ctrlKey || evt.metaKey || evt.shiftKey) {
114 evt.preventDefault();
115 }
116};
117
118/**
119 * Handles the click event to change selected rows.
120 *
121 * @param {Event} evt the DOM Event object
122 */
123RapidContext.Widget.Table.prototype._handleClick = function (evt) {
124 if (evt.target.closest("a[href]")) {
125 return;
126 }
127 const tr = evt.target.closest(".widgetTable > tbody > tr");
128 const row = tr && (tr.rowIndex - 1);
129 const isMulti = tr && this._selectMode === "multiple";
130 const isSingle = tr && this._selectMode !== "none";
131 if (isMulti && (evt.ctrlKey || evt.metaKey)) {
132 evt.preventDefault();
133 const pos = this._selected.indexOf(row);
134 if (pos >= 0) {
135 this._unmarkSelection(row);
136 this._selected.splice(pos, 1);
137 } else {
138 this._selected.push(row);
139 this._markSelection(row);
140 }
141 this.emit("select");
142 } else if (isMulti && evt.shiftKey) {
143 evt.preventDefault();
144 this._unmarkSelection();
145 this._selected.push(row);
146 const start = this._selected[0];
147 this._selected = [];
148 const step = (row >= start) ? 1 : -1;
149 for (let i = start; (step > 0) ? i <= row : i >= row; i += step) {
150 this._selected.push(i);
151 }
152 this._markSelection();
153 this.emit("select");
154 } else if (isSingle) {
155 this._unmarkSelection();
156 this._selected = [row];
157 this._markSelection();
158 this.emit("select");
159 }
160};
161
162/**
163 * Updates the widget or HTML DOM node attributes.
164 *
165 * @param {Object} attrs the widget and node attributes to set
166 * @param {string} [attrs.select] the row selection mode ('none', 'one' or
167 * 'multiple')
168 * @param {string} [attrs.key] the unique key identifier column field
169 * @param {boolean} [attrs.hidden] the hidden widget flag
170 */
171RapidContext.Widget.Table.prototype.setAttrs = function (attrs) {
172 attrs = { ...attrs };
173 if ("select" in attrs) {
174 this._selectMode = attrs.select;
175 }
176 if ("key" in attrs) {
177 this.setIdKey(attrs.key);
178 }
179 this.__setAttrs(attrs);
180};
181
182/**
183 * Adds a single child table column widget to this widget.
184 *
185 * @param {Widget} child the table column widget to add
186 */
187RapidContext.Widget.Table.prototype.addChildNode = function (child) {
188 if (!RapidContext.Widget.isWidget(child, "TableColumn")) {
189 throw new Error("Table widget can only have TableColumn children");
190 }
191 this.clear();
192 this._containerNode().append(child);
193};
194
195/**
196 * Removes a single child table column widget from this widget.
197 * This will also clear all the data in the table.
198 *
199 * @param {Widget} child the table column widget to remove
200 */
201RapidContext.Widget.Table.prototype.removeChildNode = function (child) {
202 this.clear();
203 this._containerNode().removeChild(child);
204};
205
206/**
207 * Returns the column index of a field.
208 *
209 * @param {string} field the field name
210 *
211 * @return {number} the column index, or
212 * -1 if not found
213 */
214RapidContext.Widget.Table.prototype.getColumnIndex = function (field) {
215 const cols = this.getChildNodes();
216 return cols.findIndex((col) => col.field === field);
217};
218
219/**
220 * Returns the unique key identifier column field, or null if none
221 * was set.
222 *
223 * @return {string} the key column field name, or
224 * null for none
225 */
226RapidContext.Widget.Table.prototype.getIdKey = function () {
227 if (this._keyField) {
228 return this._keyField;
229 }
230 for (const col of this.getChildNodes()) {
231 if (col.key) {
232 return col.field;
233 }
234 }
235 return null;
236};
237
238/**
239 * Sets the unique key identifier column field. Note that this
240 * method will regenerate all row identifiers if the table already
241 * contains data.
242 *
243 * @param {string} key the new key column field name
244 */
245RapidContext.Widget.Table.prototype.setIdKey = function (key) {
246 this._keyField = key;
247 for (const row of this._rows) {
248 if (this._keyField && row.$data[this._keyField] != null) {
249 row.$id = row.$data[this._keyField];
250 }
251 }
252};
253
254/**
255 * Returns the current sort key for the table.
256 *
257 * @return {string} the current sort field, or
258 * null for none
259 */
260RapidContext.Widget.Table.prototype.getSortKey = function () {
261 for (const col of this.getChildNodes()) {
262 if (col.sort && col.sort != "none") {
263 return col.field;
264 }
265 }
266 return null;
267};
268
269/**
270 * Returns a table cell element.
271 *
272 * @param {number} row the row index
273 * @param {number} col the column index
274 *
275 * @return {Node} the table cell element node, or
276 * null if not found
277 */
278RapidContext.Widget.Table.prototype.getCellElem = function (row, col) {
279 try {
280 const table = this.firstChild;
281 const tbody = table.lastChild;
282 return tbody.childNodes[row].childNodes[col];
283 } catch (e) {
284 return null;
285 }
286};
287
288/**
289 * Clears all the data in the table. The column headers will not be
290 * affected by this method. Use `removeAll()` or `removeChildNode()` to
291 * also remove columns.
292 */
293RapidContext.Widget.Table.prototype.clear = function () {
294 this.setData([]);
295};
296
297/**
298 * Returns an array with the data in the table. The array returned
299 * normally correspond exactly to the one previously set, i.e. it
300 * has not been sorted or modified in other ways. If `updateData()`
301 * is called however, a new data array is created to match current
302 * rows.
303 *
304 * @return {Array} an array with the data in the table
305 */
306RapidContext.Widget.Table.prototype.getData = function () {
307 return this._data;
308};
309
310/**
311 * Sets the table data. The table data is an array of objects, each
312 * having properties corresponding to the table column fields. Any
313 * object property not mapped to a table column will be ignored (i.e.
314 * a hidden column). See the `TableColumn` class for data mapping
315 * details. Note that automatically generated row ids will be reset
316 * by this method and any selection on such tables is lost.
317 *
318 * @param {Array} data an array with data objects
319 *
320 * @example
321 * let data = [
322 * { id: 1, name: "John Doe", modified: "@1300000000000" },
323 * { id: 2, name: "First Last", modified: new Date() },
324 * { id: 3, name: "Another Name", modified: "2004-11-30 13:33:20" }
325 * ];
326 * table.setData(data);
327 */
328RapidContext.Widget.Table.prototype.setData = function (data) {
329 const columns = this.getChildNodes();
330 const key = this.getIdKey() ?? "$id";
331 const selectedIds = key ? this.getSelectedIds() : [];
332 this.emit("clear");
333 this._data = data ?? [];
334 this._rows = this._data.map((obj, idx) => this._mapRow(columns, key, obj, idx));
335 this._selected = [];
336 const sort = this.getSortKey();
337 if (sort) {
338 this.sortData(sort);
339 } else {
340 this._renderRows();
341 }
342 if (this._selectMode !== "none") {
343 const isAuto = this._selectMode === "auto" && this._rows.length === 1;
344 if (isAuto && !selectedIds.includes(this._rows[0].$id)) {
345 this.addSelectedIds(this._rows[0].$id);
346 } else {
347 this._addSelectedIds(selectedIds);
348 }
349 }
350};
351
352/**
353 * Updates one or more rows of table data. Data is matched to
354 * existing rows either via the key identifier field or object
355 * identity. Any matching rows will be mapped and re-rendered
356 * accordingly. Non-matching data will be be ignored.
357 *
358 * @param {Array|Object} data an array with data or a single data object
359 *
360 * @example
361 * table.updateData({ id: 2, name: "New Name", modified: new Date() });
362 */
363RapidContext.Widget.Table.prototype.updateData = function (data) {
364 data = Array.isArray(data) ? data : [data];
365 const columns = this.getChildNodes();
366 const key = this.getIdKey() ?? "$id";
367 for (const obj of data) {
368 const idx = this._rows.findIndex((o) => o.$id === obj[key] || o.$data === obj);
369 if (idx >= 0) {
370 const row = this._rows[idx] = this._mapRow(columns, key, obj, idx);
371 const tr = document.createElement("tr");
372 tr.append(...columns.map((col) => col._render(row)));
373 const tbody = this.firstChild.lastChild;
374 tbody.children[idx].replaceWith(tr);
375 }
376 }
377 this._data = this._rows.map((o) => o.$data);
378 for (const sel of this._selected) {
379 this._markSelection(sel);
380 }
381};
382
383/**
384 * Creates a data row by mapping an object according to specified
385 * columns. Also extracts or creates an '$id' property and maps the
386 * source data to '$data'.
387 *
388 * @param {Array} columns the array of columns to map
389 * @param {String} key the id field, or null if not set
390 * @param {Object} obj the object with data to map
391 * @param {number} idx the row index for automatic id creation
392 *
393 * @return {Object} the data row object created
394 */
395RapidContext.Widget.Table.prototype._mapRow = function (columns, key, obj, idx) {
396 const id = (key && obj[key] != null) ? obj[key] : `id${idx}`;
397 const row = { $id: id, $data: obj };
398 for (const col of columns) {
399 row[col.field] = col._map(obj);
400 }
401 return row;
402};
403
404/**
405 * Sorts the table data by field and direction.
406 *
407 * @param {string} field the sort field
408 * @param {string} [direction] the sort direction, either "asc" or
409 * "desc"
410 */
411RapidContext.Widget.Table.prototype.sortData = function (field, direction) {
412 const selectedIds = this.getSelectedIds();
413 this._selected = [];
414 for (const col of this.getChildNodes()) {
415 if (col.sort != "none") {
416 if (col.field === field) {
417 direction = direction ?? col.sort ?? "asc";
418 col.setAttrs({ sort: direction });
419 } else {
420 col.setAttrs({ sort: null });
421 }
422 }
423 }
424 const cmp = RapidContext.Data.compare((o) => o[field]);
425 this._rows.sort((direction == "desc") ? (a, b) => cmp(b, a) : cmp);
426 this._renderRows();
427 this._addSelectedIds(selectedIds);
428};
429
430/**
431 * Redraws the table from updated source data. Note that this method
432 * will not add or remove rows and keeps the current row order
433 * intact. For a more complete redraw of the table, use `setData()`.
434 */
435RapidContext.Widget.Table.prototype.redraw = function () {
436 const cols = this.getChildNodes();
437 for (const row of this._rows) {
438 for (const col of cols) {
439 row[col.field] = col._map(row.$data);
440 }
441 }
442 this._renderRows();
443 for (const sel of this._selected) {
444 this._markSelection(sel);
445 }
446};
447
448/**
449 * Renders the table rows.
450 */
451RapidContext.Widget.Table.prototype._renderRows = function () {
452 const cols = this.getChildNodes();
453 const tbody = this.firstChild.lastChild;
454 tbody.innerHTML = "";
455 for (const row of this._rows) {
456 const tr = document.createElement("tr");
457 tr.append(...cols.map((col) => col._render(row)));
458 tbody.append(tr);
459 }
460 if (this._rows.length == 0) {
461 // Add empty row to avoid browser bugs
462 tbody.append(document.createElement("tr"));
463 }
464};
465
466/**
467 * Returns the number of rows in the table. This is a convenience
468 * method for `getData().length`.
469 *
470 * @return {number} the number of table rows
471 */
472RapidContext.Widget.Table.prototype.getRowCount = function () {
473 return this._rows.length;
474};
475
476/**
477 * Returns the row id for the specified row index. If the row index
478 * is out of bounds, null will be returned. The row ids are the data
479 * values from the key column, or automatically generated internal
480 * values if no key column is set. Note that the row index uses the
481 * current table sort order.
482 *
483 * @param {number} index the row index, 0 <= index < row count
484 *
485 * @return {string} the unique row id, or null if not found
486 */
487RapidContext.Widget.Table.prototype.getRowId = function (index) {
488 const row = this._rows[index];
489 return row ? row.$id : null;
490};
491
492/**
493 * Returns the currently selected row ids. If no rows are selected,
494 * an empty array will be returned. The row ids are the data values
495 * from the key column, or automatically generated internal values
496 * if no key column is set.
497 *
498 * @return {Array} an array with the selected row ids
499 */
500RapidContext.Widget.Table.prototype.getSelectedIds = function () {
501 return this._selected.map((idx) => this._rows[idx].$id);
502};
503
504/**
505 * Returns the currently selected row data.
506 *
507 * @return {Object|Array} the data row selected, or
508 * an array of selected data rows if multiple selection is enabled
509 */
510RapidContext.Widget.Table.prototype.getSelectedData = function () {
511 const data = this._selected.map((idx) => this._rows[idx].$data);
512 return (this._selectMode === "multiple") ? data : data[0];
513};
514
515/**
516 * Sets the selection to the specified row id values. If the current
517 * selection is changed the select signal will be emitted.
518 *
519 * @param {...(string|Array)} id the row ids or array with ids to select
520 *
521 * @return {Array} an array with the row ids actually modified
522 */
523RapidContext.Widget.Table.prototype.setSelectedIds = function (...ids) {
524 const $ids = RapidContext.Data.object([].concat(...ids), true);
525 const oldIds = RapidContext.Data.object(this.getSelectedIds(), true);
526 const res = [];
527 for (let i = 0; i < this._rows.length; i++) {
528 const rowId = this._rows[i].$id;
529 if ($ids[rowId] && !oldIds[rowId]) {
530 this._selected.push(i);
531 this._markSelection(i);
532 res.push(rowId);
533 } else if (!$ids[rowId] && oldIds[rowId]) {
534 const pos = this._selected.indexOf(i);
535 if (pos >= 0) {
536 this._selected.splice(pos, 1);
537 this._unmarkSelection(i);
538 res.push(rowId);
539 }
540 }
541 }
542 if (res.length > 0) {
543 this.emit("select");
544 }
545 return res;
546};
547
548/**
549 * Adds the specified row id values to the selection. If the current
550 * selection is changed the select signal will be emitted.
551 *
552 * @param {...(string|Array)} id the row ids or array with ids to select
553 *
554 * @return {Array} an array with the new row ids actually selected
555 */
556RapidContext.Widget.Table.prototype.addSelectedIds = function (...ids) {
557 const res = this._addSelectedIds(...ids);
558 if (res.length > 0) {
559 this.emit("select");
560 }
561 return res;
562};
563
564/**
565 * Adds the specified row id values to the selection. Note that this
566 * method does not emit any selection signal.
567 *
568 * @param {...(string|Array)} id the row ids or array with ids to select
569 *
570 * @return {Array} an array with the new row ids actually selected
571 */
572RapidContext.Widget.Table.prototype._addSelectedIds = function (...ids) {
573 const $ids = RapidContext.Data.object([].concat(...ids), true);
574 Object.assign($ids, RapidContext.Data.object(this.getSelectedIds(), false));
575 const res = [];
576 for (let i = 0; i < this._rows.length; i++) {
577 if ($ids[this._rows[i].$id] === true) {
578 this._selected.push(i);
579 this._markSelection(i);
580 res.push(this._rows[i].$id);
581 }
582 }
583 return res;
584};
585
586/**
587 * Removes the specified row id values from the selection. If the
588 * current selection is changed the select signal will be emitted.
589 *
590 * @param {...(string|Array)} id the row ids or array with ids to unselect
591 *
592 * @return {Array} an array with the row ids actually unselected
593 */
594RapidContext.Widget.Table.prototype.removeSelectedIds = function (...ids) {
595 const $ids = RapidContext.Data.object([].concat(...ids), true);
596 const res = [];
597 for (let i = 0; i < this._rows.length; i++) {
598 if ($ids[this._rows[i].$id] === true) {
599 const pos = this._selected.indexOf(i);
600 if (pos >= 0) {
601 this._selected.splice(pos, 1);
602 this._unmarkSelection(i);
603 res.push(this._rows[i].$id);
604 }
605 }
606 }
607 if (res.length > 0) {
608 this.emit("select");
609 }
610 return res;
611};
612
613/**
614 * Marks selected rows.
615 *
616 * @param {number} [index] the row index, or null to mark all
617 */
618RapidContext.Widget.Table.prototype._markSelection = function (index) {
619 const tbody = this.firstChild.lastChild;
620 const indices = (index == null) ? this._selected : [index];
621 for (const idx of indices) {
622 tbody.childNodes[idx].classList.add("selected");
623 }
624};
625
626/**
627 * Unmarks selected rows.
628 *
629 * @param {number} [index] the row index, or null to unmark all
630 */
631RapidContext.Widget.Table.prototype._unmarkSelection = function (index) {
632 const tbody = this.firstChild.lastChild;
633 const indices = (index == null) ? this._selected : [index];
634 for (const idx of indices) {
635 tbody.childNodes[idx].classList.remove("selected");
636 }
637};
638
RapidContext
Access · Discovery · Insight
www.rapidcontext.com