@ihabdevteam/core 0.321.0 → 0.323.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 (217) hide show
  1. package/CHANGELOG.md +617 -0
  2. package/dist/components.cjs.js +183 -183
  3. package/dist/components.esm.css +1 -1
  4. package/dist/hooks.cjs.js +3 -3
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +187 -187
  7. package/dist/ihabdevteam-core.d.ts +108 -66
  8. package/dist/packages/core/src/components/Accordion.js +3 -3
  9. package/dist/packages/core/src/components/Alert.js +2 -2
  10. package/dist/packages/core/src/components/BarChart.js +120 -119
  11. package/dist/packages/core/src/components/BottomNav.js +18 -18
  12. package/dist/packages/core/src/components/BulkBar.js +25 -25
  13. package/dist/packages/core/src/components/Button.js +16 -16
  14. package/dist/packages/core/src/components/Calendar.js +17 -17
  15. package/dist/packages/core/src/components/ChartSection.js +30 -30
  16. package/dist/packages/core/src/components/CountTile.js +38 -38
  17. package/dist/packages/core/src/components/DatePicker.js +59 -59
  18. package/dist/packages/core/src/components/Dialog.js +14 -14
  19. package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +8 -8
  20. package/dist/packages/core/src/components/FilterPanel.js +6 -6
  21. package/dist/packages/core/src/components/FilterSectionInfo.js +6 -6
  22. package/dist/packages/core/src/components/GameIntro.js +1 -1
  23. package/dist/packages/core/src/components/HearingDeviceInfo.d.ts +5 -5
  24. package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
  25. package/dist/packages/core/src/components/Icon.js +2 -2
  26. package/dist/packages/core/src/components/ItemActions.js +16 -16
  27. package/dist/packages/core/src/components/LandingHeader.js +6 -6
  28. package/dist/packages/core/src/components/LineChart.js +24 -24
  29. package/dist/packages/core/src/components/Loader.js +5 -5
  30. package/dist/packages/core/src/components/NoticeBox.js +3 -3
  31. package/dist/packages/core/src/components/PageTitle.js +2 -2
  32. package/dist/packages/core/src/components/Pagination.js +4 -4
  33. package/dist/packages/core/src/components/Panel.js +2 -2
  34. package/dist/packages/core/src/components/PanelRow.d.ts +2 -4
  35. package/dist/packages/core/src/components/PanelRow.js +8 -15
  36. package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
  37. package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
  38. package/dist/packages/core/src/components/PaymentPlan.js +5 -5
  39. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
  40. package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
  41. package/dist/packages/core/src/components/Popup/DeviceListPopup.d.ts +4 -8
  42. package/dist/packages/core/src/components/Popup/DeviceListPopup.js +1 -1
  43. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +19 -26
  44. package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
  45. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +10 -10
  46. package/dist/packages/core/src/components/Popup/Popup.js +27 -27
  47. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
  48. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
  49. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
  50. package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
  51. package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
  52. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
  53. package/dist/packages/core/src/components/Rating.js +20 -20
  54. package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
  55. package/dist/packages/core/src/components/RingChart.js +5 -5
  56. package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
  57. package/dist/packages/core/src/components/SegmentedControl.js +34 -34
  58. package/dist/packages/core/src/components/SideNav.js +37 -37
  59. package/dist/packages/core/src/components/Slider.js +2 -2
  60. package/dist/packages/core/src/components/SoundCompass.js +5 -5
  61. package/dist/packages/core/src/components/Table.js +257 -98
  62. package/dist/packages/core/src/components/Tabs.js +12 -12
  63. package/dist/packages/core/src/components/TextField.js +34 -34
  64. package/dist/packages/core/src/components/Toast.js +6 -6
  65. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
  66. package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
  67. package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
  68. package/dist/packages/core/src/components/TopBanner.js +28 -28
  69. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
  70. package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
  71. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +7 -4
  72. package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
  73. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
  74. package/dist/packages/core/src/components/Training/TrainingBlock.js +12 -12
  75. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
  76. package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
  77. package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
  78. package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
  79. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
  80. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +83 -83
  81. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
  82. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +128 -128
  83. package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
  84. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
  85. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
  86. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +13 -13
  87. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
  88. package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
  89. package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
  90. package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
  91. package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
  92. package/dist/packages/core/src/components/Training/oneShotSound.d.ts +1 -1
  93. package/dist/packages/core/src/components/Training/oneShotSound.js +14 -14
  94. package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +2 -2
  95. package/dist/packages/core/src/components/Training/trainingMissStats.js +22 -22
  96. package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
  97. package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
  98. package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
  99. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
  100. package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
  101. package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
  102. package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
  103. package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
  104. package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
  105. package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
  106. package/dist/packages/core/src/components/UpdateAlert.js +6 -6
  107. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
  108. package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
  109. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +18 -18
  110. package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
  111. package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
  112. package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
  113. package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
  114. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
  115. package/dist/packages/core/src/components/Word/wordScoreStats.js +17 -17
  116. package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
  117. package/dist/packages/core/src/components/Word/wordTableRecommend.js +13 -13
  118. package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
  119. package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
  120. package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
  121. package/dist/packages/core/src/components/WordTable.js +130 -130
  122. package/dist/packages/core/src/components/barChartData.d.ts +16 -1
  123. package/dist/packages/core/src/components/barChartData.js +49 -29
  124. package/dist/packages/core/src/components/barChartTypes.js +8 -8
  125. package/dist/packages/core/src/components/tableTrackList.js +11 -11
  126. package/dist/packages/core/src/components/updateAlertStack.js +28 -28
  127. package/dist/packages/core/src/components/useBarChartControl.js +4 -6
  128. package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
  129. package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
  130. package/dist/packages/core/src/components/wordTableLayout.d.ts +22 -7
  131. package/dist/packages/core/src/components/wordTableLayout.js +5 -5
  132. package/dist/packages/core/src/context/I18nProvider.js +6 -6
  133. package/dist/packages/core/src/hooks/focusReturn.js +27 -27
  134. package/dist/packages/core/src/hooks/inputModality.d.ts +1 -1
  135. package/dist/packages/core/src/hooks/inputModality.js +14 -14
  136. package/dist/packages/core/src/hooks/touchToggle.js +39 -39
  137. package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
  138. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
  139. package/dist/packages/core/src/hooks/useDeviceManagement.d.ts +5 -17
  140. package/dist/packages/core/src/hooks/useDeviceManagement.js +20 -8
  141. package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
  142. package/dist/packages/core/src/hooks/useEditableList.js +4 -4
  143. package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
  144. package/dist/packages/core/src/hooks/useFontScale.js +6 -6
  145. package/dist/packages/core/src/hooks/useIcon.js +7 -7
  146. package/dist/packages/core/src/hooks/useListControls.js +2 -2
  147. package/dist/packages/core/src/hooks/usePageResetOnChange.d.ts +3 -3
  148. package/dist/packages/core/src/hooks/usePageResetOnChange.js +6 -6
  149. package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
  150. package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
  151. package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
  152. package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
  153. package/dist/packages/core/src/hooks/useTablePaging.d.ts +7 -7
  154. package/dist/packages/core/src/hooks/useTablePaging.js +40 -34
  155. package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
  156. package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
  157. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
  158. package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
  159. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +3 -1
  160. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +14 -12
  161. package/dist/packages/core/src/i18n/locales/en.json +13 -2
  162. package/dist/packages/core/src/i18n/locales/ko.json +12 -1
  163. package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
  164. package/dist/packages/core/src/utils/audioMeter.js +5 -5
  165. package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
  166. package/dist/packages/core/src/utils/audioPeaks.js +18 -18
  167. package/dist/packages/core/src/utils/blockGrid.js +4 -4
  168. package/dist/packages/core/src/utils/chunkSkew.js +9 -9
  169. package/dist/packages/core/src/utils/clipboard.js +3 -3
  170. package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
  171. package/dist/packages/core/src/utils/date.js +12 -12
  172. package/dist/packages/core/src/utils/devWarn.d.ts +2 -2
  173. package/dist/packages/core/src/utils/devWarn.js +7 -7
  174. package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
  175. package/dist/packages/core/src/utils/deviceKind.js +53 -10
  176. package/dist/packages/core/src/utils/errorMessage.js +7 -7
  177. package/dist/packages/core/src/utils/filterPanelSummary.d.ts +1 -1
  178. package/dist/packages/core/src/utils/filterPanelSummary.js +16 -16
  179. package/dist/packages/core/src/utils/format.js +4 -4
  180. package/dist/packages/core/src/utils/getTrainingData.js +9 -9
  181. package/dist/packages/core/src/utils/imageConfig.js +17 -17
  182. package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
  183. package/dist/packages/core/src/utils/navScroll.d.ts +2 -2
  184. package/dist/packages/core/src/utils/navScroll.js +6 -6
  185. package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
  186. package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
  187. package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
  188. package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
  189. package/dist/packages/core/src/utils/phonemePairSearch.js +118 -102
  190. package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
  191. package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
  192. package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
  193. package/dist/packages/core/src/utils/pressable.d.ts +1 -1
  194. package/dist/packages/core/src/utils/pressable.js +3 -3
  195. package/dist/packages/core/src/utils/safeHref.js +4 -4
  196. package/dist/packages/core/src/utils/savedWordTables.js +5 -5
  197. package/dist/packages/core/src/utils/sessionTab.d.ts +7 -7
  198. package/dist/packages/core/src/utils/sessionTab.js +9 -9
  199. package/dist/packages/core/src/utils/speak.js +12 -12
  200. package/dist/packages/core/src/utils/textSort.d.ts +5 -5
  201. package/dist/packages/core/src/utils/textSort.js +47 -47
  202. package/dist/packages/core/src/utils/theme.js +3 -3
  203. package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
  204. package/dist/packages/core/src/utils/ttsConfig.js +2 -2
  205. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +2 -2
  206. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +48 -48
  207. package/dist/packages/core/src/utils/wordContrast.js +3 -3
  208. package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
  209. package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
  210. package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
  211. package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
  212. package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
  213. package/dist/packages/core/src/utils/wornDate.js +14 -14
  214. package/dist/packages/core/src/version.d.ts +1 -1
  215. package/dist/packages/core/src/version.js +1 -1
  216. package/dist/utils.cjs.js +4 -4
  217. package/package.json +1 -1
