From c5c92b71135eaf8cbfb4cba2031d3b89b86f4943 Mon Sep 17 00:00:00 2001 From: Ben Thomson Date: Thu, 7 Sep 2023 09:14:09 +0800 Subject: [PATCH] Change mounting and template definition This creates more flexibility on mounting, by allowing a developer to explicity provide or not provide a mount point and (optionally) a template. If a mount point is not provided, mounting is deferred until the developer chooses to. Otherwise, the mount point either acts as a container or as the reactive element, depending on the template. --- .../src/abstracts/ReactivePluginBase.js | 6 + .../src/abstracts/ReactiveSingleton.js | 4 + packages/reactivity/src/abstracts/shared.js | 196 +++++++++++++----- .../abstracts/ReactivePluginBase.test.js | 9 +- .../reactivity/tests/fixtures/TestList.js | 4 + .../tests/fixtures/TestPreExisting.js | 5 +- .../tests/fixtures/TestPreExistingTemplate.js | 23 ++ .../tests/fixtures/TestReactivePlugin.js | 4 + .../tests/fixtures/TestReactiveSingleton.js | 4 + .../tests/fixtures/TestSnowboardTemplate.js | 4 + 10 files changed, 198 insertions(+), 61 deletions(-) create mode 100644 packages/reactivity/tests/fixtures/TestPreExistingTemplate.js diff --git a/packages/reactivity/src/abstracts/ReactivePluginBase.js b/packages/reactivity/src/abstracts/ReactivePluginBase.js index 5590a98..484009b 100644 --- a/packages/reactivity/src/abstracts/ReactivePluginBase.js +++ b/packages/reactivity/src/abstracts/ReactivePluginBase.js @@ -2,6 +2,8 @@ import { PluginBase } from '@wintercms/snowboard'; import { reactivityConstructor, reactivityMount, + template, + mountTo, } from './shared'; /** @@ -16,6 +18,8 @@ import { * * @copyright 2023 Winter. * @author Ben Thomson + * @abstract + * @mixes Reactivity */ class ReactivePluginBase extends PluginBase { constructor(snowboard) { @@ -28,5 +32,7 @@ class ReactivePluginBase extends PluginBase { } ReactivePluginBase.prototype.$mount = reactivityMount; +ReactivePluginBase.prototype.template = template; +ReactivePluginBase.prototype.mountTo = mountTo; export default ReactivePluginBase; diff --git a/packages/reactivity/src/abstracts/ReactiveSingleton.js b/packages/reactivity/src/abstracts/ReactiveSingleton.js index 1f35f13..13b86ca 100644 --- a/packages/reactivity/src/abstracts/ReactiveSingleton.js +++ b/packages/reactivity/src/abstracts/ReactiveSingleton.js @@ -2,6 +2,8 @@ import { Singleton } from '@wintercms/snowboard'; import { reactivityConstructor, reactivityMount, + template, + mountTo, } from './shared'; /** @@ -29,5 +31,7 @@ class ReactiveSingleton extends Singleton { } ReactiveSingleton.prototype.$mount = reactivityMount; +ReactiveSingleton.prototype.template = template; +ReactiveSingleton.prototype.mountTo = mountTo; export default ReactiveSingleton; diff --git a/packages/reactivity/src/abstracts/shared.js b/packages/reactivity/src/abstracts/shared.js index c831638..628a64e 100644 --- a/packages/reactivity/src/abstracts/shared.js +++ b/packages/reactivity/src/abstracts/shared.js @@ -1,80 +1,112 @@ import { createApp, reactive } from 'petite-vue'; /** - * Mount reactivity to the DOM. - * - * Finally, the reactivity will be mounted to the DOM on the given element (or template), and will - * then be created as an application in Vue. It will then be ready for use. + * @typedef {import('../abstracts/ReactivePluginBase').default} ReactivePluginBase + * @typedef {import('../abstracts/ReactiveSingleton').default} ReactiveSingleton */ -function reactivityMount(element, parent = null) { - if (this.$reactive) { - throw new Error('Reactivity already initialized for this instance'); - } - - let parentNode = parent; - - if (document.body.contains(element) && !parent) { - parentNode = element.parentNode; - } else if (!document.body.contains(element) && !parent) { - parentNode = document.body; - } - - if (!document.body.contains(element)) { - parentNode.appendChild(element); - } - - createApp(this.$data).mount(element); - this.$el = element; - - // Import next tick into plugin - this.$nextTick = this.$data.$nextTick; - - // Prevent reactivity from being reset - this.$reactive = true; - Object.defineProperty(this, '$reactive', { configurable: false, writable: false }); -} /** * Fetches the available template. * * If the object provides a template function or string, this will create a template and embed it - * into the DOM. + * to the mount point. * - * In the case of a function, the function may also return a DOM element directly, which will be - * used to mount directly to a specified element. + * In the case of a function, the function may also return a DOM element directly, in which case + * this element will be cloned and used as the template. + * + * @this {ReactiveSingleton|ReactivePluginBase} + * @return {HTMLElement} */ function reactivityTemplate() { - let template = null; - - if (typeof this.template === 'function') { - template = this.template(); - } else { - template = this.template; - } + const reactiveTemplate = this.template(); - if (!template) { - return; + if (!reactiveTemplate) { + throw new Error('You must either specify a template or a mount point'); } - if (typeof template === 'string') { + if (typeof reactiveTemplate === 'string') { const parser = new DOMParser(); - const rendered = parser.parseFromString(template, 'text/html'); + const rendered = parser.parseFromString(reactiveTemplate, 'text/html'); if (rendered.body.childElementCount > 1) { throw new Error('Template must only have one root node'); } if (rendered.body.firstElementChild instanceof HTMLTemplateElement) { throw new Error('A string template must not return a template element'); } - reactivityMount.call(this, rendered.body.firstElementChild); - } else if (template instanceof HTMLTemplateElement) { - if (template.content.childElementCount > 1) { + return rendered.body.firstElementChild; + } + if (reactiveTemplate instanceof HTMLTemplateElement) { + if (reactiveTemplate.content.childElementCount > 1) { throw new Error('Template must only have one root node'); } - const cloned = template.content.firstElementChild.cloneNode(true); - reactivityMount.call(this, cloned); - } else if (template instanceof HTMLElement) { - reactivityMount.call(this, template); + const cloned = reactiveTemplate.content.firstElementChild.cloneNode(true); + return cloned; + } + if (reactiveTemplate instanceof HTMLElement) { + const cloned = reactiveTemplate.cloneNode(true); + return cloned; } + + throw new Error('Invalid template - you must provide either a string, a template element or a HTML element'); +} + +/** + * Mount reactivity to the DOM. + * + * The reactivity will be mounted to the DOM on the given element (or template), and will + * then be created as an application in Vue. It will then be ready for use. + * + * It will use the mountTo() method to determine where the mounted element should be added to in + * the DOM, unless the mounted element already exists within the DOM. + * + * @this {ReactiveSingleton|ReactivePluginBase} + * @param {HTMLElement|null} element + * @return {void} + */ +function reactivityMount(element = null) { + if (this.$reactive) { + throw new Error('Reactivity already initialized for this instance'); + } + + let mountedElement = element; + + if (!element) { + const parentElement = this.mountTo(); + + if (parentElement !== document.body && !document.body.contains(parentElement)) { + throw new Error('The mount element must exist in the DOM'); + } + if (parentElement instanceof HTMLTemplateElement) { + throw new Error('You cannot mount to a template element'); + } + + if (!this.template()) { + mountedElement = parentElement; + } else { + mountedElement = reactivityTemplate.call(this); + + if (!mountedElement) { + throw new Error('No template or mountable element provided'); + } + + if (!document.body.contains(mountedElement)) { + if (!parentElement) { + throw new Error('You must specify a mount element with mountTo() if using a template.'); + } + parentElement.appendChild(mountedElement); + } + } + } + + createApp(this.$data).mount(mountedElement); + this.$el = mountedElement; + + // Import next tick into plugin + this.$nextTick = this.$data.$nextTick; + + // Prevent reactivity from being reset + this.$reactive = true; + Object.defineProperty(this, '$reactive', { configurable: false, writable: false }); } /** @@ -89,7 +121,8 @@ function reactivityTemplate() { * The returned object will be keyed by the property or method name, and the value will be the * definition of the method or property. * - * @return {Object} + * @this {ReactiveSingleton|ReactivePluginBase} + * @return {Object.} */ function reactivityGetProperties() { const mappable = {}; @@ -101,10 +134,11 @@ function reactivityGetProperties() { 'constructor', 'construct', 'init', - 'template', 'destruct', 'destructor', 'detach', + 'template', + 'mountTo', '$reactive', '$mount', '$el', @@ -165,7 +199,9 @@ function reactivityGetProperties() { * Taking the mappable properties, this converts them into an object that is then fed to Vue as the * data store for reactivity. * - * @return {Object} + * @this {ReactiveSingleton|ReactivePluginBase} + * @param {Object.} mappable + * @return {Object.} */ function reactivityCreateStore(mappable) { const obj = {}; @@ -203,6 +239,10 @@ function reactivityCreateStore(mappable) { * Once the mappable properties have been converted into a reactive data store, this method will * map the properties back to the original object. This allows for the properties and methods to * be accessed directly from the plugin instance and will react accordingly. + * + * @this {ReactiveSingleton|ReactivePluginBase} + * @param {Object.} mappable + * @return {void} */ function reactivityMapProperties(mappable) { Object.entries(mappable).forEach(([key, prop]) => { @@ -226,12 +266,17 @@ function reactivityMapProperties(mappable) { * * This provides the lifecycle workflow in order to initialize reactivity and make it available in * the plugin. + * + * @this {ReactiveSingleton|ReactivePluginBase} + * @return {void} */ function reactivityInitialize() { const mappable = reactivityGetProperties.call(this); this.$data = reactive(reactivityCreateStore.call(this, mappable)); reactivityMapProperties.call(this, mappable); - reactivityTemplate.call(this); + if (this.mountTo()) { + reactivityMount.call(this); + } } /** @@ -239,10 +284,17 @@ function reactivityInitialize() { * * This creates the necessary properties and modifies the constructor to initialize the Reactivity * functionality. + * + * @mixin Reactivity + * @this {ReactiveSingleton|ReactivePluginBase} + * @return {void} */ function reactivityConstructor() { + /** @var {boolean} Whether reactivity is enabled on this object */ this.$reactive = false; + /** @var {Object.} The reactive data store */ this.$data = {}; + /** @var {HTMLElement} The mounted element */ this.$el = null; // Wrap the instance's constructor to call the Reactivity initialisation after construct @@ -255,7 +307,39 @@ function reactivityConstructor() { } } +/** + * The HTML template of this plugin to use for reactivity. + * + * This can be used to define either a HTML template as a string, for reactive plugins that + * generate their own HTML, or alternatively, a HTML element to mount to. + * + * @returns + */ +function template() { + return null; +} + +/** + * Determines the mount point for the template. + * + * By default, reactive plugins will not auto-mount to the DOM. In these cases, you can manually + * mount the element by calling `this.$mount()` with the element you wish to mount to. + * + * If you set this to a HTML element, this plugin will do one of two things: + * + * - If this plugin has a template, it will append to the mount point as a child node and will + * mount to this child node. + * - If this plugin does not have a template, it will mount to the element directly. + * + * @returns {HTMLElement|null} + */ +function mountTo() { + return null; +} + export { reactivityConstructor, reactivityMount, + template, + mountTo, }; diff --git a/packages/reactivity/tests/abstracts/ReactivePluginBase.test.js b/packages/reactivity/tests/abstracts/ReactivePluginBase.test.js index b8aac0b..09ff9c7 100644 --- a/packages/reactivity/tests/abstracts/ReactivePluginBase.test.js +++ b/packages/reactivity/tests/abstracts/ReactivePluginBase.test.js @@ -4,6 +4,7 @@ import TestReactivePlugin from '../fixtures/TestReactivePlugin'; import TestList from '../fixtures/TestList'; import TestPreExisting from '../fixtures/TestPreExisting'; import TestDeferredMount from '../fixtures/TestDeferredMount'; +import TestPreExistingTemplate from '../fixtures/TestPreExistingTemplate'; describe('Snowboard Reactivity package', () => { beforeEach(() => { @@ -144,13 +145,13 @@ describe('Snowboard Reactivity package', () => { instance.show(); - nextTick(() => { + instance.$nextTick(() => { expect(div.querySelector('p')).not.toBeNull(); expect(div.querySelector('p').textContent).toBe('Hello'); instance.hide(); - nextTick(() => { + instance.$nextTick(() => { expect(div.querySelector('p')).toBeNull(); }); }); @@ -165,7 +166,7 @@ describe('Snowboard Reactivity package', () => { `; - Snowboard.addPlugin('testPreExisting', TestPreExisting); + Snowboard.addPlugin('testPreExisting', TestPreExistingTemplate); const instance = Snowboard.testPreExisting(); const div = instance.$el; @@ -198,7 +199,7 @@ describe('Snowboard Reactivity package', () => { `; - Snowboard.addPlugin('testPreExisting', TestPreExisting); + Snowboard.addPlugin('testPreExisting', TestPreExistingTemplate); expect(() => { Snowboard.testPreExisting(); diff --git a/packages/reactivity/tests/fixtures/TestList.js b/packages/reactivity/tests/fixtures/TestList.js index 9358717..c5eda70 100644 --- a/packages/reactivity/tests/fixtures/TestList.js +++ b/packages/reactivity/tests/fixtures/TestList.js @@ -19,6 +19,10 @@ export default class TestList extends ReactivePluginBase { `; } + mountTo() { + return document.body; + } + addName(name) { this.names.push(name); } diff --git a/packages/reactivity/tests/fixtures/TestPreExisting.js b/packages/reactivity/tests/fixtures/TestPreExisting.js index 055ba5c..a6f850f 100644 --- a/packages/reactivity/tests/fixtures/TestPreExisting.js +++ b/packages/reactivity/tests/fixtures/TestPreExisting.js @@ -3,7 +3,10 @@ import ReactivePluginBase from '../../src/abstracts/ReactivePluginBase'; export default class TestPreExisting extends ReactivePluginBase { construct() { this.shown = false; - this.template = document.querySelector('#test'); + } + + mountTo() { + return document.querySelector('#test'); } show() { diff --git a/packages/reactivity/tests/fixtures/TestPreExistingTemplate.js b/packages/reactivity/tests/fixtures/TestPreExistingTemplate.js new file mode 100644 index 0000000..ae84406 --- /dev/null +++ b/packages/reactivity/tests/fixtures/TestPreExistingTemplate.js @@ -0,0 +1,23 @@ +import ReactivePluginBase from '../../src/abstracts/ReactivePluginBase'; + +export default class TestPreExistingTemplate extends ReactivePluginBase { + construct() { + this.shown = false; + } + + template() { + return document.querySelector('#test'); + } + + mountTo() { + return document.body; + } + + show() { + this.shown = true; + } + + hide() { + this.shown = false; + } +} diff --git a/packages/reactivity/tests/fixtures/TestReactivePlugin.js b/packages/reactivity/tests/fixtures/TestReactivePlugin.js index 1c52a22..95e104a 100644 --- a/packages/reactivity/tests/fixtures/TestReactivePlugin.js +++ b/packages/reactivity/tests/fixtures/TestReactivePlugin.js @@ -15,6 +15,10 @@ export default class TestReactivePlugin extends ReactivePluginBase { `; } + mountTo() { + return document.body; + } + get countText() { return `Count: ${this.count}`; } diff --git a/packages/reactivity/tests/fixtures/TestReactiveSingleton.js b/packages/reactivity/tests/fixtures/TestReactiveSingleton.js index c447a74..c463836 100644 --- a/packages/reactivity/tests/fixtures/TestReactiveSingleton.js +++ b/packages/reactivity/tests/fixtures/TestReactiveSingleton.js @@ -15,6 +15,10 @@ export default class TestReactiveSingleton extends ReactiveSingleton { `; } + mountTo() { + return document.body; + } + get countText() { return `Count: ${this.count}`; } diff --git a/packages/reactivity/tests/fixtures/TestSnowboardTemplate.js b/packages/reactivity/tests/fixtures/TestSnowboardTemplate.js index 2c4a727..3b01f46 100644 --- a/packages/reactivity/tests/fixtures/TestSnowboardTemplate.js +++ b/packages/reactivity/tests/fixtures/TestSnowboardTemplate.js @@ -11,4 +11,8 @@ export default class TestSnowboardTemplate extends ReactiveSingleton { `; } + + mountTo() { + return document.body; + } }