542 lines
22 KiB
JavaScript
542 lines
22 KiB
JavaScript
// Generated, do not edit. sha256:b90f82710740940a1caabe2a9b3167bc6ea309d53e7f934fd79a4afdb106eabc
|
|||
|
|
import { openPopover, closePopover, isPopoverOpen } from './popover.js';
|
||
|
|
import { isDialogOpen } from './dialog.js';
|
||
|
|
import { create as createFilter } from './filter.js';
|
||
|
|
import { esc } from './escape.js';
|
||
|
|
import { seedSticky, harvestSticky } from './table-sticky.js';
|
||
|
|
import * as tableRows from './table-rows.js';
|
||
|
|
import { initialState, chooseGroup, revealGroup } from './table-state.js';
|
||
|
|
import { groupSections } from './table-groups.js';
|
||
|
|
import { seedValues, loadOptions } from './table-edit-values.js';
|
||
|
|
import { cellHtml, expCellHtml, headRowHtml, controlsHtml, actionsCellHtml, editActionsHtml, groupHeaderRowHtml, frameHtml } from './table-markup.js';
|
||
|
|
import { editCellHtml, expandRowHtml, captureValues } from './table-edit.js';
|
||
|
|
import { captureFocus as captureTableFocus, restoreFocus as restoreTableFocus, focusFirstField as focusFirstEditField } from './table-focus.js';
|
||
|
|
|
||
|
|
const CHEVRON = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="6 9 12 15 18 9"/></svg>';
|
||
|
|
|
||
|
|
const DEFAULT_LABELS = Object.freeze({
|
||
|
|
new: 'New', save: 'Save', cancel: 'Cancel', none: 'Ungrouped', search: 'Search…', count: 'Records', edit: 'Edit',
|
||
|
|
});
|
||
|
|
let labels = { ...DEFAULT_LABELS };
|
||
|
|
let convertHook = null;
|
||
|
|
let repeatKeyOf = () => 'ctrl_enter';
|
||
|
|
let storagePrefix = 'table.';
|
||
|
|
|
||
|
|
export function configureTable({ labels: l = null, convert = null, repeatKey = null, storagePrefix: prefix = null } = {}) {
|
||
|
|
labels = { ...DEFAULT_LABELS, ...(l || {}) };
|
||
|
|
convertHook = convert;
|
||
|
|
repeatKeyOf = typeof repeatKey === 'function' ? repeatKey : () => 'ctrl_enter';
|
||
|
|
storagePrefix = prefix || 'table.';
|
||
|
|
}
|
||
|
|
|
||
|
|
function readState(key) {
|
||
|
|
if (!key) return null;
|
||
|
|
try { return JSON.parse(globalThis.localStorage?.getItem(storagePrefix + key) ?? 'null'); } catch { return null; }
|
||
|
|
}
|
||
|
|
function writeState(key, state) {
|
||
|
|
if (!key) return;
|
||
|
|
try { globalThis.localStorage?.setItem(storagePrefix + key, JSON.stringify(state)); } catch { }
|
||
|
|
}
|
||
|
|
|
||
|
|
const sameId = (a, b) => a != null && b != null && String(a) === String(b);
|
||
|
|
|
||
|
|
const OWN_CLICK = 'button, a, input, select, textarea, label, summary';
|
||
|
|
|
||
|
|
const NEW = Symbol('new');
|
||
|
|
|
||
|
|
export function keyAction({ key, ctrl, tag, isButton, popoverOpen, dialogOpen, repeatKey }) {
|
||
|
|
const overlay = !!popoverOpen || !!dialogOpen;
|
||
|
|
if (key === 'Enter') {
|
||
|
|
if (overlay) return null;
|
||
|
|
if (isButton && !ctrl) return null;
|
||
|
|
if (tag === 'TEXTAREA' && !ctrl) return null;
|
||
|
|
const repeatOnBare = repeatKey === 'enter';
|
||
|
|
if (ctrl) return repeatOnBare ? 'save' : 'saveNext';
|
||
|
|
return repeatOnBare ? 'saveNext' : 'save';
|
||
|
|
}
|
||
|
|
if (key === 'Escape') return overlay ? null : 'cancel';
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
|
||
|
|
export function renderTable(el, cfg) {
|
||
|
|
const idKey = cfg.idKey || 'id';
|
||
|
|
const editableCols = cfg.columns.filter(c => c.edit);
|
||
|
|
const canCreate = () => !!cfg.onCreate
|
||
|
|
&& !(typeof cfg.createHidden === 'function' ? cfg.createHidden() : cfg.createHidden);
|
||
|
|
const hasActions = !!(cfg.onSave || cfg.onDelete || cfg.onCreate || (cfg.extraActions && cfg.extraActions.length));
|
||
|
|
const groupCols = cfg.columns.filter(c => c.group);
|
||
|
|
const hasExpansion = !!cfg.rowExpansion;
|
||
|
|
|
||
|
|
const stored = readState(cfg.stateKey);
|
||
|
|
const storedGroupFits = stored && cfg.columns.some(c => c.key === stored.group && c.group);
|
||
|
|
const start = stored ? {
|
||
|
|
...cfg,
|
||
|
|
sort: stored.sort ?? cfg.sort,
|
||
|
|
defaultSort: cfg.defaultSort ?? cfg.sort,
|
||
|
|
group: stored.group === null ? null : (storedGroupFits ? stored.group : cfg.group),
|
||
|
|
} : cfg;
|
||
|
|
let { sort, groupKey, groupKey2, groupKey3, groupFilter, groupCollapsed, sticky } = initialState(start);
|
||
|
|
const collapsible = !!cfg.collapsibleGroups;
|
||
|
|
let search = '';
|
||
|
|
let altCount = 0;
|
||
|
|
let selectedId = null;
|
||
|
|
let editId = null;
|
||
|
|
let editVals = null;
|
||
|
|
let editError = '';
|
||
|
|
let editTouched = new Set();
|
||
|
|
|
||
|
|
const editing = () => editId !== null;
|
||
|
|
function persistState() {
|
||
|
|
if (cfg.onStateChange) cfg.onStateChange({ group: groupKey, group2: groupKey2, group3: groupKey3, sort, groupFilter, groupCollapsed });
|
||
|
|
writeState(cfg.stateKey, { sort, group: groupKey });
|
||
|
|
}
|
||
|
|
|
||
|
|
const sortRows = rows => tableRows.sortRows(rows, cfg.columns, sort);
|
||
|
|
function hiddenTokens() { return new Set(groupKey ? (groupFilter[groupKey] || []) : []); }
|
||
|
|
function collapsedTokens() { return new Set(groupKey ? (groupCollapsed[groupKey] || []) : []); }
|
||
|
|
function toggleCollapsed(tok) {
|
||
|
|
if (!groupKey) return;
|
||
|
|
groupCollapsed = { ...groupCollapsed, [groupKey]: tableRows.toggleToken(tok, groupCollapsed[groupKey]) };
|
||
|
|
persistState(); draw();
|
||
|
|
}
|
||
|
|
function cycleGroup(tok) {
|
||
|
|
if (!groupKey) return;
|
||
|
|
const { hidden, collapsed } = tableRows.cycleGroupToken(tok, groupFilter[groupKey], groupCollapsed[groupKey]);
|
||
|
|
groupFilter = { ...groupFilter, [groupKey]: hidden };
|
||
|
|
groupCollapsed = { ...groupCollapsed, [groupKey]: collapsed };
|
||
|
|
persistState(); draw();
|
||
|
|
}
|
||
|
|
function distinctGroupVals(col) {
|
||
|
|
const order = (sort && sort.key === groupKey) ? sort.dir : (col.group.order || 'asc');
|
||
|
|
return tableRows.groupValues(cfg.rows, col, order);
|
||
|
|
}
|
||
|
|
function setGroupFilter(hiddenArr) { if (!groupKey) return; groupFilter = { ...groupFilter, [groupKey]: hiddenArr }; persistState(); draw(); }
|
||
|
|
function showGroup(tok) {
|
||
|
|
const r = revealGroup({ groupKey, groupFilter, groupCollapsed }, tok);
|
||
|
|
groupFilter = r.groupFilter;
|
||
|
|
groupCollapsed = r.groupCollapsed;
|
||
|
|
return r.changed;
|
||
|
|
}
|
||
|
|
function setGroupKeys(level, value) {
|
||
|
|
({ groupKey, groupKey2, groupKey3 } = chooseGroup({ groupKey, groupKey2, groupKey3 }, level, value));
|
||
|
|
}
|
||
|
|
function mountGroupFilter() {
|
||
|
|
const fh = el.querySelector('[data-gfilter]');
|
||
|
|
if (!fh) return;
|
||
|
|
const gc = cfg.columns.find(c => c.key === groupKey);
|
||
|
|
const vals = gc ? distinctGroupVals(gc) : [];
|
||
|
|
const hidden = hiddenTokens();
|
||
|
|
createFilter(fh, {
|
||
|
|
items: vals.map(v => ({ id: v.token, label: v.label })),
|
||
|
|
selected: new Set(vals.filter(v => !hidden.has(v.token)).map(v => v.token)),
|
||
|
|
className: 'fbar--tctl',
|
||
|
|
disabled: editing(),
|
||
|
|
collapsible,
|
||
|
|
collapsedIds: collapsible ? collapsedTokens() : null,
|
||
|
|
onCycle: collapsible ? cycleGroup : null,
|
||
|
|
onChange: sel => setGroupFilter(vals.map(v => v.token).filter(tok => !sel.has(tok))),
|
||
|
|
});
|
||
|
|
}
|
||
|
|
const filterRows = rows =>
|
||
|
|
tableRows.filterRows(rows, cfg.columns, { search, groupKey, hidden: hiddenTokens() });
|
||
|
|
|
||
|
|
const seedVals = row => seedValues(editableCols, row, cfg.newDefaults);
|
||
|
|
|
||
|
|
const optionCache = new Map();
|
||
|
|
const loadOptionLists = () => loadOptions(editableCols, editVals, optionCache);
|
||
|
|
|
||
|
|
function optionList(col) {
|
||
|
|
return optionCache.get(col.key) || [];
|
||
|
|
}
|
||
|
|
|
||
|
|
const cell = cellHtml;
|
||
|
|
const convert = convertHook ? { label: cfg.convertLabel || '', canConvert: convertHook.canConvert, icon: convertHook.icon } : null;
|
||
|
|
const inputOpts = col => ({ options: optionList(col), convert });
|
||
|
|
const editCell = col => editCellHtml(col, editVals, inputOpts(col));
|
||
|
|
function actionsCell(row, isEditRow) {
|
||
|
|
if (!hasActions) return '';
|
||
|
|
return actionsCellHtml({ cfg, labels, row, idKey, isEditRow, editing: editing(), selectedId });
|
||
|
|
}
|
||
|
|
const selectable = hasActions || hasExpansion;
|
||
|
|
const isEditRowOf = id => editId !== null && editId !== NEW && sameId(editId, id);
|
||
|
|
|
||
|
|
const colCount = () => cfg.columns.length + (hasActions ? 1 : 0) + (hasExpansion ? 1 : 0);
|
||
|
|
|
||
|
|
const expCell = isOpen => expCellHtml(hasExpansion, isOpen, CHEVRON);
|
||
|
|
const expandRow = () => expandRowHtml({ editableCols, vals: editVals, error: editError, colCount: colCount(), optsFor: inputOpts, actions: editActionsHtml({ cfg, labels }) });
|
||
|
|
|
||
|
|
function editRowHtml(row) {
|
||
|
|
const cells = cfg.columns.map(c => c.edit ? editCell(c) : cell(c, row || emptyRow())).join('');
|
||
|
|
const emptyExp = hasExpansion ? '<td class="exp-cell"></td>' : '';
|
||
|
|
return `<tr class="row-editing" data-edit-row="1">${emptyExp}${cells}${actionsCell(row, true)}</tr>` + expandRow();
|
||
|
|
}
|
||
|
|
|
||
|
|
function emptyRow() { const r = {}; for (const c of cfg.columns) r[c.key] = ''; return r; }
|
||
|
|
|
||
|
|
function dataRow(row) {
|
||
|
|
const id = row[idKey];
|
||
|
|
const alt = (altCount++ % 2 === 1);
|
||
|
|
if (isEditRowOf(id)) return editRowHtml(row);
|
||
|
|
const selected = !editing() && sameId(id, selectedId);
|
||
|
|
const classes = [];
|
||
|
|
if (cfg.rowClass) { const own = cfg.rowClass(row); if (own) classes.push(own); }
|
||
|
|
if (alt) classes.push('row-alt');
|
||
|
|
if (selected) classes.push('row-selected');
|
||
|
|
const selCls = classes.length ? ` class="${esc(classes.join(' '))}"` : '';
|
||
|
|
const expansion = (selected && cfg.rowExpansion)
|
||
|
|
? `<tr class="row-expansion" data-exp-id="${esc(id)}"><td colspan="${colCount()}">${cfg.rowExpansion(row)}</td></tr>` : '';
|
||
|
|
const keys = hasExpansion ? ` tabindex="0" aria-expanded="${!!expansion}"` : '';
|
||
|
|
return `<tr data-row-id="${esc(id)}"${selCls}${keys}>${expCell(!!expansion)}${cfg.columns.map(c => cell(c, row)).join('')}${actionsCell(row, false)}</tr>` + expansion;
|
||
|
|
}
|
||
|
|
|
||
|
|
const wrapTable = bodyHtml => frameHtml({
|
||
|
|
controls: controlsHtml({ cfg, labels, canCreate: canCreate(), groupCols, groupKey, groupKey2, groupKey3, search, editing: editing() }),
|
||
|
|
head: headRowHtml({ columns: cfg.columns, sort, hasExpansion, hasActions }),
|
||
|
|
body: bodyHtml,
|
||
|
|
hasExpansion,
|
||
|
|
title: cfg.title,
|
||
|
|
});
|
||
|
|
|
||
|
|
function bodyContentHtml() {
|
||
|
|
const rows = sortRows(filterRows(cfg.rows));
|
||
|
|
altCount = 0;
|
||
|
|
const newRowHtml = (editId === NEW) ? editRowHtml(null) : '';
|
||
|
|
|
||
|
|
if (rows.length === 0 && editId !== NEW) return `<tr class="tbl-empty-spacer"><td colspan="${colCount()}"></td></tr>`;
|
||
|
|
|
||
|
|
if (!groupKey) return newRowHtml + rows.map(dataRow).join('');
|
||
|
|
|
||
|
|
const sectionList = groupSections(rows, {
|
||
|
|
columns: cfg.columns, groupKey, groupKey2, groupKey3, sort,
|
||
|
|
collapsed: collapsible ? collapsedTokens() : null,
|
||
|
|
});
|
||
|
|
const groupHeaderRow = (token, label, count, summaryHtml, isCollapsed) =>
|
||
|
|
groupHeaderRowHtml({ token, label, count, summaryHtml, isCollapsed, collapsible, colCount: colCount(), chevron: CHEVRON });
|
||
|
|
const sections = sectionList.map(s => {
|
||
|
|
if (s.collapsed) {
|
||
|
|
const summary = cfg.groupSummary ? cfg.groupSummary(s.items, s.value) : '';
|
||
|
|
return groupHeaderRow(s.token, s.label, s.items.length, summary, true);
|
||
|
|
}
|
||
|
|
return groupHeaderRow(s.token, s.label, s.items.length, '', false) + s.items.map(dataRow).join('');
|
||
|
|
}).join('');
|
||
|
|
return newRowHtml + sections;
|
||
|
|
}
|
||
|
|
|
||
|
|
const headObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(entries => {
|
||
|
|
if (!el.isConnected) { headObserver.disconnect(); return; }
|
||
|
|
for (const { target } of entries) {
|
||
|
|
if (!target.isConnected) continue;
|
||
|
|
const wrap = target.closest('.table-wrap');
|
||
|
|
wrap?.style.setProperty('--sticky-row-top', target.offsetHeight + 'px');
|
||
|
|
if (wrap) measurePinnedRow(wrap);
|
||
|
|
}
|
||
|
|
});
|
||
|
|
|
||
|
|
function measurePinnedRow(wrap) {
|
||
|
|
if (!wrap.classList.contains('sticky-sel')) return;
|
||
|
|
const row = [...wrap.querySelectorAll('tr.row-selected')].find(tr => tr.closest('.table-wrap') === wrap);
|
||
|
|
if (!row) return;
|
||
|
|
row.style.setProperty('--row-w', row.offsetWidth + 'px');
|
||
|
|
for (const td of row.children) td.style.setProperty('--cell-x', (td.offsetLeft || 0) + 'px');
|
||
|
|
}
|
||
|
|
|
||
|
|
function draw() {
|
||
|
|
const prev = el.querySelector('.table-wrap');
|
||
|
|
const sTop = prev ? prev.scrollTop : 0;
|
||
|
|
const sLeft = prev ? prev.scrollLeft : 0;
|
||
|
|
const focus = captureFocus();
|
||
|
|
el.innerHTML = wrapTable(bodyContentHtml());
|
||
|
|
bind();
|
||
|
|
mountGroupFilter();
|
||
|
|
const next = el.querySelector('.table-wrap');
|
||
|
|
if (next) {
|
||
|
|
next.scrollTop = sTop; next.scrollLeft = sLeft;
|
||
|
|
const head = next.querySelector('thead');
|
||
|
|
headObserver?.disconnect();
|
||
|
|
if (head) {
|
||
|
|
next.style.setProperty('--sticky-row-top', head.offsetHeight + 'px');
|
||
|
|
headObserver?.observe(head);
|
||
|
|
}
|
||
|
|
measurePinnedRow(next);
|
||
|
|
}
|
||
|
|
titleTruncated();
|
||
|
|
mountDrawer();
|
||
|
|
restoreFocus(focus);
|
||
|
|
cfg.onDraw?.(el);
|
||
|
|
}
|
||
|
|
|
||
|
|
const captureFocus = () => captureTableFocus(el, editing());
|
||
|
|
const restoreFocus = snap => restoreTableFocus(el, snap);
|
||
|
|
|
||
|
|
function mountDrawer() {
|
||
|
|
if (selectedId == null || !cfg.onExpansionMount || !cfg.rowExpansion) return;
|
||
|
|
const tr = [...el.querySelectorAll('tr.row-expansion')].find(t => sameId(t.dataset.expId, selectedId));
|
||
|
|
const host = tr && tr.querySelector('td');
|
||
|
|
if (!host) return;
|
||
|
|
const row = cfg.rows.find(r => sameId(r[idKey], selectedId));
|
||
|
|
if (row) cfg.onExpansionMount(row, host);
|
||
|
|
}
|
||
|
|
|
||
|
|
function titleTruncated() {
|
||
|
|
for (const cel of el.querySelectorAll('.cell-clamp')) {
|
||
|
|
if (cel.scrollHeight > cel.clientHeight + 1) cel.title = cel.textContent.trim();
|
||
|
|
else cel.removeAttribute('title');
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function updateBody() {
|
||
|
|
const tb = el.querySelector('tbody');
|
||
|
|
if (tb) tb.innerHTML = bodyContentHtml();
|
||
|
|
titleTruncated();
|
||
|
|
mountDrawer();
|
||
|
|
cfg.onDraw?.(el);
|
||
|
|
}
|
||
|
|
|
||
|
|
const captureVals = () => captureValues(el.querySelector('tbody') || el, editableCols, editVals);
|
||
|
|
|
||
|
|
async function startEdit(id) {
|
||
|
|
const row = cfg.rows.find(r => sameId(r[idKey], id));
|
||
|
|
editId = row ? row[idKey] : id; editVals = seedVals(row); editError = ''; selectedId = null; editTouched = new Set();
|
||
|
|
await loadOptionLists();
|
||
|
|
draw();
|
||
|
|
focusFirstField();
|
||
|
|
}
|
||
|
|
async function startNew(extra) {
|
||
|
|
editId = NEW; editVals = seedVals(null); if (extra) Object.assign(editVals, extra); editError = ''; selectedId = null; editTouched = new Set();
|
||
|
|
await loadOptionLists();
|
||
|
|
editVals = seedSticky({ columns: editableCols, seeded: editVals, remembered: sticky, options: optionCache });
|
||
|
|
draw();
|
||
|
|
focusFirstField();
|
||
|
|
}
|
||
|
|
|
||
|
|
const focusFirstField = () => focusFirstEditField(el);
|
||
|
|
function cancelEdit() { editId = null; editVals = null; editError = ''; draw(); }
|
||
|
|
|
||
|
|
function rememberSticky(harvested) {
|
||
|
|
sticky = harvested;
|
||
|
|
if (cfg.onStickyChange) cfg.onStickyChange(sticky);
|
||
|
|
}
|
||
|
|
|
||
|
|
let repeatPending = false;
|
||
|
|
|
||
|
|
async function commit(repeat) {
|
||
|
|
captureVals();
|
||
|
|
const wasNew = editId === NEW;
|
||
|
|
const doRepeat = !!repeat && wasNew && !!cfg.onCreate;
|
||
|
|
repeatPending = doRepeat;
|
||
|
|
const harvested = wasNew ? harvestSticky(editableCols, editVals) : null;
|
||
|
|
try {
|
||
|
|
if (wasNew) await cfg.onCreate(editVals);
|
||
|
|
else await cfg.onSave(editId, editVals);
|
||
|
|
if (harvested) rememberSticky(harvested);
|
||
|
|
editId = null; editVals = null; editError = '';
|
||
|
|
repeatPending = false;
|
||
|
|
if (doRepeat && canCreate()) await startNew();
|
||
|
|
else draw();
|
||
|
|
} catch (e) {
|
||
|
|
repeatPending = false;
|
||
|
|
editError = e.message || String(e);
|
||
|
|
draw();
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function openConvert(triggerEl) {
|
||
|
|
const key = triggerEl.dataset.convert;
|
||
|
|
const col = cfg.columns.find(c => c.key === key);
|
||
|
|
if (!col || !col.edit || typeof col.edit.convert !== 'function') return;
|
||
|
|
captureVals();
|
||
|
|
const asset = col.edit.convert(editVals);
|
||
|
|
const input = el.querySelector(`[data-field="${key}"]`);
|
||
|
|
if (!asset || !convertHook || !convertHook.canConvert(asset) || !input) return;
|
||
|
|
if (typeof col.edit.onConvert === 'function') {
|
||
|
|
col.edit.onConvert(triggerEl, {
|
||
|
|
asset, input, vals: editVals,
|
||
|
|
setValue: v => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); },
|
||
|
|
setFields: obj => Object.assign(editVals, obj),
|
||
|
|
});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
convertHook.open(triggerEl, {
|
||
|
|
asset,
|
||
|
|
value: input.value,
|
||
|
|
onApply: v => { input.value = v; input.dispatchEvent(new Event('input', { bubbles: true })); input.focus(); },
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
function openNewMenu(anchor) {
|
||
|
|
const { el: pop, position } = openPopover(anchor, { className: 'popover new-menu' });
|
||
|
|
pop.innerHTML = cfg.newOptions.map((o, i) =>
|
||
|
|
`<button class="btn btn-ghost btn-sm new-menu-item" data-new-opt="${i}">${esc(o.label)}</button>`).join('');
|
||
|
|
position();
|
||
|
|
pop.addEventListener('click', e => {
|
||
|
|
const b = e.target.closest('[data-new-opt]');
|
||
|
|
if (!b) return;
|
||
|
|
const opt = cfg.newOptions[Number(b.dataset.newOpt)];
|
||
|
|
closePopover();
|
||
|
|
startNew(opt.defaults);
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
const idOf = text => { const r = cfg.rows.find(x => sameId(x[idKey], text)); return r ? r[idKey] : text; };
|
||
|
|
|
||
|
|
function sortOn(key) {
|
||
|
|
sort = sort && sort.key === key ? { key, dir: sort.dir === 'asc' ? 'desc' : 'asc' } : { key, dir: 'asc' };
|
||
|
|
persistState(); draw();
|
||
|
|
}
|
||
|
|
|
||
|
|
function toggleRow(idText) {
|
||
|
|
const id = idOf(idText);
|
||
|
|
selectedId = sameId(selectedId, id) ? null : id;
|
||
|
|
draw();
|
||
|
|
}
|
||
|
|
|
||
|
|
function bind() {
|
||
|
|
el.onclick = e => {
|
||
|
|
e.stopPropagation();
|
||
|
|
if (editing()) {
|
||
|
|
if (e.target.closest('[data-save]')) { commit(); return; }
|
||
|
|
if (e.target.closest('[data-cancel]')) { cancelEdit(); return; }
|
||
|
|
const cv = e.target.closest('[data-convert]');
|
||
|
|
if (cv) { openConvert(cv); return; }
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const th = e.target.closest('[data-sort]');
|
||
|
|
if (th) { sortOn(th.dataset.sort); return; }
|
||
|
|
const ed = e.target.closest('[data-edit]');
|
||
|
|
if (ed && cfg.onSave) { startEdit(idOf(ed.dataset.edit)); return; }
|
||
|
|
const dl = e.target.closest('[data-del]');
|
||
|
|
if (dl && cfg.onDelete) { cfg.onDelete(idOf(dl.dataset.del)); return; }
|
||
|
|
const act = e.target.closest('[data-action]');
|
||
|
|
if (act && cfg.onAction) { cfg.onAction(act.dataset.action, idOf(act.dataset.id)); return; }
|
||
|
|
const gt = e.target.closest('[data-gcollapse]');
|
||
|
|
if (gt) { toggleCollapsed(gt.dataset.gcollapse); return; }
|
||
|
|
const row = e.target.closest('tr[data-row-id]');
|
||
|
|
if (!row || !selectable) return;
|
||
|
|
const own = e.target.closest(OWN_CLICK);
|
||
|
|
if (own && row.contains(own) && !own.classList.contains('chev-btn')) return;
|
||
|
|
toggleRow(row.dataset.rowId);
|
||
|
|
};
|
||
|
|
|
||
|
|
const newBtn = el.querySelector('[data-tbl-new]');
|
||
|
|
if (newBtn) newBtn.onclick = () => {
|
||
|
|
if (editing()) return;
|
||
|
|
if (cfg.newOptions && cfg.newOptions.length) openNewMenu(newBtn);
|
||
|
|
else startNew();
|
||
|
|
};
|
||
|
|
for (const b of el.querySelectorAll('[data-tbl-act]')) {
|
||
|
|
if (!el.contains(b) || b.closest('.table-frame')?.parentElement !== el) continue;
|
||
|
|
const a = (cfg.toolbarActions || []).find(x => String(x.id) === b.dataset.tblAct);
|
||
|
|
if (a) b.onclick = () => { if (!editing() && cfg.onToolbarAction) cfg.onToolbarAction(a.id, b); };
|
||
|
|
}
|
||
|
|
|
||
|
|
const searchEl = el.querySelector('[data-tbl-search]');
|
||
|
|
if (searchEl) searchEl.oninput = () => { search = searchEl.value; updateBody(); };
|
||
|
|
el.oninput = e => {
|
||
|
|
const field = e.target && e.target.dataset && e.target.dataset.field;
|
||
|
|
if (editing() && field) editTouched.add(field);
|
||
|
|
};
|
||
|
|
|
||
|
|
el.onkeydown = e => {
|
||
|
|
if (!editing()) {
|
||
|
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||
|
|
const th = e.target.closest?.('th[data-sort]');
|
||
|
|
if (th) { e.preventDefault(); e.stopPropagation(); sortOn(th.dataset.sort); return; }
|
||
|
|
if (hasExpansion && e.target.matches?.('tr[data-row-id]')) {
|
||
|
|
e.preventDefault(); e.stopPropagation(); toggleRow(e.target.dataset.rowId);
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
const action = keyAction({
|
||
|
|
key: e.key,
|
||
|
|
ctrl: e.ctrlKey || e.metaKey,
|
||
|
|
tag: e.target.tagName,
|
||
|
|
isButton: !!e.target.closest('button'),
|
||
|
|
popoverOpen: isPopoverOpen(),
|
||
|
|
dialogOpen: isDialogOpen(e.target),
|
||
|
|
repeatKey: repeatKeyOf(),
|
||
|
|
});
|
||
|
|
if (!action) return;
|
||
|
|
e.preventDefault();
|
||
|
|
e.stopPropagation();
|
||
|
|
if (action === 'cancel') cancelEdit();
|
||
|
|
else commit(action === 'saveNext');
|
||
|
|
};
|
||
|
|
|
||
|
|
el.onchange = e => {
|
||
|
|
e.stopPropagation();
|
||
|
|
const level = Number(e.target.dataset && e.target.dataset.tblGroup) || 0;
|
||
|
|
if (level && !editing()) { setGroupKeys(level, e.target.value); persistState(); draw(); return; }
|
||
|
|
if (editing() && e.target.matches('[data-reactive]')) {
|
||
|
|
captureVals();
|
||
|
|
if (typeof cfg.onEditChange === 'function') {
|
||
|
|
const patch = cfg.onEditChange({ ...editVals }, e.target.dataset.field, new Set(editTouched));
|
||
|
|
if (patch) Object.assign(editVals, patch);
|
||
|
|
}
|
||
|
|
loadOptionLists().then(draw);
|
||
|
|
}
|
||
|
|
};
|
||
|
|
}
|
||
|
|
|
||
|
|
function clearHighlight() { selectedId = null; editId = null; editVals = null; editError = ''; draw(); }
|
||
|
|
function refresh(rows, opts) {
|
||
|
|
if (rows) cfg.rows = rows;
|
||
|
|
if (selectedId != null && !cfg.rows.some(r => sameId(r[idKey], selectedId))) selectedId = null;
|
||
|
|
if (repeatPending) return;
|
||
|
|
if (opts && opts.locate != null) {
|
||
|
|
search = '';
|
||
|
|
if (groupKey) {
|
||
|
|
const gc = cfg.columns.find(c => c.key === groupKey);
|
||
|
|
const newRow = gc && cfg.rows.find(r => sameId(r[idKey], opts.locate));
|
||
|
|
if (newRow && showGroup(String(gc.group.value(newRow)))) persistState();
|
||
|
|
}
|
||
|
|
selectedId = idOf(opts.locate);
|
||
|
|
editId = null; editVals = null; editError = '';
|
||
|
|
draw();
|
||
|
|
requestAnimationFrame(() => locateRow(opts.locate));
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
draw();
|
||
|
|
}
|
||
|
|
const rowEl = id => [...el.querySelectorAll('tr[data-row-id]')].find(t => sameId(t.dataset.rowId, id));
|
||
|
|
function locateRow(id) {
|
||
|
|
const row = rowEl(id);
|
||
|
|
if (row) row.scrollIntoView({ block: 'center' });
|
||
|
|
}
|
||
|
|
|
||
|
|
function focusGroup(key, predicate) {
|
||
|
|
const col = cfg.columns.find(c => c.key === key && c.group);
|
||
|
|
if (!col) return false;
|
||
|
|
const row = cfg.rows.find(predicate);
|
||
|
|
if (!row) return false;
|
||
|
|
const tok = String(col.group.value(row));
|
||
|
|
setGroupKeys(1, key);
|
||
|
|
showGroup(tok);
|
||
|
|
search = '';
|
||
|
|
selectedId = null; editId = null; editVals = null; editError = '';
|
||
|
|
draw();
|
||
|
|
const headers = [...el.querySelectorAll('[data-gcollapse]')];
|
||
|
|
const header = headers.find(k => k.dataset.gcollapse === tok);
|
||
|
|
const target = header || rowEl(row[idKey]);
|
||
|
|
if (target) requestAnimationFrame(() => target.scrollIntoView({ block: 'center' }));
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function focusRow(id) {
|
||
|
|
if (id == null) return false;
|
||
|
|
const row = cfg.rows.find(r => String(r[idKey]) === String(id));
|
||
|
|
if (!row) return false;
|
||
|
|
if (groupKey) {
|
||
|
|
const gc = cfg.columns.find(c => c.key === groupKey && c.group);
|
||
|
|
if (gc) showGroup(String(gc.group.value(row)));
|
||
|
|
}
|
||
|
|
search = '';
|
||
|
|
selectedId = row[idKey];
|
||
|
|
editId = null; editVals = null; editError = '';
|
||
|
|
draw();
|
||
|
|
requestAnimationFrame(() => locateRow(row[idKey]));
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
draw();
|
||
|
|
return { clearHighlight, refresh, focusGroup, focusRow };
|
||
|
|
}
|