Transfer List
Transfer List (전송 목록)
Transfer List(또는 "셔틀(shuttle)")은 사용자가 하나 이상의 리스트 항목을 리스트 사이에서 이동할 수 있게 해줘요. 항목 선택하고 옮기는 과정을 위한 전형적인 패턴이랍니다.
출처: 문서
본문
Basic transfer list (기본 전송 목록)
완전성을 위해 이 예제에는 "모두 이동" 버튼이 포함되어 있지만, 모든 전송 목록이 이를 필요로 하는 것은 아니에요.
import * as React from 'react';
import Grid from '@mui/material/Grid';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import Checkbox from '@mui/material/Checkbox';
import Button from '@mui/material/Button';
import Paper from '@mui/material/Paper';
import Stack from '@mui/material/Stack';
import MenuItem from '@mui/material/MenuItem';
import MenuList from '@mui/material/MenuList';
function not(a: readonly number[], b: readonly number[]) {
return a.filter((value) => !b.includes(value));
}
function intersection(a: readonly number[], b: readonly number[]) {
return a.filter((value) => b.includes(value));
}
type CustomListProps = {
items: readonly number[];
checked: readonly number[];
handleToggle: (value: number) => () => void;
};
const CustomList = React.forwardRef(function CustomList(
props: CustomListProps,
ref: React.Ref<HTMLDivElement & { focus: () => void }>,
) {
const { items, checked, handleToggle } = props;
return (
<Paper sx={{ width: 200, height: 230, overflow: 'auto' }}>
<MenuList dense component="div" role="list" ref={ref}>
{items.map((value: number) => {
const labelId = `transfer-list-item-${value}-label`;
return (
<MenuItem
component="div"
key={value}
role="listitem"
onClick={handleToggle(value)}
>
<ListItemIcon>
<Checkbox
checked={checked.includes(value)}
tabIndex={-1}
disableRipple
slotProps={{
input: { 'aria-labelledby': labelId },
}}
/>
</ListItemIcon>
<ListItemText id={labelId} primary={`List item ${value + 1}`} />
</MenuItem>
);
})}
</MenuList>
</Paper>
);
});
export default function TransferList() {
const [checked, setChecked] = React.useState<readonly number[]>([]);
const [left, setLeft] = React.useState<readonly number[]>([0, 1, 2, 3]);
const [right, setRight] = React.useState<readonly number[]>([4, 5, 6, 7]);
const leftChecked = intersection(checked, left);
const rightChecked = intersection(checked, right);
const leftListRef = React.useRef<HTMLDivElement & { focus: () => void }>(null);
const rightListRef = React.useRef<HTMLDivElement & { focus: () => void }>(null);
const handleToggle = (value: number) => () => {
const currentIndex = checked.indexOf(value);
const newChecked = [...checked];
if (currentIndex === -1) {
newChecked.push(value);
} else {
newChecked.splice(currentIndex, 1);
}
setChecked(newChecked);
};
const handleAllRight = () => {
setRight(right.concat(left));
setLeft([]);
setChecked(not(checked, left));
rightListRef.current?.focus();
};
const handleCheckedRight = () => {
setRight(right.concat(leftChecked));
setLeft(not(left, leftChecked));
setChecked(not(checked, leftChecked));
rightListRef.current?.focus();
};
const handleCheckedLeft = () => {
setLeft(left.concat(rightChecked));
setRight(not(right, rightChecked));
setChecked(not(checked, rightChecked));
leftListRef.current?.focus();
};
const handleAllLeft = () => {
setLeft(left.concat(right));
setRight([]);
setChecked(not(checked, right));
leftListRef.current?.focus();
};
return (
<Grid
container
spacing={2}
sx={{ justifyContent: 'center', alignItems: 'center' }}
>
<CustomList
ref={leftListRef}
items={left}
checked={checked}
handleToggle={handleToggle}
/>
<Stack>
<Button
sx={{ my: 0.5 }}
variant="outlined"
size="small"
onClick={handleAllRight}
disabled={left.length === 0}
aria-label="move all right"
>
≫
</Button>
<Button
sx={{ my: 0.5 }}
variant="outlined"
size="small"
onClick={handleCheckedRight}
disabled={leftChecked.length === 0}
aria-label="move selected right"
>
>
</Button>
<Button
sx={{ my: 0.5 }}
variant="outlined"
size="small"
onClick={handleCheckedLeft}
disabled={rightChecked.length === 0}
aria-label="move selected left"
>
<
</Button>
<Button
sx={{ my: 0.5 }}
variant="outlined"
size="small"
onClick={handleAllLeft}
disabled={right.length === 0}
aria-label="move all left"
>
≪
</Button>
</Stack>
<CustomList
ref={rightListRef}
items={right}
checked={checked}
handleToggle={handleToggle}
/>
</Grid>
);
}
Enhanced transfer list (향상된 전송 목록)
이 예제는 "모두 이동" 버튼 대신 "모두 선택 / 모두 해제" 체크박스로 바꾸고 카운터를 추가해요.
import * as React from 'react';
import Grid from '@mui/material/Grid';
import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader';
import ListItemText from '@mui/material/ListItemText';
import ListItemIcon from '@mui/material/ListItemIcon';
import Checkbox from '@mui/material/Checkbox';
import Button from '@mui/material/Button';
import Divider from '@mui/material/Divider';
import Stack from '@mui/material/Stack';
import MenuItem from '@mui/material/MenuItem';
import MenuList from '@mui/material/MenuList';
function not(a: readonly number[], b: readonly number[]) {
return a.filter((value) => !b.includes(value));
}
function intersection(a: readonly number[], b: readonly number[]) {
return a.filter((value) => b.includes(value));
}
function union(a: readonly number[], b: readonly number[]) {
return [...a, ...not(b, a)];
}
type CustomListProps = {
title: React.ReactNode;
items: readonly number[];
checked: readonly number[];
handleToggle: (value: number) => () => void;
handleToggleAll: (items: readonly number[]) => () => void;
numberOfChecked: (items: readonly number[]) => number;
};
const CustomList = React.forwardRef(function CustomList(
props: CustomListProps,
ref: React.Ref<HTMLDivElement & { focus: () => void }>,
) {
const { title, items, checked, handleToggle, handleToggleAll, numberOfChecked } =
props;
return (
<Card>
<CardHeader
sx={{ px: 2, py: 0.5 }}
avatar={
<Checkbox
size="small"
onClick={handleToggleAll(items)}
checked={numberOfChecked(items) === items.length && items.length !== 0}
indeterminate={
numberOfChecked(items) !== items.length && numberOfChecked(items) !== 0
}
disabled={items.length === 0}
slotProps={{
input: { 'aria-label': 'all items selected' },
}}
/>
}
title={title}
subheader={`${numberOfChecked(items)}/${items.length} selected`}
/>
<Divider />
<MenuList
sx={{
width: 200,
height: 230,
bgcolor: 'background.paper',
overflow: 'auto',
}}
dense
component="div"
role="list"
ref={ref}
>
{items.map((value: number) => {
const labelId = `transfer-list-all-item-${value}-label`;
return (
<MenuItem
component="div"
key={value}
role="listitem"
onClick={handleToggle(value)}
>
<ListItemIcon>
<Checkbox
checked={checked.includes(value)}
tabIndex={-1}
disableRipple
slotProps={{
input: { 'aria-labelledby': labelId },
}}
/>
</ListItemIcon>
<ListItemText id={labelId} primary={`List item ${value + 1}`} />
</MenuItem>
);
})}
</MenuList>
</Card>
);
});
export default function SelectAllTransferList() {
const [checked, setChecked] = React.useState<readonly number[]>([]);
const [left, setLeft] = React.useState<readonly number[]>([0, 1, 2, 3]);
const [right, setRight] = React.useState<readonly number[]>([4, 5, 6, 7]);
const leftChecked = intersection(checked, left);
const rightChecked = intersection(checked, right);
const leftListRef = React.useRef<HTMLDivElement & { focus: () => void }>(null);
const rightListRef = React.useRef<HTMLDivElement & { focus: () => void }>(null);
const handleToggle = (value: number) => () => {
const currentIndex = checked.indexOf(value);
const newChecked = [...checked];
if (currentIndex === -1) {
newChecked.push(value);
} else {
newChecked.splice(currentIndex, 1);
}
setChecked(newChecked);
};
const numberOfChecked = (items: readonly number[]) =>
intersection(checked, items).length;
const handleToggleAll = (items: readonly number[]) => () => {
if (numberOfChecked(items) === items.length) {
setChecked(not(checked, items));
} else {
setChecked(union(checked, items));
}
};
const handleCheckedRight = () => {
setRight(right.concat(leftChecked));
setLeft(not(left, leftChecked));
setChecked(not(checked, leftChecked));
rightListRef.current?.focus();
};
const handleCheckedLeft = () => {
setLeft(left.concat(rightChecked));
setRight(not(right, rightChecked));
setChecked(not(checked, rightChecked));
leftListRef.current?.focus();
};
return (
<Grid
container
spacing={2}
sx={{ justifyContent: 'center', alignItems: 'center' }}
>
<CustomList
ref={leftListRef}
title="Choices"
items={left}
checked={checked}
handleToggle={handleToggle}
handleToggleAll={handleToggleAll}
numberOfChecked={numberOfChecked}
/>
<Stack>
<Button
sx={{ my: 0.5 }}
variant="outlined"
size="small"
onClick={handleCheckedRight}
disabled={leftChecked.length === 0}
aria-label="move selected right"
>
>
</Button>
<Button
sx={{ my: 0.5 }}
variant="outlined"
size="small"
onClick={handleCheckedLeft}
disabled={rightChecked.length === 0}
aria-label="move selected left"
>
<
</Button>
</Stack>
<CustomList
ref={rightListRef}
title="Chosen"
items={right}
checked={checked}
handleToggle={handleToggle}
handleToggleAll={handleToggleAll}
numberOfChecked={numberOfChecked}
/>
</Grid>
);
}
Limitations (제한 사항)
이 컴포넌트에는 몇 가지 제한 사항이 있어요:
- 데스크톱에서만 동작해요. 선택할 옵션 수가 제한적이라면 Autocomplete 컴포넌트를 선호해요. 모바일 지원이 중요하다면 #27579를 살펴보세요.
- npm에서 export되는 고수준 컴포넌트가 없어요. 데모는 composition(합성)에 기반해요. 이것이 중요하다면 #27579를 살펴보세요.
더 알아보기 (Learn more)
- Autocomplete 컴포넌트 — 제한된 옵션 목록을 다룰 때