UNPKG

@jadis/core

Version:

Jadis is a minimal JavaScript library for building web interfaces using native Web Components.

432 lines (419 loc) 14.6 kB
'use strict'; function assert(condition, message) { if (!condition) { throw new Error(message); } } class ChangeHandler { onChange; _value; constructor(initialValue, onChange) { this.onChange = onChange; this._value = initialValue; } get() { return this._value; } set(setter) { const oldValue = structuredClone(this._value); this._value = typeof setter === 'function' ? setter(this._value) : setter; this.onChange(this._value, oldValue); } } const toKebabCase = (str) => { return str.replace(/[A-Z]+(?![a-z])|[A-Z]/g, ($, ofs) => (ofs ? '-' : '') + $.toLowerCase()); }; function createElement(tag, options = {}, appendTo) { const el = document.createElement(tag.toString()); Object.entries(options.props ?? {}).forEach(([key, value]) => { const prop = el; if (prop[key] instanceof ChangeHandler) { prop[key].set(value); } else { prop[key] = value; } }); Object.entries(options.attrs ?? {}).forEach(([key, value]) => { el.setAttribute(toKebabCase(key), String(value)); }); appendTo?.appendChild(el); return el; } class Jadis extends HTMLElement { static selector; static template = ''; static observedAttributes = []; shadowRoot; attributesCallback = {}; onConnectActions = []; _abortController = new AbortController(); _isConnected = false; constructor() { super(); this.shadowRoot = this.attachShadow({ mode: 'open' }); this.shadowRoot.appendChild(this.buildTemplate()); } static toTemplate(options = {}, slotted = document.createDocumentFragment()) { const element = createElement(this.selector, options); element.appendChild(slotted.cloneNode(true)); return element; } static register() { assert(this.selector, `selector is not defined for ${this.name}`); if (!customElements.get(this.typeOfClass.selector)) { customElements.define(this.typeOfClass.selector, this.typeOfClass); } } static toString() { return this.selector; } get isConnected() { return this._isConnected; } connectedCallback() { this._isConnected = true; this.onConnectActions.forEach((fn) => { fn(); }); setTimeout(() => this.onConnect?.()); } disconnectedCallback() { this._abortController.abort(); this.onDisconnect?.(); } attributeChangedCallback(name, oldValue, newValue) { this.attributesCallback[name]?.(newValue, oldValue); } get killSignal() { return this._abortController.signal; } getElement(query) { const el = query.split('>>>').reduce((nextEl, nextQuery) => { const found = (nextEl.shadowRoot ?? nextEl).querySelector(nextQuery); assert(found, `Jadis.getElement: ${nextQuery} element is not reachable`); return found; }, this); assert(el, `${query} element is not reachable`); return el; } toggleClass(className, condition) { this.classList[condition ? 'add' : 'remove'](className); } onBus(bus, eventName, callback) { bus.register(eventName, callback, this.killSignal); } useAttributes(...attributes) { return attributes.reduce((acc, name) => { Object.defineProperty(acc, name, { enumerable: true, get: () => this.getAttribute(name), }); return acc; }, {}); } useEvents(_schema) { return Object.freeze({ emit: (eventName, ...params) => { this.dispatchEvent(new CustomEvent(eventName, { detail: params[0] })); }, register: (eventName, callback) => { const listener = ({ detail }) => callback(detail); this.addEventListener(eventName, listener, { signal: this.killSignal, }); }, }); } on(element, eventName, callback) { element.addEventListener(eventName, callback, { signal: this.killSignal, }); } useRefs(mapFn) { const structure = mapFn((query) => query); return Object.freeze(Object.entries(structure).reduce((acc, [key, query]) => { Object.defineProperty(acc, key, { configurable: false, enumerable: true, get: () => this.getElement(query), }); return acc; }, {})); } useChange(initialValue, onChange, { immediate = false } = {}) { if (immediate) { if (this._isConnected) { onChange(initialValue, initialValue); } else { this.onConnectActions.push(() => { onChange(initialValue, initialValue); }); } } return new ChangeHandler(initialValue, onChange); } buildTemplate() { const style = document.createElement('style'); style.textContent = this.templateCss?.() ?? ''; const fragment = document.createDocumentFragment(); fragment.appendChild(style); const htmlContent = this.templateHtml?.(); if (htmlContent) { fragment.appendChild(htmlContent); } return fragment; } static get typeOfClass() { return this.prototype.constructor; } } class Bus { _domElement = document.createElement('div'); constructor(_schema) { } register(event, callback, signal) { const listener = ({ detail }) => callback(detail); this._domElement.addEventListener(event, listener, { signal }); } emit(event, ...params) { this._domElement.dispatchEvent(new CustomEvent(event, { detail: params[0] })); } } function isComponentSelector(key) { return key.includes('-'); } const createSelector = (name) => { assert(isComponentSelector(name), `Custom element name must contain a hyphen: ${name}`); return name; }; function isRouteDef(obj) { return obj && typeof obj.path === 'string' && typeof obj.page === 'function'; } function normalizePath(path) { return `/${path}`.replace(/\/{2,}/g, '/').replace(/(?<=.)\/$/, ''); } function formatRouteKey(prefix, key) { return !prefix ? key : `${prefix}${key.charAt(0).toUpperCase()}${key.slice(1)}`; } function flattenRoutes(routes, prefix = '') { const result = {}; for (const [key, value] of Object.entries(routes)) { const nextKey = formatRouteKey(prefix, key); if (isRouteDef(value)) { result[nextKey] = { ...value, path: normalizePath(value.path), }; } else { Object.assign(result, flattenRoutes(value, nextKey)); } } return result; } function defineRoutes(routes) { return flattenRoutes(routes); } function defineRouteGroup(prefix, routes, options) { const normalizedPrefix = normalizePath(prefix); return Object.fromEntries(Object.entries(routes).map(([key, value]) => { return isRouteDef(value) ? [ key, { options: { ...options, ...value.options }, page: value.page, path: `${normalizedPrefix}${value.path}`, }, ] : [key, defineRouteGroup(normalizedPrefix, value, options)]; })); } function html(strings, ...values) { const { markup, markers } = htmlMarkup(strings, ...values); const templateEl = createElement('template'); templateEl.innerHTML = markup; const content = templateEl.content; const walker = document.createTreeWalker(content, NodeFilter.SHOW_COMMENT); const updates = []; while (walker.nextNode()) { const node = walker.currentNode; const match = markers[node.nodeValue?.trim() ?? '']; if (match && node.parentNode) { updates.push({ replacement: match, target: node }); } } updates.forEach(({ target, replacement }) => { target.parentNode?.replaceChild(replacement, target); }); return content; } const css = (strings, ...args) => { return strings.reduce((acc, curr, index) => `${acc}${curr}${args[index] ?? ''}`, ''); }; function createMarker(node, k1) { const key = `marker-${k1}`; return Array.isArray(node) ? node.reduce((acc, n, k2) => ({ markers: { ...acc.markers, [`${key}-${k2}`]: n }, markup: `${acc.markup}<!--${key}-${k2}-->`, }), { markers: {}, markup: '' }) : { markers: { [key]: node }, markup: `<!--${key}-->`, }; } function htmlMarkup(strings, ...values) { if (typeof strings === 'string') { return { markers: {}, markup: strings }; } return strings.reduce((acc, str, k1) => { const val = values[k1]; if (val instanceof Node || Array.isArray(val)) { const { markers, markup } = createMarker(val, k1); return { markers: { ...acc.markers, ...markers }, markup: `${acc.markup}${str}${markup}`, }; } return { markers: acc.markers, markup: `${acc.markup}${str}${String(val ?? '')}`, }; }, { markers: {}, markup: '' }); } const COMPONENT_SELECTOR_SEPARATOR = ';'; const ROUTER_PARAMETER_PREFIX = ':'; const defaultOptions = { baseUrl: '/', mode: 'history', }; class Router { _routes = []; _mode; _baseUrl; _parametersRegexp = new RegExp(`${ROUTER_PARAMETER_PREFIX}\\w+`, 'g'); _mount; _currentRoute; constructor(routes, options) { this._mode = options?.mode ?? defaultOptions.mode; this._baseUrl = options?.baseUrl ?? defaultOptions.baseUrl; this._routes = Object.entries(routes).map(([name, def]) => { const path = normalizePath(`/${def.path}`); const pathWithoutParameters = path.replace(this._parametersRegexp, '(.+)'); return { componentSelector: [def.options?.rootComponentSelector, def.page.selector] .filter(Boolean) .join(COMPONENT_SELECTOR_SEPARATOR), name, path, regexp: new RegExp(`^${pathWithoutParameters}$`), }; }); window.addEventListener(this.eventName, (evt) => { evt.preventDefault(); this.onUrlChange(); }); } get config() { return { baseUrl: this._baseUrl, mode: this._mode, }; } get currentRoute() { assert(this._currentRoute, 'No route found'); return this._currentRoute; } mountOn(el) { this._mount = el; this.onUrlChange(); } goto(name, params) { const route = this.getRouteByName(String(name)); assert(route, `No route found for name: ${String(name)}`); this.gotoPath(this.buildPath(route.path, params)); } get baseUrl() { return normalizePath(this._baseUrl); } get mountPoint() { assert(this._mount, 'No mount point defined'); return this._mount; } get eventName() { return this._mode === 'hash' ? 'hashchange' : 'popstate'; } get currentUrlPath() { const formattedPath = window.location.pathname.startsWith(this.baseUrl) ? window.location.pathname.slice(this.baseUrl.length) : window.location.pathname; const path = this._mode === 'hash' ? window.location.hash.slice(1) : formattedPath; return normalizePath(path); } gotoPath(path) { const urlPath = this._mode === 'hash' ? `#${path}` : path; window.history.pushState({}, '', normalizePath(`${this.baseUrl}/${urlPath}`)); this.onUrlChange(); } onUrlChange() { const urlPath = this.currentUrlPath; const matchedRoute = this.getRouteByPath(urlPath); assert(matchedRoute, `No route found for path: ${urlPath}`); this._currentRoute = matchedRoute; const component = this.getComponentToLoad({ ...matchedRoute, urlPath }); this.mountPoint.replaceChildren(component); } buildPath(routePath, params = {}) { const path = this.extractPathParams(routePath).reduce((acc, param) => { assert(Object.hasOwn(params, param), `Missing parameter "${param}" for path: ${routePath}`); return acc.replace(`${ROUTER_PARAMETER_PREFIX}${param}`, params[param]); }, routePath); return normalizePath(path); } getComponentToLoad(matchedRoute) { const { componentSelector } = matchedRoute; const params = this.getRouteParameters(matchedRoute); const [rootComponent, ...childComponents] = componentSelector.split(COMPONENT_SELECTOR_SEPARATOR); const rootElement = createElement(rootComponent, { attrs: params }); childComponents.reduce((parent, selector) => createElement(selector, { attrs: params }, parent), rootElement); return rootElement; } getRouteParameters(matchedRoute) { const { urlPath, regexp, path } = matchedRoute; const match = regexp.exec(urlPath); assert(match, `No match found for path: ${urlPath}`); return this.extractPathParams(path).reduce((acc, param, index) => { acc[param] = match[index + 1]; return acc; }, {}); } getRouteByName(name) { return this._routes.find(({ name: routeName }) => routeName === name) ?? null; } getRouteByPath(path) { return this._routes.find(({ regexp }) => regexp.test(path)) ?? null; } extractPathParams(path) { return path.split('/').reduce((acc, part) => { if (part.startsWith(ROUTER_PARAMETER_PREFIX)) { acc.push(part.slice(ROUTER_PARAMETER_PREFIX.length)); } return acc; }, []); } } exports.Bus = Bus; exports.Jadis = Jadis; exports.Router = Router; exports.assert = assert; exports.createElement = createElement; exports.createSelector = createSelector; exports.css = css; exports.defineRouteGroup = defineRouteGroup; exports.defineRoutes = defineRoutes; exports.html = html; exports.isComponentSelector = isComponentSelector; exports.toKebabCase = toKebabCase; //# sourceMappingURL=index.js.map