@xtia/jel
v0.11.2
Published
Lightweight DOM manipulation, componentisation and reactivity
Maintainers
Readme
Jel
Or, How I Learned To Stop Worrying And Love The DOM
Jel is a thin layer over the DOM to simplify element structure creation, manipulation and componentisation with 'vanilla' TS/JS.
See demo/index.ts for reusable components. Compare with resulting page.
$ Basic Use:
$.[tagname](details) produces an element of <tagname>. details can be content of various types or a descriptor object.
$ npm i @xtia/jelimport { $ } from "@xtia/jel";
// wrap body
const body = $(document.body);
body.append($.form([
$.h2("Sign in"),
$.label("Email"),
$.input({ attribs: { name: "email" }}),
$.label("Password"),
$.input({ attribs: { name: "password", type: "password" }}),
$.button("Sign in"),
$.a({
content: ["Having trouble? ", $.strong("Recover account")],
href: "/recover-account",
})
]));
body.append([
$.h2("Files"),
$.ul(
files.map(file => $.li(
$.a({
content: file.name,
href: `/files/${file.name}`,
})
))
)
])DOMContent
Content can be string, Text, HTMLElement, JelEntity or arbitrarily nested array of content. Typing as DOMContent carries that flexibility to your own interfaces.
function showDialogue(content: DOMContent) => {
const element = $.div({
classes: "dialogue",
content: [
content,
$.div({
classes: "buttons",
// content: [...]
})
]
});
// ...
}
interface Job {
name: string;
completionMessage: () => DOMContent;
}
showDialogue("Hello, world");
showDialogue(["Hello, ", $.i("world")]);
showDialogue([
$.h2(`${job.name} Complete`),
$.p(job.completionMessage()),
]);ElementClassDescriptor
Element classes can be specified as string, { [className]: boolean } and arbitrarily nested array thereof.
function renderFancyButton(
caption: DOMContent,
onClick: () => void,
classes: ElementClassDescriptor = []
) {
return $.button({
content: caption,
classes: ["fancy-button", classes],
// ...
});
}
function showDialogue(content: DOMContent, danger: boolean = false) {
const element = $.div({
// ...
classes: "dialogue",
content: [
content,
renderFancyButton("OK", close, ["ok-button", { danger }]),
]
});
// ...
}Jel-Wrapped Elements
Jel wraps its elements in an interface for common operations plus an append() method that accepts DOMContent.
For other operations the element is accessible via ent.element:
const div = $.div();
div.element.requestFullscreen();Shorthand
If you need an element with just a class, id and/or content you can use tag#id.classes notation, ie $("div#someId.class1.class2", content?).
showDialogue(["Hello ", $("span.green", "world")]);Event composition
Event emitters can be chained:
div.events.mousemove
.takeUntil(body.events.mousedown.filter(e => e.button === 1))
.map(ev => [ev.offsetX, ev.offsetY])
.apply(([x, y]) => console.log("mouse @ ", x, y));For RxJS users, events can be observed with fromEvent(ent.element, "mousemove").
Reactive properties
Style properties, content and class presence can be emitter subscriptions:
const mousePosition$ = $(document.body).events.mousemove
.map(ev => ({x: ev.clientX, y: ev.clientY}));
const virtualCursor = $.div({
classes: {
"virtual-cursor": true,
"near-top": mousePosition$.map(v => v.y < 100)
},
style: {
left: mousePosition$.map(v => v.x + "px"),
top: mousePosition$.map(v => v.y + "px")
}
});
virtualCursor.classes.toggle(
"near-left",
mousePosition$.map(v => v.x < 100>)
);
h1.content = websocket$
.filter(msg => msg.type == "title")
.map(msg => msg.text);
const searchInput = $("input.search");
const searchResults$ = searchInput.events.input
.debounce(300)
.map(() => searchInput.value)
.filter(term => term.length >= 2)
.mapAsync(term => performSearch(term)); // Returns emitter of search results
// Then use it reactively
$.ul({
content: searchResults$.map(results =>
results.map(result => $.li(result.title))
)
});Removing an element from the page will unsubscribe from any attached stream, and resubscribe if subsequently appended.
Emitters for this purpose can be Jel events, @xtia/timeline progressions, RxJS Observables or any object with either subscribe() or listen() that returns teardown logic.
import { animate } from "@xtia/timeline";
button.style.opacity = animate(500).tween(0, 1);Custom streams
Several utilities are provided to create event streams from existing sources and custom emit logic.
toEventEmitter(source)
Creates an EventEmitter<T> from an EmitterLike<T>, a listen function ((Handler<T>) => UnsubscribeFunc), or an EventSource + event name pair.
EmitterLike<T>is any object with a compatiblesubscribe|listenmethodEventSourceis any object with commonaddEventListener/removeEventListener|on/offmethods.
import { toEventEmitter } from "@xtia/jel";
// EventSource + name:
const keypresses$ = toEventEmitter(window, "keydown");
keypresses$.map(ev => ev.key)
.listen(key => console.log(key, "pressed"));
// EmitterLike
function logEvents(emitter: EmitterLike<any>) {
// this function accepts Jel's EventEmitter, as well as RxJS
// streams and other compatible emitters
toEventEmitter(emitter).listen(value => console.log(value));
}createEventSource()
Creates an EventEmitter and a emit(T) function to control it.
import { createEventSource } from "@xtia/jel";
function createGame() {
const winEmitPair = createEventSource<string>();
// <insert game logic>
// when someone wins:
winEmitPair.emit("player1");
return {
winEvent: winEmitPair.emitter
};
}
const game = createGame();
game.winEvent
.filter(winner => winner === me)
.apply(showConfetti);createEventsSource()
Creates an 'events' object and a trigger(name, Map[name]) function to trigger specific events.
import { createEventsSource } from "@xtia/jel";
type EventMap = {
end: { winner: string },
update: { state: GameState },
}
function createGame() {
const events = createEventsSource<EventMap>();
// when game ends
events.trigger("end", winnerName);
return {
events: events.emitters,
}
}createEventsProxy(source)
Creates an 'events' object from an EventSource.
import { createEventsProxy } from "@xtia/jel";
const windowEvents = createEventsProxy<WindowEventMap>(window);
// (this windowEvents is exported from @xtia/jel for convenience)
windowEvents.keydown
.filter(ev => ev.key == "Enter")
.apply(() => console.log("Enter pressed"));interval(ms)
Emits a number, incremented by 1 each time, as long as any subscriptions are active.
timeout(ms)
Emits once after the specified time.
animationFrames
Emits delta times from a requestAnimationFrame() loop, as long as any subscriptions are active.
import { animationFrames } from "@xtia.jel";
animationFrames.listen(delta => {
game.tick(delta);
});
## SubjectEmitter
Creates a manually-controlled emitter that maintains its last emitted value (`em.value`), emits it immediately to
and new subscription and can be updated with `em.next(value)`.