Search and filter検索と絞り込み

A search box and category buttons narrow the catalog as you type. Two-way bindings carry the input into state, and a getter on every product decides whether it matches.

検索欄とカテゴリのボタンで、入力に合わせて商品一覧を絞り込みます。双方向バインディングが入力をstateに運び、各商品のgetterが条件に合うかを決めます。

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7

The resultできあがり

Type “sen” or pick Tea. Cards that don't match disappear, and “No products match.” appears when nothing is left. The cart works as before — add from a filtered list, too.

「sen」と打つか、Teaを選んでみてください。合わないカードが消え、何も残らないと「No products match.」が出ます。カートはこれまでどおり動き、絞り込んだ一覧からも入れられます。

Live — this step's pageライブ — このステップのページOpen in a new tab ↗新しいタブで開く ↗

Change the codeコードを変える

Give every product a category — "coffee", "tea" or "tools":

各商品にcategory("coffee"・"tea"・"tools"のどれか)を加えます。

index.html — <wcs-state>changed変更
products: [
  { id: 1, name: "House Blend", category: "coffee", description: "Chocolate, caramel, a hint of citrus.", price: 14, stock: 12, icon: "☕" },
  // …
  { id: 4, name: "Sencha", category: "tea", description: "Fresh, grassy Japanese green tea.", price: 12, stock: 20, icon: "🍵" },
  // …
  { id: 6, name: "Measuring Spoon", category: "tools", description: "One scoop, one cup.", price: 6, stock: 30, icon: "🥄" },
  // …
],

Then add the two inputs and two getters, after cart: []:

続けて、cart: []の後ろに入力用のキー2つとgetter 2つを加えます。

index.html — <wcs-state>new追加
query: "",
category: "all",

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;
},

In the catalog section, put a toolbar above the grid, hide the cards that don't match, and add a message after the grid:

商品一覧のセクションでは、グリッドの上にツールバーを置き、合わないカードを隠し、グリッドの後ろにメッセージを加えます。

index.html — <section class="catalog">changed変更
<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">
    <article class="card" data-wcs="hidden: .matches|not; class.sold-out: .stock|eq(0)">
      <!-- … -->
    </article>
  </template>
</div>
<p class="no-results" data-wcs="hidden: matchCount|ne(0)">No products match.</p>

How it worksしくみ

Two-way bindings双方向バインディング

On an <input>, value: query works in both directions: every keystroke (the input event) writes the text to query, and assigning query in code writes it back into the box. radio: category binds a group of radio buttons to one value — the checked button's value — and checks the button whose value equals the state, which is why “All” starts checked. A <select> with value: and a checkbox with checked: work the same way.

<input>のvalue: queryは両方向に働きます。打鍵のたび(inputイベント)に文字列をqueryへ書き込み、コードでqueryに代入すると入力欄に書き戻します。radio: categoryはラジオボタンの組を1つの値(チェックされたボタンのvalue)にバインドし、stateとvalueが等しいボタンにチェックを付けます。「All」が最初からチェックされているのはそのためです。<select>のvalue:やチェックボックスのchecked:も同じように働きます。

A row getter can read anything行getterは何でも読める

get "products.*.matches"() combines the row's own category and name with the top-level query and category. wcstack records all of them as dependencies of every row, so a keystroke re-evaluates matches on each product, and only the hidden flags whose value changed are written to the page.

get "products.*.matches"()は、行自身のcategoryとnameに、トップレベルのqueryとcategoryを組み合わせます。wcstackはそのすべてを各行の依存として記録するので、1打鍵ごとに各商品のmatchesが評価し直され、値が変わったhiddenだけがページに書き込まれます。

Hide, don't rebuild作り直さずに隠す

hidden: .matches|not hides the card when it doesn't match — not turns true into false and back. The cards stay in the page, so the index that addToCart receives still points into products, and the handler did not have to change. matchCount counts the matches with $getAll, and the message shows when it is 0.

hidden: .matches|notは、条件に合わないカードを隠します(notはtrueとfalseを反転します)。カードはページに残っているので、addToCartが受け取るindexは今もproductsのインデックスのままで、ハンドラを変える必要はありませんでした。matchCountは$getAllで一致した数を数え、それが0のときにメッセージを出します。

Try itやってみよう

Search the descriptions too. Change the last line of matches to (this["products.*.name"] + " " + this["products.*.description"]).toLowerCase().includes(query); and type “lemon”: Ethiopia Yirgacheffe is found by its tasting notes.

