diff --git a/dev/vscode-table/shift-table-columns.html b/dev/vscode-table/shift-table-columns.html index 547e680aa..8ffcbe025 100644 --- a/dev/vscode-table/shift-table-columns.html +++ b/dev/vscode-table/shift-table-columns.html @@ -37,27 +37,21 @@

Basic example

- + - Id - First name - Last name - Email - Company @@ -101,6 +95,28 @@

Basic example

+ + +
diff --git a/dev/vscode-table/test-cases.html b/dev/vscode-table/test-cases.html new file mode 100644 index 000000000..1b30aef23 --- /dev/null +++ b/dev/vscode-table/test-cases.html @@ -0,0 +1,87 @@ + + + + + + VSCode Elements + + + + + + + +

Component demo

+
+ + + + Col 1 + Col 2 + + + + cell 1 + cell 2 + + + + + + + + + Col 1 + Col 2 + + + + cell 1 + cell 2 + + + + + + + + + + Col A + + + Col B + + Col C + + + + A + B + C + + + + +
+ + diff --git a/package.json b/package.json index 5d3d6a433..39e076509 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "icons": "node scripts/generateIconList.js", "vscode-data": "wireit", "ncu": "ncu -u", - "prepare": "npx playwright install chromium --only-shell" + "prepare": "npx playwright install chromium" }, "wireit": { "analyze": { diff --git a/src/includes/VscElement.ts b/src/includes/VscElement.ts index a08762f83..13f8db001 100644 --- a/src/includes/VscElement.ts +++ b/src/includes/VscElement.ts @@ -11,7 +11,7 @@ const warn = (message: string, componentInstance?: VscElement) => { console.warn(`${prefix}${message}\n%o`, componentInstance); } else { // eslint-disable-next-line no-console - console.warn(`${message}\n%o`, componentInstance); + console.warn(`${prefix}${message}`); } }; diff --git a/src/includes/test-helpers.ts b/src/includes/test-helpers.ts index 720ab6e6c..73bab3558 100644 --- a/src/includes/test-helpers.ts +++ b/src/includes/test-helpers.ts @@ -85,26 +85,30 @@ export async function moveMouseOnElement( /** A testing utility that drags an element with the mouse. */ export async function dragElement( - /** The element to drag */ el: Element, - /** The horizontal distance to drag in pixels */ deltaX = 0, - /** The vertical distance to drag in pixels */ deltaY = 0, - callbacks: { - afterMouseDown?: () => void | Promise; - afterMouseMove?: () => void | Promise; - } = {} + steps = 1, + position: 'left' | 'right' | 'center' = 'center' ): Promise { - await moveMouseOnElement(el); - await sendMouse({type: 'down'}); + const start = determineMousePosition(el, position, 0, 0); - await callbacks.afterMouseDown?.(); + await sendMouse({ + type: 'move', + position: [start.clickX, start.clickY], + }); - const {clickX, clickY} = determineMousePosition(el, 'center', deltaX, deltaY); - await sendMouse({type: 'move', position: [clickX, clickY]}); + await sendMouse({type: 'down'}); - await callbacks.afterMouseMove?.(); + for (let i = 1; i <= steps; i++) { + await sendMouse({ + type: 'move', + position: [ + Math.round(start.clickX + (deltaX * i) / steps), + Math.round(start.clickY + (deltaY * i) / steps), + ], + }); + } await sendMouse({type: 'up'}); } diff --git a/src/vscode-table-body/vscode-table-body.ts b/src/vscode-table-body/vscode-table-body.ts index 0d97f7576..f2a74499e 100644 --- a/src/vscode-table-body/vscode-table-body.ts +++ b/src/vscode-table-body/vscode-table-body.ts @@ -14,8 +14,15 @@ export class VscodeTableBody extends VscElement { @property({reflect: true}) override role = 'rowgroup'; + private _handleSlotChange() { + /** @internal */ + this.dispatchEvent( + new Event('vsc-table-body-slot-changed', {bubbles: true}) + ); + } + override render(): TemplateResult { - return html` `; + return html` `; } } @@ -23,4 +30,8 @@ declare global { interface HTMLElementTagNameMap { 'vscode-table-body': VscodeTableBody; } + + interface GlobalEventHandlersEventMap { + 'vsc-table-body-slot-changed': Event; + } } diff --git a/src/vscode-table-header-cell/vscode-table-header-cell.ts b/src/vscode-table-header-cell/vscode-table-header-cell.ts index 30b77b2e3..59df1d395 100644 --- a/src/vscode-table-header-cell/vscode-table-header-cell.ts +++ b/src/vscode-table-header-cell/vscode-table-header-cell.ts @@ -8,6 +8,11 @@ export type VscTableChangeMinColumnWidthEvent = CustomEvent<{ propertyValue: string; }>; +export type VscTableChangePreferredColumnWidthEvent = CustomEvent<{ + columnIndex: number; + propertyValue: string; +}>; + /** * @tag vscode-table-header-cell * @@ -20,7 +25,10 @@ export class VscodeTableHeaderCell extends VscElement { static override styles = styles; @property({attribute: 'min-width'}) - minWidth = '0'; + minWidth: string | undefined = undefined; + + @property({attribute: 'preferred-width'}) + preferredWidth = 'auto'; /** @internal */ @property({type: Number}) @@ -40,6 +48,16 @@ export class VscodeTableHeaderCell extends VscElement { }) as VscTableChangeMinColumnWidthEvent ); } + + if (changedProperties.has('preferredWidth') && this.index > -1) { + /** @internal */ + this.dispatchEvent( + new CustomEvent('vsc-table-change-preferred-column-width', { + detail: {columnIndex: this.index, propertyValue: this.preferredWidth}, + bubbles: true, + }) as VscTableChangePreferredColumnWidthEvent + ); + } } override render(): TemplateResult { @@ -58,5 +76,6 @@ declare global { interface GlobalEventHandlersEventMap { 'vsc-table-change-min-column-width': VscTableChangeMinColumnWidthEvent; + 'vsc-table-change-preferred-column-width': VscTableChangePreferredColumnWidthEvent; } } diff --git a/src/vscode-table/calculations.ts b/src/vscode-table/calculations.ts index ab7e5e585..8f7abdd5b 100644 --- a/src/vscode-table/calculations.ts +++ b/src/vscode-table/calculations.ts @@ -37,14 +37,15 @@ export function calculateColumnWidths( let totalAvailable: Percent = percent(0); for (const i of shrinkingSide) { - const available = Math.max(0, result[i] - (minWidths.get(i) ?? 0)); + const min = minWidths.get(i) ?? percent(0); + const available = Math.max(0, result[i] - min); totalAvailable = percent(totalAvailable + available); } - // Abort if the requested delta cannot be fully satisfied - if (totalAvailable < remaining) { - return result; - } + const effectiveDelta = + totalAvailable < remaining ? totalAvailable : remaining; + + remaining = percent(effectiveDelta); // Shrink columns sequentially until the delta is fully consumed for (const i of shrinkingSide) { @@ -60,7 +61,7 @@ export function calculateColumnWidths( } // Apply the exact opposite delta to the growing side - let toAdd: Percent = percent(absDelta); + let toAdd: Percent = percent(effectiveDelta); for (const i of growingSide) { if (toAdd === 0) { diff --git a/src/vscode-table/initial-column-widths.ts b/src/vscode-table/initial-column-widths.ts new file mode 100644 index 000000000..3cb09a972 --- /dev/null +++ b/src/vscode-table/initial-column-widths.ts @@ -0,0 +1,61 @@ +import {percent, Percent} from '../includes/sizes.js'; + +const PERCENT_FULL = percent(100); + +export type ColumnWidth = Percent | 'auto'; + +export interface Column { + preferredWidth: ColumnWidth; + minWidth: Percent; +} + +export function calculateInitialWidths(columns: Column[]): Percent[] { + const finalWidths: Percent[] = columns.map( + (c) => + typeof c.preferredWidth === 'number' + ? percent(Math.max(c.preferredWidth, c.minWidth)) + : percent(0) // auto placeholder + ); + + const autoIndices = columns + .map((c, i) => (c.preferredWidth === 'auto' ? i : -1)) + .filter((i) => i >= 0); + + const totalMinWidth = columns.reduce( + (sum, c) => percent(sum + c.minWidth), + percent(0) + ); + + if (totalMinWidth > PERCENT_FULL) { + const scale = PERCENT_FULL / totalMinWidth; + return columns.map((c) => percent(c.minWidth * scale)); + } + + const fixedWidthSum = finalWidths.reduce( + (sum, w) => percent(sum + w), + percent(0) + ); + const remainingSpace = percent(PERCENT_FULL - fixedWidthSum); + + if (remainingSpace > 0 && autoIndices.length > 0) { + const extraPerAuto = remainingSpace / autoIndices.length; + for (const i of autoIndices) { + finalWidths[i] = percent(Math.max(columns[i].minWidth, extraPerAuto)); + } + return finalWidths; + } + + if (autoIndices.length > 0) { + for (const i of autoIndices) { + finalWidths[i] = columns[i].minWidth; + } + return finalWidths; + } + + if (remainingSpace > 0 && autoIndices.length === 0) { + const scale = PERCENT_FULL / fixedWidthSum; + return finalWidths.map((w) => percent(w * scale)); + } + + return finalWidths; +} diff --git a/src/vscode-table/vscode-table.test.ts b/src/vscode-table/vscode-table.test.ts index 73565d1fd..f6325d255 100644 --- a/src/vscode-table/vscode-table.test.ts +++ b/src/vscode-table/vscode-table.test.ts @@ -1,7 +1,13 @@ /* eslint-disable @typescript-eslint/no-unused-expressions */ -import {$, dragElement} from '../includes/test-helpers.js'; +import {$, $$, dragElement} from '../includes/test-helpers.js'; import {VscodeTable} from './index.js'; +import '../vscode-table-body/vscode-table-body.js'; +import '../vscode-table-header/vscode-table-header.js'; +import '../vscode-table-row/vscode-table-row.js'; +import '../vscode-table-cell/vscode-table-cell.js'; +import '../vscode-table-header-cell/vscode-table-header-cell.js'; import {expect, fixture, html} from '@open-wc/testing'; +import {VscodeTableCell} from '../vscode-table-cell/vscode-table-cell.js'; describe('vscode-table', () => { it('is defined', () => { @@ -35,4 +41,245 @@ describe('vscode-table', () => { expect(await testDrag()).not.to.throw; }); + + it('resizes two columns when dragged left', async () => { + const el = await fixture(html` + + + Col 1 + Col 2 + + + + A + B + + + + `); + + await dragElement($(el.shadowRoot!, '.sash.resizable'), -100, 0); + + const cells = $$('vscode-table-cell'); + + const w1 = parseFloat(cells[0].style.width); + const w2 = parseFloat(cells[1].style.width); + + expect(w1 + w2).to.equal(100); + expect(w1).to.be.lessThan(50); + expect(w2).to.be.greaterThan(50); + }); + + it('clamps resize at min-width', async () => { + const el = await fixture(html` + + + + Col 1 + + Col 2 + + + + A + B + + + + `); + + // 500px table → min-width 100px = 20% + await dragElement($(el.shadowRoot!, '.sash.resizable'), -400, 0); + + const cells = $$('vscode-table-cell'); + + expect(cells[0].style.width).to.equal('20%'); + expect(cells[1].style.width).to.equal('80%'); + }); + + it('uses preferred-width as initial column width', async () => { + await fixture(html` + + + + Col 1 + + Col 2 + + + + A + B + + + + `); + + const cells = $$('vscode-table-cell'); + + expect(cells[0].style.width).to.equal('30%'); + expect(cells[1].style.width).to.equal('70%'); + }); + + it('min-width overrides preferred-width when preferred is too small', async () => { + await fixture(html` + + + + Col 1 + + Col 2 + + + + A + B + + + + `); + + // 150px / 500px = 30% + const cells = $$('vscode-table-cell'); + + expect(cells[0].style.width).to.equal('30%'); + expect(cells[1].style.width).to.equal('70%'); + }); + + it('chains shrinking across multiple columns when min-width is hit', async () => { + const el = await fixture(html` + + + + Col A + + + Col B + + Col C + + + + A + B + C + + + + `); + + await dragElement($(el.shadowRoot!, '.sash.resizable'), -300, 0); + + const cells = $$('vscode-table-cell'); + + const wA = parseFloat(cells[0].style.width); + const wB = parseFloat(cells[1].style.width); + const wC = parseFloat(cells[2].style.width); + + // A should absorb the remaining shrink, but not go below its min-width + expect(wA).to.be.closeTo(16.67, 0.1); + + // B should be clamped at its min-width: 200 / 600 = 33.33% + expect(wB).to.be.closeTo(50, 0.1); + + // C grows by exactly what A lost + expect(wA + wB + wC).to.be.closeTo(100, 0.01); + + // Ensure actual chaining happened (A < initial) + expect(wA).to.be.lessThan(33.34); + }); + + it('chains growing across multiple columns when right side hits min-width', async () => { + const el = await fixture(html` + + + Col A + Col B + + Col C + + + + + A + B + C + + + + `); + + // Drag splitter between B and C to the RIGHT + await dragElement($(el.shadowRoot!, '.sash.resizable'), +300, 0); + + const cells = $$('vscode-table-cell'); + + const wA = parseFloat(cells[0].style.width); + const wB = parseFloat(cells[1].style.width); + const wC = parseFloat(cells[2].style.width); + + // C should clamp at min-width: 150 / 600 = 25% + expect(wC).to.be.closeTo(25, 0.1); + + // A + B should grow + expect(wA + wB).to.be.greaterThan(66.6); + + // Sum invariant + expect(wA + wB + wC).to.be.closeTo(100, 0.01); + }); + + it('preserves column widths when table body is cleared and re-populated', async () => { + const el = await fixture(html` + + + + Col 1 + + Col 2 + + + + A + B + + + + `); + + await dragElement($(el.shadowRoot!, '.sash.resizable'), -200, 0); + + let cells = $$('vscode-table-cell'); + + const widthBefore = Array.from(cells).map((c) => + c.style.getPropertyValue('width') + ); + + // Sanity check: widths are actually set + expect(widthBefore[0]).to.not.equal(''); + expect(widthBefore[1]).to.not.equal(''); + + const body = el.querySelector('vscode-table-body')!; + body.innerHTML = ''; + + await el.updateComplete; + + body.appendChild( + document.createRange().createContextualFragment(` + + C + D + + `) + ); + + await el.updateComplete; + + cells = $$('vscode-table-cell'); + + const widthAfter = Array.from(cells).map((c) => + c.style.getPropertyValue('width') + ); + + expect(widthAfter).to.deep.equal(widthBefore); + }); }); diff --git a/src/vscode-table/vscode-table.ts b/src/vscode-table/vscode-table.ts index d8098c798..3a463cf79 100644 --- a/src/vscode-table/vscode-table.ts +++ b/src/vscode-table/vscode-table.ts @@ -22,7 +22,11 @@ import { } from '../includes/sizes.js'; import styles from './vscode-table.styles.js'; import {ColumnResizeController} from './ColumnResizeController.js'; -import {VscTableChangeMinColumnWidthEvent} from '../vscode-table-header-cell/vscode-table-header-cell.js'; +import { + VscTableChangeMinColumnWidthEvent, + VscTableChangePreferredColumnWidthEvent, +} from '../vscode-table-header-cell/vscode-table-header-cell.js'; +import {calculateInitialWidths, Column} from './initial-column-widths.js'; /** * @tag vscode-table @@ -35,6 +39,8 @@ import {VscTableChangeMinColumnWidthEvent} from '../vscode-table-header-cell/vsc export class VscodeTable extends VscElement { static override styles = styles; + //#region properties + /** @internal */ @property({reflect: true}) override role = 'table'; @@ -128,6 +134,10 @@ export class VscodeTable extends VscElement { @property({type: Boolean, reflect: true, attribute: 'zebra-odd'}) zebraOdd = false; + //#endregion + + //#region private variables + @query('.header') private _headerElement!: HTMLDivElement; @@ -192,6 +202,10 @@ export class VscodeTable extends VscElement { private _columnResizeController = new ColumnResizeController(this); + //#endregion + + //#region lifecycle methods + constructor() { super(); @@ -199,6 +213,10 @@ export class VscodeTable extends VscElement { 'vsc-table-change-min-column-width', this._handleMinColumnWidthChange ); + this.addEventListener( + 'vsc-table-change-preferred-column-width', + this._handlePreferredColumnWidthChange + ); } override connectedCallback(): void { @@ -235,6 +253,10 @@ export class VscodeTable extends VscElement { } } + //#endregion + + //#region private methods + private _memoizeComponentDimensions() { const cr = this.getBoundingClientRect(); @@ -502,6 +524,71 @@ export class VscodeTable extends VscElement { this._activeSashElementIndex = -1; } + private _updateColumnWidths() { + this._headerCells = this._queryHeaderCells(); + const minWidths: Percent[] = []; + const preferredWidths: Percent[] = []; + minWidths.fill(percent(0), 0, this._headerCells.length - 1); + preferredWidths.fill(percent(0), 0, this._headerCells.length - 1); + + this._headerCells.forEach((c, i) => { + c.index = i; + + if (c.minWidth) { + const minWidth = + parseSizeAttributeToPercent(c.minWidth, this._componentW) ?? + percent(0); + this._columnResizeController.setColumnMinWidthAt(i, minWidth); + } else { + const minWidth = + parseSizeAttributeToPercent(this.minColumnWidth, this._componentW) ?? + percent(0); + this._columnResizeController.setColumnMinWidthAt(i, minWidth); + } + }); + + const columns = this._headerCells.map((cell) => { + const preferredWidth = + cell.preferredWidth !== 'auto' + ? parseSizeAttributeToPercent( + cell.preferredWidth ?? '0', + this._componentW + ) + : cell.preferredWidth; + const minWidth = parseSizeAttributeToPercent( + cell.minWidth ?? '0', + this._componentW + ); + + return {preferredWidth, minWidth} as Column; + }); + const calculatedWidths = calculateInitialWidths(columns); + + this._columnResizeController.setColumWidths(calculatedWidths); + this._resizeColumns(true); + } + + private _updateBodyColumnWidths() { + const widths = this._columnResizeController.columnWidths; + const firstRowCells = this._getCellsOfFirstRow(); + firstRowCells.forEach((c, i) => (c.style.width = `${widths[i]}%`)); + } + + private _resizeColumns(resizeBodyCells = true) { + const widths = this._columnResizeController.columnWidths; + + const headerCells = this._getHeaderCells(); + headerCells.forEach((h, i) => (h.style.width = `${widths[i]}%`)); + + if (resizeBodyCells) { + this._updateBodyColumnWidths(); + } + } + + //#endregion + + //#region event handlers + private _onDefaultSlotChange() { this._assignedElements.forEach((el) => { if (el.tagName.toLowerCase() === 'vscode-table-header') { @@ -517,24 +604,11 @@ export class VscodeTable extends VscElement { } private _onHeaderSlotChange() { - this._headerCells = this._queryHeaderCells(); - const minWidths: Percent[] = []; - minWidths.fill(percent(0), 0, this._headerCells.length - 1); - - this._headerCells.forEach((c, i) => { - c.index = i; - - if (c.minWidth) { - const minWidth = - parseSizeAttributeToPercent(c.minWidth, this._componentW) ?? - percent(0); - this._columnResizeController.setColumnMinWidthAt(i, minWidth); - } - }); + this._updateColumnWidths(); } private _onBodySlotChange() { - this._initDefaultColumnSizes(); + // this._initDefaultColumnSizes(); this._initResizeObserver(); this._updateResizeHandlersSize(); @@ -574,18 +648,6 @@ export class VscodeTable extends VscElement { this.requestUpdate(); } - private _resizeColumns(resizeBodyCells = true) { - const widths = this._columnResizeController.columnWidths; - - const headerCells = this._getHeaderCells(); - headerCells.forEach((h, i) => (h.style.width = `${widths[i]}%`)); - - if (resizeBodyCells) { - const firstRowCells = this._getCellsOfFirstRow(); - firstRowCells.forEach((c, i) => (c.style.width = `${widths[i]}%`)); - } - } - private _handleSplitterPointerDown(event: PointerEvent) { event.stopPropagation(); @@ -636,9 +698,23 @@ export class VscodeTable extends VscElement { if (value) { this._columnResizeController.setColumnMinWidthAt(columnIndex, value); + this._updateColumnWidths(); } }; + private _handlePreferredColumnWidthChange = ( + _event: VscTableChangePreferredColumnWidthEvent + ) => { + this._updateColumnWidths(); + }; + + private _handleTableBodySlotChange() { + this._cellsOfFirstRow = []; + this._updateBodyColumnWidths(); + } + + //#endregion + override render(): TemplateResult { const splitterPositions = this._columnResizeController.splitterPositions; @@ -691,7 +767,11 @@ export class VscodeTable extends VscElement {
- +
${sashes}