Menu
사용자가 취할 수 있는 선택지나 액션 리스트를 제공하는 컴포넌트입니다.
<overlay>import IconPencilLine from "@karrotmarket/lynx-monochrome-icon/IconPencilLine";
import IconPlusLine from "@karrotmarket/lynx-monochrome-icon/IconPlusLine";
import IconTrashcanLine from "@karrotmarket/lynx-monochrome-icon/IconTrashcanLine";
import { ActionButton } from "@seed-design/lynx-react";
import {
MenuContent,
MenuGroup,
MenuGroupLabel,
MenuItem,
MenuRoot,
MenuTrigger,
} from "@/components/ui/menu";
export default function Example() {
return (
<MenuRoot>
<MenuTrigger>
<ActionButton variant="neutralSolid">열기</ActionButton>
</MenuTrigger>
<MenuContent>
<MenuGroup>
<MenuGroupLabel>작업</MenuGroupLabel>
<MenuItem label="라이브러리에 추가" prefixIcon={<IconPlusLine />} />
<MenuItem
label="수정"
description="현재 항목을 수정합니다"
prefixIcon={<IconPencilLine />}
/>
</MenuGroup>
<MenuGroup>
<MenuItem
label="삭제"
description="이 작업은 되돌릴 수 없습니다"
tone="critical"
prefixIcon={<IconTrashcanLine />}
/>
</MenuGroup>
</MenuContent>
</MenuRoot>
);
}Installation
npx @seed-design/cli@latest add ui:menupnpm dlx @seed-design/cli@latest add ui:menuyarn dlx @seed-design/cli@latest add ui:menubun x @seed-design/cli@latest add ui:menu의존성 설치
npm install @seed-design/lynx-reactyarn add @seed-design/lynx-reactpnpm add @seed-design/lynx-reactbun add @seed-design/lynx-react아래 코드를 복사 후 붙여넣고 사용하세요
/**
* @file ui:menu
* @requires @seed-design/lynx-react@^1.0.0
* @requires @seed-design/lynx-css@^1.0.0
**/
import * as React from "@lynx-js/react";
import { Menu as SeedMenu, type LynxIconElementProps } from "@seed-design/lynx-react";
export interface MenuRootProps extends SeedMenu.RootProps {}
/**
* @see https://seed-design.io/lynx/components/menu
*/
export const MenuRoot = SeedMenu.Root;
export interface MenuAnchorProps extends SeedMenu.AnchorProps {}
/**
* 위치 기준점만 만드는 Anchor입니다. 탭으로 메뉴를 열고 닫으려면 MenuTrigger를 사용하세요.
*
* @see https://seed-design.io/lynx/components/menu
*/
export const MenuAnchor = SeedMenu.Anchor;
export interface MenuTriggerProps extends SeedMenu.TriggerProps {}
/**
* @see https://seed-design.io/lynx/components/menu
*/
export const MenuTrigger = SeedMenu.Trigger;
export interface MenuContentProps extends SeedMenu.ContentProps {
/**
* 지정하면 Lynx view 밖까지 덮는 native overlay에 렌더링합니다. 생략하면 Lynx view 안의 고정 native
* `view`로 렌더링합니다.
*/
container?: SeedMenu.PositionerProps["container"];
overlayLevel?: SeedMenu.PositionerProps["overlayLevel"];
}
/**
* 메뉴 레이어(Positioner), 위치를 계산하는 표면(Content)과 긴 목록의 스크롤 영역(ScrollArea)을
* 조립합니다. 자식에 별도 Positioner나 ScrollArea를 추가하지 마세요.
*
* @see https://seed-design.io/lynx/components/menu
*/
export const MenuContent = React.forwardRef<unknown, MenuContentProps>(
({ children, container, overlayLevel, ...otherProps }, ref) => {
return (
<SeedMenu.Positioner container={container} overlayLevel={overlayLevel}>
<SeedMenu.Content ref={ref} {...otherProps}>
<SeedMenu.ScrollArea>{children}</SeedMenu.ScrollArea>
</SeedMenu.Content>
</SeedMenu.Positioner>
);
},
);
MenuContent.displayName = "MenuContent";
export interface MenuGroupProps extends SeedMenu.GroupProps {}
export const MenuGroup = SeedMenu.Group;
export interface MenuGroupLabelProps extends SeedMenu.GroupLabelProps {}
export const MenuGroupLabel = SeedMenu.GroupLabel;
export interface MenuItemProps extends Omit<SeedMenu.ItemProps, "children"> {
label: React.ReactNode;
description?: React.ReactNode;
prefixIcon?: React.ReactElement<LynxIconElementProps>;
suffixIcon?: React.ReactElement<LynxIconElementProps>;
}
/**
* 아이콘, 레이블, 설명 슬롯을 조립해 한 항목으로 제공합니다. `bindtap`은 선택 직전에
* 호출되고, 비활성 항목에는 호출되지 않습니다.
*
* @see https://seed-design.io/lynx/components/menu
*/
export const MenuItem = React.forwardRef<unknown, MenuItemProps>(
(
{
label,
description,
prefixIcon,
suffixIcon,
"accessibility-label": accessibilityLabel,
...otherProps
},
ref,
) => {
return (
<SeedMenu.Item
ref={ref}
accessibility-label={accessibilityLabel ?? (typeof label === "string" ? label : undefined)}
{...otherProps}
>
{prefixIcon ? <SeedMenu.PrefixIcon icon={prefixIcon} /> : null}
<SeedMenu.ItemBody>
<SeedMenu.ItemLabel>{label}</SeedMenu.ItemLabel>
{description != null ? (
<SeedMenu.ItemDescription>{description}</SeedMenu.ItemDescription>
) : null}
</SeedMenu.ItemBody>
{suffixIcon ? <SeedMenu.SuffixIcon icon={suffixIcon} /> : null}
</SeedMenu.Item>
);
},
);
MenuItem.displayName = "MenuItem";
/**
* This file is a snippet from SEED Design, helping you get started quickly with @seed-design/* packages.
* You can extend this snippet however you want.
*/
Usage
MenuContent는 긴 목록을 스크롤합니다. container를 지정하지 않으면 Lynx view 안에, 지정하면 native overlay에 렌더링됩니다.
import { ActionButton } from "@seed-design/lynx-react";
import {
MenuContent,
MenuGroup,
MenuGroupLabel,
MenuItem,
MenuRoot,
MenuTrigger,
} from "@/components/ui/menu";
export function App() {
return (
<MenuRoot>
<MenuTrigger>
<ActionButton variant="neutralSolid">열기</ActionButton>
</MenuTrigger>
<MenuContent>
<MenuGroup>
<MenuGroupLabel>작업</MenuGroupLabel>
<MenuItem label="항목 1" />
<MenuItem label="항목 2" />
</MenuGroup>
<MenuGroup>
<MenuItem label="항목 3" />
</MenuGroup>
</MenuContent>
</MenuRoot>
);
}MenuAnchor는 위치만 정하며 Menu를 열지 않습니다.open과onOpenChange로 상태를 제어하세요.MenuContent안에 별도의 positioner나 scroll area를 추가하지 마세요.MenuItem은description,prefixIcon,suffixIcon을 지원합니다.
Props
MenuRoot
Prop
Type
onOpenChange?((open: boolean, details: MenuOpenChangeDetails) => void) | undefinedsize?"small" | "medium" | "responsive" | undefinedopen?boolean | undefineddefaultOpen?boolean | undefinedMenuTrigger
Prop
Type
disabled?boolean | undefinedMenuAnchor
고유 prop이 없습니다. className, style, children을 받습니다.
MenuContent
Prop
Type
overlayLevel?4 | 2 | 1 | 3 | undefinedMenuGroup
고유 prop이 없습니다. className, style, children을 받습니다.
MenuGroupLabel
고유 prop이 없습니다. className, style, children을 받습니다.
MenuItem
Prop
Type
labelReact.ReactNodedescription?React.ReactNodeprefixIcon?React.ReactElement<LynxIconElementProps, string | React.JSXElementConstructor<any>> | undefinedsuffixIcon?React.ReactElement<LynxIconElementProps, string | React.JSXElementConstructor<any>> | undefineddisabled?boolean | undefinedExamples
Trigger
MenuTrigger를 탭하면 닫힌 Menu가 열립니다. MenuItem을 탭하거나 바깥 영역을 탭하면 닫힙니다.
import IconPencilLine from "@karrotmarket/lynx-monochrome-icon/IconPencilLine";
import IconPlusLine from "@karrotmarket/lynx-monochrome-icon/IconPlusLine";
import IconTrashcanLine from "@karrotmarket/lynx-monochrome-icon/IconTrashcanLine";
import { ActionButton } from "@seed-design/lynx-react";
import {
MenuContent,
MenuGroup,
MenuGroupLabel,
MenuItem,
MenuRoot,
MenuTrigger,
} from "@/components/ui/menu";
export default function Example() {
return (
<MenuRoot>
<MenuTrigger>
<ActionButton variant="neutralSolid">열기</ActionButton>
</MenuTrigger>
<MenuContent>
<MenuGroup>
<MenuGroupLabel>작업</MenuGroupLabel>
<MenuItem label="라이브러리에 추가" prefixIcon={<IconPlusLine />} />
<MenuItem
label="수정"
description="현재 항목을 수정합니다"
prefixIcon={<IconPencilLine />}
/>
</MenuGroup>
<MenuGroup>
<MenuItem
label="삭제"
description="이 작업은 되돌릴 수 없습니다"
tone="critical"
prefixIcon={<IconTrashcanLine />}
/>
</MenuGroup>
</MenuContent>
</MenuRoot>
);
}Size
size로 Menu의 크기를 정합니다. 기본값은 "medium"입니다. "responsive"는 현재 화면 너비를 기준으로 "small" 또는 "medium"으로 바뀝니다.
import IconPencilLine from "@karrotmarket/lynx-monochrome-icon/IconPencilLine";
import IconPlusLine from "@karrotmarket/lynx-monochrome-icon/IconPlusLine";
import { ActionButton, HStack } from "@seed-design/lynx-react";
import {
MenuContent,
MenuGroup,
MenuGroupLabel,
MenuItem,
MenuRoot,
MenuTrigger,
type MenuRootProps,
} from "@/components/ui/menu";
function SizeMenu({ size, label }: { size: MenuRootProps["size"]; label: string }) {
return (
<MenuRoot size={size}>
<MenuTrigger>
<ActionButton variant="neutralSolid">{label}</ActionButton>
</MenuTrigger>
<MenuContent>
<MenuGroup>
<MenuGroupLabel>작업</MenuGroupLabel>
<MenuItem label="추가" prefixIcon={<IconPlusLine />} />
<MenuItem label="수정" prefixIcon={<IconPencilLine />} />
</MenuGroup>
</MenuContent>
</MenuRoot>
);
}
export default function Example() {
return (
<HStack gap="x4" align="center">
<SizeMenu size="medium" label="Medium" />
<SizeMenu size="small" label="Small" />
<SizeMenu size="responsive" label="Responsive" />
</HStack>
);
}With Description
MenuItem의 description에 항목의 부가 설명을 전달합니다.
import IconArrowUpBracketDownLine from "@karrotmarket/lynx-monochrome-icon/IconArrowUpBracketDownLine";
import IconPencilLine from "@karrotmarket/lynx-monochrome-icon/IconPencilLine";
import IconPlusLine from "@karrotmarket/lynx-monochrome-icon/IconPlusLine";
import { ActionButton } from "@seed-design/lynx-react";
import { MenuContent, MenuGroup, MenuItem, MenuRoot, MenuTrigger } from "@/components/ui/menu";
export default function Example() {
return (
<MenuRoot>
<MenuTrigger>
<ActionButton variant="neutralSolid">열기</ActionButton>
</MenuTrigger>
<MenuContent>
<MenuGroup>
<MenuItem
label="라이브러리에 추가"
description="내 라이브러리에 항목을 추가합니다"
prefixIcon={<IconPlusLine />}
/>
<MenuItem
label="수정"
description="현재 항목을 수정합니다"
prefixIcon={<IconPencilLine />}
/>
<MenuItem
label="공유"
description="다른 사람에게 공유합니다"
prefixIcon={<IconArrowUpBracketDownLine />}
/>
</MenuGroup>
</MenuContent>
</MenuRoot>
);
}Tone
MenuItem의 tone="critical"로 삭제처럼 주의가 필요한 액션을 표시합니다.
import IconPencilLine from "@karrotmarket/lynx-monochrome-icon/IconPencilLine";
import IconPlusLine from "@karrotmarket/lynx-monochrome-icon/IconPlusLine";
import IconTrashcanLine from "@karrotmarket/lynx-monochrome-icon/IconTrashcanLine";
import { ActionButton } from "@seed-design/lynx-react";
import { MenuContent, MenuGroup, MenuItem, MenuRoot, MenuTrigger } from "@/components/ui/menu";
export default function Example() {
return (
<MenuRoot>
<MenuTrigger>
<ActionButton variant="neutralSolid">열기</ActionButton>
</MenuTrigger>
<MenuContent>
<MenuGroup>
<MenuItem label="추가" prefixIcon={<IconPlusLine />} />
<MenuItem label="수정" prefixIcon={<IconPencilLine />} />
</MenuGroup>
<MenuGroup>
<MenuItem label="삭제" tone="critical" prefixIcon={<IconTrashcanLine />} />
</MenuGroup>
</MenuContent>
</MenuRoot>
);
}Disabled Items
disabled 항목은 탭해도 bindtap이 실행되지 않고 Menu를 닫지 않습니다.
import IconArrowUpBracketDownLine from "@karrotmarket/lynx-monochrome-icon/IconArrowUpBracketDownLine";
import IconPencilLine from "@karrotmarket/lynx-monochrome-icon/IconPencilLine";
import IconPlusLine from "@karrotmarket/lynx-monochrome-icon/IconPlusLine";
import { ActionButton } from "@seed-design/lynx-react";
import { MenuContent, MenuGroup, MenuItem, MenuRoot, MenuTrigger } from "@/components/ui/menu";
export default function Example() {
return (
<MenuRoot>
<MenuTrigger>
<ActionButton variant="neutralSolid">열기</ActionButton>
</MenuTrigger>
<MenuContent>
<MenuGroup>
<MenuItem label="추가" prefixIcon={<IconPlusLine />} />
<MenuItem
label="수정"
description="현재 항목을 수정합니다"
prefixIcon={<IconPencilLine />}
disabled
/>
<MenuItem label="공유" prefixIcon={<IconArrowUpBracketDownLine />} />
</MenuGroup>
</MenuContent>
</MenuRoot>
);
}Placement
MenuRoot의 placement로 Menu를 기준 요소의 12개 방향에 배치합니다. 기본값은 "bottom"입니다. 화면 가장자리에서 공간이 부족하면 native runtime이 반대쪽으로 뒤집거나 경계 안으로 이동합니다.
import IconPencilLine from "@karrotmarket/lynx-monochrome-icon/IconPencilLine";
import IconPlusLine from "@karrotmarket/lynx-monochrome-icon/IconPlusLine";
import { ActionButton, Box, HStack, VStack } from "@seed-design/lynx-react";
import {
MenuContent,
MenuGroup,
MenuItem,
MenuRoot,
MenuTrigger,
type MenuRootProps,
} from "@/components/ui/menu";
function PlacementMenu({ placement }: { placement: NonNullable<MenuRootProps["placement"]> }) {
return (
<Box width="200px">
<MenuRoot placement={placement}>
<MenuTrigger>
<ActionButton variant="neutralSolid">{placement}</ActionButton>
</MenuTrigger>
<MenuContent>
<MenuGroup>
<MenuItem label="추가" prefixIcon={<IconPlusLine />} />
<MenuItem label="수정" prefixIcon={<IconPencilLine />} />
</MenuGroup>
</MenuContent>
</MenuRoot>
</Box>
);
}
export default function Example() {
return (
<scroll-view
scroll-orientation="horizontal"
style={{ display: "flex", flexDirection: "column", width: "100%", flex: 1, minHeight: 0 }}
>
<VStack width="full" minWidth="760px" maxWidth="760px" gap="x10" px="x10" py="x10">
<HStack gap="x10">
<PlacementMenu placement="top-end" />
<PlacementMenu placement="top" />
<PlacementMenu placement="top-start" />
</HStack>
<HStack gap="x10">
<PlacementMenu placement="left-end" />
<Box width="200px" />
<PlacementMenu placement="right-end" />
</HStack>
<HStack gap="x10">
<PlacementMenu placement="left" />
<Box width="200px" />
<PlacementMenu placement="right" />
</HStack>
<HStack gap="x10">
<PlacementMenu placement="left-start" />
<Box width="200px" />
<PlacementMenu placement="right-start" />
</HStack>
<HStack gap="x10">
<PlacementMenu placement="bottom-end" />
<PlacementMenu placement="bottom" />
<PlacementMenu placement="bottom-start" />
</HStack>
</VStack>
</scroll-view>
);
}Anchor
MenuAnchor는 Menu 위치만 정하고 탭으로 열고 닫지는 않습니다. 이 예제처럼 open과 onOpenChange로 상태를 제어할 수 있습니다.
import IconPencilLine from "@karrotmarket/lynx-monochrome-icon/IconPencilLine";
import IconPlusLine from "@karrotmarket/lynx-monochrome-icon/IconPlusLine";
import { useState } from "@lynx-js/react";
import { Box, HStack } from "@seed-design/lynx-react";
import { MenuAnchor, MenuContent, MenuGroup, MenuItem, MenuRoot } from "@/components/ui/menu";
import { Switch } from "@/components/ui/switch";
export default function Example() {
const [open, setOpen] = useState(false);
function handleOpenChange(nextOpen: boolean, details?: { reason?: string }) {
"background only";
if (!nextOpen && details?.reason === "interactOutside") return;
setOpen(nextOpen);
}
return (
<HStack width="full" align="center" justify="space-between">
<Switch tone="neutral" label="메뉴" checked={open} onCheckedChange={setOpen} />
<MenuRoot open={open} onOpenChange={handleOpenChange}>
<MenuAnchor>
<Box width="80px" height="80px" overflowX="hidden" overflowY="hidden" borderRadius="full">
<image
src="https://avatars.githubusercontent.com/u/54893898?v=4"
mode="aspectFill"
style={{ width: "80px", height: "80px" }}
/>
</Box>
</MenuAnchor>
<MenuContent>
<MenuGroup>
<MenuItem label="추가" prefixIcon={<IconPlusLine />} />
<MenuItem label="수정" prefixIcon={<IconPencilLine />} />
</MenuGroup>
</MenuContent>
</MenuRoot>
</HStack>
);
}Menu가 열려 있는 동안에는 화면 전체를 덮는 레이어가 바깥 탭을 받으므로 Anchor 바깥의 Switch를 탭할 수 없습니다. 이 예제는 Menu가 닫힌 상태에서 Switch로 열고, 항목을 선택해 닫는 흐름을 보여줍니다. 바깥 탭을 무시하는 제어는 onOpenChange에서 details.reason === "interactOutside"를 걸러 구현합니다.
Match Width
matchReferenceWidth를 사용하면 Menu 너비가 Trigger 또는 Anchor 너비와 같아집니다.
import { ActionButton, Box } from "@seed-design/lynx-react";
import { MenuContent, MenuGroup, MenuItem, MenuRoot, MenuTrigger } from "@/components/ui/menu";
export default function Example() {
return (
<Box width="full" maxWidth="400px">
<MenuRoot matchReferenceWidth>
<MenuTrigger>
<ActionButton variant="neutralSolid">열기</ActionButton>
</MenuTrigger>
<MenuContent>
<MenuGroup>
<MenuItem label="추가" />
<MenuItem label="수정" />
<MenuItem label="공유" />
</MenuGroup>
</MenuContent>
</MenuRoot>
</Box>
);
}Container
container를 지정하지 않으면 Menu 레이어는 Lynx view 안의 position: fixed view로 렌더링되고, z-index 99로 같은 화면의 형제 요소 위에 그려집니다. container="window"처럼 native container 이름을 지정하면 native overlay에 렌더링되어 Lynx view 밖의 host 화면까지 덮습니다. overlayLevel은 container를 지정했을 때 overlay의 표시 순서를 정합니다.
두 방식 모두 레이어 바깥을 탭하면 아래 화면에 탭을 전달하지 않고 "interactOutside" reason으로 닫힙니다.
import IconPencilLine from "@karrotmarket/lynx-monochrome-icon/IconPencilLine";
import IconPlusLine from "@karrotmarket/lynx-monochrome-icon/IconPlusLine";
import { useState } from "@lynx-js/react";
import { ActionButton, Text, VStack } from "@seed-design/lynx-react";
import { MenuContent, MenuGroup, MenuItem, MenuRoot, MenuTrigger } from "@/components/ui/menu";
export default function Example() {
const [closeReason, setCloseReason] = useState<string | null>(null);
const [count, setCount] = useState(0);
function handleOpenChange(open: boolean, details: { reason: string }) {
"background only";
if (!open) setCloseReason(details.reason);
}
function handleUnderlyingTap() {
"background only";
setCount((current) => current + 1);
}
return (
<VStack width="full" gap="x4">
<MenuRoot onOpenChange={handleOpenChange}>
<MenuTrigger>
<ActionButton variant="neutralSolid">window에 열기</ActionButton>
</MenuTrigger>
<MenuContent container="window">
<MenuGroup>
<MenuItem label="추가" prefixIcon={<IconPlusLine />} />
<MenuItem label="수정" prefixIcon={<IconPencilLine />} />
</MenuGroup>
</MenuContent>
</MenuRoot>
<ActionButton variant="neutralWeak" bindtap={handleUnderlyingTap}>
{`아래 버튼 ${count}`}
</ActionButton>
<Text textStyle="t3Regular" color="fg.neutralMuted">
{`마지막 닫힘 이유: ${closeReason ?? "-"}`}
</Text>
</VStack>
);
}onOpenChange details
onOpenChange의 두 번째 인자인 details에는 상태를 바꾼 이유와 native event가 들어옵니다.
- 열림:
"trigger" - 닫힘:
"trigger","interactOutside","itemClick" container를 지정한 native overlay가 닫힘을 요청한 경우(Android 뒤로 가기):"dismiss".container가 없으면 전달하지 않으며, Android 뒤로 가기는 Menu가 아니라 host 화면을 닫습니다.
import IconPencilLine from "@karrotmarket/lynx-monochrome-icon/IconPencilLine";
import IconPlusLine from "@karrotmarket/lynx-monochrome-icon/IconPlusLine";
import { useState } from "@lynx-js/react";
import { ActionButton, HStack, VStack } from "@seed-design/lynx-react";
import { MenuContent, MenuGroup, MenuItem, MenuRoot, MenuTrigger } from "@/components/ui/menu";
export default function Example() {
const [open, setOpen] = useState(false);
const [openReason, setOpenReason] = useState<string | null>(null);
const [closeReason, setCloseReason] = useState<string | null>(null);
function handleOpenChange(nextOpen: boolean, details?: { reason?: string }) {
"background only";
setOpen(nextOpen);
(nextOpen ? setOpenReason : setCloseReason)(details?.reason ?? null);
}
return (
<VStack width="full" gap="x4">
<MenuRoot open={open} onOpenChange={handleOpenChange}>
<MenuTrigger>
<ActionButton variant="neutralSolid">열기</ActionButton>
</MenuTrigger>
<MenuContent>
<MenuGroup>
<MenuItem label="추가" prefixIcon={<IconPlusLine />} />
<MenuItem label="수정" prefixIcon={<IconPencilLine />} />
</MenuGroup>
</MenuContent>
</MenuRoot>
<HStack width="full" gap="x4" style={{ display: "flex", flexWrap: "wrap" }}>
<text>마지막 열림 이유: {openReason ?? "-"}</text>
<text>마지막 닫힘 이유: {closeReason ?? "-"}</text>
</HStack>
</VStack>
);
}웹 버전과의 차이
- Trigger는 자식을 native
view로 감싸며asChild를 제공하지 않습니다. raw<text>처럼 이름을 만들 수 없는 자식에는accessibility-label을 지정하세요. - 키보드 focus, typeahead, ESC 키 닫힘은 지원하지 않습니다.
- 바깥 탭은
"interactOutside", native overlay의 닫기 요청은"dismiss"reason으로 전달합니다. - Menu가 열려 있는 동안 화면 전체 레이어가 Anchor 밖의 탭을 받습니다.
Last updated on