Material-UIを使って吹き出しと影付きのPopperを作る

# 1. 概要

### 作るもの
こんな感じのPopper

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139120/1d8b7bed-705c-a79d-f119-17aef91d0fc9.png)


ポイント
- Material-UIのPopperを使って実装している
- 三角形の頂点がPopperを開くトリガーとなるボタンに向かって伸びている
- 三角形の部分も含めて影がついている
- 三角形を描画するためのスペースが確保できるようPopperの位置が調整されている

### 補足：なぜPopperなのか
Material-UIには浮いているUIを表示する方法として、他にもPopoverやTooltipなどがあります。
しかし、今回は浮いているUIの中にフォームを作りたかったことと、浮いているUIを表示中もユーザーが自由に画面をスクロールできるようにしたかったため、Popperを使いました。
もし、中身がフォームじゃなくて良いのならTooltipで良いですし、開いた状態ではユーザーのスクロールなどの操作を制限しても良いなら、Popoverを使うのが良いかなと思います。

Tooltipを使う場合はarrowプロパティで簡単に吹き出しの矢印をつけることができます。
Popoverを使う場合はプロパティで簡単に、という方法はなさそうなので、本記事の方法が参考になるかもしれません。

# 2. 作り方

### 大まかな流れ
1. Popperを表示し全体に影をつける
2. Popperよりも手前に吹き出しの三角形を描画する
3. Popperよりも奥にも吹き出しの三角形を描画して影をつける
4. Popperの表示位置を調整する

では、具体的な実装をしていきます。

<br />

## 2-1. Popperを表示し全体に影をつける

Popperの実装の仕方自体については、公式サイトが丁寧なのでこちらを参照ください。

https://mui.com/material-ui/react-popper/

今回は以下のように`box-shadow`を使って影をつけています。影を3つ重ねていますが、もちろん影の付け方はなんでも問題ないです。

```BubblePopper.tsx
import { paper } from "@mui/material"

...

const StyledPaper = styled(Paper)(({ theme }) => ({
  padding: theme.spacing(2),
  boxShadow:
    "0px 2px 4px -1px rgba(0,0,0,0.2),0px 4px 5px 0px rgba(0,0,0,0.14),0px 1px 10px 0px rgba(0,0,0,0.12)",
  position: "relative",
}));

...

return (
    <Popper placement="right-start">
      <StyledPaper>
        ...
      </StyledPaper>
    </Popper>
)
```

<br />

現時点では↓の画像のようになっています。
（本記事では扱いませんが、ボタンを押すとPopperが開くような実装にしています。）


![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139120/f7a83adf-f871-84ee-10de-9925dd4d1134.png)

影付きのPopperが表示されていますが、まだ吹き出しになっておらず、Popperの位置がボタンにピッタリ密着しているので、吹き出しの三角形を表示するスペースがありません。

<br />

## 2-2. Popperよりも手前に吹き出しの三角形を描画する

ここから吹き出しの三角形を作っていきます。
Popper内のコンテンツを表示しているPaperに対して、擬似要素を使って三角形を描画していきます。

offsetY/offsetXで吹き出しの三角形の位置を調整しています。

```typescript:BubblePopper.tsx
import { paper } from "@mui/material"

...

const offsetY = 22;
const offsetX = -10;

const StyledPaper = styled(Paper)(({ theme }) => ({
  padding: theme.spacing(2),
  paddingBottom: theme.spacing(1),
  boxShadow:
    "0px 2px 4px -1px rgba(0,0,0,0.2),0px 4px 5px 0px rgba(0,0,0,0.14),0px 1px 10px 0px rgba(0,0,0,0.12)",
  position: "relative",
  "&::before": {
    content: '""',
    position: "absolute",
    borderTop: "10px solid transparent",
    borderRight: "10px solid white",
    borderBottom: "10px solid transparent",
    top: offsetY,
    left: offsetX,
  },
}));

...
```

<br />

これで吹き出しっぽい見た目になります。しかし、擬似要素の部分だけまだ影がついておらず不自然です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139120/ba2a85dd-e676-1bd1-496b-b1172f2f2c5f.png)

<br />

