@hjmds/design-contracts 1.12.1 → 1.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/avatar-fallback.d.ts +11 -0
- package/dist/avatar-fallback.d.ts.map +1 -1
- package/dist/avatar-fallback.js +21 -0
- package/dist/avatar-fallback.js.map +1 -1
- package/dist/base-recipes.d.ts +17 -0
- package/dist/base-recipes.d.ts.map +1 -1
- package/dist/base-recipes.js +17 -0
- package/dist/base-recipes.js.map +1 -1
- package/dist/catalog.d.ts +26 -0
- package/dist/catalog.d.ts.map +1 -1
- package/dist/command-palette.d.ts +14 -9
- package/dist/command-palette.d.ts.map +1 -1
- package/dist/command-palette.js +8 -9
- package/dist/command-palette.js.map +1 -1
- package/dist/component-recipes.d.ts +31 -0
- package/dist/component-recipes.d.ts.map +1 -1
- package/dist/component-recipes.js +20 -0
- package/dist/component-recipes.js.map +1 -1
- package/dist/provider-button.d.ts.map +1 -1
- package/dist/provider-button.js +3 -0
- package/dist/provider-button.js.map +1 -1
- package/dist/reactions.d.ts +10 -0
- package/dist/reactions.d.ts.map +1 -1
- package/dist/reactions.js +7 -0
- package/dist/reactions.js.map +1 -1
- package/dist/screen-patterns.d.ts +147 -0
- package/dist/screen-patterns.d.ts.map +1 -0
- package/dist/screen-patterns.js +149 -0
- package/dist/screen-patterns.js.map +1 -0
- package/dist/slider.d.ts +8 -0
- package/dist/slider.d.ts.map +1 -1
- package/dist/slider.js +6 -1
- package/dist/slider.js.map +1 -1
- package/dist/upload-item.d.ts +5 -0
- package/dist/upload-item.d.ts.map +1 -1
- package/dist/upload-item.js +5 -0
- package/dist/upload-item.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/version.js.map +1 -1
- package/docs/action-session.md +3 -3
- package/docs/agreement.md +5 -0
- package/docs/avatar-fallback.md +7 -0
- package/docs/bottom-navigation.md +6 -0
- package/docs/brand-boundary.md +1 -1
- package/docs/button-label.md +6 -0
- package/docs/clipboard.md +3 -0
- package/docs/command-palette.md +45 -2
- package/docs/consumer-policy.md +5 -1
- package/docs/data-table.md +6 -4
- package/docs/dialog.md +8 -2
- package/docs/form.md +51 -0
- package/docs/generated/component-maturity.md +1 -1
- package/docs/generated/renderer-evidence.json +3 -3
- package/docs/generated/renderer-evidence.md +1 -1
- package/docs/generated/showcase-manifest.json +1 -1
- package/docs/link.md +8 -0
- package/docs/migration-native-legacy-removal.md +45 -1
- package/docs/optional-adapters.md +1 -1
- package/docs/password-field.md +5 -0
- package/docs/product-composition-adoption.md +40 -0
- package/docs/progress.md +19 -1
- package/docs/provider-button.md +13 -0
- package/docs/result.md +3 -0
- package/docs/screen-chrome.md +10 -0
- package/docs/screen-patterns.md +378 -0
- package/docs/sheet.md +21 -0
- package/docs/splitter.md +8 -2
- package/docs/theming.md +36 -29
- package/docs/toggle-group.md +13 -0
- package/docs/tour.md +7 -1
- package/docs/tree.md +5 -2
- package/docs/upload-item.md +7 -0
- package/docs/usage/README.md +236 -0
- package/docs/usage/STANDARD.md +108 -0
- package/docs/usage/components/accordion.md +107 -0
- package/docs/usage/components/activity-heatmap.md +104 -0
- package/docs/usage/components/affix.md +86 -0
- package/docs/usage/components/agreement.md +129 -0
- package/docs/usage/components/alert-dialog.md +130 -0
- package/docs/usage/components/anchor.md +96 -0
- package/docs/usage/components/aspect-ratio.md +89 -0
- package/docs/usage/components/asset.md +126 -0
- package/docs/usage/components/auth-provider-button.md +116 -0
- package/docs/usage/components/auth-screen-layout.md +129 -0
- package/docs/usage/components/avatar.md +114 -0
- package/docs/usage/components/badge.md +84 -0
- package/docs/usage/components/bottom-cta.md +125 -0
- package/docs/usage/components/bottom-info.md +99 -0
- package/docs/usage/components/bottom-navigation.md +136 -0
- package/docs/usage/components/breadcrumb.md +81 -0
- package/docs/usage/components/button.md +118 -0
- package/docs/usage/components/calendar.md +122 -0
- package/docs/usage/components/card.md +110 -0
- package/docs/usage/components/carousel.md +113 -0
- package/docs/usage/components/celebration.md +96 -0
- package/docs/usage/components/chat-message.md +122 -0
- package/docs/usage/components/chat-screen.md +112 -0
- package/docs/usage/components/checkbox-group.md +104 -0
- package/docs/usage/components/checkbox.md +103 -0
- package/docs/usage/components/chip.md +107 -0
- package/docs/usage/components/code-block.md +111 -0
- package/docs/usage/components/collapsible.md +112 -0
- package/docs/usage/components/color-picker.md +86 -0
- package/docs/usage/components/combobox.md +137 -0
- package/docs/usage/components/command-palette.md +125 -0
- package/docs/usage/components/comment-thread-screen.md +125 -0
- package/docs/usage/components/container.md +98 -0
- package/docs/usage/components/content-transition.md +101 -0
- package/docs/usage/components/context-menu.md +136 -0
- package/docs/usage/components/counter-badge.md +107 -0
- package/docs/usage/components/data-table.md +122 -0
- package/docs/usage/components/date-picker.md +142 -0
- package/docs/usage/components/date-range-picker.md +111 -0
- package/docs/usage/components/description-list.md +103 -0
- package/docs/usage/components/design-system-provider.md +124 -0
- package/docs/usage/components/dialog.md +176 -0
- package/docs/usage/components/divider.md +89 -0
- package/docs/usage/components/editor-screen.md +126 -0
- package/docs/usage/components/effect-surface.md +120 -0
- package/docs/usage/components/empty-state.md +114 -0
- package/docs/usage/components/field.md +129 -0
- package/docs/usage/components/file-picker.md +114 -0
- package/docs/usage/components/floating-action-button.md +138 -0
- package/docs/usage/components/form.md +162 -0
- package/docs/usage/components/grid.md +99 -0
- package/docs/usage/components/heading.md +87 -0
- package/docs/usage/components/icon-button.md +126 -0
- package/docs/usage/components/icon.md +105 -0
- package/docs/usage/components/image.md +122 -0
- package/docs/usage/components/keyboard-avoiding.md +93 -0
- package/docs/usage/components/keyboard-dock.md +110 -0
- package/docs/usage/components/keyboard-form-scroll-view.md +95 -0
- package/docs/usage/components/keyboard-motion-provider.md +86 -0
- package/docs/usage/components/layout.md +117 -0
- package/docs/usage/components/link.md +121 -0
- package/docs/usage/components/list-detail-screen.md +103 -0
- package/docs/usage/components/list-row.md +124 -0
- package/docs/usage/components/list.md +119 -0
- package/docs/usage/components/load-more.md +115 -0
- package/docs/usage/components/masonry.md +109 -0
- package/docs/usage/components/media-selection-screen.md +119 -0
- package/docs/usage/components/mentions.md +119 -0
- package/docs/usage/components/menu.md +129 -0
- package/docs/usage/components/menubar.md +93 -0
- package/docs/usage/components/message-composer.md +124 -0
- package/docs/usage/components/moderation-screen.md +113 -0
- package/docs/usage/components/notice.md +106 -0
- package/docs/usage/components/notification-inbox-screen.md +97 -0
- package/docs/usage/components/notification-item.md +98 -0
- package/docs/usage/components/number-field.md +131 -0
- package/docs/usage/components/onboarding-screen.md +106 -0
- package/docs/usage/components/otp-field.md +101 -0
- package/docs/usage/components/pagination.md +82 -0
- package/docs/usage/components/password-field.md +137 -0
- package/docs/usage/components/permission-screen.md +107 -0
- package/docs/usage/components/photo-source-sheet.md +119 -0
- package/docs/usage/components/popover.md +108 -0
- package/docs/usage/components/profile-screen.md +89 -0
- package/docs/usage/components/progress.md +122 -0
- package/docs/usage/components/qr-code.md +122 -0
- package/docs/usage/components/radio-group.md +124 -0
- package/docs/usage/components/radio.md +104 -0
- package/docs/usage/components/result.md +116 -0
- package/docs/usage/components/saved-items-screen.md +126 -0
- package/docs/usage/components/screen-layout.md +119 -0
- package/docs/usage/components/search-field.md +120 -0
- package/docs/usage/components/search-screen.md +221 -0
- package/docs/usage/components/section.md +111 -0
- package/docs/usage/components/segmented-control.md +146 -0
- package/docs/usage/components/select.md +142 -0
- package/docs/usage/components/settings-screen.md +126 -0
- package/docs/usage/components/shared-transition-element.md +111 -0
- package/docs/usage/components/shared-transition-screen.md +86 -0
- package/docs/usage/components/sheet.md +157 -0
- package/docs/usage/components/side-panel.md +104 -0
- package/docs/usage/components/sidebar.md +107 -0
- package/docs/usage/components/skeleton.md +105 -0
- package/docs/usage/components/skip-nav.md +76 -0
- package/docs/usage/components/slider.md +121 -0
- package/docs/usage/components/sortable-collection.md +127 -0
- package/docs/usage/components/spinner.md +86 -0
- package/docs/usage/components/splitter.md +103 -0
- package/docs/usage/components/stack.md +93 -0
- package/docs/usage/components/statistic.md +123 -0
- package/docs/usage/components/steps.md +110 -0
- package/docs/usage/components/surface.md +91 -0
- package/docs/usage/components/swipe-actions.md +124 -0
- package/docs/usage/components/switch.md +120 -0
- package/docs/usage/components/tabs.md +134 -0
- package/docs/usage/components/tag.md +84 -0
- package/docs/usage/components/tags-input.md +111 -0
- package/docs/usage/components/text-area.md +112 -0
- package/docs/usage/components/text-format.md +75 -0
- package/docs/usage/components/text-transition.md +104 -0
- package/docs/usage/components/text.md +101 -0
- package/docs/usage/components/thinking-orb.md +105 -0
- package/docs/usage/components/timeline.md +105 -0
- package/docs/usage/components/toast.md +145 -0
- package/docs/usage/components/toggle-group.md +95 -0
- package/docs/usage/components/tooltip.md +103 -0
- package/docs/usage/components/top-bar.md +124 -0
- package/docs/usage/components/top.md +89 -0
- package/docs/usage/components/tour.md +118 -0
- package/docs/usage/components/transfer-list.md +115 -0
- package/docs/usage/components/tree.md +91 -0
- package/docs/usage/components/upload-item.md +99 -0
- package/docs/usage/components/virtual-list.md +105 -0
- package/docs/usage/components/visually-hidden.md +72 -0
- package/docs/usage/components/watermark.md +78 -0
- package/docs/usage/compositions/action-recovery-optimistic.md +180 -0
- package/docs/usage/compositions/action-recovery-save.md +235 -0
- package/docs/usage/compositions/action-recovery-undo.md +193 -0
- package/docs/usage/compositions/common-message.md +132 -0
- package/docs/usage/compositions/common-notification.md +101 -0
- package/docs/usage/compositions/compound-controls.md +186 -0
- package/docs/usage/compositions/data-layouts.md +157 -0
- package/docs/usage/compositions/disclosure.md +144 -0
- package/docs/usage/compositions/environment-matrix.md +139 -0
- package/docs/usage/compositions/expo-interactions.md +149 -0
- package/docs/usage/compositions/family-drawer.md +201 -0
- package/docs/usage/compositions/floating-action-button.md +197 -0
- package/docs/usage/compositions/input-sheet.md +148 -0
- package/docs/usage/compositions/interaction-adapters.md +190 -0
- package/docs/usage/compositions/interaction-flow-apply.md +205 -0
- package/docs/usage/compositions/interaction-flow-draft.md +188 -0
- package/docs/usage/compositions/interaction-flow-search.md +171 -0
- package/docs/usage/compositions/native-renderers.md +106 -0
- package/docs/usage/compositions/navigation-bar-collection.md +164 -0
- package/docs/usage/compositions/optional-adapters.md +169 -0
- package/docs/usage/compositions/optional-motion.md +109 -0
- package/docs/usage/compositions/photo-source.md +104 -0
- package/docs/usage/compositions/purpose-input-comment.md +110 -0
- package/docs/usage/compositions/purpose-input-message.md +119 -0
- package/docs/usage/compositions/reference-first.md +96 -0
- package/docs/usage/compositions/reference-review.md +107 -0
- package/docs/usage/compositions/reference-settings.md +107 -0
- package/docs/usage/compositions/selection-scope.md +174 -0
- package/docs/usage/compositions/stea-event-ticket.md +166 -0
- package/docs/usage/compositions/stea-flip-card.md +162 -0
- package/docs/usage/compositions/stea-order-progress.md +184 -0
- package/docs/usage/compositions/stea-otp-verify.md +215 -0
- package/docs/usage/compositions/stea-pixel-empty.md +140 -0
- package/docs/usage/compositions/stea-schedule-card.md +169 -0
- package/docs/usage/compositions/stea-stat-summary.md +154 -0
- package/docs/usage/compositions/time-selection.md +174 -0
- package/docs/usage/compositions/toast-layout.md +128 -0
- package/docs/usage/compositions/visual-foundations.md +185 -0
- package/docs/usage/compositions/web-additions.md +146 -0
- package/docs/usage/compositions/web-navigation.md +143 -0
- package/docs/usage/screens/common-chat.md +127 -0
- package/docs/usage/screens/common-comments.md +108 -0
- package/docs/usage/screens/common-inbox.md +110 -0
- package/docs/usage/screens/common-login.md +98 -0
- package/docs/usage/screens/common-profile.md +221 -0
- package/docs/usage/screens/common-saved.md +127 -0
- package/docs/usage/screens/common-search.md +279 -0
- package/docs/usage/screens/common-settings.md +126 -0
- package/docs/usage/screens/common-shell.md +108 -0
- package/docs/usage/screens/dashboard.md +245 -0
- package/docs/usage/screens/discovery-gallery.md +306 -0
- package/docs/usage/screens/flow-collection.md +96 -0
- package/docs/usage/screens/flow-editor.md +120 -0
- package/docs/usage/screens/flow-media.md +111 -0
- package/docs/usage/screens/flow-moderation.md +120 -0
- package/docs/usage/screens/flow-onboarding.md +193 -0
- package/docs/usage/screens/flow-permission.md +103 -0
- package/docs/usage/screens/landing.md +347 -0
- package/docs/usage/screens/mockup-studio.md +190 -0
- package/docs/usage/screens/notification-settings.md +206 -0
- package/docs/usage/screens/reference-comparison.md +159 -0
- package/docs/usage/templates/component.md +61 -0
- package/docs/usage/templates/composition.md +47 -0
- package/docs/usage/templates/screen.md +56 -0
- package/docs/usage/templates/token.md +32 -0
- package/docs/usage/tokens/color.md +142 -0
- package/docs/usage/tokens/elevation-opacity.md +86 -0
- package/docs/usage/tokens/layers.md +98 -0
- package/docs/usage/tokens/layout.md +114 -0
- package/docs/usage/tokens/motion.md +88 -0
- package/docs/usage/tokens/radius.md +53 -0
- package/docs/usage/tokens/size.md +74 -0
- package/docs/usage/tokens/spacing.md +73 -0
- package/docs/usage/tokens/stroke.md +50 -0
- package/docs/usage/tokens/theme-studio.md +70 -0
- package/docs/usage/tokens/typography-studio.md +70 -0
- package/docs/usage/tokens/typography.md +89 -0
- package/package.json +7 -1
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
# 변경 저장과 이탈 확인
|
|
2
|
+
|
|
3
|
+
- 단계: 구성
|
|
4
|
+
- 상태: 배포
|
|
5
|
+
- 지원: Web · Native
|
|
6
|
+
- 적용: 미게시(1.12.1 이후)
|
|
7
|
+
- 검토일: 2026-10-06
|
|
8
|
+
- 근거: [반복 화면 조합](../../screen-patterns.md); 공통 API와 실제 Web·Native 예제의 슬롯·상태를 대조해 중복 조립 방지. 2026-10-06 사용자 승인으로 스토리북 배포(이전 `실험/구성/설정/변경 저장과 이탈 확인`, [승인 기록](../../../../../docs/STORYBOOK_NAVIGATION.md#21-2026-10-06-전체-승격과-규격-확정))
|
|
9
|
+
- 스토리북: `배포/구성/피드백과 복구/변경 저장과 이탈 확인`
|
|
10
|
+
|
|
11
|
+
## 언제 쓰나
|
|
12
|
+
|
|
13
|
+
저장값과 편집 초안을 비교해 이탈 확인 흐름이 필요할 때 쓴다. Form의 상태·콜백을 제품 로직에 연결하며 새 데이터 엔진을 만들지 않는다.
|
|
14
|
+
|
|
15
|
+
## 구성 요소
|
|
16
|
+
|
|
17
|
+
| 컴포넌트 | 역할 | 지침 |
|
|
18
|
+
| --- | --- | --- |
|
|
19
|
+
| Form | 저장값과 편집 초안을 비교해 이탈 확인 | [공개 계약](../components/form.md) |
|
|
20
|
+
| Button | 명시 행동·재시도 | [Button](../components/button.md) |
|
|
21
|
+
|
|
22
|
+
## 배치
|
|
23
|
+
|
|
24
|
+
```text
|
|
25
|
+
부모 화면의 공개 슬롯
|
|
26
|
+
└─ 입력 → 저장·되돌리기 → 이탈 확인
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
| 영역 | 컴포넌트 | 위치 | 크기·간격 |
|
|
30
|
+
| --- | --- | --- | --- |
|
|
31
|
+
| 바깥 틀 | Form 또는 포함 Surface | 부모 화면의 해당 슬롯 | [배치](../components/form.md#배치)에 따른다 |
|
|
32
|
+
| 내용 | Form 내부 슬롯 | 입력 → 저장·되돌리기 → 이탈 확인 | spacing 토큰과 포함 컴포넌트 recipe |
|
|
33
|
+
| 행동 | Button 또는 공개 콜백 | 내용과 가까운 명시 진입점 | 주 행동 하나, 보조 행동과 구분 |
|
|
34
|
+
|
|
35
|
+
## 흐름과 상태
|
|
36
|
+
|
|
37
|
+
1. 저장값과 편집 초안을 비교해 이탈 확인.
|
|
38
|
+
2. 진행 상태와 제품의 실제 확정을 분리한다.
|
|
39
|
+
3. 버리기는 마지막 저장값 복원, 계속 수정은 현재 초안 유지.
|
|
40
|
+
|
|
41
|
+
| 상태 | 모습 | 포커스·알림 |
|
|
42
|
+
| --- | --- | --- |
|
|
43
|
+
| 기본 | 입력 → 저장·되돌리기 → 이탈 확인 | 이름·선택 여부를 보조공학에 노출 |
|
|
44
|
+
| 진행 중 | 해당 작업 pending, 입력·기존 결과 보존 | 중복 요청 차단, 로딩에 포커스를 옮기지 않음 |
|
|
45
|
+
| 실패 | 버리기는 마지막 저장값 복원, 계속 수정은 현재 초안 유지 | 오류 근처 재시도, 필요할 때만 오류 읽기 |
|
|
46
|
+
|
|
47
|
+
## 코드 골격
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
// Web
|
|
51
|
+
import { useRef } from "react";
|
|
52
|
+
import { Form, TextField } from "@hjmds/react/forms";
|
|
53
|
+
import { Button } from "@hjmds/react/actions";
|
|
54
|
+
|
|
55
|
+
const nicknameInputRef = useRef<HTMLInputElement>(null);
|
|
56
|
+
|
|
57
|
+
<Form
|
|
58
|
+
aria-label={t("profile.formLabel")}
|
|
59
|
+
onSubmit={async () => {
|
|
60
|
+
setServerError(null);
|
|
61
|
+
if (nicknameError) { nicknameInputRef.current?.focus(); return; } // Web은 첫 오류 포커스를 제품이 한다
|
|
62
|
+
try { await save(values); } catch { setServerError(t("profile.saveFailed")); }
|
|
63
|
+
}}
|
|
64
|
+
formError={serverError}
|
|
65
|
+
actions={<Button type="submit">{t("profile.save")}</Button>}
|
|
66
|
+
>
|
|
67
|
+
<TextField ref={nicknameInputRef} label={t("profile.nickname")} value={values.nickname}
|
|
68
|
+
onValueChange={setNickname} error={nicknameError ? t(nicknameError) : undefined} />
|
|
69
|
+
</Form>
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
```tsx
|
|
73
|
+
// Native
|
|
74
|
+
import { useRef } from "react";
|
|
75
|
+
import type { TextInput } from "react-native";
|
|
76
|
+
import { Form } from "@hjmds/react-native/forms";
|
|
77
|
+
import { TextField } from "@hjmds/react-native/inputs";
|
|
78
|
+
|
|
79
|
+
const nicknameInputRef = useRef<TextInput>(null);
|
|
80
|
+
|
|
81
|
+
<Form
|
|
82
|
+
label={t("profile.formLabel")}
|
|
83
|
+
values={values}
|
|
84
|
+
onSubmit={save} // 거절되면 Form이 오류를 표시한다(메시지가 없으면 fallbackErrorMessage)
|
|
85
|
+
submitLabel={t("profile.save")}
|
|
86
|
+
fallbackErrorMessage={t("profile.saveFailed")}
|
|
87
|
+
{...(nicknameError ? { firstInvalidFieldRef: nicknameInputRef } : {})} // Native는 Form이 첫 오류로 포커스를 옮긴다
|
|
88
|
+
>
|
|
89
|
+
<TextField ref={nicknameInputRef} label={t("profile.nickname")} value={values.nickname}
|
|
90
|
+
onValueChange={setNickname} {...(nicknameError ? { error: t(nicknameError) } : {})} />
|
|
91
|
+
</Form>
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
제품 데이터·콜백은 주입한다. 위 공개 API 지침에 Web·Native 차이를 유지한다.
|
|
95
|
+
|
|
96
|
+
## 플랫폼 차이
|
|
97
|
+
|
|
98
|
+
| 항목 | Web | Native |
|
|
99
|
+
| --- | --- | --- |
|
|
100
|
+
| 첫 오류 포커스 | 제품이 `onSubmit`에서 해당 입력 `ref.focus()` | `firstInvalidFieldRef`를 주면 Form이 그 입력에 포커스·보조공학 포커스를 옮기고 제출을 멈춘다 |
|
|
101
|
+
| 저장 실패 표시 | 제품이 `formError`로 넘긴다 | `onSubmit`이 거절되면 Form이 오류 메시지(없으면 `fallbackErrorMessage`)를 표시한다 |
|
|
102
|
+
| 제출 버튼 | `actions`에 `type="submit"` Button | `submitLabel`로 내장 버튼, `actions`로 교체 가능 |
|
|
103
|
+
|
|
104
|
+
## 함정
|
|
105
|
+
|
|
106
|
+
- 버리기는 마지막 저장값 복원, 계속 수정은 현재 초안 유지.
|
|
107
|
+
- 포인터·제스처만으로 기능을 숨기지 않는다. 키보드·단일 탭 경로와 취소 후 복귀도 검증한다.
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
# 대표 항목과 묶음 전체 선택
|
|
2
|
+
|
|
3
|
+
- 단계: 구성
|
|
4
|
+
- 상태: 배포
|
|
5
|
+
- 지원: Web · Native
|
|
6
|
+
- 적용: 1.12.1
|
|
7
|
+
- 검토일: 2026-10-06
|
|
8
|
+
- 근거: [제품 상호작용 품질](../../../../../docs/INTERACTION_QUALITY.md), `showcase/web/src/patterns/SelectionScope.stories.tsx`, `showcase/native/src/SelectionScope.stories.tsx`. 2026-10-06 사용자 승인으로 스토리북 배포(이전 `실험/구성/상호작용 예제/대표 항목과 묶음 전체 선택`, [승인 기록](../../../../../docs/STORYBOOK_NAVIGATION.md#21-2026-10-06-전체-승격과-규격-확정))
|
|
9
|
+
- 스토리북: `배포/구성/선택과 필터/대표 항목과 묶음 전체 선택`
|
|
10
|
+
|
|
11
|
+
## 언제 쓰나
|
|
12
|
+
|
|
13
|
+
사진 묶음·스레드처럼 대표 항목 하나와 묶음 전체가 같은 모양으로 보일 때, 공유·삭제·이동 전에 대상 범위와 개수를 고르고 문구로 확인한 뒤 적용하게 할 때 쓴다.
|
|
14
|
+
|
|
15
|
+
2026-10-02 조사에서 대표 항목과 전체 구성원을 같은 선택으로 오인할 위험을 확인했다. 길게 누르기나 작은 배지만으로 범위를 숨기는 방식은
|
|
16
|
+
발견성·접근성 때문에 쓰지 않는다. 기본 범위·공유 정책·실제 전송은 제품이 정한다.
|
|
17
|
+
|
|
18
|
+
같은 `상호작용 예제` 묶음: [선택 후 적용·취소](interaction-flow-apply.md) · [닫았다 열고 초안 이어쓰기](interaction-flow-draft.md) ·
|
|
19
|
+
[늦은 응답보다 최신 검색 유지](interaction-flow-search.md). 상태 문구 알림은 [저장과 재시도의 공통 절](action-recovery-save.md#공통-세션과-상태-알림)을 따른다.
|
|
20
|
+
|
|
21
|
+
## 구성 요소
|
|
22
|
+
|
|
23
|
+
| 컴포넌트 | 역할 | 지침 |
|
|
24
|
+
| --- | --- | --- |
|
|
25
|
+
| `Heading level="level3" semanticLevel={2}` | 질문형 제목 `t("share.scopeQuestion")` | [Heading](../components/heading.md) |
|
|
26
|
+
| `Text` | 묶음 설명: 대표 1개 포함 전체 N개 | [Text](../components/text.md) |
|
|
27
|
+
| `Button selected`(secondary) × 2 | 범위 선택: "대표만 · 1개" / "묶음 전체 · N개". 라벨에 개수를 넣는다 | [Button](../components/button.md) |
|
|
28
|
+
| `Text` | 선택 범위 요약(범위 이름 + 개수) | [Text](../components/text.md) |
|
|
29
|
+
| `Button`(primary) | 적용. 라벨에 개수를 넣는다 `t("share.apply", { count })`. 진행 중은 `loading` | [Button](../components/button.md) |
|
|
30
|
+
| `Text` 상태 | 적용 결과·안내·실패 | [Text](../components/text.md) |
|
|
31
|
+
| `Container`·`Stack` | 바깥 틀, 바깥 `gap="md"`, 범위 버튼 묶음 `gap="sm"` | [Container](../components/container.md), [Stack](../components/stack.md) |
|
|
32
|
+
|
|
33
|
+
## 배치
|
|
34
|
+
|
|
35
|
+
```text
|
|
36
|
+
┌ 바깥 틀: 스크롤(Web 문서, Native ScrollView 위아래 spacing.md 16) ┐
|
|
37
|
+
│ ← Container gutter 16(폭 600 미만)/20 · 최대 720 → │
|
|
38
|
+
│ 이 묶음에서 무엇을 공유할까요? Heading level3(24) │
|
|
39
|
+
│ 묶음 설명(대표 1 · 전체 3) ↕ spacing.md 16 │
|
|
40
|
+
│ ┌ 범위 선택 ───────────────────────┐ │
|
|
41
|
+
│ │ [ 대표 사진만 · 1개 ✓ ] │ secondary, selected │
|
|
42
|
+
│ │ ↕ spacing.sm 12 │ │
|
|
43
|
+
│ │ [ 묶음 전체 · 3개 ] │ secondary │
|
|
44
|
+
│ └──────────────────────────────────┘ ↕ spacing.md 16 │
|
|
45
|
+
│ 선택 범위: 대표 사진만 (1개) │
|
|
46
|
+
│ [ 1개 공유 대상으로 정하기 ] ← 주 행동(primary), 개수 포함 │
|
|
47
|
+
│ 결과·안내 (Web role=status, Native live region) │
|
|
48
|
+
│ ░ 하단 안전 영역(화면 host 소유) ░ │
|
|
49
|
+
└────────────────────────────────────────────────────────────────────┘
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
| 영역 | 컴포넌트 | 위치 | 크기·간격 |
|
|
53
|
+
| --- | --- | --- | --- |
|
|
54
|
+
| 바깥 틀 | Web: `Container size="reading"` → `Stack gap="md"`. Native: `ScrollView` → `Container` → `Stack gap="md"` | 화면 본문 스크롤. 안전 영역은 화면 host(SafeArea·navigation header)가 준다. 입력이 없어 키보드 처리는 없다. 적용 버튼을 하단에 고정하면 [BottomCTA](../components/bottom-cta.md)가 하단 안전 영역을 맡는다 | Native `ScrollView` 위아래 `spacing.md` 16, 좌우는 `Container` `gutter`(폭 600 미만 `compact` 16, 이상 `regular` 20). 최대 폭 `layout.readingMaxWidth` 720 |
|
|
55
|
+
| 질문·설명 | `Heading` + `Text` | 맨 위 | 사이 `spacing.md` 16 |
|
|
56
|
+
| 범위 선택 | `Button selected` × 2 | 설명 아래, 세로 묶음 | 각 `medium` 44(`control.buttonHeight`), 서로 `spacing.sm` 12 |
|
|
57
|
+
| 범위 요약 | `Text` | 범위 선택 아래 | `spacing.md` 16 |
|
|
58
|
+
| 주 행동 | `Button` 적용 | 요약 아래 | `medium` 44, `spacing.md` 16 |
|
|
59
|
+
| 결과 | `Text` 상태 | 맨 아래 | `spacing.md` 16 |
|
|
60
|
+
|
|
61
|
+
- 범위 버튼은 서로 가깝게(`spacing.sm` 12), 다른 덩어리와는 `spacing.md` 16으로 띄워 한 묶음으로 읽히게 한다.
|
|
62
|
+
- 순서는 좁은 범위(대표) → 넓은 범위(전체)다. 기본 선택은 스토리에서 대표 항목이지만 제품이 정한다.
|
|
63
|
+
- 범위 버튼은 `selected` 처리로 칠해져 primary로 세지 않는다. 화면의 primary는 적용 하나다([Button](../components/button.md) "한 화면 primary 하나"의 예외).
|
|
64
|
+
- 적용 버튼이 화면 하단 고정이면 [BottomCTA](../components/bottom-cta.md)에 두고 라벨의 개수는 유지한다.
|
|
65
|
+
|
|
66
|
+
근거: `src/component-recipes.ts`(`stackRecipe.gaps` = `spacing`, `buttonRecipe.states.selected`), `src/foundations.ts`(`control`·`layout`)
|
|
67
|
+
|
|
68
|
+
## 흐름과 상태
|
|
69
|
+
|
|
70
|
+
1. 화면은 묶음 설명과 기본 범위(대표)를 보여 준다.
|
|
71
|
+
2. 사용자가 범위 버튼을 누르면 `selected`가 옮겨 가고, 요약과 적용 버튼 라벨의 개수가 바뀐다. 이전 결과 문구는 지운다.
|
|
72
|
+
3. 적용을 누르면 그 범위의 항목 ID로 제품 작업(공유 대상 확정 등)을 실행한다. 끝날 때까지 적용 버튼은 `loading`이다.
|
|
73
|
+
4. 결과 문구로 몇 개에 적용했는지 알린다. 실패하면 범위는 그대로 두고 실패 문구를 알린다. 적용을 다시 누르면 같은 범위로 재요청한다.
|
|
74
|
+
|
|
75
|
+
| 상태 | 모습 | 포커스·알림 |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| 기본 | 기본 범위 버튼 `selected`, 요약에 개수, 안내 문구 `share.chooseScope` | `selected`는 Web `aria-pressed`·Native 접근성 state로 알림 |
|
|
78
|
+
| 범위 변경 | 요약·적용 라벨의 개수 갱신, 결과 문구는 안내로 돌아감 | 같음 |
|
|
79
|
+
| 진행 중 | 적용 버튼 `loading`(누름 막힘·스피너), 범위 버튼 `disabled` | 포커스는 적용 버튼 유지 |
|
|
80
|
+
| 성공 | 결과 문구 `t("share.applied", { count })` | 결과 알림, 포커스는 적용 버튼 유지 |
|
|
81
|
+
| 실패 | 네트워크·서버 실패: 범위 유지, 결과 문구 `share.applyFailed`, 적용 버튼 다시 활성. 재요청도 실패하면 같은 문구를 다시 알린다 | 결과 알림(Web `role="status"`, Native live region + iOS 알림) |
|
|
82
|
+
|
|
83
|
+
- 상태→문구 키는 상수 표로 둔다(아래 `resultKey`·`scopeKey`). 템플릿 문자열 키는 키 추출·누락 검사가 찾지 못한다.
|
|
84
|
+
- 선택 범위는 제품의 안정적인 항목 ID와 권한으로 확정한다. 재시도 세대·늦은 응답 처리까지 필요하면 [저장과 재시도](action-recovery-save.md)의 세션을 합친다.
|
|
85
|
+
|
|
86
|
+
## 코드 골격
|
|
87
|
+
|
|
88
|
+
```tsx
|
|
89
|
+
// Web
|
|
90
|
+
import { useState } from "react";
|
|
91
|
+
import { Button } from "@hjmds/react/actions";
|
|
92
|
+
import { Heading } from "@hjmds/react/heading";
|
|
93
|
+
import { Container, Stack, Text } from "@hjmds/react/layout";
|
|
94
|
+
|
|
95
|
+
const scopeKey = { cover: "share.scope.cover", group: "share.scope.group" } as const;
|
|
96
|
+
const resultKey = { idle: "share.chooseScope", applied: "share.applied", failed: "share.applyFailed" } as const;
|
|
97
|
+
|
|
98
|
+
const [scope, setScope] = useState<"cover" | "group">("cover");
|
|
99
|
+
const [result, setResult] = useState<"idle" | "applying" | "applied" | "failed">("idle");
|
|
100
|
+
const count = scope === "cover" ? 1 : group.items.length;
|
|
101
|
+
const choose = (next: "cover" | "group") => { setScope(next); setResult("idle"); };
|
|
102
|
+
const apply = async () => {
|
|
103
|
+
setResult("applying");
|
|
104
|
+
try { await onApply(scope); setResult("applied"); } catch { setResult("failed"); }
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
<Container size="reading">
|
|
108
|
+
<Stack gap="md">
|
|
109
|
+
<Heading level="level3" semanticLevel={2}>{t("share.scopeQuestion")}</Heading>
|
|
110
|
+
<Text as="p">{t("share.groupSummary", { total: group.items.length })}</Text>
|
|
111
|
+
<Stack gap="sm">
|
|
112
|
+
<Button tone="secondary" selected={scope === "cover"} disabled={result === "applying"} onClick={() => choose("cover")}>
|
|
113
|
+
{t("share.coverOnly", { count: 1 })}
|
|
114
|
+
</Button>
|
|
115
|
+
<Button tone="secondary" selected={scope === "group"} disabled={result === "applying"} onClick={() => choose("group")}>
|
|
116
|
+
{t("share.wholeGroup", { count: group.items.length })}
|
|
117
|
+
</Button>
|
|
118
|
+
</Stack>
|
|
119
|
+
<Text as="p">{t("share.scopeSummary", { scope: t(scopeKey[scope]), count })}</Text>
|
|
120
|
+
<Button loading={result === "applying"} onClick={() => void apply()}>{t("share.apply", { count })}</Button>
|
|
121
|
+
<Text as="p" role="status">{t(resultKey[result === "applying" ? "idle" : result], { count })}</Text>
|
|
122
|
+
</Stack>
|
|
123
|
+
</Container>
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
```tsx
|
|
127
|
+
// Native
|
|
128
|
+
import { ScrollView, useWindowDimensions } from "react-native";
|
|
129
|
+
import { Button } from "@hjmds/react-native/actions";
|
|
130
|
+
import { Heading } from "@hjmds/react-native/heading";
|
|
131
|
+
import { Container, Stack, Text } from "@hjmds/react-native/primitives";
|
|
132
|
+
import { useHjmNativeTheme } from "@hjmds/react-native/provider";
|
|
133
|
+
import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
|
|
134
|
+
|
|
135
|
+
const { spacing } = useHjmNativeTheme().tokens;
|
|
136
|
+
const gutter = resolveWindowClass(useWindowDimensions().width) === "compact" ? "compact" : "regular";
|
|
137
|
+
// scope·result·count·choose·apply·scopeKey·resultKey는 Web과 같다.
|
|
138
|
+
|
|
139
|
+
<ScrollView contentContainerStyle={{ paddingVertical: spacing.md }}>
|
|
140
|
+
<Container size="reading" gutter={gutter}>
|
|
141
|
+
<Stack gap="md">
|
|
142
|
+
<Heading level="level3" semanticLevel={2}>{t("share.scopeQuestion")}</Heading>
|
|
143
|
+
<Text>{t("share.groupSummary", { total: group.items.length })}</Text>
|
|
144
|
+
<Stack gap="sm">
|
|
145
|
+
<Button tone="secondary" selected={scope === "cover"} disabled={result === "applying"} onPress={() => choose("cover")}>
|
|
146
|
+
{t("share.coverOnly", { count: 1 })}
|
|
147
|
+
</Button>
|
|
148
|
+
<Button tone="secondary" selected={scope === "group"} disabled={result === "applying"} onPress={() => choose("group")}>
|
|
149
|
+
{t("share.wholeGroup", { count: group.items.length })}
|
|
150
|
+
</Button>
|
|
151
|
+
</Stack>
|
|
152
|
+
<Text>{t("share.scopeSummary", { scope: t(scopeKey[scope]), count })}</Text>
|
|
153
|
+
<Button loading={result === "applying"} onPress={() => void apply()}>{t("share.apply", { count })}</Button>
|
|
154
|
+
<StatusText>{t(resultKey[result === "applying" ? "idle" : result], { count })}</StatusText>
|
|
155
|
+
</Stack>
|
|
156
|
+
</Container>
|
|
157
|
+
</ScrollView>
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
`group`·`onApply`는 제품 소유다. `StatusText`는 [저장과 재시도의 공통 절](action-recovery-save.md#공통-세션과-상태-알림)의 helper(Android live region + iOS 알림)다.
|
|
161
|
+
|
|
162
|
+
## 플랫폼 차이
|
|
163
|
+
|
|
164
|
+
| 항목 | Web | Native |
|
|
165
|
+
| --- | --- | --- |
|
|
166
|
+
| 바깥 틀 | 문서 스크롤 + `Container` | `ScrollView`(위아래 `spacing.md`) + `Container` |
|
|
167
|
+
| 이벤트 | `onClick` | `onPress` |
|
|
168
|
+
| `selected` 알림 | `aria-pressed` | 접근성 state |
|
|
169
|
+
| 상태 알림 | `role="status"` | live region(Android) + iOS 알림 호출(`StatusText`) |
|
|
170
|
+
|
|
171
|
+
## 함정
|
|
172
|
+
|
|
173
|
+
- 구획 제목은 Heading으로 표시한다. 이전 Text heading 예제는 2026-10-06 제목 의미 구조를 맞추면서 수정했다.
|
|
174
|
+
- 스토리의 "다음 적용 실패시키기"(ghost)와 350ms 지연은 실패·진행 중을 확인하는 데모 전용이다. 제품에 넣지 않는다.
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
# 일정과 식별 정보 티켓
|
|
2
|
+
|
|
3
|
+
- 단계: 구성
|
|
4
|
+
- 상태: 배포
|
|
5
|
+
- 지원: Web · Native
|
|
6
|
+
- 적용: 1.12.1
|
|
7
|
+
- 검토일: 2026-10-06
|
|
8
|
+
- 근거: [QRCode](../../qr-code.md), `packages/react/src/qr-code.tsx`, `packages/react-native/src/qr-code.tsx`, `showcase/web/src/patterns/stea-expression-previews.tsx`(`EventTicket`), `showcase/native/src/stea-expression-previews.tsx`, `showcase/shared/stea-expressions.ts`(`ticketCopy`), `src/qr-code-recipe.ts`, `src/card.ts`
|
|
9
|
+
- 스토리북: `배포/구성/정보 표시/일정과 식별 정보 티켓`
|
|
10
|
+
|
|
11
|
+
## 언제 쓰나
|
|
12
|
+
|
|
13
|
+
공연·예약 입장권처럼 일시·장소·좌석 정보와 함께, 현장에서 보여 줄 QR 코드와 사람이 읽을 예매 번호를 한 카드에 담을 때 쓴다.
|
|
14
|
+
행동 버튼이 없는 표시용 카드다. 티켓 목록이면 이 카드를 반복하지 말고 [ListRow](../components/list-row.md)로 요약한 뒤 상세에서 이 구성을 연다.
|
|
15
|
+
|
|
16
|
+
## 구성 요소
|
|
17
|
+
|
|
18
|
+
| 컴포넌트 | 역할 | 지침 |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| `Card` | 제목(공연명)·설명("입장할 때 아래 코드를 보여 주세요") | [Card](../components/card.md) |
|
|
21
|
+
| `DescriptionList` | 날짜·시간·장소·좌석 | [DescriptionList](../components/description-list.md) |
|
|
22
|
+
| `QRCode` | 예매 번호를 담은 QR. `label`(필수 접근성 이름), `fallback`(필수, QR 바로 아래 항상 그려지는 대체 안내) | [QRCode](../components/qr-code.md) |
|
|
23
|
+
| `Text` label muted + `Text` title strong | "예매 번호" 라벨과 번호 | [Text](../components/text.md) |
|
|
24
|
+
| `Skeleton`·`Result` | 티켓을 불러오는 중·불러오기 실패(화면 상태) | [Skeleton](../components/skeleton.md), [Result](../components/result.md) |
|
|
25
|
+
| `Container` | 바깥 틀의 최대 폭·좌우 여백 | [Container](../components/container.md) |
|
|
26
|
+
|
|
27
|
+
## 배치
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
┌ 바깥 틀: 스크롤(Web 문서, Native ScrollView 위아래 spacing.md 16) ┐
|
|
31
|
+
│ ← Container gutter 16(폭 600 미만)/20 · 최대 720 → │
|
|
32
|
+
│ ┌ Card ────────────────────────────────────┐ body padding spacing.md 16
|
|
33
|
+
│ │ 가을 밤 재즈 공연 (title) │
|
|
34
|
+
│ │ 입장할 때 아래 코드를 보여 주세요 (muted)│
|
|
35
|
+
│ │ 날짜 2026년 10월 17일 토요일 │ ← DescriptionList
|
|
36
|
+
│ │ 시간 저녁 7시 30분 (입장 7시부터) │
|
|
37
|
+
│ │ 장소 서울 마포구 … │
|
|
38
|
+
│ │ 좌석 A열 12번 │
|
|
39
|
+
│ │ ↕ spacing.lg 20 │
|
|
40
|
+
│ │ ┌──────────┐ │
|
|
41
|
+
│ │ │ ▓▓ QR ▓▓ │ ← size 192(기본) │
|
|
42
|
+
│ │ └──────────┘ │
|
|
43
|
+
│ │ 대체 안내 (fallback, 항상 QR 바로 아래) │
|
|
44
|
+
│ │ ↕ spacing.sm 12 │
|
|
45
|
+
│ │ 예매 번호 (label muted) │
|
|
46
|
+
│ │ ↕ spacing.sm 12 │
|
|
47
|
+
│ │ JZ-1017-A12-4821 (title strong) │
|
|
48
|
+
│ └──────────────────────────────────────────┘
|
|
49
|
+
│ ░ 하단 안전 영역(Native 화면 host 소유) ░ │
|
|
50
|
+
└────────────────────────────────────────────────────────────────────┘
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
| 영역 | 컴포넌트 | 위치 | 크기·간격 |
|
|
54
|
+
| --- | --- | --- | --- |
|
|
55
|
+
| 바깥 틀 | Web: `Container size="reading"`. Native: `ScrollView` → `Container` | 화면 본문, 카드는 스크롤과 함께 움직인다. 안전 영역은 화면 host(SafeArea·navigation header)가 준다. 입력이 없어 키보드 처리는 없다 | Native `ScrollView` 위아래 `spacing.md` 16, 좌우는 `Container` `gutter`(폭 600 미만 `compact` 16, 이상 `regular` 20). 최대 폭 `layout.readingMaxWidth` 720 |
|
|
56
|
+
| 틀 | `Card` | 바깥 틀 안 | body padding `spacing.md` 16(`cardRecipe`) |
|
|
57
|
+
| 정보 | `DescriptionList` | Card 머리 아래 | 정보–식별 묶음 `Stack gap="lg"` 20 |
|
|
58
|
+
| 식별 묶음 | `QRCode`(+fallback) → 라벨 → 번호 | 정보 아래, 시작 정렬 | 묶음 안 `Stack gap="sm"` 12, QR `size` 기본 192(모듈 수의 정수배로 내림), quiet zone 4모듈. QR–fallback 사이 간격은 컴포넌트가 주지 않는다 |
|
|
59
|
+
| 로딩·실패 | `Skeleton`·`Result` | 카드 자리 | Skeleton `block` 기본 높이 `spacing.xxl` 32. Result는 자체 여백(`resultRecipe`) |
|
|
60
|
+
|
|
61
|
+
- QR은 번호 **위**에 두고, 번호는 QR이 읽히지 않을 때 직원이 입력할 수 있도록 항상 같이 보인다.
|
|
62
|
+
- QR 색은 `qrCodeRecipe` 고정값(전경 `#000000`, 배경 `#ffffff`)이다. 다크 테마에서도 흰 바탕으로 그려진다.
|
|
63
|
+
- 행동 버튼이 없는 카드다. 실패 화면의 다시 시도 하나만 primary 행동이다.
|
|
64
|
+
|
|
65
|
+
## 흐름과 상태
|
|
66
|
+
|
|
67
|
+
1. 사용자가 티켓 화면을 연다. 제품이 티켓을 불러오는 동안 카드 자리에 Skeleton을 둔다.
|
|
68
|
+
2. 불러오면 정보·QR·번호를 그린다. 상호작용은 없다.
|
|
69
|
+
3. 현장에서 QR을 보여 준다. 스캔이 안 되면 예매 번호를 읽어 준다(Native는 번호를 길게 눌러 선택·복사할 수 있다).
|
|
70
|
+
4. 불러오기가 실패하면(네트워크·서버) 카드 대신 `Result status="failure"`와 다시 시도를 둔다. 다시 시도하면 1로 돌아간다.
|
|
71
|
+
|
|
72
|
+
| 상태 | 모습 | 포커스·알림 |
|
|
73
|
+
| --- | --- | --- |
|
|
74
|
+
| 기본 | 정보 + QR + 번호 | QR은 `label`("입장 QR 코드, 예매 번호 …")로 읽힌다 |
|
|
75
|
+
| 진행 중 | 티켓을 불러오는 중(처음·다시 시도): 카드 자리에 `Skeleton`. 값이 없으면 `QRCode`를 그리지 않는다(빈 `value`는 `TypeError`) | Native Skeleton `accessibilityLabel`(`ticket.loading`) |
|
|
76
|
+
| 실패 | 네트워크·서버 실패: `Result status="failure"` + 다시 시도(primary 하나). 다시 시도도 실패하면 같은 Result를 다시 보이고 문구로 실패를 알린다 | Result 제목이 읽힌다. 다시 시도 뒤 포커스는 Result 행동에 둔다 |
|
|
77
|
+
| 스캔 실패(현장) | QR 아래 fallback 안내와 예매 번호로 대신한다 | fallback·번호가 텍스트로 읽힌다 |
|
|
78
|
+
| 다크 테마 | Card·글자는 테마를 따르고 QR은 흰 바탕 유지 | — |
|
|
79
|
+
| 큰 글자 | DescriptionList 값이 줄바꿈되고 번호가 길게 이어짐 | — |
|
|
80
|
+
|
|
81
|
+
## 코드 골격
|
|
82
|
+
|
|
83
|
+
```tsx
|
|
84
|
+
// Web
|
|
85
|
+
import { Card, DescriptionList } from "@hjmds/react/display";
|
|
86
|
+
import { Result, Skeleton } from "@hjmds/react/feedback";
|
|
87
|
+
import { Container, Stack, Text } from "@hjmds/react/layout";
|
|
88
|
+
import { QRCode } from "@hjmds/react/qr-code";
|
|
89
|
+
|
|
90
|
+
<Container size="reading">
|
|
91
|
+
{ticketState.status === "loading" ? <Skeleton shape="block" height={320} /> :
|
|
92
|
+
ticketState.status === "failed" ? (
|
|
93
|
+
<Result status="failure" title={t("ticket.loadFailed.title")} description={t("ticket.loadFailed.body")}
|
|
94
|
+
actions={[{ label: t("ticket.retry"), onAction: reload }]} />
|
|
95
|
+
) : (
|
|
96
|
+
<Card title={ticketState.ticket.title} description={t("ticket.showAtEntry")}>
|
|
97
|
+
<Stack gap="lg">
|
|
98
|
+
<DescriptionList aria-label={t("ticket.details")} items={ticketState.ticket.details} />
|
|
99
|
+
<Stack gap="sm">
|
|
100
|
+
{/* QRCode의 svg가 inline이라 fallback을 블록으로 감싸 아래 줄에 둔다. */}
|
|
101
|
+
<QRCode value={ticketState.ticket.booking} label={t("ticket.qrLabel", { booking: ticketState.ticket.booking })}
|
|
102
|
+
fallback={<Stack gap="xs"><Text tone="muted">{t("ticket.qrFallback")}</Text></Stack>} />
|
|
103
|
+
<Text variant="label" tone="muted">{t("ticket.bookingLabel")}</Text>
|
|
104
|
+
<Text variant="title" emphasis="strong">{ticketState.ticket.booking}</Text>
|
|
105
|
+
</Stack>
|
|
106
|
+
</Stack>
|
|
107
|
+
</Card>
|
|
108
|
+
)}
|
|
109
|
+
</Container>
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
```tsx
|
|
113
|
+
// Native
|
|
114
|
+
import { ScrollView, useWindowDimensions } from "react-native";
|
|
115
|
+
import { Card, DescriptionList } from "@hjmds/react-native/data-display";
|
|
116
|
+
import { Result, Skeleton } from "@hjmds/react-native/feedback";
|
|
117
|
+
import { Container, Stack, Text } from "@hjmds/react-native/primitives";
|
|
118
|
+
import { useHjmNativeTheme } from "@hjmds/react-native/provider";
|
|
119
|
+
import { QRCode } from "@hjmds/react-native/qr-code";
|
|
120
|
+
import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
|
|
121
|
+
|
|
122
|
+
const { spacing } = useHjmNativeTheme().tokens;
|
|
123
|
+
const gutter = resolveWindowClass(useWindowDimensions().width) === "compact" ? "compact" : "regular";
|
|
124
|
+
|
|
125
|
+
<ScrollView contentContainerStyle={{ paddingVertical: spacing.md }}>
|
|
126
|
+
<Container size="reading" gutter={gutter}>
|
|
127
|
+
{ticketState.status === "loading" ? <Skeleton shape="block" height={320} accessibilityLabel={t("ticket.loading")} /> :
|
|
128
|
+
ticketState.status === "failed" ? (
|
|
129
|
+
<Result status="failure" title={t("ticket.loadFailed.title")} description={t("ticket.loadFailed.body")}
|
|
130
|
+
actions={[{ label: t("ticket.retry"), onAction: reload }]} />
|
|
131
|
+
) : (
|
|
132
|
+
<Card title={ticketState.ticket.title} description={t("ticket.showAtEntry")}>
|
|
133
|
+
<Stack gap="lg">
|
|
134
|
+
<DescriptionList label={t("ticket.details")} descriptor={{ items: ticketState.ticket.details }} />
|
|
135
|
+
<Stack gap="sm">
|
|
136
|
+
<QRCode value={ticketState.ticket.booking} label={t("ticket.qrLabel", { booking: ticketState.ticket.booking })}
|
|
137
|
+
fallback={<Text tone="muted">{t("ticket.qrFallback")}</Text>} />
|
|
138
|
+
<Text variant="label" tone="muted">{t("ticket.bookingLabel")}</Text>
|
|
139
|
+
<Text selectable variant="title" emphasis="strong">{ticketState.ticket.booking}</Text>
|
|
140
|
+
</Stack>
|
|
141
|
+
</Stack>
|
|
142
|
+
</Card>
|
|
143
|
+
)}
|
|
144
|
+
</Container>
|
|
145
|
+
</ScrollView>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`ticketState`(`{ status: "loading" } | { status: "failed" } | { status: "ready"; ticket }`)·`reload`와 공연명·일시·장소·좌석·예매 번호는 제품 소유다. Skeleton 높이 320은 예시다.
|
|
149
|
+
|
|
150
|
+
## 플랫폼 차이
|
|
151
|
+
|
|
152
|
+
| 항목 | Web | Native |
|
|
153
|
+
| --- | --- | --- |
|
|
154
|
+
| 바깥 틀 | 문서 스크롤 + `Container` | `ScrollView`(위아래 `spacing.md`) + `Container` |
|
|
155
|
+
| `DescriptionList` 이름·항목 | `aria-label` + `items` | `label` + `descriptor={{ items }}` |
|
|
156
|
+
| fallback 감싸기 | inline svg 옆으로 흐르지 않게 `Stack`으로 감싼다 | `Text` 그대로 |
|
|
157
|
+
| 번호 선택 | 브라우저 기본 텍스트 선택 | `Text selectable` |
|
|
158
|
+
| QR 배치 prop | 배치 전용 `layoutStyle`만 | 없음(감싸는 레이아웃에서 정한다) |
|
|
159
|
+
| QR peer | `qrcode-generator` 2.0.4 | `qrcode-generator` 2.0.4 + `react-native-svg` 15.15.5(dev client 재빌드) |
|
|
160
|
+
|
|
161
|
+
## 함정
|
|
162
|
+
|
|
163
|
+
- `QRCode`는 root에서 내보내지 않는다. `/qr-code` subpath로만 import하고 optional peer를 설치한다. Native에서 peer가 없으면 기기 번들에서 실패한다.
|
|
164
|
+
- `label`이 비거나 `fallback`이 없거나 `size`가 모듈당 2px(`qrCodeRecipe.minModuleSize`)보다 작으면 `TypeError`다. `value`가 비어도 `TypeError`라 불러오는 중에는 QR을 그리지 않는다.
|
|
165
|
+
- `QRCode`에는 `style`·`className`이 없다. 크기는 `size`, 위치는 감싸는 레이아웃에서 정한다. Web만 배치 전용 `layoutStyle`을 받는다(`packages/react/src/qr-code.tsx`).
|
|
166
|
+
- 현재 스토리는 기본·다크·큰 글자만 있고 불러오는 중·불러오기 실패 상태가 없다.
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# 앞면과 상세 정보 전환
|
|
2
|
+
|
|
3
|
+
- 단계: 구성
|
|
4
|
+
- 상태: 배포
|
|
5
|
+
- 지원: Web · Native
|
|
6
|
+
- 적용: 1.12.1
|
|
7
|
+
- 검토일: 2026-10-06
|
|
8
|
+
- 근거: `showcase/web/src/patterns/stea-expression-previews.tsx`(`FlipInfoCard`), `showcase/native/src/stea-expression-previews.tsx`, `showcase/shared/stea-expressions.ts`(`flipCopy`), `src/content-transition.ts`, `src/card.ts`
|
|
9
|
+
- 스토리북: `배포/구성/정보 표시/앞면과 상세 정보 전환`
|
|
10
|
+
|
|
11
|
+
## 언제 쓰나
|
|
12
|
+
|
|
13
|
+
모임·상품처럼 한 카드에 요약(앞면)과 상세 항목(뒷면)이 있고, 사용자가 버튼 하나로 두 면을 오가게 할 때 쓴다.
|
|
14
|
+
3D 뒤집기 대신 같은 자리에서 `ContentTransition` `scale`로 내용을 바꾼다. 상세가 길거나 여러 묶음이면
|
|
15
|
+
[Collapsible](../components/collapsible.md)·[Accordion](../components/accordion.md)으로 펼치거나 상세 화면으로 이동한다.
|
|
16
|
+
|
|
17
|
+
## 구성 요소
|
|
18
|
+
|
|
19
|
+
| 컴포넌트 | 역할 | 지침 |
|
|
20
|
+
| --- | --- | --- |
|
|
21
|
+
| `Card` | 제목("이번 주 모임")만 가진 틀 | [Card](../components/card.md) |
|
|
22
|
+
| `ContentTransition` `preset="scale"` | 앞면 ↔ 뒷면 전환, `stateKey`="front"/"back" | [ContentTransition](../components/content-transition.md) |
|
|
23
|
+
| 앞면 `Stack gap="xs"` + `Text` 3줄 | 이름(title strong), 요약, 메모(muted) | [Text](../components/text.md) |
|
|
24
|
+
| 뒷면 `DescriptionList` | 장소·준비물·인원·진행 항목 | [DescriptionList](../components/description-list.md) |
|
|
25
|
+
| `Button` `tone="secondary"` | "자세히 보기" ↔ "앞면 보기" 토글 | [Button](../components/button.md) |
|
|
26
|
+
| 면 알림 | Web `VisuallyHidden role="status"`, Native `announceForAccessibility` | [VisuallyHidden](../components/visually-hidden.md) |
|
|
27
|
+
| `Container` | 바깥 틀의 최대 폭·좌우 여백 | [Container](../components/container.md) |
|
|
28
|
+
|
|
29
|
+
## 배치
|
|
30
|
+
|
|
31
|
+
```text
|
|
32
|
+
바깥 틀: 스크롤(Web 문서, Native ScrollView 위아래 spacing.md 16) + Container gutter 16/20 · 최대 720
|
|
33
|
+
|
|
34
|
+
앞면 뒷면
|
|
35
|
+
┌ Card ─────────────────────────────┐ ┌ Card ─────────────────────────────┐
|
|
36
|
+
│ 이번 주 모임 (title) │ │ 이번 주 모임 │
|
|
37
|
+
│ ┌ 면 영역 ──────────────────────┐ │ │ ┌ 면 영역 ──────────────────────┐ │
|
|
38
|
+
│ │ 성수 북클럽 (title strong) │ │ │ │ 장소 성수동 … │ │
|
|
39
|
+
│ │ 10월 9일 목요일 저녁 7시 │ │ │ │ 준비물 읽은 부분까지 … │ │
|
|
40
|
+
│ │ 이번 달 책은 … (muted) │ │ │ │ 인원 8명 중 6명 … │ │
|
|
41
|
+
│ └───────────────────────────────┘ │ │ │ 진행 … │ │
|
|
42
|
+
│ ↕ spacing.md 16 │ │ └───────────────────────────────┘ │
|
|
43
|
+
│ [ 자세히 보기 ] │ │ [ 앞면 보기 ] │ ← secondary, 같은 자리
|
|
44
|
+
└───────────────────────────────────┘ └───────────────────────────────────┘
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
| 영역 | 컴포넌트 | 위치 | 크기·간격 |
|
|
48
|
+
| --- | --- | --- | --- |
|
|
49
|
+
| 바깥 틀 | Web: `Container size="reading"`. Native: `ScrollView` → `Container` | 화면 본문, 카드는 스크롤과 함께 움직인다. 안전 영역은 화면 host(SafeArea·navigation header)가 준다. 입력이 없어 키보드 처리는 없다 | Native `ScrollView` 위아래 `spacing.md` 16, 좌우는 `Container` `gutter`(폭 600 미만 `compact` 16, 이상 `regular` 20). 최대 폭 `layout.readingMaxWidth` 720 |
|
|
50
|
+
| 틀 | `Card` | 바깥 틀 안 | body padding `spacing.md` 16(`cardRecipe`) |
|
|
51
|
+
| 면 | `ContentTransition` 안 앞면 Stack 또는 DescriptionList | Card 제목 아래 | 앞면 줄 사이 `spacing.xs` 8, 면 높이는 내용에 따름 |
|
|
52
|
+
| 전환 버튼 | `Button` secondary | 면 아래, 전환되는 면 **밖**, 꽉 찬 폭(Stack 기본 stretch) | 높이 `control.buttonHeight.medium` 44, 면과 `spacing.md` 16 |
|
|
53
|
+
|
|
54
|
+
- 버튼은 두 면 모두에서 같은 자리(면 아래, 면 밖)에 둔다. 면 안에 두면 면이 바뀔 때 포커스한 버튼이 사라진다.
|
|
55
|
+
- 두 면의 높이가 다르면 버튼이 위아래로 움직인다. 면 높이를 고정하려면 제품 레이아웃에서 최소 높이를 준다(스토리는 고정하지 않는다).
|
|
56
|
+
- 전환 버튼은 보기 전환이라 primary가 아니라 secondary다([Button](../components/button.md)).
|
|
57
|
+
|
|
58
|
+
## 흐름과 상태
|
|
59
|
+
|
|
60
|
+
1. 앞면(요약)으로 열린다.
|
|
61
|
+
2. "자세히 보기"를 누르면 면이 `scale`(0.96 → 1, 투명도 0 → 1)로 뒷면 DescriptionList로 바뀌고 버튼 라벨이 "앞면 보기"가 된다.
|
|
62
|
+
3. 다시 누르면 앞면으로 돌아온다.
|
|
63
|
+
|
|
64
|
+
| 상태 | 모습 | 포커스·알림 |
|
|
65
|
+
| --- | --- | --- |
|
|
66
|
+
| 기본 | 앞면: 이름·요약·메모 | — |
|
|
67
|
+
| 뒷면 | DescriptionList(이름 "모임 상세 정보") | 포커스는 버튼에 남는다. 바뀐 면을 Web 숨긴 status, Native announce로 알린다 |
|
|
68
|
+
| 진행 중 | — (면 전환은 이미 받은 데이터로 즉시 바뀐다. 카드 데이터를 불러오는 중이면 화면이 카드 자리에 [Skeleton](../components/skeleton.md)을 둔다) | — |
|
|
69
|
+
| 실패 | — (전환 자체는 실패하지 않는다. 카드 데이터 불러오기 실패는 화면이 카드 자리에 [Notice](../components/notice.md) + 다시 시도를 둔다) | — |
|
|
70
|
+
| 모션 감소 | 전환 없이 즉시 바뀐다(`ContentTransition`이 처리) | 같음 |
|
|
71
|
+
|
|
72
|
+
면→문구 키는 상수 표로 둔다(아래 `faceKey`). 템플릿 문자열 키는 키 추출·누락 검사가 찾지 못한다.
|
|
73
|
+
|
|
74
|
+
## 코드 골격
|
|
75
|
+
|
|
76
|
+
```tsx
|
|
77
|
+
// Web
|
|
78
|
+
import { Button } from "@hjmds/react/actions";
|
|
79
|
+
import { ContentTransition } from "@hjmds/react/content-transition";
|
|
80
|
+
import { Card, DescriptionList } from "@hjmds/react/display";
|
|
81
|
+
import { Container, Stack, Text, VisuallyHidden } from "@hjmds/react/layout";
|
|
82
|
+
|
|
83
|
+
const faceKey = {
|
|
84
|
+
front: { status: "meetup.showingFront", toggle: "meetup.showBack" },
|
|
85
|
+
back: { status: "meetup.showingBack", toggle: "meetup.showFront" },
|
|
86
|
+
} as const;
|
|
87
|
+
|
|
88
|
+
<Container size="reading">
|
|
89
|
+
<Card title={t("meetup.title")}>
|
|
90
|
+
<Stack gap="md">
|
|
91
|
+
<ContentTransition stateKey={face} preset="scale">
|
|
92
|
+
{face === "back"
|
|
93
|
+
? <DescriptionList aria-label={t("meetup.details")} items={details} />
|
|
94
|
+
: <Stack gap="xs">
|
|
95
|
+
<Text variant="title" emphasis="strong">{meetup.name}</Text>
|
|
96
|
+
<Text>{meetup.when}</Text>
|
|
97
|
+
<Text tone="muted">{meetup.note}</Text>
|
|
98
|
+
</Stack>}
|
|
99
|
+
</ContentTransition>
|
|
100
|
+
<VisuallyHidden role="status">{t(faceKey[face].status)}</VisuallyHidden>
|
|
101
|
+
<Button tone="secondary" onClick={() => setFace(face === "back" ? "front" : "back")}>
|
|
102
|
+
{t(faceKey[face].toggle)}
|
|
103
|
+
</Button>
|
|
104
|
+
</Stack>
|
|
105
|
+
</Card>
|
|
106
|
+
</Container>
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
```tsx
|
|
110
|
+
// Native
|
|
111
|
+
import { AccessibilityInfo, ScrollView, useWindowDimensions } from "react-native";
|
|
112
|
+
import { Button } from "@hjmds/react-native/actions";
|
|
113
|
+
import { ContentTransition } from "@hjmds/react-native/content-transition";
|
|
114
|
+
import { Card, DescriptionList } from "@hjmds/react-native/data-display";
|
|
115
|
+
import { Container, Stack, Text } from "@hjmds/react-native/primitives";
|
|
116
|
+
import { useHjmNativeTheme } from "@hjmds/react-native/provider";
|
|
117
|
+
import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
|
|
118
|
+
|
|
119
|
+
const { spacing } = useHjmNativeTheme().tokens;
|
|
120
|
+
const gutter = resolveWindowClass(useWindowDimensions().width) === "compact" ? "compact" : "regular";
|
|
121
|
+
const toggle = () => {
|
|
122
|
+
const next = face === "back" ? "front" : "back";
|
|
123
|
+
setFace(next);
|
|
124
|
+
// iOS는 accessibilityLiveRegion을 무시하므로 바뀐 면을 직접 알린다.
|
|
125
|
+
AccessibilityInfo.announceForAccessibility(t(faceKey[next].status));
|
|
126
|
+
};
|
|
127
|
+
|
|
128
|
+
<ScrollView contentContainerStyle={{ paddingVertical: spacing.md }}>
|
|
129
|
+
<Container size="reading" gutter={gutter}>
|
|
130
|
+
<Card title={t("meetup.title")}>
|
|
131
|
+
<Stack gap="md">
|
|
132
|
+
<ContentTransition stateKey={face} preset="scale">
|
|
133
|
+
{face === "back"
|
|
134
|
+
? <DescriptionList label={t("meetup.details")} descriptor={{ items: details }} />
|
|
135
|
+
: <Stack gap="xs">
|
|
136
|
+
<Text variant="title" emphasis="strong">{meetup.name}</Text>
|
|
137
|
+
<Text>{meetup.when}</Text>
|
|
138
|
+
<Text tone="muted">{meetup.note}</Text>
|
|
139
|
+
</Stack>}
|
|
140
|
+
</ContentTransition>
|
|
141
|
+
<Button tone="secondary" onPress={toggle}>{t(faceKey[face].toggle)}</Button>
|
|
142
|
+
</Stack>
|
|
143
|
+
</Card>
|
|
144
|
+
</Container>
|
|
145
|
+
</ScrollView>
|
|
146
|
+
```
|
|
147
|
+
|
|
148
|
+
`face`(`"front" | "back"`)·`setFace`와 모임 이름·일시·책·상세 항목은 제품 소유다. Native의 `faceKey`는 Web과 같은 상수 표다.
|
|
149
|
+
|
|
150
|
+
## 플랫폼 차이
|
|
151
|
+
|
|
152
|
+
| 항목 | Web | Native |
|
|
153
|
+
| --- | --- | --- |
|
|
154
|
+
| 바깥 틀 | 문서 스크롤 + `Container` | `ScrollView`(위아래 `spacing.md`) + `Container` |
|
|
155
|
+
| `DescriptionList` 이름·항목 | `aria-label` + `items` | `label` + `descriptor={{ items }}` |
|
|
156
|
+
| 면 변경 알림 | `VisuallyHidden role="status"` | `AccessibilityInfo.announceForAccessibility`(iOS는 live region을 무시한다) |
|
|
157
|
+
|
|
158
|
+
## 함정
|
|
159
|
+
|
|
160
|
+
- 3D 회전으로 뒤집지 않고 `ContentTransition`으로 면을 바꾼다. 모션 감소 처리를 ContentTransition이 맡고, 한 번에 한 면만 마운트된다.
|
|
161
|
+
- 현재 스토리는 Web 면 알림을 `<span className="hjm-visually-hidden" role="status">`로 직접 그린다. 규칙은 공개 컴포넌트 [VisuallyHidden](../components/visually-hidden.md)이다.
|
|
162
|
+
- 현재 스토리는 면 문구를 `faceStatus(back)` 함수로 만든다. 제품은 i18n 키 상수 표(`faceKey`)로 둔다.
|