@ihabdevteam/core 0.321.0 → 0.323.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (217) hide show
  1. package/CHANGELOG.md +617 -0
  2. package/dist/components.cjs.js +183 -183
  3. package/dist/components.esm.css +1 -1
  4. package/dist/hooks.cjs.js +3 -3
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +187 -187
  7. package/dist/ihabdevteam-core.d.ts +108 -66
  8. package/dist/packages/core/src/components/Accordion.js +3 -3
  9. package/dist/packages/core/src/components/Alert.js +2 -2
  10. package/dist/packages/core/src/components/BarChart.js +120 -119
  11. package/dist/packages/core/src/components/BottomNav.js +18 -18
  12. package/dist/packages/core/src/components/BulkBar.js +25 -25
  13. package/dist/packages/core/src/components/Button.js +16 -16
  14. package/dist/packages/core/src/components/Calendar.js +17 -17
  15. package/dist/packages/core/src/components/ChartSection.js +30 -30
  16. package/dist/packages/core/src/components/CountTile.js +38 -38
  17. package/dist/packages/core/src/components/DatePicker.js +59 -59
  18. package/dist/packages/core/src/components/Dialog.js +14 -14
  19. package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +8 -8
  20. package/dist/packages/core/src/components/FilterPanel.js +6 -6
  21. package/dist/packages/core/src/components/FilterSectionInfo.js +6 -6
  22. package/dist/packages/core/src/components/GameIntro.js +1 -1
  23. package/dist/packages/core/src/components/HearingDeviceInfo.d.ts +5 -5
  24. package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
  25. package/dist/packages/core/src/components/Icon.js +2 -2
  26. package/dist/packages/core/src/components/ItemActions.js +16 -16
  27. package/dist/packages/core/src/components/LandingHeader.js +6 -6
  28. package/dist/packages/core/src/components/LineChart.js +24 -24
  29. package/dist/packages/core/src/components/Loader.js +5 -5
  30. package/dist/packages/core/src/components/NoticeBox.js +3 -3
  31. package/dist/packages/core/src/components/PageTitle.js +2 -2
  32. package/dist/packages/core/src/components/Pagination.js +4 -4
  33. package/dist/packages/core/src/components/Panel.js +2 -2
  34. package/dist/packages/core/src/components/PanelRow.d.ts +2 -4
  35. package/dist/packages/core/src/components/PanelRow.js +8 -15
  36. package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
  37. package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
  38. package/dist/packages/core/src/components/PaymentPlan.js +5 -5
  39. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
  40. package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
  41. package/dist/packages/core/src/components/Popup/DeviceListPopup.d.ts +4 -8
  42. package/dist/packages/core/src/components/Popup/DeviceListPopup.js +1 -1
  43. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +19 -26
  44. package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
  45. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +10 -10
  46. package/dist/packages/core/src/components/Popup/Popup.js +27 -27
  47. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
  48. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
  49. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
  50. package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
  51. package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
  52. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
  53. package/dist/packages/core/src/components/Rating.js +20 -20
  54. package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
  55. package/dist/packages/core/src/components/RingChart.js +5 -5
  56. package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
  57. package/dist/packages/core/src/components/SegmentedControl.js +34 -34
  58. package/dist/packages/core/src/components/SideNav.js +37 -37
  59. package/dist/packages/core/src/components/Slider.js +2 -2
  60. package/dist/packages/core/src/components/SoundCompass.js +5 -5
  61. package/dist/packages/core/src/components/Table.js +257 -98
  62. package/dist/packages/core/src/components/Tabs.js +12 -12
  63. package/dist/packages/core/src/components/TextField.js +34 -34
  64. package/dist/packages/core/src/components/Toast.js +6 -6
  65. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
  66. package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
  67. package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
  68. package/dist/packages/core/src/components/TopBanner.js +28 -28
  69. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
  70. package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
  71. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +7 -4
  72. package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
  73. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
  74. package/dist/packages/core/src/components/Training/TrainingBlock.js +12 -12
  75. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
  76. package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
  77. package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
  78. package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
  79. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
  80. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +83 -83
  81. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
  82. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +128 -128
  83. package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
  84. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
  85. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
  86. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +13 -13
  87. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
  88. package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
  89. package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
  90. package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
  91. package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
  92. package/dist/packages/core/src/components/Training/oneShotSound.d.ts +1 -1
  93. package/dist/packages/core/src/components/Training/oneShotSound.js +14 -14
  94. package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +2 -2
  95. package/dist/packages/core/src/components/Training/trainingMissStats.js +22 -22
  96. package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
  97. package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
  98. package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
  99. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
  100. package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
  101. package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
  102. package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
  103. package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
  104. package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
  105. package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
  106. package/dist/packages/core/src/components/UpdateAlert.js +6 -6
  107. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
  108. package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
  109. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +18 -18
  110. package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
  111. package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
  112. package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
  113. package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
  114. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
  115. package/dist/packages/core/src/components/Word/wordScoreStats.js +17 -17
  116. package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
  117. package/dist/packages/core/src/components/Word/wordTableRecommend.js +13 -13
  118. package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
  119. package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
  120. package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
  121. package/dist/packages/core/src/components/WordTable.js +130 -130
  122. package/dist/packages/core/src/components/barChartData.d.ts +16 -1
  123. package/dist/packages/core/src/components/barChartData.js +49 -29
  124. package/dist/packages/core/src/components/barChartTypes.js +8 -8
  125. package/dist/packages/core/src/components/tableTrackList.js +11 -11
  126. package/dist/packages/core/src/components/updateAlertStack.js +28 -28
  127. package/dist/packages/core/src/components/useBarChartControl.js +4 -6
  128. package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
  129. package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
  130. package/dist/packages/core/src/components/wordTableLayout.d.ts +22 -7
  131. package/dist/packages/core/src/components/wordTableLayout.js +5 -5
  132. package/dist/packages/core/src/context/I18nProvider.js +6 -6
  133. package/dist/packages/core/src/hooks/focusReturn.js +27 -27
  134. package/dist/packages/core/src/hooks/inputModality.d.ts +1 -1
  135. package/dist/packages/core/src/hooks/inputModality.js +14 -14
  136. package/dist/packages/core/src/hooks/touchToggle.js +39 -39
  137. package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
  138. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
  139. package/dist/packages/core/src/hooks/useDeviceManagement.d.ts +5 -17
  140. package/dist/packages/core/src/hooks/useDeviceManagement.js +20 -8
  141. package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
  142. package/dist/packages/core/src/hooks/useEditableList.js +4 -4
  143. package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
  144. package/dist/packages/core/src/hooks/useFontScale.js +6 -6
  145. package/dist/packages/core/src/hooks/useIcon.js +7 -7
  146. package/dist/packages/core/src/hooks/useListControls.js +2 -2
  147. package/dist/packages/core/src/hooks/usePageResetOnChange.d.ts +3 -3
  148. package/dist/packages/core/src/hooks/usePageResetOnChange.js +6 -6
  149. package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
  150. package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
  151. package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
  152. package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
  153. package/dist/packages/core/src/hooks/useTablePaging.d.ts +7 -7
  154. package/dist/packages/core/src/hooks/useTablePaging.js +40 -34
  155. package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
  156. package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
  157. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
  158. package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
  159. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +3 -1
  160. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +14 -12
  161. package/dist/packages/core/src/i18n/locales/en.json +13 -2
  162. package/dist/packages/core/src/i18n/locales/ko.json +12 -1
  163. package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
  164. package/dist/packages/core/src/utils/audioMeter.js +5 -5
  165. package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
  166. package/dist/packages/core/src/utils/audioPeaks.js +18 -18
  167. package/dist/packages/core/src/utils/blockGrid.js +4 -4
  168. package/dist/packages/core/src/utils/chunkSkew.js +9 -9
  169. package/dist/packages/core/src/utils/clipboard.js +3 -3
  170. package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
  171. package/dist/packages/core/src/utils/date.js +12 -12
  172. package/dist/packages/core/src/utils/devWarn.d.ts +2 -2
  173. package/dist/packages/core/src/utils/devWarn.js +7 -7
  174. package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
  175. package/dist/packages/core/src/utils/deviceKind.js +53 -10
  176. package/dist/packages/core/src/utils/errorMessage.js +7 -7
  177. package/dist/packages/core/src/utils/filterPanelSummary.d.ts +1 -1
  178. package/dist/packages/core/src/utils/filterPanelSummary.js +16 -16
  179. package/dist/packages/core/src/utils/format.js +4 -4
  180. package/dist/packages/core/src/utils/getTrainingData.js +9 -9
  181. package/dist/packages/core/src/utils/imageConfig.js +17 -17
  182. package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
  183. package/dist/packages/core/src/utils/navScroll.d.ts +2 -2
  184. package/dist/packages/core/src/utils/navScroll.js +6 -6
  185. package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
  186. package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
  187. package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
  188. package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
  189. package/dist/packages/core/src/utils/phonemePairSearch.js +118 -102
  190. package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
  191. package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
  192. package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
  193. package/dist/packages/core/src/utils/pressable.d.ts +1 -1
  194. package/dist/packages/core/src/utils/pressable.js +3 -3
  195. package/dist/packages/core/src/utils/safeHref.js +4 -4
  196. package/dist/packages/core/src/utils/savedWordTables.js +5 -5
  197. package/dist/packages/core/src/utils/sessionTab.d.ts +7 -7
  198. package/dist/packages/core/src/utils/sessionTab.js +9 -9
  199. package/dist/packages/core/src/utils/speak.js +12 -12
  200. package/dist/packages/core/src/utils/textSort.d.ts +5 -5
  201. package/dist/packages/core/src/utils/textSort.js +47 -47
  202. package/dist/packages/core/src/utils/theme.js +3 -3
  203. package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
  204. package/dist/packages/core/src/utils/ttsConfig.js +2 -2
  205. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +2 -2
  206. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +48 -48
  207. package/dist/packages/core/src/utils/wordContrast.js +3 -3
  208. package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
  209. package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
  210. package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
  211. package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
  212. package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
  213. package/dist/packages/core/src/utils/wornDate.js +14 -14
  214. package/dist/packages/core/src/version.d.ts +1 -1
  215. package/dist/packages/core/src/version.js +1 -1
  216. package/dist/utils.cjs.js +4 -4
  217. package/package.json +1 -1
