@ihabdevteam/core 0.322.0 → 0.324.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 (198) hide show
  1. package/CHANGELOG.md +557 -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 +55 -17
  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 +14 -2
  149. package/dist/packages/core/src/i18n/locales/ko.json +13 -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 +129 -105
  174. package/dist/packages/core/src/utils/phonemePairSearchTypes.d.ts +2 -0
  175. package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
  176. package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
  177. package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
  178. package/dist/packages/core/src/utils/safeHref.js +4 -4
  179. package/dist/packages/core/src/utils/savedWordTables.js +5 -5
  180. package/dist/packages/core/src/utils/speak.js +12 -12
  181. package/dist/packages/core/src/utils/textSort.d.ts +0 -10
  182. package/dist/packages/core/src/utils/textSort.js +38 -48
  183. package/dist/packages/core/src/utils/theme.js +3 -3
  184. package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
  185. package/dist/packages/core/src/utils/ttsConfig.js +2 -2
  186. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +1 -1
  187. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +65 -45
  188. package/dist/packages/core/src/utils/wordContrast.js +3 -3
  189. package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
  190. package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
  191. package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
  192. package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
  193. package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
  194. package/dist/packages/core/src/utils/wornDate.js +14 -14
  195. package/dist/packages/core/src/version.d.ts +1 -1
  196. package/dist/packages/core/src/version.js +1 -1
  197. package/dist/utils.cjs.js +4 -4
  198. package/package.json +1 -1
@@ -25,30 +25,30 @@ import './SectionHeadFloat.css';
25
25
  */
26
26
  export default function SectionHeadFloat({ children, className }) {
27
27
  const ref = useRef(null);
28
- const [부모, set부모] = useState(null);
29
- useEffect(() => { set부모(ref.current?.parentElement ?? null); }, []);
28
+ const [parent, setParent] = useState(null);
29
+ useEffect(() => { setParent(ref.current?.parentElement ?? null); }, []);
30
30
  useEffect(() => {
31
31
  const el = ref.current;
32
- if (!el || !부모)
32
+ if (!el || !parent)
33
33
  return;
34
34
  /* 흐름으로 돌아간 좁은 화면에서는 비울 자리가 없다 — 그때 폭을 적어 두면 제목이 공연히 좁아진다.
35
35
  `position` 을 보고 가른다(문턱을 두 군데 적지 않으려고 — CSS 가 정본이다). */
36
- const 적기 = () => {
37
- const 띄웠나 = typeof window !== 'undefined' && window.getComputedStyle(el).position === 'absolute';
38
- const w = 띄웠나 ? Math.ceil(el.getBoundingClientRect().width) : 0;
36
+ const apply = () => {
37
+ const isFloating = typeof window !== 'undefined' && window.getComputedStyle(el).position === 'absolute';
38
+ const w = isFloating ? Math.ceil(el.getBoundingClientRect().width) : 0;
39
39
  if (w > 0)
40
- 부모.style.setProperty('--section-head-float-w', `${w}px`);
40
+ parent.style.setProperty('--section-head-float-w', `${w}px`);
41
41
  else
42
- 부모.style.removeProperty('--section-head-float-w');
42
+ parent.style.removeProperty('--section-head-float-w');
43
43
  };
44
- 적기();
44
+ apply();
45
45
  if (typeof ResizeObserver === 'undefined')
46
- return () => { 부모.style.removeProperty('--section-head-float-w'); };
46
+ return () => { parent.style.removeProperty('--section-head-float-w'); };
47
47
  /* 줄 자신과 카드를 함께 본다 — 단추 글자가 바뀌면 줄이, 창이 좁아지면 카드가 먼저 움직인다 */
48
- const 지켜보기 = new ResizeObserver(() => 적기());
49
- 지켜보기.observe(el);
50
- 지켜보기.observe(부모);
51
- return () => { 지켜보기.disconnect(); 부모.style.removeProperty('--section-head-float-w'); };
52
- }, [부모, children]);
48
+ const observer = new ResizeObserver(() => apply());
49
+ observer.observe(el);
50
+ observer.observe(parent);
51
+ return () => { observer.disconnect(); parent.style.removeProperty('--section-head-float-w'); };
52
+ }, [parent, children]);
53
53
  return _jsx("div", { ref: ref, className: cx('section-head-float', className), children: children });
