【React(TypeScript) + Chakra UI】フッターと重ならない「トップに戻るボタン」を作成する

## 作成するもの

* トップに戻るボタン
  * クリックすると**ページのトップに戻る**。
  * ページトップでは**表示されない**。
  * **画面右下に固定表示**される。
  * 画面内に少しでもフッターが表示されると**固定表示が解除され**、  
    ボタンは**フッターの上に表示**される。

![上に戻るボタン.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3322109/275bb825-16ba-545a-be87-e988635c54ff.gif)

## 前提
* TypeScript のバージョンが **4.1.0** 以上であること。（理由については [こちら](https://chakra-ui.com/getting-started/nextjs-guide#notes-on-typescript-) を参照）
* React がインストールされていること。
* Chakra UI がインストールされていること。
* Chakra UI icons がインストールされていること。  
  （ボタンのアイコンに自分で用意した画像を使用する場合はインストール不要）

## 成果物
```tsx:footer.tsx
import { Box } from "@chakra-ui/layout";
import { Fade, IconButton, ResponsiveValue } from "@chakra-ui/react";
import { ArrowLeftIcon } from "@chakra-ui/icons";
import React, { useState, useLayoutEffect, useRef } from "react";

interface ScrollToTopButtonProps {
  footerHeight     : number
  position         : ResponsiveValue<any>
}

const ScrollToTopButton = (props: ScrollToTopButtonProps) => {
  const handleClick = () => {
    window.scrollTo({ top: 0, behavior: "smooth" });
  };

  return (
    <IconButton
      aria-label  ="Scroll to top"
      icon        ={<ArrowLeftIcon transform="rotate(90deg)" w="4" h="4"/>}
      position    ={props.position}
      bottom      ={props.position === "fixed" ? "8" : props.footerHeight + 20}
      right       ="8"
      size        ="lg"
      borderRadius="full"
      color       ="white"
      opacity     ="30%"
      bg          ="gray.600"
      _hover      ={{ opacity: "70%", bg: "gray.500" }}
      onClick     ={handleClick}
    />
  );
};

const Footer = () => {
  const footerRef = useRef<HTMLDivElement>(null);
  const [pageHeight       , setPageHeight]        = useState<number>(0);
  const [windowInnerHeight, setWindowInnerHeight] = useState<number>(0);
  const [scrollY          , setScrollY]           = useState<number>(0);
  const [footerHeight     , setFooterHeight]      = useState<number>(0);
  const [position         , setPosition]          = useState<ResponsiveValue<any> | undefined>("fixed");

  const determineButtonPosition = () => {
    setPageHeight(document.body.offsetHeight);
    setWindowInnerHeight(window.innerHeight);
    setScrollY(window.scrollY);
    setFooterHeight(footerRef.current?.clientHeight ?? 0);

    if (pageHeight - windowInnerHeight - scrollY <= footerHeight) {
      setPosition("absolute");
    } else {
      setPosition("fixed");
    }
  };

  useLayoutEffect(() => {
    window.addEventListener("resize", determineButtonPosition);
    window.addEventListener("scroll", determineButtonPosition);
    return () => {
      window.removeEventListener("resize", determineButtonPosition);
      window.removeEventListener("scroll", determineButtonPosition);
    };
  });

  return (
    <Box as="footer" h= "30dvh" bg="red.50" fontSize="36px" ref={footerRef}>
      height = 30dvh のフッター
        <Fade in={scrollY > 0}>
          <ScrollToTopButton footerHeight     ={footerHeight}
                             position         ={position}
          />
        </Fade>
    </Box>
  );
};

export default Footer;
```

## 以下、コードの解説

### 0. ボタンの位置を制御するための方針
* ボタンの表示パターンは3つ

| ボタンの表示パターン     | 条件                                                                       |
|:-----------------------|:---------------------------------------------------------------------------|
| 表示なし                | 縦方向のスクロール量 = 0                                                     |
| 画面右下に表示           | 縦方向のスクロール量 > 0 <br> かつ <br> 画面内にフッターが全く表示されていない  |
| フッターの上に表示       | 縦方向のスクロール量 > 0 <br> かつ <br> 画面内にフッターが少しでも表示されている |

* 上記パターンの判定に必要なパラメータ

|  | 変数名               | 説明                               |
|:-|:--------------------|:----------------------------------|
|① | pageHeight          | ページ全体の高さ                   |
|② | windowInnerHeight   | ブラウザに表示されているページの高さ |
|③ | scrollY             | 縦方向のスクロール量               |
|④ | footerHeight        | フッターの高さ                     |

* 画面内にフッターが表示されているかどうかの判定
  * `➀ - ➁ - ➂` ≦ `➃`  
    ⇒画面内にフッターが **表示されている**
  * `➀ - ➁ - ➂` ＞ `➃`  
    ⇒画面内にフッターが **表示されていない**


 ※ページ中間までスクロールした例  
  （赤矢印は `➀ - ➁ - ➂` であり、`➀`は点線部の高さを含まない。）
![Qiita説明用赤矢印付き.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3322109/bb2b6e48-e1ae-b32a-5cc8-df4d491e04ff.png)



### 1. ボタンのコンポーネント(ScrollToTopButton)

#### 1.1 ScrollToTopButtonのprops
前述の `footerHeight` に加え、ボタンの表示位置を制御する `position` を持つ。
```tsx:Footer.tsx
interface ScrollToTopButtonProps {
  footerHeight     : number
  position         : ResponsiveValue<any>
}
```

#### 1.2 ScrollToTopButtonの実装
ボタンクリック時に `handleClick` 関数が呼び出され、ページトップまで滑らかに遷移する。
ボタンの表示位置は `props` の `position` の設定値に応じて、以下の通り変化させる。
* `position` が `"fixed"`  
⇒ボタンは画面右下に固定表示

* `position` が `"fixed"` **以外**
⇒ボタンはフッターよりも少し上に表示

```tsx:Footer.tsx
const ScrollToTopButton = (props: ScrollToTopButtonProps) => {
  const handleClick = () => {
    window.scrollTo({ top: 0, behavior: "smooth" });
  };

  return (
    <IconButton
      aria-label  ="Scroll to top"
      icon        ={<ArrowLeftIcon transform="rotate(90deg)" w="4" h="4"/>}
      position    ={props.position}
      bottom      ={props.position === "fixed" ? "8" : props.footerHeight + 20}
      right       ="8"
      size        ="lg"
      borderRadius="full"
      color       ="white"
      opacity     ="30%"
      bg          ="gray.600"
      _hover      ={{ opacity: "70%", bg: "gray.500" }}
      onClick     ={handleClick}
    />
  );
};
```

### 2.フッターのコンポーネント(Footer)

#### 2.1 フックの定義
Reactのフックを使用して、フッターの情報を格納する変数と
ボタンの表示パターンの判定に必要な変数を定義する。

:::note info
Reactのフックについては[公式のリファレンス](https://ja.react.dev/reference/react)を参照。
:::
```tsx:Footer.tsx
  const footerRef = useRef<HTMLDivElement>(null);
  const [pageHeight       , setPageHeight]        = useState<number>(0);
  const [windowInnerHeight, setWindowInnerHeight] = useState<number>(0);
  const [scrollY          , setScrollY]           = useState<number>(0);
  const [footerHeight     , setFooterHeight]      = useState<number>(0);
  const [position         , setPosition]          = useState<ResponsiveValue<any> | undefined>("fixed");
```

#### 2.2 ボタンの表示位置を決定する
**ウィンドウのサイズ変更** または **スクロール**が発生する度に `determineButtonPosition` を呼び出す。
ボタンの表示パターンの判定に必要な各パラメータを取得し、ボタンの表示位置を決定する。

```tsx:Footer.tsx
  const determineButtonPosition = () => {
    setPageHeight(document.body.offsetHeight);
    setWindowInnerHeight(window.innerHeight);
    setScrollY(window.scrollY);
    setFooterHeight(footerRef.current?.clientHeight ?? 0);

    if (pageHeight - windowInnerHeight - scrollY <= footerHeight) {
      setPosition("absolute");
    } else {
      setPosition("fixed");
    }
  };

  useLayoutEffect(() => {
    window.addEventListener("resize", determineButtonPosition);
    window.addEventListener("scroll", determineButtonPosition);
    return () => {
      window.removeEventListener("resize", determineButtonPosition);
      window.removeEventListener("scroll", determineButtonPosition);
    };
  });
```


#### 2.3 Footerコンポーネントの定義
 `determineButtonPosition` 関数がフッターの情報を取得できるよう、propsに`ref` を設定する。
また、ページトップでボタンが表示されないよう、`ScrollToTopButton` コンポーネントを
 `<Fade>` の子要素として配置する。
```tsx:Footer.tsx
  return (
    <Box as="footer" h= "30dvh" bg="red.50" fontSize="36px" ref={footerRef}>
      height = 30dvh のフッター
        <Fade in={scrollY > 0}>
          <ScrollToTopButton footerHeight     ={footerHeight}
                                 position     ={position}
          />
        </Fade>
    </Box>
  );
```

:::note info
Chakra UIの `<Fade>` については[公式のリファレンス](https://chakra-ui.com/docs/components/transitions)を参照。
:::

## 参考サイト
* Chakra UI 公式ドキュメント
  * [Chakra UI - Icon](https://chakra-ui.com/docs/components/icon)
  * [Chakra UI - Transitions](https://chakra-ui.com/docs/components/transitions)
* React公式ドキュメント
  * [React - Hooks](https://ja.react.dev/reference/react)
* [React Hooks入門ガイド](https://www.geekfeed.co.jp/geekblog/reacthooks)
* [JavaScriptでHTML要素の高さを取得する方法](https://qiita.com/ringtail003/items/00d40b4b586d33b0737a)