DraggableなAccordion コンポーネントの作成
こんにちは、AI Shiftの@jabelicです。
この記事はAI Shift Advent Calendar 2024の7日目の記事です。
はじめに
WebアプリケーションのUIにおいて、情報を整理しユーザーに分かりやすく提示するための手段は数多く存在します。その中でも、アコーディオンメニューは限られたスペースで多くの情報を扱う際に非常に有効です。
具体的には以下のようなユースケースで特に効果を発揮します:
- 設定画面での多階層メニュー
- FAQページでの質問と回答の表示
- ダッシュボードでの複数セクションの管理
しかし要件によっては、単純なアコーディオンではなくアイテムの順序をユーザー自身が変更できるドラッグ&ドロップ機能や、スタイリングの柔軟性(トンマナ)などが求められる場合があります。
今日のWebフロントエンドにはさまざまなライブラリが存在し、アイテムの順序を入れ替え可能なアコーディオンメニューは存在するかと思います。
まず最初にライブラリの導入を検討するべきかと思いますが、メンテナンス性の問題や先に書いた通りスタイリングの柔軟性を最優先とするならば自作も一つの選択肢です。
今回、そうした要件を満たすためにアコーディオンコンポーネントを自前で設計・実装しました。
軽く要件定義
プロダクトの課題を解決するために、次のような要件を定義しました:
- ドラッグ&ドロップによるアイテムの並べ替え:アイテムの順序を変更するために、直感的なドラッグ&ドロップ操作を提供する。
- アコーディオンの開閉アニメーション:開閉時にスムーズなアニメーションを実装し、ユーザーの操作感を高める。
- カスタマイズ可能なスタイリング:デザイン要件に合わせて柔軟にスタイリングを変更できるようにする。
- パフォーマンスと軽量性:ユーザー体験や開発体験を損なわないように、高パフォーマンスで軽量なコンポーネントとする。
- 将来的な拡張性:モバイル対応やアクセシビリティの強化など、今後の拡張を視野に入れる。
ドラッグ&ドロップの実現方法
ドラッグ&ドロップを実装する方法はいくつか考えられます。
HTML Drag&Drop APIを直接使用する。
1.- メリット:ブラウザネイティブで追加のライブラリが不要。
- デメリット:扱いが多少難しく、モバイル対応が困難。
2. React DnDやreact-beautiful-dndなどのライブラリを使用する。
- メリット:抽象化されており、実装が簡単。
- デメリット:使用ライブラリが増えることによる管理コストの微増。カスタマイズ性に制限がある。
今回、プロジェクトの要件として”依存関係を増やしたくない”という点があり、HTMLの Drag&Drop APIを直接使用する方針を選びました。
ただしこのAPIはブラウザ間やデバイス間の挙動の差異があることが考えられるため、用途に合わせて使用すべきです。我々の場合はPCユーザーのみを対象としているため、Drag&Drop APIを直接使用することは特に問題にはなりませんでした。
なお、実装にはReact, Emotionを使用しています。
実装
要件
- Accordion ItemはDraggableで、handleとなるIconを表示する
- Accordion Itemのタイトルを表示する
- Accordion の中身(Contentと呼びます)はComponentを取る
- AccordionHeaderのclickでopen/closeできる
- Orderを表示
これを満たすものを実装していきます。
インターフェース
export interface AccordionItem {
id: string;
title: string;
key: string;
content: () => ReactNode;
}
export interface AccordionProps {
items: AccordionItem[];
showOrder?: boolean;
onDragEnd?: (items: { id: string; order: number }[]) => void;
}
AccordionItemは一つ一つのアイテムのことを指します。AccordionItemにはid, keyの他にtitleとcontentを取ります。titleはstylingの都合によりstringで妥協しますが、contentはstyleに自由度があって欲しいのでcomponentを取ります。
AccordionPropsはこのアコーディオンメニュー全体のPropsとします。AccordionItem[]
なitemsの他、order表示の有無を表すshowOrder
とDrag & Drop後のeventHandlerへのcallback関数onDragEnd
を取ります。
表示
/* アコーディオンの各アイテム全体を包むコンテナ */
const AccordionItem = styled.div<{ isOpen: boolean }>`
background-color: #fff;
border-radius: 8px;
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
transition: all 0.3s ease;
position: relative; // これを追加
&:hover {
box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1);
}
`;
/** アコーディオンアイテムのヘッダー部分で、クリックすると内容の表示・非表示を切り替える */
const AccordionHeader = styled.div<{ isOpen: boolean }>`
background-color: ${(props) => (props.isOpen ? "#f0f0f0" : "#fff")};
padding: 15px;
display: flex;
align-items: center;
cursor: pointer;
transition: background-color 0.3s ease;
&:hover {
background-color: #f5f5f5;
}
`;
/** アコーディオンのコンテンツ部分で、開閉に応じて表示・非表示を切り替える */
const AccordionContent = styled.div<{ isOpen: boolean }>`
max-height: ${(props) => (props.isOpen ? "1000px" : "0")};
opacity: ${(props) => (props.isOpen ? "1" : "0")};
pointer-events: ${(props) => (props.isOpen ? "auto" : "none")};
transition: max-height 0.3s ease-in-out, opacity 0.3s ease-in-out,
padding 0.3s ease-in-out;
padding: ${(props) => (props.isOpen ? "15px" : "0 15px")};
overflow: visible;
`;
const DragHandle = styled.div`
cursor: move;
padding: 5px;
margin-right: 15px;
color: #888;
transition: color 0.3s ease;
&::before {
content: "≡";
font-size: 24px;
}
&:hover {
color: #333;
}
`;
/** isOpen が true の場合、アイコンを180度回転させ、開いていることを視覚的に示す */
const ToggleIcon = styled.span<{ isOpen: boolean }>`
transition: transform 0.3s ease;
${(props) =>
props.isOpen &&
css``}
`;
/** drag中にItemがどの位置に挿入されるか表すインジケーター */
const DropIndicator = styled.div`
height: 3px;
background-color: #3498db;
border-radius: 3px;
margin: 5px 0;
`;
const OrderNumber = styled.span`
display: flex;
justify-content: center;
align-items: center;
font-weight: bold;
color: #666;
margin-bottom: 10px;
`;
これらを組み合わせるとこうなります。
export const Accordion: React.FC<AccordionProps> = ({
items,
showOrder,
onDragEnd,
}) => {
return (
<div>
<DropIndicator />
{items.map((item, index) => (
<div key={`${item.key}-${index}`}>
<div
style={{
display: "grid",
gridTemplateColumns: showOrder ? "1fr 15fr" : "1fr",
marginBottom: "10px",
}}
>
{showOrder && <OrderNumber>{index + 1}.</OrderNumber>}
<AccordionItem isOpen={true} style={{ flex: 1 }}>
<AccordionHeader isOpen={true} onClick={() => {}}>
<DragHandle />
<div
style={{
flexGrow: "1",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div
style={{
fontWeight: "bold",
color: "#333",
}}
>
{item.title}
</div>
</div>
<ToggleIcon isOpen={true}>▼</ToggleIcon>
</AccordionHeader>
<AccordionContent isOpen={true}>
{item.content()}
</AccordionContent>
</AccordionItem>
</div>
</div>
))}
</div>
);
};
動きをつける
まずはAccordion Itemの開閉をさせます。
export const Accordion: React.FC<AccordionProps> = ({
items,
showOrder,
onDragEnd,
}) => {
+ const [openItems, setOpenItems] = useState<string[]>([]);
+
+ const toggleItem = (id: string) => {
+ setOpenItems((prevOpenItems) =>
+ prevOpenItems.includes(id)
+ ? prevOpenItems.filter((item) => item !== id)
+ : [...prevOpenItems, id]
+ );
+ };
return (
<div>
<DropIndicator />
{items.map((item, index) => (
<div key={`${item.key}-${index}`}>
<div
style={{
display: "grid",
gridTemplateColumns: showOrder ? "1fr 15fr" : "1fr",
marginBottom: "10px",
}}
>
{showOrder && <OrderNumber>{index + 1}.</OrderNumber>}
<AccordionItem isOpen={true} style={{ flex: 1 }}>
- <AccordionHeader isOpen={true} onClick={() => {}}>
+ <AccordionHeader
+ isOpen={openItems.includes(item.id)}
+ onClick={() => toggleItem(item.id)}
+ >
<DragHandle />
<div
style={{
flexGrow: "1",
display: "flex",
justifyContent: "space-between",
alignItems: "center",
}}
>
<div
style={{
fontWeight: "bold",
color: "#333",
}}
>
{item.title}
</div>
</div>
- <ToggleIcon isOpen={true}>▼</ToggleIcon>
+ <ToggleIcon isOpen={openItems.includes(item.id)}>▼</ToggleIcon>
</AccordionHeader>
- <AccordionContent isOpen={true}>
+ <AccordionContent isOpen={openItems.includes(item.id)}>
{item.content()}
</AccordionContent>
</AccordionItem>
</div>
</div>
))}
</div>
);
};
Drag & Drop を実装する
div tagにはdraggagble propertyがあり、これをtrueにしておくとこのようになります:
これによりHTML Drag & Drop APIが使えます。
ここから以下の3つのイベントハンドラを実装します:
-
onDragStart
: ドラッグ開始時の処理 -
onDragOver
: ドラッグ中の処理 -
onDragEnd
: ドラッグ終了時の処理
まず、必要な state と ref を追加します:
export const Accordion2: React.FC<AccordionProps> = ({
items,
showOrder,
onDragEnd,
}) => {
const [openItems, setOpenItems] = useState<string[]>([]);
+ const [dropIndex, setDropIndex] = useState<number | null>(null);
+ const dragItem = useRef<number | null>(null);
+ const wrapperRef = useRef<HTMLDivElement>(null);
1. ドラッグ開始時の処理
const handleDragStart = (e: React.DragEvent<HTMLDivElement>, index: number) => {
dragItem.current = index; // ドラッグ開始位置を保存
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/html", e.currentTarget.outerHTML);
e.currentTarget.style.opacity = "0.6"; // ドラッグ中のアイテムを半透明に
};
2. ドラッグ中の処理
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
const wrapper = wrapperRef.current;
if (!wrapper) return;
// マウスポインタの位置を計算
const rect = wrapper.getBoundingClientRect();
const y = e.clientY - rect.top;
const items = wrapper.querySelectorAll(".accordion-item");
let index = items.length;
// ドロップ位置を決定
for (let i = 0; i < items.length; i++) {
const itemRect = items[i].getBoundingClientRect();
const itemMiddle = itemRect.top + itemRect.height / 2 - rect.top;
if (y < itemMiddle) {
index = i;
break;
}
}
setDropIndex(index); // ドロップインジケータの位置を更新
};
3. ドラッグ終了時の処理
const handleDragEnd = (e: React.DragEvent<HTMLDivElement>) => {
e.currentTarget.style.opacity = "1"; // 透明度を元に戻す
if (dragItem.current !== null && dropIndex !== null) {
const [draggedItem] = items.splice(dragItem.current, 1);
items.splice(
dropIndex > dragItem.current ? dropIndex - 1 : dropIndex,
0,
draggedItem
);
// 並び替え後の順序を親コンポーネントに通知
onDragEnd?.(items.map((item, index) => ({ id: item.id, order: index })));
}
// ステートをリセット
dragItem.current = null;
setDropIndex(null);
};
これらのイベントハンドラをコンポーネントに追加します:
return (
- <div>
- <DropIndicator />
+ <div ref={wrapperRef} onDragOver={handleDragOver}>
+ {dropIndex === 0 && <DropIndicator />}
{items.map((item, index) => (
<div key={`${item.key}-${index}`}>
<div
style={{
display: "grid",
gridTemplateColumns: showOrder ? "1fr 15fr" : "1fr",
marginBottom: "10px",
}}
>
{showOrder && <OrderNumber>{index + 1}.</OrderNumber>}
<AccordionItem
+ className="accordion-item"
+ draggable
+ onDragStart={(e) => handleDragStart(e, index)}
+ onDragEnd={handleDragEnd}
isOpen={openItems.includes(item.id)}
style={{ flex: 1 }}
>
<AccordionHeader
isOpen={openItems.includes(item.id)}
onClick={() => toggleItem(item.id)}
>
<DragHandle />
{...}
</AccordionHeader>
<AccordionContent isOpen={openItems.includes(item.id)}>
{item.content()}
</AccordionContent>
</AccordionItem>
</div>
+ {dropIndex === index + 1 && <DropIndicator />}
</div>
))}
</div>
);
これで下図のようなドラッグ&ドロップが可能なアコーディオンメニューが完成します:
まとめ
今回、フルスクラッチでDrag & Drop 可能なアコーディオンコンポーネントを実装することで、プロジェクトの要件を満たすことができました。ユーザーにとって直感的で操作しやすいUIを提供できたと考えています。今後も、ユーザーエクスペリエンスを最優先に考えつつ、技術的な探求を続けていきたいと思います。
最後に
AI Shiftではエンジニアの採用に力を入れています!
少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか?
(オンライン・19時以降の面談も可能です!)
【面談フォームはこちら】
Discussion