Versie 0.2.0

This commit is contained in:
Atreides Assets committed 2026-10-29 11:57:29 +01:00
commit e77d8fd373
495 files changed
+81026

No files matched your search

+542
View File
@@ -0,0 +1,542 @@
// 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 };
}