@@ -6,7 +6,7 @@ import Popup from './Popup';
6
6
  import Table from '../Table';
7
7
  import Toolbar from '../Toolbar';
8
8
  import './ListPopup.css';
9
- import { 칸순빈값뒤 } from '../../utils/textSort';
9
+ import { compareCellBlankLast } from '../../utils/textSort';
10
10
  import { cx } from '../../utils/cx';
11
11
  /** @public */
12
12
  export default function ListPopup(props) {
@@ -48,9 +48,9 @@ export default function ListPopup(props) {
48
48
  return 0;
49
49
  /* `<`·`>` 로 견주면 코드포인트 차례가 된다 — 한글끼리는 맞아떨어져 안 드러나다가
50
50
  **한글과 영문이 섞이면** 어긋난다(라틴이 한글보다 앞이라 `fwef` 가
51
- `검수테스트2` 위에 선다). 숫자 칸은 숫자로 견주는 것을 `칸순` 이 지킨다.
51
+ `검수테스트2` 위에 선다). 숫자 칸은 숫자로 견주는 것을 `compareCell` 이 지킨다.
52
52
  빈 값(null·undefined·'')은 오름·내림 모두 맨 뒤 — 전에는 '' 로 채워 오름차순 맨 앞에 섰다(useTableSort 와 같은 규칙). */
53
- return 칸순빈값뒤(va, vb, sortAsc);
53
+ return compareCellBlankLast(va, vb, sortAsc);
54
54
  });
55
55
  }, [items, search, sortKey, sortAsc, searchEnabled, effectiveSearchFn]);
56
56
  const totalPages = Math.max(1, Math.ceil(filtered.length / Number(pageSize)));
@@ -58,8 +58,8 @@ export default function ListPopup(props) {
58
58
  떴다. 렌더 중에 상태도 맞춘다. */
59
59
  if (page > totalPages)
60
60
  setPage(totalPages);
61
- const 이쪽 = Math.min(page, totalPages);
62
- const paged = filtered.slice((이쪽 - 1) * Number(pageSize), 이쪽 * Number(pageSize));
61
+ const clampedPage = Math.min(page, totalPages);
62
+ const paged = filtered.slice((clampedPage - 1) * Number(pageSize), clampedPage * Number(pageSize));
63
63
  const handleSort = useCallback((key) => {
64
64
  if (sortKey === key)
65
65
  setSortAsc(a => !a);
@@ -99,7 +99,7 @@ export default function ListPopup(props) {
99
99
  sortDir: col.sortable !== false && sortKey === col.key ? (sortAsc ? 'asc' : 'desc') : null,
100
100
  })), [columns, sortKey, sortAsc, handleSort]);
101
101
  const pageSizeOpts = useMemo(() => pageSizeOptions.map(n => ({ value: String(n), label: t('common.pageSizeLabel', { n }) })), [pageSizeOptions, t]);
