mirror of
https://github.com/jaandrle/deka-dom-el
synced 2025-07-01 20:32:13 +02:00
Compare commits
11 Commits
v0.9.4-alp
...
a1d2c69049
Author | SHA1 | Date | |
---|---|---|---|
a1d2c69049
|
|||
7472cea880
|
|||
7664932041
|
|||
ad255e3e19
|
|||
74ed180919
|
|||
a459c0d786
|
|||
a1831d2cc4
|
|||
a8fa048522
|
|||
4dba3a292a
|
|||
8415f5cf6f
|
|||
7e3b54153d
|
@ -1,501 +0,0 @@
|
||||
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));
|
||||
}
|
||||
}
|
||||
`),
|
||||
);
|
@ -33,6 +33,7 @@ export function page({ pkg, info }){
|
||||
`),
|
||||
el(example, { src: fileURL("./components/examples/case-studies/interactive-form.js"), variant: "big" }),
|
||||
|
||||
|
||||
el(h3, t`Interactive Image Gallery`),
|
||||
el("p").append(T`
|
||||
Responsive image gallery with lightbox, keyboard navigation, and filtering. Dynamic loading of content,
|
||||
@ -40,6 +41,7 @@ export function page({ pkg, info }){
|
||||
`),
|
||||
el(example, { src: fileURL("./components/examples/case-studies/image-gallery.js"), variant: "big" }),
|
||||
|
||||
|
||||
el(h3, t`Task Manager`),
|
||||
el("p").append(T`
|
||||
Kanban-style task management app with drag-and-drop and localStorage persistence. Complex state management
|
||||
@ -48,27 +50,6 @@ export function page({ pkg, info }){
|
||||
`),
|
||||
el(example, { src: fileURL("./components/examples/case-studies/task-manager.js"), variant: "big" }),
|
||||
|
||||
el(h3, t`Product Catalog with asyncSignal`),
|
||||
el("p").append(T`
|
||||
Interactive product catalog with search, sorting, and pagination. Features include dynamic product filtering,
|
||||
responsive UI with detailed view toggles, error handling with retry capability, and proper resource cleanup.
|
||||
Demonstrates advanced signal usage, including derived signals, abortable async data fetching, and optimized
|
||||
rendering patterns.
|
||||
`),
|
||||
el("div", { className: "callout" }).append(
|
||||
el("h4", t`asyncSignal Utility`),
|
||||
el("p").append(T`
|
||||
This example showcases the asyncSignal utility, which is a powerful abstraction for handling async data
|
||||
fetching with proper state management. It provides:
|
||||
`),
|
||||
el("ul").append(
|
||||
el("li", t`Automatic tracking of loading, success, and error states`),
|
||||
el("li", t`AbortController integration for request cancellation`),
|
||||
el("li", t`Error handling and recovery`),
|
||||
el("li", t`Options for caching previous data during loading states`)
|
||||
)
|
||||
),
|
||||
el(example, { src: fileURL("./components/examples/case-studies/products.js"), variant: "big" }),
|
||||
|
||||
el(h3, t`TodoMVC`),
|
||||
el("p").append(T`
|
||||
|
Reference in New Issue
Block a user