Menu
무언가를 눌렀을 때 나타나는 액션 목록입니다. 중첩되고, 상태를 들고, 키보드만으로 끝까지 다룰 수 있습니다.
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>;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가 됩니다. 링크로 이뤄진 메뉴는 링크여야 새 탭으로 열 수 있습니다 |
| 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 | — | 열리거나 닫힐 때 |
| size공통 | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'md' | 높이와 타입 스케일 |
| color공통 | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info' | 'primary' | 의미론적 색 역할. 임의 색상값은 받지 않습니다 |
| density공통 | 'default' | 'compact' | 'default' | 행의 여백만 바꿉니다 |
예시
그룹, 링크, 아이콘
href를 주면 행이 진짜 <a>가 됩니다. 이건 사소한 차이가 아닙니다 — 링크가 아닌 링크 메뉴는 새 탭으로 열 수도, 주소를 복사할 수도 없고, 스크린 리더에게는 모든 행에 대해 틀린 말을 합니다.
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일 수 있기 때문입니다. Base UI가 호버로 열면서 안전 삼각형을 그려 주므로, 열린 서브메뉴로 비스듬히 손을 뻗어도 닫히지 않습니다.
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>
);
}상태를 든 행
체크는 "그리고"를, 점은 "대신"을 뜻합니다 — 라이브러리 어디서나 Checkbox와 Radio가 만드는 것과 같은 구분입니다. 둘 다 고른 뒤에도 메뉴를 닫지 않습니다: 체크할 것들의 목록은 여러 개를 체크하는 목록이니까요.
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>
);
}컨텍스트 메뉴
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>
);
}크기
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>
);
}행은 데이터가 아니라 코드입니다
Select는 items 배열을 받습니다. 메뉴는 받지 않습니다. 정반대이고, 의도된 것입니다.
셀렉트의 옵션은 호출하는 쪽이 이미 갖고 있는 목록의 값들입니다. 메뉴의 행은 코드입니다 — 행마다 다른 핸들러, 다른 아이콘, 어떤 것은 서브메뉴. 데이터로 만들면 행이 취할 수 있는 모든 모양마다 변형이 하나씩 있는 items 타입이 되고, 그건 판별 유니온으로 적어 놓은 컴포넌트 트리입니다.
팝업은 Select의 팝업과 같습니다
픽셀 단위로 같습니다. 셀렉트는 무엇을 골랐는지 기억하는 메뉴이고, 서로 맞지 않는 두 개의 떠 있는 행 목록은 눈이 따로 익혀야 하는 두 개의 목록이기 때문입니다.
행의 여백만은 다릅니다. List의 행은 다른 것이 폭을 정해 준 시트를 가로지르지만, 메뉴의 행은 자기 가장 긴 라벨만큼만 넓은 팝업 안에 있습니다. md에서 Box의 px-4는 "잘라내기"라고 적힌 메뉴에 32px을 더하고, 그것이 다섯 줄짜리 메뉴가 다이얼로그만큼 넓어지는 방법입니다.
Base UI가 하는 일
메뉴를 "떠 있는 div 목록"이 아니라 메뉴로 만드는 것 전부입니다. 화살표 키의 roving focus, Home과 End, 타이핑 검색, Escape, 바깥 클릭으로 닫기, 트리거로 포커스 되돌리기, 호버로 열리는 서브메뉴와 안전 삼각형, 그리고 그 모든 것이 스크린 리더에게 의미를 갖게 하는 menu / menuitem 역할.
여기 있는 것은 표면과 사다리와 행의 배치입니다.
접근성
- 지우는 행에는
color="danger"를 주세요. 색 계열 전체가 넘어가므로 글자색, 옅은 배경, 포커스 링이 함께 바뀝니다. - 라벨이 평범한 문자열이 아니면
label로 타이핑 검색이 맞춰 볼 문자열을 주세요. disabled행은 목록에 남고 타이핑 검색에도 걸립니다. 사라지지 않는 것이 요점입니다 — 없어진 행은 "여기서는 쓸 수 없음"이 아니라 "그런 건 없음"을 뜻하니까요.