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:
shkim4 committed 2026-11-09 12:01:03 +09:00
1 parent 228944a06b
commit fc0a3e7725
12 files changed
+246 -86

No files matched your search

+27 -14
View File
@@ -61,13 +61,13 @@
background: var(--hm-fg-on-green-secondary);
}
#storybook-sidebar-region .sidebar-header {
margin: 0 20px 24px 0;
margin: 0 20px 0px 0;
padding: 0;
color: var(--color-fg-on-green-primary);
}
#storybook-sidebar-region .sidebar-header > div a {
color: var(--color-fg-on-green-primary);
font: 700 20px/30px var(--hm-manager-font);
font: 700 16px/30px var(--hm-manager-font);
white-space: normal;
/* §6 Text Border: brand title keeps the original 1px (no shadow). */
-webkit-text-stroke: 1px var(--effect-text-border-color);
@@ -78,7 +78,6 @@
}
#storybook-sidebar-region [role='search'] {
margin-right: 20px;
margin-bottom: 24px;
}
#storybook-sidebar-region .search-field {
min-height: 44px;
@@ -109,16 +108,16 @@
position: relative;
margin: 0;
padding: 0 20px 0 0;
min-height: 56px;
min-height: 48px;
color: var(--hm-fg-on-green-secondary);
}
#storybook-explorer-tree .sidebar-subheading[data-hm-section]:not([data-hm-section='0']) {
margin-top: 40px;
margin-top: 24px;
}
#storybook-explorer-tree .sidebar-subheading[data-hm-section]:not([data-hm-section='0'])::before {
content: '';
position: absolute;
top: -20px;
top: -12px;
left: 0;
right: 20px;
height: 2px;
@@ -126,9 +125,9 @@
mask-image: linear-gradient(90deg, transparent, #000 20%, #000 80%, transparent);
}
#storybook-explorer-tree .sidebar-subheading > button {
min-height: 56px;
min-height: 48px;
color: inherit;
font: 700 16px/28px var(--hm-manager-font);
font: 700 14px/28px var(--hm-manager-font);
letter-spacing: 0;
text-transform: none;
}
@@ -136,7 +135,7 @@
position: relative;
margin: 0;
padding: 0;
min-height: 56px;
min-height: 48px;
overflow: visible;
border: 0;
border-radius: 0;
@@ -163,22 +162,30 @@
display: flex;
align-items: center;
gap: 12px;
min-height: 56px;
min-height: 48px;
height: auto;
width: 100%;
margin: 0;
padding: 14px 32px 14px 18px;
padding: 10px 32px 10px 18px;
border: 0;
border-radius: 4px 0 0 4px;
background: transparent;
color: inherit;
font: 700 18px/28px var(--hm-manager-font);
font: 700 14px/28px var(--hm-manager-font);
text-align: left;
text-decoration: none;
white-space: normal;
overflow: visible;
text-overflow: clip;
}
/* Sub rows (depth 2+): 42px (7px + 28px line + 7px), shorter than the 48px top-level rows. */
#storybook-explorer-tree .sidebar-item:is([data-hm-depth='2'], [data-hm-depth='3'], [data-hm-depth='4']) {
min-height: 42px;
}
#storybook-explorer-tree .sidebar-item:is([data-hm-depth='2'], [data-hm-depth='3'], [data-hm-depth='4']) > :is(a[id], button[id]:not([data-testid])) {
min-height: 42px;
padding-block: 7px;
}
#storybook-explorer-tree .sidebar-item[data-hm-depth='2'] > :is(a[id], button[id]:not([data-testid])) {
margin-left: 32px;
width: calc(100% - 32px);
@@ -188,6 +195,10 @@
margin-left: 44px;
width: calc(100% - 44px);
}
/* Menu item labels and section heading labels (.sidebar-subheading) are both 14px / 700. */
#storybook-explorer-tree .sidebar-item .hm-manager-label {
font-size: 14px;
}
#storybook-explorer-tree .hm-manager-label {
min-width: 0;
white-space: normal;
@@ -198,8 +209,10 @@
}
/* D-093 D-014 (§6 Text Border): non-selected labels, including hover and the
yellow current parent, use the specimen's 1.5px border plus lower shadow.
Thickness and offset are REVIEW VALUEs. Selected labels carry no effect. */
#storybook-explorer-tree .sidebar-item[data-selected='false'] .hm-manager-label {
Thickness and offset are REVIEW VALUEs. Selected labels carry no effect.
Section heading labels (Foundation, Components, ...) share this exact rule. */
#storybook-explorer-tree .sidebar-item[data-selected='false'] .hm-manager-label,
#storybook-explorer-tree .sidebar-subheading .hm-manager-label {
-webkit-text-stroke: 1.5px var(--effect-text-border-color);
paint-order: stroke fill;
text-shadow: 1px 2px 1px var(--effect-text-border-color);
+3
View File
@@ -31,6 +31,9 @@ document.head.appendChild(toolbarStyle)
addons.setConfig({
layout: {
showPanel: false,
// LNB width 246px. recentVisibleSizes keeps the same width when the sidebar is collapsed and expanded again.
navSize: 246,
recentVisibleSizes: { navSize: 246 },
},
theme: create({
base: 'light',
+1 -1
View File
@@ -25,7 +25,7 @@ const preview: Preview = {
includeNames: true,
order: [
"Foundation",
["Color", "Company Colors", "Typography", "Layout"],
["HM Color", "Company Colors", "Typography", "Layout"],
"Components",
[
"Form",
+2 -2
View File
@@ -499,11 +499,11 @@ Source: `src/components/tooltip.stories.tsx`; meta.tags: []
- All — Tooltip; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- Open — Open; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
### Foundation/Color
### Foundation/HM Color
Source: `src/foundation/color.stories.tsx`; meta.tags: []
- All — Color; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
- All — HM Color; tags: []; PRIMARY; KEEP. Representative story or distinct reference page.
### Foundation/Company Colors
+2 -2
View File
@@ -3637,13 +3637,13 @@
},
{
"file": "src/foundation/color.stories.tsx",
"title": "Foundation/Color",
"title": "Foundation/HM Color",
"metaTags": [],
"stories": [
{
"id": "foundation-color--all",
"exportName": "All",
"name": "Color",
"name": "HM Color",
"storyTags": [],
"effectiveTags": [],
"category": "PRIMARY",
+3 -3
View File
@@ -33,8 +33,8 @@ Orange Dark 1·2의 원본 Needs Review 상태와 [이전 검수 보고서](../r
## 호환성과 Storybook
페이지 소개는 `한맥 브랜드 컬러`로 표시한다. 그룹 설명의 단계 순서 나열 문구는 제공하지 않으며, 개별 컬러칩의 단계명과 HEX는 유지한다.
페이지 제목은 `HM Color`(메뉴 `Foundation > HM Color`)이며 별도 소개 문구는 표시하지 않는다. 기존 Story ID `foundation-color--all`은 `meta.id`로 유지한다. 과거 `한맥 브랜드 컬러` 소개 문구는 당시 이력이다. 그룹 설명의 단계 순서 나열 문구는 제공하지 않으며, 개별 컬러칩의 단계명과 HEX는 유지한다.
[Color 대표 Story](../../src/foundation/color.stories.tsx)는 한맥그린·한맥브라운·한맥오렌지 3개 그룹에 각 8개, 총 24개 컬러칩만 표시한다. Green 8/8·Brown 8/8·Orange 8/8 확정이며 단계명·현재 HEX와 Default 기본색 강조를 유지한다. 2026-10-11 최종 표시 요청에 따라 Brown Deep, Orange Light·Mid·Dark·Deep의 별도 표시, Accent·Semantic·Gradients 섹션과 개발용 Token·CSS Variable·Runtime Key 설명은 제거한다. Legacy Button 색상과 Accent·Semantic·Gradient의 실제 Key·값·소비처는 보존한다. 기존 Component mapping을 새 값에 연결하지 않으며 public Key·Property·type·Default·API·schemaVersion 1과 서비스 partial JSON 호환성을 유지한다. [Runtime Theme](runtime-theme.md)의 geometry/상태 권한과 Designer의 기존 17개 상위 역할 입력 UI는 확대하지 않는다. 한맥오렌지의 미확정 브랜드 단계는 없다. [표시 정리 검증 기록](../reports/foundation-color-display-cleanup-2026-10-11.md).
[HM Color 대표 Story](../../src/foundation/color.stories.tsx)는 한맥그린·한맥브라운·한맥오렌지 3개 그룹에 각 8개, 총 24개 컬러칩만 표시한다. Green 8/8·Brown 8/8·Orange 8/8 확정이며 단계명·현재 HEX와 Default 기본색 강조를 유지한다. 2026-10-11 최종 표시 요청에 따라 Brown Deep, Orange Light·Mid·Dark·Deep의 별도 표시, Accent·Semantic·Gradients 섹션과 개발용 Token·CSS Variable·Runtime Key 설명은 제거한다. Legacy Button 색상과 Accent·Semantic·Gradient의 실제 Key·값·소비처는 보존한다. 기존 Component mapping을 새 값에 연결하지 않으며 public Key·Property·type·Default·API·schemaVersion 1과 서비스 partial JSON 호환성을 유지한다. [Runtime Theme](runtime-theme.md)의 geometry/상태 권한과 Designer의 기존 17개 상위 역할 입력 UI는 확대하지 않는다. 한맥오렌지의 미확정 브랜드 단계는 없다. [표시 정리 검증 기록](../reports/foundation-color-display-cleanup-2026-10-11.md).
[Company Colors Story](../../src/foundation/company-colors.stories.tsx)는 Foundation > Color와 별도 메뉴(Color 바로 아래)이며 7개 계열사·14개 컬러를 회사별로 표시한다. 데이터는 OrgChart의 기존 `companyColorPresets`를 그대로 읽고(메인 → 서브 순서, 메인은 Default 강조 재사용) HEX를 중복 정의하지 않는다. Theme 토큰·`themes/*.json`·공개 계약은 변경하지 않으며 Foundation > Color의 3그룹 24칩은 그대로다.
[Company Colors Story](../../src/foundation/company-colors.stories.tsx)는 Foundation > HM Color와 별도 메뉴(HM Color 바로 아래)이며 7개 계열사·14개 컬러를 회사별로 표시한다(별도 소개 문구 없음). 데이터는 OrgChart의 기존 `companyColorPresets`를 그대로 읽고(메인 → 서브 순서, 메인은 Default 강조 재사용) HEX를 중복 정의하지 않는다. Theme 토큰·`themes/*.json`·공개 계약은 변경하지 않으며 Foundation > HM Color의 3그룹 24칩은 그대로다.
+3
View File
@@ -230,6 +230,8 @@ viewport 높이 shell에서 모바일 Header와 main을 세로 flex로 배분할
Collapsible, Navigation Menu 등 기존 primitive 코드·theme CSS는 수정하지 않았다. `primitives.css`에 `--hm-yellow-accent` 한 줄만 추가했다.
**공용 패키지 기본값 vs Storybook 전용 오버라이드:** 이 문서의 공용 패키지 기본 규격(열 272px, 메뉴 18px, 행 56px 등)과 `packages/ui`·Theme Token은 변경하지 않았다. `Patterns/Layout/LNB` 대표 Story(`src/patterns/lnb.stories.tsx`)만 `src/patterns/lnb.stories.css`로 PC 열 너비 240px(1024px 이상, 최소 너비 212px), 메뉴 폰트 14px, 행 높이 depth1 48px·depth2·3 42px를 Storybook 표시용으로 덮어쓴다(`.hm-lnb-story` 범위 안에서만 적용하며 소비 프로젝트에는 영향이 없다).
## 10. 남은 차이
| 항목 | 분류 |
@@ -251,4 +253,5 @@ packages/ui/src/patterns/lnb/{lnb.tsx,lnb-mobile.tsx,index.ts}
packages/ui/src/theme/lnb.css
packages/ui/src/theme/primitives.css (--hm-yellow-accent)
src/patterns/lnb.stories.tsx
src/patterns/lnb.stories.css (Storybook 전용 오버라이드)
```
+1 -2
View File
@@ -1,5 +1,5 @@
/* Storybook presentation only. Common page frame for representative Stories; all selectors stay inside .sb-page.
Inner (fluid; 16px each side up to 480px, growing to 60px at 768px; vertical gutter 16–40) and title (kicker 12 / title 28·36·700 / description, underlined) reuse Foundation / Color.
Inner (fluid; 16px each side up to 480px, growing to 60px at 768px; vertical gutter 16–40) and title (kicker 12 / title 28·36·700 / description, no divider below) reuse Foundation / Color.
Section reuses the Center Graphic / 교량 split: heading + description, a divider, then the specimen. */
.sb-page {
--sb-page-gutter: clamp(16px, 3vw, 40px);
@@ -22,7 +22,6 @@
.sb-page .sb-page-header {
padding-bottom: 24px;
border-bottom: 1px solid var(--hm-sem-stroke-input-default);
font-size: var(--hm-label-font-size);
line-height: var(--hm-label-line-height);
overflow-wrap: anywhere;
+4 -3
View File
@@ -35,12 +35,13 @@ function ColorChip({ prefix, stage, values }: { prefix: PalettePrefix; stage: Co
function ColorReferencePage() {
const values = useResolvedProperties(colorProperties)
return <ReferencePage title="Color" intro="한맥 브랜드 컬러">
return <ReferencePage title="HM Color">
{palettes.map(palette => <ReferenceSection key={palette.name} title={palette.name} note="">
<div className="hm-brand-color-row">{stages.map(stage => <ColorChip key={stage} prefix={palette.prefix} stage={stage} values={values} />)}</div>
</ReferenceSection>)}
</ReferencePage>
}
const meta = { title: "Foundation/Color", parameters: { layout: "fullscreen" } } satisfies Meta
// The sidebar name is "HM Color"; `id` keeps the existing Story ID (foundation-color--all) stable.
const meta = { title: "Foundation/HM Color", id: "foundation-color", parameters: { layout: "fullscreen" } } satisfies Meta
export default meta
export const All: StoryObj<typeof meta> = { name: "Color", render: () => <ColorReferencePage /> }
export const All: StoryObj<typeof meta> = { name: "HM Color", render: () => <ColorReferencePage /> }
+1 -1
View File
@@ -14,7 +14,7 @@ function CompanyColorChip({ label, hex, main }: { label: string; hex: string; ma
}
function CompanyColorsPage() {
return <ReferencePage title="Company Colors" intro="계열사 브랜드 컬러">
return <ReferencePage title="Company Colors">
{companies.map(company => <ReferenceSection key={company.companyId} title={company.companyName} note="">
<div className="hm-brand-color-row" data-company-id={company.companyId}>
<CompanyColorChip label="메인" hex={company.primary} main />
+113
View File
@@ -0,0 +1,113 @@
/* Storybook presentation only: Patterns/Layout/LNB Stories. Scoped by the decorator wrapper in lnb.stories.tsx, so the shared
@hm-design/ui LNB Pattern, its tokens and every other Story keep their values.
- min-width 212px: a floor, not a fixed width. The column follows --hm-lnb-width (240px from 1024px, see below) and may be wider.
- menu font 14px: --hm-lnb-item-font-size is redefined for this subtree; weight (700) and line height (28px) stay.
- row height: depth1 48px, depth2 and deeper 42px. Heights come from min-height + vertical padding (not a fixed height), so a
label that wraps grows the row instead of being clipped. Current and hover only change color/background, not the height. */
.hm-lnb-story {
display: contents;
--hm-lnb-item-font-size: 14px;
/* Existing variable: min-height of every item and group trigger (used by nothing else). */
--hm-lnb-item-min-height: 48px;
/* Existing variable: bottom padding of [data-slot="lnb-brand"] (22px by default; top 0 and the 30px sides are unchanged). */
--hm-lnb-brand-padding-bottom: 8px;
}
.hm-lnb-story [data-slot="lnb"] {
min-width: 212px;
}
/* PC column width 240px through the existing --hm-lnb-width (the column, the Story grid track and the menu row offset all read it).
Only from 1024px: the package sets --hm-lnb-width to 0px below that and collapses the column, which must keep working. */
@media (min-width: 1024px) {
.hm-lnb-story {
--hm-lnb-width: 240px;
}
}
/* Profile text (PC column): name (strong) 14px, description (p) 12px with no top margin, both 700. The package's existing 1px text
stroke on strong/p stays as is; the color and the 1.5 line-height ratio are unchanged (21px + 18px lines in the 48px profile).
The sizes use the existing profile variables; the container gets the name size. */
.hm-lnb-story {
--hm-lnb-profile-name-size: 14px;
--hm-lnb-profile-description-size: 12px;
}
.hm-lnb-story [data-slot="lnb"] [data-slot="lnb-profile-text"] {
font-size: 14px;
}
.hm-lnb-story [data-slot="lnb"] [data-slot="lnb-profile-text"] :is(strong, p) {
font-weight: 700;
}
.hm-lnb-story [data-slot="lnb"] [data-slot="lnb-profile-text"] p {
margin-top: 0;
}
/* Divider lines, same design as the Storybook Manager section divider (manager.css, .sidebar-subheading ::before): a 2px double
line (black 30% over white 40%) faded at both ends by a mask. Shared by the profile divider and the group divider below. */
.hm-lnb-story [data-slot="lnb"] > [data-slot="lnb-profile"]::before,
.hm-lnb-story .hm-lnb-story-group-divider {
height: 2px;
background: linear-gradient(var(--hm-lnb-text-stroke-color) 0 50%, color-mix(in srgb, var(--hm-sem-palette-white) 40%, transparent) 50% 100%);
mask-image: linear-gradient(90deg, transparent, #000 20%, #000 80%, transparent);
pointer-events: none;
}
/* Above the PC profile, 12px above the block. Like the Manager, it starts after the left inset (--hm-lnb-inset-start, 20px: where
the menu rows start) and stops 20px short of the right edge. The profile button is only as wide as its content, so the line is
sized from the column width (--hm-lnb-width) and offset back by the button's left margin. It sits in the 22px gap between the
menu and the profile, never over the avatar or text. The mobile Sheet profile is not under [data-slot="lnb"]. */
.hm-lnb-story [data-slot="lnb"] > [data-slot="lnb-profile"] {
position: relative;
}
.hm-lnb-story [data-slot="lnb"] > [data-slot="lnb-profile"]::before {
content: "";
position: absolute;
top: -12px;
left: calc(var(--hm-lnb-inset-start) + 8px - var(--hm-lnb-profile-padding-x));
width: calc(var(--hm-lnb-width) - var(--hm-lnb-inset-start) - var(--hm-lnb-inset-end));
}
/* Between large menu groups (a role="separator" element, direct child of LNBNav). The package already puts a direct child at the
menu rows' x start (margin-left), so only the other margins are set: the line ends 20px short of the column's right edge (the
nav is 1px wider than the column), and 12px + 2px + 10px = 24px in total, the Manager's section gap. */
.hm-lnb-story .hm-lnb-story-group-divider {
flex: none; /* keep the 2px in a height-limited (scrolling) menu, where an empty item would otherwise shrink to 0 */
margin-top: 12px;
margin-right: calc(var(--hm-lnb-inset-end) + 1px);
margin-bottom: 10px;
}
/* Profile in the mobile Sheet (<=1023px): 14px, 700, no text stroke. The Sheet is portaled to body, outside .hm-lnb-story, so the
Story marks its LNBProfile with a class instead. The doubled attribute raises specificity above the package's
[data-slot="lnb-profile"] strong/p rules. Menu links and the mobile header already have no text stroke. */
.hm-lnb-story-mobile-profile[data-slot="lnb-profile"] [data-slot="lnb-profile-text"],
.hm-lnb-story-mobile-profile[data-slot="lnb-profile"] strong {
font-size: 14px;
}
/* Description: 12px with no top margin, like the PC profile (the line height is unchanged, so the Sheet profile is 4px lower
without the old 4px margin: 8 + 20 + 20 + 8 = 56px). */
.hm-lnb-story-mobile-profile[data-slot="lnb-profile"] p {
font-size: 12px;
margin-top: 0;
}
.hm-lnb-story-mobile-profile[data-slot="lnb-profile"] :is(strong, p) {
font-weight: 700;
-webkit-text-stroke: 0;
paint-order: normal;
}
/* depth1: nav items and top-level group triggers. The package hard-codes 14px vertical padding (28px line + 28px = 56px). */
.hm-lnb-story [data-slot="lnb-item"],
.hm-lnb-story [data-slot="lnb-group-trigger"] {
padding-top: 10px;
padding-bottom: 10px;
}
/* depth2: links directly inside a group and nested group triggers (28px line + 14px = 42px). */
.hm-lnb-story [data-slot="lnb-group-content"] > [data-slot="lnb-item"],
.hm-lnb-story [data-slot="lnb-group-content"] [data-slot="lnb-group-trigger"] {
min-height: 42px;
padding-top: 7px;
padding-bottom: 7px;
}
/* depth3: links in a nested group (24px line + 18px = 42px). --hm-lnb-third-min-height is also the Profile's height, so it is
overridden on the row's link instead of through the variable. */
.hm-lnb-story [data-slot="lnb-group-content"] [data-slot="lnb-third-row"] > [data-slot="lnb-item"] {
min-height: 42px;
padding-top: 9px;
padding-bottom: 9px;
}
+86 -58
View File
@@ -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 = {