From 3b5245ce84a43fcc93bf195ac9f7f619e723900e Mon Sep 17 00:00:00 2001 From: pk Date: Tue, 23 Jun 2026 06:49:46 +0200 Subject: [PATCH] Support for row selection without checkboxes. --- package.json | 2 +- tp-table-item.js | 28 ++++++++++-- tp-table.js | 117 ++++++++++++++++++++++++++++++++++++++++++----- 3 files changed, 130 insertions(+), 17 deletions(-) diff --git a/package.json b/package.json index 720d897..c5667aa 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@tp/tp-table", - "version": "1.4.2", + "version": "1.5.0", "description": "", "main": "tp-table.js", "scripts": { diff --git a/tp-table-item.js b/tp-table-item.js index eba7587..1855551 100644 --- a/tp-table-item.js +++ b/tp-table-item.js @@ -71,10 +71,11 @@ class TpTableItem extends BaseElement { render() { const { columns, item, selected } = this; + const showCheckbox = this.selectable && this.selectionMode !== 'row-click'; return html`
- ${this.selectable ? html` + ${showCheckbox ? html`
@@ -88,12 +89,23 @@ class TpTableItem extends BaseElement { `; } + updated(changes) { + if (changes.has('selectionMode') || changes.has('selectable')) { + if (this.selectionMode && this.selectionMode !== 'checkbox') { + this.setAttribute('data-selection-mode', this.selectionMode); + } else { + this.removeAttribute('data-selection-mode'); + } + } + } + static get properties() { return { index: { type: Array }, item: { type: Object, hasChanged: () => true }, columns: { type: Array }, selectable: { type: Boolean }, + selectionMode: { type: String }, selected: { type: Boolean, reflect: true }, }; } @@ -103,10 +115,18 @@ class TpTableItem extends BaseElement { } async updated(changes) { - if (changes.has('columns') && Array.isArray(this.columns)) { - let colWidths = this.columns.filter(col => col.required || col.visible).map(col => col.width).join(' '); + const needsWidthRecalc = + (changes.has('columns') && Array.isArray(this.columns)) || + changes.has('selectionMode') || + changes.has('selectable'); - if (this.selectable) { + if (needsWidthRecalc) { + let colWidths = (this.columns || []) + .filter(col => col.required || col.visible) + .map(col => col.width) + .join(' '); + + if (this.selectable && this.selectionMode !== 'row-click') { colWidths = '40px ' + colWidths; } diff --git a/tp-table.js b/tp-table.js index 229f233..8062b3d 100644 --- a/tp-table.js +++ b/tp-table.js @@ -166,6 +166,13 @@ export class TpTable extends DomQuery(LitElement) { background: var(--table-row-hl); --list-columns-h-borders: var(--list-columns-h-borders-hover); } + + /* Selected rows get a pointer cursor so the click-to-select affordance + is obvious. Visual styling is the consumer's responsibility + (see terminal/frontend/src/styles/controls.js for the default). */ + [item][selected] { + cursor: pointer; + } ` ]; } @@ -173,11 +180,14 @@ export class TpTable extends DomQuery(LitElement) { render() { const columns = this.columns || []; const items = this.items || []; + const showCheckboxColumn = this.selectable && this.selectionMode !== 'row-click'; return html`
- ${this.renderTableHeader(columns)} -
this._selectionChanged(e)}> + ${this.renderTableHeader(columns, showCheckboxColumn)} +
this._selectionChanged(e)} + @click=${(e) => this._handleRowClick(e)}> ${!Array.isArray(items) || items.length === 0 ? html`
@@ -191,17 +201,21 @@ export class TpTable extends DomQuery(LitElement) { `; } - renderTableHeader(columns) { + renderTableHeader(columns, showCheckboxColumn = this.selectable) { return html`
- ${this.selectable ? html` -
this._checkedChanged(e)}>
+ ${showCheckboxColumn ? html` +
${this.renderSelectionHeader()}
` : null} ${columns.map((column, idx) => this.renderColumnHeader(column, idx))}
`; } + renderSelectionHeader() { + return html` this._checkedChanged(e)}>`; + } + renderColumnHeader(column, idx) { if (column.visible !== true && column.required !== true) return null; @@ -244,6 +258,7 @@ export class TpTable extends DomQuery(LitElement) { .item=${item} .selected=${selected} .selectable=${this.selectable} + .selectionMode=${this.selectionMode} .columns=${columns}> `; @@ -256,9 +271,18 @@ export class TpTable extends DomQuery(LitElement) { sorting: { type: Object }, selectable: { type: Boolean }, + /** + * Controls how rows are selected when `selectable` is true. + * - "checkbox" (default): header + per-row checkboxes; multi-select. + * - "row-click": click a row to select. Single-select on plain click, + * ctrl/cmd+click toggles individual rows, shift+click selects a range + * from the last clicked row. No checkbox column is rendered. + */ + selectionMode: { type: String }, items: { type: Array }, columnMoveHandle: { type: String }, - _selItems: { type: Map }, + _selItems: { state: true }, + _lastSelectedId: { state: true }, _visibleColumns: { type: Array }, _advFilters: { type: Array }, _advFilterActive: { type: Boolean }, @@ -271,10 +295,12 @@ export class TpTable extends DomQuery(LitElement) { constructor() { super(); this._selItems = new Map(); + this._lastSelectedId = null; + this.selectionMode = 'checkbox'; } updated(changes) { - if (changes.has('columns')) { + if (changes.has('columns') || changes.has('selectionMode') || changes.has('selectable')) { this._updateColumns(); } } @@ -365,7 +391,13 @@ export class TpTable extends DomQuery(LitElement) { } _updateColumns() { - this.$.tableHeader.style.gridTemplateColumns = this._updateColumnWidths(this.selectable ? ['40px'] : []).join(' '); + this.$.tableHeader.style.gridTemplateColumns = this._updateColumnWidths( + this._hasCheckboxColumn ? ['40px'] : [] + ).join(' '); + } + + get _hasCheckboxColumn() { + return this.selectable && this.selectionMode !== 'row-click'; } _updateColumnWidths(prepend) { @@ -558,15 +590,76 @@ export class TpTable extends DomQuery(LitElement) { } _updateSelEntries() { - if (!this.items) { - this._selItems = new Map(); + if (!Array.isArray(this.items)) { + if (this._selItems.size > 0) { + this._selItems = new Map(); + this._lastSelectedId = null; + } + return; } - for (const selItem of this._selItems.values()) { - if (this.items.findIndex(item => this.getItemId(item) === this.getItemId(selItem)) > -1) { + const validIds = new Set(this.items.map(item => this.getItemId(item))); + let changed = false; + for (const id of [...this._selItems.keys()]) { + if (!validIds.has(id)) { + this._selItems.delete(id); + changed = true; } } + + if (this._lastSelectedId !== null && !validIds.has(this._lastSelectedId)) { + this._lastSelectedId = null; + } + + if (changed) { + this._selectionChanged(); + } + } + + _handleRowClick(e) { + if (this.selectionMode !== 'row-click' || !this.selectable) return; + + // Walk through composedPath to find the host, since + // lit-virtualizer puts rows in a shadow tree. + const itemEl = e.composedPath().find( + n => n?.hasAttribute && n.hasAttribute('item') + ); + if (!itemEl || !itemEl.item) return; + + const id = this.getItemId(itemEl.item); + if (id == null) return; + + if (e.shiftKey && this._lastSelectedId !== null && this._lastSelectedId !== id) { + // Range select: replace selection with the range between last and current. + const items = this.items || []; + const fromIdx = items.findIndex(i => this.getItemId(i) === this._lastSelectedId); + const toIdx = items.findIndex(i => this.getItemId(i) === id); + if (fromIdx !== -1 && toIdx !== -1) { + const [lo, hi] = fromIdx < toIdx ? [fromIdx, toIdx] : [toIdx, fromIdx]; + this._selItems = new Map(); + for (let i = lo; i <= hi; i++) { + const it = items[i]; + this._selItems.set(this.getItemId(it), it); + } + } + } else if (e.ctrlKey || e.metaKey) { + // Toggle individual row. + if (this._selItems.has(id)) { + this._selItems.delete(id); + } else { + this._selItems.set(id, itemEl.item); + } + this._lastSelectedId = id; + } else { + // Plain click: single-select (replace). + this._selItems = new Map(); + this._selItems.set(id, itemEl.item); + this._lastSelectedId = id; + } + + this.requestUpdate(); + this._selectionChanged(); } }