前回からのつづき

前回、Web Conponents 「拡張tableタグ」の作り方を説明しました。

以降では、実際に作成した「拡張tableタグ」のJavaScript、テストプログラムの HTML, CSS, JavaScript の全コードを掲載します。


「拡張tableタグ」のJavaScript

//
// テーブル拡張クラス用JavaScriptコード
//
class ExpandTable extends HTMLElement {
  constructor() {
    super();
    this.shadow = this.attachShadow({ mode: "open" });
    this.shadow.innerHTML = `
      <style>
        :host {
          display: block;
          background-color: white;
        }
        table {
          border-collapse: separate;
          border-spacing: 0;
          border: 1px solid;
        }
        thead th {
          position: sticky;
          top: 0;
          z-index: 1;
          border: 1px solid;
          background-color: skyblue;
          font-weight: bold;
          height: 2rem;
        }
        tbody td {
          border: 1px solid;
          background-color: white;
          height: 2rem;
        }
        tfoot td {
          position: sticky;
          bottom: 0;
          z-index: 1;
          border: 1px solid;
          background-color: lightgray;
          font-weight: bold;
          height: 2rem;
        }
        .id { width: 20rem; }
        .hizuke { width: 30rem; }
        .hinmoku { width: 50rem; }
        .kingaku { width: 30rem; }
        .dummy-row td {
          background-color: lemonchiffon;
        }
      </style>

      <table>
        <thead></thead>
        <tbody></tbody>
        <tfoot></tfoot>
      </table>
    `;

    this.table = this.shadowRoot.querySelector("table");
    this.thead = this.shadowRoot.querySelector("thead");
    this.tbody = this.shadowRoot.querySelector("tbody");
    this.tfoot = this.shadowRoot.querySelector("tfoot");
  }

  connectedCallback() {
    const fieldsAttr = this.getAttribute("fields");
    this.fields = fieldsAttr ? JSON.parse(fieldsAttr) : [];
  }

  setHeader() {
    this.thead.textContent = "";
    this.tbody.textContent = "";
    this.tfoot.textContent = "";

    const tr = document.createElement("tr");
    this.fields.forEach(field => {
      const th = document.createElement("th");
      th.classList.add(field.class);
      th.textContent = field.content;
      tr.appendChild(th);
    });
    this.thead.appendChild(tr);
  }

  setData(data) {
    const tr = document.createElement("tr");
    this.fields.forEach(field => {
      const td = document.createElement("td");
      td.textContent = (field.type === "date"
        ? data[field.name].toLocaleDateString()
        : data[field.name]);
      tr.appendChild(td);
    });
    this.tbody.appendChild(tr);
  }

  setFooter() {
    const trec = document.createElement('tr');
    this.fields.forEach((field, columnIndex) => {
      const tdata = document.createElement('td');
      if (field.type == "number") {
        switch (field.calc) {
          case "sum" :
            const tds = this.shadowRoot.querySelectorAll(`tbody tr td:nth-child(${columnIndex + 1})`);
            let sum = 0;
            tds.forEach(td => {
              const value = Number(td.textContent.trim());
              if (!isNaN(value)) sum += value;
            });
            tdata.textContent = sum;
            break;
          default :
            tdata.textContent = field.calc;
            break;
        }
      } else {
         tdata.textContent = field.calc;
      }
      trec.appendChild(tdata);
    });
    this.tfoot.appendChild(trec);

    // レコードが少ない場合のフッター位置固定化の為、ダミーレコード挿入
    const oldDummy = this.shadowRoot.querySelector(".dummy-row");
    if (oldDummy) oldDummy.remove();
    const tds = this.shadowRoot.querySelectorAll(`tbody tr td:nth-child(1)`);
    const tdsHeight = tds.length > 0 ? tds[0].offsetHeight : 0;
    if (tdsHeight * tds.length + this.thead.offsetHeight + this.tfoot.offsetHeight < this.offsetHeight) {
      this.style.overflow = "hidden";
      const tr = document.createElement("tr");
      tr.classList.add("dummy-row");
      const td = document.createElement("td");
      td.colSpan = 99; // 何列でも対応
      td.style.height = (this.offsetHeight - this.table.offsetHeight < 0 ? 0 : this.offsetHeight - this.table.offsetHeight)  + "px";
      tr.appendChild(td);
      this.tbody.appendChild(tr);
    } else {
      this.style.overflow = "auto";
    }
  }

  resetDatas(datas) {
    this.setHeader();
    datas.forEach(data => this.setData(data));
    this.setFooter();
  }
}

customElements.define("konkon-table", ExpandTable);


テストプログラムの HTML

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Session Storage Test</title>
  <link rel="stylesheet" href="./style.css">
  <script type="text/javascript" src="./table_class.js"></script>
  <script type="text/javascript" src="./script.js"></script>
