@hjmds/design-contracts 1.20.0 → 1.22.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.
Files changed (131) hide show
  1. package/dist/authoring-tools.d.ts +38 -0
  2. package/dist/authoring-tools.d.ts.map +1 -0
  3. package/dist/authoring-tools.js +35 -0
  4. package/dist/authoring-tools.js.map +1 -0
  5. package/dist/behaviors.d.ts +33 -3
  6. package/dist/behaviors.d.ts.map +1 -1
  7. package/dist/behaviors.js +2 -0
  8. package/dist/behaviors.js.map +1 -1
  9. package/dist/calendar.d.ts +9 -1
  10. package/dist/calendar.d.ts.map +1 -1
  11. package/dist/calendar.js +4 -2
  12. package/dist/calendar.js.map +1 -1
  13. package/dist/catalog.d.ts +129 -2
  14. package/dist/catalog.d.ts.map +1 -1
  15. package/dist/catalog.js +6 -2
  16. package/dist/catalog.js.map +1 -1
  17. package/dist/chart.d.ts +398 -0
  18. package/dist/chart.d.ts.map +1 -0
  19. package/dist/chart.js +414 -0
  20. package/dist/chart.js.map +1 -0
  21. package/dist/collection-editing.d.ts +38 -0
  22. package/dist/collection-editing.d.ts.map +1 -0
  23. package/dist/collection-editing.js +53 -0
  24. package/dist/collection-editing.js.map +1 -0
  25. package/dist/communication.d.ts +29 -0
  26. package/dist/communication.d.ts.map +1 -0
  27. package/dist/communication.js +11 -0
  28. package/dist/communication.js.map +1 -0
  29. package/dist/component-definitions.d.ts +1 -0
  30. package/dist/component-definitions.d.ts.map +1 -1
  31. package/dist/component-definitions.js +1 -0
  32. package/dist/component-definitions.js.map +1 -1
  33. package/dist/data-table.d.ts +229 -5
  34. package/dist/data-table.d.ts.map +1 -1
  35. package/dist/data-table.js +242 -6
  36. package/dist/data-table.js.map +1 -1
  37. package/dist/data-workflows.d.ts +70 -0
  38. package/dist/data-workflows.d.ts.map +1 -0
  39. package/dist/data-workflows.js +109 -0
  40. package/dist/data-workflows.js.map +1 -0
  41. package/dist/dataviz.d.ts +7 -6
  42. package/dist/dataviz.d.ts.map +1 -1
  43. package/dist/dataviz.js +7 -6
  44. package/dist/dataviz.js.map +1 -1
  45. package/dist/gooey-navigation.d.ts +7 -0
  46. package/dist/gooey-navigation.d.ts.map +1 -1
  47. package/dist/gooey-navigation.js +10 -0
  48. package/dist/gooey-navigation.js.map +1 -1
  49. package/dist/index.d.ts +1 -0
  50. package/dist/index.d.ts.map +1 -1
  51. package/dist/index.js +1 -0
  52. package/dist/index.js.map +1 -1
  53. package/dist/media-transport.d.ts +16 -0
  54. package/dist/media-transport.d.ts.map +1 -0
  55. package/dist/media-transport.js +17 -0
  56. package/dist/media-transport.js.map +1 -0
  57. package/dist/recipes.d.ts +1 -0
  58. package/dist/recipes.d.ts.map +1 -1
  59. package/dist/recipes.js +1 -0
  60. package/dist/recipes.js.map +1 -1
  61. package/dist/showcase.d.ts.map +1 -1
  62. package/dist/showcase.js +3 -1
  63. package/dist/showcase.js.map +1 -1
  64. package/dist/social-compositions.d.ts +19 -0
  65. package/dist/social-compositions.d.ts.map +1 -0
  66. package/dist/social-compositions.js +33 -0
  67. package/dist/social-compositions.js.map +1 -0
  68. package/dist/streaming-answer.d.ts +18 -0
  69. package/dist/streaming-answer.d.ts.map +1 -0
  70. package/dist/streaming-answer.js +61 -0
  71. package/dist/streaming-answer.js.map +1 -0
  72. package/dist/task-controls.d.ts +31 -0
  73. package/dist/task-controls.d.ts.map +1 -0
  74. package/dist/task-controls.js +25 -0
  75. package/dist/task-controls.js.map +1 -0
  76. package/dist/toggle-feedback.d.ts +22 -0
  77. package/dist/toggle-feedback.d.ts.map +1 -0
  78. package/dist/toggle-feedback.js +32 -0
  79. package/dist/toggle-feedback.js.map +1 -0
  80. package/dist/transfer-task.d.ts +13 -0
  81. package/dist/transfer-task.d.ts.map +1 -0
  82. package/dist/transfer-task.js +13 -0
  83. package/dist/transfer-task.js.map +1 -0
  84. package/dist/version.d.ts +1 -1
  85. package/dist/version.js +1 -1
  86. package/dist/version.js.map +1 -1
  87. package/docs/authoring-tools.md +31 -0
  88. package/docs/calendar.md +3 -1
  89. package/docs/catalog-freeze.json +5 -0
  90. package/docs/chart.md +206 -0
  91. package/docs/collection-editing.md +83 -0
  92. package/docs/communication.md +35 -0
  93. package/docs/data-table.md +56 -6
  94. package/docs/data-workflows.md +26 -0
  95. package/docs/generated/component-maturity.md +6 -5
  96. package/docs/generated/renderer-evidence.json +95 -5
  97. package/docs/generated/renderer-evidence.md +6 -3
  98. package/docs/generated/showcase-manifest.json +66 -3
  99. package/docs/gooey-navigation.md +20 -0
  100. package/docs/library-gap-analysis.md +4 -2
  101. package/docs/media-transport.md +37 -0
  102. package/docs/social-compositions.md +51 -0
  103. package/docs/streaming-answer.md +57 -0
  104. package/docs/task-controls.md +38 -0
  105. package/docs/toggle-feedback.md +56 -0
  106. package/docs/transfer-task.md +33 -0
  107. package/docs/usage/README.md +15 -4
  108. package/docs/usage/components/activity-chip.md +2 -2
  109. package/docs/usage/components/button.md +39 -0
  110. package/docs/usage/components/calendar.md +4 -2
  111. package/docs/usage/components/chart.md +277 -0
  112. package/docs/usage/components/data-table.md +96 -18
  113. package/docs/usage/components/screen-layout.md +3 -1
  114. package/docs/usage/components/search-screen.md +1 -0
  115. package/docs/usage/components/tabs.md +25 -0
  116. package/docs/usage/components/toast.md +1 -1
  117. package/docs/usage/compositions/action-feedback.md +18 -2
  118. package/docs/usage/compositions/activity-status.md +2 -2
  119. package/docs/usage/compositions/authoring-tools.md +90 -0
  120. package/docs/usage/compositions/collection-editing.md +110 -0
  121. package/docs/usage/compositions/communication.md +100 -0
  122. package/docs/usage/compositions/data-workflows.md +91 -0
  123. package/docs/usage/compositions/media-transport.md +87 -0
  124. package/docs/usage/compositions/social-compositions.md +91 -0
  125. package/docs/usage/compositions/streaming-answer.md +97 -0
  126. package/docs/usage/compositions/task-controls.md +93 -0
  127. package/docs/usage/compositions/toggle-feedback.md +84 -0
  128. package/docs/usage/compositions/transfer-task.md +86 -0
  129. package/docs/usage/compositions/web-additions.md +2 -2
  130. package/docs/usage/screens/common-shell.md +4 -0
  131. package/package.json +67 -1
@@ -125,3 +125,42 @@ import { Button } from "@hjmds/react-native/actions";
125
125
 
126
126
  2026-10-10 사용자 요청으로 선택 시 추가되는 테두리를 제거했다. `selected` 접근성 상태와
127
127
  contentBrand 글자·아이콘 색은 유지하며 키보드 focus outline은 선택과 별도로 유지한다.
