Menu
trigger를 눌렀을 때 나타나는 액션 목록입니다. 중첩된 submenu와 체크 가능한 행을 포함할 수 있고, 키보드만으로 전체를 다룰 수 있습니다.
tsx
import { Button, Menu, MenuItem, MenuSeparator, MenuSubmenu } from 'neba';
<Menu trigger={<Button>액션</Button>}>
<MenuItem shortcut="⌘E">이름 바꾸기</MenuItem>
<MenuSubmenu label="이동">
<MenuItem>보관함</MenuItem>
</MenuSubmenu>
<MenuSeparator />
<MenuItem color="danger">삭제</MenuItem>
</Menu>;행은 배열이 아니라 컴포넌트로 씁니다. 행마다 다른 핸들러와 아이콘이 붙고 그중 일부가 submenu가 되기 때문입니다. 값을 고르는 목록이 필요하다면 Select를 쓰세요.
Props
Menu
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| trigger | ReactElement | — | 메뉴를 여는 요소. Base UI가 연결해 줍니다. 다른 곳에서 여는 controlled 메뉴에는 필요 없습니다 |
| open | boolean | — | 열림 여부. controlled 메뉴 |
| defaultOpen | boolean | false | 처음부터 열려 있음 |
| onOpenChange | (open: boolean) => void | — | 열리거나 닫힐 때 |
| side공통 | 'top' | 'right' | 'bottom' | 'left' | 'bottom' | 트리거의 어느 변에 걸릴지. 자리가 없으면 반대편으로 뒤집힙니다 |
| align공통 | 'start' | 'center' | 'end' | 'start' | 그 변 위에서의 위치 |
| sideOffset | number | 6 | 트리거와의 거리(px) |
| modal | boolean | true | 열려 있는 동안 뒤 페이지를 가져갈지 |
| openOnHover | boolean | false | 호버로도 열립니다. 메뉴바처럼 열린 채로 옆 메뉴로 건너가야 할 때 |
| loopFocus | boolean | true | 마지막 항목에서 화살표를 누르면 처음으로 돌아갈지 |
| disabled | boolean | false | 사용 불가. 트리거가 아무것도 열지 않습니다 |
| size공통 | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | 높이와 타입 스케일 |
| color공통 | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | 의미론적 색 역할. 임의 색상값은 받지 않습니다 |
| density공통 | 'default' | 'compact' | 'default' | 행의 여백만 바꿉니다. 메뉴는 자기 팝업 폭을 스스로 정하므로 Box보다 좁은 사다리를 씁니다 |
| children | ReactNode | — | 행들. MenuItem, MenuGroup, MenuSeparator, MenuSubmenu 등 |
MenuItem
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| onClick | (event) => void | — | 행이 하는 일 |
| href | string | — | 넘기면 진짜 a가 됩니다. 링크로 이뤄진 메뉴는 링크여야 새 탭으로 열 수 있습니다 |
| target | string | — | 링크가 열리는 곳. href가 없으면 무시됩니다. 이 탭이 아닌 곳으로 열면 rel에 noopener noreferrer가 더해집니다 |
| rel | string | — | 링크의 rel. 덮어쓰는 것이 아니라 합쳐집니다. nofollow를 쓴다고 새 탭의 보호가 사라지지는 않습니다 |
| startIcon | ReactNode | — | 라벨 앞의 내용 |
| endIcon | ReactNode | — | 라벨 뒤, shortcut 앞의 내용 |
| shortcut | ReactNode | — | 같은 일을 하는 단축키. 행 끝에 흐리게 놓입니다. 표시만 하고 바인딩하지는 않습니다 |
| description | ReactNode | — | 라벨 아래 한 줄 |
| color공통 | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | — | 이 행만 다른 색 계열로. 지우는 행에 danger. 기본값은 메뉴의 색입니다 |
| closeOnClick | boolean | true | 고르면 메뉴가 닫힐지 |
| disabled | boolean | false | 사용 불가. 목록에는 남고 타이핑 검색에도 걸립니다 |
| label | string | — | 타이핑 검색이 맞춰 볼 문자열. 라벨이 평범한 문자열이 아닐 때 |
| children | ReactNode | — | 라벨 |
MenuSubmenu
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| label | ReactNode | — | 서브메뉴를 여는 행의 라벨 |
| startIcon | ReactNode | — | 라벨 앞의 내용 |
| side공통 | 'top' | 'right' | 'bottom' | 'left' | 'right' | 부모 행의 어느 쪽으로 열릴지 |
| sideOffset | number | 4 | 부모 메뉴와의 거리(px) |
| disabled | boolean | false | 사용 불가 |
| children | ReactNode | — | 중첩된 행들. 그 안에 또 MenuSubmenu를 넣을 수 있고, 깊이 제한은 없습니다 |
ContextMenu
| Prop | 타입 | 기본값 | 설명 |
|---|---|---|---|
| content | ReactNode | — | 행들. Menu 안에 쓰는 것과 똑같이 씁니다 |
| children * | ReactNode | — | 오른쪽 클릭이나 길게 누르기에 반응할 영역 |
| open | boolean | — | 열림 여부 |
| defaultOpen | boolean | false | 처음부터 열려 있음 |
| onOpenChange | (open: boolean) => void | — | 열리거나 닫힐 때 |
| disabled | boolean | — | 우클릭과 길게 누르기에 반응하지 않게 합니다 |
| loopFocus | boolean | true | 마지막 행에서 방향키를 누르면 첫 행으로 돌아갈지 |
| size공통 | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | 높이와 타입 스케일 |
| color공통 | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | 의미론적 색 역할. 임의 색상값은 받지 않습니다 |
| density공통 | 'default' | 'compact' | 'default' | 행의 여백만 바꿉니다 |
예시
href · startIcon · shortcut
href를 주면 행이 실제 <a>로 렌더링되므로 새 탭으로 열거나 주소를 복사할 수 있습니다. shortcut에는 Shortcut을 넣습니다. MenuSeparator로 행을 그룹으로 나눕니다.
tsx
import { Button, Menu, MenuGroup, MenuItem, MenuSeparator } from 'neba';
function CopyIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<rect
x="5.5"
y="5.5"
width="8"
height="8"
rx="1.75"
stroke="currentColor"
strokeWidth="1.5"
/>
<path
d="M10.5 5.5v-1a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v4a2 2 0 0 0 2 2h1"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
);
}
function LinkIcon() {
return (
<svg viewBox="0 0 16 16" fill="none" aria-hidden="true">
<path
d="M6.5 9.5 9.5 6.5M6 4.5 7.5 3a2.9 2.9 0 0 1 4 4L10 8.5M10 11.5 8.5 13a2.9 2.9 0 0 1-4-4L6 7.5"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
/>
</svg>
);
}
export default function MenuBasic() {
return (
<div className="flex flex-wrap items-start gap-3">
<Menu
trigger={
<Button variant="outline" color="secondary">
Share
</Button>
}
>
<MenuGroup label="Copy">
<MenuItem startIcon={<LinkIcon />} shortcut="⌘L">
Copy link
</MenuItem>
<MenuItem startIcon={<CopyIcon />} description="Includes the current filters">
Copy as Markdown
</MenuItem>
</MenuGroup>
<MenuSeparator />
<MenuGroup label="Open">
{/* A menu of links has to be links — `href` renders a real anchor, so
a middle click opens a tab and a right click offers to copy it. */}
<MenuItem href="https://neba.cdget.com" target="_blank">
Documentation
</MenuItem>
<MenuItem href="https://github.com/jooy2/neba" target="_blank">
Repository
</MenuItem>
</MenuGroup>
<MenuSeparator />
<MenuItem disabled>Export as PDF</MenuItem>
</Menu>
<Menu
color="danger"
trigger={
<Button variant="outline" color="danger">
Danger zone
</Button>
}
>
<MenuItem color="secondary">Archive project</MenuItem>
<MenuSeparator />
<MenuItem color="danger" description="Members lose access immediately">
Delete project
</MenuItem>
</Menu>
</div>
);
}MenuSubmenu
깊이 제한은 없습니다. submenu의 자식도 메뉴 행이므로 그 안에 또 MenuSubmenu를 둘 수 있습니다. hover로 열리며, 열린 submenu로 포인터를 비스듬히 옮겨도 닫히지 않습니다.
tsx
import { Button, Menu, MenuItem, MenuSubmenu } from 'neba';
/**
* Nesting is unlimited because a submenu's children are just menu rows, and one
* of those rows can be another submenu. Base UI opens them on hover with a safe
* triangle, so reaching diagonally into an open submenu does not close it.
*/
export default function MenuNested() {
return (
<Menu
trigger={
<Button variant="outline" color="secondary">
Insert
</Button>
}
>
<MenuItem>Text block</MenuItem>
<MenuSubmenu label="Media">
<MenuItem>Image</MenuItem>
<MenuItem>Video</MenuItem>
<MenuSubmenu label="Embed">
<MenuItem>YouTube</MenuItem>
<MenuItem>CodeSandbox</MenuItem>
<MenuSubmenu label="Other">
<MenuItem>By URL</MenuItem>
<MenuItem>By oEmbed</MenuItem>
</MenuSubmenu>
</MenuSubmenu>
</MenuSubmenu>
<MenuSubmenu label="Data">
<MenuItem>Table</MenuItem>
<MenuItem>Chart</MenuItem>
</MenuSubmenu>
</Menu>
);
}체크와 라디오 행
체크 표시는 여러 개를 동시에 켜는 항목, 점은 여럿 중 하나를 고르는 항목에 씁니다. 두 경우 모두 선택 후 메뉴가 닫히지 않습니다. 행 단위로 closeOnClick을 지정할 수 있습니다.
tsx
import { useState } from 'react';
import {
Button,
Menu,
MenuCheckboxItem,
MenuGroup,
MenuRadioGroup,
MenuRadioItem,
MenuSeparator
} from 'neba';
export default function MenuState() {
const [density, setDensity] = useState<string | number>('cosy');
const [columns, setColumns] = useState({ status: true, owner: true, updated: false });
return (
<div className="flex flex-col items-start gap-3">
<Menu
trigger={
<Button variant="outline" color="secondary">
View
</Button>
}
>
{/* A tick says "and", a dot says "instead of" — the same distinction
Checkbox and Radio make everywhere else in the library. */}
<MenuGroup label="Columns">
{(['status', 'owner', 'updated'] as const).map((key) => (
<MenuCheckboxItem
key={key}
checked={columns[key]}
onCheckedChange={(checked) => setColumns({ ...columns, [key]: checked })}
>
{key}
</MenuCheckboxItem>
))}
</MenuGroup>
<MenuSeparator />
<MenuGroup label="Density">
<MenuRadioGroup value={density} onValueChange={setDensity}>
<MenuRadioItem value="compact">Compact</MenuRadioItem>
<MenuRadioItem value="cosy">Cosy</MenuRadioItem>
<MenuRadioItem value="roomy">Roomy</MenuRadioItem>
</MenuRadioGroup>
</MenuGroup>
</Menu>
<p className="m-0 text-[0.75rem] text-(--neba-muted-fg)">
{density} ·{' '}
{Object.entries(columns)
.filter(([, on]) => on)
.map(([key]) => key)
.join(', ') || 'no columns'}
</p>
</div>
);
}ContextMenu
우클릭으로 열리는 메뉴입니다. content에 메뉴 행을, children에 대상 영역을 넘깁니다.
tsx
import { ContextMenu, MenuItem, MenuSeparator, MenuSubmenu } from 'neba';
/**
* The same rows, opened by a right-click or a long press instead of by a button.
* It takes the rows as `content` and the area as `children` — Tooltip's shape
* rather than Menu's, because the trigger here is a region rather than an
* element you hand over.
*/
export default function MenuContext() {
return (
<ContextMenu
content={
<>
<MenuItem shortcut="⌘X">Cut</MenuItem>
<MenuItem shortcut="⌘C">Copy</MenuItem>
<MenuItem shortcut="⌘V">Paste</MenuItem>
<MenuSeparator />
<MenuSubmenu label="Transform">
<MenuItem>To uppercase</MenuItem>
<MenuItem>To lowercase</MenuItem>
</MenuSubmenu>
<MenuSeparator />
<MenuItem color="danger">Delete</MenuItem>
</>
}
>
<div className="flex h-32 items-center justify-center rounded-(--neba-radius-md) border border-dashed border-(--n-line) text-[0.8125rem] text-(--neba-muted-fg) select-none">
Right-click anywhere in this box
</div>
</ContextMenu>
);
}size와 density
tsx
import { Button, Menu, MenuItem } from 'neba';
const SIZES = ['xs', 'sm', 'md', 'lg', 'xl'] as const;
export default function MenuSizes() {
return (
<div className="flex flex-wrap items-center gap-2">
{SIZES.map((size) => (
<Menu
key={size}
size={size}
trigger={
<Button size={size} variant="outline" color="secondary">
{size}
</Button>
}
>
<MenuItem shortcut="⌘E">Rename</MenuItem>
<MenuItem shortcut="⌘D">Duplicate</MenuItem>
<MenuItem>Move to archive</MenuItem>
</Menu>
))}
</div>
);
}side · align · openOnHover
side와 align은 trigger를 기준으로 팝업이 놓일 자리입니다. openOnHover는 클릭 없이 hover만으로 열리게 합니다.
접근성
menu/menuitemrole, 방향키 roving focus, Home/End, typeahead, Escape, 바깥 클릭으로 닫기, 닫을 때 trigger로 focus 복귀가 모두 처리됩니다.- 삭제처럼 파괴적인 행에는
color="danger"를 주세요. 글자색과 옅은 배경, focus ring이 함께 바뀝니다. - 라벨이 문자열이 아니면
label에 typeahead가 매칭할 문자열을 주세요. disabled행은 목록에 남고 typeahead에도 걸립니다. 행이 사라지면 "여기서는 쓸 수 없음"이 아니라 "그런 항목이 없음"으로 읽힙니다.