## 2-3. Popperよりも奥にも吹き出しの三角形を描画して影をつける

吹き出しの部分だけ影をつけるために、cssの`filter:drop-shadow`という関数を使います。`box-shadow`を使うと、三角形の周りではなく、擬似要素の矩形の周りに影が出てしまうためです。

https://developer.mozilla.org/ja/docs/Web/CSS/filter-function/drop-shadow

ただ、2-2で作成した三角形に影をつけてしまうと、三角形の影もPopperより手前に表示されてしまうため、Popperの奥に2-2で作成したものと同じ三角形を描画し、そこに影をつけるようにします。

```typescript:BubblePopper.tsx
import { paper } from "@mui/material"

...

const offsetY = 22;
const offsetX = -10;

const StyledPaper = styled(Paper)(({ theme }) => ({
  padding: theme.spacing(2),
  paddingBottom: theme.spacing(1),
  boxShadow:
    "0px 2px 4px -1px rgba(0,0,0,0.2),0px 4px 5px 0px rgba(0,0,0,0.14),0px 1px 10px 0px rgba(0,0,0,0.12)",
  position: "relative",
  "&::before": {
    content: '""',
    position: "absolute",
    borderTop: "10px solid transparent",
    borderRight: "10px solid white",
    borderBottom: "10px solid transparent",
    top: offsetY,
    left: offsetX,
  },
  "&::after": {
    content: '""',
    position: "absolute",
    borderTop: "10px solid transparent",
    borderRight: "10px solid white",
    borderBottom: "10px solid transparent",
    top: offsetY,
    left: offsetX,
    filter: `drop-shadow(-2px 2px 4px rgba(0,0,0,0.2))
             drop-shadow(-4px 4px 5px rgba(0,0,0,0.14))
             drop-shadow(-1px 1px 10px rgba(0,0,0,0.12))`,
    zIndex: -1,
  },
}));

...
```

drop-shadowでは、複数の影を重ねたい場合、boxShadowのように1行で書くことができないため、drop-shadowを複数回書いています。

Popperよりもafterの擬似要素が奥に描画されるようにzIndexを指定しています。

これで吹き出し自体は完成です。あとは、吹き出しの三角形がボタンと被ってしまっているので、Popper全体の位置を良い感じに調整するだけです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139120/a93822da-d9e0-062a-f5e0-62dfdcef2e35.png)

<br />

## 2-4. Popperの表示位置を調整する

2-2、2-3で描画した三角形の部分がボタンと被っているので、Popper全体の位置を調整していきます。

しかし、Popperコンポーネントにはそういったプロパティは用意されておらず、sx等でスタイルを当ててもうまくいかないかと思います。そこでPopperの`modifiers`というプロパティを使用します。

<br />

**補足: modifiersの注意点**
こちらにmodifiersの説明が記載されています。

https://mui.com/material-ui/api/popper/#popper-prop-modifiers

> A modifier is a function that is called each time Popper.js needs to compute the position of the popper. For this reason, modifiers should be very performant to avoid bottlenecks.

modifiersの処理はPopperの位置を計算するたびに毎度実行されるため、高性能である必要がある、みたいなことが書かれています。今回は重たい処理を渡すようなことはしないので特に問題ないかと思いますが、念の為触れておきました。

<br />

以下がmodifiersを使ったPopperの表示位置調整の実装です。


```typescript:BubblePopper.tsx
...

return (
    <Popper
      placement="right-start"
      modifiers={[
        {
          name: "offset",
          options: {
            offset: [-15, 15],
          },
        },
      ]}
    >
      <StyledPaper>
        ...
      </StyledPaper>
    </Popper>
)
```

<br />

offsetは色々試しながら良い感じの値を探してください。

これで期待通りのPopperが完成しました！

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/139120/ffbea745-bfc7-16ba-f454-3bdafafa621f.png)

以上です。読んでいただきありがとうございました。

<br />

## 参考
<details><summary>今回実装したコードの全文はこちら</summary>

