@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
@@ -6,15 +6,15 @@
6
6
  'use client';
7
7
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  import { cx } from '../utils/cx';
9
- import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
9
+ import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
10
10
  import { useTranslation } from 'react-i18next';
11
11
  import { createPortal } from 'react-dom';
12
- import { getTodayKst } from '../utils/date';
12
+ import { todayYmdOr } from '../utils/date';
13
13
  import Calendar from 'lucide-react/dist/esm/icons/calendar.mjs';
14
14
  import ChevronLeft from 'lucide-react/dist/esm/icons/chevron-left.mjs';
15
15
  import ChevronRight from 'lucide-react/dist/esm/icons/chevron-right.mjs';
16
16
  import Dropdown from './Dropdown';
17
- import { useAnchoredMenu } from '../hooks/useAnchoredMenu';
17
+ import { useAnchoredMenu, anchoredMenuZIndex } from '../hooks/useAnchoredMenu';
18
18
  import { useCalendarMonth } from './useCalendarMonth';
19
19
  import { FOCUSABLE_SELECTOR } from '../hooks/useFocusTrap';
20
20
  import './DatePicker.css';
@@ -47,7 +47,7 @@ function buildYMOptions(viewY, 연월이름, min, max) {
47
47
  }
48
48
  return opts;
49
49
  }
