Packages

Table input widget for Livebook

Current section

Files

Jump to
kino_table_input lib assets main.js
Raw

lib/assets/main.js

const PLUS_CIRCLE = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" height="1.4rem" width="1.4rem">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v6m3-3H9m12 0a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z" />
</svg>
`;
const X_TRASH = `
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" height="1.4rem" width="1.4rem">
<path stroke-linecap="round" stroke-linejoin="round" d="m14.74 9-.346 9m-4.788 0L9.26 9m9.968-3.21c.342.052.682.107 1.022.166m-1.022-.165L18.16 19.673a2.25 2.25 0 0 1-2.244 2.077H8.084a2.25 2.25 0 0 1-2.244-2.077L4.772 5.79m14.456 0a48.108 48.108 0 0 0-3.478-.397m-12 .562c.34-.059.68-.114 1.022-.165m0 0a48.11 48.11 0 0 1 3.478-.397m7.5 0v-.916c0-1.18-.91-2.164-2.09-2.201a51.964 51.964 0 0 0-3.32 0c-1.18.037-2.09 1.022-2.09 2.201v.916m7.5 0a48.667 48.667 0 0 0-7.5 0" />
</svg>
`;
let selectedCell = {};
let name = "";
export function init(ctx, payload) {
ctx.importCSS("main.css");
ctx.root.innerHTML = `
<div id="top">
<header>
<h2 id="header">Table input</h2>
<div>
<span id="assign-to-text">Assign to</span>
<input id="assign-to" />
</div>
</header>
<div id="table-container">
<table id="table">
<thead>
<tr id="thead">
<td class="index-column"></td>
</tr>
</thead>
<tbody id="tbody">
</tbody>
</table>
</div>
</div>
`;
const assignTo = ctx.root.querySelector("#assign-to");
name = payload.name;
assignTo.value = name;
assignTo.addEventListener("focusout", (_) => {
if (assignTo.value.trim() != "" && assignTo.value != name) {
ctx.pushEvent("change-name", {name: assignTo.value});
}
});
ctx.handleEvent("name-updated", (data) => {
name = data["name"];
assignTo.value = data["name"];
});
const thead = ctx.root.querySelector("#thead");
const tbody = ctx.root.querySelector("#tbody");
const tableNode = ctx.root.querySelector("#table");
makeTable(ctx, payload.columns, payload.table, tableNode, thead, tbody);
ctx.handleEvent("data-updated", (data) => {
makeTable(ctx, data["columns"], data["table"], tableNode, thead, tbody);
});
ctx.handleSync(() => {
// Synchronously invokes change listeners
document.activeElement &&
document.activeElement.dispatchEvent(new Event("change"));
});
}
function makeTable(ctx, columns, table, tableNode, thead, tbody) {
while (thead.lastChild) {
if (thead.lastChild.classList && thead.lastChild.classList.contains("index-column")) {
break;
}
thead.removeChild(thead.lastChild);
}
while (tbody.lastChild) {
tbody.removeChild(tbody.lastChild);
}
function addHeader(text, className) {
const th = document.createElement("th");
const div = document.createElement("div");
div.dataset.pos = headerSelector(text);
div.classList.add(className);
const textDiv = document.createElement("div");
textDiv.classList.add("heading-container");
textDiv.classList.add("group");
const textSpan = document.createElement("span");
textSpan.textContent = text;
textSpan.classList.add("heading-text");
textDiv.appendChild(textSpan);
const deleteCol = document.createElement("span");
deleteCol.classList.add("delete-col");
deleteCol.innerHTML = X_TRASH;
deleteCol.addEventListener("click", (ev) => {
ev.stopPropagation();
ctx.pushEvent("delete-column", {"column": text});
});
textDiv.appendChild(deleteCol);
div.appendChild(textDiv);
const textInput = document.createElement("input");
textInput.addEventListener("focusout", (_) => {
clearSelected(tableNode, headerSelected(text));
const newName = textInput.value.trim();
if (newName != "" && textInput.value != text) {
textSpan.textContent = newName;
ctx.pushEvent("rename-column", {"old": text, "new": newName});
if (selectedCell.header) {
selectedCell.header = newName;
} else if (selectedCell.col) {
selectedCell.col = newName;
}
}
});
textInput.addEventListener("keydown", (ev) => {
if (ev.code == "Enter") {
ev.stopPropagation();
if (!ev.shiftKey) {
handleCellClicked(tableNode, text, 0);
}
textInput.blur();
} else if (ev.code == "Escape") {
ev.stopPropagation();
textInput.value = text;
textInput.blur();
} else if (ev.code == "Tab") {
ev.preventDefault();
ev.stopPropagation();
textInput.blur();
const colIndex = columns.findIndex((c) => c === text);
if (colIndex >= 0) {
let otherCol;
if (ev.shiftKey) {
otherCol = colIndex - 1;
} else {
otherCol = colIndex + 1;
}
if (columns[otherCol]) {
handleHeaderClicked(tableNode, columns[otherCol]);
}
}
}
});
div.appendChild(textInput);
div.addEventListener("click", (_) => {
// TODO If you change the value of a header, then click on
// somewhere else, the header is unfocused correctly, but
// the next cell isn't. This is because the underlying
// data gets changed during the focusout event. I don't
// have a solution to this yet, but this doesn't happen if
// you use Enter/Tab to move around.
handleHeaderClicked(tableNode, text);
});
th.appendChild(div);
thead.appendChild(th);
return div;
}
for (const col of columns) {
const h = addHeader(col, "table-cell");
}
const addColumn = addHeader("add column", "add-column");
addColumn.addEventListener("click", (_) => {
ctx.pushEvent("add-empty-column", {});
});
addColumn.innerHTML = PLUS_CIRCLE;
let numRows = 0;
if (columns.length > 0) {
numRows = table[columns[0]].length;
}
const addRowRow = document.createElement("tr");
{
const td = document.createElement("td");
td.colSpan = columns.length + 2;
const div = document.createElement("div");
div.innerHTML = PLUS_CIRCLE;
div.classList.add("add-row");
div.addEventListener("click", (_) => {
ctx.pushEvent("add-empty-row", {});
});
td.appendChild(div);
addRowRow.appendChild(td);
tbody.appendChild(addRowRow);
}
for (let i = 0; i < numRows; i++) {
const tr = document.createElement("tr");
tr.classList.add("group");
const td = document.createElement("td");
td.textContent = i + 1;
td.classList.add("index-column");
tr.appendChild(td);
for (const col of columns) {
const td = document.createElement("td");
const div = document.createElement("div");
div.dataset.pos = cellSelector(col, i);
div.classList.add("table-cell");
const textDiv = document.createElement("div");
textDiv.classList.add("text-div");
if (table[col][i] != null) {
textDiv.textContent = table[col][i];
} else {
textDiv.textContent = "";
textDiv.classList.add("empty-cell");
}
div.appendChild(textDiv);
const textInput = document.createElement("input");
if (!isNaN(Number(textDiv.textContent))) {
textDiv.classList.add("number-cell");
textInput.classList.add("number-cell");
}
textInput.addEventListener("focusout", (_) => {
clearSelected(tableNode, cellSelected(col, i));
if (textInput.value != textDiv.textContent) {
textDiv.textContent = textInput.value;
ctx.pushEvent("change-cell", {
column: col,
row: i,
value: textInput.value
});
}
});
textInput.addEventListener("keydown", (ev) => {
if (ev.code == "Enter") {
ev.stopPropagation();
textInput.blur();
let otherRow;
if (ev.shiftKey) {
otherRow = i - 1;
} else {
otherRow = i + 1;
}
if (otherRow >= 0) {
handleCellClicked(tableNode, col, otherRow);
} else {
handleHeaderClicked(tableNode, col);
}
} else if (ev.code == "Escape") {
ev.stopPropagation();
textInput.value = textDiv.textContent;
textInput.blur();
} else if (ev.code == "Tab") {
ev.preventDefault();
ev.stopPropagation();
textInput.blur();
const colIndex = columns.findIndex((c) => c === col);
if (colIndex >= 0) {
let otherCol;
if (ev.shiftKey) {
otherCol = colIndex - 1;
} else {
otherCol = colIndex + 1;
}
if (columns[otherCol]) {
handleCellClicked(tableNode, columns[otherCol], i);
}
}
}
});
div.appendChild(textInput);
div.addEventListener("click", (_) => {
// TODO If you change the value of a cell, then click on
// somewhere else, the header is unfocused correctly, but
// the next cell isn't. This is because the underlying
// data gets changed during the focusout event. I don't
// have a solution to this yet, but this doesn't happen if
// you use Enter/Tab to move around.
handleCellClicked(tableNode, col, i);
});
td.appendChild(div);
tr.appendChild(td);
}
const deleteRowCell = document.createElement("td");
deleteRowCell.addEventListener("click", (_) => {
ctx.pushEvent("delete-row", {row: i});
});
const div = document.createElement("div");
div.classList.add("delete-row");
div.innerHTML = X_TRASH;
deleteRowCell.appendChild(div);
tr.appendChild(deleteRowCell);
tbody.insertBefore(tr, addRowRow);
}
if (selectedCell["header"]) {
handleHeaderClicked(tableNode, selectedCell["header"]);
} else if (selectedCell["col"] && selectedCell["row"]) {
handleCellClicked(tableNode, selectedCell["col"], selectedCell["row"]);
}
}
function handleHeaderClicked(tableNode, col) {
const div = tableNode.querySelector(`div[data-pos="${headerSelector(col)}"]`);
if (div) {
const textInput = div.querySelector("input");
clearSelected(tableNode, null);
selectedCell = headerSelected(col);
div.classList.add("selected");
textInput.value = col;
textInput.focus();
}
}
function handleCellClicked(tableNode, col, row) {
const div = tableNode.querySelector(`div[data-pos="${cellSelector(col, row)}"]`);
if (div) {
const textInput = div.querySelector("input");
const textDiv = div.querySelector("div.text-div");
clearSelected(tableNode, null);
selectedCell = cellSelected(col, row);
div.classList.add("selected");
textInput.value = textDiv.textContent;
textInput.focus();
}
}
function clearSelected(root, onlyThis) {
if (onlyThis === null || selectedEqual(onlyThis, selectedCell)) {
let oldSelected = root.querySelector(".selected");
if (oldSelected) {
oldSelected.classList.remove("selected");
}
selectedCell = {};
}
}
function headerSelector(col) {
return `header=${col}`;
}
function cellSelector(col, row) {
return `cell=${col}-${row}`;
}
function headerSelected(col) {
return {header: col};
}
function cellSelected(col, row) {
return {col: col, row: `${row}`};
}
function selectedEqual(s1, s2) {
if (s1["header"] != null && s2["header"] != null) {
return s1["header"] == s2["header"];
} else if (s1["col"] != null && s2["col"] != null) {
return s1["col"] == s2["col"] && s1["row"] == s2["row"];
} else {
return false;
}
}