54
54
  }
@@ -18,7 +18,7 @@ import { devWarnOnce, isDev } from '../utils/devWarn';
18
18
  * 멀쩡하다) 콘솔이 울기만 하고, 정작 부딪힌 자리가 묻힌다. 둘 이상 **함께 서 있을 때만**
19
19
  * 알린다. 열쇠에 보기 이름을 넣어, 서로 다른 부딪힘은 각각 한 번씩 알린다.
20
20
  */
21
- const 이름없이선것 = new Map();
21
+ const unlabeledGroups = new Map();
22
22
  /** @public */
23
23
  export default function SegmentedControl(props) {
24
24
  const { t } = useTranslation();
@@ -31,69 +31,69 @@ export default function SegmentedControl(props) {
31
31
  아무 일도 안 일어난다. 실측으로 칸 수만큼 Tab 자리였고 ArrowRight 는 죽어 있었다.
32
32
  라디오에서는 **고름이 포커스를 따라간다** — 화살표로 옮기면 그 자리가 곧 선택이다.
33
33
  못 고르는 칸(disabled)은 건너뛰고, 끝에서는 돈다. */
34
- const 그룹 = useRef(null);
35
- const 고를수있는 = options.filter((o) => !o.disabled);
36
- const 내자리 = useId();
34
+ const groupRef = useRef(null);
35
+ const selectable = options.filter((o) => !o.disabled);
36
+ const uid = useId();
37
37
  /* 보기 이름을 이어 **이 고르개가 무엇인지** 적는다 — 콘솔만 보고 찾아갈 수 있게.
38
38
  원시 글자라 아래 useEffect 가 다시 그릴 때마다 돌지 않는다(배열을 그냥 넣으면 돈다). */
39
- const 서명 = options.map((o) => o.key).join('·');
39
+ const signature = options.map((o) => o.key).join('·');
40
40
  useEffect(() => {
41
41
  if (!isDev || (ariaLabel != null && ariaLabel !== ''))
42
42
  return;
43
- 이름없이선것.set(내자리, 서명 || '(보기 없음)');
44
- if (이름없이선것.size > 1) {
45
- const 모두 = [...이름없이선것.values()].sort();
46
- devWarnOnce(`segmented-control-unnamed:${모두.join('|')}`, `[ihab/SegmentedControl] 이름 없는 라디오그룹이 한 화면에 ${이름없이선것.size} 개 서 있습니다 — ` +
43
+ unlabeledGroups.set(uid, signature || '(보기 없음)');
44
+ if (unlabeledGroups.size > 1) {
45
+ const all = [...unlabeledGroups.values()].sort();
46
+ devWarnOnce(`segmented-control-unnamed:${all.join('|')}`, `[ihab/SegmentedControl] 이름 없는 라디오그룹이 한 화면에 ${unlabeledGroups.size} 개 서 있습니다 — ` +
47
47
  `낭독기는 모두 "선택 탭" 으로 읽어 어느 것이 무엇인지 가릴 수 없습니다. ariaLabel 을 주세요. ` +
48
- `지금 선 것(보기 이름으로 적음): ${모두.join(' / ')}`);
48
+ `지금 선 것(보기 이름으로 적음): ${all.join(' / ')}`);
49
49
  }
50
- return () => { 이름없이선것.delete(내자리); };
51
- }, [ariaLabel, 서명, 내자리]);
52
- const 옮기기 = (걸음) => {
53
- if (!고를수있는.length)
50
+ return () => { unlabeledGroups.delete(uid); };
51
+ }, [ariaLabel, signature, uid]);
52
+ const moveBy = (step) => {
53
+ if (!selectable.length)
54
54
  return;
55
- const 지금 = Math.max(0, 고를수있는.findIndex((o) => o.key === value));
56
- const 다음 = 걸음 === 'first' ? 고를수있는[0]
57
- : 걸음 === 'last' ? 고를수있는[고를수있는.length - 1]
58
- : 고를수있는[(지금 + 걸음 + 고를수있는.length) % 고를수있는.length];
59
- if (!다음)
55
+ const current = Math.max(0, selectable.findIndex((o) => o.key === value));
56
+ const next = step === 'first' ? selectable[0]
57
+ : step === 'last' ? selectable[selectable.length - 1]
58
+ : selectable[(current + step + selectable.length) % selectable.length];
59
+ if (!next)
60
60
  return;
61
61
  /* 이미 그 자리여도 포커스는 옮긴다 — 칸이 하나뿐일 때 멈춘 것처럼 보이지 않게 */
62
- onChange(다음.key);
63
- const 칸 = Array.from(그룹.current?.querySelectorAll('[data-segkey]') ?? [])
64
- .find((el) => el.dataset.segkey === 다음.key);
65
- 칸?.focus();
62
+ onChange(next.key);
63
+ const buttons = Array.from(groupRef.current?.querySelectorAll('[data-segkey]') ?? [])
64
+ .find((el) => el.dataset.segkey === next.key);
65
+ buttons?.focus();
66
66
  };
67
- const 키 = (e) => {
67
+ const onKeyDown = (e) => {
68
68
  switch (e.key) {
69
69
  case 'ArrowRight':
70
70
  case 'ArrowDown':
71
- 옮기기(1);
71
+ moveBy(1);
72
72
  break;
73
73
  case 'ArrowLeft':
74
74
  case 'ArrowUp':
75
- 옮기기(-1);
75
+ moveBy(-1);
76
76
  break;
77
77
  case 'Home':
78
- 옮기기('first');
78
+ moveBy('first');
79
79
  break;
80
80
  case 'End':
81
- 옮기기('last');
81
+ moveBy('last');
82
82
  break;
83
83
  default: return;
84
84
  }
85
85
  e.preventDefault();
86
86
  };
87
- const 이름 = (opt) => {
88
- const 글자 = (v) => typeof v === 'string' || typeof v === 'number';
89
- if (!글자(opt.label) || opt.count == null || opt.count === false || !글자(opt.count))
87
+ const labelOf = (opt) => {
88
+ const isText = (v) => typeof v === 'string' || typeof v === 'number';
89
+ if (!isText(opt.label) || opt.count == null || opt.count === false || !isText(opt.count))
90
90
  return undefined;
91
91
  return `${opt.label}, ${opt.count}`;
92
92
  };
93
93
  /* Tab 자리를 받을 한 칸 — 고른 것이 있으면 그것, 없으면 고를 수 있는 첫 칸 */
94
- const 탭자리 = 고를수있는.some((o) => o.key === value) ? value : 고를수있는[0]?.key;
95
- return (_jsx("div", { ref: 그룹, onKeyDown: 키, className: cx('segmented-control', `segmented-control--${shape}`, `segmented-control--${size}`, fullWidth && 'segmented-control--full', !useBG && 'segmented-control--no-bg', minButtonWidth != null && 'segmented-control--min', className), style: minButtonWidth != null ? { ['--segmented-min-button-w']: `${minButtonWidth}px` } : undefined, role: "radiogroup", "aria-label": ariaLabel ?? t('segmentedControl.ariaLabel', '선택 탭'), children: options.map((opt) => (_jsx(Button, { htmlType: "button", role: "radio", "aria-checked": opt.key === value, "data-segkey": opt.key, tabIndex: opt.key === 탭자리 ? 0 : -1, label: opt.label, count: opt.count,
94
+ const tabKey = selectable.some((o) => o.key === value) ? value : selectable[0]?.key;
95
+ return (_jsx("div", { ref: groupRef, onKeyDown: onKeyDown, className: cx('segmented-control', `segmented-control--${shape}`, `segmented-control--${size}`, fullWidth && 'segmented-control--full', !useBG && 'segmented-control--no-bg', minButtonWidth != null && 'segmented-control--min', className), style: minButtonWidth != null ? { ['--segmented-min-button-w']: `${minButtonWidth}px` } : undefined, role: "radiogroup", "aria-label": ariaLabel ?? t('segmentedControl.ariaLabel', '선택 탭'), children: options.map((opt) => (_jsx(Button, { htmlType: "button", role: "radio", "aria-checked": opt.key === value, "data-segkey": opt.key, tabIndex: opt.key === tabKey ? 0 : -1, label: opt.label, count: opt.count,
96
96
  /* 수를 이름에 넣는다 — 글자 이름표는 단추의 aria-label 이 되어 내용(수)이 이름 계산에서 빠졌다("전체" 로만 읽힘) */
97
- ariaLabel: 이름(opt), disabled: opt.disabled, className: opt.key === value ? 'is-selected' : '', onClick: () => { if (!opt.disabled)
97
+ ariaLabel: labelOf(opt), disabled: opt.disabled, className: opt.key === value ? 'is-selected' : '', onClick: () => { if (!opt.disabled)
98
98
  onChange(opt.key); }, size: size, shape: shape, useBorder: false, useBG: false }, opt.key))) }));
99
99
  }
@@ -22,9 +22,9 @@ function SideNavFooter(props) {
22
22
  넷 서 있었다 — 눌러도 아무 일이 없으니 사람은 고장으로 읽고, 낭독기에는
23
23
  '알림 단추' 라고 읽히는데 실제로는 아무 데도 못 간다.
24
24
  **없는 단추가 죽은 단추보다 낫다.** */
25
- const 그릴것 = onDebug || onNotification || onSetting || onHelp || onFeedback || onAccount;
25
+ const hasAny = onDebug || onNotification || onSetting || onHelp || onFeedback || onAccount;
26
26
  // 하나도 없으면 바닥 자체를 비운다 — 빈 채로 두면 여백(margin-top + padding)만 남는다.
27
- if (!그릴것)
27
+ if (!hasAny)
28
28
  return null;
29
29
  return (_jsxs("div", { className: "side-nav__footer", children: [onDebug && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "terminal", ariaLabel: t('nav.debug', '디버그'), onClick: onDebug, selected: activeKey === 'debug' })), onNotification && (_jsxs("span", { className: "side-nav__notification-wrap", children: [_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "bell", ariaLabel: nameWithCount(t, t('nav.notifications', '알림'), unreadCount), onClick: onNotification, selected: activeKey === 'notifications' }), !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true }))] })), onSetting && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "settings", ariaLabel: t('nav.settings', '설정'), onClick: onSetting, selected: activeKey === 'settings' })), onHelp && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "message-circle-question-mark", ariaLabel: t('nav.help', '도움말'), onClick: onHelp, selected: activeKey === 'help' })), onFeedback && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "bug", ariaLabel: t('nav.feedback', '버그신고'), onClick: onFeedback, selected: activeKey === 'feedback' })), onAccount && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "user-round", ariaLabel: t('nav.account', '계정'), onClick: onAccount, selected: activeKey === 'account' }))] }));
