@teaui/core
Version:
A high-level terminal UI library for Node
599 lines • 24.2 kB
JavaScript
import * as unicode from '@teaui/term';
import { Container } from '../Container.js';
import { Style } from '../Style.js';
import { Point, Rect, Size } from '../geometry.js';
import { isMouseClicked, isMouseDragging, isMousePressEnd, isMousePressStart, } from '../events/index.js';
/**
* A data table with sortable headers, selectable/scrollable rows, and column layout.
*
* ```
* Name │ Age │ Email │ Status
* ─────────────────────────────────────────────────
* Alice │ 30 │ alice@example.com │ Active
* ▶Bob │ 25 │ bob@example.com │ Pending ◀
* Charlie │ 35 │ charlie@ex.com │ Active
* ```
*/
export class Table extends Container {
#sourceData = [];
#data = [];
#columns = [];
#format = () => '';
#selectedIndex = 0;
#onSelect;
#onSort;
#sortKey;
#sortDirection = 'asc';
#showRowNumbers = false;
#isSelectable = false;
#showSelected = undefined;
#onSelectionChange;
#selectedItems = new Set();
#childOffset = 0;
get #isShowSelected() {
return this.#showSelected ?? this.#isSelectable;
}
#dragSelectState = undefined;
#dragScrollPinned = undefined;
// scroll state
#scrollOffset = 0;
#bodyHeight = 0;
#selectionDirty = true;
constructor(props) {
super(props);
this.#update(props);
}
update(props) {
this.#update(props);
super.update(props);
}
#update({ data, columns, format, selectedIndex, onSelect, onSort, sortKey, sortDirection, showRowNumbers, isSelectable, showSelected, onSelectionChange, childOffset, }) {
if (showRowNumbers !== undefined) {
this.#showRowNumbers = showRowNumbers;
}
if (isSelectable !== undefined) {
this.#isSelectable = isSelectable;
}
if (showSelected !== undefined) {
this.#showSelected = showSelected;
}
if (onSelectionChange !== undefined) {
this.#onSelectionChange = onSelectionChange;
}
if (childOffset !== undefined) {
this.#childOffset = childOffset;
}
const dataChanged = data !== undefined && data !== this.#sourceData;
const sortChanged = sortKey !== undefined &&
(sortKey !== this.#sortKey || sortDirection !== this.#sortDirection);
this.#columns = columns ?? this.#columns;
this.#format = format ?? this.#format;
if (selectedIndex !== undefined) {
this.#selectedIndex = selectedIndex;
this.#selectionDirty = true;
}
if (onSelect !== undefined) {
this.#onSelect = onSelect;
}
if (onSort !== undefined) {
this.#onSort = onSort;
}
if (sortKey !== undefined) {
this.#sortKey = sortKey;
}
if (sortDirection !== undefined) {
this.#sortDirection = sortDirection;
}
if (data !== undefined) {
this.#sourceData = data;
}
if (dataChanged || sortChanged) {
this.#sortData();
}
}
#sortData() {
if (!this.#sortKey) {
this.#data = [...this.#sourceData];
if (this.#sortDirection === 'desc') {
this.#data.reverse();
}
return;
}
const key = this.#sortKey;
const dir = this.#sortDirection;
this.#data = [...this.#sourceData].sort((a, b) => {
const aVal = this.#format(key, a);
const bVal = this.#format(key, b);
const cmp = aVal.localeCompare(bVal, undefined, { numeric: true });
return dir === 'asc' ? cmp : -cmp;
});
}
get selectedIndex() {
return this.#selectedIndex;
}
set selectedIndex(value) {
this.#selectedIndex = Math.max(0, Math.min(this.#data.length - 1, value));
this.#selectionDirty = true;
this.invalidateRender();
}
naturalSize(available) {
return available;
}
receiveKey(event) {
switch (event.name) {
case 'up':
this.selectedIndex = this.#selectedIndex - 1;
break;
case 'down':
this.selectedIndex = this.#selectedIndex + 1;
break;
case 'pageup':
this.selectedIndex =
this.#selectedIndex - Math.max(1, this.#bodyHeight - 2);
break;
case 'pagedown':
this.selectedIndex =
this.#selectedIndex + Math.max(1, this.#bodyHeight - 2);
break;
case 'home':
this.selectedIndex = 0;
break;
case 'end':
this.selectedIndex = this.#data.length - 1;
break;
case 'return':
if (this.#data.length > 0 && this.#onSelect) {
this.#onSelect(this.#data[this.#selectedIndex], this.#selectedIndex);
}
break;
case 'space':
this.#toggleSelection(this.#selectedIndex);
break;
}
}
receiveMouse(event, system) {
super.receiveMouse(event, system);
// During drag-select, pin the viewport and suppress scroll
if (this.#dragSelectState !== undefined) {
if (isMouseDragging(event)) {
this.#handleDragSelect(event.position.y);
return;
}
if (isMousePressEnd(event)) {
this.#dragSelectState = undefined;
this.#dragScrollPinned = undefined;
return;
}
}
if (event.name === 'mouse.wheel.up') {
this.#scrollOffset = Math.max(0, this.#scrollOffset - 1);
this.invalidateRender();
}
else if (event.name === 'mouse.wheel.down') {
const maxScroll = Math.max(0, this.#data.length - this.#bodyHeight);
this.#scrollOffset = Math.min(maxScroll, this.#scrollOffset + 1);
this.invalidateRender();
}
else if (isMousePressStart(event) && this.#isSelectable) {
const y = event.position.y;
if (y >= 2) {
const rowIndex = this.#scrollOffset + (y - 2);
if (rowIndex >= 0 && rowIndex < this.#data.length) {
const item = this.#data[rowIndex];
const wasChecked = this.#selectedItems.has(item);
this.#dragSelectState = wasChecked ? 'deselect' : 'select';
this.#dragScrollPinned = this.#scrollOffset;
this.#applyDragSelect(rowIndex);
this.selectedIndex = rowIndex;
}
}
}
else if (isMouseClicked(event)) {
const y = event.position.y;
// header row = 0, separator = 1, data rows start at 2
if (y === 0) {
this.#handleHeaderClick(event.position.x);
}
else if (y >= 2) {
const rowIndex = this.#scrollOffset + (y - 2);
if (rowIndex < this.#data.length) {
this.selectedIndex = rowIndex;
if (!this.#isSelectable) {
this.#onSelect?.(this.#data[rowIndex], rowIndex);
}
}
}
}
}
#handleDragSelect(y) {
if (y < 2) {
return;
}
const rowIndex = this.#scrollOffset + (y - 2);
if (rowIndex >= 0 && rowIndex < this.#data.length) {
this.#applyDragSelect(rowIndex);
// Move cursor without triggering #ensureSelectedVisible
this.#selectedIndex = rowIndex;
this.invalidateRender();
}
}
#applyDragSelect(rowIndex) {
const item = this.#data[rowIndex];
const changed = this.#dragSelectState === 'select'
? !this.#selectedItems.has(item)
: this.#selectedItems.has(item);
if (!changed) {
return;
}
if (this.#dragSelectState === 'select') {
this.#selectedItems.add(item);
}
else {
this.#selectedItems.delete(item);
}
this.invalidateRender();
this.#onSelectionChange?.(new Set(this.#selectedItems));
}
#handleHeaderClick(x) {
const INDENT = 1;
const checkboxWidth = this.#checkboxWidth();
const rowNumWidth = this.#rowNumberWidth();
// Click on checkbox column header → toggle all
if (this.#isShowSelected && x >= INDENT && x < INDENT + checkboxWidth) {
if (this.#selectedItems.size === this.#data.length) {
this.#selectedItems.clear();
}
else {
for (const item of this.#data) {
this.#selectedItems.add(item);
}
}
this.invalidateRender();
this.#onSelectionChange?.(new Set(this.#selectedItems));
return;
}
// Click on row number column → sort by original array order
if (this.#showRowNumbers &&
x >= INDENT + checkboxWidth &&
x < INDENT + checkboxWidth + rowNumWidth) {
const direction = !this.#sortKey && this.#sortDirection === 'asc' ? 'desc' : 'asc';
this.#sortKey = undefined;
this.#sortDirection = direction;
this.#sortData();
this.invalidateRender();
this.#onSort?.('#', direction);
return;
}
const TRAILING = 1;
const widths = this.#calculateColumnWidths(this.contentSize.width - INDENT - TRAILING - checkboxWidth - rowNumWidth);
let currentX = INDENT + checkboxWidth + rowNumWidth;
for (let i = 0; i < this.#columns.length; i++) {
const colWidth = widths[i];
// account for separator (3 chars: ' │ ')
const nextX = currentX + colWidth + (i < this.#columns.length - 1 ? 3 : 0);
if (x >= currentX && x < nextX) {
const col = this.#columns[i];
if (!col.sortable) {
return;
}
const direction = this.#sortKey === col.key && this.#sortDirection === 'asc'
? 'desc'
: 'asc';
this.#sortKey = col.key;
this.#sortDirection = direction;
this.#sortData();
this.invalidateRender();
this.#onSort?.(col.key, direction);
return;
}
currentX = nextX;
}
}
#toggleSelection(rowIndex) {
if (!this.#isSelectable || rowIndex < 0 || rowIndex >= this.#data.length) {
return;
}
const item = this.#data[rowIndex];
if (this.#selectedItems.has(item)) {
this.#selectedItems.delete(item);
}
else {
this.#selectedItems.add(item);
}
this.invalidateRender();
this.#onSelectionChange?.(new Set(this.#selectedItems));
}
/** Width of the checkbox column (including trailing separator). */
#checkboxWidth() {
if (!this.#isShowSelected) {
return 0;
}
return 3 + 3; // '[⨉]' (3) + ' │ ' (3)
}
/** Width of the row number column (including trailing separator space). */
#rowNumberWidth() {
if (!this.#showRowNumbers) {
return 0;
}
// Width of the largest row number, minimum width of '#' header
const digitWidth = Math.max(1, String(this.#data.length).length);
return digitWidth + 3; // digits + ' │ '
}
#calculateColumnWidths(totalWidth) {
const cols = this.#columns;
const separatorWidth = (cols.length - 1) * 3; // ' │ ' between columns
const available = totalWidth - separatorWidth;
let fixedTotal = 0;
let autoCount = 0;
for (const col of cols) {
if (typeof col.width === 'number') {
fixedTotal += col.width;
}
else {
autoCount += 1;
}
}
const autoWidth = autoCount > 0 ? Math.floor((available - fixedTotal) / autoCount) : 0;
return cols.map(col => {
if (typeof col.width === 'number') {
return col.width;
}
return Math.max(autoWidth, col.title.length);
});
}
#ensureSelectedVisible() {
if (this.#bodyHeight <= 0) {
return;
}
const halfHeight = Math.floor(this.#bodyHeight / 2);
if (this.#selectedIndex < this.#scrollOffset) {
this.#scrollOffset = this.#selectedIndex;
}
else if (this.#selectedIndex >= this.#scrollOffset + this.#bodyHeight) {
this.#scrollOffset = this.#selectedIndex - this.#bodyHeight + 1;
}
else if (this.#selectedIndex >= halfHeight &&
this.#selectedIndex < this.#data.length - halfHeight) {
// "moving window" for middle rows: selected stays at the center
this.#scrollOffset = this.#selectedIndex - halfHeight;
}
else if (this.#selectedIndex >= this.#data.length - halfHeight) {
// "bottom zone": pin scroll so last rows are visible, selected moves down
this.#scrollOffset = Math.max(0, this.#data.length - this.#bodyHeight);
}
const maxScroll = Math.max(0, this.#data.length - this.#bodyHeight);
this.#scrollOffset = Math.max(0, Math.min(maxScroll, this.#scrollOffset));
// Ensure the selected row doesn't land on an indicator row.
// Top indicator occupies position 0 when scrollOffset > 0.
// Bottom indicator occupies the last position when there are rows below.
if (this.#data.length > this.#bodyHeight) {
const rowsAbove = this.#scrollOffset;
const rowsBelow = this.#data.length - this.#scrollOffset - this.#bodyHeight;
if (rowsAbove > 0 && this.#selectedIndex === this.#scrollOffset) {
// Selected would be on the top indicator row — scroll up to make room
this.#scrollOffset = Math.max(0, this.#selectedIndex - 1);
}
if (rowsBelow > 0 &&
this.#selectedIndex === this.#scrollOffset + this.#bodyHeight - 1) {
// Selected would be on the bottom indicator row — scroll down to make room
this.#scrollOffset = Math.min(maxScroll, this.#selectedIndex - this.#bodyHeight + 2);
}
}
}
#alignText(text, width, align) {
const textWidth = unicode.lineWidth(text);
if (textWidth > width) {
// truncate
let w = 0;
let result = '';
for (const char of unicode.printableChars(text)) {
const cw = unicode.charWidth(char);
if (cw === 0) {
result += char;
continue;
}
if (w + cw > width - 1) {
result += ELLIPSIS;
break;
}
result += char;
w += cw;
}
return result;
}
const pad = width - textWidth;
switch (align) {
case 'right':
return ' '.repeat(pad) + text;
case 'center': {
const left = Math.floor(pad / 2);
const right = pad - left;
return ' '.repeat(left) + text + ' '.repeat(right);
}
default:
return text + ' '.repeat(pad);
}
}
render(viewport) {
if (viewport.isEmpty) {
return super.render(viewport);
}
viewport.registerFocus({ isDefault: true });
viewport.registerMouse(['mouse.button.left', 'mouse.wheel']);
const width = viewport.contentSize.width;
const height = viewport.contentSize.height;
// Reserve 1 character at the left and right for selection markers (▶ ◀)
const INDENT = 1;
const TRAILING = 1;
const checkboxWidth = this.#checkboxWidth();
const rowNumWidth = this.#rowNumberWidth();
const contentWidth = width - INDENT - TRAILING - checkboxWidth - rowNumWidth;
const widths = this.#calculateColumnWidths(contentWidth);
const dimStyle = new Style({ dim: true });
const headerStyle = new Style({ dim: true, bold: true });
// Cursor row (not checked)
const cursorStyle = new Style({
foreground: this.purpose.textColor,
background: this.purpose.highlightColor,
bold: true,
});
const checkedRowStyle = new Style({
background: this.purpose.tableCheckedColor,
});
const cursorCheckedStyle = new Style({
foreground: this.purpose.textColor,
background: this.purpose.tableCheckedHighlightColor,
bold: true,
});
// Header row
let headerX = INDENT;
// Checkbox column header
if (this.#isShowSelected) {
const checkedCount = this.#selectedItems.size;
const headerCheck = checkedCount === 0
? CHECKBOX_UNCHECKED
: checkedCount === this.#data.length
? CHECKBOX_CHECKED
: CHECKBOX_PARTIAL;
viewport.write(headerCheck, new Point(headerX, 0), headerStyle);
headerX += 3;
viewport.write(COLUMN_SEPARATOR, new Point(headerX, 0), dimStyle);
headerX += 3;
}
// Row number column header
if (this.#showRowNumbers) {
const numColWidth = rowNumWidth - 3; // subtract separator
const aligned = this.#alignText(ROW_NUMBER_HEADER, numColWidth, 'right');
viewport.write(aligned, new Point(headerX, 0), headerStyle);
// Show sort arrow when sorting by original order (sortKey is undefined)
if (!this.#sortKey) {
const arrow = this.#sortDirection === 'asc' ? SORT_ASC : SORT_DESC;
viewport.write(arrow, new Point(headerX, 0), headerStyle);
}
headerX += numColWidth;
viewport.write(COLUMN_SEPARATOR, new Point(headerX, 0), dimStyle);
headerX += 3;
}
for (let i = 0; i < this.#columns.length; i++) {
const col = this.#columns[i];
const aligned = this.#alignText(col.title, widths[i], col.align ?? 'left');
viewport.write(aligned, new Point(headerX, 0), headerStyle);
// Write sort arrow on top, positioned after the title text, clamped to column width
if (this.#sortKey === col.key) {
const arrow = this.#sortDirection === 'asc' ? SORT_ASC : SORT_DESC;
const titleWidth = unicode.lineWidth(col.title);
const align = col.align ?? 'left';
const titleStart = align === 'right'
? widths[i] - titleWidth
: align === 'center'
? Math.floor((widths[i] - titleWidth) / 2)
: 0;
const arrowOffset = Math.min(titleStart + titleWidth + 1, widths[i] - 1);
viewport.write(arrow, new Point(headerX + arrowOffset, 0), headerStyle);
}
headerX += widths[i];
if (i < this.#columns.length - 1) {
viewport.write(COLUMN_SEPARATOR, new Point(headerX, 0), dimStyle);
headerX += 3;
}
}
// Separator
if (height > 1) {
const sep = HORIZONTAL_LINE.repeat(width);
viewport.write(sep, new Point(0, 1), dimStyle);
}
// Body
this.#bodyHeight = Math.max(0, height - 2);
if (this.#selectionDirty) {
this.#selectionDirty = false;
this.#ensureSelectedVisible();
}
// Pin viewport during drag-select
if (this.#dragScrollPinned !== undefined) {
this.#scrollOffset = this.#dragScrollPinned;
}
for (let i = 0; i < this.#bodyHeight; i++) {
const rowIndex = this.#scrollOffset + i;
const y = i + 2;
if (rowIndex >= this.#data.length) {
break;
}
const isSelected = rowIndex === this.#selectedIndex;
const row = this.#data[rowIndex];
const isChecked = this.#selectedItems.has(row);
const rowHighlight = isSelected && isChecked
? cursorCheckedStyle
: isSelected
? cursorStyle
: isChecked
? checkedRowStyle
: Style.NONE;
const effectiveSepStyle = isSelected && isChecked
? cursorCheckedStyle
: isSelected
? cursorStyle
: isChecked
? checkedRowStyle
: dimStyle;
if (isSelected || isChecked) {
viewport.write(' '.repeat(width), new Point(0, y), rowHighlight);
}
if (isSelected) {
const selectionStyle = isChecked ? cursorCheckedStyle : cursorStyle;
viewport.write(SELECTION_MARKER, new Point(0, y), selectionStyle);
viewport.write(SELECTION_MARKER_END, new Point(width - 1, y), selectionStyle);
}
let cellX = INDENT;
// Checkbox column
if (this.#isShowSelected) {
const checkText = isChecked ? CHECKBOX_CHECKED : CHECKBOX_UNCHECKED;
viewport.write(checkText, new Point(cellX, y), rowHighlight);
cellX += 3;
viewport.write(COLUMN_SEPARATOR, new Point(cellX, y), effectiveSepStyle);
cellX += 3;
}
// Row number column
if (this.#showRowNumbers) {
const numColWidth = rowNumWidth - 3; // subtract separator
const numText = String(rowIndex + 1);
const aligned = this.#alignText(numText, numColWidth, 'right');
viewport.write(aligned, new Point(cellX, y), rowHighlight);
cellX += numColWidth;
viewport.write(COLUMN_SEPARATOR, new Point(cellX, y), effectiveSepStyle);
cellX += 3;
}
const childIndex = rowIndex - this.#childOffset;
const child = this.children[childIndex];
if (child) {
const childWidth = Math.max(0, width - cellX - 1);
if (childWidth > 0) {
viewport.clipped(new Rect(new Point(cellX, y), new Size(childWidth, 1)), childViewport => child.render(childViewport));
}
continue;
}
for (let j = 0; j < this.#columns.length; j++) {
const col = this.#columns[j];
const text = this.#format(col.key, row);
const aligned = this.#alignText(text, widths[j], col.align ?? 'left');
viewport.write(aligned, new Point(cellX, y), rowHighlight);
cellX += widths[j];
if (j < this.#columns.length - 1) {
viewport.write(COLUMN_SEPARATOR, new Point(cellX, y), effectiveSepStyle);
cellX += 3;
}
}
}
}
}
const SELECTION_MARKER = '▶';
const SELECTION_MARKER_END = '◀';
const COLUMN_SEPARATOR = ' │ ';
const HORIZONTAL_LINE = '─';
const SORT_ASC = '▲';
const SORT_DESC = '▼';
const ELLIPSIS = '…';
const CHECKBOX_CHECKED = '[✕]';
const CHECKBOX_UNCHECKED = '[ ]';
const CHECKBOX_PARTIAL = '[·]';
const ROW_NUMBER_HEADER = '#';
//# sourceMappingURL=Table.js.map