The resultできあがり
It looks and works like step 13. Each card is now one <product-card> element: the page's catalog template shrinks to a single line, and the card's markup lives in product-card.js.
見た目も動きもステップ13と同じです。各カードは<product-card>要素1つになりました。ページの商品一覧のテンプレートは1行に縮み、カードのマークアップはproduct-card.jsにあります。
Change the codeコードを変える
Create product-card.js next to index.html:
index.htmlの隣にproduct-card.jsを作ります。
// <product-card> draws one product: the row the page mounts on it (data-wcs="state: .").
// "Add to cart" is the page's business, so the card only says it was pressed.
class ProductCard extends HTMLElement {
state = {
add: () => {
this.dispatchEvent(new CustomEvent("add-to-cart", { bubbles: true }));
},
};
connectedCallback() {
// The router takes the catalog out and puts the same cards back: keep what is there.
if (this.hasChildNodes()) return;
this.innerHTML = `
<wcs-state bind-component="state"></wcs-state>
<article class="card" data-wcs="class.sold-out: stock|eq(0)">
<div class="card-icon" data-wcs="textContent: icon"></div>
<h3><a data-wcs="attr.href: href; textContent: name"></a></h3>
<p data-wcs="textContent: description"></p>
<p class="price">$<span data-wcs="textContent: price|toFixed(2)"></span></p>
<p class="badge" data-wcs="hidden: stock|ne(0)">Sold out</p>
<button type="button" data-wcs="onclick: add; disabled: stock|eq(0)">Add to cart</button>
</article>
`;
}
}
customElements.define("product-card", ProductCard);
Load it in <head>, after the router:
<head>で、ルーターの後に読み込みます。
<script type="module" src="https://esm.run/@wcstack/router@4/auto"></script>
<script type="module" src="product-card.js"></script>
In the catalog, replace the whole <article class="card"> of the row with one element:
商品一覧では、行の<article class="card">を丸ごと、要素1つに置き換えます。
<template data-wcs="for: products">
<product-card data-wcs="state: .; hidden: .matches|not; onadd-to-cart: addToCart"></product-card>
</template>
How it worksしくみ
A custom element with its own state自分のstateを持つカスタム要素
ProductCard is a plain custom element: a class that extends HTMLElement, registered under a name with a hyphen. When it is connected, it writes its markup into itself, starting with <wcs-state bind-component="state">: that makes the element's state property the state of the bindings inside it. The markup goes into the element itself (no shadow root), so shop.css styles it like the rest of the page; shop.css also has product-card { display: contents; }, so the element adds no box and its .card sits in the grid as before.
ProductCardは普通のカスタム要素です。HTMLElementを継承したクラスを、ハイフンを含む名前で登録します。接続されると、自分の中にマークアップを書き込みます。その先頭の<wcs-state bind-component="state">が、要素のstateプロパティを、中のバインディングのstateにします。マークアップは要素そのものの中に入る(shadow rootを使わない)ので、shop.cssがページのほかの部分と同じようにスタイルを当てます。shop.cssにはproduct-card { display: contents; }もあり、要素自身は箱を作らず、中の.cardがこれまでどおりグリッドに並びます。
Data in: state: .データは中へ: state: .
state: . mounts the row on the component. Inside it, name is the row's name, stock its stock, and href the page's row getter "products.*.href" — the component reads the page's tree at the row, with nothing copied. Search still filters the cards: hidden: .matches|not sits on the element in the page, where it always was.
state: .は、行を部品にマウントします。部品の中では、nameは行のnameそのもの、stockは行のstock、hrefはページの行getter"products.*.href"です。部品はページの木をその行で読み、何も写しません。検索もこれまでどおりカードを絞り込みます。hidden: .matches|notは、ページの側の要素に、元からあった場所にあります。
Events outイベントは外へ
The card doesn't know about carts. Its button calls the component's own method add, which dispatches an add-to-cart event from the element. The page binds onadd-to-cart: addToCart on it, exactly as it binds onclick: the handler receives (event, index) with the row's index, and adds the product as before. The same card could sit in a wish list whose page does something else with the event.
カードはカートのことを知りません。ボタンは部品自身のメソッドaddを呼び、それが要素からadd-to-cartイベントを送ります。ページはその要素にonadd-to-cart: addToCartをバインドします。onclickをバインドするのとまったく同じで、ハンドラは行のindex付きで(event, index)を受け取り、これまでどおり商品を加えます。同じカードを欲しいものリストに置けば、そのページはイベントで別のことをできます。
Built once作るのは1度
When you leave the catalog and come back, the router puts the same cards back in the page, and each card's connectedCallback runs again. if (this.hasChildNodes()) return; keeps the card it already built instead of building it twice.
商品一覧を離れて戻ると、ルーターは同じカードをページに戻し、各カードのconnectedCallbackがもう一度走ります。if (this.hasChildNodes()) return;は、作り直さずに、もう作ったカードをそのまま使います。
Show the category on every card with one edit. In product-card.js, add <p data-wcs="textContent: category"></p> after the description: every card in the catalog gets it.
1か所の変更で、すべてのカードにカテゴリを出してみましょう。product-card.jsの説明文の後に<p data-wcs="textContent: category"></p>を加えると、商品一覧のどのカードにも出ます。
Common mistakesよくある間違い
Forgetting state: .state: .を忘れる
Without a mount, a component whose markup is in the element itself has no state to bind to: the cards appear empty, and the console says what to do:
マウントが無いと、マークアップを要素そのものに置いた部品には、バインドするstateがありません。カードは空のまま現れ、コンソールがすべきことを言います。
[@wcstack/state] A plain (unwired) Light DOM "bind-component" is not supported. Attach a shadow root to <product-card>, or mount it from the host (data-wcs="state: path").
A typo inside the component部品の中の打ち間違い
The checker reads your HTML, not the strings in your JavaScript: write textContent: nmae in product-card.js and npx @wcstack/lint index.html still reports nothing, while every card loses its name. The next step writes tests, which do see it.
検査器が読むのはHTMLで、JavaScriptの中の文字列ではありません。product-card.jsにtextContent: nmaeと書いても、npx @wcstack/lint index.htmlは何も報告せず、どのカードからも名前が消えます。次のステップで書くテストなら、これを見つけます。
The whole fileファイル全体
The complete index.html of this step (product-card.js is shown in full above). npx @wcstack/lint index.html reports 0 error(s), 0 warning(s), 0 info for it.
このステップのindex.html全体です(product-card.jsは上に全文があります)。npx @wcstack/lint index.htmlの結果は0 error(s), 0 warning(s), 0 infoです。
Show index.htmlindex.htmlを表示
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Little Roastery</title>
<base href="/">
<link rel="stylesheet" href="https://wcstack.github.io/tutorial/shop.css">
<script type="module" src="https://esm.run/@wcstack/state@4/auto"></script>
<script type="module" src="https://esm.run/@wcstack/fetch@4/auto"></script>
<script type="module" src="https://esm.run/@wcstack/storage@4/auto"></script>
<script type="module" src="https://esm.run/@wcstack/router@4/auto"></script>
<script type="module" src="product-card.js"></script>
</head>
<body>
<wcs-state>
<script type="module">
/** @typedef {{ id: number, name: string, category: string, description: string, price: number, stock: number, icon: string }} Product */
export default {
/** @type {Product[] | null} */
catalog: null,
loading: false,
loadError: null,
reload: false,
cart: [],
routeParams: {},
query: "",
category: "all",
get products() {
return this.catalog ?? [];
},
get catalogReady() {
return this.catalog !== null;
},
get "products.*.href"() {
return "products/" + this["products.*.id"];
},
get product() {
const id = this["routeParams.id"];
return this.products.find(product => product.id === id) ?? null;
},
get "products.*.matches"() {
const query = this.query.trim().toLowerCase();
return (this.category === "all" || this["products.*.category"] === this.category) &&
this["products.*.name"].toLowerCase().includes(query);
},
get matchCount() {
return this.$getAll("products.*.matches", []).filter(Boolean).length;
},
get "cart.*.product"() {
const id = this["cart.*.id"];
return this.products.find(product => product.id === id);
},
get "cart.*.name"() {
return this["cart.*.product"]?.name ?? "…";
},
get "cart.*.price"() {
return this["cart.*.product"]?.price ?? 0;
},
get "cart.*.lineTotal"() {
return this["cart.*.price"] * this["cart.*.quantity"];
},
get itemCount() {
return this.$getAll("cart.*.quantity", []).reduce((sum, n) => sum + n, 0);
},
get subtotal() {
return this.$getAll("cart.*.lineTotal", []).reduce((sum, n) => sum + n, 0);
},
get shipping() {
return this.subtotal >= 50 ? 0 : 5;
},
get total() {
return this.subtotal + this.shipping;
},
get "cart.*.saved"() {
return { id: this["cart.*.id"], quantity: this["cart.*.quantity"] };
},
get savedCart() {
return this.$getAll("cart.*.saved", []);
},
set savedCart(lines) {
if (Array.isArray(lines)) this.cart = lines;
},
retry() {
this.reload = true;
},
addToCart(event, index) {
this.addProduct(this.products[index].id);
},
addCurrent() {
this.addProduct(this.product.id);
},
addProduct(id) {
const line = this.cart.findIndex(item => item.id === id);
if (line >= 0) {
this[`cart.${line}.quantity`] += 1;
} else {
this.cart = this.cart.concat({ id, quantity: 1 });
}
},
increase(event, index) {
this[`cart.${index}.quantity`] += 1;
},
decrease(event, index) {
this[`cart.${index}.quantity`] -= 1;
},
remove(event, index) {
this.cart = this.cart.toSpliced(index, 1);
}
};
</script>
</wcs-state>
<wcs-fetch url="https://wcstack.github.io/tutorial/data/products.json"
data-wcs="value: catalog; loading: loading; error: loadError; trigger: reload"></wcs-fetch>
<wcs-storage key="little-roastery-cart"
data-wcs="value#init=element: savedCart"></wcs-storage>
<header class="shop-header">
<h1>Little Roastery</h1>
<p class="cart-badge">Cart: <span data-wcs="textContent: itemCount"></span></p>
</header>
<p class="status" data-wcs="hidden: loading|not">Loading products…</p>
<p class="status error" data-wcs="hidden: loadError|not">
Couldn't load the products.
<button type="button" data-wcs="onclick: retry">Try again</button>
</p>
<main class="shop" data-wcs="hidden: catalogReady|not">
<wcs-router data-wcs="typedParams: routeParams">
<template>
<wcs-route path="/">
<section class="catalog">
<h2>Products</h2>
<div class="toolbar">
<input type="search" placeholder="Search products" aria-label="Search products" data-wcs="value: query">
<fieldset class="categories">
<legend class="visually-hidden">Category</legend>
<label><input type="radio" name="category" value="all" data-wcs="radio: category"> All</label>
<label><input type="radio" name="category" value="coffee" data-wcs="radio: category"> Coffee</label>
<label><input type="radio" name="category" value="tea" data-wcs="radio: category"> Tea</label>
<label><input type="radio" name="category" value="tools" data-wcs="radio: category"> Tools</label>
</fieldset>
</div>
<div class="grid">
<template data-wcs="for: products">
<product-card data-wcs="state: .; hidden: .matches|not; onadd-to-cart: addToCart"></product-card>
</template>
</div>
<p class="no-results" data-wcs="hidden: matchCount|ne(0)">No products match.</p>
</section>
</wcs-route>
<wcs-route path="/products/:id(int)">
<article class="product-page">
<p class="back"><a href="./">← All products</a></p>
<template data-wcs="if: product">
<div class="card-icon">{{ product.icon }}</div>
<h2>{{ product.name }}</h2>
<p>{{ product.description }}</p>
<p class="price">${{ product.price|toFixed(2) }}</p>
<p class="badge" data-wcs="hidden: product.stock|ne(0)">Sold out</p>
<button type="button" data-wcs="onclick: addCurrent; disabled: product.stock|eq(0)">Add to cart</button>
</template>
<template data-wcs="else:">
<p class="no-results">No such product.</p>
</template>
</article>
</wcs-route>
<wcs-route fallback>
<p class="no-results">Page not found. <a href="./">Back to the shop</a></p>
</wcs-route>
</template>
</wcs-router>
<wcs-outlet></wcs-outlet>
<aside class="cart">
<h2>Cart</h2>
<template data-wcs="if: cart.length|eq(0)">
<p class="empty">Your cart is empty.</p>
</template>
<template data-wcs="else:">
<ul class="cart-lines">
<template data-wcs="for: cart">
<li>
<span class="line-name">{{ .name }}</span>
<span class="line-total">${{ .lineTotal|toFixed(2) }}</span>
<span class="stepper">
<button type="button" aria-label="Decrease" data-wcs="onclick: decrease; disabled: .quantity|le(1)">−</button>
<span>{{ .quantity }}</span>
<button type="button" aria-label="Increase" data-wcs="onclick: increase">+</button>
</span>
<button type="button" class="remove" data-wcs="onclick: remove">Remove</button>
</li>
</template>
</ul>
<dl class="totals">
<dt>Subtotal</dt><dd>${{ subtotal|toFixed(2) }}</dd>
<dt>Shipping</dt><dd>${{ shipping|toFixed(2) }}</dd>
<dt class="grand">Total</dt><dd class="grand">${{ total|toFixed(2) }}</dd>
</dl>
<p class="hint" data-wcs="hidden: shipping|eq(0)">Free shipping on orders of $50 or more.</p>
</template>
</aside>
</main>
</body>
</html>