素のJavaScriptで楽天トラベルのAPIを使ったページネーションを実装してみた

# はじめに
こんにちは。前回記事を書いてみて言語化することで自分のわからないことが明確となり、アウトプットの大切さを実感している今日この頃です。
今回も振り返りを兼ねてページネーション実装について記録を残していきたいと思います。

# なぜやろうと思ったか
今回も楽天トラベルキーワード検索APIを使ったコーディングがきっかけです。
検索結果をページネーションで見られるようにしたいと思った際に、ページネーションの実装の仕方を論理立てて考える力が身につきそうだと思い素のJavaScriptで実装しました。

# 楽天APIで取れるページネーションの情報
[楽天トラベルキーワード検索API](https://webservice.rakuten.co.jp/documentation/keyword-hotel-search)では下記のようなページネーションの情報が入ったpagingInfoというobjectを取得できます。
このデータを元にページネーションを作っていきます。
|  パラメーター  |  備考  |
| ---- | ---- |
|  recordCount  |  検索結果のレコード件数  |
|  pageCount  |  最大100  |
|  page  |  現在のページ番号  |
|  first  |  検索結果の何件目からか  |
|  last  |  検索結果の何件目までか  |

# ページネーションを素のJavaScriptで実装する
pagingInfoをもとにページネーションを実装していきます！
```js
/**
 * ページネーションの矢印ボタンを作成
 * @param {'left'|'right'} direction 出力するボタンの方向
 * @param {HTMLElement} parentElement 親要素
 */
const createArrowButton = (direction, parentElement) => {
  const arrowButtonElement = document.createElement("li");
  // "left"または"right"の方向によってクラス名を決定
  arrowButtonElement.className = `pagination-item js-pagination-${direction}`;
  arrowButtonElement.innerHTML = direction === "left" ? "&laquo;" : "&raquo;";
  parentElement.appendChild(arrowButtonElement);
};

/**
 * ページネーションに何個のページを表示するかを計算する
 * @param {number} 全体のページ数
 * @returns {number} ページネーションに何個ページを表示するかを表す数
 */
const calculateShowPageTotal = (pageCount) => {
  // スマホの場合、画面幅が小さいので最大ページ数を5ページに設定
  const defaultPageTotal = isMobile() ? 5 : 9;

  // APIから取得した最大ページ数がデフォルトで設定した値より小さかったら最大数をAPIからの数値にする
  return Math.min(pageCount, defaultPageTotal);
};

/**
 * スマホで見ているかどうかを判定する関数
 * @returns {boolean} スマホならtrue, タブレットやPCならfalseを返す
 */
export const isMobile = () => {
  const ua = navigator.userAgent;
  return /iPhone|iPod|Android/.test(ua);
};

/**
 * ページネーションの番号部分を作成する
 * @param {number} showPageTotal ページネーションに表示するページの数
 * @param {number} startNumber ページネーションを始める番号
 * @param {number} currentPageNumber 現在閲覧しているページの番号
 * @param {HTMLElement} parentElement 親要素
 */
const createPaginationNumber = (
  showPageTotal,
  startNumber,
  currentPageNumber,
  parentElement
) => {
  for (let i = 0; i < showPageTotal; i++) {
    const paginationLiElement = document.createElement("li");
    paginationLiElement.textContent = i + startNumber;

    // 閲覧しているページを選択状態にする
    paginationLiElement.className =
      "pagination-item js-pagination-item" +
      (i + startNumber === currentPageNumber ? " is-selected" : "");
    parentElement.appendChild(paginationLiElement);
  }
};

/**
 * ページネーションを出力する
 * @param {Object} pagingInfo - ページ情報を持つオブジェクト
 * @param {number} pagingInfo.page - 現在見ているページ
 * @param {number} pagingInfo.pageCount - 最後のページ
 */
const outputPagination = ({ page, pageCount }) => {
  const paginationElement = document.querySelector(".js-pagination");
  const paginationUlElement = document.createElement("ul");
  paginationUlElement.className = "pagination-list";

  // 現在見ているページが1ページ目以外だったら左矢印ボタンを出力
  page !== 1 && createArrowButton("left", paginationUlElement);

  // ページネーションに表示させるMaxページ数を計算する
  const showPageTotal = calculateShowPageTotal(pageCount);

  // 最初の方のページ
  if (page <= showPageTotal / 2) {
    createPaginationNumber(showPageTotal, 1, page, paginationUlElement);
    // 最後の方のページ
  } else if (pageCount - page < showPageTotal / 2) {
    createPaginationNumber(
      showPageTotal,
      pageCount - showPageTotal + 1,
      page,
      paginationUlElement
    );
    // 中間のページ
  } else {
    const shiftNum = Math.floor(showPageTotal / 2);
    const startNumber = page - shiftNum;
    createPaginationNumber(
      showPageTotal,
      startNumber,
      page,
      paginationUlElement
    );
  }

  paginationElement.appendChild(paginationUlElement);

  // 現在見ているページが最終ページ以外だったら右矢印ボタンを出力
  pageCount !== page && createArrowButton("right", paginationUlElement);
};
```

## ページネーションの開始番号を調節したい
ページネーションの端に表示されている番号を押した時に、まだ表示できるページがあったら、押した番号が中央に寄るように実装しました。
outputPaginationの中の該当部分の解説をします。
```js
// 最初の方のページ
if (page <= showPageTotal / 2) {
    createPaginationNumber(showPageTotal, 1, page, paginationUlElement);
}
```
ページ番号が小さい場合は、1から連続した番号をページネーションに出します。
たとえば、showPageTotal(ページネーションに表示するMaxの個数)が9の場合、page(現在見ているページ)が1から4の場合はこの条件式がtrueとなり、1からページネーションが作成されます。
![ページネーション前半.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3889920/d9131393-e96b-c979-48e0-26632fbd3aa8.png)

```js
// 最後の方のページ
} else if (pageCount - page < showPageTotal / 2) {
    createPaginationNumber(
        showPageTotal,
        pageCount - showPageTotal + 1,
        page,
        paginationUlElement
    );
}
```
最後のページに近づいてきたと判断された場合、この条件式がtrueになります。
たとえば、pageCount(最後のページ)が100、page(現在見ているページ)が96だったとすると100 - 96 = 4
showPageTotal(ページネーションに表示するMaxの個数)が9の場合、9 / 2 = 4.5 となるので、 4 < 4.5　となります。

この場合、ページネーションをスタートする番号は、pageCount - showPageTotal + 1となるので、100 - 9 + 1 = 92　となります。なお、97ページ、98ページと先に進んでもスタートする番号は92からで固定となります。

![ページネーション後半.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3889920/a90ab975-6aca-bb2d-cfe0-5611734f5433.png)

```js
// 中間のページ
} else {
    const shiftNum = Math.floor(showPageTotal / 2);
    const startNumber = page - shiftNum;
    createPaginationNumber(
      showPageTotal,
      startNumber,
      page,
      paginationUlElement
    );
}
```
それ以外は中間のページと判断されます。ここではページネーションを開始する番号をずらすことで、自分の見ているページがページネーションの真ん中に表示されます。
たとえば、showPageTotal(ページネーションに表示するMaxの個数)が9の場合、shiftNumはMath.floor(9 / 2)で4ずれます。
今閲覧しているページをページネーションの中で真ん中に配置するため、2で割っているイメージです。

page(現在見ているページ)が9だった場合、startNumberは 9 - 4 = 5 からになります。
![ページネーション中間.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3889920/fe3221d9-fff6-4ef9-5527-c57b7457569a.png)

# APIの結果をページネーションつきで表示してみる
## HTML
```html
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
  <link href="./app.css" rel="stylesheet" type="text/css">
</head>
<body>
  <h1>ページネーションテスト</h1>
  <h2>東京で検索</h2>
  <div class="result js-result"></div>
  <div class="pagination js-pagination"></div>
</body>
<script src="./app.js" type="module"></script>
</html>
```

## CSS
```css
.pagination {
  display: flex;
  justify-content: center;
  margin-top: 20px;
}
.pagination .pagination-list {
  list-style: none;
  display: flex;
  gap: 20px;
  padding: 0;
}
.pagination .pagination-item {
  cursor: pointer;
  text-align: center;
  width: 28px;
}
.pagination .pagination-item.is-selected {
  background-color: #ffe300;
  border-radius: 4px;
}
.pagination .pagination-item:hover {
  background-color: #e7e8a4;
  border-radius: 4px;
}
```

## 追加のJavaScript
```js
/**
 * 結果をクリアする
 */
const clearResult = () => {
  document.querySelector(".js-result").innerHTML = "";
  document.querySelector(".js-pagination").innerHTML = "";
};

/**
 * result要素に結果を入れる
 * @param {Object[]} hotelList APIから取得したホテルデータ
 */
const createResult = (hotelList) => {
  const resultElement = document.querySelector(".js-result");
  const hotelNameUl = document.createElement("ul");

  hotelList.forEach(({ hotelName }) => {
    const hotelNameLi = document.createElement("li");
    hotelNameLi.textContent = hotelName;
    hotelNameUl.appendChild(hotelNameLi);
  });
  resultElement.appendChild(hotelNameUl);
};

/**
 * 結果を出力する
 * @param {Object} result APIから取得したデータ
 */
const outputResult = (result) => {
  clearResult();
  createResult(result.hotelList);
  outputPagination(result.pagingInfo);
};

/**
 * 指定された場所の旅行情報を取得し、結果を表示する
 * @param {number} [pageNumber] オプションのページ番号
 */
const fetchAndDisplayTravelInfo = async (pageNumber) => {
  try {
    const res = await getTravelInfo("東京", pageNumber);
    outputResult(res);
  } catch (error) {
    console.error("Error fetching travel info:", error);
    alert("データの取得に失敗しました。再試行してください。");
  }
};

document.body.addEventListener("click", async (e) => {
  if (e.target && e.target.matches(".js-pagination-item")) {
    const selectedPageNumber = Number(e.target.textContent);
    await fetchAndDisplayTravelInfo(selectedPageNumber);
  }

  if (e.target && e.target.matches(".js-pagination-left")) {
    const currentPageNumber = Number(
      document.querySelector(".js-pagination-item.is-selected").textContent
    );
    await fetchAndDisplayTravelInfo(currentPageNumber - 1);
  }

  if (e.target && e.target.matches(".js-pagination-right")) {
    const currentPageNumber = Number(
      document.querySelector(".js-pagination-item.is-selected").textContent
    );
    await fetchAndDisplayTravelInfo(currentPageNumber + 1);
  }
});

// 初期データの取得
fetchAndDisplayTravelInfo();
```
これにAPIからデータをとるJavaScriptを追加すれば、動くと思います！

# できあがったページ
PCで確認してみます！
![PCテスト画像.html (2).png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3889920/825b2f10-3c4d-c59b-2f17-2ee352accede.png)

スマホでもいい感じに表示されました！
![スマホテスト画像.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3889920/b421f20d-9653-867c-ca76-e83cc8bb0e66.png)


# 参考にさせていただいたページ
ありがとうございました！

https://qiita.com/ayaka105/items/c950bf8025bae699cffe