diff --git a/src/js/modules/ResponsiveLayout/ResponsiveLayout.js b/src/js/modules/ResponsiveLayout/ResponsiveLayout.js
index 6718dea0e..d1b0c0714 100644
--- a/src/js/modules/ResponsiveLayout/ResponsiveLayout.js
+++ b/src/js/modules/ResponsiveLayout/ResponsiveLayout.js
@@ -16,6 +16,7 @@ export default class ResponsiveLayout extends Module{
this.collapseFormatter = [];
this.collapseStartOpen = true;
this.collapseHandleColumn = false;
+ this.deferredRows = new Set();
this.registerTableOption("responsiveLayout", false); //responsive layout flags
this.registerTableOption("responsiveLayoutCollapseStartOpen", true); //start showing collapsed data
@@ -38,14 +39,28 @@ export default class ResponsiveLayout extends Module{
this.subscribe("table-redrawing", this.tableRedraw.bind(this));
- if(this.table.options.responsiveLayout === "collapse"){
+ //this.mode is not set until initializeResponsivity
+ if(this.isCollapseMode(this.table.options.responsiveLayout)){
this.subscribe("row-data-changed", this.generateCollapsedRowContent.bind(this));
this.subscribe("row-init", this.initializeRow.bind(this));
this.subscribe("row-layout", this.layoutRow.bind(this));
+
+ if(this.isEditableMode(this.table.options.responsiveLayout)){
+ this.subscribe("row-responsive-toggled", this.rowResponsiveToggled.bind(this));
+ this.subscribe("edit-editor-clear", this.editorCleared.bind(this));
+ }
}
}
}
+ isCollapseMode(mode = this.mode){
+ return mode === "collapse" || mode === "collapseEditable";
+ }
+
+ isEditableMode(mode = this.mode){
+ return mode === "collapseEditable";
+ }
+
tableRedraw(force){
if(["fitColumns", "fitDataStretch"].indexOf(this.layoutMode()) === -1){
if(!force){
@@ -73,7 +88,7 @@ export default class ResponsiveLayout extends Module{
column.modules.responsive.index = i;
columns.push(column);
- if(!column.visible && this.mode === "collapse"){
+ if(!column.visible && this.isCollapseMode()){
this.hiddenColumns.push(column);
}
}
@@ -89,7 +104,7 @@ export default class ResponsiveLayout extends Module{
this.columns = columns;
- if(this.mode === "collapse"){
+ if(this.isCollapseMode()){
this.generateCollapsedContent();
}
@@ -157,7 +172,7 @@ export default class ResponsiveLayout extends Module{
column.hide(false, true);
- if(this.mode === "collapse"){
+ if(this.isCollapseMode()){
this.hiddenColumns.unshift(column);
this.generateCollapsedContent();
@@ -170,11 +185,15 @@ export default class ResponsiveLayout extends Module{
showColumn(column){
var index;
+ if(this.isEditableMode()){
+ this.restoreColumnCells(column);
+ }
+
column.show(false, true);
//set column width to prevent calculation loops on uninitialized columns
column.setWidth(column.getWidth());
- if(this.mode === "collapse"){
+ if(this.isCollapseMode()){
index = this.hiddenColumns.indexOf(column);
if(index > -1){
@@ -249,8 +268,21 @@ export default class ResponsiveLayout extends Module{
var el, contents;
if(row.modules.responsiveLayout){
+ //a rebuild would tear an open editor out of the DOM, so defer it
+ if(this.isEditableMode() && this.rowIsEditing(row)){
+ this.deferredRows.add(row);
+ return;
+ }
+
+ this.deferredRows.delete(row);
+
el = row.modules.responsiveLayout.element;
+ //restore first, so the teardown below moves cells rather than orphaning them
+ if(this.isEditableMode()){
+ this.restoreCollapsedCells(row);
+ }
+
while(el.firstChild) el.removeChild(el.firstChild);
contents = this.collapseFormatter(this.generateCollapsedRowData(row));
@@ -270,6 +302,20 @@ export default class ResponsiveLayout extends Module{
var value = column.getFieldValue(data);
if(column.definition.title && column.field){
+ if(this.isEditableMode()){
+ let cell = row.getCell(column.field);
+
+ if(cell){
+ output.push({
+ field: column.field,
+ title: column.definition.title,
+ value: this.collapseCell(cell)
+ });
+ }
+
+ return;
+ }
+
if(column.modules.format && this.table.options.responsiveLayoutCollapseUseFormatters){
mockCellComponent = {
@@ -320,6 +366,90 @@ export default class ResponsiveLayout extends Module{
return output;
}
+ editorCleared(){
+ var rows = this.deferredRows;
+
+ if(rows.size){
+ this.deferredRows = new Set();
+ rows.forEach((row) => {
+ this.generateCollapsedRowContent(row);
+ });
+ }
+ }
+
+ rowIsEditing(row){
+ var currentCell = this.table.modExists("edit") ? this.table.modules.edit.currentCell : false;
+
+ return !!currentCell && currentCell.row === row;
+ }
+
+ //an editor left open in a closed container would hold the redraw block
+ rowResponsiveToggled(row, open){
+ if(!open && this.rowIsEditing(row)){
+ this.table.modules.edit.cancelEdit();
+ }
+ }
+
+ //only visibility is touched here; sizing is left to the stylesheet
+ collapseCell(cell){
+ var element = cell.getElement();
+
+ cell.show();
+
+ return element;
+ }
+
+ restoreColumnCells(column){
+ column.cells.forEach((cell) => {
+ this.restoreCell(cell);
+ });
+ }
+
+ restoreCollapsedCells(row){
+ row.cells.forEach((cell) => {
+ this.restoreCell(cell);
+ });
+ }
+
+ restoreCell(cell){
+ var config = cell.row.modules.responsiveLayout,
+ cells = cell.row.cells,
+ anchor = null,
+ rowEl;
+
+ //cell.element, not getElement(), which would force a lazy render
+ if(!config || !config.element || !cell.element || !config.element.contains(cell.element)){
+ return;
+ }
+
+ rowEl = cell.row.getElement();
+
+ for(let i = cells.indexOf(cell) + 1; i < cells.length; i++){
+ if(cells[i].element && cells[i].element.parentNode === rowEl){
+ anchor = cells[i].element;
+ break;
+ }
+ }
+
+ //the collapse container is always the row's last child
+ if(!anchor && config.element.parentNode === rowEl){
+ anchor = config.element;
+ }
+
+ if(anchor){
+ rowEl.insertBefore(cell.element, anchor);
+ }else{
+ rowEl.appendChild(cell.element);
+ }
+
+ //the column may have been shown while the cell was away
+ if(cell.column.visible){
+ cell.show();
+ }else{
+ cell.hide();
+ }
+ }
+
formatCollapsedData(data){
var list = document.createElement("table");
diff --git a/src/js/modules/ResponsiveLayout/extensions/formatters/responsiveCollapse.js b/src/js/modules/ResponsiveLayout/extensions/formatters/responsiveCollapse.js
index 6e480a7c1..5d4d5482a 100644
--- a/src/js/modules/ResponsiveLayout/extensions/formatters/responsiveCollapse.js
+++ b/src/js/modules/ResponsiveLayout/extensions/formatters/responsiveCollapse.js
@@ -1,6 +1,7 @@
export default function(cell, formatterParams, onRendered){
var el = document.createElement("div"),
- config = cell.getRow()._row.modules.responsiveLayout;
+ row = cell.getRow()._row,
+ config = row.modules.responsiveLayout;
el.classList.add("tabulator-responsive-collapse-toggle");
@@ -35,6 +36,7 @@ export default function(cell, formatterParams, onRendered){
el.addEventListener("click", function(e){
e.stopImmediatePropagation();
toggleList(!config.open);
+ row.dispatch("row-responsive-toggled", row, config.open);
cell.getTable().rowManager.adjustTableSize();
});
diff --git a/src/scss/tabulator.scss b/src/scss/tabulator.scss
index fc3ffff20..ff99f6e2c 100644
--- a/src/scss/tabulator.scss
+++ b/src/scss/tabulator.scss
@@ -883,6 +883,19 @@ $rangeHeaderTextHighlightBackground: #000000 !default; //header text color when
}
}
}
+
+ //a cell relocated here by the collapseEditable mode
+ .tabulator-cell{
+ //the column width and row height are re-applied inline on every pass
+ width:100% !important;
+ height:auto !important;
+
+ border-right:none;
+
+ white-space:normal;
+ overflow:visible;
+ text-overflow:clip;
+ }
}
//cell element
diff --git a/test/e2e/responsive-collapse-editable.html b/test/e2e/responsive-collapse-editable.html
new file mode 100644
index 000000000..1821f0ff2
--- /dev/null
+++ b/test/e2e/responsive-collapse-editable.html
@@ -0,0 +1,57 @@
+
+
+
+
+ Tabulator Responsive Collapse Editable Test
+
+
+
+
+
+
+
+
+
+
diff --git a/test/e2e/responsive-collapse-editable.spec.js b/test/e2e/responsive-collapse-editable.spec.js
new file mode 100644
index 000000000..1a1dd4e62
--- /dev/null
+++ b/test/e2e/responsive-collapse-editable.spec.js
@@ -0,0 +1,234 @@
+// @ts-check
+import { test, expect } from "@playwright/test";
+import { join } from "path";
+
+test.describe("Editing collapsed responsive columns", () => {
+ test.beforeEach(async ({ page }) => {
+ const htmlPath = join(__dirname, "responsive-collapse-editable.html");
+
+ // wide enough for every column, so each test narrows to the width it wants
+ await page.setViewportSize({ width: 1100, height: 600 });
+ await page.goto(`file://${htmlPath}`);
+ await page.waitForSelector(".tabulator-row");
+ });
+
+ const row = (page, index) => page.locator(".tabulator-row").nth(index);
+ const cell = (page, index, field) => row(page, index).locator(`.tabulator-cell[tabulator-field="${field}"]`);
+ const collapse = (page, index) => row(page, index).locator(".tabulator-responsive-collapse");
+
+ // the label cell that formatCollapsedData puts beside the relocated cell
+ const label = (page, index, field) => collapse(page, index)
+ .locator("tr", { has: page.locator(`.tabulator-cell[tabulator-field="${field}"]`) })
+ .locator("td")
+ .first();
+
+ // narrow the table until the responsive loop folds the low priority columns
+ const narrow = async (page) => {
+ await page.setViewportSize({ width: 420, height: 600 });
+ await expect(collapse(page, 0).locator('.tabulator-cell[tabulator-field="city"]')).toBeVisible();
+ };
+
+ test("folds low priority columns into the collapse container as the table narrows", async ({ page }) => {
+ // wide: every cell sits in the row itself
+ await expect(cell(page, 0, "city")).toBeVisible();
+ await expect(collapse(page, 0).locator(".tabulator-cell")).toHaveCount(0);
+
+ await narrow(page);
+
+ // the relocated cells are the real ones, still carrying their field attribute
+ await expect(collapse(page, 0).locator(".tabulator-cell")).not.toHaveCount(0);
+ await expect(collapse(page, 0).locator('.tabulator-cell[tabulator-field="code"]')).toBeVisible();
+ await expect(cell(page, 0, "name")).toBeVisible();
+ });
+
+ test("gives the relocated cell the full width of its row in the block", async ({ page }) => {
+ await narrow(page);
+
+ // the layout re-applies the column's inline width on every pass, so the
+ // stylesheet is what hands the cell the width of its container.
+ // measured against the wrapping div, not the td: the td carries 2px of UA
+ // padding, so its border box is not what a width:100% child resolves against
+ for(const field of ["age", "city", "code"]){
+ const cell = collapse(page, 0).locator(`.tabulator-cell[tabulator-field="${field}"]`);
+ const cellBox = await cell.boundingBox();
+ const hostBox = await cell.locator("xpath=..").boundingBox();
+
+ expect(cellBox).not.toBeNull();
+ expect(hostBox).not.toBeNull();
+ expect(Math.abs(cellBox.width - hostBox.width)).toBeLessThanOrEqual(1);
+
+ // and the inline column width really is being overridden, not absent
+ expect(await cell.evaluate(el => el.style.width)).not.toBe("");
+ }
+ });
+
+ test("edits a collapsed field and commits through cellEdited", async ({ page }) => {
+ await narrow(page);
+
+ const city = collapse(page, 0).locator('.tabulator-cell[tabulator-field="city"]');
+
+ await city.click();
+ await expect(city).toHaveClass(/tabulator-editing/);
+
+ const input = city.locator("input");
+
+ await expect(input).toBeVisible();
+ await input.fill("Boston");
+ await input.press("Enter");
+
+ await expect(city).not.toHaveClass(/tabulator-editing/);
+ await expect(city).toHaveText("Boston");
+
+ expect(await page.evaluate(() => window.edits)).toEqual([{ field: "city", value: "Boston" }]);
+ expect(await page.evaluate(() => window.testTable.getData()[0].city)).toBe("Boston");
+ });
+
+ test("keeps the field label beside the editor while it is open", async ({ page }) => {
+ await narrow(page);
+
+ const city = collapse(page, 0).locator('.tabulator-cell[tabulator-field="city"]');
+ const cityLabel = label(page, 0, "city");
+
+ await expect(cityLabel).toHaveText("City");
+
+ await city.click();
+ await expect(city.locator("input")).toBeVisible();
+
+ // the label has to stay put, and stay to the left of the editor
+ await expect(cityLabel).toBeVisible();
+ await expect(cityLabel).toHaveText("City");
+
+ const labelBox = await cityLabel.boundingBox();
+ const inputBox = await city.locator("input").boundingBox();
+
+ expect(labelBox).not.toBeNull();
+ expect(inputBox).not.toBeNull();
+ expect(labelBox.x + labelBox.width).toBeLessThanOrEqual(inputBox.x + 1);
+ });
+
+ test("holds the editor open when the validator fails", async ({ page }) => {
+ await narrow(page);
+
+ const code = collapse(page, 0).locator('.tabulator-cell[tabulator-field="code"]');
+
+ await code.click();
+
+ const input = code.locator("input");
+
+ await input.fill("");
+ await input.press("Enter");
+
+ await expect(code).toHaveClass(/tabulator-editing/);
+ await expect(code).toHaveClass(/tabulator-validation-fail/);
+ expect(await page.evaluate(() => window.edits)).toEqual([]);
+ expect(await page.evaluate(() => window.testTable.getData()[0].code)).toBe("AA");
+ });
+
+ test("cancels a collapsed edit on Escape", async ({ page }) => {
+ await narrow(page);
+
+ const city = collapse(page, 0).locator('.tabulator-cell[tabulator-field="city"]');
+
+ await city.click();
+ await city.locator("input").fill("Nowhere");
+ await city.locator("input").press("Escape");
+
+ await expect(city).not.toHaveClass(/tabulator-editing/);
+ await expect(city).toHaveText("New York");
+ expect(await page.evaluate(() => window.cancels)).toContain("city");
+ expect(await page.evaluate(() => window.edits)).toEqual([]);
+ });
+
+ test("closes the collapsed block, and the editor with it", async ({ page }) => {
+ await narrow(page);
+
+ const city = collapse(page, 0).locator('.tabulator-cell[tabulator-field="city"]');
+
+ await city.click();
+ await expect(city).toHaveClass(/tabulator-editing/);
+
+ await row(page, 0).locator(".tabulator-responsive-collapse-toggle").click();
+
+ await expect(collapse(page, 0)).toBeHidden();
+ await expect(city).not.toHaveClass(/tabulator-editing/);
+ expect(await page.evaluate(() => window.cancels)).toContain("city");
+ });
+
+ test("reopens the collapsed block and edits again", async ({ page }) => {
+ await narrow(page);
+
+ const toggle = row(page, 0).locator(".tabulator-responsive-collapse-toggle");
+
+ await toggle.click();
+ await expect(collapse(page, 0)).toBeHidden();
+
+ await toggle.click();
+ await expect(collapse(page, 0)).toBeVisible();
+
+ const city = collapse(page, 0).locator('.tabulator-cell[tabulator-field="city"]');
+
+ await city.click();
+ await city.locator("input").fill("Denver");
+ await city.locator("input").press("Enter");
+
+ await expect(city).toHaveText("Denver");
+ expect(await page.evaluate(() => window.edits)).toEqual([{ field: "city", value: "Denver" }]);
+ });
+
+ test("returns the cell to its column when the table widens again", async ({ page }) => {
+ await narrow(page);
+
+ // edit while collapsed, then widen: the value has to survive the move back
+ const collapsedCity = collapse(page, 0).locator('.tabulator-cell[tabulator-field="city"]');
+
+ await collapsedCity.click();
+ await collapsedCity.locator("input").fill("Austin");
+ await collapsedCity.locator("input").press("Enter");
+
+ await page.setViewportSize({ width: 1100, height: 600 });
+
+ await expect(collapse(page, 0).locator(".tabulator-cell")).toHaveCount(0);
+ await expect(cell(page, 0, "city")).toBeVisible();
+ await expect(cell(page, 0, "city")).toHaveText("Austin");
+
+ // and it is editable in its column, as any other cell
+ await cell(page, 0, "city").click();
+ await expect(cell(page, 0, "city")).toHaveClass(/tabulator-editing/);
+ });
+
+ test("puts the cells back in column order after a fold and unfold", async ({ page }) => {
+ await narrow(page);
+ await page.setViewportSize({ width: 1100, height: 600 });
+
+ await expect(collapse(page, 0).locator(".tabulator-cell")).toHaveCount(0);
+
+ const fields = await row(page, 0).locator(".tabulator-cell").evaluateAll(
+ els => els.map(el => el.getAttribute("tabulator-field"))
+ );
+
+ expect(fields).toEqual([null, "id", "name", "age", "city", "code"]);
+
+ // the collapse container stays the row's last child
+ const lastIsCollapse = await row(page, 0).evaluate(
+ el => el.lastElementChild.classList.contains("tabulator-responsive-collapse")
+ );
+
+ expect(lastIsCollapse).toBe(true);
+ });
+
+ test("edits collapsed fields on more than one row", async ({ page }) => {
+ await narrow(page);
+
+ for(const [index, value] of [[0, "Boston"], [1, "Dallas"]]){
+ const city = collapse(page, Number(index)).locator('.tabulator-cell[tabulator-field="city"]');
+
+ await city.click();
+ await city.locator("input").fill(String(value));
+ await city.locator("input").press("Enter");
+
+ await expect(city).toHaveText(String(value));
+ }
+
+ expect(await page.evaluate(() => window.testTable.getData().map(r => r.city))).toEqual(["Boston", "Dallas"]);
+ });
+});
diff --git a/test/unit/modules/ResponsiveLayoutCollapseEditable.spec.js b/test/unit/modules/ResponsiveLayoutCollapseEditable.spec.js
new file mode 100644
index 000000000..ba5ff1fc5
--- /dev/null
+++ b/test/unit/modules/ResponsiveLayoutCollapseEditable.spec.js
@@ -0,0 +1,636 @@
+import TabulatorFull from '../../../src/js/core/TabulatorFull.js';
+
+// jsdom reports every element as 0x0, so Helpers.elVisible() is false for
+// everything, and Edit.findNextEditableCell() never finds a cell to move into.
+// Give elements a size for the duration of this file.
+const sizedElement = {configurable: true, get(){ return 20; }};
+let originalWidth, originalHeight;
+
+// jsdom also reports clientWidth 0, so ResponsiveLayout.update() sees a table
+// with no room at all and folds every eligible column during the build. Give it
+// room instead, so each test folds exactly what it means to fold.
+const roomyElement = {configurable: true, get(){ return 1000; }};
+const crampedElement = {configurable: true, get(){ return 0; }};
+let originalClientWidth;
+
+// The built in editors commit or cancel on blur, which settles the edit before a
+// test can look at the open editor. Leave teardown to the table.
+function passiveEditor(cell, onRendered, success){
+ const input = document.createElement("input");
+
+ input.value = cell.getValue();
+
+ onRendered(() => input.focus());
+ input.addEventListener("change", () => success(input.value));
+
+ return input;
+}
+
+const buildTable = async (options) => {
+ document.body.innerHTML = '';
+
+ const table = new TabulatorFull("#test-table", options);
+
+ await new Promise(resolve => table.on("tableBuilt", resolve));
+
+ return table;
+};
+
+const buildCollapseTable = (options = {}, editor = passiveEditor) => buildTable({
+ data: [
+ { id: 1, name: "John", age: 25, city: "New York" },
+ { id: 2, name: "Jane", age: 30, city: "Boston" },
+ ],
+ columns: [
+ { title: "", formatter: "responsiveCollapse", width: 30, headerSort: false, responsive: 0 },
+ { title: "ID", field: "id", responsive: 0 },
+ { title: "Name", field: "name", editor: editor, responsive: 0 },
+ { title: "Age", field: "age", editor: editor, responsive: 2 },
+ { title: "City", field: "city", editor: editor, responsive: 3 },
+ ],
+ responsiveLayout: "collapseEditable",
+ ...options,
+});
+
+// the row's collapse container, reached the way the toggle formatter reaches it
+const collapseEl = (row) => row._row.modules.responsiveLayout.element;
+
+// fold a column away exactly as the resize loop would; jsdom computes no layout,
+// so ResponsiveLayout.update() can never decide to do it on its own
+const fold = (table, field) => table.module("responsiveLayout")
+ .hideColumn(table.columnManager.findColumn(field));
+
+const unfold = (table, field) => table.module("responsiveLayout")
+ .showColumn(table.columnManager.findColumn(field));
+
+// the tabulator-field of every cell element still sitting directly in the row,
+// in DOM order
+const rowFieldOrder = (row) => Array.from(row.getElement().children)
+ .filter(el => el.classList.contains("tabulator-cell"))
+ .map(el => el.getAttribute("tabulator-field"));
+
+const isEditing = (cell) => cell.getElement().classList.contains("tabulator-editing");
+
+const editingField = (table) => {
+ const current = table.module("edit").currentCell;
+
+ return current ? current.column.field : false;
+};
+
+const settle = () => new Promise(resolve => setTimeout(resolve, 20));
+
+describe("ResponsiveLayout - collapseEditable", () => {
+ let table;
+
+ beforeAll(() => {
+ originalWidth = Object.getOwnPropertyDescriptor(window.HTMLElement.prototype, "offsetWidth");
+ originalHeight = Object.getOwnPropertyDescriptor(window.HTMLElement.prototype, "offsetHeight");
+
+ Object.defineProperty(window.HTMLElement.prototype, "offsetWidth", sizedElement);
+ Object.defineProperty(window.HTMLElement.prototype, "offsetHeight", sizedElement);
+
+ // clientWidth lives on Element.prototype in jsdom, not HTMLElement.prototype
+ originalClientWidth = Object.getOwnPropertyDescriptor(window.Element.prototype, "clientWidth");
+
+ Object.defineProperty(window.Element.prototype, "clientWidth", roomyElement);
+ });
+
+ afterAll(() => {
+ if(originalWidth){
+ Object.defineProperty(window.HTMLElement.prototype, "offsetWidth", originalWidth);
+ }
+
+ if(originalHeight){
+ Object.defineProperty(window.HTMLElement.prototype, "offsetHeight", originalHeight);
+ }
+
+ if(originalClientWidth){
+ Object.defineProperty(window.Element.prototype, "clientWidth", originalClientWidth);
+ }
+ });
+
+ afterEach(() => {
+ if(table){
+ table.destroy();
+ table = null;
+ }
+ });
+
+ describe("mode recognition", () => {
+ it("treats collapseEditable as a collapse mode", async () => {
+ table = await buildCollapseTable();
+
+ const mod = table.module("responsiveLayout");
+
+ expect(mod.mode).toBe("collapseEditable");
+ expect(mod.isCollapseMode()).toBe(true);
+ expect(mod.isEditableMode()).toBe(true);
+ expect(mod.isCollapseMode("collapse")).toBe(true);
+ expect(mod.isEditableMode("collapse")).toBe(false);
+ expect(mod.isCollapseMode("hide")).toBe(false);
+ });
+
+ it("builds the row collapse container and tracks the folded column", async () => {
+ table = await buildCollapseTable();
+
+ fold(table, "city");
+
+ const mod = table.module("responsiveLayout");
+ const column = table.columnManager.findColumn("city");
+
+ expect(column.visible).toBe(false);
+ expect(mod.hiddenColumns).toContain(column);
+ expect(collapseEl(table.getRows()[0])).not.toBeUndefined();
+ });
+ });
+
+ describe("cell relocation", () => {
+ it("moves the real cell element into the collapse container", async () => {
+ table = await buildCollapseTable();
+
+ const row = table.getRows()[0];
+ const cell = row.getCell("city");
+
+ expect(cell.getElement().parentNode).toBe(row.getElement());
+
+ fold(table, "city");
+
+ expect(collapseEl(row).contains(cell.getElement())).toBe(true);
+ expect(cell.getElement().parentNode).not.toBe(row.getElement());
+ });
+
+ it("relocates cells when the responsive loop folds a column on its own", async () => {
+ table = await buildCollapseTable();
+
+ const row = table.getRows()[0];
+
+ // take the room away and let update() choose what to fold
+ Object.defineProperty(window.Element.prototype, "clientWidth", crampedElement);
+
+ try{
+ table.module("responsiveLayout").update();
+ }finally{
+ Object.defineProperty(window.Element.prototype, "clientWidth", roomyElement);
+ }
+
+ // highest responsive value folds first
+ expect(table.module("responsiveLayout").hiddenColumns.map(c => c.field)).toEqual(["age", "city"]);
+ expect(collapseEl(row).contains(row.getCell("city").getElement())).toBe(true);
+ expect(collapseEl(row).contains(row.getCell("age").getElement())).toBe(true);
+ expect(rowFieldOrder(row)).toEqual([null, "id", "name"]);
+ });
+
+ it("relocates the cell of every row, not just the first", async () => {
+ table = await buildCollapseTable();
+
+ fold(table, "city");
+
+ table.getRows().forEach((row) => {
+ expect(collapseEl(row).contains(row.getCell("city").getElement())).toBe(true);
+ });
+ });
+
+ it("shows the relocated cell, which Cell.hide() had left display:none", async () => {
+ table = await buildCollapseTable();
+
+ const cell = table.getRows()[0].getCell("city");
+
+ fold(table, "city");
+
+ // Cell.show() clears the inline display, letting the stylesheet decide
+ expect(cell.getElement().style.display).toBe("");
+
+ // width is pinned by the playwright spec; jsdom computes no layout
+ });
+
+ it("keeps the value rendered by the column's own formatter", async () => {
+ table = await buildCollapseTable({
+ columns: [
+ { title: "ID", field: "id", responsive: 0 },
+ { title: "City", field: "city", responsive: 3,
+ formatter: (cell) => "" + cell.getValue() + "" },
+ ],
+ });
+
+ fold(table, "city");
+
+ const cell = table.getRows()[0].getCell("city");
+
+ expect(cell.getElement().querySelector("b")).not.toBeNull();
+ expect(cell.getElement().textContent).toBe("New York");
+ });
+
+ it("puts the cell element back in the row, in column order, when unfolded", async () => {
+ table = await buildCollapseTable();
+
+ const row = table.getRows()[0];
+ const cell = row.getCell("city");
+
+ fold(table, "city");
+ expect(rowFieldOrder(row)).toEqual([null, "id", "name", "age"]);
+
+ unfold(table, "city");
+
+ expect(cell.getElement().parentNode).toBe(row.getElement());
+ expect(collapseEl(row).contains(cell.getElement())).toBe(false);
+ expect(rowFieldOrder(row)).toEqual([null, "id", "name", "age", "city"]);
+ });
+
+ it("keeps the collapse container as the row's last child after a round trip", async () => {
+ table = await buildCollapseTable();
+
+ const row = table.getRows()[0];
+
+ fold(table, "city");
+ fold(table, "age");
+ unfold(table, "age");
+ unfold(table, "city");
+
+ expect(row.getElement().lastChild).toBe(collapseEl(row));
+ expect(rowFieldOrder(row)).toEqual([null, "id", "name", "age", "city"]);
+ });
+
+ it("restores a cell whose column was shown outside the responsive loop", async () => {
+ table = await buildCollapseTable();
+
+ const row = table.getRows()[0];
+ const cell = row.getCell("city");
+
+ fold(table, "city");
+ expect(collapseEl(row).contains(cell.getElement())).toBe(true);
+
+ // a user calling showColumn() directly bypasses ResponsiveLayout.showColumn;
+ // the rebuild that column-show triggers has to converge anyway
+ table.showColumn("city");
+
+ expect(cell.getElement().parentNode).toBe(row.getElement());
+ expect(cell.getElement().style.display).not.toBe("none");
+ });
+ });
+
+ describe("editing a folded field", () => {
+ it("opens the column's editor inside the collapse container", async () => {
+ table = await buildCollapseTable();
+
+ const row = table.getRows()[0];
+ const cell = row.getCell("city");
+
+ fold(table, "city");
+ cell.edit();
+
+ expect(collapseEl(row).querySelector("input")).not.toBeNull();
+ expect(isEditing(cell)).toBe(true);
+ expect(editingField(table)).toBe("city");
+ });
+
+ it("keeps the field label beside the editor while it is open", async () => {
+ table = await buildCollapseTable();
+
+ const row = table.getRows()[0];
+ const cell = row.getCell("city");
+
+ fold(table, "city");
+
+ const label = cell.getElement().closest("tr").querySelector("td");
+
+ expect(label.textContent).toBe("City");
+
+ cell.edit();
+
+ // the label is a sibling cell of the editor, so it cannot move
+ expect(label.textContent).toBe("City");
+ expect(label.isConnected).toBe(true);
+ expect(collapseEl(row).contains(label)).toBe(true);
+ });
+
+ it("commits through cell.setValue, so row data and cellEdited follow", async () => {
+ table = await buildCollapseTable();
+
+ const edited = jest.fn();
+ const row = table.getRows()[0];
+ const cell = row.getCell("city");
+
+ table.on("cellEdited", c => edited(c.getField(), c.getValue()));
+
+ fold(table, "city");
+ cell.edit();
+
+ const input = collapseEl(row).querySelector("input");
+
+ input.value = "Chicago";
+ input.dispatchEvent(new window.Event("change", {bubbles: true}));
+
+ expect(cell.getValue()).toBe("Chicago");
+ expect(row.getData().city).toBe("Chicago");
+ expect(table.getData()[0].city).toBe("Chicago");
+ expect(edited).toHaveBeenCalledTimes(1);
+ expect(edited).toHaveBeenCalledWith("city", "Chicago");
+ });
+
+ it("fires cellEditing and marks the cell edited", async () => {
+ table = await buildCollapseTable();
+
+ const editing = jest.fn();
+ const row = table.getRows()[0];
+ const cell = row.getCell("city");
+
+ table.on("cellEditing", c => editing(c.getField()));
+
+ fold(table, "city");
+ cell.edit();
+
+ const input = collapseEl(row).querySelector("input");
+
+ input.value = "Chicago";
+ input.dispatchEvent(new window.Event("change", {bubbles: true}));
+
+ expect(editing).toHaveBeenCalledWith("city");
+ expect(cell.isEdited()).toBe(true);
+ expect(table.getEditedCells().map(c => c.getField())).toEqual(["city"]);
+ });
+
+ it("runs the column validator and holds the editor open on failure", async () => {
+ table = await buildCollapseTable({
+ columns: [
+ { title: "", formatter: "responsiveCollapse", width: 30, headerSort: false, responsive: 0 },
+ { title: "ID", field: "id", responsive: 0 },
+ { title: "City", field: "city", editor: passiveEditor, validator: "required", responsive: 3 },
+ ],
+ });
+
+ const row = table.getRows()[0];
+ const cell = row.getCell("city");
+
+ fold(table, "city");
+ cell.edit();
+
+ const input = collapseEl(row).querySelector("input");
+
+ input.value = "";
+ input.dispatchEvent(new window.Event("change", {bubbles: true}));
+
+ await settle();
+
+ expect(table.module("edit").invalidEdit).toBe(true);
+ expect(isEditing(cell)).toBe(true);
+ expect(row.getData().city).toBe("New York");
+ expect(cell.getElement().classList.contains("tabulator-validation-fail")).toBe(true);
+ });
+
+ it("commits on Enter and cancels on Escape with the built in editor", async () => {
+ table = await buildCollapseTable({}, "input");
+
+ const cancelled = jest.fn();
+ const row = table.getRows()[0];
+ const cell = row.getCell("city");
+
+ table.on("cellEditCancelled", c => cancelled(c.getField()));
+
+ fold(table, "city");
+
+ const press = (key) => collapseEl(row).querySelector("input")
+ .dispatchEvent(new window.KeyboardEvent("keydown", {key: key, bubbles: true}));
+
+ cell.edit();
+ collapseEl(row).querySelector("input").value = "Chicago";
+ press("Enter");
+
+ expect(cell.getValue()).toBe("Chicago");
+ expect(editingField(table)).toBe(false);
+
+ cell.edit();
+ collapseEl(row).querySelector("input").value = "Nowhere";
+ press("Escape");
+
+ expect(cell.getValue()).toBe("Chicago");
+ expect(cancelled).toHaveBeenCalledWith("city");
+ expect(editingField(table)).toBe(false);
+ });
+
+ it("honours editable:false on a folded column", async () => {
+ table = await buildCollapseTable({
+ columns: [
+ { title: "ID", field: "id", responsive: 0 },
+ { title: "City", field: "city", editor: passiveEditor, editable: false, responsive: 3 },
+ ],
+ });
+
+ const row = table.getRows()[0];
+ const cell = row.getCell("city");
+
+ fold(table, "city");
+ cell.edit();
+
+ expect(isEditing(cell)).toBe(false);
+ expect(editingField(table)).toBe(false);
+ expect(collapseEl(row).querySelector("input")).toBeNull();
+ });
+
+ it("tabs from a visible cell into a folded one", async () => {
+ table = await buildCollapseTable();
+
+ const row = table.getRows()[0];
+
+ fold(table, "city");
+
+ row.getCell("name").edit();
+ expect(editingField(table)).toBe("name");
+
+ // age is still visible, city is folded but shown inside the container, so
+ // both pass Helpers.elVisible() and both are reachable
+ expect(table.navigateNext()).toBe(true);
+ expect(editingField(table)).toBe("age");
+
+ expect(table.navigateNext()).toBe(true);
+ expect(editingField(table)).toBe("city");
+ expect(collapseEl(row).querySelector("input")).not.toBeNull();
+ });
+ });
+
+ describe("editor survival", () => {
+ it("leaves an open editor alone when the collapsed content regenerates", async () => {
+ table = await buildCollapseTable();
+
+ const row = table.getRows()[0];
+ const cell = row.getCell("city");
+
+ fold(table, "city");
+ cell.edit();
+
+ const input = collapseEl(row).querySelector("input");
+
+ input.value = "half typed";
+
+ // a resize driven hide/show, or a row update, lands here mid edit
+ table.module("responsiveLayout").generateCollapsedRowContent(row._row);
+
+ expect(collapseEl(row).querySelector("input")).toBe(input);
+ expect(input.value).toBe("half typed");
+ expect(isEditing(cell)).toBe(true);
+ expect(editingField(table)).toBe("city");
+ });
+
+ it("survives another column being folded in beside it", async () => {
+ table = await buildCollapseTable();
+
+ const row = table.getRows()[0];
+ const cell = row.getCell("city");
+
+ fold(table, "city");
+ cell.edit();
+
+ const input = collapseEl(row).querySelector("input");
+
+ fold(table, "age");
+
+ expect(input.isConnected).toBe(true);
+ expect(editingField(table)).toBe("city");
+ });
+
+ // a rebuild skipped to protect an open editor has to be replayed, or the
+ // row keeps collapsed content that no longer matches hiddenColumns
+ it("replays a rebuild that was deferred by an open editor", async () => {
+ table = await buildCollapseTable();
+
+ const row = table.getRows()[0];
+ const cell = row.getCell("city");
+
+ fold(table, "city");
+ cell.edit();
+
+ // folding a second column cannot rebuild this row yet
+ fold(table, "age");
+ expect(collapseEl(row).textContent).not.toContain("Age");
+ expect(table.module("responsiveLayout").deferredRows.has(row._row)).toBe(true);
+
+ // closing the editor has to bring the row back in line
+ table.module("edit").cancelEdit();
+
+ expect(collapseEl(row).textContent).toContain("Age");
+ expect(collapseEl(row).contains(row.getCell("age").getElement())).toBe(true);
+ expect(table.module("responsiveLayout").deferredRows.size).toBe(0);
+ });
+
+ it("replays when the editor was open on a visible cell of the row", async () => {
+ table = await buildCollapseTable();
+
+ const row = table.getRows()[0];
+
+ // the guard keys on the row, not on the edited cell being folded, so an
+ // edit anywhere in the row defers its rebuild
+ row.getCell("name").edit();
+ fold(table, "city");
+
+ expect(collapseEl(row).textContent).not.toContain("City");
+
+ table.module("edit").cancelEdit();
+
+ expect(collapseEl(row).textContent).toContain("City");
+ expect(collapseEl(row).contains(row.getCell("city").getElement())).toBe(true);
+ });
+
+ it("replays after a commit, with the committed value in place", async () => {
+ table = await buildCollapseTable();
+
+ const row = table.getRows()[0];
+ const cell = row.getCell("city");
+
+ fold(table, "city");
+ cell.edit();
+
+ const input = collapseEl(row).querySelector("input");
+
+ fold(table, "age");
+
+ input.value = "Chicago";
+ input.dispatchEvent(new window.Event("change", {bubbles: true}));
+
+ expect(cell.getValue()).toBe("Chicago");
+ expect(collapseEl(row).textContent).toContain("Age");
+ expect(collapseEl(row).textContent).toContain("Chicago");
+ });
+
+ it("closes the editor when the collapsed block is collapsed", async () => {
+ table = await buildCollapseTable();
+
+ const cancelled = jest.fn();
+ const row = table.getRows()[0];
+ const cell = row.getCell("city");
+
+ table.on("cellEditCancelled", c => cancelled(c.getField()));
+
+ fold(table, "city");
+ cell.edit();
+ expect(editingField(table)).toBe("city");
+
+ const toggle = row.getElement().querySelector(".tabulator-responsive-collapse-toggle");
+
+ toggle.dispatchEvent(new window.MouseEvent("click", {bubbles: true, cancelable: true}));
+
+ expect(row._row.modules.responsiveLayout.open).toBe(false);
+ expect(editingField(table)).toBe(false);
+ expect(cancelled).toHaveBeenCalledWith("city");
+ });
+
+ it("leaves the editor open when the block is merely opened", async () => {
+ table = await buildCollapseTable({responsiveLayoutCollapseStartOpen: false});
+
+ const row = table.getRows()[0];
+
+ fold(table, "city");
+
+ const toggle = row.getElement().querySelector(".tabulator-responsive-collapse-toggle");
+
+ toggle.dispatchEvent(new window.MouseEvent("click", {bubbles: true, cancelable: true}));
+ expect(row._row.modules.responsiveLayout.open).toBe(true);
+
+ row.getCell("city").edit();
+
+ expect(editingField(table)).toBe("city");
+ });
+ });
+
+ describe("row updates", () => {
+ it("re-renders the relocated cell when the row data changes", async () => {
+ table = await buildCollapseTable();
+
+ const row = table.getRows()[0];
+ const cell = row.getCell("city");
+
+ fold(table, "city");
+
+ await row.update({city: "Denver"});
+
+ expect(cell.getValue()).toBe("Denver");
+ expect(collapseEl(row).textContent).toContain("Denver");
+ expect(collapseEl(row).contains(row.getCell("city").getElement())).toBe(true);
+ });
+ });
+
+ describe('responsiveLayout: "collapse" is unchanged', () => {
+ it("leaves the real cell in the row and formats a copy of the value", async () => {
+ table = await buildCollapseTable({responsiveLayout: "collapse"});
+
+ const row = table.getRows()[0];
+ const cell = row.getCell("city");
+
+ fold(table, "city");
+
+ expect(cell.getElement().parentNode).toBe(row.getElement());
+ expect(collapseEl(row).contains(cell.getElement())).toBe(false);
+ expect(collapseEl(row).textContent).toContain("New York");
+ expect(collapseEl(row).querySelector(".tabulator-cell")).toBeNull();
+ });
+
+ it("still cannot edit a folded field", async () => {
+ table = await buildCollapseTable({responsiveLayout: "collapse"});
+
+ const row = table.getRows()[0];
+
+ fold(table, "city");
+
+ expect(collapseEl(row).querySelector("input")).toBeNull();
+ expect(table.module("responsiveLayout").isEditableMode()).toBe(false);
+ });
+ });
+});