mirror of
https://github.com/jaandrle/deka-dom-el
synced 2025-07-01 04:12:14 +02:00
🔤 🐛 ⚡ v0.9.1-alpha (#30)
* :tap: removed on.attributeChanged and static observedAttributes * ⚡ import optimalization * ⚡ scope.signal * 🔤 🐛 * ⚡ 🐛 registerReactivity and types * 🔤 * ⚡ * 🔤 * 🐛 Node in enviroment * ⚡ todos * ⚡ * ⚡ 🔤 * ⚡ lint * ⚡ memo * 🔤 🐛 memo * ⚡ 🔤 todomvc * 🐛 types * 🔤 p08 signal factory * 🔤 ⚡ types * ⚡ 🔤 lint * 🔤 * 🔤 * 🔤 * 🔤 * 📺
This commit is contained in:
@ -189,6 +189,7 @@ import { el } from "deka-dom-el";
|
||||
* */
|
||||
export function code({ id, src, content, language= "js", className= host.slice(1), page_id }){
|
||||
if(src) content= s.cat(src);
|
||||
content= normalizeIndentation(content);
|
||||
let dataJS;
|
||||
if(page_id){
|
||||
registerClientPart(page_id);
|
||||
@ -198,6 +199,10 @@ export function code({ id, src, content, language= "js", className= host.slice(1
|
||||
el("code", { className: "language-"+language, textContent: content.trim() })
|
||||
);
|
||||
}
|
||||
export function pre({ content }){
|
||||
content= normalizeIndentation(content);
|
||||
return el("pre").append(el("code", content.trim()));
|
||||
}
|
||||
let is_registered= {};
|
||||
/** @param {string} page_id */
|
||||
function registerClientPart(page_id){
|
||||
@ -207,33 +212,6 @@ function registerClientPart(page_id){
|
||||
document.head.append(
|
||||
// Use a newer version of Shiki with better performance
|
||||
el("script", { src: "https://cdn.jsdelivr.net/npm/shiki@0.14.3/dist/index.unpkg.iife.js", defer: true }),
|
||||
// Make sure we can match Flems styling in dark/light mode
|
||||
el("style", `
|
||||
/* Ensure CodeMirror and Shiki use the same font */
|
||||
.CodeMirror *, .shiki * {
|
||||
font-family: var(--font-mono) !important;
|
||||
}
|
||||
|
||||
/* Style Shiki's output to match our theme */
|
||||
.shiki {
|
||||
background-color: var(--shiki-color-background) !important;
|
||||
color: var(--shiki-color-text) !important;
|
||||
padding: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
/* Ensure Shiki code tokens use our CSS variables */
|
||||
.shiki .keyword { color: var(--shiki-token-keyword) !important; }
|
||||
.shiki .constant { color: var(--shiki-token-constant) !important; }
|
||||
.shiki .string { color: var(--shiki-token-string) !important; }
|
||||
.shiki .comment { color: var(--shiki-token-comment) !important; }
|
||||
.shiki .function { color: var(--shiki-token-function) !important; }
|
||||
.shiki .operator, .shiki .punctuation { color: var(--shiki-token-punctuation) !important; }
|
||||
.shiki .parameter { color: var(--shiki-token-parameter) !important; }
|
||||
.shiki .variable { color: var(--shiki-token-variable) !important; }
|
||||
.shiki .property { color: var(--shiki-token-property) !important; }
|
||||
`),
|
||||
);
|
||||
|
||||
registerClientFile(
|
||||
@ -245,3 +223,9 @@ function registerClientPart(page_id){
|
||||
|
||||
is_registered[page_id]= true;
|
||||
}
|
||||
/** @param {string} src */
|
||||
function normalizeIndentation(src){
|
||||
const lines= src.split("\n");
|
||||
const min_indent= Math.min(...lines.map(line=> line.search(/\S/)).filter(i=> i >= 0));
|
||||
return lines.map(line=> line.slice(min_indent)).join("\n");
|
||||
}
|
||||
|
@ -96,6 +96,18 @@ html[data-theme="light"] .cm-s-material .cm-error { color: #f44336 !important; }
|
||||
max-width: 100% !important;
|
||||
}
|
||||
}
|
||||
${host}[data-variant=big]{
|
||||
height: 100vh;
|
||||
|
||||
main {
|
||||
flex-flow: column nowrap;
|
||||
flex-grow: 1;
|
||||
}
|
||||
main > * {
|
||||
width: 100%;
|
||||
max-width: 100% !important;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
const dde_content= s.cat(new URL("../../dist/esm-with-signals.js", import.meta.url)).toString();
|
||||
@ -108,15 +120,16 @@ import { relative } from "node:path";
|
||||
* @param {object} attrs
|
||||
* @param {URL} attrs.src Example code file path
|
||||
* @param {"js"|"ts"|"html"|"css"} [attrs.language="js"] Language of the code
|
||||
* @param {"normal"|"big"} [attrs.variant="normal"] Size of the example
|
||||
* @param {string} attrs.page_id ID of the page
|
||||
* */
|
||||
export function example({ src, language= "js", page_id }){
|
||||
export function example({ src, language= "js", variant= "normal", page_id }){
|
||||
registerClientPart(page_id);
|
||||
const content= s.cat(src).toString()
|
||||
.replaceAll(/ from "deka-dom-el(\/signals)?";/g, ' from "./esm-with-signals.js";');
|
||||
const id= "code-example-"+generateCodeId(src);
|
||||
return el().append(
|
||||
el(code, { id, content, language, className: example.name }),
|
||||
el(code, { id, content, language, className: example.name }, el=> el.dataset.variant= variant),
|
||||
elCode({ id, content, extension: "."+language })
|
||||
);
|
||||
}
|
||||
|
@ -8,7 +8,8 @@ export class HTMLCustomElement extends HTMLElement{
|
||||
connectedCallback(){
|
||||
customElementRender(
|
||||
this.attachShadow({ mode: "open" }),
|
||||
ddeComponent
|
||||
ddeComponent,
|
||||
this
|
||||
);
|
||||
}
|
||||
set attr(value){ this.setAttribute("attr", value); }
|
||||
|
@ -2,7 +2,6 @@
|
||||
import {
|
||||
customElementRender,
|
||||
customElementWithDDE,
|
||||
observedAttributes,
|
||||
} from "deka-dom-el";
|
||||
/** @type {ddePublicElementTagNameMap} */
|
||||
import { S } from "deka-dom-el/signals";
|
||||
|
@ -9,7 +9,7 @@ export class HTMLCustomElement extends HTMLElement{
|
||||
// nice place to render custom element
|
||||
}
|
||||
attributeChangedCallback(name, oldValue, newValue){
|
||||
// listen to attribute changes (see `observedAttributes`)
|
||||
// listen to attribute changes (see `S.observedAttributes`)
|
||||
}
|
||||
disconnectedCallback(){
|
||||
// nice place to clean up
|
||||
|
@ -1,7 +1,6 @@
|
||||
import {
|
||||
customElementRender,
|
||||
customElementWithDDE,
|
||||
observedAttributes,
|
||||
el, on, scope,
|
||||
} from "deka-dom-el";
|
||||
import { S } from "deka-dom-el/signals";
|
||||
@ -9,7 +8,6 @@ export class HTMLCustomElement extends HTMLElement{
|
||||
static tagName= "custom-element";
|
||||
static observedAttributes= [ "attr" ];
|
||||
connectedCallback(){
|
||||
console.log(observedAttributes(this));
|
||||
customElementRender(
|
||||
this.attachShadow({ mode: "open" }),
|
||||
ddeComponent,
|
||||
|
@ -1,4 +1,4 @@
|
||||
// Example of reactive element marker
|
||||
<!--<dde:mark type=\"reactive\" source=\"...\">-->
|
||||
<!--<dde:mark type="reactive" source="...">-->
|
||||
<!-- content that updates when signal changes -->
|
||||
<!--</dde:mark>-->
|
@ -24,7 +24,11 @@ document.body.append(
|
||||
);
|
||||
|
||||
import { chainableAppend } from "deka-dom-el";
|
||||
/** @param {keyof HTMLElementTagNameMap} tag */
|
||||
/**
|
||||
* @template {keyof HTMLElementTagNameMap} TAG
|
||||
* @param {TAG} tag
|
||||
* @returns {ddeHTMLElementTagNameMap[TAG] extends HTMLElement ? ddeHTMLElementTagNameMap[TAG] : ddeHTMLElement}
|
||||
* */
|
||||
const createElement= tag=> chainableAppend(document.createElement(tag));
|
||||
document.body.append(
|
||||
createElement("p").append(
|
||||
|
@ -8,12 +8,12 @@ button.disabled = true;
|
||||
const button2 = Object.assign(
|
||||
document.createElement('button'),
|
||||
{
|
||||
textContent: "Click me",
|
||||
className: "primary",
|
||||
disabled: true
|
||||
textContent: "Click me",
|
||||
className: "primary",
|
||||
disabled: true
|
||||
}
|
||||
);
|
||||
|
||||
// Add to DOM
|
||||
document.body.appendChild(button);
|
||||
document.body.appendChild(button2);
|
||||
document.body.append(button);
|
||||
document.body.append(button2);
|
||||
|
@ -2,14 +2,14 @@
|
||||
const div = document.createElement('div');
|
||||
const h1 = document.createElement('h1');
|
||||
h1.textContent = 'Title';
|
||||
div.appendChild(h1);
|
||||
div.append(h1);
|
||||
|
||||
const p = document.createElement('p');
|
||||
p.textContent = 'Paragraph';
|
||||
div.appendChild(p);
|
||||
div.append(p);
|
||||
|
||||
// appendChild doesn't return parent
|
||||
// append doesn't return parent
|
||||
// so chaining is not possible
|
||||
|
||||
// Add to DOM
|
||||
document.body.appendChild(div);
|
||||
document.body.append(div);
|
||||
|
19
docs/components/examples/events/dispatch.js
Normal file
19
docs/components/examples/events/dispatch.js
Normal file
@ -0,0 +1,19 @@
|
||||
import { el, on, dispatchEvent, scope } from "deka-dom-el";
|
||||
document.body.append(
|
||||
el(component),
|
||||
);
|
||||
|
||||
function component(){
|
||||
const { host }= scope;
|
||||
const dispatchExample= dispatchEvent(
|
||||
"example",
|
||||
{ bubbles: true },
|
||||
host
|
||||
);
|
||||
|
||||
return el("div").append(
|
||||
el("p", "Dispatch events from outside of the component."),
|
||||
el("button", { textContent: "Dispatch", type: "button" },
|
||||
on("click", dispatchExample))
|
||||
);
|
||||
}
|
@ -1,9 +1,9 @@
|
||||
import { el, on } from "deka-dom-el";
|
||||
const paragraph= el("p", "See live-cycle events in console.",
|
||||
const paragraph= el("p", "See lifecycle events in console.",
|
||||
el=> log({ type: "dde:created", detail: el }),
|
||||
on.connected(log),
|
||||
on.disconnected(log),
|
||||
on.attributeChanged(log));
|
||||
);
|
||||
|
||||
document.body.append(
|
||||
paragraph,
|
||||
|
@ -6,9 +6,8 @@ let count = 0;
|
||||
button.addEventListener('click', () => {
|
||||
count++;
|
||||
document.querySelector('p').textContent =
|
||||
'Clicked ' + count + ' times';
|
||||
'Clicked ' + count + ' times';
|
||||
|
||||
if (count > 10) {
|
||||
button.disabled = true;
|
||||
}
|
||||
if (count > 10)
|
||||
button.disabled = true;
|
||||
});
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { el, on } from "deka-dom-el";
|
||||
import { el } from "deka-dom-el";
|
||||
import { S } from "deka-dom-el/signals";
|
||||
|
||||
// A HelloWorld component using the 3PS pattern
|
||||
@ -27,4 +27,4 @@ function HelloWorld({ emoji = "🚀" }) {
|
||||
// Use the component in your app
|
||||
document.body.append(
|
||||
el(HelloWorld, { emoji: "🎉" })
|
||||
);
|
||||
);
|
||||
|
@ -15,15 +15,15 @@ function HelloWorldComponent({ initial }){
|
||||
|
||||
return el().append(
|
||||
el("p", {
|
||||
textContent: S(() => `Hello World ${emoji().repeat(clicks())}`),
|
||||
textContent: S(() => `Hello World ${emoji.get().repeat(clicks.get())}`),
|
||||
className: "example",
|
||||
ariaLive: "polite", //OR ariaset: { live: "polite" },
|
||||
dataset: { example: "Example" }, //OR dataExample: "Example",
|
||||
}),
|
||||
el("button",
|
||||
{ textContent: "Fire", type: "button" },
|
||||
on("click", ()=> clicks(clicks() + 1)),
|
||||
on("keyup", ()=> clicks(clicks() - 2)),
|
||||
on("click", ()=> clicks.set(clicks.get() + 1)),
|
||||
on("keyup", ()=> clicks.set(clicks.get() - 2)),
|
||||
),
|
||||
el("select", null, onChange).append(
|
||||
el(OptionComponent, "🎉", isSelected),//OR { textContent: "🎉" }
|
||||
|
2
docs/components/examples/optimization/intro.js
Normal file
2
docs/components/examples/optimization/intro.js
Normal file
@ -0,0 +1,2 @@
|
||||
// use NPM or for example https://cdn.jsdelivr.net/gh/jaandrle/deka-dom-el/dist/esm-with-signals.js
|
||||
import { memo } from "deka-dom-el";
|
115
docs/components/examples/optimization/memo.js
Normal file
115
docs/components/examples/optimization/memo.js
Normal file
@ -0,0 +1,115 @@
|
||||
// Example of how memoization improves performance with list rendering
|
||||
import { el, on, memo } from "deka-dom-el";
|
||||
import { S } from "deka-dom-el/signals";
|
||||
|
||||
// A utility to log element creation
|
||||
function logCreation(name) {
|
||||
console.log(`Creating ${name} element`);
|
||||
return name;
|
||||
}
|
||||
|
||||
// Create a signal with our items
|
||||
const itemsSignal = S([
|
||||
{ id: 1, name: "Item 1" },
|
||||
{ id: 2, name: "Item 2" },
|
||||
{ id: 3, name: "Item 3" }
|
||||
], {
|
||||
add() {
|
||||
const { length }= this.value;
|
||||
this.value.push({
|
||||
id: length + 1,
|
||||
name: `Item ${length + 1}`
|
||||
});
|
||||
},
|
||||
force(){},
|
||||
});
|
||||
|
||||
// Without memoization - creates new elements on every render
|
||||
function withoutMemo() {
|
||||
return el("div").append(
|
||||
el("h3", "Without Memoization (check console for element creation)"),
|
||||
el("p", "Elements are recreated on every render"),
|
||||
S.el(itemsSignal, items =>
|
||||
el("ul").append(
|
||||
...items.map(item =>
|
||||
el("li").append(
|
||||
el("span", logCreation(item.name))
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// With memoization - reuses elements when possible
|
||||
function withMemo() {
|
||||
return el("div").append(
|
||||
el("h3", "With Memoization (check console for element creation)"),
|
||||
el("p", "Elements are reused when the key (item.id) stays the same"),
|
||||
S.el(itemsSignal, items =>
|
||||
el("ul").append(
|
||||
...items.map(item =>
|
||||
// Use item.id as a stable key for memoization
|
||||
memo(item.id, () =>
|
||||
el("li").append(
|
||||
el("span", logCreation(item.name))
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Using memo.scope for a custom memoized function
|
||||
const renderMemoList = memo.scope(function(items) {
|
||||
return el("ul").append(
|
||||
...items.map(item =>
|
||||
memo(item.id, () =>
|
||||
el("li").append(
|
||||
el("span", logCreation(`Custom memo: ${item.name}`))
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
function withCustomMemo() {
|
||||
return el("div").append(
|
||||
el("h3", "With Custom Memo Function"),
|
||||
el("p", "Using memo.scope to create a memoized rendering function"),
|
||||
S.el(itemsSignal, items =>
|
||||
renderMemoList(items)
|
||||
),
|
||||
el("button", "Clear Cache",
|
||||
on("click", () => {
|
||||
renderMemoList.clear();
|
||||
S.action(itemsSignal, "force");
|
||||
}
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Demo component showing the difference
|
||||
export function MemoDemo() {
|
||||
return el("div", { style: "padding: 1em; border: 1px solid #ccc;" }).append(
|
||||
el("h2", "Memoization Demo"),
|
||||
el("p", "See in the console when elements are created."),
|
||||
el("p").append(`
|
||||
Notice that without memoization, elements are recreated on every render. With memoization,
|
||||
only new elements are created.
|
||||
`),
|
||||
el("button", "Add Item",
|
||||
on("click", () => S.action(itemsSignal, "add"))
|
||||
),
|
||||
|
||||
el("div", { style: "display: flex; gap: 2em; margin-top: 1em;" }).append(
|
||||
withoutMemo(),
|
||||
withMemo(),
|
||||
withCustomMemo()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
document.body.append(el(MemoDemo));
|
386
docs/components/examples/reallife/todomvc.js
Normal file
386
docs/components/examples/reallife/todomvc.js
Normal file
@ -0,0 +1,386 @@
|
||||
import { dispatchEvent, el, memo, on, scope } from "deka-dom-el";
|
||||
import { S } from "deka-dom-el/signals";
|
||||
|
||||
/**
|
||||
* Main TodoMVC application component
|
||||
*
|
||||
* Creates and manages the TodoMVC application with the following features:
|
||||
* - Todo items management (add, edit, delete)
|
||||
* - Filtering by status (all, active, completed)
|
||||
* - Client-side routing via URL hash
|
||||
* - Persistent storage with localStorage
|
||||
*
|
||||
* @returns {HTMLElement} The root TodoMVC application element
|
||||
*/
|
||||
function Todos(){
|
||||
const pageS = routerSignal(S);
|
||||
const todosS = todosSignal();
|
||||
/** Derived signal that filters todos based on current route */
|
||||
const filteredTodosS = S(()=> {
|
||||
const todos = todosS.get();
|
||||
const filter = pageS.get();
|
||||
return todos.filter(todo => {
|
||||
if (filter === "active") return !todo.completed;
|
||||
if (filter === "completed") return todo.completed;
|
||||
return true; // "all"
|
||||
});
|
||||
});
|
||||
|
||||
// Setup hash change listener
|
||||
window.addEventListener("hashchange", () => {
|
||||
const hash = location.hash.replace("#", "") || "all";
|
||||
S.action(pageS, "set", /** @type {"all"|"active"|"completed"} */(hash));
|
||||
});
|
||||
|
||||
/** @type {ddeElementAddon<HTMLInputElement>} */
|
||||
const onToggleAll = on("change", event => {
|
||||
const checked = /** @type {HTMLInputElement} */ (event.target).checked;
|
||||
S.action(todosS, "completeAll", checked);
|
||||
});
|
||||
/** @type {ddeElementAddon<HTMLFormElement>} */
|
||||
const onSubmitNewTodo = on("submit", event => {
|
||||
event.preventDefault();
|
||||
const input = /** @type {HTMLInputElement} */(
|
||||
/** @type {HTMLFormElement} */(event.target).elements.namedItem("newTodo")
|
||||
);
|
||||
const title = input.value.trim();
|
||||
if (title) {
|
||||
S.action(todosS, "add", title);
|
||||
input.value = "";
|
||||
}
|
||||
});
|
||||
const onClearCompleted = on("click", () => S.action(todosS, "clearCompleted"));
|
||||
const onDelete = on("todo:delete", ev =>
|
||||
S.action(todosS, "delete", /** @type {{ detail: Todo["id"] }} */(ev).detail));
|
||||
const onEdit = on("todo:edit", ev =>
|
||||
S.action(todosS, "edit", /** @type {{ detail: Partial<Todo> & { id: Todo["id"] } }} */(ev).detail));
|
||||
|
||||
return el("section", { className: "todoapp" }).append(
|
||||
el("header", { className: "header" }).append(
|
||||
el("h1", "todos"),
|
||||
el("form", null, onSubmitNewTodo).append(
|
||||
el("input", {
|
||||
className: "new-todo",
|
||||
name: "newTodo",
|
||||
placeholder: "What needs to be done?",
|
||||
autocomplete: "off",
|
||||
autofocus: true
|
||||
})
|
||||
)
|
||||
),
|
||||
S.el(todosS, todos => todos.length
|
||||
? el("main", { className: "main" }).append(
|
||||
el("input", {
|
||||
id: "toggle-all",
|
||||
className: "toggle-all",
|
||||
type: "checkbox"
|
||||
}, onToggleAll),
|
||||
el("label", { htmlFor: "toggle-all", title: "Mark all as complete" }),
|
||||
el("ul", { className: "todo-list" }).append(
|
||||
S.el(filteredTodosS, filteredTodos => filteredTodos.map(todo =>
|
||||
memo(todo.id, ()=> el(TodoItem, todo, onDelete, onEdit)))
|
||||
)
|
||||
)
|
||||
)
|
||||
: el()
|
||||
),
|
||||
S.el(todosS, todos => memo(todos.length, length=> length
|
||||
? el("footer", { className: "footer" }).append(
|
||||
el("span", { className: "todo-count" }).append(
|
||||
S.el(S(() => todosS.get().filter(todo => !todo.completed).length),
|
||||
length=> el("strong").append(
|
||||
length + " ",
|
||||
length === 1 ? "item left" : "items left"
|
||||
)
|
||||
)
|
||||
),
|
||||
el("ul", { className: "filters" }).append(
|
||||
el("li").append(
|
||||
el("a", {
|
||||
textContent: "All",
|
||||
className: S(()=> pageS.get() === "all" ? "selected" : ""),
|
||||
href: "#"
|
||||
}),
|
||||
),
|
||||
el("li").append(
|
||||
el("a", {
|
||||
textContent: "Active",
|
||||
className: S(()=> pageS.get() === "active" ? "selected" : ""),
|
||||
href: "#active"
|
||||
}),
|
||||
),
|
||||
el("li").append(
|
||||
el("a", {
|
||||
textContent: "Completed",
|
||||
className: S(()=> pageS.get() === "completed" ? "selected" : ""),
|
||||
href: "#completed"
|
||||
}),
|
||||
)
|
||||
),
|
||||
S.el(S(() => todosS.get().some(todo => todo.completed)),
|
||||
hasTodosCompleted=> hasTodosCompleted
|
||||
? el("button", { textContent: "Clear completed", className: "clear-completed" }, onClearCompleted)
|
||||
: el()
|
||||
)
|
||||
)
|
||||
: el()
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Todo item data structure
|
||||
* @typedef {{ title: string, id: string, completed: boolean }} Todo
|
||||
*/
|
||||
|
||||
/**
|
||||
* Component for rendering an individual todo item
|
||||
*
|
||||
* Features:
|
||||
* - Display todo with completed state
|
||||
* - Toggle completion status
|
||||
* - Delete todo
|
||||
* - Edit todo with double-click
|
||||
* - Cancel edit with Escape key
|
||||
*
|
||||
* @param {Todo} todo - The todo item data
|
||||
* @fires {void} todo:delete - todo deletion event
|
||||
* @fires {Partial<Todo>} todo:edit - todo edits event
|
||||
*/
|
||||
function TodoItem({ id, title, completed }) {
|
||||
const { host }= scope;
|
||||
const isEditing = S(false);
|
||||
const isCompleted = S(completed);
|
||||
|
||||
/** @type {(id: string) => void} Dispatch function for deleting todo */
|
||||
const dispatchDelete= dispatchEvent("todo:delete", host);
|
||||
/** @type {(data: {id: string, [key: string]: any}) => void} Dispatch function for editing todo */
|
||||
const dispatchEdit = dispatchEvent("todo:edit", host);
|
||||
|
||||
/** @type {ddeElementAddon<HTMLInputElement>} */
|
||||
const onToggleCompleted = on("change", (ev) => {
|
||||
const completed= /** @type {HTMLInputElement} */(ev.target).checked;
|
||||
isCompleted.set(completed);
|
||||
dispatchEdit({ id, completed });
|
||||
});
|
||||
/** @type {ddeElementAddon<HTMLButtonElement>} */
|
||||
const onDelete = on("click", () => dispatchDelete(id));
|
||||
/** @type {ddeElementAddon<HTMLLabelElement>} */
|
||||
const onStartEdit = on("dblclick", () => isEditing.set(true));
|
||||
/** @type {ddeElementAddon<HTMLInputElement>} */
|
||||
const onBlurEdit = on("blur", event => {
|
||||
const value = /** @type {HTMLInputElement} */(event.target).value.trim();
|
||||
if (value) {
|
||||
dispatchEdit({ id, title: value });
|
||||
} else {
|
||||
dispatchDelete(id);
|
||||
}
|
||||
isEditing.set(false);
|
||||
});
|
||||
/** @type {ddeElementAddon<HTMLFormElement>} */
|
||||
const onSubmitEdit = on("submit", event => {
|
||||
event.preventDefault();
|
||||
const input = /** @type {HTMLFormElement} */(event.target).elements.namedItem("edit");
|
||||
const value = /** @type {HTMLInputElement} */(input).value.trim();
|
||||
if (value) {
|
||||
dispatchEdit({ id, title: value });
|
||||
} else {
|
||||
dispatchDelete(id);
|
||||
}
|
||||
isEditing.set(false);
|
||||
});
|
||||
|
||||
/**
|
||||
* Event handler for keyboard events in edit mode
|
||||
* @type {ddeElementAddon<HTMLInputElement>}
|
||||
*/
|
||||
const onKeyDown = on("keydown", event => {
|
||||
if (event.key !== "Escape") return;
|
||||
isEditing.set(false);
|
||||
});
|
||||
|
||||
return el("li", { classList: { completed: isCompleted, editing: isEditing } }).append(
|
||||
el("div", { className: "view" }).append(
|
||||
el("input", {
|
||||
className: "toggle",
|
||||
type: "checkbox",
|
||||
checked: completed
|
||||
}, onToggleCompleted),
|
||||
el("label", { textContent: title }, onStartEdit),
|
||||
el("button", { className: "destroy" }, onDelete)
|
||||
),
|
||||
S.el(isEditing, editing => editing
|
||||
? el("form", null, onSubmitEdit).append(
|
||||
el("input", {
|
||||
className: "edit",
|
||||
name: "edit",
|
||||
value: title,
|
||||
"data-id": id
|
||||
}, onBlurEdit, onKeyDown, addFocus)
|
||||
)
|
||||
: el()
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Set up the document head
|
||||
document.head.append(
|
||||
el("title", "TodoMVC: dd<el>"),
|
||||
el("meta", { name: "description", content: "A TodoMVC implementation using dd<el>." }),
|
||||
el("link", {
|
||||
rel: "stylesheet",
|
||||
href: "https://cdn.jsdelivr.net/npm/todomvc-common@1.0.5/base.css"
|
||||
}),
|
||||
el("link", {
|
||||
rel: "stylesheet",
|
||||
href: "https://cdn.jsdelivr.net/npm/todomvc-app-css@2.4.2/index.css"
|
||||
})
|
||||
);
|
||||
|
||||
// Set up the document body
|
||||
document.body.append(
|
||||
el(Todos),
|
||||
el("footer", { className: "info" }).append(
|
||||
el("p", "Double-click to edit a todo"),
|
||||
el("p").append(
|
||||
"Created with ",
|
||||
el("a", { textContent: "deka-dom-el", href: "https://github.com/jaandrle/deka-dom-el" })
|
||||
),
|
||||
el("p").append(
|
||||
"Part of ",
|
||||
el("a", { textContent: "TodoMVC", href: "http://todomvc.com" })
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Utility function to set focus on an input element
|
||||
* Uses requestAnimationFrame to ensure the element is rendered
|
||||
* before trying to focus it
|
||||
*
|
||||
* @param {HTMLInputElement} editInput - The input element to focus
|
||||
* @returns {number} The requestAnimationFrame ID
|
||||
*/
|
||||
function addFocus(editInput){
|
||||
return requestAnimationFrame(()=> {
|
||||
editInput.focus();
|
||||
editInput.selectionStart = editInput.selectionEnd = editInput.value.length;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a signal for managing todos with persistence
|
||||
*
|
||||
* Features:
|
||||
* - Loads todos from localStorage on initialization
|
||||
* - Automatically saves todos to localStorage on changes
|
||||
* - Provides actions for adding, editing, deleting todos
|
||||
*/
|
||||
function todosSignal(){
|
||||
const store_key = "dde-todos";
|
||||
// Try to load todos from localStorage
|
||||
let savedTodos = [];
|
||||
try {
|
||||
const stored = localStorage.getItem(store_key);
|
||||
if (stored) {
|
||||
savedTodos = JSON.parse(stored);
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
const out= S(/** @type {Todo[]} */(savedTodos || []), {
|
||||
/**
|
||||
* Add a new todo
|
||||
* @param {string} value - The title of the new todo
|
||||
*/
|
||||
add(value){
|
||||
this.value.push({
|
||||
completed: false,
|
||||
title: value,
|
||||
id: uuid(),
|
||||
});
|
||||
},
|
||||
/**
|
||||
* Edit an existing todo
|
||||
* @param {{ id: string, [key: string]: any }} data - Object containing id and fields to update
|
||||
*/
|
||||
edit({ id, ...update }){
|
||||
const index = this.value.findIndex(t => t.id === id);
|
||||
if (index === -1) return this.stopPropagation();
|
||||
Object.assign(this.value[index], update);
|
||||
},
|
||||
/**
|
||||
* Delete a todo by id
|
||||
* @param {string} id - The id of the todo to delete
|
||||
*/
|
||||
delete(id){
|
||||
const index = this.value.findIndex(t => t.id === id);
|
||||
if (index === -1) return this.stopPropagation();
|
||||
this.value.splice(index, 1);
|
||||
},
|
||||
/**
|
||||
* Remove all completed todos
|
||||
*/
|
||||
clearCompleted() {
|
||||
this.value = this.value.filter(todo => !todo.completed);
|
||||
},
|
||||
completeAll(state= true) {
|
||||
this.value.forEach(todo => todo.completed = state);
|
||||
},
|
||||
/**
|
||||
* Handle cleanup when signal is cleared
|
||||
*/
|
||||
[S.symbols.onclear](){
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Save todos to localStorage whenever the signal changes
|
||||
* @param {Todo[]} value - Current todos array
|
||||
*/
|
||||
S.on(out, /** @param {Todo[]} value */ function saveTodos(value) {
|
||||
try {
|
||||
localStorage.setItem(store_key, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
console.error("Failed to save todos to localStorage", e);
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a signal for managing route state
|
||||
*
|
||||
* @param {typeof S} signal - The signal constructor
|
||||
*/
|
||||
function routerSignal(signal){
|
||||
const initial = location.hash.replace("#", "") || "all";
|
||||
return signal(initial, {
|
||||
/**
|
||||
* Set the current route
|
||||
* @param {"all"|"active"|"completed"} hash - The route to set
|
||||
*/
|
||||
set(hash){
|
||||
location.hash = hash;
|
||||
this.value = hash;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a RFC4122 version 4 compliant UUID
|
||||
* Used to create unique identifiers for todo items
|
||||
*
|
||||
* @returns {string} A randomly generated UUID
|
||||
*/
|
||||
function uuid() {
|
||||
let uuid = "";
|
||||
for (let i = 0; i < 32; i++) {
|
||||
let random = (Math.random() * 16) | 0;
|
||||
|
||||
if (i === 8 || i === 12 || i === 16 || i === 20)
|
||||
uuid += "-";
|
||||
|
||||
uuid += (i === 12 ? 4 : i === 16 ? (random & 3) | 8 : random).toString(16);
|
||||
}
|
||||
return uuid;
|
||||
}
|
@ -14,7 +14,7 @@ function component(){
|
||||
const textContent= S("Click to change text.");
|
||||
|
||||
const onclickChange= on("click", function redispatch(){
|
||||
textContent("Text changed! "+(new Date()).toString())
|
||||
textContent.set("Text changed! "+(new Date()).toString())
|
||||
});
|
||||
return el("p", textContent, onclickChange);
|
||||
}
|
||||
|
@ -1,14 +0,0 @@
|
||||
import { scope } from "deka-dom-el";
|
||||
import { S } from "deka-dom-el/signals";
|
||||
|
||||
function customSignalLogic() {
|
||||
// Create an isolated scope for a specific operation
|
||||
scope.push(); // Start new scope
|
||||
|
||||
// These signals are in the new scope
|
||||
const isolatedCount = S(0);
|
||||
const isolatedDerived = S(() => isolatedCount.get() * 2);
|
||||
|
||||
// Clean up by returning to previous scope
|
||||
scope.pop();
|
||||
}
|
@ -16,7 +16,9 @@ function Counter() {
|
||||
// THE HOST IS PROBABLY DIFFERENT THAN
|
||||
// YOU EXPECT AND SIGNAL MAY BE
|
||||
// UNEXPECTEDLY REMOVED!!!
|
||||
host().querySelector("button").disabled = count.get() >= 10;
|
||||
S.on(count, (count)=>
|
||||
host().querySelector("button").disabled = count >= 10
|
||||
);
|
||||
};
|
||||
setTimeout(()=> {
|
||||
// ok, BUT consider extract to separate function
|
||||
|
@ -1,6 +1,5 @@
|
||||
// Handling async data in SSR
|
||||
import { JSDOM } from "jsdom";
|
||||
import { S } from "deka-dom-el/signals";
|
||||
import { register, queue } from "deka-dom-el/jsdom";
|
||||
|
||||
async function renderWithAsyncData() {
|
||||
@ -8,23 +7,7 @@ async function renderWithAsyncData() {
|
||||
const { el } = await register(dom);
|
||||
|
||||
// Create a component that fetches data
|
||||
function AsyncComponent() {
|
||||
const title= S("-");
|
||||
const description= S("-");
|
||||
|
||||
// Use the queue to track the async operation
|
||||
queue(fetch("https://api.example.com/data")
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
title.set(data.title);
|
||||
description.set(data.description);
|
||||
}));
|
||||
|
||||
return el("div", { className: "async-content" }).append(
|
||||
el("h2", title),
|
||||
el("p", description)
|
||||
);
|
||||
}
|
||||
const { AsyncComponent } = await import("./components/AsyncComponent.js");
|
||||
|
||||
// Render the page
|
||||
dom.window.document.body.append(
|
||||
@ -41,3 +24,24 @@ async function renderWithAsyncData() {
|
||||
}
|
||||
|
||||
renderWithAsyncData();
|
||||
|
||||
// file: components/AsyncComponent.js
|
||||
import { el } from "deka-dom-el";
|
||||
import { S } from "deka-dom-el/signals";
|
||||
function AsyncComponent() {
|
||||
const title= S("-");
|
||||
const description= S("-");
|
||||
|
||||
// Use the queue to track the async operation
|
||||
queue(fetch("https://api.example.com/data")
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
title.set(data.title);
|
||||
description.set(data.description);
|
||||
}));
|
||||
|
||||
return el("div", { className: "async-content" }).append(
|
||||
el("h2", title),
|
||||
el("p", description)
|
||||
);
|
||||
}
|
||||
|
@ -11,6 +11,7 @@ async function renderPage() {
|
||||
const { el } = await register(dom);
|
||||
|
||||
// Create a simple header component
|
||||
// can be separated into a separate file and use `import { el } from "deka-dom-el"`
|
||||
function Header({ title }) {
|
||||
return el("header").append(
|
||||
el("h1", title),
|
||||
|
@ -17,6 +17,7 @@ async function renderPage() {
|
||||
const { el } = await register(dom);
|
||||
|
||||
// 4. Dynamically import page components
|
||||
// use `import { el } from "deka-dom-el"`
|
||||
const { Header } = await import("./components/Header.js");
|
||||
const { Content } = await import("./components/Content.js");
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
// Basic jsdom integration example
|
||||
import { JSDOM } from "jsdom";
|
||||
import { register, unregister, queue } from "deka-dom-el/jsdom.js";
|
||||
import { register, unregister, queue } from "deka-dom-el/jsdom";
|
||||
|
||||
// Create a jsdom instance
|
||||
const dom = new JSDOM("<!DOCTYPE html><html><body></body></html>");
|
||||
|
@ -12,7 +12,7 @@ async function buildSite() {
|
||||
];
|
||||
|
||||
// Create output directory
|
||||
mkdirSync("./dist", { recursive: true });
|
||||
mkdirSync("./dist/docs", { recursive: true });
|
||||
|
||||
// Build each page
|
||||
for (const page of pages) {
|
||||
@ -23,6 +23,7 @@ async function buildSite() {
|
||||
const { el } = await register(dom);
|
||||
|
||||
// Import the page component
|
||||
// use `import { el } from "deka-dom-el"`
|
||||
const { default: PageComponent } = await import(page.component);
|
||||
|
||||
// Render the page with its metadata
|
||||
@ -35,7 +36,7 @@ async function buildSite() {
|
||||
|
||||
// Write the HTML to a file
|
||||
const html = dom.serialize();
|
||||
writeFileSync(`./dist/${page.id}.html`, html);
|
||||
writeFileSync(`./dist/docs/${page.id}.html`, html);
|
||||
|
||||
console.log(`Built page: ${page.id}.html`);
|
||||
}
|
||||
|
@ -11,10 +11,6 @@ export function mnemonic(){
|
||||
el("code", "customElementWithDDE(<custom-element>)"),
|
||||
" — register <custom-element> to DDE library, see also `lifecyclesToEvents`, can be also used as decorator",
|
||||
),
|
||||
el("li").append(
|
||||
el("code", "observedAttributes(<custom-element>)"),
|
||||
" — returns record of observed attributes (keys uses camelCase)",
|
||||
),
|
||||
el("li").append(
|
||||
el("code", "S.observedAttributes(<custom-element>)"),
|
||||
" — returns record of observed attributes (keys uses camelCase and values are signals)",
|
||||
@ -32,4 +28,4 @@ export function mnemonic(){
|
||||
" — simulate slots for “dde”/functional components",
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -16,15 +16,16 @@ export function mnemonic(){
|
||||
el("code", "dispatchEvent(<event>[, <options>])(element)"),
|
||||
" — just ", el("code", "<element>.dispatchEvent(new Event(<event>[, <options>]))")
|
||||
),
|
||||
el("li").append(
|
||||
el("code", "dispatchEvent(<event>, <element>)([<detail>])"),
|
||||
" — just ", el("code", "<element>.dispatchEvent(new Event(<event>))"), " or ",
|
||||
el("code", "<element>.dispatchEvent(new CustomEvent(<event>, { detail: <detail> }))")
|
||||
),
|
||||
el("li").append(
|
||||
el("code", "dispatchEvent(<event>[, <options>])(<element>[, <detail>])"),
|
||||
" — just ", el("code", "<element>.dispatchEvent(new Event(<event>[, <options>] ))"), " or ",
|
||||
el("code", "<element>.dispatchEvent(new CustomEvent(<event>, { detail: <detail> }))")
|
||||
),
|
||||
el("li").append(
|
||||
el("code", "dispatchEvent(<event>[, <options>], <host>)([<detail>])"),
|
||||
" — just ", el("code", "<host>().dispatchEvent(new Event(<event>[, <options>]))"), " or ",
|
||||
el("code", "<host>().dispatchEvent(new CustomEvent(<event>, { detail: <detail> }[, <options>] ))"),
|
||||
" (see scopes section of docs)"
|
||||
),
|
||||
);
|
||||
}
|
||||
|
15
docs/components/mnemonic/optimization-init.js
Normal file
15
docs/components/mnemonic/optimization-init.js
Normal file
@ -0,0 +1,15 @@
|
||||
import { el } from "deka-dom-el";
|
||||
import { mnemonicUl } from "../mnemonicUl.html.js";
|
||||
|
||||
export function mnemonic(){
|
||||
return mnemonicUl().append(
|
||||
el("li").append(
|
||||
el("code", "memo.scope(<function>, <argument(s)>)"),
|
||||
" — Scope for memo",
|
||||
),
|
||||
el("li").append(
|
||||
el("code", "memo(<key>, <generator>)"),
|
||||
" — returns value from memo and/or generates it (and caches it)",
|
||||
),
|
||||
);
|
||||
}
|
@ -14,6 +14,10 @@ export function mnemonic(){
|
||||
el("li").append(
|
||||
el("code", "scope.host(...<addons>)"),
|
||||
" — use addons to current component",
|
||||
),
|
||||
el("li").append(
|
||||
el("code", "scope.signal"),
|
||||
" — get AbortSignal that triggers when the element disconnects",
|
||||
)
|
||||
);
|
||||
}
|
||||
|
39
docs/components/scrollTop.css.js
Normal file
39
docs/components/scrollTop.css.js
Normal file
@ -0,0 +1,39 @@
|
||||
import { styles } from "../ssr.js";
|
||||
|
||||
styles.css`
|
||||
/* Scroll to top button */
|
||||
.scroll-top-button {
|
||||
position: fixed;
|
||||
bottom: 2rem;
|
||||
left: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 3rem;
|
||||
height: 3rem;
|
||||
border-radius: 50%;
|
||||
background-color: var(--primary);
|
||||
color: var(--button-text);
|
||||
font-size: 1.5rem;
|
||||
text-decoration: none;
|
||||
box-shadow: var(--shadow);
|
||||
transition: background-color 0.2s ease, transform 0.2s ease;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.scroll-top-button:hover {
|
||||
background-color: var(--primary-dark);
|
||||
transform: translateY(-4px);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.scroll-top-button {
|
||||
bottom: 0.5rem;
|
||||
left: unset;
|
||||
right: .5rem;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
}
|
||||
}
|
||||
`;
|
14
docs/components/scrollTop.js.js
Normal file
14
docs/components/scrollTop.js.js
Normal file
@ -0,0 +1,14 @@
|
||||
import { el } from "deka-dom-el";
|
||||
|
||||
export function scrollTop() {
|
||||
return el("a", {
|
||||
href: "#",
|
||||
className: "scroll-top-button",
|
||||
ariaLabel: "Scroll to top",
|
||||
textContent: "↑",
|
||||
onclick: (e) => {
|
||||
e.preventDefault();
|
||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
||||
}
|
||||
})
|
||||
}
|
Reference in New Issue
Block a user