From 09c6a3c2405742ea6003e9a8a30e7ad212ab6977 Mon Sep 17 00:00:00 2001
From: Dan Brown <ssddanbrown@googlemail.com>
Date: Mon, 14 Nov 2022 23:19:02 +0000
Subject: [PATCH] Started refactor and alignment of component system

- Updates old components to newer format, removes legacy component
support.
- Makes component registration easier and less duplicated.
- Adds base component class to extend for better editor support.
- Aligns global window exposure usage and aligns with other service
  names.
---
 resources/js/app.js                         |   7 +-
 resources/js/components/add-remove-rows.js  |  10 +-
 resources/js/components/ajax-delete-row.js  |  11 +-
 resources/js/components/ajax-form.js        |  11 +-
 resources/js/components/attachments-list.js |   5 +-
 resources/js/components/attachments.js      |  11 +-
 resources/js/components/auto-submit.js      |   7 +-
 resources/js/components/auto-suggest.js     |   8 +-
 resources/js/components/back-to-top.js      |  19 +-
 resources/js/components/component.js        |  58 ++++
 resources/js/components/dropdown.js         |   2 +-
 resources/js/components/image-manager.js    |   4 +-
 resources/js/components/index.js            | 341 ++++----------------
 resources/js/components/page-comments.js    |   4 +-
 resources/js/components/page-display.js     |   2 +-
 resources/js/services/components.js         | 153 +++++++++
 resources/js/services/dom.js                |   2 +-
 resources/js/wysiwyg/plugin-codeeditor.js   |   2 +-
 resources/sass/_components.scss             |  37 +++
 resources/sass/styles.scss                  |  37 ---
 resources/views/layouts/base.blade.php      |   2 +-
 21 files changed, 355 insertions(+), 378 deletions(-)
 create mode 100644 resources/js/components/component.js
 create mode 100644 resources/js/services/components.js

diff --git a/resources/js/app.js b/resources/js/app.js
index 82748b75e..e49bf5e95 100644
--- a/resources/js/app.js
+++ b/resources/js/app.js
@@ -27,5 +27,8 @@ window.trans_choice = translator.getPlural.bind(translator);
 window.trans_plural = translator.parsePlural.bind(translator);
 
 // Load Components
-import components from "./components"
-components();
\ No newline at end of file
+import * as components from "./services/components"
+import * as componentMap from "./components";
+components.register(componentMap);
+window.$components = components;
+components.init();
diff --git a/resources/js/components/add-remove-rows.js b/resources/js/components/add-remove-rows.js
index 9a5f019c5..19d2249fb 100644
--- a/resources/js/components/add-remove-rows.js
+++ b/resources/js/components/add-remove-rows.js
@@ -1,13 +1,13 @@
 import {onChildEvent} from "../services/dom";
 import {uniqueId} from "../services/util";
+import {Component} from "./component";
 
 /**
  * AddRemoveRows
  * Allows easy row add/remove controls onto a table.
  * Needs a model row to use when adding a new row.
- * @extends {Component}
  */
