Merge pull request 'style(storybook): refine form component stories' (#15) from feat/storybook-ui-refinement into main
Deploy Storybook / deploy (push) Successful in 58s
Deploy Storybook / deploy (push) Successful in 58s
Reviewed-on: #15
This commit was merged in pull request #15.
This commit is contained in:
commit
64f14c3c03
23 files changed
+471
-182
No files matched your search
@@ -7,15 +7,15 @@ This is a classification record. No Story IDs, exports or titles are renamed. HI
|
||||
## Summary
|
||||
|
||||
- PRIMARY: 82
|
||||
- VERIFICATION: 252
|
||||
- VERIFICATION: 256
|
||||
- UTILITY: 38
|
||||
- LEGACY / DUPLICATE: 0
|
||||
- KEEP: 82
|
||||
- HIDE: 290
|
||||
- HIDE: 294
|
||||
- MERGE_CANDIDATE: 33
|
||||
- DELETE_CANDIDATE: 0
|
||||
- Story files: 82
|
||||
- Stories: 372
|
||||
- Stories: 376
|
||||
- Hierarchy: Foundation → Components → Patterns → Center Graphic → Theme
|
||||
|
||||
## Full inventory
|
||||
@@ -171,6 +171,7 @@ Source: `src/components/checkbox.stories.tsx`; meta.tags: []
|
||||
- Indeterminate — Indeterminate; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Disabled — Disabled; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed.
|
||||
- Error — Error; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- LongLabel — Long Label; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Playground — Playground; tags: ["verification"]; UTILITY; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### Components/Disclosure/Collapsible
|
||||
@@ -467,6 +468,8 @@ Source: `src/components/textarea.stories.tsx`; meta.tags: []
|
||||
- InvalidFocus — Invalid Focus; tags: ["verification","play-fn"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- ErrorMessage — Error Message; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Required — Required; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Multiline — Multiline · Wrap; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- ResizeNone — Resize · none; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Readonly — Readonly; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- Disabled — Disabled; tags: ["verification"]; VERIFICATION; HIDE; MERGE_CANDIDATE. Representative board may cover the same specimen; keep direct QA until behavioral equivalence is reviewed.
|
||||
- LongContent — Long Content; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
@@ -480,6 +483,7 @@ Source: `src/components/toggle-group.stories.tsx`; meta.tags: []
|
||||
- SingleSelection — Single; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- MultipleSelection — Multiple; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- VerticalGroup — Vertical; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- ManyOptions — Many Options; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
- ContentNodes — Content Nodes; tags: ["verification"]; VERIFICATION; HIDE. Keep direct URL and tag-filter access; excluded from default sidebar.
|
||||
|
||||
### Components/Form/Toggle
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
{
|
||||
"summary": {
|
||||
"storyFiles": 82,
|
||||
"stories": 372,
|
||||
"stories": 376,
|
||||
"categories": {
|
||||
"PRIMARY": 82,
|
||||
"VERIFICATION": 252,
|
||||
"VERIFICATION": 256,
|
||||
"UTILITY": 38,
|
||||
"LEGACY / DUPLICATE": 0
|
||||
},
|
||||
"decisions": {
|
||||
"KEEP": 82,
|
||||
"HIDE": 290,
|
||||
"HIDE": 294,
|
||||
"MERGE_CANDIDATE": 33,
|
||||
"DELETE_CANDIDATE": 0
|
||||
},
|
||||
@@ -1061,6 +1061,21 @@
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "components-form-checkbox--long-label",
|
||||
"exportName": "LongLabel",
|
||||
"name": "Long Label",
|
||||
"storyTags": [
|
||||
"verification"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"verification"
|
||||
],
|
||||
"category": "VERIFICATION",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "components-form-checkbox--playground",
|
||||
"exportName": "Playground",
|
||||
@@ -3384,6 +3399,36 @@
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "components-form-textarea--multiline",
|
||||
"exportName": "Multiline",
|
||||
"name": "Multiline · Wrap",
|
||||
"storyTags": [
|
||||
"verification"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"verification"
|
||||
],
|
||||
"category": "VERIFICATION",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "components-form-textarea--resize-none",
|
||||
"exportName": "ResizeNone",
|
||||
"name": "Resize · none",
|
||||
"storyTags": [
|
||||
"verification"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"verification"
|
||||
],
|
||||
"category": "VERIFICATION",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "components-form-textarea--readonly",
|
||||
"exportName": "Readonly",
|
||||
@@ -3507,6 +3552,21 @@
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "components-form-toggle-group--many-options",
|
||||
"exportName": "ManyOptions",
|
||||
"name": "Many Options",
|
||||
"storyTags": [
|
||||
"verification"
|
||||
],
|
||||
"effectiveTags": [
|
||||
"verification"
|
||||
],
|
||||
"category": "VERIFICATION",
|
||||
"decision": "HIDE",
|
||||
"candidate": null,
|
||||
"reason": "Keep direct URL and tag-filter access; excluded from default sidebar."
|
||||
},
|
||||
{
|
||||
"id": "components-form-toggle-group--content-nodes",
|
||||
"exportName": "ContentNodes",
|
||||
|
||||
@@ -150,7 +150,7 @@ CVA 구조, `data-slot`, `asChild`(Radix `Slot`), `className` 확장, focus-visi
|
||||
## 11. Storybook
|
||||
|
||||
- 대표 Story `All`("Button")이 기본 sidebar의 유일한 Button 항목이다 → 1뎁스.
|
||||
- `All`의 구역: Overview → Sizes → Variants → States. 기본 버튼·크기·허용 조합·Brand/Standard 상태를 보여준다. Red Solid와 Outline은 Variants와 Standard States에도 포함되며, 삭제·취소 세트와 Long Label은 verification Story에서 따로 확인한다.
|
||||
- `All`의 구역: Sizes → Variants → States(2026-11-09 Overview 제거; 단독 Overview 버튼은 Sizes의 Brand Medium과 같아 통합). 기본 버튼·크기·허용 조합·Brand/Standard 상태를 보여준다. Red Solid와 Outline은 Variants와 Standard States에도 포함되며, 삭제·취소 세트와 Long Label은 verification Story에서 따로 확인한다. PC에서는 Sizes·Variants가 같은 3열(Brand / Standard Solid / Standard Outline)을 쓰고 States의 Solid·Outline 표는 좌우 2열이다.
|
||||
- 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)다.
|
||||
- 운영 규칙은 `프로젝트-지침.md` §6.3, §7 참조.
|
||||
|
||||
@@ -100,8 +100,8 @@ Radix 구조·ARIA(`role=checkbox`, `aria-checked`)·키보드, `data-slot`, `cn
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Checkbox"): `unchecked`, `checked`, `indeterminate`, `hover`, `focus`, `error`, `disabled`, `long`. 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`, `Playground`.
|
||||
- 대표 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가 아니며 소비 코드에 붙이지 않는다.
|
||||
- verification: `Unchecked`, `Checked`, `Indeterminate`, `Disabled`, `Error`, `LongLabel`("Long Label"), `Playground`.
|
||||
|
||||
## 10. 사용자 확정 결정
|
||||
|
||||
|
||||
@@ -88,7 +88,7 @@ Radix Label 동작·접근성, `data-slot`, `cn(..., className)`, `select-none`,
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Label"): 구역 `default`(htmlFor), `required`(소비 조합), `disabled`, `long`.
|
||||
- 대표 Story `All`("Label"): States(`default`(htmlFor), `disabled`)와 Usage(`select`(부서 선택), `required`(소비 조합), `long`)(2026-11-09 Overview 제거).
|
||||
- 별도 verification Story 없음(구역이 단순).
|
||||
|
||||
## 10. 사용자 확정 결정
|
||||
|
||||
@@ -57,7 +57,7 @@ shadcn `size-4 border-input text-primary shadow-xs dark:` class 제거 → theme
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
대표 Story `All`: `vertical`, `horizontal`, `hover`, `focus`, `error`, `disabled-item`, `disabled-group`. Focus는 `play`로 실제 focus. `hover`는 첫 항목을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 RadioGroupItem API가 아니다. verification: `Vertical`, `Horizontal`, `Disabled`, `Error`.
|
||||
대표 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`.
|
||||
|
||||
## 10. 사용자 확정 결정
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ Radix Root/Trigger/Portal/Content/Viewport/Item/ItemText 구조, 키보드 조
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Select"): Sizes(Medium / Large 비교, 각 Trigger로 실제 목록 열기) → States(`selected`, `disabled`, `disabled-placeholder`) → Invalid / Focus(`invalid`, `invalid-disabled`). 2026-10-22 Overview의 `default`·로드 시 열린 `open` 예시는 대표 화면에서 뺐다. 열린 목록은 숨김 `Open`, 크기별 수치 목록은 숨김 `Sizes`("Compare Sizes")에서 확인한다. size 생략 시 Large(§5)는 그대로다.
|
||||
- 대표 Story `All`("Select"): Sizes(Medium / Large 비교, 각 Trigger로 실제 목록 열기) → States(`selected`, `disabled`, `disabled-placeholder`) → Validation(`invalid`, `invalid-disabled`; 2026-11-09 `Invalid / Focus`에서 제목 변경, Focus 예시가 없다). PC에서는 세 구역이 같은 3열 칸을 쓰고 Sizes·Validation은 첫 두 칸만 채운다. 2026-10-22 Overview의 `default`·로드 시 열린 `open` 예시는 대표 화면에서 뺐다. 열린 목록은 숨김 `Open`, 크기별 수치 목록은 숨김 `Sizes`("Compare Sizes")에서 확인한다. size 생략 시 Large(§5)는 그대로다.
|
||||
- verification(`tags: ["verification"]`): `Default`, `Open`, `Selected`, `Disabled`, `Sizes`("Compare Sizes"), `Medium`, `Large`, `LongContent`, `Scroll`, `Playground`(HM 전용).
|
||||
- `Disabled` Story 구성: Trigger 비활성 상태들(placeholder 포함).
|
||||
- `InvalidFocus` verification Story: 앞/뒤 버튼 사이의 Invalid Trigger와 Disabled Invalid Trigger. Tab / Shift+Tab, Enter / Space, Escape 및 focus return을 실제 키보드로 확인한다.
|
||||
|
||||
@@ -74,7 +74,7 @@ Radix 구조·동작(pointer drag, track 클릭 이동, 키보드, thumb 정렬/
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Slider"): Single(값 label), Range(두 thumb, `minStepsBetweenThumbs` 5), Step(25), Disabled, Vertical을 한 페이지에 둔다. 값 label은 Story 조합(local state)이다.
|
||||
- 대표 Story `All`("Slider"): Variants(Single, Range(두 thumb, `minStepsBetweenThumbs` 5), Step(25), Vertical)와 States(Disabled) 두 구역으로 둔다(2026-11-09 Overview 제거, PC 4열). 값 label은 Story 조합(local state)이다.
|
||||
- verification: `Single`, `Range`, `Disabled`, `Focus`(실제 thumb focus). 기본 Sidebar에서 숨긴다.
|
||||
|
||||
## 10. 사용자 확정 결정
|
||||
|
||||
@@ -56,7 +56,7 @@ shadcn의 `h-[1.15rem] w-8`, `border`, `shadow-xs`, `dark:` class 제거 → the
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
대표 Story `All`: `off`, `on`, `hover`, `focus`, `disabled`(2026-10-22 `interactive` 구역 제거, 모든 예시가 실제 클릭으로 토글). `hover`는 Off·On을 Storybook 전용 `data-sb-hover`로 고정해 보여 주며 Switch API가 아니다. Focus는 `play`로 실제 focus. verification: `Off`, `On`, `Disabled`.
|
||||
대표 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`.
|
||||
|
||||
## 10. 사용자 확정 결정
|
||||
|
||||
|
||||
@@ -86,8 +86,8 @@ native `<textarea>` 동작 전체, `data-slot`, `cn(..., className)`, `transitio
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Textarea"): 구역 `default`, `focus`, `error`, `error-message`, `readonly`, `disabled`, `multiline`, `resize`. Focus는 `play` 함수로 실제 focus를 준다. Hover는 Default와 같아(TEXTAREA-05) 2026-10-22 대표 화면의 중복 `hover` 예시를 뺐다. 실제 포인터 확인은 Default 예시에서 한다.
|
||||
- verification(`tags: ["verification"]`): `Default`, `Focus`, `Error`, `InvalidFocus`, `ErrorMessage`, `Required`, `Readonly`, `Disabled`, `LongContent`, `Playground`(HM 전용).
|
||||
- 대표 Story `All`("Textarea"): States `default`, `focus`, `readonly`, `disabled`와 Validation `error`, `error-message`. `multiline`·`resize` 예시는 대표 페이지에서 빼고 숨김 verification Story `Multiline`·`ResizeNone`으로 옮겼다(2026-11-09). Focus는 `play` 함수로 실제 focus를 준다. Hover는 Default와 같아(TEXTAREA-05) 2026-10-22 대표 화면의 중복 `hover` 예시를 뺐다. 실제 포인터 확인은 Default 예시에서 한다.
|
||||
- verification(`tags: ["verification"]`): `Default`, `Focus`, `Error`, `InvalidFocus`, `ErrorMessage`, `Required`, `Multiline`("Multiline · Wrap"), `ResizeNone`("Resize · none"), `Readonly`, `Disabled`, `LongContent`, `Playground`(HM 전용). `Multiline`·`ResizeNone`은 2026-11-09에 대표 Story의 Usage 구역에서 옮긴 것이다.
|
||||
- Error Message·Required 표시는 Story 안의 조합 예시이며 Textarea 컴포넌트의 일부가 아니다.
|
||||
|
||||
## 10. 사용자 확정 결정
|
||||
@@ -119,8 +119,8 @@ src/components/story-board.tsx
|
||||
- `npm run typecheck`(root + ui + center-graphic), `npm run build`: 통과(2026-10-02).
|
||||
- Storybook computed style(`components-form-textarea--all`): Default min-height 112px / padding 8px 12px / border 1px rgb(222,219,210) / radius 4px / bg white / 16·24·400 / resize vertical / Placeholder rgb(153,153,153), Error border rgb(211,47,47) + padding 7.5px 11.5px, Readonly resize none · opacity 1, `resize="none"` 적용, Disabled opacity 0.6.
|
||||
- 실제 클릭 focus: `:focus-visible` 매칭, border rgb(18,51,40), padding 7.5px 11.5px 확인.
|
||||
- Story ID: `All` + verification 8개 존재 확인(index.json). `@hm-design/ui`에서 `import { Textarea }`로 Story가 빌드됨.
|
||||
- 2026-10-06 ui_viz Storybook 화면 비교(1280/768/390, Chromium): 컨트롤 computed style 일치(ui_viz Story의 114px은 `rows=4` 때문), Error+Focus outline 유지, 긴 내용은 내부 세로 scroll·가로 넘침 없음, Readonly 편집 불가·선택 가능, Disabled focus 불가. ui_viz는 Disabled에서 Field 전체(라벨 포함)를 흐리지만 HM은 컨트롤만 흐리고 라벨은 `Label`의 disabled 연동(소비 조합)이 맡는다 — 의도적 차이. 대표 Story는 ui_viz 5구역을 모두 포함한 상위 집합(Hover = Default, Error Message, Resize none 추가)이라 변경하지 않음.
|
||||
- Story ID: `All` + verification 8개 존재 확인(index.json). 2026-11-09 기준 `All` + verification 12개(index.json 13개, `components-form-textarea--multiline`·`--resize-none` 추가, 기존 ID 유지). `@hm-design/ui`에서 `import { Textarea }`로 Story가 빌드됨.
|
||||
- 2026-10-06 ui_viz Storybook 화면 비교(1280/768/390, Chromium): 컨트롤 computed style 일치(ui_viz Story의 114px은 `rows=4` 때문), Error+Focus outline 유지, 긴 내용은 내부 세로 scroll·가로 넘침 없음, Readonly 편집 불가·선택 가능, Disabled focus 불가. ui_viz는 Disabled에서 Field 전체(라벨 포함)를 흐리지만 HM은 컨트롤만 흐리고 라벨은 `Label`의 disabled 연동(소비 조합)이 맡는다 — 의도적 차이. 대표 Story는 ui_viz 5구역을 모두 포함한 상위 집합(Hover = Default, Error Message, Resize none 추가)이라 변경하지 않음(당시 기록). 2026-11-09부터 대표 Story는 States·Validation만 보여 주고 Multiline·Resize none은 숨김 Story로 옮겼다(§9).
|
||||
- 미확인: 브라우저별 렌더(Chromium 외).
|
||||
|
||||
|
||||
|
||||
@@ -72,7 +72,7 @@ Radix 구조·ARIA·roving focus(방향키, Home/End, disabled 건너뜀, `loop`
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Toggle Group"): Single(local state), Multiple(icon-only, local state), Disabled group / item, Vertical(icon-only), `spacing={2}`, Many items(`spacing={1}` + `flex-wrap`)을 한 페이지에 둔다. 대표 화면은 예시 제목·선택값 표시(`value = …`)를 생략하고 구역 제목으로 구분한다. 값 표시는 verification에 남긴다.
|
||||
- 대표 Story `All`("Toggle Group"): Variants(Single(local state), Multiple(icon-only, local state), Vertical(icon-only), `spacing={2}`), Sizes, States(Disabled group / item)를 둔다(2026-11-09 Overview 제거). `spacing={1}` + `flex-wrap`의 Many items 예시는 대표 화면에서 빼고 숨김 verification Story `ManyOptions`("Many Options")로 옮겼다. 대표 화면은 예시 제목·선택값 표시(`value = …`)를 생략하고 구역 제목으로 구분한다. 값 표시는 verification에 남긴다.
|
||||
- verification: `Single`, `Multiple`, `Vertical`.
|
||||
- meta에 `component`를 지정하지 않는다(props가 `single | multiple` union이라 args 없는 Story가 타입을 통과하지 못함).
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ Radix 구조·`aria-pressed`·`data-state`·Enter/Space·controlled/uncontrolled
|
||||
|
||||
## 9. Storybook
|
||||
|
||||
- 대표 Story `All`("Toggle"): Overview(Off/On, default·outline), 단독 · 연결 그룹(단독 Toggle, `spacing=0` ToggleGroup 단일·다중 선택), Sizes, States(Disabled off/on). 2026-10-22 Icon-only·icon + text 예시는 숨김 `IconOnly`로만 확인하고, Controlled(`pressed` 표시)·className 확장 예시는 대표 화면에서 제거했다. `pressed` / `onPressedChange`와 `className` 확장 API(§5)는 그대로다.
|
||||
- 대표 Story `All`("Toggle"): Variants(Off/On, default·outline)와 States(Disabled off/on), 단독 · 연결 그룹(단독 Toggle, `spacing=0` ToggleGroup 단일·다중 선택), Sizes(2026-11-09 Overview를 Variants로 바꾸고 PC 3열 기준으로 재배치). 2026-10-22 Icon-only·icon + text 예시는 숨김 `IconOnly`로만 확인하고, Controlled(`pressed` 표시)·className 확장 예시는 대표 화면에서 제거했다. `pressed` / `onPressedChange`와 `className` 확장 API(§5)는 그대로다.
|
||||
- verification: `States`, `Icon Only`, `Disabled`.
|
||||
|
||||
## 10. 사용자 확정 결정
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState } from "react"
|
||||
import { DownloadIcon } from "lucide-react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Button, type ButtonAppearance } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { StateBoard, type BoardState } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Story structure follows ui_viz (implementations/web/storybook/Button.stories.jsx):
|
||||
@@ -159,12 +159,13 @@ const standardStates = [
|
||||
|
||||
// Without `title` the table sits under a StateBoard label (representative page) and drops the Role column:
|
||||
// every button already shows its role name, so the row header would repeat it.
|
||||
function StandardStateTable({ appearance, title }: { appearance: "solid" | "outline"; title?: string }) {
|
||||
// `compact` (representative page only) tightens the cell padding so a 5x4 / 4x4 matrix stays short; buttons are untouched.
|
||||
function StandardStateTable({ appearance, title, compact = false }: { appearance: "solid" | "outline"; title?: string; compact?: boolean }) {
|
||||
return (
|
||||
<section className="min-w-0">
|
||||
{title ? <h3 className={heading}>{title}</h3> : null}
|
||||
<div className={`${title ? "mt-3 " : ""}min-w-0 max-w-full overflow-x-auto`}>
|
||||
<table className="w-full border-collapse [&_td]:p-3 [&_th]:p-3 [&_th]:text-left">
|
||||
<table className={`w-full border-collapse [&_th]:text-left ${compact ? "[&_td]:px-2 [&_td]:py-1 [&_th]:px-2 [&_th]:py-1" : "[&_td]:p-3 [&_th]:p-3"}`}>
|
||||
<thead>
|
||||
<tr>
|
||||
{title ? <th scope="col">Role</th> : null}
|
||||
@@ -230,31 +231,85 @@ function DestructiveBoard() {
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page (Overview → Sizes → Variants → States) with the ui_viz Button "All" boards.
|
||||
// Compact PC layout of the representative page (Storybook presentation only; Button itself is unchanged).
|
||||
// Page and section spacing are tightened for this Story only through `compactPage`; the shared story-page.css is not edited.
|
||||
// The important modifier is required because the shared rules use the same specificity.
|
||||
const compactPage = [
|
||||
"py-4!",
|
||||
"[&_.sb-page-header]:pb-3!",
|
||||
"[&_.sb-page-title]:mt-1!",
|
||||
"[&_.sb-page-title]:mb-2!",
|
||||
"[&_.sb-page-section]:mt-5!",
|
||||
"[&_.sb-page-section-header]:pb-2!",
|
||||
"[&_.sb-page-section-header]:mb-3!",
|
||||
].join(" ")
|
||||
// 8px gap so the three Brand sizes stay on one line in the narrowest (Brand) column, same as the Variants rows.
|
||||
const compactRow = "flex flex-wrap items-center gap-2"
|
||||
// Equal two columns over the whole content width. StateBoard's own auto-fill grid left an empty third column
|
||||
// (2 items in a grid that fits 3), so the pairs are laid out here and each StateBoard holds one specimen.
|
||||
const twoColumns = "grid gap-6 min-[900px]:grid-cols-[repeat(2,minmax(0,1fr))]"
|
||||
// StateBoard lays its states out in an auto-fill grid. A board that holds one specimen inside a wide column would put it
|
||||
// in the first of several inner columns (about half width at 1920px), so `min` is set larger than any column to force one.
|
||||
const SingleColumnBoard = ({ states }: { states: BoardState[] }) => <StateBoard min={10000} states={states} />
|
||||
const columnsAuto = (min: number) => `[grid-template-columns:repeat(auto-fit,minmax(min(100%,${min}px),1fr))]`
|
||||
|
||||
// 8px gap keeps all three Variants groups on one line at the 1280px PC width (Brand 3 + Solid 5 + Outline 4 buttons in a 3-column grid); narrower widths wrap.
|
||||
function CompactRoleRow({ appearance }: { appearance: (typeof appearances)[number][0] }) {
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{rolesFor(appearance).map(([variant, label]) => (
|
||||
<Button key={variant} variant={variant} appearance={appearance}>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function CompactSizeRow({ appearance }: { appearance: "brand" | "solid" }) {
|
||||
return (
|
||||
<div className={compactRow}>
|
||||
{sizes.map(([size, label]) => (
|
||||
<Button key={size} appearance={appearance} size={size}>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page (Sizes → Variants → States) with the ui_viz Button "All" boards.
|
||||
// The former Overview button was the default Brand Medium button already shown under Sizes, so Overview is merged into Sizes.
|
||||
// Named after the component, as in ui_viz. It is the only Button entry in the default sidebar, so Storybook shows Button as a single 1-depth item.
|
||||
export const All: Story = {
|
||||
name: "Button",
|
||||
parameters: { layout: "fullscreen" },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPage className={compactPage}>
|
||||
<StoryPageHeader title="Button" />
|
||||
<StorySection title="Overview">
|
||||
<Button>Button</Button>
|
||||
</StorySection>
|
||||
<StorySection title="Sizes">
|
||||
<SizesBoard />
|
||||
{/* Same columns as Variants below (Brand / Standard Solid / Standard Outline): Brand and Standard Solid sizes line up with
|
||||
their Variants columns and the third column stays empty. */}
|
||||
<div className={`grid gap-6 ${columnsAuto(260)} min-[960px]:[grid-template-columns:minmax(0,234fr)_minmax(0,353fr)_minmax(0,271fr)]`}>
|
||||
<SingleColumnBoard states={[{ id: "sizes-brand", label: "Brand", children: <CompactSizeRow appearance="brand" /> }]} />
|
||||
<SingleColumnBoard states={[{ id: "sizes-solid", label: "Standard Solid", children: <CompactSizeRow appearance="solid" /> }]} />
|
||||
</div>
|
||||
</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<StateBoard states={appearances.map(([appearance, label]) => ({ id: `roles-${appearance}`, label, wide: true, children: <RoleRow appearance={appearance} /> }))} />
|
||||
<div className={`grid gap-6 ${columnsAuto(260)} min-[960px]:[grid-template-columns:minmax(0,234fr)_minmax(0,353fr)_minmax(0,271fr)]`}>
|
||||
{appearances.map(([appearance, label]) => (
|
||||
<SingleColumnBoard key={appearance} states={[{ id: `roles-${appearance}`, label, children: <CompactRoleRow appearance={appearance} /> }]} />
|
||||
))}
|
||||
</div>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
states={[
|
||||
{ id: "brand-states", label: "Brand", wide: true, children: <BrandStates showNote={false} /> },
|
||||
{ id: "standard-solid-states", label: "Standard Solid", wide: true, children: <StandardStateTable appearance="solid" /> },
|
||||
{ id: "standard-outline-states", label: "Standard Outline", wide: true, children: <StandardStateTable appearance="outline" /> },
|
||||
]}
|
||||
/>
|
||||
<div className="grid gap-6">
|
||||
<SingleColumnBoard states={[{ id: "brand-states", label: "Brand", wide: true, children: <BrandStates showNote={false} /> }]} />
|
||||
<div className={twoColumns}>
|
||||
<SingleColumnBoard states={[{ id: "standard-solid-states", label: "Standard Solid", children: <StandardStateTable appearance="solid" compact /> }]} />
|
||||
<SingleColumnBoard states={[{ id: "standard-outline-states", label: "Standard Outline", children: <StandardStateTable appearance="outline" compact /> }]} />
|
||||
</div>
|
||||
</div>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
|
||||
@@ -1,18 +1,48 @@
|
||||
import { useId, useState, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Checkbox, Label } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { StateBoard, type BoardState } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// 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.
|
||||
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}>{label}</Label>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -49,20 +79,12 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Checkbox" />
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
min={240}
|
||||
<StorySection title="States">
|
||||
<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 /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
min={240}
|
||||
states={[
|
||||
{
|
||||
id: "hover",
|
||||
label: "Hover",
|
||||
@@ -89,26 +111,7 @@ export const All: Story = {
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Validation">
|
||||
<StateBoard min={240} states={[{ id: "error", label: "Error", children: <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }]} />
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<StateBoard
|
||||
min={240}
|
||||
states={[
|
||||
{
|
||||
id: "long",
|
||||
label: "Long Label",
|
||||
children: (
|
||||
<div className="max-w-[220px]">
|
||||
<div className="flex items-start gap-2">
|
||||
<Checkbox id="cb-long" className="mt-0.5" />
|
||||
<Label htmlFor="cb-long">A very long checkbox label that wraps onto multiple lines</Label>
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<StateGrid states={[{ id: "error", label: "Error", children: <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }]} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
@@ -123,6 +126,8 @@ export const Checked: Story = { tags: ["verification"], render: () => <Option la
|
||||
export const Indeterminate: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} checked="indeterminate" />}</Option> }
|
||||
export const Disabled: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} disabled defaultChecked />}</Option> }
|
||||
export const Error: Story = { tags: ["verification"], render: () => <Option label="Option">{(id) => <Checkbox id={id} aria-invalid />}</Option> }
|
||||
// Former Usage section of the representative page, kept as its own Story.
|
||||
export const LongLabel: Story = { tags: ["verification"], name: "Long Label", render: () => <LongLabelExample /> }
|
||||
export const Playground: Story = {
|
||||
tags: ["verification"],
|
||||
args: { disabled: false, defaultChecked: false },
|
||||
|
||||
@@ -9,8 +9,9 @@ import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-pag
|
||||
// separate Stories tagged `verification`, which the sidebar hides by default (see .storybook/main.ts).
|
||||
//
|
||||
// Label, Required star and Helper/Error message are not part of the Input component. Specimen below composes them with
|
||||
// plain elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 and Helper 12/18/400 in the
|
||||
// secondary text color, Error message 11/18 in the destructive color, field gap 8.
|
||||
// plain elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 in the default text color
|
||||
// (Disabled keeps the secondary color at 0.6) and Helper 12/18/400 in the secondary text color, Error message 11/18
|
||||
// in the destructive color, field gap 8.
|
||||
|
||||
function Specimen({
|
||||
label = "Project name",
|
||||
@@ -25,7 +26,7 @@ function Specimen({
|
||||
const messageId = `${fieldId}-message`
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor={fieldId} className={`justify-self-start text-sm font-medium text-muted-foreground${input.disabled ? " opacity-60" : ""}`}>
|
||||
<label htmlFor={fieldId} className={`justify-self-start text-sm font-medium ${input.disabled ? "text-muted-foreground opacity-60" : "text-foreground"}`}>
|
||||
{label}
|
||||
{required ? (
|
||||
<span aria-hidden="true" className="text-destructive">
|
||||
|
||||
@@ -1,11 +1,30 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Input, Label, Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { StateBoard, type BoardState } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Label has no standalone ui_viz spec. Its typography and color are the Field Label role owned by Input / Textarea
|
||||
// (14/20/500, color.fg.secondary). Required star and messages are composed here as a consumer would (not part of Label).
|
||||
|
||||
// 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. The Disabled example keeps the
|
||||
// shared secondary color (at 0.5 through `peer-disabled`). State titles are untouched.
|
||||
const fieldLabel = "[--hm-label-text:var(--foreground)]"
|
||||
|
||||
// Representative-page layout (Storybook presentation only): 3 equal columns on PC (iframe width 1000px and up), shrinking to
|
||||
// fit below that (240px minimum). 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 3 specimens keeps the
|
||||
// same column width and leaves the remaining tracks empty.
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
const meta = {
|
||||
title: "Components/Form/Label",
|
||||
component: Label,
|
||||
@@ -28,25 +47,19 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Label" />
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
<StorySection title="States">
|
||||
<SpecimenGrid
|
||||
states={[
|
||||
{
|
||||
id: "default",
|
||||
label: "Default",
|
||||
children: (
|
||||
<div className="grid max-w-sm gap-2">
|
||||
<Label htmlFor="label-default">Project name</Label>
|
||||
<Label htmlFor="label-default" className={fieldLabel}>Project name</Label>
|
||||
<Input id="label-default" size="medium" placeholder="Enter a project name" />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
states={[
|
||||
{
|
||||
id: "disabled",
|
||||
label: "Disabled",
|
||||
@@ -63,20 +76,26 @@ export const All: Story = {
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<div className="mb-4 grid max-w-sm gap-2">
|
||||
<Label htmlFor="label-select">부서 선택</Label>
|
||||
<Select><SelectTrigger id="label-select" size="medium"><SelectValue placeholder="부서를 선택하세요" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="design">디자인</SelectItem><SelectItem value="engineering">개발</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<StateBoard
|
||||
<SpecimenGrid
|
||||
states={[
|
||||
{
|
||||
id: "select",
|
||||
label: "Select",
|
||||
children: (
|
||||
<div className="grid max-w-sm gap-2">
|
||||
<Label htmlFor="label-select" className={fieldLabel}>부서 선택</Label>
|
||||
<Select><SelectTrigger id="label-select" size="medium"><SelectValue placeholder="부서를 선택하세요" /></SelectTrigger>
|
||||
<SelectContent><SelectItem value="design">디자인</SelectItem><SelectItem value="engineering">개발</SelectItem></SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "required",
|
||||
label: "Required",
|
||||
children: (
|
||||
<div className="grid max-w-sm gap-2">
|
||||
<Label htmlFor="label-required">
|
||||
<Label htmlFor="label-required" className={fieldLabel}>
|
||||
Project name
|
||||
<span aria-hidden="true" className="text-destructive">
|
||||
*
|
||||
@@ -91,7 +110,7 @@ export const All: Story = {
|
||||
label: "Long Label",
|
||||
children: (
|
||||
<div className="grid max-w-[220px] gap-2">
|
||||
<Label htmlFor="label-long">A very long label text that wraps to multiple lines</Label>
|
||||
<Label htmlFor="label-long" className={fieldLabel}>A very long label text that wraps to multiple lines</Label>
|
||||
<Input id="label-long" size="medium" />
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -1,10 +1,37 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Label, RadioGroup, RadioGroupItem } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import type { CSSProperties } from "react"
|
||||
import { StateBoard, type BoardState } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// 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
|
||||
|
||||
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",
|
||||
@@ -28,7 +55,7 @@ function Group({
|
||||
{["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}`}>Option {v.toUpperCase()}</Label>
|
||||
<Label htmlFor={`${prefix}-${v}`} className={optionLabel}>Option {v.toUpperCase()}</Label>
|
||||
</div>
|
||||
))}
|
||||
</RadioGroup>
|
||||
@@ -57,15 +84,20 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Radio Group" />
|
||||
<StorySection title="Overview">
|
||||
<StateBoard min={240} states={[{ id: "vertical", label: "Vertical", children: <Group prefix="v" defaultValue="b" /> }]} />
|
||||
</StorySection>
|
||||
{/* Overview is merged into Variants: its Vertical example is the first Variants item. */}
|
||||
<StorySection title="Variants">
|
||||
<StateBoard min={240} states={[{ id: "horizontal", label: "Horizontal", children: <Group prefix="h" orientation="horizontal" /> }]} />
|
||||
<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">
|
||||
<StateBoard
|
||||
min={240}
|
||||
<SpecimenGrid
|
||||
columns={4}
|
||||
states={[
|
||||
{ id: "hover", label: "Hover", children: <Group prefix="hv" defaultValue="" hoverItem="a" /> },
|
||||
{ id: "focus", label: "Focus", children: <Group prefix="f" /> },
|
||||
@@ -75,7 +107,8 @@ export const All: Story = {
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Validation">
|
||||
<StateBoard min={240} states={[{ id: "error", label: "Error", children: <Group prefix="e" invalid /> }]} />
|
||||
{/* 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>
|
||||
),
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useId, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, type SelectSize } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { StateBoard, type BoardState } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Story structure follows ui_viz (implementations/web/storybook/Select.stories.jsx and SelectSizes.stories.jsx):
|
||||
@@ -42,9 +42,11 @@ type SpecimenProps = {
|
||||
|
||||
function SelectSpecimen({ listSize, label = "Select", placeholder = "Choose a status", size, defaultValue, defaultOpen, disabled, "aria-invalid": ariaInvalid, options = statusOptions }: SpecimenProps) {
|
||||
const id = useId()
|
||||
// A Label next to an enabled Select uses the default text color; disabled keeps the secondary color at 0.6.
|
||||
const labelTone = disabled ? "text-muted-foreground opacity-60" : "text-foreground"
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor={id} className={`justify-self-start text-sm font-medium text-muted-foreground${disabled ? " opacity-60" : ""}`}>
|
||||
<label htmlFor={id} className={`justify-self-start text-sm font-medium ${labelTone}`}>
|
||||
{label}
|
||||
</label>
|
||||
<Select defaultValue={defaultValue} defaultOpen={defaultOpen} disabled={disabled}>
|
||||
@@ -109,6 +111,23 @@ function SizeComparison({ showSpec = true }: { showSpec?: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
// Representative-page layout (Storybook presentation only): 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 track. Full class names are listed so Tailwind can see them.
|
||||
const specimenColumns = {
|
||||
3: "min-[1000px]:grid-cols-3",
|
||||
} as const
|
||||
|
||||
function SpecimenGrid({ states, columns }: { states: BoardState[]; columns: keyof typeof specimenColumns }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] ${specimenColumns[columns]}`}>
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[state]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function DisabledStates() {
|
||||
return (
|
||||
<div className="grid max-w-[480px] gap-6">
|
||||
@@ -157,11 +176,16 @@ export const All: Story = {
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Select" />
|
||||
<StorySection title="Sizes">
|
||||
<SizeComparison showSpec={false} />
|
||||
{/* Same 3-column track as States / Validation: Medium and Large take the first two columns, the third stays empty. */}
|
||||
<div className="grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
|
||||
{sizes.map((spec) => (
|
||||
<SizeCard key={spec.size} spec={spec} showSpec={false} />
|
||||
))}
|
||||
</div>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
fixed
|
||||
<SpecimenGrid
|
||||
columns={3}
|
||||
states={[
|
||||
{ id: "selected", label: "Selected", children: <SelectSpecimen size="medium" label="Status" defaultValue="active" /> },
|
||||
{ id: "disabled", label: "Disabled", children: <SelectSpecimen size="medium" label="Status" defaultValue="active" disabled /> },
|
||||
@@ -169,9 +193,10 @@ export const All: Story = {
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Invalid / Focus">
|
||||
<StateBoard
|
||||
fixed
|
||||
<StorySection title="Validation">
|
||||
{/* Same 3-column track as States: the two specimens take the first two columns (same Select width) and the third stays empty. */}
|
||||
<SpecimenGrid
|
||||
columns={3}
|
||||
states={[
|
||||
{ id: "invalid", label: "Invalid", children: <SelectSpecimen size="medium" aria-invalid label="Status" defaultValue="active" /> },
|
||||
{ id: "invalid-disabled", label: "Invalid · Disabled", children: <SelectSpecimen size="medium" aria-invalid disabled label="Status" /> },
|
||||
|
||||
@@ -12,7 +12,7 @@ function Labeled({ label, children, valueText }: { label: string; children: Reac
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="font-medium">{label}</span>
|
||||
<span className="font-medium text-foreground">{label}</span>
|
||||
<span className="text-muted-foreground" data-testid="value">{valueText}</span>
|
||||
</div>
|
||||
{children}
|
||||
@@ -32,8 +32,8 @@ function Single() {
|
||||
function Range() {
|
||||
const [value, setValue] = useState([25, 75])
|
||||
return (
|
||||
<Labeled label="가격 범위" valueText={`${value[0]} – ${value[1]}`}>
|
||||
<Slider aria-label="가격 범위" value={value} onValueChange={setValue} max={100} step={1} minStepsBetweenThumbs={5} />
|
||||
<Labeled label="범위" valueText={`${value[0]} – ${value[1]}`}>
|
||||
<Slider aria-label="범위"value={value} onValueChange={setValue} max={100} step={1} minStepsBetweenThumbs={5} />
|
||||
</Labeled>
|
||||
)
|
||||
}
|
||||
@@ -71,20 +71,35 @@ const meta = { title: "Components/Form/Slider", component: Slider, parameters: {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={320} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
// 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 = {
|
||||
name: "Slider",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Slider" />
|
||||
<StorySection title="Overview">{board("single")}</StorySection>
|
||||
<StorySection title="Variants">{board("range", "step", "vertical")}</StorySection>
|
||||
<StorySection title="States">{board("disabled")}</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<SpecimenGrid ids={["single", "range", "step", "vertical"]} />
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<SpecimenGrid ids={["disabled"]} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,15 +1,33 @@
|
||||
import { useId, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Label, Switch } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { StateBoard, type BoardState } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// 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)]"
|
||||
|
||||
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}>{label}</Label>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -36,19 +54,11 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Switch" />
|
||||
<StorySection title="Overview">
|
||||
<StateBoard
|
||||
min={240}
|
||||
<StorySection title="States">
|
||||
<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> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
min={240}
|
||||
states={[
|
||||
{
|
||||
id: "hover",
|
||||
label: "Hover",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useId, type ComponentProps, type ReactNode } from "react"
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { Textarea } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { StateBoard, type BoardState } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Story structure follows Input: one representative "Textarea" Story shows every state on one page; the same specimens
|
||||
@@ -9,8 +9,8 @@ import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-pag
|
||||
// ui_viz docs/components/textarea.md §6 Reference Viewer: Default, Hover, Focus, Error, Error + Focus, Readonly, Disabled and Multiline.
|
||||
//
|
||||
// Label, Required star and Helper/Error message are not part of the Textarea component. Specimen composes them with plain
|
||||
// elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 and Helper 12/18/400 in the secondary
|
||||
// text color, Error message 11/18 in the destructive color, field gap 8.
|
||||
// elements and shadcn Token utilities, the way a consumer would: Label 14/20/500 in the default text color (Disabled keeps
|
||||
// the secondary color) and Helper 12/18/400 in the secondary text color, Error message 11/18 in the destructive color, field gap 8.
|
||||
|
||||
function Specimen({
|
||||
label = "Description",
|
||||
@@ -25,7 +25,7 @@ function Specimen({
|
||||
const messageId = `${fieldId}-message`
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<label htmlFor={fieldId} className="justify-self-start text-sm font-medium text-muted-foreground">
|
||||
<label htmlFor={fieldId} className={`justify-self-start text-sm font-medium ${textarea.disabled ? "text-muted-foreground" : "text-foreground"}`}>
|
||||
{label}
|
||||
{required ? (
|
||||
<span aria-hidden="true" className="text-destructive">
|
||||
@@ -49,6 +49,23 @@ function Specimen({
|
||||
)
|
||||
}
|
||||
|
||||
// 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 this grid.
|
||||
// Full class names are listed so Tailwind can see them.
|
||||
const specimenColumns = {
|
||||
4: "min-[1000px]:grid-cols-4",
|
||||
} as const
|
||||
|
||||
function SpecimenGrid({ states, columns }: { states: BoardState[]; columns: keyof typeof specimenColumns }) {
|
||||
return (
|
||||
<div className={`grid items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,220px),1fr))] ${specimenColumns[columns]}`}>
|
||||
{states.map((state) => (
|
||||
<StateBoard key={state.id} min={10000} states={[state]} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const narrow = (children: ReactNode) => <div className="max-w-sm">{children}</div>
|
||||
const placeholder = "Enter a description"
|
||||
const multiline = "First line\nSecond line keeps its line break.\nThird line."
|
||||
@@ -86,12 +103,13 @@ export const All: Story = {
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Textarea" />
|
||||
<StorySection title="Overview">
|
||||
<StateBoard states={[{ id: "default", label: "Default", children: <Specimen placeholder={placeholder} /> }]} />
|
||||
</StorySection>
|
||||
{/* PC (iframe width 1000px and up): States in 4 equal columns, Validation's 2 specimens on the same column width.
|
||||
Below that the columns shrink to fit (220px minimum, down to 1). Multiline and Resize live in their own hidden Stories. */}
|
||||
<StorySection title="States">
|
||||
<StateBoard
|
||||
<SpecimenGrid
|
||||
columns={4}
|
||||
states={[
|
||||
{ id: "default", label: "Default", children: <Specimen placeholder={placeholder} /> },
|
||||
{ id: "focus", label: "Focus", children: <Specimen placeholder={placeholder} /> },
|
||||
{ id: "readonly", label: "Readonly", children: <Specimen readOnly defaultValue="Confirmed description" /> },
|
||||
{ id: "disabled", label: "Disabled", children: <Specimen disabled defaultValue="Unavailable description" /> },
|
||||
@@ -99,21 +117,15 @@ export const All: Story = {
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Validation">
|
||||
<StateBoard
|
||||
{/* Same 4-column track as States, so the two specimens keep the States width instead of stretching across the page. */}
|
||||
<SpecimenGrid
|
||||
columns={4}
|
||||
states={[
|
||||
{ id: "error", label: "Error", children: <Specimen error defaultValue="Invalid value" /> },
|
||||
{ id: "error-message", label: "Error Message", children: <Specimen error message="설명을 입력하세요." /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
<StorySection title="Usage">
|
||||
<StateBoard
|
||||
states={[
|
||||
{ id: "multiline", label: "Multiline · Wrap", children: <Specimen defaultValue={multiline} /> },
|
||||
{ id: "resize", label: "Resize · none", children: <Specimen resize="none" defaultValue="This field cannot be resized." /> },
|
||||
]}
|
||||
/>
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
play: async ({ canvasElement }) => {
|
||||
@@ -151,6 +163,17 @@ export const Required: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen required />),
|
||||
}
|
||||
// Former Usage section of the representative page, kept as separate Stories.
|
||||
export const Multiline: Story = {
|
||||
tags: ["verification"],
|
||||
name: "Multiline · Wrap",
|
||||
render: () => narrow(<Specimen defaultValue={multiline} />),
|
||||
}
|
||||
export const ResizeNone: Story = {
|
||||
tags: ["verification"],
|
||||
name: "Resize · none",
|
||||
render: () => narrow(<Specimen resize="none" defaultValue="This field cannot be resized." />),
|
||||
}
|
||||
export const Readonly: Story = {
|
||||
tags: ["verification"],
|
||||
render: () => narrow(<Specimen readOnly defaultValue="Confirmed description" />),
|
||||
|
||||
@@ -43,21 +43,24 @@ function Multiple({ showValue = true }: { showValue?: boolean }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Disabled() {
|
||||
function DisabledGroup() {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<ToggleGroup type="single" variant="outline" defaultValue="a" disabled aria-label="비활성 그룹">
|
||||
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
||||
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ToggleGroup type="single" variant="outline" defaultValue="a" aria-label="비활성 항목">
|
||||
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
||||
<ToggleGroupItem value="b" disabled>
|
||||
B (비활성)
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="c">C</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
<ToggleGroup type="single" variant="outline" defaultValue="a" disabled aria-label="비활성 그룹">
|
||||
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
||||
<ToggleGroupItem value="b">B</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)
|
||||
}
|
||||
|
||||
function DisabledItem() {
|
||||
return (
|
||||
<ToggleGroup type="single" variant="outline" defaultValue="a" aria-label="비활성 항목">
|
||||
<ToggleGroupItem value="a">A</ToggleGroupItem>
|
||||
<ToggleGroupItem value="b" disabled>
|
||||
B (비활성)
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value="c">C</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -99,7 +102,8 @@ function Many() {
|
||||
|
||||
function Sizes() {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
// 3 equal columns on PC (iframe width 1000px and up): sm / default / lg; fewer columns below that (240px minimum).
|
||||
<div className="grid items-start justify-items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,240px),1fr))] min-[1000px]:grid-cols-3">
|
||||
{(["sm", "default", "lg"] as const).map((size) => (
|
||||
<ToggleGroup key={size} type="multiple" size={size} variant="outline" aria-label={`${size} content kinds`}>
|
||||
<ToggleGroupItem value="icon" aria-label={`${size} icon-only`}><BoldIcon /></ToggleGroupItem>
|
||||
@@ -116,29 +120,42 @@ const meta = { title: "Components/Form/Toggle Group", parameters: { layout: "pad
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
// The representative page shows the groups without example titles: the section title names them.
|
||||
const examples = (...nodes: ReactNode[]) => (
|
||||
<div className="flex flex-wrap items-start gap-x-10 gap-y-6">
|
||||
{nodes.map((node, i) => (
|
||||
<div key={i}>{node}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
// The representative page shows the groups without example titles: the section title names them. Equal columns on PC
|
||||
// (iframe width 1000px and up), fewer below that (200px minimum); the groups keep their own size (they are not stretched to the
|
||||
// column). Full class names are listed so Tailwind can see them.
|
||||
const specimenColumns = {
|
||||
3: "min-[1000px]:grid-cols-3",
|
||||
4: "min-[1000px]:grid-cols-4",
|
||||
} as const
|
||||
|
||||
function Examples({ columns, nodes }: { columns: keyof typeof specimenColumns; nodes: ReactNode[] }) {
|
||||
return (
|
||||
<div className={`grid items-start justify-items-start gap-6 [grid-template-columns:repeat(auto-fill,minmax(min(100%,200px),1fr))] ${specimenColumns[columns]}`}>
|
||||
{nodes.map((node, i) => (
|
||||
<div key={i}>{node}</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Representative Story: the common Story page; the sections group the same specimens.
|
||||
// Overview is merged into Variants (the default group is its first item). The 10-option Usage example is the hidden `ManyOptions` Story.
|
||||
export const All: Story = {
|
||||
name: "Toggle Group",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Toggle Group" />
|
||||
<StorySection title="Overview">{examples(<Single showValue={false} />)}</StorySection>
|
||||
<StorySection title="Variants">
|
||||
<Examples columns={4} nodes={[<Single key="single" showValue={false} />, <Multiple key="multiple" showValue={false} />, <Vertical key="vertical" />, <Spaced key="spaced" />]} />
|
||||
</StorySection>
|
||||
<StorySection title="Sizes">
|
||||
<Sizes />
|
||||
</StorySection>
|
||||
<StorySection title="Variants">{examples(<Multiple showValue={false} />, <Vertical />, <Spaced />)}</StorySection>
|
||||
<StorySection title="States">{examples(<Disabled />)}</StorySection>
|
||||
<StorySection title="Usage">{examples(<div className="max-w-sm"><Many /></div>)}</StorySection>
|
||||
<StorySection title="States">
|
||||
{/* First two columns of a 3-column grid; the third stays empty. */}
|
||||
<Examples columns={3} nodes={[<DisabledGroup key="group" />, <DisabledItem key="item" />]} />
|
||||
</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
@@ -146,6 +163,8 @@ export const All: Story = {
|
||||
export const SingleSelection: Story = { tags: ["verification"], name: "Single", render: () => <Single /> }
|
||||
export const MultipleSelection: Story = { tags: ["verification"], name: "Multiple", render: () => <Multiple /> }
|
||||
export const VerticalGroup: Story = { tags: ["verification"], name: "Vertical", render: () => <Vertical /> }
|
||||
// Former Usage section of the representative page (10 options that wrap), kept as its own Story.
|
||||
export const ManyOptions: Story = { tags: ["verification"], name: "Many Options", render: () => <div className="max-w-sm"><Many /></div> }
|
||||
|
||||
export const ContentNodes: Story = {
|
||||
tags: ["verification"],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite"
|
||||
import { BoldIcon, ItalicIcon, StarIcon } from "lucide-react"
|
||||
import { Toggle, ToggleGroup, ToggleGroupItem } from "@hm-design/ui"
|
||||
import { StateBoard } from "@/components/story-board"
|
||||
import { StateBoard, type BoardState } from "@/components/story-board"
|
||||
import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page"
|
||||
|
||||
// Toggle is a persistent pressed-state control. The representative Story shows each state with real, clickable toggles; the same
|
||||
@@ -39,7 +39,8 @@ function WithIcon() {
|
||||
|
||||
function Sizes() {
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
// 3 equal columns on PC (iframe width 1000px and up): sm / default / lg; fewer columns below that (240px minimum).
|
||||
<div className="grid items-start gap-6 [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-3">
|
||||
<Toggle size={size} variant="outline" aria-label={`${size} icon-only`}><StarIcon /></Toggle>
|
||||
@@ -71,21 +72,41 @@ const meta = { title: "Components/Form/Toggle", component: Toggle, parameters: {
|
||||
export default meta
|
||||
type Story = StoryObj<typeof meta>
|
||||
|
||||
const board = (...ids: string[]) => (
|
||||
<StateBoard min={360} states={sections.filter((s) => ids.includes(s.id)).map((s) => ({ id: s.id, label: s.label, children: s.node }))} />
|
||||
)
|
||||
// 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.
|
||||
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 use the first two of the same 3 equal
|
||||
// columns as the rows below, and the third column stays empty; below that they stack.
|
||||
export const All: Story = {
|
||||
name: "Toggle",
|
||||
parameters: { layout: "fullscreen", controls: { disable: true } },
|
||||
render: () => (
|
||||
<StoryPage>
|
||||
<StoryPageHeader title="Toggle" />
|
||||
<StorySection title="Overview">{board("offon")}</StorySection>
|
||||
<div className="grid gap-x-6 min-[1000px]:grid-cols-3">
|
||||
<StorySection title="Variants">
|
||||
<StateBoard min={10000} states={[specimen("offon")]} />
|
||||
</StorySection>
|
||||
<StorySection title="States">
|
||||
<StateBoard min={10000} states={[specimen("disabled")]} />
|
||||
</StorySection>
|
||||
</div>
|
||||
<StorySection title="단독 · 연결 그룹">
|
||||
<StateBoard
|
||||
min={360}
|
||||
<SpecimenGrid
|
||||
states={[
|
||||
{ id: "single-toggle", label: "단독", children: <Toggle variant="outline">알림</Toggle> },
|
||||
{ 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> },
|
||||
@@ -96,7 +117,6 @@ export const All: Story = {
|
||||
<StorySection title="Sizes">
|
||||
<Sizes />
|
||||
</StorySection>
|
||||
<StorySection title="States">{board("disabled")}</StorySection>
|
||||
</StoryPage>
|
||||
),
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user