@hjmds/design-contracts 1.12.1 → 1.13.0
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 +25 -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 +17 -0
- package/dist/component-recipes.d.ts.map +1 -1
- package/dist/component-recipes.js +5 -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 +376 -0
- package/docs/sheet.md +12 -0
- package/docs/splitter.md +8 -2
- package/docs/theming.md +36 -29
- package/docs/toggle-group.md +7 -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 +104 -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 +215 -0
- package/docs/usage/components/section.md +111 -0
- package/docs/usage/components/segmented-control.md +138 -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 +151 -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 +274 -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,201 @@
|
|
|
1
|
+
# 단계별 드로어
|
|
2
|
+
|
|
3
|
+
- 단계: 구성
|
|
4
|
+
- 상태: 배포
|
|
5
|
+
- 지원: Web · Native
|
|
6
|
+
- 적용: 1.12.1
|
|
7
|
+
- 검토일: 2026-10-06
|
|
8
|
+
- 근거: [Sheet](../../sheet.md), [Steps](../../steps.md), `src/component-recipes.ts` `sheetRecipe`, `src/steps.ts`(`currentStepStatus`), `packages/react-native/src/overlays.tsx`(footer), `showcase/shared/family-drawer.ts`
|
|
9
|
+
- 스토리북: `배포/구성/입력과 작성/단계별 드로어`
|
|
10
|
+
|
|
11
|
+
## 언제 쓰나
|
|
12
|
+
|
|
13
|
+
초대 → 설정 → 확인처럼 짧은 단계 2~5개를 현재 화면을 떠나지 않고 하단 시트 안에서 차례로 진행할 때 쓴다.
|
|
14
|
+
단계마다 화면을 push할 만큼 내용이 크지 않고, 끝나면 원래 화면으로 돌아와 결과만 알리면 되는 설정 흐름이다.
|
|
15
|
+
|
|
16
|
+
## 구성 요소
|
|
17
|
+
|
|
18
|
+
| 컴포넌트 | 역할 | 지침 |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| `Container` · `ScrollView` | 시작 화면의 바깥 틀. 제품 화면이 소유한다 | [Container](../components/container.md), [화면 여백](../tokens/layout.md) |
|
|
21
|
+
| `Heading level="level2"`, `Text` | 시작 화면의 제목·설명 | [Heading](../components/heading.md), [Text](../components/text.md) |
|
|
22
|
+
| `Button` primary | 시트를 여는 트리거(시작 화면의 주 행동) | [Button](../components/button.md) |
|
|
23
|
+
| `Sheet` | 하단 시트. 제목·닫기·`footer`, 안전 영역·스크롤·`busy` | [Sheet](../components/sheet.md) |
|
|
24
|
+
| `Steps` | 현재 단계와 전체 단계 수, 실패하면 현재 단계 `error` | [Steps](../components/steps.md) |
|
|
25
|
+
| `ContentTransition preset="slide"` | 단계 본문 교체 | [ContentTransition](../components/content-transition.md) |
|
|
26
|
+
| `Heading level="level3"`, `Text` | 단계 본문 | [Heading](../components/heading.md), [Text](../components/text.md) |
|
|
27
|
+
| `Notice tone="danger"` | 저장 실패 문구(시트 안) | [Notice](../components/notice.md) |
|
|
28
|
+
| `Button` `ghost`·primary | 이전 · 계속/마치기. Sheet `footer`에 둔다 | [Button](../components/button.md) |
|
|
29
|
+
| `Text` 상태 문구 | 완료 결과(시작 화면) | [Text](../components/text.md) |
|
|
30
|
+
|
|
31
|
+
## 배치
|
|
32
|
+
|
|
33
|
+
```text
|
|
34
|
+
시작 화면 (제품 화면 바깥 틀 안) 하단 시트 (열림, Modal)
|
|
35
|
+
┌ Web 문서 스크롤 · Native ScrollView ┐ ┌──────────── backdrop ─────────────┐
|
|
36
|
+
│ ←gutter 16|20→ Container │ │╭──────────────────────────────────╮│
|
|
37
|
+
│ ┌ Stack gap="xl" 24 ──────────────┐ │ ││ 함께하는 공간 [닫기] ││ ← 머리 고정, 최소 44
|
|
38
|
+
│ │ 공간 제목 (Heading level2) │ │ ││──────────────────────────────────││
|
|
39
|
+
│ │ 설명 (Text) │ │ ││ ① 초대 ─ ② 설정 ─ ③ 확인 ││ ← Steps
|
|
40
|
+
│ │ [ 공간 설정하기 ] primary │ │ ││ ↕ spacing.xl 24 ││ 본문(스크롤)
|
|
41
|
+
│ │ 설정을 마쳤어요. (상태 문구) │ │ ││ (실패 시 Notice danger) ││
|
|
42
|
+
│ └─────────────────────────────────┘ │ ││ 단계 제목 (Heading level3) ││ ┐ ContentTransition
|
|
43
|
+
└─────────────────────────────────────┘ ││ 단계 설명 (Text) ↕ spacing.md 16 ││ ┘ slide
|
|
44
|
+
││──────────────────────────────────││
|
|
45
|
+
││ footer Web: [이전][계속] 오른쪽 ││ ← 고정, 위 여백
|
|
46
|
+
││ footer Native: [ 계속 ] ││ (Native spacing.sm 12)
|
|
47
|
+
││ [ 이전 ] ││
|
|
48
|
+
│╰──── 하단 안전 영역(시트가 더함) ─╯│
|
|
49
|
+
└───────────────────────────────────┘
|
|
50
|
+
radius xl 24, 좌우 spacing.lg 20, 최대 높이 90%, Web 최대 폭 640
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
| 영역 | 컴포넌트 | 위치 | 크기·간격 |
|
|
54
|
+
| --- | --- | --- | --- |
|
|
55
|
+
| 바깥 틀 | 시작 화면: Web 문서 스크롤 > `Container size="reading"`, Native `ScrollView` > `Container size="reading"`. 시트: `Sheet`가 Modal로 화면 위에 뜨며 스크롤·안전 영역·키보드를 소유 | 시작 화면은 제품 화면 본문. 상단 안전 영역은 헤더(또는 [TopBar](../components/top-bar.md))가 맡는다 | 좌우 `Container gutter`: 폭 600 미만 `compact` 16, 이상 `regular` 20([화면 여백](../tokens/layout.md)). Native 위아래 `contentContainerStyle` `paddingVertical: spacing.lg` 20. 시트 안 inset은 제품이 다시 더하지 않는다 |
|
|
56
|
+
| 시작 화면 | `Stack gap="xl"` | 본문 흐름 안 | 자식 사이 `spacing.xl` 24 |
|
|
57
|
+
| 시트 표면 | `Sheet placement="bottom" size="auto"`(기본값) | 화면 아래에서 올라옴, 내용 높이 | radius `xl` 24, 최대 높이 90%. 좌우 `spacing.lg` 20·위아래 `spacing.sm` 12(두 플랫폼, `sheetRecipe.content`), Web 최대 폭 640 |
|
|
58
|
+
| 시트 머리 | Sheet `title`·`closeLabel` | 시트 맨 위 고정 | 최소 높이 `control.minTouchTarget` 44 |
|
|
59
|
+
| 단계 표시 | `Steps` | 본문 맨 위 | 항상 가로 한 줄, 아래 `spacing.xl` 24 |
|
|
60
|
+
| 단계 본문 | `ContentTransition` > `Stack gap="md"` | Steps 아래, 본문 스크롤 | 제목·설명 사이 `spacing.md` 16, `slide` 가로 16 이동 |
|
|
61
|
+
| 행동 | Sheet `footer` + Button ×2 | 시트 아래 고정(스크롤 밖) | Web 오른쪽 정렬 가로 [이전 ghost][계속 primary], 사이 `spacing.sm` 12. Native 세로 열 꽉 찬 폭 [계속]→[이전], 사이 `spacing.sm` 12, 위 `spacing.sm` 12. 높이 44 |
|
|
62
|
+
| 하단 안전 영역 | `Sheet` | 시트 아래 | Native `spacing.sm` 12 + inset, Web footer 아래 `spacing.lg` 20 + `env(safe-area-inset-bottom)` |
|
|
63
|
+
|
|
64
|
+
- 마지막 단계의 "설정 마치기"가 저장이므로 행동은 Sheet `footer`에 둔다([Sheet](../components/sheet.md) 꼭 지킬 것). 본문만 스크롤되고 행동은 큰 글자에서도 보인다.
|
|
65
|
+
- primary는 시작 화면의 트리거 하나, 시트 안의 "계속/마치기" 하나다. Sheet 안의 행동은 그 표면 안에서 primary 하나를 센다([Button](../components/button.md) 꼭 지킬 것의 예외).
|
|
66
|
+
- Native에서 본문이 길 수 있으면 `scrollable`을 켠다. 단계에 입력이 있으면 `keyboardAvoidance`도 함께 켜고 제품 키보드 처리는 두지 않는다.
|
|
67
|
+
|
|
68
|
+
## 흐름과 상태
|
|
69
|
+
|
|
70
|
+
1. 트리거를 누르면 단계를 0으로, 완료·실패 표시를 지우고 시트를 연다.
|
|
71
|
+
2. "계속"은 다음 단계로 간다. 첫 단계에서 "이전"은 `disabled`다.
|
|
72
|
+
3. 마지막 단계에서 주 행동 라벨이 "설정 마치기"로 바뀐다. 누르면 결과를 저장한다. 저장이 비동기면 저장 중 Sheet `busy`로 닫기를 막고 주 행동은 `loading`이다.
|
|
73
|
+
4. 저장이 성공하면 시트를 닫고 시작 화면에 완료 문구가 나타난다.
|
|
74
|
+
5. 저장이 실패(네트워크·서버 오류)하면 시트를 유지하고 본문에 `Notice tone="danger"`, Steps 현재 단계를 `currentStepStatus: "error"`로 둔다. 주 행동을 다시 누르면 재요청하고, 재요청도 실패하면 같은 상태를 유지한다.
|
|
75
|
+
6. 닫기 버튼·바깥·Escape/back으로 닫으면 저장하지 않는다(취소).
|
|
76
|
+
|
|
77
|
+
| 상태 | 모습 | 포커스·알림 |
|
|
78
|
+
| --- | --- | --- |
|
|
79
|
+
| 기본 | 시작 화면만 보인다. 시트 닫힘 | — |
|
|
80
|
+
| 단계 진행 | Steps가 앞 단계 `complete`, 현재 `current`, 뒤 `pending`. 본문이 가로로 미끄러져 바뀐다 | Steps 접근성 이름 "3단계 중 2, 설정"(제품 `composeAccessibleName`). reduced motion이면 즉시 교체 |
|
|
81
|
+
| 진행 중 | 저장 중. 주 행동 `loading`, "이전" `disabled`, Sheet `busy`(닫기·바깥·back 막힘) | 포커스는 주 행동에 유지 |
|
|
82
|
+
| 실패 | 시트 유지, 본문에 `Notice tone="danger"`, Steps 현재 단계 `error`, 주 행동 다시 활성 | Native `Notice announcement="polite"`로 알림, Web Notice. 포커스 이동 없음 |
|
|
83
|
+
| 완료 | 시트가 닫히고 시작 화면에 완료 문구 | Web `Text role="status"`, Native live region + iOS 알림(새로 나타난 문구라 첫 렌더에도 알린다) |
|
|
84
|
+
|
|
85
|
+
- 단계 문구 키는 단계 id → 키 상수 표(`stepKey`)로 둔다. 템플릿 문자열 키(`` `space.setup.${id}.title` ``)는 키 추출·누락 검사가 찾지 못한다.
|
|
86
|
+
- 오류 문구는 제품이 지역화한다. raw exception을 그대로 보이지 않는다.
|
|
87
|
+
|
|
88
|
+
## 코드 골격
|
|
89
|
+
|
|
90
|
+
```tsx
|
|
91
|
+
// Web
|
|
92
|
+
import { Button } from "@hjmds/react/actions";
|
|
93
|
+
import { ContentTransition } from "@hjmds/react/content-transition";
|
|
94
|
+
import { Notice } from "@hjmds/react/feedback";
|
|
95
|
+
import { Heading } from "@hjmds/react/heading";
|
|
96
|
+
import { Container, Stack, Text } from "@hjmds/react/layout";
|
|
97
|
+
import { Sheet } from "@hjmds/react/overlays";
|
|
98
|
+
import { Steps } from "@hjmds/react/steps";
|
|
99
|
+
|
|
100
|
+
const stepIds = ["invite", "preferences", "review"] as const;
|
|
101
|
+
const stepKey = {
|
|
102
|
+
invite: { label: "space.setup.invite.label", title: "space.setup.invite.title", body: "space.setup.invite.body" },
|
|
103
|
+
preferences: { label: "space.setup.preferences.label", title: "space.setup.preferences.title", body: "space.setup.preferences.body" },
|
|
104
|
+
review: { label: "space.setup.review.label", title: "space.setup.review.title", body: "space.setup.review.body" },
|
|
105
|
+
} as const;
|
|
106
|
+
|
|
107
|
+
const current = stepIds[step] ?? "invite";
|
|
108
|
+
const isLast = step === stepIds.length - 1;
|
|
109
|
+
|
|
110
|
+
<Container size="reading" gutter={gutter}>
|
|
111
|
+
<Stack gap="xl">
|
|
112
|
+
<Heading level="level2">{t("space.title")}</Heading>
|
|
113
|
+
<Text as="p">{t("space.description")}</Text>
|
|
114
|
+
<Button onClick={openSetup}>{t("space.setup.open")}</Button>
|
|
115
|
+
{saved ? <Text as="p" role="status">{t("space.setup.saved")}</Text> : null}
|
|
116
|
+
</Stack>
|
|
117
|
+
<Sheet open={open} onOpenChange={setOpen} busy={saving}
|
|
118
|
+
title={t("space.setup.title")} closeLabel={t("common.close")}
|
|
119
|
+
footer={<>
|
|
120
|
+
<Button tone="ghost" disabled={step === 0 || saving} onClick={back}>{t("common.back")}</Button>
|
|
121
|
+
<Button loading={saving} onClick={isLast ? finish : next}>{isLast ? t("space.setup.finish") : t("common.next")}</Button>
|
|
122
|
+
</>}>
|
|
123
|
+
<Stack gap="xl">
|
|
124
|
+
<Steps descriptor={{ steps: stepIds.map((id) => ({ id, label: t(stepKey[id].label) })), currentStepId: current, currentStepStatus: failed ? "error" : "current" }}
|
|
125
|
+
statusLabels={statusLabels} composeAccessibleName={stepName} />
|
|
126
|
+
{failed ? <Notice tone="danger" title={t("space.setup.failed")} /> : null}
|
|
127
|
+
<ContentTransition stateKey={current} preset="slide">
|
|
128
|
+
<Stack gap="md">
|
|
129
|
+
<Heading level="level3">{t(stepKey[current].title)}</Heading>
|
|
130
|
+
<Text as="p">{t(stepKey[current].body)}</Text>
|
|
131
|
+
</Stack>
|
|
132
|
+
</ContentTransition>
|
|
133
|
+
</Stack>
|
|
134
|
+
</Sheet>
|
|
135
|
+
</Container>
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
```tsx
|
|
139
|
+
// Native
|
|
140
|
+
import { ScrollView } from "react-native";
|
|
141
|
+
import { spacing } from "@hjmds/design-contracts/foundations";
|
|
142
|
+
import { Button } from "@hjmds/react-native/actions";
|
|
143
|
+
import { ContentTransition } from "@hjmds/react-native/content-transition";
|
|
144
|
+
import { Notice } from "@hjmds/react-native/feedback";
|
|
145
|
+
import { Heading } from "@hjmds/react-native/heading";
|
|
146
|
+
import { Sheet } from "@hjmds/react-native/overlays";
|
|
147
|
+
import { Container, Stack, Text } from "@hjmds/react-native/primitives";
|
|
148
|
+
import { Steps } from "@hjmds/react-native/steps";
|
|
149
|
+
|
|
150
|
+
// stepIds · stepKey · current · isLast는 Web과 같다.
|
|
151
|
+
|
|
152
|
+
<ScrollView contentContainerStyle={{ paddingVertical: spacing.lg }}>
|
|
153
|
+
<Container size="reading" gutter={gutter}>
|
|
154
|
+
<Stack gap="xl">
|
|
155
|
+
<Heading level="level2">{t("space.title")}</Heading>
|
|
156
|
+
<Text>{t("space.description")}</Text>
|
|
157
|
+
<Button onPress={openSetup}>{t("space.setup.open")}</Button>
|
|
158
|
+
{saved ? <StatusText announceOnMount>{t("space.setup.saved")}</StatusText> : null}
|
|
159
|
+
</Stack>
|
|
160
|
+
</Container>
|
|
161
|
+
<Sheet scrollable open={open} onOpenChange={setOpen} busy={saving}
|
|
162
|
+
title={t("space.setup.title")} closeLabel={t("common.close")}
|
|
163
|
+
footer={<>
|
|
164
|
+
<Button loading={saving} onPress={isLast ? finish : next}>{isLast ? t("space.setup.finish") : t("common.next")}</Button>
|
|
165
|
+
<Button tone="ghost" disabled={step === 0 || saving} onPress={back}>{t("common.back")}</Button>
|
|
166
|
+
</>}>
|
|
167
|
+
<Stack gap="xl">
|
|
168
|
+
<Steps descriptor={{ steps: stepIds.map((id) => ({ id, label: t(stepKey[id].label) })), currentStepId: current, currentStepStatus: failed ? "error" : "current" }}
|
|
169
|
+
statusLabels={statusLabels} composeAccessibleName={stepName} />
|
|
170
|
+
{failed ? <Notice tone="danger" announcement="polite" title={t("space.setup.failed")} /> : null}
|
|
171
|
+
<ContentTransition stateKey={current} preset="slide">
|
|
172
|
+
<Stack gap="md">
|
|
173
|
+
<Heading level="level3">{t(stepKey[current].title)}</Heading>
|
|
174
|
+
<Text>{t(stepKey[current].body)}</Text>
|
|
175
|
+
</Stack>
|
|
176
|
+
</ContentTransition>
|
|
177
|
+
</Stack>
|
|
178
|
+
</Sheet>
|
|
179
|
+
</ScrollView>
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
단계 목록·문구·저장 동작은 제품 소유다. `gutter`는 폭 구간으로 고른다(`resolveWindowClass(width) === "compact" ? "compact" : "regular"`).
|
|
183
|
+
`StatusText`는 [저장과 재시도](action-recovery-save.md#공통-세션과-상태-알림)의 helper이며, 완료 문구는 새로 나타나므로 첫 렌더에도 알리게 한다(스토리 `PatternStatus announceOnMount`).
|
|
184
|
+
|
|
185
|
+
## 플랫폼 차이
|
|
186
|
+
|
|
187
|
+
| 항목 | Web | Native |
|
|
188
|
+
| --- | --- | --- |
|
|
189
|
+
| 본문 스크롤 | Sheet 본문이 스스로 스크롤 | `scrollable`(기본 `false`)을 켠다 |
|
|
190
|
+
| footer 배치 | 오른쪽 정렬 가로 줄 [보조][주] | 세로 열, 꽉 찬 폭, 주 행동 먼저 |
|
|
191
|
+
| 완료 알림 | `Text role="status"` | `accessibilityLiveRegion`(Android) + iOS `announceForAccessibilityWithOptions` |
|
|
192
|
+
| 실패 알림 | Notice | `Notice announcement="polite"` |
|
|
193
|
+
| 포커스 | `initialFocusRef`·`returnFocusRef`, focus trap | `returnFocusRef` |
|
|
194
|
+
|
|
195
|
+
## 함정
|
|
196
|
+
|
|
197
|
+
- 저장을 시트 닫힘 사유와 묶지 않는다. 닫기·바깥·back 닫힘은 취소다.
|
|
198
|
+
- 단계를 열 때마다 0으로 되돌린다. 이전 진행 상태가 남으면 마지막 단계에서 바로 열린다.
|
|
199
|
+
- 저장 중 `busy`를 빼면 사용자가 시트를 닫아 저장 결과를 볼 곳이 사라진다.
|
|
200
|
+
- 현재 스토리는 시작 화면에 바깥 틀(Container·ScrollView)이 없고 Storybook 프레임이 감싼다. 제품은 위 바깥 틀 행을 따른다.
|
|
201
|
+
- 현재 스토리는 저장이 동기라 진행 중·실패 상태가 없다.
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
# 빠른 메모 작성
|
|
2
|
+
|
|
3
|
+
- 단계: 구성
|
|
4
|
+
- 상태: 배포
|
|
5
|
+
- 지원: Web · Native
|
|
6
|
+
- 적용: 1.12.1
|
|
7
|
+
- 검토일: 2026-10-06
|
|
8
|
+
- 근거: [FloatingActionButton](../../floating-action-button.md), `src/floating-action-button.ts`, `packages/react/src/styles.css` `.hjm-fab`, `showcase/{web/src/patterns,native/src}/FloatingActionButton.stories.tsx`
|
|
9
|
+
- 스토리북: `배포/구성/입력과 작성/빠른 메모 작성`
|
|
10
|
+
|
|
11
|
+
## 언제 쓰나
|
|
12
|
+
|
|
13
|
+
스크롤되는 기록 목록 위에 떠 있는 생성 버튼으로 짧은 입력 대화상자를 열고, 저장하면 새 항목을 목록 맨 위에 넣을 때 쓴다.
|
|
14
|
+
목록을 읽는 중에는 버튼이 원으로 접히고, 위로 돌아오면 라벨이 다시 보인다.
|
|
15
|
+
|
|
16
|
+
## 구성 요소
|
|
17
|
+
|
|
18
|
+
| 컴포넌트 | 역할 | 지침 |
|
|
19
|
+
| --- | --- | --- |
|
|
20
|
+
| TopBar | 화면 제목 | [TopBar](../components/top-bar.md) |
|
|
21
|
+
| Container | 목록 좌우 여백(gutter) | [Container](../components/container.md), [화면 여백](../tokens/layout.md) |
|
|
22
|
+
| List + ListRow | 기록 목록(제목·설명) | [List](../components/list.md), [ListRow](../components/list-row.md) |
|
|
23
|
+
| EmptyState | 기록이 없을 때 목록 자리(스토리에는 없다) | [EmptyState](../components/empty-state.md) |
|
|
24
|
+
| FloatingActionButton | 단일 생성 행동(화면의 주 행동), 스크롤 방향에 따라 접힘 | [FloatingActionButton](../components/floating-action-button.md) |
|
|
25
|
+
| `useFloatingActionButtonScroll` | 스크롤로 `layoutMode` 결정 | [FloatingActionButton](../components/floating-action-button.md) |
|
|
26
|
+
| Dialog | 작성 대화상자, 주 행동 "기록 추가" | [Dialog](../components/dialog.md) |
|
|
27
|
+
| TextArea | 메모 입력 | [TextArea](../components/text-area.md) |
|
|
28
|
+
| Notice | 저장 실패(제품이 더한다) | [Notice](../components/notice.md) |
|
|
29
|
+
|
|
30
|
+
## 배치
|
|
31
|
+
|
|
32
|
+
```text
|
|
33
|
+
┌──────────── 상단 안전 영역(TopBar safeAreaTop) ┐
|
|
34
|
+
│ 나의 기록 (TopBar, 좌우 16) │
|
|
35
|
+
├─────────── 스크롤 영역(FAB 스크롤 대상) ─┤
|
|
36
|
+
│ ←gutter→ Container ←gutter→ │
|
|
37
|
+
│ 기억하고 싶은 순간 1 (ListRow) │
|
|
38
|
+
│ 기억하고 싶은 순간 2 │
|
|
39
|
+
│ ... │
|
|
40
|
+
│ 마지막 항목 │
|
|
41
|
+
│ ┄┄┄ 하단 여백 = clearance ┄┄┄┄┄┄┄┄┄┄┄┄┄ │ ← 84 + 하단 inset
|
|
42
|
+
│ ╭──────────────╮ │
|
|
43
|
+
│ │ + 새 기록 │ │ ← FAB(주 행동), 끝 쪽 하단
|
|
44
|
+
│ ╰──────────────╯ │ 스크롤 내리면 (+) 원 52
|
|
45
|
+
│ margin 16 → │
|
|
46
|
+
└──────── 하단 안전 영역 + margin 16 ──────┘
|
|
47
|
+
|
|
48
|
+
Dialog (열림)
|
|
49
|
+
┌──────────────────────────────┐
|
|
50
|
+
│ 어떤 순간을 남길까요? [닫기] │
|
|
51
|
+
│ [저장 실패 Notice] (실패 시) │
|
|
52
|
+
│ ┌ 나의 기록 ───────────────┐ │
|
|
53
|
+
│ │ TextArea │ │
|
|
54
|
+
│ └──────────────────────────┘ │
|
|
55
|
+
│ [기록 추가] │ ← 주 행동, 비어 있으면 disabled
|
|
56
|
+
└──────────────────────────────┘
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
| 영역 | 컴포넌트 | 위치 | 크기·간격 |
|
|
60
|
+
| --- | --- | --- | --- |
|
|
61
|
+
| 바깥 틀 | Web: 높이 `100dvh`·`overflow-y: auto` 스크롤 요소(FAB hook의 대상). Native: `flex: 1`·`position: "relative"` View > `ScrollView`(`onScroll`, `scrollEventThrottle={16}`) | 화면 전체. 위 안전 영역은 TopBar(`safeAreaTop`, 내비게이션 헤더가 있으면 헤더), 아래는 FAB와 clearance | 스크롤 콘텐츠 아래 padding = `onContentClearanceChange` 값(첫 값 52 + 16×2 + 하단 inset = 84 + inset). 위아래 다른 여백은 두지 않는다(TopBar가 맨 위). 목록 좌우는 `Container gutter`: 폭 600 미만 `compact` 16 · 이상 `regular` 20([화면 여백](../tokens/layout.md)) |
|
|
62
|
+
| 제목 | TopBar | 스토리는 스크롤 콘텐츠 맨 위(함께 스크롤). 고정하려면 스크롤 영역 밖에 둔다. Container 밖(가장자리까지) | 최소 높이 52 + 위쪽 안전 영역, 좌우 `spacing.md` 16(TopBar 소유) |
|
|
63
|
+
| 목록 | Container > List > ListRow | 스크롤 영역, TopBar 아래 | ListRow 좌우 `spacing.xs` 8은 행 안 여백이고 화면 여백은 Container gutter다 |
|
|
64
|
+
| 생성 버튼 | FloatingActionButton | 스크롤 영역 뒤 sibling, 논리적 끝 쪽 하단(RTL은 왼쪽) | 지름 `large` 52, 여백 `spacing.md` 16, 펼침 라벨 좌우 `spacing.lg` 20 |
|
|
65
|
+
| 작성 | Dialog `medium` | 화면 가운데 오버레이 | 최대 폭 420. Web padding `spacing.lg` 20, Native `medium` 안쪽 `spacing.xl` 24 |
|
|
66
|
+
| 저장 실패 | Notice `tone="danger"` | 대화상자 본문 맨 위(Web), 목록 위(Native, 아래 흐름 5) | 본문 영역 사이 `spacing.md` 16 |
|
|
67
|
+
|
|
68
|
+
- 한 화면의 주 행동은 FAB 하나다. 대화상자 안 "기록 추가"는 모달 안 행동이라 화면 primary와 따로 센다([Button](../components/button.md)).
|
|
69
|
+
|
|
70
|
+
## 흐름과 상태
|
|
71
|
+
|
|
72
|
+
1. 목록을 아래로 스크롤하면 FAB가 원(`collapsed`)으로, 위로 스크롤하면 알약(`expanded`)으로 바뀐다. 같은 버튼 인스턴스라 포커스가 유지된다.
|
|
73
|
+
2. FAB를 누르면 Dialog가 열린다.
|
|
74
|
+
3. 내용을 쓰면 "기록 추가"가 활성화된다. 누르면 저장한다(비동기면 `busy`).
|
|
75
|
+
4. 성공하면 새 항목을 목록 맨 위에 넣고, 대화상자를 닫고, 입력을 비우고, 목록을 맨 위로 스크롤한다. 맨 위로 돌아오면 FAB 라벨이 다시 펼쳐진다.
|
|
76
|
+
5. 실패(네트워크·서버 오류)하면 입력을 비우지 않는다. Web은 대화상자를 연 채 본문 맨 위에 Notice를 보이고 "기록 추가"를 다시 누를 수 있다.
|
|
77
|
+
Native Dialog는 `primaryAction`을 누르면 콜백을 부른 뒤 바로 `close-action`을 요청하므로, 열어 두려면 제어형 소유자가
|
|
78
|
+
저장 중 닫기 요청을 무시한다(아래 코드 골격). 재시도도 실패하면 같은 Notice가 한 개만 남는다.
|
|
79
|
+
|
|
80
|
+
| 상태 | 모습 | 포커스·알림 |
|
|
81
|
+
| --- | --- | --- |
|
|
82
|
+
| 기본 | 목록 + 펼친 FAB(아이콘 + "새 기록" 알약). 스크롤하면 접힌 원 52 | 접근성 이름은 접힘·펼침 모두 전체 `label` |
|
|
83
|
+
| 진행 중 | 저장 중. Dialog `busy`(닫기 막음), Web "기록 추가" `loading`, Native 행동 버튼 `disabled` | 포커스는 대화상자 안에 유지 |
|
|
84
|
+
| 실패 | 대화상자 열림 유지, 입력 유지, 본문 맨 위 `Notice tone="danger"` `t("notes.compose.failed")` | Web `danger` Notice는 `role="alert"`. Native는 `announcement="assertive"`를 줘야 알린다. 포커스 이동 없음 |
|
|
85
|
+
| 입력 비어 있음 | "기록 추가" `disabled` | — |
|
|
86
|
+
| 저장 후 | 새 항목이 맨 위, 대화상자 닫힘 | Web Dialog는 닫히며 포커스를 트리거(FAB)로 돌린다. Native는 `returnFocusRef`로 지정한다 |
|
|
87
|
+
| 큰 글자 | 라벨이 여러 줄이 되어 clearance가 커진다 | 측정값을 하단 padding에 그대로 반영 |
|
|
88
|
+
| 빈 목록 | 목록 자리에 [EmptyState](../components/empty-state.md), FAB는 그대로 | — |
|
|
89
|
+
| 목록 불러오기 실패 | 목록 자리에 [EmptyState](../components/empty-state.md)(실패 문구 + `action`에 다시 불러오기 버튼). FAB는 그대로 | EmptyState `announcement`(Native)로 알림 |
|
|
90
|
+
|
|
91
|
+
## 코드 골격
|
|
92
|
+
|
|
93
|
+
```tsx
|
|
94
|
+
// Web
|
|
95
|
+
import { useState } from "react";
|
|
96
|
+
import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
|
|
97
|
+
import { Button } from "@hjmds/react/actions";
|
|
98
|
+
import { List, ListRow } from "@hjmds/react/display";
|
|
99
|
+
import { Notice } from "@hjmds/react/feedback";
|
|
100
|
+
import { FloatingActionButton, useFloatingActionButtonScroll, resolveFloatingActionButtonContentClearance } from "@hjmds/react/floating-action-button";
|
|
101
|
+
import { TextArea } from "@hjmds/react/forms";
|
|
102
|
+
import { Container } from "@hjmds/react/layout";
|
|
103
|
+
import { Dialog } from "@hjmds/react/overlays";
|
|
104
|
+
import { TopBar } from "@hjmds/react/top-bar";
|
|
105
|
+
|
|
106
|
+
function Notes() {
|
|
107
|
+
const [target, setTarget] = useState<HTMLDivElement | null>(null);
|
|
108
|
+
const layoutMode = useFloatingActionButtonScroll(target);
|
|
109
|
+
const [clearance, setClearance] = useState(resolveFloatingActionButtonContentClearance(0));
|
|
110
|
+
const gutter = resolveWindowClass(window.innerWidth) === "compact" ? "compact" : "regular";
|
|
111
|
+
return (
|
|
112
|
+
<>
|
|
113
|
+
<div ref={setTarget} style={{ blockSize: "100dvh", overflowY: "auto" }}>
|
|
114
|
+
<div style={{ paddingBottom: clearance }}>
|
|
115
|
+
<TopBar title={t("notes.title")} />
|
|
116
|
+
<Container gutter={gutter}>
|
|
117
|
+
<List label={t("notes.recent")}>{notes.map((n) => <ListRow key={n.id} title={n.text} />)}</List>
|
|
118
|
+
</Container>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
<FloatingActionButton descriptor={{ label: t("notes.new"), icon: { name: "add" }, layoutMode }}
|
|
122
|
+
renderIcon={renderIcon} onContentClearanceChange={setClearance} onClick={() => setOpen(true)} />
|
|
123
|
+
<Dialog title={t("notes.compose.title")} open={open} onOpenChange={setOpen} closeLabel={t("common.close")} busy={saving}
|
|
124
|
+
footer={<Button loading={saving} disabled={!draft.trim()} onClick={save}>{t("notes.compose.save")}</Button>}>
|
|
125
|
+
{failed && <Notice tone="danger" title={t("notes.compose.failed")} />}
|
|
126
|
+
<TextArea label={t("notes.compose.label")} value={draft} onChange={(event) => setDraft(event.currentTarget.value)} />
|
|
127
|
+
</Dialog>
|
|
128
|
+
</>
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
```tsx
|
|
134
|
+
// Native
|
|
135
|
+
import { useRef, useState } from "react";
|
|
136
|
+
import { ScrollView, View, useWindowDimensions } from "react-native";
|
|
137
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
138
|
+
import { resolveWindowClass } from "@hjmds/design-contracts/responsive";
|
|
139
|
+
import { List, ListRow } from "@hjmds/react-native/data-display";
|
|
140
|
+
import { Notice } from "@hjmds/react-native/feedback";
|
|
141
|
+
import { FloatingActionButton, useFloatingActionButtonScroll, resolveFloatingActionButtonContentClearance } from "@hjmds/react-native/floating-action-button";
|
|
142
|
+
import { TextArea } from "@hjmds/react-native/inputs";
|
|
143
|
+
import { Dialog } from "@hjmds/react-native/overlays";
|
|
144
|
+
import { Container } from "@hjmds/react-native/primitives";
|
|
145
|
+
import { TopBar } from "@hjmds/react-native/top-bar";
|
|
146
|
+
|
|
147
|
+
function Notes() {
|
|
148
|
+
const insets = useSafeAreaInsets();
|
|
149
|
+
const { width } = useWindowDimensions();
|
|
150
|
+
const scroll = useRef<ScrollView>(null);
|
|
151
|
+
const submitting = useRef(false); // save()가 동기로 true로 둔다 — Dialog가 곧바로 보내는 close-action을 무시하기 위해
|
|
152
|
+
const { layoutMode, onScroll } = useFloatingActionButtonScroll();
|
|
153
|
+
const [clearance, setClearance] = useState(resolveFloatingActionButtonContentClearance(insets.bottom));
|
|
154
|
+
return (
|
|
155
|
+
<View style={{ flex: 1, position: "relative" }}>
|
|
156
|
+
<ScrollView ref={scroll} onScroll={onScroll} scrollEventThrottle={16} contentContainerStyle={{ paddingBottom: clearance }}>
|
|
157
|
+
<TopBar title={t("notes.title")} safeAreaTop={insets.top} />
|
|
158
|
+
<Container gutter={resolveWindowClass(width) === "compact" ? "compact" : "regular"}>
|
|
159
|
+
<List label={t("notes.recent")}>{notes.map((n) => <ListRow key={n.id} title={n.text} />)}</List>
|
|
160
|
+
</Container>
|
|
161
|
+
</ScrollView>
|
|
162
|
+
<FloatingActionButton descriptor={{ label: t("notes.new"), icon: { name: "add" }, layoutMode }}
|
|
163
|
+
renderIcon={renderIcon} safeAreaBottomInset={insets.bottom}
|
|
164
|
+
onContentClearanceChange={setClearance} onPress={() => setOpen(true)} />
|
|
165
|
+
<Dialog title={t("notes.compose.title")} open={open} closeLabel={t("common.close")} busy={saving}
|
|
166
|
+
onOpenChange={(next) => { if (!next && submitting.current) return; setOpen(next); }}
|
|
167
|
+
primaryAction={{ label: t("notes.compose.save"), onPress: save, disabled: !draft.trim() }}>
|
|
168
|
+
{failed ? <Notice tone="danger" announcement="assertive" title={t("notes.compose.failed")} /> : null}
|
|
169
|
+
<TextArea label={t("notes.compose.label")} value={draft} onValueChange={setDraft} />
|
|
170
|
+
</Dialog>
|
|
171
|
+
</View>
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
`save`는 `saving`(Native는 `submitting.current`도)을 켜고 저장한 뒤 성공이면 항목 추가·닫기·입력 비우기·맨 위로 스크롤,
|
|
177
|
+
실패면 `failed`를 켜고 대화상자를 연 채 둔다. 스토리의 18개 예시 기록과 문구, `+` 글자 아이콘은 제품 데이터·아이콘으로 바꾼다.
|
|
178
|
+
|
|
179
|
+
## 플랫폼 차이
|
|
180
|
+
|
|
181
|
+
| 항목 | Web | Native |
|
|
182
|
+
| --- | --- | --- |
|
|
183
|
+
| FAB 위치 | CSS `position: fixed`, 안전 영역 `env()` 자동 | positioned 부모 안 `absolute`, `safeAreaBottomInset` 직접 전달 |
|
|
184
|
+
| 스크롤 hook | `useFloatingActionButtonScroll(target)` → `layoutMode` | `useFloatingActionButtonScroll()` → `{ layoutMode, onScroll }`, `scrollEventThrottle={16}` |
|
|
185
|
+
| 대화상자 주 행동 | `footer`에 Button(`loading` 가능) | `primaryAction` 객체. 누르면 콜백 뒤 바로 `close-action` 요청, `loading` 없음(`busy`면 `disabled`) |
|
|
186
|
+
| TextArea 값 | DOM `onChange`(Web TextArea에는 `onValueChange`가 없다, 1.12.1) | `onValueChange(value)` |
|
|
187
|
+
| 맨 위로 | `target.scrollTo({ top: 0 })` | `scroll.current?.scrollTo({ y: 0 })` |
|
|
188
|
+
|
|
189
|
+
## 함정
|
|
190
|
+
|
|
191
|
+
- clearance를 스크롤 콘텐츠 하단 padding에 넣지 않으면 마지막 항목이 FAB 아래에 가려진다. 지름만 예약하지 않는다.
|
|
192
|
+
- 한 화면에 FAB는 하나다. 하단 고정 결론 행동이 따로 있으면 [BottomCTA](../components/bottom-cta.md)를 쓴다.
|
|
193
|
+
- Native Dialog에서 비동기 저장을 `primaryAction`에 넣고 닫기 요청을 그대로 받으면 실패해도 대화상자가 이미 닫혀 있다. 저장 중 닫기 요청은 소유자가 무시한다.
|
|
194
|
+
- 현재 Native 스토리는 `safeAreaBottomInset`·TopBar `safeAreaTop`을 넘기지 않는다. 실제 화면은 inset을 넘긴다.
|
|
195
|
+
- 현재 스토리는 List를 Container 없이 화면 가장자리에 둔다(ListRow 좌우 8만 남는다). 제품은 Container gutter 안에 둔다.
|
|
196
|
+
- Web TextArea는 TextField와 달리 `onValueChange`가 없다(1.12.1). Web은 `onChange`로 값을 읽는다([TextArea](../components/text-area.md)).
|
|
197
|
+
- 현재 스토리는 저장이 동기라 진행 중·실패 상태가 없고 문구가 i18n 키 없는 리터럴이다.
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# 입력 시트
|
|
2
|
+
|
|
3
|
+
- 단계: 구성
|
|
4
|
+
- 상태: 배포
|
|
5
|
+
- 지원: Web · Native
|
|
6
|
+
- 적용: 1.12.1
|
|
7
|
+
- 검토일: 2026-10-06
|
|
8
|
+
- 근거: [Sheet 입력 화면과 가용 영역](../../sheet.md), `showcase/web/src/patterns/InputSheet.stories.tsx`, `showcase/native/src/InputSheet.stories.tsx`, `src/component-recipes.ts`(`sheetRecipe`), `packages/react/src/styles.css`(`.hjm-sheet`), `packages/react-native/src/overlays.tsx`(`Sheet`)
|
|
9
|
+
- 스토리북: `배포/구성/입력과 작성/입력 시트`
|
|
10
|
+
|
|
11
|
+
## 언제 쓰나
|
|
12
|
+
|
|
13
|
+
현재 화면 위에 하단 시트를 띄워 짧은 입력(이름 바꾸기, 메모 한 줄)을 받고, 키보드가 올라와도 본문을 스크롤하며
|
|
14
|
+
완료 버튼에 닿게 할 때 쓴다. 완료 버튼은 스크롤 밖 `footer`에 고정된다. 입력 칸이 많아 화면 하나를 차지하면
|
|
15
|
+
시트 대신 화면으로 이동하고 [KeyboardFormScrollView](../components/keyboard-form-scroll-view.md)·[BottomCTA](../components/bottom-cta.md)를 쓴다.
|
|
16
|
+
|
|
17
|
+
## 구성 요소
|
|
18
|
+
|
|
19
|
+
| 컴포넌트 | 역할 | 지침 |
|
|
20
|
+
| --- | --- | --- |
|
|
21
|
+
| `Button`(트리거) | 시트를 연다 | [Button](../components/button.md) |
|
|
22
|
+
| `Sheet` | 하단 모달. `title`·`closeLabel` 필수, Native는 `keyboardAvoidance`·`scrollable`·`safeAreaInsets` | [Sheet](../components/sheet.md) |
|
|
23
|
+
| `TextField` | 입력 칸 | [Field](../components/field.md) |
|
|
24
|
+
| 본문 `Text` | 입력 아래 설명(스토리는 스크롤 확인용 8줄) | [Text](../components/text.md) |
|
|
25
|
+
| `Notice`(실패 시) | 저장 실패를 본문 맨 위에 남긴다. 제품이 더한다(스토리에는 없다) | [Notice](../components/notice.md) |
|
|
26
|
+
| `Button`(footer) | 완료(시트의 주 행동). 스크롤 밖에 고정 | [Button](../components/button.md) |
|
|
27
|
+
|
|
28
|
+
## 배치
|
|
29
|
+
|
|
30
|
+
```text
|
|
31
|
+
Native(하단 시트, 키보드 올라옴) Web(하단 시트)
|
|
32
|
+
┌ 화면 + backdrop ───────────────┐ ┌ 화면 + backdrop ──────────────────┐
|
|
33
|
+
│ │ │ ┌ 폭 min(640, 100%) ───────┐ │
|
|
34
|
+
│ ↑ 상단 안전 영역 밖까지만 │ │ │ 기록 이름 [×] │ │ header: lg 20, gap md 16
|
|
35
|
+
├────────────────────────────────┤ 위 모서리 │ │ ┌ body(스크롤) ────────┐ │ │
|
|
36
|
+
│ 기록 이름 [×] │ radius │ │ │ 이름 [ ] │ │ │ body padding lg 20
|
|
37
|
+
│ ┌ 본문(ScrollView) ──────────┐ │ xl 24 │ │ │ 설명 … │ │ │
|
|
38
|
+
│ │ 이름 │ │ │ │ └──────────────────────┘ │ │
|
|
39
|
+
│ │ [ ] │ │ │ │ [ 완료 ]│ │ footer 오른쪽 정렬
|
|
40
|
+
│ │ 설명 … (스크롤) │ │ │ │ + 하단 안전 영역 │ │
|
|
41
|
+
│ └────────────────────────────┘ │ └──────┴──────────────────────────┴─┘
|
|
42
|
+
│ [ 완료 ] │ ← footer 고정
|
|
43
|
+
├────────────────────────────────┤
|
|
44
|
+
│ ███████ 키보드 ███████████████ │ ← 시트가 키보드 위로 올라감(하단 inset 0)
|
|
45
|
+
└────────────────────────────────┘
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
| 영역 | 컴포넌트 | 위치 | 크기·간격 |
|
|
49
|
+
| --- | --- | --- | --- |
|
|
50
|
+
| 바깥 틀 | 시트 안: `Sheet`(Modal)가 backdrop·본문 스크롤(Native `scrollable`)·키보드(Native `keyboardAvoidance`)·안전 영역을 소유. 트리거: 제품 화면의 바깥 틀(Web 문서 스크롤 > `Container`, Native `ScrollView` > `Container`) | 시트는 화면 위 오버레이, 트리거는 화면 본문 | 시트 안 여백은 아래 행들(Sheet 소유, 제품이 inset·키보드 여백을 다시 더하지 않는다). 트리거 화면 좌우는 `Container gutter` 폭 600 미만 `compact` 16 · 이상 `regular` 20([화면 여백](../tokens/layout.md)) |
|
|
51
|
+
| 시트 틀 | `Sheet` placement `bottom` | 화면 하단에 붙고 위 모서리만 둥글다 | radius `xl` 24, 최대 높이 `maxHeightRatio` 0.9(뷰포트의 90%), Web 최대 폭 640 |
|
|
52
|
+
| 머리 | 제목 + 닫기 IconButton | 시트 맨 위, 고정 | Native 최소 높이 `control.minTouchTarget` 44·좌우 `spacing.lg` 20(+좌우 inset)·위 `spacing.sm` 12. Web padding `spacing.lg` 20(아래 0), 제목–닫기 `spacing.md` 16 |
|
|
53
|
+
| 실패 알림 | `Notice tone="danger"` | 본문 맨 위(입력 위), 실패했을 때만, 스크롤 | 본문 항목 사이 `spacing.md` 16 |
|
|
54
|
+
| 본문 | `TextField` + 설명 | 머리 아래, **스크롤** | 항목 사이 `sheetRecipe.body.gap` `spacing.md` 16. Web body padding `spacing.lg` 20 |
|
|
55
|
+
| 완료 | footer `Button`(primary) | 본문 아래, **고정**(스크롤 밖) | 높이 `medium` 44. Native 위 `spacing.sm` 12, footer 세로 열이라 꽉 찬 폭(`fullWidth` 불필요). Web 오른쪽 정렬, 버튼 사이 `spacing.sm` 12, padding 0 `spacing.lg` 20 `spacing.lg` 20 |
|
|
56
|
+
| 하단 안전 영역 | Sheet가 처리 | 시트 맨 아래 | Native 아래 `spacing.sm` 12 + bottom inset(키보드가 붙어 있으면 inset 0, 대신 키보드 높이만큼 올라감). Web footer 아래 `spacing.lg` 20 + `env(safe-area-inset-bottom)` |
|
|
57
|
+
|
|
58
|
+
- 주 행동(완료)은 footer 하나다. 취소는 머리의 닫기 버튼이 맡는다. 버튼을 둘 두면 [Sheet 배치](../components/sheet.md#배치)에 따라 Web 가로 footer는 보조 → 주, Native 세로 footer는 주 → 보조 순서다.
|
|
59
|
+
- 저장·완료는 본문이 아니라 `footer`에 둔다([Sheet](../components/sheet.md)). 시트는 모달이라 뒤 화면의 primary와 같은 화면으로 세지 않는다.
|
|
60
|
+
- 시트가 위로 커질 수 있는 한계는 상단 안전 영역 아래까지다(Native는 `insets.top`을 가용 높이에서 뺀다).
|
|
61
|
+
|
|
62
|
+
## 흐름과 상태
|
|
63
|
+
|
|
64
|
+
1. 트리거 버튼을 누르면 시트가 아래에서 올라온다.
|
|
65
|
+
2. 입력 칸을 누르면 키보드가 올라오고 Native 시트는 키보드 위로 올라간다. 본문은 스크롤되고 완료 버튼은 계속 보인다.
|
|
66
|
+
3. 완료를 누르면 저장한다. 저장 중에는 footer 버튼 `loading` + Sheet `busy`로 닫기를 막는다.
|
|
67
|
+
4. 성공하면 `open`을 `false`로 바꾼다. 닫기 버튼·backdrop·Escape(Web)·Android back으로도 닫힌다(저장 중 제외).
|
|
68
|
+
5. 실패(네트워크·서버 오류)하면 시트를 닫지 않고 입력을 그대로 둔 채 본문 맨 위에 실패 Notice를 보인다. 완료를 다시 누르면
|
|
69
|
+
다시 저장한다. 재시도도 실패하면 Notice가 남고 같은 상태가 반복된다. 입력 값 자체의 오류는 Notice 대신 `TextField`의 `error`다.
|
|
70
|
+
|
|
71
|
+
| 상태 | 모습 | 포커스·알림 |
|
|
72
|
+
| --- | --- | --- |
|
|
73
|
+
| 기본 | 트리거에서 열면 하단 시트 + backdrop. 비어 있거나 공백뿐인 입력은 완료 disabled, 유효한 값이면 사용 가능 | 시트가 모달 dialog로 알려진다. Web은 focus trap, 닫히면 트리거로 돌아감 |
|
|
74
|
+
| 진행 중 | 저장 중. footer 버튼 `loading`(라벨 자리 유지) + Sheet `busy`로 닫기 버튼·backdrop·Escape·back 닫기 막음 | 포커스는 완료 버튼에 유지 |
|
|
75
|
+
| 실패 | 시트 열린 채 입력 유지, 본문 맨 위 `Notice tone="danger"`(`t("record.saveFailed")`), 완료 버튼 다시 사용 가능 | Web Notice `danger`는 `role="alert"`로 알림. Native는 `announcement="assertive"`를 줘야 알린다. 포커스 이동 없음 |
|
|
76
|
+
| 재시도 실패 | 실패와 같다. Notice는 한 개만 유지(겹쳐 쌓지 않음) | 진행 중 → 실패로 다시 나타나므로 다시 알림 |
|
|
77
|
+
| 키보드 올라옴(Native) | 시트 하단이 키보드 위, 하단 inset 0, 본문 스크롤 | 본문 탭을 놓치지 않음(`keyboardShouldPersistTaps="handled"`), iOS는 끌어서 키보드 내림 |
|
|
78
|
+
| 내용이 김 | 본문만 스크롤, 머리·footer 고정 | — |
|
|
79
|
+
| 큰 글자(Web `LargeText`, mobile1 viewport) | 본문이 더 길게 스크롤, footer는 그대로 보임 | — |
|
|
80
|
+
|
|
81
|
+
- 실패 문구는 제품이 지역화한다. raw exception을 그대로 보이지 않는다.
|
|
82
|
+
- 저장 상태를 [저장과 재시도](action-recovery-save.md)의 세션이나 제품 mutation으로 이미 갖고 있으면 그 상태에서 `saving`·`failed`를 읽는다.
|
|
83
|
+
|
|
84
|
+
## 코드 골격
|
|
85
|
+
|
|
86
|
+
```tsx
|
|
87
|
+
// Web
|
|
88
|
+
import { Button } from "@hjmds/react/actions";
|
|
89
|
+
import { Notice } from "@hjmds/react/feedback";
|
|
90
|
+
import { TextField } from "@hjmds/react/forms";
|
|
91
|
+
import { Sheet } from "@hjmds/react/overlays";
|
|
92
|
+
|
|
93
|
+
<>
|
|
94
|
+
<Button onClick={() => setOpen(true)}>{t("record.rename")}</Button>
|
|
95
|
+
<Sheet open={open} onOpenChange={setOpen} title={t("record.nameTitle")} closeLabel={t("common.close")}
|
|
96
|
+
busy={saving}
|
|
97
|
+
footer={<Button loading={saving} disabled={!name.trim()} onClick={save}>{t("common.done")}</Button>}>
|
|
98
|
+
{failed && <Notice tone="danger" title={t("record.saveFailed")} />}
|
|
99
|
+
<TextField label={t("record.name")} value={name} onValueChange={setName} />
|
|
100
|
+
</Sheet>
|
|
101
|
+
</>
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
```tsx
|
|
105
|
+
// Native
|
|
106
|
+
import { useSafeAreaInsets } from "react-native-safe-area-context";
|
|
107
|
+
import { Button } from "@hjmds/react-native/actions";
|
|
108
|
+
import { Notice } from "@hjmds/react-native/feedback";
|
|
109
|
+
import { TextField } from "@hjmds/react-native/inputs";
|
|
110
|
+
import { Sheet } from "@hjmds/react-native/overlays";
|
|
111
|
+
|
|
112
|
+
const insets = useSafeAreaInsets();
|
|
113
|
+
|
|
114
|
+
<>
|
|
115
|
+
<Button onPress={() => setOpen(true)}>{t("record.rename")}</Button>
|
|
116
|
+
<Sheet open={open} onOpenChange={setOpen} title={t("record.nameTitle")} closeLabel={t("common.close")}
|
|
117
|
+
keyboardAvoidance scrollable safeAreaInsets={insets} busy={saving}
|
|
118
|
+
footer={<Button loading={saving} disabled={!name.trim()} onPress={save}>{t("common.done")}</Button>}>
|
|
119
|
+
{failed ? <Notice tone="danger" announcement="assertive" title={t("record.saveFailed")} /> : null}
|
|
120
|
+
<TextField label={t("record.name")} value={name} onValueChange={setName} />
|
|
121
|
+
</Sheet>
|
|
122
|
+
</>
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
`save`는 `saving`을 켜고 저장한 뒤 성공이면 `setOpen(false)`, 실패면 `failed`를 켜고 시트를 연 채 둔다(다음 저장 시작 때 끈다).
|
|
126
|
+
스토리의 설명 8줄은 스크롤 확인용 데모다. `busy`·`loading`·실패 Notice는 저장이 비동기일 때 제품이 더한다(스토리에는 없다).
|
|
127
|
+
|
|
128
|
+
## 플랫폼 차이
|
|
129
|
+
|
|
130
|
+
| 항목 | Web | Native |
|
|
131
|
+
| --- | --- | --- |
|
|
132
|
+
| 키보드 | 브라우저가 처리, CSS body 스크롤 | `keyboardAvoidance`로 키보드 높이만큼 올림 |
|
|
133
|
+
| 스크롤 | body가 항상 `overflow: auto` | `scrollable`을 켜야 ScrollView가 생긴다(가상화 목록이면 끈다) |
|
|
134
|
+
| 안전 영역 | `env(safe-area-inset-*)` | `safeAreaInsets` prop(기본은 provider inset) |
|
|
135
|
+
| 폭 | `min(640px, 100%)` 가운데 | 화면 폭 100% |
|
|
136
|
+
| footer 정렬 | 오른쪽 정렬(`justify-content: flex-end`) | 세로 묶음, 위 `spacing.sm` 12 |
|
|
137
|
+
| `TextField` 값 | `onValueChange(value)`(Native와 같은 이름). DOM 이벤트가 필요할 때만 `onChange`(둘 다 호출된다) | `onValueChange(value)` |
|
|
138
|
+
| 실패 Notice 알림 | `danger`는 항상 `role="alert"` | `announcement`를 줘야 알린다(기본 `none`) |
|
|
139
|
+
|
|
140
|
+
## 함정
|
|
141
|
+
|
|
142
|
+
- Native에서 `keyboardAvoidance`만 켜고 `scrollable`을 빼면 본문이 ScrollView가 아니라서 키보드 위로 줄어든 공간에서 넘친 내용에 닿을 수 없다. 입력 시트는 둘을 같이 켠다.
|
|
143
|
+
- 제품에서 키보드 listener·`maxHeight`·중첩 ScrollView를 다시 만들지 않는다. 키보드 여백은 Sheet의 Modal이 소유하고 내부 ScrollView는 `automaticallyAdjustKeyboardInsets={false}`로 두 번 더하지 않게 돼 있다.
|
|
144
|
+
- 완료 버튼을 본문 끝에 두면 키보드에 가려진다. `footer`에 둔다.
|
|
145
|
+
- 저장 실패에 시트를 닫고 Toast만 띄우면 입력이 사라진다. 시트를 연 채 Notice로 남긴다.
|
|
146
|
+
- 현재 스토리 문구는 i18n 키가 없는 한국어 리터럴이고, 완료가 저장 없이 바로 닫는다. 제품은 키로 넣고 위 진행 중·실패 상태를 더한다.
|
|
147
|
+
|
|
148
|
+
- 2026-10-06 독립 재구현에서 기본 상태 표의 “완료 가능”과 코드의 빈 값 disabled가 충돌해 표를 수정했다. 닫기 후 초안 유지·폐기는 제품 정책이며, 저장 성공 전 초안을 잃지 않는 원칙을 유지한다.
|