# 모바일 내비 시트

좁은 화면에서 헤더 메뉴를 여는 서랍이에요. 링크, CTA, 설정 줄, 계정이 한 덩어리로 들어가요.

- Type: block
- Theme: base
- Tags: arc, block, navigation, drawer, mobile, menu, header
- English title: Mobile nav sheet
- URL: https://dsui.vercel.app/items/mobile-nav-sheet

## Used items

- [서랍](https://dsui.vercel.app/items/drawer): 화면 가장자리에서 밀려 나오는 패널이에요.

## Controls

| Key | Type | Label | Default |
| --- | --- | --- | --- |
| side | select | 열리는 쪽 | right |
| title | text | 제목 | 메뉴 |
| triggerLabel | text | 버튼 이름 | 메뉴 열기 |

## Source

### mobile-nav-sheet.module.css

```css
/* The drawer supplies the panel, its shadow and its matching border; the sheet only lays the contents out. */
.sheet { display: flex; min-height: 100%; flex-direction: column; gap: var(--space-5); }
.trigger { display: inline-flex; width: var(--control-height-md); height: var(--control-height-md); align-items: center; justify-content: center; border: 0; border-radius: var(--radius-pill); background: var(--surface-muted); color: var(--foreground); cursor: pointer; -webkit-tap-highlight-color: transparent; transition: background var(--duration-fast) var(--ease-standard); }
.nav { margin: 0; }
.list { display: flex; margin: 0; padding: 0; flex-direction: column; gap: var(--space-1); list-style: none; }
/* Touch targets are the full width of the sheet and comfortably past 44px, since this is the thumb's menu. */
.link { display: flex; min-height: var(--control-height-lg); align-items: center; gap: var(--space-3); border-radius: var(--radius-control); padding: 0 var(--space-4); color: var(--foreground); font-size: var(--label-lg); font-weight: var(--label-weight); line-height: var(--leading-body); text-decoration: none; transition: color var(--duration-fast) var(--ease-standard), background var(--duration-fast) var(--ease-standard); }
.linkLabel { flex: 1 1 auto; min-width: 0; }
.chevron { flex: none; color: var(--text-secondary); }
.link[aria-current="page"] { background: var(--accent-subtle); color: var(--accent); }
.link[aria-current="page"] .chevron { color: var(--accent); }
.cta { display: flex; min-height: var(--control-height-lg); align-items: center; justify-content: center; border: 1px solid var(--accent); border-radius: var(--radius-control); background: var(--accent); color: var(--accent-foreground); font-size: var(--label-lg); font-weight: var(--label-weight); line-height: var(--leading-body); text-decoration: none; transition: background var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard); }
.settings { display: flex; flex-direction: column; gap: var(--space-3); border-top: 1px solid var(--border-subtle); padding-top: var(--space-5); }
.settingRow { display: flex; align-items: center; gap: var(--space-3); }
.settingLabel { flex: none; width: calc(var(--space-16) + var(--space-10)); color: var(--text-secondary); font-size: var(--label-md); }
.settingControl { display: flex; min-width: 0; flex: 1 1 auto; justify-content: flex-end; }
.account { margin-top: auto; display: flex; align-items: center; border-radius: var(--radius-control); padding: var(--space-2) var(--space-3); background: var(--surface-muted); }
@media (hover: hover) and (pointer: fine) {
  .trigger:hover { background: var(--surface-sunken); }
  .link:hover { background: var(--surface-muted); }
  .link[aria-current="page"]:hover { background: var(--accent-subtle); }
  .cta:hover { background: var(--accent-strong); border-color: var(--accent-strong); }
}
.trigger:active { background: var(--surface-sunken); }
.link:active { background: var(--surface-sunken); }
.cta:active { background: var(--accent-strong); border-color: var(--accent-strong); }
@media (prefers-reduced-motion: reduce) { .trigger, .link, .cta { transition: none; } }

```

### mobile-nav-sheet.tsx

```tsx
"use client";

import { useState } from "react";
import type { ReactNode } from "react";
import { HugeiconsIcon } from "@hugeicons/react";
import { Menu01Icon } from "@hugeicons/core-free-icons";
import { Drawer, DrawerContent, DrawerTrigger } from "../../drawer/drawer";
import { playUiSound } from "../../sound/play";
import { UiIcon } from "../../theme/ui-icon";
import styles from "./mobile-nav-sheet.module.css";

export interface MobileNavLink {
  href: string;
  label: string;
  /** Marks the page the reader is on. */
  current?: boolean;
}

export interface MobileNavSettingRow {
  label: string;
  control: ReactNode;
}

export interface MobileNavSheetProps {
  /** The sheet's accessible title, e.g. "메뉴". */
  title: string;
  links: MobileNavLink[];
  /** The accessible name of the list of links, e.g. "주요 메뉴". */
  navLabel: string;
  cta?: { href: string; label: string };
  /** One row per setting, such as language and theme. */
  settings?: MobileNavSettingRow[];
  /** The account block, pinned to the bottom of the sheet. */
  account?: ReactNode;
  /** The accessible name of the default menu button, e.g. "메뉴 열기". */
  triggerLabel?: string;
  /** Replaces the default menu button. Needs to accept a ref and the Radix trigger props. */
  trigger?: ReactNode;
  side?: "left" | "right";
  open?: boolean;
  onOpenChange?: (open: boolean) => void;
}

/**
 * The header's menu on a narrow screen: the same links, the same call to action, and the settings and account that
 * do not fit in the bar. It is a drawer, so focus is trapped while it is open, Escape closes it and focus returns to
 * the button. Choosing a link closes it on the press rather than on the route change, so the panel never lingers
 * over the next page.
 */
export function MobileNavSheet({
  title,
  links,
  navLabel,
  cta,
  settings,
  account,
  triggerLabel = "메뉴 열기",
  trigger,
  side = "right",
  open: openProp,
  onOpenChange,
}: MobileNavSheetProps) {
  const [openState, setOpenState] = useState(false);
  const open = openProp ?? openState;
  const setOpen = (next: boolean) => { setOpenState(next); onOpenChange?.(next); };
  const close = () => setOpen(false);

  return (
    <Drawer open={open} onOpenChange={setOpen}>
      <DrawerTrigger asChild>
        {trigger ?? (
          <button type="button" className={styles.trigger} aria-label={triggerLabel}>
            <HugeiconsIcon icon={Menu01Icon} size={20} strokeWidth={1.75} aria-hidden="true" />
          </button>
        )}
      </DrawerTrigger>
      <DrawerContent title={title} side={side}>
        <div className={styles.sheet}>
          <nav aria-label={navLabel} className={styles.nav}>
            <ul className={styles.list}>
              {links.map(link => (
                <li key={link.href}>
                  <a
                    href={link.href}
                    aria-current={link.current ? "page" : undefined}
                    className={styles.link}
                    onClick={() => { playUiSound("tick"); close(); }}
                  >
                    <span className={styles.linkLabel}>{link.label}</span>
                    <UiIcon iconRole="chevron-right" size={16} strokeWidth={1.75} className={styles.chevron} aria-hidden="true" />
                  </a>
                </li>
              ))}
            </ul>
          </nav>

          {cta ? (
            <a href={cta.href} className={styles.cta} onClick={() => { playUiSound("click"); close(); }}>
              {cta.label}
            </a>
          ) : null}

          {settings?.length ? (
            <div className={styles.settings}>
              {settings.map(row => (
                <div key={row.label} className={styles.settingRow}>
                  <span className={styles.settingLabel}>{row.label}</span>
                  <div className={styles.settingControl}>{row.control}</div>
                </div>
              ))}
            </div>
          ) : null}

          {account ? <div className={styles.account}>{account}</div> : null}
        </div>
      </DrawerContent>
    </Drawer>
  );
}

MobileNavSheet.displayName = "MobileNavSheet";

export default MobileNavSheet;

```
