@ihabdevteam/core 0.131.0 → 0.133.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 (90) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/dist/components.cjs.js +178 -178
  3. package/dist/components.esm.css +2 -2
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +1 -1
  6. package/dist/ihabdevteam-core.cjs.js +181 -181
  7. package/dist/ihabdevteam-core.d.ts +55 -17
  8. package/dist/packages/core/src/components/Accordion.js +4 -1
  9. package/dist/packages/core/src/components/Badge.d.ts +5 -0
  10. package/dist/packages/core/src/components/Badge.js +3 -3
  11. package/dist/packages/core/src/components/BarChart.d.ts +1 -1
  12. package/dist/packages/core/src/components/BarChart.js +25 -14
  13. package/dist/packages/core/src/components/BottomNav.js +3 -3
  14. package/dist/packages/core/src/components/BulkBar.js +1 -1
  15. package/dist/packages/core/src/components/Calendar.js +4 -0
  16. package/dist/packages/core/src/components/ChartSection.css +16 -0
  17. package/dist/packages/core/src/components/ChartSection.d.ts +8 -1
  18. package/dist/packages/core/src/components/ChartSection.js +2 -2
  19. package/dist/packages/core/src/components/ChoiceChips.js +1 -1
  20. package/dist/packages/core/src/components/CodeBox.js +1 -1
  21. package/dist/packages/core/src/components/DatePicker.js +16 -3
  22. package/dist/packages/core/src/components/Dropdown.js +13 -3
  23. package/dist/packages/core/src/components/FileUpload.js +8 -7
  24. package/dist/packages/core/src/components/FilterPanel.js +8 -5
  25. package/dist/packages/core/src/components/FilterSectionInfo.js +1 -1
  26. package/dist/packages/core/src/components/Header.js +1 -1
  27. package/dist/packages/core/src/components/HearingDeviceInfo.js +1 -1
  28. package/dist/packages/core/src/components/PageTitle.css +6 -0
  29. package/dist/packages/core/src/components/PageTitle.d.ts +9 -1
  30. package/dist/packages/core/src/components/PageTitle.js +3 -3
  31. package/dist/packages/core/src/components/Pagination.js +1 -1
  32. package/dist/packages/core/src/components/PanelRow.js +3 -3
  33. package/dist/packages/core/src/components/PaymentPlan.js +1 -1
  34. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +2 -2
  35. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +2 -2
  36. package/dist/packages/core/src/components/Popup/Popup.d.ts +8 -0
  37. package/dist/packages/core/src/components/Popup/Popup.js +17 -3
  38. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +1 -1
  39. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +1 -1
  40. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +3 -3
  41. package/dist/packages/core/src/components/RingChart.d.ts +3 -1
  42. package/dist/packages/core/src/components/RingChart.js +2 -2
  43. package/dist/packages/core/src/components/SegmentedControl.js +1 -1
  44. package/dist/packages/core/src/components/SideNav.js +27 -10
  45. package/dist/packages/core/src/components/Table.d.ts +17 -1
  46. package/dist/packages/core/src/components/Table.js +15 -9
  47. package/dist/packages/core/src/components/Tabs.js +7 -2
  48. package/dist/packages/core/src/components/TextField.js +34 -5
  49. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +1 -1
  50. package/dist/packages/core/src/components/Tooltip/TextBoxTooltip.js +1 -1
  51. package/dist/packages/core/src/components/Tooltip/Tooltip.js +13 -5
  52. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +1 -1
  53. package/dist/packages/core/src/components/Training/TrainingBlock.js +1 -1
  54. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +1 -1
  55. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -1
  56. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +51 -23
  57. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +1 -1
  58. package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +1 -1
  59. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +26 -5
  60. package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +1 -1
  61. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +2 -2
  62. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +1 -1
  63. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
  64. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +1 -1
  65. package/dist/packages/core/src/components/Training/TrainingWordCard.js +11 -6
  66. package/dist/packages/core/src/components/Training/useAudioPreview.js +7 -3
  67. package/dist/packages/core/src/components/UpdateAlert.js +1 -1
  68. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +2 -2
  69. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -3
  70. package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
  71. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  72. package/dist/packages/core/src/components/WordTable.js +6 -6
  73. package/dist/packages/core/src/components/barChartTypes.d.ts +2 -0
  74. package/dist/packages/core/src/components/useComboboxSuggestions.d.ts +1 -0
  75. package/dist/packages/core/src/components/useComboboxSuggestions.js +12 -6
  76. package/dist/packages/core/src/components/useToastLifecycle.js +8 -1
  77. package/dist/packages/core/src/hooks/useAnchoredMenu.js +10 -9
  78. package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +0 -11
  79. package/dist/packages/core/src/hooks/useDocumentTitle.js +16 -3
  80. package/dist/packages/core/src/hooks/useFocusTrap.js +6 -2
  81. package/dist/packages/core/src/utils/audioPeaks.js +13 -5
  82. package/dist/packages/core/src/utils/csv.js +3 -1
  83. package/dist/packages/core/src/utils/koreanSyllable.js +2 -1
  84. package/dist/packages/core/src/utils/textSort.d.ts +0 -10
  85. package/dist/packages/core/src/utils/textSort.js +7 -2
  86. package/dist/packages/core/src/utils/wordTrainingRuntime.js +28 -4
  87. package/dist/packages/core/src/version.d.ts +1 -1
  88. package/dist/packages/core/src/version.js +1 -1
  89. package/dist/utils.cjs.js +1 -1
  90. package/package.json +1 -1
@@ -49,11 +49,11 @@ export default function TrainingStepConfirmWithText({ index, stepIndex, isLast =
49
49
  setSelectedAnswer(true);
50
50
  handleEndedLocal(true);
51
51
  }, [handleEndedLocal, currentState]);
52
- return (_jsxs(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, onDone: onDone, useTitleFaded: false, children: [_jsx(TrainingStepTextBox, { context: context, state: currentState }), _jsx("div", { className: "training-step__buttons", children: Array.isArray(choices) && choices.length > 0 ? (choices.slice(0, 6).map((c, i) => (_jsx(Button, { size: 56, type: "bar", onClick: () => {
52
+ return (_jsxs(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, onDone: onDone, useTitleFaded: false, children: [_jsx(TrainingStepTextBox, { context: context, state: currentState }), _jsx("div", { className: "training-step__buttons", children: Array.isArray(choices) && choices.length > 0 ? (choices.slice(0, 6).map((c, i) => (_jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: () => {
53
53
  if (currentState !== 'active')
54
54
  return;
55
55
  setSelectedAnswer(i);
56
56
  if (handleEndedLocal)
57
57
  handleEndedLocal(i);
58
- }, disabled: currentState === 'disabled', ariaLabel: String(c), label: c, variant: currentState, selected: selectedAnswer === i }, i)))) : (_jsxs(_Fragment, { children: [_jsx(Button, { size: 56, type: "bar", onClick: handleSelectFalse, disabled: currentState === 'disabled', icon: 'x', ariaLabel: t('training.notHeard', '안 들렸어요.'), label: t('training.notHeard', '안 들렸어요.'), variant: 'default', selected: selectedAnswer === false }), _jsx(Button, { size: 56, type: "bar", onClick: handleSelectTrue, disabled: currentState === 'disabled', icon: 'circle', ariaLabel: t('training.heardWell', '잘 들렸어요.'), label: t('training.heardWell', '잘 들렸어요.'), variant: 'default', selected: selectedAnswer === true })] })) })] }));
58
+ }, disabled: currentState === 'disabled', ariaLabel: String(c), label: c, variant: currentState, selected: selectedAnswer === i }, i)))) : (_jsxs(_Fragment, { children: [_jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: handleSelectFalse, disabled: currentState === 'disabled', icon: 'x', ariaLabel: t('training.notHeard', '안 들렸어요.'), label: t('training.notHeard', '안 들렸어요.'), variant: 'default', selected: selectedAnswer === false }), _jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: handleSelectTrue, disabled: currentState === 'disabled', icon: 'circle', ariaLabel: t('training.heardWell', '잘 들렸어요.'), label: t('training.heardWell', '잘 들렸어요.'), variant: 'default', selected: selectedAnswer === true })] })) })] }));
59
59
  }
@@ -440,6 +440,6 @@ export default function TrainingStepOrderList({ index, stepIndex, isLast = false
440
440
  }, 80);
441
441
  }
442
442
  catch (err) { }
443
- } : undefined, onDrop: currentState === 'active' ? (e) => handleDrop(idx, e) : undefined, onDragEnd: currentState === 'active' ? handleDragEnd : undefined, children: [_jsx("span", { className: "order-list__item-text body-2", children: it.text }), currentState === 'active' ? (_jsxs("span", { className: "order-list__item-buttons", children: [_jsx(Button, { size: 32, type: "dot", onClick: () => move(idx, -1), ariaLabel: t("training.up"), icon: 'chevron-up', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === 0, useBorder: false, useDisabledInvisible: true }), _jsx(Button, { size: 32, type: "dot", onClick: () => move(idx, 1), ariaLabel: t("training.down"), icon: 'chevron-down', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === shuffled.length - 1, useBorder: false, useDisabledInvisible: true })] })) : null] })] }) }, it.id));
443
+ } : undefined, onDrop: currentState === 'active' ? (e) => handleDrop(idx, e) : undefined, onDragEnd: currentState === 'active' ? handleDragEnd : undefined, children: [_jsx("span", { className: "order-list__item-text body-2", children: it.text }), currentState === 'active' ? (_jsxs("span", { className: "order-list__item-buttons", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, -1), ariaLabel: t("training.up"), icon: 'chevron-up', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === 0, useBorder: false, useDisabledInvisible: true }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", onClick: () => move(idx, 1), ariaLabel: t("training.down"), icon: 'chevron-down', variant: 'transparent-gray', width: 48, useFaded: "true", disabled: idx === shuffled.length - 1, useBorder: false, useDisabledInvisible: true })] })) : null] })] }) }, it.id));
444
444
  }) }) }));
