Mantine Tabs を模倣する

## この記事を読むための前提知識

Reactの基本的な知識と、typescript の基本的な知識があると助けになります。
また、Reactのコンポーネント構造とpropsの概念に慣れていると、この記事を理解するのがより簡単になります。

## mantine tabs

- [mantine Tabs](https://mantine.dev/core/tabs/)
- [src](https://github.com/mantinedev/mantine/blob/master/src/mantine-core/src/components/Tabs/Tabs.tsx)

### mantine tabs はどこが特殊なのか

大抵の UI コンポーネントは１つのクラスだけで構成されている.

- `<Button />`
- `<Container />`

一方で, mantine tabs は複数のクラスから構成されるのを前提としている. Tabs は内部で `Provider` をもっており、 `Tabs` グループ内でデータが完結する構成になっている.

- `<Tabs />` Tabs という塊の親
- `<Tabs.List />` Tabs の上部に位置する navigation
- `<Tabs.Tab />` List の各アイテム
- `<Tabs.Panel />` 各 Tab に紐づくコンテンツ

```tsx
<Tabs defaultValue="gallery">
  <Tabs.List>
    <Tabs.Tab value="gallery" leftSection={<IconPhoto style={iconStyle} />}>
      Gallery
    </Tabs.Tab>
    <Tabs.Tab value="messages" leftSection={<IconMessageCircle style={iconStyle} />}>
      Messages
    </Tabs.Tab>
    <Tabs.Tab value="settings" leftSection={<IconSettings style={iconStyle} />}>
      Settings
    </Tabs.Tab>
  </Tabs.List>

  <Tabs.Panel value="gallery">
    Gallery tab content
  </Tabs.Panel>

  <Tabs.Panel value="messages">
    Messages tab content
  </Tabs.Panel>

  <Tabs.Panel value="settings">
    Settings tab content
  </Tabs.Panel>
</Tabs>
```

![ezgif.com-video-to-gif (2).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1714399/c761b1a2-da13-5baa-69ac-b664858ec185.gif)


> 引用 
> https://mantine.dev/core/tabs/

## Tabs.* のようなコンポーネントを作ってみる

考え方
1. Tabs を `React.FC` で定義するとする
1. Tabs.Tab を可能としようとすれば、とりあえず `React.FC` を継承すればいいんじゃないの

実装

```tsx
import React, {ReactElement, ReactNode, forwardRef} from 'react';

interface TabsProps {
  children: ReactNode | ReactNode[];
}

// POINT: Tabs extends React.FC
interface TabsComponent extends React.FC<TabsProps> {
  Tab: typeof Tab;
}

interface TabProps {
  children: ReactNode | ReactNode[];
}

/** -------- Components -------- */
const Tab: React.FC<TabProps> = props => {
  return (
    <div style={{padding: 8, margin: 8, border: '1px solid black'}}>
      {props.children}
    </div>
  );
};

const Tabs: TabsComponent = props => {
  return <div style={{display: 'flex'}}>{props.children}</div>;
};

Tabs.Tab = Tab;
export default Tabs;

```

```tsx
<Tabs>
    <Tabs.Tab>1</Tabs.Tab>
    <Tabs.Tab>2</Tabs.Tab>
    <Tabs.Tab>3</Tabs.Tab>
</Tabs>
```

こんなんでできてもうた... 笑

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1714399/2a27301c-6f81-cd97-336b-d8c4e2b05219.png)


## TabsProvider を作ってみる

そもそも provider が必要なのか,って話なのだが,

- `Tabs` の children が**必ず** `Tabs.Tab` になる

というルールが必ず守られるのであれば必要ない(と思う).
というのも、 `Tabs` からは `Tabs.Tab` へ特定の props を渡すことになる.
例えば, `Tabs.Tab` は `isActive: boolean` を必要としているとする. 

```tsx
<div>
    {React.Children.map(props.children, (child, idx) => {
        return React.cloneElement(child, { isActive: /** TODO */});
    })}
</div>
```

だが、実際には

```tsx
<Tabs>
    <p>I shoudn't be here!</p>
</Tabs>
```

なんて実装ができてしまう。`<p/>` は　`isActive` なんて props は持っていない. 結果 typescript 的なエラーになる.

そこで

- children の props バケツリレーで値を渡す

というところを

- children の `<Tabs.Tab/>` に provider で値を渡す

で回避するのだ.

実装

```diff_tsx
-import React, {ReactElement, ReactNode, forwardRef} from 'react';
+import React, {
+  ReactNode,
+  createContext,
+  useContext,
+  useState,
+} from 'react';
 
 interface TabsProps {
   children: ReactNode | ReactNode[];
+  value: string;
 }
 
 // POINT: Tabs extends React.FC
 
 interface TabProps {
   children: ReactNode | ReactNode[];
+  value: string;
 }
 
+/** Provider */
+
+const TapsProvider = createContext({
+  value: '',
+  onClickTab: (value: string) => {},
+});
+
 /** -------- Components -------- */
 const Tab: React.FC<TabProps> = props => {
+  const {value, onClickTab} = useContext(TapsProvider);
+  const isActive = value === props.value;
+
+  const handleClick = () => {
+    onClickTab(props.value);
+  };
   return (
-    <div style={{padding: 8, margin: 8, border: '1px solid black'}}>
+    <div
+      onClick={handleClick}
+      style={{
+        padding: 8,
+        margin: 8,
+        border: '1px solid black',
+        backgroundColor: isActive ? 'salmon' : 'initial',
+      }}
+    >
       {props.children}
     </div>
   );
 };
 
 const Tabs: TabsComponent = props => {
-  return <div style={{display: 'flex'}}>{props.children}</div>;
+  const [value, setValue] = useState(props.value);
+  return (
+    <TapsProvider.Provider value={{value, onClick: setValue}}>
+      <div style={{display: 'flex'}}>{props.children}</div>
+    </TapsProvider.Provider>
+  );
 };
 
 Tabs.Tab = Tab;

```

```tsx
<Tabs value="1">
    <Tabs.Tab value="1">1</Tabs.Tab>
    <Tabs.Tab value="2">2</Tabs.Tab>
    <Tabs.Tab value="3">3</Tabs.Tab>
</Tabs>
```

![ezgif.com-video-to-gif.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1714399/96d03b9e-c80f-b9c7-671a-93a0900eaaa0.gif)

## 諸々追加して最終こうなる

- `Tabs.List` 追加
- `Tabs.Panel` 追加

```tsx
import React, {ReactNode, createContext, useContext, useState} from 'react';

interface TabsProps {
  children: ReactNode | ReactNode[];
  defaultValue: string;
}

// POINT: Tabs extends React.FC
interface TabsComponent extends React.FC<TabsProps> {
  Tab: typeof Tab;
  List: typeof List;
  Panel: typeof Panel;
}

interface ListProps {
  children: ReactNode | ReactNode[];
  orientation?: 'horizontal' | 'vertical';
}
interface TabProps {
  children: ReactNode | ReactNode[];
  value: string;
}

interface PanelProps {
  children: ReactNode | ReactNode[];
  value: string;
}

/** Provider */

const TapsProvider = createContext({
  value: '',
  onClick: (value: string) => {},
});

/** -------- Components -------- */

const List: React.FC<ListProps> = ({orientation = 'horizontal', ...props}) => {
  return (
    <div
      style={{
        display: 'flex',
        flexDirection: orientation == 'horizontal' ? 'row' : 'column',
      }}
    >
      {props.children}
    </div>
  );
};

const Tab: React.FC<TabProps> = props => {
  const {value, onClick} = useContext(TapsProvider);
  const isActive = value === props.value;

  const handleClick = () => {
    onClick(props.value);
  };
  return (
    <div
      onClick={handleClick}
      style={{
        padding: 8,
        margin: 8,
        border: '1px solid black',
        backgroundColor: isActive ? 'salmon' : 'initial',
      }}
    >
      {props.children}
    </div>
  );
};

const Panel: React.FC<PanelProps> = props => {
  const {value} = useContext(TapsProvider);
  const isActive = value === props.value;
  return (
    <div style={{display: isActive ? 'initial' : 'none'}}>{props.children}</div>
  );
};

const Tabs: TabsComponent = props => {
  const [value, setValue] = useState(props.defaultValue);
  return (
    <TapsProvider.Provider value={{value, onClick: setValue}}>
      {props.children}
    </TapsProvider.Provider>
  );
};

Tabs.Tab = Tab;
Tabs.List = List;
Tabs.Panel = Panel;
export default Tabs;
```

```tsx
<Tabs defaultValue="1">
    <Tabs.List orientation="horizontal">
        <Tabs.Tab value="1">1</Tabs.Tab>
        <Tabs.Tab value="2">2</Tabs.Tab>
        <Tabs.Tab value="3">3</Tabs.Tab>
    </Tabs.List>
    
    <Tabs.Panel value="1">contents of 1</Tabs.Panel>
    <Tabs.Panel value="2">contents of 2</Tabs.Panel>
    <Tabs.Panel value="3">contents of 3</Tabs.Panel>
</Tabs>
```

![ezgif.com-video-to-gif (1).gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1714399/b27fb9ab-8e43-1446-7865-fca1906ea628.gif)

## さいごに

今回は愚直に `React.FC` を extends しましたが, mantine はこの処理を共通化しており, `factory` とやらを定義しておりました.

ここを完全に理解するのはまだ私には早かった. :bow: 

> https://github.com/mantinedev/mantine/blob/master/src/mantine-core/src/core/factory/factory.ts

今回のポイントは 2 つですね

1. (最適なやり方ではないと思うが) `React.FC` を extends することで function component に function component を追加する
1. `Provider` を使うことで `Tabs` component をカプセル化する