102
- return (_jsxs(Popup, { className: `popup-list ${className}`, title: title, onClose: onClose, children: [topContent, 'label' in countParts && _jsx("p", { className: "toolbar__count body-1", children: countParts.label }), _jsx(Toolbar, { count: 'label' in countParts ? undefined : { value: filtered.length, prefix: countParts.prefix, suffix: countParts.suffix }, search: searchEnabled ? { value: search, onChange: setSearch, placeholder: resolvedSearchPlaceholder } : undefined, pageSize: { options: pageSizeOpts, value: pageSize, onChange: setPageSize }, pagination: { page: 이쪽, total: totalPages, onChange: setPage } }), _jsx(Table, { size: "medium", gridTemplateColumns: gridTemplateColumns, mobileGridTemplateColumns: mobileGridTemplateColumns,
102
+ return (_jsxs(Popup, { className: `popup-list ${className}`, title: title, onClose: onClose, children: [topContent, 'label' in countParts && _jsx("p", { className: "toolbar__count body-1", children: countParts.label }), _jsx(Toolbar, { count: 'label' in countParts ? undefined : { value: filtered.length, prefix: countParts.prefix, suffix: countParts.suffix }, search: searchEnabled ? { value: search, onChange: setSearch, placeholder: resolvedSearchPlaceholder } : undefined, pageSize: { options: pageSizeOpts, value: pageSize, onChange: setPageSize }, pagination: { page: clampedPage, total: totalPages, onChange: setPage } }), _jsx(Table, { size: "medium", gridTemplateColumns: gridTemplateColumns, mobileGridTemplateColumns: mobileGridTemplateColumns,
103
103
  /* 폭 조절은 켜되 **저장 열쇠는 안 준다** — 창은 열 때마다 새로 짓는 것이라 폭이 따라다니면
104
104
  엉뚱하다. 그래서 이 창의 표는 끌어도 localStorage 에 아무 자국이 없고, 끈 폭은 창이 닫히면
105
105
  사라진다. 폰에서 '오늘의 듣기 현황 상세보기' 가 `48px 48px …` 로 눌렸는데 저장소가
@@ -6,7 +6,7 @@ import Popup from './Popup';
6
6
  import Button from '../Button';
7
7
  import Dropdown from '../Dropdown';
8
8
  import { getSavedTables, deleteTable as localDeleteTable } from '../../utils/savedWordTables';
9
- import { 한글순 } from '../../utils/textSort';
9
+ import { compareKorean } from '../../utils/textSort';
10
10
  import { formatYmd } from '../../utils/date';
11
11
  import './LoadTablePopup.css';
12
12
  import { pressable } from '../../utils/pressable';
@@ -59,9 +59,9 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
59
59
  return copy.sort((a, b) => String(b.savedAt ?? '').localeCompare(String(a.savedAt ?? '')));
60
60
  if (sortKey === 'oldest')
61
61
  return copy.sort((a, b) => String(a.savedAt ?? '').localeCompare(String(b.savedAt ?? '')));
62
- /* 이름은 **`한글순`(콜레이터 하나)** — `localeCompare(…, 'ko')` 는 비교마다 콜레이터를 새로 만든다((113)) */
62
+ /* 이름은 **`compareKorean`(콜레이터 하나)** — `localeCompare(…, 'ko')` 는 비교마다 콜레이터를 새로 만든다((113)) */
63
63
  if (sortKey === 'name')
64
- return copy.sort((a, b) => 한글순(a.name, b.name));
64
+ return copy.sort((a, b) => compareKorean(a.name, b.name));
65
65
  if (sortKey === 'count')
66
66
  return copy.sort((a, b) => b.items.length - a.items.length);
67
67
  return copy;
@@ -77,20 +77,20 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
77
77
  cardEls.current.set(id, el);
78
78
  else
79
79
  cardEls.current.delete(id); };
80
- const 지운뒤초점 = useRef(null);
80
+ const focusAfterDeleteRef = useRef(null);
81
81
  useEffect(() => {
82
- const p = 지운뒤초점.current;
82
+ const p = focusAfterDeleteRef.current;
83
83
  if (!p || sorted.some(t => t.id === p.id))
84
84
  return;
85
- 지운뒤초점.current = null;
86
- const 곳 = [p.next, p.prev].find(id => id != null && sorted.some(t => t.id === id));
87
- if (곳)
88
- cardEls.current.get(곳)?.focus();
85
+ focusAfterDeleteRef.current = null;
86
+ const nextId = [p.next, p.prev].find(id => id != null && sorted.some(t => t.id === id));
87
+ if (nextId)
88
+ cardEls.current.get(nextId)?.focus();
89
89
  }, [sorted]);
90
90
  const handleDelete = useCallback((id, e) => {
91
91
  e.stopPropagation();
92
92
  const i = sorted.findIndex(t => t.id === id);
93
- 지운뒤초점.current = { id, next: sorted[i + 1]?.id ?? null, prev: i > 0 ? sorted[i - 1].id : null };
93
+ focusAfterDeleteRef.current = { id, next: sorted[i + 1]?.id ?? null, prev: i > 0 ? sorted[i - 1].id : null };
94
94
  if (onDeleteProp) {
95
95
  onDeleteProp(id);
96
96
  }
@@ -38,13 +38,13 @@ export default function Popup(props) {
38
38
  if (inline)
39
39
  return;
40
40
  if (!title && !ariaLabel && import.meta.env?.DEV) {
41
- const 본문 = panelRef.current?.textContent?.replace(/\s+/g, ' ').trim().slice(0, 60) ?? '';
42
- const 표시 = [
41
+ const bodyText = panelRef.current?.textContent?.replace(/\s+/g, ' ').trim().slice(0, 60) ?? '';
42
+ const marks = [
43
43
  className ? `class="${className}"` : null,
44
- 본문 ? `본문 "${본문}${본문.length >= 60 ? '…' : ''}"` : null,
44
+ bodyText ? `본문 "${bodyText}${bodyText.length >= 60 ? '…' : ''}"` : null,
45
45
  ].filter(Boolean).join(' · ');
46
46
  console.warn(`[Popup] 제목도 ariaLabel도 없어 다이얼로그에 접근명이 없습니다 — ariaLabel을 주세요.` +
47
- (표시 ? ` (${표시})` : ' (단서 없음 — 빈 팝업)'));
47
+ (marks ? ` (${marks})` : ' (단서 없음 — 빈 팝업)'));
48
48
  }
49
49
  }, [inline, title, ariaLabel, className]);
50
50
  // resetScrollKey가 바뀌면(탭/모드 전환 등) 본문 스크롤을 맨 위로 되돌린다 —
@@ -114,59 +114,59 @@ export default function Popup(props) {
114
114
  /* 닫힘 애니메이션 뒤 콜백을 부른다. **콜백이 던지거나 거절되면, 또는 약속을 돌려준 콜백이 끝난 뒤에도 이 창이 그대로 있으면 되살린다** —
115
115
  전에는 닫는 중 표시만 남아 투명한 막이 화면을 덮고(클릭을 가로챔) ESC·X·바탕·다시 저장이 모두 무시돼 페이지를 쓸 수
116
116
  없었다(말귀 업체 정보·훈련 묶음 수정 저장이 실패해 부모가 창을 열어 둘 때). 성공하면 부모가 곧 치우므로 되살릴 일이 없다. */
117
- const 살아있음Ref = useRef(true);
117
+ const aliveRef = useRef(true);
118
118
  /* '동작 줄이기' 면 닫기 대기를 0 으로 — CSS 는 움직임을 없애지만 이 타이머는 못 줄여, 움직임 없이 260ms 늦게 사라지며 투명한 막이 누름을 가로챘다 */
119
- const 동작줄이기 = usePrefersReducedMotion();
119
+ const reducedMotion = usePrefersReducedMotion();
120
120
  /* 닫기(X·ESC·바탕)가 걸어 둔 타이머 — 닫는 애니메이션(260ms) 안에 부모가 창을 치우면 **떠난 뒤에 onClose** 가 불렸다(닫으며
121
121
  다음 창을 여는 흐름에서 늦은 setX(null) 이 새 창을 닫을 수 있다). 떠날 때 걷는다. 소비자가 넘긴 animateThen(cb) 은
122
122
  "삭제·저장 확정" 일 수 있어 걷지 않는다(결정 2026-09-15: 떠난 뒤에도 부른다 — PopupOptions JSDoc). */
123
- const 닫기타이머Ref = useRef(null);
123
+ const closeTimerRef = useRef(null);
124
124
  useEffect(() => {
125
- 살아있음Ref.current = true;
125
+ aliveRef.current = true;
126
126
  return () => {
127
- 살아있음Ref.current = false;
128
- if (닫기타이머Ref.current) {
129
- clearTimeout(닫기타이머Ref.current);
130
- 닫기타이머Ref.current = null;
127
+ aliveRef.current = false;
128
+ if (closeTimerRef.current) {
129
+ clearTimeout(closeTimerRef.current);
130
+ closeTimerRef.current = null;
131
131
  }
132
132
  };
133
133
  }, []);
134
- const 되살리기 = useCallback(() => {
135
- if (!살아있음Ref.current || !closingRef.current)
134
+ const restore = useCallback(() => {
135
+ if (!aliveRef.current || !closingRef.current)
136
136
  return;
137
137
  closingRef.current = false;
138
138
  setClosing(false);
139
139
  }, []);
140
- const animateThen = useCallback((cb, 닫기뿐 = false) => {
140
+ const animateThen = useCallback((cb, closeOnly = false) => {
141
141
  /* 이미 떠난 창을 닫으려는 부름(ConfirmPopup 의 onConfirm 안에서 부모가 창을 치운 뒤 closeWithAnimation)은 받지 않는다 —
142
142
  정리가 끝난 뒤 걸린 타이머라 걷히지 않고 260ms 뒤 **취소 경로의 onClose** 가 불렸다(kscan 이 closeOnConfirm=false 로 우회). */
143
- if (닫기뿐 && !살아있음Ref.current)
143
+ if (closeOnly && !aliveRef.current)
144
144
  return;
145
145
  if (closingRef.current)
146
146
  return;
147
147
  closingRef.current = true;
148
148
  setClosing(true);
149
149
  const id = setTimeout(() => {
150
- if (닫기타이머Ref.current === id)
151
- 닫기타이머Ref.current = null;
152
- let 결과;
150
+ if (closeTimerRef.current === id)
151
+ closeTimerRef.current = null;
152
+ let result;
153
153
  try {
154
- 결과 = cb ? cb() : undefined;
154
+ result = cb ? cb() : undefined;
155
155
  }
156
156
  catch (e) {
157
- 되살리기();
157
+ restore();
158
158
  return;
159
159
  }
160
160
  /* 약속을 돌려준 콜백만 **끝난 뒤에도 남아 있으면** 되살린다. 동기 콜백(undefined)은 되살리지 않는다 — 부모가 저장을
161
161
  따로 기다렸다 닫는 흔한 꼴(onSave 를 부르고 약속은 안 돌려줌)에서 기다리는 사이 창이 다시 보이고 저장 단추가 다시
162
162
  눌려 두 번 저장됐다. 저장 실패에 창을 되살리고 싶으면 콜백이 그 약속을 돌려주면 된다. */
163
- if (결과 && typeof 결과.then === 'function') {
164
- 결과.then(() => { setTimeout(되살리기, 120); }, 되살리기);
163
+ if (result && typeof result.then === 'function') {
164
+ result.then(() => { setTimeout(restore, 120); }, restore);
165
165
  }
166
- }, 동작줄이기 ? 0 : ANIM_MS);
167
- if (닫기뿐)
168
- 닫기타이머Ref.current = id;
169
- }, [되살리기, 동작줄이기]);
166
+ }, reducedMotion ? 0 : ANIM_MS);
167
+ if (closeOnly)
168
+ closeTimerRef.current = id;
169
+ }, [restore, reducedMotion]);
170
170
  const closeWithAnimation = useCallback(() => {
171
171
  if (closeDisabled)
172
172
  return;
@@ -43,17 +43,17 @@ export default function SaveTablePopup({ title, saveLabel, defaultName = '', def
43
43
  existingNames.some(n => n.trim().toLowerCase() === name.trim().toLowerCase());
44
44
  /* 저장은 **한 번만** — 저장 단추의 닫힘 애니메이션(260ms) 동안 이름칸에서 Enter 를 치면 onSave 가 또 불렸다(Safari 는 단추를
45
45
  눌러도 포커스가 칸에 남아 쉽게 난다). */
46
- const 저장함 = useRef(false);
46
+ const savedRef = useRef(false);
47
47
  const handleSave = useCallback((animateThen) => {
48
- if (저장함.current)
48
+ if (savedRef.current)
49
49
  return;
50
- 저장함.current = true;
50
+ savedRef.current = true;
51
51
  animateThen(() => onSave(name.trim(), description.trim() || undefined));
52
52
  }, [name, description, onSave]);
53
53
  const handleEnter = useCallback(() => {
54
- if (저장함.current || !name.trim() || isDuplicateName)
54
+ if (savedRef.current || !name.trim() || isDuplicateName)
55
55
  return;
56
- 저장함.current = true;
56
+ savedRef.current = true;
57
57
  onSave(name.trim(), description.trim() || undefined);
58
58
  }, [name, description, isDuplicateName, onSave]);
59
59
  return (_jsx(Popup, { className: "popup--save-table", title: title ?? t('wordTable.save.title', '단어표 저장하기'), onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: saveLabel ?? t('common.save', '저장'), disabled: !name.trim() || isDuplicateName, onClick: () => handleSave(animateThen) })] })), children: _jsxs("div", { className: "save-table-popup__body", children: [preview && (_jsx("div", { ref: containerRef, className: "save-table-popup__preview-wrap", style: { height: previewState.fixedH }, children: _jsx("div", { className: "save-table-popup__preview", style: {
@@ -60,8 +60,8 @@ function resolveDecision(it) {
60
60
  export default function TrainingResultPopup(props) {
61
61
  const { score, contextsLength, results = [], subtitle, onReplay, onClose, inline = false, pending = false } = props || {};
62
62
  const { t } = useTranslation();
63
- const 까닭Id = useId();
64
- const 계산중이면막기 = useCallback(() => !pending, [pending]);
63
+ const reasonId = useId();
64
+ const blockWhilePending = useCallback(() => !pending, [pending]);
65
65
  // 축하 컨페티는 정답률 50% 초과일 때만 — 낮은 점수(≤50%)에 축하가 나가지 않게 한다(언어치료 맥락).
66
66
  useEffect(() => {
67
67
  const total = contextsLength ?? 0;
@@ -114,7 +114,7 @@ export default function TrainingResultPopup(props) {
114
114
  주는 이름이 갈라진다. */
115
115
  ariaLabel: t('training.result.title', '훈련을 모두 마쳤습니다.'), className: "popup--training-result",
116
116
  // 결과 계산 중에는 Esc 로도 완료(닫기)하지 않는다 — '완료' 단추와 같은 까닭
117
- beforeClose: 계산중이면막기, footerClassName: "trp-result__foot", footer: ({ animateThen, closeWithAnimation }) => (_jsxs(_Fragment, { children: [pending && _jsx("p", { id: 까닭Id, className: "trp-actions__reason caption-1", role: "status", children: t('training.result.pendingDone', '결과를 계산하는 중이라 아직 완료할 수 없습니다.') }), _jsxs("div", { className: "trp-actions", children: [_jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.replay', '다시하기'), onClick: () => animateThen(onReplay) }), _jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.done', '완료'), variant: "main", onClick: closeWithAnimation,
117
+ beforeClose: blockWhilePending, footerClassName: "trp-result__foot", footer: ({ animateThen, closeWithAnimation }) => (_jsxs(_Fragment, { children: [pending && _jsx("p", { id: reasonId, className: "trp-actions__reason caption-1", role: "status", children: t('training.result.pendingDone', '결과를 계산하는 중이라 아직 완료할 수 없습니다.') }), _jsxs("div", { className: "trp-actions", children: [_jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.replay', '다시하기'), onClick: () => animateThen(onReplay) }), _jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.done', '완료'), variant: "main", onClick: closeWithAnimation,
118
118
  /* 막되 초점은 남긴다(onDisabledClick 이 있으면 native disabled 대신 aria-disabled) — 키보드·낭독기가 까닭을 들을 수 있게 */
119
- disabled: pending, onDisabledClick: pending ? () => { } : undefined, "aria-describedby": pending ? 까닭Id : undefined })] })] })), children: () => (_jsxs("div", { className: "trp-result is-on", children: [_jsxs("header", { className: "trp-result__head", children: [_jsx("p", { className: "trp-result__eyebrow caption-1", children: t('training.result.eyebrow', '훈련 완료') }), _jsx("h1", { className: "trp-result__title h2", children: t('training.result.title', '훈련을 모두 마쳤습니다.') }), subtitle && _jsx("p", { className: "trp-result__subtitle body-3", children: subtitle }), _jsx("div", { className: "trp-score", children: _jsxs("div", { className: "trp-score__figure", "aria-label": t('training.result.scoreAria', { correct, total }), children: [_jsx("span", { className: "trp-score__big", children: correct }), _jsx("span", { className: "trp-score__sep", children: "/" }), _jsx("span", { className: "trp-score__total", children: total })] }) })] }), rows.length > 0 && (_jsx("section", { className: "trp-result__body", children: _jsxs("div", { className: `result-table${hasAnswerCols ? ' result-table--answer' : ''}`, role: "table", "aria-label": t('training.result.tableAriaLabel', '문항별 판정'), children: [_jsxs("div", { className: "result-table__head caption-1", role: "row", children: [_jsx("span", { role: "columnheader", children: t('common.col.problem', '문제') }), hasAnswerCols && _jsx("span", { role: "columnheader", children: t('training.result.colAnswerSelected', '정답 · 선택') }), _jsx("span", { className: "col-right", role: "columnheader", children: t('training.result.colJudge', '판정') })] }), rows.map((r, i) => (_jsxs("div", { className: "result-table__row", role: "row", children: [_jsxs("div", { className: "trp-row__problem", children: [_jsx("span", { className: "trp-row__no caption-1", children: String(i + 1).padStart(2, '0') }), _jsx("div", { className: "trp-row__q body-2", children: r.problem })] }), hasAnswerCols && (_jsxs("div", { className: "trp-row__answers", children: [r.answer !== null && (_jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: "trp-row__answer-label answer-correct caption-1", children: t('training.result.colAnswer', '정답') }), _jsx("span", { className: "trp-row__answer-value body-2", children: r.answer })] })), _jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: `trp-row__answer-label answer-selected caption-1 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: t('training.result.colSelected', '선택') }), _jsx("span", { className: `trp-row__answer-value body-2 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: r.selectedText ?? '—' })] })] })), _jsx("div", { className: "trp-row__judge", children: _jsx(Badge, { variant: r.variant, label: r.label, fill: "soft", minWidth: 80 }) })] }, i)))] }) }))] })) }));
119
+ disabled: pending, onDisabledClick: pending ? () => { } : undefined, "aria-describedby": pending ? reasonId : undefined })] })] })), children: () => (_jsxs("div", { className: "trp-result is-on", children: [_jsxs("header", { className: "trp-result__head", children: [_jsx("p", { className: "trp-result__eyebrow caption-1", children: t('training.result.eyebrow', '훈련 완료') }), _jsx("h1", { className: "trp-result__title h2", children: t('training.result.title', '훈련을 모두 마쳤습니다.') }), subtitle && _jsx("p", { className: "trp-result__subtitle body-3", children: subtitle }), _jsx("div", { className: "trp-score", children: _jsxs("div", { className: "trp-score__figure", "aria-label": t('training.result.scoreAria', { correct, total }), children: [_jsx("span", { className: "trp-score__big", children: correct }), _jsx("span", { className: "trp-score__sep", children: "/" }), _jsx("span", { className: "trp-score__total", children: total })] }) })] }), rows.length > 0 && (_jsx("section", { className: "trp-result__body", children: _jsxs("div", { className: `result-table${hasAnswerCols ? ' result-table--answer' : ''}`, role: "table", "aria-label": t('training.result.tableAriaLabel', '문항별 판정'), children: [_jsxs("div", { className: "result-table__head caption-1", role: "row", children: [_jsx("span", { role: "columnheader", children: t('common.col.problem', '문제') }), hasAnswerCols && _jsx("span", { role: "columnheader", children: t('training.result.colAnswerSelected', '정답 · 선택') }), _jsx("span", { className: "col-right", role: "columnheader", children: t('training.result.colJudge', '판정') })] }), rows.map((r, i) => (_jsxs("div", { className: "result-table__row", role: "row", children: [_jsxs("div", { className: "trp-row__problem", children: [_jsx("span", { className: "trp-row__no caption-1", children: String(i + 1).padStart(2, '0') }), _jsx("div", { className: "trp-row__q body-2", children: r.problem })] }), hasAnswerCols && (_jsxs("div", { className: "trp-row__answers", children: [r.answer !== null && (_jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: "trp-row__answer-label answer-correct caption-1", children: t('training.result.colAnswer', '정답') }), _jsx("span", { className: "trp-row__answer-value body-2", children: r.answer })] })), _jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: `trp-row__answer-label answer-selected caption-1 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: t('training.result.colSelected', '선택') }), _jsx("span", { className: `trp-row__answer-value body-2 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: r.selectedText ?? '—' })] })] })), _jsx("div", { className: "trp-row__judge", children: _jsx(Badge, { variant: r.variant, label: r.label, fill: "soft", minWidth: 80 }) })] }, i)))] }) }))] })) }));
120
120
  }
