style(storybook): unify form element story layouts

- Button: Sizes | Variants side by side, States as Brand / Standard Solid /
  Standard Outline matrices (3 / 2 / 1 columns), 8px button gap; the
  Interact row stays only in the verification "Brand States" Story
- Input / Textarea / Select: state name in the field Label slot, repeated
  field Label and group titles removed, aria-label keeps field meaning
  with the visible state name; Select uses the Input field grid
- Combobox / Date Picker: Sizes / States use the same state-name Label
  and Input grid; Usage unchanged
- Toggle: top Variants / States in 2 columns, 8px toggle gap, 단독 toggle
  keeps its own width
- Component code, props, tokens and verification Stories unchanged;
  component docs updated to match
This commit is contained in:
shkim4 committed 2026-11-12 17:11:56 +09:00
1 parent e8c16f5292
commit a7b56ff23e
14 files changed
+343 -179

No files matched your search

+1 -1
View File
@@ -150,7 +150,7 @@ CVA 구조, `data-slot`, `asChild`(Radix `Slot`), `className` 확장, focus-visi
## 11. Storybook ## 11. Storybook
- 대표 Story `All`("Button")이 기본 sidebar의 유일한 Button 항목이다 → 1뎁스. - 대표 Story `All`("Button")이 기본 sidebar의 유일한 Button 항목이다 → 1뎁스.
- `All`의 구역: Sizes → Variants → States(2026-11-09 Overview 제거; 단독 Overview 버튼은 Sizes의 Brand Medium과 같아 통합). 기본 버튼·크기·허용 조합·Brand/Standard 상태를 보여준다. Red Solid와 Outline은 Variants와 Standard States에도 포함되며, 삭제·취소 세트와 Long Label은 verification Story에서 따로 확인한다. PC에서는 Sizes·Variants가 같은 3열(Brand / Standard Solid / Standard Outline)을 쓰고 States의 Solid·Outline 표는 좌우 2열이다. - `All`의 구역: Sizes → Variants → States(2026-11-09 Overview 제거; 단독 Overview 버튼은 Sizes의 Brand Medium과 같아 통합). 기본 버튼·크기·허용 조합·Brand/Standard 상태를 보여준다. Red Solid와 Outline은 Variants와 Standard States에도 포함되며, 삭제·취소 세트와 Long Label은 verification Story에서 따로 확인한다. 2026-11-12 비교형 레이아웃: 상단은 Sizes | Variants 두 섹션이 좌우 반반(미리보기 1080px 이상, 미만은 세로)이며, 각 섹션은 그룹당 한 행(왼쪽 고정 폭 그룹명 + 가로 버튼 행, 640px 이하에서는 그룹명이 위)이다. 하단 States는 Brand | Standard Solid | Standard Outline 3열(미리보기 1250px 이상, 890px 이상은 2열, 미만은 1열)이고, 각 열은 행=상태(Brand·Standard 모두 Default/Hover/Pressed/Disabled 4개), 열=Variants와 같은 순서의 컬러인 매트릭스다. 실제 포인터 상태를 확인하는 Interact 버튼은 verification Story "Brand States"에만 있다. 열은 매트릭스 폭 비율로 전체 폭을 나눠 쓰고 매트릭스는 열 시작에 놓인다. 버튼 사이 간격은 Sizes·Variants·States 모두 가로·세로 8px로 같다. 1열에서 표가 화면보다 넓으면 표 영역 안에서만 가로 스크롤한다. 레이아웃만 바꿨고 버튼 스타일·상태 표현은 그대로다.
- verification(`tags: ["verification"]`, 기본 sidebar 제외): `Roles`, `Sizes`, `States`("Brand States"), `StandardStates`, `LongLabel`, `KoreanLabels`, `IconTextNarrow`, `Destructive`, `Playground`(HM 전용), `Compatibility`(ghost/link, default/outline 별칭). Controls는 조합별 선택을 제한하지 않으므로 §6.1의 허용 조합을 직접 지킨다. Verification은 기술 점검용이며 운영 화면의 사용 위치·세트 규칙은 §5가 우선한다. - verification(`tags: ["verification"]`, 기본 sidebar 제외): `Roles`, `Sizes`, `States`("Brand States"), `StandardStates`, `LongLabel`, `KoreanLabels`, `IconTextNarrow`, `Destructive`, `Playground`(HM 전용), `Compatibility`(ghost/link, default/outline 별칭). Controls는 조합별 선택을 제한하지 않으므로 §6.1의 허용 조합을 직접 지킨다. Verification은 기술 점검용이며 운영 화면의 사용 위치·세트 규칙은 §5가 우선한다.
- 대표 경로/ID: `Components/Form/Button → All` (`components-form-button--all`), `Components/Overlay/Dialog → All` (`components-overlay-dialog--all`), `Components/Overlay/Alert Dialog → All` (`components-overlay-alert-dialog--all`), `Patterns/Data/Filter Bar → Filter Bar` (`patterns-data-filter-bar--all`). 삭제 세트 점검 ID는 `components-form-button--destructive`(verification)다. - 대표 경로/ID: `Components/Form/Button → All` (`components-form-button--all`), `Components/Overlay/Dialog → All` (`components-overlay-dialog--all`), `Components/Overlay/Alert Dialog → All` (`components-overlay-alert-dialog--all`), `Patterns/Data/Filter Bar → Filter Bar` (`patterns-data-filter-bar--all`). 삭제 세트 점검 ID는 `components-form-button--destructive`(verification)다.
- 운영 규칙은 `프로젝트-지침.md` §6.3, §7 참조. - 운영 규칙은 `프로젝트-지침.md` §6.3, §7 참조.
+1
View File
@@ -103,6 +103,7 @@ Popover: Portal, collision, Escape, 바깥 클릭 닫힘, trigger focus 복귀.
## 10. Storybook ## 10. Storybook
- 대표 Story `All`("Combobox"): Overview(Placeholder + search, Remix는 disabled option), Sizes(Medium, 같은 size의 Select / Combobox 비교), States(Selected(controlled), Disabled), Usage(Long option). Label은 Story 조합이다. 대표 화면은 선택값 표시(`value = …`)를 숨기며 숨김 `Controlled`는 유지한다. - 대표 Story `All`("Combobox"): Overview(Placeholder + search, Remix는 disabled option), Sizes(Medium, 같은 size의 Select / Combobox 비교), States(Selected(controlled), Disabled), Usage(Long option). Label은 Story 조합이다. 대표 화면은 선택값 표시(`value = …`)를 숨기며 숨김 `Controlled`는 유지한다.
- 2026-11-12 폼 요소 전시 통일(Input·Select 기준): Sizes(`Default`, `Medium`)·States(`Selected`, `Disabled`)는 상태명을 콤보박스 Label 자리에 한 번만 표시하고 반복되던 H3 상태명·필드 Label(`프레임워크`, `선택됨 (controlled)`, `비활성`)을 없앴다. 접근성 이름은 Trigger의 `aria-label="프레임워크 (상태명)"`(보이는 상태명 포함, 팝업·목록 이름에도 쓰임). 두 섹션은 Input과 같은 그리드(최대 1120px, 최대 3열·열 최소 240px, 좁으면 2→1열, 공통 들여쓰기)를 쓴다. Usage(Long option, Select / Combobox 비교)와 verification Story, Combobox·토큰·API는 변경 없다.
- verification: `Empty`, `Controlled`, `Long Option`. - verification: `Empty`, `Controlled`, `Long Option`.
## 11. 사용자 확정 결정 / 보류 ## 11. 사용자 확정 결정 / 보류
+1
View File
@@ -91,6 +91,7 @@ Popover: Portal, collision 처리, Escape, 바깥 클릭 닫힘, trigger focus
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Date Picker"): Empty, Selected(`2026.10.06`), 기본 형식(`2026.01.09`)/custom override, Controlled(대표 화면은 값 표시 숨김, 숨김 `Controlled`는 표시), Medium, Disabled dates(5일 이전·일요일), Disabled를 한 페이지에 둔다. Label은 Story 조합이며 `today`는 Calendar prop으로 2026-10-02에 고정. - 대표 Story `All`("Date Picker"): Empty, Selected(`2026.10.06`), 기본 형식(`2026.01.09`)/custom override, Controlled(대표 화면은 값 표시 숨김, 숨김 `Controlled`는 표시), Medium, Disabled dates(5일 이전·일요일), Disabled를 한 페이지에 둔다. Label은 Story 조합이며 `today`는 Calendar prop으로 2026-10-02에 고정.
- 2026-11-12 폼 요소 전시 통일(Input·Select 기준): Sizes(`Default`, `Medium`)·States(`Selected`, `Controlled`, `Disabled dates`, `Disabled`)는 상태명을 날짜 선택 Label 자리에 한 번만 표시하고 반복되던 H3 상태명·필드 Label을 없앴다. 접근성 이름은 Trigger의 `aria-label="시작일|마감일|날짜 (상태명)"`(보이는 상태명 포함). `Disabled dates`의 규칙 문구 "5일 이전과 일요일은 선택 불가"는 Trigger 아래 도움말(12/18/400 secondary)로 옮기고 `aria-describedby`로 연결했다. 두 섹션은 Input과 같은 그리드(최대 1120px, 최대 3열·열 최소 240px, 좁으면 2→1열, 공통 들여쓰기)를 쓴다. Usage(형식 2종)와 verification Story, DatePicker·토큰·API는 변경 없다. Storybook 사이드바 표시명은 `날짜 선택`(Story ID 변경 없음).
- verification: `Empty`, `Selected`, `Controlled`. - verification: `Empty`, `Selected`, `Controlled`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+1
View File
@@ -85,6 +85,7 @@ native `<input>` 동작 전체(`type`, `file:` 스타일 class, `placeholder`),
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Input"): Sizes(Small / Medium / Large 비교) → States(`focus`, `disabled`, `readonly`) → Validation(`error`, `error-message`). Focus는 `play` 함수로 실제 focus를 준다. 2026-10-22 Overview(`default`, size 생략 = Large)는 Sizes의 Large와 같아 대표 화면에서 뺐고, 크기별 수치 목록·설명은 숨김 `Sizes`("Compare Sizes")에 남겼다. size 생략 시 Large(§5)는 그대로다. - 대표 Story `All`("Input"): Sizes(Small / Medium / Large 비교) → States(`focus`, `disabled`, `readonly`) → Validation(`error`, `error-message`). Focus는 `play` 함수로 실제 focus를 준다. 2026-10-22 Overview(`default`, size 생략 = Large)는 Sizes의 Large와 같아 대표 화면에서 뺐고, 크기별 수치 목록·설명은 숨김 `Sizes`("Compare Sizes")에 남겼다. size 생략 시 Large(§5)는 그대로다.
- 2026-11-12 대표 화면 정리: States·Validation도 Sizes처럼 상태명(Focus / Disabled / Readonly / Error / Error Message)을 입력창 Label 자리에 한 번만 표시하고, 반복되던 `Project name` Label과 별도 그룹 제목은 없앴다. 입력 필드의 접근성 이름은 `aria-label="Project name (상태명)"`으로 유지한다(보이는 상태명 포함). 세 섹션은 같은 그리드(최대 1120px, 최대 3열·열 최소 240px, 좁으면 2→1열)를 쓰며 Validation 2개는 States 앞 두 열과 정렬된다. Story 전시만 바뀌었고 Input 컴포넌트·토큰·API·verification Story는 변경 없다.
- verification(`tags: ["verification"]`): `Default`, `Focus`, `Error`, `InvalidFocus`, `ErrorMessage`, `Required`, `Disabled`, `Readonly`, `Sizes`("Compare Sizes"), `Small`, `Medium`, `Large`, `Playground`(HM 전용). - verification(`tags: ["verification"]`): `Default`, `Focus`, `Error`, `InvalidFocus`, `ErrorMessage`, `Required`, `Disabled`, `Readonly`, `Sizes`("Compare Sizes"), `Small`, `Medium`, `Large`, `Playground`(HM 전용).
- Error Message·Required 표시는 Story 안의 조합 예시이며 Input 컴포넌트의 일부가 아니다. - Error Message·Required 표시는 Story 안의 조합 예시이며 Input 컴포넌트의 일부가 아니다.
- sidebar는 `All` 하나만 보이는 1뎁스. 운영 규칙은 지침 §6.3, §7 참조. - sidebar는 `All` 하나만 보이는 1뎁스. 운영 규칙은 지침 §6.3, §7 참조.
+1 -1
View File
@@ -98,7 +98,7 @@ Radix Root/Trigger/Portal/Content/Viewport/Item/ItemText 구조, 키보드 조
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Select"): Sizes(Medium / Large 비교, 각 Trigger로 실제 목록 열기) → States(`selected`, `disabled`, `disabled-placeholder`) → Validation(`invalid`, `invalid-disabled`; 2026-11-09 `Invalid / Focus`에서 제목 변경, Focus 예시가 없다). PC에서는 세 구역이 같은 3열 칸을 쓰고 Sizes·Validation은 첫 두 칸만 채운다. 2026-10-22 Overview의 `default`·로드 시 열린 `open` 예시는 대표 화면에서 뺐다. 열린 목록은 숨김 `Open`, 크기별 수치 목록은 숨김 `Sizes`("Compare Sizes")에서 확인한다. size 생략 시 Large(§5)는 그대로다. - 대표 Story `All`("Select"): Sizes(Medium / Large 비교, 각 Trigger로 실제 목록 열기) → States(`selected`, `disabled`, `disabled-placeholder`) → Validation(`invalid`, `invalid-disabled`; 2026-11-09 `Invalid / Focus`에서 제목 변경, Focus 예시가 없다). PC에서는 세 구역이 같은 3열 칸을 쓰고 Sizes·Validation은 첫 두 칸만 채운다. 2026-11-12 폼 요소 전시 통일(Input 기준): States·Validation은 상태명을 Select Label 자리에 한 번만 표시하고 반복되던 `Status` Label과 별도 그룹 제목을 없앴다. 접근성 이름은 Trigger의 `aria-label="Status (상태명)"`(보이는 상태명 포함). 세 구역은 Input과 같은 그리드(최대 1120px, 최대 3열·열 최소 240px, 좁으면 2→1열)를 써서 Input과 같은 필드 폭을 가진다. Select·토큰·API·verification Story는 변경 없다. 2026-10-22 Overview의 `default`·로드 시 열린 `open` 예시는 대표 화면에서 뺐다. 열린 목록은 숨김 `Open`, 크기별 수치 목록은 숨김 `Sizes`("Compare Sizes")에서 확인한다. size 생략 시 Large(§5)는 그대로다.
- verification(`tags: ["verification"]`): `Default`, `Open`, `Selected`, `Disabled`, `Sizes`("Compare Sizes"), `Medium`, `Large`, `LongContent`, `Scroll`, `Playground`(HM 전용). - verification(`tags: ["verification"]`): `Default`, `Open`, `Selected`, `Disabled`, `Sizes`("Compare Sizes"), `Medium`, `Large`, `LongContent`, `Scroll`, `Playground`(HM 전용).
- `Disabled` Story 구성: Trigger 비활성 상태들(placeholder 포함). - `Disabled` Story 구성: Trigger 비활성 상태들(placeholder 포함).
- `InvalidFocus` verification Story: 앞/뒤 버튼 사이의 Invalid Trigger와 Disabled Invalid Trigger. Tab / Shift+Tab, Enter / Space, Escape 및 focus return을 실제 키보드로 확인한다. - `InvalidFocus` verification Story: 앞/뒤 버튼 사이의 Invalid Trigger와 Disabled Invalid Trigger. Tab / Shift+Tab, Enter / Space, Escape 및 focus return을 실제 키보드로 확인한다.
+1
View File
@@ -87,6 +87,7 @@ native `<textarea>` 동작 전체, `data-slot`, `cn(..., className)`, `transitio
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Textarea"): States `default`, `focus`, `readonly`, `disabled`와 Validation `error`, `error-message`. `multiline`·`resize` 예시는 대표 페이지에서 빼고 숨김 verification Story `Multiline`·`ResizeNone`으로 옮겼다(2026-11-09). Focus는 `play` 함수로 실제 focus를 준다. Hover는 Default와 같아(TEXTAREA-05) 2026-10-22 대표 화면의 중복 `hover` 예시를 뺐다. 실제 포인터 확인은 Default 예시에서 한다. - 대표 Story `All`("Textarea"): States `default`, `focus`, `readonly`, `disabled`와 Validation `error`, `error-message`. `multiline`·`resize` 예시는 대표 페이지에서 빼고 숨김 verification Story `Multiline`·`ResizeNone`으로 옮겼다(2026-11-09). Focus는 `play` 함수로 실제 focus를 준다. Hover는 Default와 같아(TEXTAREA-05) 2026-10-22 대표 화면의 중복 `hover` 예시를 뺐다. 실제 포인터 확인은 Default 예시에서 한다.
- 2026-11-12 폼 요소 전시 통일(Input 기준): States·Validation은 상태명(Default / Focus / Readonly / Disabled / Error / Error Message)을 Textarea Label 자리에 한 번만 표시하고, 반복되던 `Description` Label과 별도 그룹 제목은 없앴다. 접근성 이름은 `aria-label="Description (상태명)"`(보이는 상태명 포함). 그리드는 기존 PC 4열(열 최소 220px, 좁으면 축소)을 유지하며 공통 들여쓰기(`.sb-page-indent`)를 쓴다. Textarea·토큰·API·verification Story는 변경 없다.
- verification(`tags: ["verification"]`): `Default`, `Focus`, `Error`, `InvalidFocus`, `ErrorMessage`, `Required`, `Multiline`("Multiline · Wrap"), `ResizeNone`("Resize · none"), `Readonly`, `Disabled`, `LongContent`, `Playground`(HM 전용). `Multiline`·`ResizeNone`은 2026-11-09에 대표 Story의 Usage 구역에서 옮긴 것이다. - verification(`tags: ["verification"]`): `Default`, `Focus`, `Error`, `InvalidFocus`, `ErrorMessage`, `Required`, `Multiline`("Multiline · Wrap"), `ResizeNone`("Resize · none"), `Readonly`, `Disabled`, `LongContent`, `Playground`(HM 전용). `Multiline`·`ResizeNone`은 2026-11-09에 대표 Story의 Usage 구역에서 옮긴 것이다.
- Error Message·Required 표시는 Story 안의 조합 예시이며 Textarea 컴포넌트의 일부가 아니다. - Error Message·Required 표시는 Story 안의 조합 예시이며 Textarea 컴포넌트의 일부가 아니다.
+1
View File
@@ -70,6 +70,7 @@ Radix 구조·`aria-pressed`·`data-state`·Enter/Space·controlled/uncontrolled
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Toggle"): Variants(Off/On, default·outline)와 States(Disabled off/on), 단독 · 연결 그룹(단독 Toggle, `spacing=0` ToggleGroup 단일·다중 선택), Sizes(2026-11-09 Overview를 Variants로 바꾸고 PC 3열 기준으로 재배치). 2026-10-22 Icon-only·icon + text 예시는 숨김 `IconOnly`로만 확인하고, Controlled(`pressed` 표시)·className 확장 예시는 대표 화면에서 제거했다. `pressed` / `onPressedChange`와 `className` 확장 API(§5)는 그대로다. - 대표 Story `All`("Toggle"): Variants(Off/On, default·outline)와 States(Disabled off/on), 단독 · 연결 그룹(단독 Toggle, `spacing=0` ToggleGroup 단일·다중 선택), Sizes(2026-11-09 Overview를 Variants로 바꾸고 PC 3열 기준으로 재배치). 2026-10-22 Icon-only·icon + text 예시는 숨김 `IconOnly`로만 확인하고, Controlled(`pressed` 표시)·className 확장 예시는 대표 화면에서 제거했다. `pressed` / `onPressedChange`와 `className` 확장 API(§5)는 그대로다.
- 2026-11-12 폼 요소 전시 통일: 나란히 놓인 토글 간격을 Button Story와 같은 8px로 맞췄다(Off/On·Disabled·Sizes·숨김 `IconOnly`, 기존 12px). `단독` 예시는 열 너비로 늘어나지 않고 Toggle 자체 너비를 유지한다. 상단 Variants·States는 기존 3열 중 두 칸 배치 대신 2열로 바꿔 전체 폭을 각 행의 한 줄 폭 비율(324:234, 최소 max-content)로 나눈다. 미리보기 폭 1000px 이상(브라우저 1280px + LNB 180px 포함)에서 Variants 4개가 한 줄로 유지되고, 그 미만은 1열로 쌓여 자연스럽게 줄바꿈한다. 이 때문에 States 시작 위치는 아래 `단독 · 연결 그룹` 3열과 맞추지 않는다. Toggle·토큰·API는 변경 없다.
- verification: `States`, `Icon Only`, `Disabled`. - verification: `States`, `Icon Only`, `Disabled`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+114 -55
View File
@@ -1,9 +1,8 @@
import { useState } from "react" import { useState, type ReactNode } from "react"
import { DownloadIcon } from "lucide-react" import { DownloadIcon } from "lucide-react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Button, type ButtonAppearance } from "@hm-design/ui" import { Button, type ButtonAppearance } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board" import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Story structure follows ui_viz (implementations/web/storybook/Button.stories.jsx): // Story structure follows ui_viz (implementations/web/storybook/Button.stories.jsx):
// one representative "Button" Story shows kinds, sizes and states on a single screen; the same sections also exist // one representative "Button" Story shows kinds, sizes and states on a single screen; the same sections also exist
@@ -126,8 +125,8 @@ function SizesBoard() {
) )
} }
// `showNote={false}` keeps the representative page UI-only; the verification Story keeps the note and the Activated readout. // Verification "Brand States" (note and Activated readout). The representative page shows Brand states in StateMatrix.
function BrandStates({ showNote = true }: { showNote?: boolean }) { function BrandStates() {
const [count, setCount] = useState(0) const [count, setCount] = useState(0)
return ( return (
<div className={stack}> <div className={stack}>
@@ -140,12 +139,10 @@ function BrandStates({ showNote = true }: { showNote?: boolean }) {
Disabled Disabled
</Button> </Button>
</div> </div>
{showNote ? <output aria-live="polite">Activated: {count}</output> : null} <output aria-live="polite">Activated: {count}</output>
{showNote ? (
<p className={note}> <p className={note}>
Hover와 Pressed는 Storybook이 고정한 상태입니다. Interact 위에서 실제 Hover·Pressed·포인터 이탈을 확인합니다. Disabled는 실행되지 않습니다. Hover와 Pressed는 Storybook이 고정한 상태입니다. Interact 위에서 실제 Hover·Pressed·포인터 이탈을 확인합니다. Disabled는 실행되지 않습니다.
</p> </p>
) : null}
</div> </div>
) )
} }
@@ -157,15 +154,13 @@ const standardStates = [
["Disabled", { disabled: true }], ["Disabled", { disabled: true }],
] as const ] as const
// Without `title` the table sits under a StateBoard label (representative page) and drops the Role column: // Verification "Standard States" table (rows = roles). The representative page uses StateMatrix (rows = states) instead.
// 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 ( return (
<section className="min-w-0"> <section className="min-w-0">
{title ? <h3 className={heading}>{title}</h3> : null} {title ? <h3 className={heading}>{title}</h3> : null}
<div className={`${title ? "mt-3 " : ""}min-w-0 max-w-full overflow-x-auto`}> <div className={`${title ? "mt-3 " : ""}min-w-0 max-w-full overflow-x-auto`}>
<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"}`}> <table className="w-full border-collapse [&_th]:text-left [&_td]:p-3 [&_th]:p-3">
<thead> <thead>
<tr> <tr>
{title ? <th scope="col">Role</th> : null} {title ? <th scope="col">Role</th> : null}
@@ -231,43 +226,92 @@ function DestructiveBoard() {
) )
} }
// Page and section spacing come from the common compact Story page (story-page.css); this Story keeps no override. // Representative-page layout (2026-11-12 compact comparison). Page and section spacing come from the common Story page
// 8px gap so the three Brand sizes stay on one line in the narrowest (Brand) column, same as the Variants rows. // (story-page.css); only the arrangement below is local. Buttons are rendered with their public API and never restyled.
const compactRow = "flex flex-wrap items-center gap-2" // Group label: the StateBoard H3 style (14/20/500, secondary). `data-sb-board` gives the block the common one-step section indent.
// Equal two columns over the whole content width. StateBoard's own auto-fill grid left an empty third column const groupLabel = "m-0 text-sm font-medium text-muted-foreground"
// (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. // Sizes / Variants (top, side by side): one row per group, label on a fixed 128px left column so every group title and first
function CompactRoleRow({ appearance }: { appearance: (typeof appearances)[number][0] }) { // button line up. At 640px and below the label moves above its buttons. Buttons keep the page-wide 8px gap, across and between
// rows (same as the States matrices), and wrap only when too narrow.
function GroupRows({ groups }: { groups: { id: string; label: string; children: ReactNode }[] }) {
return ( return (
<div className="flex flex-wrap items-center gap-2"> <div data-sb-board="" className="grid gap-2 max-sm:gap-5">
{rolesFor(appearance).map(([variant, label]) => ( {groups.map(({ id, label, children }) => (
<Button key={variant} variant={variant} appearance={appearance}> <section key={id} data-overview-state={id} aria-label={label} className="flex min-w-0 items-center gap-4 max-sm:flex-col max-sm:items-start max-sm:gap-2">
{label} <h3 className={`${groupLabel} w-32 shrink-0 max-sm:w-auto`}>{label}</h3>
</Button> <div className="flex min-w-0 flex-wrap items-center gap-2">{children}</div>
</section>
))} ))}
</div> </div>
) )
} }
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 ( return (
<div className={compactRow}> <div
{sizes.map(([size, label]) => ( data-sb-board=""
<Button key={size} appearance={appearance} size={size}> className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 min-[890px]:grid-cols-[minmax(max-content,301fr)_minmax(max-content,420fr)] min-[1250px]:grid-cols-[minmax(max-content,301fr)_minmax(max-content,420fr)_minmax(max-content,338fr)]"
{label} >
</Button> {groups.map(({ id, label, children }) => (
<section key={id} data-overview-state={id} aria-label={label} className="grid min-w-0 content-start gap-2">
<h3 className={groupLabel}>{label}</h3>
{children}
</section>
))} ))}
</div> </div>
) )
} }
// 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<string, unknown>])[] }) {
const colors = rolesFor(appearance)
return (
<div className="min-w-0 max-w-full overflow-x-auto">
<table className="border-collapse">
<thead>
<tr>
<th scope="col" className={`${groupLabel} pb-1 pl-0 pr-1 text-left`}>
<span className="sr-only">State</span>
</th>
{colors.map(([variant, color]) => (
<th scope="col" key={variant} className={`${groupLabel} px-1 pb-1 text-center last:pr-0`}>
{color}
</th>
))}
</tr>
</thead>
<tbody>
{states.map(([state, props]) => (
<tr key={state}>
<th scope="row" className={`${groupLabel} py-1 pl-0 pr-1 text-left whitespace-nowrap`}>
{state}
</th>
{colors.map(([variant, color]) => (
<td key={variant} className="p-1 text-center last:pr-0">
<Button variant={variant} appearance={appearance} {...props}>
{color}
</Button>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
// 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. // 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. // 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 = { export const All: Story = {
@@ -276,29 +320,44 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Button" /> <StoryPageHeader title="Button" />
{/* 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. */}
<StorySectionGrid className="grid-cols-1 items-start min-[1080px]:grid-cols-[minmax(max-content,1fr)_minmax(max-content,1fr)]">
<StorySection title="Sizes"> <StorySection title="Sizes">
{/* Same columns as Variants below (Brand / Standard Solid / Standard Outline): Brand and Standard Solid sizes line up with <GroupRows
their Variants columns and the third column stays empty. */} groups={appearances.slice(0, 2).map(([appearance, label]) => ({
<div className={`grid gap-6 ${columnsAuto(260)} min-[960px]:[grid-template-columns:minmax(0,234fr)_minmax(0,353fr)_minmax(0,271fr)]`}> id: `sizes-${appearance}`,
<SingleColumnBoard states={[{ id: "sizes-brand", label: "Brand", children: <CompactSizeRow appearance="brand" /> }]} /> label,
<SingleColumnBoard states={[{ id: "sizes-solid", label: "Standard Solid", children: <CompactSizeRow appearance="solid" /> }]} /> children: sizes.map(([size, sizeLabel]) => (
</div> <Button key={size} appearance={appearance} size={size}>
{sizeLabel}
</Button>
)),
}))}
/>
</StorySection> </StorySection>
<StorySection title="Variants"> <StorySection title="Variants">
<div className={`grid gap-6 ${columnsAuto(260)} min-[960px]:[grid-template-columns:minmax(0,234fr)_minmax(0,353fr)_minmax(0,271fr)]`}> <GroupRows
{appearances.map(([appearance, label]) => ( groups={appearances.map(([appearance, label]) => ({
<SingleColumnBoard key={appearance} states={[{ id: `roles-${appearance}`, label, children: <CompactRoleRow appearance={appearance} /> }]} /> id: `roles-${appearance}`,
))} label,
</div> children: rolesFor(appearance).map(([variant, color]) => (
<Button key={variant} variant={variant} appearance={appearance}>
{color}
</Button>
)),
}))}
/>
</StorySection> </StorySection>
</StorySectionGrid>
<StorySection title="States"> <StorySection title="States">
<div className="grid gap-6"> <GroupColumns
<SingleColumnBoard states={[{ id: "brand-states", label: "Brand", wide: true, children: <BrandStates showNote={false} /> }]} /> groups={appearances.map(([appearance, label]) => ({
<div className={twoColumns}> id: `${appearance}-states`,
<SingleColumnBoard states={[{ id: "standard-solid-states", label: "Standard Solid", children: <StandardStateTable appearance="solid" compact /> }]} /> label,
<SingleColumnBoard states={[{ id: "standard-outline-states", label: "Standard Outline", children: <StandardStateTable appearance="outline" compact /> }]} /> children: <StateMatrix appearance={appearance} states={standardStates} />,
</div> }))}
</div> />
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
+51 -22
View File
@@ -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). // 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 ( return (
<div className="grid max-w-xs gap-2"> <div className={fill ? "grid gap-2" : "grid max-w-xs gap-2"}>
<label htmlFor={id} className={`justify-self-start text-sm font-medium ${disabled ? "text-muted-foreground opacity-60" : "text-foreground"}`}> <label htmlFor={id} className={`justify-self-start text-sm font-medium ${disabled ? "text-muted-foreground opacity-60" : "text-foreground"}`}>
{label} {label}
</label> </label>
@@ -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 ( return (
<Field label="프레임워크" id="cb-empty"> <Field label={state ?? "프레임워크"} id="cb-empty" fill={state !== undefined}>
<Combobox id="cb-empty" options={frameworks} placeholder="프레임워크 선택" searchPlaceholder="프레임워크 검색..." /> <Combobox id="cb-empty" aria-label={stateName(state, "프레임워크")} options={frameworks} placeholder="프레임워크 선택" searchPlaceholder="프레임워크 검색..." />
</Field> </Field>
) )
} }
// `showValue={false}` hides the value readout on the representative page; the verification Stories keep it. // `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") const [value, setValue] = useState("nuxt")
return ( return (
<div className="grid gap-2"> <div className="grid gap-2">
<Field label="선택됨 (controlled)" id="cb-controlled"> <Field label={state ?? "선택됨 (controlled)"} id="cb-controlled" fill={state !== undefined}>
<Combobox id="cb-controlled" controlled options={frameworks} value={value} onValueChange={setValue} /> <Combobox id="cb-controlled" aria-label={stateName(state, "프레임워크")} controlled options={frameworks} value={value} onValueChange={setValue} />
</Field> </Field>
{showValue ? <span className="text-sm text-muted-foreground" data-testid="controlled">{`value = ${value}`}</span> : null} {showValue ? <span className="text-sm text-muted-foreground" data-testid="controlled">{`value = ${value}`}</span> : null}
</div> </div>
) )
} }
function Medium() { function Medium({ state }: { state?: string }) {
return ( return (
<Field label="Medium" id="cb-medium"> <Field label={state ?? "Medium"} id="cb-medium" fill={state !== undefined}>
<Combobox id="cb-medium" size="medium" options={frameworks} defaultValue="astro" /> <Combobox id="cb-medium" aria-label={stateName(state, "프레임워크")} size="medium" options={frameworks} defaultValue="astro" />
</Field> </Field>
) )
} }
@@ -70,10 +76,10 @@ function LongLabel() {
) )
} }
function Disabled() { function Disabled({ state }: { state?: string }) {
return ( return (
<Field label="비활성" id="cb-disabled" disabled> <Field label={state ?? "비활성"} id="cb-disabled" disabled fill={state !== undefined}>
<Combobox id="cb-disabled" options={frameworks} defaultValue="next" disabled /> <Combobox id="cb-disabled" aria-label={stateName(state, "프레임워크")} options={frameworks} defaultValue="next" disabled />
</Field> </Field>
) )
} }
@@ -111,6 +117,25 @@ const Cell = ({ id, label, wide, children }: { id: string; label: string; wide?:
</div> </div>
) )
// 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 (
<div className="sb-page-indent">
<div className={fieldColumns}>
{fields.map(({ id, render }) => (
<div key={id} className="min-w-0" data-overview-state={id}>
{render}
</div>
))}
</div>
</div>
)
}
// No `component`: Combobox has a required `options` prop, so Stories without args would not type-check. // 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 const meta = { title: "Components/Search & Date/Combobox", parameters: { layout: "padded" } } satisfies Meta
export default meta export default meta
@@ -124,16 +149,20 @@ export const All: Story = {
<StoryPage> <StoryPage>
<StoryPageHeader title="Combobox" /> <StoryPageHeader title="Combobox" />
<StorySection title="Sizes"> <StorySection title="Sizes">
<Grid> <FieldRow
<Cell id="empty" label="Default"><Empty /></Cell> fields={[
<Cell id="medium" label="Medium"><Medium /></Cell> { id: "empty", render: <Empty state="Default" /> },
</Grid> { id: "medium", render: <Medium state="Medium" /> },
]}
/>
</StorySection> </StorySection>
<StorySection title="States"> <StorySection title="States">
<Grid> <FieldRow
<Cell id="controlled" label="Selected"><Controlled showValue={false} /></Cell> fields={[
<Cell id="disabled" label="Disabled"><Disabled /></Cell> { id: "controlled", render: <Controlled showValue={false} state="Selected" /> },
</Grid> { id: "disabled", render: <Disabled state="Disabled" /> },
]}
/>
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<Grid> <Grid>
+69 -29
View File
@@ -13,9 +13,10 @@ const today = new Date(2026, 9, 2)
const calendarProps = { today, startMonth: new Date(2026, 0), endMonth: new Date(2027, 11) } 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). // 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 ( return (
<div className="grid max-w-xs gap-2"> <div className={fill ? "grid gap-2" : "grid max-w-xs gap-2"}>
<label htmlFor={id} className={`justify-self-start text-sm font-medium ${disabled ? "text-muted-foreground opacity-60" : "text-foreground"}`}> <label htmlFor={id} className={`justify-self-start text-sm font-medium ${disabled ? "text-muted-foreground opacity-60" : "text-foreground"}`}>
{label} {label}
</label> </label>
@@ -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 ( return (
<Field label="시작일" id="dp-empty"> <Field label={state ?? "시작일"} id="dp-empty" fill={state !== undefined}>
<DatePicker id="dp-empty" calendarProps={calendarProps} /> <DatePicker id="dp-empty" aria-label={stateName(state, "시작일")} calendarProps={calendarProps} />
</Field> </Field>
) )
} }
function Selected() { function Selected({ state }: { state?: string }) {
return ( return (
<Field label="시작일" id="dp-selected"> <Field label={state ?? "시작일"} id="dp-selected" fill={state !== undefined}>
<DatePicker id="dp-selected" defaultValue={new Date(2026, 9, 6)} calendarProps={calendarProps} /> <DatePicker id="dp-selected" aria-label={stateName(state, "시작일")} defaultValue={new Date(2026, 9, 6)} calendarProps={calendarProps} />
</Field> </Field>
) )
} }
@@ -57,41 +63,52 @@ function CustomFormat() {
} }
// `showValue={false}` hides the value readout on the representative page; the verification Stories keep it. // `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<Date | undefined>() const [date, setDate] = useState<Date | undefined>()
return ( return (
<div className="grid gap-2"> <div className="grid gap-2">
<Field label="마감일 (controlled)" id="dp-controlled"> <Field label={state ?? "마감일 (controlled)"} id="dp-controlled" fill={state !== undefined}>
<DatePicker id="dp-controlled" controlled value={date} onValueChange={setDate} calendarProps={calendarProps} /> <DatePicker id="dp-controlled" aria-label={stateName(state, "마감일")} controlled value={date} onValueChange={setDate} calendarProps={calendarProps} />
</Field> </Field>
{showValue ? <span className="text-sm text-muted-foreground" data-testid="controlled">{`value = ${date ? date.toLocaleDateString("ko-KR") : "undefined"}`}</span> : null} {showValue ? <span className="text-sm text-muted-foreground" data-testid="controlled">{`value = ${date ? date.toLocaleDateString("ko-KR") : "undefined"}`}</span> : null}
</div> </div>
) )
} }
function Medium() { function Medium({ state }: { state?: string }) {
return ( return (
<Field label="Medium" id="dp-medium"> <Field label={state ?? "Medium"} id="dp-medium" fill={state !== undefined}>
<DatePicker id="dp-medium" size="medium" defaultValue={new Date(2026, 9, 15)} calendarProps={calendarProps} /> <DatePicker id="dp-medium" aria-label={stateName(state, "날짜")} size="medium" defaultValue={new Date(2026, 9, 15)} calendarProps={calendarProps} />
</Field> </Field>
) )
} }
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 ( return (
<Field label="5일 이전과 일요일은 선택 불가" id="dp-disabled-dates"> <Field label={state ?? disabledDatesNote} id="dp-disabled-dates" fill={state !== undefined}>
<DatePicker <DatePicker
id="dp-disabled-dates" id="dp-disabled-dates"
aria-label={stateName(state, "날짜")}
aria-describedby={state ? "dp-disabled-dates-message" : undefined}
calendarProps={{ ...calendarProps, disabled: [{ before: new Date(2026, 9, 5) }, { dayOfWeek: [0] }] }} calendarProps={{ ...calendarProps, disabled: [{ before: new Date(2026, 9, 5) }, { dayOfWeek: [0] }] }}
/> />
{state ? (
<p id="dp-disabled-dates-message" className="m-0 text-xs leading-[18px] text-muted-foreground">
{disabledDatesNote}
</p>
) : null}
</Field> </Field>
) )
} }
function Disabled() { function Disabled({ state }: { state?: string }) {
return ( return (
<Field label="비활성" id="dp-disabled" disabled> <Field label={state ?? "비활성"} id="dp-disabled" disabled fill={state !== undefined}>
<DatePicker id="dp-disabled" disabled defaultValue={new Date(2026, 9, 15)} /> <DatePicker id="dp-disabled" aria-label={stateName(state, "날짜")} disabled defaultValue={new Date(2026, 9, 15)} />
</Field> </Field>
) )
} }
@@ -107,6 +124,25 @@ const Cell = ({ id, label, children }: { id: string; label: string; children: Re
</div> </div>
) )
// 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 (
<div className="sb-page-indent">
<div className={fieldColumns}>
{fields.map(({ id, render }) => (
<div key={id} className="min-w-0" data-overview-state={id}>
{render}
</div>
))}
</div>
</div>
)
}
const meta = { title: "Components/Search & Date/Date Picker", component: DatePicker, parameters: { layout: "padded" } } satisfies Meta<typeof DatePicker> const meta = { title: "Components/Search & Date/Date Picker", component: DatePicker, parameters: { layout: "padded" } } satisfies Meta<typeof DatePicker>
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
@@ -119,18 +155,22 @@ export const All: Story = {
<StoryPage> <StoryPage>
<StoryPageHeader title="Date Picker" /> <StoryPageHeader title="Date Picker" />
<StorySection title="Sizes"> <StorySection title="Sizes">
<Grid> <FieldRow
<Cell id="empty" label="Default"><Empty /></Cell> fields={[
<Cell id="medium" label="Medium"><Medium /></Cell> { id: "empty", render: <Empty state="Default" /> },
</Grid> { id: "medium", render: <Medium state="Medium" /> },
]}
/>
</StorySection> </StorySection>
<StorySection title="States"> <StorySection title="States">
<Grid> <FieldRow
<Cell id="selected" label="Selected"><Selected /></Cell> fields={[
<Cell id="controlled" label="Controlled"><Controlled showValue={false} /></Cell> { id: "selected", render: <Selected state="Selected" /> },
<Cell id="disabled-dates" label="Disabled dates"><DisabledDates /></Cell> { id: "controlled", render: <Controlled showValue={false} state="Controlled" /> },
<Cell id="disabled" label="Disabled"><Disabled /></Cell> { id: "disabled-dates", render: <DisabledDates state="Disabled dates" /> },
</Grid> { id: "disabled", render: <Disabled state="Disabled" /> },
]}
/>
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<Grid> <Grid>
+30 -10
View File
@@ -1,7 +1,6 @@
import { useId, type ComponentProps, type ReactNode } from "react" import { useId, type ComponentProps, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Input, type InputSize } from "@hm-design/ui" import { Input, type InputSize } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Story structure follows ui_viz (implementations/web/storybook/Input.stories.jsx and InputSizes.stories.jsx): // 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<typeof Specimen> }[] }) {
return (
<div className="sb-page-indent">
<div className={fieldColumns}>
{fields.map(({ id, label, props }) => (
<div key={id} className="min-w-0" data-overview-state={id}>
<Specimen label={label} aria-label={`Project name (${label})`} {...props} />
</div>
))}
</div>
</div>
)
}
const readonlyNote = const readonlyNote =
"Readonly는 입력 상자 없이 확정된 텍스트만 표시합니다. size와 무관하게 Large 기준 typography와 42px 정렬 높이를 유지하며, Disabled처럼 흐리게 표시하지 않습니다." "Readonly는 입력 상자 없이 확정된 텍스트만 표시합니다. size와 무관하게 Large 기준 typography와 42px 정렬 높이를 유지하며, Disabled처럼 흐리게 표시하지 않습니다."
@@ -140,19 +160,19 @@ export const All: Story = {
</div> </div>
</StorySection> </StorySection>
<StorySection title="States"> <StorySection title="States">
<StateBoard <FieldRow
states={[ fields={[
{ id: "focus", label: "Focus", children: <Specimen size="medium" placeholder={placeholder} /> }, { id: "focus", label: "Focus", props: { size: "medium", placeholder } },
{ id: "disabled", label: "Disabled", children: <Specimen disabled defaultValue="Unavailable project" /> }, { id: "disabled", label: "Disabled", props: { disabled: true, defaultValue: "Unavailable project" } },
{ id: "readonly", label: "Readonly", children: <Specimen readOnly value="Confirmed project" /> }, { id: "readonly", label: "Readonly", props: { readOnly: true, value: "Confirmed project" } },
]} ]}
/> />
</StorySection> </StorySection>
<StorySection title="Validation"> <StorySection title="Validation">
<StateBoard <FieldRow
states={[ fields={[
{ id: "error", label: "Error", children: <Specimen size="medium" error defaultValue="Invalid value" /> }, { id: "error", label: "Error", props: { size: "medium", error: true, defaultValue: "Invalid value" } },
{ id: "error-message", label: "Error Message", children: <Specimen size="medium" placeholder={placeholder} error message="프로젝트명을 입력하세요." /> }, { id: "error-message", label: "Error Message", props: { size: "medium", placeholder, error: true, message: "프로젝트명을 입력하세요." } },
]} ]}
/> />
</StorySection> </StorySection>
+37 -28
View File
@@ -1,7 +1,6 @@
import { useId, type ReactNode } from "react" import { useId, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, type SelectSize } from "@hm-design/ui" 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" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Story structure follows ui_viz (implementations/web/storybook/Select.stories.jsx and SelectSizes.stories.jsx): // Story structure follows ui_viz (implementations/web/storybook/Select.stories.jsx and SelectSizes.stories.jsx):
@@ -37,10 +36,11 @@ type SpecimenProps = {
defaultOpen?: boolean defaultOpen?: boolean
disabled?: boolean disabled?: boolean
"aria-invalid"?: boolean "aria-invalid"?: boolean
"aria-label"?: string
options?: ReadonlyArray<{ value: string; 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() const id = useId()
// A Label next to an enabled Select uses the default text color; disabled keeps the secondary color at 0.6. // 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" const labelTone = disabled ? "text-muted-foreground opacity-60" : "text-foreground"
@@ -50,7 +50,7 @@ function SelectSpecimen({ listSize, label = "Select", placeholder = "Choose a st
{label} {label}
</label> </label>
<Select defaultValue={defaultValue} defaultOpen={defaultOpen} disabled={disabled}> <Select defaultValue={defaultValue} defaultOpen={defaultOpen} disabled={disabled}>
<SelectTrigger id={id} size={size} aria-invalid={ariaInvalid} className="w-full"> <SelectTrigger id={id} size={size} aria-invalid={ariaInvalid} aria-label={ariaLabel} className="w-full">
<SelectValue placeholder={placeholder} /> <SelectValue placeholder={placeholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent {...(listSize ? { "data-size": listSize } : {})}> <SelectContent {...(listSize ? { "data-size": listSize } : {})}>
@@ -111,23 +111,34 @@ function SizeComparison({ showSpec = true }: { showSpec?: boolean }) {
) )
} }
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), shrinking to // Representative-page layout (Storybook presentation only). 2026-11-12, same rule and columns as Input: the state name sits in
// fit below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so StateBoard's // the field Label slot (no repeated "Status" under a separate group title); the trigger keeps a meaningful accessible name
// auto-fill grid cannot leave an empty track. Full class names are listed so Tailwind can see them. // through aria-label, which also contains the visible state name (Label in Name). Sizes, States and Validation share up to 3
const specimenColumns = { // tracks of at least 240px within 1120px, so every Select has the Input field width and narrower widths drop to 2 / 1 columns.
3: "min-[1000px]:grid-cols-3", // The grid takes the common section indent (.sb-page-indent).
} as const 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 SpecimenGrid({ states, columns }: { states: BoardState[]; columns: keyof typeof specimenColumns }) { function FieldGrid({ children }: { children: ReactNode }) {
return ( return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] ${specimenColumns[columns]}`}> <div className="sb-page-indent">
{states.map((state) => ( <div className={fieldColumns}>{children}</div>
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div> </div>
) )
} }
function FieldRow({ fields }: { fields: { id: string; label: string; props: SpecimenProps }[] }) {
return (
<FieldGrid>
{fields.map(({ id, label, props }) => (
<div key={id} className="min-w-0" data-overview-state={id}>
<SelectSpecimen size="medium" label={label} aria-label={`Status (${label})`} {...props} />
</div>
))}
</FieldGrid>
)
}
function DisabledStates() { function DisabledStates() {
return ( return (
<div className="grid max-w-[480px] gap-6"> <div className="grid max-w-[480px] gap-6">
@@ -177,30 +188,28 @@ export const All: Story = {
<StoryPageHeader title="Select" /> <StoryPageHeader title="Select" />
<StorySection title="Sizes"> <StorySection title="Sizes">
{/* Same 3-column track as States / Validation: Medium and Large take the first two columns, the third stays empty. {/* Same 3-column track as States / Validation: Medium and Large take the first two columns, the third stays empty.
The grid takes the same common indent as the group boards (.sb-page-indent); Medium / Large stay field Labels. */} Medium / Large stay field Labels. */}
<div className="grid items-start gap-6 sb-page-indent [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3"> <FieldGrid>
{sizes.map((spec) => ( {sizes.map((spec) => (
<SizeCard key={spec.size} spec={spec} showSpec={false} /> <SizeCard key={spec.size} spec={spec} showSpec={false} />
))} ))}
</div> </FieldGrid>
</StorySection> </StorySection>
<StorySection title="States"> <StorySection title="States">
<SpecimenGrid <FieldRow
columns={3} fields={[
states={[ { id: "selected", label: "Selected", props: { defaultValue: "active" } },
{ id: "selected", label: "Selected", children: <SelectSpecimen size="medium" label="Status" defaultValue="active" /> }, { id: "disabled", label: "Disabled", props: { defaultValue: "active", disabled: true } },
{ id: "disabled", label: "Disabled", children: <SelectSpecimen size="medium" label="Status" defaultValue="active" disabled /> }, { id: "disabled-placeholder", label: "Disabled · Placeholder", props: { disabled: true } },
{ id: "disabled-placeholder", label: "Disabled · Placeholder", children: <SelectSpecimen size="medium" label="Status" disabled /> },
]} ]}
/> />
</StorySection> </StorySection>
<StorySection title="Validation"> <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. */} {/* Same 3-column track as States: the two specimens take the first two columns (same Select width) and the third stays empty. */}
<SpecimenGrid <FieldRow
columns={3} fields={[
states={[ { id: "invalid", label: "Invalid", props: { "aria-invalid": true, defaultValue: "active" } },
{ id: "invalid", label: "Invalid", children: <SelectSpecimen size="medium" aria-invalid label="Status" defaultValue="active" /> }, { id: "invalid-disabled", label: "Invalid · Disabled", props: { "aria-invalid": true, disabled: true } },
{ id: "invalid-disabled", label: "Invalid · Disabled", children: <SelectSpecimen size="medium" aria-invalid disabled label="Status" /> },
]} ]}
/> />
</StorySection> </StorySection>
+23 -25
View File
@@ -1,7 +1,6 @@
import { useId, type ComponentProps, type ReactNode } from "react" import { useId, type ComponentProps, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Textarea } from "@hm-design/ui" import { Textarea } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" 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 // Story structure follows Input: one representative "Textarea" Story shows every state on one page; the same specimens
@@ -49,20 +48,21 @@ function Specimen({
) )
} }
// Representative-page layout (Storybook presentation only). StateBoard's own auto-fill grid cannot give equal columns that // Representative-page layout (Storybook presentation only). 2026-11-12, same rule as Input: the state name sits in the field
// fill a cell, so each specimen sits in its own single-column StateBoard (min larger than any column) inside this grid. // Label slot (no repeated "Description" under a separate group title); the textarea keeps a meaningful accessible name through
// Full class names are listed so Tailwind can see them. // aria-label, which also contains the visible state name (Label in Name). PC (iframe width 1000px and up): 4 equal columns;
const specimenColumns = { // below that the columns shrink to fit (220px minimum, down to 1). The grid takes the common section indent (.sb-page-indent).
4: "min-[1000px]:grid-cols-4", function FieldGrid({ fields }: { fields: { id: string; label: string; props: ComponentProps<typeof Specimen> }[] }) {
} as const
function SpecimenGrid({ states, columns }: { states: BoardState[]; columns: keyof typeof specimenColumns }) {
return ( return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr))] ${specimenColumns[columns]}`}> <div className="sb-page-indent">
{states.map((state) => ( <div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr))] min-[1000px]:grid-cols-4">
<StateBoard key={state.id} min={10000} states={[state]} /> {fields.map(({ id, label, props }) => (
<div key={id} className="min-w-0" data-overview-state={id}>
<Specimen label={label} aria-label={`Description (${label})`} {...props} />
</div>
))} ))}
</div> </div>
</div>
) )
} }
@@ -106,23 +106,21 @@ export const All: Story = {
{/* PC (iframe width 1000px and up): States in 4 equal columns, Validation's 2 specimens on the same column width. {/* 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. */} Below that the columns shrink to fit (220px minimum, down to 1). Multiline and Resize live in their own hidden Stories. */}
<StorySection title="States"> <StorySection title="States">
<SpecimenGrid <FieldGrid
columns={4} fields={[
states={[ { id: "default", label: "Default", props: { placeholder } },
{ id: "default", label: "Default", children: <Specimen placeholder={placeholder} /> }, { id: "focus", label: "Focus", props: { placeholder } },
{ id: "focus", label: "Focus", children: <Specimen placeholder={placeholder} /> }, { id: "readonly", label: "Readonly", props: { readOnly: true, defaultValue: "Confirmed description" } },
{ id: "readonly", label: "Readonly", children: <Specimen readOnly defaultValue="Confirmed description" /> }, { id: "disabled", label: "Disabled", props: { disabled: true, defaultValue: "Unavailable description" } },
{ id: "disabled", label: "Disabled", children: <Specimen disabled defaultValue="Unavailable description" /> },
]} ]}
/> />
</StorySection> </StorySection>
<StorySection title="Validation"> <StorySection title="Validation">
{/* Same 4-column track as States, so the two specimens keep the States width instead of stretching across the page. */} {/* Same 4-column track as States, so the two fields keep the States width instead of stretching across the page. */}
<SpecimenGrid <FieldGrid
columns={4} fields={[
states={[ { id: "error", label: "Error", props: { error: true, defaultValue: "Invalid value" } },
{ id: "error", label: "Error", children: <Specimen error defaultValue="Invalid value" /> }, { id: "error-message", label: "Error Message", props: { error: true, message: "설명을 입력하세요." } },
{ id: "error-message", label: "Error Message", children: <Specimen error message="설명을 입력하세요." /> },
]} ]}
/> />
</StorySection> </StorySection>
+12 -8
View File
@@ -6,10 +6,11 @@ import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/co
// Toggle is a persistent pressed-state control. The representative Story shows each state with real, clickable toggles; the same // Toggle is a persistent pressed-state control. The representative Story shows each state with real, clickable toggles; the same
// specimens also exist as Stories tagged `verification` (hidden from the sidebar). Icon-only toggles carry an `aria-label`. // specimens also exist as Stories tagged `verification` (hidden from the sidebar). Icon-only toggles carry an `aria-label`.
// 2026-11-12: toggles side by side keep the Button Story's 8px gap (was 12px).
function OffOn() { function OffOn() {
return ( return (
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-2">
<Toggle>Off</Toggle> <Toggle>Off</Toggle>
<Toggle defaultPressed>On</Toggle> <Toggle defaultPressed>On</Toggle>
<Toggle variant="outline">Outline off</Toggle> <Toggle variant="outline">Outline off</Toggle>
@@ -22,7 +23,7 @@ function OffOn() {
function WithIcon() { function WithIcon() {
return ( return (
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-2">
<Toggle aria-label="굵게"> <Toggle aria-label="굵게">
<BoldIcon /> <BoldIcon />
</Toggle> </Toggle>
@@ -43,7 +44,7 @@ function Sizes() {
// Same common indent as the group boards of the page (.sb-page-indent). // Same common indent as the group boards of the page (.sb-page-indent).
<div className="grid items-start gap-6 sb-page-indent [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3"> <div className="grid items-start gap-6 sb-page-indent [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
{(["sm", "default", "lg"] as const).map((size) => ( {(["sm", "default", "lg"] as const).map((size) => (
<div key={size} data-toggle-size={size} className="flex flex-wrap items-center gap-3"> <div key={size} data-toggle-size={size} className="flex flex-wrap items-center gap-2">
<Toggle size={size} variant="outline" aria-label={`${size} icon-only`}><StarIcon /></Toggle> <Toggle size={size} variant="outline" aria-label={`${size} icon-only`}><StarIcon /></Toggle>
<Toggle size={size} variant="outline"><StarIcon />즐겨찾기</Toggle> <Toggle size={size} variant="outline"><StarIcon />즐겨찾기</Toggle>
<Toggle size={size} variant="outline">{size}</Toggle> <Toggle size={size} variant="outline">{size}</Toggle>
@@ -55,7 +56,7 @@ function Sizes() {
function Disabled() { function Disabled() {
return ( return (
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-2">
<Toggle disabled>Disabled off</Toggle> <Toggle disabled>Disabled off</Toggle>
<Toggle disabled defaultPressed> <Toggle disabled defaultPressed>
Disabled on Disabled on
@@ -90,15 +91,17 @@ function SpecimenGrid({ states }: { states: BoardState[] }) {
} }
// Representative Story: the common Story page; the sections group the same specimens. // 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 // Overview is renamed Variants. On PC (iframe width 1000px and up) Variants and States share the whole width in 2 columns,
// columns as the rows below, and the third column stays empty; below that they stack. // in proportion to the width their one-line row needs (324 / 234px incl. the 16px indent), never narrower than that row
// (max-content), so the 4 Variants toggles stay on one line (2026-11-12; they wrapped in the former 3 equal columns at a
// 1100px preview). Below that the sections stack and the toggle rows wrap naturally.
export const All: Story = { export const All: Story = {
name: "Toggle", name: "Toggle",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Toggle" /> <StoryPageHeader title="Toggle" />
<StorySectionGrid className="grid-cols-1 min-[1000px]:grid-cols-3"> <StorySectionGrid className="grid-cols-1 min-[1000px]:grid-cols-[minmax(max-content,324fr)_minmax(max-content,234fr)]">
<StorySection title="Variants"> <StorySection title="Variants">
<StateBoard min={10000} states={[specimen("offon")]} /> <StateBoard min={10000} states={[specimen("offon")]} />
</StorySection> </StorySection>
@@ -109,7 +112,8 @@ export const All: Story = {
<StorySection title="단독 · 연결 그룹"> <StorySection title="단독 · 연결 그룹">
<SpecimenGrid <SpecimenGrid
states={[ states={[
{ id: "single-toggle", label: "단독", children: <Toggle variant="outline">알림</Toggle> }, // The flex wrapper keeps the Toggle at its own width; as a direct grid child of the board it stretched to the column.
{ id: "single-toggle", label: "단독", children: <div className="flex"><Toggle variant="outline">알림</Toggle></div> },
{ 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> }, { 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> },
{ id: "group-multiple", label: "연결 그룹 · 다중 선택", children: <ToggleGroup type="multiple" variant="outline" defaultValue={["bold"]} aria-label="글자 스타일"><ToggleGroupItem value="bold">굵게</ToggleGroupItem><ToggleGroupItem value="italic">기울임</ToggleGroupItem></ToggleGroup> }, { id: "group-multiple", label: "연결 그룹 · 다중 선택", children: <ToggleGroup type="multiple" variant="outline" defaultValue={["bold"]} aria-label="글자 스타일"><ToggleGroupItem value="bold">굵게</ToggleGroupItem><ToggleGroupItem value="italic">기울임</ToggleGroupItem></ToggleGroup> },
]} ]}