- // ==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,
- };
-
- }());