app
Component Widgets
Public Made by Adomby adom
Reusable component widgets for pinouts, thermal vias, molecule relationships, reviewed chip outlines and lightweight four-level LOD cache listings.
master
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709
/*! adom-table -- bundled: adom-widget runtime + the widget.
* The wiki's widget frame loads ONE script and provides no runtime, so a registered
* script must be self-contained. Every other registered widget is. Shipping the widget
* alone made <adom-table> render nothing at all on a real page.
*/
/*!
* adom-widget - the runtime every Adom widget is built on.
*
* A widget is an embeddable component that renders on a page. It has two modes:
*
* front-end mode the output is a pure function of data already on the page.
* No server. Cannot go stale, cannot break, works offline.
* back-end mode the widget calls an app's back-end, because the work is
* something a browser cannot do: run a binary, hold shared
* consensus, or answer a question whose answer changes
* without the page changing.
*
* Choose front-end mode unless one of those three is true. That is the whole
* design rule, and this runtime makes front-end mode the path of least effort.
*
* Zero dependencies. Never bundles a framework. Works in three hosts:
* a wiki readme frame, a wiki section frame, and a standalone page or
* dashboard. Host-specific mechanics are detected, never assumed, so a widget
* written for a wiki page also runs inside adom-lbr or a dock dashboard.
*/
(function (global) {
'use strict';
var VERSION = '1.0.0';
/* ---------------------------------------------------------------- host --
* Detected, not configured. A widget must never ask which host it is in
* to decide what it renders; it may ask to decide how it talks outward.
*/
var host = (function () {
var framed = false;
try { framed = global.parent && global.parent !== global; } catch (_) { framed = true; }
// Inline in a wiki README the document is the wiki page, which is itself framed in a Hydrogen webview:
// that parent is not this widget's host. The sandboxed widget frame is /OWNER/SLUG/widget/NAME.
var wikiInline = !/^\/[^/]+\/[^/]+\/widget\/[^/]+\/?$/.test(location.pathname) && !!document.querySelector('.adom-widget[data-widget]');
if (wikiInline) framed = false;
// An opaque-origin frame (the readme sandbox) throws on storage access.
var opaque = false;
try { global.localStorage.getItem('_aw'); } catch (_) { opaque = true; }
return {
framed: !!framed,
opaque: opaque,
// The readme frame is sandboxed to an opaque origin; a section frame is not.
kind: !framed ? 'standalone' : (opaque ? 'readme' : 'framed')
};
}());
/* Storage that degrades instead of throwing. localStorage is unavailable at
* the readme frame's opaque origin, and a bare access there is a hard throw
* that kills widget startup before anything renders. */
var store = (function () {
var mem = {};
var real = null;
try {
global.localStorage.setItem('_aw', '1');
global.localStorage.removeItem('_aw');
real = global.localStorage;
} catch (_) { real = null; }
return {
persistent: !!real,
get: function (k) { try { return real ? real.getItem(k) : (k in mem ? mem[k] : null); } catch (_) { return null; } },
set: function (k, v) { try { if (real) real.setItem(k, v); else mem[k] = String(v); } catch (_) { mem[k] = String(v); } },
del: function (k) { try { if (real) real.removeItem(k); else delete mem[k]; } catch (_) { delete mem[k]; } }
};
}());
/* ----------------------------------------------------------- host bridge --
* Height reporting. A framed widget must tell its host how tall it is or it
* renders into a fixed-height box and clips. Every widget shipped before this
* runtime carried a byte-identical copy of this; now none of them do.
*/
var bridge = {
reportHeight: function () {
if (!host.framed) return;
var h = 0;
try { h = document.body ? document.body.scrollHeight : 0; } catch (_) { }
h = Math.max(120, Math.min(16000, h || 200));
try { global.parent.postMessage({ type: 'readme-height', height: h }, '*'); } catch (_) { }
},
// Zoom an image to the host's lightbox. Falls back to a new tab when the
// host offers no lightbox, so this is safe to call from any host.
lightbox: function (src, alt) {
if (host.framed) {
try { global.parent.postMessage({ type: 'readme-lightbox', src: src, alt: alt || '' }, '*'); return; } catch (_) { }
}
try { global.open(src, '_blank', 'noopener'); } catch (_) { }
}
};
// Auto-grow. Observed rather than polled, with a settle timer for late assets.
var autoHeight = (function () {
var armed = false;
return function () {
if (armed || !host.framed) return;
armed = true;
var fire = function () { bridge.reportHeight(); };
if (global.ResizeObserver && document.body) {
try { new global.ResizeObserver(fire).observe(document.body); } catch (_) { }
}
global.addEventListener('load', fire);
[0, 60, 250, 800, 2000].forEach(function (t) { setTimeout(fire, t); });
};
}());
/* ------------------------------------------------------------------ bus --
* The cross-highlight bus. Hovering pin 7 in a pinout lights pad 7 in the
* footprint, pin 7 in the symbol, and lead 7 in the 3D view, even though
* each of those is a separate frame. BroadcastChannel carries it same-origin;
* a parent relay carries it across frames.
*/
var bus = (function () {
// Wire protocol, as deployed by adom-lbr's symbol/footprint/3D viewers:
// { type: 'lbr-hover', pin: <string|null>, mpn: <string>, src: <sender id> }
// `src` is the sender's own id, so a sender ignores its own echo instead of
// ping-ponging. Do not "improve" this shape; other frames already speak it.
var TYPE = 'lbr-hover';
var chan = null;
try { chan = ('BroadcastChannel' in global) ? new global.BroadcastChannel(TYPE) : null; } catch (_) { chan = null; }
var subs = [];
var onMsg = function (ev) {
var d = ev && ev.data;
if (!d || d.type !== TYPE) return;
subs.forEach(function (s) {
if (d.src && d.src === s.id) return; // our own echo
try { s.fn(d); } catch (_) { }
});
};
if (chan) chan.onmessage = onMsg;
global.addEventListener('message', onMsg);
var seq = 0;
return {
newId: function () { return 'w' + (++seq) + Math.random().toString(36).slice(2, 8); },
emit: function (id, detail) {
var msg = Object.assign({ type: TYPE, src: id }, detail || {});
try { if (chan) chan.postMessage(msg); } catch (_) { }
try { if (host.framed) global.parent.postMessage(msg, '*'); } catch (_) { }
},
on: function (id, fn) {
var s = { id: id, fn: fn };
subs.push(s);
return function () { var i = subs.indexOf(s); if (i >= 0) subs.splice(i, 1); };
},
close: function () { try { if (chan) chan.close(); } catch (_) { } }
};
}());
/* -------------------------------------------------------------- backend --
* Back-end mode. The single most confusing failure in this system is a
* blocked fetch: the browser reports a bare TypeError, which reads like the
* server is down when in fact the page never allowed the origin. This turns
* that into the sentence that names the fix.
*/
function Backend(origin) {
// Deliberately does not throw. Constructing this happens at define() time,
// and a throw there kills the whole script before any element upgrades:
// the widget then renders nothing at all, with no clue why. An unusable
// origin is recorded and reported when something actually calls it.
this.origin = String(origin || '').replace(/\/+$/, '') + '/';
this.invalid = (!/^https:\/\//.test(this.origin) &&
!/^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?\//.test(this.origin))
? 'back-end origin must be https (got "' + origin + '"). A widget opened from ' +
'a file:// page has no origin to infer; pass one explicitly.'
: null;
}
Backend.prototype.url = function (path) { return this.origin + String(path || '').replace(/^\/+/, ''); };
Backend.prototype.call = function (path, opts) {
var self = this;
var o = opts || {};
if (this.invalid) return Promise.reject(new Error(this.invalid));
return global.fetch(this.url(path), o).then(function (r) {
if (!r.ok) {
return r.text().then(function (t) {
throw new Error('back-end ' + r.status + ' from ' + self.origin + (t ? ': ' + t.slice(0, 200) : ''));
});
}
var ct = r.headers.get('content-type') || '';
return ct.indexOf('json') >= 0 ? r.json() : r.text();
}).catch(function (err) {
// A CSP block and a dead server both surface as TypeError. Say which.
if (err instanceof TypeError) {
throw new Error(
'Could not reach ' + self.origin + '. If the back-end is up, this page has not ' +
'allowed the origin: add it to widget_connect in page.json.'
);
}
throw err;
});
};
Backend.prototype.health = function () { return this.call('health'); };
/* ----------------------------------------------------------------- data --
* How a widget receives input, in priority order:
* 1. an inline <script type="application/json"> child <- use this on a wiki page
* 2. a src="" URL
* 3. a data-* attribute holding JSON
*
* Inline is first because the readme frame's connect-src is 'self' plus the
* declared back-ends: a widget that fetches its own data from elsewhere is
* blocked, and it is the failure people hit first.
*/
function readData(el) {
// Set imperatively (el.data = {...}). Wins over markup, which is what a
// dashboard host needs: it owns the data and re-feeds the same element.
if (el._props && el._props.data != null) return { data: el._props.data, from: 'property' };
var inline = el.querySelector('script[type="application/json"]');
if (inline && inline.textContent.trim()) {
try { return { data: JSON.parse(inline.textContent), from: 'inline' }; }
catch (e) { return { error: 'inline JSON is malformed: ' + e.message }; }
}
var raw = el.getAttribute('data-json');
if (raw) {
try { return { data: JSON.parse(raw), from: 'attribute' }; }
catch (e) { return { error: 'data-json is malformed: ' + e.message }; }
}
var src = el.getAttribute('src');
if (src) return { src: src, from: 'src' };
return { data: null, from: 'none' };
}
/* ---------------------------------------------------------------- theme --
* One palette, so widgets from different apps sitting on one page look like
* one product. Consumers override by setting these on any ancestor.
*/
var THEME = [
'--aw-bg:#0d1117', '--aw-panel:#161b22', '--aw-line:#30363d',
'--aw-tx:#e6edf3', '--aw-tx2:#9198a1', '--aw-tx3:#6e7681',
'--aw-teal:#00e6dc', '--aw-blue:#58a6ff', '--aw-purple:#bc8cff',
'--aw-red:#f85149', '--aw-green:#3fb950', '--aw-amber:#d29922',
'--aw-radius:10px',
'--aw-font:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif',
'--aw-mono:ui-monospace,SFMono-Regular,"JetBrains Mono",Menlo,monospace'
].join(';');
var BASE_CSS =
':host{display:block;' + THEME + ';font-family:var(--aw-font);color:var(--aw-tx);' +
'box-sizing:border-box;contain:content}' +
'*,*::before,*::after{box-sizing:border-box}' +
'.aw-card{background:var(--aw-panel);border:1px solid var(--aw-line);' +
'border-radius:var(--aw-radius);overflow:hidden}' +
'.aw-state{padding:18px;font-size:13px;line-height:1.5;color:var(--aw-tx2);' +
'display:flex;align-items:center;gap:9px}' +
'.aw-state.err{color:var(--aw-red)}' +
'.aw-spin{width:13px;height:13px;border:2px solid var(--aw-line);' +
'border-top-color:var(--aw-teal);border-radius:50%;animation:aw-sp .7s linear infinite;flex:none}' +
'@keyframes aw-sp{to{transform:rotate(360deg)}}' +
'@media (prefers-reduced-motion:reduce){.aw-spin{animation:none}}';
function esc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
});
}
/* --------------------------------------------------------------- define --
* The whole authoring surface. A widget supplies css + render(), and gets
* shadow DOM, data loading, states, the bus, height reporting, cleanup and
* a re-registration guard for free.
*/
function define(tag, spec) {
if (!/^[a-z][a-z0-9]*(-[a-z0-9]+)+$/.test(tag)) {
throw new Error('adom-widget: "' + tag + '" is not a valid custom element name');
}
if (global.customElements && global.customElements.get(tag)) return global.customElements.get(tag);
var backend = spec.backend ? new Backend(spec.backend.origin || spec.backend) : null;
function Widget() { return Reflect.construct(HTMLElement, [], Widget); }
Widget.prototype = Object.create(HTMLElement.prototype);
Widget.prototype.constructor = Widget;
Object.setPrototypeOf(Widget, HTMLElement);
Object.defineProperty(Widget, 'observedAttributes', {
get: function () { return (spec.attributes || []).concat(['src', 'data-json']); }
});
// Imperative properties. Assigning re-renders, and assigning before the
// element is upgraded still works (the value is replayed on connect).
['data'].concat(spec.props || []).forEach(function (name) {
Object.defineProperty(Widget.prototype, name, {
configurable: true,
get: function () { return this._props ? this._props[name] : undefined; },
set: function (v) {
if (!this._props) this._props = {};
this._props[name] = v;
if (this._aw) this._aw.reload();
}
});
});
Widget.prototype.attributeChangedCallback = function (name, oldV, newV) {
if (this._aw && oldV !== newV) this._aw.reload();
};
Widget.prototype.connectedCallback = function () {
var el = this;
if (el._aw) return;
// A property assigned before the definition loaded sits as an own
// property, shadowing the prototype accessor. Replay it.
['data'].concat(spec.props || []).forEach(function (n) {
if (Object.prototype.hasOwnProperty.call(el, n)) {
var v = el[n];
delete el[n];
el[n] = v;
}
});
var root = el.shadowRoot || el.attachShadow({ mode: 'open' });
var teardown = [];
var ctx = {
version: VERSION,
host: host,
store: store,
bus: (function () {
var id = bus.newId();
return {
id: id,
emit: function (detail) { bus.emit(id, detail); },
on: function (fn) { var off = bus.on(id, fn); teardown.push(off); return off; }
};
}()),
backend: backend,
lightbox: bridge.lightbox,
esc: esc,
attr: function (n, dflt) { var v = el.getAttribute(n); return v == null ? dflt : v; },
// --- back-end mode helpers -------------------------------------
// Run a back-end call behind a button. Disables the button, shows
// progress, renders failure in place, and always re-enables. Every
// back-end widget was hand-rolling this, and the ones that forgot the
// re-enable left a dead button after the first error.
task: function (btn, outEl, fn, opts) {
var o = opts || {};
if (btn) { btn.disabled = true; }
if (outEl) {
outEl.innerHTML = '<div class="aw-state"><div class="aw-spin"></div><div>' +
esc(o.busy || 'Working') + '</div></div>';
}
return Promise.resolve().then(fn).catch(function (e) {
if (outEl) {
outEl.innerHTML = '<div class="aw-state err">' + esc(e.message || String(e)) + '</div>';
}
throw e;
}).then(function (v) {
if (btn) { btn.disabled = false; if (o.again) btn.textContent = o.again; }
return v;
}, function (e) {
if (btn) { btn.disabled = false; if (o.again) btn.textContent = o.again; }
console.error('[' + tag + '] task:', e);
});
},
// A wiki issue link, prefilled. Widgets are how most readers meet a
// part, so they are also where a wrong part gets reported.
reportIssueUrl: function (o) {
o = o || {};
var page = o.page || ctx.attr('page', '');
if (!page) return null;
return 'https://wiki.adom.inc/' + page + '/issues?category=' +
encodeURIComponent(o.category || 'bug-report') +
'&title=' + encodeURIComponent(o.title || '') +
'&body=' + encodeURIComponent(o.body || '');
},
// Hand the reader a file. The readme frame grants allow-downloads but
// blocks navigation, so a data: URL is the only route that works in
// every host. Never a link to another origin.
fileUrl: function (bytesOrB64, mime) {
var b64 = typeof bytesOrB64 === 'string'
? bytesOrB64
: global.btoa(String.fromCharCode.apply(null, new Uint8Array(bytesOrB64)));
return 'data:' + (mime || 'application/octet-stream') + ';base64,' + b64;
},
bytes: function (n) {
if (n == null) return '';
return n < 1024 ? n + ' B' : (n / 1024).toFixed(1) + ' KB';
},
// Ask the host to resize after content changes height (images, 3D, tabs).
resize: bridge.reportHeight,
onTeardown: function (fn) { teardown.push(fn); }
};
var paint = function (bodyHtml) {
root.innerHTML = '<style>' + BASE_CSS + (spec.css || '') + '</style>' + bodyHtml;
bridge.reportHeight();
};
var state = function (msg, isErr) {
paint('<div class="aw-card"><div class="aw-state' + (isErr ? ' err' : '') + '">' +
(isErr ? '' : '<div class="aw-spin"></div>') + '<div>' + esc(msg) + '</div></div></div>');
};
var render = function (data) {
try {
var out = spec.render.call(el, data, ctx);
var done = function (html) {
paint(html == null ? '' : html);
if (spec.mounted) {
try { spec.mounted.call(el, root, data, ctx); }
catch (e) { console.error('[' + tag + '] mounted:', e); }
}
bridge.reportHeight();
};
if (out && typeof out.then === 'function') out.then(done, function (e) { state(e.message || String(e), true); });
else done(out);
} catch (e) {
console.error('[' + tag + '] render:', e);
state(e.message || String(e), true);
}
};
var load = function () {
// A custom element upgrades when its OPENING tag is parsed, so its children do not
// exist yet. An inline <script type="application/json"> child is therefore invisible
// at this moment, readData falls through to src=, and the fetch is committed to before
// the data that was sitting right there ever appears.
//
// That is not theoretical: the wiki's ::tag widget frame emits BOTH an inlined data
// child (so a PRIVATE page's data reaches an opaque-origin frame at all) and the
// author's src=. The src won the race, the fetch 404'd from the opaque origin, and the
// table rendered empty - the exact failure the inlining exists to prevent.
// Measured on the real frame: with src= 0 rows, with the attribute removed 293.
//
// So when the parser is still running and we are about to choose src BECAUSE no inline
// child is visible yet, wait for parsing to finish and look again. Nothing else is
// delayed: data already present still renders immediately.
if (document.readyState === 'loading'
&& el.getAttribute('src')
&& !el.querySelector('script[type="application/json"]')) {
document.addEventListener('DOMContentLoaded', load, { once: true });
return;
}
var d = readData(el);
if (d.error) return state(d.error, true);
if (d.from === 'src') {
state(spec.loadingText || 'Loading');
return global.fetch(d.src)
.then(function (r) {
if (!r.ok) throw new Error('could not load ' + d.src + ' (' + r.status + ')');
return r.json();
})
.then(render)
.catch(function (e) {
var m = (e instanceof TypeError)
? 'Could not load ' + d.src + '. On a wiki page, put the data in an inline ' +
'JSON script tag instead: the frame may only fetch from its own origin.'
: (e.message || String(e));
state(m, true);
});
}
if (d.data == null && spec.requiresData !== false) {
return state(spec.emptyText || 'No data supplied to ' + tag, true);
}
render(d.data);
};
el._aw = {
ctx: ctx, reload: load, state: state, paint: paint,
destroy: function () { teardown.forEach(function (f) { try { f(); } catch (_) { } }); teardown = []; }
};
autoHeight();
// The HTML parser fires connectedCallback on the OPENING tag, so an inline
// <script type="application/json"> child does not exist yet: readData() finds
// nothing and the widget reports "No data supplied" for data sitting right
// there in the markup. Only reachable while the document is still parsing,
// which is why nobody hit it -- the wiki injects widget scripts after parse,
// and test/framed.html happens to put its <script> tags AFTER the element.
// Put them in <head> and every inline-data widget breaks.
//
// Defer to DOMContentLoaded, but ONLY when nothing is resolvable yet, so a
// widget whose data is already in hand still paints at parse time.
if (document.readyState === 'loading' && readData(el).from === 'none') {
document.addEventListener('DOMContentLoaded', function () {
if (el._aw) load();
}, { once: true });
} else {
load();
}
};
Widget.prototype.disconnectedCallback = function () {
if (this._aw) { this._aw.destroy(); this._aw = null; }
};
global.customElements.define(tag, Widget);
return Widget;
}
global.AdomWidget = {
version: VERSION,
define: define,
host: host,
store: store,
bus: bus,
backend: function (origin) { return new Backend(origin); },
lightbox: bridge.lightbox,
reportHeight: bridge.reportHeight,
esc: esc,
theme: THEME
};
}(typeof window !== 'undefined' ? window : this));
/*!
* <adom-table> — render a JSON array as a filterable, sortable table.
*
* Written because the same table has now been hand-built three times as a bespoke section
* frame (adom/stm32f103c8t6's pinout, adom/repo-ownership's ownership list, and the
* footprint variant tables). Each copy re-implements filtering, sorting, height reporting
* and theming, and each one drifts. This is that frame, once, on the runtime.
*
* Data (resolved by the runtime, in its own order of preference):
* 1. an inline <script type="application/json"> child <- preferred on a wiki README,
* because the readme frame is an opaque origin and a fetch from there is blocked
* 2. src="/api/pages/<owner>/<slug>/files/<file>.json" <- works in a SECTION frame
* 3. el.data = [...] <- dashboards / adom-lbr
*
* Accepted shapes:
* [ {col: value, ...}, ... ] columns inferred from the first row
* { columns: [...], rows: [ {...}, ... ] } explicit column order
* { columns: [{key,label,align}], rows: [...] } explicit labels
*
* Attributes:
* columns="a,b,c" restrict and order columns (overrides the data's own order)
* heading="..." caption above the table; {key} interpolates a top-level field of the
* data envelope, so heading="Regenerated {generated}" keeps a freshness
* stamp on the page without the host frame having to fetch the file
* itself just to print one line
* filter show the filter box (default on; filter="off" hides it)
* facet="owner" add a dropdown that filters on one column's distinct values
* sort="col" initial sort column; prefix "-" for descending
* dim="key:value" render rows matching key=value at reduced opacity (e.g. archived)
* colorby="col:field" colour the text (and a leading dot) of `col` using the CSS colour
* carried in each row's `field`. The colour comes from the DATA, not from
* a palette in here, so a page and its other renderings (an svg export,
* say) cannot drift into disagreeing about who is which colour. Only
* #rgb / #rrggbb is accepted; anything else is dropped rather than
* written into a style attribute.
* mark="style:key:re[; ...]" colour cells whose value matches a pattern, e.g.
* mark="warn:rule:PR ONLY|ASK; accent:rule:^merge:"
* styles: warn (red) accent (blue) ok (green). The cell text is still
* HTML-escaped - only a class is added - so this cannot inject markup.
* Added because escaping every cell flattened adom/repo-ownership's
* "PR ONLY" stop-signal into ordinary grey text, which on a guardrail
* page is the one thing the reader must not miss.
*/
(function () {
'use strict';
if (!window.AdomWidget) return;
var esc = window.AdomWidget.esc;
function normalise(data, attrColumns) {
var rows = [], cols = null;
if (Array.isArray(data)) rows = data;
else if (data && Array.isArray(data.rows)) { rows = data.rows; cols = data.columns || null; }
else if (data && typeof data === 'object') {
// a bare object of arrays, e.g. {live:[...], dormant:[...]} - concatenate and tag
var keys = Object.keys(data).filter(function (k) { return Array.isArray(data[k]); });
if (keys.length) {
keys.forEach(function (k) {
data[k].forEach(function (r) { rows.push(Object.assign({ _group: k }, r)); });
});
}
}
if (attrColumns) cols = attrColumns.split(',').map(function (s) { return s.trim(); });
if (!cols) cols = rows.length ? Object.keys(rows[0]).filter(function (k) { return k[0] !== '_'; }) : [];
cols = cols.map(function (c) {
return typeof c === 'string' ? { key: c, label: c.replace(/[_-]/g, ' ').toUpperCase() } : c;
});
return { cols: cols, rows: rows };
}
window.AdomWidget.define('adom-table', {
attributes: ['columns', 'heading', 'filter', 'facet', 'sort', 'dim', 'mark', 'colorby'],
loadingText: 'Loading table',
emptyText: 'No data supplied to <adom-table>',
css: [
'.bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin:0 0 9px}',
'.bar input,.bar select{background:var(--bg,#0d1117);color:inherit;border:1px solid var(--line,#30363d);',
'border-radius:6px;padding:6px 9px;font:inherit}',
'.bar input{min-width:200px}',
'.muted{opacity:.62;font-size:12px}',
'table{border-collapse:collapse;width:100%;font-size:13px}',
'th{text-align:left;font-size:10.5px;letter-spacing:.7px;opacity:.6;padding:6px 8px;',
'border-bottom:1px solid var(--line,#30363d);cursor:pointer;user-select:none;white-space:nowrap}',
'th[aria-sort]:after{content:"";opacity:.8}',
'th[aria-sort="ascending"]:after{content:" \\2191"}',
'th[aria-sort="descending"]:after{content:" \\2193"}',
'td{padding:5px 8px;border-bottom:1px solid var(--line2,#161b22);vertical-align:top}',
'tr.dim td{opacity:.45}',
'td .cdot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:7px;',
'vertical-align:baseline;flex:none}',
'td.mk-warn{color:var(--aw-red,#f85149);font-weight:600}',
'td.mk-accent{color:var(--aw-blue,#58a6ff);font-weight:600}',
'td.mk-ok{color:var(--aw-green,#3fb950);font-weight:600}',
'caption{text-align:left;font-weight:600;padding:0 0 8px}'
].join(''),
render: function (data, ctx) {
var n = normalise(data, this.getAttribute('columns'));
this._n = n;
var heading = this.getAttribute('heading');
// Substitute BEFORE escaping, and only from the data envelope's own top-level scalars,
// so an unknown {token} is left visible as written rather than silently vanishing.
if (heading && data && !Array.isArray(data)) {
heading = heading.replace(/\{(\w+)\}/g, function (m, k) {
var v = data[k];
return (v == null || typeof v === 'object') ? m : String(v);
});
}
var showFilter = this.getAttribute('filter') !== 'off';
var facet = this.getAttribute('facet');
var bar = '';
if (showFilter || facet) {
bar = '<div class=bar>' +
(showFilter ? '<input part=filter type=search placeholder="filter…">' : '') +
(facet ? '<select part=facet><option value="">all ' + esc(facet) + '</option></select>' : '') +
'<span class=muted data-count></span></div>';
}
return bar + '<table>' +
(heading ? '<caption>' + esc(heading) + '</caption>' : '') +
'<thead><tr>' + n.cols.map(function (c) {
return '<th data-k="' + esc(c.key) + '">' + esc(c.label) + '</th>';
}).join('') + '</tr></thead><tbody></tbody></table>';
},
mounted: function (root) {
var el = this, n = el._n, tb = root.querySelector('tbody');
var q = root.querySelector('input'), sel = root.querySelector('select');
var count = root.querySelector('[data-count]');
var dim = (el.getAttribute('dim') || '').split(':');
// A malformed rule is dropped, never thrown: one bad pattern in an attribute must not
// blank the whole table, which is the failure mode a guardrail page can least afford.
// colorby="col:field" - never interpolate a raw cell value into a style attribute;
// only a literal hex colour is allowed through.
var cb = (el.getAttribute('colorby') || '').split(':');
var colorCol = cb.length === 2 ? cb[0] : null, colorField = cb.length === 2 ? cb[1] : null;
function safeColor(v) {
return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(String(v || '')) ? String(v) : null;
}
var marks = (el.getAttribute('mark') || '').split(';').map(function (rule) {
var m = rule.trim().match(/^(warn|accent|ok):([^:]+):([\s\S]+)$/);
if (!m) return null;
try { return { style: m[1], key: m[2], re: new RegExp(m[3]) }; } catch (e) { return null; }
}).filter(Boolean);
var s = (el.getAttribute('sort') || '').replace(/^-/, '');
var dir = (el.getAttribute('sort') || '')[0] === '-' ? -1 : 1;
if (sel) {
var f = el.getAttribute('facet');
var vals = {};
n.rows.forEach(function (r) { if (r[f] != null) vals[r[f]] = 1; });
Object.keys(vals).sort().forEach(function (v) {
var o = document.createElement('option'); o.textContent = v; sel.appendChild(o);
});
}
function draw() {
var term = (q && q.value || '').toLowerCase();
var fv = sel && sel.value;
var f = el.getAttribute('facet');
var out = n.rows.filter(function (r) {
if (fv && String(r[f]) !== fv) return false;
if (!term) return true;
return n.cols.some(function (c) {
return String(r[c.key] == null ? '' : r[c.key]).toLowerCase().indexOf(term) >= 0;
});
});
if (s) out = out.slice().sort(function (a, b) {
var A = a[s], B = b[s];
if (typeof A === 'number' && typeof B === 'number') return (A - B) * dir;
return String(A).localeCompare(String(B)) * dir;
});
tb.innerHTML = out.map(function (r) {
var isDim = dim.length === 2 && String(r[dim[0]]) === dim[1];
return '<tr' + (isDim ? ' class=dim' : '') + '>' + n.cols.map(function (c) {
var v = r[c.key] == null ? '' : r[c.key], cls = '';
for (var i = 0; i < marks.length; i++) {
if (marks[i].key === c.key && marks[i].re.test(String(v))) {
cls = ' class="mk-' + marks[i].style + '"'; break;
}
}
var col = (colorCol && c.key === colorCol) ? safeColor(r[colorField]) : null;
if (col) {
return '<td' + cls + ' style="color:' + col + '">' +
'<span class=cdot style="background:' + col + '"></span>' + esc(v) + '</td>';
}
return '<td' + cls + '>' + esc(v) + '</td>';
}).join('') + '</tr>';
}).join('');
if (count) count.textContent = out.length + ' of ' + n.rows.length;
root.querySelectorAll('th').forEach(function (th) {
if (th.dataset.k === s) th.setAttribute('aria-sort', dir > 0 ? 'ascending' : 'descending');
else th.removeAttribute('aria-sort');
});
}
root.querySelectorAll('th').forEach(function (th) {
th.addEventListener('click', function () {
dir = (s === th.dataset.k) ? -dir : 1; s = th.dataset.k; draw();
});
});
if (q) q.addEventListener('input', draw);
if (sel) sel.addEventListener('change', draw);
draw();
}
});
}());