445
445
  }
@@ -70,5 +70,5 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
70
70
  fontSize: `${fontSize}px`,
71
71
  lineHeight: `${lineHeight}px`,
72
72
  }), [fontSize, lineHeight]);
73
- return (_jsxs("div", { className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
73
+ return (_jsxs("div", { className: `training-text-box ${stateClass} ${displayStatus !== 'disabled' && theme === 'dark' ? 'is-dark' : ''}`, children: [displayStatus === "disabled" && (_jsx("div", { className: "training-text-box__preview", children: _jsx(Button, { htmlType: "button", size: 48, type: "bar", onClick: handleActivate, icon: 'eye', ariaLabel: t('training.sentencePreview', '문장 미리보기'), label: t('training.sentencePreview', '문장 미리보기'), useBorder: false }) })), _jsx("div", { className: "training-text-box__area", "aria-hidden": displayStatus === "disabled", children: _jsx("span", { className: "training-text-box__inner h3", style: textStyle, children: content }) }), displayStatus !== "disabled" && (_jsxs("div", { className: "training-text-box__buttons", "aria-hidden": displayStatus === "disabled", children: [_jsx(Button, { size: 32, type: "dot", onClick: decrease, onDisabledClick: () => showNotice(t('training.fontSizeMin', '글씨 크기가 최소입니다.')), ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), icon: 'minus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { size: 32, type: "dot", onClick: increase, onDisabledClick: () => showNotice(t('training.fontSizeMax', '글씨 크기가 최대입니다.')), ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), icon: 'plus', variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease, useBorder: false, showTooltipInfo: true, ...(!isMobile && { width: '60px' }) }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: theme === 'dark' ? 'moon' : 'sun-medium', onClick: toggleTheme, ariaLabel: t('training.toggleBackground', '배경 전환'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
74
74
  }
@@ -87,5 +87,5 @@ export default function TrainingStepTitle({ title, children, className = "", sta
87
87
  setPreviewContent(null);
88
88
  }
89
89
  }, [onPreviewTextBtn, previewText, showPreview]);
90
- return (_jsxs("div", { className: rowClass, children: [_jsx("p", { className: cls, children: _jsx("span", { className: "training-step__title-text", children: title ?? children }) }), (usePreviewTextBtn || usePlayAllBtn) ? (_jsxs("div", { className: "title-controls", children: [_jsxs("div", { className: "title-buttons", children: [usePlayAllBtn && (_jsx(Button, { size: 32, type: isMobile ? 'dot' : 'bar', icon: "play-circle", label: isMobile ? undefined : t('training.playAll', '모두 듣기'), ariaLabel: t('training.playAll', '모두 듣기'), selected: playAllActive, onClick: onPlayAll, variant: state === 'disabled' ? 'disabled' : 'default', disabled: state === 'disabled' || playAllActive })), usePreviewTextBtn && (_jsx("div", { ref: btnRef, children: _jsx(Button, { size: 32, type: isMobile ? 'dot' : 'bar', icon: "eye", label: isMobile ? undefined : t('training.sentencePreview', '문장 미리보기'), ariaLabel: t('training.sentencePreview', '문장 미리보기'), selected: showPreview, onClick: handlePreviewButtonClick, variant: state === 'disabled' ? 'disabled' : 'default', disabled: !previewText && typeof onPreviewTextBtn !== 'function' }) }))] }), showPreview && (_jsx(TextBoxTooltip, { text: (previewContent ?? previewText), label: t('training.sentencePreview', '문장 미리보기'), anchorRect: anchorRect, onClose: handlePreviewClose, closeOnOutsideClick: false, isMobilePopup: true }))] })) : null] }));
90
+ return (_jsxs("div", { className: rowClass, children: [_jsx("p", { className: cls, children: _jsx("span", { className: "training-step__title-text", children: title ?? children }) }), (usePreviewTextBtn || usePlayAllBtn) ? (_jsxs("div", { className: "title-controls", children: [_jsxs("div", { className: "title-buttons", children: [usePlayAllBtn && (_jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "play-circle", label: isMobile ? undefined : t('training.playAll', '모두 듣기'), ariaLabel: t('training.playAll', '모두 듣기'), selected: playAllActive, onClick: onPlayAll, variant: state === 'disabled' ? 'disabled' : 'default', disabled: state === 'disabled' || playAllActive })), usePreviewTextBtn && (_jsx("div", { ref: btnRef, children: _jsx(Button, { htmlType: "button", size: 32, type: isMobile ? 'dot' : 'bar', icon: "eye", label: isMobile ? undefined : t('training.sentencePreview', '문장 미리보기'), ariaLabel: t('training.sentencePreview', '문장 미리보기'), selected: showPreview, onClick: handlePreviewButtonClick, variant: state === 'disabled' ? 'disabled' : 'default', disabled: !previewText && typeof onPreviewTextBtn !== 'function' }) }))] }), showPreview && (_jsx(TextBoxTooltip, { text: (previewContent ?? previewText), label: t('training.sentencePreview', '문장 미리보기'), anchorRect: anchorRect, onClose: handlePreviewClose, closeOnOutsideClick: false, isMobilePopup: true }))] })) : null] }));
91
91
  }
@@ -36,7 +36,12 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
36
36
  const audioRef = useRef(null);
37
37
  const isMobile = useMediaQuery('(max-width: 767px)');
38
38
  const [playing, setPlaying] = useState(false);
39
- const [audioUrl, setAudioUrl] = useState('');
39
+ /* 준비한 URL 은 **어느 낱말의 것인지와 함께** 둔다. 카드는 번호 key 로 재사용되는데(TrainingStepCard),
40
+ 전에는 낱말이 바뀌어도 URL 을 비우지 않아 새 낱말의 리졸버가 기다리는 동안·실패했을 때 누르면
41
+ **이전 낱말 소리**가 났다 — 변별 훈련에서 틀린 자극이다. 지금 낱말의 것이 아니면 없는 것으로 본다. */
42
+ const audioFor = `${typeof rawAudioSrc === 'string' ? rawAudioSrc : rawAudioSrc?.src ?? ''}\u0000${label}`;
43
+ const [prepared, setPrepared] = useState({ for: '', url: '' });
44
+ const audioUrl = prepared.for === audioFor ? prepared.url : '';
40
45
  // ── 이미지 ── (앞면 유지값 기준 — 플립 동안 이미지도 유지)
41
46
  const imageUrl = getImageResolver()?.(front.label, front.homonum) ?? item.image ?? '';
42
47
  const { imgRef, loading: imgLoading, hasImage, onLoad: handleImgLoad, onError: handleImgError } = useWordImage(imageUrl);
@@ -48,14 +53,14 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
48
53
  if (rawAudioSrc) {
49
54
  if (typeof rawAudioSrc === 'string') {
50
55
  if (rawAudioSrc.startsWith('http') || rawAudioSrc.includes('/')) {
51
- setAudioUrl(rawAudioSrc);
56
+ setPrepared({ for: audioFor, url: rawAudioSrc });
52
57
  return;
53
58
  }
54
59
  }
55
60
  else if (typeof rawAudioSrc === 'object' && !Array.isArray(rawAudioSrc)) {
56
61
  const src = rawAudioSrc.src;
57
62
  if (src) {
58
- setAudioUrl(src);
63
+ setPrepared({ for: audioFor, url: src });
59
64
  return;
60
65
  }
61
66
  }
@@ -66,14 +71,14 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
66
71
  try {
67
72
  const url = await Promise.resolve(resolver(label));
68
73
  if (!cancelled && url)
69
- setAudioUrl(url);
74
+ setPrepared({ for: audioFor, url });
70
75
  }
71
76
  catch { }
72
77
  }
73
78
  };
74
79
  resolveAudio();
75
80
  return () => { cancelled = true; };
76
- }, [rawAudioSrc, label]);
81
+ }, [rawAudioSrc, label, audioFor]);
77
82
  // ── 언마운트 시 오디오 정리 ──
78
83
  useEffect(() => {
79
84
  return () => {
@@ -224,6 +229,6 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
224
229
  onSelect?.();
225
230
  }
226
231
  }
227
- : undefined, children: _jsxs("div", { className: "training-word-card__flipper", style: { transitionDelay: showBack ? '0ms' : `${flipDelay}ms` }, children: [_jsxs("div", { className: "training-word-card__front", children: [selected && (_jsx("div", { className: "training-word-card__selected-badge", onClick: handleStopProp, children: _jsx(Badge, { type: "dot", size: 40, position: "inline", animate: true, icon: orderRank != null ? undefined : 'check', number: orderRank ?? undefined, "aria-label": orderRank != null ? t('training.nthSelected', { n: orderRank }) : t('training.selected', '선택됨') }) })), _jsx("div", { className: "training-word-card__image-wrap", children: hasImage ? (_jsxs(_Fragment, { children: [imgLoading && _jsx("div", { className: "training-word-card__spinner" }), _jsx("img", { ref: imgRef, className: "training-word-card__image", src: imageUrl, alt: front.label || t('common.image', '이미지'), style: { opacity: imgLoading ? 0 : 1 }, onLoad: handleImgLoad, onError: handleImgError })] })) : (_jsx("div", { className: "training-word-card__placeholder" })) }), !hideLabel && (_jsxs("div", { className: "training-word-card__body", children: [_jsx("div", { className: "training-word-card__word", children: front.label }), front.subLabel && (_jsxs("div", { className: "training-word-card__pronunciation", children: ["[", front.subLabel, "]"] })), front.colloquialLabel && (_jsxs("div", { className: "training-word-card__colloquial caption-1", children: ["[", front.colloquialLabel, "]"] }))] })), !hideAudio && (_jsx("div", { className: "training-word-card__bottom", onClick: selectable ? handleStopProp : undefined, children: _jsx(Button, { size: isMobile ? 40 : 48, type: isMobile ? 'dot' : 'bar', icon: playing ? 'pause' : 'play', label: isMobile ? undefined : t('training.playSound', '소리 듣기'), selected: playing, onClick: handlePlay, disabled: disabled, ariaLabel: t('training.playSound', '소리 듣기') }) }))] }), _jsx("div", { className: "training-word-card__back", "aria-hidden": "true", children: _jsx("img", { src: symbolSrc, className: "training-word-card__disabled-symbol", alt: "", draggable: false }) })] }) }));
232
+ : undefined, children: _jsxs("div", { className: "training-word-card__flipper", style: { transitionDelay: showBack ? '0ms' : `${flipDelay}ms` }, children: [_jsxs("div", { className: "training-word-card__front", children: [selected && (_jsx("div", { className: "training-word-card__selected-badge", onClick: handleStopProp, children: _jsx(Badge, { type: "dot", size: 40, position: "inline", animate: true, icon: orderRank != null ? undefined : 'check', number: orderRank ?? undefined, "aria-label": orderRank != null ? t('training.nthSelected', { n: orderRank }) : t('training.selected', '선택됨') }) })), _jsx("div", { className: "training-word-card__image-wrap", children: hasImage ? (_jsxs(_Fragment, { children: [imgLoading && _jsx("div", { className: "training-word-card__spinner" }), _jsx("img", { ref: imgRef, className: "training-word-card__image", src: imageUrl, alt: front.label || t('common.image', '이미지'), style: { opacity: imgLoading ? 0 : 1 }, onLoad: handleImgLoad, onError: handleImgError })] })) : (_jsx("div", { className: "training-word-card__placeholder" })) }), !hideLabel && (_jsxs("div", { className: "training-word-card__body", children: [_jsx("div", { className: "training-word-card__word", children: front.label }), front.subLabel && (_jsxs("div", { className: "training-word-card__pronunciation", children: ["[", front.subLabel, "]"] })), front.colloquialLabel && (_jsxs("div", { className: "training-word-card__colloquial caption-1", children: ["[", front.colloquialLabel, "]"] }))] })), !hideAudio && (_jsx("div", { className: "training-word-card__bottom", onClick: selectable ? handleStopProp : undefined, children: _jsx(Button, { htmlType: "button", size: isMobile ? 40 : 48, type: isMobile ? 'dot' : 'bar', icon: playing ? 'pause' : 'play', label: isMobile ? undefined : t('training.playSound', '소리 듣기'), selected: playing, onClick: handlePlay, disabled: disabled, ariaLabel: t('training.playSound', '소리 듣기') }) }))] }), _jsx("div", { className: "training-word-card__back", "aria-hidden": "true", children: _jsx("img", { src: symbolSrc, className: "training-word-card__disabled-symbol", alt: "", draggable: false }) })] }) }));
228
233
  });