</head>
<body>
  <konkon-table id="kk-table" fields='[
    {"name": "id", "class": "id", "type": "number", "calc": "合計", "content": "ID"},
    {"name": "hizuke", "class": "hizuke", "type": "date", "calc": "", "content": "日付"},
    {"name": "hinmoku", "class": "hinmoku", "type": "string", "calc": "", "content": "品目"},
    {"name": "kingaku", "class": "kingaku", "type": "number", "calc": "sum", "content": "金額"}
  ]'>
  </konkon-table>
  <fieldset class="field">
    <legend>[ 検索項目 ]</legend>
    <div>
      <label>
        <input type="radio" id="fid" name="field" value="id" checked>ID
      </label>
      <label>
        <input type="radio" id="fhizuke" name="field" value="hizuke">日付
      </label>
      <label>
        <input type="radio" id="fhinmoku" name="field" value="hinmoku">品目
      </label>
      <label>
        <input type="radio" id="fkingaku" name="field" value="kingaku">金額
      </label>
    </div>
  </fieldset>
  <fieldset class="condition">
    <legend>[ 検索条件 ]</legend>
    <div>
      <input type="number" class="value" id="input-value" disabled><br>
      <input type="number" class="bound" id="input-lower-value" disabled> ~ <input type="number" class="bound" id="input-upper-value" disabled><br>
      <label><input type="checkbox" id="input-lower-threshold" checked disabled> 閾値含む      </label>
      <label><input type="checkbox" id="input-upper-threshold" checked disabled> 閾値含む</label>
    </div>
    <div class="search-button">
      <button id="btn-only" disabled>一致検索</button>
      <button id="btn-prefix-match" disabled>前方一致検索</button><br>
      <button id="btn-lower" disabled>下限検索</button>
      <button id="btn-upper" disabled>上限検索</button>
      <button id="btn-bound" disabled>範囲検索</button>
    </div>
  </fieldset>
  <p>
    <button id="btn-all" disabled>全検索</button>
  </p>
  <p>
    <div id="popup-status"></div>
  </p>
</body>
</html>


テストプログラムの CSS

body {
  padding: 1rem;
  background-color: rgb(253, 191, 76);
  text-align: center;
  overflow: hidden;
}
#kk-table {
  height: 12rem;
}
.condition {
  display: flex;
  justify-content: center;
  gap: 1rem;
}
.search-button {
  text-align: left;
}
.value {
  width: 19.5rem;
}
.bound {
  width: 8rem;
}
#popup-status {
    font-size: 1.25rem;
    font-weight: bold;
    color: red;
    margin-bottom: 1rem;
}


テストプログラムの JavaScript

