UNPKG

tablefilter

Version:

A Javascript library making HTML tables filterable and a bit more

392 lines (365 loc) • 15 kB
import {TableFilter} from '../tablefilter.js'; import {isSkippedAttribute} from './attributes.js'; /** * `<table-filter>` custom element: declarative shell around the legacy * TableFilter engine (ROADMAP: preliminary-and-milestone-0.8.0-plan.md, * "Component 1"). * * Behaviour arrives incrementally: table discovery (#899), engine init * (#900), declarative configuration (#901), teardown (#902), event * bridging (#903) and runtime re-init on table/attribute changes (#985) * are done. * * Importing this module registers the element. It is deliberately not * imported by src/tablefilter.js yet: that entry feeds the legacy ES5 * webpack bundle, where Babel rewrites `extends HTMLElement` through * `_wrapNativeSuper`, whose `Function.prototype.toString` probe throws * in PhantomJS (2.1.1) and aborts the whole bundle. The declarative * demo page (#904) therefore loads this module through the Vite dev * server (`demos/table-filter.html`), and wiring the element into a * shipped entry belongs with the namespace migration (#965). The * reverse direction used here (the element importing the engine) does * not pull the element into that bundle. * * Events: engine `emitter` events (see `BRIDGED_EVENTS`) are * re-dispatched as bubbling DOM `CustomEvent`s on this element, one * event per engine event, with `detail: { tf, args }` (`tf` is the * engine instance, `args` the array of engine arguments after it). * Listeners are registered before `init()` so init-time events are * bridged too, and removed on disconnect so nothing leaks (#902). * Re-initialising (#985) tears the old listeners down and bridges the * fresh engine, so events keep flowing after an observed change. */ // Engine events re-dispatched as DOM CustomEvents. Keep in sync with the // `emitter.emit()` calls in src/tablefilter.js. // TODO(#989): use the shared event-name constants once the engine // exposes them, instead of duplicating the literals here. const BRIDGED_EVENTS = [ 'before-filter-init', 'after-filter-init', 'initialized', 'filters-row-inserted', 'filter-focus', 'filter-blur', 'before-filtering', 'after-filtering', 'before-clearing-filters', 'after-clearing-filters', 'destroy' ]; // `<table-filter>`-owned attributes that name structure (the `for` table // reference and the `config` JSON blob) rather than an engine option, so // they never map to config keys. const STRUCTURAL_ATTRIBUTES = ['for', 'config']; export class TableFilterElement extends HTMLElement { constructor() { super(); this._connected = false; this._emptyTableObserver = null; this._tableArrivalObserver = null; this._attrObserver = null; this._hostObserver = null; } connectedCallback() { this._connected = true; // Observe the host's attributes so runtime configuration changes // (`config`, `watermark`, `for`, …) are picked up (#985). // `observedAttributes`/`attributeChangedCallback` only support a // fixed list while the engine's option set is open-ended, so the // whole attribute surface is watched and non-configuration // attributes are filtered out. Started before `_init()` so a // change made during init is not missed. this._observeAttributes(); this._init(); } disconnectedCallback() { this._connected = false; this._teardown(); if (this._attrObserver) { this._attrObserver.disconnect(); this._attrObserver = null; } } /** * (Re)initialise the engine from the current markup: resolve the * table, map the configuration and init a fresh engine. Runs the * teardown first, so it is safe to call repeatedly — connect, table * arrival, and an empty table acquiring rows funnel through here. */ _init() { this._teardown(); this.table = this._resolveTable(); if (!this.table) { // Neither a descendant table nor a `for` target resolved yet. // Either may still be parsed/appended later; watch the root // so it is picked up without a manual re-connect. this._observeTableArrival(); return; } // An empty table cannot back a TableFilter instance (its // constructor requires at least 1 row). Leave it uninitialised // and watch the table so rows arriving later initialise it. if (this.table.rows.length === 0) { this.tf = null; this._observeEmptyTable(); return; } const config = this._extractConfig(); // An empty config must not reach the constructor: it flips the // engine's `hasConfig` flag and changes which features get // instantiated (see instantiateFeatures in src/tablefilter.js). this.tf = Object.keys(config).length === 0 ? new TableFilter(this.table) : new TableFilter(this.table, config); // Bridge before init(): `before-filter-init`, `after-filter-init`, // `filters-row-inserted` and `initialized` are emitted during // init(), so a bridge installed afterwards would miss them. this._bridgeEvents(); this.tf.init(); } /** * Resolve the table backing the engine. A descendant light-DOM * `<table>` wins over an external table referenced by id through the * `for` attribute; null when neither resolves. * @return {HTMLTableElement} Resolved table or null */ _resolveTable() { return this.querySelector('table') || (this.hasAttribute('for') ? document.getElementById(this.getAttribute('for')) : null); } /** * Release the current engine and its table/`for` observers. Owned by * teardown (#902) so a re-init or a disconnect never leaves an * observer watching a stale table. Safe to run with no live engine * (empty or missing table). */ _teardown() { this._unobserveEmptyTable(); this._unobserveTableArrival(); if (this.tf && this.tf.isInitialized()) { this.tf.destroy(); } this._unbridgeEvents(); this.tf = null; } /** * Watch for rows arriving in an otherwise-empty backing table. * Only runs when the table is resolved but has 0 rows; as soon as * `rows.length > 0` we init and disconnect so engine churn is not * observed (#985, scoped to the empty-table case). */ _observeEmptyTable() { if (!this.table || this.table.rows.length > 0) { return; } this._emptyTableObserver = new MutationObserver(() => { if (!this._connected || !this.table) { return; } if (this.table.rows.length > 0) { this._unobserveEmptyTable(); this._init(); } }); this._emptyTableObserver.observe(this.table, { childList: true, subtree: true }); // Frameworks may replace the table node (remove + append) rather // than mutating its rows; observe the host so replacement is // picked up even when the table node itself changes (#985). this._hostObserver = new MutationObserver(() => { if (!this._connected || (this.table && this.table.isConnected)) { return; } this._unobserveEmptyTable(); this._init(); }); this._hostObserver.observe(this, { childList: true }); } _unobserveEmptyTable() { if (this._emptyTableObserver) { this._emptyTableObserver.disconnect(); this._emptyTableObserver = null; } if (this._hostObserver) { this._hostObserver.disconnect(); this._hostObserver = null; } } /** * Watch the document (or the enclosing shadow root) for a table that * was not resolvable at connect time — a descendant `<table>` or a * `for` target — and re-init as soon as it appears. Only active while * no table is resolved, so the document-wide observer is * short-lived (#985). */ _observeTableArrival() { const root = this.getRootNode(); if (!root || typeof MutationObserver === 'undefined') { return; } this._tableArrivalObserver = new MutationObserver(() => { if (!this._connected || !this._resolveTable()) { return; } this._unobserveTableArrival(); this._init(); }); this._tableArrivalObserver.observe(root, { childList: true, subtree: true }); } /** * Stop watching for a table to appear. Safe when no observer is * active. */ _unobserveTableArrival() { if (this._tableArrivalObserver) { this._tableArrivalObserver.disconnect(); this._tableArrivalObserver = null; } } /** * Watch the host's attributes and re-init on any configuration * change (anything `isSkippedAttribute()` does not skip), so * restyling the host with a presentation attribute does not rebuild * the engine (#985). */ _observeAttributes() { if (this._attrObserver) { this._attrObserver.disconnect(); } this._attrObserver = new MutationObserver((records) => { if (!this._connected) { return; } // Anything that is not a presentation/noise attribute is a // configuration change worth re-initialising for — including // the structural `config` and `for` attributes. const relevant = records.some((record) => !isSkippedAttribute(record.attributeName)); if (relevant) { this._init(); } }); this._attrObserver.observe(this, { attributes: true }); } /** * Listen to the engine events in `BRIDGED_EVENTS` and re-dispatch * each as a bubbling DOM `CustomEvent` of the same name on this * element, with `detail: { tf, args }`: * - `tf` is the engine instance * - `args` is an array of the engine's remaining arguments (the * engine emits `emit(name, this, ...)`, so this is usually `[]` * or a single payload such as the filter input for `filter-focus` * or the values array for `after-filtering`) * * Re-entrant: bridging an already-bridged element first removes the * previous listeners, so a second call cannot orphan handlers. */ _bridgeEvents() { this._unbridgeEvents(); const tf = this.tf; this._bridged = {}; this._bridgedTf = tf; BRIDGED_EVENTS.forEach((name) => { const handler = (...args) => { this.dispatchEvent(new CustomEvent(name, { detail: { tf, args: args.slice(1) }, bubbles: true })); }; tf.emitter.on([name], handler); this._bridged[name] = handler; }); } /** * Remove all listeners registered by `_bridgeEvents()`. Safe when * `_bridgeEvents()` was never run or was already unregistered. * Unsubscribes on the engine captured at bridge time, not on * `this.tf`, so a handler cannot be left behind if `tf` changed. */ _unbridgeEvents() { const tf = this._bridgedTf; if (!tf || !this._bridged) { return; } Object.keys(this._bridged).forEach((name) => { tf.emitter.off([name], this._bridged[name]); }); this._bridged = null; this._bridgedTf = null; } /** * Build the TableFilter configuration from declarative markup: the * optional `config` attribute (a JSON object) merged with every * other attribute, kebab-case names mapped onto the engine's * snake_case options, values parsed as JSON where possible * (`col-types='["string"]'`, `case-sensitive="true"`), boolean * spellings the JSON parser rejects (`True`/`FALSE`) coerced, and * anything else kept as a plain string (`watermark="Search"`). * An empty value means presence, per the HTML boolean attribute * convention: `<table-filter paging>` enables paging. * Individual attributes win over the `config` blob. * * Attribute names are lowercased by the HTML parser, so options * must be written in kebab-case (`case-sensitive`, not * `caseSensitive`). Attributes skipped by `isSkippedAttribute()` are * never mapped: a stray `class` must not count as engine * configuration. Config is re-read on every re-init, including the * runtime attribute changes observed by `_observeAttributes()`. * @return {Object} TableFilter configuration object */ _extractConfig() { let config = {}; /** * Parse an attribute value: JSON when it parses, otherwise the * raw string, with the common boolean spellings coerced. * @param {String} raw Attribute value * @return {*} Parsed value */ const parseValue = (raw) => { if (raw === '') { return true; } try { return JSON.parse(raw); } catch (e) { if (/^true$/i.test(raw)) { return true; } if (/^false$/i.test(raw)) { return false; } return raw; } }; const raw = this.getAttribute('config'); if (raw !== null) { try { const parsed = JSON.parse(raw); if (parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)) { config = parsed; } else { console.warn('<table-filter>: config attribute must ' + 'be a JSON object, ignoring: ' + raw); } } catch (e) { console.warn('<table-filter>: invalid config attribute, ' + 'ignoring: ' + raw); } } Array.prototype.forEach.call(this.attributes, (attr) => { if (isSkippedAttribute(attr.name, STRUCTURAL_ATTRIBUTES)) { return; } config[attr.name.replace(/-/g, '_')] = parseValue(attr.value); }); return config; } } // Register once: loading this module twice (or on a page that already // defined the element) must not throw. if (typeof customElements !== 'undefined' && !customElements.get('table-filter')) { customElements.define('table-filter', TableFilterElement); }