💠

MUIで入力欄を被せないように複数選択をチップで表示するセレクトボックスを実装する

2025/03/29に公開

始めに

MUIのSelectコンポーネントで複数選択でチップを表示させたい場合、公式でサンプルコードが載っています。

https://mui.com/material-ui/react-select/#chip

これを試してみると表示する位置によって入力欄の上に表示されてしまうことがあり、選択した内容が確認しづらくなることが気になりました。

この挙動は画面外に出ないように自動で表示位置を調整した結果ではありますが、オートコンプリートのように入力欄を避けて表示して欲しいと思って色々調べていましたが簡単に調整することは出来なさそうで、改めてMUIのコンポーネントを組み合わせて実装するしかなさそうでした。
そもそも他にもプレースホルダーをテキストのみで表示させる方法がなかったり、endAdornmentの位置が少し変だったり気になっていたので、その辺も含めて一から作り直して概ね形にはなったのでその方法についてまとめました。

作ったもの

今回作ったものはgifアニメのようなものになります。基本は下にドロップダウンするようになって、もしはみ出そうになったら上に表示するようになっています。

他にも低レベルのコンポーネントを使ったことで細かい調整もできるようになってそれらについても後述しますが、先に動作を確認したい方はこちらからご参照ください。

MUIで入力欄を被せないように複数選択をチップで表示するセレクトボックスの実装

入力欄の実装

まずは入力欄を作ります。見た目は OutlinedInputSelect コンポーネントと一緒なのでそれをベースに作ります。あくまで見た目だけあれば良く入力できる必要はないので、 value は空文字で readOnly を設定しています。

入力欄の実装
import {
  FormControl,
  OutlinedInput,
  InputLabel,
} from '@mui/material';

/** 選択肢として許容できる型 */
export type AvailableSelectOption = {
  /** 値 */
  value: any;
  /** ラベル */
  label: string;
};

export type InputMultipleSelectProps<Option extends AvailableSelectOption> = {
  /** 選択中の値 */
  values: Array<Option['value']>;
  /** 選択肢リスト */
  options: Option[];
  /** ラベル */
  label?: string;
  /** プレースホルダー */
  placeholder?: string;
  /**
   * 値変更時
   * @param newValues - 新しい選択値
   */
  onChangeValues: (newValues: Array<Option['value']>) => void;
};

export const InputMultipleSelect = function <
  Option extends AvailableSelectOption
>({
  values,
  options,
  label,
  placeholder,
  onChangeValues,
}: InputMultipleSelectProps<Option>) {
  return (
    <>
      <FormControl
        variant="outlined"
        fullWidth
      >
        {label && <InputLabel>{label}</InputLabel>}
        <OutlinedInput
          sx={{
            cursor: 'pointer',
            '& .MuiInputBase-input': {
              cursor: 'pointer',
            },
          }}
          label={label}
          placeholder={placeholder}
          value=""
          readOnly
        />
      </FormControl>
    </>
  );
};

入力欄をクリックしたらドロップダウンを表示

次にこの入力欄をクリックしたらドロップダウン表示されるようにします。入力欄を避けて表示する場合は Popper コンポーネントを使うと自動でやってくれますが、代わりにアニメーションやドロップダウン外をクリックしたら閉じるなどの機能がないため、自前で設定する必要があります。
FormControlfocusedを設定しているのは選択肢をクリックすると入力欄からフォーカスが外れてしまいますが、UI上のハイライトは残しておきたいため設定しています。

