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