Component
ActionMenu
같은 항목을 PC 키보드 메뉴와 모바일 액션 시트로 표시합니다.
Playground
옵션을 조절하며 화면과 사용 코드를 함께 확인하세요.
기본 트리거의 접근 가능한 이름
모바일에서는 액션 시트로 자동 전환
보관 항목의 비활성 상태
import { useState } from 'react'
import { ActionMenu, IconButton, Dialog, DialogContent, DialogBody, Input, DialogFooter, DialogClose, Button, Confirm } from '@amineslab/ui'
import { MoreVertical, Pencil, Share2, Link, Download, Archive, ExternalLink, Trash2 } from '@amineslab/ui/icons'
function ActionMenuExample({ align = 'end', sheetTitle = '프로젝트 작업', triggerLabel = '프로젝트 작업', drawerOnMobile = true, blocked = true, disabled = false, }: {
align?: 'start' | 'center' | 'end';
sheetTitle?: string;
triggerLabel?: string;
drawerOnMobile?: boolean;
blocked?: boolean;
disabled?: boolean;
}) {
const [editing, setEditing] = useState(false);
const [confirming, setConfirming] = useState(false);
const [message, setMessage] = useState('');
return (<div className="flex flex-wrap items-center gap-3">
<ActionMenu drawerOnMobile={drawerOnMobile} triggerLabel={triggerLabel} sheetTitle={sheetTitle} align={align} trigger={disabled ? (<IconButton disabled label={triggerLabel} icon={<MoreVertical />}/>) : undefined} groups={[
[
{ label: '편집', icon: <Pencil />, onSelect: () => setEditing(true) },
{
id: 'share',
label: '공유',
icon: <Share2 />,
children: [
{
label: '링크 정보',
icon: <Link />,
onSelect: () => setMessage('링크 정보 선택'),
},
{
label: '내보내기',
icon: <Download />,
children: [
{ label: 'PDF', onSelect: () => setMessage('PDF 내보내기 선택') },
{ label: 'CSV', onSelect: () => setMessage('CSV 내보내기 선택') },
],
},
],
},
{
label: '보관',
icon: <Archive />,
disabled: blocked,
disabledReason: '프로젝트 관리자만 보관할 수 있습니다.',
onSelect: () => setMessage('보관했습니다.'),
},
{
label: '문서 열기',
href: '/getting-started/responsive',
target: '_blank',
trailingIcon: <ExternalLink />,
},
],
[{ label: '삭제', icon: <Trash2 />, danger: true, onSelect: () => setConfirming(true) }],
]}/>
<span role="status" className="text-body-2 text-text-secondary">
{message}
</span>
<Dialog open={editing} onOpenChange={setEditing}>
<DialogContent title="프로젝트 편집">
<DialogBody>
<Input aria-label="프로젝트 이름" defaultValue="Amineslab"/>
</DialogBody>
<DialogFooter>
<DialogClose asChild>
<Button>완료</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<Confirm open={confirming} onOpenChange={setConfirming} title="프로젝트를 삭제할까요?" description="삭제한 프로젝트는 복구할 수 없습니다." confirmLabel="삭제" destructive onConfirm={() => setMessage('삭제했습니다.')}/>
</div>);
}
export default function ExamplePreview() {
return ((<ActionMenuExample {...({"align":"end","sheetTitle":"프로젝트 작업","triggerLabel":"프로젝트 작업","drawerOnMobile":true,"blocked":true} as const)} align={"end"}/>))
}Props
import {
ActionMenu,
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuPortal,
DropdownMenuGroup,
DropdownMenuItem,
// ...
} from '@amineslab/ui'ActionMenu
| Name | Type | Default | Description |
|---|---|---|---|
groups | readonly ActionMenuItem[][] | - | 필수. 그룹별 항목. 빈 그룹은 표시하지 않습니다. id 또는 label은 같은 단계 전체에서 고유해야 합니다. 같은 label을 여러 번 쓰면 고유 id를 지정합니다. |
trigger | ReactElement | - | asChild로 연결할 버튼. 생략하면 접근명이 있는 더 보기 아이콘 버튼입니다. 사용자 트리거는 ref와 이벤트를 전달하고 자체 aria-label을 제공해야 합니다. IconButton의 label을 권장합니다. |
triggerLabel / sheetTitle | string | '더 보기' | 트리거 접근명과 모바일 시트 제목입니다. sheetTitle 생략 시 triggerLabel을 사용합니다. |
open / defaultOpen / onOpenChange | boolean / boolean / (open: boolean) => void | - | 제어·비제어 열림 상태와 변경 콜백입니다. |
mobile / align | boolean / 'start' | 'center' | 'end' | true / 'end' | 모바일 시트 전환 여부와 PC 메뉴 정렬입니다. |
item.label / icon / trailingIcon | string / ReactNode / ReactNode | - | 라벨과 앞·뒤 보조 아이콘입니다. |
item.children | readonly ActionMenuItem[] | - | 하위 작업 목록. PC는 옆으로 펼쳐지고 모바일은 같은 시트에서 이동합니다. 빈 배열은 비활성입니다. 지정하면 부모 onSelect·href·to는 실행하지 않습니다. 1~2단계 이내 구성을 권장합니다. |
item.onSelect | () => void | - | 메뉴가 닫힌 뒤 실행합니다. Confirm/Dialog는 여기서 엽니다. href 항목은 native 링크 이동이 우선합니다. |
item.href / to / target / rel | anchor attributes | - | href는 실제 링크, to는 AppLinkProvider에 연결한 내부 경로입니다. 새 탭에도 신뢰된 클릭을 유지합니다. _blank는 noopener noreferrer를 기본 추가합니다. |
item.disabled / pending / pendingLabel / disabledReason | boolean / boolean / string / string | - | 실행·이동을 차단합니다. pending은 진행 문구와 aria-busy를 표시하며, 사유는 PC·모바일 모두 항목 안에 노출합니다. |
item.danger | boolean | false | 위험 작업의 텍스트 톤. 실제 실행 전 확인이 필요하면 Confirm을 사용합니다. |
DropdownMenu primitives | Radix DropdownMenu props | - | 직접 조합하는 팝업 메뉴. 터치에서도 동작하지만 시트로 바뀌지 않습니다. Content/SubContent는 각각 포털에 렌더하고 가용 높이에 맞춰 스크롤합니다. 자동 모바일 전환에는 ActionMenu를 사용합니다. |
DropdownMenuContent / SubContent | Content.align / sideOffset / collisionPadding / className | - | Content는 align으로 정렬하고 두 Content 모두 sideOffset·collisionPadding으로 간격과 충돌 여유를 조절합니다. SubContent에 별도 Portal을 씌울 필요는 없습니다. 좁은 화면에서 팝업 유지를 선택했다면 가로 여유를 확인하세요. |
DropdownMenuSub.open / defaultOpen / onOpenChange | boolean / boolean / (open: boolean) => void | - | 직접 조합한 하위 팝업의 열림 상태입니다. SubTrigger는 hover·클릭·ArrowRight로 열리고 ArrowLeft로 부모 항목에 복귀합니다. |
DropdownMenuItem.onSelect / disabled / asChild | (event: Event) => void / boolean / boolean | - | 키보드와 클릭에 공통으로 실행됩니다. preventDefault()는 자동 닫힘을 막습니다. asChild로 링크를 연결하며 disabled이면 선택을 차단합니다. |
DropdownMenuCheckboxItem.checked / onCheckedChange | boolean | 'indeterminate' / (checked: boolean) => void | - | 체크 상태를 제어합니다. 선택 후 계속 열어 두려면 onSelect에서 preventDefault()를 호출합니다. |
DropdownMenuShortcut | HTML span props | - | 단축키 표시 전용입니다. 실제 키 이벤트는 소비 앱에서 등록합니다. |
DropdownMenuRadioGroup.value / onValueChange | string / (value: string) => void | - | 라디오 항목의 단일 선택 상태를 제어합니다. |
DropdownMenuRadioItem.indicator / closeOnSelect | 'dot' | 'none' / boolean | 'dot' / true | 라디오 선택 표시와 선택 후 닫기. false이면 메뉴를 유지하며 값을 변경합니다. |
Examples
자주 쓰는 조합을 살펴보고, 필요한 예시의 코드를 펼쳐 확인하세요.
아이콘 트리거와 다단계 하위 메뉴
기본 트리거는 더 보기 아이콘입니다. 다른 아이콘은 label이 있는 IconButton을 전달합니다. 공유 → 내보내기 → PDF/CSV로 이동해 보세요. PC는 오른쪽 하위 메뉴, 모바일은 같은 시트 안의 단계 이동입니다. 이전 메뉴로 돌아오면 방금 열었던 항목으로 포커스가 복귀합니다.
import { useState } from 'react'
import { ActionMenu, IconButton } from '@amineslab/ui'
import { Settings2, Share2, Link, Download } from '@amineslab/ui/icons'
function IconSubmenuExample() {
const [selected, setSelected] = useState('선택한 작업이 여기에 표시됩니다.');
return (<div className="flex flex-wrap items-center gap-3">
<ActionMenu triggerLabel="파일 옵션" sheetTitle="파일 옵션" trigger={<IconButton label="파일 옵션" variant="outline" icon={<Settings2 aria-hidden="true"/>}/>} groups={[
[
{
label: '공유',
icon: <Share2 />,
children: [
{
label: '링크 정보',
icon: <Link />,
onSelect: () => setSelected('링크 정보 선택'),
},
{
label: '내보내기',
icon: <Download />,
children: [
{ label: 'PDF', onSelect: () => setSelected('PDF 선택') },
{ label: 'CSV', onSelect: () => setSelected('CSV 선택') },
],
},
],
},
],
]}/>
<span role="status" className="text-body-2 text-text-secondary">
{selected}
</span>
</div>);
}
export default function ExamplePreview() {
return (<IconSubmenuExample />)
}Checkbox·Radio와 직접 조합하는 하위 메뉴
DropdownMenu primitive는 화면 크기와 관계없이 팝업 메뉴를 유지합니다. 모바일 시트로 전환할 작업은 ActionMenu의 children을 사용하세요. 이 예제는 체크 선택과 라디오 정렬을 바꿔도 메뉴를 유지합니다. Shortcut은 표시 전용으로 단축키 이벤트를 등록하지 않습니다.
import { useState } from 'react'
import { DropdownMenu, DropdownMenuTrigger, Button, DropdownMenuContent, DropdownMenuLabel, DropdownMenuCheckboxItem, DropdownMenuSub, DropdownMenuSubTrigger, DropdownMenuSubContent, DropdownMenuRadioGroup, DropdownMenuRadioItem, DropdownMenuSeparator, DropdownMenuItem, DropdownMenuShortcut } from '@amineslab/ui'
function MenuSettingsExample() {
const [showDetails, setShowDetails] = useState(true);
const [sort, setSort] = useState('recent');
const [message, setMessage] = useState('');
return (<div className="flex flex-wrap items-center gap-3">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline">목록 표시 옵션</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuLabel>표시 설정</DropdownMenuLabel>
<DropdownMenuCheckboxItem checked={showDetails} onCheckedChange={setShowDetails} onSelect={(event) => event.preventDefault()}>
상세 정보 표시
</DropdownMenuCheckboxItem>
<DropdownMenuSub>
<DropdownMenuSubTrigger>정렬</DropdownMenuSubTrigger>
<DropdownMenuSubContent>
<DropdownMenuRadioGroup value={sort} onValueChange={setSort}>
<DropdownMenuRadioItem value="recent" closeOnSelect={false}>
최신순
</DropdownMenuRadioItem>
<DropdownMenuRadioItem value="name" closeOnSelect={false}>
이름순
</DropdownMenuRadioItem>
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setMessage('새로고침 선택')}>
새로고침 <DropdownMenuShortcut>⌘R</DropdownMenuShortcut>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<span role="status" className="text-body-2 text-text-secondary">
상세 {showDetails ? '켜짐' : '꺼짐'} · {sort === 'recent' ? '최신순' : '이름순'} {message}
</span>
</div>);
}
export default function ExamplePreview() {
return (<MenuSettingsExample />)
}처리 중·비활성 사유와 긴 목록
처리 중인 하위 메뉴는 열리지 않습니다. 버튼으로 처리 중 상태를 바꾸고 다시 열어 보세요. PC는 가용 높이 안에서, 모바일은 제목·뒤로·닫기 영역을 남기고 본문만 스크롤합니다. 긴 작업 이름과 비활성 사유는 잘리지 않고 줄바꿈합니다.
import { useState } from 'react'
import { Button, ActionMenu } from '@amineslab/ui'
function MenuStatesExample() {
const [pending, setPending] = useState(true);
const [selected, setSelected] = useState('');
return (<div className="grid w-full gap-3">
<div className="flex flex-wrap items-center gap-3">
<Button variant="outline" onClick={() => setPending((value) => !value)}>
{pending ? '처리 중 상태 해제' : '처리 중 상태 켜기'}
</Button>
<ActionMenu triggerLabel="상태와 긴 메뉴" groups={[
[
{
label: '내보내기',
pending,
pendingLabel: '파일 준비 중…',
disabledReason: '준비가 끝난 뒤 다시 시도할 수 있습니다.',
children: [{ label: '다운로드', onSelect: () => setSelected('다운로드 선택') }],
},
{
label: '관리자 전용 작업',
disabled: true,
disabledReason: '관리자 권한이 필요합니다.',
},
],
Array.from({ length: 18 }, (_, index) => ({
id: `destination-${index}`,
label: `보관 위치 ${index + 1} · 여러 줄로 표시되는 긴 작업 이름`,
onSelect: () => setSelected(`보관 위치 ${index + 1} 선택`),
})),
]}/>
</div>
<span role="status" className="text-body-2 text-text-secondary">
{selected || '목록을 끝까지 스크롤해 보세요.'}
</span>
</div>);
}
export default function ExamplePreview() {
return (<MenuStatesExample />)
}메뉴 → 편집·확인
PC에서는 메뉴의 포커스 복원 뒤, 모바일에서는 Drawer 닫힘 완료 뒤에 onSelect가 실행됩니다. 메뉴와 다음 모달의 focus trap이 충돌하지 않습니다.
import { useState } from 'react'
import { ActionMenu, IconButton, Dialog, DialogContent, DialogBody, Input, DialogFooter, DialogClose, Button, Confirm } from '@amineslab/ui'
import { MoreVertical, Pencil, Share2, Link, Download, Archive, ExternalLink, Trash2 } from '@amineslab/ui/icons'
function ActionMenuExample({ align = 'end', sheetTitle = '프로젝트 작업', triggerLabel = '프로젝트 작업', drawerOnMobile = true, blocked = true, disabled = false, }: {
align?: 'start' | 'center' | 'end';
sheetTitle?: string;
triggerLabel?: string;
drawerOnMobile?: boolean;
blocked?: boolean;
disabled?: boolean;
}) {
const [editing, setEditing] = useState(false);
const [confirming, setConfirming] = useState(false);
const [message, setMessage] = useState('');
return (<div className="flex flex-wrap items-center gap-3">
<ActionMenu drawerOnMobile={drawerOnMobile} triggerLabel={triggerLabel} sheetTitle={sheetTitle} align={align} trigger={disabled ? (<IconButton disabled label={triggerLabel} icon={<MoreVertical />}/>) : undefined} groups={[
[
{ label: '편집', icon: <Pencil />, onSelect: () => setEditing(true) },
{
id: 'share',
label: '공유',
icon: <Share2 />,
children: [
{
label: '링크 정보',
icon: <Link />,
onSelect: () => setMessage('링크 정보 선택'),
},
{
label: '내보내기',
icon: <Download />,
children: [
{ label: 'PDF', onSelect: () => setMessage('PDF 내보내기 선택') },
{ label: 'CSV', onSelect: () => setMessage('CSV 내보내기 선택') },
],
},
],
},
{
label: '보관',
icon: <Archive />,
disabled: blocked,
disabledReason: '프로젝트 관리자만 보관할 수 있습니다.',
onSelect: () => setMessage('보관했습니다.'),
},
{
label: '문서 열기',
href: '/getting-started/responsive',
target: '_blank',
trailingIcon: <ExternalLink />,
},
],
[{ label: '삭제', icon: <Trash2 />, danger: true, onSelect: () => setConfirming(true) }],
]}/>
<span role="status" className="text-body-2 text-text-secondary">
{message}
</span>
<Dialog open={editing} onOpenChange={setEditing}>
<DialogContent title="프로젝트 편집">
<DialogBody>
<Input aria-label="프로젝트 이름" defaultValue="Amineslab"/>
</DialogBody>
<DialogFooter>
<DialogClose asChild>
<Button>완료</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<Confirm open={confirming} onOpenChange={setConfirming} title="프로젝트를 삭제할까요?" description="삭제한 프로젝트는 복구할 수 없습니다." confirmLabel="삭제" destructive onConfirm={() => setMessage('삭제했습니다.')}/>
</div>);
}
export default function ExamplePreview() {
return (<ActionMenuExample triggerLabel="편집·삭제 메뉴"/>)
}모달 안의 액션 메뉴
모바일에서는 중첩 Drawer가 열립니다. 항목을 선택하면 메뉴만 닫고 다음 작업으로 이어집니다.
import { Dialog, DialogTrigger, Button, DialogContent, DialogBody, ActionMenu, IconButton, Input, DialogFooter, DialogClose, Confirm } from '@amineslab/ui'
import { useState } from 'react'
import { MoreVertical, Pencil, Share2, Link, Download, Archive, ExternalLink, Trash2 } from '@amineslab/ui/icons'
function ActionMenuExample({ align = 'end', sheetTitle = '프로젝트 작업', triggerLabel = '프로젝트 작업', drawerOnMobile = true, blocked = true, disabled = false, }: {
align?: 'start' | 'center' | 'end';
sheetTitle?: string;
triggerLabel?: string;
drawerOnMobile?: boolean;
blocked?: boolean;
disabled?: boolean;
}) {
const [editing, setEditing] = useState(false);
const [confirming, setConfirming] = useState(false);
const [message, setMessage] = useState('');
return (<div className="flex flex-wrap items-center gap-3">
<ActionMenu drawerOnMobile={drawerOnMobile} triggerLabel={triggerLabel} sheetTitle={sheetTitle} align={align} trigger={disabled ? (<IconButton disabled label={triggerLabel} icon={<MoreVertical />}/>) : undefined} groups={[
[
{ label: '편집', icon: <Pencil />, onSelect: () => setEditing(true) },
{
id: 'share',
label: '공유',
icon: <Share2 />,
children: [
{
label: '링크 정보',
icon: <Link />,
onSelect: () => setMessage('링크 정보 선택'),
},
{
label: '내보내기',
icon: <Download />,
children: [
{ label: 'PDF', onSelect: () => setMessage('PDF 내보내기 선택') },
{ label: 'CSV', onSelect: () => setMessage('CSV 내보내기 선택') },
],
},
],
},
{
label: '보관',
icon: <Archive />,
disabled: blocked,
disabledReason: '프로젝트 관리자만 보관할 수 있습니다.',
onSelect: () => setMessage('보관했습니다.'),
},
{
label: '문서 열기',
href: '/getting-started/responsive',
target: '_blank',
trailingIcon: <ExternalLink />,
},
],
[{ label: '삭제', icon: <Trash2 />, danger: true, onSelect: () => setConfirming(true) }],
]}/>
<span role="status" className="text-body-2 text-text-secondary">
{message}
</span>
<Dialog open={editing} onOpenChange={setEditing}>
<DialogContent title="프로젝트 편집">
<DialogBody>
<Input aria-label="프로젝트 이름" defaultValue="Amineslab"/>
</DialogBody>
<DialogFooter>
<DialogClose asChild>
<Button>완료</Button>
</DialogClose>
</DialogFooter>
</DialogContent>
</Dialog>
<Confirm open={confirming} onOpenChange={setConfirming} title="프로젝트를 삭제할까요?" description="삭제한 프로젝트는 복구할 수 없습니다." confirmLabel="삭제" destructive onConfirm={() => setMessage('삭제했습니다.')}/>
</div>);
}
function NestedActionMenuExample() {
return (<Dialog>
<DialogTrigger asChild>
<Button variant="outline">작업 모달 열기</Button>
</DialogTrigger>
<DialogContent title="프로젝트 관리">
<DialogBody>
<ActionMenuExample triggerLabel="중첩 작업"/>
</DialogBody>
</DialogContent>
</Dialog>);
}
export default function ExamplePreview() {
return (<NestedActionMenuExample />)
}사용 기준
- 편집·공유·삭제 같은 부수 작업에는 ActionMenu를 사용합니다.
- children은 PC 하위 팝업과 모바일 시트 단계 이동으로 전환됩니다.
- 체크·라디오의 직접 조합은 DropdownMenu primitive를 사용하며 자동 시트 전환은 하지 않습니다.
- 다음 모달은 onSelect에서 열어 닫힘 완료 순서를 보장합니다.
- 겉모양만으로 사용을 결정하지 마세요.
- 의미와 키보드 동작, 모바일에서의 흐름을 함께 확인하세요.
접근성
화면에 맞는 이름을 제공하고, 키보드만으로 작업을 마칠 수 있는지 확인하세요.
Enter / Space: 메뉴 열기·항목 실행ArrowUp / ArrowDown · Home / End: PC 메뉴 항목 이동, 비활성 항목 건너뛰기ArrowRight / ArrowLeft: PC 하위 메뉴 열기·부모 항목으로 돌아가기 (LTR 기준)이전 메뉴로: 모바일에서 한 단계 복귀하고 부모 항목으로 포커스 이동Tab / Shift+Tab: 모바일 시트 안의 작업과 뒤로·닫기 버튼 사이 이동Escape: PC·모바일 모두 메뉴 전체를 닫고 트리거로 포커스 복귀. PC 한 단계 복귀는 ArrowLeft를 사용