@@ -96,21 +96,21 @@ function SortArrow({ dir }) {
96
96
  * 그대로 산다. 칸에는 `role`·`tabIndex` 를 주지 않으므로 탭 정지점은 단추 하나뿐이다.
97
97
  */
98
98
  function TableField({ children, onClick, className, sortDir, ariaSort, onResizeStart }) {
99
- const 속 = _jsxs(_Fragment, { children: [children, sortDir && _jsx(SortArrow, { dir: sortDir })] });
99
+ const inner = _jsxs(_Fragment, { children: [children, sortDir && _jsx(SortArrow, { dir: sortDir })] });
100
100
  return (_jsxs("div", { onClick: onClick, role: "columnheader", "aria-sort": ariaSort, className: cx('table-field body-3', className, sortDir ? 'table-field--sorted' : '', onResizeStart ? 'table-field--resizable' : ''), children: [onClick
101
101
  /* 단추 안에 **또 다른 조작**을 넣지 않는다 — `label` 에 `role="button"` 인 조각이나
102
102
  Checkbox 를 넣어 두면 그것이 이 단추 안으로 들어와 겹친 조작이 된다(HTML 위반).
103
103
  그런 머리는 `onClick` 없이 두고, 정렬은 그 조각이 알아서 맡는다. */
104
- ? _jsx("button", { type: "button", className: "table-field__button", children: 속 })
105
- : 속, onResizeStart && (_jsx("span", { className: "table-field__resizer", onPointerDown: onResizeStart, onClick: e => e.stopPropagation(), "aria-hidden": "true" }))] }));
104
+ ? _jsx("button", { type: "button", className: "table-field__button", children: inner })
105
+ : inner, onResizeStart && (_jsx("span", { className: "table-field__resizer", onPointerDown: onResizeStart, onClick: e => e.stopPropagation(), "aria-hidden": "true" }))] }));
106
106
  }
107
107
  /** @public */