30
30
  }
@@ -65,76 +65,76 @@ export default function SideNav(props) {
65
65
  const handleModeSwitch = useCallback(() => onAppModeChange?.(isManager ? 'malgwi' : 'manager'), [onAppModeChange, isManager]);
66
66
  /* **떠 있는 막대의 자리와 길이** — 구르는 통(이 `<nav>`)을 재어 셈한다. 네이티브 막대는
67
67
  폭을 정할 수 없고 자리를 먹어 항목을 밀어내므로(2026-09-25 실측: 오른쪽만 23px) 직접 그린다. */
68
- const 통ref = useRef(null);
69
- const [막대, set막대] = useState(null);
68
+ const containerRef = useRef(null);
69
+ const [thumb, setThumb] = useState(null);
70
70
  useEffect(() => {
71
- const el = 통ref.current;
71
+ const el = containerRef.current;
72
72
  if (!el)
73
73
  return;
74
74
  /* **굴림 한 번에 한 번만 센다**(말귀 요청 (115), 2026-10-10). 전에는 `scroll` 마다 바로 `setState` 라
75
75
  굴리는 동안 틀마다 여러 번 다시 그렸다. 틀에 한 번으로 모으고(rAF), **값이 그대로면 상태를 안
76
76
  건드린다** — 같은 수를 새 객체로 넘기면 React 는 바뀐 줄 안다. */
77
- let 예약 = null;
78
- const 셈 = () => {
77
+ let rafId = null;
78
+ const compute = () => {
79
79
  const { scrollHeight, clientHeight, scrollTop } = el;
80
80
  /* 넘치지 않으면 그리지 않는다 — 없는 스크롤을 알릴 까닭이 없다 */
81
81
  if (scrollHeight <= clientHeight + 1) {
82
- set막대(prev => (prev === null ? prev : null));
82
+ setThumb(prev => (prev === null ? prev : null));
83
83
  return;
84
84
  }
85
- const 길이 = Math.max(24, (clientHeight / scrollHeight) * clientHeight);
86
- const 남은길 = clientHeight - 길이;
87
- const 남은굴림 = scrollHeight - clientHeight;
88
- const 다음 = { top: Math.round((scrollTop / 남은굴림) * 남은길), height: Math.round(길이) };
89
- set막대(prev => (prev && prev.top === 다음.top && prev.height === 다음.height ? prev : 다음));
85
+ const thumbHeight = Math.max(24, (clientHeight / scrollHeight) * clientHeight);
86
+ const trackRemain = clientHeight - thumbHeight;
87
+ const scrollRemain = scrollHeight - clientHeight;
88
+ const next = { top: Math.round((scrollTop / scrollRemain) * trackRemain), height: Math.round(thumbHeight) };
89
+ setThumb(prev => (prev && prev.top === next.top && prev.height === next.height ? prev : next));
90
90
  };
91
- const 재기 = () => {
91
+ const measure = () => {
92
92
  if (typeof requestAnimationFrame === 'undefined') {
93
- 셈();
93
+ compute();
94
94
  return;
95
95
  }
96
- if (예약 !== null)
96
+ if (rafId !== null)
97
97
  return;
98
- 예약 = requestAnimationFrame(() => { 예약 = null; 셈(); });
98
+ rafId = requestAnimationFrame(() => { rafId = null; compute(); });
99
99
  };
100
- 셈();
101
- el.addEventListener('scroll', 재기, { passive: true });
100
+ compute();
101
+ el.addEventListener('scroll', measure, { passive: true });
102
102
  /* 항목이 늘거나 창이 바뀌면 길이도 달라진다 */
103
- const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(재기) : null;
103
+ const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null;
104
104
  ro?.observe(el);
105
105
  for (const c of Array.from(el.children))
106
106
  ro?.observe(c);
107
107
  return () => {
108
- if (예약 !== null)
109
- cancelAnimationFrame(예약);
110
- el.removeEventListener('scroll', 재기);
108
+ if (rafId !== null)
109
+ cancelAnimationFrame(rafId);
110
+ el.removeEventListener('scroll', measure);
111
111
  ro?.disconnect();
112
112
  };
113
113
  }, [items]);
114
114
  /* **막대를 잡아 끌 수 있다.** 손에 잡히게 그려 놓고 안 잡히면 그 꼴이 거짓말이 된다.
115
115
  통의 한 픽셀은 막대의 `scrollHeight / clientHeight` 배다 — 그 비로 옮긴다. */
116
- const 끌기 = useRef(null);
117
- const 막대잡기 = useCallback((e) => {
118
- const el = 통ref.current;
116
+ const dragRef = useRef(null);
117
+ const onThumbDown = useCallback((e) => {
118
+ const el = containerRef.current;
119
119
  if (!el)
120
120
  return;
121
121
  e.preventDefault();
122
- 끌기.current = { y: e.clientY, top: el.scrollTop };
122
+ dragRef.current = { y: e.clientY, top: el.scrollTop };
123
123
  e.currentTarget.setPointerCapture(e.pointerId);
124
124
  }, []);
125
- const 막대끌기 = useCallback((e) => {
126
- const el = 통ref.current;
127
- const 잡은것 = 끌기.current;
128
- if (!el || !잡은것)
125
+ const onThumbMove = useCallback((e) => {
126
+ const el = containerRef.current;
127
+ const held = dragRef.current;
128
+ if (!el || !held)
129
129
  return;
130
- el.scrollTop = 잡은것.top + (e.clientY - 잡은것.y) * (el.scrollHeight / el.clientHeight);
130
+ el.scrollTop = held.top + (e.clientY - held.y) * (el.scrollHeight / el.clientHeight);
131
131
  }, []);
132
- const 막대놓기 = useCallback((e) => {
133
- 끌기.current = null;
132
+ const onThumbUp = useCallback((e) => {
133
+ dragRef.current = null;
134
134
  if (e.currentTarget.hasPointerCapture(e.pointerId))
135
135
  e.currentTarget.releasePointerCapture(e.pointerId);
136
136
  }, []);
137
- return (_jsxs("nav", { ref: 통ref, "aria-label": t('nav.mainMenu', '메인 메뉴'), className: "side-nav__menu", children: [막대 && (_jsx("div", { className: "side-nav__scroll", "aria-hidden": "true", children: _jsx("div", { className: "side-nav__scroll-thumb", style: { top: 막대.top, height: 막대.height }, onPointerDown: 막대잡기, onPointerMove: 막대끌기, onPointerUp: 막대놓기, onPointerCancel: 막대놓기 }) })), _jsxs("div", { className: "side-nav__menu-list", children: [(items || [])
137
+ return (_jsxs("nav", { ref: containerRef, "aria-label": t('nav.mainMenu', '메인 메뉴'), className: "side-nav__menu", children: [thumb && (_jsx("div", { className: "side-nav__scroll", "aria-hidden": "true", children: _jsx("div", { className: "side-nav__scroll-thumb", style: { top: thumb.top, height: thumb.height }, onPointerDown: onThumbDown, onPointerMove: onThumbMove, onPointerUp: onThumbUp, onPointerCancel: onThumbUp }) })), _jsxs("div", { className: "side-nav__menu-list", children: [(items || [])
138
138
  .filter((it) => !it.section && (it.key || it.label))
139
139
  .map((it, idx) => (_jsxs(React.Fragment, { children: [it.divider && _jsx("div", { className: "side-nav__separator" }), _jsx(SideNavButton, { item: it, active: !!it.key && it.key === activeKey, state: it.disabled ? 'disabled' : undefined, onClick: (_key, btnEl, fromPointer) => {
140
140
  if (it.disabled)
@@ -174,8 +174,8 @@ export default function SideNav(props) {
174
174
  const supportsMatches = typeof headerEl.matches === 'function';
175
175
  /* :hover 는 **호버가 되는 기기에서만** 믿는다 — iPad 는 톡한 요소에 :hover 가 붙어 남아, "마우스가 레일 위에 있다" 로
176
176
  읽혀 강제 접힘을 건너뛰었다(메뉴를 골라도 레일이 펼친 채 본문을 가림) */
177
- const 호버기기 = window.matchMedia('(hover: hover)').matches;
178
- const isHovered = 호버기기 && supportsMatches ? headerEl.matches(':hover') : false;
177
+ const hasHover = window.matchMedia('(hover: hover)').matches;
178
+ const isHovered = hasHover && supportsMatches ? headerEl.matches(':hover') : false;
179
179
  if (!isHovered) {
180
180
  headerEl.classList.add('header--force-collapsed');
181
181
  const onEnter = () => {
@@ -87,7 +87,7 @@ export default function Slider({ value, onChange, min = 0, max = 100, step = 1,
87
87
  }
88
88
  /* 낭독기가 들을 값 글자 — 숫자만 넘기면 "1.25x"·"위"·"3 / 7" 이 "1.25"·"0"·"3" 으로 들렸다. 보이는 라벨과 같은 글자,
89
89
  라벨이 없으면 끝값의 minLabel·maxLabel. 둘 다 아니면 숫자 그대로(undefined). */
90
- const 값글자 = formatValue
90
+ const valueFormatter = formatValue
91
91
  ? formatValue(clamped)
92
92
  : label === 'percent' ? `${Math.round(max > min ? ((clamped - min) / (max - min)) * 100 : 0)}%`
93
93
  : label === 'count' ? `${clamped} / ${max}`
@@ -95,7 +95,7 @@ export default function Slider({ value, onChange, min = 0, max = 100, step = 1,
95
95
  : clamped === max && maxLabel ? maxLabel
96
96
  : undefined;
97
97
  // 루트가 아닐 때(label/showMinMax로 감싸질 때)는 className을 바깥 wrapper가 대신 가져간다
98
- const input = (_jsx("input", { type: "range", className: cx('slider', `slider--${orientation}`, isStatic && 'slider--static', !label && !showMinMax && className), min: min, max: max, step: step, value: clamped, disabled: disabled, readOnly: isStatic, tabIndex: isStatic ? -1 : undefined, "aria-label": ariaLabel, "aria-valuetext": 값글자, "aria-orientation": orientation, onChange: handleChange, style: style }));
98
+ const input = (_jsx("input", { type: "range", className: cx('slider', `slider--${orientation}`, isStatic && 'slider--static', !label && !showMinMax && className), min: min, max: max, step: step, value: clamped, disabled: disabled, readOnly: isStatic, tabIndex: isStatic ? -1 : undefined, "aria-label": ariaLabel, "aria-valuetext": valueFormatter, "aria-orientation": orientation, onChange: handleChange, style: style }));
99
99
  if (!label && !showMinMax)
100
100
  return input;
101
101
  // 트랙 양 끝 최소·최대값 텍스트 — 세로는 채움 방향(아래→위)에 맞춰 위=max/아래=min 순서로 배치
@@ -16,7 +16,7 @@ import './SoundCompass.css';
16
16
  const lerp = (a, b, t) => a + (b - a) * t;
17
17
  /** @public */
18
18
  export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSpeakerId = null, playedIds, selectedId = null, answerId = null, showFeedback = false, pulseSelected = false, pulseSelectedDuration, pulseScale, clickable = false, onSelectSpeaker, buttonSize = 40, showIcon = true, onCenterClick, centerLabel, centerDisabled = false, centerAttention = false, shortcutAnchor = 'top-right', shortcutScale, scaleShortcut = false, }) {
19
- const { t: 번역 } = useTranslation();
19
+ const { t: translate } = useTranslation();
20
20
  const t = Math.max(0, Math.min(1, viewT));
21
21
  const CENTER = size / 2;
22
22
  // ── 탑뷰 좌표 ──────────────────────────────────────────────────────────
@@ -123,10 +123,10 @@ export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSp
123
123
  const played = useMemo(() => new Set(playedIds ?? []), [playedIds]);
124
124
  const stateLabel = (state) => ({
125
125
  idle: '',
126
- active: 번역('soundCompass.state.active', '소리 나는 중'),
127
- played: 번역('soundCompass.state.played', '들음'),
128
- correct: 번역('soundCompass.state.correct', '정답'),
129
- wrong: 번역('soundCompass.state.wrong', '오답'),
126
+ active: translate('soundCompass.state.active', '소리 나는 중'),
127
+ played: translate('soundCompass.state.played', '들음'),
128
+ correct: translate('soundCompass.state.correct', '정답'),
129
+ wrong: translate('soundCompass.state.wrong', '오답'),
130
130
  })[state];
131
131
  const getSpeakerState = (sp) => {
132
132
  if (activeSpeakerId === sp.id)