@ihabdevteam/core 0.322.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 (196) hide show
  1. package/CHANGELOG.md +517 -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 +54 -16
  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/Icon.js +2 -2
  24. package/dist/packages/core/src/components/ItemActions.js +16 -16
  25. package/dist/packages/core/src/components/LandingHeader.js +6 -6
  26. package/dist/packages/core/src/components/LineChart.js +24 -24
  27. package/dist/packages/core/src/components/Loader.js +5 -5
  28. package/dist/packages/core/src/components/NoticeBox.js +3 -3
  29. package/dist/packages/core/src/components/PageTitle.js +2 -2
  30. package/dist/packages/core/src/components/Pagination.js +4 -4
  31. package/dist/packages/core/src/components/Panel.js +2 -2
  32. package/dist/packages/core/src/components/PanelRow.js +6 -13
  33. package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
  34. package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
  35. package/dist/packages/core/src/components/PaymentPlan.js +5 -5
  36. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
  37. package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
  38. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +18 -25
  39. package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
  40. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +7 -7
  41. package/dist/packages/core/src/components/Popup/Popup.js +27 -27
  42. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
  43. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
  44. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
  45. package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
  46. package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
  47. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
  48. package/dist/packages/core/src/components/Rating.js +20 -20
  49. package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
  50. package/dist/packages/core/src/components/RingChart.js +5 -5
  51. package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
  52. package/dist/packages/core/src/components/SegmentedControl.js +34 -34
  53. package/dist/packages/core/src/components/SideNav.js +37 -37
  54. package/dist/packages/core/src/components/Slider.js +2 -2
  55. package/dist/packages/core/src/components/SoundCompass.js +5 -5
  56. package/dist/packages/core/src/components/Table.js +257 -98
  57. package/dist/packages/core/src/components/Tabs.js +12 -12
  58. package/dist/packages/core/src/components/TextField.js +34 -34
  59. package/dist/packages/core/src/components/Toast.js +6 -6
  60. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
  61. package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
  62. package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
  63. package/dist/packages/core/src/components/TopBanner.js +28 -28
  64. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
  65. package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
  66. package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
  67. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
  68. package/dist/packages/core/src/components/Training/TrainingBlock.js +11 -11
  69. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
  70. package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
  71. package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
  72. package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
  73. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
  74. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +81 -81
  75. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
  76. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +127 -127
  77. package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
  78. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
  79. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
  80. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +12 -12
  81. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
  82. package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
  83. package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
  84. package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
  85. package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
  86. package/dist/packages/core/src/components/Training/oneShotSound.js +13 -13
  87. package/dist/packages/core/src/components/Training/trainingMissStats.js +20 -20
  88. package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
  89. package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
  90. package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
  91. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
  92. package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
  93. package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
  94. package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
  95. package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
  96. package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
  97. package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
  98. package/dist/packages/core/src/components/UpdateAlert.js +6 -6
  99. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
  100. package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
  101. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +10 -10
  102. package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
  103. package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
  104. package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
  105. package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
  106. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
  107. package/dist/packages/core/src/components/Word/wordScoreStats.js +15 -15
  108. package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
  109. package/dist/packages/core/src/components/Word/wordTableRecommend.js +11 -11
  110. package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
  111. package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
  112. package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
  113. package/dist/packages/core/src/components/WordTable.js +130 -130
  114. package/dist/packages/core/src/components/barChartData.d.ts +16 -1
  115. package/dist/packages/core/src/components/barChartData.js +49 -29
  116. package/dist/packages/core/src/components/barChartTypes.js +8 -8
  117. package/dist/packages/core/src/components/tableTrackList.js +11 -11
  118. package/dist/packages/core/src/components/updateAlertStack.js +28 -28
  119. package/dist/packages/core/src/components/useBarChartControl.js +4 -6
  120. package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
  121. package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
  122. package/dist/packages/core/src/components/wordTableLayout.d.ts +17 -4
  123. package/dist/packages/core/src/components/wordTableLayout.js +5 -5
  124. package/dist/packages/core/src/context/I18nProvider.js +6 -6
  125. package/dist/packages/core/src/hooks/focusReturn.js +27 -27
  126. package/dist/packages/core/src/hooks/inputModality.js +13 -13
  127. package/dist/packages/core/src/hooks/touchToggle.js +39 -39
  128. package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
  129. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
  130. package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
  131. package/dist/packages/core/src/hooks/useEditableList.js +4 -4
  132. package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
  133. package/dist/packages/core/src/hooks/useFontScale.js +6 -6
  134. package/dist/packages/core/src/hooks/useIcon.js +7 -7
  135. package/dist/packages/core/src/hooks/useListControls.js +2 -2
  136. package/dist/packages/core/src/hooks/usePageResetOnChange.js +3 -3
  137. package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
  138. package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
  139. package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
  140. package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
  141. package/dist/packages/core/src/hooks/useTablePaging.d.ts +0 -11
  142. package/dist/packages/core/src/hooks/useTablePaging.js +29 -34
  143. package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
  144. package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
  145. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
  146. package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
  147. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -7
  148. package/dist/packages/core/src/i18n/locales/en.json +13 -2
  149. package/dist/packages/core/src/i18n/locales/ko.json +12 -1
  150. package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
  151. package/dist/packages/core/src/utils/audioMeter.js +5 -5
  152. package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
  153. package/dist/packages/core/src/utils/audioPeaks.js +18 -18
  154. package/dist/packages/core/src/utils/blockGrid.js +4 -4
  155. package/dist/packages/core/src/utils/chunkSkew.js +9 -9
  156. package/dist/packages/core/src/utils/clipboard.js +3 -3
  157. package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
  158. package/dist/packages/core/src/utils/date.js +12 -12
  159. package/dist/packages/core/src/utils/devWarn.js +4 -4
  160. package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
  161. package/dist/packages/core/src/utils/deviceKind.js +53 -10
  162. package/dist/packages/core/src/utils/errorMessage.js +7 -7
  163. package/dist/packages/core/src/utils/filterPanelSummary.js +14 -14
  164. package/dist/packages/core/src/utils/format.js +4 -4
  165. package/dist/packages/core/src/utils/getTrainingData.js +9 -9
  166. package/dist/packages/core/src/utils/imageConfig.js +17 -17
  167. package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
  168. package/dist/packages/core/src/utils/navScroll.js +2 -2
  169. package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
  170. package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
  171. package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
  172. package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
  173. package/dist/packages/core/src/utils/phonemePairSearch.js +118 -102
  174. package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
  175. package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
  176. package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
  177. package/dist/packages/core/src/utils/safeHref.js +4 -4
  178. package/dist/packages/core/src/utils/savedWordTables.js +5 -5
  179. package/dist/packages/core/src/utils/speak.js +12 -12
  180. package/dist/packages/core/src/utils/textSort.d.ts +0 -10
  181. package/dist/packages/core/src/utils/textSort.js +38 -48
  182. package/dist/packages/core/src/utils/theme.js +3 -3
  183. package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
  184. package/dist/packages/core/src/utils/ttsConfig.js +2 -2
  185. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +45 -45
  186. package/dist/packages/core/src/utils/wordContrast.js +3 -3
  187. package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
  188. package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
  189. package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
  190. package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
  191. package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
  192. package/dist/packages/core/src/utils/wornDate.js +14 -14
  193. package/dist/packages/core/src/version.d.ts +1 -1
  194. package/dist/packages/core/src/version.js +1 -1
  195. package/dist/utils.cjs.js +4 -4
  196. package/package.json +1 -1