108
108
  export default function Table({ fields = [], headerGroups, items = [], renderRow, onRowClick, isRowClickable, onRowMouseEnter, onRowMouseLeave, onRowDragStart, onRowDragEnd, draggingId, gridTemplateColumns, mobileGridTemplateColumns, resizable = false, resizeMode = 'grow', resizeStorageKey, size = 'small', title, icon, count, collapsed = false, onToggleCollapse, headerActions, empty, loading, dragOver = false, onDragOver, onDragLeave, onDrop, renderExpandedRow, expandedId, }) {
109
109
  const { t } = useTranslation();
110
110
  const uid = useId();
111
- const 제목id = `table-title-${uid}`;
111
+ const titleId = `table-title-${uid}`;
112
112
  /** 줄을 눌러 들어가는 표에서만 쓰는 설명 id — `role="button"` 을 잃은 자리를 메운다 */
113
- const 줄설명id = `table-row-hint-${uid}`;
113
+ const rowHintId = `table-row-hint-${uid}`;
114
114
  /**
115
115
  * **좁은 화면에서는 폭 조절을 통째로 끈다** — 손잡이·초기화 단추·저장값.
116
116
  *
@@ -132,9 +132,9 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
132
132
  * 옆으로 미는 쪽을 골랐다. 그러니 담을 수 없을 만큼 좁을 때 칸을 눌러 담으려 드는 코드를
133
133
  * 다시 넣지 말 것 — 그게 위에 적은 48px 사건이었다.
134
134
  */
135
- const 좁은화면 = useMediaQuery('(max-width: 767px)');
136
- const 좁은틀 = mobileGridTemplateColumns && 좁은화면 ? mobileGridTemplateColumns : null;
137
- const 폭조절 = resizable && !좁은화면;
135
+ const isNarrow = useMediaQuery('(max-width: 767px)');
136
+ const narrowTemplate = mobileGridTemplateColumns && isNarrow ? mobileGridTemplateColumns : null;
137
+ const canResize = resizable && !isNarrow;
138
138
  // ── 컬럼 폭 드래그 리사이즈 (resizable) ──
139
139
  /** 만든 사람이 밑값을 안 적은 칸에 쓰는 바닥 — 이보다 좁으면 손잡이를 다시 잡을 수도 없다 */
140
140
  const MIN_COL_W = 48;
@@ -148,7 +148,7 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
148
148
  *
149
149
  * 자동맞춤은 이 값보다 위에서 정의되므로 ref 로 건넨다(isFixedPxRef 와 같은 이유).
150
150
  */
151
- const 밑값Ref = useRef(() => MIN_COL_W);
151
+ const minWidthRef = useRef(() => MIN_COL_W);
152
152
  const storeKey = resizeStorageKey ? `table-cols:${resizeStorageKey}` : null;
153
153
  const fieldsRef = useRef(null);
154
154
  /* **가로로 넘치나** — 창 크기·칸 폭·내용에 따라 바뀌므로 지켜본다(말귀 요청 ㊿).
@@ -162,14 +162,23 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
162
162
  958/333 인데 셋 다 안 붙은 채 굳었다) 다시 내지는 못했다 — **재현은 못 했지만 구멍은
163
163
  실재한다.** 그래서 `ref` 를 **함수로** 받는다: 마디가 바뀔 때마다 React 가 불러 주므로
164
164
  보개가 늘 지금 마디를 본다. */
165
- const 굴림보개 = useRef(null);
166
- const 굴림통 = useRef(null);
167
- const 굴림안 = useRef(null);
168
- const [넘치나, set넘치나] = useState(false);
169
- const 넘침재기 = useCallback(() => {
170
- const el = 굴림통.current;
171
- if (el)
172
- set넘치나(el.scrollWidth > el.clientWidth + 1); // 1px 은 반올림 몫
165
+ const resizeObserverRef = useRef(null);
166
+ const scrollOuterRef = useRef(null);
167
+ const scrollInnerRef = useRef(null);
168
+ const [overflowing, setOverflowing] = useState(false);
169
+ /* 자동맞춤이 통 폭을 따라가려면 신호가 필요하다 — 아래에서 적는다 */
170
+ const [boxWidth, setBoxWidth] = useState(0);
171
+ const measureOverflow = useCallback(() => {
172
+ const el = scrollOuterRef.current;
173
+ if (!el)
174
+ return;
175
+ setOverflowing(el.scrollWidth > el.clientWidth + 1); // 1px 은 반올림 몫
176
+ /* **통 폭도 여기서 적어 둔다.** 자동맞춤은 통이 넓어지면 따라가야 하는데, 통을 보는
177
+ 보개(`ResizeObserver`)가 이미 이 함수를 부른다 — 보개를 하나 더 세우거나 `observe`
178
+ 의 꼴을 건드리면 바로 위 머리말의 그 경합(0.237.0)을 다시 들인다. **같은 자리에
179
+ 얹는다.** 이 함수는 그려질 때마다 불리지만 같은 수면 React 가 다시 안 그리므로
180
+ 도는 일은 없다. */
181
+ setBoxWidth(el.clientWidth);
173
182
  }, []);
174
183
  /* **보개의 목숨을 `ref` 콜백 하나가 쥔다 — effect 와 나눠 갖지 않는다.**
175
184
 
@@ -182,34 +191,34 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
182
191
 
183
192
  그래서 세우는 것도 거두는 것도 `ref` 콜백이 한다. 마디가 다 떨어졌을 때 거둔다 —
184
193
  React 는 떼어낼 때 `ref` 를 `null` 로 불러 주므로 그것이 곧 뒷정리다. */
185
- const 지켜보기 = useCallback((칸, el) => {
186
- const 옛 = 칸.current;
187
- if (옛 && 굴림보개.current)
188
- 굴림보개.current.unobserve(옛);
189
- 칸.current = el;
194
+ const observe = useCallback((slot, el) => {
195
+ const prev = slot.current;
196
+ if (prev && resizeObserverRef.current)
197
+ resizeObserverRef.current.unobserve(prev);
198
+ slot.current = el;
190
199
  if (el) {
191
200
  /* 없는 환경(구형 브라우저·SSR·시험 러너)에서는 생성자 호출이 그대로 던져 렌더가
192
201
  깨진다 — 보개 없이 아래 `넘침재기()` 로 한 번 잰 값만 둔다.
193
202
  **살아 있나를 여기서 본다** — 거둬진 뒤에도 다시 세운다. */
194
- if (!굴림보개.current && typeof ResizeObserver !== 'undefined') {
195
- 굴림보개.current = new ResizeObserver(() => 넘침재기());
203
+ if (!resizeObserverRef.current && typeof ResizeObserver !== 'undefined') {
204
+ resizeObserverRef.current = new ResizeObserver(() => measureOverflow());
196
205
  }
197
- 굴림보개.current?.observe(el);
206
+ resizeObserverRef.current?.observe(el);
198
207
  }
199
- else if (!굴림통.current && !굴림안.current) {
200
- 굴림보개.current?.disconnect();
201
- 굴림보개.current = null;
208
+ else if (!scrollOuterRef.current && !scrollInnerRef.current) {
209
+ resizeObserverRef.current?.disconnect();
210
+ resizeObserverRef.current = null;
202
211
  }
203
- 넘침재기();
204
- }, [넘침재기]);
205
- const 통달기 = useCallback((el) => 지켜보기(굴림통, el), [지켜보기]);
212
+ measureOverflow();
213
+ }, [measureOverflow]);
214
+ const attachOuter = useCallback((el) => observe(scrollOuterRef, el), [observe]);
206
215
  /* **통만 보면 안엣것이 넓어질 때를 놓친다** — 칸 폭을 끌어 넓히거나 줄이 늘 때다.
207
216
  그때 통의 바깥 상자는 그대로이고 `scrollWidth` 만 바뀐다. */
208
- const 안달기 = useCallback((el) => 지켜보기(굴림안, el), [지켜보기]);
217
+ const attachInner = useCallback((el) => observe(scrollInnerRef, el), [observe]);
209
218
  /* **안전줄 — 그려질 때마다 한 번 더 잰다.**
210
219
  보개가 어떤 까닭으로든 못 울린 자리를 덮는다. 같은 값이면 React 가 다시 안 그리므로
211
220
  도는 일은 없고, 읽는 것은 마디 하나의 `scrollWidth`·`clientWidth` 둘뿐이다. */
212
- useEffect(() => { 넘침재기(); });
221
+ useEffect(() => { measureOverflow(); });
213
222
  /* **창 크기는 따로 듣는다 — 상자 보개에만 기대지 않는다.**
214
223
 
215
224
  넘치나는 `scrollWidth > clientWidth` 인데, `ResizeObserver` 는 **상자가 바뀔 때**만
@@ -224,8 +233,8 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
224
233
  useEffect(() => {
225
234
  if (typeof window === 'undefined')
226
235
  return;
227
- const 다음틀 = typeof requestAnimationFrame === 'function'
228
- ? requestAnimationFrame(() => 넘침재기())
236
+ const nextFrame = typeof requestAnimationFrame === 'function'
237
+ ? requestAnimationFrame(() => measureOverflow())
229
238
  : null;
230
239
  /* **안-모음: 모을 값이 없다**(2026-10-10 조사에서 한 번 모아 봤다가 되돌림). `넘침재기` 는
231
240
  `scrollWidth > clientWidth` 하나를 **불리언**으로 넣는데, 같은 값이면 React 가 그 자리에서
@@ -234,20 +243,20 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
234
243
  (처음엔 *"못이 이 배선을 글자로 박아 두었다"* 도 까닭으로 적었는데, 그 못을 **꼴을 재는 쪽으로
235
244
  고쳤으니** 이제 그 까닭은 없다 — 지운다. 남는 까닭은 위의 하나뿐이고, 그것만으로 충분하다.
236
245
  **까닭이 틀린 주석은 다음 사람에게 잘못된 값을 준다.**) */
237
- window.addEventListener('resize', 넘침재기);
246
+ window.addEventListener('resize', measureOverflow);
238
247
  return () => {
239
- if (다음틀 != null)
240
- cancelAnimationFrame(다음틀);
241
- window.removeEventListener('resize', 넘침재기);
248
+ if (nextFrame != null)
249
+ cancelAnimationFrame(nextFrame);
250
+ window.removeEventListener('resize', measureOverflow);
242
251
  };
243
- }, [넘침재기]);
252
+ }, [measureOverflow]);
244
253
  const isFixedPxRef = useRef(() => false);
245
254
  const defaultColsRef = useRef([]);
246
255
  // fit 모드에서는 이웃(next)에서 같은 만큼 덜어 오므로 그 컬럼의 시작 폭도 들고 있어야 한다
247
256
  const dragRef = useRef(null);
248
257
  /* 저장한 폭 읽기 — 숫자이기만 하면 받던 것을 **유한하고 최소 폭 이상인 수**만 받는다(0·음수·NaN 이 들어오면 격자 틀이
249
258
  무효가 됐다). */
250
- const 저장폭읽기 = (key) => {
259
+ const readStoredWidths = (key) => {
251
260
  if (!key || typeof window === 'undefined')
252
261
  return null;
253
262
  try {
@@ -258,13 +267,29 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
258
267
  return null;
259
268
  }
260
269
  };
261
- const [colWidths, setColWidths] = useState(() => 저장폭읽기(storeKey));
270
+ const [colWidths, setColWidths] = useState(() => readStoredWidths(storeKey));
262
271
  /* 마운트된 채 resizeStorageKey 가 바뀌면 **새 열쇠의 폭**을 읽는다 — 마운트 때 한 번만 읽어, 열 수가 같으면 앞 열쇠의 폭이
263
272
  다른 열에 그대로 씌워졌다. 렌더 중에 맞춘다(바뀐 그 렌더에서 옛 폭이 한 번이라도 그려지지 않게). */
264
- const [읽은열쇠, set읽은열쇠] = useState(storeKey);
265
- if (읽은열쇠 !== storeKey) {
266
- set읽은열쇠(storeKey);
267
- setColWidths(저장폭읽기(storeKey));
273
+ const [loadedKey, setLoadedKey] = useState(storeKey);
274
+ /**
275
+ * **자동 ↔ 손, 두 모드다. 저장소에 값이 있으면 손이다.**
276
+ *
277
+ * 손으로 조절한 적이 없는 표는 늘 자동맞춤으로 서고, 조절하면 그 값을 쓰고, '칸 너비
278
+ * 초기화' 를 누르면 다시 자동으로 돌아간다(사장님 지시 2026-10-13).
279
+ *
280
+ * 전에는 초기화가 **재서 그 숫자를 저장**했다. 그러면 손으로 끈 것과 구별이 없어지고,
281
+ * 무엇보다 **그때 화면에 있던 줄로 잰 '한 순간의 사진'** 으로 굳었다 — 그 뒤 창을
282
+ * 넓히거나 긴 글자가 든 줄이 들어와도 안 따라왔다. 그것이 "상시로 활용" 이 아니었다.
283
+ *
284
+ * 새 칸을 두지 않고 **저장소의 없음 = 자동** 으로 갈래를 둘로 줄인다. 그래서 이미 끌어 둔
285
+ * 사람의 폭은 판이 올라도 그대로 손 모드로 읽힌다 — **옛 값을 밀지 않는다.**
286
+ */
287
+ const [manual, setManual] = useState(() => readStoredWidths(storeKey) !== null);
288
+ if (loadedKey !== storeKey) {
289
+ setLoadedKey(storeKey);
290
+ const stored = readStoredWidths(storeKey);
291
+ setColWidths(stored);
292
+ setManual(stored !== null);
268
293
  }
269
294
  // 최신 colWidths를 참조로 유지 (드래그 종료 시 저장용)
270
295
  const colWidthsRef = useRef(colWidths);
@@ -284,12 +309,12 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
284
309
  // fit — 이동량을 두 컬럼이 나눠 갖는다. 둘 다 최소폭 밑으로 내려가지 않도록
285
310
  // 이동량 자체를 먼저 자른다(한쪽만 자르면 합이 달라져 표 폭이 흔들린다).
286
311
  const raw = e.clientX - d.startX;
287
- const delta = Math.max(밑값Ref.current(d.index) - d.startW, Math.min(d.startWNext - 밑값Ref.current(d.next), raw));
312
+ const delta = Math.max(minWidthRef.current(d.index) - d.startW, Math.min(d.startWNext - minWidthRef.current(d.next), raw));
288
313
  copy[d.index] = d.startW + delta;
289
314
  copy[d.next] = d.startWNext - delta;
290
315
  }
291
316
  else {
292
- copy[d.index] = Math.max(밑값Ref.current(d.index), d.startW + (e.clientX - d.startX));
317
+ copy[d.index] = Math.max(minWidthRef.current(d.index), d.startW + (e.clientX - d.startX));
293
318
  }
294
319
  colWidthsRef.current = copy;
295
320
  setColWidths(copy);
@@ -331,6 +356,9 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
331
356
  : { index, startX: e.clientX, startW: measured[index] };
332
357
  colWidthsRef.current = measured;
333
358
  setColWidths(measured);
359
+ /* **끌기 시작한 그 순간부터 손이다.** 끝날 때 저장하는 것으로 미루면, 끄는 동안
360
+ 자동 효과가 살아 있어 끌던 폭을 덮는다(지문의 통 폭이 바뀌면 바로 그렇다). */
361
+ setManual(true);
334
362
  /* **안-모음: 곧바로 써야 한다** — `onResizeMove` 가 `colWidthsRef` 를 그 자리에서 맞춘다.
335
363
  한 틀 미루면 마지막 이동 직후 손을 떼었을 때 **화면은 새 폭인데 저장소에는 한 박자 전 폭**이
336
364
  남는다(그 함수 머리말에 적힌 까닭). 자리 재기와 달리 이것은 *값을 쓰는* 일이다. */
@@ -342,7 +370,10 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
342
370
  document.body.style.userSelect = 'none';
343
371
  }, [onResizeMove, onResizeEnd, resizeMode, fields]);
