Merge pull request 'Style/storybook ui refinements' (#45) from style/storybook-ui-refinements into main
Deploy Storybook / deploy (push) Successful in 1m25s

Reviewed-on: #45
This commit was merged in pull request #45.
This commit is contained in:
shkim4 committed 2026-11-12 17:17:23 +09:00
commit f247ec54b0
30 files changed
+687 -399

No files matched your search

+10 -9
View File
@@ -41,7 +41,7 @@ const koreanLabels = {
'components-feedback': '알림·피드백', 'components-feedback': '알림·피드백',
'components-form-button': '버튼', 'components-form-button': '버튼',
'components-form-input': '입력창', 'components-form-input': '입력창',
'components-form-textarea': '여러 줄 입력창', 'components-form-textarea': '텍스트 영역',
'components-form-select': '선택 목록', 'components-form-select': '선택 목록',
'components-form-checkbox': '체크박스', 'components-form-checkbox': '체크박스',
'components-form-radio-group': '라디오 버튼', 'components-form-radio-group': '라디오 버튼',
@@ -78,7 +78,7 @@ const koreanLabels = {
'components-search-date-command': '명령어 검색', 'components-search-date-command': '명령어 검색',
'components-search-date-combobox': '콤보박스', 'components-search-date-combobox': '콤보박스',
'components-search-date-calendar': '달력', 'components-search-date-calendar': '달력',
'components-search-date-date-picker': '날짜 선택기', 'components-search-date-date-picker': '날짜 선택',
'components-feedback-alert': '알림 메시지', 'components-feedback-alert': '알림 메시지',
'components-feedback-sonner': '토스트 알림', 'components-feedback-sonner': '토스트 알림',
patterns: 'UI 패턴', patterns: 'UI 패턴',
@@ -102,10 +102,10 @@ const koreanLabels = {
sw: 'SW UI', sw: 'SW UI',
'sw-header': '헤더', 'sw-header': '헤더',
'sw-header--overview': 'SW 헤더', 'sw-header--overview': 'SW 헤더',
'sw-header--responsive-more': '화면 너비 대응', 'sw-header--responsive-more': '화면 너비',
'sw-viewer--responsive': '화면 너비 대응', 'sw-viewer--responsive': '화면 너비',
'sw-step-header--responsive': '화면 너비 대응', 'sw-step-header--responsive': '화면 너비',
'sw-footer--responsive': '화면 너비 대응', 'sw-footer--responsive': '화면 너비',
'sw-viewer': '뷰어', 'sw-viewer': '뷰어',
'sw-viewer--viewer-header': '뷰어 헤더', 'sw-viewer--viewer-header': '뷰어 헤더',
'sw-step-header': '단계별', 'sw-step-header': '단계별',
@@ -124,9 +124,10 @@ addons.setConfig({
layout: { layout: {
showPanel: false, showPanel: false,
showToolbar: false, showToolbar: false,
// LNB width 246px. recentVisibleSizes keeps the same width when the sidebar is collapsed and expanded again. // LNB width 180px (2026-11-12, was 246px). recentVisibleSizes keeps the same width when the sidebar is collapsed and expanded again.
navSize: 246, // Storybook snaps a manual drag below 240px back to 240px; the initial and re-expanded width stays 180px.
recentVisibleSizes: { navSize: 246 }, navSize: 180,
recentVisibleSizes: { navSize: 180 },
}, },
theme: create({ theme: create({
base: 'light', base: 'light',
+1 -1
View File
@@ -150,7 +150,7 @@ CVA 구조, `data-slot`, `asChild`(Radix `Slot`), `className` 확장, focus-visi
## 11. Storybook ## 11. Storybook
- 대표 Story `All`("Button")이 기본 sidebar의 유일한 Button 항목이다 → 1뎁스. - 대표 Story `All`("Button")이 기본 sidebar의 유일한 Button 항목이다 → 1뎁스.
- `All`의 구역: Sizes → Variants → States(2026-11-09 Overview 제거; 단독 Overview 버튼은 Sizes의 Brand Medium과 같아 통합). 기본 버튼·크기·허용 조합·Brand/Standard 상태를 보여준다. Red Solid와 Outline은 Variants와 Standard States에도 포함되며, 삭제·취소 세트와 Long Label은 verification Story에서 따로 확인한다. PC에서는 Sizes·Variants가 같은 3열(Brand / Standard Solid / Standard Outline)을 쓰고 States의 Solid·Outline 표는 좌우 2열이다. - `All`의 구역: Sizes → Variants → States(2026-11-09 Overview 제거; 단독 Overview 버튼은 Sizes의 Brand Medium과 같아 통합). 기본 버튼·크기·허용 조합·Brand/Standard 상태를 보여준다. Red Solid와 Outline은 Variants와 Standard States에도 포함되며, 삭제·취소 세트와 Long Label은 verification Story에서 따로 확인한다. 2026-11-12 비교형 레이아웃: 상단은 Sizes | Variants 두 섹션이 좌우 반반(미리보기 1080px 이상, 미만은 세로)이며, 각 섹션은 그룹당 한 행(왼쪽 고정 폭 그룹명 + 가로 버튼 행, 640px 이하에서는 그룹명이 위)이다. 하단 States는 Brand | Standard Solid | Standard Outline 3열(미리보기 1250px 이상, 890px 이상은 2열, 미만은 1열)이고, 각 열은 행=상태(Brand·Standard 모두 Default/Hover/Pressed/Disabled 4개), 열=Variants와 같은 순서의 컬러인 매트릭스다. 실제 포인터 상태를 확인하는 Interact 버튼은 verification Story "Brand States"에만 있다. 열은 매트릭스 폭 비율로 전체 폭을 나눠 쓰고 매트릭스는 열 시작에 놓인다. 버튼 사이 간격은 Sizes·Variants·States 모두 가로·세로 8px로 같다. 1열에서 표가 화면보다 넓으면 표 영역 안에서만 가로 스크롤한다. 레이아웃만 바꿨고 버튼 스타일·상태 표현은 그대로다.
- verification(`tags: ["verification"]`, 기본 sidebar 제외): `Roles`, `Sizes`, `States`("Brand States"), `StandardStates`, `LongLabel`, `KoreanLabels`, `IconTextNarrow`, `Destructive`, `Playground`(HM 전용), `Compatibility`(ghost/link, default/outline 별칭). Controls는 조합별 선택을 제한하지 않으므로 §6.1의 허용 조합을 직접 지킨다. Verification은 기술 점검용이며 운영 화면의 사용 위치·세트 규칙은 §5가 우선한다. - verification(`tags: ["verification"]`, 기본 sidebar 제외): `Roles`, `Sizes`, `States`("Brand States"), `StandardStates`, `LongLabel`, `KoreanLabels`, `IconTextNarrow`, `Destructive`, `Playground`(HM 전용), `Compatibility`(ghost/link, default/outline 별칭). Controls는 조합별 선택을 제한하지 않으므로 §6.1의 허용 조합을 직접 지킨다. Verification은 기술 점검용이며 운영 화면의 사용 위치·세트 규칙은 §5가 우선한다.
- 대표 경로/ID: `Components/Form/Button → All` (`components-form-button--all`), `Components/Overlay/Dialog → All` (`components-overlay-dialog--all`), `Components/Overlay/Alert Dialog → All` (`components-overlay-alert-dialog--all`), `Patterns/Data/Filter Bar → Filter Bar` (`patterns-data-filter-bar--all`). 삭제 세트 점검 ID는 `components-form-button--destructive`(verification)다. - 대표 경로/ID: `Components/Form/Button → All` (`components-form-button--all`), `Components/Overlay/Dialog → All` (`components-overlay-dialog--all`), `Components/Overlay/Alert Dialog → All` (`components-overlay-alert-dialog--all`), `Patterns/Data/Filter Bar → Filter Bar` (`patterns-data-filter-bar--all`). 삭제 세트 점검 ID는 `components-form-button--destructive`(verification)다.
- 운영 규칙은 `프로젝트-지침.md` §6.3, §7 참조. - 운영 규칙은 `프로젝트-지침.md` §6.3, §7 참조.
+1
View File
@@ -103,6 +103,7 @@ Popover: Portal, collision, Escape, 바깥 클릭 닫힘, trigger focus 복귀.
## 10. Storybook ## 10. Storybook
- 대표 Story `All`("Combobox"): Overview(Placeholder + search, Remix는 disabled option), Sizes(Medium, 같은 size의 Select / Combobox 비교), States(Selected(controlled), Disabled), Usage(Long option). Label은 Story 조합이다. 대표 화면은 선택값 표시(`value = …`)를 숨기며 숨김 `Controlled`는 유지한다. - 대표 Story `All`("Combobox"): Overview(Placeholder + search, Remix는 disabled option), Sizes(Medium, 같은 size의 Select / Combobox 비교), States(Selected(controlled), Disabled), Usage(Long option). Label은 Story 조합이다. 대표 화면은 선택값 표시(`value = …`)를 숨기며 숨김 `Controlled`는 유지한다.
- 2026-11-12 폼 요소 전시 통일(Input·Select 기준): Sizes(`Default`, `Medium`)·States(`Selected`, `Disabled`)는 상태명을 콤보박스 Label 자리에 한 번만 표시하고 반복되던 H3 상태명·필드 Label(`프레임워크`, `선택됨 (controlled)`, `비활성`)을 없앴다. 접근성 이름은 Trigger의 `aria-label="프레임워크 (상태명)"`(보이는 상태명 포함, 팝업·목록 이름에도 쓰임). 두 섹션은 Input과 같은 그리드(최대 1120px, 최대 3열·열 최소 240px, 좁으면 2→1열, 공통 들여쓰기)를 쓴다. Usage(Long option, Select / Combobox 비교)와 verification Story, Combobox·토큰·API는 변경 없다.
- verification: `Empty`, `Controlled`, `Long Option`. - verification: `Empty`, `Controlled`, `Long Option`.
## 11. 사용자 확정 결정 / 보류 ## 11. 사용자 확정 결정 / 보류
+1
View File
@@ -91,6 +91,7 @@ Popover: Portal, collision 처리, Escape, 바깥 클릭 닫힘, trigger focus
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Date Picker"): Empty, Selected(`2026.10.06`), 기본 형식(`2026.01.09`)/custom override, Controlled(대표 화면은 값 표시 숨김, 숨김 `Controlled`는 표시), Medium, Disabled dates(5일 이전·일요일), Disabled를 한 페이지에 둔다. Label은 Story 조합이며 `today`는 Calendar prop으로 2026-10-02에 고정. - 대표 Story `All`("Date Picker"): Empty, Selected(`2026.10.06`), 기본 형식(`2026.01.09`)/custom override, Controlled(대표 화면은 값 표시 숨김, 숨김 `Controlled`는 표시), Medium, Disabled dates(5일 이전·일요일), Disabled를 한 페이지에 둔다. Label은 Story 조합이며 `today`는 Calendar prop으로 2026-10-02에 고정.
- 2026-11-12 폼 요소 전시 통일(Input·Select 기준): Sizes(`Default`, `Medium`)·States(`Selected`, `Controlled`, `Disabled dates`, `Disabled`)는 상태명을 날짜 선택 Label 자리에 한 번만 표시하고 반복되던 H3 상태명·필드 Label을 없앴다. 접근성 이름은 Trigger의 `aria-label="시작일|마감일|날짜 (상태명)"`(보이는 상태명 포함). `Disabled dates`의 규칙 문구 "5일 이전과 일요일은 선택 불가"는 Trigger 아래 도움말(12/18/400 secondary)로 옮기고 `aria-describedby`로 연결했다. 두 섹션은 Input과 같은 그리드(최대 1120px, 최대 3열·열 최소 240px, 좁으면 2→1열, 공통 들여쓰기)를 쓴다. Usage(형식 2종)와 verification Story, DatePicker·토큰·API는 변경 없다. Storybook 사이드바 표시명은 `날짜 선택`(Story ID 변경 없음).
- verification: `Empty`, `Selected`, `Controlled`. - verification: `Empty`, `Selected`, `Controlled`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+1
View File
@@ -85,6 +85,7 @@ native `<input>` 동작 전체(`type`, `file:` 스타일 class, `placeholder`),
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Input"): Sizes(Small / Medium / Large 비교) → States(`focus`, `disabled`, `readonly`) → Validation(`error`, `error-message`). Focus는 `play` 함수로 실제 focus를 준다. 2026-10-22 Overview(`default`, size 생략 = Large)는 Sizes의 Large와 같아 대표 화면에서 뺐고, 크기별 수치 목록·설명은 숨김 `Sizes`("Compare Sizes")에 남겼다. size 생략 시 Large(§5)는 그대로다. - 대표 Story `All`("Input"): Sizes(Small / Medium / Large 비교) → States(`focus`, `disabled`, `readonly`) → Validation(`error`, `error-message`). Focus는 `play` 함수로 실제 focus를 준다. 2026-10-22 Overview(`default`, size 생략 = Large)는 Sizes의 Large와 같아 대표 화면에서 뺐고, 크기별 수치 목록·설명은 숨김 `Sizes`("Compare Sizes")에 남겼다. size 생략 시 Large(§5)는 그대로다.
- 2026-11-12 대표 화면 정리: States·Validation도 Sizes처럼 상태명(Focus / Disabled / Readonly / Error / Error Message)을 입력창 Label 자리에 한 번만 표시하고, 반복되던 `Project name` Label과 별도 그룹 제목은 없앴다. 입력 필드의 접근성 이름은 `aria-label="Project name (상태명)"`으로 유지한다(보이는 상태명 포함). 세 섹션은 같은 그리드(최대 1120px, 최대 3열·열 최소 240px, 좁으면 2→1열)를 쓰며 Validation 2개는 States 앞 두 열과 정렬된다. Story 전시만 바뀌었고 Input 컴포넌트·토큰·API·verification Story는 변경 없다.
- verification(`tags: ["verification"]`): `Default`, `Focus`, `Error`, `InvalidFocus`, `ErrorMessage`, `Required`, `Disabled`, `Readonly`, `Sizes`("Compare Sizes"), `Small`, `Medium`, `Large`, `Playground`(HM 전용). - verification(`tags: ["verification"]`): `Default`, `Focus`, `Error`, `InvalidFocus`, `ErrorMessage`, `Required`, `Disabled`, `Readonly`, `Sizes`("Compare Sizes"), `Small`, `Medium`, `Large`, `Playground`(HM 전용).
- Error Message·Required 표시는 Story 안의 조합 예시이며 Input 컴포넌트의 일부가 아니다. - Error Message·Required 표시는 Story 안의 조합 예시이며 Input 컴포넌트의 일부가 아니다.
- sidebar는 `All` 하나만 보이는 1뎁스. 운영 규칙은 지침 §6.3, §7 참조. - sidebar는 `All` 하나만 보이는 1뎁스. 운영 규칙은 지침 §6.3, §7 참조.
+1 -1
View File
@@ -98,7 +98,7 @@ Radix Root/Trigger/Portal/Content/Viewport/Item/ItemText 구조, 키보드 조
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Select"): Sizes(Medium / Large 비교, 각 Trigger로 실제 목록 열기) → States(`selected`, `disabled`, `disabled-placeholder`) → Validation(`invalid`, `invalid-disabled`; 2026-11-09 `Invalid / Focus`에서 제목 변경, Focus 예시가 없다). PC에서는 세 구역이 같은 3열 칸을 쓰고 Sizes·Validation은 첫 두 칸만 채운다. 2026-10-22 Overview의 `default`·로드 시 열린 `open` 예시는 대표 화면에서 뺐다. 열린 목록은 숨김 `Open`, 크기별 수치 목록은 숨김 `Sizes`("Compare Sizes")에서 확인한다. size 생략 시 Large(§5)는 그대로다. - 대표 Story `All`("Select"): Sizes(Medium / Large 비교, 각 Trigger로 실제 목록 열기) → States(`selected`, `disabled`, `disabled-placeholder`) → Validation(`invalid`, `invalid-disabled`; 2026-11-09 `Invalid / Focus`에서 제목 변경, Focus 예시가 없다). PC에서는 세 구역이 같은 3열 칸을 쓰고 Sizes·Validation은 첫 두 칸만 채운다. 2026-11-12 폼 요소 전시 통일(Input 기준): States·Validation은 상태명을 Select Label 자리에 한 번만 표시하고 반복되던 `Status` Label과 별도 그룹 제목을 없앴다. 접근성 이름은 Trigger의 `aria-label="Status (상태명)"`(보이는 상태명 포함). 세 구역은 Input과 같은 그리드(최대 1120px, 최대 3열·열 최소 240px, 좁으면 2→1열)를 써서 Input과 같은 필드 폭을 가진다. Select·토큰·API·verification Story는 변경 없다. 2026-10-22 Overview의 `default`·로드 시 열린 `open` 예시는 대표 화면에서 뺐다. 열린 목록은 숨김 `Open`, 크기별 수치 목록은 숨김 `Sizes`("Compare Sizes")에서 확인한다. size 생략 시 Large(§5)는 그대로다.
- verification(`tags: ["verification"]`): `Default`, `Open`, `Selected`, `Disabled`, `Sizes`("Compare Sizes"), `Medium`, `Large`, `LongContent`, `Scroll`, `Playground`(HM 전용). - verification(`tags: ["verification"]`): `Default`, `Open`, `Selected`, `Disabled`, `Sizes`("Compare Sizes"), `Medium`, `Large`, `LongContent`, `Scroll`, `Playground`(HM 전용).
- `Disabled` Story 구성: Trigger 비활성 상태들(placeholder 포함). - `Disabled` Story 구성: Trigger 비활성 상태들(placeholder 포함).
- `InvalidFocus` verification Story: 앞/뒤 버튼 사이의 Invalid Trigger와 Disabled Invalid Trigger. Tab / Shift+Tab, Enter / Space, Escape 및 focus return을 실제 키보드로 확인한다. - `InvalidFocus` verification Story: 앞/뒤 버튼 사이의 Invalid Trigger와 Disabled Invalid Trigger. Tab / Shift+Tab, Enter / Space, Escape 및 focus return을 실제 키보드로 확인한다.
+1
View File
@@ -87,6 +87,7 @@ native `<textarea>` 동작 전체, `data-slot`, `cn(..., className)`, `transitio
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Textarea"): States `default`, `focus`, `readonly`, `disabled`와 Validation `error`, `error-message`. `multiline`·`resize` 예시는 대표 페이지에서 빼고 숨김 verification Story `Multiline`·`ResizeNone`으로 옮겼다(2026-11-09). Focus는 `play` 함수로 실제 focus를 준다. Hover는 Default와 같아(TEXTAREA-05) 2026-10-22 대표 화면의 중복 `hover` 예시를 뺐다. 실제 포인터 확인은 Default 예시에서 한다. - 대표 Story `All`("Textarea"): States `default`, `focus`, `readonly`, `disabled`와 Validation `error`, `error-message`. `multiline`·`resize` 예시는 대표 페이지에서 빼고 숨김 verification Story `Multiline`·`ResizeNone`으로 옮겼다(2026-11-09). Focus는 `play` 함수로 실제 focus를 준다. Hover는 Default와 같아(TEXTAREA-05) 2026-10-22 대표 화면의 중복 `hover` 예시를 뺐다. 실제 포인터 확인은 Default 예시에서 한다.
- 2026-11-12 폼 요소 전시 통일(Input 기준): States·Validation은 상태명(Default / Focus / Readonly / Disabled / Error / Error Message)을 Textarea Label 자리에 한 번만 표시하고, 반복되던 `Description` Label과 별도 그룹 제목은 없앴다. 접근성 이름은 `aria-label="Description (상태명)"`(보이는 상태명 포함). 그리드는 기존 PC 4열(열 최소 220px, 좁으면 축소)을 유지하며 공통 들여쓰기(`.sb-page-indent`)를 쓴다. Textarea·토큰·API·verification Story는 변경 없다.
- verification(`tags: ["verification"]`): `Default`, `Focus`, `Error`, `InvalidFocus`, `ErrorMessage`, `Required`, `Multiline`("Multiline · Wrap"), `ResizeNone`("Resize · none"), `Readonly`, `Disabled`, `LongContent`, `Playground`(HM 전용). `Multiline`·`ResizeNone`은 2026-11-09에 대표 Story의 Usage 구역에서 옮긴 것이다. - verification(`tags: ["verification"]`): `Default`, `Focus`, `Error`, `InvalidFocus`, `ErrorMessage`, `Required`, `Multiline`("Multiline · Wrap"), `ResizeNone`("Resize · none"), `Readonly`, `Disabled`, `LongContent`, `Playground`(HM 전용). `Multiline`·`ResizeNone`은 2026-11-09에 대표 Story의 Usage 구역에서 옮긴 것이다.
- Error Message·Required 표시는 Story 안의 조합 예시이며 Textarea 컴포넌트의 일부가 아니다. - Error Message·Required 표시는 Story 안의 조합 예시이며 Textarea 컴포넌트의 일부가 아니다.
+1
View File
@@ -70,6 +70,7 @@ Radix 구조·`aria-pressed`·`data-state`·Enter/Space·controlled/uncontrolled
## 9. Storybook ## 9. Storybook
- 대표 Story `All`("Toggle"): Variants(Off/On, default·outline)와 States(Disabled off/on), 단독 · 연결 그룹(단독 Toggle, `spacing=0` ToggleGroup 단일·다중 선택), Sizes(2026-11-09 Overview를 Variants로 바꾸고 PC 3열 기준으로 재배치). 2026-10-22 Icon-only·icon + text 예시는 숨김 `IconOnly`로만 확인하고, Controlled(`pressed` 표시)·className 확장 예시는 대표 화면에서 제거했다. `pressed` / `onPressedChange`와 `className` 확장 API(§5)는 그대로다. - 대표 Story `All`("Toggle"): Variants(Off/On, default·outline)와 States(Disabled off/on), 단독 · 연결 그룹(단독 Toggle, `spacing=0` ToggleGroup 단일·다중 선택), Sizes(2026-11-09 Overview를 Variants로 바꾸고 PC 3열 기준으로 재배치). 2026-10-22 Icon-only·icon + text 예시는 숨김 `IconOnly`로만 확인하고, Controlled(`pressed` 표시)·className 확장 예시는 대표 화면에서 제거했다. `pressed` / `onPressedChange`와 `className` 확장 API(§5)는 그대로다.
- 2026-11-12 폼 요소 전시 통일: 나란히 놓인 토글 간격을 Button Story와 같은 8px로 맞췄다(Off/On·Disabled·Sizes·숨김 `IconOnly`, 기존 12px). `단독` 예시는 열 너비로 늘어나지 않고 Toggle 자체 너비를 유지한다. 상단 Variants·States는 기존 3열 중 두 칸 배치 대신 2열로 바꿔 전체 폭을 각 행의 한 줄 폭 비율(324:234, 최소 max-content)로 나눈다. 미리보기 폭 1000px 이상(브라우저 1280px + LNB 180px 포함)에서 Variants 4개가 한 줄로 유지되고, 그 미만은 1열로 쌓여 자연스럽게 줄바꿈한다. 이 때문에 States 시작 위치는 아래 `단독 · 연결 그룹` 3열과 맞추지 않는다. Toggle·토큰·API는 변경 없다.
- verification: `States`, `Icon Only`, `Disabled`. - verification: `States`, `Icon Only`, `Disabled`.
## 10. 사용자 확정 결정 ## 10. 사용자 확정 결정
+3 -1
View File
@@ -25,6 +25,7 @@
- `@hm-design/ui/theme.css`는 차트 색 별칭(`--hm-chart-*`)이 가리키는 `--hm-sem-*` 토큰과 Noto Sans KR 글꼴을 제공합니다. - `@hm-design/ui/theme.css`는 차트 색 별칭(`--hm-chart-*`)이 가리키는 `--hm-sem-*` 토큰과 Noto Sans KR 글꼴을 제공합니다.
- 차트 스타일(`@hm-design/chart/style.css`)은 JS 진입점(`@hm-design/chart`, `@hm-design/chart/examples`)이 자동으로 불러오므로 따로 import하지 않습니다. - 차트 스타일(`@hm-design/chart/style.css`)은 JS 진입점(`@hm-design/chart`, `@hm-design/chart/examples`)이 자동으로 불러오므로 따로 import하지 않습니다.
- `<html>`·`<body>`에 배경·글꼴을 정하는 것은 소비 앱의 몫입니다(차트는 전역 규칙을 넣지 않습니다). - `<html>`·`<body>`에 배경·글꼴을 정하는 것은 소비 앱의 몫입니다(차트는 전역 규칙을 넣지 않습니다).
- 흰색이 아닌 배경(카드 없이 콘텐츠 배경 위 등)에 차트를 바로 놓을 때는 차트를 감싼 요소에 `--hm-chart-surface`를 그 배경색으로 지정합니다. 예: CSS `.report-charts { --hm-chart-surface: var(--hm-sem-bg-content); }` 또는 Tailwind `[--hm-chart-surface:var(--hm-sem-bg-content)]`. 조각·세그먼트 사이 간격, 포인트 Dot 외곽선, Label halo, 래디얼 미터(`HMRadialMeterChart`) 중앙 원이 이 색을 써서 배경처럼 보입니다. 지정하지 않으면 기본값 흰색(`--hm-sem-bg-surface`)입니다.
## 2. 진입점과 차트 고르기 ## 2. 진입점과 차트 고르기
@@ -116,7 +117,7 @@ npm run storybook # Chart 그룹: Bar / Line / Donut / P
### 예제 컴포넌트 ### 예제 컴포넌트
소비 예제(`examples/chart/main.tsx`)와 Storybook은 `@hm-design/chart/examples`의 예제 카드를 사용합니다: Bar(`BarChartExample`, `StackedBarChartExample`, `HorizontalBarChartExample`, `SignedBarChartExample`), Line(`LineChartExample` 외 Curve·Straight·Step 변형), Donut(`DonutChartExample` 외 Plain·Center·Active·Interactive·`DonutSolidExample`(입체형)), Pie(`PiePlainExample`, `PieLabelExample`, `PieLabelListExample`, `PieStackedExample`, `PieSolidExample`(입체형)), Radar(`RadarPlainExample`, `RadarDotExample`, `RadarLinesOnlyExample`, `RadarCustomLabelExample`), Area(`AreaCurveExample` 외 Straight·Step·Stacked·Expanded), Radial(`RadialSimpleExample`, `RadialLabelExample`, `RadialGridExample`, `RadialTextExample`, `RadialShapeExample`, `RadialStackedExample`), 묶음 `ChartExamples`. 예시 데이터(`sampleBar`, `samplePie`, `sampleRadial` 등)는 같은 진입점에서 export됩니다. 예제 카드는 `ExampleCard`(내부 컴포넌트)로 감싸며 실제 업무 데이터가 아님을 표시합니다. 소비 예제(`examples/chart/main.tsx`)는 `@hm-design/chart/examples`의 예제 카드를 사용합니다. Storybook(`Chart` 그룹)은 같은 예시 데이터·props로 공개 차트 컴포넌트(`HM*Chart`)를 카드 없이 오픈형으로 전시합니다(Story 전용 레이아웃 `src/chart/chart-story.tsx`). 예제 카드: Bar(`BarChartExample`, `StackedBarChartExample`, `HorizontalBarChartExample`, `SignedBarChartExample`), Line(`LineChartExample` 외 Curve·Straight·Step 변형), Donut(`DonutChartExample` 외 Plain·Center·Active·Interactive·`DonutSolidExample`(입체형)), Pie(`PiePlainExample`, `PieLabelExample`, `PieLabelListExample`, `PieStackedExample`, `PieSolidExample`(입체형)), Radar(`RadarPlainExample`, `RadarDotExample`, `RadarLinesOnlyExample`, `RadarCustomLabelExample`), Area(`AreaCurveExample` 외 Straight·Step·Stacked·Expanded), Radial(`RadialSimpleExample`, `RadialLabelExample`, `RadialGridExample`, `RadialTextExample`, `RadialShapeExample`, `RadialStackedExample`), 묶음 `ChartExamples`. 예시 데이터(`sampleBar`, `samplePie`, `sampleRadial` 등)는 같은 진입점에서 export됩니다. 예제 카드는 `ExampleCard`(내부 컴포넌트)로 감싸며 실제 업무 데이터가 아님을 표시합니다.
## 공식 shadcn Chart와의 차이 ## 공식 shadcn Chart와의 차이
@@ -125,6 +126,7 @@ npm run storybook # Chart 그룹: Bar / Line / Donut / P
## 제한 ## 제한
- 차트 전용 HM 디자인 규격은 아직 없습니다. 색은 기존 HM 팔레트 Semantic 토큰의 별칭이며(`src/chart.css`), 차트 Token은 이 패키지 안에서만 정의합니다. - 차트 전용 HM 디자인 규격은 아직 없습니다. 색은 기존 HM 팔레트 Semantic 토큰의 별칭이며(`src/chart.css`), 차트 Token은 이 패키지 안에서만 정의합니다.
- 배경 관련 차트 토큰(2026-11-12): `--hm-chart-track`(기본 `--hm-sem-palette-warm-neutral-light` #E0DACE)은 래디얼 비활성 트랙(`HMRadialBarChart` 배경 트랙, `HMRadialMeterChart` 링 트랙)과 가로 막대 Hover 띠입니다. 공식 shadcn 소스는 `muted`(#F4F1EA)인데, 콘텐츠 배경 #F5F0E5와 거의 같아 보이지 않으므로 `chart.css`에서 덮어씁니다(`chart.tsx`는 그대로). `--hm-chart-surface`(기본 `--hm-sem-bg-surface` 흰색)는 차트가 놓인 면의 색입니다(위 §1 참고). `--hm-chart-bar-name-halo`는 선택 override로 남았고, 지정하지 않으면 `--hm-chart-surface`를 씁니다. 흰 배경에서는 트랙·Hover 띠만 한 단계 진해지고 나머지는 이전과 같습니다.
- 글자색은 Admin 텍스트 위계를 따릅니다: 툴팁 값·중앙값·막대 이름/값은 `--hm-sem-fg-primary`(#212121), 축 눈금(Cartesian·Radar 기본 축 이름)·범례·툴팁 이름·중앙 캡션은 `--hm-chart-axis-text` → `--hm-sem-fg-secondary`(#616161)입니다. - 글자색은 Admin 텍스트 위계를 따릅니다: 툴팁 값·중앙값·막대 이름/값은 `--hm-sem-fg-primary`(#212121), 축 눈금(Cartesian·Radar 기본 축 이름)·범례·툴팁 이름·중앙 캡션은 `--hm-chart-axis-text` → `--hm-sem-fg-secondary`(#616161)입니다.
- 접근성 후속 항목(2026-11-11 기록, 미수정): Line·Area 포인트 값 Label은 시리즈 색을 그대로 씁니다. 기본 시리즈 4 `#ECD4A2`(흰색 대비 1.45:1)·5 `#9A9284`(3.08:1)는 4.5:1 미만입니다. 시리즈 색은 유지하며 Label 색 정책은 별도 결정입니다. - 접근성 후속 항목(2026-11-11 기록, 미수정): Line·Area 포인트 값 Label은 시리즈 색을 그대로 씁니다. 기본 시리즈 4 `#ECD4A2`(흰색 대비 1.45:1)·5 `#9A9284`(3.08:1)는 4.5:1 미만입니다. 시리즈 색은 유지하며 Label 색 정책은 별도 결정입니다.
- `@hm-design/chart/examples`의 `ExampleCard`는 공개 Export가 아닙니다(예제 카드를 통해서만 사용). - `@hm-design/chart/examples`의 `ExampleCard`는 공개 Export가 아닙니다(예제 카드를 통해서만 사용).
+21 -5
View File
@@ -34,6 +34,14 @@
--hm-chart-axis-text: var(--hm-sem-fg-secondary); --hm-chart-axis-text: var(--hm-sem-fg-secondary);
--hm-chart-center-value: var(--hm-sem-fg-primary); --hm-chart-center-value: var(--hm-sem-fg-primary);
--hm-chart-font-family: var(--hm-font-family-base); --hm-chart-font-family: var(--hm-font-family-base);
/* Inactive track of the radial charts and the hover band behind horizontal bars: a warm neutral one step darker than the
muted background, so it stays visible on white and on the warm content background (#F5F0E5) without competing with data. */
--hm-chart-track: var(--hm-sem-palette-warm-neutral-light);
/* The surface the chart sits on. Gaps between slices / segments, the ring around point Dots, label halos and the circle that
cuts the radial meter track into a ring use it, so they read as background. White by default (charts on a card); a chart
placed directly on another background sets it on its container, e.g. --hm-chart-surface: var(--hm-sem-bg-content). */
--hm-chart-surface: var(--hm-sem-bg-surface);
} }
[data-slot="chart"] { [data-slot="chart"] {
@@ -53,6 +61,12 @@
stroke: var(--hm-chart-grid-color); stroke: var(--hm-chart-grid-color);
stroke-opacity: 0.6; stroke-opacity: 0.6;
} }
/* Track and hover band. The official chart.tsx paints both with `muted` (#F4F1EA), which disappears on the content background;
this unlayered rule wins over its layered utilities, so the official source stays unchanged. */
[data-slot="chart"] .recharts-radial-bar-background-sector,
[data-slot="chart"] .recharts-rectangle.recharts-tooltip-cursor {
fill: var(--hm-chart-track);
}
[data-slot="chart-tooltip-content"] { [data-slot="chart-tooltip-content"] {
border: 1px solid var(--hm-chart-tooltip-border-color); border: 1px solid var(--hm-chart-tooltip-border-color);
@@ -87,10 +101,12 @@
} }
/* Horizontal bar chart labels. The item name sits on a gradient that starts at 30% of the series color, so the dark text gets /* Horizontal bar chart labels. The item name sits on a gradient that starts at 30% of the series color, so the dark text gets
a light halo (the card surface) to stay readable from the faint left end to the opaque right end. */ a light halo (the chart surface) to stay readable from the faint left end to the opaque right end.
--hm-chart-bar-name-halo stays an optional override, but it is no longer declared here: a value declared on :root would resolve
--hm-chart-surface there, so a container that sets --hm-chart-surface would not reach the halo. The rules below fall back to
--hm-chart-surface (white by default, the same color as before). */
:root, [data-hm-theme] { :root, [data-hm-theme] {
--hm-chart-bar-name-text: var(--hm-sem-fg-primary); --hm-chart-bar-name-text: var(--hm-sem-fg-primary);
--hm-chart-bar-name-halo: var(--hm-sem-bg-surface);
--hm-chart-bar-value-text: var(--hm-sem-fg-primary); --hm-chart-bar-value-text: var(--hm-sem-fg-primary);
} }
[data-slot="chart-bar-name"], [data-slot="chart-bar-name"],
@@ -101,7 +117,7 @@
} }
[data-slot="chart-bar-name"] { [data-slot="chart-bar-name"] {
fill: var(--hm-chart-bar-name-text); fill: var(--hm-chart-bar-name-text);
stroke: var(--hm-chart-bar-name-halo); stroke: var(--hm-chart-bar-name-halo, var(--hm-chart-surface));
stroke-width: 3px; stroke-width: 3px;
stroke-linejoin: round; stroke-linejoin: round;
paint-order: stroke; paint-order: stroke;
@@ -136,7 +152,7 @@
} }
[data-slot="chart-slice-label"] { [data-slot="chart-slice-label"] {
fill: var(--hm-chart-bar-name-text); fill: var(--hm-chart-bar-name-text);
stroke: var(--hm-chart-bar-name-halo); stroke: var(--hm-chart-bar-name-halo, var(--hm-chart-surface));
stroke-width: 3px; stroke-width: 3px;
stroke-linejoin: round; stroke-linejoin: round;
paint-order: stroke; paint-order: stroke;
@@ -153,7 +169,7 @@
/* Radial: item names curved along the start of each bar. The halo keeps the text readable on pale bar colors. */ /* Radial: item names curved along the start of each bar. The halo keeps the text readable on pale bar colors. */
.hm-radial-name { .hm-radial-name {
fill: var(--hm-chart-bar-name-text); fill: var(--hm-chart-bar-name-text);
stroke: var(--hm-chart-bar-name-halo); stroke: var(--hm-chart-bar-name-halo, var(--hm-chart-surface));
stroke-width: 2px; stroke-width: 2px;
stroke-linejoin: round; stroke-linejoin: round;
paint-order: stroke; paint-order: stroke;
@@ -165,7 +165,7 @@ export function HMDonutChart({ data, totalLabel, activeId, onActiveChange, label
// Room for the active slice to grow outwards without leaving the chart. // Room for the active slice to grow outwards without leaving the chart.
outerRadius={ring.outer} outerRadius={ring.outer}
paddingAngle={paddingAngle(selectable)} paddingAngle={paddingAngle(selectable)}
stroke="var(--hm-sem-bg-surface)" stroke="var(--hm-chart-surface)"
strokeWidth={2} strokeWidth={2}
onClick={onActiveChange ? (_slice, index) => onActiveChange(data[index].id) : undefined} onClick={onActiveChange ? (_slice, index) => onActiveChange(data[index].id) : undefined}
shape={(props: PieSectorShapeProps, index: number) => ( shape={(props: PieSectorShapeProps, index: number) => (
@@ -50,7 +50,7 @@ export function PointDot({
cy={y} cy={y}
r={radius} r={radius}
fill={pointColor(payload, colorKey, fallback)} fill={pointColor(payload, colorKey, fallback)}
stroke="var(--hm-sem-bg-surface)" stroke="var(--hm-chart-surface)"
strokeWidth={2} strokeWidth={2}
/> />
) )
+3 -3
View File
@@ -126,7 +126,7 @@ export function HMPieChart({ data, labels, label, className }: HMPieChartProps)
dataKey="value" dataKey="value"
nameKey="id" nameKey="id"
outerRadius={outside ? "58%" : "80%"} outerRadius={outside ? "58%" : "80%"}
stroke="var(--hm-sem-bg-surface)" stroke="var(--hm-chart-surface)"
strokeWidth={2} strokeWidth={2}
label={ label={
outside ? <PieOutsideLabel items={data} /> : labels === "inside" ? <PieSliceLabel items={data} /> : false outside ? <PieOutsideLabel items={data} /> : labels === "inside" ? <PieSliceLabel items={data} /> : false
@@ -185,8 +185,8 @@ export function HMStackedPieChart({ rings, label, className }: HMStackedPieChart
<PieChart accessibilityLayer> <PieChart accessibilityLayer>
<ChartTooltip content={<RingTooltip />} /> <ChartTooltip content={<RingTooltip />} />
<ChartLegend itemSorter={null} content={<RingLegend rings={rings} items={items} />} /> <ChartLegend itemSorter={null} content={<RingLegend rings={rings} items={items} />} />
<Pie data={slices[0]} dataKey="value" nameKey="id" outerRadius="46%" stroke="var(--hm-sem-bg-surface)" strokeWidth={2} /> <Pie data={slices[0]} dataKey="value" nameKey="id" outerRadius="46%" stroke="var(--hm-chart-surface)" strokeWidth={2} />
<Pie data={slices[1]} dataKey="value" nameKey="id" innerRadius="54%" outerRadius="76%" stroke="var(--hm-sem-bg-surface)" strokeWidth={2} /> <Pie data={slices[1]} dataKey="value" nameKey="id" innerRadius="54%" outerRadius="76%" stroke="var(--hm-chart-surface)" strokeWidth={2} />
</PieChart> </PieChart>
</ChartContainer> </ChartContainer>
) )
@@ -193,12 +193,12 @@ export function HMRadialMeterChart({
return ( return (
<ChartContainer config={config} role="group" aria-label={label} className={cn(CHART_BOX, className)}> <ChartContainer config={config} role="group" aria-label={label} className={cn(CHART_BOX, className)}>
<RadialBarChart data={[row]} startAngle={startAngle} endAngle={endAngle} innerRadius={innerRadius} outerRadius={outerRadius}> <RadialBarChart data={[row]} startAngle={startAngle} endAngle={endAngle} innerRadius={innerRadius} outerRadius={outerRadius}>
{/* Official: the first circle is the muted track, the second (smaller) one is the surface color that cuts it into a ring. */} {/* Official: the first circle is the track (official muted, HM --hm-chart-track), the second (smaller) one is the chart surface color that cuts it into a ring. */}
<PolarGrid <PolarGrid
gridType="circle" gridType="circle"
radialLines={false} radialLines={false}
stroke="none" stroke="none"
className="first:fill-muted last:fill-[var(--hm-sem-bg-surface)]" className="first:fill-[var(--hm-chart-track)] last:fill-[var(--hm-chart-surface)]"
polarRadius={[...trackRadii]} polarRadius={[...trackRadii]}
/> />
<PolarAngleAxis type="number" domain={[0, maxOf([item.value])]} tick={false} axisLine={false} /> <PolarAngleAxis type="number" domain={[0, maxOf([item.value])]} tick={false} axisLine={false} />
@@ -269,7 +269,7 @@ export function HMRadialStackedChart({
stackId="a" stackId="a"
fill={`var(--color-${item.key})`} fill={`var(--color-${item.key})`}
cornerRadius={5} cornerRadius={5}
stroke="var(--hm-sem-bg-surface)" stroke="var(--hm-chart-surface)"
strokeWidth={2} strokeWidth={2}
/> />
))} ))}
@@ -89,7 +89,7 @@ function SolidSlice({ sector, hole, sideColor, topColor, center }: SolidSlicePro
const frontTo = Math.min(to, 360) const frontTo = Math.min(to, 360)
const backFrom = Math.max(from, 0) const backFrom = Math.max(from, 0)
const backTo = Math.min(to, 180) const backTo = Math.min(to, 180)
const seam = "var(--hm-sem-bg-surface)" const seam = "var(--hm-chart-surface)"
// The seam between slices is a surface-colored stroke; on a very thin slice it thins out so the slice stays visible. // The seam between slices is a surface-colored stroke; on a very thin slice it thins out so the slice stays visible.
const arcPx = (to - from) * RAD * p.rx const arcPx = (to - from) * RAD * p.rx
const seamTop = arcPx < 4 ? 0.5 : arcPx < 10 ? 1 : 2 const seamTop = arcPx < 4 ? 0.5 : arcPx < 10 ? 1 : 2
+37 -40
View File
@@ -1,18 +1,12 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { import { HMAreaChart } from "@hm-design/chart"
AreaCurveDotLabelExample, import { sampleLineCompare, sampleLinePointColors } from "@hm-design/chart/examples"
AreaCurveExample, import { StoryPage, StoryPageHeader, StorySectionGrid } from "@/components/story-page"
AreaExpandedExample, import { ChartSampleNotice, ChartSpecimen, chartGrid, chartPageClass } from "./chart-story"
AreaStackedExample,
AreaStepDotLabelExample,
AreaStepExample,
AreaStraightColorLabelExample,
AreaStraightExample,
} from "@hm-design/chart/examples"
import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page"
// Sample data only. The eight examples share the Line Chart data: connection styles as a plain area (left) and with Dots and // Sample data only. The eight charts share the Line Chart data: connection styles as a plain area and with Dots and value
// value Labels (right), then Stacked (running total) and Expanded (each month normalized to 100%). // Labels, then Stacked (running total) and Expanded (each month normalized to 100%). Same data and props as the
// @hm-design/chart/examples cards; open layout (no card): see chart-story.tsx.
const meta = { const meta = {
title: "Chart/Area Chart", title: "Chart/Area Chart",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
@@ -23,34 +17,37 @@ type Story = StoryObj<typeof meta>
export const Area: Story = { export const Area: Story = {
name: "Area Chart", name: "Area Chart",
render: () => ( render: () => (
<StoryPage> <StoryPage className={chartPageClass}>
<StoryPageHeader title="Area Chart" /> <StoryPageHeader title="Area Chart" />
{/* Same grid and order as Line Chart, plus a Stacked | Expanded row. */} <ChartSampleNotice />
<StorySectionGrid className="grid-cols-1 lg:auto-rows-fr lg:grid-cols-2 *:flex *:min-w-0 *:flex-col [&_.sb-page-section-body]:flex-1"> {/* Same grid and order as Line Chart, then a Stacked | Expanded row. */}
<StorySection title="Curve"> <StorySectionGrid className={chartGrid.pairs}>
<AreaCurveExample /> <ChartSpecimen title="Curve" description="monotone">
</StorySection> <HMAreaChart {...sampleLineCompare} curve="monotone" label="월별 접수·완료 곡선형 영역 차트(예시 데이터)" />
<StorySection title="Curve · Dot + Label"> </ChartSpecimen>
<AreaCurveDotLabelExample /> <ChartSpecimen title="Curve · Dot + Label" description="monotone · Dot과 값 Label">
</StorySection> <HMAreaChart {...sampleLineCompare} curve="monotone" pointLabels label="월별 접수·완료 곡선형 Dot·값 Label 영역 차트(예시 데이터)" />
<StorySection title="Straight"> </ChartSpecimen>
<AreaStraightExample /> <ChartSpecimen title="Straight" description="linear">
</StorySection> <HMAreaChart {...sampleLineCompare} curve="linear" label="월별 접수·완료 직선형 영역 차트(예시 데이터)" />
<StorySection title="Straight · Color Dot + Label"> </ChartSpecimen>
<AreaStraightColorLabelExample /> <ChartSpecimen title="Straight · Color Dot + Label" description="linear · Dot 개별 색상 + 값 Label">
</StorySection> <HMAreaChart {...sampleLinePointColors} curve="linear" pointLabels label="월별 접수·완료 직선형 포인트별 색상 영역 차트(예시 데이터)" />
<StorySection title="Step"> </ChartSpecimen>
<AreaStepExample /> <ChartSpecimen title="Step" description="step">
</StorySection> <HMAreaChart {...sampleLineCompare} curve="step" label="월별 접수·완료 계단형 영역 차트(예시 데이터)" />
<StorySection title="Step · Dot + Label"> </ChartSpecimen>
<AreaStepDotLabelExample /> <ChartSpecimen title="Step · Dot + Label" description="step · Dot과 값 Label">
</StorySection> <HMAreaChart {...sampleLineCompare} curve="step" pointLabels label="월별 접수·완료 계단형 Dot·값 Label 영역 차트(예시 데이터)" />
<StorySection title="Stacked"> </ChartSpecimen>
<AreaStackedExample /> </StorySectionGrid>
</StorySection> <StorySectionGrid className={chartGrid.pair}>
<StorySection title="Expanded"> <ChartSpecimen title="Stacked" description="값 누적">
<AreaExpandedExample /> <HMAreaChart {...sampleLineCompare} stacked label="월별 접수·완료 Stacked 영역 차트(예시 데이터)" />
</StorySection> </ChartSpecimen>
<ChartSpecimen title="Expanded" description="합계 100%">
<HMAreaChart {...sampleLineCompare} expanded label="월별 접수·완료 Expanded 영역 차트(예시 데이터)" />
</ChartSpecimen>
</StorySectionGrid> </StorySectionGrid>
</StoryPage> </StoryPage>
), ),
+23 -25
View File
@@ -1,13 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { import { HMBarChart, HMHorizontalBarChart, HMSignedBarChart } from "@hm-design/chart"
BarChartExample, import { sampleBar, sampleHorizontalBar, sampleSignedBar } from "@hm-design/chart/examples"
HorizontalBarChartExample, import { StoryPage, StoryPageHeader, StorySectionGrid } from "@/components/story-page"
SignedBarChartExample, import { ChartSampleNotice, ChartSpecimen, chartGrid, chartPageClass } from "./chart-story"
StackedBarChartExample,
} from "@hm-design/chart/examples"
import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page"
// shadcn/ui Chart (Recharts) with HM tokens, from the public @hm-design/chart entries. All values are sample data. // shadcn/ui Chart (Recharts) with HM tokens, from the public @hm-design/chart entry. All values are sample data; each chart has
// the same data and props as the matching @hm-design/chart/examples card. Open layout (no card): see chart-story.tsx.
// Narrow widths: use the toolbar Viewport presets (HM Mobile · 390). The chart is fluid. // Narrow widths: use the toolbar Viewport presets (HM Mobile · 390). The chart is fluid.
const meta = { const meta = {
title: "Chart/Bar Chart", title: "Chart/Bar Chart",
@@ -19,24 +17,24 @@ type Story = StoryObj<typeof meta>
export const Bar: Story = { export const Bar: Story = {
name: "Bar Chart", name: "Bar Chart",
render: () => ( render: () => (
<StoryPage> <StoryPage className={chartPageClass}>
<StoryPageHeader title="Bar Chart" /> <StoryPageHeader title="Bar Chart" />
{/* Two sections per row from 1024px (HM desktop boundary), one below. Source order is the mobile order; on desktop it reads <ChartSampleNotice />
row by row: Grouped | Horizontal, then Stacked | Positive / Negative. On desktop every row has the same height and each {/* Two per row from 1024px, one below. Source order is the mobile order; desktop reads row by row:
card fills its section, so card tops, bottoms and chart areas line up. */} Grouped | Horizontal, then Stacked | Positive / Negative. */}
<StorySectionGrid className="grid-cols-1 lg:auto-rows-fr lg:grid-cols-2 *:flex *:min-w-0 *:flex-col [&_.sb-page-section-body]:flex-1"> <StorySectionGrid className={chartGrid.pair}>
<StorySection title="Grouped"> <ChartSpecimen title="Grouped" description="월별 접수·완료 현황 · 상반기 월별 건수">
<BarChartExample /> <HMBarChart {...sampleBar} label="월별 접수·완료 건수 막대 차트(예시 데이터)" />
</StorySection> </ChartSpecimen>
<StorySection title="Horizontal"> <ChartSpecimen title="Horizontal" description="항목별 처리 건수 · 가로 막대">
<HorizontalBarChartExample /> <HMHorizontalBarChart {...sampleHorizontalBar} label="항목별 처리 건수 가로 막대 차트(예시 데이터)" />
</StorySection> </ChartSpecimen>
<StorySection title="Stacked"> <ChartSpecimen title="Stacked" description="월별 접수·완료 현황(누적)">
<StackedBarChartExample /> <HMBarChart {...sampleBar} stacked label="월별 접수·완료 건수 누적 막대 차트(예시 데이터)" />
</StorySection> </ChartSpecimen>
<StorySection title="Positive / Negative"> <ChartSpecimen title="Positive / Negative" description="월별 순증감 · 0 기준 양수·음수">
<SignedBarChartExample /> <HMSignedBarChart {...sampleSignedBar} label="월별 순증감 양수·음수 막대 차트(예시 데이터)" />
</StorySection> </ChartSpecimen>
</StorySectionGrid> </StorySectionGrid>
</StoryPage> </StoryPage>
), ),
+76
View File
@@ -0,0 +1,76 @@
import type { ReactNode } from "react"
import { sampleDataNotice } from "@hm-design/chart/examples"
import { StorySection } from "@/components/story-page"
// Storybook presentation only (not part of @hm-design/chart): the open Chart page layout (2026-11-12, user request).
// Charts sit directly on the Storybook content background, without the example card (background, border, padding). Each chart
// is one StorySection: H2 variant name → description (what the example shows) → thin section divider → optional toolbar → chart.
// The charts themselves are the public @hm-design/chart components on the @hm-design/chart/examples sample data, with the same
// props as the matching *Example card, so data, legend, tooltip and interaction are unchanged. No common layout change; the page
// only sets the chart's own surface token (chartPageClass).
/**
* className of every Chart page's StoryPage. The charts sit on the Storybook content background (--hm-sem-bg-content), so the page
* sets the chart's public surface token to it (2026-11-12): slice / segment gaps, Dot rings, label halos and the radial meter's
* center read as background instead of white. Only the token value is set, as a consumer would; no chart internals are overridden.
*/
export const chartPageClass = "[--hm-chart-surface:var(--hm-sem-bg-content)]"
/** One sample-data notice per page, instead of a badge and a notice under every chart. */
export function ChartSampleNotice() {
return (
<p className="m-0 text-[length:var(--hm-label-font-size)] leading-[var(--hm-label-line-height)] text-[color:var(--hm-sem-fg-secondary)]">
{sampleDataNotice}
</p>
)
}
/**
* One chart of a Chart page. `toolbar` keeps the example card's control row (Select height, so the charts of a row start at the
* same height); pass `null` for an empty row of that height next to charts that have one.
*/
export function ChartSpecimen({
title,
description,
toolbar,
children,
}: {
title: string
description?: ReactNode
toolbar?: ReactNode
children: ReactNode
}) {
return (
<StorySection title={title} description={description}>
{toolbar !== undefined ? (
<div
data-slot="chart-story-toolbar"
aria-hidden={toolbar === null ? true : undefined}
className="mb-3 flex min-h-[34px] items-center text-sm text-muted-foreground"
>
{toolbar}
</div>
) : null}
{children}
</StorySection>
)
}
// Grid columns (className of StorySectionGrid). Widths are the preview iframe width, which already excludes the Storybook sidebar.
// A third column only from 1200px, where each column keeps about 340px (Radial needs 320px). Rows align at the top; every
// chart keeps its own height. Source order is the one-column (mobile) order. All breakpoints are px (min-[…]): Tailwind does not
// order a rem breakpoint (lg) against a px one, so `lg:grid-cols-2` would win over `min-[1200px]:grid-cols-3`.
const base = "grid-cols-1 items-start *:min-w-0"
export const chartGrid = {
/** Wide charts (Bar): two per row from 1024px. */
pair: `${base} min-[1024px]:grid-cols-2`,
/** Compact round charts (Donut, Pie, Radial): two per row from 1024px, three from 1200px. */
compact: `${base} min-[1024px]:grid-cols-2 min-[1200px]:grid-cols-3`,
/** Radar (four charts): two per row from 1024px, four in one row from 1600px. */
quad: `${base} min-[1024px]:grid-cols-2 min-[1600px]:grid-cols-4`,
/**
* Plain / labelled pairs (Line, Area): two per row from 1024px (each pair in one row); from 1200px three columns that fill
* top to bottom, so the plain styles form the first row and their Dot + Label versions the second.
*/
pairs: `${base} min-[1024px]:grid-cols-2 min-[1200px]:grid-flow-col min-[1200px]:grid-cols-3 min-[1200px]:grid-rows-2`,
} as const
+61 -29
View File
@@ -1,14 +1,14 @@
import { useState } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { import { HMDonutChart, HMSolidDonutChart } from "@hm-design/chart"
DonutActiveExample, import { sampleDonutStates } from "@hm-design/chart/examples"
DonutCenterExample, import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@hm-design/ui"
DonutInteractiveExample, import { StoryPage, StoryPageHeader, StorySectionGrid } from "@/components/story-page"
DonutPlainExample, import { ChartSampleNotice, ChartSpecimen, chartGrid, chartPageClass } from "./chart-story"
DonutSolidExample,
} from "@hm-design/chart/examples"
import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page"
// Sample data only. The four examples share one data set, color mapping and item order. // Sample data only. The five charts share one data set, color mapping and item order. Same data, props and toolbar text as the
// @hm-design/chart/examples cards; open layout (no card): see chart-story.tsx. Every chart has a toolbar row (Select height),
// so the donuts of a row start at the same height.
const meta = { const meta = {
title: "Chart/Donut Chart", title: "Chart/Donut Chart",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
@@ -16,29 +16,61 @@ const meta = {
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
// Interactive: Select and slice click share one active item (same as DonutInteractiveExample).
function DonutInteractive() {
const [activeId, setActiveId] = useState(sampleDonutStates.activeId)
return (
<ChartSpecimen
title="Interactive"
description="선택 또는 클릭"
toolbar={
<Select value={activeId} onValueChange={setActiveId}>
<SelectTrigger size="medium" aria-label="강조할 업무 상태 선택" className="w-full max-w-64">
<SelectValue />
</SelectTrigger>
<SelectContent>
{sampleDonutStates.data.map(item => (
<SelectItem key={item.id} value={item.id}>{item.label}</SelectItem>
))}
</SelectContent>
</Select>
}
>
<HMDonutChart
data={sampleDonutStates.data}
activeId={activeId}
onActiveChange={setActiveId}
label="업무 상태 도넛 차트, 항목을 선택하거나 조각을 클릭해 강조(예시 데이터)"
/>
</ChartSpecimen>
)
}
export const Donut: Story = { export const Donut: Story = {
name: "Donut Chart", name: "Donut Chart",
render: () => ( render: () => {
<StoryPage> const selected = sampleDonutStates.data.find(item => item.id === sampleDonutStates.activeId)
return (
<StoryPage className={chartPageClass}>
<StoryPageHeader title="Donut Chart" /> <StoryPageHeader title="Donut Chart" />
{/* Same grid as Bar and Line Chart: two per row from 1024px, one below; source order is the mobile order. */} <ChartSampleNotice />
<StorySectionGrid className="grid-cols-1 lg:auto-rows-fr lg:grid-cols-2 *:flex *:min-w-0 *:flex-col [&_.sb-page-section-body]:flex-1"> {/* Two per row from 1024px, three from 1200px, one below; source order is the mobile order. */}
<StorySection title="Basic"> <StorySectionGrid className={chartGrid.compact}>
<DonutPlainExample /> <ChartSpecimen title="Basic" description="기본" toolbar="중앙 표시 없음">
</StorySection> <HMDonutChart data={sampleDonutStates.data} label="업무 상태 도넛 차트, 가운데 값 없음(예시 데이터)" />
<StorySection title="Basic · Center Text"> </ChartSpecimen>
<DonutCenterExample /> <ChartSpecimen title="Basic · Center Text" description="전체 합계" toolbar="중앙 표시: 전체 합계">
</StorySection> <HMDonutChart data={sampleDonutStates.data} totalLabel={sampleDonutStates.totalLabel} label="업무 상태 도넛 차트, 가운데 전체 합계(예시 데이터)" />
<StorySection title="Active"> </ChartSpecimen>
<DonutActiveExample /> <ChartSpecimen title="Active" description="선택된 항목" toolbar={`선택 항목: ${selected?.label ?? ""}`}>
</StorySection> <HMDonutChart data={sampleDonutStates.data} activeId={sampleDonutStates.activeId} label="업무 상태 도넛 차트, 선택된 항목 강조(예시 데이터)" />
<StorySection title="Interactive"> </ChartSpecimen>
<DonutInteractiveExample /> <DonutInteractive />
</StorySection> <ChartSpecimen title="Solid" description="입체형" toolbar={null}>
<StorySection title="Solid"> <HMSolidDonutChart data={sampleDonutStates.data} totalLabel={sampleDonutStates.totalLabel} label="업무 상태 입체형 도넛 차트, 가운데 전체 합계(예시 데이터)" />
<DonutSolidExample /> </ChartSpecimen>
</StorySection>
</StorySectionGrid> </StorySectionGrid>
</StoryPage> </StoryPage>
), )
},
} }
+30 -33
View File
@@ -1,16 +1,12 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { import { HMLineChart } from "@hm-design/chart"
LineCurveDotLabelExample, import { sampleLineCompare, sampleLinePointColors } from "@hm-design/chart/examples"
LineCurveExample, import { StoryPage, StoryPageHeader, StorySectionGrid } from "@/components/story-page"
LineStepDotLabelExample, import { ChartSampleNotice, ChartSpecimen, chartGrid, chartPageClass } from "./chart-story"
LineStepExample,
LineStraightColorLabelExample,
LineStraightExample,
} from "@hm-design/chart/examples"
import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page"
// Sample data only. All six examples share one data set and axis range, so connection styles compare directly: curve, straight // Sample data only. All six charts share one data set and axis range, so connection styles compare directly: curve, straight
// and step, each as a plain line (left) and with Dots and value labels (right). // and step, each as a plain line and with Dots and value labels. Same data and props as the @hm-design/chart/examples cards;
// open layout (no card): see chart-story.tsx.
const meta = { const meta = {
title: "Chart/Line Chart", title: "Chart/Line Chart",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
@@ -21,29 +17,30 @@ type Story = StoryObj<typeof meta>
export const Line: Story = { export const Line: Story = {
name: "Line Chart", name: "Line Chart",
render: () => ( render: () => (
<StoryPage> <StoryPage className={chartPageClass}>
<StoryPageHeader title="Line Chart" /> <StoryPageHeader title="Line Chart" />
{/* Same grid as Bar Chart: two per row from 1024px, one below. Source order is the mobile order; desktop reads row by row: <ChartSampleNotice />
Curve | Curve + labels, Straight | Straight + colored labels, Step | Step + labels. */} {/* Source order is the mobile order. 1024px: Curve | Curve + labels per row. 1200px: three columns, plain styles in the
<StorySectionGrid className="grid-cols-1 lg:auto-rows-fr lg:grid-cols-2 *:flex *:min-w-0 *:flex-col [&_.sb-page-section-body]:flex-1"> first row and their Dot + Label versions below. */}
<StorySection title="Curve"> <StorySectionGrid className={chartGrid.pairs}>
<LineCurveExample /> <ChartSpecimen title="Curve" description="monotone · Dot 없음">
</StorySection> <HMLineChart {...sampleLineCompare} curve="monotone" dots={false} label="월별 접수·완료 곡선형 선 차트(예시 데이터)" />
<StorySection title="Curve · Dot + Label"> </ChartSpecimen>
<LineCurveDotLabelExample /> <ChartSpecimen title="Curve · Dot + Label" description="monotone · Dot과 값 Label">
</StorySection> <HMLineChart {...sampleLineCompare} curve="monotone" pointLabels label="월별 접수·완료 곡선형 Dot·값 라벨 선 차트(예시 데이터)" />
<StorySection title="Straight"> </ChartSpecimen>
<LineStraightExample /> <ChartSpecimen title="Straight" description="linear · Dot 없음">
</StorySection> <HMLineChart {...sampleLineCompare} curve="linear" dots={false} label="월별 접수·완료 직선형 선 차트(예시 데이터)" />
<StorySection title="Straight · Color Dot + Label"> </ChartSpecimen>
<LineStraightColorLabelExample /> <ChartSpecimen title="Straight · Color Dot + Label" description="linear · Dot 개별 색상 + 값 Label">
</StorySection> <HMLineChart {...sampleLinePointColors} curve="linear" pointLabels label="월별 접수·완료 직선형 포인트별 색상 선 차트(예시 데이터)" />
<StorySection title="Step"> </ChartSpecimen>
<LineStepExample /> <ChartSpecimen title="Step" description="step · Dot 없음">
</StorySection> <HMLineChart {...sampleLineCompare} curve="step" dots={false} label="월별 접수·완료 계단형 선 차트(예시 데이터)" />
<StorySection title="Step · Dot + Label"> </ChartSpecimen>
<LineStepDotLabelExample /> <ChartSpecimen title="Step · Dot + Label" description="step · Dot과 값 Label">
</StorySection> <HMLineChart {...sampleLineCompare} curve="step" pointLabels label="월별 접수·완료 계단형 Dot·값 라벨 선 차트(예시 데이터)" />
</ChartSpecimen>
</StorySectionGrid> </StorySectionGrid>
</StoryPage> </StoryPage>
), ),
+24 -20
View File
@@ -1,8 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { PieLabelExample, PieLabelListExample, PiePlainExample, PieSolidExample, PieStackedExample } from "@hm-design/chart/examples" import { HMPieChart, HMSolidPieChart, HMStackedPieChart } from "@hm-design/chart"
import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page" import { samplePie, sampleStackedPie } from "@hm-design/chart/examples"
import { StoryPage, StoryPageHeader, StorySectionGrid } from "@/components/story-page"
import { ChartSampleNotice, ChartSpecimen, chartGrid, chartPageClass } from "./chart-story"
// Sample data only. All pies share one item list and color mapping; the stacked pie draws two series as concentric rings. // Sample data only. All pies share one item list and color mapping; the stacked pie draws two series as concentric rings.
// Same data and props as the @hm-design/chart/examples cards; open layout (no card): see chart-story.tsx.
const meta = { const meta = {
title: "Chart/Pie Chart", title: "Chart/Pie Chart",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
@@ -13,25 +16,26 @@ type Story = StoryObj<typeof meta>
export const Pie: Story = { export const Pie: Story = {
name: "Pie Chart", name: "Pie Chart",
render: () => ( render: () => (
<StoryPage> <StoryPage className={chartPageClass}>
<StoryPageHeader title="Pie Chart" /> <StoryPageHeader title="Pie Chart" />
{/* Same grid as Bar, Line and Donut Chart: two per row from 1024px, one below; source order is the mobile order. */} <ChartSampleNotice />
<StorySectionGrid className="grid-cols-1 lg:auto-rows-fr lg:grid-cols-2 *:flex *:min-w-0 *:flex-col [&_.sb-page-section-body]:flex-1"> {/* Two per row from 1024px, three from 1200px, one below; source order is the mobile order. */}
<StorySection title="Basic"> <StorySectionGrid className={chartGrid.compact}>
<PiePlainExample /> <ChartSpecimen title="Basic" description="기본">
</StorySection> <HMPieChart {...samplePie} label="업무 상태 파이 차트(예시 데이터)" />
<StorySection title="Label"> </ChartSpecimen>
<PieLabelExample /> <ChartSpecimen title="Label" description="이름과 값">
</StorySection> <HMPieChart {...samplePie} labels="outside" label="업무 상태 파이 차트, 조각 바깥 이름·값 Label(예시 데이터)" />
<StorySection title="Label List"> </ChartSpecimen>
<PieLabelListExample /> <ChartSpecimen title="Label List" description="조각 안 값">
</StorySection> <HMPieChart {...samplePie} labels="inside" label="업무 상태 파이 차트, 조각 안 값 Label(예시 데이터)" />
<StorySection title="Stacked"> </ChartSpecimen>
<PieStackedExample /> <ChartSpecimen title="Stacked" description="두 시리즈 링">
</StorySection> <HMStackedPieChart {...sampleStackedPie} label="분기별 업무 상태 Stacked 파이 차트(예시 데이터)" />
<StorySection title="Solid"> </ChartSpecimen>
<PieSolidExample /> <ChartSpecimen title="Solid" description="입체형">
</StorySection> <HMSolidPieChart {...samplePie} label="업무 상태 입체형 파이 차트(예시 데이터)" />
</ChartSpecimen>
</StorySectionGrid> </StorySectionGrid>
</StoryPage> </StoryPage>
), ),
+22 -18
View File
@@ -1,8 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { RadarCustomLabelExample, RadarDotExample, RadarLinesOnlyExample, RadarPlainExample } from "@hm-design/chart/examples" import { HMRadarChart } from "@hm-design/chart"
import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page" import { sampleRadar } from "@hm-design/chart/examples"
import { StoryPage, StoryPageHeader, StorySectionGrid } from "@/components/story-page"
import { ChartSampleNotice, ChartSpecimen, chartGrid, chartPageClass } from "./chart-story"
// Sample data only. All radars share one data set (six axes, two series) and the 0-100 axis range. // Sample data only. All radars share one data set (six axes, two series) and the 0-100 axis range. Same data and props as the
// @hm-design/chart/examples cards; open layout (no card): see chart-story.tsx.
const meta = { const meta = {
title: "Chart/Radar Chart", title: "Chart/Radar Chart",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
@@ -13,22 +16,23 @@ type Story = StoryObj<typeof meta>
export const Radar: Story = { export const Radar: Story = {
name: "Radar Chart", name: "Radar Chart",
render: () => ( render: () => (
<StoryPage> <StoryPage className={chartPageClass}>
<StoryPageHeader title="Radar Chart" /> <StoryPageHeader title="Radar Chart" />
{/* Same grid as the other Chart pages: two per row from 1024px, one below. */} <ChartSampleNotice />
<StorySectionGrid className="grid-cols-1 lg:auto-rows-fr lg:grid-cols-2 *:flex *:min-w-0 *:flex-col [&_.sb-page-section-body]:flex-1"> {/* Two per row from 1024px, all four in one row from 1600px, one below. */}
<StorySection title="Basic"> <StorySectionGrid className={chartGrid.quad}>
<RadarPlainExample /> <ChartSpecimen title="Basic" description="영역">
</StorySection> <HMRadarChart {...sampleRadar} label="단계별 처리 수준 방사형 차트(예시 데이터)" />
<StorySection title="Dot"> </ChartSpecimen>
<RadarDotExample /> <ChartSpecimen title="Dot" description="축별 Dot">
</StorySection> <HMRadarChart {...sampleRadar} dots label="단계별 처리 수준 방사형 차트, Dot(예시 데이터)" />
<StorySection title="Lines Only"> </ChartSpecimen>
<RadarLinesOnlyExample /> <ChartSpecimen title="Lines Only" description="선만">
</StorySection> <HMRadarChart {...sampleRadar} filled={false} label="단계별 처리 수준 방사형 차트, 선만(예시 데이터)" />
<StorySection title="Custom Label"> </ChartSpecimen>
<RadarCustomLabelExample /> <ChartSpecimen title="Custom Label" description="축 Label">
</StorySection> <HMRadarChart {...sampleRadar} customLabels label="단계별 처리 수준 방사형 차트, 축 Label(예시 데이터)" />
</ChartSpecimen>
</StorySectionGrid> </StorySectionGrid>
</StoryPage> </StoryPage>
), ),
+28 -31
View File
@@ -1,15 +1,11 @@
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { import { HMRadialBarChart, HMRadialMeterChart, HMRadialStackedChart } from "@hm-design/chart"
RadialGridExample, import { sampleRadial, sampleRadialShape, sampleRadialStacked, sampleRadialText } from "@hm-design/chart/examples"
RadialLabelExample, import { StoryPage, StoryPageHeader, StorySectionGrid } from "@/components/story-page"
RadialShapeExample, import { ChartSampleNotice, ChartSpecimen, chartGrid, chartPageClass } from "./chart-story"
RadialSimpleExample,
RadialStackedExample,
RadialTextExample,
} from "@hm-design/chart/examples"
import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page"
// Sample data only. One example per official shadcn/ui radial chart (radial-simple, -label, -grid, -text, -shape, -stacked). // Sample data only. One chart per official shadcn/ui radial chart (radial-simple, -label, -grid, -text, -shape, -stacked).
// Same data and props (angles, radii, tracks) as the @hm-design/chart/examples cards; open layout (no card): see chart-story.tsx.
const meta = { const meta = {
title: "Chart/Radial Chart", title: "Chart/Radial Chart",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
@@ -20,28 +16,29 @@ type Story = StoryObj<typeof meta>
export const Radial: Story = { export const Radial: Story = {
name: "Radial Chart", name: "Radial Chart",
render: () => ( render: () => (
<StoryPage> <StoryPage className={chartPageClass}>
<StoryPageHeader title="Radial Chart" /> <StoryPageHeader title="Radial Chart" />
{/* Same grid as the other Chart pages: two per row from 1024px, one below; source order is the mobile order. */} <ChartSampleNotice />
<StorySectionGrid className="grid-cols-1 lg:auto-rows-fr lg:grid-cols-2 *:flex *:min-w-0 *:flex-col [&_.sb-page-section-body]:flex-1"> {/* Two per row from 1024px, three from 1200px, one below; source order is the mobile order. */}
<StorySection title="Basic"> <StorySectionGrid className={chartGrid.compact}>
<RadialSimpleExample /> <ChartSpecimen title="Basic" description="radial-simple · 항목별 막대">
</StorySection> <HMRadialBarChart {...sampleRadial} label="업무 상태 방사형 막대 차트(예시 데이터)" />
<StorySection title="Label"> </ChartSpecimen>
<RadialLabelExample /> <ChartSpecimen title="Label" description="radial-label · 막대 안 항목명">
</StorySection> <HMRadialBarChart {...sampleRadial} labels startAngle={-90} endAngle={380} label="업무 상태 방사형 막대 차트, 항목명 Label(예시 데이터)" />
<StorySection title="Grid"> </ChartSpecimen>
<RadialGridExample /> <ChartSpecimen title="Grid" description="radial-grid · 원형 Grid">
</StorySection> <HMRadialBarChart {...sampleRadial} grid label="업무 상태 방사형 막대 차트, Grid(예시 데이터)" />
<StorySection title="Text"> </ChartSpecimen>
<RadialTextExample /> <ChartSpecimen title="Text" description="radial-text · 중앙 Text">
</StorySection> <HMRadialMeterChart {...sampleRadialText} startAngle={0} endAngle={250} innerRadius={75} outerRadius={90} trackRadii={[88.5, 76.5]} cornerRadius={10} label="진행 중 건수 방사형 차트, 중앙 Text(예시 데이터)" />
<StorySection title="Shape"> </ChartSpecimen>
<RadialShapeExample /> <ChartSpecimen title="Shape" description="radial-shape · 링 트랙">
</StorySection> <HMRadialMeterChart {...sampleRadialShape} startAngle={0} endAngle={100} innerRadius={50} outerRadius={95} trackRadii={[90.5, 54.5]} label="진행 중 건수 방사형 차트, Shape(예시 데이터)" />
<StorySection title="Stacked"> </ChartSpecimen>
<RadialStackedExample /> <ChartSpecimen title="Stacked" description="radial-stacked · 반원 누적">
</StorySection> <HMRadialStackedChart {...sampleRadialStacked} label="업무 상태 방사형 Stacked 차트(예시 데이터)" />
</ChartSpecimen>
</StorySectionGrid> </StorySectionGrid>
</StoryPage> </StoryPage>
), ),
+114 -55
View File
@@ -1,9 +1,8 @@
import { useState } from "react" import { useState, type ReactNode } from "react"
import { DownloadIcon } from "lucide-react" import { DownloadIcon } from "lucide-react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Button, type ButtonAppearance } from "@hm-design/ui" import { Button, type ButtonAppearance } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board" import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/components/story-page"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Story structure follows ui_viz (implementations/web/storybook/Button.stories.jsx): // Story structure follows ui_viz (implementations/web/storybook/Button.stories.jsx):
// one representative "Button" Story shows kinds, sizes and states on a single screen; the same sections also exist // one representative "Button" Story shows kinds, sizes and states on a single screen; the same sections also exist
@@ -126,8 +125,8 @@ function SizesBoard() {
) )
} }
// `showNote={false}` keeps the representative page UI-only; the verification Story keeps the note and the Activated readout. // Verification "Brand States" (note and Activated readout). The representative page shows Brand states in StateMatrix.
function BrandStates({ showNote = true }: { showNote?: boolean }) { function BrandStates() {
const [count, setCount] = useState(0) const [count, setCount] = useState(0)
return ( return (
<div className={stack}> <div className={stack}>
@@ -140,12 +139,10 @@ function BrandStates({ showNote = true }: { showNote?: boolean }) {
Disabled Disabled
</Button> </Button>
</div> </div>
{showNote ? <output aria-live="polite">Activated: {count}</output> : null} <output aria-live="polite">Activated: {count}</output>
{showNote ? (
<p className={note}> <p className={note}>
Hover와 Pressed는 Storybook이 고정한 상태입니다. Interact 위에서 실제 Hover·Pressed·포인터 이탈을 확인합니다. Disabled는 실행되지 않습니다. Hover와 Pressed는 Storybook이 고정한 상태입니다. Interact 위에서 실제 Hover·Pressed·포인터 이탈을 확인합니다. Disabled는 실행되지 않습니다.
</p> </p>
) : null}
</div> </div>
) )
} }
@@ -157,15 +154,13 @@ const standardStates = [
["Disabled", { disabled: true }], ["Disabled", { disabled: true }],
] as const ] as const
// Without `title` the table sits under a StateBoard label (representative page) and drops the Role column: // Verification "Standard States" table (rows = roles). The representative page uses StateMatrix (rows = states) instead.
// every button already shows its role name, so the row header would repeat it. function StandardStateTable({ appearance, title }: { appearance: "solid" | "outline"; title?: string }) {
// `compact` (representative page only) tightens the cell padding so a 5x4 / 4x4 matrix stays short; buttons are untouched.
function StandardStateTable({ appearance, title, compact = false }: { appearance: "solid" | "outline"; title?: string; compact?: boolean }) {
return ( return (
<section className="min-w-0"> <section className="min-w-0">
{title ? <h3 className={heading}>{title}</h3> : null} {title ? <h3 className={heading}>{title}</h3> : null}
<div className={`${title ? "mt-3 " : ""}min-w-0 max-w-full overflow-x-auto`}> <div className={`${title ? "mt-3 " : ""}min-w-0 max-w-full overflow-x-auto`}>
<table className={`w-full border-collapse [&_th]:text-left ${compact ? "[&_td]:px-2 [&_td]:py-1 [&_th]:px-2 [&_th]:py-1" : "[&_td]:p-3 [&_th]:p-3"}`}> <table className="w-full border-collapse [&_th]:text-left [&_td]:p-3 [&_th]:p-3">
<thead> <thead>
<tr> <tr>
{title ? <th scope="col">Role</th> : null} {title ? <th scope="col">Role</th> : null}
@@ -231,43 +226,92 @@ function DestructiveBoard() {
) )
} }
// Page and section spacing come from the common compact Story page (story-page.css); this Story keeps no override. // Representative-page layout (2026-11-12 compact comparison). Page and section spacing come from the common Story page
// 8px gap so the three Brand sizes stay on one line in the narrowest (Brand) column, same as the Variants rows. // (story-page.css); only the arrangement below is local. Buttons are rendered with their public API and never restyled.
const compactRow = "flex flex-wrap items-center gap-2" // Group label: the StateBoard H3 style (14/20/500, secondary). `data-sb-board` gives the block the common one-step section indent.
// Equal two columns over the whole content width. StateBoard's own auto-fill grid left an empty third column const groupLabel = "m-0 text-sm font-medium text-muted-foreground"
// (2 items in a grid that fits 3), so the pairs are laid out here and each StateBoard holds one specimen.
const twoColumns = "grid gap-6 min-[900px]:grid-cols-[repeat(2,minmax(0,1fr))]"
// StateBoard lays its states out in an auto-fill grid. A board that holds one specimen inside a wide column would put it
// in the first of several inner columns (about half width at 1920px), so `min` is set larger than any column to force one.
const SingleColumnBoard = ({ states }: { states: BoardState[] }) => <StateBoard min={10000} states={states} />
const columnsAuto = (min: number) => `[grid-template-columns:repeat(auto-fit,minmax(min(100%,${min}px),1fr))]`
// 8px gap keeps all three Variants groups on one line at the 1280px PC width (Brand 3 + Solid 5 + Outline 4 buttons in a 3-column grid); narrower widths wrap. // Sizes / Variants (top, side by side): one row per group, label on a fixed 128px left column so every group title and first
function CompactRoleRow({ appearance }: { appearance: (typeof appearances)[number][0] }) { // button line up. At 640px and below the label moves above its buttons. Buttons keep the page-wide 8px gap, across and between
// rows (same as the States matrices), and wrap only when too narrow.
function GroupRows({ groups }: { groups: { id: string; label: string; children: ReactNode }[] }) {
return ( return (
<div className="flex flex-wrap items-center gap-2"> <div data-sb-board="" className="grid gap-2 max-sm:gap-5">
{rolesFor(appearance).map(([variant, label]) => ( {groups.map(({ id, label, children }) => (
<Button key={variant} variant={variant} appearance={appearance}> <section key={id} data-overview-state={id} aria-label={label} className="flex min-w-0 items-center gap-4 max-sm:flex-col max-sm:items-start max-sm:gap-2">
{label} <h3 className={`${groupLabel} w-32 shrink-0 max-sm:w-auto`}>{label}</h3>
</Button> <div className="flex min-w-0 flex-wrap items-center gap-2">{children}</div>
</section>
))} ))}
</div> </div>
) )
} }
function CompactSizeRow({ appearance }: { appearance: "brand" | "solid" }) { // States (bottom): Brand | Standard Solid | Standard Outline columns, title on top, all tops aligned. The columns use the
// whole width, shared in proportion to their matrices (about 301 / 420 / 338px, never narrower than the matrix); each matrix
// keeps its 8px button gap and sits at the start of its column. 3 columns from a 1250px preview (the three need about 1106px
// of content plus the page margins), 2 columns (Brand + Solid, then Outline) from 890px, 1 column below; a 1-column matrix
// wider than the screen scrolls in its own box only (the page never scrolls sideways).
function GroupColumns({ groups }: { groups: { id: string; label: string; children: ReactNode }[] }) {
return ( return (
<div className={compactRow}> <div
{sizes.map(([size, label]) => ( data-sb-board=""
<Button key={size} appearance={appearance} size={size}> className="grid grid-cols-[minmax(0,1fr)] items-start gap-6 min-[890px]:grid-cols-[minmax(max-content,301fr)_minmax(max-content,420fr)] min-[1250px]:grid-cols-[minmax(max-content,301fr)_minmax(max-content,420fr)_minmax(max-content,338fr)]"
{label} >
</Button> {groups.map(({ id, label, children }) => (
<section key={id} data-overview-state={id} aria-label={label} className="grid min-w-0 content-start gap-2">
<h3 className={groupLabel}>{label}</h3>
{children}
</section>
))} ))}
</div> </div>
) )
} }
// Representative Story: the common Story page (Sizes → Variants → States) with the ui_viz Button "All" boards. // States: one matrix per treatment, rows = Default / Hover / Pressed / Disabled (standardStates, the same 4 rows for Brand;
// the live Interact button stays on the verification "Brand States"), columns = that treatment's colors in the Variants order.
// Columns follow their buttons (auto table layout). 4px cell padding gives the same 8px gap as Sizes / Variants between
// buttons, rows, the state label and the color header; the outer edge keeps none (pl-0 / last:pr-0) so the three matrices
// fit one row at 1440px. Buttons sit centered under their color and are never resized.
function StateMatrix({ appearance, states }: { appearance: ButtonAppearance; states: readonly (readonly [string, Record<string, unknown>])[] }) {
const colors = rolesFor(appearance)
return (
<div className="min-w-0 max-w-full overflow-x-auto">
<table className="border-collapse">
<thead>
<tr>
<th scope="col" className={`${groupLabel} pb-1 pl-0 pr-1 text-left`}>
<span className="sr-only">State</span>
</th>
{colors.map(([variant, color]) => (
<th scope="col" key={variant} className={`${groupLabel} px-1 pb-1 text-center last:pr-0`}>
{color}
</th>
))}
</tr>
</thead>
<tbody>
{states.map(([state, props]) => (
<tr key={state}>
<th scope="row" className={`${groupLabel} py-1 pl-0 pr-1 text-left whitespace-nowrap`}>
{state}
</th>
{colors.map(([variant, color]) => (
<td key={variant} className="p-1 text-center last:pr-0">
<Button variant={variant} appearance={appearance} {...props}>
{color}
</Button>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
)
}
// Representative Story: the common Story page (Sizes → Variants → States).
// The former Overview button was the default Brand Medium button already shown under Sizes, so Overview is merged into Sizes. // The former Overview button was the default Brand Medium button already shown under Sizes, so Overview is merged into Sizes.
// Named after the component, as in ui_viz. It is the only Button entry in the default sidebar, so Storybook shows Button as a single 1-depth item. // Named after the component, as in ui_viz. It is the only Button entry in the default sidebar, so Storybook shows Button as a single 1-depth item.
export const All: Story = { export const All: Story = {
@@ -276,29 +320,44 @@ export const All: Story = {
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Button" /> <StoryPageHeader title="Button" />
{/* Sizes | Variants on one row from a 1080px preview (1280px screen with the 180px LNB), sharing the whole width in two
equal halves (never narrower than their rows). Narrower previews stack the two sections. */}
<StorySectionGrid className="grid-cols-1 items-start min-[1080px]:grid-cols-[minmax(max-content,1fr)_minmax(max-content,1fr)]">
<StorySection title="Sizes"> <StorySection title="Sizes">
{/* Same columns as Variants below (Brand / Standard Solid / Standard Outline): Brand and Standard Solid sizes line up with <GroupRows
their Variants columns and the third column stays empty. */} groups={appearances.slice(0, 2).map(([appearance, label]) => ({
<div className={`grid gap-6 ${columnsAuto(260)} min-[960px]:[grid-template-columns:minmax(0,234fr)_minmax(0,353fr)_minmax(0,271fr)]`}> id: `sizes-${appearance}`,
<SingleColumnBoard states={[{ id: "sizes-brand", label: "Brand", children: <CompactSizeRow appearance="brand" /> }]} /> label,
<SingleColumnBoard states={[{ id: "sizes-solid", label: "Standard Solid", children: <CompactSizeRow appearance="solid" /> }]} /> children: sizes.map(([size, sizeLabel]) => (
</div> <Button key={size} appearance={appearance} size={size}>
{sizeLabel}
</Button>
)),
}))}
/>
</StorySection> </StorySection>
<StorySection title="Variants"> <StorySection title="Variants">
<div className={`grid gap-6 ${columnsAuto(260)} min-[960px]:[grid-template-columns:minmax(0,234fr)_minmax(0,353fr)_minmax(0,271fr)]`}> <GroupRows
{appearances.map(([appearance, label]) => ( groups={appearances.map(([appearance, label]) => ({
<SingleColumnBoard key={appearance} states={[{ id: `roles-${appearance}`, label, children: <CompactRoleRow appearance={appearance} /> }]} /> id: `roles-${appearance}`,
))} label,
</div> children: rolesFor(appearance).map(([variant, color]) => (
<Button key={variant} variant={variant} appearance={appearance}>
{color}
</Button>
)),
}))}
/>
</StorySection> </StorySection>
</StorySectionGrid>
<StorySection title="States"> <StorySection title="States">
<div className="grid gap-6"> <GroupColumns
<SingleColumnBoard states={[{ id: "brand-states", label: "Brand", wide: true, children: <BrandStates showNote={false} /> }]} /> groups={appearances.map(([appearance, label]) => ({
<div className={twoColumns}> id: `${appearance}-states`,
<SingleColumnBoard states={[{ id: "standard-solid-states", label: "Standard Solid", children: <StandardStateTable appearance="solid" compact /> }]} /> label,
<SingleColumnBoard states={[{ id: "standard-outline-states", label: "Standard Outline", children: <StandardStateTable appearance="outline" compact /> }]} /> children: <StateMatrix appearance={appearance} states={standardStates} />,
</div> }))}
</div> />
</StorySection> </StorySection>
</StoryPage> </StoryPage>
), ),
+51 -22
View File
@@ -22,9 +22,10 @@ const longOptions: ComboboxOption[] = [
] ]
// Label 14/20/500 in the default text color; a Disabled field keeps the secondary color at 0.6 (same as the Form pages). // Label 14/20/500 in the default text color; a Disabled field keeps the secondary color at 0.6 (same as the Form pages).
function Field({ label, id, disabled, children }: { label: string; id: string; disabled?: boolean; children: ReactNode }) { // `fill` lets the field take its grid column (representative page); the verification Stories keep the 320px field.
function Field({ label, id, disabled, fill, children }: { label: string; id: string; disabled?: boolean; fill?: boolean; children: ReactNode }) {
return ( return (
<div className="grid max-w-xs gap-2"> <div className={fill ? "grid gap-2" : "grid max-w-xs gap-2"}>
<label htmlFor={id} className={`justify-self-start text-sm font-medium ${disabled ? "text-muted-foreground opacity-60" : "text-foreground"}`}> <label htmlFor={id} className={`justify-self-start text-sm font-medium ${disabled ? "text-muted-foreground opacity-60" : "text-foreground"}`}>
{label} {label}
</label> </label>
@@ -33,31 +34,36 @@ function Field({ label, id, disabled, children }: { label: string; id: string; d
) )
} }
function Empty() { // Representative page (2026-11-12, same rule as Input / Select): with `state`, the state name takes the field Label slot (no
// repeated field Label under a separate group title) and the trigger keeps the field meaning in its accessible name, which also
// contains the visible state name (Label in Name), e.g. "프레임워크 (Selected)". Without `state` the verification Stories are unchanged.
const stateName = (state: string | undefined, field: string) => (state ? `${field} (${state})` : undefined)
function Empty({ state }: { state?: string }) {
return ( return (
<Field label="프레임워크" id="cb-empty"> <Field label={state ?? "프레임워크"} id="cb-empty" fill={state !== undefined}>
<Combobox id="cb-empty" options={frameworks} placeholder="프레임워크 선택" searchPlaceholder="프레임워크 검색..." /> <Combobox id="cb-empty" aria-label={stateName(state, "프레임워크")} options={frameworks} placeholder="프레임워크 선택" searchPlaceholder="프레임워크 검색..." />
</Field> </Field>
) )
} }
// `showValue={false}` hides the value readout on the representative page; the verification Stories keep it. // `showValue={false}` hides the value readout on the representative page; the verification Stories keep it.
function Controlled({ showValue = true }: { showValue?: boolean }) { function Controlled({ showValue = true, state }: { showValue?: boolean; state?: string }) {
const [value, setValue] = useState("nuxt") const [value, setValue] = useState("nuxt")
return ( return (
<div className="grid gap-2"> <div className="grid gap-2">
<Field label="선택됨 (controlled)" id="cb-controlled"> <Field label={state ?? "선택됨 (controlled)"} id="cb-controlled" fill={state !== undefined}>
<Combobox id="cb-controlled" controlled options={frameworks} value={value} onValueChange={setValue} /> <Combobox id="cb-controlled" aria-label={stateName(state, "프레임워크")} controlled options={frameworks} value={value} onValueChange={setValue} />
</Field> </Field>
{showValue ? <span className="text-sm text-muted-foreground" data-testid="controlled">{`value = ${value}`}</span> : null} {showValue ? <span className="text-sm text-muted-foreground" data-testid="controlled">{`value = ${value}`}</span> : null}
</div> </div>
) )
} }
function Medium() { function Medium({ state }: { state?: string }) {
return ( return (
<Field label="Medium" id="cb-medium"> <Field label={state ?? "Medium"} id="cb-medium" fill={state !== undefined}>
<Combobox id="cb-medium" size="medium" options={frameworks} defaultValue="astro" /> <Combobox id="cb-medium" aria-label={stateName(state, "프레임워크")} size="medium" options={frameworks} defaultValue="astro" />
</Field> </Field>
) )
} }
@@ -70,10 +76,10 @@ function LongLabel() {
) )
} }
function Disabled() { function Disabled({ state }: { state?: string }) {
return ( return (
<Field label="비활성" id="cb-disabled" disabled> <Field label={state ?? "비활성"} id="cb-disabled" disabled fill={state !== undefined}>
<Combobox id="cb-disabled" options={frameworks} defaultValue="next" disabled /> <Combobox id="cb-disabled" aria-label={stateName(state, "프레임워크")} options={frameworks} defaultValue="next" disabled />
</Field> </Field>
) )
} }
@@ -111,6 +117,25 @@ const Cell = ({ id, label, wide, children }: { id: string; label: string; wide?:
</div> </div>
) )
// Sizes / States (2026-11-12): the Input / Select field grid with the common section indent — up to 3 tracks of at least 240px
// within 1120px, narrower widths drop to 2 / 1 columns. Each cell is one field whose Label is the state name.
const fieldColumns =
"grid max-w-[1120px] items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(max(240px,calc((100%_-_48px)/3)),1fr))]"
function FieldRow({ fields }: { fields: { id: string; render: ReactNode }[] }) {
return (
<div className="sb-page-indent">
<div className={fieldColumns}>
{fields.map(({ id, render }) => (
<div key={id} className="min-w-0" data-overview-state={id}>
{render}
</div>
))}
</div>
</div>
)
}
// No `component`: Combobox has a required `options` prop, so Stories without args would not type-check. // No `component`: Combobox has a required `options` prop, so Stories without args would not type-check.
const meta = { title: "Components/Search & Date/Combobox", parameters: { layout: "padded" } } satisfies Meta const meta = { title: "Components/Search & Date/Combobox", parameters: { layout: "padded" } } satisfies Meta
export default meta export default meta
@@ -124,16 +149,20 @@ export const All: Story = {
<StoryPage> <StoryPage>
<StoryPageHeader title="Combobox" /> <StoryPageHeader title="Combobox" />
<StorySection title="Sizes"> <StorySection title="Sizes">
<Grid> <FieldRow
<Cell id="empty" label="Default"><Empty /></Cell> fields={[
<Cell id="medium" label="Medium"><Medium /></Cell> { id: "empty", render: <Empty state="Default" /> },
</Grid> { id: "medium", render: <Medium state="Medium" /> },
]}
/>
</StorySection> </StorySection>
<StorySection title="States"> <StorySection title="States">
<Grid> <FieldRow
<Cell id="controlled" label="Selected"><Controlled showValue={false} /></Cell> fields={[
<Cell id="disabled" label="Disabled"><Disabled /></Cell> { id: "controlled", render: <Controlled showValue={false} state="Selected" /> },
</Grid> { id: "disabled", render: <Disabled state="Disabled" /> },
]}
/>
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<Grid> <Grid>
+69 -29
View File
@@ -13,9 +13,10 @@ const today = new Date(2026, 9, 2)
const calendarProps = { today, startMonth: new Date(2026, 0), endMonth: new Date(2027, 11) } const calendarProps = { today, startMonth: new Date(2026, 0), endMonth: new Date(2027, 11) }
// Label 14/20/500 in the default text color; a Disabled field keeps the secondary color at 0.6 (same as the Form pages). // Label 14/20/500 in the default text color; a Disabled field keeps the secondary color at 0.6 (same as the Form pages).
function Field({ label, id, disabled, children }: { label: string; id: string; disabled?: boolean; children: ReactNode }) { // `fill` lets the field take its grid column (representative page); the verification Stories keep the 320px field.
function Field({ label, id, disabled, fill, children }: { label: string; id: string; disabled?: boolean; fill?: boolean; children: ReactNode }) {
return ( return (
<div className="grid max-w-xs gap-2"> <div className={fill ? "grid gap-2" : "grid max-w-xs gap-2"}>
<label htmlFor={id} className={`justify-self-start text-sm font-medium ${disabled ? "text-muted-foreground opacity-60" : "text-foreground"}`}> <label htmlFor={id} className={`justify-self-start text-sm font-medium ${disabled ? "text-muted-foreground opacity-60" : "text-foreground"}`}>
{label} {label}
</label> </label>
@@ -24,18 +25,23 @@ function Field({ label, id, disabled, children }: { label: string; id: string; d
) )
} }
function Empty() { // Representative page (2026-11-12, same rule as Input / Select): with `state`, the state name takes the field Label slot (no
// repeated field Label under a separate group title) and the trigger keeps the field meaning in its accessible name, which also
// contains the visible state name (Label in Name), e.g. "시작일 (Selected)". Without `state` the verification Stories are unchanged.
const stateName = (state: string | undefined, field: string) => (state ? `${field} (${state})` : undefined)
function Empty({ state }: { state?: string }) {
return ( return (
<Field label="시작일" id="dp-empty"> <Field label={state ?? "시작일"} id="dp-empty" fill={state !== undefined}>
<DatePicker id="dp-empty" calendarProps={calendarProps} /> <DatePicker id="dp-empty" aria-label={stateName(state, "시작일")} calendarProps={calendarProps} />
</Field> </Field>
) )
} }
function Selected() { function Selected({ state }: { state?: string }) {
return ( return (
<Field label="시작일" id="dp-selected"> <Field label={state ?? "시작일"} id="dp-selected" fill={state !== undefined}>
<DatePicker id="dp-selected" defaultValue={new Date(2026, 9, 6)} calendarProps={calendarProps} /> <DatePicker id="dp-selected" aria-label={stateName(state, "시작일")} defaultValue={new Date(2026, 9, 6)} calendarProps={calendarProps} />
</Field> </Field>
) )
} }
@@ -57,41 +63,52 @@ function CustomFormat() {
} }
// `showValue={false}` hides the value readout on the representative page; the verification Stories keep it. // `showValue={false}` hides the value readout on the representative page; the verification Stories keep it.
function Controlled({ showValue = true }: { showValue?: boolean }) { function Controlled({ showValue = true, state }: { showValue?: boolean; state?: string }) {
const [date, setDate] = useState<Date | undefined>() const [date, setDate] = useState<Date | undefined>()
return ( return (
<div className="grid gap-2"> <div className="grid gap-2">
<Field label="마감일 (controlled)" id="dp-controlled"> <Field label={state ?? "마감일 (controlled)"} id="dp-controlled" fill={state !== undefined}>
<DatePicker id="dp-controlled" controlled value={date} onValueChange={setDate} calendarProps={calendarProps} /> <DatePicker id="dp-controlled" aria-label={stateName(state, "마감일")} controlled value={date} onValueChange={setDate} calendarProps={calendarProps} />
</Field> </Field>
{showValue ? <span className="text-sm text-muted-foreground" data-testid="controlled">{`value = ${date ? date.toLocaleDateString("ko-KR") : "undefined"}`}</span> : null} {showValue ? <span className="text-sm text-muted-foreground" data-testid="controlled">{`value = ${date ? date.toLocaleDateString("ko-KR") : "undefined"}`}</span> : null}
</div> </div>
) )
} }
function Medium() { function Medium({ state }: { state?: string }) {
return ( return (
<Field label="Medium" id="dp-medium"> <Field label={state ?? "Medium"} id="dp-medium" fill={state !== undefined}>
<DatePicker id="dp-medium" size="medium" defaultValue={new Date(2026, 9, 15)} calendarProps={calendarProps} /> <DatePicker id="dp-medium" aria-label={stateName(state, "날짜")} size="medium" defaultValue={new Date(2026, 9, 15)} calendarProps={calendarProps} />
</Field> </Field>
) )
} }
function DisabledDates() { // With `state`, the rule that was the Label stays as helper text (12/18/400 secondary, 8px below the trigger, as Input's Helper)
// and is linked to the trigger with aria-describedby.
const disabledDatesNote = "5일 이전과 일요일은 선택 불가"
function DisabledDates({ state }: { state?: string }) {
return ( return (
<Field label="5일 이전과 일요일은 선택 불가" id="dp-disabled-dates"> <Field label={state ?? disabledDatesNote} id="dp-disabled-dates" fill={state !== undefined}>
<DatePicker <DatePicker
id="dp-disabled-dates" id="dp-disabled-dates"
aria-label={stateName(state, "날짜")}
aria-describedby={state ? "dp-disabled-dates-message" : undefined}
calendarProps={{ ...calendarProps, disabled: [{ before: new Date(2026, 9, 5) }, { dayOfWeek: [0] }] }} calendarProps={{ ...calendarProps, disabled: [{ before: new Date(2026, 9, 5) }, { dayOfWeek: [0] }] }}
/> />
{state ? (
<p id="dp-disabled-dates-message" className="m-0 text-xs leading-[18px] text-muted-foreground">
{disabledDatesNote}
</p>
) : null}
</Field> </Field>
) )
} }
function Disabled() { function Disabled({ state }: { state?: string }) {
return ( return (
<Field label="비활성" id="dp-disabled" disabled> <Field label={state ?? "비활성"} id="dp-disabled" disabled fill={state !== undefined}>
<DatePicker id="dp-disabled" disabled defaultValue={new Date(2026, 9, 15)} /> <DatePicker id="dp-disabled" aria-label={stateName(state, "날짜")} disabled defaultValue={new Date(2026, 9, 15)} />
</Field> </Field>
) )
} }
@@ -107,6 +124,25 @@ const Cell = ({ id, label, children }: { id: string; label: string; children: Re
</div> </div>
) )
// Sizes / States (2026-11-12): the Input / Select field grid with the common section indent — up to 3 tracks of at least 240px
// within 1120px, narrower widths drop to 2 / 1 columns. Each cell is one field whose Label is the state name.
const fieldColumns =
"grid max-w-[1120px] items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(max(240px,calc((100%_-_48px)/3)),1fr))]"
function FieldRow({ fields }: { fields: { id: string; render: ReactNode }[] }) {
return (
<div className="sb-page-indent">
<div className={fieldColumns}>
{fields.map(({ id, render }) => (
<div key={id} className="min-w-0" data-overview-state={id}>
{render}
</div>
))}
</div>
</div>
)
}
const meta = { title: "Components/Search & Date/Date Picker", component: DatePicker, parameters: { layout: "padded" } } satisfies Meta<typeof DatePicker> const meta = { title: "Components/Search & Date/Date Picker", component: DatePicker, parameters: { layout: "padded" } } satisfies Meta<typeof DatePicker>
export default meta export default meta
type Story = StoryObj<typeof meta> type Story = StoryObj<typeof meta>
@@ -119,18 +155,22 @@ export const All: Story = {
<StoryPage> <StoryPage>
<StoryPageHeader title="Date Picker" /> <StoryPageHeader title="Date Picker" />
<StorySection title="Sizes"> <StorySection title="Sizes">
<Grid> <FieldRow
<Cell id="empty" label="Default"><Empty /></Cell> fields={[
<Cell id="medium" label="Medium"><Medium /></Cell> { id: "empty", render: <Empty state="Default" /> },
</Grid> { id: "medium", render: <Medium state="Medium" /> },
]}
/>
</StorySection> </StorySection>
<StorySection title="States"> <StorySection title="States">
<Grid> <FieldRow
<Cell id="selected" label="Selected"><Selected /></Cell> fields={[
<Cell id="controlled" label="Controlled"><Controlled showValue={false} /></Cell> { id: "selected", render: <Selected state="Selected" /> },
<Cell id="disabled-dates" label="Disabled dates"><DisabledDates /></Cell> { id: "controlled", render: <Controlled showValue={false} state="Controlled" /> },
<Cell id="disabled" label="Disabled"><Disabled /></Cell> { id: "disabled-dates", render: <DisabledDates state="Disabled dates" /> },
</Grid> { id: "disabled", render: <Disabled state="Disabled" /> },
]}
/>
</StorySection> </StorySection>
<StorySection title="Usage"> <StorySection title="Usage">
<Grid> <Grid>
+30 -10
View File
@@ -1,7 +1,6 @@
import { useId, type ComponentProps, type ReactNode } from "react" import { useId, type ComponentProps, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Input, type InputSize } from "@hm-design/ui" import { Input, type InputSize } from "@hm-design/ui"
import { StateBoard } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Story structure follows ui_viz (implementations/web/storybook/Input.stories.jsx and InputSizes.stories.jsx): // Story structure follows ui_viz (implementations/web/storybook/Input.stories.jsx and InputSizes.stories.jsx):
@@ -99,6 +98,27 @@ function SizeComparison({ showSpec = true }: { showSpec?: boolean }) {
) )
} }
// Representative page (2026-11-12): States and Validation use the Sizes layout. The state name sits in the field Label slot
// (no repeated "Project name" and no separate group title); the input keeps a meaningful accessible name through aria-label,
// which also contains the visible state name (Label in Name). Same columns as SizeComparison: up to 3 tracks of at least
// 240px within 1120px, so Validation's 2 fields line up with the first 2 States fields and narrower widths drop to 2 / 1.
const fieldColumns =
"grid max-w-[1120px] items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(max(240px,calc((100%_-_48px)/3)),1fr))]"
function FieldRow({ fields }: { fields: { id: string; label: string; props: ComponentProps<typeof Specimen> }[] }) {
return (
<div className="sb-page-indent">
<div className={fieldColumns}>
{fields.map(({ id, label, props }) => (
<div key={id} className="min-w-0" data-overview-state={id}>
<Specimen label={label} aria-label={`Project name (${label})`} {...props} />
</div>
))}
</div>
</div>
)
}
const readonlyNote = const readonlyNote =
"Readonly는 입력 상자 없이 확정된 텍스트만 표시합니다. size와 무관하게 Large 기준 typography와 42px 정렬 높이를 유지하며, Disabled처럼 흐리게 표시하지 않습니다." "Readonly는 입력 상자 없이 확정된 텍스트만 표시합니다. size와 무관하게 Large 기준 typography와 42px 정렬 높이를 유지하며, Disabled처럼 흐리게 표시하지 않습니다."
@@ -140,19 +160,19 @@ export const All: Story = {
</div> </div>
</StorySection> </StorySection>
<StorySection title="States"> <StorySection title="States">
<StateBoard <FieldRow
states={[ fields={[
{ id: "focus", label: "Focus", children: <Specimen size="medium" placeholder={placeholder} /> }, { id: "focus", label: "Focus", props: { size: "medium", placeholder } },
{ id: "disabled", label: "Disabled", children: <Specimen disabled defaultValue="Unavailable project" /> }, { id: "disabled", label: "Disabled", props: { disabled: true, defaultValue: "Unavailable project" } },
{ id: "readonly", label: "Readonly", children: <Specimen readOnly value="Confirmed project" /> }, { id: "readonly", label: "Readonly", props: { readOnly: true, value: "Confirmed project" } },
]} ]}
/> />
</StorySection> </StorySection>
<StorySection title="Validation"> <StorySection title="Validation">
<StateBoard <FieldRow
states={[ fields={[
{ id: "error", label: "Error", children: <Specimen size="medium" error defaultValue="Invalid value" /> }, { id: "error", label: "Error", props: { size: "medium", error: true, defaultValue: "Invalid value" } },
{ id: "error-message", label: "Error Message", children: <Specimen size="medium" placeholder={placeholder} error message="프로젝트명을 입력하세요." /> }, { id: "error-message", label: "Error Message", props: { size: "medium", placeholder, error: true, message: "프로젝트명을 입력하세요." } },
]} ]}
/> />
</StorySection> </StorySection>
+37 -28
View File
@@ -1,7 +1,6 @@
import { useId, type ReactNode } from "react" import { useId, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, type SelectSize } from "@hm-design/ui" import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, type SelectSize } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Story structure follows ui_viz (implementations/web/storybook/Select.stories.jsx and SelectSizes.stories.jsx): // Story structure follows ui_viz (implementations/web/storybook/Select.stories.jsx and SelectSizes.stories.jsx):
@@ -37,10 +36,11 @@ type SpecimenProps = {
defaultOpen?: boolean defaultOpen?: boolean
disabled?: boolean disabled?: boolean
"aria-invalid"?: boolean "aria-invalid"?: boolean
"aria-label"?: string
options?: ReadonlyArray<{ value: string; label: string }> options?: ReadonlyArray<{ value: string; label: string }>
} }
function SelectSpecimen({ listSize, label = "Select", placeholder = "Choose a status", size, defaultValue, defaultOpen, disabled, "aria-invalid": ariaInvalid, options = statusOptions }: SpecimenProps) { function SelectSpecimen({ listSize, label = "Select", placeholder = "Choose a status", size, defaultValue, defaultOpen, disabled, "aria-invalid": ariaInvalid, "aria-label": ariaLabel, options = statusOptions }: SpecimenProps) {
const id = useId() const id = useId()
// A Label next to an enabled Select uses the default text color; disabled keeps the secondary color at 0.6. // A Label next to an enabled Select uses the default text color; disabled keeps the secondary color at 0.6.
const labelTone = disabled ? "text-muted-foreground opacity-60" : "text-foreground" const labelTone = disabled ? "text-muted-foreground opacity-60" : "text-foreground"
@@ -50,7 +50,7 @@ function SelectSpecimen({ listSize, label = "Select", placeholder = "Choose a st
{label} {label}
</label> </label>
<Select defaultValue={defaultValue} defaultOpen={defaultOpen} disabled={disabled}> <Select defaultValue={defaultValue} defaultOpen={defaultOpen} disabled={disabled}>
<SelectTrigger id={id} size={size} aria-invalid={ariaInvalid} className="w-full"> <SelectTrigger id={id} size={size} aria-invalid={ariaInvalid} aria-label={ariaLabel} className="w-full">
<SelectValue placeholder={placeholder} /> <SelectValue placeholder={placeholder} />
</SelectTrigger> </SelectTrigger>
<SelectContent {...(listSize ? { "data-size": listSize } : {})}> <SelectContent {...(listSize ? { "data-size": listSize } : {})}>
@@ -111,23 +111,34 @@ function SizeComparison({ showSpec = true }: { showSpec?: boolean }) {
) )
} }
// Representative-page layout (Storybook presentation only): equal columns on PC (iframe width 1000px and up), shrinking to // Representative-page layout (Storybook presentation only). 2026-11-12, same rule and columns as Input: the state name sits in
// fit below that. Each specimen sits in its own single-column StateBoard (min larger than any column), so StateBoard's // the field Label slot (no repeated "Status" under a separate group title); the trigger keeps a meaningful accessible name
// auto-fill grid cannot leave an empty track. Full class names are listed so Tailwind can see them. // through aria-label, which also contains the visible state name (Label in Name). Sizes, States and Validation share up to 3
const specimenColumns = { // tracks of at least 240px within 1120px, so every Select has the Input field width and narrower widths drop to 2 / 1 columns.
3: "min-[1000px]:grid-cols-3", // The grid takes the common section indent (.sb-page-indent).
} as const const fieldColumns =
"grid max-w-[1120px] items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(max(240px,calc((100%_-_48px)/3)),1fr))]"
function SpecimenGrid({ states, columns }: { states: BoardState[]; columns: keyof typeof specimenColumns }) { function FieldGrid({ children }: { children: ReactNode }) {
return ( return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] ${specimenColumns[columns]}`}> <div className="sb-page-indent">
{states.map((state) => ( <div className={fieldColumns}>{children}</div>
<StateBoard key={state.id} min={10000} states={[state]} />
))}
</div> </div>
) )
} }
function FieldRow({ fields }: { fields: { id: string; label: string; props: SpecimenProps }[] }) {
return (
<FieldGrid>
{fields.map(({ id, label, props }) => (
<div key={id} className="min-w-0" data-overview-state={id}>
<SelectSpecimen size="medium" label={label} aria-label={`Status (${label})`} {...props} />
</div>
))}
</FieldGrid>
)
}
function DisabledStates() { function DisabledStates() {
return ( return (
<div className="grid max-w-[480px] gap-6"> <div className="grid max-w-[480px] gap-6">
@@ -177,30 +188,28 @@ export const All: Story = {
<StoryPageHeader title="Select" /> <StoryPageHeader title="Select" />
<StorySection title="Sizes"> <StorySection title="Sizes">
{/* Same 3-column track as States / Validation: Medium and Large take the first two columns, the third stays empty. {/* Same 3-column track as States / Validation: Medium and Large take the first two columns, the third stays empty.
The grid takes the same common indent as the group boards (.sb-page-indent); Medium / Large stay field Labels. */} Medium / Large stay field Labels. */}
<div className="grid items-start gap-6 sb-page-indent [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3"> <FieldGrid>
{sizes.map((spec) => ( {sizes.map((spec) => (
<SizeCard key={spec.size} spec={spec} showSpec={false} /> <SizeCard key={spec.size} spec={spec} showSpec={false} />
))} ))}
</div> </FieldGrid>
</StorySection> </StorySection>
<StorySection title="States"> <StorySection title="States">
<SpecimenGrid <FieldRow
columns={3} fields={[
states={[ { id: "selected", label: "Selected", props: { defaultValue: "active" } },
{ id: "selected", label: "Selected", children: <SelectSpecimen size="medium" label="Status" defaultValue="active" /> }, { id: "disabled", label: "Disabled", props: { defaultValue: "active", disabled: true } },
{ id: "disabled", label: "Disabled", children: <SelectSpecimen size="medium" label="Status" defaultValue="active" disabled /> }, { id: "disabled-placeholder", label: "Disabled · Placeholder", props: { disabled: true } },
{ id: "disabled-placeholder", label: "Disabled · Placeholder", children: <SelectSpecimen size="medium" label="Status" disabled /> },
]} ]}
/> />
</StorySection> </StorySection>
<StorySection title="Validation"> <StorySection title="Validation">
{/* Same 3-column track as States: the two specimens take the first two columns (same Select width) and the third stays empty. */} {/* Same 3-column track as States: the two specimens take the first two columns (same Select width) and the third stays empty. */}
<SpecimenGrid <FieldRow
columns={3} fields={[
states={[ { id: "invalid", label: "Invalid", props: { "aria-invalid": true, defaultValue: "active" } },
{ id: "invalid", label: "Invalid", children: <SelectSpecimen size="medium" aria-invalid label="Status" defaultValue="active" /> }, { id: "invalid-disabled", label: "Invalid · Disabled", props: { "aria-invalid": true, disabled: true } },
{ id: "invalid-disabled", label: "Invalid · Disabled", children: <SelectSpecimen size="medium" aria-invalid disabled label="Status" /> },
]} ]}
/> />
</StorySection> </StorySection>
+23 -25
View File
@@ -1,7 +1,6 @@
import { useId, type ComponentProps, type ReactNode } from "react" import { useId, type ComponentProps, type ReactNode } from "react"
import type { Meta, StoryObj } from "@storybook/react-vite" import type { Meta, StoryObj } from "@storybook/react-vite"
import { Textarea } from "@hm-design/ui" import { Textarea } from "@hm-design/ui"
import { StateBoard, type BoardState } from "@/components/story-board"
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
// Story structure follows Input: one representative "Textarea" Story shows every state on one page; the same specimens // Story structure follows Input: one representative "Textarea" Story shows every state on one page; the same specimens
@@ -49,20 +48,21 @@ function Specimen({
) )
} }
// Representative-page layout (Storybook presentation only). StateBoard's own auto-fill grid cannot give equal columns that // Representative-page layout (Storybook presentation only). 2026-11-12, same rule as Input: the state name sits in the field
// fill a cell, so each specimen sits in its own single-column StateBoard (min larger than any column) inside this grid. // Label slot (no repeated "Description" under a separate group title); the textarea keeps a meaningful accessible name through
// Full class names are listed so Tailwind can see them. // aria-label, which also contains the visible state name (Label in Name). PC (iframe width 1000px and up): 4 equal columns;
const specimenColumns = { // below that the columns shrink to fit (220px minimum, down to 1). The grid takes the common section indent (.sb-page-indent).
4: "min-[1000px]:grid-cols-4", function FieldGrid({ fields }: { fields: { id: string; label: string; props: ComponentProps<typeof Specimen> }[] }) {
} as const
function SpecimenGrid({ states, columns }: { states: BoardState[]; columns: keyof typeof specimenColumns }) {
return ( return (
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr))] ${specimenColumns[columns]}`}> <div className="sb-page-indent">
{states.map((state) => ( <div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr))] min-[1000px]:grid-cols-4">
<StateBoard key={state.id} min={10000} states={[state]} /> {fields.map(({ id, label, props }) => (
<div key={id} className="min-w-0" data-overview-state={id}>
<Specimen label={label} aria-label={`Description (${label})`} {...props} />
</div>
))} ))}
</div> </div>
</div>
) )
} }
@@ -106,23 +106,21 @@ export const All: Story = {
{/* PC (iframe width 1000px and up): States in 4 equal columns, Validation's 2 specimens on the same column width. {/* PC (iframe width 1000px and up): States in 4 equal columns, Validation's 2 specimens on the same column width.
Below that the columns shrink to fit (220px minimum, down to 1). Multiline and Resize live in their own hidden Stories. */} Below that the columns shrink to fit (220px minimum, down to 1). Multiline and Resize live in their own hidden Stories. */}
<StorySection title="States"> <StorySection title="States">
<SpecimenGrid <FieldGrid
columns={4} fields={[
states={[ { id: "default", label: "Default", props: { placeholder } },
{ id: "default", label: "Default", children: <Specimen placeholder={placeholder} /> }, { id: "focus", label: "Focus", props: { placeholder } },
{ id: "focus", label: "Focus", children: <Specimen placeholder={placeholder} /> }, { id: "readonly", label: "Readonly", props: { readOnly: true, defaultValue: "Confirmed description" } },
{ id: "readonly", label: "Readonly", children: <Specimen readOnly defaultValue="Confirmed description" /> }, { id: "disabled", label: "Disabled", props: { disabled: true, defaultValue: "Unavailable description" } },
{ id: "disabled", label: "Disabled", children: <Specimen disabled defaultValue="Unavailable description" /> },
]} ]}
/> />
</StorySection> </StorySection>
<StorySection title="Validation"> <StorySection title="Validation">
{/* Same 4-column track as States, so the two specimens keep the States width instead of stretching across the page. */} {/* Same 4-column track as States, so the two fields keep the States width instead of stretching across the page. */}
<SpecimenGrid <FieldGrid
columns={4} fields={[
states={[ { id: "error", label: "Error", props: { error: true, defaultValue: "Invalid value" } },
{ id: "error", label: "Error", children: <Specimen error defaultValue="Invalid value" /> }, { id: "error-message", label: "Error Message", props: { error: true, message: "설명을 입력하세요." } },
{ id: "error-message", label: "Error Message", children: <Specimen error message="설명을 입력하세요." /> },
]} ]}
/> />
</StorySection> </StorySection>
+12 -8
View File
@@ -6,10 +6,11 @@ import { StoryPage, StoryPageHeader, StorySection, StorySectionGrid } from "@/co
// Toggle is a persistent pressed-state control. The representative Story shows each state with real, clickable toggles; the same // Toggle is a persistent pressed-state control. The representative Story shows each state with real, clickable toggles; the same
// specimens also exist as Stories tagged `verification` (hidden from the sidebar). Icon-only toggles carry an `aria-label`. // specimens also exist as Stories tagged `verification` (hidden from the sidebar). Icon-only toggles carry an `aria-label`.
// 2026-11-12: toggles side by side keep the Button Story's 8px gap (was 12px).
function OffOn() { function OffOn() {
return ( return (
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-2">
<Toggle>Off</Toggle> <Toggle>Off</Toggle>
<Toggle defaultPressed>On</Toggle> <Toggle defaultPressed>On</Toggle>
<Toggle variant="outline">Outline off</Toggle> <Toggle variant="outline">Outline off</Toggle>
@@ -22,7 +23,7 @@ function OffOn() {
function WithIcon() { function WithIcon() {
return ( return (
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-2">
<Toggle aria-label="굵게"> <Toggle aria-label="굵게">
<BoldIcon /> <BoldIcon />
</Toggle> </Toggle>
@@ -43,7 +44,7 @@ function Sizes() {
// Same common indent as the group boards of the page (.sb-page-indent). // Same common indent as the group boards of the page (.sb-page-indent).
<div className="grid items-start gap-6 sb-page-indent [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3"> <div className="grid items-start gap-6 sb-page-indent [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
{(["sm", "default", "lg"] as const).map((size) => ( {(["sm", "default", "lg"] as const).map((size) => (
<div key={size} data-toggle-size={size} className="flex flex-wrap items-center gap-3"> <div key={size} data-toggle-size={size} className="flex flex-wrap items-center gap-2">
<Toggle size={size} variant="outline" aria-label={`${size} icon-only`}><StarIcon /></Toggle> <Toggle size={size} variant="outline" aria-label={`${size} icon-only`}><StarIcon /></Toggle>
<Toggle size={size} variant="outline"><StarIcon />즐겨찾기</Toggle> <Toggle size={size} variant="outline"><StarIcon />즐겨찾기</Toggle>
<Toggle size={size} variant="outline">{size}</Toggle> <Toggle size={size} variant="outline">{size}</Toggle>
@@ -55,7 +56,7 @@ function Sizes() {
function Disabled() { function Disabled() {
return ( return (
<div className="flex flex-wrap items-center gap-3"> <div className="flex flex-wrap items-center gap-2">
<Toggle disabled>Disabled off</Toggle> <Toggle disabled>Disabled off</Toggle>
<Toggle disabled defaultPressed> <Toggle disabled defaultPressed>
Disabled on Disabled on
@@ -90,15 +91,17 @@ function SpecimenGrid({ states }: { states: BoardState[] }) {
} }
// Representative Story: the common Story page; the sections group the same specimens. // Representative Story: the common Story page; the sections group the same specimens.
// Overview is renamed Variants. On PC (iframe width 1000px and up) Variants and States use the first two of the same 3 equal // Overview is renamed Variants. On PC (iframe width 1000px and up) Variants and States share the whole width in 2 columns,
// columns as the rows below, and the third column stays empty; below that they stack. // in proportion to the width their one-line row needs (324 / 234px incl. the 16px indent), never narrower than that row
// (max-content), so the 4 Variants toggles stay on one line (2026-11-12; they wrapped in the former 3 equal columns at a
// 1100px preview). Below that the sections stack and the toggle rows wrap naturally.
export const All: Story = { export const All: Story = {
name: "Toggle", name: "Toggle",
parameters: { layout: "fullscreen", controls: { disable: true } }, parameters: { layout: "fullscreen", controls: { disable: true } },
render: () => ( render: () => (
<StoryPage> <StoryPage>
<StoryPageHeader title="Toggle" /> <StoryPageHeader title="Toggle" />
<StorySectionGrid className="grid-cols-1 min-[1000px]:grid-cols-3"> <StorySectionGrid className="grid-cols-1 min-[1000px]:grid-cols-[minmax(max-content,324fr)_minmax(max-content,234fr)]">
<StorySection title="Variants"> <StorySection title="Variants">
<StateBoard min={10000} states={[specimen("offon")]} /> <StateBoard min={10000} states={[specimen("offon")]} />
</StorySection> </StorySection>
@@ -109,7 +112,8 @@ export const All: Story = {
<StorySection title="단독 · 연결 그룹"> <StorySection title="단독 · 연결 그룹">
<SpecimenGrid <SpecimenGrid
states={[ states={[
{ id: "single-toggle", label: "단독", children: <Toggle variant="outline">알림</Toggle> }, // The flex wrapper keeps the Toggle at its own width; as a direct grid child of the board it stretched to the column.
{ id: "single-toggle", label: "단독", children: <div className="flex"><Toggle variant="outline">알림</Toggle></div> },
{ id: "group-single", label: "연결 그룹 · 단일 선택", children: <ToggleGroup type="single" variant="outline" defaultValue="left" aria-label="정렬"><ToggleGroupItem value="left">왼쪽</ToggleGroupItem><ToggleGroupItem value="center">가운데</ToggleGroupItem><ToggleGroupItem value="right">오른쪽</ToggleGroupItem></ToggleGroup> }, { id: "group-single", label: "연결 그룹 · 단일 선택", children: <ToggleGroup type="single" variant="outline" defaultValue="left" aria-label="정렬"><ToggleGroupItem value="left">왼쪽</ToggleGroupItem><ToggleGroupItem value="center">가운데</ToggleGroupItem><ToggleGroupItem value="right">오른쪽</ToggleGroupItem></ToggleGroup> },
{ id: "group-multiple", label: "연결 그룹 · 다중 선택", children: <ToggleGroup type="multiple" variant="outline" defaultValue={["bold"]} aria-label="글자 스타일"><ToggleGroupItem value="bold">굵게</ToggleGroupItem><ToggleGroupItem value="italic">기울임</ToggleGroupItem></ToggleGroup> }, { id: "group-multiple", label: "연결 그룹 · 다중 선택", children: <ToggleGroup type="multiple" variant="outline" defaultValue={["bold"]} aria-label="글자 스타일"><ToggleGroupItem value="bold">굵게</ToggleGroupItem><ToggleGroupItem value="italic">기울임</ToggleGroupItem></ToggleGroup> },
]} ]}