128
+
129
+ ## 저장 확정 라벨과 프레임 예약 (npm 1.21.0 · 2026-10-15)
130
+
131
+ `completion={{ confirmed, label }}`는 실제 mutation의 확정 결과를 표시한다. 두 라벨의
132
+ 공간을 처음부터 예약하므로 `children`을 저장 중/완료 문구로 교체하지 않는다.
133
+ `confirmed`는 현재 초안과 마지막 서버 확정 값이 같은지 제품이 판단해 전달한다.
134
+ `loading`이 true이면 확정 라벨보다 중앙 로딩을 우선한다. 타이머로 완료로 바꾸지 않는다.
135
+ 버튼은 별도 live region을 만들지 않으므로 결과 안내가 필요하면 기존 결과 영역 하나로 알린다.
136
+
137
+ ```tsx
138
+ // Web
139
+ <Button loading={feedback.pending} disabled={!feedback.canSubmit}
140
+ completion={{ confirmed: feedback.currentSaved, label: t("saved") }} onClick={save}>
141
+ {t("save")}
142
+ </Button>
143
+ ```
144
+
145
+ ```tsx
146
+ // Native
147
+ <Button loading={feedback.pending} disabled={!feedback.canSubmit}
148
+ completion={{ confirmed: feedback.currentSaved, label: t("saved") }} onPress={save}>
149
+ {t("save")}
150
+ </Button>
151
+ ```
152
+
153
+ 이 옵션의 children은 문자열·숫자 또는 그것들만의 배열이어야 한다. 아이콘은 기존
154
+ leading/trailing을 쓴다. 임의 JSX를 숨겨 복제하면 내부 상태·포커스가 중복되므로 허용하지
155
+ 않는다. 옵션 없는 Button의 기존 JSX children 계약은 유지한다.
156
+ Web은 두 숨김 라벨을 같은 grid 영역에 놓아 두 라벨의 최대 고유 폭·높이를 예약한다.
157
+ Native는 두 숨김 라벨을 같은 flow에 항상 두어 Yoga의 최대 고유 폭을 사용하고,
158
+ 현재 recipe 행 높이×줄 상한을 초기 높이로 예약한다. 각 라벨의 native layout 높이가
159
+ 더 크면 최댓값을 반영한다. 따라서 짧은 라벨만 있어도 Native slot은 기존 줄 상한만큼
160
+ 공간을 예약할 수 있다. 폭·글꼴·라벨이 바뀌면 두 라벨을 다시 측정한다.
161
+ 상태만 바뀌는 동안에는 같은 라벨 쌍과 아이콘을 유지해야 프레임이 고정된다.
162
+
163
+ Button의 loading은 표시와 UI 재실행을 막는 기능이다. 같은 프레임의 중복 실행·늦은 응답
164
+ 무효화는 기존 action-session 또는 제품 mutation이 맡는다. 저장 실패 뒤에는 입력과
165
+ 마지막 확정 값을 유지한다. [버튼 완료 피드백](../compositions/action-feedback.md)의
166
+ `resolveSaveFeedback` 조합을 사용한다.
@@ -73,18 +73,20 @@ import { Calendar } from "@hjmds/react-native/calendar";
73
73
  | `composeAccessibleName` | `(info: { date: string; isToday: boolean; isSelected: boolean; disabled: boolean; content?: Content }) => string` | 필수 | 날짜 칸의 접근성 이름 |
74
74
  | `renderCellContent` | `(cell: ResolvedCalendarDateCell<Content>) => ReactNode` | — | `cell`은 셀 descriptor에 `date`·`row`·`column`·`isToday`·`isSelected`·`selectable`·`accessibleName`이 더해진 값. 결과는 접근성에서 숨는다 |
75
75
  | Web `onNavigateBeyondGrid` | `(detail: { date: string; intent: "next-day" \| "previous-day" \| "next-week" \| "previous-week" \| "first-of-week" \| "last-of-week"; overflow: "before" \| "after" }, focusDate: (date: string) => void) => void` | — | 방향키가 격자 밖으로 나갈 때. 제품이 다음 달 grid를 넘긴 뒤 `focusDate`를 부른다 |
76
- | `size` | `medium` · `large` | `medium` | — |
76
+ | `size` | `compact` · `medium` · `large` | `medium` | — |
77
77
  | `ref` | `{ focusDate(date: string): void }` | — | 날짜에 초점을 요청한다 |
78
78
  | Web `autoFocus` | `boolean` | `false` | — |
79
79
  | `layoutStyle` | margin·width·flex·`alignSelf` | — | 배치 전용. Native도 같은 배치 슬롯을 받는다 |
80
80
 
81
81
  ## 배치
82
82
 
83
+ 2026-10-14 야잘알 경기 달력에서 날짜 원이 승패 라벨보다 과하게 커 보인 사용자 피드백으로 `compact`를 추가했다. 날짜 표시는 28px 원으로 줄이고, 실제 터치 영역과 7열 최소 폭은 기존 44px 기준을 유지한다.
84
+
83
85
  Native `renderCellContent` 최소 높이는 Provider의 `tokens.typography.label.lineHeight × textScale`다. 날짜의 원형 geometry와 선택 계약은 별도다.
84
86
 
85
87
  | 항목 | 값 | 근거 |
86
88
  | --- | --- | --- |
87
- | 크기 | 날짜 칸 44(`medium` `control.minTouchTarget`, `large` `glyph.xxl` 44 — large는 글자만 `bodyLarge`로 커진다). 격자 최소 폭 7 × 44 = 308. 월 이동 버튼 44 원(`control.buttonHeight.medium`). 셀 `content`는 날짜 아래 label 한 줄 높이 | `calendarRecipe.sizes`·`header.navButton`, `.hjm-calendar__grid` |
89
+ | 크기 | 날짜 칸은 모든 size에서 최소 44, 선택·오늘 원은 `compact` 28 / 나머지 44(`medium` `control.minTouchTarget`, `large` `glyph.xxl` 44 — large는 글자만 `bodyLarge`로 커진다). 격자 최소 폭 7 × 44 = 308. 월 이동 버튼 44 원(`control.buttonHeight.medium`). 셀 `content`는 날짜 아래 label 한 줄 높이 | `calendarRecipe.sizes`·`header.navButton`, `.hjm-calendar__grid` |
88
90
  | 간격 | 머리↔격자, 머리 안 `spacing.xs` 8. 요일 줄 위아래 `spacing.xs` 8. 날짜↔`content` `spacing.xxs` 4. Web 격자 바깥 `spacing.xxs` 4 | `calendarRecipe.header.gap`, `.hjm-calendar*` |
89
91
  | 순서·정렬 | 위→아래 [이전 월][월 제목(가운데, title)][다음 월] → 요일 줄 → 주 단위 7열. 화면 본문에 인라인으로 두며 팝업·시트를 열지 않는다 | `.hjm-calendar__header`, `calendarRecipe` 주석(`shared`) |
90
92
  | 고정·스크롤 | 폭이 308보다 좁으면 격자가 가로 스크롤한다(Web `.hjm-calendar__viewport` `overflow-x: auto`, Native `ScrollView horizontal`) | `.hjm-calendar__viewport`, `react-native/src/calendar.tsx` |
