您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Widgets for user interactions.
此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.gf.qytechs.cn/scripts/478676/1337642/NH_widget.js
// ==UserScript== // ==UserLibrary== // @name NH_widget // @description Widgets for user interactions. // @version 45 // @license GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0-standalone.html // @homepageURL https://github.com/nexushoratio/userscripts // @supportURL https://github.com/nexushoratio/userscripts/issues // @match https://www.example.com/* // ==/UserLibrary== // ==/UserScript== window.NexusHoratio ??= {}; window.NexusHoratio.widget = (function widget() { 'use strict'; /** @type {number} - Bumped per release. */ const version = 45; const NH = window.NexusHoratio.base.ensure([ {name: 'xunit', minVersion: 39}, {name: 'base', minVersion: 52}, ]); /** Library specific exception. */ class Exception extends NH.base.Exception {} /** Thrown on verification errors. */ class VerificationError extends Exception {} /** Useful for matching in tests. */ const HEX = '[0-9a-f]'; const GUID = `${HEX}{8}-(${HEX}{4}-){3}${HEX}{12}`; /** @typedef {(string|HTMLElement|Widget)} Content */ /** * Base class for rendering widgets. * * Subclasses should NOT override methods here, except for constructor(). * Instead they should register listeners for appropriate events. * * Generally, methods will fire two event verbs. The first, in present * tense, will instruct what should happen (build, destroy, etc). The * second, in past tense, will describe what should have happened (built, * destroyed, etc). Typically, subclasses will act upon the present tense, * and users of the class may act upon the past tense. * * Methods should generally be able to be chained. * * If a variable holding a widget is set to a new value, the previous widget * should be explicitly destroyed. * * When a Widget is instantiated, it should only create a container of the * requested type (done in this base class). And install any widget styles * it needs in order to function. The container property can then be placed * into the DOM. * * If a Widget needs specific CSS to function, that CSS should be shared * across all instances of the Widget by using the same values in a call to * installStyle(). Anything used for presentation should include the * Widget's id as part of the style's id. * * The build() method will fire 'build'/'built' events. Subclasses then * populate the container with HTML as appropriate. Widgets should * generally be designed to not update the internal HTML until build() is * explicitly called. * * The destroy() method will fire 'destroy'/'destroyed' events and also * clear the innerHTML of the container. Subclasses are responsible for any * internal cleanup, such as nested Widgets. * * The verify() method will fire 'verify'/'verified' events. Subclasses can * handle these to validate any internal structures they need for. For * example, Widgets that have ARIA support can ensure appropriate attributes * are in place. If a Widget fails, it should throw a VerificationError * with details. */ class Widget { /** * Each subclass should take a caller provided name. * @param {string} name - Name for this instance. * @param {string} element - Type of element to use for the container. */ constructor(name, element) { if (new.target === Widget) { throw new TypeError('Abstract class; do not instantiate directly.'); } this.#name = `${this.constructor.name} ${name}`; this.#id = NH.base.uuId(NH.base.safeId(this.name)); this.#container = document.createElement(element); this.#container.id = `${this.id}-container`; this.#dispatcher = new NH.base.Dispatcher(...Widget.#knownEvents); this.#logger = new NH.base.Logger(`${this.constructor.name}`); this.#visible = true; this.installStyle('nh-widget', [`.${Widget.classHidden} {display: none}`]); } /** @type {string} - CSS class applied to hide element. */ static get classHidden() { return 'nh-widget-hidden'; } /** @type {Element} */ get container() { return this.#container; } /** @type {string} */ get id() { return this.#id; } /** @type {NH.base.Logger} */ get logger() { return this.#logger; } /** @type {string} */ get name() { return this.#name; } /** @type {boolean} */ get visible() { return this.#visible; } /** * Materialize the contents into the container. * * Each time this is called, the Widget should repopulate the contents. * @fires 'build' 'built' * @returns {Widget} - This instance, for chaining. */ build() { this.#dispatcher.fire('build', this); this.#dispatcher.fire('built', this); this.verify(); return this; } /** * Tears down internals. E.g., any Widget that has other Widgets should * call their destroy() method as well. * @fires 'destroy' 'destroyed' * @returns {Widget} - This instance, for chaining. */ destroy() { this.#container.innerHTML = ''; this.#dispatcher.fire('destroy', this); this.#dispatcher.fire('destroyed', this); return this; } /** * Shows the Widget by removing a CSS class. * @fires 'show' 'showed' * @returns {Widget} - This instance, for chaining. */ show() { this.verify(); this.#dispatcher.fire('show', this); this.container.classList.remove(Widget.classHidden); this.#visible = true; this.#dispatcher.fire('showed', this); return this; } /** * Hides the Widget by adding a CSS class. * @fires 'hide' 'hidden' * @returns {Widget} - This instance, for chaining. */ hide() { this.#dispatcher.fire('hide', this); this.container.classList.add(Widget.classHidden); this.#visible = false; this.#dispatcher.fire('hidden', this); return this; } /** * Verifies a Widget's internal state. * * For example, a Widget may use this to enforce certain ARIA criteria. * @fires 'verify' 'verified' * @returns {Widget} - This instance, for chaining. */ verify() { this.#dispatcher.fire('verify', this); this.#dispatcher.fire('verified', this); return this; } /** Clears the container element. */ clear() { this.logger.log('clear is deprecated'); this.#container.innerHTML = ''; } /** * Attach a function to an eventType. * @param {string} eventType - Event type to connect with. * @param {NH.base.Dispatcher~Handler} func - Single argument function to * call. * @returns {Widget} - This instance, for chaining. */ on(eventType, func) { this.#dispatcher.on(eventType, func); return this; } /** * Remove all instances of a function registered to an eventType. * @param {string} eventType - Event type to disconnect from. * @param {NH.base.Dispatcher~Handler} func - Function to remove. * @returns {Widget} - This instance, for chaining. */ off(eventType, func) { this.#dispatcher.off(eventType, func); return this; } /** * Helper that sets an attribute to value. * * If value is null, the attribute is removed. * @example * w.attrText('aria-label', 'Information about the application.') * @param {string} attr - Name of the attribute. * @param {?string} value - Value to assign. * @returns {Widget} - This instance, for chaining. */ attrText(attr, value) { if (value === null) { this.container.removeAttribute(attr); } else { this.container.setAttribute(attr, value); } return this; } /** * Helper that sets an attribute to space separated {Element} ids. * * This will collect the appropriate id from each value passed then assign * that collection to the attribute. If any value is null, the everything * up to that point will be reset. If the collection ends up being empty * (e.g., no values were passed or the last was null), the attribute will * be removed. * @param {string} attr - Name of the attribute. * @param {?Content} values - Value to assign. * @returns {Widget} - This instance, for chaining. */ attrElements(attr, ...values) { const strs = []; for (const value of values) { if (value === null) { strs.length = 0; } else if (typeof value === 'string' || value instanceof String) { strs.push(value); } else if (value instanceof HTMLElement) { if (value.id) { strs.push(value.id); } } else if (value instanceof Widget) { if (value.container.id) { strs.push(value.container.id); } } } if (strs.length) { this.container.setAttribute(attr, strs.join(' ')); } else { this.container.removeAttribute(attr); } return this; } /** * Install a style if not already present. * * It will NOT overwrite an existing one. * @param {string} id - Base to use for the style id. * @param {string[]} rules - CSS rules in 'selector { declarations }'. * @returns {HTMLStyleElement} - Resulting <style> element. */ installStyle(id, rules) { const me = 'installStyle'; this.logger.entered(me, id, rules); const safeId = `${NH.base.safeId(id)}-style`; let style = document.querySelector(`#${safeId}`); if (!style) { style = document.createElement('style'); style.id = safeId; style.textContent = rules.join('\n'); document.head.append(style); } this.logger.leaving(me, style); return style; } static #knownEvents = [ 'build', 'built', 'verify', 'verified', 'destroy', 'destroyed', 'show', 'showed', 'hide', 'hidden', ]; #container #dispatcher #id #logger #name #visible } /* eslint-disable require-jsdoc */ class Test extends Widget { constructor() { super('test', 'section'); } } /* eslint-enable */ /* eslint-disable max-statements */ /* eslint-disable no-magic-numbers */ /* eslint-disable no-new */ /* eslint-disable require-jsdoc */ class WidgetTestCase extends NH.xunit.TestCase { testAbstract() { this.assertRaises(TypeError, () => { new Widget(); }); } testProperties() { // Assemble const w = new Test(); // Assert this.assertTrue(w.container instanceof HTMLElement, 'element'); this.assertRegExp( w.container.id, RegExp(`^Test-test-${GUID}-container$`, 'u'), 'container' ); this.assertRegExp(w.id, RegExp(`^Test-test-${GUID}`, 'u'), 'id'); this.assertTrue(w.logger instanceof NH.base.Logger, 'logger'); this.assertEqual(w.name, 'Test test', 'name'); } testSimpleEvents() { // Assemble const calls = []; const cb = (...rest) => { calls.push(rest); }; const w = new Test() .on('build', cb) .on('built', cb) .on('verify', cb) .on('verified', cb) .on('destroy', cb) .on('destroyed', cb) .on('show', cb) .on('showed', cb) .on('hide', cb) .on('hidden', cb); // Act w.build() .show() .hide() .destroy(); // Assert this.assertEqual(calls, [ ['build', w], ['built', w], // After build() ['verify', w], ['verified', w], // Before show() ['verify', w], ['verified', w], ['show', w], ['showed', w], ['hide', w], ['hidden', w], ['destroy', w], ['destroyed', w], ]); } testDestroyCleans() { // Assemble const w = new Test(); // XXX: Broken HTML on purpose w.container.innerHTML = '<p>Paragraph<p>'; this.assertEqual(w.container.innerHTML, '<p>Paragraph</p><p></p>', 'html got fixed'); this.assertEqual(w.container.children.length, 2, 'initial count'); // Act w.destroy(); // Assert this.assertEqual(w.container.children.length, 0, 'post destroy count'); } testHideShow() { // Assemble const w = new Test(); this.assertTrue(w.visible, 'init vis'); this.assertFalse(w.container.classList.contains(Widget.classHidden), 'init class'); w.hide(); this.assertFalse(w.visible, 'hide vis'); this.assertTrue(w.container.classList.contains(Widget.classHidden), 'hide class'); w.show(); this.assertTrue(w.visible, 'show viz'); this.assertFalse(w.container.classList.contains(Widget.classHidden), 'show class'); } testVerifyFails() { // Assemble const calls = []; const cb = (...rest) => { calls.push(rest); }; const onVerify = () => { throw new VerificationError('oopsie'); }; const w = new Test() .on('build', cb) .on('verify', onVerify) .on('show', cb); // Act/Assert this.assertRaises( VerificationError, () => { w.build() .show(); }, 'verify fails on purpose' ); this.assertEqual(calls, [['build', w]], 'we made it past build'); } testOnOff() { // Assemble const calls = []; const cb = (...rest) => { calls.push(rest); }; const w = new Test() .on('build', cb) .on('built', cb) .on('destroyed', cb) .off('build', cb) .on('destroy', cb) .off('destroyed', cb); // Act w.build() .hide() .show() .destroy(); // Assert this.assertEqual(calls, [ ['built', w], ['destroy', w], ]); } testAttrText() { // Assemble const attr = 'aria-label'; const w = new Test(); function f() { return w.container.getAttribute(attr); } this.assertEqual(f(), null, 'init does not exist'); // First value w.attrText(attr, 'App info.'); this.assertEqual(f(), 'App info.', 'exists'); // Change w.attrText(attr, 'Different value'); this.assertEqual(f(), 'Different value', 'post change'); // Empty string w.attrText(attr, ''); this.assertEqual(f(), '', 'empty string'); // Remove w.attrText(attr, null); this.assertEqual(f(), null, 'now gone'); } testAttrElements() { const attr = 'aria-labelledby'; const text = 'id1 id2'; const div = document.createElement('div'); div.id = 'div-id'; const w = new Test(); w.container.id = 'w-id'; function g() { return w.container.getAttribute(attr); } this.assertEqual(g(), null, 'init does not exist'); // Single value w.attrElements(attr, 'bob'); this.assertEqual(g(), 'bob', 'single value'); // Replace with spaces w.attrElements(attr, text); this.assertEqual(g(), 'id1 id2', 'spaces'); // Remove w.attrElements(attr, null); this.assertEqual(g(), null, 'first remove'); // Multiple values of different types w.attrElements(attr, text, div, w); this.assertEqual(g(), 'id1 id2 div-id w-id', 'everything'); // Duplicates w.attrElements(attr, text, text); this.assertEqual(g(), 'id1 id2 id1 id2', 'duplicates'); // Null in the middle w.attrElements(attr, w, null, text, null, text); this.assertEqual(g(), 'id1 id2', 'mid null'); // Null at the end w.attrElements(attr, text, w, div, null); this.assertEqual(g(), null, 'end null'); } } /* eslint-enable */ NH.xunit.testing.testCases.push(WidgetTestCase); /** * An adapter for raw HTML. * * Other Widgets may use this to wrap any HTML they may be handed so they do * not need to special case their implementation outside of construction. */ class StringAdapter extends Widget { /** * @param {string} name - Name for this instance. * @param {string} content - Item to be adapted. */ constructor(name, content) { super(name, 'content'); this.#content = content; this.on('build', this.#onBuild); } #content #onBuild = (...rest) => { const me = 'onBuild'; this.logger.entered(me, rest); this.container.innerHTML = this.#content; this.logger.leaving(me); } } /* eslint-disable no-new-wrappers */ /* eslint-disable require-jsdoc */ class StringAdapterTestCase extends NH.xunit.TestCase { testPrimitiveString() { // Assemble let p = '<p id="bob">This is my paragraph.</p>'; const content = new StringAdapter(this.id, p); // Act content.build(); // Assert this.assertTrue(content.container instanceof HTMLUnknownElement, 'is HTMLUnknownElement'); this.assertTrue((/my paragraph./u).test(content.container.innerText), 'expected text'); this.assertEqual(content.container.firstChild.tagName, 'P', 'is para'); this.assertEqual(content.container.firstChild.id, 'bob', 'is bob'); // Tweak content.container.firstChild.id = 'joe'; this.assertNotEqual(content.container.firstChild.id, 'bob', 'not bob'); // Rebuild content.build(); this.assertEqual(content.container.firstChild.id, 'bob', 'bob again'); // Tweak - Not a live string p = '<p id="changed">New para.</p>'; this.assertEqual(content.container.firstChild.id, 'bob', 'still bob'); } testStringObject() { // Assemble const p = new String('<p id="pat">This is my paragraph.</p>'); const content = new StringAdapter(this.id, p); // Act content.build(); // Assert this.assertTrue(content.container instanceof HTMLUnknownElement, 'is HTMLUnknownElement'); this.assertTrue((/my paragraph./u).test(content.container.innerText), 'expected text'); this.assertEqual(content.container.firstChild.tagName, 'P', 'is para'); this.assertEqual(content.container.firstChild.id, 'pat', 'is pat'); } } /* eslint-enable */ NH.xunit.testing.testCases.push(StringAdapterTestCase); /** * An adapter for HTMLElement. * * Other Widgets may use this to wrap any HTMLElements they may be handed so * they do not need to special case their implementation outside of * construction. */ class ElementAdapter extends Widget { /** * @param {string} name - Name for this instance. * @param {HTMLElement} content - Item to be adapted. */ constructor(name, content) { super(name, 'content'); this.#content = content; this.on('build', this.#onBuild); } #content #onBuild = (...rest) => { const me = 'onBuild'; this.logger.entered(me, rest); this.container.replaceChildren(this.#content); this.logger.leaving(me); } } /* eslint-disable require-jsdoc */ class ElementAdapterTestCase extends NH.xunit.TestCase { testElement() { // Assemble const div = document.createElement('div'); div.id = 'pat'; div.innerText = 'I am a div.'; const content = new ElementAdapter(this.id, div); // Act content.build(); // Assert this.assertTrue(content.container instanceof HTMLUnknownElement, 'is HTMLUnknownElement'); this.assertTrue((/I am a div./u).test(content.container.innerText), 'expected text'); this.assertEqual(content.container.firstChild.tagName, 'DIV', 'is div'); this.assertEqual(content.container.firstChild.id, 'pat', 'is pat'); // Tweak content.container.firstChild.id = 'joe'; this.assertNotEqual(content.container.firstChild.id, 'pat', 'not pat'); this.assertEqual(div.id, 'joe', 'demos is a live element'); // Rebuild content.build(); this.assertEqual(content.container.firstChild.id, 'joe', 'still joe'); // Multiple times content.build(); content.build(); content.build(); this.assertEqual(content.container.childNodes.length, 1, 'child nodes'); } } /* eslint-enable */ NH.xunit.testing.testCases.push(ElementAdapterTestCase); /** * Selects the best adapter to wrap the content. * @param {string} name - Name for this instance. * @param {Content} content - Content to be adapted. * @throws {TypeError} - On type not handled. * @returns {Widget} - Appropriate adapter for content. */ function contentWrapper(name, content) { if (typeof content === 'string' || content instanceof String) { return new StringAdapter(name, content); } else if (content instanceof HTMLElement) { return new ElementAdapter(name, content); } else if (content instanceof Widget) { return content; } throw new TypeError(`Unknown type for "${name}": ${content}`); } /* eslint-disable no-magic-numbers */ /* eslint-disable no-new-wrappers */ /* eslint-disable require-jsdoc */ class ContentWrapperTestCase extends NH.xunit.TestCase { testPrimitiveString() { const x = contentWrapper(this.id, 'a string'); this.assertTrue(x instanceof StringAdapter); } testStringObject() { const x = contentWrapper(this.id, new String('a string')); this.assertTrue(x instanceof StringAdapter); } testElement() { const element = document.createElement('div'); const x = contentWrapper(this.id, element); this.assertTrue(x instanceof ElementAdapter); } testWidget() { const t = new Test(); const x = contentWrapper(this.id, t); this.assertEqual(x, t); } testUnknown() { this.assertRaises( TypeError, () => { contentWrapper(this.id, null); }, 'null' ); this.assertRaises( TypeError, () => { contentWrapper(this.id, 5); }, 'int' ); this.assertRaises( TypeError, () => { contentWrapper(this.id, new Error('why not?')); }, 'error-type' ); } } /* eslint-enable */ NH.xunit.testing.testCases.push(ContentWrapperTestCase); /** * Implements the Layout pattern. */ class Layout extends Widget { /** @param {string} name - Name for this instance. */ constructor(name) { super(name, 'div'); this.on('build', this.#onBuild) .on('destroy', this.#onDestroy); for (const panel of Layout.#Panel.known) { this.set(panel, ''); } } /** @type {Widget} */ get bottom() { return this.#panels.get(Layout.BOTTOM); } /** @type {Widget} */ get left() { return this.#panels.get(Layout.LEFT); } /** @type {Widget} */ get main() { return this.#panels.get(Layout.MAIN); } /** @type {Widget} */ get right() { return this.#panels.get(Layout.RIGHT); } /** @type {Widget} */ get top() { return this.#panels.get(Layout.TOP); } /** * Sets a panel for this instance. * * @param {Layout.#Panel} panel - Panel to set. * @param {Content} content - Content to use. * @returns {Widget} - This instance, for chaining. */ set(panel, content) { if (!(panel instanceof Layout.#Panel)) { throw new TypeError('"panel" argument is not a Layout.#Panel'); } this.#panels.get(panel) ?.destroy(); this.#panels.set(panel, contentWrapper(`${panel} panel content`, content)); return this; } /** Panel enum. */ static #Panel = class { /** @param {string} name - Panel name. */ constructor(name) { this.#name = name; Layout.#Panel.known.add(this); } static known = new Set(); /** @returns {string} - The name. */ toString() { return this.#name; } #name } static { Layout.BOTTOM = new Layout.#Panel('bottom'); Layout.LEFT = new Layout.#Panel('left'); Layout.MAIN = new Layout.#Panel('main'); Layout.RIGHT = new Layout.#Panel('right'); Layout.TOP = new Layout.#Panel('top'); } #panels = new Map(); #onBuild = (...rest) => { const me = 'onBuild'; this.logger.entered(me, rest); for (const panel of this.#panels.values()) { panel.build(); } const middle = document.createElement('div'); middle.append( this.left.container, this.main.container, this.right.container ); this.container.replaceChildren( this.top.container, middle, this.bottom.container ); this.logger.leaving(me); } #onDestroy = (...rest) => { const me = 'onDestroy'; this.logger.entered(me, rest); for (const panel of this.#panels.values()) { panel.destroy(); } this.#panels.clear(); this.logger.leaving(me); } } /* eslint-disable require-jsdoc */ /* eslint-disable no-undefined */ class LayoutTestCase extends NH.xunit.TestCase { testIsDiv() { // Assemble const w = new Layout(this.id); // Assert this.assertEqual(w.container.tagName, 'DIV', 'correct element'); } testPanelsStartSimple() { // Assemble const w = new Layout(this.id); // Assert this.assertTrue(w.main instanceof Widget, 'main'); this.assertRegExp(w.main.name, / main panel content/u, 'main name'); this.assertTrue(w.top instanceof Widget, 'top'); this.assertRegExp(w.top.name, / top panel content/u, 'top name'); this.assertTrue(w.bottom instanceof Widget, 'bottom'); this.assertTrue(w.left instanceof Widget, 'left'); this.assertTrue(w.right instanceof Widget, 'right'); } testSetWorks() { // Assemble const w = new Layout(this.id); // Act w.set(Layout.MAIN, 'main') .set(Layout.TOP, document.createElement('div')); // Assert this.assertTrue(w.main instanceof Widget, 'main'); this.assertEqual( w.main.name, 'StringAdapter main panel content', 'main name' ); this.assertTrue(w.top instanceof Widget, 'top'); this.assertEqual( w.top.name, 'ElementAdapter top panel content', 'top name' ); } testSetRequiresPanel() { // Assemble const w = new Layout(this.id); // Act/Assert this.assertRaises( TypeError, () => { w.set('main', 'main'); } ); } testDefaultBuilds() { // Assemble const w = new Layout(this.id); // Act w.build(); // Assert const expected = [ '<content.*-top-panel-.*></content>', '<div>', '<content.*-left-panel-.*></content>', '<content.*-main-panel-.*></content>', '<content.*-right-panel-.*></content>', '</div>', '<content.*-bottom-panel-.*></content>', ].join(''); this.assertRegExp(w.container.innerHTML, RegExp(expected, 'u')); } testWithContentBuilds() { // Assemble const w = new Layout(this.id); w.set(Layout.MAIN, 'main') .set(Layout.TOP, 'top') .set(Layout.BOTTOM, 'bottom') .set(Layout.RIGHT, 'right') .set(Layout.LEFT, 'left'); // Act w.build(); // Assert this.assertEqual(w.container.innerText, 'topleftmainrightbottom'); } testResetingPanelDestroysPrevious() { // Assemble const calls = []; const cb = (...rest) => { calls.push(rest); }; const w = new Layout(this.id); const initMain = w.main; initMain.on('destroy', cb); const newMain = contentWrapper(this.id, 'Replacement main'); // Act w.set(Layout.MAIN, newMain); w.build(); // Assert this.assertEqual(calls, [['destroy', initMain]], 'old main destroyed'); this.assertEqual( w.container.innerText, 'Replacement main', 'new content' ); } testDestroy() { // Assemble const calls = []; const cb = (evt) => { calls.push(evt); }; const w = new Layout(this.id) .set(Layout.MAIN, 'main') .build(); w.top.on('destroy', cb); w.left.on('destroy', cb); w.main.on('destroy', cb); w.right.on('destroy', cb); w.bottom.on('destroy', cb); this.assertEqual(w.container.innerText, 'main', 'sanity check'); // Act w.destroy(); // Assert this.assertEqual(w.container.innerText, '', 'post destroy inner'); this.assertEqual(w.main, undefined, 'post destroy main'); this.assertEqual( calls, ['destroy', 'destroy', 'destroy', 'destroy', 'destroy'], 'each panel was destroyed' ); } } /* eslint-enable */ NH.xunit.testing.testCases.push(LayoutTestCase); /** * Arbitrary object to be used as data for {@link Grid}. * @typedef {object} GridRecord */ /** Column for the {@link Grid} widget. */ class GridColumn { /** * @callback ColumnClassesFunc * @param {GridRecord} record - Record to style. * @param {string} field - Field to style. * @returns {string[]} - CSS classes for item. */ /** * @callback RenderFunc * @param {GridRecord} record - Record to render. * @param {string} field - Field to render. * @returns {Widget} - Rendered content. */ /** @param {string} field - Which field to render by default. */ constructor(field) { if (!field) { throw new Exception('A "field" is required'); } this.#field = field; this.#uid = NH.base.uuId(this.constructor.name); this.colClassesFunc() .renderFunc() .setTitle(); } /** * The default implementation uses the field. * * @implements {ColumnClassesFunc} * @param {GridRecord} record - Record to style. * @param {string} field - Field to style. * @returns {string[]} - CSS classes for item. */ static defaultClassesFunc = (record, field) => { const result = [field]; return result; } /** * @implements {RenderFunc} * @param {GridRecord} record - Record to render. * @param {string} field - Field to render. * @returns {Widget} - Rendered content. */ static defaultRenderFunc = (record, field) => { const result = contentWrapper(field, record[field]); return result; } /** @type {string} - The name of the property from the record to show. */ get field() { return this.#field; } /** @type {string} - A human readable value to use in the header. */ get title() { return this.#title; } /** @type {string} */ get uid() { return this.#uid; } /** * Use the registered rendering function to create the widget. * * @param {GridRecord} record - Record to render. * @returns {Widget} - Rendered content. */ render(record) { return contentWrapper( this.#field, this.#renderFunc(record, this.#field) ); } /** * Use the registered {ColClassesFunc} to return CSS classes. * * @param {GridRecord} record - Record to examine. * @returns {string[]} - CSS classes for this record. */ classList(record) { return this.#colClassesFunc(record, this.#field); } /** * Sets the function used to style a cell. * * If no value is passed, it will set the default function. * * @param {ColClassesFunc} func - Styling function. * @returns {GridColumn} - This instance, for chaining. */ colClassesFunc(func = GridColumn.defaultClassesFunc) { if (!(func instanceof Function)) { throw new Exception( 'Invalid argument: is not a function' ); } this.#colClassesFunc = func; return this; } /** * Sets the function used to render the column. * * If no value is passed, it will set the default function. * * @param {RenderFunc} [func] - Rendering function. * @returns {GridColumn} - This instance, for chaining. */ renderFunc(func = GridColumn.defaultRenderFunc) { if (!(func instanceof Function)) { throw new Exception( 'Invalid argument: is not a function' ); } this.#renderFunc = func; return this; } /** * Set the title string. * * If no value is passed, it will default back to the name of the field. * * @param {string} [title] - New title for the column. * @returns {GridColumn} - This instance, for chaining. */ setTitle(title) { this.#title = title ?? NH.base.simpleParseWords(this.#field) .join(' '); return this; } #colClassesFunc #field #renderFunc #title #uid } /* eslint-disable no-empty-function */ /* eslint-disable no-new */ /* eslint-disable require-jsdoc */ class GridColumnTestCase extends NH.xunit.TestCase { testNoArgment() { this.assertRaisesRegExp( Exception, /A "field" is required/u, () => { new GridColumn(); } ); } testWithFieldName() { // Assemble const col = new GridColumn('fieldName'); // Assert this.assertEqual(col.field, 'fieldName'); } testBadRenderFunc() { this.assertRaisesRegExp( Exception, /Invalid argument: is not a function/u, () => { new GridColumn('testField') .renderFunc('string'); } ); } testGoodRenderFunc() { this.assertNoRaises( () => { new GridColumn('fiend') .renderFunc(() => {}); } ); } testExplicitTitle() { // Assemble const col = new GridColumn('fieldName') .setTitle('Col Title'); // Assert this.assertEqual(col.title, 'Col Title'); } testDefaultTitle() { // Assemble const col = new GridColumn('fieldName'); // Assert this.assertEqual(col.title, 'field Name'); } testUid() { // Assemble const col = new GridColumn(this.id); // Assert this.assertRegExp(col.uid, /^GridColumn-/u); } testDefaultRenderer() { // Assemble const col = new GridColumn('name'); const record = {name: 'Bob', job: 'Artist'}; // Act const w = col.render(record); // Assert this.assertTrue(w instanceof Widget, 'correct type'); this.assertEqual(w.build().container.innerHTML, 'Bob', 'right content'); } testCanSetRenderFunc() { // Assemble function renderFunc(record, field) { return contentWrapper( this.id, `${record.name}|${record.job}|${field}` ); } const col = new GridColumn('name'); const record = {name: 'Bob', job: 'Artist'}; // Act I - Default this.assertEqual( col.render(record) .build().container.innerHTML, 'Bob', 'default func' ); // Act II - Custom this.assertEqual( col.renderFunc(renderFunc) .render(record) .build().container.innerHTML, 'Bob|Artist|name', 'custom func' ); // Act III - Back to default this.assertEqual( col.renderFunc() .render(record) .build().container.innerHTML, 'Bob', 'back to default' ); } testRenderAlwaysReturnsWidget() { // Assemble function renderFunc(record, field) { return `${record.name}|${record.job}|${field}`; } const col = new GridColumn('name') .renderFunc(renderFunc); const record = {name: 'Bob', job: 'Artist'}; // Act const w = col.render(record); // Assert this.assertTrue(w instanceof Widget); } testDefaultClassesFunc() { // Assemble const col = new GridColumn('name'); const record = {name: 'Bob', job: 'Artist'}; // Act const cl = col.classList(record); // Assert this.assertTrue(cl.includes('name')); } testCanSetClassesFunc() { // Assemble function colClassesFunc(record, field) { return [`my-${field}`, 'xyzzy']; } const col = new GridColumn('name'); const record = {name: 'Bob', job: 'Artist'}; // Act I - Default let cl = col.classList(record); // Assert this.assertTrue(cl.includes('name'), 'default func has field'); this.assertFalse(cl.includes('xyzzy'), 'no magic'); // Act II - Custom col.colClassesFunc(colClassesFunc); cl = col.classList(record); // Assert this.assertTrue(cl.includes('my-name'), 'custom has field'); this.assertTrue(cl.includes('xyzzy'), 'plays adventure'); // Act III - Back to default col.colClassesFunc(); cl = col.classList(record); // Assert this.assertTrue(cl.includes('name'), 'back to default'); this.assertFalse(cl.includes('xyzzy'), 'no more magic'); } } /* eslint-enable */ NH.xunit.testing.testCases.push(GridColumnTestCase); /** * Implements the Grid pattern. * * Grid widgets will need `aria-*` attributes, TBD. * * A Grid consist of defined columns and data. * * The data is an array of objects that the caller can manipulate as needed, * such as adding/removing/updating items, sorting, etc. * * The columns is an array of {@link GridColumn}s that the caller can * manipulate as needed. * * Row based CSS classes can be controlled by setting a {Grid~ClassFunc} * using the rowClassesFunc() method. */ class Grid extends Widget { /** * @callback RowClassesFunc * @param {GridRecord} record - Record to style. * @returns {string[]} - CSS classes to add to row. */ /** @param {string} name - Name for this instance. */ constructor(name) { super(name, 'table'); this.on('build', this.#onBuild) .on('destroy', this.#onDestroy) .rowClassesFunc(); } /** * The default implementation sets no classes. * * @implements {RowClassesFunc} * @returns {string[]} - CSS classes to add to row. */ static defaultClassesFunc = () => { const result = []; return result; } /** @type {GridColumns[]} - Column definitions for the Grid. */ get columns() { return this.#columns; } /** @type {object[]} - Data used by the Grid. */ get data() { return this.#data; } /** * @param {object[]} array - Data used by the Grid. * @returns {Grid} - This instance, for chaining. */ set(array) { this.#data = array; return this; } /** * Sets the function used to style a row. * * If no value is passed, it will set the default function. * * @param {RowClassesFunc} func - Styling function. * @returns {Grid} - This instance, for chaining. */ rowClassesFunc(func = Grid.defaultClassesFunc) { if (!(func instanceof Function)) { throw new Exception( 'Invalid argument: is not a function' ); } this.#rowClassesFunc = func; return this; } #built = []; #columns = []; #data = []; #rowClassesFunc; #tbody #thead #resetBuilt = () => { for (const row of this.#built) { for (const cell of row.cells) { cell.widget.destroy(); } } this.#built.length = 0; } #resetContainer = () => { this.container.innerHTML = ''; this.#thead = document.createElement('thead'); this.#tbody = document.createElement('tbody'); this.container.append(this.#thead, this.#tbody); } #populateBuilt = () => { for (const row of this.#data) { const built = { classes: this.#rowClassesFunc(row), cells: [], }; for (const col of this.#columns) { built.cells.push( { widget: col.render(row), classes: col.classList(row), } ); } this.#built.push(built); } } #buildHeader = () => { const tr = document.createElement('tr'); for (const col of this.#columns) { const th = document.createElement('th'); th.append(col.title); tr.append(th); } this.#thead.append(tr); } #buildRows = () => { for (const row of this.#built) { const tr = document.createElement('tr'); tr.classList.add(...row.classes); for (const cell of row.cells) { const td = document.createElement('td'); td.append(cell.widget.build().container); td.classList.add(...cell.classes); tr.append(td); } this.#tbody.append(tr); } } #onBuild = (...rest) => { const me = 'onBuild'; this.logger.entered(me, rest); this.#resetBuilt(); this.#resetContainer(); this.#populateBuilt(); this.#buildHeader(); this.#buildRows(); this.logger.leaving(me); } #onDestroy = (...rest) => { const me = 'onDestroy'; this.logger.entered(me, rest); this.#resetBuilt(); this.logger.leaving(me); } } /* eslint-disable max-lines-per-function */ /* eslint-disable require-jsdoc */ class GridTestCase extends NH.xunit.TestCase { testDefaults() { // Assemble const w = new Grid(this.id); // Assert this.assertEqual(w.container.tagName, 'TABLE', 'correct element'); this.assertEqual(w.columns, [], 'default columns'); this.assertEqual(w.data, [], 'default data'); } testColumnsAreLive() { // Assemble const w = new Grid(this.id); const col = new GridColumn('fieldName'); // Act w.columns.push(col, 1); // Assert this.assertEqual(w.columns, [col, 1], 'note lack of sanity checking'); } testSetUpdatesData() { // Assemble const w = new Grid(this.id); // Act w.set([{id: 1, name: 'Sally'}]); // Assert this.assertEqual(w.data, [{id: 1, name: 'Sally'}]); } testBadRowClasses() { this.assertRaisesRegExp( Exception, /Invalid argument: is not a function/u, () => { new Grid(this.id) .rowClassesFunc('string'); } ); } testDataIsLive() { // Assemble const w = new Grid(this.id); const data = [{id: 1, name: 'Sally'}]; w.set(data); // Act I - More data.push({id: 2, name: 'Jane'}, {id: 3, name: 'Puff'}); // Assert this.assertEqual( w.data, [ {id: 1, name: 'Sally'}, {id: 2, name: 'Jane'}, {id: 3, name: 'Puff'}, ], 'new data was added' ); // Act II - Sort data.sort((a, b) => a.name.localeCompare(b.name)); // Assert this.assertEqual( w.data, [ {name: 'Jane', id: 2}, {name: 'Puff', id: 3}, {name: 'Sally', id: 1}, ], 'data was sorted' ); } testEmptyBuild() { // Assemble const w = new Grid(this.id); // Act w.build(); // Assert const expected = [ `<table id="Grid-[^-]*-${GUID}[^"]*">`, '<thead><tr></tr></thead>', '<tbody></tbody>', '</table>', ].join(''); this.assertRegExp(w.container.outerHTML, RegExp(expected, 'u')); } testBuildWithData() { // Assemble function renderInt(record, field) { const span = document.createElement('span'); span.append(record[field]); return span; } function renderType(record) { return `${record.stage}, ${record.species}`; } const w = new Grid(this.id); const data = [ {id: 1, name: 'Sally', species: 'human', stage: 'juvenile'}, {name: 'Jane', id: 2, species: 'human', stage: 'juvenile'}, {name: 'Puff', id: 3, species: 'feline', stage: 'juvenile'}, ]; w.set(data); w.columns.push( new GridColumn('id') .renderFunc(renderInt), new GridColumn('name'), new GridColumn('typ') .setTitle('Type') .renderFunc(renderType), ); // Act I - First build w.build(); // Assert const expected = [ '<table id="Grid-[^"]*">', '<thead>', '<tr><th>id</th><th>name</th><th>Type</th></tr>', '</thead>', '<tbody>', '<tr class="">', `<td class="id"><content id="ElementAdapter-id-${GUID}-container">`, '<span>1</span>', '</content></td>', '<td class="name"><content id="StringAdapter-name-.*-container">', 'Sally', '</content></td>', `<td class="typ"><content id="StringAdapter-typ-${GUID}-container">`, 'juvenile, human', '</content></td>', '</tr>', '<tr class="">', `<td class="id"><content id="ElementAdapter-id-${GUID}-container">`, '<span>2</span>', '</content></td>', '<td class="name"><content id="StringAdapter-name-.*-container">', 'Jane', '</content></td>', `<td class="typ"><content id="StringAdapter-typ-${GUID}-container">`, 'juvenile, human', '</content></td>', '</tr>', '<tr class="">', `<td class="id"><content id="ElementAdapter-id-${GUID}-container">`, '<span>3</span>', '</content></td>', '<td class="name"><content id="StringAdapter-name-.*-container">', 'Puff', '</content></td>', `<td class="typ"><content id="StringAdapter-typ-${GUID}-container">`, 'juvenile, feline', '</content></td>', '</tr>', '</tbody>', '</table>', ].join(''); this.assertRegExp( w.container.outerHTML, RegExp(expected, 'u'), 'first build' ); // Act II - Rebuild is sensible w.build(); this.assertRegExp( w.container.outerHTML, RegExp(expected, 'u'), 'second build' ); } testBuildWithClasses() { // Assemble function renderInt(record, field) { const span = document.createElement('span'); span.append(record[field]); return span; } function renderType(record) { return `${record.stage}, ${record.species}`; } function rowClassesFunc(record) { return [record.species, record.stage]; } const data = [ {id: 1, name: 'Sally', species: 'human', stage: 'juvenile'}, {name: 'Puff', id: 3, species: 'feline', stage: 'juvenile'}, {name: 'Bob', id: 4, species: 'alien', stage: 'adolescent'}, ]; const w = new Grid(this.id) .set(data) .rowClassesFunc(rowClassesFunc); w.columns.push( new GridColumn('id') .renderFunc(renderInt), new GridColumn('name'), new GridColumn('tpe') .setTitle('Type') .renderFunc(renderType), ); // Act w.build(); // Assert const expected = [ '<table id="Grid-[^"]*">', '<thead>', '<tr><th>id</th><th>name</th><th>Type</th></tr>', '</thead>', '<tbody>', '<tr class="human juvenile">', `<td class="id"><content id="ElementAdapter-id-${GUID}-container">`, '<span>1</span>', '</content></td>', '<td class="name"><content id="StringAdapter-name-.*-container">', 'Sally', '</content></td>', `<td class="tpe"><content id="StringAdapter-tpe-${GUID}-container">`, 'juvenile, human', '</content></td>', '</tr>', '<tr class="feline juvenile">', `<td class="id"><content id="ElementAdapter-id-${GUID}-container">`, '<span>3</span>', '</content></td>', '<td class="name"><content id="StringAdapter-name-.*-container">', 'Puff', '</content></td>', `<td class="tpe"><content id="StringAdapter-tpe-${GUID}-container">`, 'juvenile, feline', '</content></td>', '</tr>', '<tr class="alien adolescent">', `<td class="id"><content id="ElementAdapter-id-${GUID}-container">`, '<span>4</span>', '</content></td>', '<td class="name"><content id="StringAdapter-name-.*-container">', 'Bob', '</content></td>', `<td class="tpe"><content id="StringAdapter-tpe-${GUID}-container">`, 'adolescent, alien', '</content></td>', '</tr>', '</tbody>', '</table>', ].join(''); this.assertRegExp( w.container.outerHTML, RegExp(expected, 'u'), ); } testRebuildDestroys() { // Assemble const calls = []; const cb = (...rest) => { calls.push(rest); }; const item = contentWrapper(this.id, 'My data.') .on('destroy', cb); const w = new Grid(this.id); w.data.push({item: item}); w.columns.push(new GridColumn('item')); // Act w.build() .build(); // Assert this.assertEqual(calls, [['destroy', item]]); } testDestroy() { // Assemble const calls = []; const cb = (...rest) => { calls.push(rest); }; const item = contentWrapper(this.id, 'My data.') .on('destroy', cb); const w = new Grid(this.id); w.data.push({item: item}); w.columns.push(new GridColumn('item')); // Act w.build() .destroy(); // Assert this.assertEqual(calls, [['destroy', item]]); } } /* eslint-enable */ NH.xunit.testing.testCases.push(GridTestCase); /** Tab for the {@link Tabs} widget. */ class TabEntry { /** * @callback LabelClassesFunc * @param {string} label - Label to style. * @returns {string[]} - CSS classes for item. */ /** @param {string} label - The label for this entry. */ constructor(label) { if (!label) { throw new Exception('A "label" is required'); } this.#label = label; this.#uid = NH.base.uuId(this.constructor.name); this.labelClassesFunc() .set(); } /** * The default implementation uses the label. * * @implements {LabelClassesFunc} * @param {string} label - Label to style. * @returns {string[]} - CSS classes for item. */ static defaultClassesFunc(label) { const result = [NH.base.safeId(label)]; return result; } /** @type {string} */ get label() { return this.#label; } /** @type {Widget} */ get panel() { return this.#panel; } /** @type {string} */ get uid() { return this.#uid; } /** * Use the registered {LabelClassesFunc} to return CSS classes. * * @returns {string[]} - CSS classes for this record. */ classList() { return this.#labelClassesFunc(this.#label); } /** * Sets the function used to style the label. * * If no value is passed, it will set the default function. * * @param {LabelClassesFunc} func - Styling function. * @returns {TabEntry} - This instance, for chaining. */ labelClassesFunc(func = TabEntry.defaultClassesFunc) { if (!(func instanceof Function)) { throw new Exception( 'Invalid argument: is not a function' ); } this.#labelClassesFunc = func; return this; } /** * Set the panel content for this entry. * * If no value is passed, defaults to an empty string. * @param {Content} [panel] - Panel content. * @returns {TabEntry} - This instance, for chaining. */ set(panel = '') { this.#panel = contentWrapper('panel content', panel); return this; } #label #labelClassesFunc #panel #uid } /* eslint-disable no-new */ /* eslint-disable require-jsdoc */ class TabEntryTestCase extends NH.xunit.TestCase { testNoArgument() { this.assertRaisesRegExp( Exception, /A "label" is required/u, () => { new TabEntry(); } ); } testWithLabel() { // Assemble const entry = new TabEntry(this.id); this.assertEqual(entry.label, this.id); } testUid() { // Assemble const entry = new TabEntry(this.id); // Assert this.assertRegExp(entry.uid, RegExp(`^TabEntry-${GUID}`, 'u')); } testDefaultClassesFunc() { // Assemble const entry = new TabEntry('Tab Entry'); // Assert this.assertEqual(entry.classList(), ['Tab-Entry']); } testCanSetClassesFunc() { // Assemble function labelClassesFunc(label) { return [`my-${label}`, 'abc123']; } const entry = new TabEntry('tab-entry'); // Act I - Default let cl = entry.classList(); // Assert this.assertTrue(cl.includes('tab-entry'), 'default func has label'); this.assertFalse(cl.includes('abc123'), 'no alnum'); // Act II - Custom entry.labelClassesFunc(labelClassesFunc); cl = entry.classList(); // Assert this.assertTrue(cl.includes('my-tab-entry'), 'custom func is custom'); this.assertTrue(cl.includes('abc123'), 'has alnum'); // Act III - Back to default entry.labelClassesFunc(); cl = entry.classList(); // Assert this.assertTrue(cl.includes('tab-entry'), 'default func back to label'); this.assertFalse(cl.includes('abc123'), 'no more alnum'); } testPanel() { // Assemble/Act I - Default const entry = new TabEntry(this.id); // Assert this.assertTrue(entry.panel instanceof Widget, 'default widget'); this.assertEqual( entry.panel.name, 'StringAdapter panel content', 'default name' ); // Act II - Custom entry.set(contentWrapper('custom content', 'new panel content')); // Assert this.assertEqual( entry.panel.name, 'StringAdapter custom content', 'custom content' ); // Act III - Back to default entry.set(); // Assert this.assertEqual( entry.panel.name, 'StringAdapter panel content', 'default again' ); } } /* eslint-enable */ NH.xunit.testing.testCases.push(TabEntryTestCase); /** * Implements the Tabs pattern. * * Tabs widgets will need `aria-*` attributes, TBD. */ class Tabs extends Widget { /** @param {string} name - Name for this instance. */ constructor(name) { super(name, 'tabs'); this.on('build', this.#onBuild) .on('destroy', this.#onDestroy); } #tablist #resetContainer = () => { this.container.innerHTML = ''; this.#tablist = document.createElement('tablist'); this.#tablist.role = 'tablist'; this.container.append(this.#tablist); } #onBuild = (...rest) => { const me = 'onBuild'; this.logger.entered(me, rest); this.#resetContainer(); this.logger.leaving(me); } #onDestroy = (...rest) => { const me = 'onDestroy'; this.logger.entered(me, rest); this.logger.leaving(me); } } /* eslint-disable require-jsdoc */ class TabsTestCase extends NH.xunit.TestCase { testDefaults() { // Assemble const w = new Tabs(this.id); // Assert this.assertEqual(w.container.tagName, 'TABS', 'correct element'); } testEmptyBuild() { // Assemble const w = new Tabs(this.id); // Act w.build(); // Assert const expected = [ `^<tabs id="Tabs-[^-]*-${GUID}[^"]*">`, '<tablist role="tablist">', '</tablist>', '</tabs>$', ].join(''); this.assertRegExp(w.container.outerHTML, RegExp(expected, 'u')); } } /* eslint-enable */ NH.xunit.testing.testCases.push(TabsTestCase); /** * Implements the Modal pattern. * * Modal widgets should have exactly one of the `aria-labelledby` or * `aria-label` attributes. * * Modal widgets can use `aria-describedby` to reference an element that * describes the purpose if not clear from the initial content. */ class Modal extends Widget { /** @param {string} name - Name for this instance. */ constructor(name) { super(name, 'dialog'); this.on('build', this.#onBuild) .on('destroy', this.#onDestroy) .on('verify', this.#onVerify) .on('show', this.#onShow) .on('hide', this.#onHide) .set('') .hide(); } /** @type {Widget} */ get content() { return this.#content; } /** * Sets the content of this instance. * @param {Content} content - Content to use. * @returns {Widget} - This instance, for chaining. */ set(content) { this.#content?.destroy(); this.#content = contentWrapper('modal content', content); return this; } #content #onBuild = (...rest) => { const me = 'onBuild'; this.logger.entered(me, rest); this.#content.build(); this.container.replaceChildren(this.#content.container); this.logger.leaving(me); } #onDestroy = (...rest) => { const me = 'onDestroy'; this.logger.entered(me, rest); this.#content.destroy(); this.#content = null; this.logger.leaving(me); } #onVerify = (...rest) => { const me = 'onVerify'; this.logger.entered(me, rest); const labelledBy = this.container.getAttribute('aria-labelledby'); const label = this.container.getAttribute('aria-label'); if (!labelledBy && !label) { throw new VerificationError( `Modal "${this.name}" should have one of "aria-labelledby" ` + 'or "aria-label" attributes' ); } if (labelledBy && label) { throw new VerificationError( `Modal "${this.name}" should not have both ` + `"aria-labelledby=${labelledBy}" and "aria-label=${label}"` ); } this.logger.leaving(me); } #onShow = (...rest) => { const me = 'onShow'; this.logger.entered(me, rest); this.container.showModal(); this.#content.show(); this.logger.leaving(me); } #onHide = (...rest) => { const me = 'onHide'; this.logger.entered(me, rest); this.#content.hide(); this.container.close(); this.logger.leaving(me); } } /* eslint-disable require-jsdoc */ class ModalTestCase extends NH.xunit.TestCase { testDefaults() { // Assemble const w = new Modal(this.id); // Assert this.assertEqual(w.container.tagName, 'DIALOG', 'correct element'); this.assertFalse(w.visible, 'visibility'); this.assertTrue(w.content instanceof Widget, 'is widget'); this.assertRegExp(w.content.name, / modal content/u, 'content name'); } testSetDestroysPrevious() { // Assemble const calls = []; const cb = (...rest) => { calls.push(rest); }; const w = new Modal(this.id); const content = w.content.on('destroy', cb); // Act w.set('new stuff'); // Assert this.assertEqual(calls, [['destroy', content]]); } testCallsNestedWidget() { // Assemble const calls = []; const cb = (...rest) => { calls.push(rest); }; const w = new Modal(this.id) .attrText('aria-label', 'test widget'); const nest = contentWrapper(this.id, 'test content'); nest.on('build', cb) .on('destroy', cb) .on('show', cb) .on('hide', cb); // Act w.set(nest) .build() .hide() .destroy(); // Assert this.assertEqual(calls, [ ['build', nest], ['hide', nest], ['destroy', nest], ]); } testVerify() { // Assemble const w = new Modal(this.id); // Assert this.assertRaisesRegExp( VerificationError, /should have one of/u, () => { w.build(); }, 'no aria attributes' ); // Add labelledby w.attrText('aria-labelledby', 'some-element'); this.assertNoRaises(() => { w.build(); }, 'post add aria-labelledby'); // Add label w.attrText('aria-label', 'test modal'); this.assertRaisesRegExp( VerificationError, /should not have both "[^"]*" and "[^"]*"/u, () => { w.build(); }, 'both aria attributes' ); // Remove labelledby w.attrText('aria-labelledby', null); this.assertNoRaises(() => { w.build(); }, 'post remove aria-labelledby'); } } /* eslint-enable */ NH.xunit.testing.testCases.push(ModalTestCase); /** * A widget that can be opened and closed on demand, designed for fairly * persistent information. * * The element will get `open` and `close` events. */ class Info extends Widget { /** @param {string} name - Name for this instance. */ constructor(name) { super(name, 'dialog'); this.logger.log(`${this.name} constructed`); } /** Open the widget. */ open() { this.container.showModal(); this.container.dispatchEvent(new Event('open')); } /** Close the widget. */ close() { // HTMLDialogElement sends a close event natively. this.container.close(); } } return { version: version, Widget: Widget, Layout: Layout, GridColumn: GridColumn, Grid: Grid, Modal: Modal, Info: Info, }; }());
QingJ © 2025
镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址