merge: sync chart package with latest main
This commit is contained in:
commit
bcce6590ec
132 files changed
+2553
-1170
No files matched your search
@@ -16,7 +16,8 @@ import { withRepresentation } from "./center-shared"
|
||||
// Owners: ui-viz docs/visualization/facility.md and facility-assets.md (128 approved SVG references, shipped unchanged).
|
||||
// The display scale (Q2b) is HOLD and Facility has no Selected / Hover state.
|
||||
const meta = {
|
||||
title: "Center Graphic/Facility",
|
||||
title: "노선도/시설물",
|
||||
id: "center-graphic-facility",
|
||||
component: TunnelFacility,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof TunnelFacility>
|
||||
@@ -30,23 +31,25 @@ const page = (name: string, Demo: ComponentType): Story => ({
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title={name} />
|
||||
<StorySection title="Overview">
|
||||
<StorySection title="개요">
|
||||
{withRepresentation(() => <Demo />)}
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
})
|
||||
|
||||
export const Tunnel: Story = page("터널", TunnelReferenceDemo)
|
||||
export const Bridge: Story = page("교량", BridgeReferenceDemo)
|
||||
export const Intersection: Story = page("교차로", IntersectionReferenceDemo)
|
||||
export const Junction: Story = page("분기점", JunctionReferenceDemo)
|
||||
export const Interchange: Story = page("나들목", InterchangeReferenceDemo)
|
||||
export const Roadside: Story = page("휴게소 · 졸음쉼터", RoadsideReferenceDemo)
|
||||
// `name` is repeated as a literal because Storybook's static indexer cannot read it through page(); it drives the sidebar label.
|
||||
export const Tunnel: Story = { ...page("터널", TunnelReferenceDemo), name: "터널" }
|
||||
export const Bridge: Story = { ...page("교량", BridgeReferenceDemo), name: "교량" }
|
||||
export const Intersection: Story = { ...page("교차로", IntersectionReferenceDemo), name: "교차로" }
|
||||
export const Junction: Story = { ...page("분기점", JunctionReferenceDemo), name: "분기점" }
|
||||
export const Interchange: Story = { ...page("나들목", InterchangeReferenceDemo), name: "나들목" }
|
||||
export const Roadside: Story = { ...page("휴게소 · 졸음쉼터", RoadsideReferenceDemo), name: "휴게소 · 졸음쉼터" }
|
||||
// Linked facilities are a standalone section heading followed by item cards.
|
||||
// Keep the reference data/artwork and road-class grouping owned by the package.
|
||||
export const Linked: Story = {
|
||||
...page("연계 시설물", LinkedFacilityReferenceDemo),
|
||||
name: "연계 시설물",
|
||||
render: () => (
|
||||
<StoryPage className="sb-center-linked-page">
|
||||
<StoryPageHeader title="연계 시설물" />
|
||||
|
||||
@@ -6,7 +6,8 @@ import { withRepresentation } from "./center-shared"
|
||||
// Owner: ui-viz docs/patterns/center-information-presentation.md and center-information-{box,table,emphasis}.md.
|
||||
// Single-line ellipsis is resolved; multi-line overflow, exact placement and the presentation scale (Q3) are not.
|
||||
const meta = {
|
||||
title: "Center Graphic/Information",
|
||||
title: "노선도/정보",
|
||||
id: "center-graphic-information",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
@@ -14,11 +15,11 @@ type Story = StoryObj<typeof meta>
|
||||
|
||||
// Common Story page. The CENTER representation wraps only the specimen, so the page title is never scaled with the graphic.
|
||||
export const Information: Story = {
|
||||
name: "Information",
|
||||
name: "정보",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Information" />
|
||||
<StorySection title="Overview">
|
||||
<StoryPageHeader title="정보" />
|
||||
<StorySection title="개요">
|
||||
{withRepresentation(() => (
|
||||
<div className="sb-center-information">
|
||||
<CenterInformationDisplayReferenceDemo />
|
||||
|
||||
@@ -20,7 +20,7 @@ const STATUS: [string, string, string, string][] = [
|
||||
["Color Candidate application", "TBD", "foundation/center-color.md §6–7", "Reference 그대로"],
|
||||
]
|
||||
|
||||
const meta = { title: "Center Graphic/Overview", parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta
|
||||
const meta = { title: "노선도/Overview", id: "center-graphic-overview", parameters: { layout: "fullscreen", controls: { disable: true } } } satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
|
||||
@@ -7,7 +7,8 @@ import { withRepresentation } from "./center-shared"
|
||||
// Owners: ui-viz docs/visualization/route.md, spatial-map.md, spatial-section.md, composition.md.
|
||||
// Route layer order Shadow -> Outline -> Primary Stroke is CONFIRMED; the whole-map layer order is Candidate and line join is TBD.
|
||||
const meta = {
|
||||
title: "Center Graphic/Spatial",
|
||||
title: "노선도/노선·공간",
|
||||
id: "center-graphic-spatial",
|
||||
component: RouteLine,
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
} satisfies Meta<typeof RouteLine>
|
||||
@@ -16,11 +17,11 @@ type Story = StoryObj<typeof meta>
|
||||
|
||||
// Common Story page. The CENTER representation wraps only the specimen, so the page title is never scaled with the graphic.
|
||||
export const Spatial: Story = {
|
||||
name: "Spatial",
|
||||
name: "노선·공간",
|
||||
render: () => (
|
||||
<StoryPage className="sb-center-spatial-page">
|
||||
<StoryPageHeader title="Spatial" />
|
||||
<StorySection title="Overview">
|
||||
<StoryPageHeader title="노선·공간" />
|
||||
<StorySection title="개요">
|
||||
{withRepresentation(() => <CenterSpatialInformationReferenceDemo />)}
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
|
||||
@@ -6,7 +6,8 @@ import { withRepresentation } from "./center-shared"
|
||||
|
||||
// Owner: ui-viz docs/components/center-title.md. Title Resolution B numeric output is TBD; the per-Type palette is SCOPED_DESIGN_APPROVAL.
|
||||
const meta = {
|
||||
title: "Center Graphic/Title",
|
||||
title: "노선도/사업명",
|
||||
id: "center-graphic-title",
|
||||
component: CenterTitle,
|
||||
parameters: { layout: "padded" },
|
||||
} satisfies Meta<typeof CenterTitle>
|
||||
@@ -16,12 +17,12 @@ type Story = StoryObj<typeof meta>
|
||||
// Representative Story: the four title types. Named after the area so Storybook shows it as a single sidebar item.
|
||||
// Common Story page. The CENTER representation wraps only the specimen, so the page title is never scaled with the graphic.
|
||||
export const Compositions: Story = {
|
||||
name: "Title",
|
||||
name: "사업명",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Title" />
|
||||
<StorySection title="Overview">
|
||||
<StoryPageHeader title="사업명" />
|
||||
<StorySection title="개요">
|
||||
{withRepresentation(() => <CenterTitleReferenceDemo />)}
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } 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 "Accordion" Story puts each Radix mode on one page with real, clickable items. The same sections also exist
|
||||
@@ -115,8 +115,21 @@ const meta = { title: "Components/Disclosure/Accordion", parameters: { layout: "
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={360} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
// Representative-page layout (Storybook presentation only): 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), so the title, divider and
|
||||
// specimen start line up, unused columns stay empty and `items-start` lets an opened item grow without touching its neighbors.
|
||||
function StateGrid({ states, cols }: { states: BoardState[]; cols: 2 | 3 }) {
|
||||
return (
|
||||
<div className={`grid grid-cols-1 items-start gap-6 ${cols === 3 ? "min-[1000px]:grid-cols-3" : "min-[1000px]:grid-cols-2"}`}>
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[state]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const board = (cols: 2 | 3, ...ids: string[]) => (
|
||||
<StateGrid cols={cols} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
@@ -126,12 +139,8 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Accordion" />
|
||||
<StorySection title="Overview">{board("single")}</StorySection>
|
||||
<StorySection title="Variants">
|
||||
{board("collapsible-single", "multiple")}
|
||||
</StorySection>
|
||||
<StorySection title="States">{board("disabled")}</StorySection>
|
||||
<StorySection title="Usage">{board("long")}</StorySection>
|
||||
<StorySection title="Variants">{board(3, "single", "collapsible-single", "multiple")}</StorySection>
|
||||
<StorySection title="States">{board(3, "disabled", "long")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
AlertDialogTrigger,
|
||||
Button,
|
||||
} from "@hm-design/ui"
|
||||
import { StateBoard, type BoardState } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Alert Dialog is a modal Radix overlay (Portal) for a decision that must be confirmed or cancelled: no Close (X), no outside
|
||||
@@ -68,17 +69,34 @@ export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: the common Story page; each trigger opens the real dialog.
|
||||
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
|
||||
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
|
||||
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
|
||||
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
|
||||
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: Overview and Variants are merged into one Variants section.
|
||||
export const All: Story = {
|
||||
name: "Alert Dialog",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Alert Dialog" />
|
||||
<StorySection title="Overview">
|
||||
<Confirmation />
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<Destructive />
|
||||
<SpecimenGrid
|
||||
states={[
|
||||
{ id: "confirmation", label: "Confirmation", children: <Confirmation /> },
|
||||
{ id: "destructive", label: "Destructive", children: <Destructive /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
|
||||
@@ -27,9 +27,9 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Alert" />
|
||||
<StorySection title="Overview">
|
||||
<StorySection title="Variants">
|
||||
<StateBoard
|
||||
min={320}
|
||||
min={360}
|
||||
states={[
|
||||
{
|
||||
id: "default",
|
||||
@@ -42,13 +42,6 @@ export const All: Story = {
|
||||
</Alert>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard
|
||||
min={320}
|
||||
states={[
|
||||
{
|
||||
id: "destructive",
|
||||
label: "Destructive",
|
||||
@@ -65,7 +58,7 @@ export const All: Story = {
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<StateBoard
|
||||
min={320}
|
||||
min={360}
|
||||
states={[
|
||||
{
|
||||
id: "no-icon",
|
||||
|
||||
@@ -57,20 +57,45 @@ const meta = { title: "Components/Data & Display/Aspect Ratio", component: Aspec
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={340} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
|
||||
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
|
||||
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
|
||||
// The Card example spans two columns (about its previous width); the ratio boxes fill their column by design.
|
||||
const specimenColumns = {
|
||||
2: "min-[1000px]:grid-cols-2",
|
||||
3: "min-[1000px]:grid-cols-3",
|
||||
4: "min-[1000px]:grid-cols-4",
|
||||
} as const
|
||||
|
||||
function SpecimenGrid({ ids, columns, spans = {} }: { ids: string[]; columns: 2 | 3 | 4; spans?: Record<string, 2> }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${specimenColumns[columns]}`}>
|
||||
{ids.map((id) => {
|
||||
const s = sections.find((section) => section.id === id)!
|
||||
return (
|
||||
<div key={id} className={spans[id] ? "min-[1000px]:col-span-2" : undefined}>
|
||||
<StateBoard min={10000} states={[{ id: s.id, label: s.label, children: s.node }]} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
// Representative Story: Overview (16:9) is merged into Variants (16:9, 4:3, 1:1, Video), 4 columns on PC; Card stays under Usage.
|
||||
export const All: Story = {
|
||||
name: "Aspect Ratio",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Aspect Ratio" />
|
||||
<StorySection title="Overview">{board("r169")}</StorySection>
|
||||
<StorySection title="Variants">{board("r43", "r11", "video")}</StorySection>
|
||||
<StorySection title="Usage">{board("card")}</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<SpecimenGrid ids={["r169", "r43", "r11", "video"]} columns={4} />
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<SpecimenGrid ids={["card"]} columns={4} spans={{ card: 2 }} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -76,20 +76,44 @@ const meta = { title: "Components/Data & Display/Avatar", component: Avatar, par
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
// Representative-page layout (Storybook presentation only): 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 every subtitle and divider
|
||||
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
|
||||
const specimenColumns = {
|
||||
2: "min-[1000px]:grid-cols-2",
|
||||
3: "min-[1000px]:grid-cols-3",
|
||||
4: "min-[1000px]:grid-cols-4",
|
||||
} as const
|
||||
|
||||
function SpecimenGrid({ ids, columns, spans = {} }: { ids: string[]; columns: 2 | 3 | 4; spans?: Record<string, 2> }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] ${specimenColumns[columns]}`}>
|
||||
{ids.map((id) => {
|
||||
const s = sections.find((section) => section.id === id)!
|
||||
return (
|
||||
<div key={id} className={spans[id] ? "min-[1000px]:col-span-2" : undefined}>
|
||||
<StateBoard min={10000} states={[{ id: s.id, label: s.label, children: s.node }]} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
// Representative Story: Overview / States are merged into Variants (Image, Fallback); Composition stays under Usage. 3 columns on PC.
|
||||
export const All: Story = {
|
||||
name: "Avatar",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Avatar" />
|
||||
<StorySection title="Overview">{board("image")}</StorySection>
|
||||
<StorySection title="States">{board("fallback")}</StorySection>
|
||||
<StorySection title="Usage">{board("composition")}</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<SpecimenGrid ids={["image", "fallback"]} columns={3} />
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<SpecimenGrid ids={["composition"]} columns={3} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { CheckIcon } from "lucide-react"
|
||||
import { Badge } 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"
|
||||
|
||||
const variants = ["default", "secondary", "destructive", "outline"] as const
|
||||
@@ -23,18 +23,35 @@ const meta = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
|
||||
// (280px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
|
||||
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
|
||||
const specimenColumns = {
|
||||
2: "min-[1000px]:grid-cols-2",
|
||||
3: "min-[1000px]:grid-cols-3",
|
||||
4: "min-[1000px]:grid-cols-4",
|
||||
} as const
|
||||
|
||||
function SpecimenGrid({ states, columns }: { states: BoardState[]; columns: 2 | 3 | 4 }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,280px),1fr))] ${specimenColumns[columns]}`}>
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[state]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: Overview (a plain Badge, already the default item of Variants / Text) is removed. 2 columns on PC.
|
||||
export const All: Story = {
|
||||
name: "Badge",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Badge" />
|
||||
<StorySection title="Overview">
|
||||
<Badge>Badge</Badge>
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard
|
||||
min={280}
|
||||
<SpecimenGrid
|
||||
columns={2}
|
||||
states={[
|
||||
{
|
||||
id: "variants",
|
||||
@@ -67,8 +84,8 @@ export const All: Story = {
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<StateBoard
|
||||
min={280}
|
||||
<SpecimenGrid
|
||||
columns={2}
|
||||
states={[
|
||||
{ id: "long", label: "Long text", children: <div className="max-w-[160px]"><Badge>A very long badge label</Badge></div> },
|
||||
{
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react"
|
||||
import { DownloadIcon } from "lucide-react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button, type ButtonAppearance } 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"
|
||||
|
||||
// Story structure follows ui_viz (implementations/web/storybook/Button.stories.jsx):
|
||||
@@ -159,12 +159,13 @@ const standardStates = [
|
||||
|
||||
// Without `title` the table sits under a StateBoard label (representative page) and drops the Role column:
|
||||
// every button already shows its role name, so the row header would repeat it.
|
||||
function StandardStateTable({ appearance, title }: { appearance: "solid" | "outline"; title?: string }) {
|
||||
// `compact` (representative page only) tightens the cell padding so a 5x4 / 4x4 matrix stays short; buttons are untouched.
|
||||
function StandardStateTable({ appearance, title, compact = false }: { appearance: "solid" | "outline"; title?: string; compact?: boolean }) {
|
||||
return (
|
||||
<section className="min-w-0">
|
||||
{title ? <h3 className={heading}>{title}</h3> : null}
|
||||
<div className={`${title ? "mt-3 " : ""}min-w-0 max-w-full overflow-x-auto`}>
|
||||
<table className="w-full border-collapse [&_td]:p-3 [&_th]:p-3 [&_th]:text-left">
|
||||
<table className={`w-full border-collapse [&_th]:text-left ${compact ? "[&_td]:px-2 [&_td]:py-1 [&_th]:px-2 [&_th]:py-1" : "[&_td]:p-3 [&_th]:p-3"}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
{title ? <th scope="col">Role</th> : null}
|
||||
@@ -230,31 +231,85 @@ function DestructiveBoard() {
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page (Overview → Sizes → Variants → States) with the ui_viz Button "All" boards.
|
||||
// Compact PC layout of the representative page (Storybook presentation only; Button itself is unchanged).
|
||||
// Page and section spacing are tightened for this Story only through `compactPage`; the shared story-page.css is not edited.
|
||||
// The important modifier is required because the shared rules use the same specificity.
|
||||
const compactPage = [
|
||||
"py-4!",
|
||||
"[&_.sb-page-header]:pb-3!",
|
||||
"[&_.sb-page-title]:mt-1!",
|
||||
"[&_.sb-page-title]:mb-2!",
|
||||
"[&_.sb-page-section]:mt-5!",
|
||||
"[&_.sb-page-section-header]:pb-2!",
|
||||
"[&_.sb-page-section-header]:mb-3!",
|
||||
].join(" ")
|
||||
// 8px gap so the three Brand sizes stay on one line in the narrowest (Brand) column, same as the Variants rows.
|
||||
const compactRow = "flex flex-wrap items-center gap-2"
|
||||
// Equal two columns over the whole content width. StateBoard's own auto-fill grid left an empty third column
|
||||
// (2 items in a grid that fits 3), so the pairs are laid out here and each StateBoard holds one specimen.
|
||||
const twoColumns = "grid gap-6 min-[900px]:grid-cols-[repeat(2,minmax(0,1fr))]"
|
||||
// StateBoard lays its states out in an auto-fill grid. A board that holds one specimen inside a wide column would put it
|
||||
// in the first of several inner columns (about half width at 1920px), so `min` is set larger than any column to force one.
|
||||
const SingleColumnBoard = ({ states }: { states: BoardState[] }) => <StateBoard min={10000} states={states} />
|
||||
const columnsAuto = (min: number) => `[grid-template-columns:repeat(auto-fit,minmax(min(100%,${min}px),1fr))]`
|
||||
|
||||
// 8px gap keeps all three Variants groups on one line at the 1280px PC width (Brand 3 + Solid 5 + Outline 4 buttons in a 3-column grid); narrower widths wrap.
|
||||
function CompactRoleRow({ appearance }: { appearance: (typeof appearances)[number][0] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{rolesFor(appearance).map(([variant, label]) => (
|
||||
<Button key={variant} variant={variant} appearance={appearance}>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactSizeRow({ appearance }: { appearance: "brand" | "solid" }) {
|
||||
return (
|
||||
<div className={compactRow}>
|
||||
{sizes.map(([size, label]) => (
|
||||
<Button key={size} appearance={appearance} size={size}>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page (Sizes → Variants → States) with the ui_viz Button "All" boards.
|
||||
// The former Overview button was the default Brand Medium button already shown under Sizes, so Overview is merged into Sizes.
|
||||
// Named after the component, as in ui_viz. It is the only Button entry in the default sidebar, so Storybook shows Button as a single 1-depth item.
|
||||
export const All: Story = {
|
||||
name: "Button",
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPage className={compactPage}>
|
||||
<StoryPageHeader title="Button" />
|
||||
<StorySection title="Overview">
|
||||
<Button>Button</Button>
|
||||
</StorySection>
|
||||
<StorySection title="Sizes">
|
||||
<SizesBoard />
|
||||
{/* Same columns as Variants below (Brand / Standard Solid / Standard Outline): Brand and Standard Solid sizes line up with
|
||||
their Variants columns and the third column stays empty. */}
|
||||
<div className={`grid gap-6 ${columnsAuto(260)} min-[960px]:[grid-template-columns:minmax(0,234fr)_minmax(0,353fr)_minmax(0,271fr)]`}>
|
||||
<SingleColumnBoard states={[{ id: "sizes-brand", label: "Brand", children: <CompactSizeRow appearance="brand" /> }]} />
|
||||
<SingleColumnBoard states={[{ id: "sizes-solid", label: "Standard Solid", children: <CompactSizeRow appearance="solid" /> }]} />
|
||||
</div>
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard states={appearances.map(([appearance, label]) => ({ id: `roles-${appearance}`, label, wide: true, children: <RoleRow appearance={appearance} /> }))} />
|
||||
<div className={`grid gap-6 ${columnsAuto(260)} min-[960px]:[grid-template-columns:minmax(0,234fr)_minmax(0,353fr)_minmax(0,271fr)]`}>
|
||||
{appearances.map(([appearance, label]) => (
|
||||
<SingleColumnBoard key={appearance} states={[{ id: `roles-${appearance}`, label, children: <CompactRoleRow appearance={appearance} /> }]} />
|
||||
))}
|
||||
</div>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
states={[
|
||||
{ id: "brand-states", label: "Brand", wide: true, children: <BrandStates showNote={false} /> },
|
||||
{ id: "standard-solid-states", label: "Standard Solid", wide: true, children: <StandardStateTable appearance="solid" /> },
|
||||
{ id: "standard-outline-states", label: "Standard Outline", wide: true, children: <StandardStateTable appearance="outline" /> },
|
||||
]}
|
||||
/>
|
||||
<div className="grid gap-6">
|
||||
<SingleColumnBoard states={[{ id: "brand-states", label: "Brand", wide: true, children: <BrandStates showNote={false} /> }]} />
|
||||
<div className={twoColumns}>
|
||||
<SingleColumnBoard states={[{ id: "standard-solid-states", label: "Standard Solid", children: <StandardStateTable appearance="solid" compact /> }]} />
|
||||
<SingleColumnBoard states={[{ id: "standard-outline-states", label: "Standard Outline", children: <StandardStateTable appearance="outline" compact /> }]} />
|
||||
</div>
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
|
||||
@@ -48,22 +48,22 @@ function TwoMonths() {
|
||||
return box(<Calendar mode="single" numberOfMonths={2} defaultMonth={month} today={today} />)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "single", label: "Single", node: <Single showValue={false} /> },
|
||||
{ id: "disabled", label: "Disabled dates", node: <DisabledDates /> },
|
||||
{ id: "range", label: "Range", node: <Range /> },
|
||||
{ id: "months", label: "Two months", node: <TwoMonths /> },
|
||||
]
|
||||
// Same 3-column track as the Form pages (1 column on narrow screens). Each cell is a one-specimen StateBoard so subtitles, dividers and
|
||||
// specimen tops line up; the wide Two months specimen takes two tracks (the full row on narrow screens).
|
||||
const Grid = ({ children }: { children: ReactNode }) => (
|
||||
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] min-[1000px]:grid-cols-3">{children}</div>
|
||||
)
|
||||
const Cell = ({ id, label, wide, children }: { id: string; label: string; wide?: boolean; children: ReactNode }) => (
|
||||
<div className={wide ? "col-span-full min-w-0 min-[1000px]:col-span-2" : "min-w-0"}>
|
||||
<StateBoard min={10000} states={[{ id, label, children }]} />
|
||||
</div>
|
||||
)
|
||||
|
||||
// No `component`: Calendar props are a union of selection modes, so Stories without args would not type-check.
|
||||
const meta = { title: "Components/Search & Date/Calendar", parameters: { layout: "padded" } } satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={300} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, wide: s.id === "months", children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Calendar",
|
||||
@@ -71,9 +71,18 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Calendar" />
|
||||
<StorySection title="Overview">{board("single")}</StorySection>
|
||||
<StorySection title="Variants">{board("range", "months")}</StorySection>
|
||||
<StorySection title="States">{board("disabled")}</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<Grid>
|
||||
<Cell id="single" label="Single"><Single showValue={false} /></Cell>
|
||||
<Cell id="range" label="Range"><Range /></Cell>
|
||||
<Cell id="months" label="Two months" wide><TwoMonths /></Cell>
|
||||
</Grid>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<Grid>
|
||||
<Cell id="disabled" label="Disabled dates"><DisabledDates /></Cell>
|
||||
</Grid>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button } from "@hm-design/ui"
|
||||
import { Card, CardAction, CardContent, CardDescription, CardFooter, CardHeader, CardTitle, type CardSurface } from "@hm-design/ui"
|
||||
import { Input } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import type { BoardState } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Story structure follows Button / Input / Select: one representative "Card" Story shows the ui_viz cases on one page,
|
||||
@@ -34,6 +34,23 @@ function Specimen({ surface = "surface", action, title = "프로젝트 정보",
|
||||
)
|
||||
}
|
||||
|
||||
// Story-only equal-column board for this page: 3 columns on PC, 2 / 1 on narrower widths. Variants and Usage share it,
|
||||
// so a Usage cell has the same width as one Variants cell. Label markup/colors match StateBoard (shared component untouched).
|
||||
function CardBoard({ states }: { states: BoardState[] }) {
|
||||
return (
|
||||
<div className="grid grid-cols-1 items-start gap-x-6 gap-y-8 min-[640px]:grid-cols-2 min-[900px]:grid-cols-3">
|
||||
{states.map(({ id, label, children }) => (
|
||||
<section className="grid min-w-0 content-start gap-3" key={id} data-overview-state={id} aria-label={label}>
|
||||
<header className="grid gap-2 border-b border-black/10 pb-2">
|
||||
<h3 className="text-sm font-medium text-muted-foreground">{label}</h3>
|
||||
</header>
|
||||
{children}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FormSpecimen() {
|
||||
return (
|
||||
<Card>
|
||||
@@ -42,7 +59,7 @@ function FormSpecimen() {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="card-form-name" className="text-sm font-medium text-muted-foreground">
|
||||
<label htmlFor="card-form-name" className="text-sm font-medium text-foreground">
|
||||
프로젝트명
|
||||
</label>
|
||||
<Input id="card-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
|
||||
@@ -63,7 +80,7 @@ const meta = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: the common Story page (Overview → Variants → Usage) with every ui_viz case. Named after the component, as in ui_viz.
|
||||
// Representative Story: the common Story page (Variants → Usage) with every ui_viz case. Named after the component, as in ui_viz.
|
||||
// It is the only Card entry in the default sidebar, so Storybook shows Card as a single 1-depth item.
|
||||
export const All: Story = {
|
||||
name: "Card",
|
||||
@@ -71,20 +88,17 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Card" />
|
||||
<StorySection title="Overview">
|
||||
<StateBoard min={360} states={[{ id: "surface", label: "White", children: <Specimen /> }]} />
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard
|
||||
min={360}
|
||||
<CardBoard
|
||||
states={[
|
||||
{ id: "surface", label: "White", children: <Specimen /> },
|
||||
{ id: "content", label: "Content (Beige)", children: <Specimen surface="content" /> },
|
||||
{ id: "action", label: "Title + Action", children: <Specimen action /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<StateBoard min={360} states={[{ id: "form", label: "본문 조합 · Input", children: <FormSpecimen /> }]} />
|
||||
<CardBoard states={[{ id: "form", label: "본문 조합 · Input", children: <FormSpecimen /> }]} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
|
||||
@@ -142,20 +142,45 @@ const meta = { title: "Components/Data & Display/Carousel", component: Carousel,
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={460} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
|
||||
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
|
||||
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
|
||||
// The Media example spans two columns; carousels fill their column by design.
|
||||
const specimenColumns = {
|
||||
2: "min-[1000px]:grid-cols-2",
|
||||
3: "min-[1000px]:grid-cols-3",
|
||||
4: "min-[1000px]:grid-cols-4",
|
||||
} as const
|
||||
|
||||
function SpecimenGrid({ ids, columns, spans = {} }: { ids: string[]; columns: 2 | 3 | 4; spans?: Record<string, 2> }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${specimenColumns[columns]}`}>
|
||||
{ids.map((id) => {
|
||||
const s = sections.find((section) => section.id === id)!
|
||||
return (
|
||||
<div key={id} className={spans[id] ? "min-[1000px]:col-span-2" : undefined}>
|
||||
<StateBoard min={10000} states={[{ id: s.id, label: s.label, children: s.node }]} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
// Representative Story: Overview (Basic) is merged into Variants (Basic, Multiple items, Loop, Vertical), 4 columns on PC; Media stays under Usage.
|
||||
export const All: Story = {
|
||||
name: "Carousel",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Carousel" />
|
||||
<StorySection title="Overview">{board("basic")}</StorySection>
|
||||
<StorySection title="Variants">{board("multiple", "loop", "vertical")}</StorySection>
|
||||
<StorySection title="Usage">{board("media")}</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<SpecimenGrid ids={["basic", "multiple", "loop", "vertical"]} columns={4} />
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<SpecimenGrid ids={["media"]} columns={4} spans={{ media: 2 }} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,18 +1,48 @@
|
||||
import { useId, useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Checkbox, Label } 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"
|
||||
|
||||
// Current HM Checkbox. ui-viz has no Checkbox spec, so it was reference only.
|
||||
// The option label is the HM Label; its foreground role for Checkbox options is still TBD in ui_viz.
|
||||
|
||||
// Story-only Label color. The shared Label sets `color: var(--hm-label-text)` from an unlayered theme rule, so a utility
|
||||
// like `text-foreground` loses to it; overriding the Label's own token on the element works. Option labels use the default
|
||||
// text color; a Label next to a disabled Checkbox (`peer-disabled`) keeps the secondary color. State titles are untouched.
|
||||
const optionLabel = "[--hm-label-text:var(--foreground)] peer-disabled:[--hm-label-text:var(--hm-sem-fg-secondary)]"
|
||||
|
||||
function Option({ label, children }: { label: string; children: (id: string) => ReactNode }) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{children(id)}
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Label htmlFor={id} className={optionLabel}>{label}</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative-page layout (Storybook presentation only): 3 equal columns on PC (iframe width 1000px and up), shrinking
|
||||
// to fit below that. 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.
|
||||
function StateGrid({ states }: { states: BoardState[] }) {
|
||||
return (
|
||||
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr))] min-[1000px]:grid-cols-3">
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[state]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The wrapping Long Label example (former Usage section of the representative page), also used by the hidden `LongLabel` Story.
|
||||
function LongLabelExample() {
|
||||
return (
|
||||
<div className="max-w-[220px]">
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox id="cb-long" className="mt-0.5" />
|
||||
<Label htmlFor="cb-long" className={optionLabel}>A very long checkbox label that wraps onto multiple lines</Label>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -49,20 +79,12 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Checkbox" />
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
min={240}
|
||||
<StorySection title="States">
|
||||
<StateGrid
|
||||
states={[
|
||||
{ id: "unchecked", label: "Unchecked", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
|
||||
{ id: "checked", label: "Checked", children: <Option label="Option">{(id) => <Checkbox id={id} defaultChecked />}</Option> },
|
||||
{ id: "indeterminate", label: "Indeterminate", children: <Controlled /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
min={240}
|
||||
states={[
|
||||
{
|
||||
id: "hover",
|
||||
label: "Hover",
|
||||
@@ -89,26 +111,7 @@ export const All: Story = {
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Validation">
|
||||
<StateBoard min={240} states={[{ id: "error", label: "Error", children: <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }]} />
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<StateBoard
|
||||
min={240}
|
||||
states={[
|
||||
{
|
||||
id: "long",
|
||||
label: "Long Label",
|
||||
children: (
|
||||
<div className="max-w-[220px]">
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox id="cb-long" className="mt-0.5" />
|
||||
<Label htmlFor="cb-long">A very long checkbox label that wraps onto multiple lines</Label>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<StateGrid states={[{ id: "error", label: "Error", children: <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }]} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
@@ -123,6 +126,8 @@ export const Checked: Story = { tags: ["verification"], render: () => <Option la
|
||||
export const Indeterminate: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} checked="indeterminate" />}</Option> }
|
||||
export const Disabled: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} disabled defaultChecked />}</Option> }
|
||||
export const Error: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }
|
||||
// Former Usage section of the representative page, kept as its own Story.
|
||||
export const LongLabel: Story = { tags: ["verification"], name: "Long Label", render: () => <LongLabelExample /> }
|
||||
export const Playground: Story = {
|
||||
tags: ["verification"],
|
||||
args: { disabled: false, defaultChecked: false },
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { ChevronDownIcon } from "lucide-react"
|
||||
import { Button, Collapsible, CollapsibleContent, CollapsibleTrigger } 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 "Collapsible" Story shows one independent disclosure per state with real, clickable triggers. The same
|
||||
@@ -70,8 +70,21 @@ const meta = { title: "Components/Disclosure/Collapsible", component: Collapsibl
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={360} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
// Representative-page layout (Storybook presentation only): 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), so the title, divider and
|
||||
// specimen start line up, unused columns stay empty and `items-start` lets an opened item grow without touching its neighbors.
|
||||
function StateGrid({ states, cols }: { states: BoardState[]; cols: 2 | 3 }) {
|
||||
return (
|
||||
<div className={`grid grid-cols-1 items-start gap-6 ${cols === 3 ? "min-[1000px]:grid-cols-3" : "min-[1000px]:grid-cols-2"}`}>
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[state]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const board = (cols: 2 | 3, ...ids: string[]) => (
|
||||
<StateGrid cols={cols} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
@@ -81,9 +94,8 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Collapsible" />
|
||||
<StorySection title="Overview">{board("closed")}</StorySection>
|
||||
<StorySection title="States">{board("open", "disabled")}</StorySection>
|
||||
<StorySection title="Usage">{board("long", "controlled")}</StorySection>
|
||||
<StorySection title="States">{board(3, "closed", "open", "disabled")}</StorySection>
|
||||
<StorySection title="Usage">{board(3, "long", "controlled")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -21,10 +21,11 @@ const longOptions: ComboboxOption[] = [
|
||||
{ value: "b", label: "짧은 옵션" },
|
||||
]
|
||||
|
||||
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
|
||||
// Label 14/20/500 in the default text color; a Disabled field keeps the secondary color at 0.6 (same as the Form pages).
|
||||
function Field({ label, id, disabled, children }: { label: string; id: string; disabled?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid max-w-xs gap-2">
|
||||
<label htmlFor={id} className="justify-self-start text-sm font-medium text-muted-foreground">
|
||||
<label htmlFor={id} className={`justify-self-start text-sm font-medium ${disabled ? "text-muted-foreground opacity-60" : "text-foreground"}`}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
@@ -71,7 +72,7 @@ function LongLabel() {
|
||||
|
||||
function Disabled() {
|
||||
return (
|
||||
<Field label="비활성" id="cb-disabled">
|
||||
<Field label="비활성" id="cb-disabled" disabled>
|
||||
<Combobox id="cb-disabled" options={frameworks} defaultValue="next" disabled />
|
||||
</Field>
|
||||
)
|
||||
@@ -99,22 +100,22 @@ function SelectComparison() {
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "empty", label: "Placeholder", node: <Empty /> },
|
||||
{ id: "controlled", label: "Selected", node: <Controlled showValue={false} /> },
|
||||
{ id: "long", label: "Long option", node: <LongLabel /> },
|
||||
{ id: "disabled", label: "Disabled", node: <Disabled /> },
|
||||
]
|
||||
// Same 3-column track as the Form pages (1 column on narrow screens). Each cell is a one-specimen StateBoard so subtitles, dividers and
|
||||
// fields line up; sections with fewer specimens leave the remaining tracks empty. A wide cell takes two tracks (the full row on narrow screens).
|
||||
const Grid = ({ children }: { children: ReactNode }) => (
|
||||
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] min-[1000px]:grid-cols-3">{children}</div>
|
||||
)
|
||||
const Cell = ({ id, label, wide, children }: { id: string; label: string; wide?: boolean; children: ReactNode }) => (
|
||||
<div className={wide ? "col-span-full min-w-0 min-[1000px]:col-span-2" : "min-w-0"}>
|
||||
<StateBoard min={10000} states={[{ id, label, children }]} />
|
||||
</div>
|
||||
)
|
||||
|
||||
// No `component`: Combobox has a required `options` prop, so Stories without args would not type-check.
|
||||
const meta = { title: "Components/Search & Date/Combobox", parameters: { layout: "padded" } } satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Combobox",
|
||||
@@ -122,18 +123,24 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Combobox" />
|
||||
<StorySection title="Overview">{board("empty")}</StorySection>
|
||||
<StorySection title="Sizes">
|
||||
{/* The field label already names the size, so the Medium specimen has no separate example title. */}
|
||||
<div className="grid gap-10">
|
||||
<div className="max-w-sm">
|
||||
<Medium />
|
||||
</div>
|
||||
<StateBoard min={320} states={[{ id: "select-comparison", label: "Select / Combobox", wide: true, children: <SelectComparison /> }]} />
|
||||
</div>
|
||||
<Grid>
|
||||
<Cell id="empty" label="Default"><Empty /></Cell>
|
||||
<Cell id="medium" label="Medium"><Medium /></Cell>
|
||||
</Grid>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<Grid>
|
||||
<Cell id="controlled" label="Selected"><Controlled showValue={false} /></Cell>
|
||||
<Cell id="disabled" label="Disabled"><Disabled /></Cell>
|
||||
</Grid>
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<Grid>
|
||||
<Cell id="long" label="Long option"><LongLabel /></Cell>
|
||||
<Cell id="select-comparison" label="Select / Combobox" wide><SelectComparison /></Cell>
|
||||
</Grid>
|
||||
</StorySection>
|
||||
<StorySection title="States">{board("controlled", "disabled")}</StorySection>
|
||||
<StorySection title="Usage">{board("long")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -80,20 +80,21 @@ function Long() {
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "basic", label: "Search", node: <Basic showValue={false} /> },
|
||||
{ id: "grouped", label: "Groups", node: <Grouped /> },
|
||||
{ id: "long", label: "Long list", node: <Long /> },
|
||||
]
|
||||
// Same 3-column track as the Form pages (1 column on narrow screens). Each cell is a one-specimen StateBoard so subtitles, dividers and
|
||||
// command boxes line up; sections with fewer specimens leave the remaining tracks empty.
|
||||
const Grid = ({ children }: { children: ReactNode }) => (
|
||||
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] min-[1000px]:grid-cols-3">{children}</div>
|
||||
)
|
||||
const Cell = ({ id, label, children }: { id: string; label: string; children: ReactNode }) => (
|
||||
<div className="min-w-0">
|
||||
<StateBoard min={10000} states={[{ id, label, children }]} />
|
||||
</div>
|
||||
)
|
||||
|
||||
const meta = { title: "Components/Search & Date/Command", component: Command, parameters: { layout: "padded" } } satisfies Meta<typeof Command>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={360} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Command",
|
||||
@@ -101,9 +102,17 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Command" />
|
||||
<StorySection title="Overview">{board("basic")}</StorySection>
|
||||
<StorySection title="Variants">{board("grouped")}</StorySection>
|
||||
<StorySection title="Usage">{board("long")}</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<Grid>
|
||||
<Cell id="basic" label="Search"><Basic showValue={false} /></Cell>
|
||||
<Cell id="grouped" label="Groups"><Grouped /></Cell>
|
||||
</Grid>
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<Grid>
|
||||
<Cell id="long" label="Long list"><Long /></Cell>
|
||||
</Grid>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -83,7 +83,7 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Context Menu" />
|
||||
<StorySection title="Overview">
|
||||
<StorySection title="Basic">
|
||||
<Full />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
|
||||
@@ -12,10 +12,11 @@ import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-pag
|
||||
const today = new Date(2026, 9, 2)
|
||||
const calendarProps = { today, startMonth: new Date(2026, 0), endMonth: new Date(2027, 11) }
|
||||
|
||||
function Field({ label, id, children }: { label: string; id: string; children: ReactNode }) {
|
||||
// Label 14/20/500 in the default text color; a Disabled field keeps the secondary color at 0.6 (same as the Form pages).
|
||||
function Field({ label, id, disabled, children }: { label: string; id: string; disabled?: boolean; children: ReactNode }) {
|
||||
return (
|
||||
<div className="grid max-w-xs gap-2">
|
||||
<label htmlFor={id} className="justify-self-start text-sm font-medium text-muted-foreground">
|
||||
<label htmlFor={id} className={`justify-self-start text-sm font-medium ${disabled ? "text-muted-foreground opacity-60" : "text-foreground"}`}>
|
||||
{label}
|
||||
</label>
|
||||
{children}
|
||||
@@ -39,16 +40,19 @@ function Selected() {
|
||||
)
|
||||
}
|
||||
|
||||
function FormatExamples() {
|
||||
function DefaultFormat() {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<Field label="기본 형식 · YYYY.MM.DD" id="dp-padded">
|
||||
<DatePicker id="dp-padded" defaultValue={new Date(2026, 0, 9)} calendarProps={calendarProps} />
|
||||
</Field>
|
||||
<Field label="Custom format override" id="dp-custom">
|
||||
<DatePicker id="dp-custom" defaultValue={new Date(2026, 9, 6)} format={(date) => date.toLocaleDateString("ko-KR")} calendarProps={calendarProps} />
|
||||
</Field>
|
||||
</div>
|
||||
<Field label="기본 형식 · YYYY.MM.DD" id="dp-padded">
|
||||
<DatePicker id="dp-padded" defaultValue={new Date(2026, 0, 9)} calendarProps={calendarProps} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
function CustomFormat() {
|
||||
return (
|
||||
<Field label="Custom format override" id="dp-custom">
|
||||
<DatePicker id="dp-custom" defaultValue={new Date(2026, 9, 6)} format={(date) => date.toLocaleDateString("ko-KR")} calendarProps={calendarProps} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -86,29 +90,27 @@ function DisabledDates() {
|
||||
|
||||
function Disabled() {
|
||||
return (
|
||||
<Field label="비활성" id="dp-disabled">
|
||||
<Field label="비활성" id="dp-disabled" disabled>
|
||||
<DatePicker id="dp-disabled" disabled defaultValue={new Date(2026, 9, 15)} />
|
||||
</Field>
|
||||
)
|
||||
}
|
||||
|
||||
const sections = [
|
||||
{ id: "empty", label: "Empty", node: <Empty /> },
|
||||
{ id: "selected", label: "Selected", node: <Selected /> },
|
||||
{ id: "format", label: "Format", node: <FormatExamples /> },
|
||||
{ id: "controlled", label: "Controlled", node: <Controlled showValue={false} /> },
|
||||
{ id: "disabled-dates", label: "Disabled dates", node: <DisabledDates /> },
|
||||
{ id: "disabled", label: "Disabled", node: <Disabled /> },
|
||||
]
|
||||
// Same 3-column track as the Form pages (1 column on narrow screens). Each cell is a one-specimen StateBoard so subtitles, dividers and
|
||||
// fields line up; sections with fewer specimens leave the remaining tracks empty.
|
||||
const Grid = ({ children }: { children: ReactNode }) => (
|
||||
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] min-[1000px]:grid-cols-3">{children}</div>
|
||||
)
|
||||
const Cell = ({ id, label, children }: { id: string; label: string; children: ReactNode }) => (
|
||||
<div className="min-w-0">
|
||||
<StateBoard min={10000} states={[{ id, label, children }]} />
|
||||
</div>
|
||||
)
|
||||
|
||||
const meta = { title: "Components/Search & Date/Date Picker", component: DatePicker, parameters: { layout: "padded" } } satisfies Meta<typeof DatePicker>
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
export const All: Story = {
|
||||
name: "Date Picker",
|
||||
@@ -116,15 +118,26 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Date Picker" />
|
||||
<StorySection title="Overview">{board("empty")}</StorySection>
|
||||
{/* The field label already names the size, so the Medium specimen has no separate example title. */}
|
||||
<StorySection title="Sizes">
|
||||
<div className="max-w-sm">
|
||||
<Medium />
|
||||
</div>
|
||||
<Grid>
|
||||
<Cell id="empty" label="Default"><Empty /></Cell>
|
||||
<Cell id="medium" label="Medium"><Medium /></Cell>
|
||||
</Grid>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<Grid>
|
||||
<Cell id="selected" label="Selected"><Selected /></Cell>
|
||||
<Cell id="controlled" label="Controlled"><Controlled showValue={false} /></Cell>
|
||||
<Cell id="disabled-dates" label="Disabled dates"><DisabledDates /></Cell>
|
||||
<Cell id="disabled" label="Disabled"><Disabled /></Cell>
|
||||
</Grid>
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<Grid>
|
||||
<Cell id="format-default" label="Default format"><DefaultFormat /></Cell>
|
||||
<Cell id="format-custom" label="Custom format"><CustomFormat /></Cell>
|
||||
</Grid>
|
||||
</StorySection>
|
||||
<StorySection title="States">{board("selected", "controlled", "disabled-dates", "disabled")}</StorySection>
|
||||
<StorySection title="Usage">{board("format")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { Button } from "@hm-design/ui"
|
||||
import { Dialog, DialogBody, DialogClose, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@hm-design/ui"
|
||||
import { Input } from "@hm-design/ui"
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@hm-design/ui"
|
||||
import { StateBoard, type BoardState } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Dialog is an overlay (Radix Portal), so it does not fit the StateBoard. The representative Story is a row of triggers that open
|
||||
@@ -161,7 +162,7 @@ function FormContent({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보" actions={confirmActions}>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="dialog-form-name" className="text-sm font-medium text-muted-foreground">
|
||||
<label htmlFor="dialog-form-name" className="text-sm font-medium text-foreground">
|
||||
프로젝트명
|
||||
</label>
|
||||
<Input id="dialog-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
|
||||
@@ -176,29 +177,44 @@ type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: the common Story page; every case opens from its trigger. Named after the component, as in ui_viz.
|
||||
// It is the only Dialog entry in the default sidebar, so Storybook shows Dialog as a single 1-depth item.
|
||||
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
|
||||
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
|
||||
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
|
||||
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
|
||||
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page. Overview is merged into Variants (Basic is its first item).
|
||||
export const All: Story = {
|
||||
name: "Dialog",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
title="Dialog"
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<Basic />
|
||||
</StorySection>
|
||||
<StoryPageHeader title="Dialog" />
|
||||
<StorySection title="Variants">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<LongContent />
|
||||
<WideContent />
|
||||
<BodyScroll />
|
||||
</div>
|
||||
<SpecimenGrid
|
||||
states={[
|
||||
{ id: "basic", label: "Basic", children: <Basic /> },
|
||||
{ id: "long-content", label: "Long Content", children: <LongContent /> },
|
||||
{ id: "wide-content", label: "Wide Content", children: <WideContent /> },
|
||||
{ id: "body-scroll", label: "Body Scroll", children: <BodyScroll /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<DeleteConfirmation />
|
||||
<FormContent />
|
||||
</div>
|
||||
<SpecimenGrid
|
||||
states={[
|
||||
{ id: "delete-confirmation", label: "Delete Confirmation", children: <DeleteConfirmation /> },
|
||||
{ id: "form", label: "Form", children: <FormContent /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
// Usage: preserved native label fixtures do not define a new-screen exception. Use public Label
|
||||
// for supported fields (docs/components/label.md §5); copy only actions requested by the screen.
|
||||
import { Button, Drawer, DrawerBody, DrawerClose, DrawerContent, DrawerDescription, DrawerFooter, DrawerHeader, DrawerTitle, DrawerTrigger, Input } from "@hm-design/ui"
|
||||
import { StateBoard, type BoardState } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Drawer is a modal vaul overlay (Portal). The representative Story opens the real Drawer from its triggers; drag the handle or
|
||||
@@ -70,7 +71,7 @@ function FormCase({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보" actions={actions}>
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="drawer-form-name" className="text-sm font-medium text-muted-foreground">
|
||||
<label htmlFor="drawer-form-name" className="text-sm font-medium text-foreground">
|
||||
프로젝트명
|
||||
</label>
|
||||
<Input id="drawer-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
|
||||
@@ -95,20 +96,35 @@ const meta = { title: "Components/Overlay/Drawer", component: Drawer, parameters
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
|
||||
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
|
||||
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
|
||||
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
|
||||
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: Overview and Usage are merged into one Usage section (Basic, Form, Long Content).
|
||||
export const All: Story = {
|
||||
name: "Drawer",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Drawer" />
|
||||
<StorySection title="Overview">
|
||||
<Basic />
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<FormCase />
|
||||
<LongCase />
|
||||
</div>
|
||||
<SpecimenGrid
|
||||
states={[
|
||||
{ id: "basic", label: "Basic", children: <Basic /> },
|
||||
{ id: "form", label: "Form", children: <FormCase /> },
|
||||
{ id: "long-content", label: "Long Content", children: <LongCase /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
|
||||
@@ -87,7 +87,7 @@ export const All: Story = {
|
||||
<StoryPageHeader
|
||||
title="Dropdown Menu"
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<StorySection title="Basic">
|
||||
<Full />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@hm-design/ui"
|
||||
import { StateBoard, type BoardState } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Hover Card is a Radix overlay (Portal) for a richer preview than Tooltip. The representative Story shows real triggers
|
||||
@@ -48,17 +49,34 @@ const meta = { title: "Components/Overlay/Hover Card", component: HoverCard, par
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
|
||||
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
|
||||
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
|
||||
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
|
||||
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: Overview and Usage are merged into one Variants section (Basic, Long Content).
|
||||
export const All: Story = {
|
||||
name: "Hover Card",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Hover Card" />
|
||||
<StorySection title="Overview">
|
||||
<Preview />
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<LongContent />
|
||||
<StorySection title="Variants">
|
||||
<SpecimenGrid
|
||||
states={[
|
||||
{ id: "basic", label: "Basic", children: <Preview /> },
|
||||
{ id: "long-content", label: "Long Content", children: <LongContent /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
|
||||
@@ -9,8 +9,9 @@ import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-pag
|
||||
// separate Stories tagged `verification`, which the sidebar hides by default (see .storybook/main.ts).
|
||||
//
|
||||
// Label, Required star and Helper/Error message are not part of the Input component. Specimen below composes them with
|
||||
// plain elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 and Helper 12/18/400 in the
|
||||
// secondary text color, Error message 11/18 in the destructive color, field gap 8.
|
||||
// plain elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 in the default text color
|
||||
// (Disabled keeps the secondary color at 0.6) and Helper 12/18/400 in the secondary text color, Error message 11/18
|
||||
// in the destructive color, field gap 8.
|
||||
|
||||
function Specimen({
|
||||
label = "Project name",
|
||||
@@ -25,7 +26,7 @@ function Specimen({
|
||||
const messageId = `${fieldId}-message`
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor={fieldId} className={`justify-self-start text-sm font-medium text-muted-foreground${input.disabled ? " opacity-60" : ""}`}>
|
||||
<label htmlFor={fieldId} className={`justify-self-start text-sm font-medium ${input.disabled ? "text-muted-foreground opacity-60" : "text-foreground"}`}>
|
||||
{label}
|
||||
{required ? (
|
||||
<span aria-hidden="true" className="text-destructive">
|
||||
|
||||
@@ -1,13 +1,34 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Input, Label, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } 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"
|
||||
|
||||
// Label has no standalone ui_viz spec. Its typography and color are the Field Label role owned by Input / Textarea
|
||||
// (14/20/500, color.fg.secondary). Required star and messages are composed here as a consumer would (not part of Label).
|
||||
|
||||
// Story-only Label color. The shared Label sets `color: var(--hm-label-text)` from an unlayered theme rule, so a utility like
|
||||
// `text-foreground` loses to it; overriding the Label's own token on the element works. The Disabled example keeps the
|
||||
// shared secondary color (at 0.5 through `peer-disabled`). State titles are untouched.
|
||||
const fieldLabel = "[--hm-label-text:var(--foreground)]"
|
||||
|
||||
// Representative-page layout (Storybook presentation only): 3 equal columns on PC (iframe width 1000px and up), shrinking to
|
||||
// fit 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. A section with fewer than 3 specimens keeps the
|
||||
// same column width and leaves the remaining tracks empty.
|
||||
function SpecimenGrid({ states }: { states: BoardState[] }) {
|
||||
return (
|
||||
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[state]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Components/Form/Label",
|
||||
// `internal` keeps it out of the sidebar by default; Story IDs and direct URLs stay valid.
|
||||
tags: ["internal"],
|
||||
component: Label,
|
||||
parameters: {
|
||||
layout: "padded",
|
||||
@@ -28,25 +49,19 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Label" />
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
<StorySection title="States">
|
||||
<SpecimenGrid
|
||||
states={[
|
||||
{
|
||||
id: "default",
|
||||
label: "Default",
|
||||
children: (
|
||||
<div className="grid max-w-sm gap-2">
|
||||
<Label htmlFor="label-default">Project name</Label>
|
||||
<Label htmlFor="label-default" className={fieldLabel}>Project name</Label>
|
||||
<Input id="label-default" size="medium" placeholder="Enter a project name" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
states={[
|
||||
{
|
||||
id: "disabled",
|
||||
label: "Disabled",
|
||||
@@ -63,20 +78,26 @@ export const All: Story = {
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<div className="mb-4 grid max-w-sm gap-2">
|
||||
<Label htmlFor="label-select">부서 선택</Label>
|
||||
<Select><SelectTrigger id="label-select" size="medium"><SelectValue placeholder="부서를 선택하세요" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="design">디자인</SelectItem><SelectItem value="engineering">개발</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<StateBoard
|
||||
<SpecimenGrid
|
||||
states={[
|
||||
{
|
||||
id: "select",
|
||||
label: "Select",
|
||||
children: (
|
||||
<div className="grid max-w-sm gap-2">
|
||||
<Label htmlFor="label-select" className={fieldLabel}>부서 선택</Label>
|
||||
<Select><SelectTrigger id="label-select" size="medium"><SelectValue placeholder="부서를 선택하세요" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="design">디자인</SelectItem><SelectItem value="engineering">개발</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "required",
|
||||
label: "Required",
|
||||
children: (
|
||||
<div className="grid max-w-sm gap-2">
|
||||
<Label htmlFor="label-required">
|
||||
<Label htmlFor="label-required" className={fieldLabel}>
|
||||
Project name
|
||||
<span aria-hidden="true" className="text-destructive">
|
||||
*
|
||||
@@ -91,7 +112,7 @@ export const All: Story = {
|
||||
label: "Long Label",
|
||||
children: (
|
||||
<div className="grid max-w-[220px] gap-2">
|
||||
<Label htmlFor="label-long">A very long label text that wraps to multiple lines</Label>
|
||||
<Label htmlFor="label-long" className={fieldLabel}>A very long label text that wraps to multiple lines</Label>
|
||||
<Input id="label-long" size="medium" />
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button, Input, Popover, PopoverClose, PopoverContent, PopoverTrigger } from "@hm-design/ui"
|
||||
import { StateBoard, type BoardState } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Popover is a non-modal Radix overlay (Portal). The representative Story opens the real panels from their triggers;
|
||||
@@ -33,7 +34,7 @@ function FormPanel() {
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" aria-label="프로젝트 이름 변경">
|
||||
<div className="grid gap-3">
|
||||
<label htmlFor="popover-name" className="text-sm font-medium text-muted-foreground">
|
||||
<label htmlFor="popover-name" className="text-sm font-medium text-foreground">
|
||||
프로젝트명
|
||||
</label>
|
||||
<Input id="popover-name" size="medium" placeholder="프로젝트명을 입력하세요" />
|
||||
@@ -55,22 +56,18 @@ function FormPanel() {
|
||||
)
|
||||
}
|
||||
|
||||
function Aligned() {
|
||||
function Aligned({ align }: { align: "start" | "center" | "end" }) {
|
||||
return (
|
||||
<div className="flex w-full max-w-md justify-between gap-3">
|
||||
{(["start", "center", "end"] as const).map((align) => (
|
||||
<Popover key={align}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="primary" appearance="outline" size="sm">
|
||||
{`align ${align}`}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align={align} side="bottom" aria-label={`정렬 예제: ${align}`}>
|
||||
<p>{`align="${align}"`}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
))}
|
||||
</div>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="primary" appearance="outline" size="sm">
|
||||
{`align ${align}`}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align={align} side="bottom" aria-label={`정렬 예제: ${align}`}>
|
||||
<p>{`align="${align}"`}</p>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -78,20 +75,39 @@ const meta = { title: "Components/Overlay/Popover", component: Popover, paramete
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
|
||||
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
|
||||
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
|
||||
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
|
||||
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: Overview is merged into Variants (Basic is its first item); each align trigger sits in its own column.
|
||||
export const All: Story = {
|
||||
name: "Popover",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Popover" />
|
||||
<StorySection title="Overview">
|
||||
<Info />
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<Aligned />
|
||||
<SpecimenGrid
|
||||
states={[
|
||||
{ id: "basic", label: "Basic", children: <Info /> },
|
||||
{ id: "align-start", label: "Align Start", children: <Aligned align="start" /> },
|
||||
{ id: "align-center", label: "Align Center", children: <Aligned align="center" /> },
|
||||
{ id: "align-end", label: "Align End", children: <Aligned align="end" /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<FormPanel />
|
||||
<SpecimenGrid states={[{ id: "form", label: "Form", children: <FormPanel /> }]} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
|
||||
@@ -49,20 +49,44 @@ const meta = { title: "Components/Data & Display/Progress", component: Progress,
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
// Representative-page layout (Storybook presentation only): 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 every subtitle and divider
|
||||
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
|
||||
const specimenColumns = {
|
||||
2: "min-[1000px]:grid-cols-2",
|
||||
3: "min-[1000px]:grid-cols-3",
|
||||
4: "min-[1000px]:grid-cols-4",
|
||||
} as const
|
||||
|
||||
function SpecimenGrid({ ids, columns, spans = {} }: { ids: string[]; columns: 2 | 3 | 4; spans?: Record<string, 2> }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] ${specimenColumns[columns]}`}>
|
||||
{ids.map((id) => {
|
||||
const s = sections.find((section) => section.id === id)!
|
||||
return (
|
||||
<div key={id} className={spans[id] ? "min-[1000px]:col-span-2" : undefined}>
|
||||
<StateBoard min={10000} states={[{ id: s.id, label: s.label, children: s.node }]} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
// Representative Story: Overview (50%) is merged into States (0%, 50%, 100%); max = 200 and Interactive stay under Usage. 3 columns on PC.
|
||||
export const All: Story = {
|
||||
name: "Progress",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Progress" />
|
||||
<StorySection title="Overview">{board("middle")}</StorySection>
|
||||
<StorySection title="States">{board("zero", "full")}</StorySection>
|
||||
<StorySection title="Usage">{board("custom-maximum", "interactive")}</StorySection>
|
||||
<StorySection title="States">
|
||||
<SpecimenGrid ids={["zero", "middle", "full"]} columns={3} />
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<SpecimenGrid ids={["custom-maximum", "interactive"]} columns={3} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Label, RadioGroup, RadioGroupItem } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import type { CSSProperties } from "react"
|
||||
import { StateBoard, type BoardState } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Current HM Radio Group. ui-viz has no Radio spec, so it was reference only.
|
||||
|
||||
// Story-only Label color. The shared Label sets `color: var(--hm-label-text)` from an unlayered theme rule, so a utility
|
||||
// like `text-foreground` loses to it; overriding the Label's own token on the element works. Option labels use the default
|
||||
// text color; a Label next to a disabled item (`peer-disabled`) keeps the secondary color. State titles are untouched.
|
||||
const optionLabel = "[--hm-label-text:var(--foreground)] peer-disabled:[--hm-label-text:var(--hm-sem-fg-secondary)]"
|
||||
|
||||
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), shrinking to
|
||||
// fit below that (`min` is the narrowest column). Each specimen sits in its own single-column StateBoard (min larger than
|
||||
// any column), so StateBoard's auto-fill grid cannot leave an empty track. Full class names are listed so Tailwind sees them.
|
||||
const specimenColumns = {
|
||||
2: "min-[1000px]:grid-cols-2",
|
||||
4: "min-[1000px]:grid-cols-4",
|
||||
} as const
|
||||
|
||||
function SpecimenGrid({ states, columns, min = 220 }: { states: BoardState[]; columns: keyof typeof specimenColumns; min?: number }) {
|
||||
return (
|
||||
<div
|
||||
className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--sb-col-min)),1fr))] ${specimenColumns[columns]}`}
|
||||
style={{ "--sb-col-min": `${min}px` } as CSSProperties}
|
||||
>
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[state]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Group({
|
||||
prefix,
|
||||
defaultValue = "a",
|
||||
@@ -28,7 +55,7 @@ function Group({
|
||||
{["a", "b", "c"].map((v) => (
|
||||
<div key={v} className="flex items-center gap-2">
|
||||
<RadioGroupItem value={v} id={`${prefix}-${v}`} disabled={disabledItem === v} aria-invalid={invalid ? true : undefined} data-sb-hover={hoverItem === v ? "" : undefined} />
|
||||
<Label htmlFor={`${prefix}-${v}`}>Option {v.toUpperCase()}</Label>
|
||||
<Label htmlFor={`${prefix}-${v}`} className={optionLabel}>Option {v.toUpperCase()}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
@@ -57,15 +84,20 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Radio Group" />
|
||||
<StorySection title="Overview">
|
||||
<StateBoard min={240} states={[{ id: "vertical", label: "Vertical", children: <Group prefix="v" defaultValue="b" /> }]} />
|
||||
</StorySection>
|
||||
{/* Overview is merged into Variants: its Vertical example is the first Variants item. */}
|
||||
<StorySection title="Variants">
|
||||
<StateBoard min={240} states={[{ id: "horizontal", label: "Horizontal", children: <Group prefix="h" orientation="horizontal" /> }]} />
|
||||
<SpecimenGrid
|
||||
columns={2}
|
||||
min={320}
|
||||
states={[
|
||||
{ id: "vertical", label: "Vertical", children: <Group prefix="v" defaultValue="b" /> },
|
||||
{ id: "horizontal", label: "Horizontal", children: <Group prefix="h" orientation="horizontal" /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
min={240}
|
||||
<SpecimenGrid
|
||||
columns={4}
|
||||
states={[
|
||||
{ id: "hover", label: "Hover", children: <Group prefix="hv" defaultValue="" hoverItem="a" /> },
|
||||
{ id: "focus", label: "Focus", children: <Group prefix="f" /> },
|
||||
@@ -75,7 +107,8 @@ export const All: Story = {
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Validation">
|
||||
<StateBoard min={240} states={[{ id: "error", label: "Error", children: <Group prefix="e" invalid /> }]} />
|
||||
{/* Same 4-column track as States, so the Error example keeps the width of one States column. */}
|
||||
<SpecimenGrid columns={4} states={[{ id: "error", label: "Error", children: <Group prefix="e" invalid /> }]} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } 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 Story shows real, draggable layouts; the same specimens also exist as Stories tagged `verification` (hidden
|
||||
@@ -99,8 +99,21 @@ const meta = { title: "Components/Disclosure/Resizable", component: ResizablePan
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={580} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
// Representative-page layout (Storybook presentation only): 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), so the title, divider and
|
||||
// specimen start line up and unused columns stay empty. The frames keep their own size (max 560px wide).
|
||||
function StateGrid({ states, cols }: { states: BoardState[]; cols: 2 | 3 }) {
|
||||
return (
|
||||
<div className={`grid grid-cols-1 items-start gap-6 ${cols === 3 ? "min-[1000px]:grid-cols-3" : "min-[1000px]:grid-cols-2"}`}>
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[state]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const board = (cols: 2 | 3, ...ids: string[]) => (
|
||||
<StateGrid cols={cols} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
@@ -112,9 +125,8 @@ export const All: Story = {
|
||||
<StoryPageHeader
|
||||
title="Resizable"
|
||||
/>
|
||||
<StorySection title="Overview">{board("horizontal")}</StorySection>
|
||||
<StorySection title="Variants">{board("vertical", "handle")}</StorySection>
|
||||
<StorySection title="Usage">{board("minmax", "collapsible")}</StorySection>
|
||||
<StorySection title="Variants">{board(3, "horizontal", "handle", "vertical")}</StorySection>
|
||||
<StorySection title="Usage">{board(3, "minmax", "collapsible")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -77,20 +77,44 @@ const meta = { title: "Components/Data & Display/Scroll Area", component: Scroll
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
|
||||
// (300px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
|
||||
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
|
||||
const specimenColumns = {
|
||||
2: "min-[1000px]:grid-cols-2",
|
||||
3: "min-[1000px]:grid-cols-3",
|
||||
4: "min-[1000px]:grid-cols-4",
|
||||
} as const
|
||||
|
||||
function SpecimenGrid({ ids, columns, spans = {} }: { ids: string[]; columns: 2 | 3 | 4; spans?: Record<string, 2> }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] ${specimenColumns[columns]}`}>
|
||||
{ids.map((id) => {
|
||||
const s = sections.find((section) => section.id === id)!
|
||||
return (
|
||||
<div key={id} className={spans[id] ? "min-[1000px]:col-span-2" : undefined}>
|
||||
<StateBoard min={10000} states={[{ id: s.id, label: s.label, children: s.node }]} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
// Representative Story: Overview (Vertical) is merged into Variants (Vertical, Horizontal, Both axes); Table-like stays under Usage. 3 columns on PC.
|
||||
export const All: Story = {
|
||||
name: "Scroll Area",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Scroll Area" />
|
||||
<StorySection title="Overview">{board("vertical")}</StorySection>
|
||||
<StorySection title="Variants">{board("horizontal", "both")}</StorySection>
|
||||
<StorySection title="Usage">{board("table-like")}</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<SpecimenGrid ids={["vertical", "horizontal", "both"]} columns={3} />
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<SpecimenGrid ids={["table-like"]} columns={3} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useId, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, type SelectSize } 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"
|
||||
|
||||
// Story structure follows ui_viz (implementations/web/storybook/Select.stories.jsx and SelectSizes.stories.jsx):
|
||||
@@ -42,9 +42,11 @@ type SpecimenProps = {
|
||||
|
||||
function SelectSpecimen({ listSize, label = "Select", placeholder = "Choose a status", size, defaultValue, defaultOpen, disabled, "aria-invalid": ariaInvalid, options = statusOptions }: SpecimenProps) {
|
||||
const id = useId()
|
||||
// A Label next to an enabled Select uses the default text color; disabled keeps the secondary color at 0.6.
|
||||
const labelTone = disabled ? "text-muted-foreground opacity-60" : "text-foreground"
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor={id} className={`justify-self-start text-sm font-medium text-muted-foreground${disabled ? " opacity-60" : ""}`}>
|
||||
<label htmlFor={id} className={`justify-self-start text-sm font-medium ${labelTone}`}>
|
||||
{label}
|
||||
</label>
|
||||
<Select defaultValue={defaultValue} defaultOpen={defaultOpen} disabled={disabled}>
|
||||
@@ -109,6 +111,23 @@ function SizeComparison({ showSpec = true }: { showSpec?: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), shrinking to
|
||||
// fit below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so StateBoard's
|
||||
// auto-fill grid cannot leave an empty track. Full class names are listed so Tailwind can see them.
|
||||
const specimenColumns = {
|
||||
3: "min-[1000px]:grid-cols-3",
|
||||
} as const
|
||||
|
||||
function SpecimenGrid({ states, columns }: { states: BoardState[]; columns: keyof typeof specimenColumns }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] ${specimenColumns[columns]}`}>
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[state]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DisabledStates() {
|
||||
return (
|
||||
<div className="grid max-w-[480px] gap-6">
|
||||
@@ -157,11 +176,16 @@ export const All: Story = {
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Select" />
|
||||
<StorySection title="Sizes">
|
||||
<SizeComparison showSpec={false} />
|
||||
{/* Same 3-column track as States / Validation: Medium and Large take the first two columns, the third stays empty. */}
|
||||
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
|
||||
{sizes.map((spec) => (
|
||||
<SizeCard key={spec.size} spec={spec} showSpec={false} />
|
||||
))}
|
||||
</div>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
fixed
|
||||
<SpecimenGrid
|
||||
columns={3}
|
||||
states={[
|
||||
{ id: "selected", label: "Selected", children: <SelectSpecimen size="medium" label="Status" defaultValue="active" /> },
|
||||
{ id: "disabled", label: "Disabled", children: <SelectSpecimen size="medium" label="Status" defaultValue="active" disabled /> },
|
||||
@@ -169,9 +193,10 @@ export const All: Story = {
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Invalid / Focus">
|
||||
<StateBoard
|
||||
fixed
|
||||
<StorySection title="Validation">
|
||||
{/* Same 3-column track as States: the two specimens take the first two columns (same Select width) and the third stays empty. */}
|
||||
<SpecimenGrid
|
||||
columns={3}
|
||||
states={[
|
||||
{ id: "invalid", label: "Invalid", children: <SelectSpecimen size="medium" aria-invalid label="Status" defaultValue="active" /> },
|
||||
{ id: "invalid-disabled", label: "Invalid · Disabled", children: <SelectSpecimen size="medium" aria-invalid disabled label="Status" /> },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Separator } 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"
|
||||
|
||||
const meta = {
|
||||
@@ -20,15 +20,35 @@ const meta = {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative-page layout (Storybook presentation only): 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 every subtitle and divider
|
||||
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
|
||||
const specimenColumns = {
|
||||
2: "min-[1000px]:grid-cols-2",
|
||||
3: "min-[1000px]:grid-cols-3",
|
||||
4: "min-[1000px]:grid-cols-4",
|
||||
} as const
|
||||
|
||||
function SpecimenGrid({ states, columns }: { states: BoardState[]; columns: 2 | 3 | 4 }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] ${specimenColumns[columns]}`}>
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[state]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: Overview (Horizontal) and Variants (Vertical, Semantic) are merged into one Variants section. 3 columns on PC.
|
||||
export const All: Story = {
|
||||
name: "Separator",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Separator" />
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
min={280}
|
||||
<StorySection title="Variants">
|
||||
<SpecimenGrid
|
||||
columns={3}
|
||||
states={[
|
||||
{
|
||||
id: "horizontal",
|
||||
@@ -41,13 +61,6 @@ export const All: Story = {
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard
|
||||
min={280}
|
||||
states={[
|
||||
{
|
||||
id: "vertical",
|
||||
label: "Vertical",
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
// Usage: preserved native label fixtures do not define a new-screen exception. Use public Label
|
||||
// for supported fields (docs/components/label.md §5); copy only actions requested by the screen.
|
||||
import { Button, Input, Sheet, SheetBody, SheetClose, SheetContent, SheetDescription, SheetFooter, SheetHeader, SheetTitle, SheetTrigger } from "@hm-design/ui"
|
||||
import { StateBoard, type BoardState } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Sheet is a modal Radix overlay (Portal), so the representative Story is a row of triggers that open the real Sheet for each
|
||||
@@ -68,7 +69,7 @@ function FormCase({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
return (
|
||||
<Frame trigger="폼" defaultOpen={defaultOpen} title="프로젝트 정보">
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor="sheet-form-name" className="text-sm font-medium text-muted-foreground">
|
||||
<label htmlFor="sheet-form-name" className="text-sm font-medium text-foreground">
|
||||
프로젝트명
|
||||
</label>
|
||||
<Input id="sheet-form-name" size="medium" placeholder="프로젝트명을 입력하세요" />
|
||||
@@ -93,29 +94,44 @@ const meta = { title: "Components/Overlay/Sheet", component: Sheet, parameters:
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
|
||||
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
|
||||
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
|
||||
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
|
||||
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page. Overview is merged into Variants (Right is its first item).
|
||||
export const All: Story = {
|
||||
name: "Sheet",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader
|
||||
title="Sheet"
|
||||
/>
|
||||
<StorySection title="Overview">
|
||||
<SideCase side="right" />
|
||||
</StorySection>
|
||||
<StoryPageHeader title="Sheet" />
|
||||
<StorySection title="Variants">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<SideCase side="left" />
|
||||
<SideCase side="top" />
|
||||
<SideCase side="bottom" />
|
||||
</div>
|
||||
<SpecimenGrid
|
||||
states={[
|
||||
{ id: "right", label: "Right", children: <SideCase side="right" /> },
|
||||
{ id: "left", label: "Left", children: <SideCase side="left" /> },
|
||||
{ id: "top", label: "Top", children: <SideCase side="top" /> },
|
||||
{ id: "bottom", label: "Bottom", children: <SideCase side="bottom" /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<FormCase />
|
||||
<LongCase />
|
||||
</div>
|
||||
<SpecimenGrid
|
||||
states={[
|
||||
{ id: "form", label: "Form", children: <FormCase /> },
|
||||
{ id: "long-content", label: "Long Content", children: <LongCase /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
|
||||
@@ -53,19 +53,41 @@ const meta = { title: "Components/Data & Display/Skeleton", component: Skeleton,
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
// Representative-page layout (Storybook presentation only): 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 every subtitle and divider
|
||||
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
|
||||
const specimenColumns = {
|
||||
2: "min-[1000px]:grid-cols-2",
|
||||
3: "min-[1000px]:grid-cols-3",
|
||||
4: "min-[1000px]:grid-cols-4",
|
||||
} as const
|
||||
|
||||
function SpecimenGrid({ ids, columns, spans = {} }: { ids: string[]; columns: 2 | 3 | 4; spans?: Record<string, 2> }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] ${specimenColumns[columns]}`}>
|
||||
{ids.map((id) => {
|
||||
const s = sections.find((section) => section.id === id)!
|
||||
return (
|
||||
<div key={id} className={spans[id] ? "min-[1000px]:col-span-2" : undefined}>
|
||||
<StateBoard min={10000} states={[{ id: s.id, label: s.label, children: s.node }]} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
// Representative Story: Overview / Usage are merged into one Variants section (Text, Avatar + text, Card / list loading). 3 columns on PC.
|
||||
export const All: Story = {
|
||||
name: "Skeleton",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Skeleton" />
|
||||
<StorySection title="Overview">{board("text")}</StorySection>
|
||||
<StorySection title="Usage">{board("avatar", "card")}</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<SpecimenGrid ids={["text", "avatar", "card"]} columns={3} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ function Labeled({ label, children, valueText }: { label: string; children: Reac
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className="font-medium text-foreground">{label}</span>
|
||||
<span className="text-muted-foreground" data-testid="value">{valueText}</span>
|
||||
</div>
|
||||
{children}
|
||||
@@ -32,8 +32,8 @@ function Single() {
|
||||
function Range() {
|
||||
const [value, setValue] = useState([25, 75])
|
||||
return (
|
||||
<Labeled label="가격 범위" valueText={`${value[0]} – ${value[1]}`}>
|
||||
<Slider aria-label="가격 범위" value={value} onValueChange={setValue} max={100} step={1} minStepsBetweenThumbs={5} />
|
||||
<Labeled label="범위" valueText={`${value[0]} – ${value[1]}`}>
|
||||
<Slider aria-label="범위"value={value} onValueChange={setValue} max={100} step={1} minStepsBetweenThumbs={5} />
|
||||
</Labeled>
|
||||
)
|
||||
}
|
||||
@@ -71,20 +71,35 @@ const meta = { title: "Components/Form/Slider", component: Slider, parameters: {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
// Representative-page layout (Storybook presentation only): 4 equal columns on PC (iframe width 1000px and up), shrinking to
|
||||
// fit below that (200px minimum per column). 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. A section with fewer than 4 specimens
|
||||
// keeps the same column width and leaves the remaining tracks empty.
|
||||
function SpecimenGrid({ ids }: { ids: string[] }) {
|
||||
return (
|
||||
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] min-[1000px]:grid-cols-4">
|
||||
{ids.map((id) => {
|
||||
const s = sections.find((section) => section.id === id)!
|
||||
return <StateBoard key={id} min={10000} states={[{ id: s.id, label: s.label, children: s.node }]} />
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
// Overview is merged into Variants (Single is its first item). `Vertical` centers its Slider in its column.
|
||||
export const All: Story = {
|
||||
name: "Slider",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Slider" />
|
||||
<StorySection title="Overview">{board("single")}</StorySection>
|
||||
<StorySection title="Variants">{board("range", "step", "vertical")}</StorySection>
|
||||
<StorySection title="States">{board("disabled")}</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<SpecimenGrid ids={["single", "range", "step", "vertical"]} />
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<SpecimenGrid ids={["disabled"]} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ function Trigger({ onClick, children }: { onClick: () => void; children: ReactNo
|
||||
function Board({ ids }: { ids: string[] }) {
|
||||
return (
|
||||
<StateBoard
|
||||
min={220}
|
||||
min={360}
|
||||
states={[
|
||||
{
|
||||
id: "types",
|
||||
|
||||
@@ -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;
|
||||
@@ -62,3 +61,29 @@
|
||||
font-weight: var(--hm-input-font-weight);
|
||||
}
|
||||
.sb-page .sb-page-section-body { min-width: 0; }
|
||||
|
||||
/* Notice above a page title (StoryNotice). Reuses .sb-page / .sb-page-inner (width and side margin are not redefined).
|
||||
The frame drops its bottom gutter and pulls the next page up so the notice sits 24px above the title block's own top gutter. */
|
||||
.sb-page.sb-page-notice {
|
||||
padding-bottom: 0;
|
||||
margin-bottom: calc(24px - var(--sb-page-gutter));
|
||||
}
|
||||
.sb-page .sb-notice {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
font-size: var(--hm-label-font-size);
|
||||
line-height: var(--hm-label-line-height);
|
||||
font-weight: var(--hm-input-font-weight);
|
||||
color: var(--hm-sem-fg-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.sb-page .sb-notice-icon {
|
||||
flex: none;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
/* centers the icon on the first text line even when the line height differs from 20px */
|
||||
margin-block: calc((var(--hm-label-line-height) - 20px) / 2);
|
||||
}
|
||||
.sb-page .sb-notice-text { min-width: 0; }
|
||||
@@ -25,6 +25,26 @@ export function StoryPageHeader({ kicker, title, description }: { kicker?: strin
|
||||
)
|
||||
}
|
||||
|
||||
// Notice row (icon + text) shown above a Story page title. Reusable by any Story group through a decorator
|
||||
// (e.g. src/sw/sw-story-notice.tsx); nothing renders it unless a group opts in. It owns a page frame whose Inner is the
|
||||
// same as StoryPage's, and cancels its own bottom gutter so the page title below stays where it was.
|
||||
// Icon: Group 1171290812.svg (circle + "!"), colors via currentColor.
|
||||
export function StoryNotice({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="sb-page sb-page-notice" data-story-notice="">
|
||||
<div className="sb-page-inner">
|
||||
<p className="sb-notice">
|
||||
<svg className="sb-notice-icon" width="20" height="20" viewBox="0 0 29 29" fill="none" aria-hidden="true" focusable="false">
|
||||
<path d="M15.7415 6L15.5383 17.3984H13.3743L13.1711 6H15.7415ZM14.4602 22.1484C14.0279 22.1484 13.6581 21.9974 13.3508 21.6953C13.0435 21.388 12.8925 21.0182 12.8977 20.5859C12.8925 20.1589 13.0435 19.7943 13.3508 19.4922C13.6581 19.1849 14.0279 19.0312 14.4602 19.0312C14.8821 19.0312 15.2467 19.1849 15.554 19.4922C15.8612 19.7943 16.0175 20.1589 16.0227 20.5859C16.0175 20.8724 15.942 21.1354 15.7961 21.375C15.6555 21.6094 15.468 21.7969 15.2336 21.9375C14.9993 22.0781 14.7415 22.1484 14.4602 22.1484Z" fill="currentColor" />
|
||||
<circle cx="14.5" cy="14.5" r="13.5" stroke="currentColor" strokeWidth="2" />
|
||||
</svg>
|
||||
<span className="sb-notice-text">{children}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Headings and specimens share StoryPage's single Inner. Wide specimens own their local scroll frame.
|
||||
export function StorySection({
|
||||
title,
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
import { useId, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Label, Switch } 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"
|
||||
|
||||
// Story-only Label color. The shared Label sets `color: var(--hm-label-text)` from an unlayered theme rule, so a utility
|
||||
// like `text-foreground` loses to it; overriding the Label's own token on the element works. Option labels use the default
|
||||
// text color; a Label next to a disabled Switch (`peer-disabled`) keeps the secondary color. State titles are untouched.
|
||||
const optionLabel = "[--hm-label-text:var(--foreground)] peer-disabled:[--hm-label-text:var(--hm-sem-fg-secondary)]"
|
||||
|
||||
function Option({ label, children }: { label: string; children: (id: string) => ReactNode }) {
|
||||
const id = useId()
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{children(id)}
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
<Label htmlFor={id} className={optionLabel}>{label}</Label>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative-page layout (Storybook presentation only): 5 equal columns on PC (iframe width 1000px and up), shrinking
|
||||
// to fit below that (150px minimum per column). 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.
|
||||
function StateGrid({ states }: { states: BoardState[] }) {
|
||||
return (
|
||||
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,150px),1fr))] min-[1000px]:grid-cols-5">
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[state]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -36,19 +54,11 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Switch" />
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
min={240}
|
||||
<StorySection title="States">
|
||||
<StateGrid
|
||||
states={[
|
||||
{ id: "off", label: "Off", children: <Option label="Option">{(id) => <Switch id={id} />}</Option> },
|
||||
{ id: "on", label: "On", children: <Option label="Option">{(id) => <Switch id={id} defaultChecked />}</Option> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
min={240}
|
||||
states={[
|
||||
{
|
||||
id: "hover",
|
||||
label: "Hover",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import { useState, type CSSProperties, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Badge, Checkbox, Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
@@ -144,20 +144,48 @@ const meta = { title: "Components/Data & Display/Table", component: Table, param
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={460} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, wide: true, children: box(s.node) }))} />
|
||||
)
|
||||
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
|
||||
// (400px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
|
||||
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty.
|
||||
// The default 520px Table minimum would make the 445px columns scroll sideways, so Basic, Selected row and Long text reset the
|
||||
// documented `--hm-table-min-width` token for their own cell (same as the Tabs usage panel): the table then follows its column
|
||||
// and long text wraps there. Horizontal overflow keeps its inline `minWidth: 1100`, so it still overflows and scrolls inside its box.
|
||||
const specimenColumns = {
|
||||
2: "min-[1000px]:grid-cols-2",
|
||||
3: "min-[1000px]:grid-cols-3",
|
||||
4: "min-[1000px]:grid-cols-4",
|
||||
} as const
|
||||
|
||||
function SpecimenGrid({ ids, columns, spans = {} }: { ids: string[]; columns: 2 | 3 | 4; spans?: Record<string, 2> }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,400px),1fr))] ${specimenColumns[columns]}`}>
|
||||
{ids.map((id) => {
|
||||
const s = sections.find((section) => section.id === id)!
|
||||
return (
|
||||
<div key={id} className={spans[id] ? "min-[1000px]:col-span-2" : undefined}>
|
||||
<StateBoard min={10000} states={[{ id: s.id, label: s.label, children: s.id === "wide" ? box(s.node) : <div style={{ "--hm-table-min-width": "0px" } as CSSProperties}>{box(s.node)}</div> }]} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
// Representative Story: Overview / States / Usage are regrouped into Variants (Basic, Selected row) and Usage (Long text,
|
||||
// Horizontal overflow), two equal columns each on PC.
|
||||
export const All: Story = {
|
||||
name: "Table",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Table" />
|
||||
<StorySection title="Overview">{board("basic")}</StorySection>
|
||||
<StorySection title="States">{board("selected")}</StorySection>
|
||||
<StorySection title="Usage">{board("long", "wide")}</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<SpecimenGrid ids={["basic", "selected"]} columns={2} />
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<SpecimenGrid ids={["long", "wide"]} columns={2} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useId, type ComponentProps, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Textarea } 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"
|
||||
|
||||
// Story structure follows Input: one representative "Textarea" Story shows every state on one page; the same specimens
|
||||
@@ -9,8 +9,8 @@ import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-pag
|
||||
// ui_viz docs/components/textarea.md §6 Reference Viewer: Default, Hover, Focus, Error, Error + Focus, Readonly, Disabled and Multiline.
|
||||
//
|
||||
// Label, Required star and Helper/Error message are not part of the Textarea component. Specimen composes them with plain
|
||||
// elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 and Helper 12/18/400 in the secondary
|
||||
// text color, Error message 11/18 in the destructive color, field gap 8.
|
||||
// elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 in the default text color (Disabled keeps
|
||||
// the secondary color) and Helper 12/18/400 in the secondary text color, Error message 11/18 in the destructive color, field gap 8.
|
||||
|
||||
function Specimen({
|
||||
label = "Description",
|
||||
@@ -25,7 +25,7 @@ function Specimen({
|
||||
const messageId = `${fieldId}-message`
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor={fieldId} className="justify-self-start text-sm font-medium text-muted-foreground">
|
||||
<label htmlFor={fieldId} className={`justify-self-start text-sm font-medium ${textarea.disabled ? "text-muted-foreground" : "text-foreground"}`}>
|
||||
{label}
|
||||
{required ? (
|
||||
<span aria-hidden="true" className="text-destructive">
|
||||
@@ -49,6 +49,23 @@ function Specimen({
|
||||
)
|
||||
}
|
||||
|
||||
// Representative-page layout (Storybook presentation only). StateBoard's own auto-fill grid cannot give equal columns that
|
||||
// fill a cell, so each specimen sits in its own single-column StateBoard (min larger than any column) inside this grid.
|
||||
// Full class names are listed so Tailwind can see them.
|
||||
const specimenColumns = {
|
||||
4: "min-[1000px]:grid-cols-4",
|
||||
} as const
|
||||
|
||||
function SpecimenGrid({ states, columns }: { states: BoardState[]; columns: keyof typeof specimenColumns }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr))] ${specimenColumns[columns]}`}>
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[state]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const narrow = (children: ReactNode) => <div className="max-w-sm">{children}</div>
|
||||
const placeholder = "Enter a description"
|
||||
const multiline = "First line\nSecond line keeps its line break.\nThird line."
|
||||
@@ -86,12 +103,13 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Textarea" />
|
||||
<StorySection title="Overview">
|
||||
<StateBoard states={[{ id: "default", label: "Default", children: <Specimen placeholder={placeholder} /> }]} />
|
||||
</StorySection>
|
||||
{/* PC (iframe width 1000px and up): States in 4 equal columns, Validation's 2 specimens on the same column width.
|
||||
Below that the columns shrink to fit (220px minimum, down to 1). Multiline and Resize live in their own hidden Stories. */}
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
<SpecimenGrid
|
||||
columns={4}
|
||||
states={[
|
||||
{ id: "default", label: "Default", children: <Specimen placeholder={placeholder} /> },
|
||||
{ id: "focus", label: "Focus", children: <Specimen placeholder={placeholder} /> },
|
||||
{ id: "readonly", label: "Readonly", children: <Specimen readOnly defaultValue="Confirmed description" /> },
|
||||
{ id: "disabled", label: "Disabled", children: <Specimen disabled defaultValue="Unavailable description" /> },
|
||||
@@ -99,21 +117,15 @@ export const All: Story = {
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Validation">
|
||||
<StateBoard
|
||||
{/* Same 4-column track as States, so the two specimens keep the States width instead of stretching across the page. */}
|
||||
<SpecimenGrid
|
||||
columns={4}
|
||||
states={[
|
||||
{ id: "error", label: "Error", children: <Specimen error defaultValue="Invalid value" /> },
|
||||
{ id: "error-message", label: "Error Message", children: <Specimen error message="설명을 입력하세요." /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<StateBoard
|
||||
states={[
|
||||
{ id: "multiline", label: "Multiline · Wrap", children: <Specimen defaultValue={multiline} /> },
|
||||
{ id: "resize", label: "Resize · none", children: <Specimen resize="none" defaultValue="This field cannot be resized." /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
@@ -151,6 +163,17 @@ export const Required: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen required />),
|
||||
}
|
||||
// Former Usage section of the representative page, kept as separate Stories.
|
||||
export const Multiline: Story = {
|
||||
tags: ["verification"],
|
||||
name: "Multiline · Wrap",
|
||||
render: () => narrow(<Specimen defaultValue={multiline} />),
|
||||
}
|
||||
export const ResizeNone: Story = {
|
||||
tags: ["verification"],
|
||||
name: "Resize · none",
|
||||
render: () => narrow(<Specimen resize="none" defaultValue="This field cannot be resized." />),
|
||||
}
|
||||
export const Readonly: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen readOnly defaultValue="Confirmed description" />),
|
||||
|
||||
@@ -43,21 +43,24 @@ function Multiple({ showValue = true }: { showValue?: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Disabled() {
|
||||
function DisabledGroup() {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<ToggleGroup type="single" variant="outline" defaultValue="a" disabled aria-label="비활성 그룹">
|
||||
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
||||
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ToggleGroup type="single" variant="outline" defaultValue="a" aria-label="비활성 항목">
|
||||
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
||||
<ToggleGroupItem value="b" disabled>
|
||||
B (비활성)
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="c">C</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<ToggleGroup type="single" variant="outline" defaultValue="a" disabled aria-label="비활성 그룹">
|
||||
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
||||
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function DisabledItem() {
|
||||
return (
|
||||
<ToggleGroup type="single" variant="outline" defaultValue="a" aria-label="비활성 항목">
|
||||
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
||||
<ToggleGroupItem value="b" disabled>
|
||||
B (비활성)
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="c">C</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -99,7 +102,8 @@ function Many() {
|
||||
|
||||
function Sizes() {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
// 3 equal columns on PC (iframe width 1000px and up): sm / default / lg; fewer columns below that (240px minimum).
|
||||
<div className="grid items-start justify-items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
|
||||
{(["sm", "default", "lg"] as const).map((size) => (
|
||||
<ToggleGroup key={size} type="multiple" size={size} variant="outline" aria-label={`${size} content kinds`}>
|
||||
<ToggleGroupItem value="icon" aria-label={`${size} icon-only`}><BoldIcon /></ToggleGroupItem>
|
||||
@@ -112,33 +116,47 @@ function Sizes() {
|
||||
}
|
||||
|
||||
// No `component`: ToggleGroup props are a union (single | multiple), so Stories without args would not type-check.
|
||||
const meta = { title: "Components/Form/Toggle Group", parameters: { layout: "padded" } } satisfies Meta
|
||||
// `internal` keeps it out of the sidebar by default; Story IDs and direct URLs stay valid.
|
||||
const meta = { title: "Components/Form/Toggle Group", tags: ["internal"], parameters: { layout: "padded" } } satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// The representative page shows the groups without example titles: the section title names them.
|
||||
const examples = (...nodes: ReactNode[]) => (
|
||||
<div className="flex flex-wrap items-start gap-x-10 gap-y-6">
|
||||
{nodes.map((node, i) => (
|
||||
<div key={i}>{node}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
// The representative page shows the groups without example titles: the section title names them. Equal columns on PC
|
||||
// (iframe width 1000px and up), fewer below that (200px minimum); the groups keep their own size (they are not stretched to the
|
||||
// column). Full class names are listed so Tailwind can see them.
|
||||
const specimenColumns = {
|
||||
3: "min-[1000px]:grid-cols-3",
|
||||
4: "min-[1000px]:grid-cols-4",
|
||||
} as const
|
||||
|
||||
function Examples({ columns, nodes }: { columns: keyof typeof specimenColumns; nodes: ReactNode[] }) {
|
||||
return (
|
||||
<div className={`grid items-start justify-items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${specimenColumns[columns]}`}>
|
||||
{nodes.map((node, i) => (
|
||||
<div key={i}>{node}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
// Overview is merged into Variants (the default group is its first item). The 10-option Usage example is the hidden `ManyOptions` Story.
|
||||
export const All: Story = {
|
||||
name: "Toggle Group",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Toggle Group" />
|
||||
<StorySection title="Overview">{examples(<Single showValue={false} />)}</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<Examples columns={4} nodes={[<Single key="single" showValue={false} />, <Multiple key="multiple" showValue={false} />, <Vertical key="vertical" />, <Spaced key="spaced" />]} />
|
||||
</StorySection>
|
||||
<StorySection title="Sizes">
|
||||
<Sizes />
|
||||
</StorySection>
|
||||
<StorySection title="Variants">{examples(<Multiple showValue={false} />, <Vertical />, <Spaced />)}</StorySection>
|
||||
<StorySection title="States">{examples(<Disabled />)}</StorySection>
|
||||
<StorySection title="Usage">{examples(<div className="max-w-sm"><Many /></div>)}</StorySection>
|
||||
<StorySection title="States">
|
||||
{/* First two columns of a 3-column grid; the third stays empty. */}
|
||||
<Examples columns={3} nodes={[<DisabledGroup key="group" />, <DisabledItem key="item" />]} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -146,6 +164,8 @@ export const All: Story = {
|
||||
export const SingleSelection: Story = { tags: ["verification"], name: "Single", render: () => <Single /> }
|
||||
export const MultipleSelection: Story = { tags: ["verification"], name: "Multiple", render: () => <Multiple /> }
|
||||
export const VerticalGroup: Story = { tags: ["verification"], name: "Vertical", render: () => <Vertical /> }
|
||||
// Former Usage section of the representative page (10 options that wrap), kept as its own Story.
|
||||
export const ManyOptions: Story = { tags: ["verification"], name: "Many Options", render: () => <div className="max-w-sm"><Many /></div> }
|
||||
|
||||
export const ContentNodes: Story = {
|
||||
tags: ["verification"],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { BoldIcon, ItalicIcon, StarIcon } from "lucide-react"
|
||||
import { Toggle, ToggleGroup, ToggleGroupItem } 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"
|
||||
|
||||
// Toggle is a persistent pressed-state control. The representative Story shows each state with real, clickable toggles; the same
|
||||
@@ -39,7 +39,8 @@ function WithIcon() {
|
||||
|
||||
function Sizes() {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
// 3 equal columns on PC (iframe width 1000px and up): sm / default / lg; fewer columns below that (240px minimum).
|
||||
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
|
||||
{(["sm", "default", "lg"] as const).map((size) => (
|
||||
<div key={size} data-toggle-size={size} className="flex flex-wrap items-center gap-3">
|
||||
<Toggle size={size} variant="outline" aria-label={`${size} icon-only`}><StarIcon /></Toggle>
|
||||
@@ -71,21 +72,41 @@ const meta = { title: "Components/Form/Toggle", component: Toggle, parameters: {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={360} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
// Representative-page layout (Storybook presentation only). StateBoard's own auto-fill grid cannot give equal columns that
|
||||
// fill a cell, so each specimen sits in its own single-column StateBoard (min larger than any column) inside a grid.
|
||||
const specimen = (id: string): BoardState => {
|
||||
const s = sections.find((section) => section.id === id)!
|
||||
return { id: s.id, label: s.label, children: s.node }
|
||||
}
|
||||
function SpecimenGrid({ states }: { states: BoardState[] }) {
|
||||
return (
|
||||
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[state]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
// Overview is renamed Variants. On PC (iframe width 1000px and up) Variants and States use the first two of the same 3 equal
|
||||
// columns as the rows below, and the third column stays empty; below that they stack.
|
||||
export const All: Story = {
|
||||
name: "Toggle",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Toggle" />
|
||||
<StorySection title="Overview">{board("offon")}</StorySection>
|
||||
<div className="grid gap-x-6 min-[1000px]:grid-cols-3">
|
||||
<StorySection title="Variants">
|
||||
<StateBoard min={10000} states={[specimen("offon")]} />
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard min={10000} states={[specimen("disabled")]} />
|
||||
</StorySection>
|
||||
</div>
|
||||
<StorySection title="단독 · 연결 그룹">
|
||||
<StateBoard
|
||||
min={360}
|
||||
<SpecimenGrid
|
||||
states={[
|
||||
{ id: "single-toggle", label: "단독", children: <Toggle variant="outline">알림</Toggle> },
|
||||
{ id: "group-single", label: "연결 그룹 · 단일 선택", children: <ToggleGroup type="single" variant="outline" defaultValue="left" aria-label="정렬"><ToggleGroupItem value="left">왼쪽</ToggleGroupItem><ToggleGroupItem value="center">가운데</ToggleGroupItem><ToggleGroupItem value="right">오른쪽</ToggleGroupItem></ToggleGroup> },
|
||||
@@ -96,7 +117,6 @@ export const All: Story = {
|
||||
<StorySection title="Sizes">
|
||||
<Sizes />
|
||||
</StorySection>
|
||||
<StorySection title="States">{board("disabled")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button, Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@hm-design/ui"
|
||||
import { StateBoard, type BoardState } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Tooltip is a Radix overlay (Portal) with short, non-interactive text. The representative Story shows real triggers
|
||||
@@ -20,20 +21,16 @@ function Basic({ defaultOpen }: { defaultOpen?: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Placements() {
|
||||
function Placement({ side }: { side: "top" | "right" | "bottom" | "left" }) {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
{(["top", "right", "bottom", "left"] as const).map((side) => (
|
||||
<Tooltip key={side}>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="primary" appearance="outline" size="sm">
|
||||
{side}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side}>{`side="${side}"`}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
</div>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="primary" appearance="outline" size="sm">
|
||||
{side}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side={side}>{`side="${side}"`}</TooltipContent>
|
||||
</Tooltip>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -70,6 +67,21 @@ const meta = { title: "Components/Overlay/Tooltip", component: Tooltip, paramete
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), fewer below that
|
||||
// (200px minimum). Each specimen sits in its own single-column StateBoard (min larger than any column), so every subtitle and divider
|
||||
// has the width of its column; a section with fewer specimens than columns leaves the remaining columns empty. The trigger keeps its
|
||||
// own size (the wrapper div stretches, the inline-flex trigger does not). Overlays open in a portal, so this layout never affects them.
|
||||
function SpecimenGrid({ states, columns = 4 }: { states: BoardState[]; columns?: 4 | 5 }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${columns === 5 ? "min-[1000px]:grid-cols-5" : "min-[1000px]:grid-cols-4"}`}>
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[{ ...state, children: <div>{state.children}</div> }]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: Overview is merged into Variants (Basic is its first item, then the four sides): 5 columns on PC.
|
||||
export const All: Story = {
|
||||
name: "Tooltip",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
@@ -77,17 +89,26 @@ export const All: Story = {
|
||||
<TooltipProvider>
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Tooltip" />
|
||||
<StorySection title="Overview">
|
||||
<Basic />
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<Placements />
|
||||
<SpecimenGrid
|
||||
columns={5}
|
||||
states={[
|
||||
{ id: "basic", label: "Basic", children: <Basic /> },
|
||||
{ id: "top", label: "Top", children: <Placement side="top" /> },
|
||||
{ id: "right", label: "Right", children: <Placement side="right" /> },
|
||||
{ id: "bottom", label: "Bottom", children: <Placement side="bottom" /> },
|
||||
{ id: "left", label: "Left", children: <Placement side="left" /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<LongText />
|
||||
<Disabled />
|
||||
</div>
|
||||
<SpecimenGrid
|
||||
columns={5}
|
||||
states={[
|
||||
{ id: "long-text", label: "Long Text", children: <LongText /> },
|
||||
{ id: "disabled", label: "Disabled", children: <Disabled /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -11,7 +11,7 @@ const palettes = [
|
||||
] as const
|
||||
type ColorStage = typeof stages[number]
|
||||
type PalettePrefix = typeof palettes[number]["prefix"]
|
||||
const stageLabel = (stage: ColorStage) => stage === "default" ? "Default · 기본색" : stage.replaceAll("-", " ").replace(/^./, letter => letter.toUpperCase())
|
||||
const stageLabel = (stage: ColorStage) => stage === "default" ? "Default · 기본색" : stage === "light-4" ? "Light 4 · 보더 하이라이트용" : stage.replaceAll("-", " ").replace(/^./, letter => letter.toUpperCase())
|
||||
const paletteToken = (prefix: PalettePrefix, stage: ColorStage) => `color.palette.${prefix}-${stage}` as RuntimeThemeToken
|
||||
const colorProperties = palettes.flatMap(palette => stages.map(stage => runtimeThemeContract[paletteToken(palette.prefix, stage)].property))
|
||||
|
||||
@@ -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="한맥 컬러">
|
||||
{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 "한맥 컬러"; `id` keeps the existing Story ID (foundation-color--all) stable.
|
||||
const meta = { title: "Foundation/한맥 컬러", 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: "한맥 컬러", render: () => <ColorReferencePage /> }
|
||||
@@ -4,7 +4,15 @@ import { ReferencePage, ReferenceSection } from "./reference"
|
||||
|
||||
// Company colors come from the existing OrgChart presets; HEX values are never duplicated here.
|
||||
// The chip markup and classes are shared with Foundation/Color (foundation.css).
|
||||
const companies = Object.values(companyColorPresets)
|
||||
// Display order only. PC rows: 한맥기술/삼안 · 장헌/피티씨 · 장헌산업/한라산업개발/바론컨설턴트.
|
||||
// Row starts (jangheon, jangheon-industry) are pinned to column 1 in foundation.css via the section ids; companies not listed follow in preset order.
|
||||
const companyRows = [["hanmac", "saman"], ["jangheon", "ptc"], ["jangheon-industry", "halla", "baron"]]
|
||||
const presetCompanies = Object.values(companyColorPresets)
|
||||
const listedIds = companyRows.flat()
|
||||
const companies = [
|
||||
...companyRows.flatMap(ids => ids.flatMap(id => presetCompanies.filter(company => company.companyId === id))),
|
||||
...presetCompanies.filter(company => !listedIds.includes(company.companyId)),
|
||||
]
|
||||
|
||||
function CompanyColorChip({ label, hex, main }: { label: string; hex: string; main: boolean }) {
|
||||
return <article className="hm-brand-color-chip" data-company-color={hex} data-base={main ? "" : undefined}>
|
||||
@@ -14,15 +22,18 @@ function CompanyColorChip({ label, hex, main }: { label: string; hex: string; ma
|
||||
}
|
||||
|
||||
function CompanyColorsPage() {
|
||||
return <ReferencePage title="Company Colors" intro="계열사 브랜드 컬러">
|
||||
{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>)}
|
||||
return <ReferencePage title="가족사 컬러">
|
||||
<div className="hm-company-grid">
|
||||
{companies.map(company => <ReferenceSection key={company.companyId} title={company.companyName} note="" id={`company-${company.companyId}`}>
|
||||
<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
|
||||
// The sidebar name is "가족사 컬러"; `id` keeps the existing Story ID (foundation-company-colors--all) stable.
|
||||
const meta = { title: "Foundation/가족사 컬러", id: "foundation-company-colors", parameters: { layout: "fullscreen" } } satisfies Meta
|
||||
export default meta
|
||||
export const All: StoryObj<typeof meta> = { name: "Company Colors", render: () => <CompanyColorsPage /> }
|
||||
export const All: StoryObj<typeof meta> = { name: "가족사 컬러", render: () => <CompanyColorsPage /> }
|
||||
Vendored
+56
-4
@@ -24,11 +24,12 @@
|
||||
.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); }
|
||||
/* Hanmac Color only (data-color-stage): wider gap (8px + 16px extra = 24px) between Light 4 and Light 3 with a divider centered in it. */
|
||||
.hm-brand-color-chip[data-color-stage="light-3"] { --hm-color-divider-extra: 16px; position: relative; margin-left: var(--hm-color-divider-extra); }
|
||||
.hm-brand-color-chip[data-color-stage="light-3"]::before { content: ""; position: absolute; top: 0; bottom: 0; left: calc(-1px - 0.5px - (8px + var(--hm-color-divider-extra)) / 2); width: 1px; background: var(--hm-sem-stroke-default); pointer-events: none; }
|
||||
.hm-brand-color-chip h3, .hm-brand-color-chip p { margin: 8px 0 0; font-size: 13px; }
|
||||
|
||||
/* Color reference additions only; preserve the existing palette row and Default emphasis. */
|
||||
@@ -39,5 +40,56 @@
|
||||
.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; }
|
||||
/* Row starts for the 2 / 2 / 3 company layout (한맥·삼안 / 장헌·피티씨 / 장헌산업·한라산업개발·바론컨설턴트). */
|
||||
.hm-foundation .hm-company-grid > #company-jangheon,
|
||||
.hm-foundation .hm-company-grid > #company-jangheon-industry { grid-column-start: 1; }
|
||||
.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)); } }
|
||||
@@ -2,16 +2,35 @@ 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
|
||||
// Hidden from the sidebar with the verification tag; the Story ID and direct URL (foundation-layout--all) stay available.
|
||||
const meta = { title: "Foundation/Layout", tags: ["verification"], parameters: { layout: "fullscreen" } } satisfies Meta
|
||||
export default meta
|
||||
export const All: StoryObj<typeof meta> = { name: "Layout", render: () => <LayoutReferencePage /> }
|
||||
@@ -1,20 +1,65 @@
|
||||
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>
|
||||
return <ReferencePage title="폰트">
|
||||
<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
|
||||
// The sidebar name is "폰트"; `id` keeps the existing Story ID (foundation-typography--all) stable.
|
||||
const meta = { title: "Foundation/폰트", id: "foundation-typography", parameters: { layout: "fullscreen" } } satisfies Meta
|
||||
export default meta
|
||||
export const All: StoryObj<typeof meta> = { name: "Typography", render: () => <TypographyReferencePage /> }
|
||||
export const All: StoryObj<typeof meta> = { name: "폰트", render: () => <TypographyReferencePage /> }
|
||||
@@ -1,7 +1,7 @@
|
||||
/* Department Colors Storybook adapter only. The actual Tunnel sample/stage
|
||||
frame comes from center-stories.css; this sheet never selects employee cards. */
|
||||
.hm-orgchart-department-preview .hm-orgchart-department-colors-design {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: var(--sb-center-column-gap);
|
||||
}
|
||||
|
||||
@@ -43,6 +43,12 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.hm-orgchart-department-preview .hm-orgchart-department-colors-design {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.hm-orgchart-department-preview .hm-orgchart-department-colors-design {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { CoreComponentPreview } from './core-component-preview';
|
||||
import { organizationStylePresets } from '../../packages/org-chart/src/core/style-presets';
|
||||
|
||||
const meta = { title: 'OrgChart/Components/Cards', tags: ['internal'], component: CoreComponentPreview, parameters: { layout: 'padded', controls: { disable: true } } } satisfies Meta<typeof CoreComponentPreview>;
|
||||
const meta = { title: '조직도/Components/Cards', id: 'orgchart-components-cards', tags: ['internal'], component: CoreComponentPreview, parameters: { layout: 'padded', controls: { disable: true } } } satisfies Meta<typeof CoreComponentPreview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
function organization(variant: number): Story {
|
||||
|
||||
@@ -2,7 +2,7 @@ import { StrictMode } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { DepartmentTypographyPreview } from './org-chart-page-previews';
|
||||
|
||||
const meta = { title: 'OrgChart/Department Colors', component: DepartmentTypographyPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof DepartmentTypographyPreview>;
|
||||
const meta = { title: '조직도/부서별 색상', component: DepartmentTypographyPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof DepartmentTypographyPreview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const DepartmentColors: Story = { name: 'Department Colors', parameters: { __id: 'orgchart--department-colors' } };
|
||||
export const DepartmentColors: Story = { name: '부서별 색상', parameters: { __id: 'orgchart--department-colors' } };
|
||||
@@ -50,7 +50,7 @@ function ExpandedPreview() {
|
||||
})), []);
|
||||
|
||||
return <StoryPage className="hm-orgchart-story-page hm-orgchart-story-expanded">
|
||||
<StoryPageHeader title="Expanded OrgChart" />
|
||||
<StoryPageHeader title="확장 조직도" />
|
||||
{/* Preview first: the chart sits right under the page title; its own header already names it, so no section header. */}
|
||||
<div className="hm-orgchart-story hm-orgchart-story-lead">
|
||||
{error ? <p role="alert" className="hm-orgchart-story-note">{error}</p> : null}
|
||||
@@ -61,7 +61,7 @@ function ExpandedPreview() {
|
||||
</StoryPage>;
|
||||
}
|
||||
|
||||
const meta = { title: 'OrgChart/Expanded OrgChart', component: ExpandedPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof ExpandedPreview>;
|
||||
const meta = { title: '조직도/확장 조직도', component: ExpandedPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof ExpandedPreview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const ExpandedOrgChart: Story = { name: 'Expanded OrgChart', parameters: { __id: 'orgchart--expanded-org-chart' } };
|
||||
export const ExpandedOrgChart: Story = { name: '확장 조직도', parameters: { __id: 'orgchart--expanded-org-chart' } };
|
||||
@@ -2,7 +2,7 @@ import { StrictMode } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { CompositionPreview } from './org-chart-page-previews';
|
||||
|
||||
const meta = { title: 'OrgChart/Full Composition', component: CompositionPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof CompositionPreview>;
|
||||
const meta = { title: '조직도/전체 구성', component: CompositionPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof CompositionPreview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const FullComposition: Story = { name: 'Full Composition', parameters: { __id: 'orgchart--full-composition' } };
|
||||
export const FullComposition: Story = { name: '전체 구성', parameters: { __id: 'orgchart--full-composition' } };
|
||||
@@ -3,7 +3,7 @@ import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { CoreComponentPreview } from './core-component-preview';
|
||||
import './org-chart-stories.css';
|
||||
|
||||
const meta = { title: 'OrgChart/Verification/Overview', component: CoreComponentPreview, tags: ['internal'], parameters: { layout: 'padded', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof CoreComponentPreview>;
|
||||
const meta = { title: '조직도/Verification/Overview', component: CoreComponentPreview, tags: ['internal'], parameters: { layout: 'padded', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof CoreComponentPreview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
// Literal IDs preserve every existing direct URL after moving the hidden CSF entries.
|
||||
|
||||
@@ -2,7 +2,7 @@ import { StrictMode } from 'react';
|
||||
import type { Meta, StoryObj } from '@storybook/react-vite';
|
||||
import { OverviewPreview } from './org-chart-page-previews';
|
||||
|
||||
const meta = { title: 'OrgChart/Overview', component: OverviewPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof OverviewPreview>;
|
||||
const meta = { title: '조직도/개요', component: OverviewPreview, parameters: { layout: 'fullscreen', controls: { disable: true } }, decorators: [(Story) => <StrictMode><Story /></StrictMode>] } satisfies Meta<typeof OverviewPreview>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
export const LogoPersonnelSummary: Story = { name: 'Overview', parameters: { __id: 'orgchart--logo-personnel-summary' } };
|
||||
export const LogoPersonnelSummary: Story = { name: '개요', parameters: { __id: 'orgchart--logo-personnel-summary' } };
|
||||
@@ -19,7 +19,7 @@ export function CompositionPreview() {
|
||||
const host = useRef<HTMLElement>(null);
|
||||
useLayoutEffect(() => centerCompositionPreview(host.current!), []);
|
||||
return <StoryPage className="hm-orgchart-story-page">
|
||||
<StoryPageHeader title="Full Composition" />
|
||||
<StoryPageHeader title="전체 구성" />
|
||||
<StorySection title="고정형 조직도">
|
||||
<section ref={host} className="hm-orgchart-story hm-orgchart-story-full" aria-label="Full Composition">
|
||||
<OrgChart dataSource={compositionSource} stylesheetUrls={compositionStylesheets} displayMode="readOnly" className="hm-orgchart-story-chart" />
|
||||
@@ -30,7 +30,7 @@ export function CompositionPreview() {
|
||||
|
||||
export function DepartmentTypographyPreview() {
|
||||
return <StoryPage className="hm-orgchart-story-page">
|
||||
<StoryPageHeader title="Department Colors" />
|
||||
<StoryPageHeader title="부서별 색상" />
|
||||
<StorySection title="조직별 컬러 · 카드 미리보기">
|
||||
<div className="hm-orgchart-story-preview"><CoreComponentPreview kind="DepartmentColors" presentation="design" /></div>
|
||||
</StorySection>
|
||||
@@ -39,7 +39,7 @@ export function DepartmentTypographyPreview() {
|
||||
|
||||
export function OverviewPreview() {
|
||||
return <StoryPage className="hm-orgchart-story-page">
|
||||
<StoryPageHeader title="Overview" />
|
||||
<StoryPageHeader title="개요" />
|
||||
<StorySection title="관계사·회사 로고">
|
||||
<div className="hm-orgchart-story-preview"><CoreComponentPreview kind="Logo" hideLogoHeading /></div>
|
||||
</StorySection>
|
||||
|
||||
@@ -5,7 +5,8 @@ import { CoreComponentPreview } from "./core-component-preview"
|
||||
// and automatic Raw Count. Acquired logo mapping stays in the shared Core/asset
|
||||
// layer; Stories do not duplicate summary UI or add employee data fields.
|
||||
const meta = {
|
||||
title: "OrgChart/Components/Summaries",
|
||||
title: "조직도/Components/Summaries",
|
||||
id: "orgchart-components-summaries",
|
||||
tags: ["internal"],
|
||||
component: CoreComponentPreview,
|
||||
args: { kind: "PositionSummary" },
|
||||
|
||||
@@ -167,7 +167,8 @@ function MultipleContent() {
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "OrgChart/Content",
|
||||
title: "조직도/콘텐츠",
|
||||
id: "orgchart-content",
|
||||
tags: ["internal"],
|
||||
component: OrgChart,
|
||||
parameters: {
|
||||
@@ -190,28 +191,28 @@ export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
export const BasicOrganization: Story = {
|
||||
name: "Basic Organization",
|
||||
name: "기본 조직도",
|
||||
render: args => <Content note="HTML 공통 v1: 조직 3개, 고유 직원 4명 (Root 1 / Team A 2 / Team B 1). 정책 미주입 시 원본 집계만 표시하고 업무상 총원은 미확정입니다."><OrgChart {...args} className="hm-orgchart-story-chart" /></Content>,
|
||||
}
|
||||
|
||||
export const OrganizationHierarchy: Story = {
|
||||
name: "Organization Hierarchy",
|
||||
name: "조직 계층",
|
||||
args: { dataSource: hierarchySource },
|
||||
render: args => <Content note="공통 v1의 parentId 계층·조직장과 연결선을 확인합니다. showEmployees/showSummary만 숨기며 원본 소속과 집계는 유지합니다. 하위 조직 접기/펼치기는 Core 버튼의 키보드 Enter/Space로 실행합니다."><OrgChart {...args} className="hm-orgchart-story-chart" /></Content>,
|
||||
}
|
||||
|
||||
export const EmployeeCards: Story = {
|
||||
name: "Employee Cards",
|
||||
name: "직원 카드",
|
||||
args: { dataSource: employeeSource },
|
||||
render: args => <Content note="공통 v1에서 긴 조직명·직원명·직위·직책만 파생한 가상 데이터입니다. null 프로필 이미지는 Core의 첫 글자 대체 표시를 사용합니다. 재직·파견·휴직 상태와 조직장 구분은 동일 Core 노드 UI입니다."><OrgChart {...args} className="hm-orgchart-story-chart" /></Content>,
|
||||
}
|
||||
|
||||
export const OrganizationSummary: Story = {
|
||||
name: "Organization Summary",
|
||||
name: "조직 요약",
|
||||
render: args => <Content note="공통 v1 원본 직원 전체 4명, 직접 인원 Root 1 / Team A 2 / Team B 1. 하위 포함 인원은 서로 합산하지 않습니다. 승인 집계 정책을 임의 생성하지 않으며 업무상 총원은 정책 승인 필요로 표시합니다."><OrgChart {...args} className="hm-orgchart-story-chart" /></Content>,
|
||||
}
|
||||
|
||||
export const JSONDataUpdate: Story = { name: "JSON Data Update", render: () => <DataUpdateContent /> }
|
||||
export const LoadingEmptyError: Story = { name: "Loading / Empty / Error", render: () => <LoadingContent /> }
|
||||
export const LoadingEmptyError: Story = { name: "로딩 / 비어 있음 / 오류", render: () => <LoadingContent /> }
|
||||
export const ResponsiveContainer: Story = { name: "Responsive Container", render: () => <ResponsiveContent /> }
|
||||
export const MultipleInstances: Story = { name: "Multiple Instances", render: () => <MultipleContent /> }
|
||||
@@ -180,3 +180,15 @@
|
||||
}
|
||||
|
||||
/* Compact geometry is shared by all viewports. Dashboard's native scroll frame preserves its minimum width. */
|
||||
|
||||
/* Representative page only: the Dashboard grows with its content instead of being fixed to 100dvh, so the content surface has no
|
||||
inner vertical scrollbar and the Storybook page itself scrolls. The body's `flex: 1 1 0` is a vertical share of the fixed height,
|
||||
so it becomes content-sized; `dashboard-main` keeps its own `flex: 1 1 0`, which is its horizontal fill inside the body row.
|
||||
The package's rules (fixed height, overflow-y: auto) stay as they are for operating screens; the horizontal scroll frame
|
||||
(min-width 960px) is unchanged. */
|
||||
.dashboard-all-page [data-slot="dashboard-scroll"] {
|
||||
height: auto;
|
||||
}
|
||||
.dashboard-all-page [data-slot="dashboard-body"] {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
@@ -429,7 +429,7 @@ const frame = (height?: number): CSSProperties | undefined => (height ? ({ "--hm
|
||||
// story composition here prevents the export from becoming a second hand-written UI.
|
||||
export function DashboardAll() {
|
||||
return (
|
||||
<StoryPage>
|
||||
<StoryPage className="dashboard-all-page">
|
||||
<StoryPageHeader title="Dashboard" />
|
||||
<StorySection title="Overview">
|
||||
<DashboardScreen mode="compact" />
|
||||
|
||||
@@ -155,15 +155,18 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Data Table" />
|
||||
<StorySection title="Overview">
|
||||
<Basic />
|
||||
</StorySection>
|
||||
<StorySection title="기본 목록 검색"><WithFilterBar extended={false} /></StorySection>
|
||||
<StorySection title="추가 조건 목록 검색"><WithFilterBar /></StorySection>
|
||||
<StorySection title="Advanced Search"><WithFilterBar /></StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// The other two usage types that used to be stacked on the representative page; kept as separate Stories.
|
||||
// Plain table with column visibility (selection, sorting, row actions, pagination).
|
||||
export const Overview: Story = { tags: ["verification"], render: () => <Basic /> }
|
||||
|
||||
// Basic list search: status + keyword search, centered above the table.
|
||||
export const BasicSearch: Story = { tags: ["verification"], name: "Basic Search", render: () => <WithFilterBar extended={false} /> }
|
||||
|
||||
// Combination Story: Filter Bar (search, status, department, date, reset) filtering the Data Table rows.
|
||||
export const WithFilterBarStory: Story = { tags: ["verification"], name: "With Filter Bar", render: () => <WithFilterBar /> }
|
||||
|
||||
|
||||
@@ -90,7 +90,7 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Filter Bar" />
|
||||
<StorySection title="Overview">
|
||||
<StorySection title="Basic">
|
||||
<Full />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// GNB is a composition pattern. Layout visual reference is the ui-viz GNB (green 70px bar, centered menu, Beige current item joined to
|
||||
@@ -308,12 +309,14 @@ function Bar({
|
||||
}
|
||||
|
||||
// The bar with the content that follows it (the GNB Mode page owns this part; here it only shows the join).
|
||||
function Page({ children, label }: { children: ReactNode; label?: string }) {
|
||||
// `compact` is for the representative page only: it trims the connected area's height and padding (the 12px join and rounded corners stay).
|
||||
const compactContentStyle: CSSProperties = { ...contentStyle, minHeight: 0, padding: 16 }
|
||||
function Page({ children, label, compact = false }: { children: ReactNode; label?: string; compact?: boolean }) {
|
||||
return (
|
||||
<div>
|
||||
{children}
|
||||
<div style={contentStyle}>
|
||||
<p className="text-sm text-muted-foreground">{label ?? "연결된 콘텐츠 영역: GNB는 자기 높이와 12px 접점까지만 차지합니다."}</p>
|
||||
<div style={compact ? compactContentStyle : contentStyle}>
|
||||
{label === "" ? null : <p className="text-sm text-muted-foreground">{label ?? "연결된 콘텐츠 영역: GNB는 자기 높이와 12px 접점까지만 차지합니다."}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -426,50 +429,60 @@ const meta = { title: "Patterns/Layout/GNB", component: GNB, parameters: { layou
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// Representative Story: the green bar with a current item, an open 2-depth panel, long labels, a logo, the optional utility icons and
|
||||
// sticky. Menus and the user menu open for real. Narrow the page to 390px for the mobile bar (hamburger → Sheet).
|
||||
// Each GNB fills its specimen parent inside the same Inner as the page and section headings.
|
||||
// The representative page names the connected area only; the verification Stories keep the default 12px join note.
|
||||
const contentLabel = "콘텐츠 영역"
|
||||
// Representative Story: Variants (Default, Long Labels) and States (Navigation Menu Open). Menus and the user menu open for real.
|
||||
// Narrow the page to 390px for the mobile bar (hamburger → Sheet). Each GNB fills its specimen parent inside the same Inner as the
|
||||
// page and section headings. The connected-area note shows once (Default); the verification Stories keep the default 12px join note.
|
||||
// With Logo, Utilities, Sticky, GNB Sidebar and Brown Sidebar live in their own verification Stories.
|
||||
export const All: Story = {
|
||||
name: "GNB",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="GNB" />
|
||||
<StorySection title="Overview">
|
||||
<Page label={contentLabel}>
|
||||
<Bar />
|
||||
</Page>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard
|
||||
states={[
|
||||
{
|
||||
id: "default",
|
||||
label: "Default",
|
||||
wide: true,
|
||||
children: (
|
||||
<Page compact label="콘텐츠 영역">
|
||||
<Bar />
|
||||
</Page>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "long-labels",
|
||||
label: "Long Labels",
|
||||
wide: true,
|
||||
children: (
|
||||
<Page compact label="">
|
||||
<Bar items={longNavItems} person={longUser} eyebrow="한맥기술 인프라 통합 시스템" brand="스마트 시설물 통합 관리 시스템" />
|
||||
</Page>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States · Navigation Menu Open">
|
||||
<div style={{ minHeight: 360 }}>
|
||||
<Page label="프로젝트 > 진행 중 프로젝트">
|
||||
<Bar initial="projects/active" openMenu="projects" />
|
||||
</Page>
|
||||
</div>
|
||||
{/* The open 2-depth panel ends 238px below the bar's top; the wrapper reserves that room so it never overlaps the page below. */}
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
states={[
|
||||
{
|
||||
id: "navigation-menu-open",
|
||||
label: "Navigation Menu Open",
|
||||
wide: true,
|
||||
children: (
|
||||
<div style={{ minHeight: 248 }}>
|
||||
<Page compact label="프로젝트 > 진행 중 프로젝트">
|
||||
<Bar initial="projects/active" openMenu="projects" />
|
||||
</Page>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Variants · Long Labels">
|
||||
<Page label={contentLabel}>
|
||||
<Bar items={longNavItems} person={longUser} eyebrow="한맥기술 인프라 통합 시스템" brand="스마트 시설물 통합 관리 시스템" />
|
||||
</Page>
|
||||
</StorySection>
|
||||
<StorySection title="Variants · With Logo">
|
||||
<Page label={contentLabel}>
|
||||
<Bar logo={testLogo} />
|
||||
</Page>
|
||||
</StorySection>
|
||||
<StorySection title="Variants · Utilities">
|
||||
<Page label={contentLabel}>
|
||||
<Bar utilities />
|
||||
</Page>
|
||||
</StorySection>
|
||||
<StorySection title="Usage · Sticky">
|
||||
<StickyPage />
|
||||
</StorySection>
|
||||
<StorySection title="Usage · GNB Sidebar">
|
||||
<SidebarPage showCurrent={false} />
|
||||
</StorySection>
|
||||
<StorySection title="Usage · Brown 콘텐츠 LNB"><SidebarPage color="brown" showCurrent={false} /></StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -477,6 +490,8 @@ export const All: Story = {
|
||||
export const Default: Story = { tags: ["verification"], render: () => <Page><Bar /></Page> }
|
||||
export const Sticky: Story = { tags: ["verification"], render: () => <StickyPage /> }
|
||||
export const Sidebar: Story = { tags: ["verification"], name: "GNB Sidebar", render: () => <SidebarPage fullHeight /> }
|
||||
// Brown content LNB: same condition the representative page used to show (560px frame, no route readout).
|
||||
export const BrownSidebar: Story = { tags: ["verification"], name: "Brown Sidebar", render: () => <SidebarPage color="brown" showCurrent={false} /> }
|
||||
export const NavigationMenuOpen: Story = {
|
||||
tags: ["verification"],
|
||||
name: "Navigation Menu Open",
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
/* Storybook presentation only: Patterns/Layout/LNB Stories. Scoped by the decorator wrapper in lnb.stories.tsx.
|
||||
Column width (240px), menu font (14px) and row heights (depth1 48px, depth2/3 42px) are now the @hm-design/ui package defaults
|
||||
(packages/ui/src/theme/lnb.css); only the Story-specific presentation below stays here.
|
||||
- min-width 212px: a floor, not a fixed width. The column follows --hm-lnb-width and may be wider.
|
||||
- brand bottom padding 8px, profile text, and divider lines. */
|
||||
.hm-lnb-story {
|
||||
display: contents;
|
||||
/* 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;
|
||||
}
|
||||
|
||||
/* 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;
|
||||
}
|
||||
|
||||
+80
-104
@@ -32,9 +32,12 @@ 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: 240px green column, 14/700 items,
|
||||
// 48px 1-depth and 42px 2/3-depth rows, Beige current item joined to the content, Black 10% hover, Black 8% open group). The sizes come from the package
|
||||
// (`--hm-lnb-*` in packages/ui/src/theme/lnb.css); lnb.stories.css only keeps Story presentation (212px min width, brand bottom padding, profile text,
|
||||
// divider lines). 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 +109,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 +125,7 @@ function Menu({
|
||||
many,
|
||||
height,
|
||||
profile = true,
|
||||
dividers = true,
|
||||
}: {
|
||||
initial?: string | null
|
||||
groupOpen?: boolean
|
||||
@@ -128,6 +138,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 +174,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 +182,7 @@ function Menu({
|
||||
</LNBItem>
|
||||
))}
|
||||
</LNBGroup>
|
||||
{dividers ? <GroupDivider /> : null}
|
||||
<LNBItem href="#reports" active={current === "reports"} onClick={go("reports")}>
|
||||
<ChartColumnIcon aria-hidden="true" />
|
||||
메뉴 3
|
||||
@@ -262,6 +276,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 +292,7 @@ function ThirdLevelMenu({
|
||||
일정 관리
|
||||
</LNBItem>
|
||||
</LNBGroup>
|
||||
<GroupDivider />
|
||||
<LNBItem href="#reports" active={current === "reports"} onClick={go("reports")}>
|
||||
<ChartColumnIcon aria-hidden="true" />
|
||||
현황 보고
|
||||
@@ -361,6 +377,7 @@ function ResponsiveSpecimen({
|
||||
brand = "프로젝트 대시보드",
|
||||
longLabels,
|
||||
height,
|
||||
dividers = true,
|
||||
}: {
|
||||
nodes: LocalNode[]
|
||||
initial: string | null
|
||||
@@ -370,6 +387,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 +399,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 +470,7 @@ function ResponsiveSpecimen({
|
||||
modal={false}
|
||||
trigger={
|
||||
<LNBProfile
|
||||
className="hm-lnb-story-mobile-profile"
|
||||
name={profile.name}
|
||||
description={profile.description}
|
||||
>
|
||||
@@ -500,7 +522,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 +530,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 +591,7 @@ function Standalone({ mobileOpen = false, profileOpen = false }: { mobileOpen?:
|
||||
modal={false}
|
||||
trigger={
|
||||
<LNBProfile
|
||||
className="hm-lnb-story-mobile-profile"
|
||||
name={profileData.name}
|
||||
description={profileData.description}
|
||||
>
|
||||
@@ -595,29 +620,8 @@ const mobileViewport = {
|
||||
globals: { viewport: { value: "hmMobile", isRotated: false } },
|
||||
}
|
||||
|
||||
// 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 }) {
|
||||
return (
|
||||
<figure className="grid min-w-0 content-start gap-2">
|
||||
<figcaption className="text-sm text-muted-foreground">
|
||||
{label} · {width}px
|
||||
</figcaption>
|
||||
<div className="max-w-full overflow-x-auto">
|
||||
<iframe
|
||||
title={`${label} ${width}px`}
|
||||
src={`iframe.html?id=patterns-layout-lnb--${story}&viewMode=story`}
|
||||
width={width}
|
||||
height={height}
|
||||
className="box-content block border border-black/10"
|
||||
/>
|
||||
</div>
|
||||
</figure>
|
||||
)
|
||||
}
|
||||
|
||||
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, brand bottom padding, profile text and divider lines 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>
|
||||
|
||||
@@ -628,58 +632,24 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="LNB" />
|
||||
<StorySection title="Overview">
|
||||
<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 }} /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
min={560}
|
||||
states={[
|
||||
{ id: "default", label: "Default", children: <ResponsiveSpecimen nodes={simpleNav()} initial={null} initialOpen={{ tasks: false }} /> },
|
||||
{ id: "selected", label: "Selected", children: <ResponsiveSpecimen nodes={simpleNav()} initial="overview" initialOpen={{ tasks: true }} dividers={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>
|
||||
<StorySection title="Variants">
|
||||
<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 /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage · Scroll">
|
||||
<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} /> },
|
||||
]}
|
||||
/>
|
||||
</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-mobile" label="Mobile" width={390} height={640} />
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// Usage examples live in their own verification Stories: Scroll, Third Level Scroll, LNB Standalone Desktop / Mobile (and Mobile Menu Open).
|
||||
|
||||
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 +703,13 @@ 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).
|
||||
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 = {
|
||||
|
||||
@@ -22,7 +22,8 @@ function Fullscreen() {
|
||||
)
|
||||
}
|
||||
|
||||
const meta = { title: "Patterns/Layout/Loading Screen", parameters: { layout: "padded", controls: { disable: true } } } satisfies Meta
|
||||
// `internal` keeps it out of the sidebar by default; Story IDs and direct URLs stay valid.
|
||||
const meta = { title: "Patterns/Layout/Loading Screen", tags: ["internal"], parameters: { layout: "padded", controls: { disable: true } } } satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
@@ -33,12 +34,12 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Loading Screen" />
|
||||
<StorySection title="Overview">
|
||||
<LoadingScreen />
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
{/* Default spans the full width; the other two share an equal 2-column row (1 column below 2 × 560px). */}
|
||||
<StateBoard
|
||||
min={560}
|
||||
states={[
|
||||
{ id: "default", label: "Default", wide: true, children: <LoadingScreen /> },
|
||||
{ id: "label", label: "Custom label", children: <LoadingScreen>데이터를 불러오는 중</LoadingScreen> },
|
||||
{ id: "narrow", label: "Narrow parent (240px)", children: <div style={{ width: 240 }}><LoadingScreen /></div> },
|
||||
]}
|
||||
|
||||
@@ -15,10 +15,12 @@ import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
Tabs, TabsContent, TabsList, TabsTrigger,
|
||||
} from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
const meta = {
|
||||
title: "Patterns/Layout/Page Header",
|
||||
tags: ["verification"], // 임시 숨김: 사이드바에서만 제외. 다시 표시하려면 이 줄 삭제
|
||||
component: PageHeader,
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
} satisfies Meta<typeof PageHeader>
|
||||
@@ -27,62 +29,91 @@ type Story = StoryObj<typeof meta>
|
||||
|
||||
// Content composition only. The consuming screen owns navigation and action handlers.
|
||||
// Dashboard's minimum width and internal navigation belong to DashboardLayout.
|
||||
// The Breadcrumb header and the task Tabs / Table are shared by the representative page (header only) and the Full Page Story.
|
||||
function BreadcrumbHeader() {
|
||||
return (
|
||||
<PageHeader>
|
||||
<PageHeaderContent>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#home">홈</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#projects">프로젝트</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>업무 현황</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<PageHeaderTitle>업무 현황</PageHeaderTitle>
|
||||
<PageHeaderDescription>프로젝트별 업무 진행 상황을 확인하고 조회합니다.</PageHeaderDescription>
|
||||
</PageHeaderContent>
|
||||
<PageHeaderActions>
|
||||
<Button type="button" variant="primary" appearance="brand" size="default">
|
||||
신규 등록
|
||||
</Button>
|
||||
</PageHeaderActions>
|
||||
</PageHeader>
|
||||
)
|
||||
}
|
||||
|
||||
function TaskTabs() {
|
||||
return (
|
||||
<Tabs defaultValue="mine" className="min-w-0">
|
||||
<TabsList aria-label="업무 목록 보기">
|
||||
<TabsTrigger value="mine">내 업무</TabsTrigger>
|
||||
<TabsTrigger value="completed">완료 업무</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="mine" className="px-0 py-6">
|
||||
<Table aria-label="내 업무">
|
||||
<TableHeader><TableRow><TableHead scope="col">업무</TableHead><TableHead scope="col">마감일</TableHead><TableHead scope="col">상태</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
<TableRow><TableCell>화면 설계서 검토</TableCell><TableCell>10월 14일</TableCell><TableCell>검토</TableCell></TableRow>
|
||||
<TableRow><TableCell>고객 요구사항 정리</TableCell><TableCell>10월 16일</TableCell><TableCell>진행</TableCell></TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
<TabsContent value="completed" className="px-0 py-6">
|
||||
<Table aria-label="완료 업무">
|
||||
<TableHeader><TableRow><TableHead scope="col">업무</TableHead><TableHead scope="col">완료일</TableHead><TableHead scope="col">상태</TableHead></TableRow></TableHeader>
|
||||
<TableBody><TableRow><TableCell>프로젝트 기본 정보 등록</TableCell><TableCell>10월 9일</TableCell><TableCell>완료</TableCell></TableRow></TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
export const All: Story = {
|
||||
name: "Page Header",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Page Header" />
|
||||
<StorySection title="Overview">
|
||||
<PageHeader><PageHeaderContent><PageHeaderTitle>업무 현황</PageHeaderTitle></PageHeaderContent></PageHeader>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard
|
||||
states={[
|
||||
{ id: "basic", label: "Basic", wide: true, children: <PageHeader><PageHeaderContent><PageHeaderTitle>업무 현황</PageHeaderTitle></PageHeaderContent></PageHeader> },
|
||||
{ id: "with-breadcrumb", label: "With Breadcrumb", wide: true, children: <BreadcrumbHeader /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
// The former representative page's full work screen (Breadcrumb header + Tabs + Table), kept unchanged as its own Story.
|
||||
export const FullPage: Story = {
|
||||
tags: ["verification"],
|
||||
name: "Full Page",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Page Header" />
|
||||
<StorySection title="Breadcrumb 포함형">
|
||||
<PageHeader>
|
||||
<PageHeaderContent>
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#home">홈</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbLink href="#projects">프로젝트</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
<BreadcrumbSeparator />
|
||||
<BreadcrumbItem>
|
||||
<BreadcrumbPage>업무 현황</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
<PageHeaderTitle>업무 현황</PageHeaderTitle>
|
||||
<PageHeaderDescription>프로젝트별 업무 진행 상황을 확인하고 조회합니다.</PageHeaderDescription>
|
||||
</PageHeaderContent>
|
||||
<PageHeaderActions>
|
||||
<Button type="button" variant="primary" appearance="brand" size="default">
|
||||
신규 등록
|
||||
</Button>
|
||||
</PageHeaderActions>
|
||||
</PageHeader>
|
||||
<Tabs defaultValue="mine" className="min-w-0">
|
||||
<TabsList aria-label="업무 목록 보기">
|
||||
<TabsTrigger value="mine">내 업무</TabsTrigger>
|
||||
<TabsTrigger value="completed">완료 업무</TabsTrigger>
|
||||
</TabsList>
|
||||
<TabsContent value="mine" className="px-0 py-6">
|
||||
<Table aria-label="내 업무">
|
||||
<TableHeader><TableRow><TableHead scope="col">업무</TableHead><TableHead scope="col">마감일</TableHead><TableHead scope="col">상태</TableHead></TableRow></TableHeader>
|
||||
<TableBody>
|
||||
<TableRow><TableCell>화면 설계서 검토</TableCell><TableCell>10월 14일</TableCell><TableCell>검토</TableCell></TableRow>
|
||||
<TableRow><TableCell>고객 요구사항 정리</TableCell><TableCell>10월 16일</TableCell><TableCell>진행</TableCell></TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
<TabsContent value="completed" className="px-0 py-6">
|
||||
<Table aria-label="완료 업무">
|
||||
<TableHeader><TableRow><TableHead scope="col">업무</TableHead><TableHead scope="col">완료일</TableHead><TableHead scope="col">상태</TableHead></TableRow></TableHeader>
|
||||
<TableBody><TableRow><TableCell>프로젝트 기본 정보 등록</TableCell><TableCell>10월 9일</TableCell><TableCell>완료</TableCell></TableRow></TableBody>
|
||||
</Table>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<BreadcrumbHeader />
|
||||
<TaskTabs />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
.sw-footer-story-screen { position: relative; display: flex; flex-direction: column; background: #060606; }
|
||||
.sw-footer-story-canvas { position: relative; background: var(--sw-story-canvas); }
|
||||
/* 2026-10-22: the workspace fits the viewport under the page title (720px at most), so the page itself does not
|
||||
scroll where there is room; a short window keeps 480px and the page scrolls instead of clipping. */
|
||||
.sw-footer-story-workspace { display: flex; flex-direction: column; height: clamp(480px, calc(100dvh - 210px), 720px); background: #060606; }
|
||||
scroll where there is room; a short window keeps 480px and the page scrolls instead of clipping.
|
||||
2026-11-09: the 49px SWHeader above the drawing area was removed, so every bound is 49px lower (drawing area and footer unchanged). */
|
||||
.sw-footer-story-workspace { display: flex; flex-direction: column; height: clamp(431px, calc(100dvh - 259px), 671px); background: #060606; }
|
||||
.sw-footer-story-canvas--fill { flex: 1 1 auto; min-height: 0; }
|
||||
.sw-footer-story-nodes { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 16px; }
|
||||
.sw-footer-story-node { margin: 0; padding: 12px; border: 1px solid #dedbd2; border-radius: 6px; background: #fff; }
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { withSWNotice } from "./sw-story-notice"
|
||||
import {
|
||||
SWFooter, SWFooterAddSlot, SWFooterAuxButton, SWFooterAuxGroup, SWFooterCommandLine, SWFooterGuide, SWFooterLayoutTabs, SWFooterMenu,
|
||||
SWFooterToolButton, SWHeader, SWTheme, SW_FOOTER_MODEL, swFooterToolIcons,
|
||||
SWFooterToolButton, SWTheme, SW_FOOTER_MODEL, swFooterToolIcons,
|
||||
type SWFooterAuxItem, type SWFooterCategory, type SWFooterLayout, type SWFooterTool,
|
||||
} from "@hm-design/sw"
|
||||
import "@hm-design/sw/styles.css"
|
||||
@@ -14,7 +15,6 @@ import {
|
||||
} from "./sw-footer-fixture"
|
||||
import "./sw-stories.css"
|
||||
import "./sw-footer-stories.css"
|
||||
import placeholderLogo from "./assets/sw-header-logo-placeholder.svg"
|
||||
|
||||
// SW/Footer (2026-10-20): Figma 이지빔-SW 26:10097 bottom bar and its 34 component sets as @hm-design/sw components.
|
||||
// Stories use the public API only, like a consuming program. No CAD engine: commands are logged.
|
||||
@@ -22,8 +22,11 @@ const KICKER = "HM DESIGN SYSTEM / SW"
|
||||
const ref = (id: string) => new URL(`./assets/figma-reference/footer/${id}.png`, import.meta.url).href
|
||||
|
||||
const meta = {
|
||||
title: "SW/Footer",
|
||||
title: "SW/하단 명령어바",
|
||||
// Fixed so the Story IDs (sw-footer--*) and direct URLs stay unchanged when the sidebar title changes.
|
||||
id: "sw-footer",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
decorators: [withSWNotice],
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
@@ -51,7 +54,7 @@ function FigmaFooter() {
|
||||
|
||||
const LAYOUTS_2: SWFooterLayout[] = [{ id: "layout2", label: "Layout 02" }, { id: "layout1", label: "Layout 01" }]
|
||||
|
||||
/** The reference screen (2026-10-20): SWHeader, drawing area, footer with the command line inside the bar. */
|
||||
/** The reference screen (2026-10-20): drawing area, footer with the command line inside the bar. 2026-11-09: no SWHeader above the drawing area. */
|
||||
function Workspace({ width = "100%" }: { width?: number | string }) {
|
||||
const demo = useFooterDemo({ aux: REFERENCE_AUX, layouts: LAYOUTS_2, history: REFERENCE_HISTORY })
|
||||
const [commandOpen, setCommandOpen] = useState(false)
|
||||
@@ -60,7 +63,6 @@ function Workspace({ width = "100%" }: { width?: number | string }) {
|
||||
<Log lines={demo.log} />
|
||||
{/* 2026-10-22: no inner scroller; `width` is a maximum and the header / footer fit the available width. */}
|
||||
<div className="sw-footer-story-workspace" style={{ width: "100%", maxWidth: width }}>
|
||||
<SWHeader documentTitle="Drawing1.grm" logoSrc={placeholderLogo} logoAlt="예제 SW" />
|
||||
<div className="sw-footer-story-canvas sw-footer-story-canvas--fill" aria-label="도면 영역 (예시)" />
|
||||
<SWFooter categories={REFERENCE_CATEGORIES} categoryId={demo.categoryId} onCategoryChange={demo.onCategoryChange}
|
||||
status="7.4177, -0.2807, 0.0000"
|
||||
@@ -76,10 +78,10 @@ function Workspace({ width = "100%" }: { width?: number | string }) {
|
||||
}
|
||||
|
||||
export const FullFooter: Story = {
|
||||
name: "SW Footer",
|
||||
name: "SW 하단 명령어바",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="SW Footer"description="사용자 제공 실제 화면(2026-10-20) 구성: SWHeader + 도면 영역 + SWFooter(좌표 status, 바 안의 명령창 command, 오른쪽 명령 칸, Layout 탭). 모든 상태는 호스트(Story)가 Props로 소유하고, 컴포넌트는 Callback만 보냅니다." />
|
||||
<StoryPageHeader title="SW 하단 명령어바"description="사용자 제공 실제 화면(2026-10-20) 구성: 도면 영역 + SWFooter(좌표 status, 바 안의 명령창 command, 오른쪽 명령 칸, Layout 탭). 모든 상태는 호스트(Story)가 Props로 소유하고, 컴포넌트는 Callback만 보냅니다." />
|
||||
{/* 2026-10-22 (user request): no section heading; the live composition follows the page title. Behaviour: command
|
||||
click = select + history, category tabs, empty slot guide, aux toggles, ▲ = command window, Layout tabs, Enter runs. */}
|
||||
<section className="sb-page-section" aria-label="실제 Footer 조합 (동작)">
|
||||
@@ -379,10 +381,10 @@ function ReferenceFooter() {
|
||||
}
|
||||
|
||||
export const Responsive: Story = {
|
||||
name: "Responsive More",
|
||||
name: "화면 너비",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Responsive More" description="칸 영역은 바 폭으로만 정해지고 분류의 명령 수와 무관합니다(분류를 바꿔도 위치 고정). Figma에는 좁은 폭 정의가 없어 사용자 제공 실제 화면(2026-10-20)을 따릅니다: 빈 칸(+)이 먼저 줄고, 그래도 넘치는 명령은 마지막 칸의 ▲(ico_arrow)로 칸 위 패널에 펼침(▼ = 열림, Escape·바깥 클릭 닫힘). 분류 탭이 넘치면 ◀ ▶로 스크롤. 바 폭 1279px 이하에서 보조기능은 아이콘만(45 × 33). 바 안 명령창은 최소 280px." />
|
||||
<StoryPageHeader title="SW 하단 명령어바 — 화면 너비"description="칸 영역은 바 폭으로만 정해지고 분류의 명령 수와 무관합니다(분류를 바꿔도 위치 고정). Figma에는 좁은 폭 정의가 없어 사용자 제공 실제 화면(2026-10-20)을 따릅니다: 빈 칸(+)이 먼저 줄고, 그래도 넘치는 명령은 마지막 칸의 ▲(ico_arrow)로 칸 위 패널에 펼침(▼ = 열림, Escape·바깥 클릭 닫힘). 분류 탭이 넘치면 ◀ ▶로 스크롤. 바 폭 1279px 이하에서 보조기능은 아이콘만(45 × 33). 바 안 명령창은 최소 280px." />
|
||||
{/* Same 1920px row rule as SW Header / SW Viewer Responsive More (sw-stories.css .sw-story-responsive). */}
|
||||
<div className="sw-story-responsive">
|
||||
{[1920, 1440, 1280, 950, 860, 760].map(width => (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { withSWNotice } from "./sw-story-notice"
|
||||
import { ComposedDemo } from "./sw-header.stories"
|
||||
import "./sw-composed-controls.css"
|
||||
|
||||
@@ -7,6 +8,7 @@ const meta = {
|
||||
// Keep existing bookmarks; Dropdown is a Story directly under Header.
|
||||
id: "sw-header",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
decorators: [withSWNotice],
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useLayoutEffect, useRef, useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { withSWNotice } from "./sw-story-notice"
|
||||
// Public API only, like a consuming project.
|
||||
import {
|
||||
SWHeader, SWHeaderPropertyBar, SWHomeMenu, SWHomeMenuItem, SWHomeMenuSection, SWPopup, SWPopupRow, SWPopupSection,
|
||||
@@ -25,6 +26,7 @@ const KICKER = "HM DESIGN SYSTEM / SW"
|
||||
const meta = {
|
||||
title: "SW/Header",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
decorators: [withSWNotice],
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
@@ -114,6 +116,8 @@ function FitToPanel({ children }: { children: ReactNode }) {
|
||||
/** The sidebar Story: the logo menu of SWHeader (SW normal mode) with its Figma states and sub panel (the Escape stack demo is the hidden Integration Story). */
|
||||
export const HomeMenu: Story = {
|
||||
name: "Home Menu",
|
||||
// `internal` keeps it out of the sidebar by default; Story ID and direct URL stay valid.
|
||||
tags: ["internal"],
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Home Menu — 로고(홈) 버튼 메뉴" description="SW 일반 모드 전용(뷰어 모드 미적용) · 로고 클릭으로 열고 닫기 · 상세 동작은 SW AI 가이드 COMPONENT_CATALOG" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { withSWNotice } from "./sw-story-notice"
|
||||
import {
|
||||
SWHeader, SWHeaderDocumentTitle, SWHeaderHome, SWHeaderLayerMenu, SWTheme, SWToolButton,
|
||||
type SWHeaderDocument, type SWHeaderLayer, type SWHeaderProps,
|
||||
@@ -54,6 +55,7 @@ const meta = {
|
||||
title: "SW/Header",
|
||||
id: "sw-header",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
decorators: [withSWNotice],
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
@@ -69,8 +69,10 @@ export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFi
|
||||
if (!slot) return
|
||||
const measure = () => {
|
||||
const bounds = slot.getBoundingClientRect()
|
||||
const width = Math.max(0, Math.min(bounds.width, bounds.height * 16 / 9))
|
||||
setSize(previous => previous.width === width ? previous : { width, height: width * 9 / 16 })
|
||||
// 2026-11-09: the Stage height is still the 16:9 height of the fitted area (unchanged); the width now fills the slot.
|
||||
const height = Math.max(0, Math.min(bounds.width, bounds.height * 16 / 9)) * 9 / 16
|
||||
const width = Math.max(0, bounds.width)
|
||||
setSize(previous => previous.width === width && previous.height === height ? previous : { width, height })
|
||||
}
|
||||
measure()
|
||||
const observer = new ResizeObserver(measure)
|
||||
@@ -150,7 +152,7 @@ export function SWHeaderOverview({ initial, fixtures, propertyGroups, propertyFi
|
||||
</div></details>
|
||||
</div>}
|
||||
<div ref={slotRef} className="sw-overview-slot" data-preview-slot="">
|
||||
<div className="sw-overview-stage" data-preview-stage="" role="region" tabIndex={0} aria-label="SW Header 16:9 미리보기, 작은 화면에서는 내부 스크롤"
|
||||
<div className="sw-overview-stage" data-preview-stage="" role="region" tabIndex={0} aria-label="SW Header 미리보기, 작은 화면에서는 내부 스크롤"
|
||||
style={{ ...size, "--sw-preview-stage-height": `${size.height}px`, "--sw-preview-panel-left": `${panelLeft}px`, "--sw-preview-panel-top": barOpen ? "77px" : "49px" } as CSSProperties}>
|
||||
<div className="sw-overview-world">
|
||||
<SWHeader ref={headerRef} {...initial}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useId, useRef, useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { withSWNotice } from "./sw-story-notice"
|
||||
import {
|
||||
SWHeader, SWHeaderColorMenu, SWHeaderLineStyleMenu, SWHeaderTextStyleMenu, SWHeaderDimensionStyleMenu,
|
||||
SWHeaderProperties, SWHeaderWindowControls, SWTheme, SWHeaderLayerMenu, parseSWHeaderColor, swHeaderLinePatterns, SWHeaderPropertyPanel, SWHeaderPropertyBar,
|
||||
@@ -68,6 +69,7 @@ const meta = {
|
||||
excludeStories: ["ComposedDemo", "PropertyPanelComparePage"],
|
||||
component: SWHeader,
|
||||
parameters: { layout: "fullscreen" },
|
||||
decorators: [withSWNotice],
|
||||
args: { ...figmaArgs, ...callbacks },
|
||||
argTypes: {
|
||||
activeMenu: { control: "select", options: [null, "layer", "color", "lineStyle", "textStyle", "dimensionStyle"] },
|
||||
@@ -450,7 +452,9 @@ const LOGO_CASES: { title: string; props: Partial<SWHeaderProps> }[] = [
|
||||
]
|
||||
|
||||
export const LogoSlot: Story = {
|
||||
name: "Logo",
|
||||
name: "로고",
|
||||
// `internal` keeps it out of the sidebar by default; Story ID and direct URL stay valid.
|
||||
tags: ["internal"],
|
||||
parameters: { controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
@@ -780,17 +784,21 @@ function StyleHeader({ onLog, showState, ...props }: StyleHostProps) {
|
||||
const MORE_WIDTHS = [1920, 1440, 1280, 950, 860, 760]
|
||||
|
||||
export const ResponsiveMore: Story = {
|
||||
name: "Responsive More",
|
||||
name: "화면 너비",
|
||||
parameters: { controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="SW Header — 화면 너비" />
|
||||
<div className="sw-story-responsive">
|
||||
<div className="sw-story-stack sw-story-responsive">
|
||||
{MORE_WIDTHS.map(width => (
|
||||
// 2026-10-23: the 1920 row stays inside the Story column (as SW Viewer › Responsive) and is hidden on narrow pages.
|
||||
<div key={width} className={width === 1920 ? "sw-story-responsive-hidden" : undefined}>
|
||||
<StorySection title={`${width}px`}>
|
||||
<MenuFrame width={width} fluid><StyleHeader /></MenuFrame>
|
||||
{/* Same structure as the Logo page (no black frame). This page only shows widths, so the five field dropdowns are
|
||||
held closed with the public controlled props; the open callbacks and header data are unchanged. */}
|
||||
<div style={{ width: "100%", maxWidth: width }} data-frame-width={width}>
|
||||
<StyleHeader layerMenuOpen={false} colorMenuOpen={false} lineStyleMenuOpen={false} textStyleMenuOpen={false} dimensionStyleMenuOpen={false} />
|
||||
</div>
|
||||
</StorySection>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useId, useState } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { withSWNotice } from "./sw-story-notice"
|
||||
import {
|
||||
SWHeaderInput, SWHeaderPropertyPanel, SWPopup, SWPopupPropertyRow, SWPopupRow, SWPopupSection, SWSelect, SWSlider, SWTheme,
|
||||
type SWHeaderColorOption, type SWHeaderLayer, type SWHeaderProperty,
|
||||
@@ -30,6 +31,7 @@ const layerProperty = (value: string): SWHeaderProperty => ({ id: "layer", name:
|
||||
const meta = {
|
||||
title: "SW/Popup",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
decorators: [withSWNotice],
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { withSWNotice } from "./sw-story-notice"
|
||||
import {
|
||||
SWProcessBar, SWProcessHeader, SWProcessSplitButton, SWTheme, SWToolButton, type SWProcessGroup, type SWProcessHeaderProps, type SWProcessSplitType,
|
||||
} from "@hm-design/sw"
|
||||
@@ -31,6 +32,7 @@ const LOGO = { logoSrc: placeholderLogo, logoAlt: "임시 로고" } satisfies Pa
|
||||
const meta = {
|
||||
title: "SW/Step Header",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
decorators: [withSWNotice],
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
@@ -141,7 +143,10 @@ const FIGMA_FRAMES: { node: string; step: string; documentTitle?: string }[] = [
|
||||
{ node: "873:17421", step: "body-plan" },
|
||||
]
|
||||
|
||||
// One page (2026-11-03): the Default header, then the three Multi Step frames; each header keeps its own state.
|
||||
// The representative page keeps only the middle frame (project open); the three frames stay in the MultiStep verification story.
|
||||
const OPEN_PROJECT_FRAME = FIGMA_FRAMES[1]
|
||||
|
||||
// One page: the Default header, then the project-open Multi Step frame; each header keeps its own state.
|
||||
export const ProcessHeader: Story = {
|
||||
name: "Step Header",
|
||||
render: () => (
|
||||
@@ -151,11 +156,7 @@ export const ProcessHeader: Story = {
|
||||
<LiveHeader />
|
||||
</StorySection>
|
||||
<StorySection title="Multi Step">
|
||||
<div className="sw-story-stack">
|
||||
{FIGMA_FRAMES.map(frame => (
|
||||
<Row key={frame.node}><div data-figma-node={frame.node}><LiveMultiHeader step={frame.step} documentTitle={frame.documentTitle} /></div></Row>
|
||||
))}
|
||||
</div>
|
||||
<Row><div data-figma-node={OPEN_PROJECT_FRAME.node}><LiveMultiHeader step={OPEN_PROJECT_FRAME.step} documentTitle={OPEN_PROJECT_FRAME.documentTitle} /></div></Row>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
@@ -164,7 +165,7 @@ export const ProcessHeader: Story = {
|
||||
const WIDTHS = [1920, 1366, 1100]
|
||||
|
||||
export const Responsive: Story = {
|
||||
name: "Responsive More",
|
||||
name: "화면 너비",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="SW Step Header — 화면 너비" />
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { withSWNotice } from "./sw-story-notice"
|
||||
import { PropertyPanelComparePage } from "./sw-header.stories"
|
||||
|
||||
const meta = {
|
||||
@@ -8,10 +9,11 @@ const meta = {
|
||||
// Custom Content and Multiple Popups.
|
||||
id: "sw-header",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
decorators: [withSWNotice],
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
|
||||
export const PropertyPanelCompare: StoryObj<typeof meta> = {
|
||||
name: "Property Panel", // 2026-10-22 (user request): was "Property Panel · 4개 비교"; ID unchanged
|
||||
name: "속성창", // 2026-10-22 (user request): was "Property Panel · 4개 비교"; ID unchanged
|
||||
render: () => <PropertyPanelComparePage />,
|
||||
}
|
||||
@@ -68,20 +68,20 @@
|
||||
.sw-story-detail-row > div { display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* Overview host layout only. Native controls below are Storybook data controls, outside the product. */
|
||||
.sw-story-overview.sb-page { height: 100dvh; min-height: 0; padding-block: 16px; }
|
||||
.sw-story-overview > .sb-page-inner { height: 100%; display: flex; flex-direction: column; gap: 12px; }
|
||||
.sw-story-overview .sb-page-header { flex: 0 0 auto; padding-bottom: 8px; }
|
||||
.sw-story-overview .sb-page-header .sb-page-kicker { margin-bottom: 4px; }
|
||||
.sw-story-overview .sb-page-header .sb-page-title { margin-block: 0 4px; }
|
||||
.sw-overview-toolbar { display: flex; flex: 0 0 auto; flex-wrap: wrap; align-items: center; gap: 8px; font-size: 13px; }
|
||||
/* 2026-11-09: the page frame (gutter, title block, Inner) is the common SW Story page; only the Stage keeps a viewport-based height.
|
||||
The Stage fits the slot (16:9 inside it), so the slot gets a definite height instead of the page being locked to 100dvh. */
|
||||
.sw-story-overview > .sb-page-inner { display: flex; flex-direction: column; }
|
||||
.sw-overview-toolbar { display: flex; flex: 0 0 auto; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 12px; font-size: 13px; }
|
||||
.sw-overview-toolbar select, .sw-overview-data button { font: inherit; padding: 3px 8px; color: inherit; background: var(--hm-sem-bg-surface); border: 1px solid var(--hm-sem-stroke-input-default); border-radius: 4px; }
|
||||
.sw-overview-toolbar details { flex-basis: 100%; }
|
||||
.sw-overview-toolbar summary { width: max-content; cursor: pointer; }
|
||||
.sw-overview-data { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding-block: 8px; }
|
||||
.sw-overview-data label { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.sw-overview-slot { flex: 1 1 0; min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.sw-overview-slot { flex: none; height: max(360px, calc(100dvh - 260px)); min-width: 0; min-height: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.sw-overview-stage { flex: 0 0 auto; overflow: auto; overscroll-behavior: contain; background: var(--sw-story-canvas); outline: 1px solid var(--hm-sem-stroke-input-default); }
|
||||
.sw-overview-world { min-width: 390px; min-height: 100%; position: relative; }
|
||||
/* 2026-11-09: floor 390 -> 358, the measured minimum width of SWHeader (content width 358px; narrower overflows). 390 forced a 32px
|
||||
Stage scroll at a 390px viewport (Inner 358px). Below 358px the Stage still scrolls instead of clipping. */
|
||||
.sw-overview-world { min-width: 358px; min-height: 100%; position: relative; }
|
||||
/* Placement-only Storybook exception: the panel sits in the Stage. 2026-10-26 (user request): no Stage scrollbar when
|
||||
it opens — the panel is clamped to the Stage height (as the product clamps it to the viewport) and its body scrolls.
|
||||
The Stage height comes from the Story (--sw-preview-stage-height): the panel's containing block is the Header.
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { Decorator } from "@storybook/react-vite"
|
||||
import { StoryNotice } from "@/components/story-page"
|
||||
|
||||
// Storybook presentation only. Common notice shown above the page title of every SW Story (SW/* groups only):
|
||||
// each SW stories file opts in with `decorators: [withSWNotice]`, so other menus never render it.
|
||||
// Hidden verification / internal Stories are skipped; `parameters.swNotice: false` opts a Story out
|
||||
// (use it where the page already carries the same notice, so it is never shown twice).
|
||||
export const SW_NOTICE_TEXT =
|
||||
"SW UI는 공통 컴포넌트의 기능과 동작을 유지하면서, 제품별 특성에 따라 시각적 스타일을 다르게 적용할 수 있습니다."
|
||||
|
||||
const HIDDEN_TAGS = ["verification", "internal"]
|
||||
|
||||
export const withSWNotice: Decorator = (Story, context) => {
|
||||
const hidden = (context.tags ?? []).some(tag => HIDDEN_TAGS.includes(tag))
|
||||
if (hidden || context.parameters.swNotice === false) return <Story />
|
||||
return (
|
||||
<>
|
||||
<StoryNotice>{SW_NOTICE_TEXT}</StoryNotice>
|
||||
<Story />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, type CSSProperties, type ReactNode } from "react"
|
||||
import { createPortal } from "react-dom"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { withSWNotice } from "./sw-story-notice"
|
||||
import {
|
||||
SWFooter, SWFooterCommandLine, SWHeader, SWTheme, SWViewerButton, SWViewerHeader, SWViewerHint, SWViewerSection,
|
||||
} from "@hm-design/sw"
|
||||
@@ -23,6 +24,7 @@ const NESTED_THEME = { "--hm-sw-header-font-family": '"Malgun Gothic", sans-seri
|
||||
const meta = {
|
||||
title: "SW/Theme",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
decorators: [withSWNotice],
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
@@ -46,5 +46,3 @@
|
||||
.sw-viewer-story-steps { margin: 0; padding-left: 20px; font-size: 14px; }
|
||||
.sw-viewer-story-context { display: flex; align-items: center; justify-content: center; height: 160px; border: 1px dashed #9a9284; border-radius: 4px; background: #f5f0e5; color: #3e3523; font-size: 13px; }
|
||||
.sw-viewer-story-mode { display: flex; gap: 16px; margin-bottom: 8px; font-size: 13px; }
|
||||
/* Room below a single header row so its logo menu / document list are not cut by the scroller. */
|
||||
.sw-viewer-story-menu-row { padding-bottom: 120px; }
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useRef, useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { withSWNotice } from "./sw-story-notice"
|
||||
import {
|
||||
SWHeader, SWHeaderWindowControls, SWViewerButton, SWViewerHeader, SWViewerHint, SWViewerMenu, SWViewerPanel, SWViewerSection,
|
||||
SWHeader, SWViewerButton, SWViewerHeader, SWViewerHint, SWViewerMenu, SWViewerPanel, SWViewerSection,
|
||||
type SWViewerHeaderGroup, type SWViewerMenuEntry,
|
||||
} from "@hm-design/sw"
|
||||
import "@hm-design/sw/styles.css"
|
||||
@@ -20,6 +21,7 @@ const KICKER = "HM DESIGN SYSTEM / SW"
|
||||
const meta = {
|
||||
title: "SW/Viewer",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
decorators: [withSWNotice],
|
||||
} satisfies Meta
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
@@ -59,21 +61,12 @@ function AboutPanel({ demo }: { demo: ViewerDemo }) {
|
||||
}
|
||||
|
||||
// 2026-10-22 (user request): the Viewer Header screens have no 1440px maximum; they fill the Story column.
|
||||
function ViewerScreen({ width = null as number | null, initialDocument = SAMPLES[1].name as string | null, open = [] as ("layers" | "options" | "properties")[], windowControls = false }) {
|
||||
function ViewerScreen({ width = null as number | null, initialDocument = SAMPLES[1].name as string | null, open = [] as ("layers" | "options" | "properties")[] }) {
|
||||
const demo = useViewerDemo({ initialDocument, initialPanels: open })
|
||||
// 2026-10-21 new viewer version: SWHeader's own minimize / close (SWHeaderWindowControls, same icons, size, hover)
|
||||
// at the right end after the existing trailing items (the `trailing` slot, no new API). The program owns the real
|
||||
// window actions (onMinimize / onClose, as in SWHeader); the Story only logs them.
|
||||
const trailing = windowControls ? (
|
||||
<>
|
||||
{demo.trailing}
|
||||
<SWHeaderWindowControls showWindowMove={false} onMinimize={() => demo.record("minimize")} onClose={() => demo.record("close")} />
|
||||
</>
|
||||
) : demo.trailing
|
||||
return (
|
||||
<>
|
||||
<Log lines={demo.log} />
|
||||
<ViewerFrame width={width} header={<SWViewerHeader {...VIEWER_STORY_LOGO} documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={trailing} onCommand={demo.onCommand} />}>
|
||||
<ViewerFrame width={width} header={<SWViewerHeader {...VIEWER_STORY_LOGO} documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} />}>
|
||||
<LayerPanel demo={demo} strategy="absolute" />
|
||||
<OptionsPanel demo={demo} strategy="absolute" inset={{ side: 356 }} />
|
||||
<PropertiesPanel demo={demo} strategy="absolute" inset={{ side: 24, top: 488 }} />
|
||||
@@ -87,15 +80,12 @@ export const ViewerHeader: Story = {
|
||||
name: "Viewer Header",
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
{/* 2026-10-22 (user request): page title and the two sections only. The minimize / close version keeps
|
||||
SWHeaderWindowControls at the right end of `trailing`; the program owns the real window actions. */}
|
||||
{/* The minimize / close example was removed from this Story; the component keeps supporting it
|
||||
(SWHeaderWindowControls at the right end of `trailing`, VIEWER.md §2.1). */}
|
||||
<StoryPageHeader title="SW Viewer Header" />
|
||||
<StorySection title="기본형">
|
||||
<ViewerScreen />
|
||||
</StorySection>
|
||||
<StorySection title="최소화 · 닫기 버튼">
|
||||
<div data-viewer-window-controls=""><ViewerScreen windowControls /></div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -280,7 +270,7 @@ export const SettingsPanel: Story = {
|
||||
export const ContentVariants: Story = {
|
||||
// 2026-10-21: the one settings-popup Story (former 설정창 and 같은 프레임, 다른 내부 콘텐츠 pages); ID kept.
|
||||
// 2026-10-22 (user request): the 축척 지정 demo popup was removed.
|
||||
name: "Settings Popup",
|
||||
name: "설정창",
|
||||
render: () => {
|
||||
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name, initialPanels: ["layers", "properties", "options"] })
|
||||
return (
|
||||
@@ -327,7 +317,7 @@ export const VariableSize: Story = {
|
||||
}
|
||||
|
||||
export const Logo: Story = {
|
||||
name: "Logo",
|
||||
name: "로고",
|
||||
render: () => {
|
||||
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
|
||||
const rows: { title: string; props: { logoSrc?: string; logo?: ReactNode; logoAlt?: string } }[] = [
|
||||
@@ -355,7 +345,7 @@ export const Logo: Story = {
|
||||
}
|
||||
|
||||
export const Responsive: Story = {
|
||||
name: "Responsive More",
|
||||
name: "화면 너비",
|
||||
render: () => {
|
||||
const demo = useViewerDemo({ initialDocument: SAMPLES[1].name })
|
||||
return (
|
||||
@@ -369,7 +359,8 @@ export const Responsive: Story = {
|
||||
// as the SW Header / Footer 1920 rows (sw-story-responsive-hidden, sw-stories.css), e.g. a 1920px screen.
|
||||
<div key={width} className={width === 1920 ? "sw-story-responsive-hidden" : undefined}><StorySection title={`${width}px`}>
|
||||
{/* The row width is a maximum (2026-10-22): on a narrower screen the header shrinks with it instead of scrolling. */}
|
||||
<div className="sw-story-scroller sw-viewer-story-menu-row"><div style={{ width: "100%", maxWidth: width }} data-width={width}><SWViewerHeader {...VIEWER_STORY_LOGO} documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} /></div></div>
|
||||
{/* Same structure as the Logo page (no scroller / bottom padding), so the spacing matches it. */}
|
||||
<div style={{ width: "100%", maxWidth: width }} data-width={width}><SWViewerHeader {...VIEWER_STORY_LOGO} documentTitle={demo.documentName} {...demo.fileProps} groups={demo.groups} trailing={demo.trailing} onCommand={demo.onCommand} /></div>
|
||||
</StorySection></div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -240,7 +240,7 @@ export const LiveVerification: Story = {
|
||||
}
|
||||
fillJson('{"schemaVersion":1,"tokens":{"color.state.active":"#345678"}}'); click("Import JSON")
|
||||
await waitForThemeState(() => variable("--hm-sem-state-active") === "#345678", "Designer partial import")
|
||||
assert(variable("--hm-sem-state-active") === "#345678" && variable("--hm-sem-action-primary") === "#123328" && variable("--hm-sem-stroke-input-focus") === "#266651", "Partial import replaces previous keys; unspecified roles use Default")
|
||||
assert(variable("--hm-sem-state-active") === "#345678" && variable("--hm-sem-action-primary") === "#164434" && variable("--hm-sem-stroke-input-focus") === "#266651", "Partial import replaces previous keys; unspecified roles use Default")
|
||||
for (const [index, [token, label]] of roles.entries()) {
|
||||
const value = `#3456${(120 + index).toString(16)}`
|
||||
setInput(`${label} HEX`, value)
|
||||
|
||||
@@ -50,7 +50,7 @@ async function verifyLoader(container: HTMLElement) {
|
||||
assert(checked.dataset.state === "checked" && checked.getAttribute("aria-checked") === "true" && getComputedStyle(checked).backgroundColor === "rgb(0, 127, 115)", "HTTP JSON reaches rendered Checkbox")
|
||||
assert(JSON.stringify(geometry()) === initial, "External JSON preserves component geometry")
|
||||
const resolved = resolveRuntimeTheme({ schemaVersion: 1, tokens: { "color.state.active": "#345678" } }, defaults)
|
||||
assert(resolved.tokens["color.action.primary"] === "#123328" && resolved.tokens["color.state.active"] === "#345678", "Default JSON + partial service override merge")
|
||||
assert(resolved.tokens["color.action.primary"] === "#164434" && resolved.tokens["color.state.active"] === "#345678", "Default JSON + partial service override merge")
|
||||
assert(await loadRuntimeTheme(root, "/themes/service-example.json", { defaults }), "Loader accepts separately fetched Default JSON")
|
||||
assert(variable("--hm-sem-action-primary") === "#6837a8", "Service override wins supplied Default")
|
||||
resetRuntimeTheme(root)
|
||||
|
||||
@@ -190,7 +190,7 @@ async function verifyRuntime(canvasElement: HTMLElement, keyboardFocus = false)
|
||||
applyRuntimeTheme(after, {schemaVersion:1,tokens:{"color.state.active":"#345678"}})
|
||||
assert(variable("--hm-checkbox-checked-background") === "#345678", "Scoped B overrides global A")
|
||||
assert(getComputedStyle(globalRoot).getPropertyValue("--hm-checkbox-checked-background").trim() === "#007f73", "Global A retains its own mapping")
|
||||
assert(variable("--hm-badge-default-background") === "#123328", "Explicit isolated scope falls back to HM preset")
|
||||
assert(variable("--hm-badge-default-background") === "#164434", "Explicit isolated scope falls back to HM preset")
|
||||
} finally { resetRuntimeTheme(globalRoot); resetRuntimeTheme(after) }
|
||||
after.style.setProperty("--hm-sem-state-active", "#112233", "important")
|
||||
applyRuntimeTheme(after, external); resetRuntimeTheme(after)
|
||||
|
||||
@@ -588,7 +588,7 @@
|
||||
"--hm-label-line-height": "20px",
|
||||
"--hm-label-font-weight": "500",
|
||||
"--hm-label-text": "var(--hm-sem-fg-secondary)",
|
||||
"--hm-lnb-width": "272px",
|
||||
"--hm-lnb-width": "240px",
|
||||
"--hm-lnb-join": "18px",
|
||||
"--hm-lnb-background": "linear-gradient(90deg, var(--hm-green-default) 0%, var(--hm-green-light-1) 30%, var(--hm-green-light-2) 50%, var(--hm-green-light-3) 90%, var(--hm-green-light-2) 100%)",
|
||||
"--hm-lnb-background-end": "var(--hm-green-light-2)",
|
||||
@@ -608,10 +608,11 @@
|
||||
"--hm-lnb-brand-eyebrow-spacing": "0.12em",
|
||||
"--hm-lnb-brand-name-size": "17px",
|
||||
"--hm-lnb-brand-text-gap": "5px",
|
||||
"--hm-lnb-item-min-height": "56px",
|
||||
"--hm-lnb-item-min-height": "48px",
|
||||
"--hm-lnb-item-padding-block": "10px",
|
||||
"--hm-lnb-item-padding-start": "18px",
|
||||
"--hm-lnb-item-gap": "12px",
|
||||
"--hm-lnb-item-font-size": "18px",
|
||||
"--hm-lnb-item-font-size": "14px",
|
||||
"--hm-lnb-item-line-height": "28px",
|
||||
"--hm-lnb-item-font-weight": "700",
|
||||
"--hm-lnb-item-radius": "4px 0 0 4px",
|
||||
@@ -631,9 +632,11 @@
|
||||
"--hm-lnb-group-current-text": "var(--hm-yellow-accent)",
|
||||
"--hm-lnb-group-arrow-size": "18px",
|
||||
"--hm-lnb-subitem-padding-start": "68px",
|
||||
"--hm-lnb-subitem-min-height": "42px",
|
||||
"--hm-lnb-subitem-padding-block": "7px",
|
||||
"--hm-lnb-group-padding-bottom": "8px",
|
||||
"--hm-lnb-third-min-height": "48px",
|
||||
"--hm-lnb-third-padding-block": "12px",
|
||||
"--hm-lnb-third-min-height": "42px",
|
||||
"--hm-lnb-third-padding-block": "9px",
|
||||
"--hm-lnb-third-padding-start": "88px",
|
||||
"--hm-lnb-third-line-height": "24px",
|
||||
"--hm-lnb-third-font-weight": "500",
|
||||
@@ -646,6 +649,7 @@
|
||||
"--hm-lnb-profile-padding-x": "30px",
|
||||
"--hm-lnb-profile-padding-top": "22px",
|
||||
"--hm-lnb-profile-gap": "12px",
|
||||
"--hm-lnb-profile-min-height": "48px",
|
||||
"--hm-lnb-profile-name-size": "16px",
|
||||
"--hm-lnb-profile-description-size": "12px",
|
||||
"--hm-loading-screen-background": "var(--hm-green-default)",
|
||||
@@ -779,13 +783,13 @@
|
||||
"--hm-green-light-3": "#249473",
|
||||
"--hm-green-light-2": "#266651",
|
||||
"--hm-green-light-1": "#205241",
|
||||
"--hm-green-default": "#123328",
|
||||
"--hm-green-dark-1": "#0a221d",
|
||||
"--hm-green-dark-2": "#071e18",
|
||||
"--hm-green-default": "#164434",
|
||||
"--hm-green-dark-1": "#11372d",
|
||||
"--hm-green-dark-2": "#0b2b23",
|
||||
"--hm-brown-light-2": "#897445",
|
||||
"--hm-brown-light-1": "#604f32",
|
||||
"--hm-brown-default": "#3e3523",
|
||||
"--hm-brown-dark-1": "#352d1d",
|
||||
"--hm-brown-default": "#4e422d",
|
||||
"--hm-brown-dark-1": "#403726",
|
||||
"--hm-brown-light-4": "#ecd4a2",
|
||||
"--hm-orange-light-4": "#feba88",
|
||||
"--hm-orange-default": "#cc5200",
|
||||
|
||||
Reference in new issue
Block a user