@@ -0,0 +1,277 @@
1
+ # Chart
2
+
3
+ - 단계: 컴포넌트
4
+ - 상태: 배포
5
+ - 지원: Web · Native
6
+ - 적용: 1.21.0
7
+ - 검토일: 2026-10-15
8
+ - 근거: [Chart](../../chart.md), `src/chart.ts`(`chartRecipe`, `describeChart`, `resolveLineGeometry`), `src/dataviz.ts`(`resolveDatavizSeriesColor`, `datavizChromeTokens`)
9
+ - 스토리북: `배포/컴포넌트/데이터 표시/차트`
10
+
11
+ ## 언제 쓰나
12
+
13
+ 수치의 흐름·비교·몫·모양·관계를 그림으로 읽게 할 때 쓴다. 시간에 따른 흐름(기록·가격·순위)은 `LineChart`,
14
+ 범주끼리의 크기 비교는 `BarChart`, 전체 안의 몫은 `PieChart`, 여러 능력치의 모양은 `RadarChart`,
15
+ 두 수치의 관계는 `ScatterChart`, 목록 행·표 셀 안의 작은 추세는 `Sparkline`을 쓴다.
16
+ 여러 계열의 이름과 색을 차트 밖에 따로 보일 때 `ChartLegend`를 쓴다.
17
+
18
+ ## 쓰지 않을 때
19
+
20
+ | 상황 | 대신 쓸 것 |
21
+ | --- | --- |
22
+ | 숫자 지표 하나 또는 몇 개(현재 값·증감) | [Statistic](statistic.md) |
23
+ | 진행률·완료 비율 하나 | [Progress](progress.md) |
24
+ | 1년 단위 날짜별 활동 밀도 | [ActivityHeatmap](activity-heatmap.md) |
25
+ | 정확한 값을 읽고 정렬·비교해야 하는 기록 | [DataTable](data-table.md) |
26
+ | 계열이 8개(`datavizSeriesCount`)를 넘는 비교 | [DataTable](data-table.md) — 팔레트가 순환해 색으로 구분되지 않는다 |
27
+ | 성공·경고처럼 의미가 있는 상태 표시 | [Badge](badge.md)·[Tag](tag.md)·[Notice](notice.md) — 차트는 상태색을 쓰지 않는다 |
28
+ | 캔들스틱·지도·트리맵·이중 y축·확대/이동 | 제품 라이브러리 + `dataviz` 토큰([계약의 넣지 않은 것](../../chart.md#넣지-않은-것)) |
29
+
30
+ ## 공개 이름과 import
31
+
32
+ | 이름 | 역할 | Web | Native |
33
+ | --- | --- | --- | --- |
34
+ | `LineChart` | 기본 — 선·영역·쌓은 영역, 스크럽 | `@hjmds/react/chart` | `@hjmds/react-native/chart` |
35
+ | `BarChart` | 기본 — 세로·가로, 묶음·쌓기 막대 | `@hjmds/react/chart` | `@hjmds/react-native/chart` |
36
+ | `PieChart` | 기본 — 파이·도넛과 몫 범례 | `@hjmds/react/chart` | `@hjmds/react-native/chart` |
37
+ | `RadarChart` | 기본 — 공통 범위 레이더 | `@hjmds/react/chart` | `@hjmds/react-native/chart` |
38
+ | `ScatterChart` | 기본 — 두 수치의 산점도 | `@hjmds/react/chart` | `@hjmds/react-native/chart` |
39
+ | `Sparkline` | 동반 — 축 없는 작은 추세선 | `@hjmds/react/chart` | `@hjmds/react-native/chart` |
40
+ | `ChartLegend` | 보조 — 색 견본 + 이름(+ 값) 범례 행 | `@hjmds/react/chart` | `@hjmds/react-native/chart` |
41
+
42
+ 데이터 타입(`ChartSeries`·`ScatterDatum`)과 `describeChart`·`chartRecipe`는 `@hjmds/design-contracts/components/chart`에서,
43
+ 팔레트는 `@hjmds/design-contracts/dataviz`에서 가져온다.
44
+
45
+ ## 최소 사용 예
46
+
47
+ ```tsx
48
+ // Web
49
+ import { useState } from "react";
50
+ import { LineChart } from "@hjmds/react/chart";
51
+ import type { ChartSeries } from "@hjmds/design-contracts/components/chart";
52
+
53
+ const series: ChartSeries[] = [
54
+ { id: "avg", label: t("record.battingAverage"), values: seasons.map((season) => season.avg ?? null) },
55
+ ];
56
+ const [focus, setFocus] = useState<number | null>(null);
57
+ const shown = focus ?? seasons.length - 1;
58
+
59
+ <>
60
+ <p>{formatAverage(seasons[shown]?.avg ?? null)}</p>
61
+ <LineChart
62
+ categories={seasons.map((season) => season.label)}
63
+ series={series}
64
+ accessibilityLabel={t("record.battingAverageTrend")}
65
+ formatValue={formatAverage}
66
+ missingLabel={t("record.noRecord")}
67
+ showAxis
68
+ onFocusChange={(index) => setFocus(index)}
69
+ />
70
+ </>
71
+ ```
72
+
73
+ ```tsx
74
+ // Native
75
+ import { BarChart, Sparkline } from "@hjmds/react-native/chart";
76
+
77
+ <>
78
+ <BarChart
79
+ categories={months.map((month) => month.label)}
80
+ series={[{ id: "wins", label: t("team.wins"), values: months.map((month) => month.wins) }]}
81
+ accessibilityLabel={t("team.winsByMonth")}
82
+ formatValue={formatCount}
83
+ missingLabel={t("team.noGame")}
84
+ showValues
85
+ />
86
+ {/* 옆 텍스트가 값을 말하므로 이름 없이 장식으로 둔다 */}
87
+ <Sparkline values={lastTenGames} />
88
+ </>
89
+ ```
90
+
91
+ ## 축과 기본값
92
+
93
+ 모든 차트 공통: 보이는 문구와 숫자 서식은 제품이 준다(HJM 안에서 번역·서식하지 않는다).
94
+ `series[].values` 길이는 `categories` 길이와 같아야 하고 `null`은 빈 관측이다.
95
+
96
+ ### LineChart
97
+
98
+ | prop | 값 | 기본값 | 설명 |
99
+ | --- | --- | --- | --- |
100
+ | `categories` | `readonly string[]` | — (필수) | x축 범주(시간 순서) |
101
+ | `series` | `{ id, label, values: (number \| null)[], color? }[]` | — (필수) | `color`가 없으면 dataviz 팔레트 index 색 |
102
+ | `accessibilityLabel` | `string` | — (필수) | 차트 이름. Web은 `aria-label`로 나간다 |
103
+ | `formatValue` | `(value: number) => string` | — (필수) | 축 눈금·텍스트 대안·포커스 값 서식 |
104
+ | `missingLabel` | `string` | — (필수) | 텍스트 대안에서 빈 값을 읽는 문구 |
105
+ | `height` | `number` | `chartRecipe.heights.regular` 180 | 가로는 부모 폭을 채운다 |
106
+ | `curve` | `linear` · `monotone` · `step` | `linear` | `monotone`은 데이터 밖으로 넘지 않는 곡선 |
107
+ | `area` | `boolean` | 계열이 하나면 `true` | 선 아래 그라데이션(0.32 → 0) |
108
+ | `stacked` | `boolean` | `false` | 쌓은 영역. 0을 항상 포함 |
109
+ | `invert` | `boolean` | `false` | 작을수록 위(순위·평균자책점) |
110
+ | `domain` | `readonly [number, number]` | 관측 범위 | 값 범위 고정 |
111
+ | `includeZero` | `boolean` | `false` | 선 범위에 0을 포함 |
112
+ | `referenceValue` | `number` · `"first"` | — | 점선 가로 기준선(`"first"`는 첫 관측값) |
113
+ | `extremes` | `{ high: string; low: string }` | — | 포커스가 없을 때 첫 계열 최고·최저 표시와 라벨 |
114
+ | `showAxis` | `boolean` | `false` | 왼쪽 y 눈금 + plot 아래 첫·끝 범주 |
115
+ | `focusIndex` | `number \| null` | — | 제어 포커스 |
116
+ | `defaultFocusIndex` | `number \| null` | 마지막 관측 index | 비제어 시작 포커스 |
117
+ | `onFocusChange` | `(index: number \| null, detail: { scrubbing: boolean }) => void` | — | 스크럽 중 `scrubbing: true`, 손을 떼면 `false` |
118
+ | `cursorLabel` | `(index: number) => string` | `categories[index]` | 스크럽 중 커서 위 말풍선 |
119
+
120
+ ### BarChart
121
+
122
+ | prop | 값 | 기본값 | 설명 |
123
+ | --- | --- | --- | --- |
124
+ | `categories`·`series`·`accessibilityLabel`·`formatValue`·`missingLabel` | LineChart와 같음 | — (필수) | — |
125
+ | `height` | `number` | `chartRecipe.heights.regular` 180 | — |
126
+ | `orientation` | `vertical` · `horizontal` | `vertical` | 범주 이름이 길면 `horizontal` |
127
+ | `layout` | `grouped` · `stacked` | `grouped` | 범위는 항상 0을 포함 |
128
+ | `domain` | `readonly [number, number]` | 0을 포함한 관측 범위 | — |
129
+ | `showAxis` | `boolean` | `false` | — |
130
+ | `showValues` | `boolean` | `false` | 막대 끝 값 글자 |
131
+ | `focusIndex`·`defaultFocusIndex`·`onFocusChange` | LineChart와 같음 | — | 누름·끌기로 범주 선택, 나머지 막대 불투명도 `chartRecipe.bar.restOpacity` 0.4 |
132
+
133
+ ### PieChart
134
+
135
+ | prop | 값 | 기본값 | 설명 |
136
+ | --- | --- | --- | --- |
137
+ | `data` | `{ id, label, value: number \| null, color? }[]` | — (필수) | 0 이하·`null`은 조각에서 빠진다 |
138
+ | `accessibilityLabel` | `string` | — (필수) | — |
139
+ | `formatValue` | `(value: number) => string` | — (필수) | 범례 값 서식 |
140
+ | `donut` | `boolean` | `true` | 안쪽 반지름 비율 `chartRecipe.pie.donutRatio` 0.62 |
141
+ | `size` | `number` | 180 | 정사각 지름 |
142
+ | `centerContent` | `ReactNode` | — | 도넛 가운데(합계 등) |
143
+ | `selectedId` | `string \| null` | — | 제어 선택 |
144
+ | `defaultSelectedId` | `string \| null` | `null` | 비제어 선택 |
145
+ | `onSelect` | `(id: string \| null) => void` | — | 조각·범례 선택 |
146
+ | `showLegend` | `boolean` | `true` | 견본 + 이름 + 서식 값 + 백분율 행 |
147
+
148
+ ### RadarChart
149
+
150
+ | prop | 값 | 기본값 | 설명 |
151
+ | --- | --- | --- | --- |
152
+ | `categories` | `readonly string[]` (3개 이상) | — (필수) | 축 이름 |
153
+ | `series`·`accessibilityLabel`·`formatValue`·`missingLabel` | LineChart와 같음 | — (필수) | — |
154
+ | `domain` | `readonly [number, number]` | `[0, 모든 계열 최댓값]` | 모든 축이 같은 범위 |
155
+ | `levels` | `number` | `chartRecipe.radar.levels` 4 | 격자 고리 수 |
156
+ | `size` | `number` | 240 | 정사각 지름 |
157
+ | `showLegend` | `boolean` | 계열이 둘 이상이면 `true` | — |
158
+
159
+ ### ScatterChart
160
+
161
+ | prop | 값 | 기본값 | 설명 |
162
+ | --- | --- | --- | --- |
163
+ | `points` | `{ id, label, x, y, seriesIndex? }[]` | — (필수) | — |
164
+ | `accessibilityLabel` | `string` | — (필수) | — |
165
+ | `xLabel`·`yLabel` | `string` | — (필수) | 축 이름 |
166
+ | `formatX`·`formatY` | `(value: number) => string` | — (필수) | 축 눈금·포커스 값 서식 |
167
+ | `height` | `number` | `chartRecipe.heights.tall` 240 | — |
168
+ | `seriesLabels` | `readonly string[]` | — | `seriesIndex`별 범례 이름 |
169
+ | `focusId` | `string \| null` | — | 제어 포커스 |
170
+ | `onFocusChange` | `(id: string \| null) => void` | — | 가장 가까운 점(32 안) 포커스 |
171
+
172
+ ### Sparkline · ChartLegend
173
+
174
+ | prop | 값 | 기본값 | 설명 |
175
+ | --- | --- | --- | --- |
176
+ | `values`(`Sparkline`) | `readonly (number \| null)[]` | — (필수) | 빈 값은 끊김 |
177
+ | `color`(`Sparkline`) | `string` | 첫 dataviz 계열색 | — |
178
+ | `invert`(`Sparkline`) | `boolean` | `false` | 작을수록 위 |
179
+ | `domain`(`Sparkline`) | `readonly [number, number]` | 관측 범위 | 여러 행의 스파크라인을 같은 척도로 비교할 때 고정 |
180
+ | `width`·`height`(`Sparkline`) | `number` | 72 · 28 | `chartRecipe.sparkline` |
181
+ | `accessibilityLabel`(`Sparkline`) | `string` | — | 없으면 장식(보조 기술에서 숨김) |
182
+ | `items`(`ChartLegend`) | `{ id, label, color, value?: string }[]` | — (필수) | `value`는 서식이 끝난 문자열 |
183
+
184
+ ## 배치
185
+
186
+ | 항목 | 값 | 근거 |
187
+ | --- | --- | --- |
188
+ | 크기 | 선·막대·산점도는 부모 폭을 채우고 높이는 `chartRecipe.heights`(`compact` 128 · `regular` 180 · `tall` 240)에서 고른다. 파이 180, 레이더 240 정사각. Sparkline 72 × 28 | `src/chart.ts` `chartRecipe.heights`·`sparkline` |
189
+ | 간격 | plot 안쪽 여백: 축 없음 위 `spacing.lg` 20 · 나머지 `spacing.xs` 8, 축 있음 위 `spacing.md` 16 · 아래 `spacing.lg` 20 · 왼쪽 36(y 눈금 칸). 차트와 위 제목·현재 값, 아래 범례 사이는 부모 [Stack](stack.md)의 gap으로 준다 | `chartRecipe.insets` |
190
+ | 순서·정렬 | 위에서 제목([Section](section.md)·[Heading](heading.md)) → 현재 값 텍스트(제품, `onFocusChange`로 갱신) → 차트 → 범례. y 눈금은 왼쪽, 첫·끝 범주는 plot 아래 양끝 | [계약 스크럽 절](../../chart.md#스크럽linechart) |
191
+ | 고정·스크롤 | 차트는 스크롤하지 않는다. 가로로 끌면 스크럽, 세로로 끌면 페이지 스크롤이다. 가로 스크롤 컨테이너·[Carousel](carousel.md) 안에 `LineChart`를 두지 않는다(제스처 충돌) | 계약 스크럽 3단계 |
192
+ | 좁은 폭·큰 글자 | 높이는 `height`로 고정되고 가로만 줄어든다. 축 글자는 caption 변형(`chartRecipe.chrome.labelVariant`)이다. 범주가 많아도 축에는 첫·끝 범주만 나오므로 범주 이름 길이에 배치가 흔들리지 않는다. 범주 이름이 긴 막대는 `orientation="horizontal"` | `datavizChromeTokens` |
193
+
194
+ ```text
195
+ ┌ Section 제목 ───────────────────────────────┐
196
+ │ 0.312 (현재 값, 제품 텍스트) │
197
+ │ ┌ LineChart ─────────────────── height 180 ┐ │
198
+ │ │ .350┤ ╭─●╮ ┆ (커서) │ │
199
+ │ │ .300┤ ╭──╮ ╱ ╰───┆── │ │
200
+ │ │ .250┤─╯ ╰─╯ ┆ │ │
201
+ │ │ 2021 2025 │ │
202
+ │ └──────────────────────────────────────────┘ │
203
+ │ ● 타율 ● 출루율 (ChartLegend) │
204
+ └──────────────────────────────────────────────┘
205
+ ```
206
+
207
+ ## 꼭 지킬 것
208
+
209
+ - 모든 차트에 `accessibilityLabel`을 준다. 값의 텍스트 대안은 `formatValue`·`missingLabel`로 `describeChart`가 만든다.
210
+ 숫자 서식·빈 값 문구는 i18n과 제품 formatter로 공급한다.
211
+ - 빈 관측은 `null`로 넘긴다. 0으로 채우면 "기록 0"이 되고, 앞뒤 값을 이어 붙이면 없는 추세가 생긴다.
212
+ - 계열 색은 제품 `color` 또는 dataviz 팔레트만 쓴다. `success`·`warning` 같은 상태색을 계열색으로 쓰지 않는다.
213
+ - 순위·평균자책점처럼 작을수록 좋은 값은 `invert`로 그린다(데이터를 음수로 바꾸지 않는다).
214
+ - 막대·쌓기의 0 기준선을 `domain`으로 빼지 않는다. 막대 길이가 값의 크기다.
215
+ - 현재 값 텍스트는 차트 밖에 두고 `onFocusChange`로 갱신한다. 차트 안 툴팁에 제품 컴포넌트를 넣는 경로는 없다.
216
+ - 레이더는 한 척도의 값만 넣는다. 단위가 다른 축을 섞어야 하면 [DataTable](data-table.md)이나 막대 여러 개를 쓴다.
217
+
218
+ ## 플랫폼 차이
219
+
220
+ | 항목 | Web | Native |
221
+ | --- | --- | --- |
222
+ | 그리기 | SVG DOM | `react-native-svg` |
223
+ | 스크럽 입력 | pointer event + `touch-action: pan-y` | `PanResponder`, `\|dy\| > \|dx\|`이면 부모 스크롤에 응답권을 넘김 |
224
+ | 끌지 않는 탐색 | `role="slider"`, `aria-valuetext`, ArrowLeft/ArrowRight/Home/End | `accessibilityRole="adjustable"`, increment/decrement |
225
+ | 차트 이름 | `aria-label` | `accessibilityLabel` |
226
+
227
+ ## 함정
228
+
229
+ - `onFocusChange`는 손을 뗄 때도 불린다(`scrubbing: false`, 기본 포커스 index). 이 호출을 "사용자가 고른 값"으로 저장하면 스크럽이
230
+ 끝날 때마다 마지막 값으로 덮인다. `detail.scrubbing`으로 구분한다.
231
+ - 스크럽과 화살표 이동은 관측값이 있는 index만 지난다. 모든 계열이 `null`인 범주에는 포커스가 가지 않는다.
232
+ - 2026-10-14 작성 시점에 Web·Native renderer와 Storybook 항목은 구현 중이다. prop 표는 공유 사양 기준이며 renderer가 들어온 뒤
233
+ 실제 Props 타입과 다시 대조한다.
234
+
235
+ ## 도넛 범주를 실제로 제외하기
236
+
237
+ 범주를 강조하려면 selectedId/onSelect, 분모에서 빼려면 exclusion을 쓴다. 기존 강조 API의 의미는 바꾸지 않았다.
238
+
239
+ ```tsx
240
+ // Web — Native는 @hjmds/react-native/chart에서 같은 PieChart를 가져온다.
241
+ <PieChart data={data} accessibilityLabel={t("share")} formatValue={formatCount}
242
+ exclusion={{ excludedIds, onExcludedIdsChange: setExcludedIds,
243
+ label: (datum, included) => t(included ? "included" : "excluded", { name: datum.label }) }}
244
+ centerContent={summary => <span>{formatCount(summary.total)}</span>} />;
245
+ ```
246
+
247
+ Native centerContent에는 Text 등 Native 노드를 쓴다. 모두 제외하거나 합계 0이면 조각을 그리지 않는다.
248
+ 항목 ID·색 인덱스는 그대로 보존하고 실제 포함값으로 비율을 재계산한다. 차트 의미 전달에는 수치·checkbox 상태도 사용한다.
249
+
250
+ ## 데이터 전환과 스크럽 분할 강조
251
+
252
+ 실제 기간 필터 결과를 교체할 때 `LineChart`·`BarChart`에 `animateChanges`를 준다.
253
+ `LineChart`는 `scrubEmphasis="before"`(또는 `"after"`)로 포커스 한쪽만 계열 색으로
254
+ 강조할 수 있다. 축·접근성 값은 최신 데이터이며 전환 경로 자체는 관측값이 아니다.
255
+ 고주기 스트림에서는 기본값 false를 유지한다. 큰 자료(선 구간 양쪽 합계 1,024점 초과,
256
+ 막대 200개 초과)·reduced motion은 즉시 교체한다. 제어 포커스는 제품이 범주 변경 후
257
+ 새 index로 맞추고, 비제어 포커스는 같은 범주 레이블을 따라간다.
258
+
259
+ ```tsx
260
+ // Web: API가 돌려준 기간별 실제 데이터다.
261
+ <LineChart categories={period.labels} series={period.series}
262
+ accessibilityLabel={t("trend")} formatValue={formatValue} missingLabel={t("missing")}
263
+ animateChanges scrubEmphasis="before" holdOnRelease />
264
+ <BarChart categories={comparison.labels} series={comparison.series}
265
+ accessibilityLabel={t("comparison")} formatValue={formatValue} missingLabel={t("missing")}
266
+ animateChanges showAxis />
267
+ ```
268
+
269
+ ```tsx
270
+ // Native: 같은 데이터와 옵션을 @hjmds/react-native/chart에 전달한다.
271
+ <LineChart categories={period.labels} series={period.series}
272
+ accessibilityLabel={t("trend")} formatValue={formatValue} missingLabel={t("missing")}
273
+ animateChanges scrubEmphasis="before" holdOnRelease />
274
+ <BarChart categories={comparison.labels} series={comparison.series}
275
+ accessibilityLabel={t("comparison")} formatValue={formatValue} missingLabel={t("missing")}
276
+ animateChanges showAxis />
277
+ ```
@@ -2,34 +2,42 @@
2
2
 
3
3
  - 단계: 컴포넌트
4
4
  - 상태: 배포
5
- - 지원: Web
5
+ - 지원: Web · Native
6
6
  - 적용: 1.12.1
7
- - 검토일: 2026-10-06
8
- - 근거: [DataTable](../../data-table.md), `src/data-table.ts`(`dataTableRecipe`, `getNextDataTableSortState`)
9
- - 스토리북: `배포/컴포넌트/데이터 표시/데이터 표`
7
+ - 검토일: 2026-10-14
8
+ - 근거: [DataTable](../../data-table.md), `src/data-table.ts`(`dataTableRecipe`, `getNextDataTableSortState`, `sortDataTableRows`, `splitPinnedDataTableColumns`, `resolveDataTableHeaderBands`, `resolveDataTableBody`, `percentileRank`, `percentileHeat`, `dataTableFamilyRecipe`, `resolveLineScore`, `resolveMatrix`, `resolveComparisonRow`)
9
+ - 스토리북: `배포/컴포넌트/데이터 표시/데이터 표`, `배포/컴포넌트/데이터 표시/라인스코어`, `배포/컴포넌트/데이터 표시/대전표`, `배포/컴포넌트/데이터 표시/비교 표`
10
10
 
11
11
  ## 언제 쓰나
12
12
 
13
- 여러 행의 데이터를 열로 맞춰 훑고, 열 기준으로 정렬하거나 행을 골라 일괄 작업할 때 쓴다(Web).
14
- 정렬·필터 실행, 페이지 나누기는 제품이 소유한다. DataTable은 다음 정렬 상태와 선택 상태만 판정한다.
15
- 선택·정렬이 없는 단순 표는 companion `Table`을 쓴다.
13
+ 여러 행의 데이터를 열로 맞춰 훑고, 열 기준으로 정렬하거나 행을 골라 일괄 작업할 때 쓴다.
14
+ DataTable은 다음 정렬 상태와 선택 상태만 판정하고 행을 재배열하지 않는다. 로컬 배열 정렬은 계약의 선택형 helper
15
+ `sortDataTableRows`로, 서버 정렬·필터·페이지 나누기는 제품이 한다. 선택·정렬이 없는 단순 Web 표는 companion `Table`을 쓴다.
16
+ Native renderer(기록 표: 고정 열 + 가로 스크롤)는 2026-10-14 추가분이며 미게시(1.20.0 이후)다.
16
17
 
17
18
  ## 쓰지 않을 때
18
19
 
19
20
  | 상황 | 대신 쓸 것 |
20
21
  | --- | --- |
21
22
  | 한 대상의 속성(이름–값) 나열 | [DescriptionList](description-list.md) |
22
- | 행 하나가 하나의 항목인 목록, 모바일 화면 | [List](list.md), [ListRow](list-row.md) |
23
+ | 행 하나가 하나의 항목인 목록(열 비교가 아님) | [List](list.md), [ListRow](list-row.md) |
24
+ | 값의 흐름·비교를 그림으로 읽기 | [Chart](chart.md) |
23
25
  | 아주 긴 목록의 가상 스크롤 | [VirtualList](virtual-list.md) |
24
26
  | 행 확장(상세 펼침) | [Accordion](accordion.md)·[Collapsible](collapsible.md)을 행 안에 합성 |
25
- | Native 화면 | Native renderer 없음 |
27
+ | Native에서 여러 행 체크 선택·일괄 작업 | Native는 행 하나 강조(`onRowPress`·`selectedRowId`)만 있다. [List](list.md)의 선택을 쓴다 |
26
28
 
27
29
  ## 공개 이름과 import
28
30
 
29
31
  | 이름 | 역할 | Web | Native |
30
32
  | --- | --- | --- | --- |
31
- | `DataTable` | 기본 — 정렬·행 선택·비동기 상태를 갖는 표 | `@hjmds/react`, `/data-table` | 없음 |
33
+ | `DataTable` | 기본 — 정렬·행 선택·비동기 상태를 갖는 표 | `@hjmds/react`, `/data-table` | `@hjmds/react-native/data-table` |
32
34
  | `Table` | 동반 — 행 객체와 `cell` 렌더러를 받는 단순 표 | `@hjmds/react`, `/display` | 없음 |
35
+ | `LineScore` | 동반 — 회별 점수 + 합계 열(9회 채움·연장 확장·진행 중 회 강조) | `@hjmds/react/data-table` | `@hjmds/react-native/data-table` |
36
+ | `MatrixTable` | 동반 — 같은 id 목록의 행×열 교차표(상대 전적), 대각선은 빈 칸 | `@hjmds/react/data-table` | `@hjmds/react-native/data-table` |
37
+ | `ComparisonTable` | 동반 — 두 대상의 지표를 행마다 값 + 막대로 비교 | `@hjmds/react/data-table` | `@hjmds/react-native/data-table` |
38
+
39
+ 정렬 helper(`getNextDataTableSortState`·`dataTableFirstSortDirection`·`toDataTableSortValue`·`compareDataTableValues`·
40
+ `sortDataTableRows`·`splitPinnedDataTableColumns`)는 `@hjmds/design-contracts/components/data-table`에서 가져온다.
33
41
 
34
42
  ## 최소 사용 예
35
43
 
@@ -59,26 +67,67 @@ import { DataTable } from "@hjmds/react/data-table";
59
67
  />
60
68
  ```
61
69
 
62
- Native: 없음. Native renderer가 없다.
70
+ ```tsx
71
+ // Native
72
+ import { useState } from "react";
73
+ import { DataTable } from "@hjmds/react-native/data-table";
74
+ import {
75
+ sortDataTableRows,
76
+ toDataTableSortValue,
77
+ type DataTableSortState,
78
+ } from "@hjmds/design-contracts/components/data-table";
79
+
80
+ const columns = [
81
+ { id: "name", header: t("record.col.player"), pinned: "start" as const },
82
+ { id: "avg", header: t("record.col.avg"), align: "end" as const, sortable: true, sortDescFirst: true },
83
+ { id: "era", header: t("record.col.era"), align: "end" as const, sortable: true },
84
+ ];
85
+ const [sort, setSort] = useState<DataTableSortState<string>>({ columnId: "avg", direction: "descending" });
86
+ // 표시 문자열("0.340", "—")에서 정렬 키를 만들고, 빈 값은 방향과 상관없이 마지막이다.
87
+ const sorted = sortDataTableRows(players, sort, (player, columnId) => toDataTableSortValue(player.cells[columnId]));
88
+
89
+ <DataTable
90
+ columns={columns}
91
+ rows={sorted.map((player) => ({ id: player.id }))}
92
+ renderCell={(rowId, columnId) => cellText(rowId, columnId)}
93
+ labels={{ table: t("record.table"), sortColumn: (header) => t("record.sortBy", { header }) }}
94
+ sortState={sort}
95
+ onSortChange={setSort}
96
+ onRowPress={(rowId) => openPlayer(rowId)}
97
+ summaryRow={(columnId) => totalText(columnId)}
98
+ />
99
+ ```
63
100
 
64
101
  ## 축과 기본값
65
102
 
66
103
  | prop | 값 | 기본값 | 설명 |
67
104
  | --- | --- | --- | --- |
68
- | `columns` | `{ id, header: string, align?, sortable?, width? }[]` | — (필수, 하나 이상) | `id`는 비어 있지 않고 중복 없음 |
105
+ | `columns` | `{ id, header: string, align?, sortable?, width?, sortDescFirst?, pinned? }[]` | — (필수, 하나 이상) | `id`는 비어 있지 않고 중복 없음 |
69
106
  | `rows` | `{ id, disabled? }[]` | — (필수) | 셀 내용은 `renderCell`이 그린다 |
70
107
  | `renderCell` | `(rowId: RowKey, columnId: ColumnKey) => ReactNode` | — (필수) | 행·열 id로 셀을 그린다 |
71
- | `labels` | `{ table: string; selectAll: string; selectRow: (rowId: string) => string; sortColumn: (header: string, direction: DataTableSortState) => string }` | — (필수) | 모든 문구는 i18n 키 |
108
+ | `labels` | `{ table: string; selectAll: string; selectRow: (rowId: string) => string; sortColumn: (header: string, direction: DataTableSortState) => string }` | — (필수) | 모든 문구는 i18n 키. Native는 `table`·`sortColumn`만 받는다 |
72
109
  | `sortState` | `{ columnId, direction: "ascending" \| "descending" } \| null` | `null` | 제어 전용(내부 상태 없음) |
73
110
  | `onSortChange` | `(next: DataTableSortState<ColumnKey>) => void` | — | 다음 정렬 상태(`null` = 정렬 없음)를 알린다. 재정렬은 제품이 한다 |
74
- | `sortCycle` | `three-state` · `two-state` | `three-state` | `three-state`는 오름차순 → 내림차순 → 정렬 없음 |
75
- | `selection` | `{ mode: "none" }` · `{ mode: "single", selectedKey: Key \| null, onSelectionChange: (key: Key \| null) => void, disallowEmptySelection? }` · `{ mode: "multiple", selectedKeys: ReadonlySet<Key>, onSelectionChange: (keys: ReadonlySet<Key>) => void }` | 없음(선택 열 없음) | 제어(`selectedKey(s)`) 대신 `defaultSelectedKey(s)`를 주면 비제어로 내부 상태가 유지된다 |
111
+ | `sortCycle` | `three-state` · `two-state` | `three-state` | `three-state`는 첫 방향 → 반대 방향 → 정렬 없음. 첫 방향은 열 `sortDescFirst`가 정한다(기본 오름차순) |
112
+ | `selection` | `{ mode: "none" }` · `{ mode: "single", selectedKey: Key \| null, onSelectionChange: (key: Key \| null) => void, disallowEmptySelection? }` · `{ mode: "multiple", selectedKeys: ReadonlySet<Key>, onSelectionChange: (keys: ReadonlySet<Key>) => void }` | 없음(선택 열 없음) | Web만. 제어(`selectedKey(s)`) 대신 `defaultSelectedKey(s)`를 주면 비제어로 내부 상태가 유지된다 |
76
113
  | `asyncState` | `{ status: "idle" }` · `{ status: "loading" \| "loadingMore" \| "empty" \| "error", message: string }` | `{ status: "idle" }` | `error`는 `role="alert"`, 나머지는 `role="status"`로 표 위에 나온다 |
77
114
  | `density` | `regular` · `compact` | Provider 밀도 | 지정하지 않으면 Provider 밀도를 따른다(`comfortable` → `regular`) |
78
115
  | 열 `align` | `start` · `center` · `end` | `start` | 머리 칸과 본문 칸 모두에 적용 |
79
116
  | 열 `sortable` | `boolean` | `false` | — |
80
117
  | 열 `width` | 양수 px | — | 힌트 |
118
+ | 열 `sortDescFirst` | `boolean` | `false` | 첫 누름이 내림차순(높은 값 먼저). 낮을수록 좋은 열(평균자책점·순위)은 끈다. 미게시(1.20.0 이후) |
119
+ | 열 `pinned` | `"start"` | — | 가로 스크롤 중에도 보이는 열. 시작 쪽에서 끊기지 않고 이어진 열만 고정된다. 미게시(1.20.0 이후) |
120
+ | `summaryRow` | `(columnId: ColumnKey) => ReactNode` | — | 표 끝 합계·평균 행(Web `tfoot`). 값 계산·문구는 제품. 미게시(1.20.0 이후) |
121
+ | `onRowPress`(Native) | `(rowId: RowKey) => void` | — | 행 누름. 미게시(1.20.0 이후) |
122
+ | `selectedRowId`(Native) | `RowKey \| null` | — | 강조할 행 하나. 미게시(1.20.0 이후) |
123
+ | `emptyLabel`(Native) | `string` | — | 본문 상태 문구(제품 문구) |
81
124
  | `footer` | `ReactNode` | — | 표 아래 합성 영역 |
125
+ | `columnGroups` | `{ id, header: string, columns: ColumnKey[] }[]` | — | 잎 머리글 위 묶음 머리글(타격 \| 주루). 묶음 열은 이어져야 하고, 묶이지 않은 열은 빈 칸. Web은 `scope="colgroup"`. 미게시(1.20.0 이후) |
126
+ | `rowGroups` | `{ id, label: string, rows: RowKey[] }[]` | — | 행 묶음(선발 / 교체). 묶음마다 이름 행이 먼저 나오고, 묶이지 않은 행은 맨 앞. Web은 묶음마다 `tbody` + `scope="rowgroup"`. 미게시(1.20.0 이후) |
127
+ | 행 `depth` | `0`–`3` | `0` | 하위 행 들여쓰기. 첫 열에 `dataTableFamilyRecipe.depthIndent`(16) × depth |
128
+ | `cellStyle` | `(rowId, columnId) => { tone?, heat? } \| null` | — | 셀 강조. `tone`: `leader` · `positive` · `negative` · `muted`, `heat`: `{ side: "high" \| "low", strength: 1 \| 2 \| 3 }`(백분위 색). 판정은 제품(`percentileRank`·`percentileHeat`). 미게시(1.20.0 이후) |
129
+ | `heatColors` | `{ high: string; low: string }` | 높음 `danger` 빨강 · 낮음 `info` 파랑 | heat 기준색. 칠할 때 `heatAlpha`(0.14·0.26·0.42)를 곱한다 |
130
+ | `maxBodyHeight` | 양수 px | — | 높이 제한. 머리글은 고정되고 본문만 세로 스크롤 |
82
131
  | `layoutStyle` | 배치 전용 style(여백·폭·grid 위치) | — | 루트 wrapper에 붙는다. 시각 키는 받지 않는다 |
83
132
 
84
133
  ## 배치
@@ -88,8 +137,8 @@ Native: 없음. Native renderer가 없다.
88
137
  | 크기 | 본문 폭을 채운다(표 `inline-size: 100%`). 선택 열 폭 `control.minTouchTarget` 44, 정렬 버튼·체크 상자 터치 영역 44 | `packages/react/src/styles.css` `.hjm-data-table*`, `src/data-table.ts` |
89
138
  | 간격 | 루트 세로 간격 `spacing.sm` 12. 셀 여백(`dataTableRecipe.density`): `regular` 세로 `spacing.sm` 12 · 가로 `spacing.md` 16, `compact` 세로 `spacing.xs` 8 · 가로 `spacing.sm` 12. `footer` 간격 `spacing.sm` 12 | `packages/react/src/styles.css` |
90
139
  | 순서·정렬 | 위에서 [상태 메시지] → 표 → [footer]. 선택 열은 맨 앞. 숫자·금액 열은 `align: "end"`로 끝 정렬하고 머리 행 정렬도 열 `align`을 따른다. 셀은 위 정렬. `footer`는 한 줄 flex(`space-between`, 넘치면 줄바꿈)이며 요약을 앞에, [Pagination](pagination.md)을 끝에 둔다 | `packages/react/src/data-table.tsx` |
91
- | 고정·스크롤 | 가로 스크롤 컨테이너는 그리지 않는다. 긴 글자는 줄바꿈한다 | `packages/react/src/styles.css` |
92
- | 좁은 폭·큰 글자 | 좁은 폭(모바일)에서 열이 많으면 표를 줄이지 말고 [List](list.md)로 바꾼다 | — |
140
+ | 고정·스크롤 | `maxBodyHeight`를 주면 Web은 스크롤 상자(`max-block-size`, `overflow: auto`) 안에서 `thead`를 `sticky`로, Native는 머리글을 본문 밖에 두고 본문만 세로 `ScrollView`에 넣는다. 그 밖의 Web 표는 가로 스크롤 컨테이너를 그리지 않고 긴 글자를 줄바꿈한다. `pinned: "start"` 열은 `position: sticky`로 시작 쪽에 붙는다. Native는 고정 열을 제자리에 두고 나머지 열을 가로 `ScrollView`에 넣으며 스크롤 가장자리에 흐림을 둔다 | `packages/react/src/styles.css`, `react-native/src/data-table.tsx`, `splitPinnedDataTableColumns` |
141
+ | 좁은 폭·큰 글자 | 열 비교가 목적인 기록 표는 열을 빼지 말고 이름 열을 `pinned: "start"`로 고정해 가로 스크롤로 보존한다. 행 하나가 하나의 항목이면 [List](list.md)로 바꾼다 | [계약](../../data-table.md#정렬-helper와-native-renderer--2026-10-14) |
93
142
 
94
143
  ```text
95
144
  ┌ DataTable ───────────────────────────────────────┐
@@ -106,9 +155,28 @@ Native: 없음. Native renderer가 없다.
106
155
  - 열 `id`·행 `id`는 비어 있지 않고 중복이 없어야 한다. 열은 하나 이상. 어기면 렌더 중 `TypeError`가 난다.
107
156
  - `sortState`는 `sortable: true`인 열만 가리킨다. 아니면 `TypeError`.
108
157
  - `labels`의 모든 문구는 i18n 키로 만든다. `selectRow`는 행 `id`를 받으므로 사람이 읽을 이름으로 바꿔 돌려준다.
109
- - 받은 `onSortChange` 값으로 행을 재정렬하는 일은 제품이 한다(로컬 배열이든 서버 쿼리든).
158
+ - 받은 `onSortChange` 값으로 행을 재정렬하는 일은 제품이 한다. 로컬 배열은 `sortDataTableRows`를 쓰고 같은 규칙(숫자 해석·빈 값
159
+ 마지막·안정 정렬)을 제품 코드에 다시 만들지 않는다. 서버 정렬은 쿼리로 넘긴다.
160
+ - `sortDescFirst` 열은 제품이 `onSortChange` 밖에서 첫 상태를 만들 때도 `dataTableFirstSortDirection(column)`을 쓴다.
110
161
  - 페이지네이션·더 보기는 `footer`에 [Pagination](pagination.md)·[LoadMore](load-more.md)로 합성한다.
111
162
  - 배치는 `layoutStyle`, 시각 override는 `className`으로만 한다. 행 hover·선택 색을 덮지 않는다.
163
+ - 백분위 색은 같은 열 안의 비교다. `percentileRank(value, 열 전체 값, { lowerIsBetter })`로 열마다 따로 구하고, 낮을수록 좋은
164
+ 지표(평균자책·도루자)는 `lowerIsBetter`를 켠다. 색만으로 뜻을 전하지 않는다 — 셀 글자는 그대로 값이다.
165
+ - `LineScore`·`MatrixTable`·`ComparisonTable`은 `accessibilityLabel`(Web은 `aria-label`)이 필수다. 문구는 i18n 키로 만든다.
166
+
167
+ ## 플랫폼 차이
168
+
169
+ | 항목 | Web | Native |
170
+ | --- | --- | --- |
171
+ | 행 선택 | `selection`(checkbox·radio 열, 전체 선택 tri-state) | `onRowPress` + `selectedRowId` 행 하나 강조. 선택 열 없음 |
172
+ | 넓은 표 | 표가 폭을 채우고 긴 글자는 줄바꿈, `pinned` 열은 sticky | `pinned` 열 고정 + 나머지 가로 `ScrollView`, 가장자리 흐림 |
173
+ | 요약 행 | `summaryRow` → `tfoot` | `summaryRow` → 본문 아래 행 |
174
+ | 접근성 | `role="table"`, 머리 칸 안 정렬 버튼 + `aria-sort` | 정렬 머리 칸 `button`, 머리 행 `header`, 선택 행 `selected` |
175
+ | companion | `Table` | 없음 |
176
+ | 묶음 머리글·행 묶음 | 머리 행 하나 더(`colgroup`) · 묶음마다 `tbody` + `rowgroup` 머리 칸 | 머리 띠 · 묶음 이름 행(`header`) |
177
+ | 높이 제한 | 스크롤 상자 + `thead` sticky | 머리글 분리 + 본문 세로 `ScrollView` |
178
+ | `LineScore`·`MatrixTable` | `<table>`, 진행 중 회 머리 칸 `aria-current="true"`, 대각선 칸 `aria-hidden` | 팀 행마다 합계를 읽는 접근성 이름, 셀 단위 이름 |
179
+ | `ComparisonTable` | `section` + `role="list"`, 행마다 화면 읽기 문장 하나 | 행마다 접근성 이름 하나 |
112
180
 
113
181
  ## 함정
114
182
 
@@ -120,3 +188,13 @@ Native: 없음. Native renderer가 없다.
120
188
  따로 필요하면 [EmptyState](empty-state.md)로 표를 대신 그린다.
121
189
  - `Table`의 `onSortChange`는 `null`을 받지 못해 항상 two-state로 돈다. `emptyState`는 필수 prop이다.
122
190
  - 1.13.0 변경: 1.12.1까지 Web `regular` 셀은 사방 `spacing.sm` 12였다. 열 폭을 그 값으로 맞춘 화면은 가로 16에서 다시 확인한다.
191
+ - `pinned: "start"`는 맨 앞부터 이어진 열에만 적용된다. 가운데 열에 주면 무시된다(`splitPinnedDataTableColumns`).
192
+ - `toDataTableSortValue`는 글자가 둘 이상이면 문자열로 본다. "7승0무3패"는 문자열 정렬이 되므로 승수로 정렬하려면
193
+ `getValue`에서 숫자를 직접 돌려준다.
194
+ - 2026-10-14 작성 시점에 Native renderer와 Native Storybook 등록은 구현 중이다. Native 행은 renderer가 들어온 뒤 실제 Props와 다시 대조한다.
195
+ - `cellStyle`은 셀마다 호출된다. 백분위 표본(열 전체 값)은 함수 밖에서 한 번 만들어 두고 셀에서는 조회만 한다.
196
+ - `columnGroups`의 묶음은 고정 열 경계를 넘지 않게 나눈다. Web은 묶음 안 열이 모두 고정일 때만 묶음 머리 칸도 고정된다.
197
+
198
+ ### ComparisonTable 중복 머리글
199
+
200
+ 2026-10-14 야잘알 비교 화면에서 상단 선택 카드의 선수·시즌이 표에 반복되어 읽는 흐름을 끊었다. Web/Native의 `showSideNames={false}`로 시각 머리글만 숨긴다(기본 `true`). `sides`는 계속 전달하며 각 행의 접근성 이름에는 양쪽 이름과 수치를 보존한다. 상단에서 양쪽 대상을 식별할 수 있을 때만 사용한다. 행 여백과 막대 간격을 넓히고 지표·비우세 수치의 대비를 유지해 기록을 구분한다.
@@ -4,7 +4,7 @@
4
4
  - 상태: 배포
5
5
  - 지원: Web · Native
6
6
  - 적용: 1.17.1
7
- - 검토일: 2026-10-13
7
+ - 검토일: 2026-10-14
8
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
9
  - 스토리북: `배포/화면/화면 틀과 도구/화면 골격과 상태`
10
10
 
@@ -97,6 +97,8 @@ import { ScreenLayout } from "@hjmds/react-native/screens";
97
97
 
98
98
  ## 꼭 지킬 것
99
99
 
100
+ - Native에서 `headerAlignment="center"` + `titleVisibility="hidden"`이고 설명·사용자 지정 헤더가 없으면 아이콘 헤더는 최소 48pt, 세로 여백 2pt다. 2026-10-14 야잘알의 상단 버튼–탭 사이 빈 공간 피드백으로 제목용 60pt 높이를 줄였다. `notice`가 없을 때만 본문 상단 여백을 최대 4pt로 줄이며, `notice`에 둔 탭과 본문 카테고리 사이 기본 16pt 간격은 유지한다. 좌우 여백·하단 여백·터치 영역은 그대로다.
101
+
100
102
  - Web의 화면 스크롤 본문은 Tab으로 진입할 수 있다. `scroll="content"`의 정상 본문은 자식이 키보드 스크롤을 소유하지만, 오류·빈 상태 등 대체 안내는 화면 스크롤로 바뀌며 Tab 진입도 함께 복원된다.
101
103
  - Web에서 본문에 초점을 가진 입력·재시도 버튼이 상태 전환으로 제거되면 같은 본문으로 초점을 복구한다. `scroll="content"`의 본문은 이 복구만을 위한 `tabIndex=-1`을 가지며 일반 Tab 순서에는 추가되지 않는다. 사용자가 다른 컨트롤이나 바깥 영역으로 초점을 옮긴 경우에는 이동시키지 않는다. 2026-10-13 재시도·권한 제한 전환에서 초점이 body로 유실된 경로를 수정했다.
102
104
  - 가상화 목록(FlatList, VirtualList)을 넣으면 `scroll="content"`로 둔다. 스크롤을 이중으로 중첩하지 않는다.
@@ -154,6 +154,7 @@ import { SearchScreen } from "@hjmds/react-native/screen-flows";
154
154
  | `suggestedQueries` | `{ title, items: string[] }` | 없음 | 검색 전(최근 검색 아래)과 검색어 때문인 0건 아래 Chip 줄. 누르면 확정 |
155
155
  | `suggestions` | `{ items: { query, match?: { start, end } }[], commitLabel(query), countLabel(count), icon?, maxVisible? }` | 없음 | 입력 중 본문: 첫 행 `commitLabel`(그대로 확정) + 제안 최대 6행. `match` 범위는 굵게(Web). 개수를 `countLabel`로 낭독 |
156
156
  | `resultSummary` | `{ count: number \| null, countLabel(count), loadingLabel, sort?, notice?, empty?: { title, description? } }` | 없음 | 결과 머리. `count = null`이면 개수 자리 Skeleton + `children` 대신 로딩 4행. `notice`는 개수 위(이전 결과 유지 오류). `count = 0`이면 개수 대신 `empty`로 EmptyState |
157
+ | `resultSummary.countVisibility` | `visible` · `hidden` | `visible` | `hidden`은 시각적 개수·개수 Skeleton만 숨기며 정렬·로딩 행·빈 상태·접근성 개수 알림은 유지한다. 2026-10-14 야잘알 검색 결과에서 개수 없는 목록을 요청해 추가한 선택형 축(미게시)이다 |
157
158
  | `resultSummary.sort` | `{ label, triggerLabel, value, options: { id, label }[], onChange(id), icon? }` (Native는 `dismissLabel` 추가) | 없음 | 결과 머리 끝 Menu(single). 바꾸면 본문을 맨 위로 |
158
159
  | `appliedFilters` | `{ items: { key, label }[], removeLabel(label), onRemove(key), clearAllLabel, onClearAll(), removeIcon? }` | 없음 | 결과 머리 아래 × 칩 + `모두 해제`. 개수는 trigger 이름과 0건 원인 판정에도 쓴다 |
159
160
  | `filterSheet` | `{ open, onOpenChange(open), title, value, onApply(next), count(draft): number \| null, reset(draft), isDefault(draft), renderContent(draft, setDraft), labels: { close, reset, apply(count) }, size?, trigger? }` | 없음 | 열 때 `value`를 초안으로 복사, 닫히면 초안을 버림, 주 행동에서만 `onApply`. `count = 0`이면 주 행동 비활성, `isDefault`면 초기화 비활성. `trigger`를 주면 칩 줄 맨 앞에 `label(적용 개수)` Chip |
@@ -141,3 +141,28 @@ import { Tabs } from "@hjmds/react-native/navigation";
141
141
  - Native `Tabs`는 예전 `options` prop을 받으면 `TypeError`("options was removed")를 던진다. `items`로 옮긴다.
142
142
  - 외부 `TabPanel`을 쓸 때 Tabs에 `id`를 주지 않으면 Web은 생성 id를 써서 `tabsId`를 맞출 수 없다.
143
143
  `id`를 명시하고 같은 값을 `TabPanel tabsId`에 넘긴다.
144
+
145
+ ## 가로 페이저와 연속 밑줄 연결 (npm 1.21.0)
146
+
147
+ 실제 페이저의 소수 index를 `swipePosition`에 발행한다. 별도 state 갱신 없이 기존
148
+ 측정된 탭 밑줄만 따라간다. 첫 항목은 0, 두 번째로 절반 이동하면 0.5다. 패널 선택은
149
+ 페이지가 정착했을 때 `onValueChange`와 동일한 제품 상태로 확정하고, 탭 활성화에서는
150
+ 제품 페이저를 해당 index로 이동한다. 이 옵션은 페이지 제스처를 가로채지 않는다.
151
+ 세로 Tabs는 무시한다. source의 `subscribe`는 반드시 unsubscribe 함수를 반환한다.
152
+
153
+ ```tsx
154
+ // Web
155
+ <Tabs label={t("pages")} items={items} value={selected} onValueChange={selectAndScrollPager}
156
+ swipePosition={{ get: () => pager.index, subscribe: listener => pager.onPosition(listener) }}
157
+ renderPanels={false} />
158
+ ```
159
+
160
+ ```tsx
161
+ // Native: pager.onScroll/position 이벤트를 같은 source에 연결한다.
162
+ <Tabs label={t("pages")} items={items} value={selected} onValueChange={selectAndScrollPager}
163
+ swipePosition={{ get: () => pager.index, subscribe: listener => pager.onPosition(listener) }}
164
+ renderPanels={false} />
165
+ ```
166
+
167
+ source 객체는 렌더마다 바꾸지 않도록 `useMemo` 또는 제품 어댑터의 안정 인스턴스를
168
+ 사용한다. source는 시각적 위치만 전달하므로 접근성·키보드 선택을 대신하지 않는다.
@@ -6,7 +6,7 @@
6
6
  - 적용: 1.12.1
7
7
  - 검토일: 2026-10-13
8
8
  - 근거: [Toast](../../toast.md), [Liquid Toast](../../../../react-native/docs/liquid-toast.md), `src/component-recipes.ts`(`toastRecipe`)
9
- - 스토리북: `배포/컴포넌트/상태와 알림/토스트`, `배포/컴포넌트/상태와 알림/리퀴드 토스트`, `실험/구성/피드백과 복구/백그라운드 작업 상태`
9
+ - 스토리북: `배포/컴포넌트/상태와 알림/토스트`, `배포/컴포넌트/상태와 알림/리퀴드 토스트`, `배포/구성/피드백과 복구/백그라운드 작업 상태`
10
10
 
11
11
  ## 언제 쓰나
12
12