229
234
  export default TrainingWordCard;
@@ -1,5 +1,5 @@
1
1
  'use client';
2
- import { useCallback, useEffect, useRef, useState } from 'react';
2
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { resolveSoundPath, getSignedOrPublicUrl, downloadSound } from '../../utils/storage';
4
4
  function derivePreviewMeta(trainingData, typeKey) {
5
5
  if (!trainingData)
@@ -46,7 +46,9 @@ function derivePreviewMeta(trainingData, typeKey) {
46
46
  export function useAudioPreview(trainingData, typeKey) {
47
47
  const [playing, setPlaying] = useState(false);
48
48
  const [playingIndex, setPlayingIndex] = useState(-1);
49
- const [previewUrls, setPreviewUrls] = useState([]);
49
+ /* 받은 주소는 **어느 코드 묶음의 것인지와 함께** 둔다. 코드가 없는 훈련으로 바뀌면 아래 효과가 일찍 빠져나가
50
+ 이전 훈련 주소가 남았고, 새 주소를 받는 동안에도 이전 주소로 미리듣기가 났다. 지금 묶음의 것만 쓴다. */
51
+ const [resolved, setResolved] = useState({ key: '', urls: [] });
50
52
  const previewAudioRef = useRef(null);
51
53
  const playingRef = useRef(false);
52
54
  const previewBtnRef = useRef(null);
@@ -55,6 +57,8 @@ export function useAudioPreview(trainingData, typeKey) {
55
57
  // 우리가 만든 게 아니므로 revoke 대상이 아니다
56
58
  const ownedBlobUrlsRef = useRef([]);
57
59
  const { previewCodes, previewBucket } = derivePreviewMeta(trainingData, typeKey);
60
+ const previewKey = `${previewCodes.join(',')}|${previewBucket ?? ''}`;
61
+ const previewUrls = useMemo(() => (resolved.key === previewKey ? resolved.urls : []), [resolved, previewKey]);
58
62
  // Resolve audio URLs upfront when training data changes
59
63
  useEffect(() => {
60
64
  if (!previewCodes.length)
@@ -98,7 +102,7 @@ export function useAudioPreview(trainingData, typeKey) {
98
102
  }
99
103
  catch { } });
100
104
  ownedBlobUrlsRef.current = newBlobUrls;
101
- setPreviewUrls(resolvedUrls);
105
+ setResolved({ key: previewKey, urls: resolvedUrls });
102
106
  }
103
107
  else {
104
108
  // 이 effect가 취소된 사이 새로 만든 blob은 ref에 등록되지 못하므로 즉시 해제
@@ -21,5 +21,5 @@ import './UpdateAlert.css';
21
21
  export default function UpdateAlert({ title, message, actionLabel, onAction, icon, onClose, className = '', }) {
22
22
  const { t } = useTranslation();
23
23
  const resolvedActionLabel = actionLabel ?? t('common.update', '업데이트');
24
- return (_jsxs("div", { className: cx('update-alert', className), role: "alert", children: [onClose && (_jsx("button", { type: "button", className: "update-alert__close", onClick: onClose, "aria-label": t('common.close', '닫기'), children: "\u00D7" })), _jsxs("div", { className: "update-alert__top", children: [_jsx("div", { className: "update-alert__icon", children: icon ?? _jsx(Download, { size: 20, strokeWidth: 2 }) }), _jsxs("div", { className: "update-alert__body", children: [_jsx("div", { className: "update-alert__title h4", children: title }), _jsx("div", { className: "update-alert__message body-3", children: message })] })] }), onAction && (_jsx(Button, { size: 32, variant: "main", shape: "pill", className: "update-alert__action", onClick: onAction, children: resolvedActionLabel }))] }));
24
+ return (_jsxs("div", { className: cx('update-alert', className), role: "alert", children: [onClose && (_jsx("button", { type: "button", className: "update-alert__close", onClick: onClose, "aria-label": t('common.close', '닫기'), children: "\u00D7" })), _jsxs("div", { className: "update-alert__top", children: [_jsx("div", { className: "update-alert__icon", children: icon ?? _jsx(Download, { size: 20, strokeWidth: 2 }) }), _jsxs("div", { className: "update-alert__body", children: [_jsx("div", { className: "update-alert__title h4", children: title }), _jsx("div", { className: "update-alert__message body-3", children: message })] })] }), onAction && (_jsx(Button, { htmlType: "button", size: 32, variant: "main", shape: "pill", className: "update-alert__action", onClick: onAction, children: resolvedActionLabel }))] }));
25
25
  }
@@ -45,7 +45,7 @@ export default function PhonemeSelectBlock({ sections, selectedList, count, onCo
45
45
  const hasLabel = !!value && value !== '없음';
46
46
  return (_jsxs("span", { className: cx('phoneme-select-block__cell-jamo-part', value ? '' : 'phoneme-select-block__cell-jamo-part--empty', hasLabel ? '' : 'phoneme-select-block__cell-jamo-part--no-label'), children: [_jsx("span", { className: `phoneme-select-block__cell-jamo-char h1${value === '없음' ? ' phoneme-select-block__cell-jamo-char--excluded' : ''}`, children: value === '없음' ? 'x' : (value || placeholder) }), hasLabel && _jsx("span", { className: "phoneme-select-block__cell-jamo-label caption-2", children: displayLabel ?? label })] }, label));
47
47
  }), [sections, placeholder]);
48
- const infoButton = (_jsxs(_Fragment, { children: [_jsx("div", { "data-tooltip-ignore": true, children: _jsx(Button, { size: 40, type: isMobile ? 'dot' : undefined, icon: "info", label: isMobile ? undefined : t('wordPairs.caution', '유의사항'), selected: !!infoAnchorRect, onClick: (e) => toggleInfo(e.currentTarget) }) }), infoAnchorRect && (_jsx(DescriptionTooltip, { title: t('wordPairs.caution', '유의사항'), items: infoNotes, anchorRect: infoAnchorRect, onClose: handleCloseInfo }))] }));
48
+ const infoButton = (_jsxs(_Fragment, { children: [_jsx("div", { "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 40, type: isMobile ? 'dot' : undefined, icon: "info", label: isMobile ? undefined : t('wordPairs.caution', '유의사항'), selected: !!infoAnchorRect, onClick: (e) => toggleInfo(e.currentTarget) }) }), infoAnchorRect && (_jsx(DescriptionTooltip, { title: t('wordPairs.caution', '유의사항'), items: infoNotes, anchorRect: infoAnchorRect, onClose: handleCloseInfo }))] }));
49
49
  return (_jsxs(Section, { type: sectionType, title: title, icon: titleIcon, disc: disc, action: infoButton, className: cx('phoneme-select-block', className), children: [beforeContent, (!hideCountControl || (showInfoButton && !(title || disc))) && (_jsxs("div", { className: "phoneme-select-block__controls", children: [!hideCountControl && (_jsx(SegmentedControl, { options: countOptions, value: String(count), onChange: handleCountChange, size: 40, className: "phoneme-select-block__count-control" })), showInfoButton && !(title || disc) && infoButton] })), _jsx("div", { className: "phoneme-select-block__cells", children: pairMode
50
50
  ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "phoneme-select-block__cell-group", children: Array.from({ length: count }).map((_, pairIdx) => {
51
51
  const sel = selectedList[pairIdx * 2] ?? {};
@@ -66,6 +66,6 @@ export default function PhonemeSelectBlock({ sections, selectedList, count, onCo
66
66
  // 종성 '제외'(없음)만 선택된 슬롯은 실제 음소를 고른 것으로 치지 않는다 —
67
67
  // "받침 없음"은 단독으로는 검색 조건이 되기엔 너무 느슨한 보조 조건이라서다.
68
68
  const isEmpty = selectedList.every(sel => Object.entries(sel).every(([k, v]) => !v || (k === '종성' && v === '없음')));
69
- return (_jsxs("div", { className: "phoneme-select-block__search-row", onClick: isEmpty ? onSearchEmpty : undefined, children: [onClearPhoneme && (_jsx(Button, { size: "56", type: "bar", label: t('wordPairs.resetOptions', '검색 옵션 초기화'), selected: false, disabled: isEmpty, onClick: isEmpty ? undefined : onClearPhoneme })), searchChecking ? (_jsxs("div", { className: "phoneme-select-block__checking", children: [_jsx("div", { className: "phoneme-select-block__spinner" }), _jsx("span", { className: "button-1 phoneme-select-block__checking-label", children: searchLabel ?? t('wordSearch.searchButton', '검색하기') })] })) : onSearch && (_jsx(Button, { size: "56", type: "bar", variant: "main", label: searchLabel ?? t('wordSearch.searchButton', '검색하기'), disabled: isEmpty || searchDisabled, onClick: isEmpty || searchDisabled ? undefined : onSearch }))] }));
69
+ return (_jsxs("div", { className: "phoneme-select-block__search-row", onClick: isEmpty ? onSearchEmpty : undefined, children: [onClearPhoneme && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('wordPairs.resetOptions', '검색 옵션 초기화'), selected: false, disabled: isEmpty, onClick: isEmpty ? undefined : onClearPhoneme })), searchChecking ? (_jsxs("div", { className: "phoneme-select-block__checking", children: [_jsx("div", { className: "phoneme-select-block__spinner" }), _jsx("span", { className: "button-1 phoneme-select-block__checking-label", children: searchLabel ?? t('wordSearch.searchButton', '검색하기') })] })) : onSearch && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", variant: "main", label: searchLabel ?? t('wordSearch.searchButton', '검색하기'), disabled: isEmpty || searchDisabled, onClick: isEmpty || searchDisabled ? undefined : onSearch }))] }));
70
70
  })(), afterContent] }));
71
71
  }
@@ -135,7 +135,7 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
135
135
  return (_jsx("div", { onMouseEnter: e => {
136
136
  openHover(e.currentTarget);
137
137
  setHoverWords(item.words);
138
- }, onMouseLeave: handleHoverLeave, children: _jsx(Button, { size: "word", align: "grid", label: item.words.map(w => w.label), subLabel: item.words.map(w => w.subLabel).filter(Boolean).length > 0
138
+ }, onMouseLeave: handleHoverLeave, children: _jsx(Button, { htmlType: "button", size: "word", align: "grid", label: item.words.map(w => w.label), subLabel: item.words.map(w => w.subLabel).filter(Boolean).length > 0
139
139
  ? item.words.map(w => w.subLabel)
140
140
  : undefined, selected: isSelected, onClick: () => handleClick(item.id), minWidth: 200 }) }, item.id));
141
141
  }) }) }, group.label)))) : (_jsx("div", { className: "word-search-result-block__items", children: pagedItems.map((item) => {
@@ -143,8 +143,8 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
143
143
  return (_jsx("div", { onMouseEnter: e => {
144
144
  openHover(e.currentTarget);
145
145
  setHoverWords(item.words);
146
- }, onMouseLeave: handleHoverLeave, children: _jsx(Button, { size: "word", align: "grid", label: item.words.map(w => w.label), subLabel: item.words.map(w => w.subLabel).filter(Boolean).length > 0
146
+ }, onMouseLeave: handleHoverLeave, children: _jsx(Button, { htmlType: "button", size: "word", align: "grid", label: item.words.map(w => w.label), subLabel: item.words.map(w => w.subLabel).filter(Boolean).length > 0
147
147
  ? item.words.map(w => w.subLabel)
148
148
  : undefined, selected: isSelected, onClick: () => handleClick(item.id), minWidth: 200 }) }, item.id));
149
- }) })), hoverWords && hoverAnchorRect && (_jsx(WordImageTooltip, { words: hoverWords, anchorRect: hoverAnchorRect }))] }), totalPages > 1 && (_jsx("div", { className: "word-search-result-block__pagination-bottom", children: _jsx(Pagination, { page: pageClamped, totalPages: totalPages, onChange: setPage }) })), (onClearSelection || onPrint) && (_jsxs("div", { className: "word-search-result-block__actions", children: [onClearSelection && (_jsx(Button, { size: "56", type: "bar", label: t('common.resetSelection', '선택 초기화'), selected: false, disabled: selected.length === 0, onClick: selected.length === 0 ? undefined : onClearSelection })), onPrint && (_jsx("div", { onClick: selected.length === 0 ? onPrintEmpty : undefined, children: _jsx(Button, { size: "56", type: "bar", variant: "main", label: t('wordTable.title', '단어 표 만들기'), disabled: selected.length === 0, onClick: selected.length === 0 ? undefined : onPrint }) }))] }))] }));
149
+ }) })), hoverWords && hoverAnchorRect && (_jsx(WordImageTooltip, { words: hoverWords, anchorRect: hoverAnchorRect }))] }), totalPages > 1 && (_jsx("div", { className: "word-search-result-block__pagination-bottom", children: _jsx(Pagination, { page: pageClamped, totalPages: totalPages, onChange: setPage }) })), (onClearSelection || onPrint) && (_jsxs("div", { className: "word-search-result-block__actions", children: [onClearSelection && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('common.resetSelection', '선택 초기화'), selected: false, disabled: selected.length === 0, onClick: selected.length === 0 ? undefined : onClearSelection })), onPrint && (_jsx("div", { onClick: selected.length === 0 ? onPrintEmpty : undefined, children: _jsx(Button, { htmlType: "button", size: "56", type: "bar", variant: "main", label: t('wordTable.title', '단어 표 만들기'), disabled: selected.length === 0, onClick: selected.length === 0 ? undefined : onPrint }) }))] }))] }));
150
150
  }
