/** @module component */
import { Tracked, Cache, setScheduleRerender } from './tracking.js';
import {
scheduleRender,
registerRenderer,
unregisterRenderer
} from './rendering.js';
setScheduleRerender(scheduleRender);
const RENDER = Symbol('memoized render');
const templates = new Map();
const dasherize = (str) => str.replace(/([a-z\d])([A-Z])/g, '$1-$2').toLowerCase();
const findClosest = (t, s) => t.closest(`#${s}`) ?? t.closest(s);
const yieldableProxy = (self) => new Proxy(self, {
get: (target, prop) => {
if (typeof prop !== 'string') return;
return findClosest(target, dasherize(prop))?.yields?.();
},
});
function makeTemplateElement({ template, shadow }) {
if (template === null && shadow === null) { return null; }
let element = document.createElement('template');
if (template) {
element.innerHTML = template;
} else if (shadow) {
element.innerHTML = shadow;
element.dataset.shadow = true;
}
return element;
}
function appendWithSlotableContent(target, content) {
target.querySelectorAll('[slot]').forEach(node => {
let slotName = node.getAttribute('slot');
content.querySelector(`slot[name="${slotName}"]`)?.after(node);
});
content.querySelector('slot:not([name])')?.after(...target.childNodes);
content.querySelectorAll('slot').forEach(node => node.remove());
target.append(content);
}
/**
* ```js
* // Unminified
* import { componentOf } from 'https://fancy-pants.js.org/component.js';
*
* // Minified
* import { componentOf } from 'https://fancy-pants.js.org/min/component.js';
* ```
*
* Construct a Component class that extends from a specific kind of HTMLElement.
* Use this if you want a custom element to work like a Component but extend
* from a more specific type.
*
* ```js
* class MyComponent extends componentOf(HTMLParagraphElement) {
* …
* }
* MyComponent.register();
* ```
*
* @param {HTMLElement} ElementClass the HTMLElement the component class will extend from
* @returns Component a FancyPants component that extends from ElementClass
*/
function componentOf(ElementClass) {
/**
* ```js
* // Unminified
* import Component from 'https://fancy-pants.js.org/component.js';
*
* // Minified
* import Component from 'https://fancy-pants.js.org/min/component.js';
* ```
*
* This is the main class for using the FancyPants system. When extended it
* will provide easy means for tracking dirty changes and rendering dynamic
* parts of the custom element's DOM. You have access to both a ShadowDOM and
* the HTMLElement itself.
*
* You don't need to instantiate it yourself as this is done by the insertion
* of the custom element into the DOM.
*
* Use the [.register()]{@link module:component~Component.register} method to
* define the custom element and associate a template to the custom element.
*/
class Component extends ElementClass {
#initialized = false;
/**
* Direct access to the Shadow DOM.
* @instance
* @memberof Component
* @member {ShadowRoot} shadow
*/
/**
* Perform any setup in the constructor. This
* is a good place to assign [tracked()]{@link module:tracking.tracked}
* properties when targeting Mobile Safari browsers.
*
* ```js
* import Component from './fancy-pants/component.js';
*
* class MyComponent extends Component {
* constructor() {
* super();
* this.foobar = tracked();
* }
* }
*
* MyComponent.register();
* ```
*
* If you do not need to support **Mobile Safari** then you can use class
* field syntax instead.
*
* ```js
* class MyComponent extends Component {
* foobar = tracked();
* }
* ```
*/
constructor() {
super();
this[RENDER] = Cache.memoize(() => this.render(yieldableProxy(this)));
}
/**
* Here is a good place to add event listeners to the DOM.
* Activates tracking and registers this component instance with the
* renderer. First render is scheduled after this callback.
*
* ```js
* class MyComponent extends Component {
* connectedCallback() {
* super.connectedCallback();
* this.clickHandler = () => this.doSomething();
* this.shadow.querySelector('button')
* .addEventListener('click', this.clickHandler);
* }
* }
* ```
*/
connectedCallback() {
if (!this.#initialized) {
let template = templates.get(this.tagName.toLowerCase());
if (template) {
if (template.dataset.shadow === undefined) {
appendWithSlotableContent(this, template.content.cloneNode(true));
} else {
this.shadow = this.attachShadow({ mode: 'open' });
this.shadow.appendChild(template.content.cloneNode(true));
}
}
this.track();
registerRenderer(this[RENDER]);
this.#initialized = true;
}
scheduleRender();
}
/**
* Here is a good place to remove event listeners to the DOM.
* Removes this component instance from the renderer.
*
* ```js
* class MyComponent extends Component {
* disconnectedCallback() {
* super.disconnectedCallback();
* this.shadow.querySelector('button')
* .removeEventListener('click', this.clickHandler);
* }
* }
* ```
*/
disconnectedCallback() {
unregisterRenderer(this[RENDER]);
}
/**
* This will auto-track any attributes listed in `observedAttributes`.
*
* ```js
* class MyComponent extends Component {
* static get observedAttributes() {
* return ['foo', 'bar'];
* }
* }
* ```
*/
attributeChangedCallback(name) {
Tracked.for(this, name).dirty();
}
getAttribute(name) {
Tracked.for(this, name).consume();
return super.getAttribute(name);
}
hasAttribute(name) {
Tracked.for(this, name).consume();
return super.hasAttribute(name);
}
/**
* Activate tracking on this component. Called automatically during
* [.connectedCallback()]{@link module:component~Component#connectedCallback}
* Use this if you want to assign to a tracked property prior to being
* appended to the DOM.
*
* ```js
* let myComponent = document.createElement('my-component').track();
* myComponent.foobar = 'This is now tracked';
* document.body.appendChild(myComponent);
* ```
*
* @returns {this}
*/
track() {
Tracked.activate(this);
return this;
}
/**
* When this component needs to render this function will be called.
*
* ```js
* class MyComponent extends Component {
* render() {
* this.shadow.querySelector('.foo').textContent = this.foobar;
* }
* }
* ```
*
* @param {object} attrs an object with all values which have been
* yielded by parent components index by their camelized name or id of the
* respective components
*/
render() {}
/**
* Called during child components' rendering to ask for any yielded values.
* This is a method to pass tracked values to other components in it. Most
* cases this will be a component that has `null` for the shadow and
* template but it isn't required. The `render()` and the `yields()` are
* two separate ways to react to tracked changes but they can be used
* together. Anything returned from this hook will be passed to any child
* component's `render()` hook as named argument.
*
* ```html
* <my-provider>
* <my-presenter></my-presenter>
* </my-provider>
* ```
*
* ```js
* class MyProvider extends Component {
* trackedValue = tracked();
* static get shadow() { return null; }
* yields() {
* return { yieldedValue: this.trackedValue };
* }
* }
* MyProvider.register();
*
* class MyPresenter extends Component {
* render(attrs) {
* let { yieldedValue } = attrs.myProvider;
* this.shadow.textContent = yieldedValue;
* }
* }
* MyPresenter.register();
* ```
*
* @tutorial example10
* @return any
*/
yields() {
return this;
}
/**
* Call this static method to define the component as a custom element with
* the browser.
*
* ```js
* class MyComponent extends Component {
* …
* }
*
* MyComponent.register();
* MyComponent.register('#selector');
* MyComponent.register(myTemplateNode);
* ```
*
* When defining a template, register will look for a `<template>` element
* that matches the kabob-case of the component's class name. The shadow
* DOM can be used instead by adding the `data-shadow` attribute to the
* template element.
*
* @param {HTMLElement|string|undefined} [templatable] optional template
* element or selector
* @param {Queryable} [queryable=document] optional scope for the template
* selector
*/
static register(templatable, queryable = document) {
let templateFactory;
if (typeof templatable === 'string') {
templateFactory = () => queryable.querySelector(templatable);
} else if (templatable instanceof Element) {
templateFactory = () => templatable;
} else {
let templateEl = queryable.querySelector(`template#${this.tagName}`);
templateFactory = () => templateEl ?? makeTemplateElement(this);
}
templates.set(this.tagName, templateFactory());
customElements.define(this.tagName, this);
}
/**
* Optional tag name. Defaults to a dasherized version of the class name
*
* ```js
* class MyComponent extends Component {
* static get tagName() {
* return 'my-alternative-component-tag-name';
* }
* }
* ```
*
* @member {string}
*/
static get tagName() {
return dasherize(this.name);
}
/**
* Optional string version of the Shadow DOM template.
* Defaults to null.
*
* ```js
* class MyComponent extends Component {
* static get shadow() {
* return `<p>lorem ipsum</p>`;
* }
* }
* ```
*
* @member {string}
*/
static get shadow() {
return null;
}
/**
* Optional string version of the innerHTML template.
* Defaults to `null`.
* Return `null` to **disable** appendChild on construction.
*
* ```js
* class MyComponent extends Component {
* static get template() {
* return `<p>lorem ipsum</p>`;
* }
* }
* ```
*
* @member {string}
*/
static get template() {
return null;
}
}
return Component;
}
export default componentOf(HTMLElement);
export { componentOf };
Source