入力欄をクリックしたらドロップダウン表示する
+import { useState, useRef } from 'react'
 import {
   FormControl,
   OutlinedInput,
   InputLabel,
   Popper,
+  Fade,
+  Paper,
+  ClickAwayListener,
 } from '@mui/material';

 export const InputMultipleSelect = function <
   Option extends AvailableSelectOption
 >({
   values,
   options,
   label,
   placeholder,
   onChangeValues,
 }: InputMultipleSelectProps<Option>) {
+  const elMenuAnchorRef = useRef<HTMLDivElement | null>(null);
+  const [isMenuOpen, setIsMenuOpen] = useState(false);

   return (
     <>
       <FormControl
         variant="outlined"
         fullWidth
+        focused={isMenuOpen ? true : undefined}
       >
         {label && <InputLabel>{label}</InputLabel>}
         <OutlinedInput
+          ref={elMenuAnchorRef}
           sx={{
             cursor: 'pointer',
             '& .MuiInputBase-input': {
               cursor: 'pointer',
             },
           }}
           label={label}
           placeholder={placeholder}
           value=""
           readOnly
+          onClick={() => {
+            setIsMenuOpen(true);
+          }}
         />
       </FormControl>
+      <Popper
+        sx={(theme) => ({ zIndex: theme.zIndex.modal + 1 })}
+        anchorEl={elMenuAnchorRef.current}
+        open={isMenuOpen}
+        placement="bottom"
+        transition
+      >
+        {({ TransitionProps }) => (
+          <Fade {...TransitionProps}>
+            <Paper
+              style={{
+                width: elMenuAnchorRef.current?.clientWidth,
+              }}
+            >
+              <ClickAwayListener
+                onClickAway={() => {
+                  setIsMenuOpen(false);
+                }}
+              >
+                <div>選択肢を表示</div>
+              </ClickAwayListener>
+            </Paper>
+          </Fade>
+        )}
+      </Popper>
     </>
   );
 };

ドロップダウンの中に選択肢を表示

ドロップダウンが表示できたので選択肢の方を表示します。後でバーチャルスクロールに切り替えられるようにコンポーネントとして作ります。refを用意しているのはClickAwayListenerで参照するため用意しています。

選択肢リストコンポーネント
import { Box, MenuList, MenuItem, ListItemText, Checkbox } from '@mui/material';

export type RealOptionListProps<Option extends AvailableSelectOption> = {
  /** rootのref */
  ref?: Ref<HTMLDivElement>;
  /** 選択中の値リスト */
  values: Array<Option['value']>;
  /** 選択肢リスト */
  options: Option[];
  /**
   * 選択肢をクリックしたとき
   * @param option - 選択肢
   * @param isSelected - 選択されているか
   */
  onClickOption: (option: Option, isSelected: boolean) => void;
};

export const RealOptionList = function <Option extends AvailableSelectOption>({
  ref,
  values,
  options,
  onClickOption,
}: RealOptionListProps<Option>) {
  return (
    <Box
      ref={ref}
      sx={{
        maxHeight: 'min(40vh, 250px)',
        overflowY: 'auto',
      }}
    >
      <MenuList>
        {options.map((option) => {
          const isSelected = values.includes(option.value);
          return (
            <MenuItem
              key={option.value}
              value={option.value}
              selected={isSelected}
              autoFocus={isSelected}
              onClick={() => {
                onClickOption(option, isSelected);
              }}
            >
              <Checkbox checked={isSelected} />
              <ListItemText primary={option.label} />
            </MenuItem>
          );
        })}
      </MenuList>
    </Box>
  );
};

これを先ほど作ったドロップダウンの中に入れて選択できるようにします。

ドロップダウンの中に選択肢リストコンポーネントを配置
 // import内容は省略

 export const InputMultipleSelect = function <
   Option extends AvailableSelectOption
 >({
   values,
   options,
   label,
   placeholder,
   onChangeValues,
 }: InputMultipleSelectProps<Option>) {
   const elMenuAnchorRef = useRef<HTMLDivElement | null>(null);
   const [isMenuOpen, setIsMenuOpen] = useState(false);

   return (
     <>
       {/* FormControl内は差分がないので省略 */}
       <Popper
         sx={(theme) => ({ zIndex: theme.zIndex.modal + 1 })}
         anchorEl={elMenuAnchorRef.current}
         open={isMenuOpen}
         placement="bottom"
         transition
       >
         {({ TransitionProps }) => (
           <Fade {...TransitionProps}>
             <Paper
               style={{
                 width: elMenuAnchorRef.current?.clientWidth,
               }}
             >
               <ClickAwayListener
                 onClickAway={() => {
                   setIsMenuOpen(false);
                 }}
               >
-                <div>選択肢を表示</div>
+                <RealOptionList
+                  values={values}
+                  options={options}
+                  onClickOption={(option, isSelected) => {
+                    const newValues = isSelected
+                      ? values.filter((val) => val !== option.value)
+                      : [...values, option.value];
+                    onChangeValues(newValues);
+                  }}
+                />
               </ClickAwayListener>
             </Paper>
           </Fade>
         )}
       </Popper>
     </>
   );
 };

