NH_widget

Widgets for user interactions.

当前为 2024-01-04 提交的版本,查看 最新版本

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

  1. // ==UserScript==
  2. // ==UserLibrary==
  3. // @name NH_widget
  4. // @description Widgets for user interactions.
  5. // @version 23
  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 = 23;
  20.  
  21. const NH = window.NexusHoratio.base.ensure([
  22. {name: 'xunit', minVersion: 39},
  23. {name: 'base'},
  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. /** @typedef {(string|HTMLElement|Widget)} Content */
  41.  
  42. /**
  43. * Base class for rendering widgets.
  44. *
  45. * Subclasses should NOT override methods here, except for constructor().
  46. * Instead they should register listeners for appropriate events.
  47. *
  48. * Generally, methods will fire two event verbs. The first, in present
  49. * tense, will instruct what should happen (build, destroy, etc). The
  50. * second, in past tense, will describe what should have happened (built,
  51. * destroyed, etc). Typically, subclasses will act upon the present tense,
  52. * and users of the class may act upon the past tense.
  53. *
  54. * Methods should generally be able to be chained.
  55. *
  56. * If a variable holding a widget is set to a new value, the previous widget
  57. * should be explicitly destroyed.
  58. *
  59. * When a Widget is instantiated, it should only create a container of the
  60. * requested type (done in this base class). And install any widget styles
  61. * it needs in order to function. The container property can then be placed
  62. * into the DOM.
  63. *
  64. * If a Widget needs specific CSS to function, that CSS should be shared
  65. * across all instances of the Widget by using the same values in a call to
  66. * installStyle(). Anything used for presentation should include the
  67. * Widget's id as part of the style's id.
  68. *
  69. * The build() method will fire 'build'/'built' events. Subclasses then
  70. * populate the container with HTML as appropriate. Widgets should
  71. * generally be designed to not update the internal HTML until build() is
  72. * explicitly called.
  73. *
  74. * The destroy() method will fire 'destroy'/'destroyed' events and also
  75. * clear the innerHTML of the container. Subclasses are responsible for any
  76. * internal cleanup, such as nested Widgets.
  77. *
  78. * The verify() method will fire 'verify'/'verified' events. Subclasses can
  79. * handle these to validate any internal structures they need for. For
  80. * example, Widgets that have ARIA support can ensure appropriate attributes
  81. * are in place. If a Widget fails, it should throw a VerificationError
  82. * with details.
  83. */
  84. class Widget {
  85.  
  86. /**
  87. * Each subclass should take a caller provided name.
  88. * @param {string} name - Name for this instance.
  89. * @param {string} element - Type of element to use for the container.
  90. */
  91. constructor(name, element) {
  92. if (new.target === Widget) {
  93. throw new TypeError('Abstract class; do not instantiate directly.');
  94. }
  95.  
  96. this.#name = `${this.constructor.name} ${name}`;
  97. this.#id = NH.base.uuId(NH.base.safeId(this.name));
  98. this.#container = document.createElement(element);
  99. this.#container.id = `${this.id}-container`;
  100. this.#dispatcher = new NH.base.Dispatcher(...Widget.#knownEvents);
  101. this.#logger = new NH.base.Logger(`${this.constructor.name}`);
  102. this.#visible = true;
  103.  
  104. this.installStyle('nh-widget',
  105. [`.${Widget.classHidden} {display: none}`]);
  106. }
  107.  
  108. /** @type {string} - CSS class applied to hide element. */
  109. static get classHidden() {
  110. return 'nh-widget-hidden';
  111. }
  112.  
  113. /** @type {Element} */
  114. get container() {
  115. return this.#container;
  116. }
  117.  
  118. /** @type {string} */
  119. get id() {
  120. return this.#id;
  121. }
  122.  
  123. /** @type {NH.base.Logger} */
  124. get logger() {
  125. return this.#logger;
  126. }
  127.  
  128. /** @type {string} */
  129. get name() {
  130. return this.#name;
  131. }
  132.  
  133. /** @type {boolean} */
  134. get visible() {
  135. return this.#visible;
  136. }
  137.  
  138. /**
  139. * Materialize the contents into the container.
  140. *
  141. * Each time this is called, the Widget should repopulate the contents.
  142. * @fires 'build' 'built'
  143. * @returns {Widget} - This instance, for chaining.
  144. */
  145. build() {
  146. this.#dispatcher.fire('build', this);
  147. this.#dispatcher.fire('built', this);
  148. this.verify();
  149. return this;
  150. }
  151.  
  152. /**
  153. * Tears down internals. E.g., any Widget that has other Widgets should
  154. * call their destroy() method as well.
  155. * @fires 'destroy' 'destroyed'
  156. * @returns {Widget} - This instance, for chaining.
  157. */
  158. destroy() {
  159. this.#container.innerHTML = '';
  160. this.#dispatcher.fire('destroy', this);
  161. this.#dispatcher.fire('destroyed', this);
  162. return this;
  163. }
  164.  
  165. /**
  166. * Shows the Widget by removing a CSS class.
  167. * @fires 'show' 'showed'
  168. * @returns {Widget} - This instance, for chaining.
  169. */
  170. show() {
  171. this.verify();
  172. this.#dispatcher.fire('show', this);
  173. this.container.classList.remove(Widget.classHidden);
  174. this.#visible = true;
  175. this.#dispatcher.fire('showed', this);
  176. return this;
  177. }
  178.  
  179. /**
  180. * Hides the Widget by adding a CSS class.
  181. * @fires 'hide' 'hidden'
  182. * @returns {Widget} - This instance, for chaining.
  183. */
  184. hide() {
  185. this.#dispatcher.fire('hide', this);
  186. this.container.classList.add(Widget.classHidden);
  187. this.#visible = false;
  188. this.#dispatcher.fire('hidden', this);
  189. return this;
  190. }
  191.  
  192. /**
  193. * Verifies a Widget's internal state.
  194. *
  195. * For example, a Widget may use this to enforce certain ARIA criteria.
  196. * @fires 'verify' 'verified'
  197. * @returns {Widget} - This instance, for chaining.
  198. */
  199. verify() {
  200. this.#dispatcher.fire('verify', this);
  201. this.#dispatcher.fire('verified', this);
  202. return this;
  203. }
  204.  
  205. /** Clears the container element. */
  206. clear() {
  207. this.logger.log('clear is deprecated');
  208. this.#container.innerHTML = '';
  209. }
  210.  
  211. /**
  212. * Attach a function to an eventType.
  213. * @param {string} eventType - Event type to connect with.
  214. * @param {NH.base.Dispatcher~Handler} func - Single argument function to
  215. * call.
  216. * @returns {Widget} - This instance, for chaining.
  217. */
  218. on(eventType, func) {
  219. this.#dispatcher.on(eventType, func);
  220. return this;
  221. }
  222.  
  223. /**
  224. * Remove all instances of a function registered to an eventType.
  225. * @param {string} eventType - Event type to disconnect from.
  226. * @param {NH.base.Dispatcher~Handler} func - Function to remove.
  227. * @returns {Widget} - This instance, for chaining.
  228. */
  229. off(eventType, func) {
  230. this.#dispatcher.off(eventType, func);
  231. return this;
  232. }
  233.  
  234. /**
  235. * Helper that sets an attribute to value.
  236. *
  237. * If value is null, the attribute is removed.
  238. * @example
  239. * w.attrText('aria-label', 'Information about the application.')
  240. * @param {string} attr - Name of the attribute.
  241. * @param {?string} value - Value to assign.
  242. * @returns {Widget} - This instance, for chaining.
  243. */
  244. attrText(attr, value) {
  245. if (value === null) {
  246. this.container.removeAttribute(attr);
  247. } else {
  248. this.container.setAttribute(attr, value);
  249. }
  250. return this;
  251. }
  252.  
  253. /**
  254. * Helper that sets an attribute to space separated {Element} ids.
  255. *
  256. * This will collect the appropriate id from each value passed then assign
  257. * that collection to the attribute. If any value is null, the everything
  258. * up to that point will be reset. If the collection ends up being empty
  259. * (e.g., no values were passed or the last was null), the attribute will
  260. * be removed.
  261. * @param {string} attr - Name of the attribute.
  262. * @param {?Content} values - Value to assign.
  263. * @returns {Widget} - This instance, for chaining.
  264. */
  265. attrElements(attr, ...values) {
  266. const strs = [];
  267. for (const value of values) {
  268. if (value === null) {
  269. strs.length = 0;
  270. } else if (typeof value === 'string' || value instanceof String) {
  271. strs.push(value);
  272. } else if (value instanceof HTMLElement) {
  273. if (value.id) {
  274. strs.push(value.id);
  275. }
  276. } else if (value instanceof Widget) {
  277. if (value.container.id) {
  278. strs.push(value.container.id);
  279. }
  280. }
  281. }
  282. if (strs.length) {
  283. this.container.setAttribute(attr, strs.join(' '));
  284. } else {
  285. this.container.removeAttribute(attr);
  286. }
  287. return this;
  288. }
  289.  
  290. /**
  291. * Install a style if not already present.
  292. *
  293. * It will NOT overwrite an existing one.
  294. * @param {string} id - Base to use for the style id.
  295. * @param {string[]} rules - CSS rules in 'selector { declarations }'.
  296. * @returns {HTMLStyleElement} - Resulting <style> element.
  297. */
  298. installStyle(id, rules) {
  299. const me = 'installStyle';
  300. this.logger.entered(me, id, rules);
  301.  
  302. const safeId = `${NH.base.safeId(id)}-style`;
  303. let style = document.querySelector(`#${safeId}`);
  304. if (!style) {
  305. style = document.createElement('style');
  306. style.id = safeId;
  307. style.textContent = rules.join('\n');
  308. document.head.append(style);
  309. }
  310.  
  311. this.logger.leaving(me, style);
  312. return style;
  313. }
  314.  
  315. static #knownEvents = [
  316. 'build',
  317. 'built',
  318. 'verify',
  319. 'verified',
  320. 'destroy',
  321. 'destroyed',
  322. 'show',
  323. 'showed',
  324. 'hide',
  325. 'hidden',
  326. ];
  327.  
  328. #container
  329. #dispatcher
  330. #id
  331. #logger
  332. #name
  333. #visible
  334.  
  335. }
  336.  
  337. /* eslint-disable require-jsdoc */
  338. class Test extends Widget {
  339.  
  340. constructor() {
  341. super('test', 'section');
  342. }
  343.  
  344. }
  345. /* eslint-enable */
  346.  
  347. /* eslint-disable max-statements */
  348. /* eslint-disable no-magic-numbers */
  349. /* eslint-disable no-new */
  350. /* eslint-disable require-jsdoc */
  351. class WidgetTestCase extends NH.xunit.TestCase {
  352.  
  353. testAbstract() {
  354. this.assertRaises(TypeError, () => {
  355. new Widget();
  356. });
  357. }
  358.  
  359. testProperties() {
  360. // Assemble
  361. const w = new Test();
  362.  
  363. // Assert
  364. this.assertTrue(w.container instanceof HTMLElement, 'element');
  365. this.assertRegExp(w.container.id, /^Test.*-container$/u, 'container');
  366.  
  367. this.assertRegExp(w.id, /^Test-test.*-.*-/u, 'id');
  368. this.assertTrue(w.logger instanceof NH.base.Logger, 'logger');
  369. this.assertEqual(w.name, 'Test test', 'name');
  370. }
  371.  
  372. testSimpleEvents() {
  373. // Assemble
  374. const calls = [];
  375. const cb = (...rest) => {
  376. calls.push(rest);
  377. };
  378. const w = new Test()
  379. .on('build', cb)
  380. .on('built', cb)
  381. .on('verify', cb)
  382. .on('verified', cb)
  383. .on('destroy', cb)
  384. .on('destroyed', cb)
  385. .on('show', cb)
  386. .on('showed', cb)
  387. .on('hide', cb)
  388. .on('hidden', cb);
  389.  
  390. // Act
  391. w.build()
  392. .show()
  393. .hide()
  394. .destroy();
  395.  
  396. // Assert
  397. this.assertEqual(calls, [
  398. ['build', w],
  399. ['built', w],
  400. // After build()
  401. ['verify', w],
  402. ['verified', w],
  403. // Before show()
  404. ['verify', w],
  405. ['verified', w],
  406. ['show', w],
  407. ['showed', w],
  408. ['hide', w],
  409. ['hidden', w],
  410. ['destroy', w],
  411. ['destroyed', w],
  412. ]);
  413. }
  414.  
  415. testDestroyCleans() {
  416. // Assemble
  417. const w = new Test();
  418. // XXX: Broken HTML on purpose
  419. w.container.innerHTML = '<p>Paragraph<p>';
  420.  
  421. this.assertEqual(w.container.innerHTML,
  422. '<p>Paragraph</p><p></p>',
  423. 'html got fixed');
  424. this.assertEqual(w.container.children.length, 2, 'initial count');
  425.  
  426. // Act
  427. w.destroy();
  428.  
  429. // Assert
  430. this.assertEqual(w.container.children.length, 0, 'post destroy count');
  431. }
  432.  
  433. testHideShow() {
  434. // Assemble
  435. const w = new Test();
  436.  
  437. this.assertTrue(w.visible, 'init vis');
  438. this.assertFalse(w.container.classList.contains(Widget.classHidden),
  439. 'init class');
  440.  
  441. w.hide();
  442.  
  443. this.assertFalse(w.visible, 'hide vis');
  444. this.assertTrue(w.container.classList.contains(Widget.classHidden),
  445. 'hide class');
  446.  
  447. w.show();
  448.  
  449. this.assertTrue(w.visible, 'show viz');
  450. this.assertFalse(w.container.classList.contains(Widget.classHidden),
  451. 'show class');
  452. }
  453.  
  454. testVerifyFails() {
  455. // Assemble
  456. const calls = [];
  457. const cb = (...rest) => {
  458. calls.push(rest);
  459. };
  460. const onVerify = () => {
  461. throw new VerificationError('oopsie');
  462. };
  463. const w = new Test()
  464. .on('build', cb)
  465. .on('verify', onVerify)
  466. .on('show', cb);
  467.  
  468. // Act/Assert
  469. this.assertRaises(
  470. VerificationError,
  471. () => {
  472. w.build()
  473. .show();
  474. },
  475. 'verify fails on purpose'
  476. );
  477. this.assertEqual(calls, [['build', w]], 'we made it past build');
  478. }
  479.  
  480. testOnOff() {
  481. // Assemble
  482. const calls = [];
  483. const cb = (...rest) => {
  484. calls.push(rest);
  485. };
  486. const w = new Test()
  487. .on('build', cb)
  488. .on('built', cb)
  489. .on('destroyed', cb)
  490. .off('build', cb)
  491. .on('destroy', cb)
  492. .off('destroyed', cb);
  493.  
  494. // Act
  495. w.build()
  496. .hide()
  497. .show()
  498. .destroy();
  499.  
  500. // Assert
  501. this.assertEqual(calls, [
  502. ['built', w],
  503. ['destroy', w],
  504. ]);
  505. }
  506.  
  507. testAttrText() {
  508. // Assemble
  509. const attr = 'aria-label';
  510. const w = new Test();
  511.  
  512. function f() {
  513. return w.container.getAttribute(attr);
  514. }
  515.  
  516. this.assertEqual(f(), null, 'init does not exist');
  517.  
  518. // First value
  519. w.attrText(attr, 'App info.');
  520. this.assertEqual(f(), 'App info.', 'exists');
  521.  
  522. // Change
  523. w.attrText(attr, 'Different value');
  524. this.assertEqual(f(), 'Different value', 'post change');
  525.  
  526. // Empty string
  527. w.attrText(attr, '');
  528. this.assertEqual(f(), '', 'empty string');
  529.  
  530. // Remove
  531. w.attrText(attr, null);
  532. this.assertEqual(f(), null, 'now gone');
  533. }
  534.  
  535. testAttrElements() {
  536. const attr = 'aria-labelledby';
  537. const text = 'id1 id2';
  538. const div = document.createElement('div');
  539. div.id = 'div-id';
  540. const w = new Test();
  541. w.container.id = 'w-id';
  542.  
  543. function g() {
  544. return w.container.getAttribute(attr);
  545. }
  546.  
  547. this.assertEqual(g(), null, 'init does not exist');
  548.  
  549. // Single value
  550. w.attrElements(attr, 'bob');
  551. this.assertEqual(g(), 'bob', 'single value');
  552.  
  553. // Replace with spaces
  554. w.attrElements(attr, text);
  555. this.assertEqual(g(), 'id1 id2', 'spaces');
  556.  
  557. // Remove
  558. w.attrElements(attr, null);
  559. this.assertEqual(g(), null, 'first remove');
  560.  
  561. // Multiple values of different types
  562. w.attrElements(attr, text, div, w);
  563. this.assertEqual(g(), 'id1 id2 div-id w-id', 'everything');
  564.  
  565. // Duplicates
  566. w.attrElements(attr, text, text);
  567. this.assertEqual(g(), 'id1 id2 id1 id2', 'duplicates');
  568.  
  569. // Null in the middle
  570. w.attrElements(attr, w, null, text, null, text);
  571. this.assertEqual(g(), 'id1 id2', 'mid null');
  572.  
  573. // Null at the end
  574. w.attrElements(attr, text, w, div, null);
  575. this.assertEqual(g(), null, 'end null');
  576. }
  577.  
  578. }
  579. /* eslint-enable */
  580.  
  581. NH.xunit.testing.testCases.push(WidgetTestCase);
  582.  
  583. /**
  584. * An adapter for raw HTML.
  585. *
  586. * Other Widgets may use this to wrap any HTML they may be handed so they do
  587. * not need to special case their implementation outside of construction.
  588. */
  589. class StringAdapter extends Widget {
  590.  
  591. /**
  592. * @param {string} name - Name for this instance.
  593. * @param {string} content - Item to be adapted.
  594. */
  595. constructor(name, content) {
  596. super(name, 'content');
  597. this.#content = content;
  598. this.on('build', this.#onBuild);
  599. }
  600.  
  601. #content
  602.  
  603. #onBuild = (...rest) => {
  604. const me = 'onBuild';
  605. this.logger.entered(me, rest);
  606.  
  607. this.container.innerHTML = this.#content;
  608.  
  609. this.logger.leaving(me);
  610. }
  611.  
  612. }
  613.  
  614. /* eslint-disable no-new-wrappers */
  615. /* eslint-disable require-jsdoc */
  616. class StringAdapterTestCase extends NH.xunit.TestCase {
  617.  
  618. testPrimitiveString() {
  619. // Assemble
  620. let p = '<p id="bob">This is my paragraph.</p>';
  621. const content = new StringAdapter(this.id, p);
  622.  
  623. // Act
  624. content.build();
  625.  
  626. // Assert
  627. this.assertTrue(content.container instanceof HTMLUnknownElement,
  628. 'is HTMLUnknownElement');
  629. this.assertTrue((/my paragraph./u).test(content.container.innerText),
  630. 'expected text');
  631. this.assertEqual(content.container.firstChild.tagName, 'P', 'is para');
  632. this.assertEqual(content.container.firstChild.id, 'bob', 'is bob');
  633.  
  634. // Tweak
  635. content.container.firstChild.id = 'joe';
  636. this.assertNotEqual(content.container.firstChild.id, 'bob', 'not bob');
  637.  
  638. // Rebuild
  639. content.build();
  640. this.assertEqual(content.container.firstChild.id, 'bob', 'bob again');
  641.  
  642. // Tweak - Not a live string
  643. p = '<p id="changed">New para.</p>';
  644. this.assertEqual(content.container.firstChild.id, 'bob', 'still bob');
  645. }
  646.  
  647. testStringObject() {
  648. // Assemble
  649. const p = new String('<p id="pat">This is my paragraph.</p>');
  650. const content = new StringAdapter(this.id, p);
  651.  
  652. // Act
  653. content.build();
  654. // Assert
  655. this.assertTrue(content.container instanceof HTMLUnknownElement,
  656. 'is HTMLUnknownElement');
  657. this.assertTrue((/my paragraph./u).test(content.container.innerText),
  658. 'expected text');
  659. this.assertEqual(content.container.firstChild.tagName, 'P', 'is para');
  660. this.assertEqual(content.container.firstChild.id, 'pat', 'is pat');
  661. }
  662.  
  663. }
  664. /* eslint-enable */
  665.  
  666. NH.xunit.testing.testCases.push(StringAdapterTestCase);
  667.  
  668. /**
  669. * An adapter for HTMLElement.
  670. *
  671. * Other Widgets may use this to wrap any HTMLElements they may be handed so
  672. * they do not need to special case their implementation outside of
  673. * construction.
  674. */
  675. class ElementAdapter extends Widget {
  676.  
  677. /**
  678. * @param {string} name - Name for this instance.
  679. * @param {HTMLElement} content - Item to be adapted.
  680. */
  681. constructor(name, content) {
  682. super(name, 'content');
  683. this.#content = content;
  684. this.on('build', this.#onBuild);
  685. }
  686.  
  687. #content
  688.  
  689. #onBuild = (...rest) => {
  690. const me = 'onBuild';
  691. this.logger.entered(me, rest);
  692.  
  693. this.container.replaceChildren(this.#content);
  694.  
  695. this.logger.leaving(me);
  696. }
  697.  
  698. }
  699. /* eslint-disable require-jsdoc */
  700. class ElementAdapterTestCase extends NH.xunit.TestCase {
  701.  
  702. testElement() {
  703. // Assemble
  704. const div = document.createElement('div');
  705. div.id = 'pat';
  706. div.innerText = 'I am a div.';
  707. const content = new ElementAdapter(this.id, div);
  708.  
  709. // Act
  710. content.build();
  711.  
  712. // Assert
  713. this.assertTrue(content.container instanceof HTMLUnknownElement,
  714. 'is HTMLUnknownElement');
  715. this.assertTrue((/I am a div./u).test(content.container.innerText),
  716. 'expected text');
  717. this.assertEqual(content.container.firstChild.tagName, 'DIV', 'is div');
  718. this.assertEqual(content.container.firstChild.id, 'pat', 'is pat');
  719.  
  720. // Tweak
  721. content.container.firstChild.id = 'joe';
  722. this.assertNotEqual(content.container.firstChild.id, 'pat', 'not pat');
  723. this.assertEqual(div.id, 'joe', 'demos is a live element');
  724.  
  725. // Rebuild
  726. content.build();
  727. this.assertEqual(content.container.firstChild.id, 'joe', 'still joe');
  728.  
  729. // Multiple times
  730. content.build();
  731. content.build();
  732. content.build();
  733. this.assertEqual(content.container.childNodes.length, 1, 'child nodes');
  734. }
  735.  
  736. }
  737. /* eslint-enable */
  738.  
  739. NH.xunit.testing.testCases.push(ElementAdapterTestCase);
  740.  
  741. /**
  742. * Selects the best adapter to wrap the content.
  743. * @param {string} name - Name for this instance.
  744. * @param {Content} content - Content to be adapted.
  745. * @throws {TypeError} - On type not handled.
  746. * @returns {Widget} - Appropriate adapter for content.
  747. */
  748. function contentWrapper(name, content) {
  749. if (typeof content === 'string' || content instanceof String) {
  750. return new StringAdapter(name, content);
  751. } else if (content instanceof HTMLElement) {
  752. return new ElementAdapter(name, content);
  753. } else if (content instanceof Widget) {
  754. return content;
  755. }
  756. throw new TypeError(`Unknown type for "${name}": ${content}`);
  757. }
  758.  
  759. /* eslint-disable no-magic-numbers */
  760. /* eslint-disable no-new-wrappers */
  761. /* eslint-disable require-jsdoc */
  762. class ContentWrapperTestCase extends NH.xunit.TestCase {
  763.  
  764. testPrimitiveString() {
  765. const x = contentWrapper(this.id, 'a string');
  766.  
  767. this.assertTrue(x instanceof StringAdapter);
  768. }
  769.  
  770. testStringObject() {
  771. const x = contentWrapper(this.id, new String('a string'));
  772.  
  773. this.assertTrue(x instanceof StringAdapter);
  774. }
  775.  
  776. testElement() {
  777. const element = document.createElement('div');
  778. const x = contentWrapper(this.id, element);
  779.  
  780. this.assertTrue(x instanceof ElementAdapter);
  781. }
  782.  
  783. testWidget() {
  784. const t = new Test();
  785. const x = contentWrapper(this.id, t);
  786.  
  787. this.assertEqual(x, t);
  788. }
  789.  
  790. testUnknown() {
  791. this.assertRaises(
  792. TypeError,
  793. () => {
  794. contentWrapper(this.id, null);
  795. },
  796. 'null'
  797. );
  798.  
  799. this.assertRaises(
  800. TypeError,
  801. () => {
  802. contentWrapper(this.id, 5);
  803. },
  804. 'int'
  805. );
  806.  
  807. this.assertRaises(
  808. TypeError,
  809. () => {
  810. contentWrapper(this.id, new Error('why not?'));
  811. },
  812. 'error-type'
  813. );
  814. }
  815.  
  816. }
  817. /* eslint-enable */
  818.  
  819. NH.xunit.testing.testCases.push(ContentWrapperTestCase);
  820.  
  821. /**
  822. * Implements the Layout pattern.
  823. */
  824. class Layout extends Widget {
  825.  
  826. /** @param {string} name - Name for this instance. */
  827. constructor(name) {
  828. super(name, 'div');
  829. this.on('build', this.#onBuild);
  830. for (const panel of Layout.#Panel.known) {
  831. this.set(panel, '');
  832. }
  833. }
  834.  
  835. /** @type {Widget} */
  836. get bottom() {
  837. return this.#panels.get(Layout.BOTTOM);
  838. }
  839.  
  840. /** @type {Widget} */
  841. get left() {
  842. return this.#panels.get(Layout.LEFT);
  843. }
  844.  
  845. /** @type {Widget} */
  846. get main() {
  847. return this.#panels.get(Layout.MAIN);
  848. }
  849.  
  850. /** @type {Widget} */
  851. get right() {
  852. return this.#panels.get(Layout.RIGHT);
  853. }
  854.  
  855. /** @type {Widget} */
  856. get top() {
  857. return this.#panels.get(Layout.TOP);
  858. }
  859.  
  860. /**
  861. * Sets a panel for this instance.
  862. *
  863. * @param {Layout.#Panel} panel - Panel to set.
  864. * @param {Content} content - Content to use.
  865. * @returns {Widget} - This instance, for chaining.
  866. */
  867. set(panel, content) {
  868. if (!(panel instanceof Layout.#Panel)) {
  869. throw new TypeError('"panel" argument is not a Layout.#Panel');
  870. }
  871.  
  872. this.#panels.set(panel,
  873. contentWrapper(`${panel} panel content`, content));
  874.  
  875. return this;
  876. }
  877.  
  878. /** Panel enum. */
  879. static #Panel = class {
  880.  
  881. /** @param {string} name - Panel name. */
  882. constructor(name) {
  883. this.#name = name;
  884.  
  885. Layout.#Panel.known.add(this);
  886. }
  887.  
  888. static known = new Set();
  889.  
  890. /** @returns {string} - The name. */
  891. toString() {
  892. return this.#name;
  893. }
  894.  
  895. #name
  896.  
  897. }
  898.  
  899. static {
  900. Layout.BOTTOM = new Layout.#Panel('bottom');
  901. Layout.LEFT = new Layout.#Panel('left');
  902. Layout.MAIN = new Layout.#Panel('main');
  903. Layout.RIGHT = new Layout.#Panel('right');
  904. Layout.TOP = new Layout.#Panel('top');
  905. }
  906.  
  907. #panels = new Map();
  908.  
  909. #onBuild = (...rest) => {
  910. const me = 'onBuild';
  911. this.logger.entered(me, rest);
  912.  
  913. this.top.build();
  914. this.left.build();
  915. this.main.build();
  916. this.right.build();
  917. this.bottom.build();
  918.  
  919. const middle = document.createElement('div');
  920. middle.append(
  921. this.left.container, this.main.container, this.right.container
  922. );
  923. this.container.replaceChildren(
  924. this.top.container, middle, this.bottom.container
  925. );
  926.  
  927. this.logger.leaving(me);
  928. }
  929.  
  930. }
  931.  
  932. /* eslint-disable require-jsdoc */
  933. /* eslint-disable no-undefined */
  934. class LayoutTestCase extends NH.xunit.TestCase {
  935.  
  936. testIsDiv() {
  937. // Assemble
  938. const w = new Layout(this.id);
  939.  
  940. // Assert
  941. this.assertEqual(w.container.tagName, 'DIV', 'correct element');
  942. }
  943.  
  944. testPanelsStartSimple() {
  945. // Assemble
  946. const w = new Layout(this.id);
  947.  
  948. // Assert
  949. this.assertTrue(w.main instanceof Widget, 'main');
  950. this.assertRegExp(w.main.name, / main panel content/u, 'main name');
  951. this.assertTrue(w.top instanceof Widget, 'top');
  952. this.assertRegExp(w.top.name, / top panel content/u, 'top name');
  953. this.assertTrue(w.bottom instanceof Widget, 'bottom');
  954. this.assertTrue(w.left instanceof Widget, 'left');
  955. this.assertTrue(w.right instanceof Widget, 'right');
  956. }
  957.  
  958. testSetWorks() {
  959. // Assemble
  960. const w = new Layout(this.id);
  961.  
  962. // Act
  963. w.set(Layout.MAIN, 'main')
  964. .set(Layout.TOP, document.createElement('div'));
  965.  
  966. // Assert
  967. this.assertTrue(w.main instanceof Widget, 'main');
  968. this.assertEqual(
  969. w.main.name, 'StringAdapter main panel content', 'main name'
  970. );
  971. this.assertTrue(w.top instanceof Widget, 'top');
  972. this.assertEqual(
  973. w.top.name, 'ElementAdapter top panel content', 'top name'
  974. );
  975. }
  976.  
  977. testSetRequiresPanel() {
  978. // Assemble
  979. const w = new Layout(this.id);
  980.  
  981. // Act/Assert
  982. this.assertRaises(
  983. TypeError,
  984. () => {
  985. w.set('main', 'main');
  986. }
  987. );
  988. }
  989.  
  990. testDefaultBuilds() {
  991. // Assemble
  992. const w = new Layout(this.id);
  993.  
  994. // Act
  995. w.build();
  996.  
  997. // Assert
  998. const expected = [
  999. '<content.*top-panel.*></content>',
  1000. '<div>',
  1001. '<content.*left-panel.*></content>',
  1002. '<content.*main-panel.*></content>',
  1003. '<content.*right-panel.*></content>',
  1004. '</div>',
  1005. '<content.*bottom-panel.*></content>',
  1006. ].join('');
  1007. this.assertRegExp(w.container.innerHTML, RegExp(expected, 'u'));
  1008. }
  1009.  
  1010. testWithContentBuilds() {
  1011. // Assemble
  1012. const w = new Layout(this.id);
  1013. w.set(Layout.MAIN, 'main')
  1014. .set(Layout.TOP, 'top')
  1015. .set(Layout.BOTTOM, 'bottom')
  1016. .set(Layout.RIGHT, 'right')
  1017. .set(Layout.LEFT, 'left');
  1018.  
  1019. // Act
  1020. w.build();
  1021.  
  1022. // Assert
  1023. this.assertEqual(w.container.innerText, 'topleftmainrightbottom');
  1024. }
  1025.  
  1026. }
  1027. /* eslint-enable */
  1028.  
  1029. NH.xunit.testing.testCases.push(LayoutTestCase);
  1030.  
  1031. /**
  1032. * Implements the Modal pattern.
  1033. *
  1034. * Modal widgets should have exactly one of the `aria-labelledby` or
  1035. * `aria-label` attributes.
  1036. *
  1037. * Modal widgets can use `aria-describedby` to reference an element that
  1038. * describes the purpose if not clear from the initial content.
  1039. */
  1040. class Modal extends Widget {
  1041.  
  1042. /** @param {string} name - Name for this instance. */
  1043. constructor(name) {
  1044. super(name, 'dialog');
  1045. this.on('build', this.#onBuild)
  1046. .on('destroy', this.#onDestroy)
  1047. .on('verify', this.#onVerify)
  1048. .on('show', this.#onShow)
  1049. .on('hide', this.#onHide);
  1050.  
  1051. this.hide();
  1052. }
  1053.  
  1054. /**
  1055. * Sets the content of this instance.
  1056. * @param {Content} content - Content to use.
  1057. * @returns {Widget} - This instance, for chaining.
  1058. */
  1059. set(content) {
  1060. this.#content = contentWrapper('modal content', content);
  1061. return this;
  1062. }
  1063.  
  1064. #content
  1065.  
  1066. #onBuild = (...rest) => {
  1067. const me = 'onBuild';
  1068. this.logger.entered(me, rest);
  1069.  
  1070. this.#content?.build();
  1071. this.container.replaceChildren(this.#content?.container);
  1072.  
  1073. this.logger.leaving(me);
  1074. }
  1075.  
  1076. #onDestroy = (...rest) => {
  1077. const me = 'onDestroy';
  1078. this.logger.entered(me, rest);
  1079.  
  1080. this.#content?.destroy();
  1081. this.#content = null;
  1082.  
  1083. this.logger.leaving(me);
  1084. }
  1085.  
  1086. #onVerify = (...rest) => {
  1087. const me = 'onVerify';
  1088. this.logger.entered(me, rest);
  1089.  
  1090. const labelledBy = this.container.getAttribute('aria-labelledby');
  1091. const label = this.container.getAttribute('aria-label');
  1092.  
  1093. if (!labelledBy && !label) {
  1094. throw new VerificationError(
  1095. `Modal "${this.name}" should have one of "aria-labelledby" ` +
  1096. 'or "aria-label" attributes'
  1097. );
  1098. }
  1099.  
  1100. if (labelledBy && label) {
  1101. throw new VerificationError(
  1102. `Modal "${this.name}" should not have both ` +
  1103. `"aria-labelledby=${labelledBy}" and "aria-label=${label}"`
  1104. );
  1105. }
  1106.  
  1107. this.logger.leaving(me);
  1108. }
  1109.  
  1110. #onShow = (...rest) => {
  1111. const me = 'onShow';
  1112. this.logger.entered(me, rest);
  1113.  
  1114. this.container.showModal();
  1115. this.#content?.show();
  1116.  
  1117. this.logger.leaving(me);
  1118. }
  1119.  
  1120. #onHide = (...rest) => {
  1121. const me = 'onHide';
  1122. this.logger.entered(me, rest);
  1123.  
  1124. this.#content?.hide();
  1125. this.container.close();
  1126.  
  1127. this.logger.leaving(me);
  1128. }
  1129.  
  1130. }
  1131.  
  1132. /* eslint-disable require-jsdoc */
  1133. class ModalTestCase extends NH.xunit.TestCase {
  1134.  
  1135. testIsDialog() {
  1136. // Assemble
  1137. const w = new Modal(this.id);
  1138.  
  1139. // Assert
  1140. this.assertEqual(w.container.tagName, 'DIALOG', 'correct element');
  1141. this.assertFalse(w.visible, 'visibility');
  1142. }
  1143.  
  1144. testCallsNestedWidget() {
  1145. // Assemble
  1146. const calls = [];
  1147. const cb = (...rest) => {
  1148. calls.push(rest);
  1149. };
  1150. const w = new Modal(this.id)
  1151. .attrText('aria-label', 'test widget');
  1152. const nest = contentWrapper(this.id, 'test content');
  1153.  
  1154. nest.on('build', cb)
  1155. .on('destroy', cb)
  1156. .on('show', cb)
  1157. .on('hide', cb);
  1158.  
  1159. // Act
  1160. w.set(nest)
  1161. .build()
  1162. .hide()
  1163. .destroy();
  1164.  
  1165. // Assert
  1166. this.assertEqual(calls, [
  1167. ['build', nest],
  1168. ['hide', nest],
  1169. ['destroy', nest],
  1170. ]);
  1171. }
  1172.  
  1173. testVerify() {
  1174. // Assemble
  1175. const w = new Modal(this.id);
  1176.  
  1177. // Assert
  1178. this.assertRaisesRegExp(
  1179. VerificationError,
  1180. /should have one of/u,
  1181. () => {
  1182. w.build();
  1183. },
  1184. 'no aria attributes'
  1185. );
  1186.  
  1187. // Add labelledby
  1188. w.attrText('aria-labelledby', 'some-element');
  1189. this.assertNoRaises(() => {
  1190. w.build();
  1191. }, 'post add aria-labelledby');
  1192.  
  1193. // Add label
  1194. w.attrText('aria-label', 'test modal');
  1195. this.assertRaisesRegExp(
  1196. VerificationError,
  1197. /should not have both.*some-element/u,
  1198. () => {
  1199. w.build();
  1200. },
  1201. 'both aria attributes'
  1202. );
  1203.  
  1204. // Remove labelledby
  1205. w.attrText('aria-labelledby', null);
  1206. this.assertNoRaises(() => {
  1207. w.build();
  1208. }, 'post remove aria-labelledby');
  1209. }
  1210.  
  1211. }
  1212. /* eslint-enable */
  1213.  
  1214. NH.xunit.testing.testCases.push(ModalTestCase);
  1215.  
  1216. /**
  1217. * A widget that can be opened and closed on demand, designed for fairly
  1218. * persistent information.
  1219. *
  1220. * The element will get `open` and `close` events.
  1221. */
  1222. class Info extends Widget {
  1223.  
  1224. /** @param {string} name - Name for this instance. */
  1225. constructor(name) {
  1226. super(name, 'dialog');
  1227. this.logger.log(`${this.name} constructed`);
  1228. }
  1229.  
  1230. /** Open the widget. */
  1231. open() {
  1232. this.container.showModal();
  1233. this.container.dispatchEvent(new Event('open'));
  1234. }
  1235.  
  1236. /** Close the widget. */
  1237. close() {
  1238. // HTMLDialogElement sends a close event natively.
  1239. this.container.close();
  1240. }
  1241.  
  1242. }
  1243.  
  1244. return {
  1245. version: version,
  1246. Widget: Widget,
  1247. Modal: Modal,
  1248. Info: Info,
  1249. };
  1250.  
  1251. }());

QingJ © 2025

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