컴포넌트

Navigation Item

아이콘과 레이블로 탐색 항목을 표시합니다.

설치

pnpm dlx ply-ui add navigation-item badge

공통 설치 설정을 먼저 적용합니다.

사용법

화면 안의 작업은 NavigationItem, 주소 이동은 NavigationLink로 구성합니다. active는 현재 위치 표시입니다. leading이 있으면 icon 대신 그 내용을 표시합니다.

페이지 이동

NavigationLink는 실제 주소를 가진 링크입니다. active로 현재 페이지를 표시합니다.

보조 정보

trailing으로 개수나 상태를 표시합니다. 행 안에는 다른 버튼을 넣지 않습니다.

비활성

NavigationItem.disabled로 작업을 막습니다. 링크의 비활성 처리는 제공하지 않습니다.

속성

이 프로젝트에서 추가하거나 제한한 속성입니다.

속성

타입

속성

타입

구현 코드

components/ui/navigation-item.tsx
navigation-item.tsx
"use client";
import { hasContent } from "../../lib/content";

import type { ComponentProps, ReactNode } from "react";
import { Button as Primitive } from "@base-ui/react/button";
import { useRender } from "@base-ui/react/use-render";
import { cx, withClassName } from "../../lib/cx";
import { Icon, type IconName } from "./icon";
import "./navigation-item.css";

type NavigationContentProps = {
  icon?: IconName;
  /** Avatar 등으로 아이콘 자리를 교체합니다. 내부에 버튼을 넣지 마세요. */
  leading?: ReactNode;
  trailing?: ReactNode;
  children?: ReactNode;
};
export type NavigationItemProps = ComponentProps<typeof Primitive> &
  NavigationContentProps & { active?: boolean };
export type NavigationLinkProps = useRender.ComponentProps<
  "a",
  { active: boolean }
> &
  NavigationContentProps & { active?: boolean };

// 버튼과 링크가 같은 시각 파트를 재사용합니다. 동작은 각 HTML 역할을 보존합니다.
function NavigationContent({
  icon,
  leading,
  trailing,
  children,
}: NavigationContentProps) {
  const visual = leading ?? (icon ? <Icon name={icon} size={24} /> : null);
  return (
    <>
      {hasContent(visual) && (
        <span className="rbx-navigation-item-leading">{visual}</span>
      )}
      <span className="rbx-navigation-item-label">{children}</span>
      {hasContent(trailing) && (
        <span className="rbx-navigation-item-trailing">{trailing}</span>
      )}
    </>
  );
}
/** 화면 내 선택/작업: Base UI Button. */
export function NavigationItem({
  icon,
  leading,
  trailing,
  active = false,
  children,
  className,
  ...props
}: NavigationItemProps) {
  return (
    <Primitive
      aria-current={active ? "page" : undefined}
      {...props}
      data-active={active || undefined}
      data-leading={icon || hasContent(leading) ? "true" : undefined}
      className={withClassName("rbx-navigation-item", className)}
    >
      <NavigationContent icon={icon} leading={leading} trailing={trailing}>
        {children}
      </NavigationContent>
    </Primitive>
  );
}
/** 페이지 이동: native anchor. render={<Link href="…" />}로 라우터도 조합합니다. */
export function NavigationLink({
  icon,
  leading,
  trailing,
  active = false,
  children,
  className,
  render,
  ref,
  ...props
}: NavigationLinkProps) {
  return useRender({
    defaultTagName: "a",
    render,
    ref,
    state: { active },
    props: {
      "aria-current": active ? "page" : undefined,
      ...props,
      "data-leading": icon || hasContent(leading) ? "true" : undefined,
      className: cx("rbx-navigation-item", className),
      children: (
        <NavigationContent icon={icon} leading={leading} trailing={trailing}>
          {children}
        </NavigationContent>
      ),
    },
  });
}
components/ui/navigation-item.css
navigation-item.css
@layer theme, base, components, utilities;
@layer components.ply-base, components.ply;

@layer components.ply {
  /* Roblox Home / Navigation.css: 40px row, 40px icon slot, 24px glyph. */
  .rbx-navigation-item {
    box-sizing: border-box;
    position: relative;
    isolation: isolate;
    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
    min-width: 0;
    height: 40px;
    padding-block: 0;
    padding-inline: 4px 2px;
    border: 0;
    border-radius: var(--rbx-radius-medium);
    background: transparent;
    color: var(--rbx-color-content-emphasis);
    font: 700 16px/1.4 var(--rbx-font-body);
    text-align: start;
    text-decoration: none;
    cursor: pointer;
  }
  /* Overlay preserves the selected background underneath hover/press. */
  .rbx-navigation-item::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    border-radius: inherit;
    pointer-events: none;
    transition: background-color var(--rbx-motion-duration-feedback)
      var(--rbx-motion-ease-enter);
  }
  .rbx-navigation-item[data-active],
  .rbx-navigation-item[aria-current="page"] {
    background: var(--rbx-color-shift-200);
  }
  @media (hover: hover) {
    .rbx-navigation-item:not([data-disabled]):hover::before {
      background: var(--rbx-color-state-hover);
    }
  }
  .rbx-navigation-item:not([data-disabled]):active::before {
    background: var(--rbx-color-state-press);
  }
  .rbx-navigation-item:focus-visible {
    outline: 3px solid var(--rbx-color-selection-start);
    outline-offset: 3px;
  }
  .rbx-navigation-item[data-disabled] {
    opacity: 0.5;
    cursor: not-allowed;
  }
  .rbx-navigation-item-leading {
    display: flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 40px;
    height: 40px;
  }
  .rbx-navigation-item-label {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .rbx-navigation-item-trailing {
    display: flex;
    flex-shrink: 0;
    margin-inline-start: auto;
  }
  /* Text-only catalog rows are a BLOCK/UI extension. */
  .rbx-navigation-item:not([data-leading]) {
    padding-inline: 12px;
  }
  @media (prefers-reduced-motion: reduce) {
    .rbx-navigation-item::before {
      transition: none;
    }
  }
}

목차