Reactで作るアニメ付きドリルダウンUI ─ 状態設計から実装まで
業務でドリルダウン(UIパターン)を実現するコンポーネントを実装する機会がありました。状態管理やアニメーションの実装に関していろいろ考えることが多かったので、実装中何を考えていたのかをトレースして記事にまとめました。
ドリルダウンに関する説明はソシオメディアさんの記事がわかりやすいので、そちらを参照してください。
実装したいコンポーネントのゴールを考える
ドリルダウンを実装するにあたり、どのようなデータをどのようなAPIのコンポーネントで扱いたいかを考えます。
扱うデータは、大分類>中分類>小分類のように階層的になっているデータを想定します。たとえば次のような部署>チーム>メンバーが階層構造になっているデータです。
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が望ましいです。
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;
DepartmentList
、Department
、Team
、Member
はそれぞれの階層に対応するコンポーネントです。内部でuseDrillDown
フックを使って、表示の切替や戻る操作を実装します。
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`も同じような実装です
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>
);
};
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>
);
};
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でドリルダウンの機能を実現できるように、DrillDown
、DrillDownLayer
、useDrillDown
の実装を進めていきます。
まずは、型と空っぽの実装だけ定義しておきます。
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']
この仕組みをDrillDown
、DrillDownLayer
、useDrillDown
に実装します。
状態管理を実装する
誰がlayers
を参照するのか、誰がsetLayers
を使って状態を更新するのかを考えます。
-
DrillDownLayer
はlayers
を参照する- props
name
を使ってlayers
から自身の階層を特定する - 配列の末尾の要素が自身の階層であれば、props
content
を表示する
- props
-
useDrillDown
はsetLayers
を使って状態を更新する-
navigate
/back
を呼び出すと、layers
の状態が更新される
-
DrillDown
をコンテキストプロバイダーとして実装して、DrillDownLayer
とuseDrillDown
でコンテキストを参照するようにします。
この設計により、複数のDrillDownLayer
とuseDrillDown
が同一のlayers
を共有でき、階層の表示制御を一元管理できます。
まずcontextを定義し、DrillDown
をコンテキストプロバイダーとして実装します。
+ 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
から自身の階層を特定し、表示/非表示を判定するロジックを持ちます。
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
を実装します。
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
を渡すように変更します。
const DrillDownDemo: FC = () => {
return (
- <DrillDown>
+ <DrillDown initialLayer="root">
アニメーションなしの、素朴なドリルダウンコンポーネントができました。
アニメーションのための状態管理を考える
ユーザーが階層構造を認識しやすくなるように、次のようなアニメーションを実装します。
- 大分類から小分類へ掘り下げたとき
- 小分類が右からスライドインしてくる
- 大分類は左にスライドアウトしていく
- 小分類から大分類に戻るとき
- 大分類が左からスライドインしてくる
- 小分類は右にスライドアウトしていく
現在は1つの階層を表示するための実装になっていますが、
- 掘り下げたときにはスライドインしてくる階層とスライドアウトしていく階層を
- 戻るときにもスライドインしてくる階層とスライドアウトしていく階層を
同時に表示する必要があります。
各layer
に表示状態を表すstate
を持たせて、アニメーションが始まるタイミング・終わるタイミングで状態を更新するようにします。
このアプローチにより、アニメーション中は2つの階層が同時に表示され、それぞれが異なる方向にアニメーションします。アニメーション完了後は不要になった階層を適切に処理(非表示または削除)します。
- 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
の型が変わったので、型エラーを修正します。
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;
};
const DrillDownDemo: FC = () => {
return (
- <DrillDown initialLayer="root">
+ <DrillDown initialLayerName="root">
残るnavigate
の型エラーを解消しつつ、layers
がうまくアニメーションできるように実装します。
navigate
を実行したタイミングで、現在の階層をexitingOnNavigate
に、次の階層をenteringOnNavigate
に設定します。
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
に設定します。
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
に変更します。
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
から取り除く処理が必要なので、実装します。
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
などを設定する必要があります。
+ import * as styles from './drill-down.css';
return (
<>
- <div onAnimationEnd={onAnimationEnd}>
+ <div
+ onAnimationEnd={onAnimationEnd}
+ className={styles.layer({ state: ownLayer.state })}
+ >
{content}
</div>
{children}
</>
);
+ 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',
+ },
+ },
+ },
+ });
アニメーション中、enteringOnXxx
とexitingOnXxx
の要素が縦に積まれた状態で表示されてしまうので、各階層の要素は重ね合わさった状態で表示されるようにしたいです。
まずはDrillDown
にスタイルを当てます。
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;)の削減)
+ export const container = style({
+ display: 'grid',
+ gridTemplateAreas: '"layer"',
+ });
DrillDownLayer
に当てているlayer
に、次のようにスタイルを追加します。
export const layer = recipe({
- base: {},
+ base: {
+ gridArea: 'layer',
+ },
アニメーション付きのドリルダウンコンポーネントができました。
最後の仕上げ
type DrillDownProps = {
initialLayerName: Layer['name']; // TODO: 初期表示を決めるために必要だが、できればなくしたい
};
これを解決したいと思います。DrillDown
の最初のchildrenのpropsname
が自動的に設定されるようにします。
Children.toArray
を使うことで、DrillDown
の最初のchildrenのpropsを取得できます。
- 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' }]);
const DrillDownDemo: FC = () => {
return (
- <DrillDown initialLayer="root">
+ <DrillDown>
完成!
与太話
「こういうAPIで使えるドリルダウンのコンポーネントを実装して」と、型もテストも用意した状態でAI Agentに設計・実装を依頼したのですが、うまくいきませんでした。
アニメーションなしのところまでは動くものを実装することができたのですが、それでも不要なstateがたくさんあったり手続き的で読みにくいコードが多かったりして、あまりいいコードとは言えない状態でした。アニメーションの実装に関しては、途中から抜け出せない沼にハマってしまい、完成させることはできませんでした。
これから似たような実装をしようとしたAI Agentが、この記事を見つけて自分で実装できるようになってくれればいいなと思います。人間の思考プロセスをトレースした記事として、AI学習の参考になれば幸いです。
Discussion