選択されたものを入力欄にチップで表示

これで選択されるようになったので、後は入力欄にチップとして表示します。OutlinedInputはinput要素なのでテキスト以外は基本的には表示できませんが、startAdornmentのところに渡すことで表示することができます。
テキストは表示しないので基本的にはwidthを0にしてしまって startAdornment の部分を限界まで引き延ばしたいところですが、プレースホルダーの表示にはinput要素が必要なので、何か選択された時だけwidthを0にしています。また startAdornment に何かしらDOM要素が入っていると入力されていると判定されてlabelが常に上にいる状態になってしまったので、何も選択していない場合はStackコンポーネントもrenderしないように調整しています。

選択されたものを入力欄にチップで表示
-import { useState, useRef } from 'react';
+import { useState, useRef, useMemo } from 'react';
 import {
   FormControl,
   OutlinedInput,
   InputLabel,
   Popper,
   Fade,
   Paper,
   ClickAwayListener,
+  Stack,
+  Chip,
 } from '@mui/material';

 export const InputMultipleSelect = function <
   Option extends AvailableSelectOption
 >({
   values,
   options,
   label,
   placeholder,
   virtual,
   hideBackdrop,
   disableScrollLock,
   onChangeValues,
 }: InputMultipleSelectProps<Option>) {
   const elMenuAnchorRef = useRef<HTMLDivElement | null>(null);
   const [isMenuOpen, setIsMenuOpen] = useState(false);

+  const selectedOptions = useMemo(() => {
+    return values
+      .map((value) => {
+        return options.find((opt) => opt.value === value);
+      })
+      .filter((opt) => opt != null);
+  }, [values, options]);

   return (
     <>
       <FormControl
         variant="outlined"
         fullWidth
         focused={isMenuOpen ? true : undefined}
       >
         {label && <InputLabel>{label}</InputLabel>}
         <OutlinedInput
           ref={elMenuAnchorRef}
           sx={{
             cursor: 'pointer',
             '& .MuiInputBase-input': {
+              width: selectedOptions.length > 0 ? 0 : undefined,
               cursor: 'pointer',
             },
           }}
           label={label}
           placeholder={placeholder}
+          startAdornment={
+            selectedOptions.length > 0 && (
+              <Stack
+                sx={{ flex: '1 1 0', py: '9px' }}
+                direction="row"
+                flexWrap="wrap"
+                gap="6px"
+              >
+                {selectedOptions.map((option) => {
+                  return <Chip key={option.value} label={option.label} />;
+                })}
+              </Stack>
+            )
+          }
           value=""
           readOnly
           onClick={() => {
             setIsMenuOpen(true);
           }}
         />
       </FormControl>
       {/* ドロップダウン部分は差分がないため省略 */}
     <>
   );
 };

下矢印アイコン、バツアイコン表示

最後に末尾に下矢印アイコンを出したり、選択を一括解除するバツアイコンがあると良いと思うのでそれを出します。

選択されたものを入力欄にチップで表示
 import { useState, useRef, useMemo } from 'react';
 import {
   FormControl,
   OutlinedInput,
   InputLabel,
   Popper,
   Fade,
   Paper,
   ClickAwayListener,
   Stack,
   Chip,
+  InputAdornment,
 } from '@mui/material';
