Files
HMDesign/src/components/navigation-menu.stories.tsx
T

147 lines
6.5 KiB
TypeScript

import type { ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuIndicator,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
} from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Navigation Menu is for destinations (links), not commands. The representative Story opens the real menus with hover, click or
// keyboard; the same specimens also exist as Stories tagged `verification` (hidden from the sidebar). Title + description children
// inside a content link are marked with `data-description` for the secondary text.
function Destination({ href, title, description, active }: { href: string; title: string; description?: string; active?: boolean }) {
return (
<NavigationMenuLink href={href} active={active}>
<span>{title}</span>
{description ? <span data-description="">{description}</span> : null}
</NavigationMenuLink>
)
}
function Specimen({ viewport = true }: { viewport?: boolean }) {
return (
<NavigationMenu viewport={viewport}>
<NavigationMenuList>
<NavigationMenuItem>
<NavigationMenuTrigger>제품</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="sm:w-[min(360px,calc(100cqw-18px))]" style={{ display: "grid", gap: 4 }}>
<li>
<Destination href="#analytics" title="분석" description="노선과 시설물 데이터를 한눈에 확인합니다." />
</li>
<li>
<Destination href="#maps" title="지도" description="공간 정보를 지도 위에서 관리합니다." />
</li>
<li>
<Destination href="#reports" title="리포트" description="점검 결과를 보고서로 만듭니다." />
</li>
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger>자료</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="sm:w-[min(300px,calc(100cqw-18px))]" style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 4 }}>
<li>
<Destination href="#docs" title="문서" />
</li>
<li>
<Destination href="#guides" title="가이드" />
</li>
<li>
<Destination href="#api" title="API" />
</li>
<li>
<Destination href="#blog" title="블로그" />
</li>
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink href="#company" active>
회사
</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuLink href="#contact">문의</NavigationMenuLink>
</NavigationMenuItem>
<NavigationMenuItem>
<NavigationMenuTrigger disabled>준비 중</NavigationMenuTrigger>
</NavigationMenuItem>
</NavigationMenuList>
{viewport ? <NavigationMenuIndicator /> : null}
</NavigationMenu>
)
}
// Size specimens to their review area (including the panel's 8px padding + 1px border on each side).
// Mobile uses the content's natural minimum width instead of a fixed desktop specimen width.
// `minHeight: 256` reserves room for the open panel (about 232px below the menu bar) so it never covers what follows.
// `compact` (representative page only) drops that reservation on PC (iframe width 1000px and up), where the two examples sit side by
// side and nothing is below them; in the one-column layout below 1000px the 256px stays so an open menu does not cover the next example.
const frame = (node: ReactNode, compact = false) => (
<div
style={{ width: "100%", containerType: "inline-size", ...(compact ? {} : { minHeight: 256 }) }}
className={`max-md:[&_[data-viewport=false]_[data-slot=navigation-menu-content]]:absolute${compact ? " max-[999px]:min-h-64" : ""}`}
>
{node}
</div>
)
const meta = { title: "Components/Navigation/Navigation Menu", component: NavigationMenu, parameters: { layout: "padded" } } satisfies Meta<typeof NavigationMenu>
export default meta
type Story = StoryObj<typeof meta>
export const All: Story = {
name: "Navigation Menu",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader title="Navigation Menu" />
{/* Overview is merged into Variants (Viewport is its first item). Two equal columns on PC (iframe width 1000px and up), one
column below that; each specimen sits in its own single-column StateBoard (min larger than any column). */}
<StorySection title="Variants">
<div className="grid items-start gap-6 min-[1000px]:grid-cols-2">
<StateBoard min={10000} states={[{ id: "viewport", label: "Viewport", children: frame(<Specimen />, true) }]} />
<StateBoard min={10000} states={[{ id: "no-viewport", label: "No viewport", children: frame(<Specimen viewport={false} />, true) }]} />
</div>
</StorySection>
</StoryPage>
),
}
export const Default: Story = { tags: ["verification"], render: () => frame(<Specimen />) }
export const NoViewport: Story = { tags: ["verification"], name: "No Viewport", render: () => frame(<Specimen viewport={false} />) }
// Three enabled triggers with different widths exercise first / middle / last offsets in both positioning modes.
export const IndicatorAlignment: Story = {
tags: ["verification"],
render: () => (
<StateBoard min={520} states={[true, false].map((viewport) => ({
id: viewport ? "viewport" : "no-viewport",
label: `Indicator alignment · viewport=${viewport}`,
children: frame(
<NavigationMenu viewport={viewport}>
<NavigationMenuList>
{["제품", "기술 자료", "지원 센터"].map((label) => (
<NavigationMenuItem key={label}>
<NavigationMenuTrigger>{label}</NavigationMenuTrigger>
<NavigationMenuContent><Destination href="#verification" title={`${label} 안내`} /></NavigationMenuContent>
</NavigationMenuItem>
))}
</NavigationMenuList>
{viewport ? <NavigationMenuIndicator /> : null}
</NavigationMenu>
),
}))} />
),
}