@@ -26,13 +26,13 @@ export default function Tabs({ items = [], value, onChange, className = '', size
26
26
  const listRef = useRef(null);
27
27
  /* value 가 없거나 items 에 없으면 **켜진 첫 탭**을 고른 것으로 본다. 전에는 무조건 첫 탭이라, 첫 탭이 꺼져 있으면
28
28
  꺼진 탭의 판이 보이고 Tab 자리(tabIndex 0)가 꺼진 탭 하나뿐이어서 키보드로 탭 목록에 닿을 수 없었다. */
29
- const 켜진첫키 = items.find(item => !item.disabled)?.key;
30
- const activeKey = items.some(item => item.key === value) ? value : (켜진첫키 ?? items[0]?.key);
29
+ const firstEnabledKey = items.find(item => !item.disabled)?.key;
30
+ const activeKey = items.some(item => item.key === value) ? value : (firstEnabledKey ?? items[0]?.key);
31
31
  /* Tab 자리 — 고른 탭이 꺼져 있으면(부모가 꺼진 탭을 value 로 줌) 켜진 첫 탭이 받는다 */
32
- const 탭자리키 = items.find(item => item.key === activeKey && !item.disabled)?.key ?? 켜진첫키;
32
+ const tabStopKey = items.find(item => item.key === activeKey && !item.disabled)?.key ?? firstEnabledKey;
33
33
  /** 지금 화면에 선 **켜진 탭들** — 옮기는 자리는 전부 이 목록 기준이다 */
34
- const 켜진탭들 = () => Array.from(listRef.current?.querySelectorAll('[role="tab"]:not(:disabled)') ?? []);
35
- const focusTabAt = (index, tabs = 켜진탭들()) => {
34
+ const enabledTabs = () => Array.from(listRef.current?.querySelectorAll('[role="tab"]:not(:disabled)') ?? []);
35
+ const focusTabAt = (index, tabs = enabledTabs()) => {
36
36
  if (tabs.length === 0)
37
37
  return;
38
38
  const clamped = ((index % tabs.length) + tabs.length) % tabs.length;
@@ -47,23 +47,23 @@ export default function Tabs({ items = [], value, onChange, className = '', size
47
47
  * 지금 눌린 탭이 **켜진 것들 안에서 몇 번째인지**를 세어 거기서 옮긴다.
48
48
  */
49
49
  const handleKeyDown = (e) => {
50
- const 이동키 = ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'];
51
- if (!이동키.includes(e.key))
50
+ const MOVE_KEYS = ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'];
51
+ if (!MOVE_KEYS.includes(e.key))
52
52
  return;
53
53
  e.preventDefault();
54
- const tabs = 켜진탭들();
55
- const 지금 = tabs.indexOf(e.currentTarget);
54
+ const tabs = enabledTabs();
55
+ const current = tabs.indexOf(e.currentTarget);
56
56
  if (e.key === 'ArrowRight' || e.key === 'ArrowDown')
57
- focusTabAt(지금 + 1, tabs);
57
+ focusTabAt(current + 1, tabs);
58
58
  else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp')
59
- focusTabAt(지금 - 1, tabs);
59
+ focusTabAt(current - 1, tabs);
60
60
  else if (e.key === 'Home')
61
61
  focusTabAt(0, tabs);
62
62
  else
63
63
  focusTabAt(tabs.length - 1, tabs);
64
64
  };
65
65
  const activeItem = items.find(item => item.key === activeKey);
66
- return (_jsxs("div", { className: `tabs ${className}`.trim(), children: [_jsx("div", { ref: listRef, className: `tabs__list tabs__list--${size}`, role: "tablist", "aria-label": t('tabs.ariaLabel', '탭'), children: items.map(item => (_jsx("button", { id: `tabs__tab-${uid}-${item.key}`, type: "button", role: "tab", "aria-selected": item.key === activeKey, "aria-controls": `tabs__panel-${uid}-${item.key}`, disabled: item.disabled, tabIndex: item.key === 탭자리키 ? 0 : -1, className: `tabs__tab body-2${item.key === activeKey ? ' tabs__tab--active' : ''}`,
66
+ return (_jsxs("div", { className: `tabs ${className}`.trim(), children: [_jsx("div", { ref: listRef, className: `tabs__list tabs__list--${size}`, role: "tablist", "aria-label": t('tabs.ariaLabel', '탭'), children: items.map(item => (_jsx("button", { id: `tabs__tab-${uid}-${item.key}`, type: "button", role: "tab", "aria-selected": item.key === activeKey, "aria-controls": `tabs__panel-${uid}-${item.key}`, disabled: item.disabled, tabIndex: item.key === tabStopKey ? 0 : -1, className: `tabs__tab body-2${item.key === activeKey ? ' tabs__tab--active' : ''}`,
67
67
  /* 값이 바뀔 때만 onChange(2026-09-15 결정) — 지금 탭을 다시 누르면 아무 일도 없다 */
68
68
  onClick: () => { if (item.key !== value)
69
69
  onChange?.(item.key); }, onKeyDown: handleKeyDown, children: item.label }, item.key))) }), activeItem && (_jsx("div", { id: `tabs__panel-${uid}-${activeItem.key}`, role: "tabpanel", "aria-labelledby": `tabs__tab-${uid}-${activeItem.key}`, tabIndex: 0, className: "tabs__panel", children: activeItem.panel }, activeItem.key))] }));
@@ -46,61 +46,61 @@ export default function TextField(props) {
46
46
  autoActiveFirst,
47
47
  });
48
48
  /* 후보 목록이 지금 DOM 에 있는가 — 아래 렌더 조건과 **같은 식**이어야 한다 */
49
- const 목록떴나 = !!suggestions && combobox.open && combobox.filtered.length > 0;
49
+ const listOpen = !!suggestions && combobox.open && combobox.filtered.length > 0;
50
50
  /* 후보 목록은 **body 로 빼서** 칸 좌표에 fixed 로 띄운다 — 칸 아래 absolute 로 두었더니 팝업 본문(overflow: auto)에
51
51
  잘려 창 아래쪽 칸(훈련 묶음의 그룹 이름)에서 후보가 보이지 않았다. Dropdown 메뉴와 같은 방식(층·data-tooltip-ignore). */
52
52
  const rootRef = useRef(null);
53
53
  const [listStyle, setListStyle] = useState(null);
54
- const 목록자리재기 = useCallback(() => {
54
+ const measureListRect = useCallback(() => {
55
55
  const root = rootRef.current;
56
56
  if (!root || typeof window === 'undefined')
57
57
  return;
58
58
  const r = root.getBoundingClientRect();
59
- const 최대높이 = 220 + 8 + 2; // max-height + padding + 테두리
60
- const 아래 = window.innerHeight - r.bottom;
61
- const 위로 = 아래 < 최대높이 + 8 && r.top > 최대높이 + 8;
59
+ const maxHeight = 220 + 8 + 2; // max-height + padding + 테두리
60
+ const spaceBelow = window.innerHeight - r.bottom;
61
+ const openUpward = spaceBelow < maxHeight + 8 && r.top > maxHeight + 8;
62
62
  setListStyle({
63
63
  position: 'fixed',
64
64
  left: r.left,
65
65
  width: r.width,
66
- ...(위로 ? { bottom: window.innerHeight - r.top + 8 } : { top: r.bottom + 8 }),
66
+ ...(openUpward ? { bottom: window.innerHeight - r.top + 8 } : { top: r.bottom + 8 }),
67
67
  zIndex: anchoredMenuZIndex(root),
68
68
  });
69
69
  }, []);
70
70
  useIsoLayoutEffect(() => {
71
- if (!목록떴나)
71
+ if (!listOpen)
72
72
  return;
73
- 목록자리재기();
73
+ measureListRect();
74
74
  /* 굴리거나 창 크기가 바뀌면 칸을 따라간다 — 목록 안을 굴리는 것은 무시 */
75
75
  /* **틀에 한 번으로 모은다** — (115) 와 같은 꼴, 셋째 자리(2026-10-10, 코어 자체 조사). `목록자리재기` 는
76
76
  `getBoundingClientRect()` 로 레이아웃을 읽고 상태를 바꾸는데, 이 듣는 이는 **문서 전체의 굴림을
77
77
  capture 로** 받는다 — 목록이 뜬 동안 굴리면 틀마다 여러 번 돌았다. 여기는 **닫는 길이 없다**(자리만
78
78
  따라간다) — 미뤄도 되는 일뿐이다. */
79
- let 예약 = null;
80
- const 자리다시 = () => {
79
+ let rafId = null;
80
+ const reposition = () => {
81
81
  if (typeof requestAnimationFrame === 'undefined') {
82
- 목록자리재기();
82
+ measureListRect();
83
83
  return;
84
84
  }
85
- if (예약 !== null)
85
+ if (rafId !== null)
86
86
  return;
87
- 예약 = requestAnimationFrame(() => { 예약 = null; 목록자리재기(); });
87
+ rafId = requestAnimationFrame(() => { rafId = null; measureListRect(); });
88
88
  };
89
- const 굴림 = (e) => {
89
+ const onScroll = (e) => {
90
90
  const t = e.target;
91
91
  if (t && t.nodeType === 1 && t.closest?.('.text-field__suggestions'))
92
92
  return;
93
- 자리다시();
93
+ reposition();
94
94
  };
95
- window.addEventListener('scroll', 굴림, true);
96
- window.addEventListener('resize', 자리다시);
95
+ window.addEventListener('scroll', onScroll, true);
96
+ window.addEventListener('resize', reposition);
97
97
  return () => {
98
- if (예약 !== null)
99
- cancelAnimationFrame(예약);
100
- window.removeEventListener('scroll', 굴림, true);
101
- window.removeEventListener('resize', 자리다시);
98
+ if (rafId !== null)
99
+ cancelAnimationFrame(rafId);
100
+ window.removeEventListener('scroll', onScroll, true);
101
+ window.removeEventListener('resize', reposition);
102
102
  };
103
- }, [목록떴나, 목록자리재기]);
103
+ }, [listOpen, measureListRect]);
104
104
  // 외부 inputRef + 내부 autoRef 동시 연결 — 외부에서 전달받은 ref 객체(.current)에 노드를 채우는
105
105
  // React의 표준 ref 병합/전달(forwarding) 패턴이며, 이 외에 구현할 방법이 없다
106
106
  /* eslint-disable react-hooks/immutability */
@@ -114,7 +114,7 @@ export default function TextField(props) {
114
114
  /* eslint-enable react-hooks/immutability */
115
115
  /* 추천이 있으면 자리표시가 **추천 + 받는 법**이 된다. 추천이 없으면 그 칸의 평소 자리표시로 물러선다.
116
116
  (네이티브 자리표시는 칸이 비었을 때만 보이므로 "비었을 때만" 은 따로 가릴 것이 없다.) */
117
- const 자리표시 = ghostText
117
+ const placeholderGhost = ghostText
118
118
  ? `${ghostText}${ghostHint ?? t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)')}`
119
119
  : placeholder;
120
120
  const isControlled = value !== undefined;
@@ -165,7 +165,7 @@ export default function TextField(props) {
165
165
  // 단 비밀번호는 둘 다 넘기지 않는다 — 어느 쪽이든 HTML value 속성에 평문이 박힌다
166
166
  ...(isPasswordControlled ? {} : isControlled ? { value } : { defaultValue }),
167
167
  ref: setNodeRef,
168
- placeholder: 자리표시,
168
+ placeholder: placeholderGhost,
169
169
  name,
170
170
  disabled,
171
171
  readOnly,
@@ -193,14 +193,14 @@ export default function TextField(props) {
193
193
  "맞는 항목이 없습니다" 를 **잇따라** 말한다(말귀 실측). 바로 아래
194
194
  `aria-controls` 에 이미 건 가드를 여기만 안 걸고 있었다: 한 결정이 세 속성으로
195
195
  갈라져 둘만 고쳐진 꼴이다. 셋이 같은 식을 본다. */
196
- 'aria-expanded': suggestions ? 목록떴나 : undefined,
196
+ 'aria-expanded': suggestions ? listOpen : undefined,
197
197
  /* **목록이 실제로 그려졌을 때만** 가리킨다. 예전에는 `suggestions` 만 있으면 늘
198
198
  걸어 두었는데, 목록은 열려 있고 걸린 후보가 있을 때만 그려진다(아래 렌더 조건).
199
199
  그래서 닫힌 콤보박스가 **없는 id 를 가리키고** 있었다 — 놀이터에서 실측으로
200
200
  `aria-controls=_r_7l_` 이 어느 원소도 안 가리켰다. 낭독기는 그 관계를 못 풀고
201
201
  조용히 버린다. */
202
- 'aria-controls': 목록떴나 ? combobox.listId : undefined,
203
- 'aria-activedescendant': 목록떴나 && combobox.activeIdx >= 0 ? `${combobox.listId}-option-${combobox.activeIdx}` : undefined,
202
+ 'aria-controls': listOpen ? combobox.listId : undefined,
203
+ 'aria-activedescendant': listOpen && combobox.activeIdx >= 0 ? `${combobox.listId}-option-${combobox.activeIdx}` : undefined,
204
204
  onChange: (e) => {
205
205
  const node = e.target;
206
206
  if (!isControlled)
@@ -237,8 +237,8 @@ export default function TextField(props) {
237
237
  비출 추천이 있을 때만. 받은 키는 이 칸이 먹으므로 초점이 옮겨 가지 않고 `onKeyDown` 으로도
238
238
  안 간다 — 한 번 더 누르면 칸이 찼으니 평소대로 넘어간다. 조합 중(한글 중간 글자)에는 손대지
239
239
  않는다: 그때의 Space 는 글자를 맺는 키다. */
240
- const 추천받는키 = ((e.key === 'Tab' && !e.shiftKey) || e.key === ' ') && !e.nativeEvent.isComposing;
241
- if (ghostText && 추천받는키 && !readOnly && !disabled && !(e.currentTarget.value ?? '').trim()) {
240
+ const acceptsSuggestion = ((e.key === 'Tab' && !e.shiftKey) || e.key === ' ') && !e.nativeEvent.isComposing;
241
+ if (ghostText && acceptsSuggestion && !readOnly && !disabled && !(e.currentTarget.value ?? '').trim()) {
242
242
  e.preventDefault();
243
243
  const node = e.currentTarget;
244
244
  if (!isControlled) {
@@ -248,8 +248,8 @@ export default function TextField(props) {
248
248
  onChange?.(ghostText);
249
249
  return;
250
250
  }
251
- const 후보고르는엔터 = !!suggestions && e.key === 'Enter' && !e.nativeEvent.isComposing && combobox.activeIdx >= 0;
252
- if (후보고르는엔터)
251
+ const entryPicksCandidate = !!suggestions && e.key === 'Enter' && !e.nativeEvent.isComposing && combobox.activeIdx >= 0;
252
+ if (entryPicksCandidate)
253
253
  e.preventDefault();
254
254
  else if (onEnter && e.key === 'Enter' && !e.nativeEvent.isComposing) {
255
255
  e.preventDefault();
@@ -275,8 +275,8 @@ export default function TextField(props) {
275
275
  // variant="display"는 값과 placeholder 글자 크기가 크게 달라 네이티브 ::placeholder로는
276
276
  // 세로 중앙을 못 맞춘다. 전용 오버레이 div를 flex로 정중앙 정렬하고,
277
277
  // 값이 있으면(:placeholder-shown 해제) CSS로 숨긴다.
278
- const showPlaceholderOverlay = variant === 'display' && !newline && !multiline && !!자리표시;
279
- const renderInputArea = () => (_jsxs(_Fragment, { children: [renderInput(), showPlaceholderOverlay && (_jsx("span", { className: "text-field__placeholder", "aria-hidden": "true", children: 자리표시 }))] }));
278
+ const showPlaceholderOverlay = variant === 'display' && !newline && !multiline && !!placeholderGhost;
279
+ const renderInputArea = () => (_jsxs(_Fragment, { children: [renderInput(), showPlaceholderOverlay && (_jsx("span", { className: "text-field__placeholder", "aria-hidden": "true", children: placeholderGhost }))] }));
280
280
  return (_jsxs("div", { ref: rootRef, className: cls, children: [label && (_jsxs("label", { htmlFor: inputId, className: "text-field__label caption-1", children: [label, required && _jsx("span", { className: "text-field__required", children: "*" })] })), _jsxs("div", { className: "text-field__wrap", children: [leftIcon && (_jsx("span", { className: "text-field__icon text-field__icon--left", children: _jsx(Icon, { icon: leftIcon, label: "", className: "", svgClassName: "text-field__icon-svg", size: iconSize }) })), renderInputArea(), maxLength != null && showCount && (_jsxs("span", { className: `text-field__count caption-2${isOver ? ' text-field__count--over' : ''}`, children: [len, "/", maxLength] })), showClear && (_jsx(Button, { size: 24, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, className: "text-field__clear", ariaLabel: t('textField.clearAriaLabel', '입력값 지우기'),
281
281
  // 폼 안에서 쓰일 때 기본값(submit)으로 눌려 폼이 제출되지 않도록.
282
282
  htmlType: "button",
@@ -294,7 +294,7 @@ export default function TextField(props) {
294
294
  onChange?.('');
295
295
  }, tabIndex: -1 })), rightIcon && !showClear && (_jsx("span", { className: "text-field__icon text-field__icon--right", children: _jsx(Icon, { icon: rightIcon, label: "", className: "", svgClassName: "text-field__icon-svg", size: iconSize }) }))] }), !!suggestions && (_jsx("span", { className: "text-field__status", role: "status", "aria-live": "polite", children: combobox.open && (value ?? '').length > 0 && combobox.filtered.length === 0
296
296
  ? t('common.noMatches', '맞는 항목이 없습니다')
297
- : '' })), 목록떴나 && (typeof document === 'undefined' ? null : createPortal(_jsx("ul", { id: combobox.listId, className: "text-field__suggestions", role: "listbox", style: listStyle ?? { position: 'fixed', visibility: 'hidden' }, "data-tooltip-ignore": true, children: combobox.filtered.map((s, i) => (_jsx("li", { id: `${combobox.listId}-option-${i}`, role: "option", "aria-selected": i === combobox.activeIdx, className: `text-field__suggestion body-2${i === combobox.activeIdx ? ' is-active' : ''}`,
297
+ : '' })), listOpen && (typeof document === 'undefined' ? null : createPortal(_jsx("ul", { id: combobox.listId, className: "text-field__suggestions", role: "listbox", style: listStyle ?? { position: 'fixed', visibility: 'hidden' }, "data-tooltip-ignore": true, children: combobox.filtered.map((s, i) => (_jsx("li", { id: `${combobox.listId}-option-${i}`, role: "option", "aria-selected": i === combobox.activeIdx, className: `text-field__suggestion body-2${i === combobox.activeIdx ? ' is-active' : ''}`,
298
298
  // 선택은 click 에서 한다. onMouseDown 만 두면 마우스로는 눌리지만 스크린리더가
299
299
  // 이 option 을 활성화할 때는 click 만 발생해 아무 일도 일어나지 않는다.
300
300
  // mousedown 에서는 기본 동작(포커스 이동 → 입력 blur)을 막아, click 이 도달하기
@@ -11,20 +11,20 @@ onClose = () => { }, duration = 2000, // 0 = persistent until message cleared
11
11
  const { mounted, showing, popupRef, pause, resume } = useToastLifecycle({ message, position, duration, onClose });
12
12
  /* 톡(터치)은 멈추지 않는다 — 톡하면 호환 mouseenter 로 멈추고 mouseleave 는 다른 곳을 톡하기 전까지 안 와 알림이 사라지지 않았다.
13
13
  마우스 머무름·키보드 포커스는 그대로 멈춘다. 마지막 포인터 종류를 pointerenter 로 기억해 가른다. */
14
- const 마지막포인터 = useRef(null);
14
+ const lastPointerType = useRef(null);
15
15
  /* **첫 문구가 안 읽혔다** — live 영역(role=status)이 문구와 한꺼번에 문서에 들어오면 낭독기는 "바뀐 것" 으로 보지 않는다.
16
16
  영역을 먼저 붙이고, 다음 틱(등장 애니메이션이 시작되는 두 프레임 뒤)에 문구를 접근 트리에 넣는다.
17
17
  문구 노드는 처음부터 두고 `aria-hidden` 만 푼다 — 글자를 늦게 넣으면 문구를 곧바로 찾는 시험(getByText·status 의
18
18
  textContent)이 깨지고, 늘 비어 있는 status 영역을 따로 두면 getByRole('status') 가 둘을 찾는다. 이미 떠 있는 동안 바뀐 문구는 곧바로 읽힌다. */
19
- const [읽힘, set읽힘] = useState(false);
19
+ const [announced, setAnnounced] = useState(false);
20
20
  useEffect(() => {
21
21
  // 등장(showing)을 따라 한 번 켜고, 내려가면 끈다 — 외부(애니메이션 수명) 상태와의 의도된 동기화
22
22
  // eslint-disable-next-line react-hooks/set-state-in-effect
23
23
  if (showing)
24
- set읽힘(true);
24
+ setAnnounced(true);
25
25
  // eslint-disable-next-line react-hooks/set-state-in-effect
26
26
  else if (!mounted)
27
- set읽힘(false);
27
+ setAnnounced(false);
28
28
  }, [showing, mounted]);
29
29
  if (!mounted)
30
30
  return null;
@@ -35,8 +35,8 @@ onClose = () => { }, duration = 2000, // 0 = persistent until message cleared
35
35
  `duration={0}` 으로 아예 안 사라지게 하는 길은 전부터 있었지만 그건 부르는
36
36
  쪽이 미리 정하는 것이고, 읽는 사람이 **그 자리에서** 멈출 수는 없었다.
37
37
  다시 움직일 때는 **남은 시간부터** 돈다 — 처음부터가 아니다. */
38
- _jsx("div", { ref: popupRef, className: `toast toast--${position} ${showing ? 'is-visible' : ''}`, role: "status", "aria-live": "polite", onPointerEnter: (e) => { 마지막포인터.current = e.pointerType; }, onMouseEnter: () => { if (마지막포인터.current !== 'touch')
39
- pause(); }, onMouseLeave: resume, onFocusCapture: pause, onBlurCapture: resume, children: _jsx("div", { className: `toast__inner toast__inner--${normalizedType} body-2`, "aria-hidden": 읽힘 || showing ? undefined : true, children: _jsx("span", { className: "toast__text", children: message }) }) }));
38
+ _jsx("div", { ref: popupRef, className: `toast toast--${position} ${showing ? 'is-visible' : ''}`, role: "status", "aria-live": "polite", onPointerEnter: (e) => { lastPointerType.current = e.pointerType; }, onMouseEnter: () => { if (lastPointerType.current !== 'touch')
39
+ pause(); }, onMouseLeave: resume, onFocusCapture: pause, onBlurCapture: resume, children: _jsx("div", { className: `toast__inner toast__inner--${normalizedType} body-2`, "aria-hidden": announced || showing ? undefined : true, children: _jsx("span", { className: "toast__text", children: message }) }) }));
40
40
  if (typeof document !== 'undefined') {
41
41
  return createPortal(el, document.body);
42
42
  }
@@ -26,8 +26,8 @@ export default function MenuTooltip({ columns = [], onClose, className = '', siz
26
26
  return out;
27
27
  }, [columns, path]);
28
28
  const current = stack[stack.length - 1];
29
- const 보이는깊이 = stack.length - 1;
30
- const goBack = useCallback(() => setPath((p) => p.slice(0, Math.max(0, Math.min(p.length, 보이는깊이) - 1))), [보이는깊이]);
29
+ const visibleDepth = stack.length - 1;
30
+ const goBack = useCallback(() => setPath((p) => p.slice(0, Math.max(0, Math.min(p.length, visibleDepth) - 1))), [visibleDepth]);
31
31
  // 키보드 포커스 — Tooltip은 body로 포털되므로, 포커스를 옮겨주지 않으면 ⋯ 버튼을 누른
32
32
  // 키보드 사용자가 메뉴에 닿으려면 페이지 끝까지 Tab 해야 한다(사실상 도달 불가).
33
33
  // Tooltip에 이미 있는 ESC·Tab 트랩과 짝을 이루도록 첫 항목으로 포커스를 넣고,
@@ -65,7 +65,7 @@ export default function MenuTooltip({ columns = [], onClose, className = '', siz
65
65
  if (!item)
66
66
  return;
67
67
  if (Array.isArray(item.children) && item.children.length) {
68
- setPath((p) => [...p.slice(0, 보이는깊이), { ci, ii }]);
68
+ setPath((p) => [...p.slice(0, visibleDepth), { ci, ii }]);
69
69
  return;
70
70
  }
71
71
  try {
@@ -80,7 +80,7 @@ export default function MenuTooltip({ columns = [], onClose, className = '', siz
80
80
  onClose();
81
81
  }
82
82
  catch (err) { }
83
- }, [onClose, 보이는깊이]);
83
+ }, [onClose, visibleDepth]);
84
84
  return (_jsxs("div", { className: `tooltip-menu tooltip-menu--${size} ${className}`, children: [(stack.length > 1 || current.title) && (_jsxs("div", { className: "tooltip-menu__header", children: [stack.length > 1 ? (_jsx(Button, { htmlType: "button", size: itemSize, type: "dot", icon: "chevron-left", ariaLabel: t('common.back', '뒤로'), onClick: goBack, variant: "transparent-gray", useBorder: false })) : null, _jsx("div", { className: "tooltip-menu__title button-1", children: current.title })] })), _jsx("div", { className: "tooltip-menu__columns", ref: itemsWrapRef, children: current.columns && current.columns.map((col, ci) => (_jsxs("div", { className: "tooltip-menu__column", children: [col.title ? _jsx("div", { className: "tooltip-menu__column-title body-3", children: col.title }) : null, _jsx("div", { className: "tooltip-menu__items", children: (col.items || []).map((item, idx) => (_jsxs(Fragment, { children: [item.separatorBefore && idx > 0 && _jsx("div", { className: "tooltip-menu__separator", role: "separator" }), _jsx(Button, { htmlType: "button", className: "tooltip-menu__item", size: itemSize, type: item.label ? 'bar' : 'dot', icon: item.selected ? 'check' : (item.icon || 'none'), rightIcon: Array.isArray(item.children) && item.children.length
85
85
  ? 'chevron-right'
86
86
  : item.badgeCount
@@ -144,15 +144,15 @@ export default function Tooltip(props) {
144
144
  굴림을 **capture** 로 받아, 굴리는 동안 틀마다 여러 번 `computePosition` 이 돌았다(거기서 레이아웃을
145
145
  읽는다). **닫는 길은 안 미룬다** — 아래 `onScroll` 의 닫기는 그대로 즉시다. 미루면 앵커에서 떨어진
146
146
  툴팁이 한 틀 더 허공에 남는다. 미루는 것은 *자리* 뿐이다. */
147
- let 예약 = null;
147
+ let rafId = null;
148
148
  const onWindowChange = () => {
149
149
  if (typeof requestAnimationFrame === 'undefined') {
150
150
  computePosition(true);
151
151
  return;
152
152
  }
153
- if (예약 !== null)
153
+ if (rafId !== null)
154
154
  return;
155
- 예약 = requestAnimationFrame(() => { 예약 = null; computePosition(true); });
155
+ rafId = requestAnimationFrame(() => { rafId = null; computePosition(true); });
156
156
  };
157
157
  /* 고정 사각형(anchorRect)으로 띄운 툴팁은 스크롤해도 앵커를 따라갈 수 없다 — 같은 자리를 다시 계산해 **허공에** 남았다
158
158
  (목록을 굴리면 카드 메뉴가 카드에서 떨어져 뜬 채). 닫을 곳(onClose)이 있으면 닫는다. 툴팁 안 스크롤은 제외. */
@@ -175,8 +175,8 @@ export default function Tooltip(props) {
175
175
  window.addEventListener('resize', onWindowChange);
176
176
  document.addEventListener('scroll', onScroll, { passive: true, capture: true });
177
177
  return () => {
178
- if (예약 !== null)
179
- cancelAnimationFrame(예약);
178
+ if (rafId !== null)
179
+ cancelAnimationFrame(rafId);
180
180
  window.removeEventListener('resize', onWindowChange);
181
181
  document.removeEventListener('scroll', onScroll, { capture: true });
182
182
  };
@@ -218,9 +218,9 @@ export default function Tooltip(props) {
218
218
  // 배경 자신의 onClick 과 document 바깥클릭 감지가 함께 걸려 실제로 onClose 가 두 번
219
219
  // 불렸다. ref 로 "이미 닫는 중"을 즉시 표시해 한 번만 지나가게 한다(Popup 과 같은 방식).
220
220
  const closingRef = useRef(false);
221
- const 닫기타이머Ref = useRef(null);
221
+ const closeTimerRef = useRef(null);
222
222
  /* '동작 줄이기' 면 닫기 대기를 0 으로(페이드가 없으니 기다릴 까닭도 없다) */
223
- const 동작줄이기 = usePrefersReducedMotion();
223
+ const reducedMotion = usePrefersReducedMotion();
224
224
  const startClose = useCallback((immediate = false) => {
225
225
  /* 닫기를 넘길 곳(onClose)이 없으면 아무것도 하지 않는다. 예전에는 그래도 is-closing 을
226
226
  붙여, 부모가 치우지 않는 툴팁이 **흐려진 채 사라진 것처럼** 그 자리에 남았다
@@ -246,8 +246,8 @@ export default function Tooltip(props) {
246
246
  }
247
247
  setClosing(true);
248
248
  // 떠날 때 걷는다 — ESC 로 닫는 200ms 안에 부모가 치우면 떠난 뒤에 onClose 가 불렸다
249
- 닫기타이머Ref.current = setTimeout(() => {
250
- 닫기타이머Ref.current = null;
249
+ closeTimerRef.current = setTimeout(() => {
250
+ closeTimerRef.current = null;
251
251
  try {
252
252
  if (onClose)
253
253
  onClose();
@@ -256,10 +256,10 @@ export default function Tooltip(props) {
256
256
  closingRef.current = false;
257
257
  setClosing(false);
258
258
  }
259
- }, 동작줄이기 ? 0 : 200);
260
- }, [closing, onClose, setClosing, 동작줄이기]);
261
- useEffect(() => () => { if (닫기타이머Ref.current)
262
- clearTimeout(닫기타이머Ref.current); }, []);
259
+ }, reducedMotion ? 0 : 200);
260
+ }, [closing, onClose, setClosing, reducedMotion]);
261
+ useEffect(() => () => { if (closeTimerRef.current)
262
+ clearTimeout(closeTimerRef.current); }, []);
263
263
  useEffect(() => {
264
264
  /* 제자리(글 흐름 안)에 그린 툴팁은 떠 있는 층이 아니라 본문의 한 조각이다 — document 의
265
265
  바깥 누름·Esc·Tab 을 듣지 않는다. 들으면 화면 아무 데나 누르거나 Esc 를 칠 때마다
@@ -372,25 +372,25 @@ export default function Tooltip(props) {
372
372
  **조작이 있을 때만** 옮긴다 — 글만 있는 풍선(`InfoTooltip`·`DescriptionTooltip`)에서
373
373
  초점을 뺏으면 읽던 자리를 잃는다. **재고 난 뒤**에 옮긴다: 재기 전에는
374
374
  `visibility: hidden` 이라 초점이 안 선다. */
375
- const 열기전초점 = useRef(null);
375
+ const focusBeforeOpenRef = useRef(null);
376
376
  useEffect(() => {
377
377
  if (isInline || focusOnOpen === false || !measured)
378
378
  return;
379
379
  const el = ref.current;
380
380
  if (!el)
381
381
  return;
382
- const 첫조작 = el.querySelector(FOCUSABLE_SELECTOR);
383
- if (!첫조작)
382
+ const firstFocusable = el.querySelector(FOCUSABLE_SELECTOR);
383
+ if (!firstFocusable)
384
384
  return;
385
- 열기전초점.current = document.activeElement;
386
- 첫조작.focus();
385
+ focusBeforeOpenRef.current = document.activeElement;
386
+ firstFocusable.focus();
387
387
  return () => {
388
- const 돌아갈곳 = 열기전초점.current;
389
- 열기전초점.current = null;
388
+ const returnTo = focusBeforeOpenRef.current;
389
+ focusBeforeOpenRef.current = null;
390
390
  /* 닫을 때 **열었던 자리로 돌려준다** — 안 돌려주면 초점이 `body` 로 떨어져
391
391
  자판 쓰는 사람이 처음부터 다시 Tab 해야 한다. 그 사이 사라진 마디면 안 옮긴다. */
392
- if (돌아갈곳 && document.contains(돌아갈곳))
393
- 돌아갈곳.focus();
392
+ if (returnTo && document.contains(returnTo))
393
+ returnTo.focus();
394
394
  };
395
395
  }, [isInline, focusOnOpen, measured]);
396
396
  /* 이름 없는 `role="dialog"` 는 낭독기가 "대화상자" 라고만 읽는다 — 무슨 창인지 알 수 없다.
@@ -32,15 +32,15 @@ export default function TrainingTooltip({ plan, groups: givenGroups, anchorRect,
32
32
  (111) 을 넣으며 한 번 "찾은 자료가 있어야" 로 바꿨다가 옛 못 넷이 울었다 — 풀에 없는 항목만 든
33
33
  계획에도 상자는 떠야 한다(타일 호버가 그 상자를 기다린다). 두 길의 "빈 묶음" 기준이 다른 까닭이다. */
34
34
  return translateTrainingTypes(t).map(type => {
35
- const 고른것 = (plan?.items ?? []).filter(i => i.trainingType === type.key);
35
+ const picked = (plan?.items ?? []).filter(i => i.trainingType === type.key);
36
36
  return {
37
37
  key: type.key,
38
38
  label: type.label,
39
39
  type,
40
- 고른수: 고른것.length,
41
- data: 고른것.map(findTrainingData).filter((td) => td != null),
40
+ pickedCount: picked.length,
41
+ data: picked.map(findTrainingData).filter((td) => td != null),
42
42
  };
43
- }).filter(g => g.고른수 > 0);
43
+ }).filter(g => g.pickedCount > 0);
44
44
  }, [givenGroups, plan?.items, t]);
45
45
  if (groups.length === 0)
46
46
  return null;
@@ -27,71 +27,71 @@ export default function TopBanner({ children, interval = 5000, dotTone, classNam
27
27
  /* 터치 — 누르는 동안과 뗀 뒤 5초는 멈춘다(배너 밖을 톡하면 곧바로 다시 돈다). 전에는 톡의 호환 mouseenter 로 멈추고 mouseleave 가
28
28
  안 와 영영 멈추거나, 누르고 읽는 사이 장이 넘어갔다. 마지막 포인터가 터치면 mouseenter 는 멈춤으로 치지 않는다. */
29
29
  const [touched, setTouched] = useState(false);
30
- const 마지막포인터 = useRef(null);
31
- const 터치타이머 = useRef(null);
30
+ const lastPointerType = useRef(null);
31
+ const touchTimer = useRef(null);
32
32
  const sectionRef = useRef(null);
33
33
  /* '동작 줄이기' — 저절로 넘어가는 장은 움직임이고 읽는 사이 내용이 바뀐다(WCAG 2.2.2). 자동 롤링을 끈다 */
34
- const 동작줄이기 = usePrefersReducedMotion();
34
+ const reducedMotion = usePrefersReducedMotion();
35
35
  const paused = hovered || focused || touched;
36
36
  /* 슬라이드 수가 줄면 **지금 장을 범위 안으로 조인다** — `index % count` 는 3장 중 셋째(2)를 보다 2장이 되면 첫째(0)로 튀었다 */
37
37
  const current = count > 0 ? Math.min(index, count - 1) : 0;
38
38
  useEffect(() => {
39
- if (count < 2 || interval <= 0 || paused || 동작줄이기)
39
+ if (count < 2 || interval <= 0 || paused || reducedMotion)
40
40
  return;
41
41
  const id = window.setInterval(() => setIndex(i => (Math.min(i, count - 1) + 1) % count), interval);
42
42
  return () => window.clearInterval(id);
43
- }, [count, interval, paused, 동작줄이기]);
44
- const 터치재개예약 = (ms) => {
45
- if (터치타이머.current != null)
46
- window.clearTimeout(터치타이머.current);
47
- 터치타이머.current = window.setTimeout(() => { 터치타이머.current = null; setTouched(false); }, ms);
43
+ }, [count, interval, paused, reducedMotion]);
44
+ const scheduleTouchResume = (ms) => {
45
+ if (touchTimer.current != null)
46
+ window.clearTimeout(touchTimer.current);
47
+ touchTimer.current = window.setTimeout(() => { touchTimer.current = null; setTouched(false); }, ms);
48
48
  };
49
- useEffect(() => () => { if (터치타이머.current != null)
50
- window.clearTimeout(터치타이머.current); }, []);
49
+ useEffect(() => () => { if (touchTimer.current != null)
50
+ window.clearTimeout(touchTimer.current); }, []);
51
51
  /* 배너 밖을 톡하면 곧바로 다시 돈다 */
52
52
  useEffect(() => {
53
53
  if (!touched)
54
54
  return;
55
- const 밖톡 = (e) => {
55
+ const onOutsidePress = (e) => {
56
56
  if (e.pointerType !== 'touch')
57
57
  return;
58
58
  if (sectionRef.current && sectionRef.current.contains(e.target))
59
59
  return;
60
- if (터치타이머.current != null) {
61
- window.clearTimeout(터치타이머.current);
62
- 터치타이머.current = null;
60
+ if (touchTimer.current != null) {
61
+ window.clearTimeout(touchTimer.current);
62
+ touchTimer.current = null;
63
63
  }
64
64
  setTouched(false);
65
65
  };
66
- document.addEventListener('pointerdown', 밖톡, true);
67
- return () => document.removeEventListener('pointerdown', 밖톡, true);
66
+ document.addEventListener('pointerdown', onOutsidePress, true);
67
+ return () => document.removeEventListener('pointerdown', onOutsidePress, true);
68
68
  }, [touched]);
69
69
  if (count === 0)
70
70
  return null;
71
- return (_jsxs("section", { ref: sectionRef, className: cx('top-banner', className), "aria-label": ariaLabel, "aria-roledescription": "carousel", onPointerEnter: (e) => { 마지막포인터.current = e.pointerType; }, onPointerDown: (e) => {
72
- 마지막포인터.current = e.pointerType;
71
+ return (_jsxs("section", { ref: sectionRef, className: cx('top-banner', className), "aria-label": ariaLabel, "aria-roledescription": "carousel", onPointerEnter: (e) => { lastPointerType.current = e.pointerType; }, onPointerDown: (e) => {
72
+ lastPointerType.current = e.pointerType;
73
73
  if (e.pointerType !== 'touch')
74
74
  return;
75
- if (터치타이머.current != null) {
76
- window.clearTimeout(터치타이머.current);
77
- 터치타이머.current = null;
75
+ if (touchTimer.current != null) {
76
+ window.clearTimeout(touchTimer.current);
77
+ touchTimer.current = null;
78
78
  }
79
79
  setTouched(true);
80
80
  }, onPointerUp: (e) => { if (e.pointerType === 'touch')
81
- 터치재개예약(5000); }, onPointerCancel: (e) => { if (e.pointerType === 'touch')
82
- 터치재개예약(5000); }, onMouseEnter: () => { if (마지막포인터.current !== 'touch')
81
+ scheduleTouchResume(5000); }, onPointerCancel: (e) => { if (e.pointerType === 'touch')
82
+ scheduleTouchResume(5000); }, onMouseEnter: () => { if (lastPointerType.current !== 'touch')
83
83
  setHovered(true); }, onMouseLeave: () => setHovered(false),
84
84
  /* 포커스 멈춤은 **보이는 포커스(:focus-visible)** 일 때만 — 마우스로 누른 단추(점·배너 단추)에 남은 포커스로 멈추면
85
85
  마우스가 떠난 뒤에도 영영 멈춰 있었다. 키보드로 들어온 포커스는 그대로 멈춘다(0545ddf) */
86
86
  onFocus: (e) => {
87
- let 보임 = true;
87
+ let alive = true;
88
88
  try {
89
- 보임 = e.target.matches(':focus-visible');
89
+ alive = e.target.matches(':focus-visible');
90
90
  }
91
91
  catch {
92
- 보임 = true;
92
+ alive = true;
93
93
  }
94
- setFocused(보임);
94
+ setFocused(alive);
95
95
  }, onBlur: (e) => { if (!e.currentTarget.contains(e.relatedTarget))
96
96
  setFocused(false); }, children: [_jsx("div", { className: "top-banner__viewport", children: _jsx("div", { className: "top-banner__track", style: { transform: `translateX(-${current * 100}%)` }, children: slides.map((slide, i) => (_jsx("div", { className: "top-banner__slide", "aria-hidden": i !== current, ...(i !== current ? { inert: true } : {}), children: slide }, i))) }) }), count > 1 && (_jsx("div", { className: `top-banner__dots${(dotTone?.[current] ?? 'light') === 'dark' ? ' top-banner__dots--dark' : ''}`, children: slides.map((_, i) => (_jsx("button", { type: "button", className: `top-banner__dot${i === current ? ' top-banner__dot--active' : ''}`, "aria-label": t('topBanner.dotAriaLabel', {
97
97
  count, index: i + 1, defaultValue: `${count}개 중 ${i + 1}번째 배너`,
@@ -197,16 +197,16 @@ export default function WaveForm(props) {
197
197
  * 소리는 가운데 절반만 차지한다. 뜸이 0 이면 `{ lead: 0, sound: 전부, tail: 0 }` 이라
198
198
  * 아래 셈이 **전과 똑같이** 돈다.
199
199
  */
200
- const 구간 = useMemo(() => padBarSpans({ bars: barsCountForMix, trackDurationSec, leadInMs, tailMs }), [barsCountForMix, trackDurationSec, leadInMs, tailMs]);
200
+ const bandCounts = useMemo(() => padBarSpans({ bars: barsCountForMix, trackDurationSec, leadInMs, tailMs }), [barsCountForMix, trackDurationSec, leadInMs, tailMs]);
201
201
  const bars = useMemo(() => Array.from({ length: barsCountForMix }).map((_, i) => {
202
202
  /* 뜸 구간 막대는 소리가 없다 — 봉우리 0. 그 자리는 **소음 파형이 채운다**(아래 바닥값).
203
203
  소리 구간은 봉우리를 그 폭에 다시 고른다(전에는 막대 번호가 곧 봉우리 번호였다). */
204
- const 소리안 = i >= 구간.lead && i < 구간.lead + 구간.sound;
205
- const 봉우리번호 = 구간.sound > 0 && 소리안
206
- ? Math.min(barsCountForMix - 1, Math.round(((i - 구간.lead) / 구간.sound) * barsCountForMix))
204
+ const inSound = i >= bandCounts.lead && i < bandCounts.lead + bandCounts.sound;
205
+ const peakIndex = bandCounts.sound > 0 && inSound
206
+ ? Math.min(barsCountForMix - 1, Math.round(((i - bandCounts.lead) / bandCounts.sound) * barsCountForMix))
207
207
  : -1;
208
- const peak = 소리안
209
- ? (wavePeaks && wavePeaks[봉우리번호] != null ? wavePeaks[봉우리번호] : null)
208
+ const peak = inSound
209
+ ? (wavePeaks && wavePeaks[peakIndex] != null ? wavePeaks[peakIndex] : null)
210
210
  : (wavePeaks ? 0 : null);
211
211
  const eff = peak == null ? null : Math.min(1, (peak / sentenceMaxPeak) * mainAmp);
212
212
  // 로딩 시 placeholder(0.15); 계산 후엔 렌더 높이(waveBarPx)가 MIN_BAR_PX 이하면 숨김(0).
@@ -233,14 +233,14 @@ export default function WaveForm(props) {
233
233
  거기는 정말로 아무 소리도 안 난다.
234
234
  작은 막대를 숨기는 옛 규칙 하나만 남는다(소음이 있든 없든 같다). */
235
235
  const val = eff == null ? 0.15 : (belowMin ? 0 : waveBarVal(eff));
236
- const 숨긴다 = belowMin;
236
+ const hidden = belowMin;
237
237
  // 가로 위치 비율(0=왼쪽 lightMain ~ 1=오른쪽 lightSub) — 바 색을 위치에 따라 믹스
238
238
  const mix = barsCountForMix > 1 ? i / (barsCountForMix - 1) : 0;
239
239
  const style = { ['--val']: val, ['--i']: i, ['--mix']: mix };
240
240
  // MIN_BAR_PX 이하로 숨겨지는 바는 최초 등장 애니메이션에서도 잠깐 비쳐 보이지 않도록 애니메이션 자체를 건너뛴다
241
- const className = 숨긴다 ? 'audio-player__bar audio-player__bar--hidden' : 'audio-player__bar';
241
+ const className = hidden ? 'audio-player__bar audio-player__bar--hidden' : 'audio-player__bar';
242
242
  return _jsx("div", { className: className, style: style }, i);
243
- }), [barsCountForMix, wavePeaks, sentenceMaxPeak, mainAmp, waveHeightPx, 구간]);
243
+ }), [barsCountForMix, wavePeaks, sentenceMaxPeak, mainAmp, waveHeightPx, bandCounts]);
244
244
  // 초기 progress CSS 변수 값 (React 상태 기준 — RAF 루프가 이후 직접 갱신합니다)
245
245
  const progressStyle = { '--progress': String(Math.max(0, Math.min(1, progress))) };
246
246
  // 컨테이너 실제 폭에 맞춰 정밀 계산한 바 간격 — outer에 걸어 .audio-player__wave-inner 등
@@ -292,10 +292,10 @@ export default function WaveForm(props) {
292
292
  * **정말로** 문장보다 커질 때만 자라고, 그때는 그 큰 막대가 꼭대기에 닿는다.
293
293
  */
294
294
  /* 가장 큰 소음 막대가 `M × R` 이므로((89)) 그것이 곧 그릴 최대다 — 소음이 아직 안 왔으면 1 */
295
- const 그릴최대 = (!noiseSrc || !noisePeaks || !noisePeaks.length) ? 1 : Math.max(1, noiseAmp);
295
+ const drawMax = (!noiseSrc || !noisePeaks || !noisePeaks.length) ? 1 : Math.max(1, noiseAmp);
296
296
  const outerStyle = {
297
297
  '--bar-gap': `${barGap}px`,
298
- '--wave-h': `${waveBarPx(그릴최대)}px`,
298
+ '--wave-h': `${waveBarPx(drawMax)}px`,
299
299
  '--wave-h1': `${WAVE_H1_PX}px`,
300
300
  };
301
301
  const noiseReady = !!noisePeaks;
@@ -32,7 +32,7 @@ export default function CategoryIcon({ icon, label, tooltip, wrapperStyle, color
32
32
  * 서면 (105) 의 반대 거짓이다. 그래서 **말풍선이 설 때만** 이 표를 붙이고, 호버 테두리는
33
33
  * `--interactive` 에만 산다. 보여 줄 글이 없으면(`tipText` 없음) 올려도 아무 일이 없으니 표도 없다.
34
34
  */
35
- const 반응하나 = useHover && Boolean(tipText);
35
+ const interactive = useHover && Boolean(tipText);
36
36
  const [anchorRect, setAnchorRect] = useState(null);
37
37
  useLayoutEffect(() => {
38
38
  if (hover)
@@ -85,5 +85,5 @@ export default function CategoryIcon({ icon, label, tooltip, wrapperStyle, color
85
85
  setHover(v => !v);
86
86
  }
87
87
  }, []);
88
- return (_jsxs("span", { ref: ref, className: cx('category-icon__wrap', 반응하나 && 'category-icon__wrap--interactive'), onPointerEnter: handleHoverOn, onPointerLeave: handleHoverOff, onFocus: handleHoverOn, onBlur: handleHoverOff, onKeyDown: handleKeyDown, role: "button", tabIndex: 0, "aria-label": typeof tipText === 'string' ? t('common.descriptionFor', { name: tipText, defaultValue: `${tipText} 설명` }) : undefined, "aria-haspopup": !!tipText, "aria-expanded": hover, "data-tooltip-ignore": true, children: [_jsx(Icon, { icon: icon, label: label, className: cls, imgClassName: "category-icon__image", maskClassName: "category-icon__mask", svgClassName: "category-icon__svg", wrapperStyle: resolvedWrapperStyle, size: svgSize }), hover && tipText && (_jsx(InfoTooltip, { anchorRect: anchorRect, text: typeof tipText === 'string' ? tipText : undefined, offset: 8, onClose: handleTooltipClose, className: "category-icon__tooltip", ariaLabel: typeof tipText === 'string' ? tipText : undefined }))] }));
88
+ return (_jsxs("span", { ref: ref, className: cx('category-icon__wrap', interactive && 'category-icon__wrap--interactive'), onPointerEnter: handleHoverOn, onPointerLeave: handleHoverOff, onFocus: handleHoverOn, onBlur: handleHoverOff, onKeyDown: handleKeyDown, role: "button", tabIndex: 0, "aria-label": typeof tipText === 'string' ? t('common.descriptionFor', { name: tipText, defaultValue: `${tipText} 설명` }) : undefined, "aria-haspopup": !!tipText, "aria-expanded": hover, "data-tooltip-ignore": true, children: [_jsx(Icon, { icon: icon, label: label, className: cls, imgClassName: "category-icon__image", maskClassName: "category-icon__mask", svgClassName: "category-icon__svg", wrapperStyle: resolvedWrapperStyle, size: svgSize }), hover && tipText && (_jsx(InfoTooltip, { anchorRect: anchorRect, text: typeof tipText === 'string' ? tipText : undefined, offset: 8, onClose: handleTooltipClose, className: "category-icon__tooltip", ariaLabel: typeof tipText === 'string' ? tipText : undefined }))] }));
89
89
  }