@@ -259,7 +259,7 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
259
259
  }
260
260
  };
261
261
  const savePreview = (_jsx(WordTable, { items: saveItems, orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: count }));
262
- return (_jsxs(_Fragment, { children: [_jsxs(Section, { type: "page", icon: titleIcon, title: title, disc: disc, className: cx('word-table-block', className), children: [showSaveAsPopup && (_jsx(Popup, { size: "small", title: secondarySaveButtonLabel ?? t('wordTable.saveAsNewButton', '다른 이름으로 저장'), onClose: () => setShowSaveAsPopup(false), footer: ({ closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx(Button, { size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation }), _jsx(Button, { size: 48, type: "bar", variant: "main", label: t('common.save', '저장'), disabled: !saveAsTrimmed || saveAsCollides, onClick: handleSaveAsConfirm })] })), children: _jsx(TextField, { label: t('wordTable.save.nameLabel', '이름'), value: saveAsName, onChange: setSaveAsName, placeholder: t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'), maxLength: 40, size: 48, fullWidth: true, autoFocus: true, onEnter: handleSaveAsConfirm, error: saveAsCollides ? t('wordTable.save.duplicateName', '이미 사용 중인 이름입니다.') : undefined }) })), showSavePopup && onSaveComplete && renderSavePopup?.({
262
+ return (_jsxs(_Fragment, { children: [_jsxs(Section, { type: "page", icon: titleIcon, title: title, disc: disc, className: cx('word-table-block', className), children: [showSaveAsPopup && (_jsx(Popup, { size: "small", title: secondarySaveButtonLabel ?? t('wordTable.saveAsNewButton', '다른 이름으로 저장'), onClose: () => setShowSaveAsPopup(false), footer: ({ 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: t('common.save', '저장'), disabled: !saveAsTrimmed || saveAsCollides, onClick: handleSaveAsConfirm })] })), children: _jsx(TextField, { label: t('wordTable.save.nameLabel', '이름'), value: saveAsName, onChange: setSaveAsName, placeholder: t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'), maxLength: 40, size: 48, fullWidth: true, autoFocus: true, onEnter: handleSaveAsConfirm, error: saveAsCollides ? t('wordTable.save.duplicateName', '이미 사용 중인 이름입니다.') : undefined }) })), showSavePopup && onSaveComplete && renderSavePopup?.({
263
263
  defaultName: typeof title === 'string' ? title : t('wordTable.defaultName', '단어 표'),
264
264
  preview: savePreview,
265
265
  previewW: width,
@@ -299,5 +299,5 @@ export default function WordTableBlock({ title, titleIcon, disc, items, onSaveCo
299
299
  e.preventDefault();
300
300
  onDescriptionChange?.(descriptionPlaceholder ?? '');
301
301
  }
302
- } }) })] }))] }), !hideControls && (_jsxs("div", { className: "word-table-block__actions", children: [_jsxs("div", { className: "word-table-block__actions-left", children: [!saveOnly && onReset && (_jsx(Button, { size: 40, type: "bar", label: resolvedResetLabel, onClick: onReset })), !saveOnly && onLoad && (_jsx(Button, { size: 40, type: "bar", label: t('wordTable.load', '불러오기'), onClick: onLoad })), !saveOnly && _jsx(Button, { size: 40, type: "bar", label: t('wordTable.openNewWindow', '새 창으로 보기'), onClick: handleOpenNewWindow })] }), _jsxs("div", { className: "word-table-block__actions-right", children: [onCancel && _jsx(Button, { size: "56", type: "bar", label: t('common.cancel', '취소'), onClick: onCancel }), !saveOnly && _jsx(Button, { size: "56", type: "bar", label: t('wordTable.print', '인쇄하기'), onClick: handlePrint }), secondarySaveButtonLabel && (_jsx(Button, { size: "56", type: "bar", label: secondarySaveButtonLabel, onClick: () => inlineNameFields ? openSaveAsPopup() : handleSave(true), disabled: noItems, onDisabledClick: () => { showWarning(t('wordTable.emptyItems', '단어를 1개 이상 추가해주세요.')); } })), _jsx(Button, { size: "56", type: "bar", variant: "main", label: saveButtonLabel ?? t('wordTable.saveButton', '저장하기'), onClick: () => handleSave(false), disabled: mainSaveDisabled, onDisabledClick: handleMainDisabledClick })] })] })), _jsx(Toast, { message: toastMessage, type: toastType, onClose: clearToast })] }));
302
+ } }) })] }))] }), !hideControls && (_jsxs("div", { className: "word-table-block__actions", children: [_jsxs("div", { className: "word-table-block__actions-left", children: [!saveOnly && onReset && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", label: resolvedResetLabel, onClick: onReset })), !saveOnly && onLoad && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", label: t('wordTable.load', '불러오기'), onClick: onLoad })), !saveOnly && _jsx(Button, { htmlType: "button", size: 40, type: "bar", label: t('wordTable.openNewWindow', '새 창으로 보기'), onClick: handleOpenNewWindow })] }), _jsxs("div", { className: "word-table-block__actions-right", children: [onCancel && _jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('common.cancel', '취소'), onClick: onCancel }), !saveOnly && _jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('wordTable.print', '인쇄하기'), onClick: handlePrint }), secondarySaveButtonLabel && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: secondarySaveButtonLabel, onClick: () => inlineNameFields ? openSaveAsPopup() : handleSave(true), disabled: noItems, onDisabledClick: () => { showWarning(t('wordTable.emptyItems', '단어를 1개 이상 추가해주세요.')); } })), _jsx(Button, { htmlType: "button", size: "56", type: "bar", variant: "main", label: saveButtonLabel ?? t('wordTable.saveButton', '저장하기'), onClick: () => handleSave(false), disabled: mainSaveDisabled, onDisabledClick: handleMainDisabledClick })] })] })), _jsx(Toast, { message: toastMessage, type: toastType, onClose: clearToast })] }));
303
303
  }
