@hjmds/design-contracts 1.12.0 → 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,115 @@
|
|
|
1
|
+
# LoadMore
|
|
2
|
+
|
|
3
|
+
- 단계: 컴포넌트
|
|
4
|
+
- 상태: 배포
|
|
5
|
+
- 지원: Web · Native
|
|
6
|
+
- 적용: 1.12.1
|
|
7
|
+
- 검토일: 2026-10-06
|
|
8
|
+
- 근거: [LoadMore](../../load-more.md), `src/component-recipes.ts`(`loadMoreRecipe`), `src/load-more.ts`(상태·controller)
|
|
9
|
+
- 스토리북: `배포/컴포넌트/탐색/더 보기`
|
|
10
|
+
|
|
11
|
+
## 언제 쓰나
|
|
12
|
+
|
|
13
|
+
이미 그린 항목을 그대로 둔 채 목록 끝에서 다음 페이지를 요청하는 footer에 쓴다. 피드, 댓글, 검색 결과처럼
|
|
14
|
+
끝까지 이어 읽는 목록이다. LoadMore는 데이터·cursor를 갖지 않고 footer 상태 표시와 같은 cursor의
|
|
15
|
+
중복 요청 방지만 맡는다.
|
|
16
|
+
|
|
17
|
+
- 목록 본체(한 번에 그림): [List](list.md) + [ListRow](list-row.md)
|
|
18
|
+
- 목록 본체(고정 높이 행이 매우 많음): [VirtualList](virtual-list.md)
|
|
19
|
+
- 목록 아래 다음 페이지 footer: `LoadMore`
|
|
20
|
+
|
|
21
|
+
## 쓰지 않을 때
|
|
22
|
+
|
|
23
|
+
| 상황 | 대신 쓸 것 |
|
|
24
|
+
| --- | --- |
|
|
25
|
+
| 페이지 번호로 이동 | [Pagination](pagination.md) |
|
|
26
|
+
| 처음 화면을 채우는 로딩 | [Skeleton](skeleton.md), [ListRow](list-row.md) `loading`(Web) |
|
|
27
|
+
| 목록이 비었음 | [EmptyState](empty-state.md) |
|
|
28
|
+
|
|
29
|
+
## 공개 이름과 import
|
|
30
|
+
|
|
31
|
+
| 이름 | 역할 | Web | Native |
|
|
32
|
+
| --- | --- | --- | --- |
|
|
33
|
+
| `LoadMore` | 기본 | `@hjmds/react`, `/navigation` | `@hjmds/react-native`, `/navigation`, `/top-bar` |
|
|
34
|
+
|
|
35
|
+
## 최소 사용 예
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
// Web — 화면에 보이면 자동 요청(IntersectionObserver)
|
|
39
|
+
import { LoadMore } from "@hjmds/react/navigation";
|
|
40
|
+
<LoadMore
|
|
41
|
+
descriptor={{ state: footerState, labels: {
|
|
42
|
+
loadMore: t("feed.more"), loading: t("common.loading"),
|
|
43
|
+
retry: t("common.retry"), complete: t("feed.end") } }}
|
|
44
|
+
onLoadMore={({ requestKey }) => fetchNextPage(requestKey)} // query가 끝날 때 settle하는 Promise
|
|
45
|
+
/>
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
```tsx
|
|
49
|
+
// Native — FlatList의 끝 도달을 ref로 연결
|
|
50
|
+
import { useRef } from "react";
|
|
51
|
+
import { FlatList } from "react-native";
|
|
52
|
+
import { LoadMore, type LoadMoreHandle } from "@hjmds/react-native/navigation";
|
|
53
|
+
const loadMore = useRef<LoadMoreHandle>(null);
|
|
54
|
+
<FlatList data={items} renderItem={renderRow}
|
|
55
|
+
onEndReached={() => { void loadMore.current?.onEndReached(); }}
|
|
56
|
+
ListFooterComponent={<LoadMore ref={loadMore} descriptor={footer} onLoadMore={fetchNext} />} />
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## 축과 기본값
|
|
60
|
+
|
|
61
|
+
표의 prop은 Web·Native 공통이다(`(Web)` 표시만 Web 전용). 타입은 `@hjmds/design-contracts/components/load-more`에서 가져온다.
|
|
62
|
+
|
|
63
|
+
| prop | 값 | 기본값 | 설명 |
|
|
64
|
+
| --- | --- | --- | --- |
|
|
65
|
+
| `descriptor.state` | `LoadMoreState`: `{ status: "ready", requestKey }` · `{ status: "loading", requestKey }` · `{ status: "error", requestKey, message }` · `{ status: "complete" }` | 필수 | `requestKey`는 제품이 cursor·offset으로 만든 안정된 문자열. `message`는 현지화된 오류 문구 |
|
|
66
|
+
| `descriptor.labels` | `{ loadMore, loading, retry, complete }`(모두 `string`) | 필수 | 네 문구 모두 필수. renderer는 영어 대체 문구를 만들지 않는다 |
|
|
67
|
+
| `onLoadMore` | `(request: { requestKey: string; reason: "viewport" \| "manual" \| "retry" }) => Promise<void>` | 필수 | 실제 요청이 끝날 때 settle해야 같은 `requestKey`의 중복 요청을 막는다 |
|
|
68
|
+
| `mode` | `automatic` · `manual` | `automatic` | `automatic`은 화면 도달로도 요청하고 ready 상태엔 수동 버튼도 함께 나온다. `manual`은 버튼만 |
|
|
69
|
+
| `density` | `regular` · `compact` | `regular` | 위아래 여백·간격(아래 배치) |
|
|
70
|
+
| `onRequestOutcome` | `(outcome: "started" \| "blocked-by-mode" \| "blocked-by-state" \| "already-requesting", reason) => void` | — | 요청 시도 결과 관찰(분석·디버그용) |
|
|
71
|
+
| `onRequestError` | `(error: unknown, reason) => void` | — | `onLoadMore`가 reject했을 때. 화면 상태는 여전히 제품이 `error`로 바꾼다 |
|
|
72
|
+
| `rootMargin`(Web) | 문자열 | `"200px 0px"` | 자동 요청 감지 여백 |
|
|
73
|
+
| `intersectionRoot`(Web) | `Element \| Document \| null` | viewport | 자동 요청 감지 기준 |
|
|
74
|
+
| `ref` | Web `HTMLDivElement` · Native `LoadMoreHandle` `{ onEndReached(): Promise<outcome> }` | — | Native는 FlatList `onEndReached`에서 `ref.current?.onEndReached()`를 부른다 |
|
|
75
|
+
| `layoutStyle` | 배치 전용 style 객체 | — | 바깥 여백·폭 같은 배치만. 시각 값은 받지 않는다 |
|
|
76
|
+
|
|
77
|
+
## 배치
|
|
78
|
+
|
|
79
|
+
| 항목 | 값 | 근거 |
|
|
80
|
+
| --- | --- | --- |
|
|
81
|
+
| 크기 | 더 보기·다시 시도 버튼은 최소 높이 `control.minTouchTarget` 44, 좌우 안쪽 `spacing.md` 16, radius `radius.md` 12의 텍스트 버튼. 끝 문구는 `caption` | `loadMoreRecipe.trigger`, `.hjm-load-more__trigger` |
|
|
82
|
+
| 간격 | 위아래 여백·줄 간격: `regular` `spacing.lg` 20 · `spacing.sm` 12, `compact` `spacing.sm` 12 · `spacing.xs` 8 | `loadMoreRecipe.density` |
|
|
83
|
+
| 순서·정렬 | 목록 마지막 항목 바로 아래, 한 목록에 하나. 내용은 가로 가운데 정렬한 한 열. 오류일 때 Web은 문구와 다시 시도를 한 줄에 두고(모자라면 줄바꿈), Native는 문구 아래에 다시 시도를 쌓는다 | `.hjm-load-more`, `.hjm-load-more__error`, Native `LoadMore` |
|
|
84
|
+
| 고정·스크롤 | 목록과 같은 스크롤 영역 안에 둔다. 화면 하단에 고정하지 않는다. Native는 FlatList `ListFooterComponent`, Web은 목록 요소 다음 형제 | — |
|
|
85
|
+
| 좁은 폭·큰 글자 | Web 오류 줄은 `flex-wrap`으로 줄을 바꾼다. 버튼은 최소 높이만 있어 큰 글자에서 높이가 늘어난다 | `.hjm-load-more__status`, `.hjm-load-more__error` |
|
|
86
|
+
|
|
87
|
+
```text
|
|
88
|
+
┌─ 스크롤 영역 ─────────────────┐
|
|
89
|
+
│ [ListRow] │
|
|
90
|
+
│ [ListRow] │
|
|
91
|
+
│ ┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄┄ │ ← padding spacing.lg 20
|
|
92
|
+
│ [ 더 보기 ] │ ← ready: 높이 ≥ 44
|
|
93
|
+
│ 오류 문구 [ 다시 시도 ] │ ← error(Web 한 줄, Native 두 줄)
|
|
94
|
+
│ t("feed.end") │ ← complete: caption
|
|
95
|
+
└───────────────────────────────┘
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
## 꼭 지킬 것
|
|
99
|
+
|
|
100
|
+
- `onLoadMore`는 실제 query가 끝날 때 settle되는 Promise를 반환한다. `void fetchNextPage()`처럼 바로 끝나면 같은 cursor가 두 번 돈다.
|
|
101
|
+
- 상태는 제품이 바꾼다. 요청 성공 뒤 다음 `requestKey`의 `ready` 또는 `complete`, 실패 뒤 `error`(현지화 message)를 넘긴다.
|
|
102
|
+
- 오류가 나도 이미 그린 항목을 숨기지 않는다.
|
|
103
|
+
- 로딩 중에는 스피너만 보이고 문구는 접근성 이름으로만 쓴다. 옆에 문구를 따로 붙이지 않는다.
|
|
104
|
+
|
|
105
|
+
## 플랫폼 차이
|
|
106
|
+
|
|
107
|
+
| 항목 | Web | Native |
|
|
108
|
+
| --- | --- | --- |
|
|
109
|
+
| 자동 감지 | 내부 sentinel + IntersectionObserver | 제품 FlatList `onEndReached` → `ref.onEndReached()` |
|
|
110
|
+
| 배치 | `className`, `layoutStyle`(HTML `style`도 받음) | `layoutStyle` |
|
|
111
|
+
|
|
112
|
+
## 함정
|
|
113
|
+
|
|
114
|
+
- [VirtualList](virtual-list.md)는 끝 도달 callback을 노출하지 않는다. Native에서 VirtualList 아래 자동 요청은 연결할 수 없으니
|
|
115
|
+
`mode="manual"`로 두거나 제품 FlatList를 쓴다.
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# Masonry
|
|
2
|
+
|
|
3
|
+
- 단계: 컴포넌트
|
|
4
|
+
- 상태: 배포
|
|
5
|
+
- 지원: Web · Native
|
|
6
|
+
- 적용: 1.12.1
|
|
7
|
+
- 검토일: 2026-10-06
|
|
8
|
+
- 근거: [Masonry](../../masonry.md), `src/masonry.ts`(`resolveMasonryLayout`, `masonryRecipe`)
|
|
9
|
+
- 스토리북: `배포/컴포넌트/레이아웃/높이가 다른 카드 배치`
|
|
10
|
+
|
|
11
|
+
## 언제 쓰나
|
|
12
|
+
|
|
13
|
+
높이가 서로 다른 카드(사진 피드, 핀보드, 갤러리)를 여러 열에 빈틈없이 쌓을 때 쓴다. 각 항목 높이를
|
|
14
|
+
제품이 계산해 넘기면 가장 짧은 열에 입력 순서대로 놓는다. 읽기 순서는 입력 순서 그대로다.
|
|
15
|
+
|
|
16
|
+
## 쓰지 않을 때
|
|
17
|
+
|
|
18
|
+
| 상황 | 대신 쓸 것 |
|
|
19
|
+
| --- | --- |
|
|
20
|
+
| 칸 높이가 같은 격자 | [Grid](grid.md) |
|
|
21
|
+
| 한 줄씩 쌓는 목록 | [List](list.md), [ListRow](list-row.md) |
|
|
22
|
+
| 고정 높이 행이 매우 많음 | [VirtualList](virtual-list.md) (Masonry는 가상화하지 않는다) |
|
|
23
|
+
| 사진을 넘겨 보기 | [Carousel](carousel.md) |
|
|
24
|
+
|
|
25
|
+
## 공개 이름과 import
|
|
26
|
+
|
|
27
|
+
| 이름 | 역할 | Web | Native |
|
|
28
|
+
| --- | --- | --- | --- |
|
|
29
|
+
| `Masonry` | 기본 | `/masonry` | `/masonry` |
|
|
30
|
+
|
|
31
|
+
루트 entry에는 없다. 추가 peer는 없다.
|
|
32
|
+
|
|
33
|
+
## 최소 사용 예
|
|
34
|
+
|
|
35
|
+
```tsx
|
|
36
|
+
// Web
|
|
37
|
+
import { Masonry } from "@hjmds/react/masonry";
|
|
38
|
+
import { EmptyState } from "@hjmds/react/feedback";
|
|
39
|
+
|
|
40
|
+
<Masonry label={t("gallery.title")} items={photos} keyExtractor={(p) => p.id}
|
|
41
|
+
width={containerWidth} columns={3}
|
|
42
|
+
getItemHeight={(p, itemWidth) => itemWidth * (p.height / p.width)}
|
|
43
|
+
renderItem={(p) => <PhotoCard photo={p} />}
|
|
44
|
+
empty={<EmptyState title={t("gallery.empty.title")} />} />
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
// Native
|
|
49
|
+
import { Masonry } from "@hjmds/react-native/masonry";
|
|
50
|
+
|
|
51
|
+
<Masonry label={t("gallery.title")} items={photos} keyExtractor={(p) => p.id}
|
|
52
|
+
width={layoutWidth} getItemHeight={(p, itemWidth) => itemWidth * (p.height / p.width)}
|
|
53
|
+
renderItem={(p) => <PhotoCard photo={p} />} />
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 축과 기본값
|
|
57
|
+
|
|
58
|
+
표의 prop은 Web·Native 공통이다.
|
|
59
|
+
|
|
60
|
+
| prop | 값 | 기본값 | 설명 |
|
|
61
|
+
| --- | --- | --- | --- |
|
|
62
|
+
| `items` | `readonly T[]` | 필수 | 입력 순서가 읽기·포커스 순서다 |
|
|
63
|
+
| `keyExtractor` | `(item: T) => string` | 필수 | 유일한 키. 중복이면 `TypeError` |
|
|
64
|
+
| `renderItem` | `(item: T, index: number) => ReactNode` | 필수 | 열 폭은 넘기지 않는다. 항목 래퍼가 `getItemHeight` 높이·열 폭으로 고정돼 있어 Web은 일반 레이아웃 div에 `height: "100%", display: "flex"`를 주고 그 안의 Surface에는 `layoutStyle={{ flex: 1 }}`을 준다. Native Surface는 `layoutStyle={{ flex: 1 }}`로 채운다. HJM layoutStyle에 height를 넣지 않는다 |
|
|
65
|
+
| `getItemHeight` | `(item: T, itemWidth: number) => number` | 필수 | 그 열 폭에서 항목의 확정 높이(양의 유한수). 루트 높이는 가장 긴 열로 정해진다 |
|
|
66
|
+
| `width` | 숫자(> 0) | 필수 | 컨테이너 폭. 측정은 소비 화면이 한다(Web ResizeObserver, Native `onLayout` 등) |
|
|
67
|
+
| `columns` | 1~12 정수 | 2 | 열 수 |
|
|
68
|
+
| `gap` | 0 이상 숫자 | 12 | 가로·세로 간격 |
|
|
69
|
+
| `label` | 문자열 | 필수 | 현지화 |
|
|
70
|
+
| `empty` | 노드 | — | 항목이 없을 때 보일 내용 |
|
|
71
|
+
| `layoutStyle` | 배치 전용 style 객체 | — | 루트 바깥 배치만. 측정한 `width`·높이가 우선한다 |
|
|
72
|
+
|
|
73
|
+
## 배치
|
|
74
|
+
|
|
75
|
+
| 항목 | 값 | 근거 |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| 크기 | 열 폭 = (`width` − `gap` × (`columns` − 1)) ÷ `columns`. 루트 높이는 가장 긴 열의 끝. `columns` 1~12 | `resolveMasonryLayout`, `masonryRecipe.maxColumns` |
|
|
78
|
+
| 간격 | `gap` 기본 12(`spacing.sm`과 같은 값), 가로·세로 같은 값. `width`는 화면 좌우 여백(`layout.pagePadding` compact 16 · regular 20 · spacious 24)을 뺀 본문 폭 | `src/masonry.ts`, `layout.pagePadding` |
|
|
79
|
+
| 순서·정렬 | 원본 순서대로 가장 짧은 열에 채운다. 읽기·포커스 순서는 열이 아니라 원본 순서. RTL은 오른쪽 열부터 | `masonryRecipe.readingOrder`, `insetInlineStart`(Web)·`right`(Native) |
|
|
80
|
+
| 고정·스크롤 | 본문 스크롤 영역 안에 놓는다. Masonry는 스크롤을 갖지 않는다 | `src/masonry.tsx`(Web·Native) |
|
|
81
|
+
| 좁은 폭·큰 글자 | 열 수는 제품이 폭으로 정한다(좁은 폭 2열, `breakpoint.medium` 600 이상에서 늘림). 큰 글자로 카드 높이가 바뀌면 `getItemHeight`를 다시 계산하고, 폭이 바뀌면 `width`를 다시 넘긴다 | `breakpoint` |
|
|
82
|
+
|
|
83
|
+
## 꼭 지킬 것
|
|
84
|
+
|
|
85
|
+
- 높이는 추정하지 않고 확정 값을 준다. 긴 문구·큰 글자로 카드 높이가 바뀌면 `getItemHeight`도 다시 계산한다.
|
|
86
|
+
모자라게 주면 항목이 겹친다(절대 위치 배치).
|
|
87
|
+
- `keyExtractor`는 유일한 키를 돌려준다. 중복 키는 `TypeError`다.
|
|
88
|
+
- `width`가 0 이하이거나 열 폭이 0 이하, `getItemHeight`가 양의 유한수가 아니면 `TypeError`가 난다. 항목이 비어
|
|
89
|
+
`empty`만 그릴 때도 기하 검사를 먼저 하므로 같다. 측정 전(폭 0)에는 Masonry를 렌더하지 않는다.
|
|
90
|
+
- 화면 폭이 바뀌면 `width`를 다시 넘긴다.
|
|
91
|
+
|
|
92
|
+
## 플랫폼 차이
|
|
93
|
+
|
|
94
|
+
| 항목 | Web | Native |
|
|
95
|
+
| --- | --- | --- |
|
|
96
|
+
| 목록 의미 | `role="list"`·`listitem` | `accessibilityLabel`만(목록 role 없음) |
|
|
97
|
+
| RTL | `insetInlineStart`로 자동 반전 | provider direction이 rtl이면 `right` 기준 |
|
|
98
|
+
| 루트 배치 | `layoutStyle`(측정 `width`·높이가 우선) | `layoutStyle`(측정 `width`·높이가 우선) |
|
|
99
|
+
|
|
100
|
+
## 함정
|
|
101
|
+
|
|
102
|
+
- 모든 항목을 한 번에 그린다. 항목이 많은 무한 피드에서는 렌더 비용이 쌓인다.
|
|
103
|
+
- 항목이 없으면 `empty`만 그리고 목록 role이 붙지 않는다. Web 빈 분기는 `role="group"` + `label` 이름이다(미게시(1.12.1 이후).
|
|
104
|
+
1.12.1은 role 없는 `<div aria-label>`이라 이름이 노출되지 않았다). 빈 상태 문구는 `empty` 안에 보이는 글로 둔다.
|
|
105
|
+
- 다음 페이지는 목록 다음 형제로 [LoadMore](load-more.md)를 둔다. Native에서 FlatList 밖(ScrollView 안 Masonry)이면
|
|
106
|
+
`onEndReached`가 없으므로 `mode="manual"`로 둔다. Web `automatic`은 sentinel로 동작하지만 비가상화라 항목이
|
|
107
|
+
누적되는 비용을 감안해 제품이 고른다.
|
|
108
|
+
- 첫 로딩 자리를 Masonry + aria-hidden Skeleton으로 채우면 Web은 "빈 항목 목록"으로 읽힌다. 로딩 동안은 Masonry 대신
|
|
109
|
+
[Skeleton](skeleton.md)의 영역 단위 알림을 쓴다.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# MediaSelectionScreen
|
|
2
|
+
|
|
3
|
+
- 단계: 컴포넌트
|
|
4
|
+
- 상태: 배포
|
|
5
|
+
- 지원: Web · Native
|
|
6
|
+
- 적용: 미게시(1.12.1 이후)
|
|
7
|
+
- 검토일: 2026-10-06
|
|
8
|
+
- 근거: [반복 화면 조합](../../screen-patterns.md), Web·Native `src/screens.tsx`·`src/screen-flows.tsx`; 기존 개별 지침을 새 규격으로 통합. 예제 스토리는 2026-10-06 사용자 승인으로 스토리북 배포([승인 기록](../../../../../docs/STORYBOOK_NAVIGATION.md#21-2026-10-06-전체-승격과-규격-확정)). 스토리북 배포는 API 게시가 아니다(`적용` 참고)
|
|
9
|
+
- 스토리북: `배포/화면/콘텐츠/사진 선택과 업로드`
|
|
10
|
+
|
|
11
|
+
## 언제 쓰나
|
|
12
|
+
|
|
13
|
+
고른 사진·영상을 큰 썸네일 격자로 보여 주고, 각 항목의 업로드 상태·재시도·취소·순서 이동·삭제와
|
|
14
|
+
"추가"·"완료" 행동을 한 화면에 묶을 때 쓴다. 제품이 사진 라이브러리 picker를 직접 그리려면 `library` 슬롯을 쓴다.
|
|
15
|
+
실제 picker 실행·권한·이미지 URI 수명·업로드는 제품이 소유한다.
|
|
16
|
+
|
|
17
|
+
## 쓰지 않을 때
|
|
18
|
+
|
|
19
|
+
| 상황 | 대신 쓸 것 |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| 앨범·촬영 중 고르는 첫 단계 | [PhotoSourceSheet](photo-source-sheet.md) |
|
|
22
|
+
| 파일 선택 컨트롤 하나 | [FilePicker](file-picker.md) |
|
|
23
|
+
| 업로드 한 건의 상태 행 | [UploadItem](upload-item.md) |
|
|
24
|
+
| 사진 권한 요청·거부 안내 | [PermissionScreen](permission-screen.md) |
|
|
25
|
+
| 높이가 다른 사진 피드 보기 | [Masonry](masonry.md) |
|
|
26
|
+
| 끌어서 순서 바꾸기 | [SortableCollection](sortable-collection.md) (이 화면은 위·아래 버튼으로 옮긴다) |
|
|
27
|
+
|
|
28
|
+
## 공개 이름과 import
|
|
29
|
+
|
|
30
|
+
| 이름 | 역할 | Web | Native |
|
|
31
|
+
| --- | --- | --- | --- |
|
|
32
|
+
| `MediaSelectionScreen` | 미디어 선택 검토 화면 | `/screen-flows` | `/screen-flows` |
|
|
33
|
+
|
|
34
|
+
granular subpath로만 import 된다(루트 entry에 없음). 추가 peer는 없다.
|
|
35
|
+
|
|
36
|
+
## 최소 사용 예
|
|
37
|
+
|
|
38
|
+
```tsx
|
|
39
|
+
// Web
|
|
40
|
+
import { MediaSelectionScreen } from "@hjmds/react/screen-flows";
|
|
41
|
+
|
|
42
|
+
<MediaSelectionScreen
|
|
43
|
+
title={t("media.title")}
|
|
44
|
+
items={picked.map((p) => ({ descriptor: { id: p.id, name: p.fileName, state: p.upload },
|
|
45
|
+
preview: <img src={p.uri} alt="" width={p.width} height={p.height} /> }))}
|
|
46
|
+
add={{ label: t("media.add"), onAction: openPicker }}
|
|
47
|
+
done={{ label: t("media.done"), onAction: submit, disabled: uploading }}
|
|
48
|
+
labels={{ pending: t("upload.pending"), uploading: t("upload.uploading"),
|
|
49
|
+
success: t("upload.success"), cancel: t("upload.cancel"), retry: t("upload.retry") }}
|
|
50
|
+
actionLabels={{ remove: t("media.remove"), moveUp: t("media.moveUp"), moveDown: t("media.moveDown") }}
|
|
51
|
+
removeLabel={(item) => t("media.removeNamed", { name: item.descriptor.name })}
|
|
52
|
+
moveUpLabel={(item) => t("media.moveUpNamed", { name: item.descriptor.name })}
|
|
53
|
+
moveDownLabel={(item) => t("media.moveDownNamed", { name: item.descriptor.name })}
|
|
54
|
+
onRemove={remove} onMove={(id, direction) => move(id, direction)}
|
|
55
|
+
onRetry={retryUpload} onCancel={cancelUpload}
|
|
56
|
+
/>
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
```tsx
|
|
60
|
+
// Native
|
|
61
|
+
import { MediaSelectionScreen } from "@hjmds/react-native/screen-flows";
|
|
62
|
+
import { Image } from "react-native";
|
|
63
|
+
|
|
64
|
+
<MediaSelectionScreen
|
|
65
|
+
title={t("media.title")}
|
|
66
|
+
items={picked.map((p) => ({ descriptor: { id: p.id, name: p.fileName, state: p.upload },
|
|
67
|
+
preview: <Image src={p.uri} width={p.width} height={p.height} /> }))}
|
|
68
|
+
add={{ label: t("media.add"), onAction: openPicker }}
|
|
69
|
+
done={{ label: t("media.done"), onAction: submit, disabled: uploading }}
|
|
70
|
+
labels={{ pending: t("upload.pending"), uploading: t("upload.uploading"),
|
|
71
|
+
success: t("upload.success"), cancel: t("upload.cancel"), retry: t("upload.retry") }}
|
|
72
|
+
actionLabels={{ remove: t("media.remove"), moveUp: t("media.moveUp"), moveDown: t("media.moveDown") }}
|
|
73
|
+
removeLabel={(item) => t("media.removeNamed", { name: item.descriptor.name })}
|
|
74
|
+
moveUpLabel={(item) => t("media.moveUpNamed", { name: item.descriptor.name })}
|
|
75
|
+
moveDownLabel={(item) => t("media.moveDownNamed", { name: item.descriptor.name })}
|
|
76
|
+
onRemove={remove} onMove={(id, direction) => move(id, direction)}
|
|
77
|
+
onRetry={retryUpload} onCancel={cancelUpload}
|
|
78
|
+
/>
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Web의 `<img alt="">`는 장식용 미리보기다. 사진 이름은 `removeLabel` 등 접근성 이름과 UploadItem 행이 읽는다.
|
|
82
|
+
|
|
83
|
+
## 축과 기본값
|
|
84
|
+
|
|
85
|
+
| prop | 값 | 기본값 | 설명 |
|
|
86
|
+
| --- | --- | --- | --- |
|
|
87
|
+
| `items` | `readonly { descriptor: UploadItemDescriptor; preview?: ReactNode }[]` | 필수 | descriptor는 `id`·`name`·선택 `sizeLabel`·`state`(`pending` · `uploading` · `success` · `error`) |
|
|
88
|
+
| `library` | `ReactNode` | 없음 | 주면 `items` 격자 대신 이 슬롯을 그린다 |
|
|
89
|
+
| `selectionSummary` | `ReactNode` | 없음 | footer에서 `done` 위 |
|
|
90
|
+
| `add` | `ScreenFlowAction`(`{ label, onAction(), disabled?, pending? }`) | 필수 | 상단 `actions` 자리의 ghost 버튼 |
|
|
91
|
+
| `done` | `ScreenFlowAction` | 필수 | footer의 primary 버튼 |
|
|
92
|
+
| `labels` | `UploadItemLabels`(`{ pending, uploading, success, cancel, retry }`) | 필수 | 각 항목 UploadItem 문구 |
|
|
93
|
+
| `actionLabels` | `{ remove, moveUp, moveDown }` | 필수 | 항목 아래 버튼의 짧은 표시 문구 |
|
|
94
|
+
| `removeLabel` · `moveUpLabel` · `moveDownLabel` | `(item: MediaSelectionItem) => string` | 필수 | 사진 이름을 포함한 접근성 이름 |
|
|
95
|
+
| `onMove` | `(id: string, direction: -1 \| 1) => void` | 필수 | 첫 항목의 위로·마지막 항목의 아래로는 비활성 |
|
|
96
|
+
| `onRemove` · `onRetry` · `onCancel` | `(id: string) => void` | 필수 | 삭제, UploadItem 재시도·취소 |
|
|
97
|
+
| 나머지 | `ScreenLayout`과 같음(`children`·`footer` 제외) | — | `actions`는 `add`가 덮는다 |
|
|
98
|
+
|
|
99
|
+
## 배치
|
|
100
|
+
|
|
101
|
+
| 항목 | 값 | 근거 |
|
|
102
|
+
| --- | --- | --- |
|
|
103
|
+
| 크기 | 격자 열 compact(창 0~959) 2열 · expanded(창 960 이상) 3열, 열 폭은 ScreenLayout 폭(최대 720)을 나눈다; 미리보기는 열 폭, 항목 버튼은 `Button size="small"` | `Grid columns={{ compact: 2, expanded: 3 }}`, `breakpoint` |
|
|
104
|
+
| 간격 | 화면 padding `spacing.md` 16; 격자 간격 `spacing.md` 16; 항목 안 미리보기–UploadItem–버튼 줄 `spacing.xs` 8; 버튼 사이 `spacing.xxs` 4; footer 요약–완료 `spacing.sm` 12 | Web·Native `MediaSelectionScreen` |
|
|
105
|
+
| 순서·정렬 | 헤더(제목 → 추가) → 격자(항목마다 미리보기 → UploadItem → 위로·아래로·삭제) → footer(`selectionSummary` → 완료) | 렌더 순서 |
|
|
106
|
+
| 고정·스크롤 | 헤더·footer 고정, 격자는 본문 스크롤(`scroll` 기본 `"screen"`) | `ScreenLayout` |
|
|
107
|
+
| 좁은 폭·큰 글자 | 좁은 폭에서도 2열 유지; 항목 버튼 줄은 줄바꿈(`flexWrap: "wrap"`)해 버튼을 자르지 않는다 | `screen-flows.tsx` |
|
|
108
|
+
|
|
109
|
+
## 꼭 지킬 것
|
|
110
|
+
|
|
111
|
+
- `actionLabels`는 짧은 표시 문구, `removeLabel`·`moveUpLabel`·`moveDownLabel`은 사진 이름을 포함한 접근성 이름이다.
|
|
112
|
+
둘 다 현지화한다.
|
|
113
|
+
- preview는 작은 leading 아이콘으로 줄이지 않고 큰 썸네일로 둔다.
|
|
114
|
+
- 업로드 진행·재시도·취소의 실제 동작과 개수·크기 제한, EXIF 처리는 제품이 한다.
|
|
115
|
+
|
|
116
|
+
## 함정
|
|
117
|
+
|
|
118
|
+
- `actions`를 넘겨도 `add` 버튼이 그 자리를 덮는다. 다른 상단 행동은 `leading`이나 `notice`로 둔다.
|
|
119
|
+
- `library`를 주면 `items` 격자와 업로드 상태 표시는 그려지지 않는다. 선택 결과는 `selectionSummary`로 보여 준다.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Mentions
|
|
2
|
+
|
|
3
|
+
- 단계: 컴포넌트
|
|
4
|
+
- 상태: 배포
|
|
5
|
+
- 지원: Web · Native
|
|
6
|
+
- 적용: 1.12.1
|
|
7
|
+
- 검토일: 2026-10-06
|
|
8
|
+
- 근거: [Mentions](../../mentions.md), 트리거 판정 `src/mentions.ts`
|
|
9
|
+
- 스토리북: `배포/컴포넌트/입력/사용자 언급`
|
|
10
|
+
|
|
11
|
+
## 언제 쓰나
|
|
12
|
+
|
|
13
|
+
여러 줄 입력 중 `@`(사람)·`#`(해시태그) 같은 트리거를 치면 후보를 띄우고, 고른 후보를 트리거부터 커서까지
|
|
14
|
+
자리에 넣고 공백 하나를 붙이는 입력에 쓴다. 댓글·게시글 본문 같은 자유 텍스트다.
|
|
15
|
+
트리거는 단어의 시작에서만 열리고(`user@example.com`은 열리지 않음), 공백을 치면 닫힌다.
|
|
16
|
+
|
|
17
|
+
## 쓰지 않을 때
|
|
18
|
+
|
|
19
|
+
| 상황 | 대신 쓸 것 |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| 입력창 전체가 검색어인 자동완성 | [Combobox](combobox.md) |
|
|
22
|
+
| 태그를 칩으로 하나씩 추가 | [TagsInput](tags-input.md) |
|
|
23
|
+
| 트리거 없는 여러 줄 입력 | [TextArea](text-area.md) |
|
|
24
|
+
|
|
25
|
+
## 공개 이름과 import
|
|
26
|
+
|
|
27
|
+
| 이름 | 역할 | Web | Native |
|
|
28
|
+
| --- | --- | --- | --- |
|
|
29
|
+
| `Mentions` | 기본 | `@hjmds/react`, `/mentions` | `@hjmds/react-native`, `/mentions` |
|
|
30
|
+
|
|
31
|
+
## 최소 사용 예
|
|
32
|
+
|
|
33
|
+
```tsx
|
|
34
|
+
// Web
|
|
35
|
+
import { Mentions } from "@hjmds/react/mentions";
|
|
36
|
+
|
|
37
|
+
<Mentions
|
|
38
|
+
label={t("post.body")}
|
|
39
|
+
value={body}
|
|
40
|
+
onValueChange={setBody}
|
|
41
|
+
triggers={[{ id: "user", trigger: "@" }, { id: "tag", trigger: "#" }]}
|
|
42
|
+
candidates={candidates}
|
|
43
|
+
onMentionQueryChange={(match) => setQuery(match)}
|
|
44
|
+
emptyMessage={t("mention.noMatch")}
|
|
45
|
+
listLabel={t("mention.candidates")}
|
|
46
|
+
/>
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```tsx
|
|
50
|
+
// Native
|
|
51
|
+
import { Mentions } from "@hjmds/react-native/mentions";
|
|
52
|
+
|
|
53
|
+
<Mentions label={t("comment.body")} value={body} onValueChange={setBody}
|
|
54
|
+
triggers={[{ id: "user", trigger: "@" }]} candidates={candidates}
|
|
55
|
+
onMentionQueryChange={setQuery} emptyMessage={t("mention.noMatch")} listLabel={t("mention.candidates")} />
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
## 축과 기본값
|
|
59
|
+
|
|
60
|
+
표의 prop은 Web·Native 공통이다. 타입 `MentionMatch`·`MentionTriggerConfig`는 `@hjmds/design-contracts/components/mentions`,
|
|
61
|
+
`MentionCandidate`는 각 `/mentions` entry에서 가져온다.
|
|
62
|
+
|
|
63
|
+
| prop | 값 | 기본값 | 설명 |
|
|
64
|
+
| --- | --- | --- | --- |
|
|
65
|
+
| `value` | 문자열 | 필수 | 제어 값. 비제어(`defaultValue`)는 받지 않는다 |
|
|
66
|
+
| `onValueChange` | `(value: string) => void` | 필수 | 입력·후보 확정 때마다 전체 본문을 넘긴다 |
|
|
67
|
+
| `triggers` | `readonly { id: TriggerId; trigger: string }[]` | 필수 | trigger는 공백 아닌 한 글자이고 글자·id 모두 중복 불가 |
|
|
68
|
+
| `candidates` | `readonly { id, label, insertText?, description? }[]` | 필수 | 넣는 글자는 `insertText`, 없으면 `label` |
|
|
69
|
+
| `onMentionQueryChange` | `(match: { triggerId, trigger, triggerStart, query } \| null) => void` | — | 활성 트리거가 바뀔 때마다(닫힐 때 `null`) 호출. 후보 필터링·로딩은 제품이 한다 |
|
|
70
|
+
| `renderCandidate` | `(candidate: MentionCandidate) => ReactNode` | `label`(Web은 + `description`) | 후보 한 줄의 내용 |
|
|
71
|
+
| `emptyMessage` · `listLabel` | 문자열 | 필수 | 현지화 |
|
|
72
|
+
| `layoutStyle` | 배치 전용 style 객체 | — | Web은 입력+후보 기준 블록 전체, Native는 입력 영역만 배치한다 |
|
|
73
|
+
| 나머지 입력 prop | `label`, `description`, `error` 등 | — | [TextArea](text-area.md)를 따른다 |
|
|
74
|
+
|
|
75
|
+
## 배치
|
|
76
|
+
|
|
77
|
+
| 항목 | 값 | 근거 |
|
|
78
|
+
| --- | --- | --- |
|
|
79
|
+
| 크기 | 후보 한 줄 최소 높이 44(`control.minTouchTarget`). Web 목록 최대 높이 `14rem`, 입력 폭에 맞춤 | `.hjm-mentions__option`·`__list`, Native `minHeight: 44` |
|
|
80
|
+
| 간격 | 라벨·입력·설명·오류 사이 `spacing.xs` 8. Web 목록은 입력과 8, 화면 가장자리 8, 안쪽 8, radius `radius.md` 12 — 모두 Combobox 목록과 같은 `comboboxRecipe.popover`(sideOffset·collisionPadding·padding `spacing.xs`). Native 목록 안쪽 8(같은 recipe)·항목 사이 4, radius `radius.md` 12. 후보 좌우 여백 8. 2026-10-06까지 Web 가장자리 16·안쪽 4, Native 안쪽 4였다(1.12.1 이후 미게시) | `comboboxRecipe.popover`, `.hjm-mentions__*`, `react-native/src/mentions.tsx` |
|
|
81
|
+
| 순서·정렬 | 후보 목록은 입력 **바로 아래**. Web은 아래 공간이 모자라면 위로 뒤집는다. Web 후보는 `label` 옆에 `description`, 좁으면 줄바꿈 | `useAnchoredPopup`, `.hjm-mentions__option` |
|
|
82
|
+
| 고정·스크롤 | Web 목록은 떠 있는 portal(z-index `layer.dropdown` 400)이고 넘치면 목록 안 스크롤. Native 목록은 문서 흐름 안에 끼어들어 아래 내용을 민다 | `src/mentions.tsx`(Web·Native) |
|
|
83
|
+
| 좁은 폭·큰 글자 | 키보드 위 입력이면 Native 목록이 가려질 수 있으니 [KeyboardFormScrollView](keyboard-form-scroll-view.md) 안에 둔다 | — |
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
Web Native
|
|
87
|
+
┌ 본문 ──────────────────────┐ ┌ 본문 ──────────────────────┐
|
|
88
|
+
│ 라벨 │ │ 라벨 │
|
|
89
|
+
│ ┌─────────────────────────┐ │ │ ┌─────────────────────────┐ │
|
|
90
|
+
│ │ 안녕 @ji| │ │ │ │ 안녕 @ji| │ │
|
|
91
|
+
│ └─────────────────────────┘ │ │ └─────────────────────────┘ │
|
|
92
|
+
│ ┌─────────────────────────┐ │ ← 8 │ ┌─────────────────────────┐ │ ← 흐름 안
|
|
93
|
+
│ │ jimin 설명 │ │ 떠 있음│ │ jimin │ │ (아래 내용이 밀림)
|
|
94
|
+
│ │ jiho │ │ ≤14rem │ │ jiho │ │
|
|
95
|
+
│ └─────────────────────────┘ │ │ └─────────────────────────┘ │
|
|
96
|
+
└─────────────────────────────┘ └─────────────────────────────┘
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## 꼭 지킬 것
|
|
100
|
+
|
|
101
|
+
- `value`/`onValueChange`로 제어한다. 후보를 고르면 새 문자열이 `onValueChange`로 온다.
|
|
102
|
+
- 트리거 문자를 후보 `insertText`에 넣지 않는다. 컴포넌트가 정확히 한 번 붙인다.
|
|
103
|
+
- `triggerId`로 후보 출처(사람·태그)를 나눈다. 후보 조회 경합·취소는 제품이 처리한다.
|
|
104
|
+
- 문구는 모두 i18n 키로 넣는다.
|
|
105
|
+
|
|
106
|
+
## 플랫폼 차이
|
|
107
|
+
|
|
108
|
+
| 항목 | Web | Native |
|
|
109
|
+
| --- | --- | --- |
|
|
110
|
+
| 후보 표시 | 입력 아래 떠 있는 listbox(portal) | 입력 아래 문서 흐름 안 목록 |
|
|
111
|
+
| 키보드 | ↑↓ 이동, Enter 확정, Esc 닫기(입력은 유지) | 해당 없음(누름으로 확정) |
|
|
112
|
+
| 기본 후보 렌더 | `label` + `description` | `label`만 |
|
|
113
|
+
| 추가 prop | `className`, ref(`HTMLTextAreaElement`). `style`은 안쪽 textarea에 붙는다 | `listStyle`은 deprecated(다음 major 제거, 대체 없음 — 후보 목록 recipe가 외형을 가진다) |
|
|
114
|
+
|
|
115
|
+
## 함정
|
|
116
|
+
|
|
117
|
+
- 결과는 평문 문자열이다. 어느 후보를 골랐는지(id)는 돌려주지 않으므로 서버에 구조화된 멘션이 필요하면
|
|
118
|
+
제품이 따로 기록하거나 본문을 파싱한다.
|
|
119
|
+
- Native는 커서를 `onSelectionChange`로 추적하므로 이 prop을 넘겨도 컴포넌트 것으로 덮인다.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
# Menu
|
|
2
|
+
|
|
3
|
+
- 단계: 컴포넌트
|
|
4
|
+
- 상태: 배포
|
|
5
|
+
- 지원: Web · Native
|
|
6
|
+
- 적용: 1.12.1
|
|
7
|
+
- 검토일: 2026-10-06
|
|
8
|
+
- 근거: [Dropdown 판정](../../dropdown.md), [Popover 경계](../../popover.md), `src/component-recipes.ts`(`menuRecipe`)
|
|
9
|
+
- 스토리북: `배포/컴포넌트/탐색/메뉴`
|
|
10
|
+
|
|
11
|
+
## 언제 쓰나
|
|
12
|
+
|
|
13
|
+
트리거 버튼을 누르면 뜨는 **항목 목록**에 쓴다. 더보기(⋯) 행동, 정렬 기준 고르기,
|
|
14
|
+
보기 옵션 켜고 끄기처럼 안정적인 id를 가진 action·단일 선택·다중 선택 목록이 여기에 속한다.
|
|
15
|
+
트리거·떠 있는 표면·항목 목록을 Menu 하나가 소유하므로 별도 Dropdown은 없다.
|
|
16
|
+
|
|
17
|
+
## 쓰지 않을 때
|
|
18
|
+
|
|
19
|
+
| 상황 | 대신 쓸 것 |
|
|
20
|
+
| --- | --- |
|
|
21
|
+
| 목록이 아닌 임의 콘텐츠(작은 폼, 링크 섞인 본문) | [Popover](popover.md) (Web) |
|
|
22
|
+
| 우클릭·키보드 메뉴 키로 포인터 위치에서 여는 메뉴 | [ContextMenu](context-menu.md) |
|
|
23
|
+
| 데스크톱 앱의 파일·편집·보기 가로 막대 | [Menubar](menubar.md) (Web) |
|
|
24
|
+
| 폼 값 하나를 고르는 입력 | [Select](select.md), [Combobox](combobox.md) |
|
|
25
|
+
| 하단에서 올라오는 큰 선택 화면 | [Sheet](sheet.md) |
|
|
26
|
+
| 명령 검색 | [CommandPalette](command-palette.md) |
|
|
27
|
+
|
|
28
|
+
## 공개 이름과 import
|
|
29
|
+
|
|
30
|
+
| 이름 | 역할 | Web | Native |
|
|
31
|
+
| --- | --- | --- | --- |
|
|
32
|
+
| `Menu` | 기본 | `@hjmds/react`, `/overlays` | `@hjmds/react-native`, `/navigation`, `/top-bar` |
|
|
33
|
+
| `MorphingMenu` | action 전용 morph 표현(optional peer `bloom-menu` 0.1.0 필요) | `/menu-morph` | — |
|
|
34
|
+
|
|
35
|
+
## 최소 사용 예
|
|
36
|
+
|
|
37
|
+
```tsx
|
|
38
|
+
// Web
|
|
39
|
+
import { IconButton } from "@hjmds/react/actions";
|
|
40
|
+
import { Menu } from "@hjmds/react/overlays";
|
|
41
|
+
|
|
42
|
+
<Menu
|
|
43
|
+
label={t("post.more")}
|
|
44
|
+
trigger={<IconButton label={t("post.more")}>⋯</IconButton>}
|
|
45
|
+
items={[
|
|
46
|
+
{ id: "edit", label: t("post.edit") },
|
|
47
|
+
{ id: "delete", label: t("post.delete"), tone: "danger" },
|
|
48
|
+
]}
|
|
49
|
+
onActionAfterDismiss={(id) => handle(id)}
|
|
50
|
+
/>
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
```tsx
|
|
54
|
+
// Native
|
|
55
|
+
import { Menu } from "@hjmds/react-native/navigation";
|
|
56
|
+
|
|
57
|
+
<Menu
|
|
58
|
+
triggerLabel={t("post.more")}
|
|
59
|
+
dismissLabel={t("common.close")}
|
|
60
|
+
items={[
|
|
61
|
+
{ id: "edit", label: t("post.edit") },
|
|
62
|
+
{ id: "delete", label: t("post.delete"), tone: "danger" },
|
|
63
|
+
]}
|
|
64
|
+
onActionAfterDismiss={(id) => handle(id)}
|
|
65
|
+
/>
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## 축과 기본값
|
|
69
|
+
|
|
70
|
+
| prop | 값 | 기본값 | 설명 |
|
|
71
|
+
| --- | --- | --- | --- |
|
|
72
|
+
| `items` · `sections` | 둘 중 정확히 하나(Native는 `source`도 가능) | 필수 | 섹션마다 `label` 또는 `accessibilityLabel`이 필요하다. 활성 항목이 하나도 없으면 `TypeError` |
|
|
73
|
+
| 항목 모양 | Web `{ id, label: ReactNode, textValue?, description?, leading?, trailing?, tone?, disabled? }` · Native `{ id, label: string, textValue?, description?, shortcut?, tone?, disabled? }` | — | Web `label`이 글이 아니면 `textValue` 필수(typeahead) |
|
|
74
|
+
| 항목 `tone` | `neutral` · `danger` | `neutral` | |
|
|
75
|
+
| `onAction` | Web `(id: string) => void` · Native `(value) => void \| Promise<void>` | — | 고르는 즉시. 메뉴가 닫히기 전이다 |
|
|
76
|
+
| `onActionAfterDismiss` | Web `(id: string) => void` · Native `(value) => void \| Promise<void>` | — | 메뉴가 실제로 닫힌 뒤 한 번. Dialog·Sheet 열기·화면 이동은 여기서 한다 |
|
|
77
|
+
| `open` · `defaultOpen` · `onOpenChange` | `onOpenChange: (open: boolean, detail) => void` — Web `detail = { reason }`, Native 두 번째 인자가 `reason` | 비제어 `false` | reason: Web `trigger` · `selection` · `escape` · `outside` · `tab`, Native `trigger` · `selection` · `escape` · `outside` · `programmatic`. 제어하면 `onOpenChange` 필수(Web) |
|
|
78
|
+
| 선택(Web) | `selectionMode`: `action` · `single`(`value: string \| null`, `onValueChange(value: string)`) · `multiple`(`value: ReadonlySet<string>`, `onValueChange(value: ReadonlySet<string>)`) | `action` | `defaultValue`로 비제어 |
|
|
79
|
+
| 선택(Native) | `selection`: `{ mode: "none" }` · `{ mode: "single", selectedKey, onSelectionChange(key \| null) }` · `{ mode: "multiple", selectedKeys: ReadonlySet, onSelectionChange(keys) }` | `{ mode: "none" }` | `defaultSelectedKey(s)`로 비제어 |
|
|
80
|
+
| `density` | `comfortable` · `compact` | `comfortable`(recipe) | Web은 생략하면 Provider density를 따른다 |
|
|
81
|
+
| `asyncState` | `{ status: "idle" }` · `{ status: "loading" \| "loadingMore" \| "empty" \| "error", message }` | `{ status: "idle" }` | `message`는 현지화(Web `ReactNode`, Native `string`) |
|
|
82
|
+
| `align`(Web) | `start` · `end` | `start` | RTL에서 자동 반전 |
|
|
83
|
+
| `layoutStyle` | 배치 전용 style 객체 | — | Web은 트리거를 감싼 흐름 안 wrapper, Native는 트리거 host. 표면(popup·Modal)은 따라 움직이지 않는다 |
|
|
84
|
+
|
|
85
|
+
## 배치
|
|
86
|
+
|
|
87
|
+
| 항목 | 값 | 근거 |
|
|
88
|
+
| --- | --- | --- |
|
|
89
|
+
| 크기 | 항목 높이 `comfortable` 56(`layout.rowHeight.singleLine`) · `compact` 44(`control.minTouchTarget`). Web 표면 폭 `13.75rem`~`min(24rem, 90vw)`, 높이 최대 `100dvh − 2 × spacing.md`. Native 표면 폭 100%·최대 520, 높이 최대 75% | `menuRecipe.density`, `.hjm-menu__content`, `react-native/src/navigation.tsx` |
|
|
90
|
+
| 간격 | 항목 좌우 `spacing.sm` 12, leading·문구·단축키 사이 12. 섹션 제목 좌우 12 · 위아래 `spacing.xs` 8. Web: 트리거와 8(`menuRecipe.sideOffset`), 화면 가장자리 8(`collisionPadding`), 안쪽 `spacing.xs` 8(`menuRecipe.surface.padding`; 2026-10-06까지 4, 1.12.1 이후 미게시), 표면·항목 radius `radius.md` 12, 구분선 위아래 4 · 좌우 8. Native: 바깥 여백·안쪽 `spacing.md` 16, 제목·목록·닫기 사이 12, radius `radius.lg` 16 | `collectionItemContract`, `menuRecipe.sectionLabel`·`surface`, `.hjm-menu__*`, `useAnchoredPopup` |
|
|
91
|
+
| 순서·정렬 | 일반 행동을 위에, `danger` 행동은 맨 아래. Web `align="start"`(기본)는 트리거 시작 끝, `end`는 끝 끝에 맞춘다(행 끝 ⋯은 `end`). Native는 위→아래 제목 → 항목 → 닫기 버튼(`secondary`) | `menuRecipe`, `src/overlays.tsx` |
|
|
92
|
+
| 고정·스크롤 | Web: 트리거에 붙는 portal(z-index `layer.dropdown` 400), 아래가 모자라면 위로 뒤집고 가로는 화면 안으로 민다. 넘치면 표면 안 스크롤. Native: `Modal`로 화면 **가운데**, scrim을 누르면 닫히고 항목 목록만 스크롤 | `useAnchoredPopup`, `react-native/src/navigation.tsx`(`Menu`) |
|
|
93
|
+
| 좁은 폭·큰 글자 | Web 폭 상한 `90vw`, 항목 문구는 줄바꿈된다. Native 높이 상한 75%를 넘으면 목록이 스크롤된다 | `.hjm-menu__content`, `maxHeight: "75%"` |
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
Web (트리거 아래, 앵커) Native (Modal, 화면 가운데)
|
|
97
|
+
┌ 행 ───────────────── [⋯] ┐ ┌──────────── 화면 ─────────────┐
|
|
98
|
+
└──────────────────────────┘ │░░░░░░░░ scrim(누르면 닫힘) ░░░│
|
|
99
|
+
↓ 8 │░ ┌─────────────────────────┐ ░│
|
|
100
|
+
┌─────────────────┐ │░ │ 제목 │ ░│
|
|
101
|
+
│ 편집 │ ← 56 │░ │ 편집 │ ░│ ← 항목 56
|
|
102
|
+
│ 공유 │ │░ │ 공유 │ ░│ (스크롤)
|
|
103
|
+
│─────────────────│ │░ │ 삭제 (danger) │ ░│
|
|
104
|
+
│ 삭제 (danger) │ │░ │ [ 닫기 ] │ ░│ ← secondary
|
|
105
|
+
└─────────────────┘ │░ └─────────────────────────┘ ░│
|
|
106
|
+
13.75rem ~ 24rem │░ 폭 ≤520 · 높이 ≤75% · 여백 16│
|
|
107
|
+
└───────────────────────────────┘
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
## 꼭 지킬 것
|
|
111
|
+
|
|
112
|
+
- 항목 id는 비어 있지 않고 유일해야 하며, 활성 항목이 하나 이상 있어야 한다. 어기면 렌더 중 던진다.
|
|
113
|
+
- Web에서 `label`이 문자열이 아닌 항목은 typeahead용 `textValue`를 준다. 없으면 던진다.
|
|
114
|
+
- 다른 모달·시트·화면 이동을 여는 action은 `onActionAfterDismiss`로 실행한다. 이 콜백은 메뉴가 실제로 닫힌 뒤(Native는 Modal teardown 뒤)에만 실행되므로 두 표면이 겹치지 않는다.
|
|
115
|
+
- 항목 문구·아이콘(제품 소유)은 i18n 키와 제품 아이콘으로 넣고, 색은 `tone`으로만 바꾼다.
|
|
116
|
+
- `MorphingMenu`는 action 전용이다. 선택·async가 필요하면 `Menu`를 쓴다. reduce-motion·RTL에서는 스스로 `Menu`로 돌아간다.
|
|
117
|
+
|
|
118
|
+
## 플랫폼 차이
|
|
119
|
+
|
|
120
|
+
| 항목 | Web | Native |
|
|
121
|
+
| --- | --- | --- |
|
|
122
|
+
| 트리거 | `trigger`(element 필수), `label`은 메뉴 이름 | `triggerLabel` 필수, `trigger`/`renderTrigger` 선택 |
|
|
123
|
+
| 닫기 버튼 이름 | 없음 | `dismissLabel` 필수 |
|
|
124
|
+
| 선택 | `selectionMode="single"\|"multiple"` + `value`/`onValueChange` | `selection={{ mode, selectedKey(s), onSelectionChange }}` |
|
|
125
|
+
| 선택 후 실행 | 없음 | `onSelectionAfterDismiss` |
|
|
126
|
+
| 상태 | `disabled` | `disabled`, `readOnly`(+`readOnlyLabel`), `busy`, `onRetry`/`retryLabel` |
|
|
127
|
+
| 표면 | 트리거에 붙는 portal(`portalContainer`) | `Modal` |
|
|
128
|
+
| 항목 label | `ReactNode`(+`textValue`) | `string` |
|
|
129
|
+
| 배치 | `className`, `layoutStyle` | `layoutStyle`(`style`은 deprecated — 개발 모드 경고, 다음 major 제거) |
|