```typescript:App.tsx
import React, { useState, useCallback, useRef } from "react";

import { Box, Button } from "@mui/material";
import { BubblePopper } from "./BubblePopper";

export const App = () => {
  const [open, setOpen] = useState(false);
  const [title, setTitle] = useState("");
  const [isPublic, setIsPublic] = useState("true");
  const buttonRef = useRef(null);

  const handleClick = () => setOpen(!open);

  const handleSave = useCallback(() => setOpen(false), []);

  return (
    <Box sx={{ pl: "16px", pt: "32px" }}>
      <Button ref={buttonRef} onClick={handleClick}>
        open
      </Button>
      <BubblePopper
        open={open}
        setTitle={setTitle}
        title={title}
        setIsPublic={setIsPublic}
        isPublic={isPublic}
        onSave={handleSave}
        parentRef={buttonRef}
      />
    </Box>
  );
};

```

```typescript:BubblePopper.tsx
import React, { MutableRefObject, useCallback } from "react";
import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import Popper from "@mui/material/Popper";
import Input from "@mui/material/Input";
import {
  Button,
  FormControlLabel,
  Paper,
  Radio,
  RadioGroup,
  SxProps,
  Theme,
  styled,
} from "@mui/material";

const offsetY = 22;
const offsetX = -10;

const StyledPaper = styled(Paper)(({ theme }) => ({
  padding: theme.spacing(2),
  boxShadow:
    "0px 2px 4px -1px rgba(0,0,0,0.2),0px 4px 5px 0px rgba(0,0,0,0.14),0px 1px 10px 0px rgba(0,0,0,0.12)",
  position: "relative",
  "&::before": {
    content: '""',
    position: "absolute",
    borderTop: "10px solid transparent",
    borderRight: "10px solid white",
    borderBottom: "10px solid transparent",
    top: offsetY,
    left: offsetX,
  },
  "&::after": {
    content: '""',
    position: "absolute",
    borderTop: "10px solid transparent",
    borderRight: "10px solid white",
    borderBottom: "10px solid transparent",
    top: offsetY,
    left: offsetX,
    filter: `drop-shadow(-2px 2px 4px rgba(0,0,0,0.2))
             drop-shadow(-4px 4px 5px rgba(0,0,0,0.14))
             drop-shadow(-1px 1px 10px rgba(0,0,0,0.12))`,
    zIndex: -1,
  },
}));

type Props = {
  sx?: SxProps<Theme>;
  open: boolean;
  setTitle: (title: string) => void;
  title: string;
  setIsPublic: (isPublic: string) => void;
  isPublic: string;
  onSave: () => void;
  parentRef: MutableRefObject<null>;
};

export const BubblePopper = ({
  sx,
  open,
  setTitle,
  title,
  setIsPublic,
  isPublic,
  onSave,
  parentRef,
}: Props) => {
  const changeIsPublic = (e: React.ChangeEvent<HTMLInputElement>) => {
    setIsPublic(e.target.value);
  };

  const handleSave = useCallback(() => {
    onSave();
  }, []);

  return (
    <Popper
      open={open}
      placement="right-start"
      anchorEl={parentRef.current}
      sx={{
        ...sx,
      }}
      modifiers={[
        {
          name: "offset",
          options: {
            offset: [-15, 15],
          },
        },
      ]}
    >
      <StyledPaper>
        <Typography fontSize={12}>タイトル</Typography>
        <Input
          onChange={(e) => setTitle(e.target.value)}
          placeholder="untitled"
        />
        <Typography mt={3} fontSize={12}>
          公開設定
        </Typography>
        <RadioGroup
          row
          aria-label="isPublic"
          name="isPublic"
          onChange={changeIsPublic}
        >
          <FormControlLabel
            control={<Radio value={true} checked={isPublic === "true"} />}
            label="公開"
            labelPlacement="end"
          />
          <FormControlLabel
            control={<Radio value={false} checked={isPublic === "false"} />}
            label="非公開"
            labelPlacement="end"
          />
        </RadioGroup>
        <Box
          sx={{
            display: "flex",
            width: "100%",
            justifyContent: "end",
          }}
        >
          <Button onClick={handleSave}>作成</Button>
        </Box>
      </StyledPaper>
    </Popper>
  );
};

```
</details>

吹き出しに影をつける方法についてはこちらのブログを参考にさせていただきました。

https://turicco.com/drop-shadow/