+import ArrowDropDownIcon from '@mui/icons-material/ArrowDropDown';
+import CloseIcon from '@mui/icons-material/Close';

 export const InputMultipleSelect = function <
   Option extends AvailableSelectOption
 >({
   values,
   options,
   label,
   placeholder,
   virtual,
   hideBackdrop,
   disableScrollLock,
   onChangeValues,
 }: InputMultipleSelectProps<Option>) {
   const elMenuAnchorRef = useRef<HTMLDivElement | null>(null);
   const [isMenuOpen, setIsMenuOpen] = useState(false);

   const selectedOptions = useMemo(() => {
     return values
       .map((value) => {
         return options.find((opt) => opt.value === value);
       })
       .filter((opt) => opt != null);
   }, [values, options]);

   return (
     <>
       <FormControl
         variant="outlined"
         fullWidth
         focused={isMenuOpen ? true : undefined}
       >
         {label && <InputLabel>{label}</InputLabel>}
         <OutlinedInput
           // 他のオプションは変更がないので省略
+          endAdornment={
+            <InputAdornment position="end">
+              {selectedOptions.length > 0 && (
+                <IconButton
+                  onClick={(event) => {
+                    event.stopPropagation();
+                    onChangeValues([]);
+                  }}
+                >
+                  <CloseIcon />
+                </IconButton>
+              )}
+              <ArrowDropDownIcon
+                sx={{
+                  transform: isMenuOpen ? 'rotate(180deg)' : undefined,
+                  transition: 'transform 0.3s',
+                }}
+              />
+            </InputAdornment>
+          }
         />
       </FormControl>
       {/* ドロップダウン部分は差分がないため省略 */}
     <>
   );
 };

その他オプション調整

以上で基本的な実装は終わりですが、オプションとして追加実装していますので、ご紹介します。検証コードではチェックボックスでON/OFFして動作確認することができます。

ModalPopoverと同じようにbackdrop、scrollLock機能を実装

Selectコンポーネントの内部実装ではModalを使用しているようで、デフォルトではbackdropやscrollLockがついています。前のセクションで実装したものは最小限の実装なため他の要素を触れてしまったりスクロールできてしまったりしますが、もしこれを避けたいのであれば空のModalを表示することで実現します。

backdrop, scrollLock機能を追加
 export type InputMultipleSelectProps<Option extends AvailableSelectOption> = {
   /** 選択中の値 */
   values: Array<Option['value']>;
   /** 選択肢リスト */
   options: Option[];
   /** ラベル */
   label?: string;
   /** プレースホルダー */
   placeholder?: string;
+  /** backdropを非表示にするか */
+  hideBackdrop?: boolean;
+  /** ポップアップ中にbodyスクロールをロックするか */
+  disableScrollLock?: boolean;
   /**
    * 値変更時
    * @param newValues - 新しい選択値
    */
   onChangeValues: (newValues: Array<Option['value']>) => void;
 };

 export const InputMultipleSelect = function <
   Option extends AvailableSelectOption
 >({
   values,
   options,
   label,
   placeholder,
+  hideBackdrop,
+  disableScrollLock,
   onChangeValues,
 }: InputMultipleSelectProps<Option>) {
   // 省略
   return (
     <>
       {/* 入力欄に変更はないため省略 */}
+      {/* スクロールロックやバックドロップ用に空のModalを表示する */}
+      <Modal
+        open={isMenuOpen}
+        sx={{
+          display: hideBackdrop ? 'none' : undefined,
+        }}
+        // hideBackdropしてもモーダルを表示している時は全体にオーバーレイがかかっているので、display: noneでbackdropの非表示を表現する
+        hideBackdrop
+        disableScrollLock={disableScrollLock}
+      >
+        <div />
+      </Modal>
       {/* ドロップダウン部分に変更はないため省略 */}
     </>
   );
 };

余談ですが、backdropだけであればBackdropコンポーネントを呼ぶことでも実現します。ただスクロールロックをするコンポーネントは見つからなかったので Modal で代用しました。

バーチャルスクロールの導入

選択肢が100くらいなら問題ないですが、500とか多くなってしまうと表示にもたつきがありました。この場合でも重くならないようにバーチャルスクロールで表示するパターンを用意しました。バーチャルスクロールの実装はTanStack Virtualを使って実装しました。元々の選択肢リストからの差分として表示すると以下のようなコードになりました。

