feat(storybook): refine Manager LNB, HM Color and Patterns/Layout/LNB
- Manager LNB: 246px width, 16px brand title, 14px/700 menu and section titles, depth1 48px / depth2+ 42px rows, 24px section gap - Foundation: rename Color to HM Color (Story ID foundation-color--all kept), remove Company Colors subtitle and the page-title bottom border - Patterns/Layout/LNB: Storybook-only overrides in lnb.stories.css (240px column, 212px min width, 14px menu, 48/42px rows, profile text, group dividers); representative board dividers per section - Sync story inventory and docs (brand-color-steps, lnb); shared package defaults and tokens are unchanged
This commit is contained in:
1 parent
228944a06b
commit
fc0a3e7725
12 files changed
+246
-86
No files matched your search
@@ -32,9 +32,11 @@ import {
|
||||
} from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
import "./lnb.stories.css"
|
||||
|
||||
// LNB is a composition pattern. Layout visual reference is the ui-viz LNB (272px green column, 18/700 items, Beige current item joined
|
||||
// to the content, Black 10% hover, Black 8% open group). The menu data and the current location are local sample data for this Story
|
||||
// LNB is a composition pattern. Layout visual reference is the ui-viz LNB (the shared package default: 272px green column, 18/700 items,
|
||||
// Beige current item joined to the content, Black 10% hover, Black 8% open group). This Story overrides only the display values (240px column,
|
||||
// 212px min width, 14px items, 48/42px rows) in lnb.stories.css; the package defaults are unchanged. The menu data and the current location are local sample data for this Story
|
||||
// (no router). The content next to the column is a positioned surface with `--hm-lnb-join` rounded left corners: the LNB Mode page
|
||||
// owns it, here it only shows the connection. The column is desktop only (hidden below 1024px).
|
||||
|
||||
@@ -106,6 +108,12 @@ const contentStyle: CSSProperties = {
|
||||
type Child = { id: string; label: string }
|
||||
type LocalNode = { id: string; label: string; icon?: LucideIcon; children?: LocalNode[] }
|
||||
|
||||
// Line between large menu groups (a top-level group and its neighbours). A direct child of LNBNav, so it follows the menu rows'
|
||||
// x start; the look is in lnb.stories.css (same line as the profile divider). Desktop column only: the mobile Sheet is unchanged.
|
||||
function GroupDivider() {
|
||||
return <div role="separator" className="hm-lnb-story-group-divider" />
|
||||
}
|
||||
|
||||
function Menu({
|
||||
initial = "overview",
|
||||
groupOpen = true,
|
||||
@@ -116,6 +124,7 @@ function Menu({
|
||||
many,
|
||||
height,
|
||||
profile = true,
|
||||
dividers = true,
|
||||
}: {
|
||||
initial?: string | null
|
||||
groupOpen?: boolean
|
||||
@@ -128,6 +137,8 @@ function Menu({
|
||||
height?: number
|
||||
/** Story composition only: GNB + LNB gives profile ownership to the GNB. */
|
||||
profile?: boolean
|
||||
/** Group divider lines around the menu group. `false` hides them (the profile divider above the profile stays). */
|
||||
dividers?: boolean
|
||||
}) {
|
||||
const [current, setCurrent] = useState<string | null>(initial)
|
||||
const [open, setOpen] = useState(groupOpen)
|
||||
@@ -162,6 +173,7 @@ function Menu({
|
||||
<LayoutGridIcon aria-hidden="true" />
|
||||
{longLabels ? "프로젝트 현황과 전체 일정을 한눈에 보는 개요" : "메뉴 1"}
|
||||
</LNBItem>
|
||||
{dividers ? <GroupDivider /> : null}
|
||||
<LNBGroup label={longLabels ? "업무 관리와 진행 상황 점검" : "메뉴 2"} current={Boolean(current?.startsWith("tasks/"))} open={open} onOpenChange={setOpen}>
|
||||
{children.map((child) => (
|
||||
<LNBItem key={child.id} href={`#${child.id}`} active={current === child.id} onClick={go(child.id)}>
|
||||
@@ -169,6 +181,7 @@ function Menu({
|
||||
</LNBItem>
|
||||
))}
|
||||
</LNBGroup>
|
||||
{dividers ? <GroupDivider /> : null}
|
||||
<LNBItem href="#reports" active={current === "reports"} onClick={go("reports")}>
|
||||
<ChartColumnIcon aria-hidden="true" />
|
||||
메뉴 3
|
||||
@@ -262,6 +275,7 @@ function ThirdLevelMenu({
|
||||
<LayoutGridIcon aria-hidden="true" />
|
||||
대시보드
|
||||
</LNBItem>
|
||||
<GroupDivider />
|
||||
<LNBGroup label="프로젝트 관리" current={onPath("projects")} open={open.projects} onOpenChange={(value) => setOpen((state) => ({ ...state, projects: value }))}>
|
||||
<LNBGroup label="디자인 관리" current={onPath("projects/design")} open={open.design} onOpenChange={(value) => setOpen((state) => ({ ...state, design: value }))}>
|
||||
{thirdLevelChildren(longLabels).map((child) => (
|
||||
@@ -277,6 +291,7 @@ function ThirdLevelMenu({
|
||||
일정 관리
|
||||
</LNBItem>
|
||||
</LNBGroup>
|
||||
<GroupDivider />
|
||||
<LNBItem href="#reports" active={current === "reports"} onClick={go("reports")}>
|
||||
<ChartColumnIcon aria-hidden="true" />
|
||||
현황 보고
|
||||
@@ -361,6 +376,7 @@ function ResponsiveSpecimen({
|
||||
brand = "프로젝트 대시보드",
|
||||
longLabels,
|
||||
height,
|
||||
dividers = true,
|
||||
}: {
|
||||
nodes: LocalNode[]
|
||||
initial: string | null
|
||||
@@ -370,6 +386,8 @@ function ResponsiveSpecimen({
|
||||
brand?: string
|
||||
longLabels?: boolean
|
||||
height?: number
|
||||
/** Group divider lines in the desktop menu, like `Menu`'s `dividers`. `false` hides them (the profile divider stays). */
|
||||
dividers?: boolean
|
||||
}) {
|
||||
const [current, setCurrent] = useState<string | null>(initial)
|
||||
const [open, setOpen] = useState<Record<string, boolean>>(initialOpen)
|
||||
@@ -380,25 +398,27 @@ function ResponsiveSpecimen({
|
||||
}
|
||||
const onPath = (id: string) => Boolean(current?.startsWith(`${id}/`))
|
||||
const isOpen = (id: string) => open[id] ?? true
|
||||
const lnbMenu = (items: LocalNode[]): ReactNode =>
|
||||
items.map((node) =>
|
||||
node.children ? (
|
||||
<LNBGroup
|
||||
key={node.id}
|
||||
label={node.label}
|
||||
current={onPath(node.id)}
|
||||
open={isOpen(node.id)}
|
||||
onOpenChange={(value) => setOpen((state) => ({ ...state, [node.id]: value }))}
|
||||
>
|
||||
{lnbMenu(node.children)}
|
||||
</LNBGroup>
|
||||
) : (
|
||||
<LNBItem key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
|
||||
{node.icon ? <node.icon aria-hidden="true" /> : null}
|
||||
{node.label}
|
||||
</LNBItem>
|
||||
)
|
||||
)
|
||||
const lnbMenu = (items: LocalNode[], top = true): ReactNode =>
|
||||
items.map((node, index) => (
|
||||
<Fragment key={node.id}>
|
||||
{dividers && top && index > 0 && (node.children || items[index - 1].children) ? <GroupDivider /> : null}
|
||||
{node.children ? (
|
||||
<LNBGroup
|
||||
label={node.label}
|
||||
current={onPath(node.id)}
|
||||
open={isOpen(node.id)}
|
||||
onOpenChange={(value) => setOpen((state) => ({ ...state, [node.id]: value }))}
|
||||
>
|
||||
{lnbMenu(node.children, false)}
|
||||
</LNBGroup>
|
||||
) : (
|
||||
<LNBItem href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
|
||||
{node.icon ? <node.icon aria-hidden="true" /> : null}
|
||||
{node.label}
|
||||
</LNBItem>
|
||||
)}
|
||||
</Fragment>
|
||||
))
|
||||
const sheetMenu = (items: LocalNode[]): ReactNode =>
|
||||
items.map((node) =>
|
||||
node.children ? (
|
||||
@@ -449,6 +469,7 @@ function ResponsiveSpecimen({
|
||||
modal={false}
|
||||
trigger={
|
||||
<LNBProfile
|
||||
className="hm-lnb-story-mobile-profile"
|
||||
name={profile.name}
|
||||
description={profile.description}
|
||||
>
|
||||
@@ -500,7 +521,7 @@ const standaloneNav: LocalNode[] = [
|
||||
{ id: "settings", label: "설정", icon: SettingsIcon },
|
||||
]
|
||||
|
||||
function Standalone({ mobileOpen = false, profileOpen = false }: { mobileOpen?: boolean; profileOpen?: boolean }) {
|
||||
function Standalone({ mobileOpen = false, profileOpen = false, dividers = true }: { mobileOpen?: boolean; profileOpen?: boolean; dividers?: boolean }) {
|
||||
const [current, setCurrent] = useState("projects/design/tokens")
|
||||
const [open, setOpen] = useState<Record<string, boolean>>({})
|
||||
const go = (id: string) => (event: MouseEvent) => {
|
||||
@@ -508,25 +529,27 @@ function Standalone({ mobileOpen = false, profileOpen = false }: { mobileOpen?:
|
||||
setCurrent(id)
|
||||
}
|
||||
const onPath = (id: string) => current.startsWith(`${id}/`)
|
||||
const lnbMenu = (nodes: LocalNode[]): ReactNode =>
|
||||
nodes.map((node) =>
|
||||
node.children ? (
|
||||
<LNBGroup
|
||||
key={node.id}
|
||||
label={node.label}
|
||||
current={onPath(node.id)}
|
||||
open={open[node.id] ?? true}
|
||||
onOpenChange={(value) => setOpen((state) => ({ ...state, [node.id]: value }))}
|
||||
>
|
||||
{lnbMenu(node.children)}
|
||||
</LNBGroup>
|
||||
) : (
|
||||
<LNBItem key={node.id} href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
|
||||
{node.icon ? <node.icon aria-hidden="true" /> : null}
|
||||
{node.label}
|
||||
</LNBItem>
|
||||
)
|
||||
)
|
||||
const lnbMenu = (nodes: LocalNode[], top = true): ReactNode =>
|
||||
nodes.map((node, index) => (
|
||||
<Fragment key={node.id}>
|
||||
{dividers && top && index > 0 && (node.children || nodes[index - 1].children) ? <GroupDivider /> : null}
|
||||
{node.children ? (
|
||||
<LNBGroup
|
||||
label={node.label}
|
||||
current={onPath(node.id)}
|
||||
open={open[node.id] ?? true}
|
||||
onOpenChange={(value) => setOpen((state) => ({ ...state, [node.id]: value }))}
|
||||
>
|
||||
{lnbMenu(node.children, false)}
|
||||
</LNBGroup>
|
||||
) : (
|
||||
<LNBItem href={`#${node.id}`} active={current === node.id} onClick={go(node.id)}>
|
||||
{node.icon ? <node.icon aria-hidden="true" /> : null}
|
||||
{node.label}
|
||||
</LNBItem>
|
||||
)}
|
||||
</Fragment>
|
||||
))
|
||||
const sheetMenu = (nodes: LocalNode[]): ReactNode =>
|
||||
nodes.map((node) =>
|
||||
node.children ? (
|
||||
@@ -567,6 +590,7 @@ function Standalone({ mobileOpen = false, profileOpen = false }: { mobileOpen?:
|
||||
modal={false}
|
||||
trigger={
|
||||
<LNBProfile
|
||||
className="hm-lnb-story-mobile-profile"
|
||||
name={profileData.name}
|
||||
description={profileData.description}
|
||||
>
|
||||
@@ -598,7 +622,7 @@ const mobileViewport = {
|
||||
// Representative-board preview of a Story at a fixed viewport. The LNB/bar switch is a viewport media query, so the board embeds the
|
||||
// verification Story (the same `Standalone` composition) in an iframe of that width instead of rebuilding it in a narrow container.
|
||||
// Each iframe is its own document, so the closed Sheets do not share a portal or focus with the board.
|
||||
function ViewportPreview({ story, label, width, height }: { story: string; label: string; width: number; height: number }) {
|
||||
function ViewportPreview({ story, label, width, height, args }: { story: string; label: string; width: number; height: number; args?: string }) {
|
||||
return (
|
||||
<figure className="grid min-w-0 content-start gap-2">
|
||||
<figcaption className="text-sm text-muted-foreground">
|
||||
@@ -607,7 +631,7 @@ function ViewportPreview({ story, label, width, height }: { story: string; label
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
<iframe
|
||||
title={`${label} ${width}px`}
|
||||
src={`iframe.html?id=patterns-layout-lnb--${story}&viewMode=story`}
|
||||
src={`iframe.html?id=patterns-layout-lnb--${story}&viewMode=story${args ? `&args=${args}` : ""}`}
|
||||
width={width}
|
||||
height={height}
|
||||
className="box-content block border border-black/10"
|
||||
@@ -617,7 +641,8 @@ function ViewportPreview({ story, label, width, height }: { story: string; label
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Patterns/Layout/LNB", component: LNB, parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta<typeof LNB>
|
||||
// `hm-lnb-story` (display: contents, see lnb.stories.css) scopes the 212px min width and 14px menu font to this Story only.
|
||||
const meta = { title: "Patterns/Layout/LNB", component: LNB, decorators: [(Story) => <div className="hm-lnb-story"><Story /></div>], parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta<typeof LNB>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
@@ -632,8 +657,8 @@ export const All: Story = {
|
||||
<StateBoard
|
||||
min={560}
|
||||
states={[
|
||||
{ id: "selected", label: "Selected", children: <ResponsiveSpecimen nodes={simpleNav()} initial="overview" initialOpen={{ tasks: true }} /> },
|
||||
{ id: "third-selected", label: "3뎁스 · Selected", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: true, "projects/design": true }} /> },
|
||||
{ id: "selected", label: "Selected", children: <ResponsiveSpecimen nodes={simpleNav()} initial="overview" initialOpen={{ tasks: true }} dividers={false} /> },
|
||||
{ id: "third-selected", label: "3뎁스 · Selected", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: true, "projects/design": true }} dividers={false} /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
@@ -641,12 +666,8 @@ export const All: Story = {
|
||||
<StateBoard
|
||||
min={560}
|
||||
states={[
|
||||
{ id: "default", label: "Default", children: <ResponsiveSpecimen nodes={simpleNav()} initial={null} initialOpen={{ tasks: false }} /> },
|
||||
{ id: "expanded", label: "Expanded", children: <ResponsiveSpecimen nodes={simpleNav()} initial={null} initialOpen={{ tasks: true }} /> },
|
||||
{ id: "selected-child", label: "Selected Child", children: <ResponsiveSpecimen nodes={simpleNav()} initial="tasks/list" initialOpen={{ tasks: true }} /> },
|
||||
{ id: "collapsed-with-selection", label: "Collapsed With Selection", children: <ResponsiveSpecimen nodes={simpleNav()} initial="tasks/list" initialOpen={{ tasks: false }} /> },
|
||||
{ id: "third-second-collapsed", label: "3뎁스 · 2뎁스 접힘", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: true, "projects/design": false }} /> },
|
||||
{ id: "third-first-collapsed", label: "3뎁스 · 1뎁스 접힘", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: false, "projects/design": true }} /> },
|
||||
{ id: "states-third-selected", label: "3뎁스 · Selected", children: <ResponsiveSpecimen nodes={thirdLevelNav()} initial="projects/design/tokens" initialOpen={{ projects: true, "projects/design": true }} /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
@@ -654,8 +675,8 @@ export const All: Story = {
|
||||
<StateBoard
|
||||
min={560}
|
||||
states={[
|
||||
{ id: "with-logo", label: "With Logo", children: <ResponsiveSpecimen nodes={simpleNav()} initial="overview" initialOpen={{ tasks: true }} logo={testLogo} /> },
|
||||
{ id: "long", label: "Long Labels", children: <ResponsiveSpecimen nodes={simpleNav(true)} initial="tasks/list" initialOpen={{ tasks: true }} longLabels /> },
|
||||
{ id: "with-logo", label: "With Logo", children: <ResponsiveSpecimen nodes={simpleNav()} initial="overview" initialOpen={{ tasks: true }} logo={testLogo} dividers={false} /> },
|
||||
{ id: "long", label: "Long Labels", children: <ResponsiveSpecimen nodes={simpleNav(true)} initial="tasks/list" initialOpen={{ tasks: true }} longLabels dividers={false} /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
@@ -663,14 +684,14 @@ export const All: Story = {
|
||||
<StateBoard
|
||||
min={560}
|
||||
states={[
|
||||
{ id: "scroll", label: "Scroll", children: <ResponsiveSpecimen nodes={simpleNav(false, true)} initial="overview" initialOpen={{ tasks: true }} height={460} /> },
|
||||
{ id: "third-scroll", label: "3뎁스 · Scroll", children: <ResponsiveSpecimen nodes={thirdLevelNav(true, true)} initial="projects/design/layout" initialOpen={{ projects: true, "projects/design": true }} longLabels height={460} /> },
|
||||
{ id: "scroll", label: "Scroll", children: <ResponsiveSpecimen nodes={simpleNav(false, true)} initial="overview" initialOpen={{ tasks: true }} height={460} dividers={false} /> },
|
||||
{ id: "third-scroll", label: "3뎁스 · Scroll", children: <ResponsiveSpecimen nodes={thirdLevelNav(true, true)} initial="projects/design/layout" initialOpen={{ projects: true, "projects/design": true }} longLabels height={460} dividers={false} /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage · Standalone 반응형">
|
||||
<div className="flex flex-wrap items-start gap-6">
|
||||
<ViewportPreview story="standalone-desktop" label="Desktop" width={1024} height={640} />
|
||||
<ViewportPreview story="standalone-desktop" label="Desktop" width={1024} height={640} args="dividers:!false" />
|
||||
<ViewportPreview story="standalone-mobile" label="Mobile" width={390} height={640} />
|
||||
</div>
|
||||
</StorySection>
|
||||
@@ -679,7 +700,7 @@ export const All: Story = {
|
||||
}
|
||||
|
||||
export const Default: Story = { tags: ["verification"], render: () => <Menu initial={null} groupOpen={false} /> }
|
||||
export const Selected: Story = { tags: ["verification"], render: () => <Menu /> }
|
||||
export const Selected: Story = { tags: ["verification"], render: () => <Menu dividers={false} /> }
|
||||
export const Expanded: Story = { tags: ["verification"], render: () => <Menu initial={null} /> }
|
||||
export const SelectedChild: Story = { tags: ["verification"], name: "Selected Child", render: () => <Menu initial="tasks/list" /> }
|
||||
export const CollapsedWithSelection: Story = { tags: ["verification"], name: "Collapsed With Selection", render: () => <Menu initial="tasks/list" groupOpen={false} /> }
|
||||
@@ -733,7 +754,14 @@ export const WithGNB: Story = {
|
||||
|
||||
// LNB without a GNB, responsive. Desktop (>= 1024px): the column. Mobile (<= 1023px): the column is hidden and the connected-content
|
||||
// column becomes a full-width stack whose first row opens a Sheet with the same tree, selected item and every depth.
|
||||
export const StandaloneDesktop: Story = { tags: ["verification"], name: "LNB Standalone Desktop", render: () => <Standalone /> }
|
||||
// `dividers` is a Story arg (default true: the Story itself keeps its group dividers). The representative board's Desktop preview
|
||||
// opens this Story with `&args=dividers:!false` (see ViewportPreview) to show it without them.
|
||||
export const StandaloneDesktop: StoryObj<{ dividers: boolean }> = {
|
||||
tags: ["verification"],
|
||||
name: "LNB Standalone Desktop",
|
||||
args: { dividers: true },
|
||||
render: (args) => <Standalone dividers={args.dividers} />,
|
||||
}
|
||||
export const LNBProfileMenuOpen: Story = { tags: ["verification"], name: "LNB Profile Menu Open", render: () => <Standalone profileOpen /> }
|
||||
export const StandaloneMobile: Story = { tags: ["verification"], name: "LNB Standalone Mobile", ...mobileViewport, render: () => <Standalone /> }
|
||||
export const StandaloneMobileMenuOpen: Story = {
|
||||
|
||||
Reference in new issue
Block a user