style(storybook): group form, selection and date picker story pages

- Button page shows Toggle; new Selection Controls page compares Checkbox,
  Radio Group and Switch states (specimens moved to *-examples.tsx)
- Date Picker page includes Calendar; Combobox, Input and Slider layouts
  (Korean slider labels) tidied
- Absorbed representative Stories keep their IDs as verification Stories
- Component docs updated to the new representative pages
This commit is contained in:
shkim4 committed 2026-11-13 17:57:44 +09:00
1 parent fe9c115cf9
commit 71d504407d
28 files changed
+1235 -605

No files matched your search

+3 -2
View File
@@ -11,7 +11,7 @@
| 구현 상태 | HM 스타일 적용 완료 (`hm-*` class 없음) | | 구현 상태 | HM 스타일 적용 완료 (`hm-*` class 없음) |
| 기준일 | 2026-10-08 (Role × Appearance 계약·현재 Story 대조) | | 기준일 | 2026-10-08 (Role × Appearance 계약·현재 Story 대조) |
| 기반 | shadcn/ui Button (CVA, Radix `Slot`) | | 기반 | shadcn/ui Button (CVA, Radix `Slot`) |
| 대표 Story | `Components/Form/Button` → `All` (이름 "Button") | | 대표 Story | `Components/Form/Button` → `All` (이름 "Button", 사이드바 `버튼`, Toggle Button 예제 포함) |
| 보류 | Brand label Text Border 두께(EXPERIMENTAL, 미적용), Focus(ui_viz TBD) | | 보류 | Brand label Text Border 두께(EXPERIMENTAL, 미적용), Focus(ui_viz TBD) |
## 2. 적용 근거 ## 2. 적용 근거
@@ -150,7 +150,8 @@ 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에서 따로 확인한다. 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열에서 표가 화면보다 넓으면 표 영역 안에서만 가로 스크롤한다. 레이아웃만 바꿨고 버튼 스타일·상태 표현은 그대로다. - 2026-11-13 현재 `All`의 구역: Brand | Standard Solid | Standard Outline 세 섹션(H2, 공통 제목 → 콘텐츠 12px). 각 섹션 안에 Sizes(H3 → 8px → Small / Medium / Large)와 States(H3 → 8px → 행 Default/Hover/Pressed/Disabled × 해당 Treatment 컬러 매트릭스)가 24px 간격으로 온다. 페이지 단위 Sizes·Variants·States 제목은 없다(컬러 조합은 매트릭스 열과 verification `Roles`에서 확인). 미리보기 1400px 이상에서 같은 너비 3열, 미만은 Brand → Solid → Outline 1열(섹션 간격 28px)이다. 2026-11-13: 매트릭스가 자기 열보다 넓으면(390px에서 420px Standard Solid) 세 Treatment가 함께 상태별 세로 배치(상태 이름 12/16/400 보조색 → 4px → 버튼 줄, 버튼 간격 8px·줄바꿈, 상태 간 12px)로 바뀌어 표 내부·페이지 가로 스크롤이 없다. 버튼 크기·상태·색상과 props는 그대로다. 표 하단에는 독립 H2 "Toggle Button" 한 섹션(./toggle-examples, 기존 Toggle 대표 화면과 같은 예시)이 버튼 열과 36px 간격으로 오며, 그룹 H3 Variants / States(Off / On, Disabled) · Groups(단독, 단일 선택, 다중 선택 — 연결 그룹 예시, 이 페이지에서는 "연결 그룹 · " 접두어 생략) · Sizes(Small, Medium, Large — 2026-11-13 Default 라벨을 Button과 같은 Medium으로, 글자 토글은 prop 값 대신 예시 문구 "알림") 3개를 버튼과 같이 미리보기 1400px 이상에서 같은 너비 3열(Brand / Standard Solid / Standard Outline 열과 같은 위치), 미만은 1열(그룹 간 24px)로 놓는다(2026-11-13). 그룹 라벨은 버튼과 같은 규칙(들여쓰기 0, H3 → 8px)이고, 각 행은 States 매트릭스와 같은 형식(왼쪽 라벨 14/20/500 보조색 → 8px → 컨트롤, 행 간 8px, 그룹 안 컨트롤 시작선 정렬)이다. 버튼 매트릭스가 세로 배치로 바뀌면 Toggle 행도 함께 세로 배치(라벨 12/16/400 → 4px → 컨트롤, 행 간 12px)로 바뀐다. 버튼 스타일·상태 표현·매트릭스 구조는 그대로다. 아래 2026-11-12 설명은 이전 배치 기록이다.
- `All`의 구역(2026-11-12 기록): 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 -1
View File
@@ -10,7 +10,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Calendar = **react-day-picker v10** | | 기반 | shadcn/ui Calendar = **react-day-picker v10** |
| 대표 Story | `Components/Search & Date/Calendar` → `All` (이름 "Calendar") | | 대표 Story | `Components/Search & Date/Date Picker` → `All`의 "Calendar · …" 섹션(사이드바 `날짜 선택`). 기존 `Components/Search & Date/Calendar` → `All`(`components-search-date-calendar--all`)은 URL 직접 접근용으로 유지(사이드바 숨김) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
+2 -2
View File
@@ -11,7 +11,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Checkbox (Radix `Checkbox`) | | 기반 | shadcn/ui Checkbox (Radix `Checkbox`) |
| 대표 Story | `Components/Form/Checkbox` → `All` (이름 "Checkbox") | | 대표 Story | `Components/Form/Selection Controls` → `All`의 상태 매트릭스 Checkbox 열(Default·Hover·Focus·Disabled·Validation)과 Variants의 Indeterminate(사이드바 `선택 컨트롤`, `components-form-selection-controls--all`). 기존 `Components/Form/Checkbox` → `All`(`components-form-checkbox--all`)은 URL 직접 접근용으로 유지(사이드바 숨김) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
@@ -100,7 +100,7 @@ Radix 구조·ARIA(`role=checkbox`, `aria-checked`)·키보드, `data-slot`, `cn
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Checkbox"): States `unchecked`, `checked`, `indeterminate`, `hover`, `focus`, `disabled`(PC 3열 × 2행)와 Validation `error`. Overview·Usage 구역은 2026-11-09에 없앴고 `long`(Long Label) 예시는 숨김 verification Story `LongLabel`로 옮겼다. 옵션 Label은 Story에서만 `--hm-label-text`를 `--foreground`로 덮어 기본 텍스트 색으로 보이며(공용 Label은 그대로, 비활성 Checkbox 옆 Label은 `peer-disabled`로 보조색 유지), 예시 제목은 보조색이다. Focus는 `play`로 실제 focus. `indeterminate`는 controlled 예시를 겸해 클릭하면 checked가 된다(Radix, 2026-10-22 별도 `interactive` 구역 통합). `hover`는 Unchecked·Checked를 Storybook 전용 `data-sb-hover`(`.storybook/pseudo-states.ts`)로 고정해 보여 준다. Checkbox API가 아니며 소비 코드에 붙이지 않는다. - 숨김 기존 `All`("Checkbox", URL 직접 접근용; 사이드바 대표는 Selection Controls, §표 참고): States `unchecked`, `checked`, `indeterminate`, `hover`, `focus`, `disabled`(PC 3열 × 2행)와 Validation `error`. Overview·Usage 구역은 2026-11-09에 없앴고 `long`(Long Label) 예시는 숨김 verification Story `LongLabel`로 옮겼다. 옵션 Label은 Story에서만 `--hm-label-text`를 `--foreground`로 덮어 기본 텍스트 색으로 보이며(공용 Label은 그대로, 비활성 Checkbox 옆 Label은 `peer-disabled`로 보조색 유지), 예시 제목은 보조색이다. Focus는 `play`로 실제 focus. `indeterminate`는 controlled 예시를 겸해 클릭하면 checked가 된다(Radix, 2026-10-22 별도 `interactive` 구역 통합). `hover`는 Unchecked·Checked를 Storybook 전용 `data-sb-hover`(`.storybook/pseudo-states.ts`)로 고정해 보여 준다. Checkbox API가 아니며 소비 코드에 붙이지 않는다.
- verification: `Unchecked`, `Checked`, `Indeterminate`, `Disabled`, `Error`, `LongLabel`("Long Label"), `Playground`. - verification: `Unchecked`, `Checked`, `Indeterminate`, `Disabled`, `Error`, `LongLabel`("Long Label"), `Playground`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+2 -2
View File
@@ -102,8 +102,8 @@ 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"): Sizes(Large·Medium), States(Selected(controlled), Disabled), Usage(Long option, 같은 size의 Select / Combobox 비교). Overview 구역은 없다. 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는 변경 없다. - 2026-11-12 폼 요소 전시 통일(Input·Select 기준): Sizes(`Default`, `Medium`; 2026-11-13 기본 크기가 실제 large라 `Default` 라벨을 `Large`로 정정)·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. 사용자 확정 결정 / 보류
+2 -2
View File
@@ -12,7 +12,7 @@
| 구현 상태 | 구현 완료 (single-date, 현재 HM 규격) | | 구현 상태 | 구현 완료 (single-date, 현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | `Popover`(public API) + `Calendar`(public API) 조합. shadcn 공식 Date Picker는 예시 조합이다 | | 기반 | `Popover`(public API) + `Calendar`(public API) 조합. shadcn 공식 Date Picker는 예시 조합이다 |
| 대표 Story | `Components/Search & Date/Date Picker` → `All` (이름 "Date Picker") | | 대표 Story | `Components/Search & Date/Date Picker` → `All` (이름 "Date Picker", 사이드바 `날짜 선택`, Calendar 예제 포함) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
@@ -91,7 +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 변경 없음). - 2026-11-12 폼 요소 전시 통일(Input·Select 기준): Sizes(`Default`, `Medium`; 2026-11-13 기본 크기가 실제 large라 `Default` 라벨을 `Large`로 정정, Usage Default format의 Label에서 H3와 반복되던 "기본 형식 · " 제거)·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
@@ -86,6 +86,7 @@ native `<input>` 동작 전체(`type`, `file:` 스타일 class, `placeholder`),
- 대표 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는 변경 없다. - 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는 변경 없다.
- 2026-11-13: States의 Disabled를 Focus·Validation과 같은 Medium(34px)으로 맞췄다. Readonly는 컴포넌트 규칙(텍스트만, size와 무관하게 Large 수치)이라 42px 그대로다. Input 기본값·크기 정책은 변경 없음.
- 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 참조.
+2 -2
View File
@@ -9,7 +9,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Radio Group (Radix `RadioGroup`) | | 기반 | shadcn/ui Radio Group (Radix `RadioGroup`) |
| 대표 Story | `Components/Form/Radio Group` → `All` (이름 "Radio Group") | | 대표 Story | `Components/Form/Selection Controls` → `All`의 상태 매트릭스 Radio Group 열(Default·Hover·Focus·Disabled 항목/그룹·Validation)과 Variants의 Vertical·Horizontal(사이드바 `선택 컨트롤`, `components-form-selection-controls--all`). 기존 `Components/Form/Radio Group` → `All`(`components-form-radio-group--all`)은 URL 직접 접근용으로 유지(사이드바 숨김) Focus 칸의 링은 Storybook 전용 `data-sb-focus`(`.storybook/pseudo-states.ts`, 2026-11-13)로 고정해 보여 주며 실제 Tab 포커스 동작은 같다. |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
@@ -57,7 +57,7 @@ shadcn `size-4 border-input text-primary shadow-xs dark:` class 제거 → theme
## 9. Storybook ## 9. Storybook
대표 Story `All`: Variants(`vertical`, `horizontal`; 2026-11-09 Overview 제거로 Vertical이 Variants 첫 항목), States(`hover`, `focus`, `disabled-item`, `disabled-group`), Validation(`error`). Focus는 `play`로 실제 focus. `hover`는 첫 항목을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 RadioGroupItem API가 아니다. verification: `Vertical`, `Horizontal`, `Disabled`, `Error`. 숨김 기존 `All`(URL 직접 접근용; 사이드바 대표는 Selection Controls, §표 참고): Variants(`vertical`, `horizontal`; 2026-11-09 Overview 제거로 Vertical이 Variants 첫 항목), States(`hover`, `focus`, `disabled-item`, `disabled-group`), Validation(`error`). Focus는 `play`로 실제 focus. `hover`는 첫 항목을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 RadioGroupItem API가 아니다. verification: `Vertical`, `Horizontal`, `Disabled`, `Error`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+2 -2
View File
@@ -9,7 +9,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Switch (Radix `Switch`) | | 기반 | shadcn/ui Switch (Radix `Switch`) |
| 대표 Story | `Components/Form/Switch` → `All` (이름 "Switch") | | 대표 Story | `Components/Form/Selection Controls` → `All`의 상태 매트릭스 Switch 열(Default·Hover·Focus·Disabled, Validation 없음)(사이드바 `선택 컨트롤`, `components-form-selection-controls--all`). 기존 `Components/Form/Switch` → `All`(`components-form-switch--all`)은 URL 직접 접근용으로 유지(사이드바 숨김) Focus 칸의 링은 Storybook 전용 `data-sb-focus`(`.storybook/pseudo-states.ts`, 2026-11-13)로 고정해 보여 주며 실제 Tab 포커스 동작은 같다. |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
@@ -56,7 +56,7 @@ shadcn의 `h-[1.15rem] w-8`, `border`, `shadow-xs`, `dark:` class 제거 → the
## 9. Storybook ## 9. Storybook
대표 Story `All`: States 한 구역에 `off`, `on`, `hover`, `focus`, `disabled`(2026-11-09 Overview 제거, PC 5열; 2026-10-22 `interactive` 구역 제거, 모든 예시가 실제 클릭으로 토글). `hover`는 Off·On을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 Switch API가 아니다. Focus는 `play`로 실제 focus. verification: `Off`, `On`, `Disabled`. 숨김 기존 `All`(URL 직접 접근용; 사이드바 대표는 Selection Controls, §표 참고): States 한 구역에 `off`, `on`, `hover`, `focus`, `disabled`(2026-11-09 Overview 제거, PC 5열; 2026-10-22 `interactive` 구역 제거, 모든 예시가 실제 클릭으로 토글). `hover`는 Off·On을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 Switch API가 아니다. Focus는 `play`로 실제 focus. verification: `Off`, `On`, `Disabled`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+3 -3
View File
@@ -9,7 +9,7 @@
| 구현 상태 | 구현 완료 (현재 HM 규격) | | 구현 상태 | 구현 완료 (현재 HM 규격) |
| 기준일 | 2026-10-02 | | 기준일 | 2026-10-02 |
| 기반 | shadcn/ui Toggle (Radix `Toggle`) | | 기반 | shadcn/ui Toggle (Radix `Toggle`) |
| 대표 Story | `Components/Form/Toggle` → `All` (이름 "Toggle") | | 대표 Story | `Components/Form/Button` → `All`의 "Toggle Button" 섹션(H3 Variants / States · Groups · Sizes, 사이드바 `버튼`). 기존 `Components/Form/Toggle` → `All`(`components-form-toggle--all`)은 URL 직접 접근용으로 유지(사이드바 숨김) |
| 보류 | 아래 §11 | | 보류 | 아래 §11 |
## 2. 적용 근거 ## 2. 적용 근거
@@ -69,9 +69,9 @@ 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 — 2026-11-13 행 라벨과 반복되던 "Disabled" 글자 제거), 단독 · 연결 그룹(단독 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는 변경 없다. - 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: `Toggle`(기존 `All`, 사이드바 숨김), `States`, `Icon Only`, `Disabled`, `Content Nodes`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+116 -71
View File
@@ -1,8 +1,9 @@
import { useState, type ReactNode } from "react" import { createContext, useCallback, useContext, useId, useLayoutEffect, useMemo, useRef, 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 { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page"
import { ToggleSections } from "./toggle-examples"
// 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
@@ -231,52 +232,96 @@ function DestructiveBoard() {
// Group label: the StateBoard H3 style (14/20/500, secondary). `data-sb-board` gives the block the common one-step section indent. // Group label: the StateBoard H3 style (14/20/500, secondary). `data-sb-board` gives the block the common one-step section indent.
const groupLabel = "m-0 text-sm font-medium text-muted-foreground" const groupLabel = "m-0 text-sm font-medium text-muted-foreground"
// Sizes / Variants (top, side by side): one row per group, label on a fixed 128px left column so every group title and first // 2026-11-13 (user request): one column per treatment, Sizes and States inside it. Each block is its H3 label (StateBoard H3
// button line up. At 640px and below the label moves above its buttons. Buttons keep the page-wide 8px gap, across and between // style), 8px, then the specimens; Sizes and States are 24px apart. The blocks stay on the section edge (no data-sb-board
// rows (same as the States matrices), and wrap only when too narrow. // indent) so the 420px Standard Solid matrix fits an equal third at the 1400px breakpoint without an inner scroll.
function GroupRows({ groups }: { groups: { id: string; label: string; children: ReactNode }[] }) { function GroupBlock({ id, label, children }: { id: string; label: string; children: ReactNode }) {
return ( return (
<div data-sb-board="" className="grid gap-2 max-sm:gap-5"> <section data-overview-state={id} aria-label={label} className="grid min-w-0 content-start gap-2">
{groups.map(({ id, label, children }) => (
<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">
<h3 className={`${groupLabel} w-32 shrink-0 max-sm:w-auto`}>{label}</h3>
<div className="flex min-w-0 flex-wrap items-center gap-2">{children}</div>
</section>
))}
</div>
)
}
// 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 (
<div
data-sb-board=""
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)]"
>
{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> <h3 className={groupLabel}>{label}</h3>
{children} {children}
</section> </section>
))}
</div>
) )
} }
// States: one matrix per treatment, rows = Default / Hover / Pressed / Disabled (standardStates, the same 4 rows for Brand; // 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. // the live Interact button stays on the verification "Brand States"), columns = that treatment's colors in the roles order.
// Columns follow their buttons (auto table layout). 4px cell padding gives the same 8px gap as Sizes / Variants between // Columns follow their buttons (auto table layout). 4px cell padding gives the same 8px gap as Sizes between buttons, rows,
// buttons, rows, the state label and the color header; the outer edge keeps none (pl-0 / last:pr-0) so the three matrices // the state label and the color header; the outer edge keeps none (pl-0 / last:pr-0). Buttons sit centered under their color
// fit one row at 1440px. Buttons sit centered under their color and are never resized. // and are never resized. Matrix widths: Brand 301px, Standard Solid 420px, Standard Outline 338px.
// 2026-11-13 (user request, no inner scroll on narrow screens): when a matrix is wider than its column (the 420px Standard Solid
// matrix in a 358px column at 390px), every treatment switches together to the stacked layout: one row per state, its label
// above the buttons, which wrap with the same 8px gap. Each matrix measures its own table (the auto-layout width it needs) and
// reports whether it fits; MatrixFitProvider stacks all of them while any does not, so the three treatments keep one layout.
// Storybook presentation only: the buttons, their props and states are unchanged.
// Stacked layout state names (2026-11-13, user request): one step below the H3 "States" label (14/20/500) so the two levels
// read apart — 12/16/400, same secondary color. Only these names change; the common H2 / H3 styles are untouched.
const stackedStateLabel = "text-xs font-normal text-muted-foreground"
const MatrixFit = createContext<{ stacked: boolean; report: (id: string, overflow: boolean) => void }>({ stacked: false, report: () => {} })
function MatrixFitProvider({ children }: { children: ReactNode }) {
const [overflowing, setOverflowing] = useState<ReadonlySet<string>>(() => new Set())
const report = useCallback((id: string, overflow: boolean) => {
setOverflowing((current) => {
if (current.has(id) === overflow) return current
const next = new Set(current)
if (overflow) next.add(id)
else next.delete(id)
return next
})
}, [])
const value = useMemo(() => ({ stacked: overflowing.size > 0, report }), [overflowing, report])
return <MatrixFit.Provider value={value}>{children}</MatrixFit.Provider>
}
function StateMatrix({ appearance, states }: { appearance: ButtonAppearance; states: readonly (readonly [string, Record<string, unknown>])[] }) { function StateMatrix({ appearance, states }: { appearance: ButtonAppearance; states: readonly (readonly [string, Record<string, unknown>])[] }) {
const colors = rolesFor(appearance) const colors = rolesFor(appearance)
const { stacked, report } = useContext(MatrixFit)
const frame = useRef<HTMLDivElement>(null)
// The table's own width, kept while the stacked layout is shown (no table to measure then).
const tableWidth = useRef(0)
const labelId = useId()
useLayoutEffect(() => {
const element = frame.current
if (!element) return
const check = () => {
const table = element.querySelector("table")
if (table) tableWidth.current = table.offsetWidth
report(appearance, tableWidth.current > element.clientWidth)
}
const observer = new ResizeObserver(check)
observer.observe(element)
check()
// The labels may get wider once the web font has loaded.
void document.fonts?.ready.then(check)
return () => {
observer.disconnect()
report(appearance, false)
}
}, [appearance, report])
if (stacked) {
return ( return (
<div className="min-w-0 max-w-full overflow-x-auto"> <div ref={frame} data-state-matrix="stacked" className="grid min-w-0 max-w-full gap-3">
{states.map(([state, props], index) => (
<div key={state} role="group" aria-labelledby={`${labelId}-${index}`} className="grid gap-1">
<span id={`${labelId}-${index}`} className={stackedStateLabel}>
{state}
</span>
<div className="flex min-w-0 flex-wrap items-center gap-2">
{colors.map(([variant, color]) => (
<Button key={variant} variant={variant} appearance={appearance} {...props}>
{color}
</Button>
))}
</div>
</div>
))}
</div>
)
}
return (
<div ref={frame} data-state-matrix="table" className="min-w-0 max-w-full overflow-x-auto">
<table className="border-collapse"> <table className="border-collapse">
<thead> <thead>
<tr> <tr>
@@ -311,8 +356,16 @@ function StateMatrix({ appearance, states }: { appearance: ButtonAppearance; sta
) )
} }
// Representative Story: the common Story page (Sizes → Variants → States). // The Toggle Button section of the representative page, in the same stacked / matrix form as the state matrices.
function ToggleButtonSection() {
const { stacked } = useContext(MatrixFit)
return <ToggleSections titlePrefix="Toggle Button" idPrefix="toggle-" flat stacked={stacked} />
}
// Representative Story: the common Story page, one section per treatment (Brand | Standard Solid | Standard Outline).
// 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.
// 2026-11-13 (user request): the Variants section and the page-level Sizes / States titles are removed; each treatment section
// (H2, common title → content 12px) holds its own Sizes and States (H3). The verification "Roles" Story keeps the role rows.
// 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 = {
name: "Button", name: "Button",
@@ -320,45 +373,37 @@ 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-width columns from a 1400px preview (the 420px Standard Solid matrix fits a third: 3 × 420 + 2 × 24 plus the page
equal halves (never narrower than their rows). Narrower previews stack the two sections. */} margins and the scrollbar); below that Brand → Standard Solid → Standard Outline in one column, 28px apart (the
<StorySectionGrid className="grid-cols-1 items-start min-[1080px]:grid-cols-[minmax(max-content,1fr)_minmax(max-content,1fr)]"> common section gap of StorySectionGrid). */}
<StorySection title="Sizes"> <MatrixFitProvider>
<GroupRows <StorySectionGrid className="grid-cols-1 items-start min-[1400px]:grid-cols-3">
groups={appearances.slice(0, 2).map(([appearance, label]) => ({ {appearances.map(([appearance, label]) => (
id: `sizes-${appearance}`, <StorySection key={appearance} id={`${appearance}-group`} title={label}>
label, <div className="grid min-w-0 gap-6">
children: sizes.map(([size, sizeLabel]) => ( <GroupBlock id={`sizes-${appearance}`} label="Sizes">
<div className="flex min-w-0 flex-wrap items-center gap-2">
{sizes.map(([size, sizeLabel]) => (
<Button key={size} appearance={appearance} size={size}> <Button key={size} appearance={appearance} size={size}>
{sizeLabel} {sizeLabel}
</Button> </Button>
)), ))}
}))} </div>
/> </GroupBlock>
</StorySection> <GroupBlock id={`${appearance}-states`} label="States">
<StorySection title="Variants"> <StateMatrix appearance={appearance} states={standardStates} />
<GroupRows </GroupBlock>
groups={appearances.map(([appearance, label]) => ({ </div>
id: `roles-${appearance}`,
label,
children: rolesFor(appearance).map(([variant, color]) => (
<Button key={variant} variant={variant} appearance={appearance}>
{color}
</Button>
)),
}))}
/>
</StorySection> </StorySection>
))}
</StorySectionGrid> </StorySectionGrid>
<StorySection title="States"> {/* 2026-11-13 (user request): Toggle Button joins the Button page ("버튼") after the treatment columns, from
<GroupColumns ./toggle-examples unchanged; the Toggle Stories keep their IDs and direct URLs but are hidden from the sidebar.
groups={appearances.map(([appearance, label]) => ({ `flat`: one "Toggle Button" H2 section, 36px below the columns above, with 3 equal group columns (Variants / States,
id: `${appearance}-states`, Groups, Sizes) from 1400px like the treatments, each row in the States matrix form (label left, 8px rhythm).
label, Inside MatrixFitProvider so its rows switch to the stacked form together with the matrices. */}
children: <StateMatrix appearance={appearance} states={standardStates} />, <ToggleButtonSection />
}))} </MatrixFitProvider>
/>
</StorySection>
</StoryPage> </StoryPage>
), ),
} }
+82
View File
@@ -0,0 +1,82 @@
import { useState, type ReactNode } from "react"
import type { DateRange } from "react-day-picker"
import { Calendar } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StorySection } from "@/components/story-page"
// Shared Calendar specimens (Storybook only). Used by the Calendar Stories (verification) and by the Date Picker representative
// page, which shows Date Picker and Calendar under one sidebar item ("날짜 선택").
// Each mode uses real, clickable calendars. `today` is pinned to 2026-10-02 so the Story looks the same on any day; this uses
// react-day-picker's own `today` prop, not an HM state. Korean is the Calendar default locale.
const today = new Date(2026, 9, 2)
const month = new Date(2026, 9, 1)
const box = (node: ReactNode) => <div className="rounded-md border bg-card w-fit">{node}</div>
// `showValue={false}` hides the value readout on the representative page; the verification Stories keep it.
export function Single({ showValue = true }: { showValue?: boolean }) {
const [date, setDate] = useState<Date | undefined>(new Date(2026, 9, 15))
return (
<div className="grid gap-2">
{box(<Calendar mode="single" selected={date} onSelect={setDate} defaultMonth={month} today={today} />)}
{showValue ? <span className="text-sm text-muted-foreground" data-testid="single">{`선택: ${date ? date.toLocaleDateString("ko-KR") : "없음"}`}</span> : null}
</div>
)
}
export function DisabledDates() {
const [date, setDate] = useState<Date | undefined>()
return box(
<Calendar
mode="single"
selected={date}
onSelect={setDate}
defaultMonth={month}
today={today}
disabled={[{ before: new Date(2026, 9, 5) }, { dayOfWeek: [0] }]}
/>
)
}
export function Range() {
const [range, setRange] = useState<DateRange | undefined>({ from: new Date(2026, 9, 8), to: new Date(2026, 9, 14) })
return box(<Calendar mode="range" selected={range} onSelect={setRange} defaultMonth={month} today={today} />)
}
function TwoMonths() {
return box(<Calendar mode="single" numberOfMonths={2} defaultMonth={month} today={today} />)
}
// Same 3-column track as the Form pages (1 column on narrow screens). Each cell is a one-specimen StateBoard so subtitles 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="sb-calendar-example-grid 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 ? "sb-calendar-example-wide col-span-full min-w-0 min-[1000px]:col-span-2" : "min-w-0"}>
<StateBoard min={10000} states={[{ id, label, children }]} />
</div>
)
// The Calendar sections of a representative page. `titlePrefix` names the component in each section title and `idPrefix`
// keeps the specimen ids (data-overview-state) unique when the sections share a page with other components.
export function CalendarSections({ titlePrefix, idPrefix = "" }: { titlePrefix?: string; idPrefix?: string }) {
const title = (name: string) => (titlePrefix ? `${titlePrefix} · ${name}` : name)
return (
<>
<StorySection title={title("Variants")}>
<Grid>
<Cell id={`${idPrefix}single`} label="Single"><Single showValue={false} /></Cell>
<Cell id={`${idPrefix}range`} label="Range"><Range /></Cell>
<Cell id={`${idPrefix}months`} label="Two months" wide><TwoMonths /></Cell>
</Grid>
</StorySection>
<StorySection title={title("States")}>
<Grid>
<Cell id={`${idPrefix}disabled`} label="Disabled dates"><DisabledDates /></Cell>
</Grid>
</StorySection>
</>
)
}
+9 -71
View File
@@ -1,88 +1,26 @@
import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import type { DateRange } from "react-day-picker" import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { Calendar } from "@hm-design/ui" import { CalendarSections, DisabledDates, Range, Single } from "./calendar-examples"
import { StateBoard } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// The representative "Calendar" Story shows each mode with real, clickable calendars; the same specimens also exist as Stories // The Calendar specimens live in ./calendar-examples and are also shown on the Date Picker representative page ("날짜 선택"),
// tagged `verification` (hidden from the sidebar). `today` is pinned to 2026-10-02 so the Story looks the same on any day; this // the single sidebar item for both. Every Story here keeps its ID and direct URL but is hidden from the sidebar
// uses react-day-picker's own `today` prop, not an HM state. Korean is the Calendar default locale. // (tag `verification`).
const today = new Date(2026, 9, 2)
const month = new Date(2026, 9, 1)
const box = (node: ReactNode) => <div className="rounded-md border bg-card w-fit">{node}</div>
// `showValue={false}` hides the value readout on the representative page; the verification Stories keep it.
function Single({ showValue = true }: { showValue?: boolean }) {
const [date, setDate] = useState<Date | undefined>(new Date(2026, 9, 15))
return (
<div className="grid gap-2">
{box(<Calendar mode="single" selected={date} onSelect={setDate} defaultMonth={month} today={today} />)}
{showValue ? <span className="text-sm text-muted-foreground" data-testid="single">{`선택: ${date ? date.toLocaleDateString("ko-KR") : "없음"}`}</span> : null}
</div>
)
}
function DisabledDates() {
const [date, setDate] = useState<Date | undefined>()
return box(
<Calendar
mode="single"
selected={date}
onSelect={setDate}
defaultMonth={month}
today={today}
disabled={[{ before: new Date(2026, 9, 5) }, { dayOfWeek: [0] }]}
/>
)
}
function Range() {
const [range, setRange] = useState<DateRange | undefined>({ from: new Date(2026, 9, 8), to: new Date(2026, 9, 14) })
return box(<Calendar mode="range" selected={range} onSelect={setRange} defaultMonth={month} today={today} />)
}
function TwoMonths() {
return box(<Calendar mode="single" numberOfMonths={2} defaultMonth={month} today={today} />)
}
// 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. // 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 const meta = { title: "Components/Search & Date/Calendar", parameters: { layout: "padded" } } satisfies Meta
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative Story: the common Story page; the sections group the same specimens. // Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Date
// Picker page shows the same sections.
export const All: Story = { export const All: Story = {
tags: ["verification"],
name: "Calendar", name: "Calendar",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Calendar" /> <StoryPageHeader title="Calendar" />
<StorySection title="Variants"> <CalendarSections />
<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> </StoryPage>
), ),
} }
+107
View File
@@ -0,0 +1,107 @@
import { useId, useState, type ReactNode } from "react"
import { Checkbox, Label } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StorySection } from "@/components/story-page"
// Shared Checkbox specimens (Storybook only). Used by the Checkbox Stories (verification) and by the Selection Controls
// representative page ("선택 컨트롤"), which shows Checkbox, Radio Group and Switch under one sidebar item.
// 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.
export const optionLabel = "[--hm-label-text:var(--foreground)] peer-disabled:[--hm-label-text:var(--hm-sem-fg-secondary)]"
export 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} 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.
// `className` replaces the track (the Selection Controls page passes its shared 4-column grid).
function StateGrid({ states, className }: { states: BoardState[]; className?: string }) {
return (
<div className={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.
export 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>
)
}
// Starts indeterminate; a click makes it checked (Radix), so the Indeterminate example is also the interactive one.
// Exported for the Selection Controls page's Variants section.
export function Controlled() {
const [checked, setChecked] = useState<boolean | "indeterminate">("indeterminate")
return (
<Option label="Option">
{(id) => <Checkbox id={id} checked={checked} onCheckedChange={setChecked} />}
</Option>
)
}
// The Checkbox sections of a representative page. `titlePrefix` names the component in each section title and `idPrefix`
// keeps the specimen ids (data-overview-state) unique when the sections share a page with other components. `gridClassName`
// gives every section of a shared page one column track (Selection Controls); without it the Checkbox page keeps its own.
export function CheckboxSections({ titlePrefix, idPrefix = "", gridClassName }: { titlePrefix?: string; idPrefix?: string; gridClassName?: string }) {
const title = (name: string) => (titlePrefix ? `${titlePrefix} · ${name}` : name)
return (
<>
<StorySection title={title("States")}>
<StateGrid
className={gridClassName}
states={[
{ id: `${idPrefix}unchecked`, label: "Unchecked", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
{ id: `${idPrefix}checked`, label: "Checked", children: <Option label="Option">{(id) => <Checkbox id={id} defaultChecked />}</Option> },
{ id: `${idPrefix}indeterminate`, label: "Indeterminate", children: <Controlled /> },
{
id: `${idPrefix}hover`,
label: "Hover",
// data-sb-hover pins :hover (Storybook only, .storybook/pseudo-states.ts).
children: (
<div className="grid gap-3">
<Option label="Unchecked">{(id) => <Checkbox id={id} data-sb-hover="" />}</Option>
<Option label="Checked">{(id) => <Checkbox id={id} defaultChecked data-sb-hover="" />}</Option>
</div>
),
},
{ id: `${idPrefix}focus`, label: "Focus", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
{
id: `${idPrefix}disabled`,
label: "Disabled",
children: (
<div className="grid gap-3">
<Option label="Unchecked">{(id) => <Checkbox id={id} disabled />}</Option>
<Option label="Checked">{(id) => <Checkbox id={id} disabled defaultChecked />}</Option>
</div>
),
},
]}
/>
</StorySection>
<StorySection title={title("Validation")}>
<StateGrid className={gridClassName} states={[{ id: `${idPrefix}error`, label: "Error", children: <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }]} />
</StorySection>
</>
)
}
+10 -90
View File
@@ -1,61 +1,11 @@
import { useId, useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Checkbox, Label } from "@hm-design/ui" import { Checkbox } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board" import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { CheckboxSections, LongLabelExample, Option } from "./checkbox-examples"
// Current HM Checkbox. ui-viz has no Checkbox spec, so it was reference only. // Current HM Checkbox. The specimens live in ./checkbox-examples and are also shown on the Selection Controls representative
// The option label is the HM Label; its foreground role for Checkbox options is still TBD in ui_viz. // page ("선택 컨트롤", Checkbox / Radio Group / Switch), the single sidebar item for the three. Every Story here keeps its ID and
// direct URL but is hidden from the sidebar (tag `verification`).
// 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} 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>
)
}
// Starts indeterminate; a click makes it checked (Radix), so the Indeterminate example is also the interactive one.
function Controlled() {
const [checked, setChecked] = useState<boolean | "indeterminate">("indeterminate")
return (
<Option label="Option">
{(id) => <Checkbox id={id} checked={checked} onCheckedChange={setChecked} />}
</Option>
)
}
const meta = { const meta = {
title: "Components/Form/Checkbox", title: "Components/Form/Checkbox",
@@ -73,46 +23,16 @@ const meta = {
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Selection
// Controls page shows the same sections.
export const All: Story = { export const All: Story = {
tags: ["verification"],
name: "Checkbox", name: "Checkbox",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Checkbox" /> <StoryPageHeader title="Checkbox" />
<StorySection title="States"> <CheckboxSections />
<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 /> },
{
id: "hover",
label: "Hover",
// data-sb-hover pins :hover (Storybook only, .storybook/pseudo-states.ts).
children: (
<div className="grid gap-3">
<Option label="Unchecked">{(id) => <Checkbox id={id} data-sb-hover="" />}</Option>
<Option label="Checked">{(id) => <Checkbox id={id} defaultChecked data-sb-hover="" />}</Option>
</div>
),
},
{ id: "focus", label: "Focus", children: <Option label="Option">{(id) => <Checkbox id={id} />}</Option> },
{
id: "disabled",
label: "Disabled",
children: (
<div className="grid gap-3">
<Option label="Unchecked">{(id) => <Checkbox id={id} disabled />}</Option>
<Option label="Checked">{(id) => <Checkbox id={id} disabled defaultChecked />}</Option>
</div>
),
},
]}
/>
</StorySection>
<StorySection title="Validation">
<StateGrid states={[{ id: "error", label: "Error", children: <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }]} />
</StorySection>
</StoryPage> </StoryPage>
), ),
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
+5 -17
View File
@@ -1,7 +1,6 @@
import { useState, type ReactNode } from "react" import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Combobox, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, type ComboboxOption } from "@hm-design/ui" import { Combobox, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, type ComboboxOption } 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"
// Combobox is a Popover + Command composition (search + single choice); Select stays for short lists without search. // Combobox is a Popover + Command composition (search + single choice); Select stays for short lists without search.
@@ -106,17 +105,6 @@ function SelectComparison() {
) )
} }
// 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>
)
// Sizes / States (2026-11-12): the Input / Select field grid with the common section indent — up to 3 tracks of at least 240px // 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. // within 1120px, narrower widths drop to 2 / 1 columns. Each cell is one field whose Label is the state name.
const fieldColumns = const fieldColumns =
@@ -151,7 +139,7 @@ export const All: Story = {
<StorySection title="Sizes"> <StorySection title="Sizes">
<FieldRow <FieldRow
fields={[ fields={[
{ id: "empty", render: <Empty state="Default" /> }, { id: "empty", render: <Empty state="Large" /> },
{ id: "medium", render: <Medium state="Medium" /> }, { id: "medium", render: <Medium state="Medium" /> },
]} ]}
/> />
@@ -165,10 +153,10 @@ export const All: Story = {
/> />
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<Grid> <div className="sb-page-indent grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,300px),1fr))] min-[1000px]:grid-cols-3">
<Cell id="long" label="Long option"><LongLabel /></Cell> <div className="min-w-0" data-overview-state="long"><LongLabel /></div>
<Cell id="select-comparison" label="Select / Combobox" wide><SelectComparison /></Cell> <div className="col-span-full min-w-0 min-[1000px]:col-span-2" data-overview-state="select-comparison"><SelectComparison /></div>
</Grid> </div>
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
+99
View File
@@ -0,0 +1,99 @@
.sb-page.sb-date-picker {
--sb-page-section-gap: 24px;
--sb-date-picker-field-max: 358px;
/* 276px Calendar plus the existing 1px border on each side. */
--sb-date-picker-calendar-width: 278px;
--sb-date-picker-column-gap: 16px;
}
.sb-date-picker > .sb-page-inner {
container: date-picker-story / inline-size;
}
.sb-date-picker-fields {
display: grid;
grid-template-columns: minmax(0, var(--sb-date-picker-field-max));
align-items: start;
gap: var(--sb-date-picker-column-gap);
}
.sb-date-picker-fields--usage > * {
max-width: 320px;
}
.sb-date-picker .sb-date-picker-calendars {
column-gap: var(--sb-date-picker-column-gap);
}
.sb-date-picker-calendars .sb-calendar-example-grid {
grid-template-columns: minmax(0, 1fr);
gap: var(--sb-date-picker-column-gap);
}
.sb-date-picker-calendars .sb-calendar-example-grid > * {
overflow-x: auto;
}
.sb-date-picker-calendars .sb-calendar-example-wide {
grid-column: 1 / -1;
justify-self: start;
width: fit-content;
max-width: 100%;
}
/* Two field tracks fit after the Storybook indent. */
@container date-picker-story (min-width: 512px) {
.sb-date-picker-fields {
grid-template-columns: repeat(2, minmax(0, var(--sb-date-picker-field-max)));
}
}
@container date-picker-story (min-width: 588px) {
.sb-date-picker-calendars .sb-calendar-example-grid:has(.sb-calendar-example-wide) {
grid-template-columns: repeat(2, var(--sb-date-picker-calendar-width));
}
}
/* Four calendar-width tracks + three 16px gaps + the 16px Storybook indent. */
@container date-picker-story (min-width: 1176px) {
.sb-date-picker-fields,
.sb-date-picker .sb-date-picker-calendars {
grid-template-columns: repeat(4, minmax(var(--sb-date-picker-calendar-width), var(--sb-date-picker-field-max)));
}
/* Subgrids align the group headings and first specimen row without flattening either section. */
.sb-date-picker .sb-date-picker-calendars {
row-gap: 0;
padding-left: var(--sb-page-indent);
}
.sb-date-picker-calendars > .sb-page-section,
.sb-date-picker-calendars .sb-page-section-body,
.sb-date-picker-calendars .sb-calendar-example-grid {
display: grid;
grid-template: subgrid / subgrid;
}
.sb-date-picker-calendars > .sb-page-section:first-child {
grid-area: 1 / 1 / span 3 / span 2;
}
.sb-date-picker-calendars > .sb-page-section:last-child {
grid-area: 1 / 3 / span 2;
}
.sb-date-picker-calendars .sb-page-section-body {
grid-area: 2 / 1 / -1 / -1;
}
.sb-date-picker .sb-date-picker-calendars .sb-page-section-body > .sb-calendar-example-grid {
grid-area: 1 / 1 / -1 / -1;
grid-template-columns: subgrid;
padding-left: 0;
row-gap: 0;
}
.sb-date-picker-calendars .sb-calendar-example-wide {
margin-top: var(--sb-date-picker-column-gap);
}
}
+26 -39
View File
@@ -1,8 +1,9 @@
import { useState, type ReactNode } from "react" import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { DatePicker } from "@hm-design/ui" import { DatePicker } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board" import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { CalendarSections } from "./calendar-examples"
import "./date-picker-story.css"
// DatePicker is a Popover + Calendar composition packaged as a component (value handling, trigger text, close on select). // DatePicker is a Popover + Calendar composition packaged as a component (value handling, trigger text, close on select).
// The representative Story shows real pickers; the same specimens also exist as Stories tagged `verification` (hidden from the // The representative Story shows real pickers; the same specimens also exist as Stories tagged `verification` (hidden from the
@@ -46,19 +47,14 @@ function Selected({ state }: { state?: string }) {
) )
} }
function DefaultFormat() { function FormatExample({ id, triggerId, label, children }: { id: string; triggerId: string; label: string; children: ReactNode }) {
return ( return (
<Field label="기본 형식 · YYYY.MM.DD" id="dp-padded"> <section className="grid min-w-0 content-start gap-3" data-overview-state={id}>
<DatePicker id="dp-padded" defaultValue={new Date(2026, 0, 9)} calendarProps={calendarProps} /> <h3 className="text-sm font-medium text-muted-foreground">
</Field> <label id={`${triggerId}-label`} htmlFor={triggerId}>{label}</label>
) </h3>
} {children}
</section>
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>
) )
} }
@@ -113,33 +109,15 @@ function Disabled({ state }: { state?: string }) {
) )
} }
// 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>
)
// 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 }[] }) { function FieldRow({ fields }: { fields: { id: string; render: ReactNode }[] }) {
return ( return (
<div className="sb-page-indent"> <div className="sb-page-indent sb-date-picker-fields">
<div className={fieldColumns}>
{fields.map(({ id, render }) => ( {fields.map(({ id, render }) => (
<div key={id} className="min-w-0" data-overview-state={id}> <div key={id} className="min-w-0" data-overview-state={id}>
{render} {render}
</div> </div>
))} ))}
</div> </div>
</div>
) )
} }
@@ -148,16 +126,18 @@ export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
// 2026-11-13 (user request): it also shows the Calendar sections from ./calendar-examples (titles "Calendar · …"), so
// "날짜 선택" is the one sidebar item for both; the Calendar Stories keep their IDs and direct URLs but are hidden from the sidebar.
export const All: Story = { export const All: Story = {
name: "Date Picker", name: "Date Picker",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage className="sb-date-picker">
<StoryPageHeader title="Date Picker" /> <StoryPageHeader title="Date Picker" />
<StorySection title="Sizes"> <StorySection title="Sizes">
<FieldRow <FieldRow
fields={[ fields={[
{ id: "empty", render: <Empty state="Default" /> }, { id: "empty", render: <Empty state="Large" /> },
{ id: "medium", render: <Medium state="Medium" /> }, { id: "medium", render: <Medium state="Medium" /> },
]} ]}
/> />
@@ -173,11 +153,18 @@ export const All: Story = {
/> />
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<Grid> <div className="sb-page-indent sb-date-picker-fields sb-date-picker-fields--usage">
<Cell id="format-default" label="Default format"><DefaultFormat /></Cell> <FormatExample id="format-default" triggerId="dp-padded" label="Default format">
<Cell id="format-custom" label="Custom format"><CustomFormat /></Cell> <DatePicker id="dp-padded" aria-labelledby="dp-padded-label" defaultValue={new Date(2026, 0, 9)} calendarProps={calendarProps} />
</Grid> </FormatExample>
<FormatExample id="format-custom" triggerId="dp-custom" label="Custom format">
<DatePicker id="dp-custom" aria-labelledby="dp-custom-label" defaultValue={new Date(2026, 9, 6)} format={(date) => date.toLocaleDateString("ko-KR")} calendarProps={calendarProps} />
</FormatExample>
</div>
</StorySection> </StorySection>
<StorySectionGrid className="sb-date-picker-calendars">
<CalendarSections titlePrefix="Calendar" idPrefix="calendar-" />
</StorySectionGrid>
</StoryPage> </StoryPage>
), ),
} }
+3 -1
View File
@@ -163,7 +163,9 @@ export const All: Story = {
<FieldRow <FieldRow
fields={[ fields={[
{ id: "focus", label: "Focus", props: { size: "medium", placeholder } }, { id: "focus", label: "Focus", props: { size: "medium", placeholder } },
{ id: "disabled", label: "Disabled", props: { disabled: true, defaultValue: "Unavailable project" } }, // 2026-11-13: Disabled is Medium like Focus and the Validation row (34px). Readonly keeps the component's own rule
// (packages/ui input.css: text only, always Large metrics whatever the size), so its size prop is not set.
{ id: "disabled", label: "Disabled", props: { size: "medium", disabled: true, defaultValue: "Unavailable project" } },
{ id: "readonly", label: "Readonly", props: { readOnly: true, value: "Confirmed project" } }, { id: "readonly", label: "Readonly", props: { readOnly: true, value: "Confirmed project" } },
]} ]}
/> />
+118
View File
@@ -0,0 +1,118 @@
import { Label, RadioGroup, RadioGroupItem } from "@hm-design/ui"
import type { CSSProperties } from "react"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StorySection } from "@/components/story-page"
// Shared Radio Group specimens (Storybook only). Used by the Radio Group Stories (verification) and by the Selection Controls
// representative page ("선택 컨트롤"), which shows Checkbox, Radio Group and Switch under one sidebar item.
// 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
// `className` replaces the track (the Selection Controls page passes its shared 4-column grid); `spans` gives a specimen
// cell more than one track there.
function SpecimenGrid({ states, columns, min = 220, className, spans }: { states: BoardState[]; columns: keyof typeof specimenColumns; min?: number; className?: string; spans?: Record<string, string> }) {
return (
<div
className={className ?? `grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,var(--sb-col-min)),1fr))] ${specimenColumns[columns]}`}
style={className ? undefined : ({ "--sb-col-min": `${min}px` } as CSSProperties)}
>
{states.map((state) =>
spans?.[state.id] ? (
<div key={state.id} className={`min-w-0 ${spans[state.id]}`}>
<StateBoard min={10000} states={[state]} />
</div>
) : (
<StateBoard key={state.id} min={10000} states={[state]} />
),
)}
</div>
)
}
export function Group({
prefix,
defaultValue = "a",
disabled,
disabledItem,
invalid,
orientation,
hoverItem,
focusItem,
}: {
prefix: string
defaultValue?: string
disabled?: boolean
disabledItem?: string
invalid?: boolean
orientation?: "horizontal" | "vertical"
// Pins :hover on one item with the Storybook-only data-sb-hover attribute (.storybook/pseudo-states.ts).
hoverItem?: string
// Pins :focus-visible (the focus ring) on one item with the Storybook-only data-sb-focus attribute (2026-11-13).
focusItem?: string
}) {
return (
// The group name is a readable Korean name; `prefix` stays an internal id prefix only (screen readers no longer read it).
<RadioGroup defaultValue={defaultValue} disabled={disabled} orientation={orientation} aria-label="옵션 선택">
{["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} data-sb-focus={focusItem === v ? "" : undefined} />
<Label htmlFor={`${prefix}-${v}`} className={optionLabel}>Option {v.toUpperCase()}</Label>
</div>
))}
</RadioGroup>
)
}
// The Radio Group sections of a representative page. `titlePrefix` names the component in each section title and `idPrefix`
// keeps the specimen ids (data-overview-state) unique when the sections share a page with other components. `gridClassName`
// gives every section of a shared page one column track (Selection Controls); without it the Radio Group page keeps its own.
// In the shared track the Horizontal group takes 2 tracks from 1000px and the whole row below that, so its 3 options stay on
// one line (the horizontal group wraps, flex-wrap, when its cell is narrower than the row).
export function RadioGroupSections({ titlePrefix, idPrefix = "", gridClassName }: { titlePrefix?: string; idPrefix?: string; gridClassName?: string }) {
const title = (name: string) => (titlePrefix ? `${titlePrefix} · ${name}` : name)
return (
<>
{/* Overview is merged into Variants: its Vertical example is the first Variants item. */}
<StorySection title={title("Variants")}>
<SpecimenGrid
className={gridClassName}
spans={gridClassName ? { [`${idPrefix}horizontal`]: "col-span-full min-[1000px]:col-span-2" } : undefined}
columns={2}
min={320}
states={[
{ id: `${idPrefix}vertical`, label: "Vertical", children: <Group prefix="v" defaultValue="b" /> },
{ id: `${idPrefix}horizontal`, label: "Horizontal", children: <Group prefix="h" orientation="horizontal" /> },
]}
/>
</StorySection>
<StorySection title={title("States")}>
<SpecimenGrid
className={gridClassName}
columns={4}
states={[
{ id: `${idPrefix}hover`, label: "Hover", children: <Group prefix="hv" defaultValue="" hoverItem="a" /> },
{ id: `${idPrefix}focus`, label: "Focus", children: <Group prefix="f" /> },
{ id: `${idPrefix}disabled-item`, label: "Disabled · 항목", children: <Group prefix="di" disabledItem="b" /> },
{ id: `${idPrefix}disabled-group`, label: "Disabled · 그룹", children: <Group prefix="dg" disabled /> },
]}
/>
</StorySection>
<StorySection title={title("Validation")}>
{/* Same 4-column track as States, so the Error example keeps the width of one States column. */}
<SpecimenGrid className={gridClassName} columns={4} states={[{ id: `${idPrefix}error`, label: "Error", children: <Group prefix="e" invalid /> }]} />
</StorySection>
</>
)
}
+10 -87
View File
@@ -1,66 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Label, RadioGroup, RadioGroupItem } from "@hm-design/ui" import { RadioGroup } from "@hm-design/ui"
import type { CSSProperties } from "react" import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { StateBoard, type BoardState } from "@/components/story-board" import { Group, RadioGroupSections } from "./radio-group-examples"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Current HM Radio Group. ui-viz has no Radio spec, so it was reference only. // Current HM Radio Group. The specimens live in ./radio-group-examples and are also shown on the Selection Controls
// representative page ("선택 컨트롤", Checkbox / Radio Group / Switch), the single sidebar item for the three. Every Story here
// Story-only Label color. The shared Label sets `color: var(--hm-label-text)` from an unlayered theme rule, so a utility // keeps its ID and direct URL but is hidden from the sidebar (tag `verification`).
// 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",
disabled,
disabledItem,
invalid,
orientation,
hoverItem,
}: {
prefix: string
defaultValue?: string
disabled?: boolean
disabledItem?: string
invalid?: boolean
orientation?: "horizontal" | "vertical"
// Pins :hover on one item with the Storybook-only data-sb-hover attribute (.storybook/pseudo-states.ts).
hoverItem?: string
}) {
return (
<RadioGroup defaultValue={defaultValue} disabled={disabled} orientation={orientation} aria-label={prefix}>
{["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}`} className={optionLabel}>Option {v.toUpperCase()}</Label>
</div>
))}
</RadioGroup>
)
}
const meta = { const meta = {
title: "Components/Form/Radio Group", title: "Components/Form/Radio Group",
@@ -78,38 +23,16 @@ const meta = {
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Selection
// Controls page shows the same sections.
export const All: Story = { export const All: Story = {
tags: ["verification"],
name: "Radio Group", name: "Radio Group",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Radio Group" /> <StoryPageHeader title="Radio Group" />
{/* Overview is merged into Variants: its Vertical example is the first Variants item. */} <RadioGroupSections />
<StorySection title="Variants">
<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">
<SpecimenGrid
columns={4}
states={[
{ id: "hover", label: "Hover", children: <Group prefix="hv" defaultValue="" hoverItem="a" /> },
{ id: "focus", label: "Focus", children: <Group prefix="f" /> },
{ id: "disabled-item", label: "Disabled · 항목", children: <Group prefix="di" disabledItem="b" /> },
{ id: "disabled-group", label: "Disabled · 그룹", children: <Group prefix="dg" disabled /> },
]}
/>
</StorySection>
<StorySection title="Validation">
{/* 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> </StoryPage>
), ),
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
@@ -0,0 +1,23 @@
.sb-selection-sections > .sb-page-section:first-child {
margin-top: 0;
}
@container (width >= 720px) {
.sb-selection-controls .sb-selection-sections {
display: grid;
grid-template-columns: max-content repeat(3, minmax(max-content, 1fr));
column-gap: 24px;
}
.sb-selection-sections > .sb-page-section,
.sb-selection-sections > .sb-page-section > .sb-page-section-body,
.sb-selection-sections .sb-selection-grid {
display: grid;
grid-column: 1 / -1;
grid-template-columns: subgrid;
}
.sb-selection-sections > .sb-page-section > .sb-page-section-header {
grid-column: 1 / -1;
}
}
@@ -0,0 +1,234 @@
import type { Meta, StoryObj } from "@storybook/react-vite"
import type { CSSProperties, ReactNode } from "react"
import { Checkbox, Switch } from "@hm-design/ui"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
import { Controlled, Option } from "./checkbox-examples"
import { Group } from "./radio-group-examples"
import { Option as SwitchOption } from "./switch-examples"
import "./selection-controls-story.css"
// Selection Controls ("선택 컨트롤", 2026-11-13): one representative page for the form on/off and choice controls Checkbox,
// Radio Group and Switch, which share the option Label, state and Disabled arrangement. Storybook presentation only: no new
// component, prop or API; the specimens are the components' public API with the ./<name>-examples parts (Option, Group,
// Controlled). The former Checkbox / Radio Group / Switch representative Stories keep their IDs and direct URLs but are
// hidden from the sidebar (tag `verification`), as are their own verification Stories.
// 2026-11-13 (user request, state comparison matrix): the six "<Component> · <Section>" sections are replaced by one state
// matrix (rows Default / Hover / Focus / Disabled / Validation, columns Checkbox / Radio Group / Switch) and one Variants
// section for what is not a common state (Checkbox Indeterminate, Radio Group Vertical / Horizontal; Switch has none).
const meta = { title: "Components/Form/Selection Controls", parameters: { layout: "padded" } } satisfies Meta
export default meta
type Story = StoryObj<typeof meta>
// Labels follow the Button page matrix: H3 / row and column headers 14/20/500 secondary (StateBoard H3 style); the per-cell
// state names of the stacked layout and the sub-specimen captions one step below, 12/16/400 secondary.
const groupLabel = "m-0 text-sm font-medium text-muted-foreground"
const caption = "text-xs font-normal text-muted-foreground"
const states = [
["default", "Default"],
["hover", "Hover"],
["focus", "Focus"],
["disabled", "Disabled"],
["validation", "Validation"],
] as const
type StateKey = (typeof states)[number][0]
// Two specimens of one cell side by side (Unchecked / Checked, Off / On, 항목 / 그룹); they wrap only in the stacked layout.
function Pair({ children }: { children: ReactNode }) {
return <div className="flex flex-wrap items-start gap-x-6 gap-y-3">{children}</div>
}
// A specimen with its own small caption (Radio Group Disabled 항목 / 그룹, the Variants).
function Captioned({ id, label, children }: { id?: string; label: string; children: ReactNode }) {
return (
<div data-overview-state={id} role="group" aria-label={label} className="grid content-start gap-1">
<span className={caption} aria-hidden>
{label}
</span>
{children}
</div>
)
}
// Only states each component implements; Switch has no Validation state, so its cell shows "—".
// Hover pins :hover with the Storybook-only data-sb-hover attribute (.storybook/pseudo-states.ts). Radio Group ids use the
// "sc-" prefix so they never collide with another Group on the page.
const columns: { id: string; label: string; cells: Partial<Record<StateKey, ReactNode>> }[] = [
{
id: "checkbox",
label: "Checkbox",
cells: {
default: (
<Pair>
<Option label="Unchecked">{(id) => <Checkbox id={id} />}</Option>
<Option label="Checked">{(id) => <Checkbox id={id} defaultChecked />}</Option>
</Pair>
),
hover: (
<Pair>
<Option label="Unchecked">{(id) => <Checkbox id={id} data-sb-hover="" />}</Option>
<Option label="Checked">{(id) => <Checkbox id={id} defaultChecked data-sb-hover="" />}</Option>
</Pair>
),
focus: <Option label="Option">{(id) => <Checkbox id={id} />}</Option>,
disabled: (
<Pair>
<Option label="Unchecked">{(id) => <Checkbox id={id} disabled />}</Option>
<Option label="Checked">{(id) => <Checkbox id={id} disabled defaultChecked />}</Option>
</Pair>
),
validation: <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option>,
},
},
{
id: "radio-group",
label: "Radio Group",
cells: {
default: <Group prefix="sc-d" defaultValue="b" />,
hover: <Group prefix="sc-hv" defaultValue="" hoverItem="a" />,
// 2026-11-13: the ring is pinned on Option A (data-sb-focus); only the Checkbox specimen holds the page focus.
focus: <Group prefix="sc-f" focusItem="a" />,
disabled: (
<Pair>
<Captioned id="radio-group-disabled-item" label="항목">
<Group prefix="sc-di" disabledItem="b" />
</Captioned>
<Captioned id="radio-group-disabled-group" label="그룹">
<Group prefix="sc-dg" disabled />
</Captioned>
</Pair>
),
// 2026-11-13 (user request): no option selected, so the Error row shows no green selected mark.
validation: <Group prefix="sc-e" defaultValue="" invalid />,
},
},
{
id: "switch",
label: "Switch",
cells: {
default: (
<Pair>
<SwitchOption label="Off">{(id) => <Switch id={id} />}</SwitchOption>
<SwitchOption label="On">{(id) => <Switch id={id} defaultChecked />}</SwitchOption>
</Pair>
),
hover: (
<Pair>
<SwitchOption label="Off">{(id) => <Switch id={id} data-sb-hover="" />}</SwitchOption>
<SwitchOption label="On">{(id) => <Switch id={id} defaultChecked data-sb-hover="" />}</SwitchOption>
</Pair>
),
focus: <SwitchOption label="Option">{(id) => <Switch id={id} data-sb-focus="" />}</SwitchOption>,
disabled: (
<Pair>
<SwitchOption label="Off">{(id) => <Switch id={id} disabled />}</SwitchOption>
<SwitchOption label="On">{(id) => <Switch id={id} disabled defaultChecked />}</SwitchOption>
</Pair>
),
},
},
]
// One DOM for both layouts, rendered once in component order (Checkbox → Radio Group → Switch, each Default → Validation),
// the stacked layout's reading order. In the matrix each component block becomes display: contents,
// so its header and cells use the same tracks as Variants.
const matrixPosition = "@min-[720px]:[grid-row:var(--sc-row)] @min-[720px]:[grid-column:var(--sc-col)]"
const place = (row: number, col: number) => ({ "--sc-row": row, "--sc-col": col }) as CSSProperties
function StateMatrix() {
return (
<div className="sb-selection-grid grid items-start gap-6">
{/* Row headers: the matrix only; in the stacked layout each cell names its own state. */}
{states.map(([key, label], r) => (
<div key={key} aria-hidden className={`hidden ${groupLabel} @min-[720px]:block ${matrixPosition}`} style={place(r + 2, 1)}>
{label}
</div>
))}
{columns.map((column, c) => (
<section key={column.id} aria-label={column.label} className="grid min-w-0 content-start gap-6 @min-[720px]:contents">
<h3 className={`${groupLabel} ${matrixPosition}`} style={place(1, c + 2)}>
{column.label}
</h3>
{states.map(([key, label], r) => {
const content = column.cells[key]
if (!content) {
return (
<div key={key} className={`hidden @min-[720px]:block ${matrixPosition}`} style={place(r + 2, c + 2)}>
<span aria-hidden className="text-sm text-muted-foreground">—</span>
<span className="sr-only">{`${column.label} ${label}: 해당 상태 없음`}</span>
</div>
)
}
return (
<div
key={key}
data-overview-state={`${column.id}-${key}`}
role="group"
aria-label={`${column.label} ${label}${key === "validation" ? " · Error" : ""}`}
className={`grid min-w-0 content-start gap-1 ${matrixPosition}`}
style={place(r + 2, c + 2)}
>
<span aria-hidden className={`${caption} @min-[720px]:hidden`}>
{label}
</span>
{content}
</div>
)
})}
</section>
))}
</div>
)
}
function Variants() {
return (
<div className="sb-selection-grid grid items-start gap-6">
<section aria-label="Checkbox" className="grid min-w-0 content-start gap-2 @min-[720px]:col-start-2">
<h3 className={groupLabel}>Checkbox</h3>
<Captioned id="checkbox-indeterminate" label="Indeterminate">
<Controlled />
</Captioned>
</section>
<section aria-label="Radio Group" className="grid min-w-0 content-start gap-2 @min-[720px]:col-span-2 @min-[720px]:col-start-3">
<h3 className={groupLabel}>Radio Group</h3>
<Pair>
<Captioned id="radio-group-vertical" label="Vertical">
<Group prefix="sc-v" defaultValue="b" />
</Captioned>
<Captioned id="radio-group-horizontal" label="Horizontal">
<Group prefix="sc-h" orientation="horizontal" />
</Captioned>
</Pair>
</section>
</div>
)
}
// Named after the group, so Storybook shows it as a single 1-depth item ("선택 컨트롤").
export const All: Story = {
name: "Selection Controls",
parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => (
<StoryPage>
<StoryPageHeader title="Selection Controls" />
<div className="sb-selection-controls @container [&_[data-sb-focus]]:ring-3 [&_[data-sb-focus]]:ring-ring/50">
<div className="sb-selection-sections">
<StorySection id="states" title="Selection Controls">
<StateMatrix />
</StorySection>
<StorySection id="variants" title="Variants">
<Variants />
</StorySection>
</div>
</div>
</StoryPage>
),
play: async ({ canvasElement }) => {
// Only one element can hold focus, so the page focuses the Checkbox Focus specimen. The Radio Group and Switch Focus
// specimens pin the same ring utilities with data-sb-focus, including in the optimized Storybook build.
// preventScroll: the page opens at its top, not at the focused specimen.
canvasElement.querySelector<HTMLButtonElement>('[data-overview-state="checkbox-focus"] [role="checkbox"]')?.focus({ preventScroll: true })
},
}
+57
View File
@@ -0,0 +1,57 @@
.sb-slider-layout .sb-page-section:first-child {
margin-top: 0;
}
.sb-slider-layout .sb-page-section-body {
display: grid;
align-items: start;
gap: 24px;
grid-template-columns: repeat(auto-fill, minmax(min(100%, 200px), 1fr));
padding-inline-start: var(--sb-page-indent);
}
@media (min-width: 1000px) {
.sb-slider-layout {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
grid-template-rows: max-content max-content 1fr;
column-gap: 24px;
padding-inline-start: var(--sb-page-indent);
}
.sb-slider-layout .sb-page-section-header {
margin-inline-start: calc(-1 * var(--sb-page-indent));
}
/* The vertical specimen spans the last two rows so the horizontal examples set the start of States. */
.sb-slider-layout #slider-variants {
display: grid;
grid-area: 1 / 1 / 4 / -1;
grid-template: subgrid / subgrid;
}
.sb-slider-layout #slider-variants > .sb-page-section-header {
grid-column: 1 / -1;
}
.sb-slider-layout #slider-variants > .sb-page-section-body {
grid-area: 2 / 1 / 4 / -1;
grid-template: subgrid / subgrid;
row-gap: 0;
padding-inline-start: 0;
}
.sb-slider-layout [data-overview-state="vertical"] {
grid-area: 1 / 4 / 3 / 5;
}
.sb-slider-layout #slider-states {
grid-area: 3 / 1 / 4 / 2;
align-self: start;
}
.sb-slider-layout #slider-states > .sb-page-section-body {
grid-template-columns: minmax(0, 1fr);
padding-inline-start: 0;
}
}
+27 -41
View File
@@ -1,38 +1,40 @@
import { useState, type ReactNode } from "react" import { useState, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Slider } from "@hm-design/ui" import { Slider } 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"
import "./slider-story.css"
// The representative Story shows each Slider mode with real, draggable controls; the same specimens also exist as Stories tagged // The representative Story shows each Slider mode with real, draggable controls; the same specimens also exist as Stories tagged
// `verification` (hidden from the sidebar). The value label is the Story's composition (Slider has no built-in label). // `verification` (hidden from the sidebar). The value label is the Story's composition (Slider has no built-in label).
// Names (`aria-label`) are provided by the consumer. // Names (`aria-label`) are provided by the consumer.
function Labeled({ label, children, valueText }: { label: string; children: ReactNode; valueText: string }) { function Labeled({ id, label, children, valueText }: { id?: string; label: string; children: ReactNode; valueText?: string }) {
const Example = id ? "section" : "div"
const Label = id ? "h3" : "span"
return ( return (
<div className="grid gap-3"> <Example className="grid min-w-0 gap-3" data-overview-state={id} aria-label={id ? label : undefined}>
<div className="flex justify-between text-sm"> <div className="flex justify-between gap-2 text-sm">
<span className="font-medium text-foreground">{label}</span> <Label className={id ? "font-medium text-muted-foreground" : "font-medium text-foreground"}>{label}</Label>
<span className="text-muted-foreground" data-testid="value">{valueText}</span> {valueText !== undefined && <span className="text-muted-foreground" data-testid="value">{valueText}</span>}
</div> </div>
{children} {children}
</div> </Example>
) )
} }
function Single() { function Single({ example = false }: { example?: boolean }) {
const [value, setValue] = useState([40]) const [value, setValue] = useState([40])
return ( return (
<Labeled label="볼륨" valueText={`${value[0]}`}> <Labeled id={example ? "single" : undefined} label="볼륨" valueText={`${value[0]}`}>
<Slider aria-label="볼륨" value={value} onValueChange={setValue} max={100} step={1} /> <Slider aria-label="볼륨" value={value} onValueChange={setValue} max={100} step={1} />
</Labeled> </Labeled>
) )
} }
function Range() { function Range({ example = false }: { example?: boolean }) {
const [value, setValue] = useState([25, 75]) const [value, setValue] = useState([25, 75])
return ( return (
<Labeled label="범위" valueText={`${value[0]} – ${value[1]}`}> <Labeled id={example ? "range" : undefined} label="범위" valueText={`${value[0]} – ${value[1]}`}>
<Slider aria-label="범위" value={value} onValueChange={setValue} max={100} step={1} minStepsBetweenThumbs={5} /> <Slider aria-label="범위" value={value} onValueChange={setValue} max={100} step={1} minStepsBetweenThumbs={5} />
</Labeled> </Labeled>
) )
@@ -41,7 +43,7 @@ function Range() {
function Step() { function Step() {
const [value, setValue] = useState([50]) const [value, setValue] = useState([50])
return ( return (
<Labeled label="단계 (step 25)" valueText={`${value[0]}`}> <Labeled id="step" label="단계 (25)" valueText={`${value[0]}`}>
<Slider aria-label="단계" value={value} onValueChange={setValue} max={100} step={25} /> <Slider aria-label="단계" value={value} onValueChange={setValue} max={100} step={25} />
</Labeled> </Labeled>
) )
@@ -59,47 +61,31 @@ function Vertical() {
) )
} }
const sections = [
{ id: "single", label: "Single", node: <Single /> },
{ id: "range", label: "Range", node: <Range /> },
{ id: "step", label: "Step", node: <Step /> },
{ id: "disabled", label: "Disabled", node: <Disabled /> },
{ id: "vertical", label: "Vertical", node: <Vertical /> },
]
const meta = { title: "Components/Form/Slider", component: Slider, parameters: { layout: "padded" } } satisfies Meta<typeof Slider> const meta = { title: "Components/Form/Slider", component: Slider, parameters: { layout: "padded" } } satisfies Meta<typeof Slider>
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// 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 = { export const All: Story = {
name: "Slider", name: "Slider",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Slider" /> <StoryPageHeader title="Slider" />
<StorySection title="Variants"> <div className="sb-slider-layout">
<SpecimenGrid ids={["single", "range", "step", "vertical"]} /> <StorySection id="slider-variants" title="Variants">
<Single example />
<Range example />
<Step />
<Labeled id="vertical" label="세로 슬라이더">
<Vertical />
</Labeled>
</StorySection> </StorySection>
<StorySection title="States"> <StorySection id="slider-states" title="States">
<SpecimenGrid ids={["disabled"]} /> <Labeled id="disabled" label="비활성">
<Disabled />
</Labeled>
</StorySection> </StorySection>
</div>
</StoryPage> </StoryPage>
), ),
} }
+76
View File
@@ -0,0 +1,76 @@
import { useId, type ReactNode } from "react"
import { Label, Switch } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StorySection } from "@/components/story-page"
// Shared Switch specimens (Storybook only). Used by the Switch Stories (verification) and by the Selection Controls
// representative page ("선택 컨트롤"), which shows Checkbox, Radio Group and Switch under one sidebar item.
// 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)]"
export 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} 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.
// `className` replaces the track (the Selection Controls page passes its shared 4-column grid).
function StateGrid({ states, className }: { states: BoardState[]; className?: string }) {
return (
<div className={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>
)
}
// The Switch sections of a representative page. `titlePrefix` names the component in each section title and `idPrefix`
// keeps the specimen ids (data-overview-state) unique when the sections share a page with other components. `gridClassName`
// gives every section of a shared page one column track (Selection Controls); without it the Switch page keeps its own.
export function SwitchSections({ titlePrefix, idPrefix = "", gridClassName }: { titlePrefix?: string; idPrefix?: string; gridClassName?: string }) {
const title = (name: string) => (titlePrefix ? `${titlePrefix} · ${name}` : name)
return (
<StorySection title={title("States")}>
<StateGrid
className={gridClassName}
states={[
{ id: `${idPrefix}off`, label: "Off", children: <Option label="Option">{(id) => <Switch id={id} />}</Option> },
{ id: `${idPrefix}on`, label: "On", children: <Option label="Option">{(id) => <Switch id={id} defaultChecked />}</Option> },
{
id: `${idPrefix}hover`,
label: "Hover",
// data-sb-hover pins :hover (Storybook only, .storybook/pseudo-states.ts).
children: (
<div className="grid gap-3">
<Option label="Off">{(id) => <Switch id={id} data-sb-hover="" />}</Option>
<Option label="On">{(id) => <Switch id={id} defaultChecked data-sb-hover="" />}</Option>
</div>
),
},
{ id: `${idPrefix}focus`, label: "Focus", children: <Option label="Option">{(id) => <Switch id={id} />}</Option> },
{
id: `${idPrefix}disabled`,
label: "Disabled",
children: (
<div className="grid gap-3">
<Option label="Off">{(id) => <Switch id={id} disabled />}</Option>
<Option label="On">{(id) => <Switch id={id} disabled defaultChecked />}</Option>
</div>
),
},
]}
/>
</StorySection>
)
}
+10 -61
View File
@@ -1,36 +1,11 @@
import { useId, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Label, Switch } from "@hm-design/ui" import { Switch } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board" import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { Option, SwitchSections } from "./switch-examples"
// Story-only Label color. The shared Label sets `color: var(--hm-label-text)` from an unlayered theme rule, so a utility // Current HM Switch. The specimens live in ./switch-examples and are also shown on the Selection Controls representative page
// like `text-foreground` loses to it; overriding the Label's own token on the element works. Option labels use the default // ("선택 컨트롤", Checkbox / Radio Group / Switch), the single sidebar item for the three. Every Story here keeps its ID and
// text color; a Label next to a disabled Switch (`peer-disabled`) keeps the secondary color. State titles are untouched. // direct URL but is hidden from the sidebar (tag `verification`).
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} 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>
)
}
const meta = { const meta = {
title: "Components/Form/Switch", title: "Components/Form/Switch",
@@ -48,42 +23,16 @@ const meta = {
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Selection
// Controls page shows the same sections.
export const All: Story = { export const All: Story = {
tags: ["verification"],
name: "Switch", name: "Switch",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Switch" /> <StoryPageHeader title="Switch" />
<StorySection title="States"> <SwitchSections />
<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> },
{
id: "hover",
label: "Hover",
// data-sb-hover pins :hover (Storybook only, .storybook/pseudo-states.ts).
children: (
<div className="grid gap-3">
<Option label="Off">{(id) => <Switch id={id} data-sb-hover="" />}</Option>
<Option label="On">{(id) => <Switch id={id} defaultChecked data-sb-hover="" />}</Option>
</div>
),
},
{ id: "focus", label: "Focus", children: <Option label="Option">{(id) => <Switch id={id} />}</Option> },
{
id: "disabled",
label: "Disabled",
children: (
<div className="grid gap-3">
<Option label="Off">{(id) => <Switch id={id} disabled />}</Option>
<Option label="On">{(id) => <Switch id={id} disabled defaultChecked />}</Option>
</div>
),
},
]}
/>
</StorySection>
</StoryPage> </StoryPage>
), ),
play: async ({ canvasElement }) => { play: async ({ canvasElement }) => {
+194
View File
@@ -0,0 +1,194 @@
import { BoldIcon, ItalicIcon, StarIcon } from "lucide-react"
import { Toggle, ToggleGroup, ToggleGroupItem } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StorySection, StorySectionGrid } from "@/components/story-page"
// Shared Toggle specimens (Storybook only). Used by the Toggle Stories (verification) and by the Button representative page,
// which shows Button and Toggle Button under one sidebar item ("버튼").
// Toggle is a persistent pressed-state control. The sections show 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`.
// 2026-11-12: toggles side by side keep the Button Story's 8px gap (was 12px).
export function OffOn() {
return (
<div className="flex flex-wrap items-center gap-2">
<Toggle>Off</Toggle>
<Toggle defaultPressed>On</Toggle>
<Toggle variant="outline">Outline off</Toggle>
<Toggle variant="outline" defaultPressed>
Outline on
</Toggle>
</div>
)
}
export function WithIcon() {
return (
<div className="flex flex-wrap items-center gap-2">
<Toggle aria-label="굵게">
<BoldIcon />
</Toggle>
<Toggle aria-label="기울임" defaultPressed>
<ItalicIcon />
</Toggle>
<Toggle variant="outline">
<StarIcon />
즐겨찾기
</Toggle>
</div>
)
}
const toggleSizes = ["sm", "default", "lg"] as const
function SizeRow({ size }: { size: (typeof toggleSizes)[number] }) {
return (
<div data-toggle-size={size} className="flex flex-wrap items-center gap-2">
{/* 2026-11-13: the text-only toggle shows sample text instead of the size prop value (the row label names the size). */}
<Toggle size={size} variant="outline" aria-label="즐겨찾기"><StarIcon /></Toggle>
<Toggle size={size} variant="outline"><StarIcon />즐겨찾기</Toggle>
<Toggle size={size} variant="outline">알림</Toggle>
</div>
)
}
function Sizes() {
return (
// 3 equal columns on PC (iframe width 1000px and up): sm / default / lg; fewer columns below that (240px minimum).
// Same common indent as the group boards of the page (.sb-page-indent).
<div className="sb-page-indent grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
{toggleSizes.map((size) => <SizeRow key={size} size={size} />)}
</div>
)
}
export function Disabled() {
return (
<div className="flex flex-wrap items-center gap-2">
<Toggle disabled>Off</Toggle>
<Toggle disabled defaultPressed>
On
</Toggle>
</div>
)
}
const block = (state: BoardState) => <StateBoard key={state.id} min={10000} states={[state]} />
// 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.
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(block)}
</div>
)
}
// The 단독 / 연결 그룹 specimens, shared by the Toggle page and the Button page. `short` (Button page, 2026-11-13 user
// request): under its "Groups" H3 the labels drop "연결 그룹 · " so the left label column stays narrow; the Toggle page keeps them.
const groupSpecimens = (idPrefix: string, short?: boolean): BoardState[] => [
// The flex wrapper keeps the Toggle at its own width; as a direct grid child of the board it stretched to the column.
{ id: `${idPrefix}single-toggle`, label: "단독", children: <div className="flex"><Toggle variant="outline">알림</Toggle></div> },
{ id: `${idPrefix}group-single`, label: short ? "단일 선택" : "연결 그룹 · 단일 선택", children: <ToggleGroup type="single" variant="outline" defaultValue="left" aria-label="정렬"><ToggleGroupItem value="left">왼쪽</ToggleGroupItem><ToggleGroupItem value="center">가운데</ToggleGroupItem><ToggleGroupItem value="right">오른쪽</ToggleGroupItem></ToggleGroup> },
{ id: `${idPrefix}group-multiple`, label: short ? "다중 선택" : "연결 그룹 · 다중 선택", children: <ToggleGroup type="multiple" variant="outline" defaultValue={["bold"]} aria-label="글자 스타일"><ToggleGroupItem value="bold">굵게</ToggleGroupItem><ToggleGroupItem value="italic">기울임</ToggleGroupItem></ToggleGroup> },
]
// Button page (2026-11-13, user request): one "Toggle Button" H2 section with 3 equal columns — Variants / States, Groups,
// Sizes — laid out like the Button States matrix above (button.stories.tsx StateMatrix). Each column is a Button group block
// (GroupBlock: H3 14/20/500 secondary on the section edge, 8px to its rows). Each row is its label on the left, in the
// matrix row-label style (14/20/500 secondary, one line), 8px, then the controls; rows are 8px apart and the controls of a
// group start on one line (the label column takes the widest label). `stacked` (the Button matrices' stacked layout, shared
// through the Button page's MatrixFit) switches every group with them to the stacked form: the label above (12/16/400
// secondary, the stacked state name), 4px, then the controls; rows 12px apart. Storybook presentation only.
const rowLabel = "text-sm font-medium text-muted-foreground whitespace-nowrap"
const stackedLabel = "text-xs font-normal text-muted-foreground"
function ButtonPageGroup({ id, label, items, stacked }: { id: string; label: string; items: BoardState[]; stacked?: boolean }) {
return (
<section data-overview-state={id} aria-label={label} className="grid min-w-0 content-start gap-2">
<h3 className="m-0 text-sm font-medium text-muted-foreground">{label}</h3>
<div data-toggle-rows={stacked ? "stacked" : "matrix"} className={stacked ? "grid min-w-0 gap-3" : "grid min-w-0 grid-cols-[max-content_minmax(0,1fr)] gap-2"}>
{items.map((item) => (
<div
key={item.id}
data-overview-state={item.id}
role="group"
aria-labelledby={`${item.id}-label`}
className={stacked ? "grid min-w-0 justify-items-start gap-1" : "col-span-2 grid min-w-0 grid-cols-subgrid items-center justify-items-start"}
>
<span id={`${item.id}-label`} className={stacked ? stackedLabel : rowLabel}>
{item.label}
</span>
{item.children}
</div>
))}
</div>
</section>
)
}
// Same size names as the Button Sizes (Small / Medium / Large); `default` is the medium size.
const sizeLabels = { sm: "Small", default: "Medium", lg: "Large" } as const
// Same columns as the Button treatments above: 3 equal columns from a 1400px preview, one column below that.
function ButtonPageToggleSection({ title, idPrefix, stacked }: { title: string; idPrefix: string; stacked?: boolean }) {
return (
// pt-2: 8px on top of the 28px section gap, so Toggle Button sits 36px below the Button columns.
<div className="pt-2">
<StorySection id={`${idPrefix}button`} title={title}>
<div className="grid grid-cols-1 items-start gap-6 min-[1400px]:grid-cols-3">
<ButtonPageGroup
id={`${idPrefix}variants-states`}
label="Variants / States"
stacked={stacked}
items={[
{ id: `${idPrefix}offon`, label: "Off / On", children: <OffOn /> },
{ id: `${idPrefix}disabled`, label: "Disabled", children: <Disabled /> },
]}
/>
<ButtonPageGroup id={`${idPrefix}groups`} label="Groups" stacked={stacked} items={groupSpecimens(idPrefix, true)} />
<ButtonPageGroup
id={`${idPrefix}sizes`}
label="Sizes"
stacked={stacked}
items={toggleSizes.map((size) => ({ id: `${idPrefix}size-${size}`, label: sizeLabels[size], children: <SizeRow size={size} /> }))}
/>
</div>
</StorySection>
</div>
)
}
// The Toggle sections of a representative page. `titlePrefix` names the component in each section title and `idPrefix`
// keeps the specimen ids (data-overview-state) unique when the sections share a page with other components.
// On PC (iframe width 1000px and up) Variants and States share the whole width in 2 columns, 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.
// `flat` (Button page, 2026-11-13): one "Toggle Button" section (`titlePrefix`) in 3 equal columns, ButtonPageToggleSection;
// `stacked` follows the Button matrices' stacked layout. The Toggle page (no `flat`) keeps the separate sections below.
export function ToggleSections({ titlePrefix, idPrefix = "", flat, stacked }: { titlePrefix?: string; idPrefix?: string; flat?: boolean; stacked?: boolean }) {
if (flat) return <ButtonPageToggleSection title={titlePrefix ?? "Toggle"} idPrefix={idPrefix} stacked={stacked} />
const title = (name: string) => (titlePrefix ? `${titlePrefix} · ${name}` : name)
return (
<>
{/* Each class stays a whole token next to spaces, so Tailwind's source scan still finds the arbitrary grid-cols value. */}
<StorySectionGrid className="grid-cols-1 min-[1000px]:grid-cols-[minmax(max-content,324fr)_minmax(max-content,234fr)]">
<StorySection title={title("Variants")}>
{block({ id: `${idPrefix}offon`, label: "Off / On", children: <OffOn /> })}
</StorySection>
<StorySection title={title("States")}>
{block({ id: `${idPrefix}disabled`, label: "Disabled", children: <Disabled /> })}
</StorySection>
</StorySectionGrid>
{/* With `titlePrefix` the 단독 · 연결 그룹 title reads "단독/연결 그룹" (no second "·" after the prefix). */}
<StorySection title={titlePrefix ? `${titlePrefix} · 단독/연결 그룹` : "단독 · 연결 그룹"}>
<SpecimenGrid states={groupSpecimens(idPrefix)} />
</StorySection>
<StorySection title={title("Sizes")}>
<Sizes />
</StorySection>
</>
)
}
+11 -111
View File
@@ -1,127 +1,27 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { BoldIcon, ItalicIcon, StarIcon } from "lucide-react" import { StarIcon } from "lucide-react"
import { Toggle, ToggleGroup, ToggleGroupItem } from "@hm-design/ui" import { Toggle } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board" import { StoryPage, StoryPageHeader } from "@/components/story-page"
import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page" import { Disabled, OffOn, ToggleSections, WithIcon } from "./toggle-examples"
// 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 specimens live in ./toggle-examples and are also shown on the Button
// specimens also exist as Stories tagged `verification` (hidden from the sidebar). Icon-only toggles carry an `aria-label`. // representative page ("버튼", Toggle Button section), the single sidebar item for both. Every Story here keeps its ID and
// 2026-11-12: toggles side by side keep the Button Story's 8px gap (was 12px). // direct URL but is hidden from the sidebar (tag `verification`).
function OffOn() {
return (
<div className="flex flex-wrap items-center gap-2">
<Toggle>Off</Toggle>
<Toggle defaultPressed>On</Toggle>
<Toggle variant="outline">Outline off</Toggle>
<Toggle variant="outline" defaultPressed>
Outline on
</Toggle>
</div>
)
}
function WithIcon() {
return (
<div className="flex flex-wrap items-center gap-2">
<Toggle aria-label="굵게">
<BoldIcon />
</Toggle>
<Toggle aria-label="기울임" defaultPressed>
<ItalicIcon />
</Toggle>
<Toggle variant="outline">
<StarIcon />
즐겨찾기
</Toggle>
</div>
)
}
function Sizes() {
return (
// 3 equal columns on PC (iframe width 1000px and up): sm / default / lg; fewer columns below that (240px minimum).
// 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">
{(["sm", "default", "lg"] as const).map((size) => (
<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"><StarIcon />즐겨찾기</Toggle>
<Toggle size={size} variant="outline">{size}</Toggle>
</div>
))}
</div>
)
}
function Disabled() {
return (
<div className="flex flex-wrap items-center gap-2">
<Toggle disabled>Disabled off</Toggle>
<Toggle disabled defaultPressed>
Disabled on
</Toggle>
</div>
)
}
const sections = [
{ id: "offon", label: "Off / On", node: <OffOn /> },
{ id: "disabled", label: "Disabled", node: <Disabled /> },
]
const meta = { title: "Components/Form/Toggle", component: Toggle, parameters: { layout: "padded" } } satisfies Meta<typeof Toggle> const meta = { title: "Components/Form/Toggle", component: Toggle, parameters: { layout: "padded" } } satisfies Meta<typeof Toggle>
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Representative-page layout (Storybook presentation only). StateBoard's own auto-fill grid cannot give equal columns that // Former representative Story: kept for its ID and direct URL; hidden from the sidebar (tag `verification`) since the Button
// fill a cell, so each specimen sits in its own single-column StateBoard (min larger than any column) inside a grid. // page shows the same sections.
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 share the whole width in 2 columns,
// 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 = {
tags: ["verification"],
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-[minmax(max-content,324fr)_minmax(max-content,234fr)]"> <ToggleSections />
<StorySection title="Variants">
<StateBoard min={10000} states={[specimen("offon")]} />
</StorySection>
<StorySection title="States">
<StateBoard min={10000} states={[specimen("disabled")]} />
</StorySection>
</StorySectionGrid>
<StorySection title="단독 · 연결 그룹">
<SpecimenGrid
states={[
// 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-multiple", label: "연결 그룹 · 다중 선택", children: <ToggleGroup type="multiple" variant="outline" defaultValue={["bold"]} aria-label="글자 스타일"><ToggleGroupItem value="bold">굵게</ToggleGroupItem><ToggleGroupItem value="italic">기울임</ToggleGroupItem></ToggleGroup> },
]}
/>
</StorySection>
<StorySection title="Sizes">
<Sizes />
</StorySection>
</StoryPage> </StoryPage>
), ),
} }