50
- export default function DatePicker({ value, onChange, size = 48, min, max, disabled, placeholder: placeholderProp, className, }) {
50
+ export default function DatePicker({ value, onChange, size = 48, min, max, disabled, placeholder: placeholderProp, className, today, }) {
51
51
  const { t } = useTranslation();
52
52
  /* 기본 안내 문구도 사전에서 — 예전에는 '날짜 선택' 이 박혀 있어 영어 화면에서도 한국어였다.
53
53
  달력 판의 이름(`datePicker.chooseDate`)과 같은 말이라 같은 열쇠를 쓴다. */
@@ -57,23 +57,33 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
57
57
  진작 `calendar.dow` 를 쓰고 있었고, 사전에는 영어(`Sun`·`Mon`…)도 들어 있었다. */
58
58
  const DOW = useMemo(() => t('calendar.dow', { returnObjects: true }), [t]);
59
59
  // 오늘 표시·열 때 포커스는 KST 의 날 — 기기 달력이면 해외 기기에서 하루 어긋나 min=오늘 달력이 앞 달로 열렸다
60
- const 오늘ISO = getTodayKst();
61
- const { viewY, viewM, prevMonth, nextMonth, setViewYM } = useCalendarMonth(value);
62
- const computeStyle = useCallback((rect) => {
63
- const spaceBelow = window.innerHeight - rect.bottom;
64
- const dropdownH = 300;
60
+ const 오늘ISO = todayYmdOr(today);
61
+ const { viewY, viewM, prevMonth, nextMonth, setViewYM } = useCalendarMonth(value, today);
62
+ const panelId = useId();
63
+ const computeStyle = useCallback((rect, trigger) => {
64
+ const vh = window.innerHeight;
65
+ const vw = window.innerWidth;
66
+ /* 판의 실제 크기 — 열린 뒤에 재므로 있다. 없으면(첫 계산 전) 어림값 */
67
+ const 판 = typeof document !== 'undefined' ? document.getElementById(panelId) : null;
68
+ const dropdownH = 판?.offsetHeight || 300;
69
+ const 판폭 = Math.max(rect.width, 판?.offsetWidth || 0, 260);
70
+ const spaceBelow = vh - rect.bottom;
65
71
  const above = spaceBelow < dropdownH && rect.top > dropdownH;
72
+ /* **창 안으로 조인다** — 스크롤을 따라 다시 잴 때 트리거가 화면 밖으로 나가면 달력이 창 위·아래 밖까지 따라가
73
+ 고를 수 없었다. 판이 창보다 크면 위 여백에 붙인다. */
74
+ const 여백 = 8;
75
+ const 조이기 = (v, 최대) => Math.min(Math.max(여백, v), Math.max(여백, 최대));
66
76
  return {
67
77
  position: 'fixed',
68
- left: rect.left,
78
+ left: 조이기(rect.left, vw - 판폭 - 여백),
69
79
  width: rect.width,
70
80
  minWidth: 260,
71
81
  ...(above
72
- ? { bottom: window.innerHeight - rect.top + 6 }
73
- : { top: rect.bottom + 6 }),
74
- zIndex: 9999,
82
+ ? { bottom: 조이기(vh - rect.top + 6, vh - dropdownH - 여백) }
83
+ : { top: 조이기(rect.bottom + 6, vh - dropdownH - 여백) }),
84
+ zIndex: anchoredMenuZIndex(trigger),
75
85
  };
76
- }, []);
86
+ }, [panelId]);
77
87
  const { open, setOpen, toggle, style: dropdownStyle, triggerRef, menuRef } = useAnchoredMenu({
78
88
  computeStyle,
79
89
  onScroll: 'reposition',
@@ -230,7 +240,7 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
230
240
  const iconSize = size === 64 ? 20 : size === 40 ? 16 : 18;
231
241
  const ymOptions = buildYMOptions(viewY, (year, month) => t('calendar.yearMonth', { year, month, defaultValue: '{{year}}년 {{month}}월' }), min, max);
232
242
  const ymValue = `${viewY}-${String(viewM + 1).padStart(2, '0')}`;
233
- const dropdown = open ? createPortal(_jsxs("div", { ref: menuRef, className: "date-picker__dropdown", style: dropdownStyle, "data-tooltip-ignore": true, role: "dialog", "aria-label": t('datePicker.chooseDate', '날짜 선택'), tabIndex: -1, children: [_jsxs("div", { className: "date-picker__nav", children: [_jsx("button", { type: "button", className: "date-picker__nav-btn", onClick: prevMonth, "aria-label": t('calendar.prevMonth', '이전 달'), children: _jsx(ChevronLeft, { size: 16, strokeWidth: 2 }) }), _jsx(Dropdown, { className: "date-picker__nav-dropdown", ariaLabel: t('datePicker.selectYearMonth', '연월 선택'), options: ymOptions, value: ymValue, onChange: (v) => {
243
+ const dropdown = open ? createPortal(_jsxs("div", { ref: menuRef, id: panelId, className: "date-picker__dropdown", style: dropdownStyle, "data-tooltip-ignore": true, role: "dialog", "aria-label": t('datePicker.chooseDate', '날짜 선택'), tabIndex: -1, children: [_jsxs("div", { className: "date-picker__nav", children: [_jsx("button", { type: "button", className: "date-picker__nav-btn", onClick: prevMonth, "aria-label": t('calendar.prevMonth', '이전 달'), children: _jsx(ChevronLeft, { size: 16, strokeWidth: 2 }) }), _jsx(Dropdown, { className: "date-picker__nav-dropdown", ariaLabel: t('datePicker.selectYearMonth', '연월 선택'), options: ymOptions, value: ymValue, onChange: (v) => {
234
244
  const [y, m] = v.split('-');
235
245
  setViewYM(+y, +m - 1);
236
246
  }, block: true, size: 40 }), _jsx("button", { type: "button", className: "date-picker__nav-btn", onClick: nextMonth, "aria-label": t('calendar.nextMonth', '다음 달'), children: _jsx(ChevronRight, { size: 16, strokeWidth: 2 }) })] }), _jsxs("div", { className: "date-picker__grid", ref: 격자Ref, onKeyDown: 격자키, children: [DOW.map(d => (_jsx("div", { className: "date-picker__dow caption-1", children: d }, d))), cells.map((d, i) => {
@@ -9,6 +9,7 @@ export interface DropdownOption {
9
9
  export interface DropdownProps {
10
10
  options: DropdownOption[];
11
11
  value?: string;
12
+ /** 고른 값으로 불린다. **값이 바뀔 때만** — 이미 고른 항목을 다시 고르면 부르지 않고 메뉴만 닫는다. */
12
13
  onChange?: (value: string) => void;
13
14
  placeholder?: string;
14
15
  disabled?: boolean;
@@ -4,7 +4,7 @@ import { cx } from '../utils/cx';
4
4
  import { useCallback, useEffect, useId, useRef, useState } from 'react';
5
5
  import { createPortal } from 'react-dom';
6
6
  import { useTranslation } from 'react-i18next';
7
- import { useAnchoredMenu } from '../hooks/useAnchoredMenu';
7
+ import { useAnchoredMenu, anchoredMenuZIndex } from '../hooks/useAnchoredMenu';
8
8
  import './Dropdown.css';
9
9
  /** @public */
10
10
  export default function Dropdown({ options, value, onChange, placeholder, disabled = false, block = false, size = 48, error = false, className = '', ariaLabel, ariaDescribedBy }) {
@@ -13,7 +13,7 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
13
13
  const listId = useId();
14
14
  const valueId = useId();
15
15
  const containerRef = useRef(null);
16
- const computeStyle = useCallback((rect) => {
16
+ const computeStyle = useCallback((rect, trigger) => {
17
17
  // 아래쪽 공간이 메뉴 최대 높이(260px, Dropdown.css)보다 부족하고 위쪽에 공간이
18
18
  // 있으면 위로 뒤집는다 — DatePicker.tsx의 동일 패턴
19
19
  const menuMaxH = 260;
@@ -26,7 +26,7 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
26
26
  ...(above
27
27
  ? { bottom: window.innerHeight - rect.top + 6 }
28
28
  : { top: rect.bottom + 6 }),
29
- zIndex: 9999,
29
+ zIndex: anchoredMenuZIndex(trigger),
30
30
  };
31
31
  }, []);
32
32
  const { open, setOpen, toggle: rawToggle, style: menuStyle, triggerRef, menuRef } = useAnchoredMenu({
@@ -80,9 +80,11 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
80
80
  // 열린 채 disabled 가 되면(저장 중 잠금) 남은 메뉴에서 고른 것이 그대로 onChange 로 나갔다
81
81
  if (opt.disabled || disabled)
82
82
  return;
83
- onChange?.(opt.value);
83
+ /* **값이 바뀔 때만 onChange** — 이미 고른 것을 다시 고르면 닫기만 한다(2026-09-15 결정. SegmentedControl 은 다시 누름도 부른다) */
84
+ if (opt.value !== value)
85
+ onChange?.(opt.value);
84
86
  setOpen(false);
85
- }, [onChange, setOpen, disabled]);
87
+ }, [onChange, setOpen, disabled, value]);
86
88
  // 막히면 열린 메뉴도 닫는다
87
89
  useEffect(() => { if (disabled)
88
90
  setOpen(false); }, [disabled, setOpen]);
@@ -1,5 +1,16 @@
1
- /** @public */
1
+ /**
2
+ * @public 앱에 한 번 붙여 두는 폭죽 자리.
3
+ *
4
+ * - `active`·`trigger` 를 주면 그대로 `EffectPartyPopper` 에 넘긴다(전과 같다).
5
+ * - **`window` 의 `party:trigger` 이벤트를 듣는다** — `TrainingResultPopup` 이 정답률 50% 초과일 때 보낸다.
6
+ * `detail` 의 `count`·`strength`·`duration` 으로 한 번 터뜨리고, 다 사라지면 걷는다. 전에는 아무도 이 이벤트를
7
+ * 안 들어 결과 창의 폭죽이 한 번도 안 났다. 움직임 최소화(prefers-reduced-motion: reduce)면
8
+ * `EffectPartyPopper` 가 그리지 않는다. 보내는 쪽이 한 틱 미뤄 보내 StrictMode 에서도 한 번만 온다.
9
+ * - 이 자리가 앱에 없으면 이벤트는 아무 일도 안 한다.
10
+ */
2
11
  export default function EffectPartyPopperHost({ trigger, active }: {
12
+ /** 값이 바뀌면 다시 터진다 — EffectPartyPopper 에 그대로 넘긴다. */
3
13
  trigger?: number;
14
+ /** true 인 동안 그린다 — EffectPartyPopper 에 그대로 넘긴다. 이벤트로 터지는 폭죽과는 따로다. */
4
15
  active?: boolean;
5
16
  }): import("react").JSX.Element;
@@ -1,13 +1,30 @@
1
1
  'use client';
2
- import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useEffect, useRef } from "react";
2
+ import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useCallback, useEffect, useState } from "react";
4
4
  import EffectPartyPopper from "./EffectPartyPopper";
5
- /** @public */
5
+ const 수 = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined);
6
+ /**
7
+ * @public 앱에 한 번 붙여 두는 폭죽 자리.
8
+ *
9
+ * - `active`·`trigger` 를 주면 그대로 `EffectPartyPopper` 에 넘긴다(전과 같다).
10
+ * - **`window` 의 `party:trigger` 이벤트를 듣는다** — `TrainingResultPopup` 이 정답률 50% 초과일 때 보낸다.
11
+ * `detail` 의 `count`·`strength`·`duration` 으로 한 번 터뜨리고, 다 사라지면 걷는다. 전에는 아무도 이 이벤트를
12
+ * 안 들어 결과 창의 폭죽이 한 번도 안 났다. 움직임 최소화(prefers-reduced-motion: reduce)면
13
+ * `EffectPartyPopper` 가 그리지 않는다. 보내는 쪽이 한 틱 미뤄 보내 StrictMode 에서도 한 번만 온다.
14
+ * - 이 자리가 앱에 없으면 이벤트는 아무 일도 안 한다.
15
+ */
6
16
  export default function EffectPartyPopperHost({ trigger = 0, active = false }) {
7
- const ref = useRef(0);
17
+ const [터짐, set터짐] = useState(null);
8
18
  useEffect(() => {
9
- ref.current = trigger;
10
- }, [trigger]);
11
- // The host orchestrates triggers and mounts the popper; it keeps minimal state
12
- return _jsx(EffectPartyPopper, { active: active, trigger: trigger });
19
+ if (typeof window === 'undefined')
20
+ return;
21
+ const 받기 = (e) => {
22
+ const d = (e.detail ?? {});
23
+ set터짐((앞) => ({ n: (앞?.n ?? 0) + 1, count: 수(d.count), strength: 수(d.strength), duration: 수(d.duration) }));
24
+ };
25
+ window.addEventListener('party:trigger', 받기);
26
+ return () => window.removeEventListener('party:trigger', 받기);
27
+ }, []);
28
+ const 걷기 = useCallback(() => set터짐(null), []);
29
+ return (_jsxs(_Fragment, { children: [_jsx(EffectPartyPopper, { active: active, trigger: trigger }), 터짐 && (_jsx(EffectPartyPopper, { active: true, trigger: 터짐.n, count: 터짐.count, strength: 터짐.strength, duration: 터짐.duration, onComplete: 걷기 }))] }));
13
30
  }
@@ -7,10 +7,14 @@ export interface EmptyStateProps {
7
7
  message?: string;
8
8
  /** 메시지 아래 보조 안내(선택). message와 별도 문단으로 렌더 — 문자열 접합 오용 방지. */
9
9
  hint?: string;
10
+ /**
11
+ * 메시지 아래 단추(다시 시도 등). `onClick` 이 **약속(Promise)을 돌려주면 끝날 때까지(성공·실패 모두) 단추를 막는다** —
12
+ * 두 번 눌러 요청이 두 번 나가지 않게.
13
+ */
10
14
  action?: {
11
15
  label: string;
12
16
  icon?: string;
13
- onClick: () => void;
17
+ onClick: () => void | Promise<unknown>;
14
18
  };
15
19
  /** 표 안의 빈 행처럼 좁은 자리에 넣을 때 위아래 여백을 줄인다. */
16
20
  compact?: boolean;
@@ -1,9 +1,12 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import { cx } from '../utils/cx';
3
4
  import './EmptyState.css';
4
5
  import Icon from './Icon';
6
+ import { usePendingPress } from '../hooks/usePendingPress';
5
7
  export default function EmptyState({ icon, emoji, title, message, hint, action, compact, className }) {
8
+ const { pending, press } = usePendingPress(action?.onClick);
6
9
  return (_jsxs("div", { className: cx('empty-state', compact && 'empty-state--compact', className), children: [emoji && _jsx("span", { className: "empty-state__emoji", children: emoji }), icon && _jsx(Icon, { icon: icon, className: "empty-state__icon" }), title && _jsx("p", { className: "empty-state__title body-2", children: title }), message && _jsx("p", { className: "empty-state__message body-3", children: message }), hint && _jsx("p", { className: "empty-state__hint caption-1", children: hint }), action && (
7
10
  // type="button" — 없으면 폼 안에서 누르는 순간 폼이 제출된다
8
- _jsxs("button", { type: "button", className: "empty-state__action button button40 button40--bar button--variant-main button--align-center button--shape-rounded", onClick: action.onClick, children: [action.icon && _jsx(Icon, { icon: action.icon, className: "button__icon" }), _jsx("span", { className: "button__label button-1", children: action.label })] }))] }));
11
+ _jsxs("button", { type: "button", className: "empty-state__action button button40 button40--bar button--variant-main button--align-center button--shape-rounded", onClick: press, disabled: pending, "aria-busy": pending || undefined, children: [action.icon && _jsx(Icon, { icon: action.icon, className: "button__icon" }), _jsx("span", { className: "button__label button-1", children: action.label })] }))] }));
9
12
  }
@@ -9,8 +9,11 @@ export interface ErrorStateProps {
9
9
  /** 보조 안내(선택). */
10
10
  hint?: string;
11
11
  icon?: string;
12
- /** 다시 시도 콜백 — 지정 시 '다시 시도' 버튼 노출. */
13
- onRetry?: () => void;
12
+ /**
13
+ * 다시 시도 콜백 — 지정 시 '다시 시도' 버튼 노출.
14
+ * **약속(Promise)을 돌려주면 끝날 때까지(성공·실패 모두) 단추를 막는다** — 느린 다시 불러오기에서 두 번 눌러 요청이 두 번 나가지 않게.
15
+ */
16
+ onRetry?: () => void | Promise<unknown>;
14
17
  /** 재시도 버튼 라벨(미지정 시 공용 기본). */
15
18
  retryLabel?: string;
16
19
  className?: string;
@@ -5,9 +5,11 @@ import { cx } from '../utils/cx';
5
5
  import './EmptyState.css';
6
6
  import './ErrorState.css';
7
7
  import Icon from './Icon';
8
+ import { usePendingPress } from '../hooks/usePendingPress';
8
9
  export default function ErrorState({ message, hint, icon = 'triangle-alert', onRetry, retryLabel, className }) {
9
10
  const { t } = useTranslation();
11
+ const { pending, press } = usePendingPress(onRetry);
10
12
  return (_jsxs("div", { className: cx('empty-state', 'error-state', className), role: "alert", children: [_jsx(Icon, { icon: icon, className: "empty-state__icon error-state__icon" }), _jsx("p", { className: "empty-state__message body-3", children: message ?? t('common.errorState.message', { defaultValue: '불러오지 못했습니다.' }) }), hint && _jsx("p", { className: "empty-state__hint caption-1", children: hint }), onRetry && (_jsxs("button", {
11
13
  // type="button" — 없으면 폼 안에서 다시 시도를 누르는 순간 폼이 제출된다
12
- type: "button", className: "empty-state__action button button40 button40--bar button--variant-main button--align-center button--shape-rounded", onClick: onRetry, children: [_jsx(Icon, { icon: "rotate-cw", className: "button__icon" }), _jsx("span", { className: "button__label button-1", children: retryLabel ?? t('common.errorState.retry', { defaultValue: '다시 시도' }) })] }))] }));
14
+ type: "button", className: "empty-state__action button button40 button40--bar button--variant-main button--align-center button--shape-rounded", onClick: press, disabled: pending, "aria-busy": pending || undefined, children: [_jsx(Icon, { icon: "rotate-cw", className: "button__icon" }), _jsx("span", { className: "button__label button-1", children: retryLabel ?? t('common.errorState.retry', { defaultValue: '다시 시도' }) })] }))] }));
13
15
  }
@@ -1,7 +1,15 @@
1
1
  import './FileUpload.css';
2
2
  /** @public */
3
3
  export interface FileUploadProps {
4
- /** 파일 선택(드래그·드롭·클릭) 시 호출. 선택된 File[]을 그대로 전달 — 업로드/네트워크는 호출부 책임 */
4
+ /**
5
+ * 파일 선택(드래그·드롭·클릭) 시 호출 — 업로드/네트워크는 호출부 책임. accept·multiple 로 거른 뒤 부른다.
6
+ *
7
+ * - **비제어(`files` 없음)**: 늘 **지금 보이는 전체 목록**을 넘긴다 — multiple 이면 앞서 고른 것 + 새로 고른 것,
8
+ * 아니면 새로 고른 하나. `onRemove` 가 없으면 목록에서 뺄 때도 줄어든 전체 목록으로 부른다.
9
+ * - **제어(`files` 줌)**: **새로 고른 것만** 넘긴다 — 목록에 합치는 일은 부모가 한다(뺄 때는 `onRemove`).
10
+ *
11
+ * 이 함수가 던져도 같은 파일을 다시 고를 수 있다(입력 값은 늘 비운다).
12
+ */
5
13
  onFilesSelected?: (files: File[]) => void;
6
14
  /** input accept 속성 (예: "image/*", ".pdf,.docx") */
7
15
  accept?: string;
@@ -83,8 +83,14 @@ export default function FileUpload({ onFilesSelected, accept, multiple = false,
83
83
  applyFiles(e.dataTransfer.files);
84
84
  };
85
85
  const handleInputChange = (e) => {
86
- applyFiles(e.target.files);
87
- e.target.value = '';
86
+ const input = e.target;
87
+ /* 비우기는 finally 에서 — onFilesSelected 가 던지면 값이 남아, 같은 파일을 다시 골라도 change 가 안 나 다시 시도할 수 없었다 */
88
+ try {
89
+ applyFiles(input.files);
90
+ }
91
+ finally {
92
+ input.value = '';
93
+ }
88
94
  };
89
95
  const handleBrowseClick = () => {
90
96
  if (disabled)
@@ -1,9 +1,11 @@
1
1
  'use client';
2
- import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useRef } from 'react';
3
4
  import { useTranslation } from 'react-i18next';
4
5
  import Button from './Button';
5
6
  import DescriptionTooltip from './Tooltip/DescriptionTooltip';
6
7
  import { useHoverAnchor } from '../hooks/useHoverAnchor';
8
+ import { isTouchPointer, recentlyTouched, useTouchToggle } from '../hooks/touchToggle';
7
9
  /**
8
10
  * FilterPanel 섹션 라벨 옆에 붙이는 "?" 버튼 — 마우스를 올리면 DescriptionTooltip으로 설명을 보여준다.
9
11
  * @public
@@ -11,5 +13,15 @@ import { useHoverAnchor } from '../hooks/useHoverAnchor';
11
13
  export default function FilterSectionInfo({ title, items }) {
12
14
  const { t } = useTranslation();
13
15
  const { anchorRect, openAt, close: handleClose } = useHoverAnchor();
14
- return (_jsxs(_Fragment, { children: [_jsx(Button, { htmlType: "button", size: 24, type: "dot", icon: "info", variant: "transparent-gray", useBorder: false, selected: !!anchorRect, ariaLabel: t('common.info', '설명'), onPointerEnter: (e) => openAt(e.currentTarget), onPointerLeave: handleClose, onFocus: (e) => openAt(e.currentTarget), onBlur: handleClose }), anchorRect && (_jsx(DescriptionTooltip, { title: title, items: items, showIndex: items.length > 1, anchorRect: anchorRect, onClose: handleClose }))] }));
16
+ /* 터치 — 톡하면 열렸다 떼자마자 닫혀 설명을 읽을 수 없었다. 톡으로 열고 닫는다(마우스는 그대로).
17
+ 트리거를 품는 자리 — 상자를 만들지 않는(display: contents) 껍데기라 배치는 그대로다. */
18
+ const 자리Ref = useRef(null);
19
+ const 톡토글 = useTouchToggle(!!anchorRect, (next, el) => { if (next && el)
20
+ openAt(자리Ref.current?.querySelector('button') ?? el);
21
+ else
22
+ handleClose(); }, 자리Ref);
23
+ return (_jsxs("span", { ref: 자리Ref, style: { display: 'contents' }, children: [_jsx(Button, { htmlType: "button", size: 24, type: "dot", icon: "info", variant: "transparent-gray", useBorder: false, selected: !!anchorRect, ariaLabel: t('common.info', '설명'), onPointerEnter: (e) => { if (!isTouchPointer(e))
24
+ openAt(e.currentTarget); }, onPointerLeave: (e) => { if (!isTouchPointer(e))
25
+ handleClose(); }, onClick: (e) => { 톡토글.touchPress(e.currentTarget); }, onFocus: (e) => { if (!recentlyTouched())
26
+ openAt(e.currentTarget); }, onBlur: handleClose }), anchorRect && (_jsx(DescriptionTooltip, { title: title, items: items, showIndex: items.length > 1, anchorRect: anchorRect, onClose: handleClose }))] }));
15
27
  }
@@ -7,12 +7,13 @@ import BottomNav from './BottomNav';
7
7
  import Button from './Button';
8
8
  import Badge from './Badge';
9
9
  import MenuTooltip from './Tooltip/MenuTooltip';
10
- import { useAnchoredMenu } from '../hooks/useAnchoredMenu';
10
+ import { useAnchoredMenu, anchoredMenuZIndex } from '../hooks/useAnchoredMenu';
11
11
  import { createPortal } from 'react-dom';
12
12
  // 메뉴 표면(.tooltip-content) 정의 — Tooltip.tsx를 거치지 않고 클래스만 쓰므로 명시적으로.
13
13
  import './Tooltip/Tooltip.css';
14
14
  import SideNav from './SideNav';
15
15
  import { resolveAssetUrl } from '../utils';
16
+ import { isSafeOpenHref } from '../utils/safeHref';
16
17
  import LogoSvg from '../logo/Logo_speechear_kr.svg';
17
18
  import LogoManagerSvg from '../logo/Logo_speechearManager_kr.svg';
18
19
  import SymbolSvg from '../logo/Symbol_speechear.svg';
@@ -64,8 +65,10 @@ export default function Header({ activeKey, onChange, items, malgwiItems: _malgw
64
65
  if (it?.disabled)
65
66
  return;
66
67
  // SideNav 와 같다 — href 가 있으면 앱 안 이동이 아니라 새 창으로 연다.
68
+ // http(s)·상대 경로·mailto·tel 만 연다 — javascript: 같은 주소가 그대로 열렸다
67
69
  if (it?.href) {
68
- window.open(it.href, '_blank', 'noopener,noreferrer');
70
+ if (isSafeOpenHref(it.href))
71
+ window.open(it.href, '_blank', 'noopener,noreferrer');
69
72
  return;
70
73
  }
71
74
  onChange?.(key);
@@ -95,14 +98,14 @@ export default function Header({ activeKey, onChange, items, malgwiItems: _malgw
95
98
  // 헤더 햄버거 — SideNav 푸터에 해당하는 유틸(알림·설정·도움말·계정·디버그)을 띄운다.
96
99
  // 데스크톱에선 SideNav 푸터에 늘 보이지만 모바일에선 그 자리가 없어, 같은 항목을
97
100
  // 하단 더보기(…)와 동일한 구성(bottomMoreItems)으로 재사용한다.
98
- const computeUtilMenuStyle = useCallback((rect) => ({
101
+ const computeUtilMenuStyle = useCallback((rect, trigger) => ({
99
102
  position: 'fixed',
100
103
  top: rect.bottom + 8,
101
104
  right: Math.max(8, window.innerWidth - rect.right),
102
- zIndex: 10000,
105
+ zIndex: anchoredMenuZIndex(trigger),
103
106
  }), []);
104
107
  const utilMenuId = useId();
105
- const { open: utilOpen, toggle: toggleUtilMenu, close: closeUtilMenu, style: utilMenuStyle, triggerRef: utilTriggerRef, menuRef: utilMenuRef } = useAnchoredMenu({ computeStyle: computeUtilMenuStyle, onScroll: 'ignore', onResize: 'ignore' });
108
+ const { open: utilOpen, toggle: toggleUtilMenu, close: closeUtilMenu, style: utilMenuStyle, triggerRef: utilTriggerRef, menuRef: utilMenuRef } = useAnchoredMenu({ computeStyle: computeUtilMenuStyle, onScroll: 'ignore', onResize: 'reposition' /* 회전하면 옛 좌표에 남았다 — 다시 잰다 */ });
106
109
  const utilColumns = useMemo(() => [{
107
110
  items: bottomMoreItems.map(it => ({
108
111
  label: it.label,
@@ -2,12 +2,13 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import Button from './Button';
5
+ import { formatWornDate } from '../utils/wornDate';
5
6
  import './HearingDeviceInfo.css';
6
7
  export default function HearingDeviceInfo({ devices, onAddDevice, onOpenList, onRemoveDevice }) {
7
8
  const { t } = useTranslation();
8
9
  return (_jsxs("div", { className: "hearing-device-info", children: [_jsx("div", { className: "hearing-device-info__title body-3", children: t('customers.device.title', '청각재활기기') }), _jsx("div", { className: "hearing-device-info__row", children: ['좌', '우'].map(side => {
9
10
  const d = devices[side];
10
11
  const sideLabel = side === '좌' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
11
- return (_jsxs("div", { className: "hearing-device-info__side", children: [_jsx("span", { className: "hearing-device-info__side-label body-3", children: sideLabel }), d ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "hearing-device-info__device", children: [_jsx("span", { className: "hearing-device-info__device-name body-3", children: d.brand }), _jsxs("span", { className: "hearing-device-info__device-detail", children: [d.device, d.fitType ? ` · ${d.fitType}` : ''] }), d.productName && _jsx("span", { className: "hearing-device-info__device-product", children: d.productName }), _jsxs("span", { className: "hearing-device-info__device-date", children: [d.wornYear, ".", d.wornMonth, ".", d.wornDay] })] }), _jsxs("div", { className: "hearing-device-info__actions", children: [_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "plus", onClick: () => onAddDevice(side), showTooltipInfo: t('device.addNew', '새 기기 추가'), ariaLabel: t('device.addNew', '새 기기 추가') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "list", onClick: () => onOpenList(side), showTooltipInfo: t('device.selectFromList', '목록에서 선택'), ariaLabel: t('device.selectFromList', '목록에서 선택') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "danger", onClick: () => onRemoveDevice(side), showTooltipInfo: t('device.removeDevice', '착용 해제'), ariaLabel: t('device.removeDevice', '착용 해제') })] })] })) : (_jsxs("div", { className: "hearing-device-info__actions", children: [_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "plus", onClick: () => onAddDevice(side), showTooltipInfo: t('device.addNew', '새 기기 추가'), ariaLabel: t('device.addNew', '새 기기 추가') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "list", onClick: () => onOpenList(side), showTooltipInfo: t('device.selectFromList', '목록에서 선택'), ariaLabel: t('device.selectFromList', '목록에서 선택') })] }))] }, side));
12
+ return (_jsxs("div", { className: "hearing-device-info__side", children: [_jsx("span", { className: "hearing-device-info__side-label body-3", children: sideLabel }), d ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "hearing-device-info__device", children: [_jsx("span", { className: "hearing-device-info__device-name body-3", children: d.brand }), _jsxs("span", { className: "hearing-device-info__device-detail", children: [d.device, d.fitType ? ` · ${d.fitType}` : ''] }), d.productName && _jsx("span", { className: "hearing-device-info__device-product", children: d.productName }), formatWornDate(d.wornYear, d.wornMonth, d.wornDay) && _jsx("span", { className: "hearing-device-info__device-date", children: formatWornDate(d.wornYear, d.wornMonth, d.wornDay) })] }), _jsxs("div", { className: "hearing-device-info__actions", children: [_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "plus", onClick: () => onAddDevice(side), showTooltipInfo: t('device.addNew', '새 기기 추가'), ariaLabel: t('device.addNew', '새 기기 추가') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "list", onClick: () => onOpenList(side), showTooltipInfo: t('device.selectFromList', '목록에서 선택'), ariaLabel: t('device.selectFromList', '목록에서 선택') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "danger", onClick: () => onRemoveDevice(side), showTooltipInfo: t('device.removeDevice', '착용 해제'), ariaLabel: t('device.removeDevice', '착용 해제') })] })] })) : (_jsxs("div", { className: "hearing-device-info__actions", children: [_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "plus", onClick: () => onAddDevice(side), showTooltipInfo: t('device.addNew', '새 기기 추가'), ariaLabel: t('device.addNew', '새 기기 추가') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "list", onClick: () => onOpenList(side), showTooltipInfo: t('device.selectFromList', '목록에서 선택'), ariaLabel: t('device.selectFromList', '목록에서 선택') })] }))] }, side));
12
13
  }) })] }));
13
14
  }
@@ -6,6 +6,7 @@ export interface IconProps {
6
6
  * 아이콘 이름(lucide 이름 또는 파일 경로), 또는 그려 넣을 노드.
7
7
  *
8
8
  * 이름은 `icon` 이다 — `name` 이 아니다. 주지 않으면 아무것도 그리지 않는다.
9
+ * `.svg` 경로는 파일 이름이 lucide 이름이면 lucide 로 그린다(`'/images/brand/check.svg'` → lucide `check`, 의도한 동작 — useLucideIcon 참고).
9
10
  */
10
11
  icon?: ReactNode | string;
11
12
  label?: ReactNode;
@@ -33,7 +34,7 @@ export interface IconProps {
33
34
  svgClassName?: string;
34
35
  /** 감싸는 `<span>` 의 인라인 스타일. */
35
36
  wrapperStyle?: CSSProperties;
36
- /** 한 변 픽셀. 크기는 클래스가 아니라 이것으로 정한다. */
37
+ /** 한 변 픽셀. 크기는 클래스가 아니라 이것으로 정한다. lucide·마스크·이미지(파일 경로) 모두에 적용된다. */
37
38
  size?: number;
38
39
  }
39
40
  /** @public */
@@ -21,9 +21,12 @@ export default function Icon(props) {
21
21
  const { iconToUse, mode, svgText, blobUrl } = useIcon(iconStr);
22
22
  // ────────────────────────────────────────────────────────────────────────────
23
23
  const createWrapper = (children, extraProps = {}) => (_jsx("span", { className: wrapperClass, style: wrapperStyle, ...extraProps, children: children }));
24
+ /* 이미지(파일 경로)에도 size 를 한 변 픽셀로 준다 — 전에는 lucide·마스크만 size 를 읽어 PNG·<img> 로 그린 SVG 는
25
+ 원본 크기로 떴다. 줄 때만 박고(object-fit: contain 으로 비율 유지), 안 주면 지금처럼 클래스·원본 크기. */
26
+ const 이미지크기 = typeof size === 'number' ? { width: size, height: size, objectFit: 'contain' } : null;
24
27
  const createImage = (src, extraProps = {}) => (
25
28
  // 글자가 아닌 label(요소)은 alt 로 못 쓴다 — String() 하면 "[object Object]" 가 읽혔다
26
- _jsx("img", { src: src, alt: typeof label === 'string' ? label : (typeof label === 'number' ? String(label) : ''), className: defaultImgClass, ...extraProps }));
29
+ _jsx("img", { src: src, alt: typeof label === 'string' ? label : (typeof label === 'number' ? String(label) : ''), className: defaultImgClass, ...extraProps, style: 이미지크기 ? { ...extraProps.style, ...이미지크기 } : extraProps.style }));
27
30
  if (!icon)
28
31
  return null;
29
32
  if (typeof icon !== 'string')
@@ -39,6 +39,34 @@
39
39
  width: 0%;
40
40
  transition: width 220ms cubic-bezier(.2,.8,.2,1);
41
41
  }
42
+ /* 값을 모르는 막대 — 짧은 채움이 오간다 */
43
+ .loader-bar--indeterminate .loader-bar__inner {
44
+ width: 40%;
45
+ animation: loader-bar-indeterminate 1.4s ease-in-out infinite;
46
+ }
47
+ @keyframes loader-bar-indeterminate {
48
+ 0% { transform: translateX(-150%); }
49
+ 100% { transform: translateX(400%); }
50
+ }
51
+
52
+ /* '동작 줄이기' — 전역 규칙(styles.css)이 반복을 1 회로 줄여 **스피너가 멈추고**, 값 모르는 막대는 40% 채움으로 굳어
53
+ "40% 됐다" 로 보였다.
54
+ · 스피너는 규칙의 예외로 **천천히 한 방향으로만** 돈다(길이가 출렁이는 dash 는 끈다) — 멈춘 원은 고장으로 보인다.
55
+ · 값 모르는 막대는 움직이지 않는 **전체 폭** 으로 둔다(얼마나 됐다는 뜻을 싣지 않는다). */
56
+ @media (prefers-reduced-motion: reduce) {
57
+ .loader-spinner__svg {
58
+ animation-duration: 2.4s !important;
59
+ animation-iteration-count: infinite !important;
60
+ }
61
+ .loader-spinner__fg {
62
+ animation: none !important;
63
+ }
64
+ .loader-bar--indeterminate .loader-bar__inner {
65
+ width: 100%;
66
+ animation: none !important;
67
+ transform: none;
68
+ }
69
+ }
42
70
 
43
71
  @keyframes core-loader-rotate {
44
72
  100% { transform: rotate(360deg); }
@@ -5,7 +5,11 @@ export interface LoaderProps {
5
5
  variant?: 'spinner' | 'bar';
6
6
  /** 스피너 크기(px 또는 CSS 값). `variant='spinner'`일 때 적용됩니다. */
7
7
  size?: number | string;
8
- /** 진행률(0–100). `variant='bar'`일 때 적용됩니다. */
8
+ /**
9
+ * 진행률(0–100). `variant='bar'`일 때 적용됩니다.
10
+ * 주지 않거나 `null` 이면 **얼마나 됐는지 모르는(indeterminate)** 막대 — 짧은 채움이 오가고, 낭독기에는 값(aria-valuenow) 없이
11
+ * "진행 중" 으로만 알린다('동작 줄이기' 에서는 움직이지 않는 전체 폭 막대).
12
+ */
9
13
  progress?: number | null;
10
14
  /** 접근성 이름(aria-label). 화면에 글자로 나오지는 않는다. 안 주면 i18n 의 `loader.loading`(기본 'Loading'). */
11
15
  label?: string;
@@ -14,8 +14,10 @@ export default function Loader(props) {
14
14
  // 기본 이름이 영문 고정이라 한국어 화면에서도 낭독기가 'Loading' 으로 읽었다 — 사전을 거친다.
15
15
  const name = label || t('loader.loading', 'Loading');
16
16
  if (variant === 'bar') {
17
- const pct = Math.max(0, Math.min(100, Number(progress) || 0));
18
- return (_jsx("div", { className: "loader-bar", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": pct, "aria-label": name, children: _jsx("div", { className: "loader-bar__inner", style: { width: `${pct}%` } }) }));
17
+ /* 값을 모르면 0% 로 읽히고(빈 막대) 멈춘 채 보였다 — 값 없이 진행 중으로 둔다 */
18
+ const 모름 = progress == null || !Number.isFinite(Number(progress));
19
+ const pct = 모름 ? 0 : Math.max(0, Math.min(100, Number(progress)));
20
+ return (_jsx("div", { className: `loader-bar${모름 ? ' loader-bar--indeterminate' : ''}`, role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": 모름 ? undefined : pct, "aria-label": name, children: _jsx("div", { className: "loader-bar__inner", style: 모름 ? undefined : { width: `${pct}%` } }) }));
19
21
  }
20
22
  const dim = typeof size === 'number' ? `${size}px` : size;
21
23
  return (_jsxs("div", { className: "loader-spinner", style: { width: dim, height: dim }, role: "status", "aria-label": name, children: [_jsx("span", { style: 보이지않게, children: name }), _jsxs("svg", { className: "loader-spinner__svg", viewBox: "0 0 50 50", width: size, height: size, children: [_jsx("circle", { className: "loader-spinner__bg", cx: "25", cy: "25", r: "20", fill: "none", strokeWidth: "4" }), _jsx("circle", { className: "loader-spinner__fg", cx: "25", cy: "25", r: "20", fill: "none", strokeWidth: "4", strokeLinecap: "round" })] })] }));
@@ -18,7 +18,7 @@ export interface NoticeBoxProps {
18
18
  children?: ReactNode;
19
19
  /** 스타일 변형. `'default'` | `'dashed'` | `'important'` */
20
20
  variant?: 'default' | 'dashed' | 'important' | string;
21
- /** 닫기(×) 버튼 표시 여부 */
21
+ /** 닫기(×) 버튼 표시 여부 — `notices` 모드에서도 상자 전체를 닫는다 */
22
22
  dismissible?: boolean;
23
23
  className?: string;
24
24
  }
@@ -1,21 +1,30 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useCallback, useState } from 'react';
3
+ import { useCallback, useRef, useState } from 'react';
4
+ import { moveFocusOutside } from '../hooks/focusReturn';
4
5
  import { useTranslation } from 'react-i18next';
5
6
  import './NoticeBox.css';
6
7
  /** @public */
7
8
  export default function NoticeBox({ title = '', lines = [], notices = null, children, variant = 'default', dismissible = false, className = '', }) {
8
9
  const { t } = useTranslation();
9
10
  const [dismissed, setDismissed] = useState(false);
10
- const handleDismiss = useCallback(() => setDismissed(true), []);
11
+ const rootRef = useRef(null);
12
+ /* 닫기 단추에 있던 포커스가 상자와 함께 사라져 body 로 떨어졌다 — 떼기 전에 상자 앞의 포커스 받을 곳으로 옮긴다 */
13
+ const handleDismiss = useCallback(() => {
14
+ const root = rootRef.current;
15
+ if (root && root.contains(document.activeElement))
16
+ moveFocusOutside(root);
17
+ setDismissed(true);
18
+ }, []);
11
19
  if (dismissed)
12
20
  return null;
13
21
  const baseClass = `notice-box notice-box--${variant} body-2 ${className}`.trim();
22
+ const 닫기단추 = dismissible && (_jsx("button", {
23
+ // type="button" — 없으면 폼 안에서 닫기를 누르는 순간 폼이 제출된다
24
+ type: "button", className: "notice-box__dismiss", onClick: handleDismiss, "aria-label": t('common.close', '닫기'), children: "\u00D7" }));
14
25
  if (notices && Array.isArray(notices) && notices.length) {
15
- return (_jsx("div", { className: baseClass, role: "note", children: notices.map((n, idx) => (_jsxs("div", { className: "notice-box__inner", children: [n.title ? _jsx("span", { className: "notice-box__title body-3", children: n.title }) : null, _jsx("div", { className: "notice-box__body", children: n.lines && n.lines.length ? (_jsx("div", { children: n.lines.map((l, i) => (_jsx("span", { className: "notice-box__line body-3", children: l }, i))) })) : (n.children ?? null) })] }, idx))) }));
26
+ return (_jsxs("div", { className: baseClass, role: "note", children: [notices.map((n, idx) => (_jsxs("div", { className: "notice-box__inner", children: [n.title ? _jsx("span", { className: "notice-box__title body-3", children: n.title }) : null, _jsx("div", { className: "notice-box__body", children: n.lines && n.lines.length ? (_jsx("div", { children: n.lines.map((l, i) => (_jsx("span", { className: "notice-box__line body-3", children: l }, i))) })) : (n.children ?? null) })] }, idx))), 닫기단추] }));
16
27
  }
17
28
  const content = (title || lines.length) ? (_jsxs("div", { className: "notice-box__inner", children: [title ? _jsx("span", { className: "notice-box__title body-1", children: title }) : null, _jsx("div", { className: "notice-box__body", children: lines.length ? (_jsx("ul", { children: lines.map((l, i) => (_jsx("li", { className: "notice-box__line", children: l }, i))) })) : children })] })) : (_jsx("div", { className: "notice-box__inner", children: children }));
18
- return (_jsxs("div", { className: baseClass, role: "note", children: [content, dismissible && (_jsx("button", {
19
- // type="button" — 없으면 폼 안에서 닫기를 누르는 순간 폼이 제출된다
20
- type: "button", className: "notice-box__dismiss", onClick: handleDismiss, "aria-label": t('common.close', '닫기'), children: "\u00D7" }))] }));
29
+ return (_jsxs("div", { ref: rootRef, className: baseClass, role: "note", children: [content, 닫기단추] }));
21
30
  }
@@ -9,6 +9,7 @@ import Dropdown from './Dropdown';
9
9
  import Button from './Button';
10
10
  import Badge from './Badge';
11
11
  import { deviceKindVariant } from '../utils/deviceKind';
12
+ import { formatWornDate } from '../utils/wornDate';
12
13
  /* 기기 종류 배지 이름은 지금 언어로 — 값은 저장된 한국어(보청기·인공와우)라 그대로 보이면 영어 화면에도 한국어였다.
13
14
  색(deviceKindVariant)은 저장값으로 가른다. 모르는 값은 그대로 */
14
15
  const 기기종류열쇠 = { '보청기': 'hearingAid', '인공와우': 'cochlearImplant' };
@@ -159,6 +160,6 @@ export function PanelRowDevice({ devices, onRelease, emptyLabel }) {
159
160
  (잠시 안 씀·수리 보냄 같은 경우가 실제로 있다). */
160
161
  _jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "x",
161
162
  // 좌·우 두 단추가 같은 이름이라 어느 쪽 기기를 내리는지 안 들렸다 — 이름에만 쪽을 붙인다(툴팁은 그대로)
162
- ariaLabel: `${sideLabel} ${t('customers.device.unwear', '착용 해제')}`, showTooltipInfo: t('customers.device.unwear', '착용 해제'), onClick: () => onRelease(side) }))] }), d ? (_jsxs("div", { className: "panel-row__device-info", children: [_jsxs("span", { className: "panel-row__device-kind", children: [d.device && _jsx(Badge, { size: "small", variant: deviceKindVariant(d.device), fill: "soft", label: 기기종류이름(d.device, t) }), 메타(d) && _jsx("span", { className: "panel-row__device-detail body-3", children: 메타(d) })] }), _jsx("span", { className: "panel-row__device-name body-2b", children: d.productName || d.brand }), _jsx("span", { className: "panel-row__device-date caption-1", children: t('customers.device.wornSince', { date: `${d.wornYear}.${d.wornMonth}.${d.wornDay}` }) })] })) : (_jsx("span", { className: "panel-row__device-empty body-3", children: emptyLabel ?? t('customers.device.notRegistered', '미등록') }))] }, side));
163
+ ariaLabel: `${sideLabel} ${t('customers.device.unwear', '착용 해제')}`, showTooltipInfo: t('customers.device.unwear', '착용 해제'), onClick: () => onRelease(side) }))] }), d ? (_jsxs("div", { className: "panel-row__device-info", children: [_jsxs("span", { className: "panel-row__device-kind", children: [d.device && _jsx(Badge, { size: "small", variant: deviceKindVariant(d.device), fill: "soft", label: 기기종류이름(d.device, t) }), 메타(d) && _jsx("span", { className: "panel-row__device-detail body-3", children: 메타(d) })] }), _jsx("span", { className: "panel-row__device-name body-2b", children: d.productName || d.brand }), formatWornDate(d.wornYear, d.wornMonth, d.wornDay) && (_jsx("span", { className: "panel-row__device-date caption-1", children: t('customers.device.wornSince', { date: formatWornDate(d.wornYear, d.wornMonth, d.wornDay) }) }))] })) : (_jsx("span", { className: "panel-row__device-empty body-3", children: emptyLabel ?? t('customers.device.notRegistered', '미등록') }))] }, side));
163
164
  }) }));