//
// IndexedDBの動作確認用JavaScriptコード
//
window.addEventListener("load", function() {

  // 画面要素取得
  const table = document.getElementById("kk-table");
  const input_fields = document.getElementsByName("field");
  const input_value = document.getElementById("input-value");
  const input_lower_value = document.getElementById("input-lower-value");
  const input_upper_value = document.getElementById("input-upper-value");
  const input_lower_threshold = document.getElementById("input-lower-threshold");
  const input_upper_threshold = document.getElementById("input-upper-threshold");
  const btn_only = document.getElementById("btn-only");
  const btn_prefix_match = document.getElementById("btn-prefix-match");
  const btn_lower = document.getElementById("btn-lower");
  const btn_upper = document.getElementById("btn-upper");
  const btn_bound = document.getElementById("btn-bound");
  const btn_all = document.getElementById("btn-all");
  const status = document.getElementById("popup-status");

  // 固定値定義
  const MAX_COUNT = 10;
  const datas = [
    { id: 1, hizuke: new Date("2026-05-01"), hinmoku: "ノート", kingaku: 2000 },
    { id: 2, hizuke: new Date("2026-05-02"), hinmoku: "えんぴつ", kingaku: 1200 },
    { id: 3, hizuke: new Date("2026-05-02"), hinmoku: "消しゴム", kingaku: 500 },
    { id: 4, hizuke: new Date("2026-05-03"), hinmoku: "ボールペン", kingaku: 1500 },
    { id: 5, hizuke: new Date("2026-05-03"), hinmoku: "シャーペン", kingaku: 100 },
    { id: 6, hizuke: new Date("2026-05-04"), hinmoku: "定規", kingaku: 900 },
    { id: 7, hizuke: new Date("2026-05-04"), hinmoku: "コンパス", kingaku: 2500 },
    { id: 8, hizuke: new Date("2026-05-05"), hinmoku: "ノートA4", kingaku: 1000 },
    { id: 9, hizuke: new Date("2026-05-06"), hinmoku: "ノートB5横", kingaku: 6000 },
    { id: 10, hizuke: new Date("2026-05-07"), hinmoku: "ノートB5縦", kingaku: 2000 }
  ];

  // 変数定義
  let db = null;              // IndexedDBのIDBDatabase(DB接続)オブジェクト
  let threshold_open = false; // 閾値フラグ false:含まない/true:含む

  // 検索項目に従い、入力欄の種別変更
  input_fields.forEach(function (field) {
    field.addEventListener('click', function(event) {
      input_value.value = "";
      input_lower_value.value = "";
      input_upper_value.value = "";
      btn_only.disabled = true;
      btn_prefix_match.disabled = true;
      btn_lower.disabled = true;
      btn_upper.disabled = true;
      btn_bound.disabled = true;

      switch (field.value) {
        case "id" :
          input_value.type = "number";
          input_lower_value.type = "number";
          input_upper_value.type = "number";
          break;
        case "hizuke" :
          input_value.type = "date";
          input_lower_value.type = "date";
          input_upper_value.type = "date";
          break;
        case "hinmoku" :
          input_value.type = "text";
          input_lower_value.type = "text";
          input_upper_value.type = "text";
          break;
        case "kingaku" :
          input_value.type = "number";
          input_lower_value.type = "number";
          input_upper_value.type = "number";
          break;
        default :
          input_value.type = "text";
          input_lower_value.type = "text";
          input_upper_value.type = "text";
          break;
      }
    });
  });

  // 一致検索、前方一致検索の入力欄の変更を監視して、入力があれば検索ボタンを有効化
  ['keyup', 'change'].forEach(function (eventType) {
    input_value.addEventListener(eventType, function(event) {
      input_lower_value.value = "";
      input_upper_value.value = "";
      input_lower_threshold.disabled =true;
      input_upper_threshold.disabled =true;
      btn_lower.disabled = true;
      btn_upper.disabled = true;
      btn_bound.disabled = true;
      if (this.value) {
        btn_only.disabled = false;
        if (input_value.type == "text") btn_prefix_match.disabled = false;
      } else {
        btn_only.disabled = true;
        btn_prefix_match.disabled = true;
      }
      status.textContent = "";
    });
  });

  // 下限検索の入力欄の変更を監視して、入力があれば検索ボタンを有効化
  ['keyup', 'change'].forEach(function (eventType) {
    input_lower_value.addEventListener(eventType, function(event) {
      input_value.value = "";
      btn_only.disabled = true;
      btn_prefix_match.disabled = true;
      if (this.value) {
        input_lower_threshold.disabled =false;
        btn_lower.disabled = false;
        if (input_upper_value.value) btn_bound.disabled = false;
      } else {
        input_lower_threshold.disabled =true;
        btn_lower.disabled = true;
        btn_bound.disabled = true;
      }
      status.textContent = "";
    });
  });

  // 下限検索、上限検索、範囲検索の入力欄の変更を監視して、入力があれば検索ボタンを有効化
  ['keyup', 'change'].forEach(function (eventType) {
    input_upper_value.addEventListener(eventType, function(event) {
      input_value.value = "";
      btn_only.disabled = true;
      btn_prefix_match.disabled = true;
      if (this.value) {
        input_upper_threshold.disabled =false;
        btn_upper.disabled = false;
        if (input_lower_value.value) btn_bound.disabled = false;
      } else {
        input_upper_threshold.disabled =true;
        btn_upper.disabled = true;
        btn_bound.disabled = true;
      }
      status.textContent = "";
    });
  });

  // 一致検索、前方一致検索ボタン押下時に、IndexedDBから値を取得し、テーブルに表示
  [btn_only, btn_prefix_match].forEach(function (button) {
    button.addEventListener('click', function(event) {
      const tx = db.transaction('sales', 'readonly');
      const store = tx.objectStore('sales');

      table.setHeader();

      const results = []

      function query(value) {
        switch (button.id) {
          case "btn-only" :
            return IDBKeyRange.only(value);
          case "btn-prefix-match" :
            return IDBKeyRange.bound(value, value+"\uffff");
          default :
            return IDBKeyRange.only(value);
        }
      }

      let request;
      input_fields.forEach((input_field) => {
        if (input_field.checked) {
          switch (input_field.value) {
            case "id" : 
              request = store.openCursor(query(Number(input_value.value)));
              break;
            case "hizuke" :
              request = store.index(input_field.value + "Index").openCursor(query(new Date(input_value.value)));
              break;
            case "hinmoku" :
              request = store.index(input_field.value + "Index").openCursor(query(input_value.value));
              break;
            case "kingaku" :
              request = store.index(input_field.value + "Index").openCursor(query(Number(input_value.value)));
              break;
            default :
              request = store.openCursor(query(0));
              break;
          }
        }
      });

      request.onsuccess = (event) => {
        const cursor = event.target.result;
        if (!cursor) {
          table.setFooter();
          if (!results.length) status.textContent = "指定されたデータがありません。";
          return;
        }

        results.push(cursor.value)
        table.setData(cursor.value);

        status.textContent = "完了"

        if (results.length < MAX_COUNT) {
          cursor.continue();
          return;
        }

        table.setFooter();
      }

      request.onerror = () => {
        status.textContent = "エラー発生"
      }
    });
  });

  // 下限検索、上限検索、範囲検索ボタン押下時に、IndexedDBから値を取得し、テーブルに表示
  [btn_lower, btn_upper, btn_bound].forEach(function (button) {
    button.addEventListener('click', function(event) {
      const tx = db.transaction('sales', 'readonly');
      const store = tx.objectStore('sales');

      table.setHeader();

      const results = []

      function query(lowerValue, upperValue, lowerOpen, upperOpen) {
        switch (button.id) {
          case "btn-lower" :
            return IDBKeyRange.lowerBound(lowerValue, lowerOpen);
          case "btn-upper" :
            return IDBKeyRange.upperBound(upperValue, upperOpen);
          case "btn-bound" :
            if (lowerValue == upperValue && lowerOpen != upperOpen) {
              return IDBKeyRange.only(0);
            } else if (lowerValue > upperValue) {
              return IDBKeyRange.only(0);
            } else {
              return IDBKeyRange.bound(lowerValue, upperValue, lowerOpen, upperOpen);
            }
          default :
            return IDBKeyRange.only(0);
        }
      }

      let request;
      input_fields.forEach((input_field) => {
        if (input_field.checked) {
          const lower_open = (input_lower_threshold.checked ? false : true);
          const upper_open = (input_upper_threshold.checked ? false : true);

          switch (input_field.value) {
            case "id" : 
              request = store.openCursor(query(Number(input_lower_value.value), Number(input_upper_value.value), lower_open, upper_open));
              break;
            case "hizuke" :
              request = store.index(input_field.value + "Index").openCursor(query(new Date(input_lower_value.value), new Date(input_upper_value.value), lower_open, upper_open));
              break;
            case "hinmoku" :
              request = store.index(input_field.value + "Index").openCursor(query(input_lower_value.value, input_upper_value.value, lower_open, upper_open));
              break;
            case "kingaku" :
              request = store.index(input_field.value + "Index").openCursor(query(Number(input_lower_value.value), Number(input_upper_value.value), lower_open, upper_open));
              break;
            default :
              request = store.openCursor(query(0, 0, 0, 0));
              break;
          }
        }
      });

      request.onsuccess = (event) => {
        const cursor = event.target.result;
        if (!cursor) {
          table.setFooter();
          if (!results.length) status.textContent = "指定されたデータがありません。";
          return;
        }

        results.push(cursor.value)
        table.setData(cursor.value);

        status.textContent = "完了"

        if (results.length < MAX_COUNT) {
          cursor.continue();
          return;
        }

        table.setFooter();
      }

      request.onerror = () => {
        status.textContent = "エラー発生"
      }
    });
  });

  // 全検索ボタン押下時に、カスタムイベント発生
  btn_all.addEventListener('click', function(event) {
    document.dispatchEvent(new CustomEvent('reloadTable', {
      detail: { storename: 'sales' }
    }));
  });

  // カスタムイベント発生時、IndexedDBから全データ取得し、テーブルを再表示
  document.addEventListener('reloadTable', (e) => {
    const tx = db.transaction(e.detail.storename, 'readonly');
    const store = tx.objectStore(e.detail.storename);

    table.setHeader();

    const results = []

    const request = store.openCursor();
    request.onsuccess = (event) => {
      const cursor = event.target.result;
      if (!cursor) {
        table.setFooter();
        return;
      }

      results.push(cursor.value)
      table.setData(cursor.value);

      if (results.length < MAX_COUNT) {
          cursor.continue();
          return;
      }

      table.setFooter();
    }
  });

  // 初期処理
  status.textContent = "データベースオープン中";

  const request = indexedDB.open('SalesDatabase', 1);

  request.onupgradeneeded = (event) => {
    db = event.target.result;
    if (!db.objectStoreNames.contains("sales")) {
      const store = db.createObjectStore("sales", { keyPath: "id" });
      store.createIndex("hizukeIndex", "hizuke");
      store.createIndex("hinmokuIndex", "hinmoku");
      store.createIndex("kingakuIndex", "kingaku");
      datas.forEach(data => {
        store.add(data);
      });
    }
  };

  request.onsuccess = (event) => {
    db = event.target.result;
    document.dispatchEvent(new CustomEvent('reloadTable', {
      detail: { storename: 'sales' }
    }));
    input_value.disabled=false;
    input_lower_value.disabled=false;
    input_upper_value.disabled=false;
    btn_all.disabled=false;
    status.textContent = "";
  };
});