🗂️

Reactで作るアニメ付きドリルダウンUI ─ 状態設計から実装まで

に公開

業務でドリルダウン(UIパターン)を実現するコンポーネントを実装する機会がありました。状態管理やアニメーションの実装に関していろいろ考えることが多かったので、実装中何を考えていたのかをトレースして記事にまとめました。

ドリルダウンに関する説明はソシオメディアさんの記事がわかりやすいので、そちらを参照してください。

実装したいコンポーネントのゴールを考える

ドリルダウンを実装するにあたり、どのようなデータをどのようなAPIのコンポーネントで扱いたいかを考えます。

扱うデータは、大分類>中分類>小分類のように階層的になっているデータを想定します。たとえば次のような部署>チーム>メンバーが階層構造になっているデータです。

src/data.ts
type Department = { id: string; name: string; teams: Team[] };
type Team = { id: string; name: string; members: Member[] };
type Member = { id: string; name: string };

export const departments: Department[] = [
  {
    id: 'dep-eng',
    name: 'エンジニアリング部',
    teams: [
      {
        id: 'team-fe',
        name: 'フロントエンドチーム',
        members: [
          { id: 'mem-001', name: '佐藤 沙羅' },
          { id: 'mem-002', name: '田中 慎吾' },
        ],
      },
      {
        id: 'team-be',
        name: 'バックエンドチーム',
        members: [
          { id: 'mem-003', name: '山田 真央' },
          { id: 'mem-004', name: '鈴木 翔太' },
        ],
      },
    ],
  },
  {
    id: 'dep-sales',
    name: '営業部',
    teams: [
      {
        id: 'team-domestic',
        name: '国内営業チーム',
        members: [
          { id: 'mem-005', name: '高橋 直子' },
          { id: 'mem-006', name: '渡辺 洋介' },
        ],
      },
      {
        id: 'team-global',
        name: '海外営業チーム',
        members: [
          { id: 'mem-007', name: '伊藤 純平' },
          { id: 'mem-008', name: '中村 恵' },
        ],
      },
    ],
  },
  {
    id: 'dep-marketing',
    name: 'マーケティング部',
    teams: [
      {
        id: 'team-content',
        name: 'コンテンツマーケティング',
        members: [
          { id: 'mem-009', name: '石井 智子' },
          { id: 'mem-010', name: '森 大輔' },
        ],
      },
      {
        id: 'team-growth',
        name: 'グロースチーム',
        members: [
          { id: 'mem-011', name: '池田 明' },
          { id: 'mem-012', name: '橋本 彩花' },
        ],
      },
    ],
  },
  {
    id: 'dep-hr',
    name: '人事部',
    teams: [
      {
        id: 'team-recruit',
        name: '採用チーム',
        members: [
          { id: 'mem-013', name: '阿部 里奈' },
          { id: 'mem-014', name: '福田 剛' },
        ],
      },
      {
        id: 'team-training',
        name: '育成・研修チーム',
        members: [
          { id: 'mem-015', name: '藤田 翔' },
          { id: 'mem-016', name: '西村 佳奈' },
        ],
      },
    ],
  },
  {
    id: 'dep-cs',
    name: 'カスタマーサクセス部',
    teams: [
      {
        id: 'team-onboarding',
        name: 'オンボーディングチーム',
        members: [
          { id: 'mem-017', name: '太田 翔子' },
          { id: 'mem-018', name: '三浦 賢一' },
        ],
      },
      {
        id: 'team-support',
        name: 'サポートチーム',
        members: [
          { id: 'mem-019', name: '岡田 修平' },
          { id: 'mem-020', name: '長谷川 千尋' },
        ],
      },
    ],
  },
];

次に、コンポーネントが持つ必要がある機能・ユーザビリティのためにあると望ましい機能を考えます。

  • コンポーネントが持つ必要がある機能
    • 大分類から小分類へと階層的に構造化されたデータの特定の1階層を表示することができる
    • 大分類から小分類にデータを掘り下げるように表示を切り替えることができる
    • 小分類から大分類に戻るように表示を切り替えることができる
  • ユーザビリティのためにあると望ましい機能
    • 表示の切替時にアニメーションをつけることで、ユーザーが階層構造を認識しやすくする