@@ -21,7 +21,7 @@ export default function WordTableButton({ items, name, description, createdAt, p
21
21
  onClick?.();
22
22
  }
23
23
  };
24
- return (_jsxs("div", { className: cx('word-table-button', selected ? 'word-table-button--selected' : '', !hasStoreFooter ? 'word-table-button--no-footer' : '', className), role: "button", tabIndex: 0, "aria-label": name, onClick: renaming ? undefined : onClick, onKeyDown: handleKey, children: [isNew && _jsx("span", { className: "word-table-button__new-dot", "aria-label": t('myWordTable.newBadge', '새로 만든 표') }), _jsx(WordTableThumbnail, { items: items, className: "word-table-button__thumb" }), _jsxs("div", { className: "word-table-button__info", children: [_jsxs("div", { className: "word-table-button__info-text", children: [titleNode ?? _jsx("div", { className: "word-table-button__title h4", children: name }), description && _jsx("div", { className: "word-table-button__desc body-3", children: description })] }), onMore && !renaming && (_jsx(Button, { className: "word-table-button__more", size: 32, type: "dot", variant: "transparent-gray", useBorder: false, icon: "ellipsis",
24
+ return (_jsxs("div", { className: cx('word-table-button', selected ? 'word-table-button--selected' : '', !hasStoreFooter ? 'word-table-button--no-footer' : '', className), role: "button", tabIndex: 0, "aria-label": name, onClick: renaming ? undefined : onClick, onKeyDown: handleKey, children: [isNew && _jsx("span", { className: "word-table-button__new-dot", "aria-label": t('myWordTable.newBadge', '새로 만든 표') }), _jsx(WordTableThumbnail, { items: items, className: "word-table-button__thumb" }), _jsxs("div", { className: "word-table-button__info", children: [_jsxs("div", { className: "word-table-button__info-text", children: [titleNode ?? _jsx("div", { className: "word-table-button__title h4", children: name }), description && _jsx("div", { className: "word-table-button__desc body-3", children: description })] }), onMore && !renaming && (_jsx(Button, { htmlType: "button", className: "word-table-button__more", size: 32, type: "dot", variant: "transparent-gray", useBorder: false, icon: "ellipsis",
25
25
  /* 하드코딩 '더보기'였다(영어 화면에서도 한국어로 남았다). 목록에 표가
26
26
  여러 개면 이름이 같은 버튼이 여러 개이기도 해서 이름을 함께 넣는다. */
27
27
  ariaLabel: t('common.moreFor', { name }), onClick: (e) => { e.stopPropagation(); onMore(e); } }))] }), (createdAt || published || shared) && (_jsxs("div", { className: "word-table-button__meta-row", children: [_jsx("span", { className: "word-table-button__meta caption-1", children: createdAt ? formatRelativeTime(createdAt, t) : '' }), _jsxs("span", { className: "word-table-button__badges", children: [published && (_jsx(Badge, { className: "word-table-button__badge", size: "small", variant: "main", fill: "soft", iconLeft: "book-marked", label: publishedLabel ?? t('myWordTable.publishedBadge', '발행됨') })), shared && (_jsx(Badge, { className: "word-table-button__badge", size: "small", fill: "soft", iconLeft: "link", label: sharedLabel ?? t('myWordTable.sharedBadge', '공유 중') }))] })] })), hasStoreFooter && (_jsxs("div", { className: "word-table-button__footer", children: [_jsxs("span", { className: "word-table-button__store-by", children: [publisherName && (_jsx("button", { className: "training-card__publisher body-4", type: "button", onClick: (e) => { e.stopPropagation(); onPublisherClick?.(); }, children: publisherName })), publishedAt && _jsx("span", { className: "training-card__date body-4", children: formatRelativeTime(publishedAt, t) })] }), _jsxs("span", { className: "word-table-button__store-stats", children: [likeCount != null && _jsx(Badge, { size: "small", fill: "soft", variant: "like", iconLeft: "heart", label: String(likeCount) }), downloadCount != null && _jsx(Badge, { size: "small", fill: "soft", variant: "main", iconLeft: "download", label: String(downloadCount) })] })] }))] }));
@@ -119,10 +119,10 @@ function WordCell({ label, subLabel, colloquialLabel, homonum, showWord = true,
119
119
  obs.observe(el);
120
120
  return () => obs.disconnect();
121
121
  }, [refW, refH]);
122
- return (_jsxs("div", { ref: outerRef, className: "word-table-cell", children: [_jsxs("div", { className: "word-table-cell__inner", style: { transform: `scale(${scale})` }, children: [hasImage && (_jsxs("div", { className: "word-table-cell__image-wrap", children: [imgLoading && _jsx("div", { className: "word-table-cell__spinner" }), _jsx("img", { ref: imgRef, className: "word-table-cell__image", src: imageUrl, alt: label, draggable: false, style: { opacity: imgLoading ? 0 : 1 }, onLoad: handleImgLoad, onError: handleImgError })] })), showWord && _jsx("span", { className: "word-table-cell__word h0", children: label }), showPronunciation && pron && _jsxs("span", { className: "word-table-cell__sub h3", children: ["[", pron, "]"] }), showColloquial && colloquialLabel && _jsxs("span", { className: "word-table-cell__colloquial h5", children: ["[", colloquialLabel, "]"] })] }), _jsxs("div", { className: "word-table-cell__play-button", "data-audio-src": audioUrl, children: [_jsx("span", { "data-play-state": "play", style: { display: playing ? 'none' : undefined }, children: _jsx(Button, { size: buttonSize, type: "dot", icon: "play", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: t('common.play', '재생'), onClick: handlePlay }) }), _jsx("span", { "data-play-state": "pause", style: { display: playing ? undefined : 'none' }, children: _jsx(Button, { size: buttonSize, type: "dot", icon: "pause", useBorder: false, selected: true, showTooltipInfo: t('common.pause', '멈춤'), onClick: handlePlay }) })] }), wordInfo && (_jsxs("div", { ref: infoRef, className: "word-table-cell__info-button", onPointerEnter: handleInfoEnter, onPointerLeave: handleInfoLeave,
122
+ return (_jsxs("div", { ref: outerRef, className: "word-table-cell", children: [_jsxs("div", { className: "word-table-cell__inner", style: { transform: `scale(${scale})` }, children: [hasImage && (_jsxs("div", { className: "word-table-cell__image-wrap", children: [imgLoading && _jsx("div", { className: "word-table-cell__spinner" }), _jsx("img", { ref: imgRef, className: "word-table-cell__image", src: imageUrl, alt: label, draggable: false, style: { opacity: imgLoading ? 0 : 1 }, onLoad: handleImgLoad, onError: handleImgError })] })), showWord && _jsx("span", { className: "word-table-cell__word h0", children: label }), showPronunciation && pron && _jsxs("span", { className: "word-table-cell__sub h3", children: ["[", pron, "]"] }), showColloquial && colloquialLabel && _jsxs("span", { className: "word-table-cell__colloquial h5", children: ["[", colloquialLabel, "]"] })] }), _jsxs("div", { className: "word-table-cell__play-button", "data-audio-src": audioUrl, children: [_jsx("span", { "data-play-state": "play", style: { display: playing ? 'none' : undefined }, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "play", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: t('common.play', '재생'), onClick: handlePlay }) }), _jsx("span", { "data-play-state": "pause", style: { display: playing ? undefined : 'none' }, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "pause", useBorder: false, selected: true, showTooltipInfo: t('common.pause', '멈춤'), onClick: handlePlay }) })] }), wordInfo && (_jsxs("div", { ref: infoRef, className: "word-table-cell__info-button", onPointerEnter: handleInfoEnter, onPointerLeave: handleInfoLeave,
123
123
  // 이 div 자체는 포커스 대상이 아니지만 안의 Button이 포커스를 받으면
124
124
  // focusin이 여기로 올라온다 — 그래서 래퍼에 걸어도 동작한다.
125
- onFocus: handleInfoEnter, onBlur: handleInfoLeave, children: [_jsx(Button, { size: buttonSize, type: "dot", icon: "info", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: false,
125
+ onFocus: handleInfoEnter, onBlur: handleInfoLeave, children: [_jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "info", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: false,
126
126
  /* 아이콘만 있는 버튼이라 이름이 없으면 화면 낭독기가 "버튼"으로만 읽는다.
127
127
  한 표에 칸마다 하나씩 있어 열두 개가 다 "버튼"이 된다 — 어느 낱말의
128
128
  것인지 알 길이 없으므로 낱말을 이름에 넣는다. */
@@ -146,7 +146,7 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
146
146
  /* 세 단추를 늘 함께 그린다. 매기지 않은 칸은 통째로 숨어 있다가 마우스를 올리면
147
147
  나타나고, 매긴 칸은 **고른 것만** 남는다(CSS 가 나머지를 감춘다). 그래야 표를
148
148
  훑어 "어디를 어떻게 봤는지"가 한눈에 들어온다. */
149
- _jsx("div", { className: cx('word-table-cell__score', mark ? 'word-table-cell__score--marked' : ''), children: MARKS.map(m => (_jsx("span", { className: cx('word-table-cell__score-slot', mark === m ? 'word-table-cell__score-slot--on' : ''), children: _jsx(Button, { size: buttonSize, type: "dot", icon: MARK_ICON[m], variant: mark === m ? MARK_VARIANT[m] : 'default', selected: mark === m, useBorder: false, showTooltipInfo: false, ariaLabel: t(`wordTable.scoring.${m}`, { word: cellLabel, defaultValue: `${cellLabel} ${m}` }), onClick: (e) => { e.stopPropagation(); clickMark(cellIndex, m); } }) }, m))) }));
149
+ _jsx("div", { className: cx('word-table-cell__score', mark ? 'word-table-cell__score--marked' : ''), children: MARKS.map(m => (_jsx("span", { className: cx('word-table-cell__score-slot', mark === m ? 'word-table-cell__score-slot--on' : ''), children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: MARK_ICON[m], variant: mark === m ? MARK_VARIANT[m] : 'default', selected: mark === m, useBorder: false, showTooltipInfo: false, ariaLabel: t(`wordTable.scoring.${m}`, { word: cellLabel, defaultValue: `${cellLabel} ${m}` }), onClick: (e) => { e.stopPropagation(); clickMark(cellIndex, m); } }) }, m))) }));
150
150
  };
151
151
  const { t } = useTranslation();
152
152
  const isEdit = editCells !== undefined;
@@ -173,11 +173,11 @@ export default function WordTable({ title, titleIcon, disc, className, orientati
173
173
  const counterScale = { transform: `scale(${1 / scale})`, transformOrigin: 'center center' };
174
174
  const grid = (_jsx("div", { ref: scalerRef, className: "word-table__scaler", style: { height: height * scale }, children: _jsx("div", { className: cx('word-table', className), style: { width, height, transform: `scale(${scale})`, transformOrigin: 'top left', marginLeft: offset }, children: _jsxs("div", { ref: contentRef, className: contentClass, style: { gridTemplateColumns: `repeat(${cols}, 1fr)`, gridTemplateRows: `repeat(${rows}, 1fr)` }, children: [isEdit ? (_jsx(_Fragment, { children: (editCells ?? []).map((cell, i) => (_jsx("div", { className: cx('word-table-block__edit-cell', cell ? 'word-table-block__edit-cell--filled' : 'word-table-block__edit-cell--empty', activeCell === i ? 'word-table-block__edit-cell--active' : ''), style: dotDashStyle(i, cols, rows, cutLine), children: cell ? (_jsxs(_Fragment, { children: [cell.map((e, wi) => (_jsxs(Fragment, { children: [wi > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: e.word, subLabel: e.pronunciation,
175
175
  // 편집 칸도 구어체 발음을 넘긴다 — 빠져 있어 '구어체 발음'을 켜도 편집 모드에서만 비었다.
176
- colloquialLabel: e.colloquialPronunciation, homonum: e.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize })] }, wi))), scoring && renderScore(i, cell.map(e => e.word).join(' / ')), onRemoveCell && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveCell(i) }) }))] })) : (onActivateCell && (_jsx("div", { style: counterScale, children: _jsx(Button, { size: "48", type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell(i) }) }))) }, i))) })) : (_jsxs(_Fragment, { children: [(items ?? []).map((item, index) => (_jsxs("div", { className: `word-table-block__card${onReorder ? ' word-table-block__card--draggable' : ''}${dragOver === index ? ' word-table-block__card--drag-over' : ''}`, style: dotDashStyle(index, cols, rows, cutLine), draggable: !!onReorder, onDragStart: onReorder ? () => { dragIndex.current = index; } : undefined, onDragOver: onReorder ? (e) => { e.preventDefault(); setDragOver(index); } : undefined, onDragLeave: onReorder ? () => setDragOver(null) : undefined, onDrop: onReorder ? (e) => { e.preventDefault(); if (dragIndex.current !== null && dragIndex.current !== index) {
176
+ colloquialLabel: e.colloquialPronunciation, homonum: e.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize })] }, wi))), scoring && renderScore(i, cell.map(e => e.word).join(' / ')), onRemoveCell && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: cell.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveCell(i) }) }))] })) : (onActivateCell && (_jsx("div", { style: counterScale, children: _jsx(Button, { htmlType: "button", size: "48", type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell(i) }) }))) }, i))) })) : (_jsxs(_Fragment, { children: [(items ?? []).map((item, index) => (_jsxs("div", { className: `word-table-block__card${onReorder ? ' word-table-block__card--draggable' : ''}${dragOver === index ? ' word-table-block__card--drag-over' : ''}`, style: dotDashStyle(index, cols, rows, cutLine), draggable: !!onReorder, onDragStart: onReorder ? () => { dragIndex.current = index; } : undefined, onDragOver: onReorder ? (e) => { e.preventDefault(); setDragOver(index); } : undefined, onDragLeave: onReorder ? () => setDragOver(null) : undefined, onDrop: onReorder ? (e) => { e.preventDefault(); if (dragIndex.current !== null && dragIndex.current !== index) {
177
177
  onReorder(dragIndex.current, index);
178
- } ; dragIndex.current = null; setDragOver(null); } : undefined, onDragEnd: onReorder ? () => { dragIndex.current = null; setDragOver(null); } : undefined, children: [item.words.map((w, i) => (_jsxs(Fragment, { children: [i > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: w.label, subLabel: w.subLabel, colloquialLabel: w.colloquialLabel, homonum: w.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize, wordInfo: (w.grade || w.syllables != null || w.pos || w.wordType) ? { grade: w.grade, syllables: w.syllables, pos: w.pos, wordType: w.wordType } : undefined })] }, i))), scoring && renderScore(index, item.words.map(w => w.label).join(' / ')), onRemoveItem && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: item.words.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: item.words.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveItem(item.id) }) }))] }, item.id))), Array.from({ length: Math.max(0, cols * rows - (items?.length ?? 0)) }).map((_, i) => {
178
+ } ; dragIndex.current = null; setDragOver(null); } : undefined, onDragEnd: onReorder ? () => { dragIndex.current = null; setDragOver(null); } : undefined, children: [item.words.map((w, i) => (_jsxs(Fragment, { children: [i > 0 && _jsx("div", { className: "word-table-block__pair-divider" }), _jsx(WordCell, { label: w.label, subLabel: w.subLabel, colloquialLabel: w.colloquialLabel, homonum: w.homonum, showWord: show.word, showImage: show.image, showPronunciation: show.pron, showColloquial: show.colloq, buttonSize: buttonSize, wordInfo: (w.grade || w.syllables != null || w.pos || w.wordType) ? { grade: w.grade, syllables: w.syllables, pos: w.pos, wordType: w.wordType } : undefined })] }, i))), scoring && renderScore(index, item.words.map(w => w.label).join(' / ')), onRemoveItem && (_jsx("div", { className: "word-table-block__card-close", children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "dot", icon: "x", variant: "transparent-lightGray", useBorder: false, showTooltipInfo: item.words.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), ariaLabel: item.words.length > 1 ? t('wordPrint.removePair', '대립쌍 빼기') : t('wordPrint.removeWord', '단어 빼기'), onClick: () => onRemoveItem(item.id) }) }))] }, item.id))), Array.from({ length: Math.max(0, cols * rows - (items?.length ?? 0)) }).map((_, i) => {
179
179
  const isWithinCount = (items?.length ?? 0) + i < cellCount;
180
- return (_jsx("div", { className: "word-table-block__card word-table-block__card--filler", children: showFillerAdd && onActivateCell && isWithinCount && (_jsx("div", { style: counterScale, children: _jsx(Button, { size: buttonSize, type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell((items?.length ?? 0) + i) }) })) }, `filler-${i}`));
180
+ return (_jsx("div", { className: "word-table-block__card word-table-block__card--filler", children: showFillerAdd && onActivateCell && isWithinCount && (_jsx("div", { style: counterScale, children: _jsx(Button, { htmlType: "button", size: buttonSize, type: "bar", icon: "plus", label: t('wordTable.addWord', '추가하기'), onClick: () => onActivateCell((items?.length ?? 0) + i) }) })) }, `filler-${i}`));
181
181
  })] })), pageNumber && pageNumberPosition !== 'none' && (_jsx("span", { className: `word-table__page-number body-3 word-table__page-number--${pageNumberPosition}`, children: t('wordTable.filter.pageIndicator', { current: pageNumber.current, total: pageNumber.total }) }))] }) }) }));
