1
0
mirror of https://github.com/jaandrle/deka-dom-el synced 2025-07-01 04:12:14 +02:00

🐛 🔤 v0.9.4-alpha (#42)

* 🐛 fixes #41

*  adjust package size limits

* 🔤

* 📺 requestIdleCallback doesn need to be global

* 🔤 corrects irland page headers

* 📺 version

*  Signal ← SignalReadonly

* 🐛 ensures only one disconncetd listener

…for cleanup

*  🔤 Better build and improve texting

* 🐛 logo alignemt (due to gh)

* 🔤 md enhancements

* 🔤  products
This commit is contained in:
2025-03-19 17:10:43 +01:00
committed by GitHub
parent 04f93345f8
commit 4c450ae763
44 changed files with 854 additions and 339 deletions

View File

@ -1,4 +1,4 @@
import { registerClientFile, styles } from "../ssr.js";
import { page_id, registerClientFile, styles } from "../ssr.js";
const host= "."+code.name;
styles.css`
/* Code block styling */
@ -177,6 +177,9 @@ ${host}:hover .copy-button {
}
`;
import { el } from "deka-dom-el";
/**
* @typedef {"js"|"ts"|"html"|"css"|"shell"|"-"} Language
* */
/**
* Prints code to the page and registers flems to make it interactive.
* @param {object} attrs
@ -184,15 +187,17 @@ 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"|"shell"} [attrs.language="js"] Language of the code
* @param {string} [attrs.page_id] ID of the page, if setted it registers shiki
* @param {Language} [attrs.language="-s"] Language of the code
* */
export function code({ id, src, content, language= "js", className= host.slice(1), page_id }){
if(src) content= s.cat(src);
export function code({ id, src, content, language= "-", className= host.slice(1) }){
if(src){
content= s.cat(src);
if(language=== "-") language= /** @type {Language} */(src.pathname.split(".").pop());
}
content= normalizeIndentation(content);
let dataJS;
if(page_id){
registerClientPart(page_id);
if(language!== "-"){
registerClientPart();
dataJS= "todo";
}
return el("div", { id, className, dataJS, tabIndex: 0 }).append(
@ -204,8 +209,7 @@ export function pre({ content }){
return el("pre").append(el("code", content.trim()));
}
let is_registered= {};
/** @param {string} page_id */
function registerClientPart(page_id){
function registerClientPart(){
if(is_registered[page_id]) return;
// Add Shiki with a more reliable loading method

View File

@ -1,4 +1,4 @@
import { styles } from "../ssr.js";
import { page_id, styles } from "../ssr.js";
styles.css`
#html-to-dde-converter {
@ -149,12 +149,11 @@ 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 }){
export function converter(){
registerClientPart(page_id);
return el(ireland, {
src: fileURL("./converter.js.js"),
exportName: "converter",
page_id,
});
}

View File

@ -1,4 +1,4 @@
import { styles } from "../ssr.js";
import { page_id, styles } from "../ssr.js";
const host= "."+example.name;
styles.css`
${host} {
@ -119,9 +119,8 @@ import { relative } from "node:path";
* @param {URL} attrs.src Example code file path
* @param {"js"|"ts"|"html"|"css"} [attrs.language="js"] Language of the code
* @param {"normal"|"big"} [attrs.variant="normal"] Size of the example
* @param {string} attrs.page_id ID of the page
* */
export function example({ src, language= "js", variant= "normal", page_id }){
export function example({ src, language= "js", variant= "normal" }){
registerClientPart(page_id);
const content= s.cat(src).toString()
.replaceAll(/ from "deka-dom-el(\/signals)?";/g, ' from "./esm-with-signals.js";');

View File

@ -303,6 +303,7 @@ document.body.append(
padding: 1rem;
margin-bottom: 1.5rem;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
overflow: auto;
}
.loading-spinner {

View File

@ -0,0 +1,501 @@
import { el, on } from "deka-dom-el";
import { S } from "deka-dom-el/signals";
export function ProductCatalog() {
const itemsPerPage = 5;
const products = asyncSignal(S, fetchProducts, { initial: [], keepLast: true });
const searchTerm = S("");
const handleSearch = (e) => searchTerm.set(e.target.value);
const sortOrder = S("default");
const handleSort = (e) => sortOrder.set(e.target.value);
const page = S(1);
const handlePageChange = (newPage) => page.set(newPage);
const resetFilters = () => {
searchTerm.set("");
sortOrder.set("default");
page.set(1);
};
const filteredProducts = S(() => {
if (products.status.get() !== "resolved") return [];
const results = products.result.get().filter(product =>
product.title.toLowerCase().includes(searchTerm.get().toLowerCase()) ||
product.description.toLowerCase().includes(searchTerm.get().toLowerCase())
);
return [...results].sort((a, b) => {
const order = sortOrder.get();
if (order === "price-asc") return a.price - b.price;
if (order === "price-desc") return b.price - a.price;
if (order === "rating") return b.rating - a.rating;
return 0; // default: no sorting
});
});
const totalPages = S(() => Math.ceil(filteredProducts.get().length / itemsPerPage));
const paginatedProducts = S(() => {
const currentPage = page.get();
const filtered = filteredProducts.get();
const start = (currentPage - 1) * itemsPerPage;
return filtered.slice(start, start + itemsPerPage);
});
// Component structure
return el("div", { className: "product-catalog" }).append(
el("header", { className: "catalog-header" }).append(
el("h2", "Product Catalog"),
el("div", { className: "toolbar" }).append(
el("button", {
className: "refresh-btn",
textContent: "Refresh Products",
type: "button",
onclick: () => products.invoke(),
}),
el("button", {
className: "reset-btn",
textContent: "Reset Filters",
type: "button",
onclick: resetFilters,
})
)
),
// Search and filter controls
el("div", { className: "controls" }).append(
el("div", { className: "search-box" }).append(
el("input", {
type: "search",
placeholder: "Search products...",
value: searchTerm,
oninput: handleSearch,
})
),
el("div", { className: "sort-options" }).append(
el("label", "Sort by: "),
el("select", { onchange: handleSort }, on.defer(el => el.value = sortOrder.get())).append(
el("option", { value: "default", textContent: "Default" }),
el("option", { value: "price-asc", textContent: "Price: Low to High" }),
el("option", { value: "price-desc", textContent: "Price: High to Low" }),
el("option", { value: "rating", textContent: "Top Rated" })
)
)
),
// Status indicators
el("div", { className: "status-container" }).append(
S.el(products.status, status =>
status === "pending" ?
el("div", { className: "loader" }).append(
el("div", { className: "spinner" }),
el("p", "Loading products...")
)
: status === "rejected" ?
el("div", { className: "error-message" }).append(
el("p", products.error.get().message),
el("button", {
textContent: "Try Again",
onclick: () => products.invoke()
})
)
: el()
)
),
// Results count
S.el(S(()=> [filteredProducts.get(), searchTerm.get()]), ([filtered, term]) =>
products.status.get() === "resolved"
? el("div", {
className: "results-info",
textContent: term ?
`Found ${filtered.length} products matching "${term}"`
: `Showing all ${filtered.length} products`
})
: el()
),
// Products grid
el("div", { className: "products-grid" }).append(
S.el(paginatedProducts, paginatedItems =>
products.status.get() === "resolved" && paginatedItems.length > 0 ?
paginatedItems.map(product => el(ProductCard, { product }))
: products.status.get() === "resolved" && paginatedItems.length === 0 ?
el("p", { className: "no-results", textContent: "No products found matching your criteria." })
: el()
)
),
// Pagination
S.el(S(()=> [totalPages.get(), page.get()]), ([total, current]) =>
products.status.get() === "resolved" && total > 1 ?
el("div", { className: "pagination" }).append(
el("button", {
textContent: "Previous",
disabled: current === 1,
onclick: () => handlePageChange(current - 1)
}),
...Array.from({ length: total }, (_, i) => i + 1).map(num =>
el("button", {
className: num === current ? "current-page" : "",
textContent: num,
onclick: () => handlePageChange(num)
})
),
el("button", {
textContent: "Next",
disabled: current === total,
onclick: () => handlePageChange(current + 1)
})
)
: el()
)
);
}
// Product card component
function ProductCard({ product }) {
const showDetails = S(false);
return el("div", { className: "product-card" }).append(
el("div", { className: "product-image" }).append(
el("img", { src: product.thumbnail, alt: product.title })
),
el("div", { className: "product-info" }).append(
el("h3", { className: "product-title", textContent: product.title }),
el("div", { className: "product-price-rating" }).append(
el("span", { className: "product-price", textContent: `$${product.price.toFixed(2)}` }),
el("span", { className: "product-rating" }).append(
el("span", { className: "stars", textContent: "★".repeat(Math.round(product.rating)) }),
el("span", { className: "rating-value", textContent: `(${product.rating})` }),
)
),
el("p", { className: "product-category", textContent: `Category: ${product.category}` }),
S.el(showDetails, details =>
details ?
el("div", { className: "product-details" }).append(
el("p", { className: "product-description", textContent: product.description }),
el("div", { className: "product-meta" }).append(
el("p", `Brand: ${product.brand}`),
el("p", `Stock: ${product.stock} units`),
el("p", `Discount: ${product.discountPercentage}%`)
)
)
: el()
),
el("div", { className: "product-actions" }).append(
el("button", {
className: "details-btn",
textContent: S(() => showDetails.get() ? "Hide Details" : "Show Details"),
onclick: () => showDetails.set(!showDetails.get())
}),
el("button", {
className: "add-to-cart-btn",
textContent: "Add to Cart"
})
)
)
);
}
// Data fetching function
async function fetchProducts({ signal }) {
await simulateNetworkDelay();
// Simulate random errors for demonstration
if (Math.random() > 0.9) throw new Error("Failed to load products. Network error.");
const response = await fetch("https://dummyjson.com/products", { signal });
if (!response.ok) throw new Error(`API error: ${response.status}`);
const data = await response.json();
return data.products.slice(0, 20); // Limit to 20 products for the demo
}
// Utility for simulating network latency
function simulateNetworkDelay(min = 300, max = 1200) {
const delay = Math.floor(Math.random() * (max - min + 1)) + min;
return new Promise(resolve => setTimeout(resolve, delay));
}
/**
* Custom hook for async data fetching with signals
* @template T
* @param {typeof S} S - Signal constructor
* @param {(params: { signal: AbortSignal }) => Promise<T>} invoker - Async function to execute
* @param {{ initial?: T, keepLast?: boolean }} options - Configuration options
* @returns {Object} Status signals and control methods
*/
export function asyncSignal(S, invoker, { initial, keepLast } = {}) {
// Status tracking signals
const status = S("pending");
const result = S(initial);
const error = S(null);
let controller = null;
// Function to trigger data fetching
async function invoke() {
// Cancel any in-flight request
if (controller) controller.abort();
controller = new AbortController();
status.set("pending");
error.set(null);
if (!keepLast) result.set(initial);
try {
const data = await invoker({
signal: controller.signal,
});
if (!controller.signal.aborted) {
status.set("resolved");
result.set(data);
}
} catch (e) {
if (e.name !== "AbortError") {
error.set(e);
status.set("rejected");
}
}
}
// Initial data fetch
invoke();
return { status, result, error, invoke };
}
// Initialize the component
document.body.append(
el(ProductCatalog),
el("style", `
.product-catalog {
font-family: system-ui, -apple-system, sans-serif;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
}
.catalog-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.toolbar button {
margin-left: 10px;
padding: 8px 12px;
border-radius: 4px;
border: none;
background: #4a6cf7;
color: white;
cursor: pointer;
}
.controls {
display: flex;
justify-content: space-between;
margin-bottom: 20px;
gap: 15px;
flex-wrap: wrap;
}
.search-box input {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
width: 300px;
max-width: 100%;
}
.sort-options select {
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
}
.loader {
text-align: center;
padding: 40px 0;
}
.spinner {
display: inline-block;
width: 40px;
height: 40px;
border: 4px solid rgba(0, 0, 0, 0.1);
border-left-color: #4a6cf7;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.error-message {
background: #ffebee;
color: #c62828;
padding: 15px;
border-radius: 4px;
margin: 20px 0;
text-align: center;
}
.results-info {
margin-bottom: 15px;
color: #666;
}
.products-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 20px;
margin-bottom: 30px;
}
.product-card {
border: 1px solid #eee;
border-radius: 8px;
overflow: hidden;
transition: transform 0.2s, box-shadow 0.2s;
}
.product-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0, 0, 0, 0.1);
}
.product-image img {
width: 100%;
height: 180px;
object-fit: cover;
display: block;
}
.product-info {
padding: 15px;
}
.product-title {
margin: 0 0 10px;
font-size: 1.1rem;
height: 2.4rem;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.product-price-rating {
display: flex;
justify-content: space-between;
margin-bottom: 10px;
}
.product-price {
font-weight: bold;
color: #4a6cf7;
font-size: 1.2rem;
}
.stars {
color: gold;
margin-right: 5px;
}
.product-category {
color: #666;
font-size: 0.9rem;
margin-bottom: 15px;
}
.product-details {
margin: 15px 0;
font-size: 0.9rem;
}
.product-description {
line-height: 1.5;
margin-bottom: 10px;
color: #444;
}
.product-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
color: #666;
font-size: 0.85rem;
}
.product-actions {
display: flex;
gap: 10px;
margin-top: 15px;
}
.product-actions button {
flex: 1;
padding: 8px 0;
border: none;
border-radius: 4px;
cursor: pointer;
}
.details-btn {
background: #eee;
color: #333;
}
.add-to-cart-btn {
background: #4a6cf7;
color: white;
}
.pagination {
display: flex;
justify-content: center;
gap: 5px;
margin-top: 30px;
}
.pagination button {
padding: 8px 12px;
border: 1px solid #ddd;
background: white;
border-radius: 4px;
cursor: pointer;
}
.pagination button.current-page {
background: #4a6cf7;
color: white;
border-color: #4a6cf7;
}
.pagination button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.no-results {
grid-column: 1 / -1;
text-align: center;
padding: 40px;
color: #666;
}
@media (max-width: 768px) {
.controls {
flex-direction: column;
}
.search-box input {
width: 100%;
}
.products-grid {
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
}
}
`),
);

View File

@ -483,7 +483,7 @@ document.body.append(
.task-board {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
}

View File

@ -4,11 +4,11 @@ import { el } from "deka-dom-el";
const button = el("button", {
textContent: "Click me",
className: "primary",
disabled: true
disabled: true,
});
// Shorter and more expressive
// than the native approach
// Add to DOM
document.body.append(button);
document.body.append(button);

View File

@ -6,6 +6,6 @@ import { el } from "deka-dom-el";
document.body.append(
el("div").append(
el("h1", "Title"),
el("p", "Paragraph")
)
el("p", "Paragraph"),
),
);

View File

@ -81,11 +81,11 @@ function Todos(){
)
)
),
S.el(todosS, todos => !todos.length
S.el(todosS, ({ length }) => !length
? el()
: el("footer", { className: "footer" }).append(
el("span", { className: "todo-count" }).append(
noOfLeft()
el("strong", length + " " + (length === 1 ? "item" : "items")),
),
memo("filters", ()=>
el("ul", { className: "filters" }).append(
@ -100,7 +100,7 @@ function Todos(){
)
),
),
todos.length - todosRemainingS.get() === 0
length - todosRemainingS.get() === 0
? el()
: memo("delete", () =>
el("button",
@ -110,13 +110,6 @@ function Todos(){
)
)
);
function noOfLeft(){
const length = todosRemainingS.get();
return el("strong").append(
length + " ",
length === 1 ? "item left" : "items left"
)
}
}
/**

View File

@ -1,4 +1,4 @@
import { styles } from "../ssr.js";
import { styles, page_id } from "../ssr.js";
styles.css`
#library-url-form {
@ -74,7 +74,7 @@ styles.css`
import { el } from "deka-dom-el";
import { ireland } from "./ireland.html.js";
export function getLibraryUrl({ page_id }){
export function getLibraryUrl(){
return el(ireland, {
src: new URL("./getLibraryUrl.js.js", import.meta.url),
exportName: "getLibraryUrl",

View File

@ -43,7 +43,6 @@ const componentsRegistry = new Map();
* @param {object} attrs
* @param {URL} attrs.src - Path to the file containing the component
* @param {string} [attrs.exportName="default"] - Name of the export to use
* @param {string} attrs.page_id - ID of the current page
* @param {object} [attrs.props={}] - Props to pass to the component
*/
export function ireland({ src, exportName = "default", props = {} }) {

View File

@ -12,6 +12,10 @@ export function mnemonic(){
" — corresponds to custom elemnets callbacks ", el("code", "<live-cycle>Callback(...){...}"),
". To connect to custom element see following page, else it is simulated by MutationObserver."
),
el("li").append(
el("code", "on.defer(<identity>=> <identity>)(<identity>)"),
" — calls callback later",
),
el("li").append(
el("code", "dispatchEvent(<event>[, <options>])(element)"),
" — just ", el("code", "<element>.dispatchEvent(new Event(<event>[, <options>]))")