扱うデータの構造とコンポーネントの機能を念頭に置きつつ、まずは実現可能性を考えすぎずに理想的なAPIを考えます。階層構造を宣言的に表現できるような形のAPIが望ましいです。

src/App.tsx
import { DrillDown, DrillDownLayer } from './components/drill-down/drill-down';
import { DepartmentList } from './components/department-list/department-list';
import { Department } from './components/department/department';
import { Team } from './components/team/team';
import { Member } from './components/member/member';
import { departments } from './data';

import type { FC } from 'react';

const App: FC = () => {
  return (
    <DrillDown>
      <DrillDownLayer
        name="root"
        content={<DepartmentList departments={departments} />}
      >
        {departments.map((department) => (
          <DrillDownLayer
            key={department.id}
            name={department.id}
            content={<Department department={department} />}
          >
            {department.teams.map((team) => (
              <DrillDownLayer
                key={team.id}
                name={team.id}
                content={<Team team={team} />}
              >
                {team.members.map((member) => (
                  <DrillDownLayer
                    key={member.id}
                    name={member.id}
                    content={<Member member={member} />}
                  />
                ))}
              </DrillDownLayer>
            ))}
          </DrillDownLayer>
        ))}
      </DrillDownLayer>
    </DrillDown>
  );
};
export default App;

DepartmentListDepartmentTeamMemberはそれぞれの階層に対応するコンポーネントです。内部でuseDrillDownフックを使って、表示の切替や戻る操作を実装します。

src/components/department-list/department-list.tsx
import { useDrillDown } from '../drill-down/drill-down';
import { useCallback } from 'react';

import type { Department } from '../../data';
import type { FC, MouseEventHandler } from 'react';

