- {rolesFor(appearance).map(([variant, label]) => (
-
+
+ {groups.map(({ id, label, children }) => (
+
))}
)
}
-function CompactSizeRow({ appearance }: { appearance: "brand" | "solid" }) {
+// States (bottom): Brand | Standard Solid | Standard Outline columns, title on top, all tops aligned. The columns use the
+// whole width, shared in proportion to their matrices (about 301 / 420 / 338px, never narrower than the matrix); each matrix
+// keeps its 8px button gap and sits at the start of its column. 3 columns from a 1250px preview (the three need about 1106px
+// of content plus the page margins), 2 columns (Brand + Solid, then Outline) from 890px, 1 column below; a 1-column matrix
+// wider than the screen scrolls in its own box only (the page never scrolls sideways).
+function GroupColumns({ groups }: { groups: { id: string; label: string; children: ReactNode }[] }) {
return (
-
- {sizes.map(([size, label]) => (
-
+
+ {groups.map(({ id, label, children }) => (
+
))}
)
}
-// Representative Story: the common Story page (Sizes → Variants → States) with the ui_viz Button "All" boards.
+// States: one matrix per treatment, rows = Default / Hover / Pressed / Disabled (standardStates, the same 4 rows for Brand;
+// the live Interact button stays on the verification "Brand States"), columns = that treatment's colors in the Variants order.
+// Columns follow their buttons (auto table layout). 4px cell padding gives the same 8px gap as Sizes / Variants between
+// buttons, rows, the state label and the color header; the outer edge keeps none (pl-0 / last:pr-0) so the three matrices
+// fit one row at 1440px. Buttons sit centered under their color and are never resized.
+function StateMatrix({ appearance, states }: { appearance: ButtonAppearance; states: readonly (readonly [string, Record
])[] }) {
+ const colors = rolesFor(appearance)
+ return (
+
+
+
+
+ |
+ State
+ |
+ {colors.map(([variant, color]) => (
+
+ {color}
+ |
+ ))}
+
+
+
+ {states.map(([state, props]) => (
+
+ |
+ {state}
+ |
+ {colors.map(([variant, color]) => (
+
+
+ |
+ ))}
+
+ ))}
+
+
+
+ )
+}
+
+// Representative Story: the common Story page (Sizes → Variants → States).
// 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 = {
@@ -276,29 +320,44 @@ export const All: Story = {
render: () => (
-
- {/* 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. */}
-
- }]} />
- }]} />
-
-
-
-
- {appearances.map(([appearance, label]) => (
- }]} />
- ))}
-
-
+ {/* Sizes | Variants on one row from a 1080px preview (1280px screen with the 180px LNB), sharing the whole width in two
+ equal halves (never narrower than their rows). Narrower previews stack the two sections. */}
+
+
+ ({
+ id: `sizes-${appearance}`,
+ label,
+ children: sizes.map(([size, sizeLabel]) => (
+
+ )),
+ }))}
+ />
+
+
+ ({
+ id: `roles-${appearance}`,
+ label,
+ children: rolesFor(appearance).map(([variant, color]) => (
+
+ )),
+ }))}
+ />
+
+
-
-
}]} />
-
- }]} />
- }]} />
-
-
+ ({
+ id: `${appearance}-states`,
+ label,
+ children: ,
+ }))}
+ />
),
diff --git a/src/components/combobox.stories.tsx b/src/components/combobox.stories.tsx
index 4f9a157..82f9bdf 100644
--- a/src/components/combobox.stories.tsx
+++ b/src/components/combobox.stories.tsx
@@ -22,9 +22,10 @@ const longOptions: ComboboxOption[] = [
]
// 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 }) {
+// `fill` lets the field take its grid column (representative page); the verification Stories keep the 320px field.
+function Field({ label, id, disabled, fill, children }: { label: string; id: string; disabled?: boolean; fill?: boolean; children: ReactNode }) {
return (
-
+
@@ -33,31 +34,36 @@ function Field({ label, id, disabled, children }: { label: string; id: string; d
)
}
-function Empty() {
+// Representative page (2026-11-12, same rule as Input / Select): with `state`, the state name takes the field Label slot (no
+// repeated field Label under a separate group title) and the trigger keeps the field meaning in its accessible name, which also
+// contains the visible state name (Label in Name), e.g. "프레임워크 (Selected)". Without `state` the verification Stories are unchanged.
+const stateName = (state: string | undefined, field: string) => (state ? `${field} (${state})` : undefined)
+
+function Empty({ state }: { state?: string }) {
return (
-
-
+
+
)
}
// `showValue={false}` hides the value readout on the representative page; the verification Stories keep it.
-function Controlled({ showValue = true }: { showValue?: boolean }) {
+function Controlled({ showValue = true, state }: { showValue?: boolean; state?: string }) {
const [value, setValue] = useState("nuxt")
return (
-
-
+
+
{showValue ? {`value = ${value}`} : null}
)
}
-function Medium() {
+function Medium({ state }: { state?: string }) {
return (
-
-
+
+
)
}
@@ -70,10 +76,10 @@ function LongLabel() {
)
}
-function Disabled() {
+function Disabled({ state }: { state?: string }) {
return (
-
-
+
+
)
}
@@ -111,6 +117,25 @@ const Cell = ({ id, label, wide, children }: { id: string; label: string; wide?:
)
+// Sizes / States (2026-11-12): the Input / Select field grid with the common section indent — up to 3 tracks of at least 240px
+// within 1120px, narrower widths drop to 2 / 1 columns. Each cell is one field whose Label is the state name.
+const fieldColumns =
+ "grid max-w-[1120px] items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(max(240px,calc((100%_-_48px)/3)),1fr))]"
+
+function FieldRow({ fields }: { fields: { id: string; render: ReactNode }[] }) {
+ return (
+
+
+ {fields.map(({ id, render }) => (
+
+ {render}
+
+ ))}
+
+
+ )
+}
+
// 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
@@ -124,16 +149,20 @@ export const All: Story = {
-
- |
- |
-
+ },
+ { id: "medium", render: },
+ ]}
+ />
-
- |
- |
-
+ },
+ { id: "disabled", render: },
+ ]}
+ />
diff --git a/src/components/date-picker.stories.tsx b/src/components/date-picker.stories.tsx
index e52cb20..81d038b 100644
--- a/src/components/date-picker.stories.tsx
+++ b/src/components/date-picker.stories.tsx
@@ -13,9 +13,10 @@ const today = new Date(2026, 9, 2)
const calendarProps = { today, startMonth: new Date(2026, 0), endMonth: new Date(2027, 11) }
// 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 }) {
+// `fill` lets the field take its grid column (representative page); the verification Stories keep the 320px field.
+function Field({ label, id, disabled, fill, children }: { label: string; id: string; disabled?: boolean; fill?: boolean; children: ReactNode }) {
return (
-
+
@@ -24,18 +25,23 @@ function Field({ label, id, disabled, children }: { label: string; id: string; d
)
}
-function Empty() {
+// Representative page (2026-11-12, same rule as Input / Select): with `state`, the state name takes the field Label slot (no
+// repeated field Label under a separate group title) and the trigger keeps the field meaning in its accessible name, which also
+// contains the visible state name (Label in Name), e.g. "시작일 (Selected)". Without `state` the verification Stories are unchanged.
+const stateName = (state: string | undefined, field: string) => (state ? `${field} (${state})` : undefined)
+
+function Empty({ state }: { state?: string }) {
return (
-
-
+
+
)
}
-function Selected() {
+function Selected({ state }: { state?: string }) {
return (
-
-
+
+
)
}
@@ -57,41 +63,52 @@ function CustomFormat() {
}
// `showValue={false}` hides the value readout on the representative page; the verification Stories keep it.
-function Controlled({ showValue = true }: { showValue?: boolean }) {
+function Controlled({ showValue = true, state }: { showValue?: boolean; state?: string }) {
const [date, setDate] = useState()
return (
-
-
+
+
{showValue ? {`value = ${date ? date.toLocaleDateString("ko-KR") : "undefined"}`} : null}
)
}
-function Medium() {
+function Medium({ state }: { state?: string }) {
return (
-
-
+
+
)
}
-function DisabledDates() {
+// With `state`, the rule that was the Label stays as helper text (12/18/400 secondary, 8px below the trigger, as Input's Helper)
+// and is linked to the trigger with aria-describedby.
+const disabledDatesNote = "5일 이전과 일요일은 선택 불가"
+
+function DisabledDates({ state }: { state?: string }) {
return (
-
+
+ {state ? (
+
+ {disabledDatesNote}
+
+ ) : null}
)
}
-function Disabled() {
+function Disabled({ state }: { state?: string }) {
return (
-
-
+
+
)
}
@@ -107,6 +124,25 @@ const Cell = ({ id, label, children }: { id: string; label: string; children: Re
)
+// Sizes / States (2026-11-12): the Input / Select field grid with the common section indent — up to 3 tracks of at least 240px
+// within 1120px, narrower widths drop to 2 / 1 columns. Each cell is one field whose Label is the state name.
+const fieldColumns =
+ "grid max-w-[1120px] items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(max(240px,calc((100%_-_48px)/3)),1fr))]"
+
+function FieldRow({ fields }: { fields: { id: string; render: ReactNode }[] }) {
+ return (
+
+
+ {fields.map(({ id, render }) => (
+
+ {render}
+
+ ))}
+
+
+ )
+}
+
const meta = { title: "Components/Search & Date/Date Picker", component: DatePicker, parameters: { layout: "padded" } } satisfies Meta
export default meta
type Story = StoryObj
@@ -119,18 +155,22 @@ export const All: Story = {
-
- |
- |
-
+ },
+ { id: "medium", render: },
+ ]}
+ />
-
- |
- |
- |
- |
-
+ },
+ { id: "controlled", render: },
+ { id: "disabled-dates", render: },
+ { id: "disabled", render: },
+ ]}
+ />
diff --git a/src/components/input.stories.tsx b/src/components/input.stories.tsx
index d4bd5dc..e86c3f7 100644
--- a/src/components/input.stories.tsx
+++ b/src/components/input.stories.tsx
@@ -1,7 +1,6 @@
import { useId, type ComponentProps, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite"
import { Input, type InputSize } from "@hm-design/ui"
-import { StateBoard } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Story structure follows ui_viz (implementations/web/storybook/Input.stories.jsx and InputSizes.stories.jsx):
@@ -99,6 +98,27 @@ function SizeComparison({ showSpec = true }: { showSpec?: boolean }) {
)
}
+// Representative page (2026-11-12): States and Validation use the Sizes layout. The state name sits in the field Label slot
+// (no repeated "Project name" and no separate group title); the input keeps a meaningful accessible name through aria-label,
+// which also contains the visible state name (Label in Name). Same columns as SizeComparison: up to 3 tracks of at least
+// 240px within 1120px, so Validation's 2 fields line up with the first 2 States fields and narrower widths drop to 2 / 1.
+const fieldColumns =
+ "grid max-w-[1120px] items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(max(240px,calc((100%_-_48px)/3)),1fr))]"
+
+function FieldRow({ fields }: { fields: { id: string; label: string; props: ComponentProps }[] }) {
+ return (
+
+
+ {fields.map(({ id, label, props }) => (
+
+
+
+ ))}
+
+
+ )
+}
+
const readonlyNote =
"Readonly는 입력 상자 없이 확정된 텍스트만 표시합니다. size와 무관하게 Large 기준 typography와 42px 정렬 높이를 유지하며, Disabled처럼 흐리게 표시하지 않습니다."
@@ -140,19 +160,19 @@ export const All: Story = {
- },
- { id: "disabled", label: "Disabled", children: },
- { id: "readonly", label: "Readonly", children: },
+
- },
- { id: "error-message", label: "Error Message", children: },
+
diff --git a/src/components/select.stories.tsx b/src/components/select.stories.tsx
index 47d9fe2..e559263 100644
--- a/src/components/select.stories.tsx
+++ b/src/components/select.stories.tsx
@@ -1,7 +1,6 @@
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, 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):
@@ -37,10 +36,11 @@ type SpecimenProps = {
defaultOpen?: boolean
disabled?: boolean
"aria-invalid"?: boolean
+ "aria-label"?: string
options?: ReadonlyArray<{ value: string; label: string }>
}
-function SelectSpecimen({ listSize, label = "Select", placeholder = "Choose a status", size, defaultValue, defaultOpen, disabled, "aria-invalid": ariaInvalid, options = statusOptions }: SpecimenProps) {
+function SelectSpecimen({ listSize, label = "Select", placeholder = "Choose a status", size, defaultValue, defaultOpen, disabled, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, 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"
@@ -50,7 +50,7 @@ function SelectSpecimen({ listSize, label = "Select", placeholder = "Choose a st
{label}