@@ -5,8 +5,8 @@
5
5
  import i18n from 'i18next';
6
6
  import { getTodayKst } from '../../utils/date';
7
7
  /** i18n 이 아직 안 섰으면(초기화 전 `i18n.t` 는 undefined) 한국어 기본값을 직접 채운다. */
8
- function 번역됐거나(s, 기본, vars) {
9
- return typeof s === 'string' && s ? s : 기본.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k]));
8
+ function translatedOr(s, fallback, vars) {
9
+ return typeof s === 'string' && s ? s : fallback.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k]));
10
10
  }
11
11
  export function getDayOptions(year, month, daySuffix) {
12
12
  const days = new Date(Number(year), Number(month), 0).getDate();
@@ -16,16 +16,16 @@ export function getDayOptions(year, month, daySuffix) {
16
16
  });
17
17
  }
18
18
  /** 'YYYY.MM.DD' · 'YYYY-MM-DD' · 'YYYYMMDD' 를 숫자 셋으로. 없는 날(2/30 등)이면 null. */
19
- function 날짜쪼개기(s) {
20
- const 숫자 = s.trim().replace(/[.-]/g, '');
21
- if (!/^\d{8}$/.test(숫자))
19
+ function splitDate(s) {
20
+ const digits = s.trim().replace(/[.-]/g, '');
21
+ if (!/^\d{8}$/.test(digits))
22
22
  return null;
23
- const y = Number(숫자.slice(0, 4)), m = Number(숫자.slice(4, 6)), d = Number(숫자.slice(6, 8));
24
- if (m < 1 || m > 12 || d < 1 || d > 말일(y, m))
23
+ const y = Number(digits.slice(0, 4)), m = Number(digits.slice(4, 6)), d = Number(digits.slice(6, 8));
24
+ if (m < 1 || m > 12 || d < 1 || d > lastDayOf(y, m))
25
25
  return null;
26
26
  return { y, m, d };
27
27
  }
28
- const 말일 = (y, m) => new Date(y, m, 0).getDate();
28
+ const lastDayOf = (y, m) => new Date(y, m, 0).getDate();
29
29
  /**
30
30
  * 만 나이 — "N세 M개월" 을 `ageStr` 로 적는다. 못 읽으면 '-'.
31
31
  *
@@ -44,15 +44,15 @@ const 말일 = (y, m) => new Date(y, m, 0).getDate();
44
44
  export function calcAge(birthDate, ageStr) {
45
45
  if (!birthDate)
46
46
  return '-';
47
- const b = 날짜쪼개기(birthDate);
48
- const t = 날짜쪼개기(getTodayKst());
47
+ const b = splitDate(birthDate);
48
+ const t = splitDate(getTodayKst());
49
49
  if (!b || !t)
50
50
  return '-';
51
51
  let years = t.y - b.y;
52
52
  let months = t.m - b.m;
53
53
  // 일(day)까지 비교해야 함 — 월만 비교하면 생일이 아직 안 지난 달엔 나이가 1살 많게 나온다.
54
54
  // 이번 달에 그 날이 없으면 말일이 그날이다.
55
- if (t.d < Math.min(b.d, 말일(t.y, t.m)))
55
+ if (t.d < Math.min(b.d, lastDayOf(t.y, t.m)))
56
56
  months--;
57
57
  if (months < 0) {
58
58
  years--;
@@ -78,17 +78,17 @@ export function visitItemKey(v) {
78
78
  */
79
79
  export function formatDuration(sec, durationStr) {
80
80
  // 초 단위로 반올림한 뒤 나눈다 — 90.5 가 "1분 30.5초", 59.999 가 "59.999초" 로 찍혔다
81
- const 초 = Number.isFinite(sec) ? Math.round(sec) : 0;
82
- if (!초 || 초 <= 0)
81
+ const seconds = Number.isFinite(sec) ? Math.round(sec) : 0;
82
+ if (!seconds || seconds <= 0)
83
83
  return '-';
84
- const m = Math.floor(초 / 60);
85
- const s = 초 % 60;
84
+ const m = Math.floor(seconds / 60);
85
+ const s = seconds % 60;
86
86
  if (durationStr)
87
87
  return durationStr(m, s);
88
88
  // 열쇠를 글자로 적어 둔다 — test/i18nKeysExist 가 사전에 있는지 훑는다
89
89
  if (m > 0 && s > 0)
90
- return 번역됐거나(i18n.t('customers.duration.minSec', { m, s, defaultValue: '{{m}}분 {{s}}초' }), '{{m}}분 {{s}}초', { m, s });
90
+ return translatedOr(i18n.t('customers.duration.minSec', { m, s, defaultValue: '{{m}}분 {{s}}초' }), '{{m}}분 {{s}}초', { m, s });
91
91
  if (m > 0)
92
- return 번역됐거나(i18n.t('customers.duration.min', { m, defaultValue: '{{m}}분' }), '{{m}}분', { m });
93
- return 번역됐거나(i18n.t('customers.duration.sec', { s, defaultValue: '{{s}}초' }), '{{s}}초', { s });
92
+ return translatedOr(i18n.t('customers.duration.min', { m, defaultValue: '{{m}}분' }), '{{m}}분', { m });
93
+ return translatedOr(i18n.t('customers.duration.sec', { s, defaultValue: '{{s}}초' }), '{{s}}초', { s });
94
94
  }
@@ -37,12 +37,12 @@ import ConfirmPopup from './ConfirmPopup';
37
37
  export function useDiscardGuard(isDirty, onClose, labels = {}) {
38
38
  const { t } = useTranslation();
39
39
  const [confirming, setConfirming] = useState(false);
40
- const 통과 = useRef(false);
41
- const allowClose = useCallback(() => { 통과.current = true; }, []);
40
+ const bypassRef = useRef(false);
41
+ const allowClose = useCallback(() => { bypassRef.current = true; }, []);
42
42
  const beforeClose = useCallback(() => {
43
43
  /* 한 번 쓰고 되돌린다 — 안 되돌리면 그 뒤로 영영 안 묻는다 */
44
- if (통과.current) {
45
- 통과.current = false;
44
+ if (bypassRef.current) {
45
+ bypassRef.current = false;
46
46
  return true;
47
47
  }
48
48
  if (isDirty) {
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
4
4
  import useMediaQuery from '../hooks/useMediaQuery';
5
5
  import './ProgressIndicator.css';
6
6
  /** 눈에는 안 보이고 낭독기에는 읽히는 글자 */
7
- const 보이지않게 = {
7
+ const srOnly = {
8
8
  position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden',
9
9
  clipPath: 'inset(50%)', whiteSpace: 'nowrap', border: 0,
10
10
  };
@@ -48,13 +48,13 @@ export default function ProgressIndicator({ max, index, inactiveBg, inactiveColo
48
48
  묶고 이름표에 자리를 적는다. 화면이 스스로 넘어가는 자리에서는
49
49
  announce 로 role="status" 를 켠다. */
50
50
  if (variant === 'dot') {
51
- return (_jsxs("div", { className: "progress-indicator progress-indicator--dot", style: rootStyle, role: announce ? 'status' : 'img', "aria-label": announce ? undefined : groupLabel, children: [announce && _jsx("span", { style: 보이지않게, children: groupLabel }), Array.from({ length: max }).map((_, i) => (_jsx("span", { "aria-hidden": "true", className: `progress-indicator-dot--dot${i < idx ? ' done' : i === idx ? ' active' : ''}` }, i)))] }));
51
+ return (_jsxs("div", { className: "progress-indicator progress-indicator--dot", style: rootStyle, role: announce ? 'status' : 'img', "aria-label": announce ? undefined : groupLabel, children: [announce && _jsx("span", { style: srOnly, children: groupLabel }), Array.from({ length: max }).map((_, i) => (_jsx("span", { "aria-hidden": "true", className: `progress-indicator-dot--dot${i < idx ? ' done' : i === idx ? ' active' : ''}` }, i)))] }));
52
52
  }
53
53
  /* 번호·모바일 모드의 알리는 자리 — 전에는 announce 가 점 모드에서만 먹었다. 목록 곁 형제로 두고 absolute 라 배치에 안 끼어든다. */
54
- const 알림 = announce ? _jsx("span", { role: "status", style: 보이지않게, children: groupLabel }) : null;
54
+ const liveRegion = announce ? _jsx("span", { role: "status", style: srOnly, children: groupLabel }) : null;
55
55
  // 모바일: 현재 단계만 표시 (n / max 형태)
56
56
  if (isMobile) {
57
- return (_jsxs(_Fragment, { children: [_jsx("div", { className: "progress-indicator progress-indicator--mobile", style: rootStyle, role: "list", "aria-label": groupLabel, children: _jsxs("span", { role: "listitem", className: "progress-indicator-dot active body-3", children: [idx + 1, " / ", max] }) }), 알림] }));
57
+ return (_jsxs(_Fragment, { children: [_jsx("div", { className: "progress-indicator progress-indicator--mobile", style: rootStyle, role: "list", "aria-label": groupLabel, children: _jsxs("span", { role: "listitem", className: "progress-indicator-dot active body-3", children: [idx + 1, " / ", max] }) }), liveRegion] }));
58
58
  }
59
- return (_jsxs(_Fragment, { children: [알림, _jsx("div", { className: "progress-indicator", style: rootStyle, role: "list", "aria-label": groupLabel, children: Array.from({ length: max }).map((_, i) => (_jsx("span", { role: "listitem", "aria-current": i === idx ? 'step' : undefined, className: `progress-indicator-dot ${i < idx ? ' done' : i === idx ? ' active' : ''} body-3`, children: i + 1 }, i))) })] }));
59
+ return (_jsxs(_Fragment, { children: [liveRegion, _jsx("div", { className: "progress-indicator", style: rootStyle, role: "list", "aria-label": groupLabel, children: Array.from({ length: max }).map((_, i) => (_jsx("span", { role: "listitem", "aria-current": i === idx ? 'step' : undefined, className: `progress-indicator-dot ${i < idx ? ' done' : i === idx ? ' active' : ''} body-3`, children: i + 1 }, i))) })] }));
60
60
  }
@@ -2,9 +2,9 @@ import i18n from '../../i18n';
2
2
  /* 라벨은 **읽는 때의 언어**로 — 전에는 한국어가 박혀 영어 화면의 배경 고르개(어드민 공지 폼·놀이터)도 한국어였다.
3
3
  배열·항목 모양(key·label·textTone)은 그대로 두고 label 만 읽을 때 사전(`promoBannerBg.<key>`)에서 꺼내는 getter 로 둔다
4
4
  — 모듈을 읽은 순간의 언어로 굳지 않고, `{ ...o }` 로 펼치면 그때의 글자가 담긴다. ko 글자는 전과 같다. */
5
- const 배경 = (key, 한국어, textTone) => ({
5
+ const bg = (key, ko, textTone) => ({
6
6
  key,
7
- get label() { return i18n.t(`promoBannerBg.${key}`, 한국어); },
7
+ get label() { return i18n.t(`promoBannerBg.${key}`, ko); },
8
8
  textTone,
9
9
  });
10
10
  /**
@@ -12,20 +12,20 @@ const 배경 = (key, 한국어, textTone) => ({
12
12
  * @public
13
13
  */
14
14
  export const PROMO_BANNER_BG_OPTIONS = [
15
- 배경('main', '메인 그라디언트', 'light'),
16
- 배경('soft', '소프트', 'dark'),
17
- 배경('sub', '서브 컬러', 'light'),
18
- 배경('deep', '딥 그라디언트', 'light'),
19
- 배경('softSub', '소프트 서브', 'dark'),
20
- 배경('warm', '웜(강조)', 'dark'),
21
- 배경('dark', '다크', 'light'),
22
- 배경('radial', '방사형 그라디언트', 'light'),
23
- 배경('diagonal', '대각선 패턴', 'dark'),
24
- 배경('dots', '도트 패턴', 'dark'),
25
- 배경('outline', '아웃라인', 'dark'),
26
- 배경('success', '성공/완료', 'light'),
27
- 배경('naver', '네이버 블로그 안내', 'light'),
28
- 배경('instagram', '인스타그램 안내', 'light'),
15
+ bg('main', '메인 그라디언트', 'light'),
16
+ bg('soft', '소프트', 'dark'),
17
+ bg('sub', '서브 컬러', 'light'),
18
+ bg('deep', '딥 그라디언트', 'light'),
19
+ bg('softSub', '소프트 서브', 'dark'),
20
+ bg('warm', '웜(강조)', 'dark'),
21
+ bg('dark', '다크', 'light'),
22
+ bg('radial', '방사형 그라디언트', 'light'),
23
+ bg('diagonal', '대각선 패턴', 'dark'),
24
+ bg('dots', '도트 패턴', 'dark'),
25
+ bg('outline', '아웃라인', 'dark'),
26
+ bg('success', '성공/완료', 'light'),
27
+ bg('naver', '네이버 블로그 안내', 'light'),
28
+ bg('instagram', '인스타그램 안내', 'light'),
29
29
  ];
30
30
  import PromoBannerBgMain from './PromoBannerBgMain';
31
31
  import PromoBannerBgSoft from './PromoBannerBgSoft';
@@ -17,16 +17,16 @@ export default function Rating({ value, onChange, max = 5, readOnly = false, siz
17
17
  const [hoverValue, setHoverValue] = useState(null);
18
18
  /* 고르는 모드는 별 하나 단위라 **반올림한 값**으로 그리고 옮긴다 — 소수 value(3.4)를 그대로 두면 별 셋 반쯤이 칠해지는데
19
19
  어느 라디오도 골라지지 않았고(aria-checked 없음), 화살표는 4.4·2.4 로 옮겨 가 정수 별과 어긋났다. 읽기 전용은 소수 그대로. */
20
- const 값 = isReadOnly ? value : (Number.isFinite(value) ? Math.round(value) : 0);
21
- const displayValue = hoverValue ?? 값;
20
+ const current = isReadOnly ? value : (Number.isFinite(value) ? Math.round(value) : 0);
21
+ const displayValue = hoverValue ?? current;
22
22
  const iconSize = ICON_SIZE[size];
23
23
  /* 값이 바뀔 때만 onChange(2026-09-15 결정) — 소수 value 3.4 에서 별 3 을 누르면 3 으로 바뀌는 것이라 부른다 */
24
- const 알림 = (다음) => { if (다음 !== value)
25
- onChange?.(다음); };
24
+ const notify = (next) => { if (next !== value)
25
+ onChange?.(next); };
26
26
  const handleClick = (star) => {
27
27
  if (isReadOnly)
28
28
  return;
29
- 알림(star);
29
+ notify(star);
30
30
  };
31
31
  const handleMouseEnter = (star) => {
32
32
  if (isReadOnly)
@@ -41,38 +41,38 @@ export default function Rating({ value, onChange, max = 5, readOnly = false, siz
41
41
  이라 읽어 주는데 화살표가 죽어 있다. 실측으로 별 다섯이 전부 Tab 자리였다.
42
42
  별점은 순서가 뜻을 지니므로 **돌지 않는다** — 1에서 왼쪽, 5에서 오른쪽은 그 자리에
43
43
  머문다. 아직 안 골랐으면 첫 화살표가 1을 고른다. */
44
- const 그룹 = useRef(null);
45
- const 옮기기 = (다음) => {
46
- const 잡은값 = Math.min(max, Math.max(1, 다음));
47
- 알림(잡은값);
48
- const 칸 = Array.from(그룹.current?.querySelectorAll('[data-star]') ?? [])
49
- .find((el) => el.dataset.star === String(잡은값));
50
- 칸?.focus();
44
+ const groupRef = useRef(null);
45
+ const moveTo = (next) => {
46
+ const clamped = Math.min(max, Math.max(1, next));
47
+ notify(clamped);
48
+ const stars = Array.from(groupRef.current?.querySelectorAll('[data-star]') ?? [])
49
+ .find((el) => el.dataset.star === String(clamped));
50
+ stars?.focus();
51
51
  };
52
- const 키 = (e) => {
52
+ const onKeyDown = (e) => {
53
53
  if (isReadOnly)
54
54
  return;
55
55
  switch (e.key) {
56
56
  case 'ArrowRight':
57
57
  case 'ArrowUp':
58
- 옮기기((값 || 0) + 1);
58
+ moveTo((current || 0) + 1);
59
59
  break;
60
60
  case 'ArrowLeft':
61
61
  case 'ArrowDown':
62
- 옮기기((값 || 1) - 1);
62
+ moveTo((current || 1) - 1);
63
63
  break;
64
64
  case 'Home':
65
- 옮기기(1);
65
+ moveTo(1);
66
66
  break;
67
67
  case 'End':
68
- 옮기기(max);
68
+ moveTo(max);
69
69
  break;
70
70
  default: return;
71
71
  }
72
72
  e.preventDefault();
73
73
  };
74
74
  /* Tab 자리를 받을 한 칸 — 고른 별, 없으면 첫 별 */
75
- const 탭자리 = 값 >= 1 && 값 <= max ? Math.round(값) : 1;
75
+ const tabValue = current >= 1 && current <= max ? Math.round(current) : 1;
76
76
  const handleMouseLeave = () => {
77
77
  if (isReadOnly)
78
78
  return;
@@ -83,7 +83,7 @@ export default function Rating({ value, onChange, max = 5, readOnly = false, siz
83
83
  대체 글자가 없으면 낭독기에는 빈 자리다 — 눈에는 별 넷 반이 보이는데 아무것도
84
84
  안 읽힌다. 별점을 보여 주는 것이 이 모드의 유일한 일인데 그것이 사라진다.
85
85
  (별을 하나씩 읽히게 하면 "별·별·별…" 이 다섯 번 나올 뿐이라 묶음에 준다.) */
86
- _jsx("div", { className: `rating rating--${size}${isReadOnly ? ' rating--readOnly' : ''} ${className}`.trim(), ref: 그룹, onKeyDown: 키, role: isReadOnly ? 'img' : 'radiogroup', "aria-label": isReadOnly
86
+ _jsx("div", { className: `rating rating--${size}${isReadOnly ? ' rating--readOnly' : ''} ${className}`.trim(), ref: groupRef, onKeyDown: onKeyDown, role: isReadOnly ? 'img' : 'radiogroup', "aria-label": isReadOnly
87
87
  ? t('rating.valueLabel', { value, max })
88
88
  : (ariaLabel ?? t('rating.groupLabel', '별점')), onMouseLeave: handleMouseLeave,
89
89
  /* 터치를 떼거나 취소되면 미리보기를 푼다 */
@@ -99,6 +99,6 @@ export default function Rating({ value, onChange, max = 5, readOnly = false, siz
99
99
  if (isReadOnly) {
100
100
  return (_jsx("span", { className: "rating__item", "aria-hidden": "true", children: starNode }, star));
101
101
  }
102
- return (_jsx("button", { type: "button", className: "rating__item rating__item--interactive", role: "radio", "aria-checked": 값 === star, "aria-label": t('rating.starLabel', { n: star }), "data-star": star, tabIndex: star === 탭자리 ? 0 : -1, onClick: () => handleClick(star), onMouseEnter: () => handleMouseEnter(star), children: starNode }, star));
102
+ return (_jsx("button", { type: "button", className: "rating__item rating__item--interactive", role: "radio", "aria-checked": current === star, "aria-label": t('rating.starLabel', { n: star }), "data-star": star, tabIndex: star === tabValue ? 0 : -1, onClick: () => handleClick(star), onMouseEnter: () => handleMouseEnter(star), children: starNode }, star));
103
103
  }) }));
104
104
  }