@jadis/core
Version:
Jadis is a minimal JavaScript library for building web interfaces using native Web Components.
432 lines (419 loc) • 14.6 kB
JavaScript
;
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