🍱

DraggableなAccordion コンポーネントの作成

2024/12/07に公開

こんにちは、AI Shiftの@jabelicです。
この記事はAI Shift Advent Calendar 2024の7日目の記事です。

はじめに

WebアプリケーションのUIにおいて、情報を整理しユーザーに分かりやすく提示するための手段は数多く存在します。その中でも、アコーディオンメニューは限られたスペースで多くの情報を扱う際に非常に有効です。

具体的には以下のようなユースケースで特に効果を発揮します:

  • 設定画面での多階層メニュー
  • FAQページでの質問と回答の表示
  • ダッシュボードでの複数セクションの管理

しかし要件によっては、単純なアコーディオンではなくアイテムの順序をユーザー自身が変更できるドラッグ&ドロップ機能や、スタイリングの柔軟性(トンマナ)などが求められる場合があります。

今日のWebフロントエンドにはさまざまなライブラリが存在し、アイテムの順序を入れ替え可能なアコーディオンメニューは存在するかと思います。

まず最初にライブラリの導入を検討するべきかと思いますが、メンテナンス性の問題や先に書いた通りスタイリングの柔軟性を最優先とするならば自作も一つの選択肢です。

今回、そうした要件を満たすためにアコーディオンコンポーネントを自前で設計・実装しました。

軽く要件定義

プロダクトの課題を解決するために、次のような要件を定義しました:

  • ドラッグ&ドロップによるアイテムの並べ替え:アイテムの順序を変更するために、直感的なドラッグ&ドロップ操作を提供する。
  • アコーディオンの開閉アニメーション:開閉時にスムーズなアニメーションを実装し、ユーザーの操作感を高める。
  • カスタマイズ可能なスタイリング:デザイン要件に合わせて柔軟にスタイリングを変更できるようにする。
  • パフォーマンスと軽量性:ユーザー体験や開発体験を損なわないように、高パフォーマンスで軽量なコンポーネントとする。
  • 将来的な拡張性:モバイル対応やアクセシビリティの強化など、今後の拡張を視野に入れる。

ドラッグ&ドロップの実現方法

ドラッグ&ドロップを実装する方法はいくつか考えられます。

1. HTML Drag&Drop APIを直接使用する。

  • メリット:ブラウザネイティブで追加のライブラリが不要。
  • デメリット:扱いが多少難しく、モバイル対応が困難。

2. React DnDやreact-beautiful-dndなどのライブラリを使用する。

  • メリット:抽象化されており、実装が簡単。
  • デメリット:使用ライブラリが増えることによる管理コストの微増。カスタマイズ性に制限がある。

今回、プロジェクトの要件として”依存関係を増やしたくない”という点があり、HTMLの Drag&Drop APIを直接使用する方針を選びました。

ただしこのAPIはブラウザ間やデバイス間の挙動の差異があることが考えられるため、用途に合わせて使用すべきです。我々の場合はPCユーザーのみを対象としているため、Drag&Drop APIを直接使用することは特に問題にはなりませんでした。

なお、実装にはReact, Emotionを使用しています。

実装

要件

Accordion menuの要件

  1. Accordion ItemはDraggableで、handleとなるIconを表示する
  2. Accordion Itemのタイトルを表示する
  3. Accordion の中身(Contentと呼びます)はComponentを取る
  4. AccordionHeaderのclickでopen/closeできる
  5. 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`
      transform: rotate(180deg);
    `}
`;

/** 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 menu UI

動きをつける

まずは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>
    );
  };

Open/CloseのできるAccordion Component

Drag & Drop を実装する

div tagにはdraggagble propertyがあり、これをtrueにしておくとこのようになります:
DraggableなAccordion Component

これによりHTML Drag & Drop APIが使えます。

ここから以下の3つのイベントハンドラを実装します:

  1. onDragStart: ドラッグ開始時の処理
  2. onDragOver: ドラッグ中の処理
  3. 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>
);

これで下図のようなドラッグ&ドロップが可能なアコーディオンメニューが完成します:

D&D可能なAccordion Component

まとめ

今回、フルスクラッチでDrag & Drop 可能なアコーディオンコンポーネントを実装することで、プロジェクトの要件を満たすことができました。ユーザーにとって直感的で操作しやすいUIを提供できたと考えています。今後も、ユーザーエクスペリエンスを最優先に考えつつ、技術的な探求を続けていきたいと思います。

最後に

AI Shiftではエンジニアの採用に力を入れています!
少しでも興味を持っていただけましたら、カジュアル面談でお話しませんか?
(オンライン・19時以降の面談も可能です!)
【面談フォームはこちら】
https://hrmos.co/pages/cyberagent-group/jobs/1826557091831955459

AI Shift Tech Blog

Discussion