182
182
  if (title != null || titleIcon || disc) {
183
183
  return (_jsx(Section, { type: "page", icon: titleIcon, title: title, disc: disc, className: cx('word-table-block', className), children: grid }));
@@ -85,6 +85,8 @@ export interface BarChartProps {
85
85
  loading?: boolean;
86
86
  /** 지정 시 헤더 우측에 '상세보기' 버튼 표시 → 클릭 콜백 */
87
87
  onDetail?: () => void;
88
+ /** CSS 격자 칸 안에 세운다 — 아래 여백·최소 폭을 풀고 막대를 카드 바닥에 맞춘다. ChartSection `inGrid` 참고. */
89
+ inGrid?: boolean;
88
90
  }
89
91
  /** 현재 컨트롤 선택(주/월)의 날짜 범위 — 상세보기(기간별 리스트)용 */
90
92
  export declare function getControlDateRange(control: BarChartControl): {
@@ -17,6 +17,7 @@ export declare function useComboboxSuggestions({ value, onChange, suggestions, o
17
17
  filtered: string[];
18
18
  handleFocus: () => void;
19
19
  handleBlur: () => void;
20
+ handleInput: () => void;
20
21
  handleKeyDown: (e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
21
22
  handleSelect: (item: string) => void;
22
23
  };
@@ -25,11 +25,17 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
25
25
  setActiveIdx(-1); }, [open]);
26
26
  useEffect(() => () => { if (blurTimerRef.current)
27
27
  clearTimeout(blurTimerRef.current); }, []);
28
+ /* 입력으로 후보가 줄면 가리키던 번호가 목록 밖에 남는다 — 그 채로 Enter 를 누르면 filtered[3] 같은
29
+ 없는 칸을 골라 onChange(undefined) 가 나갔다. 밖이면 '안 가리킴' 으로 본다. */
30
+ const safeIdx = activeIdx < filtered.length ? activeIdx : -1;
28
31
  const handleSelect = useCallback((item) => {
29
32
  onChange?.(item);
30
33
  setOpen(false);
31
34
  }, [onChange]);
32
35
  const handleFocus = useCallback(() => setOpen(true), []);
36
+ /* 글자를 치면 목록을 다시 연다 — 고르거나 Esc 로 닫은 뒤에는 포커스를 뺐다 넣어야만 열렸다.
37
+ 새 글자에는 새 후보가 서므로 가리킴도 처음으로 돌린다. */
38
+ const handleInput = useCallback(() => { setOpen(true); setActiveIdx(-1); }, []);
33
39
  // blur가 리스트 항목의 onMouseDown보다 먼저 발생하는 걸 막기 위한 딜레이
34
40
  const handleBlur = useCallback(() => {
35
41
  blurTimerRef.current = setTimeout(() => setOpen(false), 120);
@@ -38,16 +44,16 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
38
44
  if (e.key === 'ArrowDown') {
39
45
  e.preventDefault();
40
46
  setOpen(true);
41
- setActiveIdx(i => Math.min(filtered.length - 1, i + 1));
47
+ setActiveIdx(i => Math.min(filtered.length - 1, (i < filtered.length ? i : -1) + 1));
42
48
  }
43
49
  else if (e.key === 'ArrowUp') {
44
50
  e.preventDefault();
45
- setActiveIdx(i => Math.max(-1, i - 1));
51
+ setActiveIdx(i => Math.max(-1, (i < filtered.length ? i : filtered.length) - 1));
46
52
  }
47
53
  else if (e.key === 'Enter') {
48
54
  // 한글 조합을 끝내는 Enter는 선택이 아니다 — 조합 중엔 제안을 고르지 않는다
49
- if (activeIdx >= 0 && !e.nativeEvent.isComposing)
50
- handleSelect(filtered[activeIdx]);
55
+ if (safeIdx >= 0 && !e.nativeEvent.isComposing)
56
+ handleSelect(filtered[safeIdx]);
51
57
  }
52
58
  else if (e.key === 'Escape') {
53
59
  if (open) {
@@ -55,6 +61,6 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
55
61
  setOpen(false);
56
62
  } // 제안이 열렸을 때만 소비(상위 팝업 ESC보다 우선하지 않도록)
57
63
  }
58
- }, [filtered, activeIdx, open, handleSelect]);
59
- return { listId, open, activeIdx, filtered, handleFocus, handleBlur, handleKeyDown, handleSelect };
64
+ }, [filtered, safeIdx, open, handleSelect]);
65
+ return { listId, open, activeIdx: safeIdx, filtered, handleFocus, handleBlur, handleInput, handleKeyDown, handleSelect };
60
66
  }