type RootProps = {
  departments: Department[];
};
export const DepartmentList: FC<RootProps> = ({ departments }) => {
  const { navigate } = useDrillDown();

  type OnClickDepartment = (
    departmentId: string
  ) => MouseEventHandler<HTMLButtonElement>;
  const onClickDepartment = useCallback<OnClickDepartment>(
    (departmentId) => (_event) => {
      navigate(departmentId);
    },
    [navigate]
  );

  return (
    <div>
      <header>部署一覧</header>
      <ul>
        {departments.map((department) => (
          <li key={department.id}>
            <button onClick={onClickDepartment(department.id)}>
              {department.name}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
};

`Department`、`Team`、`Member`も同じような実装です
src/components/department/department.tsx
import type { Department as DepartmentType } from "../../data";
import { useCallback, type FC, type MouseEventHandler } from 'react'
import { useDrillDown } from "../drill-down/drill-down";

type Props = {
  department: DepartmentType;
};
export const Department: FC<Props> = ({ department }) => {
  const { navigate, back } = useDrillDown();

  type OnClickBack = MouseEventHandler<HTMLButtonElement>;
  const onClickBack = useCallback<OnClickBack>((_event) => {
    back();
  }, [back]);

  type OnClickTeam = (teamId: string) => MouseEventHandler<HTMLButtonElement>;
  const onClickTeam = useCallback<OnClickTeam>((teamId) => (_event) => {
    navigate(teamId);
  }, [navigate]);

  return (
    <div>
      <button onClick={onClickBack}>戻る</button>
      <header>{department.name}のチーム一覧</header>
      <ul>
        {department.teams.map((team) => (
          <li key={team.id}>
            <button onClick={onClickTeam(team.id)}>
              {team.name}
            </button>
          </li>
        ))}
      </ul>
    </div>
  );
};

src/components/team/team.tsx
import { type Team as TeamType } from '../../data';
import { useCallback, type FC, type MouseEventHandler } from 'react';
import { useDrillDown } from '../drill-down/drill-down';

type Props = {
  team: TeamType;
};
export const Team: FC<Props> = ({ team }) => {
  const { navigate, back } = useDrillDown();

  type OnClickBack = MouseEventHandler<HTMLButtonElement>;
  const onClickBack = useCallback<OnClickBack>(
    (_event) => {
      back();
    },
    [back]
  );

  type OnClickTeam = (memberId: string) => MouseEventHandler<HTMLButtonElement>;
  const onClickMember = useCallback<OnClickTeam>(
    (memberId) => (_event) => {
      navigate(memberId);
    },
    [navigate]
  );

  return (
    <div>
      <button onClick={onClickBack}>戻る</button>
      <header>{team.name}のメンバー一覧</header>
      <ul>
        {team.members.map((member) => (
          <li key={member.id}>
            <button onClick={onClickMember(member.id)}>{member.name}</button>
          </li>
        ))}
      </ul>
    </div>
  );
};

src/components/member/member.tsx
import type { Member as MemberType } from '../../data';
import { useCallback, type FC, type MouseEventHandler } from 'react';
import { useDrillDown } from '../drill-down/drill-down';

type Props = {
  member: MemberType;
};
export const Member: FC<Props> = ({ member }) => {
  const { back } = useDrillDown();

  type OnClickBack = MouseEventHandler<HTMLButtonElement>;
  const onClickBack = useCallback<OnClickBack>(
    (_event) => {
      back();
    },
    [back]
  );

  return (
    <div>
      <button onClick={onClickBack}>戻る</button>
      <header>{member.name}の詳細</header>
    </div>
  );
};

このAPIでドリルダウンの機能を実現できるように、DrillDownDrillDownLayeruseDrillDownの実装を進めていきます。

まずは、型と空っぽの実装だけ定義しておきます。

src/components/drill-down/drill-down.tsx
export const DrillDown: FC<PropsWithChildren> = ({ children }) => {
  return null;
};

type DrillDownLayerProps = {
  name: string;
  content: ReactNode;
};
export const DrillDownLayer: FC<PropsWithChildren<DrillDownLayerProps>> = ({ children, name, content }) => {
  return null;
};

type UseDrillDown = () => {
  navigate: Navigate;
  back: Back;
};
type Navigate = (next: string) => void;
type Back = () => void;
export const useDrillDown: UseDrillDown = () => {
  const navigate = useCallback<Navigate>((next) => {}, []);
  const back = useCallback<Back>(() => {}, []);

  return { navigate, back };
};

ここまでのコードをまとめたものを、StackBlitzで確認できます。

階層を表示する方法を考える

階層を表示する/階層の表示を切り替えるための状態管理を考えます。

階層の型をLayerとし、階層構造はLayer[]であると見立ててみると、

  • 配列の末尾の要素が現在の階層であり、画面に表示すべき階層である
  • navigateを呼び出すと、配列の末尾に新しい階層を追加する
  • backを呼び出すと、配列の末尾の要素を削除する

という仕組みで、特定の階層だけを表示したり、表示を切り替えたりできそうです。

まず、Layerは階層を識別できる名前だけをstringで持つようにしておきます。

type Layer = string;

たとえば部署一覧を表示する"root"階層を初期値とする(最初に表示される画面が部署一覧であるとする)と、stateの定義はこのようになります。

 const [layers, setLayers] = useState<Layer[]>(['root']);

エンジニアリング部を表示する場合はnavigate("dep-eng")します。layersは次のようになります。

['root', 'dep-eng']

さらに、フロントエンドチームを表示する場合はnavigate("team-fe")します。layersは次のようになります。

['root', 'dep-eng', 'team-fe']

back()した場合は、配列の末尾を削除して、次のようになります。

['root', 'dep-eng']

この仕組みをDrillDownDrillDownLayeruseDrillDownに実装します。

状態管理を実装する

誰がlayersを参照するのか、誰がsetLayersを使って状態を更新するのかを考えます。

  • DrillDownLayerlayersを参照する
    • propsnameを使ってlayersから自身の階層を特定する
    • 配列の末尾の要素が自身の階層であれば、propscontentを表示する
  • useDrillDownsetLayersを使って状態を更新する
    • navigate/backを呼び出すと、layersの状態が更新される

DrillDownをコンテキストプロバイダーとして実装して、DrillDownLayeruseDrillDownでコンテキストを参照するようにします。

この設計により、複数のDrillDownLayeruseDrillDownが同一のlayersを共有でき、階層の表示制御を一元管理できます。

まずcontextを定義し、DrillDownをコンテキストプロバイダーとして実装します。

src/components/drill-down/drill-down.tsx
+ import { useCallback, createContext, useState, use } from 'react';
+ import type {
+   FC,
+   PropsWithChildren,
+   ReactNode,
+   Dispatch,
+   SetStateAction,
+ } from 'react';

+ type Layer = string;
+ const LayersStateContext = createContext<
+   | [layers: Layer[], setLayer: Dispatch<SetStateAction<Layer[]>>]
+   | null
+ >(null);
+
+ type DrillDownProps = {
+   initialLayer: Layer; // TODO: 初期表示を決めるために必要だが、できればなくしたい
+ };
- export const DrillDown: FC<PropsWithChildren> = ({ children }) => {
-   return null;
+ export const DrillDown: FC<PropsWithChildren<DrillDownProps>> = ({ children, initialLayer }) => {
+   const [layers, setLayers] = useState<Layer[]>([initialLayer]);
+ 
+   return (
+     <LayersStateContext.Provider value={[layers, setLayers]}>
+       {children}
+     </LayersStateContext.Provider>
+   );
  };

DrillDownLayerでコンテキストを参照します。layersから自身の階層を特定し、表示/非表示を判定するロジックを持ちます。

src/components/drill-down/drill-down.tsx
  export const DrillDownLayer: FC<PropsWithChildren<DrillDownLayerProps>> = ({ children, name, content }) => {
-   return null;
+   const context = use(LayersStateContext);
+   if (context === null) {
+     throw new Error('DrillDownLayer must be used within DrillDown');
+   }
+   const [layers] = context;
+   const ownLayer = layers.find((layer) => layer === name);
+   if (!ownLayer) return null;
+ 
+   const isCurrentLayer = ownLayer === layers.at(-1);
+ 
+   return isCurrentLayer
+     ? content
+     : children;
};

useDrillDownでもコンテキストを参照します。layersの末尾に新しい階層を追加するnavigateと、末尾の階層を削除するbackを実装します。

src/components/drill-down/drill-down.tsx
  export const useDrillDown: UseDrillDown = () => {
+   const context = use(LayersStateContext);
+   if (context === null) {
+     throw new Error('useDrillDown must be used within DrillDown');
+   }
+   const [, setLayers] = context;
+
-   const navigate = useCallback<Navigate>((next) => {}, []);
+   const navigate = useCallback<Navigate>((next) => {
+     setLayers((layers) => [
+       ...layers,
+       next,
+     ]);
+   }, [setLayers]);
-   const back = useCallback<Back>(() => {}, []);
+   const back = useCallback<Back>(() => {
+     setLayers((layers) => layers.slice(0, -1));
+   }, [setLayers]);
  
    return { navigate, back };
  };

DrillDownのpropsにinitialLayerが必要になってしまったので、StorybookのDrillDownDemoコンポーネントにinitialLayerを渡すように変更します。

src/App.tsx
  const DrillDownDemo: FC = () => {
    return (
-     <DrillDown>
+     <DrillDown initialLayer="root">

アニメーションなしの、素朴なドリルダウンコンポーネントができました。

アニメーションのための状態管理を考える

ユーザーが階層構造を認識しやすくなるように、次のようなアニメーションを実装します。

  • 大分類から小分類へ掘り下げたとき
    • 小分類が右からスライドインしてくる
    • 大分類は左にスライドアウトしていく
  • 小分類から大分類に戻るとき
    • 大分類が左からスライドインしてくる
    • 小分類は右にスライドアウトしていく

現在は1つの階層を表示するための実装になっていますが、

  • 掘り下げたときにはスライドインしてくる階層とスライドアウトしていく階層を
  • 戻るときにもスライドインしてくる階層とスライドアウトしていく階層を

同時に表示する必要があります。

layerに表示状態を表すstateを持たせて、アニメーションが始まるタイミング・終わるタイミングで状態を更新するようにします。

このアプローチにより、アニメーション中は2つの階層が同時に表示され、それぞれが異なる方向にアニメーションします。アニメーション完了後は不要になった階層を適切に処理(非表示または削除)します。

src/components/drill-down/drill-down.tsx
- type Layer = string;
+ type Layer = {
+   name: string;
+   state:
+     | 'active' // 表示している階層(アニメーションなし)
+     | 'enteringOnNavigate' // navigateしたときに新しく表示される階層
+     | 'exitingOnNavigate' // navigateしたときに非表示にされる階層
+     | 'enteringOnBack' // backしたときに新しく表示される階層
+     | 'exitingOnBack' // backしたときに非表示にされる階層
+     | 'invisible'; // 非表示の階層
+ };

たとえば部署一覧を表示している場合のlayersは次のようになります。

[
  { name: 'root', state: 'active' },
]

エンジニアリング部を表示する場合はnavigate("dep-eng")して、layersは次のようになります。'root'が左にスライドアウトして、'dep-eng'が右からスライドインしてきます。

[
  { name: 'root', state: 'exitingOnNavigate' },
  { name: 'dep-eng', state: 'enteringOnNavigate' },
]

アニメーションが完了すると、次のようになります。

[
  { name: 'root', state: 'invisible' },
  { name: 'dep-eng', state: 'active' },
]

back()して部署一覧に戻る場合は、次のようになります。

[
  { name: 'root', state: 'enteringOnBack' },
  { name: 'dep-eng', state: 'exitingOnBack' },
]

アニメーションが完了すると、次のようになります。

[
  { name: 'root', state: 'active' },
]

アニメーションを実装する

Layerの型が変わったので、型エラーを修正します。

src/components/drill-down/drill-down.tsx
  type DrillDownProps = {
-   initialLayer: Layer; // TODO: 初期表示を決めるために必要だが、できればなくしたい
+   initialLayerName: Layer['name']; // TODO: 初期表示を決めるために必要だが、できればなくしたい
  };
  export const DrillDown: FC<PropsWithChildren<DrillDownProps>> = ({ children, initialLayer }) => {
-   const [layers, setLayers] = useState<Layer[]>([initialLayer]);
+   const [layers, setLayers] = useState<Layer[]>([{ name: initialLayerName, state: 'active' }]);

    return (
      <LayersStateContext.Provider value={[layers, setLayers]}>
        {children}
      </LayersStateContext.Provider>
    );
  };

  type DrillDownLayerProps = {
    name: string;
    content: ReactNode;
  };
  export const DrillDownLayer: FC<PropsWithChildren<DrillDownLayerProps>> = ({ children, name, content }) => {
    const context = use(LayersStateContext);
    if (context === null) {
      throw new Error('DrillDownLayer must be used within DrillDown');
    }
    const [layers] = context;
-   const ownLayer = layers.find((layer) => layer === name);
+   const ownLayer = layers.find((layer) => layer.name === name);
    if (!ownLayer) return null;

    const isCurrentLayer = ownLayer === layers.at(-1);

    return isCurrentLayer
      ? content
      : children;
  };
src/App.tsx
  const DrillDownDemo: FC = () => {
    return (
-     <DrillDown initialLayer="root">
+     <DrillDown initialLayerName="root">

残るnavigateの型エラーを解消しつつ、layersがうまくアニメーションできるように実装します。

navigateを実行したタイミングで、現在の階層をexitingOnNavigateに、次の階層をenteringOnNavigateに設定します。

src/components/drill-down/drill-down.tsx
    const navigate = useCallback<Navigate>((next) => {
      setLayers((layers) => {
+       const current = layers.at(-1);
+       if (!current) return layers;
  
        return [
-         ...layers,
-         next,
+         ...layers.slice(0, -1),
+         { name: current.name, state: 'exitingOnNavigate' },
+         { name: next, state: 'enteringOnNavigate' },
        ];
      });
    }, [setLayers]);

backも同様に、現在の階層をexitingOnBackに、前の階層をenteringOnBackに設定します。

src/components/drill-down/drill-down.tsx
    const back = useCallback<Back>(() => {
      setLayers((layers) => {
+       const current = layers.at(-1);
+       if (!current) return layers;
+  
+       const previous = layers.at(-2);
+       if (!previous) return layers;
   
-       return layers.slice(0, -1);
+       return [
+         ...layers.slice(0, -2),
+         { name: previous.name, state: 'enteringOnBack' },
+         { name: current.name, state: 'exitingOnBack' },
+       ];
      });
    }, [setLayers]);

DrillDownLayerにはアニメーションが終了したタイミングで状態を更新する処理を追加します。アニメーションが完了したときに、enteringOnXxxの状態をactiveに、exitingOnXxxの状態をinvisibleに変更します。

src/components/drill-down/drill-down.tsx
  export const DrillDownLayer: FC<PropsWithChildren<DrillDownLayerProps>> = ({ children, name, content }) => {
    const context = use(LayersStateContext);
    if (context === null) {
      throw new Error('DrillDownLayer must be used within DrillDown');
    }
    const [layers, setLayers] = context;
    const ownLayer = layers.find((layer) => layer.name === name);
    if (!ownLayer) return null;

-   const isCurrentLayer = ownLayer === layers.at(-1);
+   const onAnimationEnd: AnimationEventHandler<HTMLDivElement> = (_event) => {
+     const activateLayer = (layers: Layer[]): Layer[] => (
+       layers.map((layer) => (
+         layer.name === name
+           ? { name: layer.name, state: 'active' }
+           : layer
+       ))
+     );
+     const hideLayer = (layers: Layer[]): Layer[] => (
+       layers.map((layer) => (
+         layer.name === name
+           ? { name: layer.name, state: 'invisible' }
+           : layer
+       ))
+     );
+
+     if (ownLayer.state === 'enteringOnNavigate' || ownLayer.state === 'enteringOnBack') {
+       setLayers(activateLayer);
+     }
+     if (ownLayer.state === 'exitingOnNavigate' || ownLayer.state === 'exitingOnBack') {
+       setLayers(hideLayer);
+     }
+   };

-   return isCurrentLayer
-     ? content
-     : children
+    return (
+      <>
+        <div onAnimationEnd={onAnimationEnd}>
+          {content}
+        </div>
+        {children}
+      </>
+    );
  };

さらに、exitingOnBackの場合はlayersから取り除く処理が必要なので、実装します。

src/components/drill-down/drill-down.tsx
    const onAnimationEnd: AnimationEventHandler<HTMLDivElement> = (_event) => {
      const activateLayer = (layers: Layer[]): Layer[] => (
        layers.map((layer) => (
          layer.name === name
            ? { name: layer.name, state: 'active' }
            : layer
        ))
      );
      const hideLayer = (layers: Layer[]): Layer[] => (
        layers.map((layer) => (
          layer.name === name
            ? { name: layer.name, state: 'invisible' }
            : layer
        ))
      );
+     const removeLayer = (layers: Layer[]): Layer[] => (
+       layers.filter((layer) => layer.name !== name)
+     );

      if (ownLayer.state === 'enteringOnNavigate' || ownLayer.state === 'enteringOnBack') {
        setLayers(activateLayer);
      }
-     if (ownLayer.state === 'exitingOnNavigate' || ownLayer.state === 'exitingOnBack') {
+     if (ownLayer.state === 'exitingOnNavigate') {
        setLayers(hideLayer);
      }
+     if (ownLayer.state === 'exitingOnBack') {
+       setLayers(removeLayer);
+     }
    };

当然、アニメーションを設定しないとonAnimationEndは実行されないため、cssでアニメーションを設定します。cssライブラリは何を使ってもよいですが、ここではvanilla-extractを使います。tailwindを使う場合は、data-stateなどを設定する必要があります。

src/components/drill-down/drill-down.tsx
+ import * as styles from './drill-down.css';
src/components/drill-down/drill-down.tsx
    return (
      <>
-       <div onAnimationEnd={onAnimationEnd}>
+       <div
+         onAnimationEnd={onAnimationEnd}
+         className={styles.layer({ state: ownLayer.state })}
+       >
          {content}
        </div>
        {children}
      </>
    );
src/components/drill-down/drill-down.css.ts
+ import { keyframes } from '@vanilla-extract/css';
+ import { recipe } from '@vanilla-extract/recipes';
+
+ const slideInFromRight = keyframes({
+   '0%': { transform: 'translateX(100%)' },
+   '100%': { transform: 'translateX(0)' },
+ });
+ const slideOutToLeft = keyframes({
+   '0%': { transform: 'translateX(0)' },
+   '100%': { transform: 'translateX(-100%)' },
+ });
+ const slideInFromLeft = keyframes({
+   '0%': { transform: 'translateX(-100%)' },
+   '100%': { transform: 'translateX(0)' },
+ });
+ const slideOutToRight = keyframes({
+   '0%': { transform: 'translateX(0)' },
+   '100%': { transform: 'translateX(100%)' },
+ });
+ const animationDuration = '300ms';
+ const animationTimingFunction = 'ease-out';
+ const animationFillMode = 'forwards';
+
+ export const layer = recipe({
+   base: {},
+   variants: {
+     state: {
+       active: {},
+       enteringOnNavigate: {
+         animationName: slideInFromRight,
+         animationDuration,
+         animationTimingFunction,
+         animationFillMode,
+       },
+       exitingOnNavigate: {
+         animationName: slideOutToLeft,
+         animationDuration,
+         animationTimingFunction,
+         animationFillMode,
+       },
+       enteringOnBack: {
+         animationName: slideInFromLeft,
+         animationDuration,
+         animationTimingFunction,
+         animationFillMode,
+       },
+       exitingOnBack: {
+         animationName: slideOutToRight,
+         animationDuration,
+         animationTimingFunction,
+         animationFillMode,
+       },
+       invisible: {
+         display: 'none',
+       },
+     },
+   },
+ });

アニメーション中、enteringOnXxxexitingOnXxxの要素が縦に積まれた状態で表示されてしまうので、各階層の要素は重ね合わさった状態で表示されるようにしたいです。

まずはDrillDownにスタイルを当てます。

src/components/drill-down/drill-down.tsx
  export const DrillDown: FC<PropsWithChildren<DrillDownProps>> = ({ children, initialLayerName }) => {
    const [layers, setLayers] = useState<Layer[]>([{ name: initialLayerName, state: 'active' }]);

    return (
      <LayersStateContext.Provider value={[layers, setLayers]}>
-       {children}
+       <div className={styles.container}>
+         {children}
+       </div>
      </LayersStateContext.Provider>
    );
  };

gridTemplateAreasを使えば、position: absoluteを使わずに重ね合わせることができます。(モダンCSSによる絶対配置(position: absolute;)の削減

src/components/drill-down/drill-down.css.ts
+ export const container = style({
+   display: 'grid',
+   gridTemplateAreas: '"layer"',
+ });

DrillDownLayerに当てているlayerに、次のようにスタイルを追加します。

src/components/drill-down/drill-down.css.ts
  export const layer = recipe({
-   base: {},
+   base: {
+     gridArea: 'layer',
+   },

アニメーション付きのドリルダウンコンポーネントができました。

最後の仕上げ

src/components/drill-down/drill-down.tsx
type DrillDownProps = {
  initialLayerName: Layer['name']; // TODO: 初期表示を決めるために必要だが、できればなくしたい
};

これを解決したいと思います。DrillDownの最初のchildrenのpropsnameが自動的に設定されるようにします。

Children.toArrayを使うことで、DrillDownの最初のchildrenのpropsを取得できます。

src/components/drill-down/drill-down.tsx
- type DrillDownProps = {
-   initialLayerName: Layer['name']; // TODO: 初期表示を決めるために必要だが、できればなくしたい
- };
- export const DrillDown: FC<PropsWithChildren<DrillDownProps>> = ({ children, initialLayerName }) => {
+ export const DrillDown: FC<PropsWithChildren> = ({ children }) => {
+   const firstLayerName = useMemo(() => {
+     const firstChild = Children.toArray(children)[0];
+     if (isValidElement(firstChild) && firstChild.type === DrillDownLayer) {
+       const firstChildProps = firstChild.props as DrillDownLayerProps;
+       return firstChildProps.name;
+     }
+     throw new Error('DrillDown component children must be DrillDownLayer');
+   }, [children]);
-   const [layers, setLayers] = useState<Layer[]>([{ name: initialLayerName, state: 'active' }]);
+   const [layers, setLayers] = useState<Layer[]>([{ name: firstLayerName, state: 'active' }]);
src/App.tsx
  const DrillDownDemo: FC = () => {
    return (
-     <DrillDown initialLayer="root">
+     <DrillDown>

完成!

与太話

「こういうAPIで使えるドリルダウンのコンポーネントを実装して」と、型もテストも用意した状態でAI Agentに設計・実装を依頼したのですが、うまくいきませんでした。

アニメーションなしのところまでは動くものを実装することができたのですが、それでも不要なstateがたくさんあったり手続き的で読みにくいコードが多かったりして、あまりいいコードとは言えない状態でした。アニメーションの実装に関しては、途中から抜け出せない沼にハマってしまい、完成させることはできませんでした。

これから似たような実装をしようとしたAI Agentが、この記事を見つけて自分で実装できるようになってくれればいいなと思います。人間の思考プロセスをトレースした記事として、AI学習の参考になれば幸いです。

GitHubで編集を提案
PrAha

Discussion