mirror of
https://github.com/jaandrle/deka-dom-el
synced 2025-07-01 04:12:14 +02:00
v0.9.2 — 🐛 types, ⚡ on.defer and other small ⚡ (#36)
* 🔤 ⚡ T now uses DocumentFragment * 🔤 * 🔤 ⚡ * 🐛 lint * ⚡ cleanup * ⚡ 🔤 lib download * ⚡ 🔤 ui * ⚡ reorganize files * ⚡ on.host * 🐛 on.* types * ⚡ 🔤 cdn * 🔤 converter * 🐛 signal.set(value, force) * ⚡ 🔤 * 🔤 ⚡ converter - convert also comments * ⚡ bs/build * 🔤 ui p14 * 🔤 * 🔤 Examples * 🔤 * 🐛 now only el(..., string|number) * 🐛 fixes #38 * 🔤 * ⚡ on.host → on.defer * 🔤 * 📺
This commit is contained in:
@ -184,7 +184,7 @@ import { el } from "deka-dom-el";
|
||||
* @param {string} [attrs.className]
|
||||
* @param {URL} [attrs.src] Example code file path
|
||||
* @param {string} [attrs.content] Example code
|
||||
* @param {"js"|"ts"|"html"|"css"} [attrs.language="js"] Language of the code
|
||||
* @param {"js"|"ts"|"html"|"css"|"shell"} [attrs.language="js"] Language of the code
|
||||
* @param {string} [attrs.page_id] ID of the page, if setted it registers shiki
|
||||
* */
|
||||
export function code({ id, src, content, language= "js", className= host.slice(1), page_id }){
|
||||
|
176
docs/components/converter.html.js
Normal file
176
docs/components/converter.html.js
Normal file
@ -0,0 +1,176 @@
|
||||
import { styles } from "../ssr.js";
|
||||
|
||||
styles.css`
|
||||
#html-to-dde-converter {
|
||||
grid-column: full-main;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--bg-sidebar);
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
#html-to-dde-converter h3 {
|
||||
margin-top: 0;
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
#html-to-dde-converter .description {
|
||||
color: var(--text-light);
|
||||
font-size: 0.95rem;
|
||||
margin-top: -1rem;
|
||||
}
|
||||
|
||||
#html-to-dde-converter .converter-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
#html-to-dde-converter .input-group,
|
||||
#html-to-dde-converter .output-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
#html-to-dde-converter [type="number"]{
|
||||
width: 3em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
#html-to-dde-converter label {
|
||||
font-weight: 500;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#html-to-dde-converter .options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
#html-to-dde-converter .option-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
#html-to-dde-converter textarea {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9rem;
|
||||
padding: 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
border: 1px solid var(--border);
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
min-height: 200px;
|
||||
height: 25em;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
#html-to-dde-converter textarea:focus {
|
||||
outline: 2px solid var(--primary-light);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
#html-to-dde-converter .button-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#html-to-dde-converter button {
|
||||
padding: 0.5rem 1rem;
|
||||
border-radius: var(--border-radius);
|
||||
border: none;
|
||||
background-color: var(--primary);
|
||||
color: var(--button-text);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
#html-to-dde-converter button:hover {
|
||||
background-color: var(--primary-dark);
|
||||
}
|
||||
|
||||
#html-to-dde-converter button.secondary {
|
||||
background-color: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
#html-to-dde-converter button.secondary:hover {
|
||||
background-color: var(--bg);
|
||||
border-color: var(--primary);
|
||||
}
|
||||
|
||||
#html-to-dde-converter .copy-button {
|
||||
background-color: var(--secondary);
|
||||
}
|
||||
|
||||
#html-to-dde-converter .copy-button:hover {
|
||||
background-color: var(--secondary-dark);
|
||||
}
|
||||
|
||||
#html-to-dde-converter .status {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
#html-to-dde-converter .error {
|
||||
color: hsl(0, 100%, 60%);
|
||||
font-size: 0.9rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
/* Sample HTML examples list */
|
||||
#html-to-dde-converter .examples-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
#html-to-dde-converter .example-button {
|
||||
font-size: 0.85rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
}
|
||||
`;
|
||||
|
||||
import { ireland } from "./ireland.html.js";
|
||||
import { el } from "deka-dom-el";
|
||||
const fileURL= url=> new URL(url, import.meta.url);
|
||||
|
||||
export function converter({ page_id }){
|
||||
registerClientPart(page_id);
|
||||
return el(ireland, {
|
||||
src: fileURL("./converter.js.js"),
|
||||
exportName: "converter",
|
||||
page_id,
|
||||
});
|
||||
}
|
||||
|
||||
let is_registered= {};
|
||||
/** @param {string} page_id */
|
||||
function registerClientPart(page_id){
|
||||
if(is_registered[page_id]) return;
|
||||
|
||||
document.head.append(
|
||||
el("script", {
|
||||
// src: "https://unpkg.com/@beforesemicolon/html-parser/dist/client.js",
|
||||
src: "https://cdn.jsdelivr.net/npm/@beforesemicolon/html-parser/dist/client.js",
|
||||
type: "text/javascript",
|
||||
charset: "utf-8",
|
||||
defer: true
|
||||
}),
|
||||
);
|
||||
is_registered[page_id]= true;
|
||||
}
|
384
docs/components/converter.js.js
Normal file
384
docs/components/converter.js.js
Normal file
@ -0,0 +1,384 @@
|
||||
import { el, on } from "deka-dom-el";
|
||||
import { S } from "deka-dom-el/signals";
|
||||
const { parse }= globalThis.BFS || { parse(){ return { children: [ "not implemented" ] } } };
|
||||
// Example HTML snippets
|
||||
const examples = [
|
||||
{
|
||||
name: "Simple Component",
|
||||
html: `<div class="card">
|
||||
<img src="image.jpg" alt="Card Image" class="card-image">
|
||||
<h2 class="card-title">Card Title</h2>
|
||||
<p class="card-text">This is a simple card component</p>
|
||||
<button aria-pressed="mixed" type="button" class="card-button">Click Me</button>
|
||||
</div>`
|
||||
},
|
||||
{
|
||||
name: "Navigation",
|
||||
html: `<nav class="main-nav">
|
||||
<ul>
|
||||
<li><a href="/" class="active">Home</a></li>
|
||||
<li><a href="/about">About</a></li>
|
||||
<li><a href="/services">Services</a></li>
|
||||
<li><a href="/contact">Contact</a></li>
|
||||
</ul>
|
||||
</nav>`
|
||||
},
|
||||
{
|
||||
name: "Form",
|
||||
html: `<form class="contact-form" onsubmit="submitForm(event)">
|
||||
<div class="form-group">
|
||||
<label for="name">Name:</label>
|
||||
<input type="text" id="name" name="name" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Email:</label>
|
||||
<input type="email" id="email" name="email" required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="message">Message:</label>
|
||||
<textarea id="message" name="message" rows="4" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="submit-btn">Send Message</button>
|
||||
</form>`
|
||||
}
|
||||
];
|
||||
|
||||
// Convert HTML to dd<el> code
|
||||
function convertHTMLtoDDE(html, options = {}) {
|
||||
|
||||
try {
|
||||
const parsed = parse(html);
|
||||
const content = parsed.children[0] || parsed.childNodes[0];
|
||||
return !content ? "" : nodeToDDE(content, options);
|
||||
} catch (error) {
|
||||
console.error("Parsing error:", error);
|
||||
return `// Error parsing HTML: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Node types based on standard DOM nodeType values
|
||||
const NODE_TYPE = {
|
||||
ELEMENT: 1, // Standard element node (equivalent to node.type === "element")
|
||||
TEXT: 3, // Text node (equivalent to node.type === "text")
|
||||
COMMENT: 8 // Comment node (equivalent to node.type === "comment")
|
||||
};
|
||||
|
||||
// Convert a parsed node to dd<el> code
|
||||
function nodeToDDE(node, options = {}, level = 0) {
|
||||
const tab= options.indent === "-1" ? "\t" : " ".repeat(options.indent);
|
||||
const indent = tab.repeat(level);
|
||||
const nextIndent = tab.repeat(level + 1);
|
||||
|
||||
const { nodeType } = node;
|
||||
// Handle text nodes
|
||||
if (nodeType === NODE_TYPE.TEXT) {
|
||||
const text = el("i", { innerText: node.nodeValue }).textContent;
|
||||
if (!text.trim()) return null;
|
||||
|
||||
// Return as plain text or template string for longer text
|
||||
return text.includes("\n") || text.includes('"')
|
||||
? `\`${text}\``
|
||||
: `"${text}"`;
|
||||
}
|
||||
|
||||
// Handle comment nodes
|
||||
if (nodeType === NODE_TYPE.COMMENT) {
|
||||
const text = node.nodeValue;
|
||||
if (!text.trim()) return null;
|
||||
return text.includes("\n")
|
||||
? [ "/*", ...text.trim().split("\n").map(l=> tab+l), "*/" ]
|
||||
: [ `// ${text}` ];
|
||||
}
|
||||
|
||||
// For element nodes
|
||||
if (nodeType === NODE_TYPE.ELEMENT) {
|
||||
// Special case for SVG elements
|
||||
const isNS = node.tagName === "svg";
|
||||
const elFunction = isNS ? "elNS" : "el";
|
||||
|
||||
// Get tag name
|
||||
let tagStr = `"${node.tagName}"`;
|
||||
|
||||
// Process attributes
|
||||
const attrs = [];
|
||||
const sets = {
|
||||
aria: {},
|
||||
data: {},
|
||||
}
|
||||
|
||||
for (const { name: key, value } of node.attributes) {
|
||||
// Handle class attribute
|
||||
if (key === "class") {
|
||||
attrs.push(`className: "${value}"`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle style attribute
|
||||
if (key === "style") {
|
||||
if (options.styleAsObject) {
|
||||
// Convert inline style to object
|
||||
const styleObj = {};
|
||||
value.split(";").forEach(part => {
|
||||
const [propRaw, valueRaw] = part.split(":");
|
||||
if (propRaw && valueRaw) {
|
||||
const prop = propRaw.trim();
|
||||
const propCamel = prop.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||||
styleObj[propCamel] = valueRaw.trim();
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(styleObj).length > 0) {
|
||||
const styleStr = JSON.stringify(styleObj).replace(/"([^"]+)":/g, "$1:");
|
||||
attrs.push(`style: ${styleStr}`);
|
||||
}
|
||||
} else {
|
||||
// Keep as string
|
||||
attrs.push(`style: "${value}"`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle boolean attributes
|
||||
if (value === "" || value === key) {
|
||||
attrs.push(`${key}: true`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle data/aria attributes
|
||||
if (key.startsWith("data-") || key.startsWith("aria-")) {
|
||||
const keyName = key.startsWith("aria-") ? "aria" : "data";
|
||||
const keyCamel = key.slice(keyName.length + 1).replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||||
sets[keyName][keyCamel] = value;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Regular attributes
|
||||
const keyRegular = key==="for"
|
||||
? "htmlFor"
|
||||
: key.startsWith("on")
|
||||
? `"=${key}"`
|
||||
: key.replace(/-([a-z])/g, (_, c) => c.toUpperCase());
|
||||
attrs.push(`${keyRegular}: "${value}"`);
|
||||
}
|
||||
|
||||
// Process sets
|
||||
for (const [name, set] of Object.entries(sets)) {
|
||||
if(options.dataAttrsAsCamel)
|
||||
for (const [key, value] of Object.entries(set))
|
||||
attrs.push(`${name}${key[0].toUpperCase() + key.substring(1)}: "${value}"`);
|
||||
else {
|
||||
const setStr= Object.entries(set).map(([key, value]) => `${key}: "${value}"`).join(",");
|
||||
if (setStr !== "")
|
||||
attrs.push(`${name}set: { ${setStr} }`);
|
||||
}
|
||||
}
|
||||
|
||||
// Process children
|
||||
const children = [];
|
||||
for (const child of node.childNodes) {
|
||||
const childCode = nodeToDDE(child, options, level + 1);
|
||||
if (!childCode) continue;
|
||||
|
||||
children.push(childCode);
|
||||
}
|
||||
if(node.childNodes.length===1 && node.childNodes[0].nodeType===NODE_TYPE.TEXT){
|
||||
const textContent= children.pop().slice(1, -1);
|
||||
attrs.unshift(`textContent: "${textContent}"`);
|
||||
}
|
||||
|
||||
// Build the element creation code
|
||||
let result = `${elFunction}("${node.tagName.toLowerCase()}"`;
|
||||
|
||||
// Add attributes if any
|
||||
if (attrs.length > 0) {
|
||||
const tooLong= attrs.join(``).length+result.length > 55;
|
||||
if(options.expaned || tooLong || attrs.length > 3)
|
||||
result += `, {\n${nextIndent}${attrs.join(`,\n${nextIndent}`)},\n${indent}}`;
|
||||
else
|
||||
result += `, { ${attrs.join(", ")} }`;
|
||||
}
|
||||
|
||||
// Add children if any
|
||||
if (children.length > 0) {
|
||||
const chs= children.map(ch=>
|
||||
Array.isArray(ch) ? ch.map(l=> nextIndent + l).join("\n") :
|
||||
nextIndent + ch + ",");
|
||||
result += `).append(\n${chs.join("\n")}\n${indent})`;
|
||||
} else {
|
||||
result += ")";
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function converter() {
|
||||
// State for the converter
|
||||
const htmlInput = S(examples[0].html);
|
||||
const error = S("");
|
||||
|
||||
const status = S("");
|
||||
const showStatus= msg => {
|
||||
status.set(msg);
|
||||
// Clear status after 3 seconds
|
||||
setTimeout(() => status.set(""), 3000);
|
||||
};
|
||||
|
||||
// Options state
|
||||
const options = {
|
||||
styleAsObject: {
|
||||
title: "Convert style to object",
|
||||
value: S(true),
|
||||
},
|
||||
dataAttrsAsCamel: {
|
||||
title: "dataKey/ariaKey (or dataset/ariaset)",
|
||||
value: S(true),
|
||||
},
|
||||
indent: {
|
||||
title: "Indentation (-1 for tabs)",
|
||||
value: S("-1"),
|
||||
type: "number",
|
||||
},
|
||||
expaned: {
|
||||
title: "Force multiline",
|
||||
value: S(false),
|
||||
}
|
||||
};
|
||||
const getOptions = ()=> Object.fromEntries(Object.entries(options)
|
||||
.map(([key, option]) => ([
|
||||
key,
|
||||
option.value.get()
|
||||
]))
|
||||
);
|
||||
|
||||
// Update the dd<el> output when input or options change
|
||||
const ddeOutput = S(() => {
|
||||
try {
|
||||
const result = convertHTMLtoDDE(htmlInput.get(), getOptions());
|
||||
error.set("");
|
||||
return result;
|
||||
} catch (err) {
|
||||
error.set(`Error: ${err.message}`);
|
||||
return "";
|
||||
}
|
||||
});
|
||||
|
||||
// Event handlers
|
||||
const onConvert = on("submit", e => {
|
||||
e.preventDefault();
|
||||
htmlInput.set(htmlInput.get(), true);
|
||||
showStatus("Converted!");
|
||||
});
|
||||
|
||||
const onCopy = on("click", async () => {
|
||||
if (!ddeOutput.get()) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(ddeOutput.get());
|
||||
showStatus("Copied to clipboard!");
|
||||
} catch (err) {
|
||||
error.set(`Could not copy: ${err.message}`);
|
||||
}
|
||||
});
|
||||
const onClear = on("click", () => {
|
||||
htmlInput.set("");
|
||||
showStatus("Input cleared");
|
||||
});
|
||||
const onExampleLoad = (example) => on("click", () => {
|
||||
htmlInput.set(example.html);
|
||||
showStatus(`Loaded "${example.name}" example`);
|
||||
});
|
||||
|
||||
const optionsElements = () => Object.entries(options)
|
||||
.map(([key, option]) =>
|
||||
el("label", { className: "option-group" }).append(
|
||||
option.type==="number"
|
||||
? el("input", {
|
||||
type: option.type || "checkbox",
|
||||
name: key,
|
||||
value: option.value.get(),
|
||||
max: 10,
|
||||
}, on("change", e => option.value.set(e.target.value)))
|
||||
: el("input", {
|
||||
type: option.type || "checkbox",
|
||||
name: key,
|
||||
checked: option.value.get(),
|
||||
}, on("change", e => option.value.set(e.target.checked))),
|
||||
option.title,
|
||||
)
|
||||
);
|
||||
const exampleButtons = examples.map(example =>
|
||||
el("button", {
|
||||
type: "button",
|
||||
className: "secondary example-button"
|
||||
}, onExampleLoad(example)).append(example.name)
|
||||
);
|
||||
|
||||
return el("div", { id: "html-to-dde-converter" }).append(
|
||||
el("h3", "HTML to dd<el> Converter"),
|
||||
el("p", { className: "description" }).append(
|
||||
"Convert HTML markup to dd<el> JavaScript code. Paste your HTML below or choose from an example."
|
||||
),
|
||||
|
||||
el("form", { className: "converter-form" }, onConvert).append(
|
||||
el("div", { className: "options" }).append(...optionsElements()),
|
||||
|
||||
el("div", { className: "examples-list" }).append(
|
||||
el("label", "Examples: "),
|
||||
...exampleButtons
|
||||
),
|
||||
|
||||
el("div", { className: "editor-container" }).append(
|
||||
el("div", { className: "input-group" }).append(
|
||||
el("label", { htmlFor: "html-input" }).append(
|
||||
"HTML Input",
|
||||
el("div", { className: "button-group" }).append(
|
||||
el("button", {
|
||||
type: "button",
|
||||
className: "secondary",
|
||||
title: "Clear input"
|
||||
}, onClear).append("Clear")
|
||||
)
|
||||
),
|
||||
el("textarea", {
|
||||
id: "html-input",
|
||||
spellcheck: false,
|
||||
value: htmlInput,
|
||||
placeholder: "Paste your HTML here or choose an example",
|
||||
oninput: e => htmlInput.set(e.target.value)
|
||||
})
|
||||
),
|
||||
|
||||
el("div", { className: "output-group" }).append(
|
||||
el("label", { htmlFor: "dde-output" }).append(
|
||||
"dd<el> Output",
|
||||
el("div", { className: "button-group" }).append(
|
||||
el("button", {
|
||||
textContent: "Copy",
|
||||
type: "button",
|
||||
className: "copy-button",
|
||||
title: "Copy to clipboard",
|
||||
disabled: S(() => !ddeOutput.get())
|
||||
}, onCopy)
|
||||
)
|
||||
),
|
||||
el("textarea", {
|
||||
id: "dde-output",
|
||||
readonly: true,
|
||||
spellcheck: false,
|
||||
placeholder: "The converted dd<el> code will appear here",
|
||||
value: S(() => ddeOutput.get() || "// Convert HTML to see results here")
|
||||
})
|
||||
)
|
||||
),
|
||||
|
||||
el("div", { className: "button-group" }).append(
|
||||
S.el(error, error => !error ? el() : el("div", { className: "error" }).append(error)),
|
||||
el("div", { className: "status", textContent: status }),
|
||||
el("button", { type: "submit" }).append("Convert")
|
||||
)
|
||||
)
|
||||
);
|
||||
}
|
@ -3,7 +3,6 @@ const host= "."+example.name;
|
||||
styles.css`
|
||||
${host} {
|
||||
grid-column: full-main;
|
||||
width: calc(100% - .75em);
|
||||
height: calc(4/6 * var(--body-max-width));
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--shadow);
|
||||
@ -84,7 +83,6 @@ html[data-theme="light"] .cm-s-material .cm-error { color: #f44336 !important; }
|
||||
@media (max-width: 767px) {
|
||||
${host} {
|
||||
height: 50vh;
|
||||
max-width: 100%;
|
||||
}
|
||||
${host} main {
|
||||
flex-grow: 1;
|
||||
@ -97,7 +95,7 @@ html[data-theme="light"] .cm-s-material .cm-error { color: #f44336 !important; }
|
||||
}
|
||||
}
|
||||
${host}[data-variant=big]{
|
||||
height: 100vh;
|
||||
height: 150vh;
|
||||
|
||||
main {
|
||||
flex-flow: column nowrap;
|
||||
|
394
docs/components/examples/case-studies/data-dashboard.js
Normal file
394
docs/components/examples/case-studies/data-dashboard.js
Normal file
@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Case Study: Data Dashboard with Charts
|
||||
*
|
||||
* This example demonstrates:
|
||||
* - Integration with a third-party charting library
|
||||
* - Data fetching and state management
|
||||
* - Responsive layout design
|
||||
* - Multiple interactive components working together
|
||||
*/
|
||||
|
||||
import { el, on } from "deka-dom-el";
|
||||
import { S } from "deka-dom-el/signals";
|
||||
|
||||
/**
|
||||
* Data Dashboard Component with Chart Integration
|
||||
* @returns {HTMLElement} Dashboard element
|
||||
*/
|
||||
export function DataDashboard() {
|
||||
// Mock data for demonstration
|
||||
const DATA = {
|
||||
sales: [42, 58, 65, 49, 72, 85, 63, 70, 78, 89, 95, 86],
|
||||
visitors: [1420, 1620, 1750, 1850, 2100, 2400, 2250, 2500, 2750, 2900, 3100, 3200],
|
||||
conversion: [2.9, 3.5, 3.7, 2.6, 3.4, 3.5, 2.8, 2.8, 2.8, 3.1, 3.0, 2.7],
|
||||
months: ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
};
|
||||
|
||||
// Application state
|
||||
const selectedYear = S(2024);
|
||||
const selectedDataType = S(/** @type {'sales' | 'visitors' | 'conversion'} */ ('sales'));
|
||||
const isLoading = S(false);
|
||||
const error = S(null);
|
||||
|
||||
// Filter options
|
||||
const years = [2022, 2023, 2024];
|
||||
const dataTypes = [
|
||||
{ id: 'sales', label: 'Sales', unit: 'K' },
|
||||
{ id: 'visitors', label: 'Visitors', unit: '' },
|
||||
{ id: 'conversion', label: 'Conversion Rate', unit: '%' }
|
||||
];
|
||||
|
||||
// Computed values
|
||||
const selectedData = S(() => {
|
||||
return DATA[selectedDataType.get()];
|
||||
});
|
||||
|
||||
const currentDataType = S(() => {
|
||||
return dataTypes.find(type => type.id === selectedDataType.get());
|
||||
});
|
||||
|
||||
const totalValue = S(() => {
|
||||
const data = selectedData.get();
|
||||
return data.reduce((sum, value) => sum + value, 0);
|
||||
});
|
||||
|
||||
const averageValue = S(() => {
|
||||
const data = selectedData.get();
|
||||
return data.reduce((sum, value) => sum + value, 0) / data.length;
|
||||
});
|
||||
|
||||
const highestValue = S(() => {
|
||||
return Math.max(...selectedData.get());
|
||||
});
|
||||
|
||||
// Event handlers
|
||||
const onYearChange = on("change", e => {
|
||||
selectedYear.set(parseInt(/** @type {HTMLSelectElement} */(e.target).value));
|
||||
loadData();
|
||||
});
|
||||
|
||||
const onDataTypeChange = on("click", e => {
|
||||
const type = /** @type {'sales' | 'visitors' | 'conversion'} */(
|
||||
/** @type {HTMLButtonElement} */(e.currentTarget).dataset.type);
|
||||
selectedDataType.set(type);
|
||||
});
|
||||
|
||||
// Simulate data loading
|
||||
function loadData() {
|
||||
isLoading.set(true);
|
||||
error.set(null);
|
||||
|
||||
// Simulate API call
|
||||
setTimeout(() => {
|
||||
if (Math.random() > 0.9) {
|
||||
// Simulate occasional error
|
||||
error.set('Failed to load data. Please try again.');
|
||||
}
|
||||
isLoading.set(false);
|
||||
}, 800);
|
||||
}
|
||||
|
||||
// Reactive chart rendering
|
||||
const chart = S(()=> {
|
||||
const chart= el("canvas", { id: "chart-canvas", width: 800, height: 400 });
|
||||
const ctx = chart.getContext('2d');
|
||||
const data = selectedData.get();
|
||||
const months = DATA.months;
|
||||
const width = chart.width;
|
||||
const height = chart.height;
|
||||
const maxValue = Math.max(...data) * 1.1;
|
||||
const barWidth = width / data.length - 10;
|
||||
|
||||
// Clear canvas
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
|
||||
// Draw background grid
|
||||
ctx.beginPath();
|
||||
ctx.strokeStyle = '#f0f0f0';
|
||||
ctx.lineWidth = 1;
|
||||
for(let i = 0; i < 5; i++) {
|
||||
const y = height - (height * (i / 5)) - 30;
|
||||
ctx.moveTo(50, y);
|
||||
ctx.lineTo(width - 20, y);
|
||||
|
||||
// Draw grid labels
|
||||
ctx.fillStyle = '#999';
|
||||
ctx.font = '12px Arial';
|
||||
ctx.fillText(Math.round(maxValue * (i / 5)), 20, y + 5);
|
||||
}
|
||||
ctx.stroke();
|
||||
|
||||
// Draw bars
|
||||
data.forEach((value, index) => {
|
||||
const x = index * (barWidth + 10) + 60;
|
||||
const barHeight = (value / maxValue) * (height - 60);
|
||||
|
||||
// Bar
|
||||
ctx.fillStyle = '#4a90e2';
|
||||
ctx.fillRect(x, height - barHeight - 30, barWidth, barHeight);
|
||||
|
||||
// Month label
|
||||
ctx.fillStyle = '#666';
|
||||
ctx.font = '12px Arial';
|
||||
ctx.fillText(months[index], x + barWidth/2 - 10, height - 10);
|
||||
});
|
||||
|
||||
// Chart title
|
||||
ctx.fillStyle = '#333';
|
||||
ctx.font = 'bold 14px Arial';
|
||||
ctx.fillText(`${currentDataType.get().label} (${selectedYear.get()})`, width/2 - 80, 20);
|
||||
return chart;
|
||||
});
|
||||
|
||||
return el("div", { className: "dashboard" }).append(
|
||||
el("header", { className: "dashboard-header" }).append(
|
||||
el("h1", "Sales Performance Dashboard"),
|
||||
el("div", { className: "year-filter" }).append(
|
||||
el("label", { htmlFor: "yearSelect", textContent: "Select Year:" }),
|
||||
el("select", { id: "yearSelect" },
|
||||
on.defer(el=> el.value = selectedYear.get().toString()),
|
||||
onYearChange
|
||||
).append(
|
||||
...years.map(year => el("option", { value: year, textContent: year }))
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
// Error message (only shown when there's an error)
|
||||
S.el(error, errorMsg => !errorMsg
|
||||
? el()
|
||||
: el("div", { className: "error-message" }).append(
|
||||
el("p", errorMsg),
|
||||
el("button", { textContent: "Retry", type: "button" }, on("click", loadData)),
|
||||
),
|
||||
),
|
||||
|
||||
// Loading indicator
|
||||
S.el(isLoading, loading => !loading
|
||||
? el()
|
||||
: el("div", { className: "loading-spinner" })
|
||||
),
|
||||
|
||||
// Main dashboard content
|
||||
el("div", { className: "dashboard-content" }).append(
|
||||
// Metrics cards
|
||||
el("div", { className: "metrics-container" }).append(
|
||||
el("div", { className: "metric-card" }).append(
|
||||
el("h3", "Total"),
|
||||
el("#text", S(() => `${totalValue.get().toLocaleString()}${currentDataType.get().unit}`)),
|
||||
),
|
||||
el("div", { className: "metric-card" }).append(
|
||||
el("h3", "Average"),
|
||||
el("#text", S(() => `${averageValue.get().toFixed(1)}${currentDataType.get().unit}`)),
|
||||
),
|
||||
el("div", { className: "metric-card" }).append(
|
||||
el("h3", "Highest"),
|
||||
el("#text", S(() => `${highestValue.get()}${currentDataType.get().unit}`)),
|
||||
),
|
||||
),
|
||||
|
||||
// Data type selection tabs
|
||||
el("div", { className: "data-type-tabs" }).append(
|
||||
...dataTypes.map(type =>
|
||||
el("button", {
|
||||
type: "button",
|
||||
className: S(() => selectedDataType.get() === type.id ? 'active' : ''),
|
||||
dataType: type.id,
|
||||
textContent: type.label
|
||||
}, onDataTypeChange)
|
||||
)
|
||||
),
|
||||
|
||||
// Chart container
|
||||
el("div", { className: "chart-container" }).append(
|
||||
S.el(chart, chart => chart)
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Render the component
|
||||
document.body.append(
|
||||
el("div", { style: "padding: 20px; background: #f5f5f5; min-height: 100vh;" }).append(
|
||||
el(DataDashboard)
|
||||
),
|
||||
el("style", `
|
||||
.dashboard {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
max-width: 1000px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
background: #fff;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
padding-bottom: 1rem;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.dashboard-header h1 {
|
||||
font-size: 1.5rem;
|
||||
margin: 0;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.year-filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.year-filter select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.metrics-container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.metric-card {
|
||||
background: #f9f9f9;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
transition: transform 0.2s ease;
|
||||
}
|
||||
|
||||
.metric-card:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.metric-card h3 {
|
||||
margin-top: 0;
|
||||
color: #666;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.metric-card p {
|
||||
font-size: 1.5rem;
|
||||
font-weight: bold;
|
||||
color: #333;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.data-type-tabs {
|
||||
display: flex;
|
||||
border-bottom: 1px solid #eee;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.data-type-tabs button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.data-type-tabs button.active {
|
||||
color: #4a90e2;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.data-type-tabs button.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: -1px;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
background: #4a90e2;
|
||||
border-radius: 3px 3px 0 0;
|
||||
}
|
||||
|
||||
.chart-container {
|
||||
background: #fff;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
.loading-spinner::before {
|
||||
content: '';
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #4a90e2;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background: #ffecec;
|
||||
color: #e74c3c;
|
||||
padding: 1rem;
|
||||
border-radius: 4px;
|
||||
margin-bottom: 1.5rem;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.error-message p {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.error-message button {
|
||||
background: #e74c3c;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.5rem 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.metrics-container {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.year-filter {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.year-filter select {
|
||||
flex-grow: 1;
|
||||
}
|
||||
}
|
||||
`)
|
||||
);
|
417
docs/components/examples/case-studies/image-gallery.js
Normal file
417
docs/components/examples/case-studies/image-gallery.js
Normal file
@ -0,0 +1,417 @@
|
||||
/**
|
||||
* Case Study: Interactive Image Gallery
|
||||
*
|
||||
* This example demonstrates:
|
||||
* - Dynamic loading of content
|
||||
* - Lightbox functionality
|
||||
* - Animation handling
|
||||
* - Keyboard and gesture navigation
|
||||
*/
|
||||
|
||||
import { el, memo, on } from "deka-dom-el";
|
||||
import { S } from "deka-dom-el/signals";
|
||||
|
||||
// Sample image data
|
||||
const imagesSample = (url=> [
|
||||
{ id: 1, src: url+'nature', alt: 'Nature', title: 'Beautiful Landscape' },
|
||||
{ id: 2, src: url+'places', alt: 'City', title: 'Urban Architecture' },
|
||||
{ id: 3, src: url+'people', alt: 'People', title: 'Street Photography' },
|
||||
{ id: 4, src: url+'food', alt: 'Food', title: 'Culinary Delights' },
|
||||
{ id: 5, src: url+'animals', alt: 'Animals', title: 'Wildlife' },
|
||||
{ id: 6, src: url+'travel', alt: 'Travel', title: 'Adventure Awaits' },
|
||||
{ id: 7, src: url+'computer', alt: 'Technology', title: 'Modern Tech' },
|
||||
{ id: 8, src: url+'music', alt: 'Art', title: 'Creative Expression' },
|
||||
])('https://api.algobook.info/v1/randomimage?category=');
|
||||
/**
|
||||
* Interactive Image Gallery Component
|
||||
* @returns {HTMLElement} Gallery element
|
||||
*/
|
||||
export function ImageGallery(images= imagesSample) {
|
||||
|
||||
// Application state
|
||||
const selectedImageId = S(null);
|
||||
const filterTag = S('all');
|
||||
const imagesToDisplay = S(() => {
|
||||
const tag = filterTag.get();
|
||||
if (tag === 'all') return images;
|
||||
else return images.filter(img => img.alt.toLowerCase() === tag);
|
||||
})
|
||||
|
||||
// Derived state
|
||||
const selectedImage = S(() => {
|
||||
const id = selectedImageId.get();
|
||||
return id ? images.find(img => img.id === id) : null;
|
||||
});
|
||||
|
||||
const isLightboxOpen = S(() => selectedImage.get() !== null);
|
||||
|
||||
// Event handlers
|
||||
const onImageClick = id => on("click", () => {
|
||||
selectedImageId.set(id);
|
||||
document.body.style.overflow = 'hidden'; // Prevent scrolling when lightbox is open
|
||||
|
||||
// Add keyboard event listeners when lightbox opens
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
});
|
||||
const closeLightbox = () => {
|
||||
selectedImageId.set(null);
|
||||
document.body.style.overflow = ''; // Restore scrolling
|
||||
|
||||
// Remove keyboard event listeners when lightbox closes
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
const onPrevImage = e => {
|
||||
e.stopPropagation(); // Prevent closing the lightbox
|
||||
const images = imagesToDisplay.get();
|
||||
const currentId = selectedImageId.get();
|
||||
const currentIndex = images.findIndex(img => img.id === currentId);
|
||||
const prevIndex = (currentIndex - 1 + images.length) % images.length;
|
||||
selectedImageId.set(images[prevIndex].id);
|
||||
};
|
||||
const onNextImage = e => {
|
||||
e.stopPropagation(); // Prevent closing the lightbox
|
||||
const images = imagesToDisplay.get();
|
||||
const currentId = selectedImageId.get();
|
||||
const currentIndex = images.findIndex(img => img.id === currentId);
|
||||
const nextIndex = (currentIndex + 1) % images.length;
|
||||
selectedImageId.set(images[nextIndex].id);
|
||||
};
|
||||
const onFilterChange = tag => on("click", () => {
|
||||
filterTag.set(tag);
|
||||
});
|
||||
|
||||
// Keyboard navigation handler
|
||||
function handleKeyDown(e) {
|
||||
switch(e.key) {
|
||||
case 'Escape':
|
||||
closeLightbox();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
document.querySelector('.lightbox-prev-btn').click();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
document.querySelector('.lightbox-next-btn').click();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Build the gallery UI
|
||||
return el("div", { className: "gallery-container" }).append(
|
||||
// Gallery header
|
||||
el("header", { className: "gallery-header" }).append(
|
||||
el("h1", "Interactive Image Gallery"),
|
||||
el("p", "Click on any image to view it in the lightbox. Use arrow keys for navigation.")
|
||||
),
|
||||
|
||||
// Filter options
|
||||
el("div", { className: "gallery-filters" }).append(
|
||||
el("button", {
|
||||
classList: { active: S(() => filterTag.get() === 'all') },
|
||||
textContent: "All"
|
||||
}, onFilterChange('all')),
|
||||
el("button", {
|
||||
classList: { active: S(() => filterTag.get() === 'nature') },
|
||||
textContent: "Nature"
|
||||
}, onFilterChange('nature')),
|
||||
el("button", {
|
||||
classList: { active: S(() => filterTag.get() === 'urban') },
|
||||
textContent: "Urban"
|
||||
}, onFilterChange('urban')),
|
||||
el("button", {
|
||||
classList: { active: S(() => filterTag.get() === 'people') },
|
||||
textContent: "People"
|
||||
}, onFilterChange('people'))
|
||||
),
|
||||
|
||||
// Image grid
|
||||
el("div", { className: "gallery-grid" }).append(
|
||||
S.el(imagesToDisplay, images =>
|
||||
images.map(image =>
|
||||
memo(image.id, ()=>
|
||||
el("div", {
|
||||
className: "gallery-item",
|
||||
dataTag: image.alt.toLowerCase()
|
||||
}).append(
|
||||
el("img", {
|
||||
src: image.src,
|
||||
alt: image.alt,
|
||||
loading: "lazy"
|
||||
}, onImageClick(image.id)),
|
||||
el("div", { className: "gallery-item-caption" }).append(
|
||||
el("h3", image.title),
|
||||
el("p", image.alt)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
// Lightbox (only shown when an image is selected)
|
||||
S.el(isLightboxOpen, open => !open
|
||||
? el()
|
||||
: el("div", { className: "lightbox-overlay" }, on("click", closeLightbox)).append(
|
||||
el("div", {
|
||||
className: "lightbox-content",
|
||||
onClick: e => e.stopPropagation() // Prevent closing when clicking inside
|
||||
}).append(
|
||||
el("button", {
|
||||
className: "lightbox-close-btn",
|
||||
"aria-label": "Close lightbox"
|
||||
}, on("click", closeLightbox)).append("×"),
|
||||
|
||||
el("button", {
|
||||
className: "lightbox-prev-btn",
|
||||
"aria-label": "Previous image"
|
||||
}, on("click", onPrevImage)).append("❮"),
|
||||
|
||||
el("button", {
|
||||
className: "lightbox-next-btn",
|
||||
"aria-label": "Next image"
|
||||
}, on("click", onNextImage)).append("❯"),
|
||||
|
||||
S.el(selectedImage, img => !img
|
||||
? el()
|
||||
: el("div", { className: "lightbox-image-container" }).append(
|
||||
el("img", {
|
||||
src: img.src,
|
||||
alt: img.alt,
|
||||
className: "lightbox-image"
|
||||
}),
|
||||
el("div", { className: "lightbox-caption" }).append(
|
||||
el("h2", img.title),
|
||||
el("p", img.alt)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Render the component
|
||||
document.body.append(
|
||||
el("div", { style: "padding: 20px; background: #f5f5f5; min-height: 100vh;" }).append(
|
||||
el(ImageGallery)
|
||||
),
|
||||
el("style", `
|
||||
.gallery-container {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.gallery-header {
|
||||
text-align: center;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.gallery-header h1 {
|
||||
margin-bottom: 0.5rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.gallery-header p {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.gallery-filters {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: 2rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.gallery-filters button {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0.5rem 1.5rem;
|
||||
margin: 0 0.5rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
border-radius: 30px;
|
||||
transition: all 0.3s ease;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.gallery-filters button:hover {
|
||||
background: #f0f0f0;
|
||||
}
|
||||
|
||||
.gallery-filters button.active {
|
||||
background: #4a90e2;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.gallery-item {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 3px 10px rgba(0, 0, 0, 0.1);
|
||||
transition: transform 0.3s ease, box-shadow 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.gallery-item:hover {
|
||||
transform: translateY(-5px);
|
||||
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.gallery-item img {
|
||||
width: 100%;
|
||||
height: 200px;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
transition: transform 0.5s ease;
|
||||
}
|
||||
|
||||
.gallery-item:hover img {
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
.gallery-item-caption {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.7), transparent);
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.gallery-item:hover .gallery-item-caption {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.gallery-item-caption h3 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
.gallery-item-caption p {
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* Lightbox styles */
|
||||
.lightbox-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.9);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
padding: 2rem;
|
||||
}
|
||||
|
||||
.lightbox-content {
|
||||
position: relative;
|
||||
max-width: 90%;
|
||||
max-height: 90%;
|
||||
}
|
||||
|
||||
.lightbox-image-container {
|
||||
overflow: hidden;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 0 30px rgba(0, 0, 0, 0.5);
|
||||
background: #000;
|
||||
}
|
||||
|
||||
.lightbox-image {
|
||||
max-width: 100%;
|
||||
max-height: 80vh;
|
||||
display: block;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.lightbox-caption {
|
||||
background: #222;
|
||||
color: white;
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.lightbox-caption h2 {
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.lightbox-caption p {
|
||||
margin: 0;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.lightbox-close-btn,
|
||||
.lightbox-prev-btn,
|
||||
.lightbox-next-btn {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
color: white;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
width: 50px;
|
||||
height: 50px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
transition: background 0.3s ease;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.lightbox-close-btn:hover,
|
||||
.lightbox-prev-btn:hover,
|
||||
.lightbox-next-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.lightbox-close-btn {
|
||||
top: -25px;
|
||||
right: -25px;
|
||||
}
|
||||
|
||||
.lightbox-prev-btn {
|
||||
left: -25px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.lightbox-next-btn {
|
||||
right: -25px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.gallery-container {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.gallery-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.lightbox-prev-btn,
|
||||
.lightbox-next-btn {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
}
|
||||
`)
|
||||
);
|
342
docs/components/examples/case-studies/interactive-form.js
Normal file
342
docs/components/examples/case-studies/interactive-form.js
Normal file
@ -0,0 +1,342 @@
|
||||
/**
|
||||
* Case Study: Interactive Form with Validation
|
||||
*
|
||||
* This example demonstrates:
|
||||
* - Form handling with real-time validation
|
||||
* - Reactive UI updates based on input state
|
||||
* - Complex form state management
|
||||
* - Clean separation of concerns (data, validation, UI)
|
||||
*/
|
||||
|
||||
import { dispatchEvent, el, on, scope } from "deka-dom-el";
|
||||
import { S } from "deka-dom-el/signals";
|
||||
|
||||
/**
|
||||
* @typedef {Object} FormState
|
||||
* @property {string} name
|
||||
* @property {string} email
|
||||
* @property {string} password
|
||||
* @property {string} confirmPassword
|
||||
* @property {boolean} agreedToTerms
|
||||
* */
|
||||
/**
|
||||
* Interactive Form with Validation Component
|
||||
* @returns {HTMLElement} Form element
|
||||
*/
|
||||
export function InteractiveForm() {
|
||||
const submitted = S(false);
|
||||
/** @type {FormState|null} */
|
||||
let formState = null;
|
||||
/** @param {CustomEvent<FormState>} event */
|
||||
const onSubmit = ({ detail }) => {
|
||||
submitted.set(true);
|
||||
formState = detail;
|
||||
};
|
||||
const onAnotherAccount = () => {
|
||||
submitted.set(false)
|
||||
formState = null;
|
||||
};
|
||||
|
||||
return el("div", { className: "form-container" }).append(
|
||||
S.el(submitted, s => s
|
||||
? el("div", { className: "success-message" }).append(
|
||||
el("h3", "Thank you for registering!"),
|
||||
el("p", `Welcome, ${formState.name}! Your account has been created successfully.`),
|
||||
el("button", { textContent: "Register another account", type: "button" },
|
||||
on("click", onAnotherAccount)
|
||||
),
|
||||
)
|
||||
: el(Form, { initial: formState }, on("form:submit", onSubmit))
|
||||
)
|
||||
);
|
||||
}
|
||||
/**
|
||||
* Form Component
|
||||
* @type {(props: { initial: FormState | null }) => HTMLElement}
|
||||
* */
|
||||
export function Form({ initial }) {
|
||||
const { host }= scope;
|
||||
// Form state management
|
||||
const formState = S(initial || {
|
||||
name: '',
|
||||
email: '',
|
||||
password: '',
|
||||
confirmPassword: '',
|
||||
agreedToTerms: false
|
||||
}, {
|
||||
/**
|
||||
* @template {keyof FormState} K
|
||||
* @param {K} key
|
||||
* @param {FormState[K]} value
|
||||
* */
|
||||
update(key, value) {
|
||||
this.value[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
// Derived signals for validation
|
||||
const nameValid = S(() => formState.get().name.length >= 3);
|
||||
const emailValid = S(() => {
|
||||
const email = formState.get().email;
|
||||
return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email);
|
||||
});
|
||||
const passwordValid = S(() => {
|
||||
const password = formState.get().password;
|
||||
return password.length >= 8 && /[A-Z]/.test(password) && /[0-9]/.test(password);
|
||||
});
|
||||
const passwordsMatch = S(() => {
|
||||
const { password, confirmPassword } = formState.get();
|
||||
return password === confirmPassword && confirmPassword !== '';
|
||||
});
|
||||
const termsAgreed = S(() => formState.get().agreedToTerms);
|
||||
|
||||
// Overall form validity
|
||||
const formValid = S(() =>
|
||||
nameValid.get() &&
|
||||
emailValid.get() &&
|
||||
passwordValid.get() &&
|
||||
passwordsMatch.get() &&
|
||||
termsAgreed.get()
|
||||
);
|
||||
|
||||
// Event handlers
|
||||
/**
|
||||
* Event handler for input events
|
||||
* @param {"value"|"checked"} prop
|
||||
* @returns {(ev: Event) => void}
|
||||
* */
|
||||
const onChange= prop => ev => {
|
||||
const input = /** @type {HTMLInputElement} */(ev.target);
|
||||
S.action(formState, "update", /** @type {keyof FormState} */(input.id), input[prop]);
|
||||
};
|
||||
const dispatcSubmit = dispatchEvent("form:submit", host);
|
||||
const onSubmit = on("submit", e => {
|
||||
e.preventDefault();
|
||||
if (!formValid.get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
dispatcSubmit(formState.get());
|
||||
});
|
||||
|
||||
// Component UI
|
||||
return el("form", { className: "registration-form" }, onSubmit).append(
|
||||
el("h2", "Create an Account"),
|
||||
|
||||
// Name field
|
||||
el("div", { classList: {
|
||||
"form-group": true,
|
||||
valid: nameValid,
|
||||
invalid: S(()=> !nameValid.get() && formState.get().name)
|
||||
}}).append(
|
||||
el("label", { htmlFor: "name", textContent: "Full Name" }),
|
||||
el("input", {
|
||||
id: "name",
|
||||
type: "text",
|
||||
value: formState.get().name,
|
||||
placeholder: "Enter your full name"
|
||||
}, on("input", onChange("value"))),
|
||||
el("div", { className: "validation-message", textContent: "Name must be at least 3 characters long" }),
|
||||
),
|
||||
|
||||
// Email field
|
||||
el("div", { classList: {
|
||||
"form-group": true,
|
||||
valid: emailValid,
|
||||
invalid: S(()=> !emailValid.get() && formState.get().email)
|
||||
}}).append(
|
||||
el("label", { htmlFor: "email", textContent: "Email Address" }),
|
||||
el("input", {
|
||||
id: "email",
|
||||
type: "email",
|
||||
value: formState.get().email,
|
||||
placeholder: "Enter your email address"
|
||||
}, on("input", onChange("value"))),
|
||||
el("div", { className: "validation-message", textContent: "Please enter a valid email address" })
|
||||
),
|
||||
|
||||
// Password field
|
||||
el("div", { classList: {
|
||||
"form-group": true,
|
||||
valid: passwordValid,
|
||||
invalid: S(()=> !passwordValid.get() && formState.get().password)
|
||||
}}).append(
|
||||
el("label", { htmlFor: "password", textContent: "Password" }),
|
||||
el("input", {
|
||||
id: "password",
|
||||
type: "password",
|
||||
value: formState.get().password,
|
||||
placeholder: "Create a password"
|
||||
}, on("input", onChange("value"))),
|
||||
el("div", {
|
||||
className: "validation-message",
|
||||
textContent: "Password must be at least 8 characters with at least one uppercase letter and one number",
|
||||
}),
|
||||
),
|
||||
|
||||
// Confirm password field
|
||||
el("div", { classList: {
|
||||
"form-group": true,
|
||||
valid: passwordsMatch,
|
||||
invalid: S(()=> !passwordsMatch.get() && formState.get().confirmPassword)
|
||||
}}).append(
|
||||
el("label", { htmlFor: "confirmPassword", textContent: "Confirm Password" }),
|
||||
el("input", {
|
||||
id: "confirmPassword",
|
||||
type: "password",
|
||||
value: formState.get().confirmPassword,
|
||||
placeholder: "Confirm your password"
|
||||
}, on("input", onChange("value"))),
|
||||
el("div", { className: "validation-message", textContent: "Passwords must match" }),
|
||||
),
|
||||
|
||||
// Terms agreement
|
||||
el("div", { className: "form-group checkbox-group" }).append(
|
||||
el("input", {
|
||||
id: "agreedToTerms",
|
||||
type: "checkbox",
|
||||
checked: formState.get().agreedToTerms
|
||||
}, on("change", onChange("checked"))),
|
||||
el("label", { htmlFor: "agreedToTerms", textContent: "I agree to the Terms and Conditions" }),
|
||||
),
|
||||
|
||||
// Submit button
|
||||
el("button", {
|
||||
textContent: "Create Account",
|
||||
type: "submit",
|
||||
className: "submit-button",
|
||||
disabled: S(() => !formValid.get())
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Render the component
|
||||
document.body.append(
|
||||
el("div", { style: "padding: 20px; background: #f5f5f5; min-height: 100vh;" }).append(
|
||||
el(InteractiveForm)
|
||||
),
|
||||
el("style", `
|
||||
.form-container {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
max-width: 500px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
color: #333;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.5rem;
|
||||
position: relative;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: 0.5rem;
|
||||
color: #555;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
input[type="text"],
|
||||
input[type="email"],
|
||||
input[type="password"] {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.3s ease;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: #4a90e2;
|
||||
box-shadow: 0 0 0 2px rgba(74, 144, 226, 0.2);
|
||||
}
|
||||
|
||||
.checkbox-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.checkbox-group label {
|
||||
margin: 0 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.validation-message {
|
||||
font-size: 0.85rem;
|
||||
color: #e74c3c;
|
||||
margin-top: 0.5rem;
|
||||
height: 0;
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.form-group.invalid .validation-message {
|
||||
height: auto;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.form-group.valid input {
|
||||
border-color: #2ecc71;
|
||||
}
|
||||
|
||||
.form-group.invalid input {
|
||||
border-color: #e74c3c;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
background-color: #4a90e2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.3s ease;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
background-color: #3a7bc8;
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
background-color: #b5b5b5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.success-message {
|
||||
text-align: center;
|
||||
color: #2ecc71;
|
||||
}
|
||||
|
||||
.success-message h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.success-message button {
|
||||
background-color: #2ecc71;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.success-message button:hover {
|
||||
background-color: #27ae60;
|
||||
}
|
||||
`),
|
||||
);
|
715
docs/components/examples/case-studies/task-manager.js
Normal file
715
docs/components/examples/case-studies/task-manager.js
Normal file
@ -0,0 +1,715 @@
|
||||
/**
|
||||
* Case Study: Task Manager Application
|
||||
*
|
||||
* This example demonstrates:
|
||||
* - Complex state management with signals
|
||||
* - Drag and drop functionality
|
||||
* - Local storage persistence
|
||||
* - Responsive design for different devices
|
||||
*/
|
||||
|
||||
import { el, on, dispatchEvent, scope } from "deka-dom-el";
|
||||
import { S } from "deka-dom-el/signals";
|
||||
|
||||
/** @typedef {{ id: number, title: string, description: string, priority: string, status: string }} Task */
|
||||
/**
|
||||
* Task Manager Component
|
||||
* @returns {HTMLElement} Task manager UI
|
||||
*/
|
||||
export function TaskManager() {
|
||||
// <Tasks store>
|
||||
const STORAGE_KEY = 'dde-task-manager';
|
||||
const STATUSES = {
|
||||
TODO: 'todo',
|
||||
IN_PROGRESS: 'in-progress',
|
||||
DONE: 'done'
|
||||
};
|
||||
/** @type {Task[]} */
|
||||
let initialTasks = [];
|
||||
try {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
if (saved) {
|
||||
initialTasks = JSON.parse(saved);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load tasks from localStorage', e);
|
||||
}
|
||||
if (!initialTasks.length) {
|
||||
initialTasks = [
|
||||
{ id: 1, title: 'Create project structure', description: 'Set up folders and initial files',
|
||||
status: STATUSES.DONE, priority: 'high' },
|
||||
{ id: 2, title: 'Design UI components', description: 'Create mockups for main views',
|
||||
status: STATUSES.IN_PROGRESS, priority: 'medium' },
|
||||
{ id: 3, title: 'Implement authentication', description: 'Set up user login and registration',
|
||||
status: STATUSES.TODO, priority: 'high' },
|
||||
{ id: 4, title: 'Write documentation', description: 'Document API endpoints and usage examples',
|
||||
status: STATUSES.TODO, priority: 'low' },
|
||||
];
|
||||
}
|
||||
const tasks = S(initialTasks, {
|
||||
add(task) { this.value.push(task); },
|
||||
remove(id) { this.value = this.value.filter(task => task.id !== id); },
|
||||
update(id, task) {
|
||||
const current= this.value.find(t => t.id === id);
|
||||
if (current) Object.assign(current, task);
|
||||
}
|
||||
});
|
||||
S.on(tasks, value => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
console.error('Failed to save tasks to localStorage', e);
|
||||
}
|
||||
});
|
||||
// </Tasks store>
|
||||
|
||||
const filterPriority = S('all');
|
||||
const searchQuery = S('');
|
||||
// Filtered tasks based on priority and search query
|
||||
const filteredTasks = S(() => {
|
||||
let filtered = tasks.get();
|
||||
|
||||
// Filter by priority
|
||||
if (filterPriority.get() !== 'all') {
|
||||
filtered = filtered.filter(task => task.priority === filterPriority.get());
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
const query = searchQuery.get().toLowerCase();
|
||||
if (query) {
|
||||
filtered = filtered.filter(task =>
|
||||
task.title.toLowerCase().includes(query) ||
|
||||
task.description.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
});
|
||||
/** Tasks grouped by status for display in columns */
|
||||
const tasksByStatus = S(() => {
|
||||
const filtered = filteredTasks.get();
|
||||
return {
|
||||
[STATUSES.TODO]: filtered.filter(t => t.status === STATUSES.TODO),
|
||||
[STATUSES.IN_PROGRESS]: filtered.filter(t => t.status === STATUSES.IN_PROGRESS),
|
||||
[STATUSES.DONE]: filtered.filter(t => t.status === STATUSES.DONE)
|
||||
};
|
||||
});
|
||||
|
||||
// <Add> signals and handlers for adding new tasks
|
||||
const newTask = { title: '', description: '', priority: 'medium' };
|
||||
const onAddTask = e => {
|
||||
e.preventDefault();
|
||||
if (!newTask.title) return;
|
||||
|
||||
S.action(tasks, "add", {
|
||||
id: Date.now(),
|
||||
status: STATUSES.TODO,
|
||||
...newTask
|
||||
});
|
||||
e.target.reset();
|
||||
};
|
||||
// </Add>
|
||||
const onCardEdit= on("card:edit", /** @param {CardEditEvent} ev */({ detail: [ id, task ] })=>
|
||||
S.action(tasks, "update", id, task));
|
||||
const onCardDelete= on("card:delete", /** @param {CardDeleteEvent} ev */({ detail: id })=>
|
||||
S.action(tasks, "remove", id));
|
||||
|
||||
const { onDragable, onDragArea }= moveElementAddon(
|
||||
(id, status) => S.action(tasks, "update", id, { status })
|
||||
);
|
||||
|
||||
// Build the task manager UI
|
||||
return el("div", { className: "task-manager" }).append(
|
||||
el("header", { className: "app-header" }).append(
|
||||
el("h1", "DDE Task Manager"),
|
||||
el("div", { className: "app-controls" }).append(
|
||||
el("input", {
|
||||
type: "text",
|
||||
placeholder: "Search tasks...",
|
||||
value: searchQuery.get()
|
||||
}, on("input", e => searchQuery.set(e.target.value))),
|
||||
el("select", null,
|
||||
on.defer(el=> el.value= filterPriority.get()),
|
||||
on("change", e => filterPriority.set(e.target.value))
|
||||
).append(
|
||||
el("option", { value: "all", textContent: "All Priorities" }),
|
||||
el("option", { value: "low", textContent: "Low Priority" }),
|
||||
el("option", { value: "medium", textContent: "Medium Priority" }),
|
||||
el("option", { value: "high", textContent: "High Priority" })
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
// Add new task form
|
||||
el("form", { className: "new-task-form" }, on("submit", onAddTask)).append(
|
||||
el("div", { className: "form-row" }).append(
|
||||
el("input", {
|
||||
type: "text",
|
||||
placeholder: "New task title",
|
||||
value: newTask.title,
|
||||
required: true
|
||||
}, on("input", e => newTask.title= e.target.value.trim())),
|
||||
el("select", null,
|
||||
on.defer(el=> el.value= newTask.priority),
|
||||
on("change", e => newTask.priority= e.target.value)
|
||||
).append(
|
||||
el("option", { value: "low", textContent: "Low" }),
|
||||
el("option", { value: "medium", textContent: "Medium" }),
|
||||
el("option", { value: "high", textContent: "High" })
|
||||
),
|
||||
el("button", { type: "submit", className: "add-btn" }).append("Add Task")
|
||||
),
|
||||
el("textarea", {
|
||||
placeholder: "Task description (optional)",
|
||||
value: newTask.description
|
||||
}, on("input", e => newTask.description= e.target.value.trim()))
|
||||
),
|
||||
|
||||
// Task board with columns
|
||||
el("div", { className: "task-board" }).append(
|
||||
// Todo column
|
||||
el("div", {
|
||||
id: `column-${STATUSES.TODO}`,
|
||||
className: "task-column"
|
||||
}, onDragArea(STATUSES.TODO)).append(
|
||||
el("h2", { className: "column-header" }).append(
|
||||
"To Do ",
|
||||
el("span", {
|
||||
textContent: S(() => tasksByStatus.get()[STATUSES.TODO].length),
|
||||
className: "task-count"
|
||||
}),
|
||||
),
|
||||
S.el(S(() => tasksByStatus.get()[STATUSES.TODO]), tasks =>
|
||||
el("div", { className: "column-tasks" }).append(
|
||||
...tasks.map(task=> el(TaskCard, { task, onDragable }, onCardEdit, onCardDelete))
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
// In Progress column
|
||||
el("div", {
|
||||
id: `column-${STATUSES.IN_PROGRESS}`,
|
||||
className: "task-column"
|
||||
}, onDragArea(STATUSES.IN_PROGRESS)).append(
|
||||
el("h2", { className: "column-header" }).append(
|
||||
"In Progress ",
|
||||
el("span", {
|
||||
textContent: S(() => tasksByStatus.get()[STATUSES.IN_PROGRESS].length),
|
||||
className: "task-count",
|
||||
}),
|
||||
),
|
||||
S.el(S(() => tasksByStatus.get()[STATUSES.IN_PROGRESS]), tasks =>
|
||||
el("div", { className: "column-tasks" }).append(
|
||||
...tasks.map(task=> el(TaskCard, { task, onDragable }, onCardEdit, onCardDelete))
|
||||
)
|
||||
)
|
||||
),
|
||||
|
||||
// Done column
|
||||
el("div", {
|
||||
id: `column-${STATUSES.DONE}`,
|
||||
className: "task-column"
|
||||
}, onDragArea(STATUSES.DONE)).append(
|
||||
el("h2", { className: "column-header" }).append(
|
||||
"Done ",
|
||||
el("span", {
|
||||
textContent: S(() => tasksByStatus.get()[STATUSES.DONE].length),
|
||||
className: "task-count",
|
||||
}),
|
||||
),
|
||||
S.el(S(() => tasksByStatus.get()[STATUSES.DONE]), tasks =>
|
||||
el("div", { className: "column-tasks" }).append(
|
||||
...tasks.map(task=> el(TaskCard, { task, onDragable }, onCardEdit, onCardDelete))
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
}
|
||||
/** @typedef {CustomEvent<[ string, Task ]>} CardEditEvent */
|
||||
/** @typedef {CustomEvent<string>} CardDeleteEvent */
|
||||
/**
|
||||
* Task Card Component
|
||||
* @type {(props: { task: Task, onDragable: (id: number) => ddeElementAddon<HTMLDivElement> }) => HTMLElement}
|
||||
* @fires {CardEditEvent} card:edit
|
||||
* @fires {CardDeleteEvent} card:delete
|
||||
* */
|
||||
function TaskCard({ task, onDragable }){
|
||||
const { host }= scope;
|
||||
const isEditing = S(false);
|
||||
const onEditStart = () => isEditing.set(true);
|
||||
|
||||
const dispatchEdit= dispatchEvent("card:edit", host);
|
||||
const dispatchDelete= dispatchEvent("card:delete", host).bind(null, task.id);
|
||||
|
||||
return el("div", {
|
||||
id: `task-${task.id}`,
|
||||
className: `task-card priority-${task.priority}`,
|
||||
draggable: true
|
||||
}, onDragable(task.id)).append(
|
||||
S.el(isEditing, editing => editing
|
||||
? el(EditMode)
|
||||
: el().append(
|
||||
el("div", { className: "task-header" }).append(
|
||||
el("h3", { className: "task-title", textContent: task.title }),
|
||||
el("div", { className: "task-actions" }).append(
|
||||
el("button", {
|
||||
textContent: "✎",
|
||||
className: "edit-btn",
|
||||
ariaLabel: "Edit task"
|
||||
}, on("click", onEditStart)),
|
||||
el("button", {
|
||||
textContent: "✕",
|
||||
className: "delete-btn",
|
||||
ariaLabel: "Delete task"
|
||||
}, on("click", dispatchDelete))
|
||||
)
|
||||
),
|
||||
!task.description
|
||||
? el()
|
||||
: el("p", { className: "task-description", textContent: task.description }),
|
||||
el("div", { className: "task-meta" }).append(
|
||||
el("span", {
|
||||
className: `priority-badge priority-${task.priority}`,
|
||||
textContent: task.priority.charAt(0).toUpperCase() + task.priority.slice(1)
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
function EditMode(){
|
||||
const onSubmit = on("submit", e => {
|
||||
e.preventDefault();
|
||||
const formData = new FormData(/** @type {HTMLFormElement} */(e.target));
|
||||
const title = formData.get("title");
|
||||
const description = formData.get("description");
|
||||
const priority = formData.get("priority");
|
||||
isEditing.set(false);
|
||||
dispatchEdit([ task.id, { title, description, priority } ]);
|
||||
})
|
||||
const onEditCancel = () => isEditing.set(false);
|
||||
|
||||
return el("form", { className: "task-edit-form" }, onSubmit).append(
|
||||
el("input", {
|
||||
name: "title",
|
||||
className: "task-title-input",
|
||||
defaultValue: task.title,
|
||||
placeholder: "Task title",
|
||||
required: true,
|
||||
autoFocus: true
|
||||
}),
|
||||
el("textarea", {
|
||||
name: "description",
|
||||
className: "task-desc-input",
|
||||
defaultValue: task.description,
|
||||
placeholder: "Description (optional)"
|
||||
}),
|
||||
el("select", {
|
||||
name: "priority",
|
||||
}, on.defer(el=> el.value = task.priority)).append(
|
||||
el("option", { value: "low", textContent: "Low Priority" }),
|
||||
el("option", { value: "medium", textContent: "Medium Priority" }),
|
||||
el("option", { value: "high", textContent: "High Priority" })
|
||||
),
|
||||
el("div", { className: "task-edit-actions" }).append(
|
||||
el("button", {
|
||||
textContent: "Cancel",
|
||||
type: "button",
|
||||
className: "cancel-btn"
|
||||
}, on("click", onEditCancel)),
|
||||
el("button", {
|
||||
textContent: "Save",
|
||||
type: "submit",
|
||||
className: "save-btn"
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to handle move an element
|
||||
* @param {(id: string, status: string) => void} onMoved
|
||||
* */
|
||||
function moveElementAddon(onMoved){
|
||||
let draggedTaskId = null;
|
||||
function onDragable(id) {
|
||||
return element => {
|
||||
on("dragstart", e => {
|
||||
draggedTaskId= id;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
|
||||
// Add some styling to the element being dragged
|
||||
setTimeout(() => {
|
||||
const el = document.getElementById(`task-${id}`);
|
||||
if (el) el.classList.add('dragging');
|
||||
}, 0);
|
||||
})(element);
|
||||
|
||||
on("dragend", () => {
|
||||
draggedTaskId= null;
|
||||
|
||||
// Remove the styling
|
||||
const el = document.getElementById(`task-${id}`);
|
||||
if (el) el.classList.remove('dragging');
|
||||
})(element);
|
||||
};
|
||||
}
|
||||
function onDragArea(status) {
|
||||
return element => {
|
||||
on("dragover", e => {
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
|
||||
// Add a visual indicator for the drop target
|
||||
const column = document.getElementById(`column-${status}`);
|
||||
if (column) column.classList.add('drag-over');
|
||||
})(element);
|
||||
|
||||
on("dragleave", () => {
|
||||
// Remove the visual indicator
|
||||
const column = document.getElementById(`column-${status}`);
|
||||
if (column) column.classList.remove('drag-over');
|
||||
})(element);
|
||||
|
||||
on("drop", e => {
|
||||
e.preventDefault();
|
||||
const id = draggedTaskId;
|
||||
if (id) onMoved(id, status);
|
||||
// Remove the visual indicator
|
||||
const column = document.getElementById(`column-${status}`);
|
||||
if (column) column.classList.remove('drag-over');
|
||||
})(element);
|
||||
};
|
||||
}
|
||||
return { onDragable, onDragArea };
|
||||
}
|
||||
|
||||
// Render the component
|
||||
document.body.append(
|
||||
el("div", { style: "padding: 20px; background: #f5f5f5; min-height: 100vh;" }).append(
|
||||
el(TaskManager)
|
||||
),
|
||||
el("style", `
|
||||
.task-manager {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.app-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.app-header h1 {
|
||||
margin: 0;
|
||||
color: #2d3748;
|
||||
}
|
||||
|
||||
.app-controls {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.app-controls input,
|
||||
.app-controls select {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.new-task-form {
|
||||
background: white;
|
||||
padding: 1.5rem;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-row input {
|
||||
flex-grow: 1;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.form-row select {
|
||||
width: 100px;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: #4a90e2;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
padding: 0.75rem 1.5rem;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.add-btn:hover {
|
||||
background: #3a7bc8;
|
||||
}
|
||||
|
||||
.new-task-form textarea {
|
||||
width: 100%;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 1rem;
|
||||
resize: vertical;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.task-board {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.task-column {
|
||||
background: #f7fafc;
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
min-height: 400px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.column-header {
|
||||
margin-top: 0;
|
||||
padding-bottom: 0.75rem;
|
||||
border-bottom: 2px solid #e2e8f0;
|
||||
font-size: 1.25rem;
|
||||
color: #2d3748;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.task-count {
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
background: #e2e8f0;
|
||||
color: #4a5568;
|
||||
border-radius: 50%;
|
||||
width: 25px;
|
||||
height: 25px;
|
||||
font-size: 0.875rem;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
.column-tasks {
|
||||
margin-top: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
min-height: 200px;
|
||||
}
|
||||
|
||||
.task-card {
|
||||
background: white;
|
||||
border-radius: 6px;
|
||||
padding: 1rem;
|
||||
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.05);
|
||||
cursor: grab;
|
||||
transition: transform 0.2s ease, box-shadow 0.2s ease;
|
||||
position: relative;
|
||||
border-left: 4px solid #ccc;
|
||||
}
|
||||
|
||||
.task-card:hover {
|
||||
transform: translateY(-3px);
|
||||
box-shadow: 0 5px 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.task-card.dragging {
|
||||
opacity: 0.5;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.task-card.priority-low {
|
||||
border-left-color: #38b2ac;
|
||||
}
|
||||
|
||||
.task-card.priority-medium {
|
||||
border-left-color: #ecc94b;
|
||||
}
|
||||
|
||||
.task-card.priority-high {
|
||||
border-left-color: #e53e3e;
|
||||
}
|
||||
|
||||
.task-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.task-title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
color: #2d3748;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.task-description {
|
||||
margin: 0.5rem 0;
|
||||
font-size: 0.9rem;
|
||||
color: #4a5568;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.task-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.edit-btn,
|
||||
.delete-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: inline-flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
border-radius: 50%;
|
||||
color: #718096;
|
||||
transition: background 0.2s ease, color 0.2s ease;
|
||||
}
|
||||
|
||||
.edit-btn:hover {
|
||||
background: #edf2f7;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.delete-btn:hover {
|
||||
background: #fed7d7;
|
||||
color: #e53e3e;
|
||||
}
|
||||
|
||||
.task-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.priority-badge {
|
||||
font-size: 0.75rem;
|
||||
padding: 0.2rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.priority-badge.priority-low {
|
||||
background: #e6fffa;
|
||||
color: #2c7a7b;
|
||||
}
|
||||
|
||||
.priority-badge.priority-medium {
|
||||
background: #fefcbf;
|
||||
color: #975a16;
|
||||
}
|
||||
|
||||
.priority-badge.priority-high {
|
||||
background: #fed7d7;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.drag-over {
|
||||
background: #f0f9ff;
|
||||
border: 2px dashed #4a90e2;
|
||||
}
|
||||
|
||||
.task-edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.task-title-input,
|
||||
.task-desc-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.task-desc-input {
|
||||
min-height: 60px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.task-edit-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.cancel-btn,
|
||||
.save-btn {
|
||||
padding: 0.4rem 0.75rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
background: #edf2f7;
|
||||
color: #4a5568;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
background: #4a90e2;
|
||||
color: white;
|
||||
border: none;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.app-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.app-controls {
|
||||
width: 100%;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.task-board {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
`)
|
||||
);
|
@ -1,5 +1,6 @@
|
||||
import { S } from "deka-dom-el/signals";
|
||||
// Debouncing signal updates
|
||||
|
||||
// ===== Approach 1: Traditional debouncing with utility function =====
|
||||
function debounce(func, wait) {
|
||||
let timeout;
|
||||
return (...args)=> {
|
||||
@ -8,8 +9,59 @@ function debounce(func, wait) {
|
||||
};
|
||||
}
|
||||
|
||||
const inputSignal= S("");
|
||||
const debouncedSet= debounce(value => inputSignal.set(value), 300);
|
||||
const inputSignal = S("");
|
||||
const debouncedSet = debounce(value => inputSignal.set(value), 300);
|
||||
|
||||
// In your input handler
|
||||
inputElement.addEventListener("input", e=> debouncedSet(e.target.value));
|
||||
inputElement.addEventListener("input", e => debouncedSet(e.target.value));
|
||||
|
||||
// ===== Approach 2: Signal debouncing utility =====
|
||||
/**
|
||||
* Creates a debounced signal that only updates after delay
|
||||
* @param {any} initialValue Initial signal value
|
||||
* @param {number} delay Debounce delay in ms
|
||||
*/
|
||||
function createDebouncedSignal(initialValue, delay = 300) {
|
||||
// Create two signals: one for immediate updates, one for debounced values
|
||||
const immediateSignal = S(initialValue);
|
||||
const debouncedSignal = S(initialValue);
|
||||
|
||||
// Keep track of the timeout
|
||||
let timeout = null;
|
||||
|
||||
// Set up a listener on the immediate signal
|
||||
S.on(immediateSignal, value => {
|
||||
// Clear any existing timeout
|
||||
if (timeout) clearTimeout(timeout);
|
||||
|
||||
// Set a new timeout to update the debounced signal
|
||||
timeout = setTimeout(() => {
|
||||
debouncedSignal.set(value);
|
||||
}, delay);
|
||||
});
|
||||
|
||||
// Return an object with both signals and a setter function
|
||||
return {
|
||||
// The raw signal that updates immediately
|
||||
raw: immediateSignal,
|
||||
// The debounced signal that only updates after delay
|
||||
debounced: debouncedSignal,
|
||||
// Setter function to update the immediate signal
|
||||
set: value => immediateSignal.set(value)
|
||||
};
|
||||
}
|
||||
|
||||
// Usage example
|
||||
const searchInput = createDebouncedSignal("", 300);
|
||||
|
||||
// Log immediate changes for demonstration
|
||||
S.on(searchInput.raw, value => console.log("Input changed to:", value));
|
||||
|
||||
// Only perform expensive operations on the debounced value
|
||||
S.on(searchInput.debounced, value => {
|
||||
console.log("Performing search with:", value);
|
||||
// Expensive operation would go here
|
||||
});
|
||||
|
||||
// In your input handler
|
||||
searchElement.addEventListener("input", e => searchInput.set(e.target.value));
|
||||
|
@ -1,15 +1,28 @@
|
||||
import { el, on } from "deka-dom-el";
|
||||
const paragraph= el("p", "See lifecycle events in console.",
|
||||
el=> log({ type: "dde:created", detail: el }),
|
||||
on.connected(log),
|
||||
on.disconnected(log),
|
||||
);
|
||||
function allLifecycleEvents(){
|
||||
return el("form", null,
|
||||
el=> log({ type: "dde:created", detail: el }),
|
||||
on.connected(log),
|
||||
on.disconnected(log),
|
||||
).append(
|
||||
el("select", { id: "country" }, on.defer(select => {
|
||||
// This runs when the select is ready with all its options
|
||||
select.value = "cz"; // Pre-select Czechia
|
||||
log({ type: "dde:on.defer", detail: select });
|
||||
})).append(
|
||||
el("option", { value: "au", textContent: "Australia" }),
|
||||
el("option", { value: "ca", textContent: "Canada" }),
|
||||
el("option", { value: "cz", textContent: "Czechia" }),
|
||||
),
|
||||
el("p", "See lifecycle events in console."),
|
||||
);
|
||||
}
|
||||
|
||||
document.body.append(
|
||||
paragraph,
|
||||
el("button", "Update attribute", on("click", ()=> paragraph.setAttribute("test", Math.random().toString()))),
|
||||
" ",
|
||||
el("button", "Remove", on("click", ()=> paragraph.remove()))
|
||||
el(allLifecycleEvents),
|
||||
el("button", "Remove Element", on("click", function(){
|
||||
this.previousSibling.remove();
|
||||
}))
|
||||
);
|
||||
|
||||
/** @param {Partial<CustomEvent>} event */
|
||||
|
@ -7,20 +7,20 @@ function HelloWorld({ emoji = "🚀" }) {
|
||||
const clicks = S(0);
|
||||
|
||||
return el().append(
|
||||
// PART 2: Bind state to UI elements
|
||||
el("p", {
|
||||
className: "greeting",
|
||||
// This paragraph automatically updates when clicks changes
|
||||
textContent: S(() => `Hello World ${emoji.repeat(clicks.get())}`)
|
||||
}),
|
||||
// PART 2: Bind state to UI elements
|
||||
el("p", {
|
||||
className: "greeting",
|
||||
// This paragraph automatically updates when clicks changes
|
||||
textContent: S(() => `Hello World ${emoji.repeat(clicks.get())}`)
|
||||
}),
|
||||
|
||||
// PART 3: Update state in response to events
|
||||
el("button", {
|
||||
type: "button",
|
||||
textContent: "Add emoji",
|
||||
// When clicked, update the state
|
||||
onclick: () => clicks.set(clicks.get() + 1)
|
||||
})
|
||||
// PART 3: Update state in response to events
|
||||
el("button", {
|
||||
type: "button",
|
||||
textContent: "Add emoji",
|
||||
// When clicked, update the state
|
||||
onclick: () => clicks.set(clicks.get() + 1)
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -9,7 +9,7 @@ document.body.append(
|
||||
padding: 1em;
|
||||
margin: 1em;
|
||||
}
|
||||
`.trim())
|
||||
`.trim())
|
||||
);
|
||||
|
||||
export function CounterStandard() {
|
||||
|
@ -13,41 +13,38 @@ import { S } from "deka-dom-el/signals";
|
||||
* @returns {HTMLElement} The root TodoMVC application element
|
||||
*/
|
||||
function Todos(){
|
||||
const pageS = routerSignal(S);
|
||||
const { signal } = scope;
|
||||
const pageS = routerSignal(S, signal);
|
||||
const todosS = todosSignal();
|
||||
/** Derived signal that filters todos based on current route */
|
||||
const filteredTodosS = S(()=> {
|
||||
const todosFilteredS = S(()=> {
|
||||
const todos = todosS.get();
|
||||
const filter = pageS.get();
|
||||
if (filter === "all") return todos;
|
||||
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));
|
||||
});
|
||||
const todosRemainingS = S(()=> todosS.get().filter(todo => !todo.completed).length);
|
||||
|
||||
/** @type {ddeElementAddon<HTMLInputElement>} */
|
||||
const onToggleAll = on("change", event => {
|
||||
const checked = /** @type {HTMLInputElement} */ (event.target).checked;
|
||||
S.action(todosS, "completeAll", checked);
|
||||
});
|
||||
const formNewTodo = "newTodo";
|
||||
/** @type {ddeElementAddon<HTMLFormElement>} */
|
||||
const onSubmitNewTodo = on("submit", event => {
|
||||
event.preventDefault();
|
||||
const input = /** @type {HTMLInputElement} */(
|
||||
/** @type {HTMLFormElement} */(event.target).elements.namedItem("newTodo")
|
||||
/** @type {HTMLFormElement} */(event.target).elements.namedItem(formNewTodo)
|
||||
);
|
||||
const title = input.value.trim();
|
||||
if (title) {
|
||||
S.action(todosS, "add", title);
|
||||
input.value = "";
|
||||
}
|
||||
if (!title) return;
|
||||
|
||||
S.action(todosS, "add", title);
|
||||
input.value = "";
|
||||
});
|
||||
const onClearCompleted = on("click", () => S.action(todosS, "clearCompleted"));
|
||||
const onDelete = on("todo:delete", ev =>
|
||||
@ -61,15 +58,16 @@ function Todos(){
|
||||
el("form", null, onSubmitNewTodo).append(
|
||||
el("input", {
|
||||
className: "new-todo",
|
||||
name: "newTodo",
|
||||
name: formNewTodo,
|
||||
placeholder: "What needs to be done?",
|
||||
autocomplete: "off",
|
||||
autofocus: true
|
||||
})
|
||||
)
|
||||
),
|
||||
S.el(todosS, todos => todos.length
|
||||
? el("main", { className: "main" }).append(
|
||||
S.el(todosS, todos => !todos.length
|
||||
? el()
|
||||
: el("main", { className: "main" }).append(
|
||||
el("input", {
|
||||
id: "toggle-all",
|
||||
className: "toggle-all",
|
||||
@ -77,55 +75,48 @@ function Todos(){
|
||||
}, onToggleAll),
|
||||
el("label", { htmlFor: "toggle-all", title: "Mark all as complete" }),
|
||||
el("ul", { className: "todo-list" }).append(
|
||||
S.el(filteredTodosS, filteredTodos => filteredTodos.map(todo =>
|
||||
S.el(todosFilteredS, 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(
|
||||
S.el(todosS, todos => !todos.length
|
||||
? el()
|
||||
: 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"
|
||||
noOfLeft()
|
||||
),
|
||||
memo("filters", ()=>
|
||||
el("ul", { className: "filters" }).append(
|
||||
...[ "All", "Active", "Completed" ].map(textContent =>
|
||||
el("li").append(
|
||||
el("a", {
|
||||
textContent,
|
||||
classList: { selected: S(()=> pageS.get() === textContent.toLowerCase()) },
|
||||
href: `#${textContent.toLowerCase()}`
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
),
|
||||
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()
|
||||
)
|
||||
todos.length - todosRemainingS.get() === 0
|
||||
? el()
|
||||
: memo("delete", () =>
|
||||
el("button",
|
||||
{ textContent: "Clear completed", className: "clear-completed" },
|
||||
onClearCompleted)
|
||||
)
|
||||
)
|
||||
: el()
|
||||
))
|
||||
)
|
||||
);
|
||||
function noOfLeft(){
|
||||
const length = todosRemainingS.get();
|
||||
return el("strong").append(
|
||||
length + " ",
|
||||
length === 1 ? "item left" : "items left"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -177,10 +168,11 @@ function TodoItem({ id, title, completed }) {
|
||||
}
|
||||
isEditing.set(false);
|
||||
});
|
||||
const formEdit = "edit";
|
||||
/** @type {ddeElementAddon<HTMLFormElement>} */
|
||||
const onSubmitEdit = on("submit", event => {
|
||||
event.preventDefault();
|
||||
const input = /** @type {HTMLFormElement} */(event.target).elements.namedItem("edit");
|
||||
const input = /** @type {HTMLFormElement} */(event.target).elements.namedItem(formEdit);
|
||||
const value = /** @type {HTMLInputElement} */(input).value.trim();
|
||||
if (value) {
|
||||
dispatchEdit({ id, title: value });
|
||||
@ -207,18 +199,17 @@ function TodoItem({ id, title, completed }) {
|
||||
checked: completed
|
||||
}, onToggleCompleted),
|
||||
el("label", { textContent: title }, onStartEdit),
|
||||
el("button", { className: "destroy" }, onDelete)
|
||||
el("button", { ariaLabel: "Delete todo", className: "destroy" }, onDelete)
|
||||
),
|
||||
S.el(isEditing, editing => editing
|
||||
? el("form", null, onSubmitEdit).append(
|
||||
S.el(isEditing, editing => !editing
|
||||
? el()
|
||||
: el("form", null, onSubmitEdit).append(
|
||||
el("input", {
|
||||
className: "edit",
|
||||
name: "edit",
|
||||
name: formEdit,
|
||||
value: title,
|
||||
"data-id": id
|
||||
}, onBlurEdit, onKeyDown, addFocus)
|
||||
)
|
||||
: el()
|
||||
)
|
||||
);
|
||||
}
|
||||
@ -342,6 +333,7 @@ function todosSignal(){
|
||||
localStorage.setItem(store_key, JSON.stringify(value));
|
||||
} catch (e) {
|
||||
console.error("Failed to save todos to localStorage", e);
|
||||
// Optionally, provide user feedback
|
||||
}
|
||||
});
|
||||
return out;
|
||||
@ -350,20 +342,30 @@ function todosSignal(){
|
||||
/**
|
||||
* Creates a signal for managing route state
|
||||
*
|
||||
* @param {typeof S} signal - The signal constructor
|
||||
* @param {typeof S} signal - The signal constructor from a library
|
||||
* @param {AbortSignal} abortSignal
|
||||
*/
|
||||
function routerSignal(signal){
|
||||
function routerSignal(signal, abortSignal){
|
||||
const initial = location.hash.replace("#", "") || "all";
|
||||
return signal(initial, {
|
||||
const out = signal(initial, {
|
||||
/**
|
||||
* Set the current route
|
||||
* @param {"all"|"active"|"completed"} hash - The route to set
|
||||
*/
|
||||
set(hash){
|
||||
location.hash = hash;
|
||||
this.value = hash;
|
||||
}
|
||||
//this.value = hash;
|
||||
},
|
||||
});
|
||||
|
||||
// Setup hash change listener
|
||||
window.addEventListener("hashchange", () => {
|
||||
const hash = location.hash.replace("#", "") || "all";
|
||||
//S.action(out, "set", /** @type {"all"|"active"|"completed"} */(hash));
|
||||
out.set(hash);
|
||||
}, { signal: abortSignal });
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -7,7 +7,7 @@ const todos= S([], {
|
||||
const removed= this.value.pop();
|
||||
if(removed) S.clear(removed);
|
||||
},
|
||||
[S.symbols.onclear](){ // this covers `O.clear(todos)`
|
||||
[S.symbols.onclear](){ // this covers `S.clear(todos)`
|
||||
S.clear(...this.value);
|
||||
}
|
||||
});
|
||||
|
83
docs/components/getLibraryUrl.html.js
Normal file
83
docs/components/getLibraryUrl.html.js
Normal file
@ -0,0 +1,83 @@
|
||||
import { styles } from "../ssr.js";
|
||||
|
||||
styles.css`
|
||||
#library-url-form {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: var(--border-radius);
|
||||
background-color: var(--bg-sidebar);
|
||||
box-shadow: var(--shadow);
|
||||
border: 1px solid var(--border);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
#library-url-form .selectors {
|
||||
display: flex;
|
||||
flex-flow: row wrap;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
#library-url-form output {
|
||||
display: flex;
|
||||
flex-flow: column nowrap;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
#library-url-form output p {
|
||||
font-weight: 500;
|
||||
margin: 0.25rem 0;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
#library-url-form .url-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: -0.25rem;
|
||||
}
|
||||
|
||||
#library-url-form .url-title strong {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
#library-url-form .url-title span {
|
||||
color: var(--text-light);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
#library-url-form .code {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
#library-url-form .info-text {
|
||||
font-size: 0.9rem;
|
||||
font-style: italic;
|
||||
margin-top: 1rem;
|
||||
color: var(--text-light);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
#library-url-form .selectors {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
#library-url-form select {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
import { el } from "deka-dom-el";
|
||||
import { ireland } from "./ireland.html.js";
|
||||
|
||||
export function getLibraryUrl({ page_id }){
|
||||
return el(ireland, {
|
||||
src: new URL("./getLibraryUrl.js.js", import.meta.url),
|
||||
exportName: "getLibraryUrl",
|
||||
page_id,
|
||||
});
|
||||
}
|
92
docs/components/getLibraryUrl.js.js
Normal file
92
docs/components/getLibraryUrl.js.js
Normal file
@ -0,0 +1,92 @@
|
||||
import { el, on } from "deka-dom-el";
|
||||
import { S } from "deka-dom-el/signals";
|
||||
|
||||
const url_base= {
|
||||
jsdeka: "https://cdn.jsdelivr.net/gh/jaandrle/deka-dom-el/dist/",
|
||||
};
|
||||
|
||||
export function getLibraryUrl(){
|
||||
const lib= S([ "esm", "-with-signals", ".min" ]);
|
||||
const url= S(()=> url_base.jsdeka+lib.get().join(""));
|
||||
const urlLabel= S(() => {
|
||||
const [format, signalsPart, minified] = lib.get();
|
||||
const formatText = format === "esm" ? "ES Module" : "IIFE";
|
||||
const signalsText = signalsPart ? " with signals" : "";
|
||||
const minText = minified ? " (minified)" : "";
|
||||
return `${formatText}${signalsText}${minText}`;
|
||||
})
|
||||
const onSubmit= on("submit", ev => {
|
||||
ev.preventDefault();
|
||||
const form= new FormData(/** @type {HTMLFormElement} */ (ev.target));
|
||||
lib.set([
|
||||
"module",
|
||||
"what",
|
||||
"minified",
|
||||
].map(name => /** @type {string} */(form.get(name))));
|
||||
});
|
||||
const onChangeSubmit= on("change",
|
||||
ev=> /** @type {HTMLSelectElement} */(ev.target).form.requestSubmit()
|
||||
);
|
||||
|
||||
return el("form", { id: "library-url-form" }, onSubmit).append(
|
||||
el("h4", "Select your preferred library format:"),
|
||||
el("div", { className: "selectors" }).append(
|
||||
el("select", { name: "module" }, onChangeSubmit,
|
||||
on.defer(select => select.value = lib.get()[0]),
|
||||
).append(
|
||||
el("option", { value: "esm", textContent: "ESM — modern JavaScript module" }),
|
||||
el("option", { value: "iife", textContent: "IIFE — legacy JavaScript with DDE global variable" }),
|
||||
),
|
||||
el("select", { name: "what" }, onChangeSubmit,
|
||||
on.defer(select => select.value = lib.get()[1]),
|
||||
).append(
|
||||
el("option", { value: "", textContent: "DOM part only" }),
|
||||
el("option", { value: "-with-signals", textContent: "DOM + signals" }),
|
||||
),
|
||||
el("select", { name: "minified" }, onChangeSubmit,
|
||||
on.defer(select => select.value = lib.get()[2]),
|
||||
).append(
|
||||
el("option", { value: "", textContent: "Unminified" }),
|
||||
el("option", { value: ".min", textContent: "Minified" }),
|
||||
),
|
||||
),
|
||||
el("output").append(
|
||||
el("div", { className: "url-title" }).append(
|
||||
el("strong", "JavaScript:"),
|
||||
el("span", urlLabel),
|
||||
),
|
||||
el(code, { value: S(()=> url.get()+".js") }),
|
||||
el("div", { className: "url-title" }).append(
|
||||
el("strong", "TypeScript definition:")
|
||||
),
|
||||
el(code, { value: S(()=> url.get()+".d.ts") }),
|
||||
el("p", { className: "info-text",
|
||||
textContent: "Use the CDN URL in your HTML or import it in your JavaScript files."
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
/** @param {{ value: ddeSignal<string> }} props */
|
||||
function code({ value }){
|
||||
/** @type {ddeSignal<"Copy"|"Copied!">} */
|
||||
const textContent= S("Copy");
|
||||
const onCopy= on("click", () => {
|
||||
navigator.clipboard.writeText(value.get());
|
||||
|
||||
textContent.set("Copied!");
|
||||
setTimeout(() => {
|
||||
textContent.set("Copy");
|
||||
}, 1500);
|
||||
});
|
||||
return el("div", { className: "code", dataJs: "done", tabIndex: 0 }).append(
|
||||
el("code").append(
|
||||
el("pre", value),
|
||||
),
|
||||
el("button", {
|
||||
className: "copy-button",
|
||||
textContent,
|
||||
ariaLabel: "Copy code to clipboard",
|
||||
}, onCopy)
|
||||
)
|
||||
;
|
||||
}
|
@ -1,3 +1,33 @@
|
||||
import { styles } from "../ssr.js";
|
||||
styles.css`
|
||||
[data-dde-mark] {
|
||||
opacity: .5;
|
||||
filter: grayscale();
|
||||
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
animation: fadein 2s infinite ease forwards;;
|
||||
}
|
||||
|
||||
position: relative;
|
||||
&::after {
|
||||
content: "Loading Ireland…";
|
||||
background-color: rgba(0, 0, 0, .5);
|
||||
color: white;
|
||||
font-weight: bold;
|
||||
border-radius: 5px;
|
||||
padding: 5px 10px;
|
||||
position: absolute;
|
||||
top: 3%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
@keyframes fadein {
|
||||
from { opacity: .5; }
|
||||
to { opacity: .85; }
|
||||
}
|
||||
`;
|
||||
|
||||
import { el, queue } from "deka-dom-el";
|
||||
import { addEventListener, registerClientFile } from "../ssr.js";
|
||||
import { relative } from "node:path";
|
||||
@ -21,10 +51,17 @@ export function ireland({ src, exportName = "default", props = {} }) {
|
||||
const path= "./"+relative(dir, src.pathname);
|
||||
const id = "ireland-" + generateComponentId(src);
|
||||
const element = el.mark({ type: "later", name: ireland.name });
|
||||
queue(import(path).then(module => {
|
||||
const component = module[exportName];
|
||||
element.replaceWith(el(component, props, mark(id)));
|
||||
}));
|
||||
queue(
|
||||
import(path)
|
||||
.then(module => {
|
||||
const component = module[exportName];
|
||||
const content= el(component, props, mark(id));
|
||||
element.replaceWith(content);
|
||||
content.querySelectorAll("input, textarea, button")
|
||||
.forEach(el=> el.disabled= true);
|
||||
})
|
||||
.catch(console.error)
|
||||
);
|
||||
|
||||
if(!componentsRegistry.size)
|
||||
addEventListener("oneachrender", registerClientPart);
|
||||
|
@ -14,10 +14,6 @@ export function mnemonic(){
|
||||
el("code", "S.on(<signal>, <listener>[, <options>])"),
|
||||
" — listen to the signal value changes",
|
||||
),
|
||||
el("li").append(
|
||||
el("code", "S.clear(...<signals>)"),
|
||||
" — off and clear signals",
|
||||
),
|
||||
el("li").append(
|
||||
el("code", "S(<value>, <actions>)"),
|
||||
" — signal: pattern to create complex reactive objects/arrays",
|
||||
@ -29,6 +25,11 @@ export function mnemonic(){
|
||||
el("li").append(
|
||||
el("code", "S.el(<signal>, <function-returning-dom>)"),
|
||||
" — render partial dom structure (template) based on the current signal value",
|
||||
)
|
||||
),
|
||||
el("li").append(
|
||||
el("code", "S.clear(...<signals>)"),
|
||||
" — off and clear signals (most of the time it is not needed as reactive ",
|
||||
"attributes and elements are cleared automatically)",
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -37,3 +37,14 @@ styles.css`
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
import { el } from "deka-dom-el";
|
||||
import { ireland } from "./ireland.html.js";
|
||||
|
||||
export function scrollTop(){
|
||||
return el(ireland, {
|
||||
src: new URL("./scrollTop.js.js", import.meta.url),
|
||||
exportName: "scrollTop",
|
||||
page_id: "*",
|
||||
});
|
||||
}
|
Reference in New Issue
Block a user