164
165
  }
@@ -25,11 +25,17 @@ export interface PassCardProps {
25
25
  endDate?: string;
26
26
  /**
27
27
  * 전환으로 일시정지되어 남은 시간(초)이 알려진 경우에만 지정.
28
- * 지정하면 우측 하단에 "사용량%·남은 시간"을 이 값 기준으로 표시하고,
28
+ * 지정하면 우측 하단에 "사용량%·남은 시간"을 이 값 기준으로 표시하고(`product` 가 없어 전체 기간을 모르면 남은 시간만),
29
29
  * 지정 없이 state==='active'면 endDate에서 카드가 스스로 계산한다(pending인데
30
30
  * 이 값도 없으면 = 한 번도 안 쓴 새 이용권 → 전체 기간이 그대로 남은 것으로 표시).
31
31
  */
32
32
  remainingSeconds?: number;
33
+ /**
34
+ * 선택. true 면 종료일 **당일(KST)까지** 쓸 수 있는 이용권으로 보고 남은 날에 그 날을 넣는다 — 마지막 날이 "0초 남음·0%" 가
35
+ * 아니라 "1일 남음" 이 된다(말귀 개인 이용권: 종료일까지 이용). 기본 false(종료일 0시에 끝나는 것으로 센다 — 지금 동작).
36
+ * `endDate` 로 셀 때만 쓴다(`remainingSeconds` 를 주면 그 값 그대로).
37
+ */
38
+ endInclusive?: boolean;
33
39
  state?: PassCardState;
34
40
  /** @deprecated 읽지 않는다 — 대기 순서는 카드에 그리지 않는다. 넘겨도 화면은 같다. */
35
41
  queueIndex?: number;
@@ -62,4 +68,4 @@ export interface PassCardProps {
62
68
  onDelete?: () => void;
63
69
  }
64
70
  /** @public */
65
- export default function PassCard({ product, showPrice, plan, type, endDate, remainingSeconds, state, bg, bgOnly, textColor, subColor, copyrightColor, logoSrc, label, price, originalPrice, discountLabel, onUse, onRefund, onDelete }: PassCardProps): import("react").JSX.Element;
71
+ export default function PassCard({ product, showPrice, plan, type, endDate, remainingSeconds, endInclusive, state, bg, bgOnly, textColor, subColor, copyrightColor, logoSrc, label, price, originalPrice, discountLabel, onUse, onRefund, onDelete }: PassCardProps): import("react").JSX.Element;