344
372
  /**
345
- * **칸 너비를 내용에 맞춰 다시 잡는다.**
373
+ * **칸 너비를 내용에 맞춰 잰다 — 재기만 한다.**
374
+ *
375
+ * 쓰는 것도 저장하는 것도 부르는 쪽이 한다(꼬리의 까닭을 볼 것). 전에는 이 함수가
376
+ * 재서 쓰고 **저장까지** 했고, 그것이 '칸 너비 초기화' 를 한 순간의 사진으로 굳혔다.
346
377
  *
347
378
  * 손으로 끌어 놓은 폭을 그냥 지우면(=기본 격자로 되돌리면) 만든 사람이 적어 둔
348
379
  * 틀로 갈 뿐, 지금 화면에 든 글자에는 안 맞는다. 그래서 **재서** 맞춘다 —
@@ -352,35 +383,35 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
352
383
  * 고정 px 로 못 박은 열(아이콘·숫자 칸)은 그대로 둔다 — 내용에 맞추면 정해 둔
353
384
  * 크기를 잃는다.
354
385
  */
355
- const 자동맞춤 = useCallback(() => {
386
+ const measureAutoWidths = useCallback(() => {
356
387
  const el = fieldsRef.current;
357
388
  const wrap = el?.closest('.table-wrap');
358
389
  if (!el || !wrap)
359
- return;
360
- const 이전 = wrap.style.getPropertyValue('--table-grid-cols');
390
+ return null;
391
+ const before = wrap.style.getPropertyValue('--table-grid-cols');
361
392
  wrap.style.setProperty('--table-grid-cols', fields.map((_, i) => (isFixedPxRef.current(i) ? defaultColsRef.current[i] : 'max-content')).join(' '));
362
393
  /* 재는 동안만 max-content 다. `.table-fields` 와 각 줄은 서로 다른 격자라
363
394
  이때는 열이 어긋나 보이지만, 같은 함수 안에서 되돌리므로 화면에는 안 나온다. */
364
- const 줄들 = Array.from(wrap.querySelectorAll('.table-row'))
395
+ const rows = Array.from(wrap.querySelectorAll('.table-row'))
365
396
  .filter(r => !r.classList.contains('table-row--loading') && !r.classList.contains('table-row--empty'));
366
- const 잰것 = fields.map((_, i) => {
397
+ const measured = fields.map((_, i) => {
367
398
  let w = el.children[i]?.getBoundingClientRect().width ?? 0;
368
- for (const 줄 of 줄들) {
369
- const c = 줄.children[i];
399
+ for (const row of rows) {
400
+ const c = row.children[i];
370
401
  if (c)
371
402
  w = Math.max(w, c.getBoundingClientRect().width);
372
403
  }
373
404
  /* 고정 px 열은 정해 둔 값 그대로 — 최소 폭(MIN_COL_W)으로 올린 값을 고정 합에 넣으면 격자에는 원래 값(40px)으로 나가
374
405
  그만큼(8px) 표 오른쪽이 비었다(말귀 어드민 버그 탭·쿠폰) */
375
- const 고정값 = isFixedPxRef.current(i) ? parseFloat(defaultColsRef.current[i]) : NaN;
376
- return Number.isFinite(고정값) ? 고정값 : Math.max(밑값Ref.current(i), Math.ceil(w));
406
+ const fixedWidth = isFixedPxRef.current(i) ? parseFloat(defaultColsRef.current[i]) : NaN;
407
+ return Number.isFinite(fixedWidth) ? fixedWidth : Math.max(minWidthRef.current(i), Math.ceil(w));
377
408
  });
378
409
  /* 격자가 실제로 채우는 폭 — `clientWidth` 는 안쪽 여백을 포함하므로 뺀다
379
410
  (되돌리기 단추 자리로 40px 을 비워 두었다). */
380
411
  const cs = window.getComputedStyle(el);
381
- const 그릇 = el.clientWidth - (parseFloat(cs.paddingLeft) || 0) - (parseFloat(cs.paddingRight) || 0);
382
- if (이전)
383
- wrap.style.setProperty('--table-grid-cols', 이전);
412
+ const containerWidth = el.clientWidth - (parseFloat(cs.paddingLeft) || 0) - (parseFloat(cs.paddingRight) || 0);
413
+ if (before)
414
+ wrap.style.setProperty('--table-grid-cols', before);
384
415
  else
385
416
  wrap.style.removeProperty('--table-grid-cols');
386
417
  /* **남는 자리를 비율대로 나눈다.** 잰 값을 그대로 쓰면 칸이 제 글자만큼만
@@ -391,33 +422,139 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
391
422
 
392
423
  `fit` 은 이 값이 fr 가중치가 되어 어차피 비율로 채우지만, 저장되는 숫자도
393
424
  같은 뜻이 되게 함께 맞춘다. */
394
- let 최종 = 잰것;
395
- const 고정 = 잰것.reduce((a, w, i) => a + (isFixedPxRef.current(i) ? w : 0), 0);
396
- const 유동 = 잰것.reduce((a, w, i) => a + (isFixedPxRef.current(i) ? 0 : w), 0);
397
- const 남은 = 그릇 - 고정;
398
- if (그릇 > 0 && 유동 > 0 && 남은 > 0) {
399
- const 비 = 남은 / 유동;
400
- 최종 = 잰것.map((w, i) => (isFixedPxRef.current(i) ? w : Math.max(밑값Ref.current(i), Math.round(w * 비))));
425
+ let finalWidths = measured;
426
+ const fixedTotal = measured.reduce((a, w, i) => a + (isFixedPxRef.current(i) ? w : 0), 0);
427
+ const fluidTotal = measured.reduce((a, w, i) => a + (isFixedPxRef.current(i) ? 0 : w), 0);
428
+ const available = containerWidth - fixedTotal;
429
+ if (containerWidth > 0 && fluidTotal > 0 && available > 0) {
430
+ const ratio = available / fluidTotal;
431
+ finalWidths = measured.map((w, i) => (isFixedPxRef.current(i) ? w : Math.max(minWidthRef.current(i), Math.round(w * ratio))));
401
432
  /* 반올림으로 남은 몇 px 은 가장 넓은 유동 열에 얹는다 — 안 그러면 늘 몇 px 이
402
433
  비거나 넘친다. 최소 폭에 걸린 열이 있으면 넘칠 수 있는데, 그건 담을 수
403
434
  없을 만큼 좁다는 뜻이라 가로 스크롤로 두는 편이 맞다. */
404
- const 차 = Math.round(남은 - 최종.reduce((a, w, i) => a + (isFixedPxRef.current(i) ? 0 : w), 0));
405
- if (차 !== 0) {
406
- let 넓은 = -1;
407
- 최종.forEach((w, i) => { if (!isFixedPxRef.current(i) && (넓은 < 0 || w > 최종[넓은]))
408
- 넓은 = i; });
409
- if (넓은 >= 0)
410
- 최종[넓은] = Math.max(밑값Ref.current(넓은), 최종[넓은] + 차);
435
+ const diff = Math.round(available - finalWidths.reduce((a, w, i) => a + (isFixedPxRef.current(i) ? 0 : w), 0));
436
+ if (diff !== 0) {
437
+ let widestIndex = -1;
438
+ finalWidths.forEach((w, i) => { if (!isFixedPxRef.current(i) && (widestIndex < 0 || w > finalWidths[widestIndex]))
439
+ widestIndex = i; });
440
+ if (widestIndex >= 0)
441
+ finalWidths[widestIndex] = Math.max(minWidthRef.current(widestIndex), finalWidths[widestIndex] + diff);
411
442
  }
412
443
  }
413
- setColWidths(최종);
444
+ /* **쓰지도 저장하지도 않는다 — 잰 값을 돌려줄 뿐이다.** 자동 모드에서는 저장하면
445
+ 안 된다(저장소에 값이 있으면 손 모드이므로, 저장하는 순간 자동으로 돌아갈 길이
446
+ 사라진다). 쓰는 것은 부르는 쪽이 한다. */
447
+ return finalWidths;
448
+ }, [fields, resizeMode]);
449
+ /* 잰 값이 지금 것과 같은가 — 아래 효과가 **돌지 않게** 하는 못이다.
450
+ 같으면 `setColWidths` 가 옛 배열을 그대로 돌려주어 React 가 다시 안 그린다. */
451
+ const sameWidths = (a, b) => a !== null && a.length === b.length && a.every((n, i) => n === b[i]);
452
+ /* 재는 함수를 참조로 쥔다 — `fields` 가 소비처에서 매 렌더 새 배열인 자리가 많아
453
+ 그 identity 를 효과의 걸이에 넣으면 **그려질 때마다** 표 전체를 다시 잰다
454
+ (`max-content` 로 바꿔 모든 칸의 상자를 읽는 일이다. 싸지 않다). */
455
+ const measureRef = useRef(measureAutoWidths);
456
+ useEffect(() => { measureRef.current = measureAutoWidths; });
457
+ /**
458
+ * **자동 모드일 때 다시 잰다 — 줄이 들어온 뒤에, 그리고 바뀔 때마다.**
459
+ *
460
+ * 첫 렌더에는 받는 중이라 **잴 줄이 없다**(`measureAutoWidths` 는 `--loading`·`--empty` 줄을
461
+ * 일부러 뺀다). 그때 한 번 재고 끝내면 빈 표 기준으로 굳으므로, 줄이 들어온 뒤에 잰다.
462
+ *
463
+ * 줄이 없을 때는 `null` 로 두어 **만든 사람이 적은 격자**로 선다 — 머리만 재어 굳히는
464
+ * 것보다 낫다.
465
+ *
466
+ * 좁은 화면에서는 아무것도 안 한다. `canResize` 가 이미 `!isNarrow` 이고, 좁은 화면에서는
467
+ * 폭 조절을 통째로 끄는 것이 이 컴포넌트의 규칙이다(손잡이·초기화 단추·저장값). 자동으로
468
+ * 재면 모바일 격자와 싸운다.
469
+ */
470
+ /* **다시 재라는 신호.** 초기화 단추가 이것을 올린다 — 없으면 *이미 자동인* 표에서
471
+ 단추가 아무 일도 안 한다(저장값을 지워도 모드가 안 바뀌어 아래 지문이 그대로다).
472
+ 실제로 그랬다: 눌렀더니 잰 폭이 **지워지고 만든 사람의 틀로 돌아갔다**(`40px 1fr 1fr`).
473
+ 손→자동으로 갈 때는 `manual` 이 바뀌어 지문이 달라지므로 그 길만 멀쩡했고, 더 흔한
474
+ 자동→자동 길이 조용히 죽어 있었다. 시험 둘이 이것을 잡았다. */
475
+ const [refitNonce, setRefitNonce] = useState(0);
476
+ /* **저장 열쇠가 있는 표만 자동으로 선다.** 열쇠가 없으면 '손으로 조절했다' 를 기억할
477
+ 자리가 없어 영영 자동이 되는데, 이 저장소에서 열쇠 없는 `resizable` 표는 전부
478
+ **치면서 걸러 보는 창**이다(`ListPopup` 을 거치는 일곱 자리 + 둘). 거기서는 글자를 칠
479
+ 때마다 결과 줄이 갈리고, "줄이 바뀌면 다시 잰다" 가 **보는 사람 눈앞에서 칸을 움직인다**
480
+ — 폭을 기억하지 않는 것과 폭이 매 글자마다 바뀌는 것은 다른 일이고 뒤쪽이 더 거슬린다
481
+ (말귀가 그 창들을 열어 재어 짚었다, 2026-10-13).
482
+ 그 창들이 적어 둔 칸 틀에는 까닭이 있다 — `minmax` 바닥은 좁은 창에서 유동 칸이 0px 로
483
+ 눌려 글자가 포개지는 것을 막고, 백분율은 걸러도 비율이 안 흔들리게 한다. 자동맞춤은
484
+ 유동 칸을 내용 크기로 바꾸므로 그 결을 매번 덮는다.
485
+ 나중에 그 창들도 자동으로 서는 게 낫다면 **그 표에 저장 열쇠를 주면** 된다 — 그러면
486
+ 처음엔 자동, 끌면 기억, 초기화하면 다시 자동으로 지시가 통째로 들어맞는다. */
487
+ /**
488
+ * **다시 재는 계기에 '줄 바뀜' 은 없다.** 줄이 *처음* 들어온 것만 계기다.
489
+ *
490
+ * 처음에는 "줄이 바뀔 때마다" 재게 두었다(말귀 청의 ①이 그렇게 적혀 있었다). **말귀가
491
+ * 제 앱을 재어 그 청을 스스로 물렸다**(2026-10-13): 저장열쇠가 있는 표 스물여덟 중
492
+ * **열아홉**이 찾는 칸이나 쪽 나누기를 갖고 있다. 거기서는 걸러도, 쪽을 넘겨도 줄이
493
+ * 갈리고 그때마다 칸이 다시 선다 — 예외가 아니라 **대다수**다.
494
+ *
495
+ * 쪽 넘기기가 더 눈에 띈다. 치는 것은 내가 하는 일이라 움직임이 내 탓으로 읽히지만, 쪽
496
+ * 넘기기는 "같은 표의 다음 장" 이라 **같은 표가 다른 모습**으로 보인다.
497
+ *
498
+ * 첫 줄 묶음으로 잰 폭이 뒤 쪽의 긴 글자에 안 맞을 수 있다. 그런데 그것은 **사람이 손으로
499
+ * 끌어 둔 표와 똑같은 흠**이다 — 끌어 둔 폭도 다음 쪽 글자에 안 맞는다. 안 맞으면 초기화를
500
+ * 누르면 되고 그 단추가 바로 그 자리에 있다. **들쭉날쭉한 맞음보다 가만한 안 맞음이 낫다.**
501
+ *
502
+ * ('늦추기' 는 안 쓴다 — 움직임을 늦출 뿐 없애지 못하고, 치다 멈춘 뒤 한 박자 있다 칸이
503
+ * 움직이면 내가 한 일과 끊겨 보여 더 뜻 모를 움직임이 된다.)
504
+ *
505
+ * 지문에 드는 것: 다시 재라는 신호 · 칸 수 · 통 폭 · **줄이 있나 없나**(수가 아니다).
506
+ */
507
+ const hasRows = items.length > 0;
508
+ /* **재야 하는 조건** — 이것이 같으면 한 번만 잰다. 줄이 있나 없나는 여기 **안 든다**:
509
+ 들면 걸러서 0건이 되었다 돌아올 때 지문이 두 번 바뀌어 **다시 재고 칸이 튄다**(실측:
510
+ `200px 400px` → `120px 480px`). 저장 열쇠가 드는 까닭은 열쇠가 갈리면 다른 표의
511
+ 조건이기 때문이다 — 안 넣으면 새 열쇠의 표가 옛 표의 잼을 물려받고 안 잰다. */
512
+ const refitBasis = `${storeKey ?? ''}|${refitNonce}|${fields.length}|${boxWidth}`;
513
+ /* 걸이에는 `hasRows` 도 든다 — 줄이 **처음** 들어온 그때 효과가 돌아야 하므로. 두 번째
514
+ 부터는 아래 걸쇠가 막는다. */
515
+ const autoSignature = !manual && canResize && storeKey !== null && !loading
516
+ ? `${refitBasis}|${hasRows ? 1 : 0}`
517
+ : null;
518
+ const measuredForRef = useRef(null);
519
+ useEffect(() => {
520
+ if (autoSignature === null)
521
+ return;
522
+ /* 아직 잴 줄이 없다 — 먼저 선 격자를 **그대로 둔다.** `null` 로 되돌리지 않는다:
523
+ 걸러서 0건이 되었을 때 만든 사람의 틀로 튀고, 줄이 돌아오면 또 튄다. */
524
+ if (!hasRows)
525
+ return;
526
+ /* **이 조건에서 이미 쟀다.** 줄만 갈린 것으로는 다시 재지 않는다(위 머리말). */
527
+ if (measuredForRef.current === refitBasis)
528
+ return;
529
+ const measured = measureRef.current();
530
+ if (!measured)
531
+ return;
532
+ measuredForRef.current = refitBasis;
533
+ setColWidths(prev => (sameWidths(prev, measured) ? prev : measured));
534
+ }, [autoSignature]);
535
+ /**
536
+ * **'칸 너비 초기화' — 자동 모드로 되돌아간다.**
537
+ *
538
+ * 저장값을 **지운다**. 재서 그 숫자를 저장하던 전 꼴은 손으로 끈 것과 구별이 없어졌고
539
+ * 한 순간의 사진으로 굳었다(위 `manual` 머리말).
540
+ */
541
+ const resetColumns = useCallback(() => {
414
542
  if (storeKey) {
415
543
  try {
416
- window.localStorage.setItem(storeKey, JSON.stringify(최종));
544
+ window.localStorage.removeItem(storeKey);
417
545
  }
418
- catch { /* 저장 실패 무시 */ }
546
+ catch { /* 지우기 실패 무시 */ }
419
547
  }
420
- }, [fields, resizeMode, storeKey]);
548
+ setManual(false);
549
+ /* **지금 자리에서 곧 재어 쓴다.** 신호(아래)만 올리면 저장 열쇠가 없는 표에서는 아무
550
+ 일도 안 일어난다 — 그 표에는 자동 모드가 없으므로 위 지문이 `null` 이고, 눌러도
551
+ 만든 사람의 틀로 돌아가 끝난다. 열쇠가 있는 표에서는 아래 신호가 그 뒤로도 계속
552
+ 맞춰 주고, 같은 수가 나오면 React 가 다시 안 그린다. */
553
+ const measured = measureRef.current();
554
+ setColWidths(measured);
555
+ colWidthsRef.current = measured;
556
+ setRefitNonce(n => n + 1);
557
+ }, [storeKey]);
421
558
  // 컬럼 리사이즈 드래그 도중(pointerup 발생 전) Table이 언마운트되면(예: 필터링/페이지 전환으로
422
559
  // 목록이 통째로 바뀜) startResize에서 window에 등록한 pointermove/pointerup 리스너가 정리되지
423
560
  // 않고 남아 언마운트된 컴포넌트의 setColWidths를 계속 호출하고, body 커서/userSelect도 고정된
@@ -463,9 +600,9 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
463
600
  /* `minmax(auto, 1fr)` 처럼 **안에 든** 것도 잡는다 — 최솟값이 내용 기반이면
464
601
  머리와 몸통에서 다르게 풀리는 것은 마찬가지다. 처음에 트랙의 **맨 앞**만 보다가
465
602
  심어서 그 구멍을 찾았다(2026-09-09). */
466
- const 내용트랙 = defaultCols.filter((t) => /\b(auto|min-content|max-content)\b|fit-content\(/.test(t));
467
- if (내용트랙.length > 0) {
468
- devWarnOnce(`table-content-track:${gridTemplateColumns}`, `[ihabdevteam-core] Table 의 gridTemplateColumns 에 내용으로 크기가 정해지는 트랙(${내용트랙.join(', ')})이 있습니다. ` +
603
+ const contentTracks = defaultCols.filter((t) => /\b(auto|min-content|max-content)\b|fit-content\(/.test(t));
604
+ if (contentTracks.length > 0) {
605
+ devWarnOnce(`table-content-track:${gridTemplateColumns}`, `[ihabdevteam-core] Table 의 gridTemplateColumns 에 내용으로 크기가 정해지는 트랙(${contentTracks.join(', ')})이 있습니다. ` +
469
606
  '머리와 몸통은 서로 다른 격자라 그 트랙이 각자 다르게 풀려 열이 어긋납니다. px 이나 fr 을 쓰세요. ' +
470
607
  `(받은 값: ${gridTemplateColumns})`);
471
608
  }
@@ -473,41 +610,63 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
473
610
  둘에 붙으면 낭독기가 어느 쪽이 지금 정렬인지 못 고른다. 여럿이 오면 **첫 칸만** 방향을 갖고
474
611
  나머지는 누를 수 있는 칸의 기본값(none)으로 떨어뜨린 뒤 개발 모드에서 알린다.
475
612
  (실제 부르는 쪽 — `useTablePaging`·`ListPopup` — 은 이미 하나만 준다.) */
476
- const 정렬칸 = fields.findIndex(f => !!f.sortDir);
613
+ const sortedColumn = fields.findIndex(f => !!f.sortDir);
477
614
  if (fields.filter(f => !!f.sortDir).length > 1) {
478
615
  devWarnOnce('table-multi-sort', '[ihabdevteam-core] Table 의 fields 에 sortDir 이 둘 이상 있습니다. ' +
479
616
  'aria-sort 는 표 하나에 한 칸만 가질 수 있어 첫 칸만 방향으로 알리고 나머지는 none 으로 둡니다.');
480
617
  }
481
618
  /** 머리 칸의 `aria-sort` — 정렬 중인 한 칸만 방향, 누를 수 있는 나머지는 none, 정렬과 무관한 칸에는 안 붙인다 */
482
619
  const ariaSortOf = (i) => {
483
- if (i === 정렬칸)
620
+ if (i === sortedColumn)
484
621
  return fields[i].sortDir === 'asc' ? 'ascending' : 'descending';
485
622
  return fields[i].onClick ? 'none' : undefined;
486
623
  };
487
624
  const isFixedPx = (i) => /^\d+(\.\d+)?px$/.test(defaultCols[i] ?? '');
488
625
  /** 그 칸에 적힌 밑값 — `minmax(130px, …)` 의 130, 못 박은 `150px` 의 150, 없으면 MIN_COL_W */
489
- const 선언밑값 = (i) => {
626
+ const declaredMinWidth = (i) => {
490
627
  const t = defaultCols[i] ?? '';
491
628
  const m = /^minmax\(\s*(\d+(?:\.\d+)?)px\s*,/.exec(t) ?? /^(\d+(?:\.\d+)?)px$/.exec(t);
492
629
  return m ? parseFloat(m[1]) : MIN_COL_W;
493
630
  };
494
631
  /* `자동맞춤` 은 이 셋보다 위에서 정의되므로(콜백이라 부를 때 읽힌다) ref 로 건넨다 */
495
632
  isFixedPxRef.current = isFixedPx;
496
- 밑값Ref.current = 선언밑값;
633
+ minWidthRef.current = declaredMinWidth;
497
634
  defaultColsRef.current = defaultCols;
498
635
  const widthUnit = (i) => (resizeMode === 'fit' && !isFixedPx(i)) ? 'fr' : 'px';
499
636
  /* 좁은 화면에서는 저장값을 아예 안 쓴다 — 위 `폭조절` 의 글을 보라(넓은 화면에서 끈 폭이 폰에 씌워진다) */
637
+ /**
638
+ * **줄이 0개일 때도 쥔 폭을 쓴다.** 한때 `(resizeMode === 'fit' || items.length > 0)` 이
639
+ * 붙어 있었다 — `grow` 에서 줄이 없으면 쥔 폭을 버리고 만든 사람의 틀로 되돌렸다.
640
+ * 그 까닭이 지워진 주석에 남아 있다:
641
+ *
642
+ * > 항목이 0개일 때(로딩/빈 상태)는 저장된 절대 px 폭 합이 현재 컨테이너 폭보다 커서
643
+ * > 불필요한 가로 스크롤이 생길 수 있어, 이때는 기본(fr 단위) grid로 되돌린다.
644
+ *
645
+ * **그 막음은 뜻을 잃었다.** `grow` 는 끈 만큼 넓어져 **줄이 있을 때 이미** 그 가로
646
+ * 스크롤이 서는 모드다(그것이 `fit` 과 갈리는 점이다). 저장된 px 합이 통보다 크다는 것은
647
+ * 그 사람이 그만큼 넓혀 두었다는 뜻이다. 그러니 이 조건이 없앤 것은 가로 스크롤이 아니라
648
+ * **빈 상태만 다르게 보이는 것**이었고, 막는 것보다 어긋나게 만드는 쪽이 컸다.
649
+ *
650
+ * 어긋남은 두 자리에서 보인다 — ① 화면에 들어갈 때(`loading` 이면 `items` 가 비어 있다)
651
+ * 만든 사람의 틀로 섰다가 자료가 오면 쥔 폭으로 **튄다**, ② 걸러서 0건이 되면 또 튄다.
652
+ * 말귀가 제 앱을 재어 `grow` 표 스물하나(그중 0건이 날 수 있는 것 열일곱)를 세어 왔다.
653
+ *
654
+ * **그리고 자동맞춤이 이것을 모두의 일로 바꿨다.** 전에는 `colWidths` 가 끌어 본 사람에게만
655
+ * 있어 이 튐도 그 사람에게만 났다. 이제는 아무도 안 끌어도 찬다.
656
+ *
657
+ * 빈 줄(`.table-row--empty`)은 격자 줄이 아니라 `display: flex` 라 칸 폭을 안 쓴다 —
658
+ * 걷어도 움직이는 것은 **머리뿐**이다.
659
+ */
500
660
  const canApplyWidths = colWidths && colWidths.length === fields.length
501
- && !좁은화면
502
- && (resizeMode === 'fit' || items.length > 0);
503
- const effectiveGrid = 좁은틀 ?? (canApplyWidths
661
+ && !isNarrow;
662
+ const effectiveGrid = narrowTemplate ?? (canApplyWidths
504
663
  ? fields.map((f, i) => (f.resizable === false && defaultCols[i]) ? defaultCols[i] : `${colWidths[i]}${widthUnit(i)}`).join(' ')
505
664
  : gridTemplateColumns);
506
665
  const gridStyle = effectiveGrid
507
666
  ? { '--table-grid-cols': effectiveGrid }
508
667
  : undefined;
509
668
  const hasHeader = title !== undefined;
510
- return (_jsxs("div", { ref: 통달기, className: `table-wrap table-wrap--${size}${폭조절 ? ' table-wrap--resizable' : ''}${dragOver ? ' table-wrap--dragover' : ''}${collapsed ? ' table-wrap--collapsed' : ''}`, style: gridStyle, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop,
669
+ return (_jsxs("div", { ref: attachOuter, className: `table-wrap table-wrap--${size}${canResize ? ' table-wrap--resizable' : ''}${dragOver ? ' table-wrap--dragover' : ''}${collapsed ? ' table-wrap--collapsed' : ''}`, style: gridStyle, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop,
511
670
  /* **넘칠 때만 초점을 받는다**(말귀 요청 ㊿, 2026-10-01).
512
671
  이 통은 `overflow-x: auto` 인데 초점을 못 받았다. 안에 초점 받을 것이 없으면
513
672
  자판만 쓰는 사람은 **오른쪽 칸을 영영 못 본다** — Tab 이 왼쪽 칸을 벗어나지
@@ -518,7 +677,7 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
518
677
  한 칸씩 는다. 그래서 `scrollWidth > clientWidth` 를 재서 켠다.
519
678
  **이름 있는 `region`** 으로 둔다: 이름 없는 `region` 은 낭독기가 건너뛰고,
520
679
  초점만 받는 `div` 는 "여기서 뭘 하라는 건지" 를 말해 주지 못한다. */
521
- tabIndex: 넘치나 ? 0 : undefined, role: 넘치나 ? 'region' : undefined, "aria-label": 넘치나 ? (typeof title === 'string' ? title : t('table.scrollRegion', '가로로 굴릴 수 있는 표')) : undefined, children: [!!onRowClick && _jsx("span", { id: 줄설명id, className: "table-row-hint", children: t('table.rowActionHint', '줄을 누르면 자세히 봅니다') }), _jsxs("div", { ref: 안달기, role: "table", "aria-labelledby": hasHeader && typeof title === 'string' ? 제목id : undefined, className: "table body-2", children: [hasHeader && (_jsxs("div", { className: cx('table-header', onToggleCollapse && 'table-header--clickable'), onClick: onToggleCollapse,
680
+ tabIndex: overflowing ? 0 : undefined, role: overflowing ? 'region' : undefined, "aria-label": overflowing ? (typeof title === 'string' ? title : t('table.scrollRegion', '가로로 굴릴 수 있는 표')) : undefined, children: [!!onRowClick && _jsx("span", { id: rowHintId, className: "table-row-hint", children: t('table.rowActionHint', '줄을 누르면 자세히 봅니다') }), _jsxs("div", { ref: attachInner, role: "table", "aria-labelledby": hasHeader && typeof title === 'string' ? titleId : undefined, className: "table body-2", children: [hasHeader && (_jsxs("div", { className: cx('table-header', onToggleCollapse && 'table-header--clickable'), onClick: onToggleCollapse,
522
681
  /**
523
682
  * **머리는 늘 `caption` 이다 — 단추가 아니다**(사장님 지시 2026-10-02).
524
683
  *
@@ -540,28 +699,28 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
540
699
  */
541
700
  role: "caption", children: [onToggleCollapse && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-right' : 'chevron-down', ariaLabel: collapsed ? t('common.expand', '펼치기') : t('common.collapse', '접기'),
542
701
  /* 여닫힘은 **여는 것**이 말한다 — 전에는 겉의 `div` 에 달려 있었다(위 머리글) */
543
- ariaExpanded: !collapsed, className: "table-header__chevron" })), icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "table-header__icon" })), _jsx("span", { id: 제목id, className: "table-header__title h5b", children: title }), count !== undefined && _jsx("span", { className: "table-header__count body-3b", children: count }), headerActions && (_jsx("span", { className: "table-header__actions", onClick: e => e.stopPropagation(), children: headerActions }))] })), !collapsed && (_jsxs(_Fragment, { children: [headerGroups && headerGroups.length > 0 && (_jsx("div", { role: "row", className: "table-fields table-fields--groups", children: headerGroups.map((g, i) => (_jsx("div", { role: "columnheader", "aria-colspan": g.span, className: cx('table-field body-3', g.className), style: { gridColumn: `span ${g.span}` }, children: g.label }, i))) })), _jsxs("div", { role: "presentation", className: cx('table-fields-strip', 폭조절 && 'table-fields-strip--resizable'), children: [_jsx("div", { role: "row", className: "table-fields", ref: fieldsRef, children: fields.map((f, i) => (_jsx(TableField, { onClick: f.onClick, className: f.className, sortDir: f.sortDir, ariaSort: ariaSortOf(i),
702
+ ariaExpanded: !collapsed, className: "table-header__chevron" })), icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "table-header__icon" })), _jsx("span", { id: titleId, className: "table-header__title h5b", children: title }), count !== undefined && _jsx("span", { className: "table-header__count body-3b", children: count }), headerActions && (_jsx("span", { className: "table-header__actions", onClick: e => e.stopPropagation(), children: headerActions }))] })), !collapsed && (_jsxs(_Fragment, { children: [headerGroups && headerGroups.length > 0 && (_jsx("div", { role: "row", className: "table-fields table-fields--groups", children: headerGroups.map((g, i) => (_jsx("div", { role: "columnheader", "aria-colspan": g.span, className: cx('table-field body-3', g.className), style: { gridColumn: `span ${g.span}` }, children: g.label }, i))) })), _jsxs("div", { role: "presentation", className: cx('table-fields-strip', canResize && 'table-fields-strip--resizable'), children: [_jsx("div", { role: "row", className: "table-fields", ref: fieldsRef, children: fields.map((f, i) => (_jsx(TableField, { onClick: f.onClick, className: f.className, sortDir: f.sortDir, ariaSort: ariaSortOf(i),
544
703
  /* fit 모드는 오른쪽 이웃에서 폭을 덜어 오므로, 덜어 올 컬럼이 없는 자리에는
545
704
  손잡이를 두지 않는다 — 두면 그 컬럼만 grow처럼 넓어져 표가 밀린다. */
546
- onResizeStart: 폭조절 && i < fields.length - 1 && f.resizable !== false
705
+ onResizeStart: canResize && i < fields.length - 1 && f.resizable !== false
547
706
  && (resizeMode !== 'fit' || fields.some((g, j) => j > i && g.resizable !== false))
548
- ? (e) => startResize(i, e) : undefined, children: f.label }, i))) }), 폭조절 && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: "chevrons-left-right-ellipsis", ariaLabel: t('table.resetColumns', '칸 너비 초기화'), showTooltipInfo: t('table.resetColumns', '칸 너비 초기화'), className: "table-fields__reset", onClick: 자동맞춤 }))] }), _jsx("div", { role: "rowgroup", className: "table-body", children: loading ? (_jsx(TableRow, { variant: "loading", colSpan: fields.length || undefined })) : items.length === 0 && empty ? (_jsx(TableRow, { variant: "empty", colSpan: fields.length || undefined, children: empty })) : items.map((item, idx) => {
707
+ ? (e) => startResize(i, e) : undefined, children: f.label }, i))) }), canResize && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: "chevrons-left-right-ellipsis", ariaLabel: t('table.resetColumns', '칸 너비 초기화'), showTooltipInfo: t('table.resetColumns', '칸 너비 초기화'), className: "table-fields__reset", onClick: resetColumns }))] }), _jsx("div", { role: "rowgroup", className: "table-body", children: loading ? (_jsx(TableRow, { variant: "loading", colSpan: fields.length || undefined })) : items.length === 0 && empty ? (_jsx(TableRow, { variant: "empty", colSpan: fields.length || undefined, children: empty })) : items.map((item, idx) => {
549
708
  const isDragging = draggingId != null && item.id === draggingId;
550
- const 눌림 = !!onRowClick && (isRowClickable ? isRowClickable(item) : true);
709
+ const clickable = !!onRowClick && (isRowClickable ? isRowClickable(item) : true);
551
710
  /* 글자로 견준다 — URL·폼에서 온 '5' 와 자료의 5 가 같은 줄인데 `===` 로는 안 펴졌다 */
552
711
  const isExpanded = expandedId != null && item.id != null && (expandedId instanceof Set
553
712
  ? expandedId.has(item.id) || [...expandedId].some(x => String(x) === String(item.id))
554
713
  : String(item.id) === String(expandedId));
555
714
  return (_jsx(TableRow
556
715
  // 타입까지 열쇠에 — id 없는 줄(idx 0)과 id:0, 1 과 '1' 이 같은 열쇠가 되어 목록을 바꾼 뒤 옛 줄이 남았다
557
- , { className: `${눌림 ? ' table-row--clickable' : ''}${onRowDragStart ? ' table-row--draggable' : ''}${isDragging ? ' table-row--dragging' : ''}`.trim(), draggable: !!onRowDragStart,
716
+ , { className: `${clickable ? ' table-row--clickable' : ''}${onRowDragStart ? ' table-row--draggable' : ''}${isDragging ? ' table-row--dragging' : ''}`.trim(), draggable: !!onRowDragStart,
558
717
  // 옆의 핸들러들처럼 있을 때만 넘긴다. 항상 함수를 만들어 넘기면
559
718
  // TableRow 가 "클릭 가능"으로 보고 role="button" · tabIndex=0 을 붙여,
560
719
  // 클릭 동작이 없는 표의 모든 행이 버튼으로 읽히고 탭 정지점이 된다.
561
- onClick: 눌림 ? () => onRowClick(item) : undefined, colSpan: fields.length || undefined,
720
+ onClick: clickable ? () => onRowClick(item) : undefined, colSpan: fields.length || undefined,
562
721
  /* **줄 안에 진짜 단추가 서면 줄은 정지점에서 내린다** — 한 가지 일에
563
722
  정지점이 둘이면 자판으로 도는 사람이 같은 줄을 두 번 잡는다(0.254.0 에서
564
723
  접기 머리에 걷은 그 꼴). 설명도 그때는 필요 없다 — 단추가 제 이름으로 말한다. */
565
- rowHasButton: renderRow(item).some(c => !!c.rowLabel), describedBy: 줄설명id, onMouseEnter: onRowMouseEnter ? (e) => onRowMouseEnter(item, e) : undefined, onMouseLeave: onRowMouseLeave ? (e) => onRowMouseLeave(item, e) : undefined, onDragStart: onRowDragStart ? (e) => onRowDragStart(item, e) : undefined, onDragEnd: onRowDragEnd ? (e) => onRowDragEnd(item, e) : undefined, expandedContent: isExpanded && renderExpandedRow ? renderExpandedRow(item) : undefined, children: renderRow(item).map((cell, i) => (_jsx(TableCell, { className: cell.className, style: cell.style, rowLabel: 눌림 ? cell.rowLabel : undefined, onRowClick: 눌림 ? () => onRowClick(item) : undefined, children: cell.content }, i))) }, item.id != null ? `${typeof item.id}:${item.id}` : `__idx:${idx}`));
724
+ rowHasButton: renderRow(item).some(c => !!c.rowLabel), describedBy: rowHintId, onMouseEnter: onRowMouseEnter ? (e) => onRowMouseEnter(item, e) : undefined, onMouseLeave: onRowMouseLeave ? (e) => onRowMouseLeave(item, e) : undefined, onDragStart: onRowDragStart ? (e) => onRowDragStart(item, e) : undefined, onDragEnd: onRowDragEnd ? (e) => onRowDragEnd(item, e) : undefined, expandedContent: isExpanded && renderExpandedRow ? renderExpandedRow(item) : undefined, children: renderRow(item).map((cell, i) => (_jsx(TableCell, { className: cell.className, style: cell.style, rowLabel: clickable ? cell.rowLabel : undefined, onRowClick: clickable ? () => onRowClick(item) : undefined, children: cell.content }, i))) }, item.id != null ? `${typeof item.id}:${item.id}` : `__idx:${idx}`));
566
725
  }) })] }))] })] }));
567
726
  }