-class AddRemoveRows {
+export class AddRemoveRows extends Component {
     setup() {
         this.modelRow = this.$refs.model;
         this.addButton = this.$refs.add;
@@ -31,7 +31,7 @@ class AddRemoveRows {
         clone.classList.remove('hidden');
         this.setClonedInputNames(clone);
         this.modelRow.parentNode.insertBefore(clone, this.modelRow);
-        window.components.init(clone);
+        window.$components.init(clone);
     }
 
     /**
@@ -49,6 +49,4 @@ class AddRemoveRows {
             elem.name = elem.name.split('randrowid').join(rowId);
         }
     }
-}
-
-export default AddRemoveRows;
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/resources/js/components/ajax-delete-row.js b/resources/js/components/ajax-delete-row.js
index 2feb3d5ac..f1af7f6cb 100644
--- a/resources/js/components/ajax-delete-row.js
+++ b/resources/js/components/ajax-delete-row.js
@@ -1,10 +1,7 @@
-/**
- * AjaxDelete
- * @extends {Component}
- */
 import {onSelect} from "../services/dom";
+import {Component} from "./component";
 
-class AjaxDeleteRow {
+export class AjaxDeleteRow extends Component {
     setup() {
         this.row = this.$el;
         this.url = this.$opts.url;
@@ -27,6 +24,4 @@ class AjaxDeleteRow {
             this.row.style.pointerEvents = null;
         });
     }
-}
-
-export default AjaxDeleteRow;
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/resources/js/components/ajax-form.js b/resources/js/components/ajax-form.js
index 91029d042..6f4e5af08 100644
--- a/resources/js/components/ajax-form.js
+++ b/resources/js/components/ajax-form.js
@@ -1,4 +1,5 @@
 import {onEnterPress, onSelect} from "../services/dom";
+import {Component} from "./component";
 
 /**
  * Ajax Form
@@ -8,10 +9,8 @@ import {onEnterPress, onSelect} from "../services/dom";
  *
  * Will handle a real form if that's what the component is added to
  * otherwise will act as a fake form element.
- *
- * @extends {Component}
  */
-class AjaxForm {
+export class AjaxForm extends Component {
     setup() {
         this.container = this.$el;
         this.responseContainer = this.container;
@@ -72,11 +71,9 @@ class AjaxForm {
             this.responseContainer.innerHTML = err.data;
         }
 
-        window.components.init(this.responseContainer);
+        window.$components.init(this.responseContainer);
         this.responseContainer.style.opacity = null;
         this.responseContainer.style.pointerEvents = null;
     }
 
-}
-
-export default AjaxForm;
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/resources/js/components/attachments-list.js b/resources/js/components/attachments-list.js
index 34979c2e7..5004f9357 100644
--- a/resources/js/components/attachments-list.js
+++ b/resources/js/components/attachments-list.js
@@ -1,10 +1,11 @@
+import {Component} from "./component";
+
 /**
  * Attachments List
  * Adds '?open=true' query to file attachment links
  * when ctrl/cmd is pressed down.
- * @extends {Component}
  */
-class AttachmentsList {
+export class AttachmentsList extends Component {
 
     setup() {
         this.container = this.$el;
diff --git a/resources/js/components/attachments.js b/resources/js/components/attachments.js
index 6dcfe9f12..a01147aa2 100644
--- a/resources/js/components/attachments.js
+++ b/resources/js/components/attachments.js
@@ -1,10 +1,7 @@
-/**
- * Attachments
- * @extends {Component}
- */
 import {showLoading} from "../services/dom";
+import {Component} from "./component";
 
-class Attachments {
+export class Attachments extends Component {
 
     setup() {
         this.container = this.$el;
@@ -49,7 +46,7 @@ class Attachments {
         this.mainTabs.components.tabs.show('items');
         window.$http.get(`/attachments/get/page/${this.pageId}`).then(resp => {
             this.list.innerHTML = resp.data;
-            window.components.init(this.list);
+            window.$components.init(this.list);
         });
     }
 
@@ -66,7 +63,7 @@ class Attachments {
         showLoading(this.editContainer);
         const resp = await window.$http.get(`/attachments/edit/${id}`);
         this.editContainer.innerHTML = resp.data;
-        window.components.init(this.editContainer);
+        window.$components.init(this.editContainer);
     }
 
     stopEdit() {
diff --git a/resources/js/components/auto-submit.js b/resources/js/components/auto-submit.js
index 11494ae82..c8726ca7e 100644
--- a/resources/js/components/auto-submit.js
+++ b/resources/js/components/auto-submit.js
@@ -1,5 +1,6 @@
+import {Component} from "./component";
 
-class AutoSubmit {
+export class AutoSubmit extends Component {
 
     setup() {
         this.form = this.$el;
@@ -7,6 +8,4 @@ class AutoSubmit {
         this.form.submit();
     }
 
-}
-
-export default AutoSubmit;
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/resources/js/components/auto-suggest.js b/resources/js/components/auto-suggest.js
index 80857cbe5..b4e6c5957 100644
--- a/resources/js/components/auto-suggest.js
+++ b/resources/js/components/auto-suggest.js
@@ -1,13 +1,13 @@
 import {escapeHtml} from "../services/util";
 import {onChildEvent} from "../services/dom";
+import {Component} from "./component";
 
 const ajaxCache = {};
 
 /**
  * AutoSuggest
- * @extends {Component}
  */
-class AutoSuggest {
+export class AutoSuggest extends Component {
     setup() {
         this.parent = this.$el.parentElement;
         this.container = this.$el;
@@ -148,6 +148,4 @@ class AutoSuggest {
             this.hideSuggestions();
         }
     }
-}
-
-export default AutoSuggest;
\ No newline at end of file
+}
\ No newline at end of file
diff --git a/resources/js/components/back-to-top.js b/resources/js/components/back-to-top.js
index a1d87f22e..7a3719493 100644
--- a/resources/js/components/back-to-top.js
+++ b/resources/js/components/back-to-top.js
@@ -1,34 +1,35 @@
+import {Component} from "./component";
 
-class BackToTop {
+export class BackToTop extends Component {
 
-    constructor(elem) {
-        this.elem = elem;
+    setup() {
+        this.button = this.$el;
         this.targetElem = document.getElementById('header');
         this.showing = false;
         this.breakPoint = 1200;
 
         if (document.body.classList.contains('flexbox')) {
-            this.elem.style.display = 'none';
+            this.button.style.display = 'none';
             return;
         }
 
-        this.elem.addEventListener('click', this.scrollToTop.bind(this));
+        this.button.addEventListener('click', this.scrollToTop.bind(this));
         window.addEventListener('scroll', this.onPageScroll.bind(this));
     }
 
     onPageScroll() {
         let scrollTopPos = document.documentElement.scrollTop || document.body.scrollTop || 0;
         if (!this.showing && scrollTopPos > this.breakPoint) {
-            this.elem.style.display = 'block';
+            this.button.style.display = 'block';
             this.showing = true;
             setTimeout(() => {
-                this.elem.style.opacity = 0.4;
+                this.button.style.opacity = 0.4;
             }, 1);
         } else if (this.showing && scrollTopPos < this.breakPoint) {
-            this.elem.style.opacity = 0;
+            this.button.style.opacity = 0;
             this.showing = false;
             setTimeout(() => {
-                this.elem.style.display = 'none';
+                this.button.style.display = 'none';
             }, 500);
         }
     }
diff --git a/resources/js/components/component.js b/resources/js/components/component.js
new file mode 100644
index 000000000..292bbb624
--- /dev/null
+++ b/resources/js/components/component.js
@@ -0,0 +1,58 @@
+export class Component {
+
+    /**
+     * The registered name of the component.
+     * @type {string}
+     */
+    $name = '';
+
+    /**
+     * The element that the component is registered upon.
+     * @type {Element}
+     */
+    $el = null;
+
+    /**
+     * Mapping of referenced elements within the component.
+     * @type {Object<string, Element>}
+     */
+    $refs = {};
+
+    /**
+     * Mapping of arrays of referenced elements within the component so multiple
+     * references, sharing the same name, can be fetched.
+     * @type {Object<string, Element[]>}
+     */
+    $manyRefs = {};
+
+    /**
+     * Options passed into this component.
+     * @type {Object<String, String>}
+     */
+    $opts = {};
+
+    /**
+     * Component-specific setup methods.
+     * Use this to assign local variables and run any initial setup or actions.
+     */
+    setup() {
+        //
+    }
+
+    /**
+     * Emit an event from this component.
+     * Will be bubbled up from the dom element this is registered on, as a custom event
+     * with the name `<elementName>-<eventName>`, with the provided data in the event detail.
+     * @param {String} eventName
+     * @param {Object} data
+     */
+    $emit(eventName, data = {}) {
+        data.from = this;
+        const componentName = this.$name;
+        const event = new CustomEvent(`${componentName}-${eventName}`, {
+            bubbles: true,
+            detail: data
+        });
+        this.$el.dispatchEvent(event);
+    }
+}
\ No newline at end of file
diff --git a/resources/js/components/dropdown.js b/resources/js/components/dropdown.js
index 781f90860..06a89d08c 100644
--- a/resources/js/components/dropdown.js
+++ b/resources/js/components/dropdown.js
@@ -74,7 +74,7 @@ class DropDown {
     }
 
     hideAll() {
-        for (let dropdown of window.components.dropdown) {
+        for (let dropdown of window.$components.get('dropdown')) {
             dropdown.hide();
         }
     }
diff --git a/resources/js/components/image-manager.js b/resources/js/components/image-manager.js
index 23a6c4cbb..1222096d4 100644
--- a/resources/js/components/image-manager.js
+++ b/resources/js/components/image-manager.js
@@ -132,7 +132,7 @@ class ImageManager {
     addReturnedHtmlElementsToList(html) {
         const el = document.createElement('div');
         el.innerHTML = html;
-        window.components.init(el);
+        window.$components.init(el);
         for (const child of [...el.children]) {
             this.listContainer.appendChild(child);
         }
@@ -207,7 +207,7 @@ class ImageManager {
         const params = requestDelete ? {delete: true} : {};
         const {data: formHtml} = await window.$http.get(`/images/edit/${imageId}`, params);
         this.formContainer.innerHTML = formHtml;
-        window.components.init(this.formContainer);
+        window.$components.init(this.formContainer);
     }
 
 }
diff --git a/resources/js/components/index.js b/resources/js/components/index.js
index 9f801668e..e3fa2cf78 100644
--- a/resources/js/components/index.js
+++ b/resources/js/components/index.js
@@ -1,282 +1,59 @@
-import addRemoveRows from "./add-remove-rows.js"
-import ajaxDeleteRow from "./ajax-delete-row.js"
-import ajaxForm from "./ajax-form.js"
-import attachments from "./attachments.js"
-import attachmentsList from "./attachments-list.js"
-import autoSuggest from "./auto-suggest.js"
-import autoSubmit from "./auto-submit.js";
-import backToTop from "./back-to-top.js"
-import bookSort from "./book-sort.js"
-import chapterContents from "./chapter-contents.js"
-import codeEditor from "./code-editor.js"
-import codeHighlighter from "./code-highlighter.js"
-import codeTextarea from "./code-textarea.js"
-import collapsible from "./collapsible.js"
-import confirmDialog from "./confirm-dialog"
-import customCheckbox from "./custom-checkbox.js"
-import detailsHighlighter from "./details-highlighter.js"
-import dropdown from "./dropdown.js"
-import dropdownSearch from "./dropdown-search.js"
-import dropzone from "./dropzone.js"
-import editorToolbox from "./editor-toolbox.js"
-import entityPermissions from "./entity-permissions";
-import entitySearch from "./entity-search.js"
-import entitySelector from "./entity-selector.js"
-import entitySelectorPopup from "./entity-selector-popup.js"
-import eventEmitSelect from "./event-emit-select.js"
-import expandToggle from "./expand-toggle.js"
-import headerMobileToggle from "./header-mobile-toggle.js"
-import homepageControl from "./homepage-control.js"
-import imageManager from "./image-manager.js"
-import imagePicker from "./image-picker.js"
-import listSortControl from "./list-sort-control.js"
-import markdownEditor from "./markdown-editor.js"
-import newUserPassword from "./new-user-password.js"
-import notification from "./notification.js"
-import optionalInput from "./optional-input.js"
-import pageComments from "./page-comments.js"
-import pageDisplay from "./page-display.js"
-import pageEditor from "./page-editor.js"
-import pagePicker from "./page-picker.js"
-import permissionsTable from "./permissions-table.js"
-import pointer from "./pointer.js";
-import popup from "./popup.js"
-import settingAppColorPicker from "./setting-app-color-picker.js"
-import settingColorPicker from "./setting-color-picker.js"
-import shelfSort from "./shelf-sort.js"
-import shortcuts from "./shortcuts";
-import shortcutInput from "./shortcut-input";
-import sidebar from "./sidebar.js"
-import sortableList from "./sortable-list.js"
-import submitOnChange from "./submit-on-change.js"
-import tabs from "./tabs.js"
-import tagManager from "./tag-manager.js"
-import templateManager from "./template-manager.js"
-import toggleSwitch from "./toggle-switch.js"
-import triLayout from "./tri-layout.js"
-import userSelect from "./user-select.js"
-import webhookEvents from "./webhook-events";
-import wysiwygEditor from "./wysiwyg-editor.js"
-
-const componentMapping = {
-    "add-remove-rows": addRemoveRows,
-    "ajax-delete-row": ajaxDeleteRow,
-    "ajax-form": ajaxForm,
-    "attachments": attachments,
-    "attachments-list": attachmentsList,
-    "auto-suggest": autoSuggest,
-    "auto-submit": autoSubmit,
-    "back-to-top": backToTop,
-    "book-sort": bookSort,
-    "chapter-contents": chapterContents,
-    "code-editor": codeEditor,
-    "code-highlighter": codeHighlighter,
-    "code-textarea": codeTextarea,
-    "collapsible": collapsible,
-    "confirm-dialog": confirmDialog,
-    "custom-checkbox": customCheckbox,
-    "details-highlighter": detailsHighlighter,
-    "dropdown": dropdown,
-    "dropdown-search": dropdownSearch,
-    "dropzone": dropzone,
-    "editor-toolbox": editorToolbox,
-    "entity-permissions": entityPermissions,
-    "entity-search": entitySearch,
-    "entity-selector": entitySelector,
-    "entity-selector-popup": entitySelectorPopup,
-    "event-emit-select": eventEmitSelect,
-    "expand-toggle": expandToggle,
-    "header-mobile-toggle": headerMobileToggle,
-    "homepage-control": homepageControl,
-    "image-manager": imageManager,
-    "image-picker": imagePicker,
-    "list-sort-control": listSortControl,
-    "markdown-editor": markdownEditor,
-    "new-user-password": newUserPassword,
-    "notification": notification,
-    "optional-input": optionalInput,
-    "page-comments": pageComments,
-    "page-display": pageDisplay,
-    "page-editor": pageEditor,
-    "page-picker": pagePicker,
-    "permissions-table": permissionsTable,
-    "pointer": pointer,
-    "popup": popup,
-    "setting-app-color-picker": settingAppColorPicker,
-    "setting-color-picker": settingColorPicker,
-    "shelf-sort": shelfSort,
-    "shortcuts": shortcuts,
-    "shortcut-input": shortcutInput,
-    "sidebar": sidebar,
-    "sortable-list": sortableList,
-    "submit-on-change": submitOnChange,
-    "tabs": tabs,
-    "tag-manager": tagManager,
-    "template-manager": templateManager,
-    "toggle-switch": toggleSwitch,
-    "tri-layout": triLayout,
-    "user-select": userSelect,
-    "webhook-events": webhookEvents,
-    "wysiwyg-editor": wysiwygEditor,
-};
-
-window.components = {};
-
-/**
- * Initialize components of the given name within the given element.
- * @param {String} componentName
- * @param {HTMLElement|Document} parentElement
- */
-function searchForComponentInParent(componentName, parentElement) {
-    const elems = parentElement.querySelectorAll(`[${componentName}]`);
-    for (let j = 0, jLen = elems.length; j < jLen; j++) {
-        initComponent(componentName, elems[j]);
-    }
-}
-
-/**
- * Initialize a component instance on the given dom element.
- * @param {String} name
- * @param {Element} element
- */
-function initComponent(name, element) {
-    const componentModel = componentMapping[name];
-    if (componentModel === undefined) return;
-
-    // Create our component instance
-    let instance;
-    try {
-        instance = new componentModel(element);
-        instance.$el = element;
-        const allRefs = parseRefs(name, element);
-        instance.$refs = allRefs.refs;
-        instance.$manyRefs = allRefs.manyRefs;
-        instance.$opts = parseOpts(name, element);
-        instance.$emit = (eventName, data = {}) => {
-            data.from = instance;
-            const event = new CustomEvent(`${name}-${eventName}`, {
-                bubbles: true,
-                detail: data
-            });
-            instance.$el.dispatchEvent(event);
-        };
-        if (typeof instance.setup === 'function') {
-            instance.setup();
-        }
-    } catch (e) {
-        console.error('Failed to create component', e, name, element);
-    }
-
-
-    // Add to global listing
-    if (typeof window.components[name] === "undefined") {
-        window.components[name] = [];
-    }
-    window.components[name].push(instance);
-
-    // Add to element listing
-    if (typeof element.components === 'undefined') {
-        element.components = {};
-    }
-    element.components[name] = instance;
-}
-
-/**
- * Parse out the element references within the given element
- * for the given component name.
- * @param {String} name
- * @param {Element} element
- */
-function parseRefs(name, element) {
-    const refs = {};
-    const manyRefs = {};
-
-    const prefix = `${name}@`
-    const selector = `[refs*="${prefix}"]`;
-    const refElems = [...element.querySelectorAll(selector)];
-    if (element.matches(selector)) {
-        refElems.push(element);
-    }
-
-    for (const el of refElems) {
-        const refNames = el.getAttribute('refs')
-            .split(' ')
-            .filter(str => str.startsWith(prefix))
-            .map(str => str.replace(prefix, ''))
-            .map(kebabToCamel);
-        for (const ref of refNames) {
-            refs[ref] = el;
-            if (typeof manyRefs[ref] === 'undefined') {
-                manyRefs[ref] = [];
-            }
-            manyRefs[ref].push(el);
-        }
-    }
-    return {refs, manyRefs};
-}
-
-/**
- * Parse out the element component options.
- * @param {String} name
- * @param {Element} element
- * @return {Object<String, String>}
- */
-function parseOpts(name, element) {
-    const opts = {};
-    const prefix = `option:${name}:`;
-    for (const {name, value} of element.attributes) {
-        if (name.startsWith(prefix)) {
-            const optName = name.replace(prefix, '');
-            opts[kebabToCamel(optName)] = value || '';
-        }
-    }
-    return opts;
-}
-
-/**
- * Convert a kebab-case string to camelCase
- * @param {String} kebab
- * @returns {string}
- */
-function kebabToCamel(kebab) {
-    const ucFirst = (word) => word.slice(0,1).toUpperCase() + word.slice(1);
-    const words = kebab.split('-');
-    return words[0] + words.slice(1).map(ucFirst).join('');
-}
-
-/**
- * Initialize all components found within the given element.
- * @param parentElement
- */
-function initAll(parentElement) {
-    if (typeof parentElement === 'undefined') parentElement = document;
-
-    // Old attribute system
-    for (const componentName of Object.keys(componentMapping)) {
-        searchForComponentInParent(componentName, parentElement);
-    }
-
-    // New component system
-    const componentElems = parentElement.querySelectorAll(`[component],[components]`);
-
-    for (const el of componentElems) {
-        const componentNames = `${el.getAttribute('component') || ''} ${(el.getAttribute('components'))}`.toLowerCase().split(' ').filter(Boolean);
-        for (const name of componentNames) {
-            initComponent(name, el);
-        }
-    }
-}
-
-window.components.init = initAll;
-window.components.first = (name) => (window.components[name] || [null])[0];
-
-export default initAll;
-
-/**
- * @typedef Component
- * @property {HTMLElement} $el
- * @property {Object<String, HTMLElement>} $refs
- * @property {Object<String, HTMLElement[]>} $manyRefs
- * @property {Object<String, String>} $opts
- * @property {function(string, Object)} $emit
- */
\ No newline at end of file
+export {AddRemoveRows} from "./add-remove-rows.js"
+export {AjaxDeleteRow} from "./ajax-delete-row.js"
+export {AjaxForm} from "./ajax-form.js"
+export {Attachments} from "./attachments.js"
+export {AttachmentsList} from "./attachments-list.js"
+export {AutoSuggest} from "./auto-suggest.js"
+export {AutoSubmit} from "./auto-submit.js";
+export {BackToTop} from "./back-to-top.js"
+// export {BookSort} from "./book-sort.js"
+// export {ChapterContents} from "./chapter-contents.js"
+// export {CodeEditor} from "./code-editor.js"
+// export {CodeHighlighter} from "./code-highlighter.js"
+// export {CodeTextarea} from "./code-textarea.js"
+// export {Collapsible} from "./collapsible.js"
+// export {ConfirmDialog} from "./confirm-dialog"
+// export {CustomCheckbox} from "./custom-checkbox.js"
+// export {DetailsHighlighter} from "./details-highlighter.js"
+// export {Dropdown} from "./dropdown.js"
+// export {DropdownSearch} from "./dropdown-search.js"
+// export {Dropzone} from "./dropzone.js"
+// export {EditorToolbox} from "./editor-toolbox.js"
+// export {EntityPermissions} from "./entity-permissions";
+// export {EntitySearch} from "./entity-search.js"
+// export {EntitySelector} from "./entity-selector.js"
+// export {EntitySelectorPopup} from "./entity-selector-popup.js"
+// export {EventEmitSelect} from "./event-emit-select.js"
+// export {ExpandToggle} from "./expand-toggle.js"
+// export {HeaderMobileToggle} from "./header-mobile-toggle.js"
+// export {HomepageControl} from "./homepage-control.js"
+// export {ImageManager} from "./image-manager.js"
+// export {ImagePicker} from "./image-picker.js"
+// export {ListSortControl} from "./list-sort-control.js"
+// export {MarkdownEditor} from "./markdown-editor.js"
+// export {NewUserPassword} from "./new-user-password.js"
+// export {Notification} from "./notification.js"
+// export {OptionalInput} from "./optional-input.js"
+// export {PageComments} from "./page-comments.js"
+// export {PageDisplay} from "./page-display.js"
+// export {PageEditor} from "./page-editor.js"
+// export {PagePicker} from "./page-picker.js"
+// export {PermissionsTable} from "./permissions-table.js"
+// export {Pointer} from "./pointer.js";
+// export {Popup} from "./popup.js"
+// export {SettingAppColorPicker} from "./setting-app-color-picker.js"
+// export {SettingColorPicker} from "./setting-color-picker.js"
+// export {ShelfSort} from "./shelf-sort.js"
+// export {Shortcuts} from "./shortcuts";
+// export {ShortcutInput} from "./shortcut-input";
+// export {Sidebar} from "./sidebar.js"
+// export {SortableList} from "./sortable-list.js"
+// export {SubmitOnChange} from "./submit-on-change.js"
+// export {Tabs} from "./tabs.js"
+// export {TagManager} from "./tag-manager.js"
+// export {TemplateManager} from "./template-manager.js"
+// export {ToggleSwitch} from "./toggle-switch.js"
+// export {TriLayout} from "./tri-layout.js"
+// export {UserSelect} from "./user-select.js"
+// export {WebhookEvents} from "./webhook-events";
+// export {WysiwygEditor} from "./wysiwyg-editor.js"
\ No newline at end of file
diff --git a/resources/js/components/page-comments.js b/resources/js/components/page-comments.js
index c86eead1b..0264e24c6 100644
--- a/resources/js/components/page-comments.js
+++ b/resources/js/components/page-comments.js
@@ -90,7 +90,7 @@ class PageComments {
             newComment.innerHTML = resp.data;
             this.editingComment.innerHTML = newComment.children[0].innerHTML;
             window.$events.success(this.updatedText);
-            window.components.init(this.editingComment);
+            window.$components.init(this.editingComment);
             this.closeUpdateForm();
             this.editingComment = null;
         }).catch(window.$events.showValidationErrors).then(() => {
@@ -123,7 +123,7 @@ class PageComments {
             newComment.innerHTML = resp.data;
             let newElem = newComment.children[0];
             this.container.appendChild(newElem);
-            window.components.init(newElem);
+            window.$components.init(newElem);
             window.$events.success(this.createdText);
             this.resetForm();
             this.updateCount();
diff --git a/resources/js/components/page-display.js b/resources/js/components/page-display.js
index b4f1cca4f..f8377130c 100644
--- a/resources/js/components/page-display.js
+++ b/resources/js/components/page-display.js
@@ -22,7 +22,7 @@ class PageDisplay {
         if (sidebarPageNav) {
             DOM.onChildEvent(sidebarPageNav, 'a', 'click', (event, child) => {
                 event.preventDefault();
-                window.components['tri-layout'][0].showContent();
+                window.$components.first('tri-layout').showContent();
                 const contentId = child.getAttribute('href').substr(1);
                 this.goToText(contentId);
                 window.history.pushState(null, null, '#' + contentId);
diff --git a/resources/js/services/components.js b/resources/js/services/components.js
new file mode 100644
index 000000000..006f16e86
--- /dev/null
+++ b/resources/js/services/components.js
@@ -0,0 +1,153 @@
+const components = {};
+const componentMap = {};
+
+/**
+ * Initialize a component instance on the given dom element.
+ * @param {String} name
+ * @param {Element} element
+ */
+function initComponent(name, element) {
+    /** @type {Function<Component>|undefined} **/
+    const componentModel = componentMap[name];
+    if (componentModel === undefined) return;
+
+    // Create our component instance
+    /** @type {Component} **/
+    let instance;
+    try {
+        instance = new componentModel();
+        instance.$name = name;
+        instance.$el = element;
+        const allRefs = parseRefs(name, element);
+        instance.$refs = allRefs.refs;
+        instance.$manyRefs = allRefs.manyRefs;
+        instance.$opts = parseOpts(name, element);
+        instance.setup();
+    } catch (e) {
+        console.error('Failed to create component', e, name, element);
+    }
+
+    // Add to global listing
+    if (typeof components[name] === "undefined") {
+        components[name] = [];
+    }
+    components[name].push(instance);
+
+    // Add to element listing
+    if (typeof element.components === 'undefined') {
+        element.components = {};
+    }
+    element.components[name] = instance;
+}
+
+/**
+ * Parse out the element references within the given element
+ * for the given component name.
+ * @param {String} name
+ * @param {Element} element
+ */
+function parseRefs(name, element) {
+    const refs = {};
+    const manyRefs = {};
+
+    const prefix = `${name}@`
+    const selector = `[refs*="${prefix}"]`;
+    const refElems = [...element.querySelectorAll(selector)];
+    if (element.matches(selector)) {
+        refElems.push(element);
+    }
+
+    for (const el of refElems) {
+        const refNames = el.getAttribute('refs')
+            .split(' ')
+            .filter(str => str.startsWith(prefix))
+            .map(str => str.replace(prefix, ''))
+            .map(kebabToCamel);
+        for (const ref of refNames) {
+            refs[ref] = el;
+            if (typeof manyRefs[ref] === 'undefined') {
+                manyRefs[ref] = [];
+            }
+            manyRefs[ref].push(el);
+        }
+    }
+    return {refs, manyRefs};
+}
+
+/**
+ * Parse out the element component options.
+ * @param {String} name
+ * @param {Element} element
+ * @return {Object<String, String>}
+ */
+function parseOpts(name, element) {
+    const opts = {};
+    const prefix = `option:${name}:`;
+    for (const {name, value} of element.attributes) {
+        if (name.startsWith(prefix)) {
+            const optName = name.replace(prefix, '');
+            opts[kebabToCamel(optName)] = value || '';
+        }
+    }
+    return opts;
+}
+
+/**
+ * Convert a kebab-case string to camelCase
+ * @param {String} kebab
+ * @returns {string}
+ */
+function kebabToCamel(kebab) {
+    const ucFirst = (word) => word.slice(0,1).toUpperCase() + word.slice(1);
+    const words = kebab.split('-');
+    return words[0] + words.slice(1).map(ucFirst).join('');
+}
+
+/**
+ * Initialize all components found within the given element.
+ * @param {Element|Document} parentElement
+ */
+export function init(parentElement = document) {
+    const componentElems = parentElement.querySelectorAll(`[component],[components]`);
+
+    for (const el of componentElems) {
+        const componentNames = `${el.getAttribute('component') || ''} ${(el.getAttribute('components'))}`.toLowerCase().split(' ').filter(Boolean);
+        for (const name of componentNames) {
+            initComponent(name, el);
+        }
+    }
+}
+
+/**
+ * Register the given component mapping into the component system.
+ * @param {Object<String, ObjectConstructor<Component>>} mapping
+ */
+export function register(mapping) {
+    const keys = Object.keys(mapping);
+    for (const key of keys) {
+        componentMap[camelToKebab(key)] = mapping[key];
+    }
+    console.log(componentMap);
+}
+
+/**
+ * Get the first component of the given name.
+ * @param {String} name
+ * @returns {Component|null}
+ */
+export function first(name) {
+    return (components[name] || [null])[0];
+}
+
+/**
+ * Get all the components of the given name.
+ * @param {String} name
+ * @returns {Component[]}
+ */
+export function get(name = '') {
+    return components[name] || [];
+}
+
+function camelToKebab(camelStr) {
+    return camelStr.replace(/[A-Z]/g, (str, offset) =>  (offset > 0 ? '-' : '') + str.toLowerCase());
+}
\ No newline at end of file
diff --git a/resources/js/services/dom.js b/resources/js/services/dom.js
index eb5f6a853..882d5228d 100644
--- a/resources/js/services/dom.js
+++ b/resources/js/services/dom.js
@@ -128,6 +128,6 @@ export function removeLoading(element) {
 export function htmlToDom(html) {
     const wrap = document.createElement('div');
     wrap.innerHTML = html;
-    window.components.init(wrap);
+    window.$components.init(wrap);
     return wrap.children[0];
 }
\ No newline at end of file
diff --git a/resources/js/wysiwyg/plugin-codeeditor.js b/resources/js/wysiwyg/plugin-codeeditor.js
index 66441c87e..cd0078b1d 100644
--- a/resources/js/wysiwyg/plugin-codeeditor.js
+++ b/resources/js/wysiwyg/plugin-codeeditor.js
@@ -9,7 +9,7 @@ function elemIsCodeBlock(elem) {
  * @param {function(string, string)} callback (Receives (code: string,language: string)
  */
 function showPopup(editor, code, language, callback) {
-    window.components.first('code-editor').open(code, language, (newCode, newLang) => {
+    window.$components.first('code-editor').open(code, language, (newCode, newLang) => {
         callback(newCode, newLang)
         editor.focus()
     });
diff --git a/resources/sass/_components.scss b/resources/sass/_components.scss
index 66d76aaa2..db9248719 100644
--- a/resources/sass/_components.scss
+++ b/resources/sass/_components.scss
@@ -1010,4 +1010,41 @@ body.flexbox-support #entity-selector-wrap .popup-body .form-group {
   border: 1px solid #b4b4b4;
   box-shadow: 0 1px 1px rgba(0, 0, 0, .2), 0 2px 0 0 rgba(255, 255, 255, .7) inset;
   color: #333;
+}
+
+// Back to top link
+$btt-size: 40px;
+.back-to-top {
+  background-color: var(--color-primary);
+  position: fixed;
+  bottom: $-m;
+  right: $-l;
+  padding: 5px 7px;
+  cursor: pointer;
+  color: #FFF;
+  fill: #FFF;
+  svg {
+    width: math.div($btt-size, 1.5);
+    height: math.div($btt-size, 1.5);
+    margin-inline-end: 4px;
+  }
+  width: $btt-size;
+  height: $btt-size;
+  border-radius: $btt-size;
+  transition: all ease-in-out 180ms;
+  opacity: 0;
+  z-index: 999;
+  overflow: hidden;
+  &:hover {
+    width: $btt-size*3.4;
+    opacity: 1 !important;
+  }
+  .inner {
+    width: $btt-size*3.4;
+  }
+  span {
+    position: relative;
+    vertical-align: top;
+    line-height: 2;
+  }
 }
\ No newline at end of file
diff --git a/resources/sass/styles.scss b/resources/sass/styles.scss
index 5e31dbdfb..23959d1f8 100644
--- a/resources/sass/styles.scss
+++ b/resources/sass/styles.scss
@@ -100,43 +100,6 @@ $loadingSize: 10px;
   }
 }
 
-// Back to top link
-$btt-size: 40px;
-[back-to-top] {
-  background-color: var(--color-primary);
-  position: fixed;
-  bottom: $-m;
-  right: $-l;
-  padding: 5px 7px;
-  cursor: pointer;
-  color: #FFF;
-  fill: #FFF;
-  svg {
-    width: math.div($btt-size, 1.5);
-    height: math.div($btt-size, 1.5);
-    margin-inline-end: 4px;
-  }
-  width: $btt-size;
-  height: $btt-size;
-  border-radius: $btt-size;
-  transition: all ease-in-out 180ms;
-  opacity: 0;
-  z-index: 999;
-  overflow: hidden;
-  &:hover {
-    width: $btt-size*3.4;
-    opacity: 1 !important;
-  }
-  .inner {
-    width: $btt-size*3.4;
-  }
-  span {
-    position: relative;
-    vertical-align: top;
-    line-height: 2;
-  }
-}
-
 .skip-to-content-link {
   position: fixed;
   top: -52px;
diff --git a/resources/views/layouts/base.blade.php b/resources/views/layouts/base.blade.php
index 2f649423d..76d220952 100644
--- a/resources/views/layouts/base.blade.php
+++ b/resources/views/layouts/base.blade.php
@@ -49,7 +49,7 @@
 
     @include('common.footer')
 
-    <div back-to-top class="primary-background print-hidden">
+    <div component="back-to-top" class="back-to-top print-hidden">
         <div class="inner">
             @icon('chevron-up') <span>{{ trans('common.back_to_top') }}</span>
         </div>