style(storybook): refine foundation and navigation stories

This commit is contained in:
shkim4 committed 2026-11-09 15:48:34 +09:00
1 parent f6b92d781a
commit 8827eb2681
14 files changed
+215 -86

No files matched your search

+20 -7
View File
@@ -13,7 +13,7 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// The representative "Breadcrumb" Story shows each case on one page (real links with sample hrefs); the same specimens also exist
@@ -122,20 +122,33 @@ const meta = { title: "Components/Navigation/Breadcrumb", component: Breadcrumb,
export default meta
type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => (
<StateBoard min={420} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: narrow(s.node) }))} />
)
// Representative-page layout (Storybook presentation only): Variants use 3 equal columns on PC (iframe width 1000px and up),
// fewer below that (240px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so
// StateBoard's auto-fill grid cannot leave an empty or stretched track.
const specimen = (id: string, children?: ReactNode): BoardState => {
const s = sections.find((section) => section.id === id)!
return { id: s.id, label: s.label, children: children ?? narrow(s.node) }
}
// Representative Story: the common Story page; the sections group the same specimens.
// Overview is merged into Variants (Basic is its first item). The Long path example gets up to 900px so it fits on one line on
// PC; BreadcrumbList is `flex-wrap`, so it wraps naturally (never clipped or forced nowrap) when the space is narrower.
export const All: Story = {
name: "Breadcrumb",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader title="Breadcrumb" />
<StorySection title="Overview">{board("basic")}</StorySection>
<StorySection title="Variants">{board("ellipsis", "custom")}</StorySection>
<StorySection title="Usage">{board("long")}</StorySection>
<StorySection title="Variants">
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
{["basic", "ellipsis", "custom"].map((id) => (
<StateBoard key={id} min={10000} states={[specimen(id)]} />
))}
</div>
</StorySection>
<StorySection title="Usage">
<StateBoard min={10000} states={[specimen("long", <div className="w-full max-w-[900px]">{sections.find((s) => s.id === "long")!.node}</div>)]} />
</StorySection>
</StoryPage>
),
}
+4 -4
View File
@@ -97,10 +97,10 @@ export const All: Story = {
render: () => (
<StoryPage>
<StoryPageHeader title="Menubar" />
<StorySection title="Overview">
<div style={{ minHeight: 384 }}>
<Specimen />
</div>
{/* The section is titled Basic (was Overview). The former `minHeight: 384` reserved room for the open menus; they render in a
portal over the page (with collision handling), nothing follows this section, so the fixed height is not needed. */}
<StorySection title="Basic">
<Specimen />
</StorySection>
</StoryPage>
),
+12 -17
View File
@@ -83,10 +83,13 @@ function Specimen({ viewport = true }: { viewport?: boolean }) {
// 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.
const frame = (node: ReactNode) => (
// `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={{ minHeight: 256, width: "100%", containerType: "inline-size" }}
className="max-md:[&_[data-viewport=false]_[data-slot=navigation-menu-content]]:absolute"
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>
@@ -102,21 +105,13 @@ export const All: Story = {
render: () => (
<StoryPage>
<StoryPageHeader title="Navigation Menu" />
<StorySection title="Overview">
<StateBoard
min={520}
states={[
{ id: "viewport", label: "Viewport", children: frame(<Specimen />) },
]}
/>
</StorySection>
{/* 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">
<StateBoard
min={520}
states={[
{ id: "no-viewport", label: "No viewport", children: frame(<Specimen viewport={false} />) },
]}
/>
<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>
),
+17 -7
View File
@@ -79,20 +79,30 @@ const meta = { title: "Components/Navigation/Pagination", component: Pagination,
export default meta
type Story = StoryObj<typeof meta>
const board = (...ids: string[]) => (
<StateBoard min={420} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: wrap(s.node) }))} />
)
// Representative-page layout (Storybook presentation only): one Variants section, 2 equal columns x 2 rows on PC (iframe width
// 1030px and up), 1 column below that. Each specimen sits in its own single-column StateBoard (min larger than any
// column), so every title and divider has the width of its column. The Pagination root is `mx-auto flex w-full justify-center`;
// `justify-start` on the cell left-aligns the button group in its column without stretching it.
const cellAlignLeft = "w-full [&_[data-slot=pagination]]:justify-start"
// Representative Story: the common Story page; the sections group the same specimens.
// Representative Story: the common Story page. Overview / States / Usage are merged into Variants
// (First page, Middle page, Last page, Long range).
export const All: Story = {
name: "Pagination",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader title="Pagination" />
<StorySection title="Overview">{board("middle")}</StorySection>
<StorySection title="States">{board("first", "last")}</StorySection>
<StorySection title="Usage">{board("ellipsis")}</StorySection>
<StorySection title="Variants">
{/* Long range is 447px wide (20 pages, 7 items), so a column must be at least that: 16px column gap and the 2-column switch at
iframe width 1030px give 449px columns at 1280px; below that it is one column (460px minimum) instead of a wrapped "Next". */}
<div className="grid items-start gap-x-4 gap-y-8 [grid-template-columns:repeat(auto-fill,minmax(min(100%,460px),1fr))] min-[1030px]:grid-cols-2">
{["first", "middle", "last", "ellipsis"].map((id) => {
const spec = sections.find((section) => section.id === id)!
return <StateBoard key={id} min={10000} states={[{ id: spec.id, label: spec.label, children: <div className={cellAlignLeft}>{spec.node}</div> }]} />
})}
</div>
</StorySection>
</StoryPage>
),
}
+27 -26
View File
@@ -1,11 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import { useId, useState, type ComponentProps, type CSSProperties } from "react"
import { useId, useState, type ComponentProps, type CSSProperties, type ReactNode } from "react"
import {
Card, CardContent, CardHeader, CardTitle, Input, Label, Textarea,
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
Tabs, TabsContent, TabsList, TabsTrigger,
} from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Representative "Tabs" Story shows default Primary, explicit API comparisons and Secondary; Legacy stays verification-only.
@@ -127,6 +127,18 @@ function LongLabelSpecimens() {
)
}
// Equal two columns on PC (one column below 900px). Each board holds a single specimen: StateBoard's own auto-fill grid
// would leave an empty third column for two items, so `min` is forced above any column width (same approach as Button).
const TwoColumns = ({ children }: { children: ReactNode }) => (
<div className="grid items-start gap-6 min-[900px]:grid-cols-[repeat(2,minmax(0,1fr))]">{children}</div>
)
const SingleBoard = ({ states }: { states: BoardState[] }) => <StateBoard min={10000} states={states} />
// Variants: equal three columns on PC (iframe width 1000px and up); fewer columns below that (240px minimum). `min-w-0` lets the
// Overflow Tabs keep its own internal scroll (previous / next buttons) inside its column instead of widening the page.
const ThreeColumns = ({ children }: { children: ReactNode }) => (
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">{children}</div>
)
const meta = {
title: "Components/Navigation/Tabs",
component: Tabs,
@@ -147,34 +159,23 @@ export const All: Story = {
name: "Tabs",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
// `py-4!` trims this page's top and bottom padding (30-40px by viewport width) so the page fits a 900px-high PC screen without
// shrinking any component; the important modifier is needed because the shared rule has the same specificity. Section gaps stay 32px.
<StoryPage className="py-4!">
<StoryPageHeader title="Tabs" />
<StorySection title="Overview">
<StateBoard
states={[
{ id: "primary", label: "Primary", children: <Specimen /> },
]}
/>
</StorySection>
<StorySection title="Variants">
<StateBoard
states={[
{ id: "secondary", label: "Secondary", children: <Specimen variant="secondary" /> },
]}
/>
</StorySection>
<StorySection title="States">
<StateBoard
states={[
{ id: "overflow", wide: true, label: "Overflow", children: <div className="w-full min-w-0 max-w-4xl"><Specimen count={20} /></div> },
]}
/>
{/* The former States section (Overflow) is the third Variants item. */}
<ThreeColumns>
<SingleBoard states={[{ id: "primary", label: "Primary", children: <Specimen /> }]} />
<SingleBoard states={[{ id: "secondary", label: "Secondary", children: <Specimen variant="secondary" /> }]} />
<SingleBoard states={[{ id: "overflow", label: "Overflow", children: <div className="w-full min-w-0"><Specimen count={20} /></div> }]} />
</ThreeColumns>
</StorySection>
<StorySection title="Usage">
<StateBoard states={[
{ id: "usage-card", label: "Card 내부 · Secondary", children: <SurfaceUsage inCard /> },
{ id: "usage-page", label: "페이지 직접 배치 · Primary", children: <SurfaceUsage /> },
]} />
<TwoColumns>
<SingleBoard states={[{ id: "usage-card", label: "Card 내부 · Secondary", children: <div className="w-full min-w-0 max-w-lg"><SurfaceUsage inCard /></div> }]} />
<SingleBoard states={[{ id: "usage-page", label: "페이지 직접 배치 · Primary", children: <div className="w-full min-w-0 max-w-lg"><SurfaceUsage /></div> }]} />
</TwoColumns>
</StorySection>
</StoryPage>
),
+8 -6
View File
@@ -15,12 +15,14 @@ function CompanyColorChip({ label, hex, main }: { label: string; hex: string; ma
function CompanyColorsPage() {
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 />
{company.secondary.map((hex, index) => <CompanyColorChip key={hex} label={`서브 ${index + 1}`} hex={hex} main={false} />)}
</div>
</ReferenceSection>)}
<div className="hm-company-grid">
{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 />
{company.secondary.map((hex, index) => <CompanyColorChip key={hex} label={`서브 ${index + 1}`} hex={hex} main={false} />)}
</div>
</ReferenceSection>)}
</div>
</ReferencePage>
}
const meta = { title: "Foundation/Company Colors", parameters: { layout: "fullscreen" } } satisfies Meta
+50 -4
View File
@@ -24,8 +24,6 @@
.hm-foundation-specimen small { display: block; font-size: var(--hm-button-font-size-sm); line-height: var(--hm-button-line-height-sm); font-weight: var(--hm-input-font-weight); }
.hm-foundation-spacing { max-width: 100%; height: 24px; margin-block: 12px; background: var(--hm-sem-state-active); }
.hm-foundation-radius { width: 100%; height: 64px; margin-bottom: 16px; border: 1px solid var(--hm-sem-stroke-default); background: var(--hm-sem-bg-content); }
.hm-foundation-layout-sample { display: grid; min-width: 0; background: var(--hm-sem-bg-content); border: 1px solid var(--hm-sem-stroke-input-default); }
.hm-foundation-layout-card { min-width: 0; border: 1px solid var(--hm-card-border-color); border-radius: var(--hm-card-radius); background: var(--hm-card-surface); }
.hm-brand-color-row { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 8px; }
.hm-brand-color-chip { flex: 1 0 110px; min-width: 0; padding: 8px; border: 1px solid var(--hm-sem-stroke-default); }
.hm-brand-color-chip[data-base] { border: 2px solid var(--hm-sem-fg-primary); font-weight: var(--hm-font-weight-bold); }
@@ -39,5 +37,53 @@
.hm-foundation .hm-color-gradient-stops .hm-brand-color-chip[data-gradient-stop-token] { flex-basis: 220px; }
.hm-foundation .hm-color-foreground-sample { padding: 16px; background: var(--hm-sem-bg-surface); }
/* Company Colors: keep each chip close to the Color row width so 1-3 chips per company do not stretch across the page. */
.hm-foundation .hm-brand-color-row[data-company-id] .hm-brand-color-chip { flex: 0 1 140px; }
/* Company Colors: two-level grid. Columns follow the Story content width (not the viewport): companies 3 -> 2 -> 1 (min 316px each),
chips up to 3 per row (min 100px each, 3 -> 2 -> 1). Chip size stays at one third even when a company has fewer chips. */
.hm-foundation .hm-company-grid { display: grid; gap: 48px 32px; grid-template-columns: repeat(auto-fill, minmax(max(316px, (100% - 64px) / 3), 1fr)); }
.hm-foundation .hm-company-grid > .sb-page-section { min-width: 0; margin-top: 0; }
.hm-foundation .hm-brand-color-row[data-company-id] { display: grid; gap: 8px; overflow: visible; padding-bottom: 0; grid-template-columns: repeat(auto-fill, minmax(max(100px, (100% - 16px) / 3), 1fr)); }
/* Typography: compact specification page. Font Family one line, Font Weight small cards, Specimens as comparison tables
(two columns on wide screens, stacked rows on narrow screens). */
.hm-foundation .hm-typo-family { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 16px; margin: 0; }
.hm-foundation .hm-typo-weights { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr)); gap: 12px; }
.hm-foundation .hm-typo-weight { padding: 12px 16px; }
.hm-foundation .hm-typo-weight p { margin: 0; }
.hm-foundation .hm-typo-weight-preview { margin: 4px 0 !important; font-size: var(--hm-card-title-font-size); line-height: var(--hm-card-title-line-height); }
.hm-foundation .hm-typo-tables { display: grid; grid-template-columns: minmax(0, 1fr); gap: 16px 32px; align-items: start; }
.hm-foundation .hm-typo-table { width: 100%; border-collapse: collapse; }
.hm-foundation .hm-typo-table th, .hm-foundation .hm-typo-table td { padding: 8px 12px 8px 0; border-bottom: 1px solid var(--hm-sem-stroke-input-default); text-align: left; vertical-align: baseline; font-weight: var(--hm-input-font-weight); }
.hm-foundation .hm-typo-table thead th { color: var(--hm-sem-fg-secondary); font-size: var(--hm-button-font-size-sm); line-height: var(--hm-button-line-height-sm); }
.hm-foundation .hm-typo-table tbody th { width: 27%; font-weight: var(--hm-font-weight-bold); }
.hm-foundation .hm-typo-table td:last-child { width: 26%; padding-right: 0; }
.hm-foundation .hm-typo-metrics span, .hm-foundation .hm-typo-metrics small { display: block; }
.hm-foundation .hm-typo-metrics small { font-size: var(--hm-button-font-size-sm); line-height: var(--hm-button-line-height-sm); }
@media (min-width: 1100px) { .hm-foundation .hm-typo-tables { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 640px) {
.hm-foundation .hm-typo-table thead { display: none; }
.hm-foundation .hm-typo-table, .hm-foundation .hm-typo-table tbody, .hm-foundation .hm-typo-table tr, .hm-foundation .hm-typo-table th, .hm-foundation .hm-typo-table td { display: block; width: auto !important; }
.hm-foundation .hm-typo-table tr { padding: 8px 0; border-bottom: 1px solid var(--hm-sem-stroke-input-default); }
.hm-foundation .hm-typo-table th, .hm-foundation .hm-typo-table td { padding: 2px 0; border-bottom: 0; }
}
/* Layout: compact specification page (PC: three bands; narrow screens stack and scroll naturally).
Tightening applies only inside .hm-layout-ref; the common Story page and other Foundation pages are unchanged. No fixed heights or clipping. */
.hm-foundation .hm-layout-ref { display: grid; gap: 32px; word-break: keep-all; }
.hm-foundation .hm-layout-ref .sb-page-section { margin-top: 0; }
.hm-foundation .hm-layout-ref .sb-page-section-header { padding-bottom: 8px; margin-bottom: 12px; }
.hm-foundation .hm-layout-ref p, .hm-foundation .hm-layout-ref h3 { margin: 0; }
.hm-foundation .hm-layout-ref .hm-foundation-card, .hm-foundation .hm-layout-ref .hm-foundation-panel { padding: 8px 16px; }
.hm-foundation .hm-layout-ref code { font-size: var(--hm-button-font-size-sm); color: var(--hm-sem-fg-secondary); }
.hm-foundation .hm-layout-ref .hm-layout-value { font-weight: var(--hm-font-weight-bold); }
.hm-foundation .hm-layout-list { display: grid; gap: 8px 24px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 170px), 1fr)); align-content: center; }
.hm-foundation .hm-layout-spacing-grid { display: grid; gap: 12px; grid-template-columns: minmax(0, 1fr); }
.hm-foundation .hm-layout-spacing-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.hm-foundation .hm-layout-spacing-foot { display: flex; align-items: center; gap: 12px; margin-top: 4px; min-height: 14px; }
.hm-foundation .hm-layout-spacing-foot .hm-foundation-spacing { flex: none; height: 6px; margin: 0; }
.hm-foundation .hm-layout-spacing-foot .hm-foundation-note { min-width: 0; font-size: var(--hm-button-font-size-sm); }
.hm-foundation .hm-layout-radius-grid { display: grid; gap: 12px; grid-template-columns: repeat(auto-fit, minmax(min(100%, 150px), 1fr)); }
.hm-foundation .hm-layout-ref .hm-layout-radius .hm-foundation-radius { height: 24px; margin: 0 0 6px; }
.hm-foundation .hm-layout-radius .hm-layout-value { display: block; }
.hm-foundation .hm-layout-radius .hm-layout-spacing-head { margin-top: 0; }
@media (min-width: 480px) { .hm-foundation .hm-layout-spacing-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (min-width: 640px) { .hm-foundation .hm-layout-spacing-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); } }
+23 -5
View File
@@ -2,14 +2,32 @@ import type { Meta, StoryObj } from "@storybook/react-vite"
import { layoutProperties, radiusReferences, spacingReferences } from "./reference-data"
import { ReferencePage, ReferenceSection, useResolvedProperties } from "./reference"
// Compact specification page: item name, key value and preview only. PC shows every section within one screen, narrow screens stack and scroll naturally.
// Token names stay in the data (reference-data.ts) and in data-token attributes. Shortened wording (kept here for reference):
// Desktop: "Desktop Navigation · LNB 표시 · GNB desktop menu" / Mobile: "Mobile Navigation · LNB 숨김 · GNB mobile menu"
// Only verified items are shown (checked against the token definitions, CSS usage and docs on 2026-11-09):
// - "Mobile 20px" for Dashboard padding is not implemented: --hm-dashboard-padding is 34px in every viewport (docs/patterns/dashboard-layout.md).
// - --hm-radius-select is declared in default.css but has no consumer (Select uses --hm-select-radius -> --hm-radius-input), so it is not listed.
const radiusLabels: Record<string, string> = {
"--hm-radius-control-md": "Control",
"--hm-radius-button": "Button",
"--hm-radius-input": "Input",
"--hm-radius-container": "Container",
}
const shownRadiusReferences = radiusReferences.filter(property => property in radiusLabels)
function Item({ name, value, note }: { name: string; value: string; note?: string }) {
return <div><div className="hm-layout-spacing-head"><h3>{name}</h3><span className="hm-layout-value">{value}</span></div>{note ? <p className="hm-foundation-note">{note}</p> : null}</div>
}
function LayoutReferencePage() {
const values = useResolvedProperties(layoutProperties)
return <ReferencePage title="Layout">
<ReferenceSection title="Responsive Boundary"><div className="hm-foundation-grid"><article className="hm-foundation-panel"><h3>≥ 1024px · Desktop</h3><p>Desktop Navigation · LNB 표시 · GNB desktop menu</p></article><article className="hm-foundation-panel"><h3>≤ 1023px · Mobile</h3><p>Mobile Navigation · LNB 숨김 · GNB mobile menu</p></article></div></ReferenceSection>
<ReferenceSection title="QA Reference Viewports"><div className="hm-foundation-grid"><div className="hm-foundation-panel"><h3>1280 × 900</h3><p>HM Desktop · QA reference</p></div><div className="hm-foundation-panel"><h3>390 × 844</h3><p>HM Mobile · QA reference</p></div></div></ReferenceSection>
<ReferenceSection title="Spacing · Current Usage Reference"><div className="hm-foundation-grid">{spacingReferences.map(([property, name, responsive]) =><article className="hm-foundation-card" key={property}><h3>{name}</h3><code>{property}</code><p data-layout-value>{values[property]}</p><div className="hm-foundation-spacing" style={{ width: `var(${property})` }} aria-hidden="true" />{responsive ? <p className="hm-foundation-note">{responsive}</p> : null}</article>)}</div></ReferenceSection>
<ReferenceSection title="Radius"><div className="hm-foundation-grid">{radiusReferences.map(property => <article className="hm-foundation-card" key={property}><div className="hm-foundation-radius" style={{ borderRadius: `var(${property})` }} /><code>{property}</code><p>{values[property]}</p></article>)}</div></ReferenceSection>
<ReferenceSection title="Layout / Container Reference"><div className="hm-foundation-layout-sample" style={{ padding: "var(--hm-dashboard-padding)", gap: "var(--hm-dashboard-gap)" }}><div className="hm-foundation-layout-card" style={{ padding: "var(--hm-card-padding)" }}><strong>Dashboard content</strong><p>가용 폭을 사용하며 긴 텍스트는 내부에서 줄바꿈합니다.</p></div><div className="hm-foundation-layout-card" style={{ padding: "var(--hm-card-padding)" }}>다음 섹션 · gap: {values["--hm-dashboard-gap"]}</div></div></ReferenceSection>
<div className="hm-layout-ref">
<ReferenceSection title="Responsive"><div className="hm-foundation-panel hm-layout-list"><Item name="Desktop" value="≥ 1024px" note="LNB 표시" /><Item name="Mobile" value="≤ 1023px" note="LNB 숨김" /></div></ReferenceSection>
<ReferenceSection title="Spacing"><div className="hm-layout-spacing-grid">{spacingReferences.map(([property, name]) =><article className="hm-foundation-card" key={property} data-token={property}><div className="hm-layout-spacing-head"><h3>{name}</h3><span className="hm-layout-value" data-layout-value>{values[property]}</span></div><div className="hm-layout-spacing-foot"><div className="hm-foundation-spacing" style={{ width: `var(${property})` }} aria-hidden="true" /></div></article>)}</div></ReferenceSection>
<ReferenceSection title="Radius"><div className="hm-layout-radius-grid">{shownRadiusReferences.map(property => <article className="hm-foundation-card hm-layout-radius" key={property} data-token={property}><div className="hm-foundation-radius" style={{ borderRadius: `var(${property})` }} /><div className="hm-layout-spacing-head"><h3>{radiusLabels[property]}</h3><span className="hm-layout-value">{values[property]}</span></div></article>)}</div></ReferenceSection>
</div>
</ReferencePage>
}
const meta = { title: "Foundation/Layout", parameters: { layout: "fullscreen" } } satisfies Meta
+48 -4
View File
@@ -1,18 +1,62 @@
import { useEffect, useRef, useState } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { typographyProperties, typographyReferences } from "./reference-data"
import { ReferencePage, ReferenceSection, Specimen, useResolvedProperties } from "./reference"
import { ReferencePage, ReferenceSection, useResolvedProperties } from "./reference"
type TypographyReference = (typeof typographyReferences)[number]
const weights = [
{ property: "--hm-input-font-weight", label: "Regular" },
{ property: "--hm-font-weight-medium", label: "Medium" },
{ property: "--hm-font-weight-bold", label: "Bold" },
]
// Left table: text styles. Right table: form controls. Together they cover every entry in typographyReferences.
const textLabels = ["Page Header · title", "Card · title", "Page Header · description", "Label"]
const textRows = typographyReferences.filter(item => textLabels.includes(item.label))
const controlRows = typographyReferences.filter(item => !textLabels.includes(item.label))
function SpecimenRow({ label, size, line, weight }: TypographyReference & { weight?: string }) {
const ref = useRef<HTMLDivElement>(null)
const [metrics, setMetrics] = useState({ main: "", spacing: "" })
useEffect(() => {
let mounted = true
void document.fonts.ready.then(() => {
if (!mounted || !ref.current) return
const style = getComputedStyle(ref.current)
setMetrics({ main: `${style.fontSize} / ${style.lineHeight} / ${style.fontWeight}`, spacing: `letter-spacing: ${style.letterSpacing}` })
})
return () => { mounted = false }
}, [])
return <tr>
<th scope="row">{label}</th>
<td><div ref={ref} className="hm-typo-sample" data-typography-sample={label} lang="ko" style={{ fontSize: `var(${size})`, lineHeight: `var(${line})`, fontWeight: weight ? `var(${weight})` : "normal" }}>한맥 Hanmac 0123</div></td>
<td className="hm-typo-metrics" data-typography-metrics><span>{metrics.main}</span><small className="hm-foundation-note">{metrics.spacing}</small></td>
</tr>
}
function SpecimenTable({ rows }: { rows: readonly TypographyReference[] }) {
return <table className="hm-typo-table">
<thead><tr><th scope="col">Component</th><th scope="col">Sample</th><th scope="col">Size / Line / Weight</th></tr></thead>
<tbody>{rows.map(item => <SpecimenRow key={item.label} {...item} />)}</tbody>
</table>
}
function TypographyReferencePage() {
const values = useResolvedProperties(typographyProperties)
return <ReferencePage title="Typography">
<ReferenceSection title="Font Family"><div className="hm-foundation-panel"><p>Noto Sans KR → 맑은 고딕 → 시스템 sans-serif</p></div></ReferenceSection>
<ReferenceSection title="Font Weight"><div className="hm-foundation-grid">{weights.map(({ property, label }) => <article className="hm-foundation-card" key={property}><code>{property}</code><p>{values[property]} — {label}</p><p style={{ fontWeight: `var(${property})` }}>한맥 프로젝트<br />Hanmac Project<br />0123456789</p></article>)}</div></ReferenceSection>
<ReferenceSection title="Component Specimens"><div className="hm-foundation-specimens">{typographyReferences.map(item => <article className="hm-foundation-panel" key={item.label}><h3>{item.label}</h3><Specimen {...item} /></article>)}</div></ReferenceSection>
<ReferenceSection title="Font Family">
<p className="hm-typo-family"><span>Noto Sans KR → 맑은 고딕 → 시스템 sans-serif</span><code className="hm-foundation-note">--hm-font-family-base: {values["--hm-font-family-base"]}</code></p>
</ReferenceSection>
<ReferenceSection title="Font Weight">
<div className="hm-typo-weights">{weights.map(({ property, label }) => <article className="hm-foundation-card hm-typo-weight" key={property}>
<p className="hm-typo-weight-meta"><strong>{label}</strong> <span className="hm-foundation-note">{values[property]}</span></p>
<p className="hm-typo-weight-preview" style={{ fontWeight: `var(${property})` }}>한맥 Hanmac 0123</p>
<code className="hm-foundation-note">{property}</code>
</article>)}</div>
</ReferenceSection>
<ReferenceSection title="Typography Specimens">
<div className="hm-typo-tables"><SpecimenTable rows={textRows} /><SpecimenTable rows={controlRows} /></div>
</ReferenceSection>
</ReferencePage>
}
const meta = { title: "Foundation/Typography", parameters: { layout: "fullscreen" } } satisfies Meta