// 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 = ''; 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 ? '' : ''; return `${emptyExp}${cells}${actionsCell(row, true)}` + 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) ? `${cfg.rowExpansion(row)}` : ''; const keys = hasExpansion ? ` tabindex="0" aria-expanded="${!!expansion}"` : ''; return `${expCell(!!expansion)}${cfg.columns.map(c => cell(c, row)).join('')}${actionsCell(row, false)}` + 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 ``; 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) => ``).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 }; }