MUIで入力欄を被せないように複数選択をチップで表示するセレクトボックスを実装する
始めに
MUIのSelectコンポーネントで複数選択でチップを表示させたい場合、公式でサンプルコードが載っています。
これを試してみると表示する位置によって入力欄の上に表示されてしまうことがあり、選択した内容が確認しづらくなることが気になりました。
この挙動は画面外に出ないように自動で表示位置を調整した結果ではありますが、オートコンプリートのように入力欄を避けて表示して欲しいと思って色々調べていましたが簡単に調整することは出来なさそうで、改めてMUIのコンポーネントを組み合わせて実装するしかなさそうでした。
そもそも他にもプレースホルダーをテキストのみで表示させる方法がなかったり、endAdornmentの位置が少し変だったり気になっていたので、その辺も含めて一から作り直して概ね形にはなったのでその方法についてまとめました。
作ったもの
今回作ったものはgifアニメのようなものになります。基本は下にドロップダウンするようになって、もしはみ出そうになったら上に表示するようになっています。
他にも低レベルのコンポーネントを使ったことで細かい調整もできるようになってそれらについても後述しますが、先に動作を確認したい方はこちらからご参照ください。
MUIで入力欄を被せないように複数選択をチップで表示するセレクトボックスの実装
入力欄の実装
まずは入力欄を作ります。見た目は OutlinedInput
が Select
コンポーネントと一緒なのでそれをベースに作ります。あくまで見た目だけあれば良く入力できる必要はないので、 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
コンポーネントを使うと自動でやってくれますが、代わりにアニメーションやドロップダウン外をクリックしたら閉じるなどの機能がないため、自前で設定する必要があります。
FormControl
にfocused
を設定しているのは選択肢をクリックすると入力欄からフォーカスが外れてしまいますが、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して動作確認することができます。
Modal
やPopover
と同じようにbackdrop、scrollLock機能を実装
Select
コンポーネントの内部実装ではModal
を使用しているようで、デフォルトではbackdropやscrollLockがついています。前のセクションで実装したものは最小限の実装なため他の要素を触れてしまったりスクロールできてしまったりしますが、もしこれを避けたいのであれば空のModalを表示することで実現します。
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のセレクトボックス周りで困っている方の何かの参考になれば幸いです。
Discussion