NH_widget

Widgets for user interactions.

当前为 2024-02-23 提交的版本,查看 最新版本

此脚本不应直接安装。它是供其他脚本使用的外部库,要使用该库请加入元指令 // @require https://update.gf.qytechs.cn/scripts/478676/1332407/NH_widget.js

  1. // ==UserScript==
  2. // ==UserLibrary==
  3. // @name NH_widget
  4. // @description Widgets for user interactions.
  5. // @version 42
  6. // @license GPL-3.0-or-later; https://www.gnu.org/licenses/gpl-3.0-standalone.html
  7. // @homepageURL https://github.com/nexushoratio/userscripts
  8. // @supportURL https://github.com/nexushoratio/userscripts/issues
  9. // @match https://www.example.com/*
  10. // ==/UserLibrary==
  11. // ==/UserScript==
  12.  
  13. window.NexusHoratio ??= {};
  14.  
  15. window.NexusHoratio.widget = (function widget() {
  16. 'use strict';
  17.  
  18. /** @type {number} - Bumped per release. */
  19. const version = 42;
  20.  
  21. const NH = window.NexusHoratio.base.ensure([
  22. {name: 'xunit', minVersion: 39},
  23. {name: 'base', minVersion: 21},
  24. ]);
  25.  
  26. /** Library specific exception. */
  27. class WidgetError extends Error {
  28.  
  29. /** @inheritdoc */
  30. constructor(...rest) {
  31. super(...rest);
  32. this.name = this.constructor.name;
  33. }
  34.  
  35. }
  36.  
  37. /** Thrown on verification errors. */
  38. class VerificationError extends WidgetError {}
  39.  
  40. /** Useful for matching in tests. */
  41. const HEX = '[0-9a-f]';
  42. const GUID = `${HEX}{8}-(${HEX}{4}-){3}${HEX}{12}`;
  43.  
  44. /** @typedef {(string|HTMLElement|Widget)} Content */
  45.  
  46. /**
  47. * Base class for rendering widgets.
  48. *
  49. * Subclasses should NOT override methods here, except for constructor().
  50. * Instead they should register listeners for appropriate events.
  51. *
  52. * Generally, methods will fire two event verbs. The first, in present
  53. * tense, will instruct what should happen (build, destroy, etc). The
  54. * second, in past tense, will describe what should have happened (built,
  55. * destroyed, etc). Typically, subclasses will act upon the present tense,
  56. * and users of the class may act upon the past tense.
  57. *
  58. * Methods should generally be able to be chained.
  59. *
  60. * If a variable holding a widget is set to a new value, the previous widget
  61. * should be explicitly destroyed.
  62. *
  63. * When a Widget is instantiated, it should only create a container of the
  64. * requested type (done in this base class). And install any widget styles
  65. * it needs in order to function. The container property can then be placed
  66. * into the DOM.
  67. *
  68. * If a Widget needs specific CSS to function, that CSS should be shared
  69. * across all instances of the Widget by using the same values in a call to
  70. * installStyle(). Anything used for presentation should include the
  71. * Widget's id as part of the style's id.
  72. *
  73. * The build() method will fire 'build'/'built' events. Subclasses then
  74. * populate the container with HTML as appropriate. Widgets should
  75. * generally be designed to not update the internal HTML until build() is
  76. * explicitly called.
  77. *
  78. * The destroy() method will fire 'destroy'/'destroyed' events and also
  79. * clear the innerHTML of the container. Subclasses are responsible for any
  80. * internal cleanup, such as nested Widgets.
  81. *
  82. * The verify() method will fire 'verify'/'verified' events. Subclasses can
  83. * handle these to validate any internal structures they need for. For
  84. * example, Widgets that have ARIA support can ensure appropriate attributes
  85. * are in place. If a Widget fails, it should throw a VerificationError
  86. * with details.
  87. */
  88. class Widget {
  89.  
  90. /**
  91. * Each subclass should take a caller provided name.
  92. * @param {string} name - Name for this instance.
  93. * @param {string} element - Type of element to use for the container.
  94. */
  95. constructor(name, element) {
  96. if (new.target === Widget) {
  97. throw new TypeError('Abstract class; do not instantiate directly.');
  98. }
  99.  
  100. this.#name = `${this.constructor.name} ${name}`;
  101. this.#id = NH.base.uuId(NH.base.safeId(this.name));
  102. this.#container = document.createElement(element);
  103. this.#container.id = `${this.id}-container`;
  104. this.#dispatcher = new NH.base.Dispatcher(...Widget.#knownEvents);
  105. this.#logger = new NH.base.Logger(`${this.constructor.name}`);
  106. this.#visible = true;
  107.  
  108. this.installStyle('nh-widget',
  109. [`.${Widget.classHidden} {display: none}`]);
  110. }
  111.  
  112. /** @type {string} - CSS class applied to hide element. */
  113. static get classHidden() {
  114. return 'nh-widget-hidden';
  115. }
  116.  
  117. /** @type {Element} */
  118. get container() {
  119. return this.#container;
  120. }
  121.  
  122. /** @type {string} */
  123. get id() {
  124. return this.#id;
  125. }
  126.  
  127. /** @type {NH.base.Logger} */
  128. get logger() {
  129. return this.#logger;
  130. }
  131.  
  132. /** @type {string} */
  133. get name() {
  134. return this.#name;
  135. }
  136.  
  137. /** @type {boolean} */
  138. get visible() {
  139. return this.#visible;
  140. }
  141.  
  142. /**
  143. * Materialize the contents into the container.
  144. *
  145. * Each time this is called, the Widget should repopulate the contents.
  146. * @fires 'build' 'built'
  147. * @returns {Widget} - This instance, for chaining.
  148. */
  149. build() {
  150. this.#dispatcher.fire('build', this);
  151. this.#dispatcher.fire('built', this);
  152. this.verify();
  153. return this;
  154. }
  155.  
  156. /**
  157. * Tears down internals. E.g., any Widget that has other Widgets should
  158. * call their destroy() method as well.
  159. * @fires 'destroy' 'destroyed'
  160. * @returns {Widget} - This instance, for chaining.
  161. */
  162. destroy() {
  163. this.#container.innerHTML = '';
  164. this.#dispatcher.fire('destroy', this);
  165. this.#dispatcher.fire('destroyed', this);
  166. return this;
  167. }
  168.  
  169. /**
  170. * Shows the Widget by removing a CSS class.
  171. * @fires 'show' 'showed'
  172. * @returns {Widget} - This instance, for chaining.
  173. */
  174. show() {
  175. this.verify();
  176. this.#dispatcher.fire('show', this);
  177. this.container.classList.remove(Widget.classHidden);
  178. this.#visible = true;
  179. this.#dispatcher.fire('showed', this);
  180. return this;
  181. }
  182.  
  183. /**
  184. * Hides the Widget by adding a CSS class.
  185. * @fires 'hide' 'hidden'
  186. * @returns {Widget} - This instance, for chaining.
  187. */
  188. hide() {
  189. this.#dispatcher.fire('hide', this);
  190. this.container.classList.add(Widget.classHidden);
  191. this.#visible = false;
  192. this.#dispatcher.fire('hidden', this);
  193. return this;
  194. }
  195.  
  196. /**
  197. * Verifies a Widget's internal state.
  198. *
  199. * For example, a Widget may use this to enforce certain ARIA criteria.
  200. * @fires 'verify' 'verified'
  201. * @returns {Widget} - This instance, for chaining.
  202. */
  203. verify() {
  204. this.#dispatcher.fire('verify', this);
  205. this.#dispatcher.fire('verified', this);
  206. return this;
  207. }
  208.  
  209. /** Clears the container element. */
  210. clear() {
  211. this.logger.log('clear is deprecated');
  212. this.#container.innerHTML = '';
  213. }
  214.  
  215. /**
  216. * Attach a function to an eventType.
  217. * @param {string} eventType - Event type to connect with.
  218. * @param {NH.base.Dispatcher~Handler} func - Single argument function to
  219. * call.
  220. * @returns {Widget} - This instance, for chaining.
  221. */
  222. on(eventType, func) {
  223. this.#dispatcher.on(eventType, func);
  224. return this;
  225. }
  226.  
  227. /**
  228. * Remove all instances of a function registered to an eventType.
  229. * @param {string} eventType - Event type to disconnect from.
  230. * @param {NH.base.Dispatcher~Handler} func - Function to remove.
  231. * @returns {Widget} - This instance, for chaining.
  232. */
  233. off(eventType, func) {
  234. this.#dispatcher.off(eventType, func);
  235. return this;
  236. }
  237.  
  238. /**
  239. * Helper that sets an attribute to value.
  240. *
  241. * If value is null, the attribute is removed.
  242. * @example
  243. * w.attrText('aria-label', 'Information about the application.')
  244. * @param {string} attr - Name of the attribute.
  245. * @param {?string} value - Value to assign.
  246. * @returns {Widget} - This instance, for chaining.
  247. */
  248. attrText(attr, value) {
  249. if (value === null) {
  250. this.container.removeAttribute(attr);
  251. } else {
  252. this.container.setAttribute(attr, value);
  253. }
  254. return this;
  255. }
  256.  
  257. /**
  258. * Helper that sets an attribute to space separated {Element} ids.
  259. *
  260. * This will collect the appropriate id from each value passed then assign
  261. * that collection to the attribute. If any value is null, the everything
  262. * up to that point will be reset. If the collection ends up being empty
  263. * (e.g., no values were passed or the last was null), the attribute will
  264. * be removed.
  265. * @param {string} attr - Name of the attribute.
  266. * @param {?Content} values - Value to assign.
  267. * @returns {Widget} - This instance, for chaining.
  268. */
  269. attrElements(attr, ...values) {
  270. const strs = [];
  271. for (const value of values) {
  272. if (value === null) {
  273. strs.length = 0;
  274. } else if (typeof value === 'string' || value instanceof String) {
  275. strs.push(value);
  276. } else if (value instanceof HTMLElement) {
  277. if (value.id) {
  278. strs.push(value.id);
  279. }
  280. } else if (value instanceof Widget) {
  281. if (value.container.id) {
  282. strs.push(value.container.id);
  283. }
  284. }
  285. }
  286. if (strs.length) {
  287. this.container.setAttribute(attr, strs.join(' '));
  288. } else {
  289. this.container.removeAttribute(attr);
  290. }
  291. return this;
  292. }
  293.  
  294. /**
  295. * Install a style if not already present.
  296. *
  297. * It will NOT overwrite an existing one.
  298. * @param {string} id - Base to use for the style id.
  299. * @param {string[]} rules - CSS rules in 'selector { declarations }'.
  300. * @returns {HTMLStyleElement} - Resulting <style> element.
  301. */
  302. installStyle(id, rules) {
  303. const me = 'installStyle';
  304. this.logger.entered(me, id, rules);
  305.  
  306. const safeId = `${NH.base.safeId(id)}-style`;
  307. let style = document.querySelector(`#${safeId}`);
  308. if (!style) {
  309. style = document.createElement('style');
  310. style.id = safeId;
  311. style.textContent = rules.join('\n');
  312. document.head.append(style);
  313. }
  314.  
  315. this.logger.leaving(me, style);
  316. return style;
  317. }
  318.  
  319. static #knownEvents = [
  320. 'build',
  321. 'built',
  322. 'verify',
  323. 'verified',
  324. 'destroy',
  325. 'destroyed',
  326. 'show',
  327. 'showed',
  328. 'hide',
  329. 'hidden',
  330. ];
  331.  
  332. #container
  333. #dispatcher
  334. #id
  335. #logger
  336. #name
  337. #visible
  338.  
  339. }
  340.  
  341. /* eslint-disable require-jsdoc */
  342. class Test extends Widget {
  343.  
  344. constructor() {
  345. super('test', 'section');
  346. }
  347.  
  348. }
  349. /* eslint-enable */
  350.  
  351. /* eslint-disable max-statements */
  352. /* eslint-disable no-magic-numbers */
  353. /* eslint-disable no-new */
  354. /* eslint-disable require-jsdoc */
  355. class WidgetTestCase extends NH.xunit.TestCase {
  356.  
  357. testAbstract() {
  358. this.assertRaises(TypeError, () => {
  359. new Widget();
  360. });
  361. }
  362.  
  363. testProperties() {
  364. // Assemble
  365. const w = new Test();
  366.  
  367. // Assert
  368. this.assertTrue(w.container instanceof HTMLElement, 'element');
  369. this.assertRegExp(
  370. w.container.id,
  371. RegExp(`^Test-test-${GUID}-container$`, 'u'),
  372. 'container'
  373. );
  374.  
  375. this.assertRegExp(w.id, RegExp(`^Test-test-${GUID}`, 'u'), 'id');
  376. this.assertTrue(w.logger instanceof NH.base.Logger, 'logger');
  377. this.assertEqual(w.name, 'Test test', 'name');
  378. }
  379.  
  380. testSimpleEvents() {
  381. // Assemble
  382. const calls = [];
  383. const cb = (...rest) => {
  384. calls.push(rest);
  385. };
  386. const w = new Test()
  387. .on('build', cb)
  388. .on('built', cb)
  389. .on('verify', cb)
  390. .on('verified', cb)
  391. .on('destroy', cb)
  392. .on('destroyed', cb)
  393. .on('show', cb)
  394. .on('showed', cb)
  395. .on('hide', cb)
  396. .on('hidden', cb);
  397.  
  398. // Act
  399. w.build()
  400. .show()
  401. .hide()
  402. .destroy();
  403.  
  404. // Assert
  405. this.assertEqual(calls, [
  406. ['build', w],
  407. ['built', w],
  408. // After build()
  409. ['verify', w],
  410. ['verified', w],
  411. // Before show()
  412. ['verify', w],
  413. ['verified', w],
  414. ['show', w],
  415. ['showed', w],
  416. ['hide', w],
  417. ['hidden', w],
  418. ['destroy', w],
  419. ['destroyed', w],
  420. ]);
  421. }
  422.  
  423. testDestroyCleans() {
  424. // Assemble
  425. const w = new Test();
  426. // XXX: Broken HTML on purpose
  427. w.container.innerHTML = '<p>Paragraph<p>';
  428.  
  429. this.assertEqual(w.container.innerHTML,
  430. '<p>Paragraph</p><p></p>',
  431. 'html got fixed');
  432. this.assertEqual(w.container.children.length, 2, 'initial count');
  433.  
  434. // Act
  435. w.destroy();
  436.  
  437. // Assert
  438. this.assertEqual(w.container.children.length, 0, 'post destroy count');
  439. }
  440.  
  441. testHideShow() {
  442. // Assemble
  443. const w = new Test();
  444.  
  445. this.assertTrue(w.visible, 'init vis');
  446. this.assertFalse(w.container.classList.contains(Widget.classHidden),
  447. 'init class');
  448.  
  449. w.hide();
  450.  
  451. this.assertFalse(w.visible, 'hide vis');
  452. this.assertTrue(w.container.classList.contains(Widget.classHidden),
  453. 'hide class');
  454.  
  455. w.show();
  456.  
  457. this.assertTrue(w.visible, 'show viz');
  458. this.assertFalse(w.container.classList.contains(Widget.classHidden),
  459. 'show class');
  460. }
  461.  
  462. testVerifyFails() {
  463. // Assemble
  464. const calls = [];
  465. const cb = (...rest) => {
  466. calls.push(rest);
  467. };
  468. const onVerify = () => {
  469. throw new VerificationError('oopsie');
  470. };
  471. const w = new Test()
  472. .on('build', cb)
  473. .on('verify', onVerify)
  474. .on('show', cb);
  475.  
  476. // Act/Assert
  477. this.assertRaises(
  478. VerificationError,
  479. () => {
  480. w.build()
  481. .show();
  482. },
  483. 'verify fails on purpose'
  484. );
  485. this.assertEqual(calls, [['build', w]], 'we made it past build');
  486. }
  487.  
  488. testOnOff() {
  489. // Assemble
  490. const calls = [];
  491. const cb = (...rest) => {
  492. calls.push(rest);
  493. };
  494. const w = new Test()
  495. .on('build', cb)
  496. .on('built', cb)
  497. .on('destroyed', cb)
  498. .off('build', cb)
  499. .on('destroy', cb)
  500. .off('destroyed', cb);
  501.  
  502. // Act
  503. w.build()
  504. .hide()
  505. .show()
  506. .destroy();
  507.  
  508. // Assert
  509. this.assertEqual(calls, [
  510. ['built', w],
  511. ['destroy', w],
  512. ]);
  513. }
  514.  
  515. testAttrText() {
  516. // Assemble
  517. const attr = 'aria-label';
  518. const w = new Test();
  519.  
  520. function f() {
  521. return w.container.getAttribute(attr);
  522. }
  523.  
  524. this.assertEqual(f(), null, 'init does not exist');
  525.  
  526. // First value
  527. w.attrText(attr, 'App info.');
  528. this.assertEqual(f(), 'App info.', 'exists');
  529.  
  530. // Change
  531. w.attrText(attr, 'Different value');
  532. this.assertEqual(f(), 'Different value', 'post change');
  533.  
  534. // Empty string
  535. w.attrText(attr, '');
  536. this.assertEqual(f(), '', 'empty string');
  537.  
  538. // Remove
  539. w.attrText(attr, null);
  540. this.assertEqual(f(), null, 'now gone');
  541. }
  542.  
  543. testAttrElements() {
  544. const attr = 'aria-labelledby';
  545. const text = 'id1 id2';
  546. const div = document.createElement('div');
  547. div.id = 'div-id';
  548. const w = new Test();
  549. w.container.id = 'w-id';
  550.  
  551. function g() {
  552. return w.container.getAttribute(attr);
  553. }
  554.  
  555. this.assertEqual(g(), null, 'init does not exist');
  556.  
  557. // Single value
  558. w.attrElements(attr, 'bob');
  559. this.assertEqual(g(), 'bob', 'single value');
  560.  
  561. // Replace with spaces
  562. w.attrElements(attr, text);
  563. this.assertEqual(g(), 'id1 id2', 'spaces');
  564.  
  565. // Remove
  566. w.attrElements(attr, null);
  567. this.assertEqual(g(), null, 'first remove');
  568.  
  569. // Multiple values of different types
  570. w.attrElements(attr, text, div, w);
  571. this.assertEqual(g(), 'id1 id2 div-id w-id', 'everything');
  572.  
  573. // Duplicates
  574. w.attrElements(attr, text, text);
  575. this.assertEqual(g(), 'id1 id2 id1 id2', 'duplicates');
  576.  
  577. // Null in the middle
  578. w.attrElements(attr, w, null, text, null, text);
  579. this.assertEqual(g(), 'id1 id2', 'mid null');
  580.  
  581. // Null at the end
  582. w.attrElements(attr, text, w, div, null);
  583. this.assertEqual(g(), null, 'end null');
  584. }
  585.  
  586. }
  587. /* eslint-enable */
  588.  
  589. NH.xunit.testing.testCases.push(WidgetTestCase);
  590.  
  591. /**
  592. * An adapter for raw HTML.
  593. *
  594. * Other Widgets may use this to wrap any HTML they may be handed so they do
  595. * not need to special case their implementation outside of construction.
  596. */
  597. class StringAdapter extends Widget {
  598.  
  599. /**
  600. * @param {string} name - Name for this instance.
  601. * @param {string} content - Item to be adapted.
  602. */
  603. constructor(name, content) {
  604. super(name, 'content');
  605. this.#content = content;
  606. this.on('build', this.#onBuild);
  607. }
  608.  
  609. #content
  610.  
  611. #onBuild = (...rest) => {
  612. const me = 'onBuild';
  613. this.logger.entered(me, rest);
  614.  
  615. this.container.innerHTML = this.#content;
  616.  
  617. this.logger.leaving(me);
  618. }
  619.  
  620. }
  621.  
  622. /* eslint-disable no-new-wrappers */
  623. /* eslint-disable require-jsdoc */
  624. class StringAdapterTestCase extends NH.xunit.TestCase {
  625.  
  626. testPrimitiveString() {
  627. // Assemble
  628. let p = '<p id="bob">This is my paragraph.</p>';
  629. const content = new StringAdapter(this.id, p);
  630.  
  631. // Act
  632. content.build();
  633.  
  634. // Assert
  635. this.assertTrue(content.container instanceof HTMLUnknownElement,
  636. 'is HTMLUnknownElement');
  637. this.assertTrue((/my paragraph./u).test(content.container.innerText),
  638. 'expected text');
  639. this.assertEqual(content.container.firstChild.tagName, 'P', 'is para');
  640. this.assertEqual(content.container.firstChild.id, 'bob', 'is bob');
  641.  
  642. // Tweak
  643. content.container.firstChild.id = 'joe';
  644. this.assertNotEqual(content.container.firstChild.id, 'bob', 'not bob');
  645.  
  646. // Rebuild
  647. content.build();
  648. this.assertEqual(content.container.firstChild.id, 'bob', 'bob again');
  649.  
  650. // Tweak - Not a live string
  651. p = '<p id="changed">New para.</p>';
  652. this.assertEqual(content.container.firstChild.id, 'bob', 'still bob');
  653. }
  654.  
  655. testStringObject() {
  656. // Assemble
  657. const p = new String('<p id="pat">This is my paragraph.</p>');
  658. const content = new StringAdapter(this.id, p);
  659.  
  660. // Act
  661. content.build();
  662. // Assert
  663. this.assertTrue(content.container instanceof HTMLUnknownElement,
  664. 'is HTMLUnknownElement');
  665. this.assertTrue((/my paragraph./u).test(content.container.innerText),
  666. 'expected text');
  667. this.assertEqual(content.container.firstChild.tagName, 'P', 'is para');
  668. this.assertEqual(content.container.firstChild.id, 'pat', 'is pat');
  669. }
  670.  
  671. }
  672. /* eslint-enable */
  673.  
  674. NH.xunit.testing.testCases.push(StringAdapterTestCase);
  675.  
  676. /**
  677. * An adapter for HTMLElement.
  678. *
  679. * Other Widgets may use this to wrap any HTMLElements they may be handed so
  680. * they do not need to special case their implementation outside of
  681. * construction.
  682. */
  683. class ElementAdapter extends Widget {
  684.  
  685. /**
  686. * @param {string} name - Name for this instance.
  687. * @param {HTMLElement} content - Item to be adapted.
  688. */
  689. constructor(name, content) {
  690. super(name, 'content');
  691. this.#content = content;
  692. this.on('build', this.#onBuild);
  693. }
  694.  
  695. #content
  696.  
  697. #onBuild = (...rest) => {
  698. const me = 'onBuild';
  699. this.logger.entered(me, rest);
  700.  
  701. this.container.replaceChildren(this.#content);
  702.  
  703. this.logger.leaving(me);
  704. }
  705.  
  706. }
  707. /* eslint-disable require-jsdoc */
  708. class ElementAdapterTestCase extends NH.xunit.TestCase {
  709.  
  710. testElement() {
  711. // Assemble
  712. const div = document.createElement('div');
  713. div.id = 'pat';
  714. div.innerText = 'I am a div.';
  715. const content = new ElementAdapter(this.id, div);
  716.  
  717. // Act
  718. content.build();
  719.  
  720. // Assert
  721. this.assertTrue(content.container instanceof HTMLUnknownElement,
  722. 'is HTMLUnknownElement');
  723. this.assertTrue((/I am a div./u).test(content.container.innerText),
  724. 'expected text');
  725. this.assertEqual(content.container.firstChild.tagName, 'DIV', 'is div');
  726. this.assertEqual(content.container.firstChild.id, 'pat', 'is pat');
  727.  
  728. // Tweak
  729. content.container.firstChild.id = 'joe';
  730. this.assertNotEqual(content.container.firstChild.id, 'pat', 'not pat');
  731. this.assertEqual(div.id, 'joe', 'demos is a live element');
  732.  
  733. // Rebuild
  734. content.build();
  735. this.assertEqual(content.container.firstChild.id, 'joe', 'still joe');
  736.  
  737. // Multiple times
  738. content.build();
  739. content.build();
  740. content.build();
  741. this.assertEqual(content.container.childNodes.length, 1, 'child nodes');
  742. }
  743.  
  744. }
  745. /* eslint-enable */
  746.  
  747. NH.xunit.testing.testCases.push(ElementAdapterTestCase);
  748.  
  749. /**
  750. * Selects the best adapter to wrap the content.
  751. * @param {string} name - Name for this instance.
  752. * @param {Content} content - Content to be adapted.
  753. * @throws {TypeError} - On type not handled.
  754. * @returns {Widget} - Appropriate adapter for content.
  755. */
  756. function contentWrapper(name, content) {
  757. if (typeof content === 'string' || content instanceof String) {
  758. return new StringAdapter(name, content);
  759. } else if (content instanceof HTMLElement) {
  760. return new ElementAdapter(name, content);
  761. } else if (content instanceof Widget) {
  762. return content;
  763. }
  764. throw new TypeError(`Unknown type for "${name}": ${content}`);
  765. }
  766.  
  767. /* eslint-disable no-magic-numbers */
  768. /* eslint-disable no-new-wrappers */
  769. /* eslint-disable require-jsdoc */
  770. class ContentWrapperTestCase extends NH.xunit.TestCase {
  771.  
  772. testPrimitiveString() {
  773. const x = contentWrapper(this.id, 'a string');
  774.  
  775. this.assertTrue(x instanceof StringAdapter);
  776. }
  777.  
  778. testStringObject() {
  779. const x = contentWrapper(this.id, new String('a string'));
  780.  
  781. this.assertTrue(x instanceof StringAdapter);
  782. }
  783.  
  784. testElement() {
  785. const element = document.createElement('div');
  786. const x = contentWrapper(this.id, element);
  787.  
  788. this.assertTrue(x instanceof ElementAdapter);
  789. }
  790.  
  791. testWidget() {
  792. const t = new Test();
  793. const x = contentWrapper(this.id, t);
  794.  
  795. this.assertEqual(x, t);
  796. }
  797.  
  798. testUnknown() {
  799. this.assertRaises(
  800. TypeError,
  801. () => {
  802. contentWrapper(this.id, null);
  803. },
  804. 'null'
  805. );
  806.  
  807. this.assertRaises(
  808. TypeError,
  809. () => {
  810. contentWrapper(this.id, 5);
  811. },
  812. 'int'
  813. );
  814.  
  815. this.assertRaises(
  816. TypeError,
  817. () => {
  818. contentWrapper(this.id, new Error('why not?'));
  819. },
  820. 'error-type'
  821. );
  822. }
  823.  
  824. }
  825. /* eslint-enable */
  826.  
  827. NH.xunit.testing.testCases.push(ContentWrapperTestCase);
  828.  
  829. /**
  830. * Implements the Layout pattern.
  831. */
  832. class Layout extends Widget {
  833.  
  834. /** @param {string} name - Name for this instance. */
  835. constructor(name) {
  836. super(name, 'div');
  837. this.on('build', this.#onBuild)
  838. .on('destroy', this.#onDestroy);
  839. for (const panel of Layout.#Panel.known) {
  840. this.set(panel, '');
  841. }
  842. }
  843.  
  844. /** @type {Widget} */
  845. get bottom() {
  846. return this.#panels.get(Layout.BOTTOM);
  847. }
  848.  
  849. /** @type {Widget} */
  850. get left() {
  851. return this.#panels.get(Layout.LEFT);
  852. }
  853.  
  854. /** @type {Widget} */
  855. get main() {
  856. return this.#panels.get(Layout.MAIN);
  857. }
  858.  
  859. /** @type {Widget} */
  860. get right() {
  861. return this.#panels.get(Layout.RIGHT);
  862. }
  863.  
  864. /** @type {Widget} */
  865. get top() {
  866. return this.#panels.get(Layout.TOP);
  867. }
  868.  
  869. /**
  870. * Sets a panel for this instance.
  871. *
  872. * @param {Layout.#Panel} panel - Panel to set.
  873. * @param {Content} content - Content to use.
  874. * @returns {Widget} - This instance, for chaining.
  875. */
  876. set(panel, content) {
  877. if (!(panel instanceof Layout.#Panel)) {
  878. throw new TypeError('"panel" argument is not a Layout.#Panel');
  879. }
  880.  
  881. this.#panels.get(panel)
  882. ?.destroy();
  883.  
  884. this.#panels.set(panel,
  885. contentWrapper(`${panel} panel content`, content));
  886.  
  887. return this;
  888. }
  889.  
  890. /** Panel enum. */
  891. static #Panel = class {
  892.  
  893. /** @param {string} name - Panel name. */
  894. constructor(name) {
  895. this.#name = name;
  896.  
  897. Layout.#Panel.known.add(this);
  898. }
  899.  
  900. static known = new Set();
  901.  
  902. /** @returns {string} - The name. */
  903. toString() {
  904. return this.#name;
  905. }
  906.  
  907. #name
  908.  
  909. }
  910.  
  911. static {
  912. Layout.BOTTOM = new Layout.#Panel('bottom');
  913. Layout.LEFT = new Layout.#Panel('left');
  914. Layout.MAIN = new Layout.#Panel('main');
  915. Layout.RIGHT = new Layout.#Panel('right');
  916. Layout.TOP = new Layout.#Panel('top');
  917. }
  918.  
  919. #panels = new Map();
  920.  
  921. #onBuild = (...rest) => {
  922. const me = 'onBuild';
  923. this.logger.entered(me, rest);
  924.  
  925. for (const panel of this.#panels.values()) {
  926. panel.build();
  927. }
  928.  
  929. const middle = document.createElement('div');
  930. middle.append(
  931. this.left.container, this.main.container, this.right.container
  932. );
  933. this.container.replaceChildren(
  934. this.top.container, middle, this.bottom.container
  935. );
  936.  
  937. this.logger.leaving(me);
  938. }
  939.  
  940. #onDestroy = (...rest) => {
  941. const me = 'onDestroy';
  942. this.logger.entered(me, rest);
  943.  
  944. for (const panel of this.#panels.values()) {
  945. panel.destroy();
  946. }
  947. this.#panels.clear();
  948.  
  949. this.logger.leaving(me);
  950. }
  951.  
  952. }
  953.  
  954. /* eslint-disable require-jsdoc */
  955. /* eslint-disable no-undefined */
  956. class LayoutTestCase extends NH.xunit.TestCase {
  957.  
  958. testIsDiv() {
  959. // Assemble
  960. const w = new Layout(this.id);
  961.  
  962. // Assert
  963. this.assertEqual(w.container.tagName, 'DIV', 'correct element');
  964. }
  965.  
  966. testPanelsStartSimple() {
  967. // Assemble
  968. const w = new Layout(this.id);
  969.  
  970. // Assert
  971. this.assertTrue(w.main instanceof Widget, 'main');
  972. this.assertRegExp(w.main.name, / main panel content/u, 'main name');
  973. this.assertTrue(w.top instanceof Widget, 'top');
  974. this.assertRegExp(w.top.name, / top panel content/u, 'top name');
  975. this.assertTrue(w.bottom instanceof Widget, 'bottom');
  976. this.assertTrue(w.left instanceof Widget, 'left');
  977. this.assertTrue(w.right instanceof Widget, 'right');
  978. }
  979.  
  980. testSetWorks() {
  981. // Assemble
  982. const w = new Layout(this.id);
  983.  
  984. // Act
  985. w.set(Layout.MAIN, 'main')
  986. .set(Layout.TOP, document.createElement('div'));
  987.  
  988. // Assert
  989. this.assertTrue(w.main instanceof Widget, 'main');
  990. this.assertEqual(
  991. w.main.name, 'StringAdapter main panel content', 'main name'
  992. );
  993. this.assertTrue(w.top instanceof Widget, 'top');
  994. this.assertEqual(
  995. w.top.name, 'ElementAdapter top panel content', 'top name'
  996. );
  997. }
  998.  
  999. testSetRequiresPanel() {
  1000. // Assemble
  1001. const w = new Layout(this.id);
  1002.  
  1003. // Act/Assert
  1004. this.assertRaises(
  1005. TypeError,
  1006. () => {
  1007. w.set('main', 'main');
  1008. }
  1009. );
  1010. }
  1011.  
  1012. testDefaultBuilds() {
  1013. // Assemble
  1014. const w = new Layout(this.id);
  1015.  
  1016. // Act
  1017. w.build();
  1018.  
  1019. // Assert
  1020. const expected = [
  1021. '<content.*-top-panel-.*></content>',
  1022. '<div>',
  1023. '<content.*-left-panel-.*></content>',
  1024. '<content.*-main-panel-.*></content>',
  1025. '<content.*-right-panel-.*></content>',
  1026. '</div>',
  1027. '<content.*-bottom-panel-.*></content>',
  1028. ].join('');
  1029. this.assertRegExp(w.container.innerHTML, RegExp(expected, 'u'));
  1030. }
  1031.  
  1032. testWithContentBuilds() {
  1033. // Assemble
  1034. const w = new Layout(this.id);
  1035. w.set(Layout.MAIN, 'main')
  1036. .set(Layout.TOP, 'top')
  1037. .set(Layout.BOTTOM, 'bottom')
  1038. .set(Layout.RIGHT, 'right')
  1039. .set(Layout.LEFT, 'left');
  1040.  
  1041. // Act
  1042. w.build();
  1043.  
  1044. // Assert
  1045. this.assertEqual(w.container.innerText, 'topleftmainrightbottom');
  1046. }
  1047.  
  1048. testResetingPanelDestroysPrevious() {
  1049. // Assemble
  1050. const calls = [];
  1051. const cb = (...rest) => {
  1052. calls.push(rest);
  1053. };
  1054. const w = new Layout(this.id);
  1055. const initMain = w.main;
  1056. initMain.on('destroy', cb);
  1057. const newMain = contentWrapper(this.id, 'Replacement main');
  1058.  
  1059. // Act
  1060. w.set(Layout.MAIN, newMain);
  1061. w.build();
  1062.  
  1063. // Assert
  1064. this.assertEqual(calls, [['destroy', initMain]], 'old main destroyed');
  1065. this.assertEqual(
  1066. w.container.innerText, 'Replacement main', 'new content'
  1067. );
  1068. }
  1069.  
  1070. testDestroy() {
  1071. // Assemble
  1072. const calls = [];
  1073. const cb = (evt) => {
  1074. calls.push(evt);
  1075. };
  1076. const w = new Layout(this.id)
  1077. .set(Layout.MAIN, 'main')
  1078. .build();
  1079.  
  1080. w.top.on('destroy', cb);
  1081. w.left.on('destroy', cb);
  1082. w.main.on('destroy', cb);
  1083. w.right.on('destroy', cb);
  1084. w.bottom.on('destroy', cb);
  1085.  
  1086. this.assertEqual(w.container.innerText, 'main', 'sanity check');
  1087.  
  1088. // Act
  1089. w.destroy();
  1090.  
  1091. // Assert
  1092. this.assertEqual(w.container.innerText, '', 'post destroy inner');
  1093. this.assertEqual(w.main, undefined, 'post destroy main');
  1094. this.assertEqual(
  1095. calls,
  1096. ['destroy', 'destroy', 'destroy', 'destroy', 'destroy'],
  1097. 'each panel was destroyed'
  1098. );
  1099. }
  1100.  
  1101. }
  1102. /* eslint-enable */
  1103.  
  1104. NH.xunit.testing.testCases.push(LayoutTestCase);
  1105.  
  1106. /**
  1107. * Arbitrary object to be used as data for {@link Grid}.
  1108. * @typedef {object} GridRecord
  1109. */
  1110.  
  1111. /** Column for the {@link Grid} widget. */
  1112. class GridColumn {
  1113.  
  1114. /**
  1115. * @callback ColumnClassesFunc
  1116. * @param {GridRecord} record - Record to style.
  1117. * @param {string} field - Field to style.
  1118. * @returns {string[]} - CSS classes for item.
  1119. */
  1120.  
  1121. /**
  1122. * @callback RenderFunc
  1123. * @param {GridRecord} record - Record to render.
  1124. * @param {string} field - Field to render.
  1125. * @returns {Content} - Rendered content.
  1126. */
  1127.  
  1128. /** @param {string} field - Which field to render by default. */
  1129. constructor(field) {
  1130. if (!field) {
  1131. throw new WidgetError('A "field" is required');
  1132. }
  1133. this.#field = field;
  1134. this.#uid = NH.base.uuId(this.constructor.name);
  1135. this.colClassesFunc()
  1136. .renderFunc()
  1137. .setTitle();
  1138. }
  1139.  
  1140. /**
  1141. * The default implementation uses the field.
  1142. *
  1143. * @implements {ColumnClassesFunc}
  1144. * @param {GridRecord} record - Record to style.
  1145. * @param {string} field - Field to style.
  1146. * @returns {string[]} - CSS classes for item.
  1147. */
  1148. static defaultClassesFunc = (record, field) => {
  1149. const result = [field];
  1150. return result;
  1151. }
  1152.  
  1153. /**
  1154. * @implements {RenderFunc}
  1155. * @param {GridRecord} record - Record to render.
  1156. * @param {string} field - Field to render.
  1157. * @returns {Widget} - Rendered content.
  1158. */
  1159. static defaultRenderFunc = (record, field) => {
  1160. const result = contentWrapper(field, record[field]);
  1161. return result;
  1162. }
  1163.  
  1164. /** @type {string} - The name of the property from the record to show. */
  1165. get field() {
  1166. return this.#field;
  1167. }
  1168.  
  1169. /** @type {string} - A human readable value to use in the header. */
  1170. get title() {
  1171. return this.#title;
  1172. }
  1173.  
  1174. /** @type {string} */
  1175. get uid() {
  1176. return this.#uid;
  1177. }
  1178.  
  1179. /**
  1180. * Use the registered rendering function to create the widget.
  1181. *
  1182. * @param {GridRecord} record - Record to render.
  1183. * @returns {Widget} - Rendered content.
  1184. */
  1185. render(record) {
  1186. return contentWrapper(
  1187. this.#field, this.#renderFunc(record, this.#field)
  1188. );
  1189. }
  1190.  
  1191. /**
  1192. * Use the registered {ColClassesFunc} to return CSS classes.
  1193. *
  1194. * @param {GridRecord} record - Record to examine.
  1195. * @returns {string[]} - CSS classes for this record.
  1196. */
  1197. classList(record) {
  1198. return this.#colClassesFunc(record, this.#field);
  1199. }
  1200.  
  1201. /**
  1202. * Sets the function used to style a cell.
  1203. *
  1204. * If no value is passed, it will set the default function.
  1205. *
  1206. * @param {ColClassesFunc} func - Styling function.
  1207. * @returns {GridColumn} - This instance, for chaining.
  1208. */
  1209. colClassesFunc(func = GridColumn.defaultClassesFunc) {
  1210. if (!(func instanceof Function)) {
  1211. throw new WidgetError(
  1212. 'Invalid argument: is not a function'
  1213. );
  1214. }
  1215. this.#colClassesFunc = func;
  1216. return this;
  1217. }
  1218.  
  1219. /**
  1220. * Sets the function used to render the column.
  1221. *
  1222. * If no value is passed, it will set the default function.
  1223. *
  1224. * @param {RenderFunc} [func] - Rendering function.
  1225. * @returns {GridColumn} - This instance, for chaining.
  1226. */
  1227. renderFunc(func = GridColumn.defaultRenderFunc) {
  1228. if (!(func instanceof Function)) {
  1229. throw new WidgetError(
  1230. 'Invalid argument: is not a function'
  1231. );
  1232. }
  1233. this.#renderFunc = func;
  1234. return this;
  1235. }
  1236.  
  1237. /**
  1238. * Set the title string.
  1239. *
  1240. * If no value is passed, it will default back to the name of the field.
  1241. *
  1242. * @param {string} [title] - New title for the column.
  1243. * @returns {GridColumn} - This instance, for chaining.
  1244. */
  1245. setTitle(title) {
  1246. this.#title = title ?? NH.base.simpleParseWords(this.#field)
  1247. .join(' ');
  1248. return this;
  1249. }
  1250.  
  1251. #colClassesFunc
  1252. #field
  1253. #renderFunc
  1254. #title
  1255. #uid
  1256.  
  1257. }
  1258.  
  1259. /* eslint-disable no-empty-function */
  1260. /* eslint-disable no-new */
  1261. /* eslint-disable require-jsdoc */
  1262. class GridColumnTestCase extends NH.xunit.TestCase {
  1263.  
  1264. testNoArgment() {
  1265. this.assertRaisesRegExp(
  1266. WidgetError,
  1267. /A "field" is required/u,
  1268. () => {
  1269. new GridColumn();
  1270. }
  1271. );
  1272. }
  1273.  
  1274. testWithFieldName() {
  1275. // Assemble
  1276. const col = new GridColumn('fieldName');
  1277.  
  1278. // Assert
  1279. this.assertEqual(col.field, 'fieldName');
  1280. }
  1281.  
  1282. testBadRenderFunc() {
  1283. this.assertRaisesRegExp(
  1284. WidgetError,
  1285. /Invalid argument: is not a function/u,
  1286. () => {
  1287. new GridColumn('testField')
  1288. .renderFunc('string');
  1289. }
  1290. );
  1291. }
  1292.  
  1293. testGoodRenderFunc() {
  1294. this.assertNoRaises(
  1295. () => {
  1296. new GridColumn('fiend')
  1297. .renderFunc(() => {});
  1298. }
  1299. );
  1300. }
  1301.  
  1302. testExplicitTitle() {
  1303. // Assemble
  1304. const col = new GridColumn('fieldName')
  1305. .setTitle('Col Title');
  1306.  
  1307. // Assert
  1308. this.assertEqual(col.title, 'Col Title');
  1309. }
  1310.  
  1311. testDefaultTitle() {
  1312. // Assemble
  1313. const col = new GridColumn('fieldName');
  1314.  
  1315. // Assert
  1316. this.assertEqual(col.title, 'field Name');
  1317. }
  1318.  
  1319. testUid() {
  1320. // Assemble
  1321. const col = new GridColumn(this.id);
  1322.  
  1323. // Assert
  1324. this.assertRegExp(col.uid, /^GridColumn-/u);
  1325. }
  1326.  
  1327. testDefaultRenderer() {
  1328. // Assemble
  1329. const col = new GridColumn('name');
  1330. const record = {name: 'Bob', job: 'Artist'};
  1331.  
  1332. // Act
  1333. const w = col.render(record);
  1334.  
  1335. // Assert
  1336. this.assertTrue(w instanceof Widget, 'correct type');
  1337. this.assertEqual(w.build().container.innerHTML, 'Bob', 'right content');
  1338. }
  1339.  
  1340. testCanSetRenderFunc() {
  1341. // Assemble
  1342. function renderFunc(record, field) {
  1343. return contentWrapper(
  1344. this.id, `${record.name}|${record.job}|${field}`
  1345. );
  1346. }
  1347.  
  1348. const col = new GridColumn('name');
  1349. const record = {name: 'Bob', job: 'Artist'};
  1350.  
  1351. // Act I - Default
  1352. this.assertEqual(
  1353. col.render(record)
  1354. .build().container.innerHTML,
  1355. 'Bob',
  1356. 'default func'
  1357. );
  1358.  
  1359. // Act II - Custom
  1360. this.assertEqual(
  1361. col.renderFunc(renderFunc)
  1362. .render(record)
  1363. .build().container.innerHTML,
  1364. 'Bob|Artist|name',
  1365. 'custom func'
  1366. );
  1367.  
  1368. // Act III - Back to default
  1369. this.assertEqual(
  1370. col.renderFunc()
  1371. .render(record)
  1372. .build().container.innerHTML,
  1373. 'Bob',
  1374. 'back to default'
  1375. );
  1376. }
  1377.  
  1378. testRenderAlwaysReturnsWidget() {
  1379. // Assemble
  1380. function renderFunc(record, field) {
  1381. return `${record.name}|${record.job}|${field}`;
  1382. }
  1383.  
  1384. const col = new GridColumn('name')
  1385. .renderFunc(renderFunc);
  1386. const record = {name: 'Bob', job: 'Artist'};
  1387.  
  1388. // Act
  1389. const w = col.render(record);
  1390.  
  1391. // Assert
  1392. this.assertTrue(w instanceof Widget);
  1393. }
  1394.  
  1395. testDefaultClassesFunc() {
  1396. // Assemble
  1397. const col = new GridColumn('name');
  1398. const record = {name: 'Bob', job: 'Artist'};
  1399.  
  1400. // Act
  1401. const cl = col.classList(record);
  1402.  
  1403. // Assert
  1404. this.assertTrue(cl.includes('name'));
  1405. }
  1406.  
  1407. testCanSetClassesFunc() {
  1408. // Assemble
  1409. function colClassesFunc(record, field) {
  1410. return [`my-${field}`, 'xyzzy'];
  1411. }
  1412. const col = new GridColumn('name');
  1413. const record = {name: 'Bob', job: 'Artist'};
  1414.  
  1415. // Act I - Default
  1416. let cl = col.classList(record);
  1417.  
  1418. // Assert
  1419. this.assertTrue(cl.includes('name'), 'default func has field');
  1420. this.assertFalse(cl.includes('xyzzy'), 'no magic');
  1421.  
  1422. // Act II - Custom
  1423. col.colClassesFunc(colClassesFunc);
  1424. cl = col.classList(record);
  1425.  
  1426. // Assert
  1427. this.assertTrue(cl.includes('my-name'), 'custom has field');
  1428. this.assertTrue(cl.includes('xyzzy'), 'plays adventure');
  1429.  
  1430. // Act III - Back to default
  1431. col.colClassesFunc();
  1432. cl = col.classList(record);
  1433.  
  1434. // Assert
  1435. this.assertTrue(cl.includes('name'), 'back to default');
  1436. this.assertFalse(cl.includes('xyzzy'), 'no more magic');
  1437. }
  1438.  
  1439. }
  1440. /* eslint-enable */
  1441.  
  1442. NH.xunit.testing.testCases.push(GridColumnTestCase);
  1443.  
  1444. /**
  1445. * Implements the Grid pattern.
  1446. *
  1447. * Grid widgets will need `aria-*` attributes, TBD.
  1448. *
  1449. * A Grid consist of defined columns and data.
  1450. *
  1451. * The data is an array of objects that the caller can manipulate as needed,
  1452. * such as adding/removing/updating items, sorting, etc.
  1453. *
  1454. * The columns is an array of {@link GridColumn}s that the caller can
  1455. * manipulate as needed.
  1456. *
  1457. * Row based CSS classes can be controlled by setting a {Grid~ClassFunc}
  1458. * using the rowClassesFunc() method.
  1459. */
  1460. class Grid extends Widget {
  1461.  
  1462. /**
  1463. * @callback RowClassesFunc
  1464. * @param {GridRecord} record - Record to style.
  1465. * @returns {string[]} - CSS classes to add to row.
  1466. */
  1467.  
  1468. /** @param {string} name - Name for this instance. */
  1469. constructor(name) {
  1470. super(name, 'table');
  1471. this.on('build', this.#onBuild)
  1472. .on('destroy', this.#onDestroy)
  1473. .rowClassesFunc();
  1474. }
  1475.  
  1476. /**
  1477. * The default implementation sets no classes.
  1478. *
  1479. * @implements {RowClassesFunc}
  1480. * @returns {string[]} - CSS classes to add to row.
  1481. */
  1482. static defaultClassesFunc = () => {
  1483. const result = [];
  1484. return result;
  1485. }
  1486.  
  1487. /** @type {GridColumns[]} - Column definitions for the Grid. */
  1488. get columns() {
  1489. return this.#columns;
  1490. }
  1491.  
  1492. /** @type {object[]} - Data used by the Grid. */
  1493. get data() {
  1494. return this.#data;
  1495. }
  1496.  
  1497. /**
  1498. * @param {object[]} array - Data used by the Grid.
  1499. * @returns {Grid} - This instance, for chaining.
  1500. */
  1501. set(array) {
  1502. this.#data = array;
  1503. return this;
  1504. }
  1505.  
  1506. /**
  1507. * Sets the function used to style a row.
  1508. *
  1509. * If no value is passed, it will set the default function.
  1510. *
  1511. * @param {RowClassesFunc} func - Styling function.
  1512. * @returns {Grid} - This instance, for chaining.
  1513. */
  1514. rowClassesFunc(func = Grid.defaultClassesFunc) {
  1515. if (!(func instanceof Function)) {
  1516. throw new WidgetError(
  1517. 'Invalid argument: is not a function'
  1518. );
  1519. }
  1520. this.#rowClassesFunc = func;
  1521. return this;
  1522. }
  1523.  
  1524. #built = [];
  1525. #columns = [];
  1526. #data = [];
  1527. #rowClassesFunc;
  1528. #tbody
  1529. #thead
  1530.  
  1531. #resetBuilt = () => {
  1532. for (const row of this.#built) {
  1533. for (const cell of row.cells) {
  1534. cell.widget.destroy();
  1535. }
  1536. }
  1537.  
  1538. this.#built.length = 0;
  1539. }
  1540.  
  1541. #resetContainer = () => {
  1542. this.container.innerHTML = '';
  1543. this.#thead = document.createElement('thead');
  1544. this.#tbody = document.createElement('tbody');
  1545. this.container.append(this.#thead, this.#tbody);
  1546. }
  1547.  
  1548. #populateBuilt = () => {
  1549. for (const row of this.#data) {
  1550. const built = {
  1551. classes: this.#rowClassesFunc(row),
  1552. cells: [],
  1553. };
  1554. for (const col of this.#columns) {
  1555. built.cells.push(
  1556. {
  1557. widget: col.render(row),
  1558. classes: col.classList(row),
  1559. }
  1560. );
  1561. }
  1562. this.#built.push(built);
  1563. }
  1564. }
  1565.  
  1566. #buildHeader = () => {
  1567. const tr = document.createElement('tr');
  1568. for (const col of this.#columns) {
  1569. const th = document.createElement('th');
  1570. th.append(col.title);
  1571. tr.append(th);
  1572. }
  1573. this.#thead.append(tr);
  1574. }
  1575.  
  1576. #buildRows = () => {
  1577. for (const row of this.#built) {
  1578. const tr = document.createElement('tr');
  1579. tr.classList.add(...row.classes);
  1580. for (const cell of row.cells) {
  1581. const td = document.createElement('td');
  1582. td.append(cell.widget.build().container);
  1583. td.classList.add(...cell.classes);
  1584. tr.append(td);
  1585. }
  1586. this.#tbody.append(tr);
  1587. }
  1588. }
  1589.  
  1590. #onBuild = (...rest) => {
  1591. const me = 'onBuild';
  1592. this.logger.entered(me, rest);
  1593.  
  1594. this.#resetBuilt();
  1595. this.#resetContainer();
  1596. this.#populateBuilt();
  1597. this.#buildHeader();
  1598. this.#buildRows();
  1599.  
  1600. this.logger.leaving(me);
  1601. }
  1602.  
  1603. #onDestroy = (...rest) => {
  1604. const me = 'onDestroy';
  1605. this.logger.entered(me, rest);
  1606.  
  1607. this.#resetBuilt();
  1608.  
  1609. this.logger.leaving(me);
  1610. }
  1611.  
  1612. }
  1613.  
  1614. /* eslint-disable max-lines-per-function */
  1615. /* eslint-disable require-jsdoc */
  1616. class GridTestCase extends NH.xunit.TestCase {
  1617.  
  1618. testDefaults() {
  1619. // Assemble
  1620. const w = new Grid(this.id);
  1621.  
  1622. // Assert
  1623. this.assertEqual(w.container.tagName, 'TABLE', 'correct element');
  1624. this.assertEqual(w.columns, [], 'default columns');
  1625. this.assertEqual(w.data, [], 'default data');
  1626. }
  1627.  
  1628. testColumnsAreLive() {
  1629. // Assemble
  1630. const w = new Grid(this.id);
  1631. const col = new GridColumn('fieldName');
  1632.  
  1633. // Act
  1634. w.columns.push(col, 1);
  1635.  
  1636. // Assert
  1637. this.assertEqual(w.columns, [col, 1], 'note lack of sanity checking');
  1638. }
  1639.  
  1640. testSetUpdatesData() {
  1641. // Assemble
  1642. const w = new Grid(this.id);
  1643.  
  1644. // Act
  1645. w.set([{id: 1, name: 'Sally'}]);
  1646.  
  1647. // Assert
  1648. this.assertEqual(w.data, [{id: 1, name: 'Sally'}]);
  1649. }
  1650.  
  1651. testBadRowClasses() {
  1652. this.assertRaisesRegExp(
  1653. WidgetError,
  1654. /Invalid argument: is not a function/u,
  1655. () => {
  1656. new Grid(this.id)
  1657. .rowClassesFunc('string');
  1658. }
  1659. );
  1660. }
  1661.  
  1662. testDataIsLive() {
  1663. // Assemble
  1664. const w = new Grid(this.id);
  1665. const data = [{id: 1, name: 'Sally'}];
  1666. w.set(data);
  1667.  
  1668. // Act I - More
  1669. data.push({id: 2, name: 'Jane'}, {id: 3, name: 'Puff'});
  1670.  
  1671. // Assert
  1672. this.assertEqual(
  1673. w.data,
  1674. [
  1675. {id: 1, name: 'Sally'},
  1676. {id: 2, name: 'Jane'},
  1677. {id: 3, name: 'Puff'},
  1678. ],
  1679. 'new data was added'
  1680. );
  1681.  
  1682. // Act II - Sort
  1683. data.sort((a, b) => a.name.localeCompare(b.name));
  1684.  
  1685. // Assert
  1686. this.assertEqual(
  1687. w.data,
  1688. [
  1689. {name: 'Jane', id: 2},
  1690. {name: 'Puff', id: 3},
  1691. {name: 'Sally', id: 1},
  1692. ],
  1693. 'data was sorted'
  1694. );
  1695. }
  1696.  
  1697. testEmptyBuild() {
  1698. // Assemble
  1699. const w = new Grid(this.id);
  1700.  
  1701. // Act
  1702. w.build();
  1703.  
  1704. // Assert
  1705. const expected = [
  1706. `<table id="Grid-[^-]*-${GUID}[^"]*">`,
  1707. '<thead><tr></tr></thead>',
  1708. '<tbody></tbody>',
  1709. '</table>',
  1710. ].join('');
  1711. this.assertRegExp(w.container.outerHTML, RegExp(expected, 'u'));
  1712. }
  1713.  
  1714. testBuildWithData() {
  1715. // Assemble
  1716. function renderInt(record, field) {
  1717. const span = document.createElement('span');
  1718. span.append(record[field]);
  1719. return span;
  1720. }
  1721. function renderType(record) {
  1722. return `${record.stage}, ${record.species}`;
  1723. }
  1724.  
  1725. const w = new Grid(this.id);
  1726. const data = [
  1727. {id: 1, name: 'Sally', species: 'human', stage: 'juvenile'},
  1728. {name: 'Jane', id: 2, species: 'human', stage: 'juvenile'},
  1729. {name: 'Puff', id: 3, species: 'feline', stage: 'juvenile'},
  1730. ];
  1731. w.set(data);
  1732. w.columns.push(
  1733. new GridColumn('id')
  1734. .renderFunc(renderInt),
  1735. new GridColumn('name'),
  1736. new GridColumn('typ')
  1737. .setTitle('Type')
  1738. .renderFunc(renderType),
  1739. );
  1740.  
  1741. // Act I - First build
  1742. w.build();
  1743.  
  1744. // Assert
  1745. const expected = [
  1746. '<table id="Grid-[^"]*">',
  1747. '<thead>',
  1748. '<tr><th>id</th><th>name</th><th>Type</th></tr>',
  1749. '</thead>',
  1750. '<tbody>',
  1751. '<tr class="">',
  1752.  
  1753. `<td class="id"><content id="ElementAdapter-id-${GUID}-container">`,
  1754. '<span>1</span>',
  1755. '</content></td>',
  1756.  
  1757. '<td class="name"><content id="StringAdapter-name-.*-container">',
  1758. 'Sally',
  1759. '</content></td>',
  1760.  
  1761. `<td class="typ"><content id="StringAdapter-typ-${GUID}-container">`,
  1762. 'juvenile, human',
  1763. '</content></td>',
  1764.  
  1765. '</tr>',
  1766. '<tr class="">',
  1767.  
  1768. `<td class="id"><content id="ElementAdapter-id-${GUID}-container">`,
  1769. '<span>2</span>',
  1770. '</content></td>',
  1771.  
  1772. '<td class="name"><content id="StringAdapter-name-.*-container">',
  1773. 'Jane',
  1774. '</content></td>',
  1775.  
  1776. `<td class="typ"><content id="StringAdapter-typ-${GUID}-container">`,
  1777. 'juvenile, human',
  1778. '</content></td>',
  1779.  
  1780. '</tr>',
  1781. '<tr class="">',
  1782.  
  1783. `<td class="id"><content id="ElementAdapter-id-${GUID}-container">`,
  1784. '<span>3</span>',
  1785. '</content></td>',
  1786.  
  1787. '<td class="name"><content id="StringAdapter-name-.*-container">',
  1788. 'Puff',
  1789. '</content></td>',
  1790.  
  1791. `<td class="typ"><content id="StringAdapter-typ-${GUID}-container">`,
  1792. 'juvenile, feline',
  1793. '</content></td>',
  1794.  
  1795. '</tr>',
  1796. '</tbody>',
  1797. '</table>',
  1798. ].join('');
  1799. this.assertRegExp(
  1800. w.container.outerHTML,
  1801. RegExp(expected, 'u'),
  1802. 'first build'
  1803. );
  1804.  
  1805. // Act II - Rebuild is sensible
  1806. w.build();
  1807. this.assertRegExp(
  1808. w.container.outerHTML,
  1809. RegExp(expected, 'u'),
  1810. 'second build'
  1811. );
  1812. }
  1813.  
  1814. testBuildWithClasses() {
  1815. // Assemble
  1816. function renderInt(record, field) {
  1817. const span = document.createElement('span');
  1818. span.append(record[field]);
  1819. return span;
  1820. }
  1821. function renderType(record) {
  1822. return `${record.stage}, ${record.species}`;
  1823. }
  1824. function rowClassesFunc(record) {
  1825. return [record.species, record.stage];
  1826. }
  1827.  
  1828. const data = [
  1829. {id: 1, name: 'Sally', species: 'human', stage: 'juvenile'},
  1830. {name: 'Puff', id: 3, species: 'feline', stage: 'juvenile'},
  1831. {name: 'Bob', id: 4, species: 'alien', stage: 'adolescent'},
  1832. ];
  1833. const w = new Grid(this.id)
  1834. .set(data)
  1835. .rowClassesFunc(rowClassesFunc);
  1836. w.columns.push(
  1837. new GridColumn('id')
  1838. .renderFunc(renderInt),
  1839. new GridColumn('name'),
  1840. new GridColumn('tpe')
  1841. .setTitle('Type')
  1842. .renderFunc(renderType),
  1843. );
  1844.  
  1845. // Act
  1846. w.build();
  1847.  
  1848. // Assert
  1849. const expected = [
  1850. '<table id="Grid-[^"]*">',
  1851. '<thead>',
  1852. '<tr><th>id</th><th>name</th><th>Type</th></tr>',
  1853. '</thead>',
  1854. '<tbody>',
  1855. '<tr class="human juvenile">',
  1856.  
  1857. `<td class="id"><content id="ElementAdapter-id-${GUID}-container">`,
  1858. '<span>1</span>',
  1859. '</content></td>',
  1860.  
  1861. '<td class="name"><content id="StringAdapter-name-.*-container">',
  1862. 'Sally',
  1863. '</content></td>',
  1864.  
  1865. `<td class="tpe"><content id="StringAdapter-tpe-${GUID}-container">`,
  1866. 'juvenile, human',
  1867. '</content></td>',
  1868.  
  1869. '</tr>',
  1870. '<tr class="feline juvenile">',
  1871.  
  1872. `<td class="id"><content id="ElementAdapter-id-${GUID}-container">`,
  1873. '<span>3</span>',
  1874. '</content></td>',
  1875.  
  1876. '<td class="name"><content id="StringAdapter-name-.*-container">',
  1877. 'Puff',
  1878. '</content></td>',
  1879.  
  1880. `<td class="tpe"><content id="StringAdapter-tpe-${GUID}-container">`,
  1881. 'juvenile, feline',
  1882. '</content></td>',
  1883.  
  1884. '</tr>',
  1885. '<tr class="alien adolescent">',
  1886.  
  1887. `<td class="id"><content id="ElementAdapter-id-${GUID}-container">`,
  1888. '<span>4</span>',
  1889. '</content></td>',
  1890.  
  1891. '<td class="name"><content id="StringAdapter-name-.*-container">',
  1892. 'Bob',
  1893. '</content></td>',
  1894.  
  1895. `<td class="tpe"><content id="StringAdapter-tpe-${GUID}-container">`,
  1896. 'adolescent, alien',
  1897. '</content></td>',
  1898.  
  1899. '</tr>',
  1900. '</tbody>',
  1901. '</table>',
  1902. ].join('');
  1903. this.assertRegExp(
  1904. w.container.outerHTML,
  1905. RegExp(expected, 'u'),
  1906. );
  1907. }
  1908.  
  1909. testRebuildDestroys() {
  1910. // Assemble
  1911. const calls = [];
  1912. const cb = (...rest) => {
  1913. calls.push(rest);
  1914. };
  1915. const item = contentWrapper(this.id, 'My data.')
  1916. .on('destroy', cb);
  1917. const w = new Grid(this.id);
  1918. w.data.push({item: item});
  1919. w.columns.push(new GridColumn('item'));
  1920.  
  1921. // Act
  1922. w.build()
  1923. .build();
  1924.  
  1925. // Assert
  1926. this.assertEqual(calls, [['destroy', item]]);
  1927. }
  1928.  
  1929. testDestroy() {
  1930. // Assemble
  1931. const calls = [];
  1932. const cb = (...rest) => {
  1933. calls.push(rest);
  1934. };
  1935. const item = contentWrapper(this.id, 'My data.')
  1936. .on('destroy', cb);
  1937. const w = new Grid(this.id);
  1938. w.data.push({item: item});
  1939. w.columns.push(new GridColumn('item'));
  1940.  
  1941. // Act
  1942. w.build()
  1943. .destroy();
  1944.  
  1945. // Assert
  1946. this.assertEqual(calls, [['destroy', item]]);
  1947. }
  1948.  
  1949. }
  1950. /* eslint-enable */
  1951.  
  1952. NH.xunit.testing.testCases.push(GridTestCase);
  1953.  
  1954. /**
  1955. * Implements the Modal pattern.
  1956. *
  1957. * Modal widgets should have exactly one of the `aria-labelledby` or
  1958. * `aria-label` attributes.
  1959. *
  1960. * Modal widgets can use `aria-describedby` to reference an element that
  1961. * describes the purpose if not clear from the initial content.
  1962. */
  1963. class Modal extends Widget {
  1964.  
  1965. /** @param {string} name - Name for this instance. */
  1966. constructor(name) {
  1967. super(name, 'dialog');
  1968. this.on('build', this.#onBuild)
  1969. .on('destroy', this.#onDestroy)
  1970. .on('verify', this.#onVerify)
  1971. .on('show', this.#onShow)
  1972. .on('hide', this.#onHide)
  1973. .set('')
  1974. .hide();
  1975. }
  1976.  
  1977. /** @type {Widget} */
  1978. get content() {
  1979. return this.#content;
  1980. }
  1981.  
  1982. /**
  1983. * Sets the content of this instance.
  1984. * @param {Content} content - Content to use.
  1985. * @returns {Widget} - This instance, for chaining.
  1986. */
  1987. set(content) {
  1988. this.#content?.destroy();
  1989. this.#content = contentWrapper('modal content', content);
  1990. return this;
  1991. }
  1992.  
  1993. #content
  1994.  
  1995. #onBuild = (...rest) => {
  1996. const me = 'onBuild';
  1997. this.logger.entered(me, rest);
  1998.  
  1999. this.#content.build();
  2000. this.container.replaceChildren(this.#content.container);
  2001.  
  2002. this.logger.leaving(me);
  2003. }
  2004.  
  2005. #onDestroy = (...rest) => {
  2006. const me = 'onDestroy';
  2007. this.logger.entered(me, rest);
  2008.  
  2009. this.#content.destroy();
  2010. this.#content = null;
  2011.  
  2012. this.logger.leaving(me);
  2013. }
  2014.  
  2015. #onVerify = (...rest) => {
  2016. const me = 'onVerify';
  2017. this.logger.entered(me, rest);
  2018.  
  2019. const labelledBy = this.container.getAttribute('aria-labelledby');
  2020. const label = this.container.getAttribute('aria-label');
  2021.  
  2022. if (!labelledBy && !label) {
  2023. throw new VerificationError(
  2024. `Modal "${this.name}" should have one of "aria-labelledby" ` +
  2025. 'or "aria-label" attributes'
  2026. );
  2027. }
  2028.  
  2029. if (labelledBy && label) {
  2030. throw new VerificationError(
  2031. `Modal "${this.name}" should not have both ` +
  2032. `"aria-labelledby=${labelledBy}" and "aria-label=${label}"`
  2033. );
  2034. }
  2035.  
  2036. this.logger.leaving(me);
  2037. }
  2038.  
  2039. #onShow = (...rest) => {
  2040. const me = 'onShow';
  2041. this.logger.entered(me, rest);
  2042.  
  2043. this.container.showModal();
  2044. this.#content.show();
  2045.  
  2046. this.logger.leaving(me);
  2047. }
  2048.  
  2049. #onHide = (...rest) => {
  2050. const me = 'onHide';
  2051. this.logger.entered(me, rest);
  2052.  
  2053. this.#content.hide();
  2054. this.container.close();
  2055.  
  2056. this.logger.leaving(me);
  2057. }
  2058.  
  2059. }
  2060.  
  2061. /* eslint-disable require-jsdoc */
  2062. class ModalTestCase extends NH.xunit.TestCase {
  2063.  
  2064. testDefaults() {
  2065. // Assemble
  2066. const w = new Modal(this.id);
  2067.  
  2068. // Assert
  2069. this.assertEqual(w.container.tagName, 'DIALOG', 'correct element');
  2070. this.assertFalse(w.visible, 'visibility');
  2071. this.assertTrue(w.content instanceof Widget, 'is widget');
  2072. this.assertRegExp(w.content.name, / modal content/u, 'content name');
  2073. }
  2074.  
  2075. testSetDestroysPrevious() {
  2076. // Assemble
  2077. const calls = [];
  2078. const cb = (...rest) => {
  2079. calls.push(rest);
  2080. };
  2081. const w = new Modal(this.id);
  2082. const content = w.content.on('destroy', cb);
  2083.  
  2084. // Act
  2085. w.set('new stuff');
  2086.  
  2087. // Assert
  2088. this.assertEqual(calls, [['destroy', content]]);
  2089. }
  2090.  
  2091. testCallsNestedWidget() {
  2092. // Assemble
  2093. const calls = [];
  2094. const cb = (...rest) => {
  2095. calls.push(rest);
  2096. };
  2097. const w = new Modal(this.id)
  2098. .attrText('aria-label', 'test widget');
  2099. const nest = contentWrapper(this.id, 'test content');
  2100.  
  2101. nest.on('build', cb)
  2102. .on('destroy', cb)
  2103. .on('show', cb)
  2104. .on('hide', cb);
  2105.  
  2106. // Act
  2107. w.set(nest)
  2108. .build()
  2109. .hide()
  2110. .destroy();
  2111.  
  2112. // Assert
  2113. this.assertEqual(calls, [
  2114. ['build', nest],
  2115. ['hide', nest],
  2116. ['destroy', nest],
  2117. ]);
  2118. }
  2119.  
  2120. testVerify() {
  2121. // Assemble
  2122. const w = new Modal(this.id);
  2123.  
  2124. // Assert
  2125. this.assertRaisesRegExp(
  2126. VerificationError,
  2127. /should have one of/u,
  2128. () => {
  2129. w.build();
  2130. },
  2131. 'no aria attributes'
  2132. );
  2133.  
  2134. // Add labelledby
  2135. w.attrText('aria-labelledby', 'some-element');
  2136. this.assertNoRaises(() => {
  2137. w.build();
  2138. }, 'post add aria-labelledby');
  2139.  
  2140. // Add label
  2141. w.attrText('aria-label', 'test modal');
  2142. this.assertRaisesRegExp(
  2143. VerificationError,
  2144. /should not have both "[^"]*" and "[^"]*"/u,
  2145. () => {
  2146. w.build();
  2147. },
  2148. 'both aria attributes'
  2149. );
  2150.  
  2151. // Remove labelledby
  2152. w.attrText('aria-labelledby', null);
  2153. this.assertNoRaises(() => {
  2154. w.build();
  2155. }, 'post remove aria-labelledby');
  2156. }
  2157.  
  2158. }
  2159. /* eslint-enable */
  2160.  
  2161. NH.xunit.testing.testCases.push(ModalTestCase);
  2162.  
  2163. /**
  2164. * A widget that can be opened and closed on demand, designed for fairly
  2165. * persistent information.
  2166. *
  2167. * The element will get `open` and `close` events.
  2168. */
  2169. class Info extends Widget {
  2170.  
  2171. /** @param {string} name - Name for this instance. */
  2172. constructor(name) {
  2173. super(name, 'dialog');
  2174. this.logger.log(`${this.name} constructed`);
  2175. }
  2176.  
  2177. /** Open the widget. */
  2178. open() {
  2179. this.container.showModal();
  2180. this.container.dispatchEvent(new Event('open'));
  2181. }
  2182.  
  2183. /** Close the widget. */
  2184. close() {
  2185. // HTMLDialogElement sends a close event natively.
  2186. this.container.close();
  2187. }
  2188.  
  2189. }
  2190.  
  2191. return {
  2192. version: version,
  2193. Widget: Widget,
  2194. Layout: Layout,
  2195. GridColumn: GridColumn,
  2196. Grid: Grid,
  2197. Modal: Modal,
  2198. Info: Info,
  2199. };
  2200.  
  2201. }());

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址