説明文も検索の対象にしてみましょう。matchesの最後の行を(this["products.*.name"] + " " + this["products.*.description"]).toLowerCase().includes(query);に変えて「lemon」と打つと、Ethiopia Yirgacheffeが味の説明から見つかります。

Common mistakesよくある間違い

A filter on for:for:にフィルタ

for: takes no filters. A row is named by its position in the list it loops over, so the rows of a filtered copy would name other elements, and a write through a row would land on the wrong product. The page refuses to start, and the checker says why:

for:はフィルタを取りません。行は回しているリストの中の位置で名指されるので、絞り込んだコピーの行は別の要素を指してしまい、行を通した書き込みが違う商品に届いてしまいます。ページは起動せず、検査器がその理由を示します。

npx @wcstack/lint index.html
error wcs/binding-syntax Binding syntax error (the runtime throws at load time): "for: products|not": "for:" takes no filters — a row is "<path>.<index>", so the rows of a filtered list would name other elements. Declare a getter that returns the filtered list and loop over it ("for: <getter>").

Numbers from inputs are strings入力から来る数値は文字列

value: always writes a string. Bind a quantity box with value: qty and you get "2" — and "2" + 1 is "21". For numbers, bind valueAsNumber: qty, or convert on the way in with an input filter on the left side: value|number: qty.

value:が書き込むのは常に文字列です。数量の欄をvalue: qtyでバインドすると"2"が入り、"2" + 1は"21"になります。数値はvalueAsNumber: qtyでバインドするか、左辺に入力フィルタを付けて入口で変換します(value|number: qty)。

The whole fileファイル全体

The complete index.html of this step — the finished shop of Part 1. npx @wcstack/lint index.html reports 0 error(s), 0 warning(s), 0 info for it.

このステップのindex.html全体で、Part 1のショップの完成形です。npx @wcstack/lint index.htmlの結果は0 error(s), 0 warning(s), 0 infoです。

Show index.htmlindex.htmlを表示
index.htmlstep 6ステップ6
<!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>
  <link rel="stylesheet" href="https://wcstack.github.io/tutorial/shop.css">
  <script type="module" src="https://esm.run/@wcstack/state@4/auto"></script>
</head>
<body>

<wcs-state>
  <script type="module">
    export default {
      products: [
        { id: 1, name: "House Blend", category: "coffee", description: "Chocolate, caramel, a hint of citrus.", price: 14, stock: 12, icon: "☕" },
        { id: 2, name: "Ethiopia Yirgacheffe", category: "coffee", description: "Bright and floral, with jasmine and lemon.", price: 18, stock: 5, icon: "☕" },
        { id: 3, name: "Colombia Supremo", category: "coffee", description: "Nutty and smooth, with a cocoa finish.", price: 16, stock: 8, icon: "☕" },
        { id: 4, name: "Sencha", category: "tea", description: "Fresh, grassy Japanese green tea.", price: 12, stock: 20, icon: "🍵" },
        { id: 5, name: "Matcha", category: "tea", description: "Stone-ground, ceremonial grade.", price: 24, stock: 0, icon: "🍵" },
        { id: 6, name: "Measuring Spoon", category: "tools", description: "One scoop, one cup.", price: 6, stock: 30, icon: "🥄" },
        { id: 7, name: "Milk Pitcher", category: "tools", description: "Stainless steel, 350 ml.", price: 15, stock: 4, icon: "🥛" }
      ],
      cart: [],
      query: "",
      category: "all",

      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.*.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;
      },

      addToCart(event, index) {
        const product = this.products[index];
        const line = this.cart.findIndex(item => item.id === product.id);
        if (line >= 0) {
          this[`cart.${line}.quantity`] += 1;
        } else {
          this.cart = this.cart.concat({ id: product.id, name: product.name, price: product.price, 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>

<header class="shop-header">
  <h1>Little Roastery</h1>
  <p class="cart-badge">Cart: <span data-wcs="textContent: itemCount"></span></p>
</header>

<main class="shop">
  <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">
        <article class="card" data-wcs="hidden: .matches|not; class.sold-out: .stock|eq(0)">
          <div class="card-icon">{{ .icon }}</div>
          <h3>{{ .name }}</h3>
          <p>{{ .description }}</p>
          <p class="price">${{ .price|toFixed(2) }}</p>
          <p class="badge" data-wcs="hidden: .stock|ne(0)">Sold out</p>
          <button type="button" data-wcs="onclick: addToCart; disabled: .stock|eq(0)">Add to cart</button>
        </article>
      </template>
    </div>
    <p class="no-results" data-wcs="hidden: matchCount|ne(0)">No products match.</p>
  </section>

  <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>