@jadis/core
Version:
Jadis is a minimal JavaScript library for building web interfaces using native Web Components.
553 lines (542 loc) • 19.1 kB
JavaScript
;
/**
* A helper function to assert conditions in the code.
* It throws an error if the condition is not met.
* @param condition The condition to check
* @param message The error message to throw if the condition is not met
* @throws Will throw an error if the condition is false
*/
function assert(condition, message) {
if (!condition) {
throw new Error(message);
}
}
/**
* Converts a string to kebab-case.
* This function replaces uppercase letters with their lowercase equivalents,
* prefixing them with a hyphen if they are not at the start of the string.
* @example
* toKebabCase('myVariableName'); // 'my-variable-name'
* toKebabCase('MyVariableName'); // 'my-variable-name'
* @param str The input string
* @returns The kebab-cased string
*/
const toKebabCase = (str) => {
return str.replace(/[A-Z]+(?![a-z])|[A-Z]/g, ($, ofs) => (ofs ? '-' : '') + $.toLowerCase());
};
function createElement(tag, attributes = {}, appendTo) {
const el = document.createElement(tag);
Object.entries(attributes).forEach(([key, value]) => el.setAttribute(toKebabCase(key), value));
appendTo?.appendChild(el);
return el;
}
/**
* Base class for all Jadis components.
* It provides a structure for creating web components with a shadow DOM,
* event handling, and attribute management.
*/
class Jadis extends HTMLElement {
static selector;
static template = '';
static observedAttributes = [];
shadowRoot;
attributesCallback = {};
_abortController = new AbortController();
_isConnected = false;
constructor() {
super();
this.shadowRoot = this.attachShadow({ mode: 'open' });
this.shadowRoot.appendChild(this.buildTemplate());
}
/**
* Creates a new instance of the component.
* @param attributes The attributes to set on the component
* @param appendTo The element to append the component to
* @returns The created component instance
*/
static createElement(attributes = {}, appendTo) {
return createElement(this.selector, attributes, appendTo);
}
/**
* Registers the component as a custom element.
* This method should be called once to define the custom element in the browser.
* It checks if the selector is defined and if the custom element is not already registered.
* @throws Will throw an error if the selector is not defined for the component
*/
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);
}
}
/**
* Checks if the component is connected to the DOM.
* @returns True if the component is connected, false otherwise
*/
get isConnected() {
return this._isConnected;
}
connectedCallback() {
this._isConnected = true;
setTimeout(() => this.onConnect?.());
}
disconnectedCallback() {
this._abortController.abort();
this.onDisconnect?.();
}
attributeChangedCallback(name, oldValue, newValue) {
this.attributesCallback[name]?.(newValue, oldValue);
}
/**
* Returns the AbortSignal associated with this component.
* This signal can be used to cancel ongoing operations or event listeners.
* @returns The AbortSignal for this component
*/
get killSignal() {
return this._abortController.signal;
}
/**
* Retrieves an element from the component's template.
* @param query The query string to find the element
* @returns The found element
* @throws Will throw an error if the element is not found
*/
getElement(query) {
const el = query
.split('>>>')
.reduce((nextEl, nextQuery) => {
return (nextEl.shadowRoot ?? nextEl).querySelector(nextQuery);
}, this);
assert(el, `${query} element is not reachable`);
return el;
}
/**
* Toggles a class on the component based on a condition.
* If the condition is true, the class will be added; if false, it will be removed.
* @param className The name of the class to toggle
* @param condition The binary condition to determine whether to add or remove the class
*/
toggleClass(className, condition) {
condition
? this.classList.add(className)
: this.classList.remove(className);
}
/**
* Registers a callback for a specific event on a bus.
* @param bus The event bus to register the callback on
* @param event The event key to listen for
* @param callback The callback to invoke when the event is emitted
*/
onBus(bus, event, callback) {
bus.register(event, callback, this.killSignal);
}
/**
* Creates a handler for events on the component.
* This handler allows registering and emitting events in a type-safe manner.
* @template EventType The type of events to handle
* @returns An object with methods to register and emit events
* @example
* // Typescript usage:
* const events = this.useEvents<{ someEvent: string }>();
* events.register('someEvent', (detail) => {
* console.log('Event detail:', detail);
* });
* events.emit('someEvent', 'Hello World');
*
* // Javascript usage:
* const events = this.useEvents({someEvent: String});
* events.register('someEvent', (detail) => {
* console.log('Event detail:', detail);
* });
* events.emit('someEvent', 'Hello World');
*/
useEvents(_schema) {
return {
register: (event, callback) => {
const listener = ({ detail, }) => callback(detail);
this.addEventListener(event, listener, {
signal: this.killSignal,
});
},
emit: (event, ...params) => {
this.dispatchEvent(new CustomEvent(event, { detail: params[0] }));
},
};
}
/**
* Registers a callback for a specific event on an element.
* @param element The element to listen for events on
* @param event The event key to listen for
* @param callback The callback to invoke when the event is emitted
*/
on(element, event, callback) {
element.addEventListener(event, callback, {
signal: this.killSignal,
});
}
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;
}
get typeOfConstructor() {
return this.constructor;
}
}
const ROUTER_PARAMETER_PREFIX = ':';
const defaultOptions = {
mode: 'history',
baseUrl: '/',
};
/**
* Router class for managing navigation and routing in a web application.
* It supports both hash and history modes for navigation.
* It allows defining routes, navigating to them, and mounting components based on the current URL.
*/
class Router {
_routes = [];
_mode;
_baseUrl;
_parametersRegexp = new RegExp(`${ROUTER_PARAMETER_PREFIX}\\w+`, 'g');
_mount;
_currentRoute;
constructor(options) {
this._mode = options?.mode ?? defaultOptions.mode;
this._baseUrl = options?.baseUrl ?? defaultOptions.baseUrl;
window.addEventListener(this.eventName, (evt) => {
evt.preventDefault();
this.onUrlChange();
});
}
get config() {
return {
mode: this._mode,
baseUrl: this._baseUrl,
};
}
/**
* Gets the current route.
* @throws Will throw an error if no route is found
* @returns The current route
*/
get currentRoute() {
assert(this._currentRoute, 'No route found');
return this._currentRoute;
}
/**
* Adds a new route to the router.
* @param path The path of the route
* @param componentSelector The selector of the component to mount for this route
* @param name An optional name for the route
* @example
* router.addRoute('/home', 'home-component', 'home');
* @returns this
*/
addRoute(path, componentSelector, name) {
const pathWithoutParameters = path.replace(this._parametersRegexp, '(.+)');
this._routes.push({
name,
path,
componentSelector,
regexp: new RegExp(`^${pathWithoutParameters}$`),
});
return this;
}
/**
* Adds a group of routes defined in a RouteGroup.
* This allows for organizing routes under a common prefix.
* @param routeGroup The RouteGroup containing routes to add
* @example
* const group = RouteGroup.create('/api')
* .addRoute('/users', 'user-list')
* .addRoute('/users/:id', 'user-detail');
* router.addGroup(group);
* @returns this
*/
addGroup(routeGroup) {
routeGroup
.getRoutes()
.forEach(({ path, componentSelector, name }) => this.addRoute(path, componentSelector, name));
return this;
}
/**
* Mounts the router on a specific HTML element.
* @param el The element to mount the router on
*/
mountOn(el) {
this._mount = el;
this.onUrlChange();
}
/**
* Navigates to a route by its name.
* @param name The name of the route to navigate to
* @param params The parameters to include with the route
*/
gotoName(name, params) {
const route = this.getRouteByName(name);
assert(route, `No route found for name: ${name}`);
this.gotoPath(this.formatPath(route.path, params));
}
/**
* Navigates to a route by its path.
* @param path The path of the route to navigate to
*/
gotoPath(path) {
const urlPath = this._mode === 'hash' ? `#${path}` : path;
window.history.pushState({}, '', `${this.baseUrl}/${urlPath}`.replace(/\/{2,}/g, '/'));
this.onUrlChange();
}
get baseUrl() {
return this._baseUrl.endsWith('/')
? this._baseUrl.slice(0, -1)
: 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 path.startsWith('/') ? path : `/${path}`;
}
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);
}
formatPath(routePath, params = {}) {
return this.extractPathParams(routePath).reduce((acc, param) => {
assert(params.hasOwnProperty(param), `Missing parameter "${param}" for path: ${routePath}`);
return acc.replace(`${ROUTER_PARAMETER_PREFIX}${param}`, params[param]);
}, routePath);
}
getComponentToLoad(matchedRoute) {
const { componentSelector } = matchedRoute;
const params = this.getRouteParameters(matchedRoute);
return createElement(componentSelector, params);
}
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) => {
return { ...acc, [param]: match[index + 1] };
}, {});
}
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) => {
return part.startsWith(ROUTER_PARAMETER_PREFIX)
? [...acc, part.slice(ROUTER_PARAMETER_PREFIX.length)]
: acc;
}, []);
}
}
class RouteGroup {
_routes = [];
routePrefix;
namePrefix;
constructor(routePrefix, namePrefix) {
this.routePrefix = routePrefix;
this.namePrefix = namePrefix ?? '';
}
/**
* Creates a new RouteGroup instance.
* @param routePrefix The prefix for the route paths.
* @param namePrefix The prefix for the route names.
* @returns A new RouteGroup instance.
*/
static create(routePrefix, namePrefix) {
const prefixed = routePrefix.startsWith('/')
? routePrefix
: `/${routePrefix}`;
const suffixed = prefixed.endsWith('/') ? prefixed : `${prefixed}/`;
return new RouteGroup(suffixed, namePrefix);
}
getRoutes() {
return this._routes;
}
/**
* Adds a route to the group.
* @param path The path of the route.
* @param componentSelector The component selector for the route.
* @param name The name of the route.
* @returns The current RouteGroup instance.
*/
addRoute(path, componentSelector, name) {
this._routes.push({
componentSelector,
path: `${this.routePrefix}${path.startsWith('/') ? path.slice(1) : path}`,
name: name ? `${this.namePrefix}${name}` : undefined,
});
return this;
}
/**
* Adds a group of routes to the current group.
* @param group The route group to add.
* @returns The current RouteGroup instance.
*/
addGroup(group) {
group.getRoutes().forEach(({ path, componentSelector, name }) => {
this.addRoute(path, componentSelector, name);
});
return this;
}
}
/**
* A helper for creating HTML templates using tagged template literals.
* It allows for easy creation of HTML structures with interpolation.
* @example
* const template = html`
* <div class="my-class">
* <p>${content}</p>
* </div>
* `;
* @returns A DocumentFragment containing the HTML structure
* @throws Will throw an error if the template contains invalid HTML.
*/
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({ target: node, replacement: match });
}
}
updates.forEach(({ target, replacement }) => {
target.parentNode?.replaceChild(replacement, target);
});
return content;
}
/**
* A helper for creating CSS styles using tagged template literals.
* It allows for easy creation of CSS styles with interpolation.
* @example
* const styles = css`
* .my-class {
* color: red;
* }
* `;
* @returns The concatenated CSS string
*/
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}-->`,
}), { markup: '', markers: {} })
: {
markers: { [key]: node },
markup: `<!--${key}-->`,
};
}
function htmlMarkup(strings, ...values) {
if (typeof strings === 'string') {
return { markup: strings, markers: {} };
}
return strings.reduce((acc, str, k1) => {
const val = values[k1];
if (val instanceof Node || Array.isArray(val)) {
const { markers, markup } = createMarker(val, k1);
return {
markup: `${acc.markup}${str}${markup}`,
markers: { ...acc.markers, ...markers },
};
}
return {
markup: `${acc.markup}${str}${String(val ?? '')}`,
markers: acc.markers,
};
}, { markup: '', markers: {} });
}
/**
* A bus for handling events in a type-safe manner.
* It allows registering and emitting events with specific types.
*/
class Bus {
domElement = document.createElement('div');
constructor(_schema) {
// only for typing
}
/**
* Registers a callback for a specific event.
* @param event The event key to listen for
* @param callback The callback to invoke when the event is emitted
* @param signal The AbortSignal to cancel the listener
*/
register(event, callback, signal) {
const listener = ({ detail }) => callback(detail);
this.domElement.addEventListener(event, listener, { signal });
}
/**
* Emits an event on the bus.
* @param event The event key to emit
* @param params The parameters to include with the event
*/
emit(event, ...params) {
this.domElement.dispatchEvent(new CustomEvent(event, { detail: params[0] }));
}
}
/**
* Checks if a string is a valid component selector.
* @param key The string to check
* @returns True if the string is a valid component selector, false otherwise
*/
function isComponentSelector(key) {
return key.includes('-');
}
/**
* Creates a component selector from a string.
* @param name The name of the component. It must contain a hyphen.
* @throws Will throw an error if the name does not contain a hyphen.
* @returns The component selector
*/
const createSelector = (name) => {
assert(isComponentSelector(name), `Custom element name must contain a hyphen: ${name}`);
return name;
};
exports.Bus = Bus;
exports.Jadis = Jadis;
exports.RouteGroup = RouteGroup;
exports.Router = Router;
exports.assert = assert;
exports.createElement = createElement;
exports.createSelector = createSelector;
exports.css = css;
exports.html = html;
exports.isComponentSelector = isComponentSelector;
exports.toKebabCase = toKebabCase;
//# sourceMappingURL=index.js.map