list: drag-to-resize and drag-to-reorder for columns

Mirrors the grid module's UX in Frappe's desk list view — drag the right
edge of a header to resize, drag the header itself to reorder. Widths
and order persist per doctype in localStorage.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ali 2026-04-20 11:23:48 +00:00
parent fe9dc89eb6
commit 4e038cf050
2 changed files with 584 additions and 1 deletions

View File

@ -249,6 +249,51 @@ input.list-header-checkbox:checked::after {
background: rgba(36, 144, 239, 0.05); background: rgba(36, 144, 239, 0.05);
} }
/* ==========================================================================
List View Column Resize + Reorder
========================================================================== */
.list-row-head .list-row-col {
position: relative;
}
/* Column dividers — hint that headers can be resized / reordered. */
.list-row-head .list-row-col:not(:last-child) {
border-right: 1px solid var(--border-color, #e2e6e9);
}
.list-row-head .jey-col-resize {
position: absolute;
top: 0;
right: -2px;
width: 5px;
height: 100%;
cursor: col-resize;
z-index: 3;
background: transparent;
transition: background 0.15s;
}
.list-row-head .jey-col-resize:hover,
.list-row-head .jey-col-resize:active {
background: var(--primary, #2490ef);
opacity: 0.5;
}
.list-row-head .list-row-col[data-jey-draggable]:not(.list-subject) {
cursor: grab;
}
.list-row-head .list-row-col.jey-drop-target.jey-drop-left {
box-shadow: inset 3px 0 0 var(--primary, #2490ef);
background: rgba(36, 144, 239, 0.05);
}
.list-row-head .list-row-col.jey-drop-target.jey-drop-right {
box-shadow: inset -3px 0 0 var(--primary, #2490ef);
background: rgba(36, 144, 239, 0.05);
}
/* ========================================================================== /* ==========================================================================
Sidebar Full Width button Sidebar Full Width button
========================================================================== */ ========================================================================== */

View File

@ -23,6 +23,7 @@
resizeStyle.id = "jey-col-resize-styles"; resizeStyle.id = "jey-col-resize-styles";
var rules = []; var rules = [];
var earlyGridKeys = []; var earlyGridKeys = [];
var earlyListDoctypes = [];
for (var i = 0; i < localStorage.length; i++) { for (var i = 0; i < localStorage.length; i++) {
var k = localStorage.key(i); var k = localStorage.key(i);
if (k.indexOf("jey-col-widths:") === 0) { if (k.indexOf("jey-col-widths:") === 0) {
@ -37,13 +38,27 @@
); );
}); });
} catch (e) {} } catch (e) {}
} else if (k.indexOf("jey-list-col-widths:") === 0) {
var listDoctype = k.substring(20);
earlyListDoctypes.push(listDoctype);
try {
var lwidths = JSON.parse(localStorage.getItem(k));
Object.keys(lwidths).forEach(function (fn) {
var esc = (window.CSS && CSS.escape) ? CSS.escape(fn) : fn.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
rules.push(
'.list-view[data-jey-list="' + listDoctype + '"] .list-row-col.' + esc +
" { flex: 0 0 " + lwidths[fn] + "px !important; width: " + lwidths[fn] +
"px !important; max-width: none !important; min-width: 0 !important; }"
);
});
} catch (e) {}
} }
} }
resizeStyle.textContent = rules.join("\n"); resizeStyle.textContent = rules.join("\n");
document.head.appendChild(resizeStyle); document.head.appendChild(resizeStyle);
// Set data-jey-grid on grids as soon as they appear so CSS rules apply immediately // Set data-jey-grid on grids as soon as they appear so CSS rules apply immediately
if (earlyGridKeys.length) { if (earlyGridKeys.length || earlyListDoctypes.length) {
var earlyObs = new MutationObserver(function () { var earlyObs = new MutationObserver(function () {
earlyGridKeys.forEach(function (key) { earlyGridKeys.forEach(function (key) {
// key = "Sales Invoice:items" → fieldname = "items" // key = "Sales Invoice:items" → fieldname = "items"
@ -58,6 +73,18 @@
} }
} }
}); });
if (earlyListDoctypes.length) {
// Stamp data-jey-list on any .list-view whose route matches a saved doctype
var route = (window.frappe && frappe.get_route) ? frappe.get_route() : null;
var routeDoctype = (route && route[0] === "List") ? route[1] : null;
if (routeDoctype && earlyListDoctypes.indexOf(routeDoctype) !== -1) {
document.querySelectorAll(".list-view").forEach(function (lv) {
if (lv.dataset.jeyList !== routeDoctype) {
lv.dataset.jeyList = routeDoctype;
}
});
}
}
}); });
earlyObs.observe(document.body, { childList: true, subtree: true }); earlyObs.observe(document.body, { childList: true, subtree: true });
} }
@ -1344,6 +1371,517 @@
dragObserver.observe(document.body, { childList: true, subtree: true }); dragObserver.observe(document.body, { childList: true, subtree: true });
})(); })();
/* ==========================================================================
List View Column Resize + Reorder
Drag the right edge of a list header to resize; drag the header body to
reorder. Widths + order persist in localStorage per doctype:
jey-list-col-widths:<Doctype> { fieldname: pxWidth, ... }
jey-list-col-order:<Doctype> [fieldname, fieldname, ...]
========================================================================== */
(function () {
"use strict";
var MIN_COL_WIDTH = 60;
var WIDTH_PREFIX = "jey-list-col-widths:";
var ORDER_PREFIX = "jey-list-col-order:";
var DRAG_THRESHOLD = 5;
// Classnames that appear on .list-row-col but are NOT fieldnames.
var NON_FIELD_CLASSES = {
"list-row-col": 1, "ellipsis": 1, "hidden-xs": 1, "text-right": 1,
"list-subject": 1, "level": 1, "level-item": 1,
"tag-col": 1, "hide": 1, "bold": 1, "seen": 1, "notseen": 1,
"inner-group-button": 1
};
var styleEl = document.getElementById("jey-col-resize-styles");
// { "Sales Invoice": { customer: 220, ... }, ... }
var allListWidths = {};
// Tracks doctypes already initialised from localStorage during this session
var loaded = {};
function escapeClass(fn) {
return (window.CSS && CSS.escape) ? CSS.escape(fn) : fn.replace(/[^a-zA-Z0-9_-]/g, "\\$&");
}
// Rebuild only the list-scoped portion of the shared stylesheet.
// Grid rules live inside their own <style> section? No — they share this element.
// To avoid trashing grid rules, we keep a dedicated second stylesheet for lists.
var listStyleEl = document.getElementById("jey-list-col-styles");
if (!listStyleEl) {
listStyleEl = document.createElement("style");
listStyleEl.id = "jey-list-col-styles";
document.head.appendChild(listStyleEl);
}
// FOUC-only stylesheet: used before JS has had a chance to stamp inline
// styles on each cell. Targets header cells via their native fieldname
// class (Frappe sets that from the first paint). Runtime width locking
// happens through applyWidthsToCells(), which writes inline !important
// styles by position — that's the one that has to win against Frappe's
// apply_column_widths() and the inherent flex weirdness of .level-left.
function rebuildListStylesheet() {
var rules = [];
Object.keys(allListWidths).forEach(function (doctype) {
var widths = allListWidths[doctype];
Object.keys(widths).forEach(function (fn) {
var w = widths[fn];
var esc = escapeClass(fn);
rules.push(
'.list-view[data-jey-list="' + doctype + '"] .list-row-col.' + esc +
" { flex: 0 0 " + w + "px !important; width: " + w +
"px !important; max-width: none !important; min-width: 0 !important; }"
);
});
});
listStyleEl.textContent = rules.join("\n");
}
function setCellWidthStyle(cell, w) {
cell.style.setProperty("flex", "0 0 " + w + "px", "important");
cell.style.setProperty("width", w + "px", "important");
cell.style.setProperty("max-width", "none", "important");
cell.style.setProperty("min-width", "0", "important");
}
// Freeze EVERY cell (header + data rows) at a fixed width. Saved width
// wins if we have one for the column's fieldname; otherwise we use the
// cell's current rendered width.
//
// Freezing unconditionally — even columns we can't identify a fieldname
// for (Status without a docfield, Button columns, etc.) — is essential:
// otherwise one flex-shrinkable column absorbs all of another column's
// growth and you get "right edge doesn't move, left edge slides left".
function applyWidthsToCells(listview) {
var root = listview.$result && listview.$result[0];
if (!root) return;
var saved = allListWidths[listview.doctype] || {};
var headerCells = root.querySelectorAll(".list-row-head .level-left > .list-row-col");
if (!headerCells.length) return;
// Pass 1: decide each column's target width.
var widths = [];
var fieldOrder = [];
headerCells.forEach(function (col) {
var fn = fieldnameOf(col);
fieldOrder.push(fn);
var w;
if (fn && saved[fn] != null) {
w = saved[fn];
} else {
w = Math.round(col.getBoundingClientRect().width);
}
widths.push(w);
});
// Pass 2: apply. Header first.
headerCells.forEach(function (col, idx) {
if (widths[idx] > 0) setCellWidthStyle(col, widths[idx]);
});
// Then every data row, matched to header by position.
root.querySelectorAll(".list-row:not(.list-row-head) .level-left").forEach(function (rowLeft) {
var cells = rowLeft.querySelectorAll(":scope > .list-row-col");
cells.forEach(function (cell, idx) {
if (widths[idx] > 0) setCellWidthStyle(cell, widths[idx]);
});
});
}
// During drag, only the active column's cells change. Update just those —
// same O(rows) cost as the full apply, but narrower scope.
function applyActiveColumnWidth(listview, fn, w) {
if (!fn) return;
var root = listview.$result && listview.$result[0];
if (!root) return;
var headerCells = root.querySelectorAll(".list-row-head .level-left > .list-row-col");
var activeIdx = -1;
for (var i = 0; i < headerCells.length; i++) {
if (fieldnameOf(headerCells[i]) === fn) { activeIdx = i; break; }
}
if (activeIdx === -1) return;
setCellWidthStyle(headerCells[activeIdx], w);
root.querySelectorAll(".list-row:not(.list-row-head) .level-left").forEach(function (rowLeft) {
var cells = rowLeft.querySelectorAll(":scope > .list-row-col");
if (cells[activeIdx]) setCellWidthStyle(cells[activeIdx], w);
});
}
function loadWidths(doctype) {
if (loaded[doctype]) return allListWidths[doctype] || null;
loaded[doctype] = true;
try {
var raw = localStorage.getItem(WIDTH_PREFIX + doctype);
if (raw) {
var parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") {
allListWidths[doctype] = parsed;
rebuildListStylesheet();
return parsed;
}
}
} catch (e) {}
return null;
}
function saveWidths(doctype) {
if (!doctype || !allListWidths[doctype]) return;
localStorage.setItem(WIDTH_PREFIX + doctype, JSON.stringify(allListWidths[doctype]));
}
function loadOrder(doctype) {
try {
var raw = localStorage.getItem(ORDER_PREFIX + doctype);
if (raw) {
var parsed = JSON.parse(raw);
if (Array.isArray(parsed)) return parsed;
}
} catch (e) {}
return null;
}
function saveOrder(doctype, order) {
localStorage.setItem(ORDER_PREFIX + doctype, JSON.stringify(order));
}
function fieldnameOf(colEl) {
// Prefer the stamped attribute (covers Status/Tag/etc cells that
// ship without a fieldname class).
if (colEl.dataset && colEl.dataset.jeyField) return colEl.dataset.jeyField;
// Headers always carry <span data-sort-by="<fieldname>">.
var sortSpan = colEl.querySelector && colEl.querySelector("[data-sort-by]");
if (sortSpan) {
var fn = sortSpan.getAttribute("data-sort-by");
if (fn) return fn;
}
// Fallback: first non-structural class.
var classes = (colEl.className || "").split(/\s+/);
for (var i = 0; i < classes.length; i++) {
var c = classes[i];
if (c && !NON_FIELD_CLASSES[c] && c.indexOf("jey-") !== 0) return c;
}
return null;
}
// Stamp data-jey-field on every column cell (header + data rows) so the
// rest of the module can target columns uniformly. Frappe ships some
// built-in columns (Tag, Status, Button, …) without a fieldname class
// or a data-sort-by span — we assign a synthetic position-based key
// so those still participate in resize and reorder.
function stampFieldAttributes(listview) {
var $result = listview.$result;
if (!$result || !$result.length) return;
var root = $result[0];
var headerLeft = root.querySelector(".list-row-head .level-left");
if (!headerLeft) return;
var headerCells = headerLeft.querySelectorAll(":scope > .list-row-col");
var fieldOrder = [];
headerCells.forEach(function (col, idx) {
var fn = fieldnameOf(col);
if (!fn) fn = "__jey_col_" + idx + "__";
col.dataset.jeyField = fn;
fieldOrder.push(fn);
});
root.querySelectorAll(".list-row:not(.list-row-head) .level-left").forEach(function (rowLeft) {
var cells = rowLeft.querySelectorAll(":scope > .list-row-col");
cells.forEach(function (cell, idx) {
var fn = fieldOrder[idx];
if (fn) cell.dataset.jeyField = fn;
});
});
}
function isSubject(colEl) {
return colEl.classList.contains("list-subject");
}
function getListContainer(listview) {
var root = listview && listview.$result ? listview.$result[0] : null;
if (!root) return null;
return root.closest(".list-view");
}
function applyOrder(listview) {
var doctype = listview.doctype;
var order = loadOrder(doctype);
if (!order || !order.length) return;
var $result = listview.$result;
if (!$result || !$result.length) return;
// Header first, then every data row.
var groups = [];
var headerLeft = $result[0].querySelector(".list-row-head .level-left");
if (headerLeft) groups.push(headerLeft);
$result[0].querySelectorAll(".list-row:not(.list-row-head) .level-left").forEach(function (g) {
groups.push(g);
});
groups.forEach(function (group) {
var byField = {};
group.querySelectorAll(":scope > .list-row-col").forEach(function (col) {
var fn = fieldnameOf(col);
if (fn) byField[fn] = col;
});
// Append in saved order; subject column stays where it is (skipped).
order.forEach(function (fn) {
var node = byField[fn];
if (node && !isSubject(node)) group.appendChild(node);
});
});
}
function buildCurrentOrder(listview) {
var $result = listview.$result;
if (!$result || !$result.length) return [];
var header = $result[0].querySelector(".list-row-head .level-left");
if (!header) return [];
var order = [];
header.querySelectorAll(":scope > .list-row-col").forEach(function (col) {
if (isSubject(col)) return;
var fn = fieldnameOf(col);
if (fn) order.push(fn);
});
return order;
}
function injectResizeHandles(listview) {
var $result = listview.$result;
if (!$result || !$result.length) return;
var headerCols = $result[0].querySelectorAll(".list-row-head .level-left > .list-row-col");
var doctype = listview.doctype;
headerCols.forEach(function (col) {
// Subject gets a resize handle too (right edge, well away from
// the checkbox on the left) — its content often outruns Frappe's
// auto-calculated width and users need to widen it.
if (col.querySelector(".jey-col-resize")) return;
var handle = document.createElement("div");
handle.className = "jey-col-resize";
col.appendChild(handle);
handle.addEventListener("mousedown", function (e) {
e.preventDefault();
e.stopPropagation();
startResize(listview, col, e.clientX);
});
handle.addEventListener("dblclick", function (e) {
e.preventDefault();
e.stopPropagation();
autoFit(listview, col);
});
});
}
function autoFit(listview, headerCol) {
var doctype = listview.doctype;
var fn = fieldnameOf(headerCol);
if (!fn) return;
var label = headerCol.querySelector("span, a, .list-subject");
var text = label ? label.textContent.trim() : fn;
var canvas = document.createElement("canvas");
var ctx = canvas.getContext("2d");
var style = getComputedStyle(headerCol);
ctx.font = style.fontWeight + " " + style.fontSize + " " + style.fontFamily;
var w = Math.max(MIN_COL_WIDTH, Math.ceil(ctx.measureText(text).width) + 40);
if (!allListWidths[doctype]) allListWidths[doctype] = {};
allListWidths[doctype][fn] = w;
applyActiveColumnWidth(listview, fn, w);
rebuildListStylesheet();
saveWidths(doctype);
}
function startResize(listview, headerCol, startX) {
var doctype = listview.doctype;
var fn = fieldnameOf(headerCol);
if (!fn) return;
var startWidth = headerCol.getBoundingClientRect().width;
if (!allListWidths[doctype]) allListWidths[doctype] = {};
var widths = allListWidths[doctype];
// Freeze every header column at its current rendered width so only the
// dragged one moves. Includes .list-subject — it has flex-shrink:1
// from list.scss and would otherwise compress while another column
// grows, visually anchoring the dragged column's right edge.
var cols = listview.$result[0].querySelectorAll(".list-row-head .level-left > .list-row-col");
cols.forEach(function (c) {
var f = fieldnameOf(c);
if (f && !widths[f]) {
widths[f] = Math.round(c.getBoundingClientRect().width);
}
});
applyWidthsToCells(listview);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
function onMove(e) {
var w = Math.max(MIN_COL_WIDTH, Math.round(startWidth + (e.clientX - startX)));
widths[fn] = w;
applyActiveColumnWidth(listview, fn, w);
}
function onUp() {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
rebuildListStylesheet();
saveWidths(doctype);
}
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
}
/* -------- Drag to reorder -------- */
var dragState = null;
function bindReorder(listview) {
var $result = listview.$result;
if (!$result || !$result.length) return;
var headerCols = $result[0].querySelectorAll(".list-row-head .level-left > .list-row-col");
headerCols.forEach(function (col) {
if (isSubject(col)) return;
if (col.dataset.jeyDraggable) return;
col.dataset.jeyDraggable = "1";
col.addEventListener("mousedown", function (e) {
if (e.target.classList.contains("jey-col-resize")) return;
if (e.button !== 0) return;
var fn = fieldnameOf(col);
if (!fn) return;
dragState = {
listview: listview,
fromField: fn,
startX: e.clientX,
startY: e.clientY,
dragging: false,
toField: null
};
});
});
}
document.addEventListener("mousemove", function (e) {
if (!dragState) return;
if (!dragState.dragging) {
var dx = Math.abs(e.clientX - dragState.startX);
var dy = Math.abs(e.clientY - dragState.startY);
if (dx < DRAG_THRESHOLD && dy < DRAG_THRESHOLD) return;
dragState.dragging = true;
document.body.style.cursor = "grabbing";
document.body.style.userSelect = "none";
}
var root = dragState.listview.$result[0];
root.querySelectorAll(".list-row-head .jey-drop-target").forEach(function (c) {
c.classList.remove("jey-drop-target", "jey-drop-left", "jey-drop-right");
});
var target = document.elementFromPoint(e.clientX, e.clientY);
if (!target) return;
var col = target.closest(".list-row-head .level-left > .list-row-col");
if (!col || !root.contains(col) || isSubject(col)) {
dragState.toField = null;
return;
}
var fn = fieldnameOf(col);
if (!fn || fn === dragState.fromField) {
dragState.toField = null;
return;
}
var headerCols = Array.from(root.querySelectorAll(".list-row-head .level-left > .list-row-col"));
var fi = headerCols.findIndex(function (c) { return fieldnameOf(c) === dragState.fromField; });
var ti = headerCols.indexOf(col);
col.classList.add("jey-drop-target");
col.classList.toggle("jey-drop-right", fi < ti);
col.classList.toggle("jey-drop-left", fi >= ti);
dragState.toField = fn;
});
document.addEventListener("mouseup", function () {
if (!dragState) return;
var state = dragState;
dragState = null;
document.body.style.cursor = "";
document.body.style.userSelect = "";
var root = state.listview.$result ? state.listview.$result[0] : null;
if (root) {
root.querySelectorAll(".list-row-head .jey-drop-target").forEach(function (c) {
c.classList.remove("jey-drop-target", "jey-drop-left", "jey-drop-right");
});
}
if (!state.dragging || !state.toField) return;
var order = buildCurrentOrder(state.listview);
var fromIdx = order.indexOf(state.fromField);
var toIdx = order.indexOf(state.toField);
if (fromIdx === -1 || toIdx === -1 || fromIdx === toIdx) return;
var draggingRight = fromIdx < toIdx;
order.splice(fromIdx, 1);
var newToIdx = order.indexOf(state.toField);
if (newToIdx === -1) return;
order.splice(draggingRight ? newToIdx + 1 : newToIdx, 0, state.fromField);
saveOrder(state.listview.doctype, order);
applyOrder(state.listview);
});
/* -------- Wire into ListView render pipeline -------- */
function onListRender(listview) {
if (!listview || listview.view_name !== "List") return;
var container = getListContainer(listview);
if (container) container.dataset.jeyList = listview.doctype;
loadWidths(listview.doctype);
stampFieldAttributes(listview);
applyOrder(listview);
applyWidthsToCells(listview);
injectResizeHandles(listview);
bindReorder(listview);
}
function wrapRenderList() {
if (!window.frappe || !frappe.views || !frappe.views.ListView) return false;
var proto = frappe.views.ListView.prototype;
if (proto.__jeyRenderListWrapped) return true;
proto.__jeyRenderListWrapped = true;
var originalRender = proto.render_list;
proto.render_list = function () {
var result = originalRender.apply(this, arguments);
try { onListRender(this); } catch (e) { /* noop */ }
return result;
};
return true;
}
// ListView is only loaded when the user first opens a list — poll briefly.
if (!wrapRenderList()) {
var attempts = 0;
var iv = setInterval(function () {
attempts++;
if (wrapRenderList() || attempts > 100) clearInterval(iv);
}, 200);
}
})();
/* ========================================================================== /* ==========================================================================
List View Override SVG sprite icons List View Override SVG sprite icons
Replaces <symbol> content in Frappe's SVG sprite sheets so every Replaces <symbol> content in Frappe's SVG sprite sheets so every