@@ -87,7 +87,14 @@ export function useToastLifecycle({ message, position, duration, onClose }) {
87
87
  사라짐걸기.current(DURATION_MS);
88
88
  }
89
89
  lastMessageRef.current = message;
90
- return () => { };
90
+ /* 여기서 건 사라짐 타이머도 떠날 때 걷는다 — 빈 정리 함수라 문구를 바꾼 뒤 언마운트하면 타이머가 살아
91
+ 떠난 뒤에 onClose 가 불렸다. */
92
+ return () => {
93
+ if (hideTimerRef.current) {
94
+ clearTimeout(hideTimerRef.current);
95
+ hideTimerRef.current = null;
96
+ }
97
+ };
91
98
  }
92
99
  // 처음 등장할 때만 슬라이드인 애니메이션 실행
93
100
  const animateIn = () => {
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { pushEsc } from './escStack';
3
4
  /**
4
5
  * 트리거 요소에 fixed 포지션으로 앵커링되는 메뉴/드롭다운/팝오버의 공용 로직.
5
6
  * 열림상태 + 위치 계산 + 바깥클릭/스크롤/리사이즈 처리를 담당하고, 렌더링(포탈 등)은
@@ -49,23 +50,23 @@ export function useAnchoredMenu({ computeStyle, onScroll = 'close', onResize = '
49
50
  };
50
51
  if (onResize !== 'ignore')
51
52
  window.addEventListener('resize', handleResize);
52
- // ESC로 닫기 — 이 훅을 쓰는 컴포넌트가 자체 키보드 핸들링(예: Dropdown의 화살표 탐색)을
53
- // 따로 두더라도 무해하게 중복 호출될 뿐이므로, 여기서 공통으로 처리해 DatePicker/BottomNav
54
- // 더보기 메뉴처럼 자체 ESC 처리가 없던 소비자도 함께 혜택을 받게 한다.
55
- const handleKeyDown = (e) => {
56
- if (e.key === 'Escape')
57
- setOpen(false);
58
- };
59
- document.addEventListener('keydown', handleKeyDown);
60
53
  return () => {
61
54
  document.removeEventListener('mousedown', handleOutsideClick);
62
55
  if (onScroll !== 'ignore')
63
56
  window.removeEventListener('scroll', handleScroll, true);
64
57
  if (onResize !== 'ignore')
65
58
  window.removeEventListener('resize', handleResize);
66
- document.removeEventListener('keydown', handleKeyDown);
67
59
  };
68
60
  }, [open, recalc, onScroll, onResize, ignoreOutsideClickSelector]);
61
+ /* ESC 로 닫기 — **공유 ESC 스택**에 올린다(Popup 과 같은 스택). 전에는 document 에서 따로 들어,
62
+ 팝업 안의 DatePicker 에서 Esc 를 누르면 달력과 팝업이 함께 닫혔고(스택은 이 리스너를 모른다),
63
+ 달력 안 연월 Dropdown 의 Esc 는 달력까지 닫았다. 스택은 맨 위 하나만 닫는다 — 나중에 연 메뉴가 위다.
64
+ DatePicker·BottomNav 더보기처럼 자체 ESC 처리가 없는 쓰임도 그대로 닫힌다. */
65
+ useEffect(() => {
66
+ if (!open)
67
+ return;
68
+ return pushEsc(() => setOpen(false));
69
+ }, [open]);
69
70
  const toggle = useCallback(() => setOpen(o => !o), []);
70
71
  const close = useCallback(() => setOpen(false), []);
71
72
  return { open, setOpen, toggle, close, style, triggerRef, menuRef };
@@ -7,16 +7,5 @@
7
7
  * @public
8
8
  */
9
9
  export declare const DocumentTitleSuppressContext: import("react").Context<boolean>;
10
- /**
11
- * 현재 화면의 제목을 `document.title`에 반영한다 — `'<제목> · <앱 이름>'`.
12
- *
13
- * SPA는 라우트를 옮겨도 문서 제목이 그대로라, 브라우저 탭·히스토리·북마크로 위치를
14
- * 구분할 수 없고 화면낭독기도 이동을 인지하지 못한다(WCAG 2.4.2).
15
- * 언마운트하면 이전 제목으로 되돌린다.
16
- *
17
- * @param title 페이지 제목. 비어 있거나 문자열이 아니면 아무것도 하지 않는다.
18
- *
19
- * @public
20
- */
21
10
  export declare function useDocumentTitle(title?: string | null): void;
22
11
  export default useDocumentTitle;
@@ -34,6 +34,11 @@ export const DocumentTitleSuppressContext = createContext(false);
34
34
  *
35
35
  * @public
36
36
  */
37
+ /* 제목을 건 화면들 — 나중에 건 것이 위. 떠날 때 **남은 맨 위 제목**으로 돌린다.
38
+ 전에는 저마다 건 순간의 제목(prev)을 기억했다가 되돌려, A 가 떠 있는 동안 B 가 붙고 A 가 나중에 떠나면
39
+ A 의 prev 로 돌아가 B 의 제목이 지워졌다(말귀 CustomersPage 가 겪고 우회해 둠). */
40
+ const 걸린제목 = [];
41
+ let 처음제목 = null;
37
42
  export function useDocumentTitle(title) {
38
43
  const suppressed = useContext(DocumentTitleSuppressContext);
39
44
  useEffect(() => {
@@ -41,9 +46,17 @@ export function useDocumentTitle(title) {
41
46
  return;
42
47
  if (typeof title !== 'string' || !title)
43
48
  return;
44
- const prev = document.title;
45
- document.title = BASE_TITLE ? `${title}${SEP}${BASE_TITLE}` : title;
46
- return () => { document.title = prev; };
49
+ if (걸린제목.length === 0)
50
+ 처음제목 = document.title;
51
+ const 칸 = { text: BASE_TITLE ? `${title}${SEP}${BASE_TITLE}` : title };
52
+ 걸린제목.push(칸);
53
+ document.title = 칸.text;
54
+ return () => {
55
+ const i = 걸린제목.lastIndexOf(칸);
56
+ if (i >= 0)
57
+ 걸린제목.splice(i, 1);
58
+ document.title = 걸린제목.length ? 걸린제목[걸린제목.length - 1].text : (처음제목 ?? document.title);
59
+ };
47
60
  }, [title, suppressed]);
48
61
  }
49
62
  export default useDocumentTitle;
@@ -37,11 +37,15 @@ export function useFocusTrap(panelRef, enabled = true) {
37
37
  }
38
38
  const first = focusables[0];
39
39
  const last = focusables[focusables.length - 1];
40
- if (e.shiftKey && document.activeElement === first) {
40
+ const active = document.activeElement;
41
+ /* 포커스가 **판 자체**(tabIndex=-1 — 본문 글자를 누르면 판이 받는다)에 있으면 첫 칸·끝 칸 어느 쪽도 아니라
42
+ 되감지 않아, Shift+Tab 한 번에 뒤 화면으로 나갔다. 판 자체면 Shift+Tab 은 끝 칸으로 보낸다. */
43
+ const 판자체 = active === panel;
44
+ if (e.shiftKey && (active === first || 판자체)) {
41
45
  e.preventDefault();
42
46
  last.focus();
43
47
  }
44
- else if (!e.shiftKey && document.activeElement === last) {
48
+ else if (!e.shiftKey && active === last) {
45
49
  e.preventDefault();
46
50
  first.focus();
47
51
  }
@@ -14,9 +14,12 @@ import { getErrorMessage } from './errorMessage';
14
14
  // 컴포넌트 상태를 참조하지 않는 순수 계산이라 모듈 스코프로 분리 — 매 렌더 재생성되지 않는다.
15
15
  /** 내려받은 바이트를 디코딩해 봉우리를 센다 — 아래 `computePeaksFromUrl` 만 쓴다. */
16
16
  async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds) {
17
+ /* 컨텍스트는 **어느 길로 나가든** 닫는다 — 디코딩 실패(404·만료 주소의 HTML)·채널 없음에서 닫지 않아
18
+ 파형 하나 실패할 때마다 AudioContext 가 하나씩 남았다(브라우저 동시 한도에 닿으면 이후 파형이 모두 실패). */
19
+ let audioCtx = null;
17
20
  try {
18
21
  const AudioCtxConstructor = window.AudioContext || window.webkitAudioContext;
19
- const audioCtx = new AudioCtxConstructor();
22
+ audioCtx = new AudioCtxConstructor();
20
23
  const decoded = await audioCtx.decodeAudioData(arrayBuffer.slice(0));
21
24
  const channelData = decoded.numberOfChannels > 0 ? decoded.getChannelData(0) : null;
22
25
  if (!channelData)
@@ -37,21 +40,26 @@ async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds) {
37
40
  }
38
41
  return max;
39
42
  });
40
- try {
41
- audioCtx.close && audioCtx.close();
42
- }
43
- catch (e) { }
44
43
  // 절대 진폭(0~1, full-scale 기준) 반환 — 문장/소음이 같은 기준을 쓰게 해 dBFS 미터와 일치.
45
44
  return peaks;
46
45
  }
47
46
  catch (e) {
48
47
  return null;
49
48
  }
49
+ finally {
50
+ try {
51
+ void audioCtx?.close?.();
52
+ }
53
+ catch (e) { }
54
+ }
50
55
  }
51
56
  /** 주소에서 소리를 받아 파형 막대 높이 배열을 낸다. 실패하면 null. @public */
52
57
  export async function computePeaksFromUrl(url, bars, windowSeconds) {
53
58
  try {
54
59
  const resp = await fetch(url);
60
+ // 404·만료 주소는 소리가 아니다 — 디코딩까지 가지 않는다
61
+ if (resp.ok === false)
62
+ return null;
55
63
  const arrayBuffer = await resp.arrayBuffer();
56
64
  return await decodeAndComputePeaks(arrayBuffer, bars, windowSeconds);
57
65
  }