Skip to content

Instantly share code, notes, and snippets.

@monokee
Last active September 28, 2024 00:52
Show Gist options
  • Save monokee/03230511f1e2214dc1f0b17763d85369 to your computer and use it in GitHub Desktop.
Save monokee/03230511f1e2214dc1f0b17763d85369 to your computer and use it in GitHub Desktop.
Tiny customElement wrapper that enables scalable web component architecture. Define custom elements with a configuration object that separates markup from css and javascript. Uses a slotted light DOM (no shadow DOM) to allow for powerful component extension, composition and easier styling with external stylesheets and global css variables. Expor…
/**
* Tiny customElement wrapper that enables scalable web component architecture.
* Define custom elements with a configuration object that separates markup from css and javascript.
* Uses a slotted light DOM (no shadow DOM) to allow for powerful component extension,
* composition and easier styling with external stylesheets and global css variables.
* Exports a component class that can be imported and explicitly used to be picked up by module bundlers.
* See comments for examples and GNU license below.
*/
export function defineComponent(name, config) {
const [USED, INIT] = defineComponent.x || (defineComponent.x = [Symbol(), Symbol()]);
const TEMPLATE = config.element ? Object.assign(document.createElement('template'), {
innerHTML: config.element
}).content : null;
const HAS_SLOTS = TEMPLATE ? TEMPLATE.querySelector('slot') !== null : false;
const REFS = defineComponent.y || (defineComponent.y = {
get: (comp, ref) => comp.querySelector(`[ref="${ref}"]`)
});
const STYLESHEET = defineComponent.z || (defineComponent.z = document.head.appendChild(document.createElement('style')));
if (config.style) {
STYLESHEET.innerHTML += config.style.replaceAll(':host', `:is(${name}, [extends*="${name}"])`);
}
class Component extends HTMLElement {
static use() {
if (this[USED]) return;
this[USED] = true;
customElements.define(name, this);
}
static extend(variantName, variantConfig) {
const {element, style, initialize, ...base} = config;
return defineComponent(variantName, Object.assign(base, variantConfig, {
extends: this.prototype.extends ? this.prototype.extends + ' ' + name : name,
element: (config.element || '') + (variantConfig.element || ''),
initialize(refs) {
config.initialize && config.initialize.call(this, refs);
variantConfig.initialize && variantConfig.initialize.call(this, refs);
}
}));
}
static html(attributes = '', children = '') {
this.use();
return `<${name} ${attributes}>${children}</${name}>`;
}
connectedCallback() {
if (this[INIT]) return;
this[INIT] = true;
config.extends && this.setAttribute('extends', config.extends);
if (TEMPLATE) {
this.appendChild(TEMPLATE.cloneNode(true));
HAS_SLOTS && this.querySelectorAll('slot').forEach(slot => {
const child = this.querySelector(`[slot="${slot.getAttribute('name')}"]`);
child ? slot.replaceWith(child) : slot.remove();
});
}
config.initialize && requestAnimationFrame(() => {
config.initialize.call(this, new Proxy(this, REFS))
});
}
}
const {element, style, ...proto} = Object.getOwnPropertyDescriptors(config);
Object.defineProperties(Component.prototype, proto);
return Component;
}
/**
* License
* Copyright (C) 2022 Jonathan M. Ochmann
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see https://www.gnu.org/licenses.
*/
@monokee
Copy link
Author

monokee commented Jun 26, 2022

  import { defineComponent } from "./define-component.js";
  
  export const BaseComponent = defineComponent('base-component', {

   // element is the inner html of the component.
   // the "ref" element will be passed to the initialize method
   // the "slot" element will be replaced when the component is extended or composed with slotted content.
    element: (`
      <button ref="$button">Click me!</button>
      <slot name="content"></slot> 
    `),
    
    // style is the css for the component. :host refers to the element itself and can be used for manual scoping.
    style: (`
      :host {
        position: relative;
        display: inline-block;
        box-sizing: content-box;
        cursor: pointer;
        user-select: none;
      }
      :host[disabled] {
        opacity: 0.5;
        pointer-events: none;
      }
      :host:focus {
        border: 1px dotted blue;
      }
    `),
    
    // initialize is a lifecycle method that is called the first time a component is added to the DOM.
    // it receives an object with all child nodes marked with a "ref" attribute in the markup above.
    initialize({$button}) {
      $button.addEventListener('click', event => {
        alert(event);
      });
    }

  });

@monokee
Copy link
Author

monokee commented Jun 27, 2022

Create variants of a shared base component via Component.extend()

import { BaseComponent } from "./comment-above/base-component.js"

export const DerivedComponent = BaseCompnent.extend('derived-component', {
  
  // the input element is composed into the slot position defined by BaseComponent
  element: (`
    <input slot="content" ref="$input" type="number" value="0">
  `),
  
  // :host refers only to this derived component. All styles from BaseComponent are inherited.
  style: (`
    :host {
      background-color: blue;
    }
  `),
  
  // this initialize will run after the initialize of the BaseComponent. 
  // both receive all refs nodes i.e. the BaseComponent could also access the $input ref
  initialize({$button, $input}) {
  
    $button.addEventListener('click', e => {
      $input.value = parseInt($input.value) + 1;
    });
    
    if (this.hasAttribute('title')) {
      $button.textContent = this.getAttribute('title');
    }
    
  }

})

@monokee
Copy link
Author

monokee commented Jun 27, 2022

Using Components

In Markup

import { DerivedComponent } from './comment-above/derived-component.js'

// Defines the component and ensures it gets picked up by bundlers.
DerivedComponent.use();

document.body.innerHTML += (`<derived-component
  title="Hello World"
></derived-component>`);

In Markup via Component.html()

import { DerivedComponent } from './comment-above/derived-component.js'

// Component.html accepts two string arguments: attributes and innerHTML
document.body.innerHTML += DerivedComponent.html(`title="Hello World"`);

Using regular DOM APIs

import { DerivedComponent } from './comment-above/derived-component.js'

// Defines the component and ensures it gets picked up by bundlers.
DerivedComponent.use();

const $el = document.createElement('derived-component');
$el.setAttribute('title', 'Hello World');
document.body.appendChild($el);

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment