@ihabdevteam/core 0.150.0 → 0.152.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 (218) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/components.cjs.js +199 -186
  3. package/dist/components.esm.css +5 -5
  4. package/dist/hooks.cjs.js +5 -1
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core-tokens.css +1 -1
  7. package/dist/ihabdevteam-core.cjs.js +200 -187
  8. package/dist/ihabdevteam-core.css +1 -1
  9. package/dist/ihabdevteam-core.d.ts +405 -67
  10. package/dist/packages/core/src/components/Accordion.d.ts +6 -0
  11. package/dist/packages/core/src/components/Accordion.js +5 -2
  12. package/dist/packages/core/src/components/Alert.d.ts +7 -1
  13. package/dist/packages/core/src/components/Alert.js +12 -3
  14. package/dist/packages/core/src/components/BarChart.js +7 -2
  15. package/dist/packages/core/src/components/Board.d.ts +5 -2
  16. package/dist/packages/core/src/components/Board.js +9 -3
  17. package/dist/packages/core/src/components/BottomNav.js +5 -4
  18. package/dist/packages/core/src/components/Breadcrumb.css +9 -0
  19. package/dist/packages/core/src/components/Breadcrumb.d.ts +12 -1
  20. package/dist/packages/core/src/components/Breadcrumb.js +8 -2
  21. package/dist/packages/core/src/components/BulkBar.css +2 -0
  22. package/dist/packages/core/src/components/BulkBar.js +21 -2
  23. package/dist/packages/core/src/components/Button.d.ts +12 -2
  24. package/dist/packages/core/src/components/Button.js +70 -13
  25. package/dist/packages/core/src/components/Calendar.d.ts +6 -1
  26. package/dist/packages/core/src/components/Calendar.js +3 -3
  27. package/dist/packages/core/src/components/ChartSection.css +11 -4
  28. package/dist/packages/core/src/components/DatePicker.d.ts +7 -1
  29. package/dist/packages/core/src/components/DatePicker.js +25 -15
  30. package/dist/packages/core/src/components/Dropdown.d.ts +1 -0
  31. package/dist/packages/core/src/components/Dropdown.js +7 -5
  32. package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.d.ts +12 -1
  33. package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +25 -8
  34. package/dist/packages/core/src/components/EmptyState.d.ts +5 -1
  35. package/dist/packages/core/src/components/EmptyState.js +4 -1
  36. package/dist/packages/core/src/components/ErrorState.d.ts +5 -2
  37. package/dist/packages/core/src/components/ErrorState.js +3 -1
  38. package/dist/packages/core/src/components/FileUpload.d.ts +9 -1
  39. package/dist/packages/core/src/components/FileUpload.js +8 -2
  40. package/dist/packages/core/src/components/FilterSectionInfo.js +14 -2
  41. package/dist/packages/core/src/components/Header.js +8 -5
  42. package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -1
  43. package/dist/packages/core/src/components/Icon.d.ts +2 -1
  44. package/dist/packages/core/src/components/Icon.js +4 -1
  45. package/dist/packages/core/src/components/Loader.css +28 -0
  46. package/dist/packages/core/src/components/Loader.d.ts +5 -1
  47. package/dist/packages/core/src/components/Loader.js +4 -2
  48. package/dist/packages/core/src/components/NoticeBox.d.ts +1 -1
  49. package/dist/packages/core/src/components/NoticeBox.js +15 -6
  50. package/dist/packages/core/src/components/PanelRow.js +2 -1
  51. package/dist/packages/core/src/components/PassCard/PassCard.d.ts +8 -2
  52. package/dist/packages/core/src/components/PassCard/PassCard.js +25 -10
  53. package/dist/packages/core/src/components/PassCard/passCardProduct.d.ts +1 -1
  54. package/dist/packages/core/src/components/PassCard/passCardProduct.js +11 -2
  55. package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +11 -2
  56. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +18 -3
  57. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +10 -8
  58. package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
  59. package/dist/packages/core/src/components/Popup/LoadTablePopup.css +7 -0
  60. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +27 -5
  61. package/dist/packages/core/src/components/Popup/Popup.d.ts +8 -0
  62. package/dist/packages/core/src/components/Popup/Popup.js +6 -3
  63. package/dist/packages/core/src/components/Popup/SaveTablePopup.d.ts +5 -1
  64. package/dist/packages/core/src/components/Popup/TrainingResultPopup.css +3 -0
  65. package/dist/packages/core/src/components/Popup/TrainingResultPopup.d.ts +5 -0
  66. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +10 -4
  67. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.css +9 -0
  68. package/dist/packages/core/src/components/ProgressIndicator.css +5 -0
  69. package/dist/packages/core/src/components/ProgressIndicator.d.ts +3 -1
  70. package/dist/packages/core/src/components/ProgressIndicator.js +5 -3
  71. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.d.ts +4 -1
  72. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +27 -15
  73. package/dist/packages/core/src/components/Rating.d.ts +8 -2
  74. package/dist/packages/core/src/components/Rating.js +22 -8
  75. package/dist/packages/core/src/components/Report/ReportDocument.d.ts +10 -7
  76. package/dist/packages/core/src/components/Report/ReportDocument.js +39 -13
  77. package/dist/packages/core/src/components/RingChart.d.ts +1 -1
  78. package/dist/packages/core/src/components/RingChart.js +1 -1
  79. package/dist/packages/core/src/components/SegmentedControl.d.ts +4 -0
  80. package/dist/packages/core/src/components/SegmentedControl.js +9 -1
  81. package/dist/packages/core/src/components/ShortcutBadge.css +16 -1
  82. package/dist/packages/core/src/components/ShortcutBadge.d.ts +1 -0
  83. package/dist/packages/core/src/components/SideNav.js +8 -2
  84. package/dist/packages/core/src/components/SummaryStrip.d.ts +1 -1
  85. package/dist/packages/core/src/components/SummaryStrip.js +2 -2
  86. package/dist/packages/core/src/components/Table.d.ts +1 -1
  87. package/dist/packages/core/src/components/Table.js +4 -1
  88. package/dist/packages/core/src/components/Tabs.d.ts +1 -0
  89. package/dist/packages/core/src/components/Tabs.js +4 -1
  90. package/dist/packages/core/src/components/TextField.css +5 -6
  91. package/dist/packages/core/src/components/TextField.js +46 -3
  92. package/dist/packages/core/src/components/Toast.js +16 -2
  93. package/dist/packages/core/src/components/Tooltip/Tooltip.css +3 -1
  94. package/dist/packages/core/src/components/Tooltip/Tooltip.js +5 -2
  95. package/dist/packages/core/src/components/Tooltip/tooltipConstants.d.ts +10 -2
  96. package/dist/packages/core/src/components/Tooltip/tooltipConstants.js +10 -2
  97. package/dist/packages/core/src/components/TopBanner.d.ts +1 -1
  98. package/dist/packages/core/src/components/TopBanner.js +66 -8
  99. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.d.ts +1 -0
  100. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +65 -15
  101. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +3 -2
  102. package/dist/packages/core/src/components/Training/NoisePlayerContext.js +10 -4
  103. package/dist/packages/core/src/components/Training/TrainingBlockButton.css +7 -0
  104. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +3 -1
  105. package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +18 -5
  106. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +93 -28
  107. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +1 -0
  108. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +16 -3
  109. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +25 -9
  110. package/dist/packages/core/src/components/Training/TrainingStepCard.js +19 -3
  111. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +15 -1
  112. package/dist/packages/core/src/components/Training/TrainingWordCard.css +16 -0
  113. package/dist/packages/core/src/components/Training/TrainingWordCard.d.ts +6 -0
  114. package/dist/packages/core/src/components/Training/TrainingWordCard.js +7 -2
  115. package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +2 -2
  116. package/dist/packages/core/src/components/Training/trainingMissStats.d.ts +10 -0
  117. package/dist/packages/core/src/components/Training/trainingMissStats.js +19 -1
  118. package/dist/packages/core/src/components/Training/useFloatingFocus.d.ts +13 -0
  119. package/dist/packages/core/src/components/Training/useFloatingFocus.js +59 -0
  120. package/dist/packages/core/src/components/Training/useNoiseFade.js +20 -11
  121. package/dist/packages/core/src/components/Training/useProgressTicker.d.ts +0 -18
  122. package/dist/packages/core/src/components/Training/useProgressTicker.js +59 -14
  123. package/dist/packages/core/src/components/Training/useTTSPlayback.d.ts +1 -1
  124. package/dist/packages/core/src/components/Training/useTTSPlayback.js +56 -25
  125. package/dist/packages/core/src/components/Training/useWaveReady.d.ts +4 -1
  126. package/dist/packages/core/src/components/Training/useWaveReady.js +17 -8
  127. package/dist/packages/core/src/components/UpdateAlert.css +4 -2
  128. package/dist/packages/core/src/components/UpdateAlert.d.ts +3 -1
  129. package/dist/packages/core/src/components/UpdateAlert.js +21 -1
  130. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.d.ts +2 -0
  131. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +12 -4
  132. package/dist/packages/core/src/components/Word/WordSearchResultBlock.d.ts +6 -0
  133. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +26 -13
  134. package/dist/packages/core/src/components/Word/WordTableBlock.js +24 -21
  135. package/dist/packages/core/src/components/Word/WordTableThumbnail.js +1 -1
  136. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -10
  137. package/dist/packages/core/src/components/Word/useWordTablePrint.d.ts +1 -1
  138. package/dist/packages/core/src/components/Word/useWordTablePrint.js +9 -3
  139. package/dist/packages/core/src/components/Word/wordScoreStats.d.ts +14 -1
  140. package/dist/packages/core/src/components/Word/wordScoreStats.js +7 -3
  141. package/dist/packages/core/src/components/Word/wordTablePrint.js +15 -3
  142. package/dist/packages/core/src/components/Word/wordTableReview.d.ts +58 -1
  143. package/dist/packages/core/src/components/Word/wordTableReview.js +75 -17
  144. package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +9 -0
  145. package/dist/packages/core/src/components/Word/wordTableSettings.js +11 -0
  146. package/dist/packages/core/src/components/WordTable.css +10 -0
  147. package/dist/packages/core/src/components/WordTable.js +39 -26
  148. package/dist/packages/core/src/components/barChartTypes.d.ts +2 -2
  149. package/dist/packages/core/src/components/buttonTypes.d.ts +8 -0
  150. package/dist/packages/core/src/components/updateAlertStack.d.ts +2 -0
  151. package/dist/packages/core/src/components/updateAlertStack.js +68 -0
  152. package/dist/packages/core/src/components/useCalendarMonth.d.ts +4 -1
  153. package/dist/packages/core/src/components/useCalendarMonth.js +6 -3
  154. package/dist/packages/core/src/components/useComboboxSuggestions.d.ts +7 -0
  155. package/dist/packages/core/src/components/useComboboxSuggestions.js +21 -2
  156. package/dist/packages/core/src/components/wordTableLayout.d.ts +4 -0
  157. package/dist/packages/core/src/components/wordTableLayout.js +6 -0
  158. package/dist/packages/core/src/components.d.ts +2 -1
  159. package/dist/packages/core/src/components.js +1 -1
  160. package/dist/packages/core/src/hooks/bodyScrollLock.js +8 -2
  161. package/dist/packages/core/src/hooks/focusReturn.d.ts +12 -0
  162. package/dist/packages/core/src/hooks/focusReturn.js +49 -0
  163. package/dist/packages/core/src/hooks/touchToggle.d.ts +22 -0
  164. package/dist/packages/core/src/hooks/touchToggle.js +114 -0
  165. package/dist/packages/core/src/hooks/useAnchoredMenu.d.ts +12 -3
  166. package/dist/packages/core/src/hooks/useAnchoredMenu.js +37 -3
  167. package/dist/packages/core/src/hooks/useDailyTrainingSelection.d.ts +4 -2
  168. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +23 -11
  169. package/dist/packages/core/src/hooks/useIcon.js +29 -5
  170. package/dist/packages/core/src/hooks/useLucideIcon.d.ts +4 -0
  171. package/dist/packages/core/src/hooks/useLucideIcon.js +4 -0
  172. package/dist/packages/core/src/hooks/usePendingPress.d.ts +9 -0
  173. package/dist/packages/core/src/hooks/usePendingPress.js +26 -0
  174. package/dist/packages/core/src/hooks/usePrefersReducedMotion.d.ts +15 -0
  175. package/dist/packages/core/src/hooks/usePrefersReducedMotion.js +26 -0
  176. package/dist/packages/core/src/hooks/useTablePaging.d.ts +27 -2
  177. package/dist/packages/core/src/hooks/useTablePaging.js +37 -5
  178. package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +7 -0
  179. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +25 -0
  180. package/dist/packages/core/src/i18n/index.js +9 -1
  181. package/dist/packages/core/src/i18n/locales/en.json +24 -2
  182. package/dist/packages/core/src/i18n/locales/ko.json +24 -2
  183. package/dist/packages/core/src/tokens.css +5 -0
  184. package/dist/packages/core/src/types/wordSearch.d.ts +12 -0
  185. package/dist/packages/core/src/utils/csv.d.ts +7 -2
  186. package/dist/packages/core/src/utils/csv.js +12 -5
  187. package/dist/packages/core/src/utils/dailySelectionUtils.js +44 -14
  188. package/dist/packages/core/src/utils/date.d.ts +8 -0
  189. package/dist/packages/core/src/utils/date.js +44 -10
  190. package/dist/packages/core/src/utils/format.d.ts +1 -1
  191. package/dist/packages/core/src/utils/format.js +6 -7
  192. package/dist/packages/core/src/utils/noiseSettingsStore.d.ts +7 -3
  193. package/dist/packages/core/src/utils/phonemeConvert.d.ts +9 -0
  194. package/dist/packages/core/src/utils/phonemeConvert.js +11 -0
  195. package/dist/packages/core/src/utils/phonemePairSearch.d.ts +7 -1
  196. package/dist/packages/core/src/utils/phonemePairSearch.js +45 -21
  197. package/dist/packages/core/src/utils/safeHref.d.ts +8 -0
  198. package/dist/packages/core/src/utils/safeHref.js +24 -0
  199. package/dist/packages/core/src/utils/savedWordTables.d.ts +19 -3
  200. package/dist/packages/core/src/utils/savedWordTables.js +27 -19
  201. package/dist/packages/core/src/utils/textSort.d.ts +9 -0
  202. package/dist/packages/core/src/utils/textSort.js +37 -1
  203. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +31 -0
  204. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +163 -0
  205. package/dist/packages/core/src/utils/wordContrast.d.ts +4 -2
  206. package/dist/packages/core/src/utils/wordContrast.js +8 -2
  207. package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +5 -2
  208. package/dist/packages/core/src/utils/wordTrainingGenerators.js +52 -2
  209. package/dist/packages/core/src/utils/wordTrainingPool.js +2 -2
  210. package/dist/packages/core/src/utils/wordTrainingRuntime.js +15 -7
  211. package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.d.ts +1 -1
  212. package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.js +5 -4
  213. package/dist/packages/core/src/utils/wornDate.d.ts +12 -0
  214. package/dist/packages/core/src/utils/wornDate.js +15 -0
  215. package/dist/packages/core/src/version.d.ts +1 -1
  216. package/dist/packages/core/src/version.js +1 -1
  217. package/dist/utils.cjs.js +2 -2
  218. package/package.json +1 -1
@@ -16,12 +16,27 @@
16
16
  * 알게 되므로 몇 건을 뺐는지 함께 돌려준다.
17
17
  */
18
18
  import { 한글순 } from '../../utils/textSort';
19
+ /** 같은 시각 두 행의 차례 — 둘 다 id 가 있을 때만 가린다(숫자는 수 크기, 그 밖은 글자 사전순). 못 가리면 0. */
20
+ function 같은시각차례(a, b) {
21
+ if (a == null || b == null || a === '' || b === '')
22
+ return 0;
23
+ if (typeof a === 'number' && typeof b === 'number')
24
+ return a - b;
25
+ // 기계 열쇠(행 id)라 로케일 없이 부호 단위로 견준다 — 사람이 읽는 글자가 아니다
26
+ const x = String(a), y = String(b);
27
+ return Number(x > y) - Number(x < y);
28
+ }
19
29
  /**
20
30
  * @public 문항별로 모아 **약한 것부터** 늘어놓는다.
21
31
  *
22
32
  * 정렬은 세 겹이다. 정답률이 낮은 것이 먼저이되, **같은 정답률이면 여러 번 푼 것을
23
33
  * 앞에 둔다** — 한 번 틀린 것과 다섯 번 내리 틀린 것은 무게가 다르다. 그래도 같으면
24
34
  * 글자순으로 못 박는다(그러지 않으면 다시 읽을 때마다 차례가 흔들려 보던 줄을 놓친다).
35
+ *
36
+ * **"최근"(latestCorrect·latestAt·lastSelected·setIds 앞자리)은 `answeredAt` 으로 가린다.** 시각이 같은 행끼리는
37
+ * 둘 다 `id` 가 있으면 `id` 가 큰 쪽(숫자는 수 크기, 글은 사전순)이 최근이고, 그렇지 않으면 **넘어온 배열에서
38
+ * 뒤에 있는 쪽**이 최근이다 — 즉 `id` 없이 넘길 때는 **시간순(옛것 먼저)** 으로 넘겨야 같은 시각의 앞뒤가 맞다.
39
+ * (최신 먼저 넘기면 같은 시각 행의 최근이 뒤집힌다. 시각이 다른 행은 차례와 상관없이 맞다.)
25
40
  */
26
41
  export function summarizeMisses(records) {
27
42
  const 모음 = new Map();
@@ -29,7 +44,10 @@ export function summarizeMisses(records) {
29
44
  let skippedNoTitle = 0;
30
45
  /* 최근 것을 알려면 시간순으로 훑어야 한다. 서버는 최신순으로 주지만 여기서 다시
31
46
  정렬한다 — 부르는 쪽의 차례에 기대면 그쪽이 바뀔 때 조용히 어긋난다. */
32
- const 시간순 = [...records].sort((a, b) => a.answeredAt.localeCompare(b.answeredAt));
47
+ const 시간순 = records
48
+ .map((r, 자리) => ({ r, 자리 }))
49
+ .sort((a, b) => a.r.answeredAt.localeCompare(b.r.answeredAt) || 같은시각차례(a.r.id, b.r.id) || a.자리 - b.자리)
50
+ .map(x => x.r);
33
51
  for (const r of 시간순) {
34
52
  // 요약·시간 행은 문항이 아니다. 틀림으로 세면 한 판마다 오답이 늘어난다.
35
53
  if (r.isCorrect == null)
@@ -0,0 +1,13 @@
1
+ /**
2
+ * 훈련 창 안에서 여는 **떠 있는 창**(훈련 설정·문장 미리보기)에 초점을 넣고, 닫으면 연 단추로 돌려준다.
3
+ *
4
+ * 떠 있는 창(Tooltip)은 document.body 로 포털된다 — 훈련 판(panel) 밖이다. 판의 Tab 가둠(useFocusTrap)은
5
+ * 판 안의 칸만 돌아서, 창을 열어도 Tab 으로는 끝내 닿지 못했고(판 끝에서 첫 칸으로 되감김) 판의 aria-modal 이
6
+ * 낭독기에게서 창을 가렸다. 열릴 때 초점을 창의 첫 칸으로 넣으면 — 판 가둠은 판 안 keydown 만 들으므로 끼어들지
7
+ * 않고, 창 안 Tab 은 Tooltip 이 스스로 가두며, 낭독기는 초점이 든 창을 읽는다. 공용 파일은 건드리지 않는다.
8
+ *
9
+ * @param open 창이 열려 있나
10
+ * @param findRoot 열린 창의 뿌리(.tooltip-content)를 찾는다 — 포털이라 ref 대신 찾는다
11
+ * @param returnTo 닫힐 때 초점을 돌려줄 곳(연 단추). 초점이 이미 다른 칸으로 옮겨 갔으면 빼앗지 않는다.
12
+ */
13
+ export declare function useFloatingFocus(open: boolean, findRoot: () => HTMLElement | null, returnTo: () => HTMLElement | null): void;
@@ -0,0 +1,59 @@
1
+ 'use client';
2
+ import { useEffect, useRef } from 'react';
3
+ import { FOCUSABLE_SELECTOR } from '../../hooks/useFocusTrap';
4
+ /**
5
+ * 훈련 창 안에서 여는 **떠 있는 창**(훈련 설정·문장 미리보기)에 초점을 넣고, 닫으면 연 단추로 돌려준다.
6
+ *
7
+ * 떠 있는 창(Tooltip)은 document.body 로 포털된다 — 훈련 판(panel) 밖이다. 판의 Tab 가둠(useFocusTrap)은
8
+ * 판 안의 칸만 돌아서, 창을 열어도 Tab 으로는 끝내 닿지 못했고(판 끝에서 첫 칸으로 되감김) 판의 aria-modal 이
9
+ * 낭독기에게서 창을 가렸다. 열릴 때 초점을 창의 첫 칸으로 넣으면 — 판 가둠은 판 안 keydown 만 들으므로 끼어들지
10
+ * 않고, 창 안 Tab 은 Tooltip 이 스스로 가두며, 낭독기는 초점이 든 창을 읽는다. 공용 파일은 건드리지 않는다.
11
+ *
12
+ * @param open 창이 열려 있나
13
+ * @param findRoot 열린 창의 뿌리(.tooltip-content)를 찾는다 — 포털이라 ref 대신 찾는다
14
+ * @param returnTo 닫힐 때 초점을 돌려줄 곳(연 단추). 초점이 이미 다른 칸으로 옮겨 갔으면 빼앗지 않는다.
15
+ */
16
+ export function useFloatingFocus(open, findRoot, returnTo) {
17
+ const findRootRef = useRef(findRoot);
18
+ const returnToRef = useRef(returnTo);
19
+ useEffect(() => { findRootRef.current = findRoot; returnToRef.current = returnTo; });
20
+ useEffect(() => {
21
+ if (!open || typeof document === 'undefined')
22
+ return;
23
+ let raf = null;
24
+ let 남은틀 = 10; // Tooltip 은 위치를 잰 뒤에야 보인다 — 보이기 전 focus() 는 먹지 않아 몇 틀 다시 해 본다
25
+ const 넣기 = () => {
26
+ raf = null;
27
+ const root = findRootRef.current();
28
+ const 첫칸 = root
29
+ ? Array.from(root.querySelectorAll(FOCUSABLE_SELECTOR)).find((el) => !el.closest('[aria-hidden="true"]'))
30
+ : undefined;
31
+ if (첫칸) {
32
+ try {
33
+ 첫칸.focus();
34
+ }
35
+ catch (e) { /* 떼어진 노드 */ }
36
+ if (document.activeElement === 첫칸)
37
+ return;
38
+ }
39
+ if (--남은틀 > 0)
40
+ raf = requestAnimationFrame(넣기);
41
+ };
42
+ raf = requestAnimationFrame(넣기);
43
+ return () => {
44
+ if (raf !== null)
45
+ cancelAnimationFrame(raf);
46
+ // 닫혔다(또는 떠난다) — 초점이 창과 함께 사라져 body 로 떨어졌을 때만 연 단추로 돌려준다
47
+ const 지금 = document.activeElement;
48
+ if (!지금 || 지금 === document.body || !document.contains(지금) || !!지금.closest('.tooltip-content')) {
49
+ const 단추 = returnToRef.current();
50
+ if (단추) {
51
+ try {
52
+ 단추.focus();
53
+ }
54
+ catch (e) { /* 떼어진 노드 */ }
55
+ }
56
+ }
57
+ };
58
+ }, [open]);
59
+ }
@@ -215,25 +215,34 @@ export function useNoiseFade({ noisePlayer, audioRef, playbackStateRef, }) {
215
215
  }, [fadeOutNoise, noiseInUse, audioRef, playbackStateRef]);
216
216
  // 탭 백그라운드 전환 시 소음 즉시 정지 — 백그라운드 탭에서는 requestAnimationFrame이
217
217
  // 멈추거나 크게 스로틀돼 fadeOutNoise의 볼륨 램프가 완주하지 못할 수 있어, 페이드 없이
218
- // 바로 pause한다. 탭 복귀 시 자동 재개는 하지 않는다 — 메인 오디오가 재생 중이면 기존
219
- // 소음 설정 useEffect(레벨/타입 변경 등)가 다시 fadeInNoise를 트리거하고, 아니라면
220
- // 사용자가 의도적으로 멈춘 것과 구분이 안 되므로 그대로 둔다.
218
+ // 바로 pause한다.
219
+ /* 탭으로 돌아왔을 때 **문장이 재생 중이고 소음 설정이 켜져 있으면** 소음을 다시 켠다. 전에는 자동 재개를 안 해서
220
+ 문장은 계속 나오는데 소음만 꺼진 채로 남았다(설정 효과는 설정이 바뀌어야 다시 돈다 — 기대던 전제가 틀렸다).
221
+ 문장이 멈춰 있으면(사용자가 멈춘 것) 켜지 않는다. 볼륨·켜짐은 지금 설정(noiseSettingsRef)으로 본다. */
221
222
  useEffect(() => {
222
223
  const handleVisibilityChange = () => {
223
- if (!document.hidden)
224
- return;
225
224
  const noiseEl = noiseAudioRef.current;
226
- if (noiseEl && !noiseEl.paused) {
227
- cancelNoiseFade();
228
- try {
229
- noiseEl.pause();
225
+ if (document.hidden) {
226
+ if (noiseEl && !noiseEl.paused) {
227
+ cancelNoiseFade();
228
+ try {
229
+ noiseEl.pause();
230
+ }
231
+ catch (e) { }
230
232
  }
231
- catch (e) { }
233
+ return;
232
234
  }
235
+ if (!noiseEl || !noiseEl.paused)
236
+ return;
237
+ const a = audioRef.current;
238
+ const mainPlaying = (!!a && !a.paused && !a.ended) || playbackStateRef.current === 'playing';
239
+ const 지금 = noiseSettingsRef.current;
240
+ if (mainPlaying && 지금?.enabled && 지금.volume > 0)
241
+ fadeInNoise(지금.volume);
233
242
  };
234
243
  document.addEventListener('visibilitychange', handleVisibilityChange);
235
244
  return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
236
- }, [cancelNoiseFade]);
245
+ }, [cancelNoiseFade, fadeInNoise, audioRef, playbackStateRef]);
237
246
  // noiseSettingsRef 최신화 (재생 이벤트 핸들러 클로저에서 사용)
238
247
  useEffect(() => {
239
248
  noiseSettingsRef.current = noisePlayer
@@ -1,23 +1,5 @@
1
1
  import { type RefObject } from 'react';
2
2
  import type { Source } from './trainingStepAudioPlayerTypes';
3
- /**
4
- * 재생 중 진행 표시를 매 프레임 갱신한다.
5
- *
6
- * 진행 마커와 파형의 채워진 부분은 **React 를 거치지 않고** DOM 스타일을 직접 쓴다.
7
- * 초당 예순 번 상태를 바꾸면 그때마다 컴포넌트 전체가 다시 그려져서, 파형 바 300개가
8
- * 매 프레임 재생성된다. 눈에 보이는 것은 두 값(마커 위치, 파형 진행률)뿐이라 그 둘만
9
- * 직접 만진다.
10
- *
11
- * **모아 놓은 진짜 이유는 멈추는 쪽이다.** 이 프레임 사슬은 재생·일시정지·종료·트랙
12
- * 이동·화면 떠나기 — 다섯 갈래에서 멈춰야 하는데, 원래는 그 취소 코드가 **아홉 군데**에
13
- * 똑같이 흩어져 있었다.
14
- *
15
- * if (rafRef.current) { cancelAnimationFrame(rafRef.current); rafRef.current = null }
16
- *
17
- * 한 군데를 빠뜨리면 사슬이 살아남아 이미 끝난 소리의 진행률을 계속 그린다 — 마커가
18
- * 혼자 움직이는 상태다. 오류도 안 뜨고, 다음 재생이 시작되면 겹쳐서 두 배 속도로 움직인다.
19
- * `start()` / `stop()` 둘로 줄인다.
20
- */
21
3
  export declare function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, }: {
22
4
  audioRef: RefObject<HTMLAudioElement | null>;
23
5
  /** 진행 마커 — `left` 를 퍼센트로 민다 */
@@ -19,6 +19,30 @@ import { progressOf } from './audioPlaybackRules';
19
19
  * 혼자 움직이는 상태다. 오류도 안 뜨고, 다음 재생이 시작되면 겹쳐서 두 배 속도로 움직인다.
20
20
  * `start()` / `stop()` 둘로 줄인다.
21
21
  */
22
+ /**
23
+ * `<audio>` 가 알려 주는 총 길이(초). 유한한 양수면 그것, Infinity·NaN(스트리밍·길이 머리 없음)이면
24
+ * 되감을 수 있는 구간(seekable)의 끝을 총 길이로 본다. 그것도 없으면 `null` — 진행을 모른다.
25
+ * 요소가 없으면 0(그릴 것 없음, 전과 같게 마커 0).
26
+ */
27
+ function audioDurationOf(a) {
28
+ if (!a)
29
+ return 0;
30
+ const d = a.duration;
31
+ if (Number.isFinite(d) && d > 0)
32
+ return d;
33
+ if (d === 0)
34
+ return 0;
35
+ try {
36
+ const s = a.seekable;
37
+ if (s && s.length > 0) {
38
+ const 끝 = s.end(s.length - 1);
39
+ if (Number.isFinite(끝) && 끝 > 0)
40
+ return 끝;
41
+ }
42
+ }
43
+ catch { /* seekable 을 못 읽는 환경 */ }
44
+ return null;
45
+ }
22
46
  export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesRef, currentIndexRef, }) {
23
47
  const rafRef = useRef(null);
24
48
  const stop = useCallback(() => {
@@ -34,21 +58,40 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
34
58
  const step = () => {
35
59
  try {
36
60
  const a = audioRef.current;
37
- const pos = progressOf({
38
- currentTime: a && typeof a.currentTime === 'number' ? a.currentTime : 0,
39
- sources: sourcesRef.current,
40
- index: currentIndexRef.current,
41
- audioDuration: a && a.duration ? a.duration : 0,
42
- });
43
- try {
44
- if (fillRef.current)
45
- fillRef.current.style.left = `${pos * 100}%`;
61
+ const 트랙 = Array.isArray(sourcesRef.current) ? sourcesRef.current[currentIndexRef.current || 0] : undefined;
62
+ const 트랙길이알아 = !!트랙 && Number.isFinite(트랙.duration) && (트랙.duration ?? 0) > 0;
63
+ // 트랙 길이를 알면 progressOf 가 그것을 쓴다 — <audio> 길이는 모를 때만 본다
64
+ const 총길이 = 트랙길이알아 ? 0 : audioDurationOf(a);
65
+ const fill = fillRef.current;
66
+ if (총길이 === null) {
67
+ /* 길이를 모른다(스트리밍 등으로 duration 이 Infinity·NaN 이고 되감을 수 있는 끝도 없다) —
68
+ 마커를 0 에 붙여 두면 "안 움직이는 재생"으로 읽힌다. 진행을 모르니 마커를 숨긴다. */
69
+ try {
70
+ if (fill && fill.style.visibility !== 'hidden')
71
+ fill.style.visibility = 'hidden';
72
+ }
73
+ catch { /* 떼어진 노드 */ }
46
74
  }
47
- catch { /* 떼어진 노드 */ }
48
- try {
49
- waveProgressRef.current?.style.setProperty('--progress', String(pos));
75
+ else {
76
+ const pos = progressOf({
77
+ currentTime: a && typeof a.currentTime === 'number' ? a.currentTime : 0,
78
+ sources: sourcesRef.current,
79
+ index: currentIndexRef.current,
80
+ audioDuration: 총길이,
81
+ });
82
+ try {
83
+ if (fill) {
84
+ if (fill.style.visibility)
85
+ fill.style.visibility = '';
86
+ fill.style.left = `${pos * 100}%`;
87
+ }
88
+ }
89
+ catch { /* 떼어진 노드 */ }
90
+ try {
91
+ waveProgressRef.current?.style.setProperty('--progress', String(pos));
92
+ }
93
+ catch { /* 위와 같다 */ }
50
94
  }
51
- catch { /* 위와 같다 */ }
52
95
  }
53
96
  catch { /* 한 프레임이 실패해도 사슬은 이어 간다 */ }
54
97
  rafRef.current = requestAnimationFrame(step);
@@ -59,8 +102,10 @@ export function useProgressTicker({ audioRef, fillRef, waveProgressRef, sourcesR
59
102
  const settleTo = useCallback((pos) => {
60
103
  stop();
61
104
  try {
62
- if (fillRef.current)
105
+ if (fillRef.current) {
106
+ fillRef.current.style.visibility = '';
63
107
  fillRef.current.style.left = `${pos * 100}%`;
108
+ }
64
109
  }
65
110
  catch { /* 떼어진 노드 */ }
66
111
  try {
@@ -25,7 +25,7 @@ export declare function useTTSPlayback({ ttsText, playbackRate, mainVolume, fill
25
25
  onDoneRef: RefObject<((ok?: boolean) => void) | undefined>;
26
26
  handleEndRef: RefObject<(() => void) | null>;
27
27
  }): {
28
- startTTS: (text?: string) => void;
28
+ startTTS: (text?: string | string[]) => void;
29
29
  stopTTS: () => void;
30
30
  };
31
31
  export {};
@@ -50,11 +50,19 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
50
50
  return 1200;
51
51
  }
52
52
  };
53
+ /**
54
+ * 말하기 시작. `text` 를 주면 설정(ttsText)보다 이긴다.
55
+ *
56
+ * - 글이 배열이면 **차례로 모두** 말하고 마지막 문장이 끝난 뒤에 한 번 끝 처리(handleEnd)를 부른다.
57
+ * 전에는 첫 문장만 말하고 문항이 끝났다(리졸버가 없거나 실패한 여러 문장 훈련).
58
+ * - 브라우저에 음성 합성이 없으면 곧바로 `onDone(false)` — **소리를 못 냈다**고 알린다.
59
+ * 전에는 `onDone(true)` 라 들은 것으로 셌다. 훈련 흐름(TrainingPagePopup)은 false 에도 다음 단계로 간다.
60
+ */
53
61
  const startTTS = useCallback((text) => {
54
62
  if (typeof window === 'undefined' || !window.speechSynthesis) {
55
63
  try {
56
64
  if (typeof onDoneRef.current === 'function')
57
- onDoneRef.current(true);
65
+ onDoneRef.current(false);
58
66
  }
59
67
  catch (e) { }
60
68
  return;
@@ -67,17 +75,11 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
67
75
  }
68
76
  catch (e) { }
69
77
  const rawTts = text ?? ttsText;
70
- const t = (Array.isArray(rawTts) ? rawTts[0] ?? '' : rawTts) || '';
71
- const utt = new SpeechSynthesisUtterance(t);
72
- try {
73
- utt.rate = Math.max(0.3, Math.min(3, playbackRate));
74
- }
75
- catch (e) { }
76
- try {
77
- utt.volume = Math.max(0, Math.min(1, mainVolume));
78
- }
79
- catch (e) { }
80
- ttsEstimatedDurationRef.current = estimateTTSDuration(t, utt.rate || 1);
78
+ const 문장들 = (Array.isArray(rawTts) ? rawTts.filter((s) => !!s) : [rawTts || '']);
79
+ if (문장들.length === 0)
80
+ 문장들.push('');
81
+ const rate = Math.max(0.3, Math.min(3, playbackRate));
82
+ ttsEstimatedDurationRef.current = 문장들.reduce((합, s) => 합 + estimateTTSDuration(s, rate || 1), 0);
81
83
  ttsStartRef.current = performance.now();
82
84
  setProgress(0);
83
85
  setPlaybackState('playing');
@@ -108,7 +110,7 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
108
110
  }
109
111
  catch (e) { }
110
112
  };
111
- utt.onend = () => {
113
+ const 끝처리 = () => {
112
114
  if (myToken !== ttsTokenRef.current)
113
115
  return;
114
116
  try {
@@ -131,23 +133,52 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
131
133
  }
132
134
  catch (e) { }
133
135
  };
134
- /* 합성이 실패하면(synthesis-failed·not-allowed·language-unavailable) end 없이 error 만 온다 — 끝난 것과 같이 정리한다.
135
- 전에는 재생 상태·소음이 그대로 남고 handleEnd·onDone 이 안 불려 문항이 넘어가지 않았다. 정지·다시 시작이 올린 번호의
136
- error(취소)는 위 onend 와 같이 넘긴다. */
137
- utt.onerror = () => { utt.onend?.(new Event('end')); };
138
- utt.onstart = () => {
136
+ const 말하기 = (i) => {
137
+ const utt = new SpeechSynthesisUtterance(문장들[i]);
139
138
  try {
140
- if (ttsRafRef.current) {
141
- cancelAnimationFrame(ttsRafRef.current);
142
- ttsRafRef.current = null;
143
- }
144
- ttsRafRef.current = requestAnimationFrame(tick);
139
+ utt.rate = rate;
145
140
  }
146
141
  catch (e) { }
147
- };
148
- try {
142
+ try {
143
+ utt.volume = Math.max(0, Math.min(1, mainVolume));
144
+ }
145
+ catch (e) { }
146
+ utt.onend = () => {
147
+ if (myToken !== ttsTokenRef.current)
148
+ return;
149
+ // 남은 문장이 있으면 이어 말한다 — 진행 표시는 전체 추정 길이로 계속 흐른다
150
+ if (i < 문장들.length - 1) {
151
+ try {
152
+ 말하기(i + 1);
153
+ }
154
+ catch (e) {
155
+ 끝처리();
156
+ }
157
+ return;
158
+ }
159
+ 끝처리();
160
+ };
161
+ /* 합성이 실패하면(synthesis-failed·not-allowed·language-unavailable) end 없이 error 만 온다 — 끝난 것과 같이 정리한다.
162
+ 전에는 재생 상태·소음이 그대로 남고 handleEnd·onDone 이 안 불려 문항이 넘어가지 않았다. 정지·다시 시작이 올린 번호의
163
+ error(취소)는 onend 와 같이 넘긴다. 중간 문장이 실패하면 남은 문장도 같은 까닭으로 실패하기 쉬워 거기서 끝낸다. */
164
+ utt.onerror = () => { 끝처리(); };
165
+ utt.onstart = () => {
166
+ if (i > 0)
167
+ return; // 진행 사슬은 첫 문장에서 한 번만 건다
168
+ try {
169
+ if (ttsRafRef.current) {
170
+ cancelAnimationFrame(ttsRafRef.current);
171
+ ttsRafRef.current = null;
172
+ }
173
+ ttsRafRef.current = requestAnimationFrame(tick);
174
+ }
175
+ catch (e) { }
176
+ };
149
177
  말했나Ref.current = true;
150
178
  synth.speak(utt);
179
+ };
180
+ try {
181
+ 말하기(0);
151
182
  }
152
183
  catch (e) {
153
184
  try {
@@ -15,6 +15,9 @@
15
15
  * 3. **트랙이 바뀌면 다시 기다려야 한다** — 이전 트랙의 '준비됨'을 물려받으면 안 된다.
16
16
  * 그래서 트랙을 갈아 끼우는 자리마다 `markNotReady()` 를 부른다.
17
17
  *
18
+ * `waitForReady()` 는 **`true`(계속 재생해도 된다)** 또는 **`false`(화면을 떠났다 — 재생을 이어 가지 말 것)** 로 풀린다.
19
+ * 준비 신호·상한·그릴 파형 없음은 모두 `true` 다.
20
+ *
18
21
  * 원래 `TrainingStepAudioPlayer` 안에 흩어져 있었다. ref 둘과 함수 둘, 그리고
19
22
  * `waveReadyRef.current = false` 가 **일곱 군데**에 직접 흩어져 있어서, 새 재생 경로를
20
23
  * 추가하는 사람이 그 한 줄을 빠뜨리기 쉬웠다. 이름을 준 함수 하나로 모은다.
@@ -31,6 +34,6 @@ timeoutMs, }: {
31
34
  timeoutMs?: number;
32
35
  }): {
33
36
  onReadyChange: (ready: boolean) => void;
34
- waitForReady: () => Promise<void>;
37
+ waitForReady: () => Promise<boolean>;
35
38
  markNotReady: () => void;
36
39
  };
@@ -17,6 +17,9 @@ import { useCallback, useEffect, useRef } from 'react';
17
17
  * 3. **트랙이 바뀌면 다시 기다려야 한다** — 이전 트랙의 '준비됨'을 물려받으면 안 된다.
18
18
  * 그래서 트랙을 갈아 끼우는 자리마다 `markNotReady()` 를 부른다.
19
19
  *
20
+ * `waitForReady()` 는 **`true`(계속 재생해도 된다)** 또는 **`false`(화면을 떠났다 — 재생을 이어 가지 말 것)** 로 풀린다.
21
+ * 준비 신호·상한·그릴 파형 없음은 모두 `true` 다.
22
+ *
20
23
  * 원래 `TrainingStepAudioPlayer` 안에 흩어져 있었다. ref 둘과 함수 둘, 그리고
21
24
  * `waveReadyRef.current = false` 가 **일곱 군데**에 직접 흩어져 있어서, 새 재생 경로를
22
25
  * 추가하는 사람이 그 한 줄을 빠뜨리기 쉬웠다. 이름을 준 함수 하나로 모은다.
@@ -38,28 +41,34 @@ timeoutMs = 2500, }) {
38
41
  const 기다리던것 = resolversRef.current;
39
42
  resolversRef.current = [];
40
43
  for (const 깨우기 of 기다리던것)
41
- 깨우기();
44
+ 깨우기(true);
42
45
  }, []);
43
46
  /** 새 트랙을 물렸다 — 이전 트랙의 '준비됨'을 물려받지 않는다 */
44
47
  const markNotReady = useCallback(() => { readyRef.current = false; }, []);
48
+ /** 파형이 준비되면(또는 상한이 지나면) `true`, 기다리는 사이 화면을 떠나면 `false` 로 풀린다. */
45
49
  const waitForReady = useCallback(() => {
46
50
  // 그릴 파형이 없으면 기다릴 것도 없다
47
51
  if (!showSoundWaveForm && !showNoiseWaveForm)
48
- return Promise.resolve();
52
+ return Promise.resolve(true);
49
53
  if (readyRef.current)
50
- return Promise.resolve();
54
+ return Promise.resolve(true);
51
55
  return new Promise((resolve) => {
52
- const timer = setTimeout(resolve, timeoutMs);
53
- resolversRef.current.push(() => { clearTimeout(timer); resolve(); });
56
+ const 풀기 = (계속) => {
57
+ clearTimeout(timer);
58
+ resolversRef.current = resolversRef.current.filter((r) => r !== 풀기);
59
+ resolve(계속);
60
+ };
61
+ const timer = setTimeout(() => 풀기(true), timeoutMs);
62
+ resolversRef.current.push(풀기);
54
63
  });
55
64
  }, [showSoundWaveForm, showNoiseWaveForm, timeoutMs]);
56
- /* 화면을 떠날 때 기다리던 것들을 놓아 준다. 안 그러면 상한 타이머가 살아남아
57
- 이미 사라진 화면의 재생 흐름을 뒤늦게 깨운다. */
65
+ /* 화면을 떠날 때 기다리던 것들을 `false`(취소)로 놓아 준다. 매달린 약속·상한 타이머를 남기지 않되,
66
+ `true` 로 풀면 부르는 쪽이 사라진 화면의 재생을 이어 갔다(떼어진 <audio> 에 play). 부르는 쪽은 false 면 멈춘다. */
58
67
  useEffect(() => () => {
59
68
  const 남은것 = resolversRef.current;
60
69
  resolversRef.current = [];
61
70
  for (const 깨우기 of 남은것)
62
- 깨우기();
71
+ 깨우기(false);
63
72
  }, []);
64
73
  return { onReadyChange, waitForReady, markNotReady };
65
74
  }
@@ -1,6 +1,7 @@
1
1
  .update-alert {
2
2
  position: fixed;
3
- top: 16px;
3
+ /* 함께 뜬 다른 알림 자리 아래로 비킨다 — updateAlertStack.ts 가 앞 자리들의 높이를 넣는다 */
4
+ top: calc(16px + var(--update-alert-stack-offset, 0px));
4
5
  right: 16px;
5
6
  z-index: var(--z-alert);
6
7
  width: 380px;
@@ -27,7 +28,7 @@
27
28
  */
28
29
  .update-alert-stack {
29
30
  position: fixed;
30
- top: 16px;
31
+ top: calc(16px + var(--update-alert-stack-offset, 0px));
31
32
  right: 16px;
32
33
  z-index: var(--z-alert);
33
34
  display: flex;
@@ -39,6 +40,7 @@
39
40
  /* relative — 흐름 배치로 돌리되 닫기 버튼(absolute)의 기준은 낱장으로 남긴다 */
40
41
  .update-alert-stack .update-alert {
41
42
  position: relative;
43
+ top: auto;
42
44
  z-index: auto;
43
45
  }
44
46
 
@@ -1,4 +1,4 @@
1
- import type { ReactNode } from 'react';
1
+ import { type ReactNode } from 'react';
2
2
  import './UpdateAlert.css';
3
3
  /** @public */
4
4
  export interface UpdateAlertProps {
@@ -17,6 +17,8 @@ export interface UpdateAlertProps {
17
17
  *
18
18
  * 여러 장을 동시에 띄우려면 `.update-alert-stack`으로 감싼다 — 낱장은 스스로
19
19
  * fixed로 붙지만 스택 안에서는 흐름 배치가 되어 아래로 쌓인다.
20
+ * 따로 놓인 낱장과 스택(또는 낱장 여럿)이 함께 뜨면 문서 순서대로 서로 아래로 비켜 쌓인다(겹치지 않는다).
21
+ * 층은 창(Popup·Dialog) 위 — 창 위에서도 알림을 읽고 닫을 수 있다(`--z-alert`).
20
22
  *
21
23
  * @public
22
24
  */
@@ -6,6 +6,9 @@
6
6
  'use client';
7
7
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  import Download from 'lucide-react/dist/esm/icons/download.mjs';
9
+ import { useLayoutEffect, useRef } from 'react';
10
+ import { moveFocusOutside } from '../hooks/focusReturn';
11
+ import { registerUpdateAlertSlot } from './updateAlertStack';
9
12
  import { useTranslation } from 'react-i18next';
10
13
  import { cx } from '../utils/cx';
11
14
  import Button from './Button';
@@ -15,11 +18,28 @@ import './UpdateAlert.css';
15
18
  *
16
19
  * 여러 장을 동시에 띄우려면 `.update-alert-stack`으로 감싼다 — 낱장은 스스로
17
20
  * fixed로 붙지만 스택 안에서는 흐름 배치가 되어 아래로 쌓인다.
21
+ * 따로 놓인 낱장과 스택(또는 낱장 여럿)이 함께 뜨면 문서 순서대로 서로 아래로 비켜 쌓인다(겹치지 않는다).
22
+ * 층은 창(Popup·Dialog) 위 — 창 위에서도 알림을 읽고 닫을 수 있다(`--z-alert`).
18
23
  *
19
24
  * @public
20
25
  */
21
26
  export default function UpdateAlert({ title, message, actionLabel, onAction, icon, onClose, className = '', }) {
22
27
  const { t } = useTranslation();
23
28
  const resolvedActionLabel = actionLabel ?? t('common.update', '업데이트');
24
- return (_jsxs("div", { className: cx('update-alert', className), role: "alert", children: [onClose && (_jsx("button", { type: "button", className: "update-alert__close", onClick: onClose, "aria-label": t('common.close', '닫기'), children: "\u00D7" })), _jsxs("div", { className: "update-alert__top", children: [_jsx("div", { className: "update-alert__icon", children: icon ?? _jsx(Download, { size: 20, strokeWidth: 2 }) }), _jsxs("div", { className: "update-alert__body", children: [_jsx("div", { className: "update-alert__title h4", children: title }), _jsx("div", { className: "update-alert__message body-3", children: message })] })] }), onAction && (_jsx(Button, { htmlType: "button", size: 32, variant: "main", shape: "pill", className: "update-alert__action", onClick: onAction, children: resolvedActionLabel }))] }));
29
+ /* 닫기(×)를 눌러 부모가 알림을 치우면 그 단추에 있던 포커스가 body 로 떨어졌다. 닫기로 떠날 때만(화면 전체가 내려갈 때는 아님)
30
+ 떼기 직전(레이아웃 효과 정리 — 아직 문서에 붙어 있다) 알림 앞의 포커스 받을 곳으로 옮긴다. */
31
+ const rootRef = useRef(null);
32
+ const 닫기눌림Ref = useRef(false);
33
+ useLayoutEffect(() => {
34
+ const root = rootRef.current;
35
+ /* 낱장과 스택이 함께 뜨면 둘 다 우상단 16px 에 붙어 겹쳤다 — 자리(낱장 자신 또는 감싼 스택)를 올려 문서 순서대로 아래로 쌓는다 */
36
+ const 자리 = root?.parentElement?.closest('.update-alert-stack') ?? root;
37
+ const 내리기 = 자리 ? registerUpdateAlertSlot(자리) : null;
38
+ return () => {
39
+ 내리기?.();
40
+ if (닫기눌림Ref.current && root && root.contains(document.activeElement))
41
+ moveFocusOutside(root);
42
+ };
43
+ }, []);
44
+ return (_jsxs("div", { ref: rootRef, className: cx('update-alert', className), role: "alert", children: [onClose && (_jsx("button", { type: "button", className: "update-alert__close", onClick: () => { 닫기눌림Ref.current = true; onClose(); }, "aria-label": t('common.close', '닫기'), children: "\u00D7" })), _jsxs("div", { className: "update-alert__top", children: [_jsx("div", { className: "update-alert__icon", children: icon ?? _jsx(Download, { size: 20, strokeWidth: 2 }) }), _jsxs("div", { className: "update-alert__body", children: [_jsx("div", { className: "update-alert__title h4", children: title }), _jsx("div", { className: "update-alert__message body-3", children: message })] })] }), onAction && (_jsx(Button, { htmlType: "button", size: 32, variant: "main", shape: "pill", className: "update-alert__action", onClick: onAction, children: resolvedActionLabel }))] }));
25
45
  }
@@ -24,8 +24,10 @@ export interface PhonemeSelectBlockProps {
24
24
  onSearchEmpty?: () => void;
25
25
  searchDisabled?: boolean;
26
26
  searchLabel?: string;
27
+ /** 검색 확인 중 — 검색 단추를 그대로 두고 스피너·aria-busy 로 알린다(누름은 막되 초점은 남는다). */
27
28
  searchChecking?: boolean;
28
29
  pairMode?: boolean;
30
+ /** 머리의 "유의사항" 단추(툴팁). **기본 true** — 늘 보이던 단추를 지킨다. false 를 주면 숨는다. */
29
31
  showInfoButton?: boolean;
30
32
  /** 검색 버튼 바로 위에 표시할 내용(예: 현재 검색 조건 요약 Badge) */
31
33
  beforeSearchContent?: ReactNode;
@@ -13,7 +13,7 @@ import Button from '../Button';
13
13
  import DescriptionTooltip from '../Tooltip/DescriptionTooltip';
14
14
  import { sectionValues } from './phonemeSelectBlockUtils';
15
15
  /** @public */
16
- export default function PhonemeSelectBlock({ sections, selectedList, count, onCountChange, maxCount = 4, minCount = 1, combinationSections, combinationSectionsForSlot, onSelect, placeholder = '-', className, title, titleIcon, disc, showInfoButton = false, onClearPhoneme, onSearch, onSearchEmpty, searchDisabled = false, searchLabel, searchChecking = false, pairMode = false, sectionType = 'page', beforeSearchContent, beforeContent, afterContent, hideCountControl = false, ordinal = false, }) {
16
+ export default function PhonemeSelectBlock({ sections, selectedList, count, onCountChange, maxCount = 4, minCount = 1, combinationSections, combinationSectionsForSlot, onSelect, placeholder = '-', className, title, titleIcon, disc, showInfoButton = true, onClearPhoneme, onSearch, onSearchEmpty, searchDisabled = false, searchLabel, searchChecking = false, pairMode = false, sectionType = 'page', beforeSearchContent, beforeContent, afterContent, hideCountControl = false, ordinal = false, }) {
17
17
  const { t } = useTranslation();
18
18
  const infoNotes = useMemo(() => t('wordPairs.infoNotes', { returnObjects: true }), [t]);
19
19
  const countOptions = useMemo(() => Array.from({ length: maxCount - minCount + 1 }, (_, i) => {
@@ -47,8 +47,13 @@ export default function PhonemeSelectBlock({ sections, selectedList, count, onCo
47
47
  }), [sections, placeholder]);
48
48
  const infoButton = (_jsxs(_Fragment, { children: [_jsx("div", { "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 40, type: isMobile ? 'dot' : undefined, icon: "info", label: isMobile ? undefined : t('wordPairs.caution', '유의사항'),
49
49
  // 좁은 화면에서는 아이콘만 남는다 — 이름을 늘 준다
50
- ariaLabel: t('wordPairs.caution', '유의사항'), selected: !!infoAnchorRect, onClick: (e) => toggleInfo(e.currentTarget) }) }), infoAnchorRect && (_jsx(DescriptionTooltip, { title: t('wordPairs.caution', '유의사항'), items: infoNotes, anchorRect: infoAnchorRect, onClose: handleCloseInfo }))] }));
51
- return (_jsxs(Section, { type: sectionType, title: title, icon: titleIcon, disc: disc, action: infoButton, className: cx('phoneme-select-block', className), children: [beforeContent, (!hideCountControl || (showInfoButton && !(title || disc))) && (_jsxs("div", { className: "phoneme-select-block__controls", children: [!hideCountControl && (_jsx(SegmentedControl, { options: countOptions, value: String(count), onChange: handleCountChange, size: 40, className: "phoneme-select-block__count-control" })), showInfoButton && !(title || disc) && infoButton] })), _jsx("div", { className: "phoneme-select-block__cells", children: pairMode
50
+ ariaLabel: t('wordPairs.caution', '유의사항'),
51
+ /* 여닫이 — aria-pressed(selected) 대신 aria-expanded. 보이는 선택 모양은 같은 클래스로 */
52
+ className: infoAnchorRect ? 'is-selected' : undefined, "aria-expanded": !!infoAnchorRect, "aria-haspopup": "dialog", onClick: (e) => toggleInfo(e.currentTarget) }) }), infoAnchorRect && (_jsx(DescriptionTooltip, { title: t('wordPairs.caution', '유의사항'), items: infoNotes, anchorRect: infoAnchorRect, onClose: handleCloseInfo }))] }));
53
+ return (
54
+ /* showInfoButton 은 기본 true 였어야 했다 — 적힌 기본값은 false 인데 단추는 늘 머리(action)에 떴다. 이제 false 면 숨는다.
55
+ (예전에는 true + 제목·설명 없음이면 조절 줄에 같은 단추를 하나 더 그려 둘이 한 툴팁을 열었다 — 머리 하나로 둔다.) */
56
+ _jsxs(Section, { type: sectionType, title: title, icon: titleIcon, disc: disc, action: showInfoButton ? infoButton : undefined, className: cx('phoneme-select-block', className), children: [beforeContent, !hideCountControl && (_jsx("div", { className: "phoneme-select-block__controls", children: _jsx(SegmentedControl, { options: countOptions, value: String(count), onChange: handleCountChange, size: 40, className: "phoneme-select-block__count-control" }) })), _jsx("div", { className: "phoneme-select-block__cells", children: pairMode
52
57
  ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "phoneme-select-block__cell-group", children: Array.from({ length: count }).map((_, pairIdx) => {
53
58
  const sel = selectedList[pairIdx * 2] ?? {};
54
59
  return (_jsxs("div", { className: "phoneme-select-block__cell", children: [_jsx("div", { className: "phoneme-select-block__cell-circle", children: _jsx("div", { className: "phoneme-select-block__cell-jamo", children: renderJamoParts(sel) }) }), _jsx("span", { className: "phoneme-select-block__cell-index caption-1", children: phonemeLabel(pairIdx + 1) })] }, pairIdx));
@@ -68,6 +73,9 @@ export default function PhonemeSelectBlock({ sections, selectedList, count, onCo
68
73
  // 종성 '제외'(없음)만 선택된 슬롯은 실제 음소를 고른 것으로 치지 않는다 —
69
74
  // "받침 없음"은 단독으로는 검색 조건이 되기엔 너무 느슨한 보조 조건이라서다.
70
75
  const isEmpty = selectedList.every(sel => Object.entries(sel).every(([k, v]) => !v || (k === '종성' && v === '없음')));
71
- return (_jsxs("div", { className: "phoneme-select-block__search-row", onClick: isEmpty ? onSearchEmpty : undefined, children: [onClearPhoneme && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('wordPairs.resetOptions', '검색 옵션 초기화'), selected: false, disabled: isEmpty, onClick: isEmpty ? undefined : onClearPhoneme })), searchChecking ? (_jsxs("div", { className: "phoneme-select-block__checking", children: [_jsx("div", { className: "phoneme-select-block__spinner" }), _jsx("span", { className: "button-1 phoneme-select-block__checking-label", children: searchLabel ?? t('wordSearch.searchButton', '검색하기') })] })) : onSearch && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", variant: "main", label: searchLabel ?? t('wordSearch.searchButton', '검색하기'), disabled: isEmpty || searchDisabled, onClick: isEmpty || searchDisabled ? undefined : onSearch }))] }));
76
+ return (_jsxs("div", { className: "phoneme-select-block__search-row", onClick: isEmpty ? onSearchEmpty : undefined, children: [onClearPhoneme && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('wordPairs.resetOptions', '검색 옵션 초기화'), selected: false, disabled: isEmpty, onClick: isEmpty ? undefined : onClearPhoneme })), onSearch ? (
77
+ /* 확인 중에도 단추를 **바꿔 끼우지 않는다** — 전에는 스피너 칸으로 갈아 끼워, 키보드로 누른 단추가 사라지며 초점이
78
+ body 로 빠졌다. 막힘은 aria-disabled(onDisabledClick)로만 걸어 초점을 남기고, aria-busy·스피너로 알린다. */
79
+ _jsx(Button, { htmlType: "button", size: "56", type: "bar", variant: "main", icon: searchChecking ? _jsx("span", { className: "phoneme-select-block__spinner", "aria-hidden": "true" }) : undefined, label: searchLabel ?? t('wordSearch.searchButton', '검색하기'), disabled: isEmpty || searchDisabled || searchChecking, onDisabledClick: searchChecking ? () => { } : undefined, "aria-busy": searchChecking || undefined, onClick: onSearch })) : searchChecking ? (_jsxs("div", { className: "phoneme-select-block__checking", "aria-busy": "true", children: [_jsx("div", { className: "phoneme-select-block__spinner" }), _jsx("span", { className: "button-1 phoneme-select-block__checking-label", children: searchLabel ?? t('wordSearch.searchButton', '검색하기') })] })) : null] }));
72
80
  })(), afterContent] }));
73
81
  }