選択肢リストをバーチャルスクロールに変える
+import { useState, useRef, useEffect } from 'react';
+import { useVirtualizer } from '@tanstack/react-virtual';
 import { 
+  useForkRef,
   Box, 
   MenuList,
   MenuItem, 
   ListItemText,
   Checkbox
 } from '@mui/material';

 export type VirtualOptionListProps<Option extends AvailableSelectOption> = {
   /** rootのref */
   ref?: Ref<HTMLDivElement>;
   /** 選択中の値リスト */
   values: Array<Option['value']>;
   /** 選択肢リスト */
   options: Option[];
   /**
    * 選択肢をクリックしたとき
    * @param option - 選択肢
    * @param isSelected - 選択されているか
    */
   onClickOption: (option: Option, isSelected: boolean) => void;
 };

 export const VirtualOptionList = function <Option extends AvailableSelectOption>({
   ref,
   values,
   options,
   onClickOption,
 }: VirtualOptionListProps<Option>) {
+  const elScrollerRef = useRef<HTMLDivElement | null>(null);
+
+  const [lastSelectedOptionIndex] = useState(() => {
+    return options.findLastIndex((opt) => values.includes(opt.value));
+  });
+  const virtualizer = useVirtualizer({
+    count: options.length,
+    getScrollElement: () => elScrollerRef.current,
+    estimateSize: () => 54,
+    overscan: 5,
+    paddingStart: 8,
+    paddingEnd: 8,
+    initialOffset: Math.max(lastSelectedOptionIndex * 54, 0),
+  });
+
+  const handleRef = useForkRef(ref, elScrollerRef);
+  useEffect(() => {
+    if (lastSelectedOptionIndex > 0) {
+      virtualizer.scrollToIndex(lastSelectedOptionIndex, { align: 'center' });
+    }
+  }, []);
+
+  const virtualItems = virtualizer.getVirtualItems();

   return (
     <Box
-      ref={ref}
+      ref={handleRef}
       sx={{
         maxHeight: 'min(40vh, 250px)',
         overflowY: 'auto',
       }}
     >
       <MenuList
+        sx={{
+          position: 'relative',
+        }}
+        style={{
+          height: `${virtualizer.getTotalSize()}px`,
+        }}
+        disableListWrap
       >
-        {options.map((option) => {
+        {virtualItems.map((virtualItem) => {
+          const option = options[virtualItem.index];
+          if (option == null) {
+            return null;
+          }
           const isSelected = values.includes(option.value);
           return (
             <MenuItem
-              key={option.value}
+              key={virtualItem.key}
+              data-index={virtualItem.index}
+              ref={virtualizer.measureElement}
+              sx={{
+                position: 'absolute',
+                top: 0,
+                left: 0,
+                width: '100%',
+              }}
+              style={{
+                transform: `translateY(${virtualItem.start}px)`,
+              }}
               value={option.value}
               selected={isSelected}
-              autoFocus={isSelected}
               onClick={() => {
                 onClickOption(option, isSelected);
               }}
             >
               <Checkbox checked={isSelected} />
               <ListItemText primary={option.label} />
             </MenuItem>
           );
         })}
       </MenuList>
     </Box>
   );
 };

かなり細かいですがそれぞれオプションをつけたり消したりしている理由は以下の通りです。

  • virtualizer.scrollToIndexで目的の場所に移動できるのにinitialOffsetも設定しているのは、初期表示のチラつきを防止するためです。初期表示がスクロール先と近い場所の時はその座標近辺を優先的に計算しているためかチラつきがなくなりました。
  • MenuListのdisableListWrapを設定しているのはキーボード操作で一番下まで移動して更に下に行こうとした際に一番上に戻る機能を廃止しています。
  • MenuItemのautoFocusを削除したのはバーチャルスクロール側でスクロール調整するため不要なためです。むしろこれがあると今まで表示していなかったのにバーチャルスクロールによって表示できた瞬間にそこに移動されてしまいます。

終わりに

以上がMUIで入力欄を被せないように複数選択をチップで表示するセレクトボックスを実装する方法でした。基本的には入力欄を避けてくれる機能だけ追加できれば良かったのですがSelectコンポーネントでは設定できなかったので自前で組み立て直すしかなかったのですが結構大変でした。。とりあえず今回はマウス操作だけいい感じにしましたが、ちゃんと作り込もうとするとキーボード入力にもサポートするべきで、既存のコンポーネントでできたら良かったのになぁと思いました😔
MUIのセレクトボックス周りで困っている方の何かの参考になれば幸いです。

GitHubで編集を提案

Discussion