Skip to content

Commit

Permalink
Change mounting and template definition
Browse files Browse the repository at this point in the history
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.
  • Loading branch information
bennothommo committed Sep 7, 2023
1 parent d296919 commit c5c92b7
Show file tree
Hide file tree
Showing 10 changed files with 198 additions and 61 deletions.
6 changes: 6 additions & 0 deletions packages/reactivity/src/abstracts/ReactivePluginBase.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { PluginBase } from '@wintercms/snowboard';
import {
reactivityConstructor,
reactivityMount,
template,
mountTo,
} from './shared';

/**
Expand All @@ -16,6 +18,8 @@ import {
*
* @copyright 2023 Winter.
* @author Ben Thomson <[email protected]>
* @abstract
* @mixes Reactivity
*/
class ReactivePluginBase extends PluginBase {
constructor(snowboard) {
Expand All @@ -28,5 +32,7 @@ class ReactivePluginBase extends PluginBase {
}

ReactivePluginBase.prototype.$mount = reactivityMount;
ReactivePluginBase.prototype.template = template;
ReactivePluginBase.prototype.mountTo = mountTo;

export default ReactivePluginBase;
4 changes: 4 additions & 0 deletions packages/reactivity/src/abstracts/ReactiveSingleton.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { Singleton } from '@wintercms/snowboard';
import {
reactivityConstructor,
reactivityMount,
template,
mountTo,
} from './shared';

/**
Expand Down Expand Up @@ -29,5 +31,7 @@ class ReactiveSingleton extends Singleton {
}

ReactiveSingleton.prototype.$mount = reactivityMount;
ReactiveSingleton.prototype.template = template;
ReactiveSingleton.prototype.mountTo = mountTo;

export default ReactiveSingleton;
196 changes: 140 additions & 56 deletions packages/reactivity/src/abstracts/shared.js
Original file line number Diff line number Diff line change
@@ -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 });
}

/**
Expand All @@ -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.<string, {type: string, value: any}>}
*/
function reactivityGetProperties() {
const mappable = {};
Expand All @@ -101,10 +134,11 @@ function reactivityGetProperties() {
'constructor',
'construct',
'init',
'template',
'destruct',
'destructor',
'detach',
'template',
'mountTo',
'$reactive',
'$mount',
'$el',
Expand Down Expand Up @@ -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.<string, {type: string, value: any}>} mappable
* @return {Object.<string, {type: string, value: PropertyDescriptor}>}
*/
function reactivityCreateStore(mappable) {
const obj = {};
Expand Down Expand Up @@ -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.<string, {type: string, value: any}>} mappable
* @return {void}
*/
function reactivityMapProperties(mappable) {
Object.entries(mappable).forEach(([key, prop]) => {
Expand All @@ -226,23 +266,35 @@ 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);
}
}

/**
* Constructor for the Reactivity functionality.
*
* 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.<string, any>} 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
Expand All @@ -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,
};
Original file line number Diff line number Diff line change
Expand Up @@ -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(() => {
Expand Down Expand Up @@ -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();
});
});
Expand All @@ -165,7 +166,7 @@ describe('Snowboard Reactivity package', () => {
</template>
`;

Snowboard.addPlugin('testPreExisting', TestPreExisting);
Snowboard.addPlugin('testPreExisting', TestPreExistingTemplate);

const instance = Snowboard.testPreExisting();
const div = instance.$el;
Expand Down Expand Up @@ -198,7 +199,7 @@ describe('Snowboard Reactivity package', () => {
</template>
`;

Snowboard.addPlugin('testPreExisting', TestPreExisting);
Snowboard.addPlugin('testPreExisting', TestPreExistingTemplate);

expect(() => {
Snowboard.testPreExisting();
Expand Down
4 changes: 4 additions & 0 deletions packages/reactivity/tests/fixtures/TestList.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@ export default class TestList extends ReactivePluginBase {
`;
}

mountTo() {
return document.body;
}

addName(name) {
this.names.push(name);
}
Expand Down
5 changes: 4 additions & 1 deletion packages/reactivity/tests/fixtures/TestPreExisting.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Loading

0 comments on commit c5c92b7

Please sign in to comment.