@ihabdevteam/core 0.132.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 (71) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/components.cjs.js +178 -178
  3. package/dist/hooks.cjs.js +1 -1
  4. package/dist/i18n.cjs.js +1 -1
  5. package/dist/ihabdevteam-core.cjs.js +182 -182
  6. package/dist/ihabdevteam-core.d.ts +1 -12
  7. package/dist/packages/core/src/components/BarChart.js +2 -2
  8. package/dist/packages/core/src/components/BottomNav.js +3 -3
  9. package/dist/packages/core/src/components/BulkBar.js +1 -1
  10. package/dist/packages/core/src/components/ChoiceChips.js +1 -1
  11. package/dist/packages/core/src/components/CodeBox.js +1 -1
  12. package/dist/packages/core/src/components/DatePicker.js +16 -3
  13. package/dist/packages/core/src/components/Dropdown.js +13 -3
  14. package/dist/packages/core/src/components/FileUpload.js +8 -7
  15. package/dist/packages/core/src/components/FilterPanel.js +8 -5
  16. package/dist/packages/core/src/components/FilterSectionInfo.js +1 -1
  17. package/dist/packages/core/src/components/Header.js +1 -1
  18. package/dist/packages/core/src/components/HearingDeviceInfo.js +1 -1
  19. package/dist/packages/core/src/components/PageTitle.js +1 -1
  20. package/dist/packages/core/src/components/Pagination.js +1 -1
  21. package/dist/packages/core/src/components/PanelRow.js +3 -3
  22. package/dist/packages/core/src/components/PaymentPlan.js +1 -1
  23. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +2 -2
  24. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +2 -2
  25. package/dist/packages/core/src/components/Popup/Popup.js +15 -3
  26. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +1 -1
  27. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +1 -1
  28. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +3 -3
  29. package/dist/packages/core/src/components/SegmentedControl.js +1 -1
  30. package/dist/packages/core/src/components/SideNav.js +1 -1
  31. package/dist/packages/core/src/components/Table.js +2 -2
  32. package/dist/packages/core/src/components/Tabs.js +7 -2
  33. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +1 -1
  34. package/dist/packages/core/src/components/Tooltip/TextBoxTooltip.js +1 -1
  35. package/dist/packages/core/src/components/Tooltip/Tooltip.js +13 -5
  36. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +1 -1
  37. package/dist/packages/core/src/components/Training/TrainingBlock.js +1 -1
  38. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +1 -1
  39. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -1
  40. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +51 -23
  41. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +1 -1
  42. package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +1 -1
  43. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +26 -5
  44. package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +1 -1
  45. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +2 -2
  46. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +1 -1
  47. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
  48. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +1 -1
  49. package/dist/packages/core/src/components/Training/TrainingWordCard.js +11 -6
  50. package/dist/packages/core/src/components/Training/useAudioPreview.js +7 -3
  51. package/dist/packages/core/src/components/UpdateAlert.js +1 -1
  52. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +2 -2
  53. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -3
  54. package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
  55. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  56. package/dist/packages/core/src/components/WordTable.js +6 -6
  57. package/dist/packages/core/src/components/useToastLifecycle.js +8 -1
  58. package/dist/packages/core/src/hooks/useAnchoredMenu.js +10 -9
  59. package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +0 -11
  60. package/dist/packages/core/src/hooks/useDocumentTitle.js +16 -3
  61. package/dist/packages/core/src/hooks/useFocusTrap.js +6 -2
  62. package/dist/packages/core/src/utils/audioPeaks.js +13 -5
  63. package/dist/packages/core/src/utils/csv.js +3 -1
  64. package/dist/packages/core/src/utils/koreanSyllable.js +2 -1
  65. package/dist/packages/core/src/utils/textSort.d.ts +0 -10
  66. package/dist/packages/core/src/utils/textSort.js +7 -2
  67. package/dist/packages/core/src/utils/wordTrainingRuntime.js +28 -4
  68. package/dist/packages/core/src/version.d.ts +1 -1
  69. package/dist/packages/core/src/version.js +1 -1
  70. package/dist/utils.cjs.js +1 -1
  71. package/package.json +1 -1
@@ -292,12 +292,12 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
292
292
  return (_jsx("div", { className: `table-wrap table-wrap--${size}${폭조절 ? ' table-wrap--resizable' : ''}${dragOver ? ' table-wrap--dragover' : ''}${collapsed ? ' table-wrap--collapsed' : ''}`, style: gridStyle, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, children: _jsxs("div", { className: "table body-2", children: [hasHeader && (_jsxs("div", { className: "table-header", onClick: onToggleCollapse, role: onToggleCollapse ? 'button' : undefined, tabIndex: onToggleCollapse ? 0 : undefined, "aria-expanded": onToggleCollapse ? !collapsed : undefined, onKeyDown: onToggleCollapse ? (e) => { if (e.key === 'Enter' || e.key === ' ') {
293
293
  e.preventDefault();
294
294
  onToggleCollapse();
295
- } } : undefined, children: [onToggleCollapse && (_jsx(Button, { size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-right' : 'chevron-down', ariaLabel: collapsed ? t('common.expand', '펼치기') : t('common.collapse', '접기'), className: "table-header__chevron" })), icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "table-header__icon" })), _jsx("span", { className: "table-header__title h5b", children: title }), count !== undefined && _jsx("span", { className: "table-header__count body-3b", children: count }), headerActions && (_jsx("span", { className: "table-header__actions", onClick: e => e.stopPropagation(), children: headerActions }))] })), !collapsed && (_jsxs(_Fragment, { children: [headerGroups && headerGroups.length > 0 && (_jsx("div", { className: "table-fields table-fields--groups", children: headerGroups.map((g, i) => (_jsx("div", { className: cx('table-field body-3', g.className), style: { gridColumn: `span ${g.span}` }, children: g.label }, i))) })), _jsxs("div", { className: cx('table-fields-strip', 폭조절 && 'table-fields-strip--resizable'), children: [_jsx("div", { className: "table-fields", ref: fieldsRef, children: fields.map((f, i) => (_jsx(TableField, { onClick: f.onClick, className: f.className, sortDir: f.sortDir,
295
+ } } : undefined, children: [onToggleCollapse && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-right' : 'chevron-down', ariaLabel: collapsed ? t('common.expand', '펼치기') : t('common.collapse', '접기'), className: "table-header__chevron" })), icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "table-header__icon" })), _jsx("span", { className: "table-header__title h5b", children: title }), count !== undefined && _jsx("span", { className: "table-header__count body-3b", children: count }), headerActions && (_jsx("span", { className: "table-header__actions", onClick: e => e.stopPropagation(), children: headerActions }))] })), !collapsed && (_jsxs(_Fragment, { children: [headerGroups && headerGroups.length > 0 && (_jsx("div", { className: "table-fields table-fields--groups", children: headerGroups.map((g, i) => (_jsx("div", { className: cx('table-field body-3', g.className), style: { gridColumn: `span ${g.span}` }, children: g.label }, i))) })), _jsxs("div", { className: cx('table-fields-strip', 폭조절 && 'table-fields-strip--resizable'), children: [_jsx("div", { className: "table-fields", ref: fieldsRef, children: fields.map((f, i) => (_jsx(TableField, { onClick: f.onClick, className: f.className, sortDir: f.sortDir,
296
296
  /* fit 모드는 오른쪽 이웃에서 폭을 덜어 오므로, 덜어 올 컬럼이 없는 자리에는
297
297
  손잡이를 두지 않는다 — 두면 그 컬럼만 grow처럼 넓어져 표가 밀린다. */
298
298
  onResizeStart: 폭조절 && i < fields.length - 1 && f.resizable !== false
299
299
  && (resizeMode !== 'fit' || fields.some((g, j) => j > i && g.resizable !== false))
300
- ? (e) => startResize(i, e) : undefined, children: f.label }, i))) }), 폭조절 && (_jsx(Button, { size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: "chevrons-left-right-ellipsis", ariaLabel: t('table.resetColumns', '칸 너비 초기화'), showTooltipInfo: t('table.resetColumns', '칸 너비 초기화'), className: "table-fields__reset", onClick: 자동맞춤 }))] }), _jsx("div", { className: "table-body", children: loading ? (_jsx(TableRow, { variant: "loading" })) : items.length === 0 && empty ? (_jsx(TableRow, { variant: "empty", children: empty })) : items.map((item, idx) => {
300
+ ? (e) => startResize(i, e) : undefined, children: f.label }, i))) }), 폭조절 && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: "chevrons-left-right-ellipsis", ariaLabel: t('table.resetColumns', '칸 너비 초기화'), showTooltipInfo: t('table.resetColumns', '칸 너비 초기화'), className: "table-fields__reset", onClick: 자동맞춤 }))] }), _jsx("div", { className: "table-body", children: loading ? (_jsx(TableRow, { variant: "loading" })) : items.length === 0 && empty ? (_jsx(TableRow, { variant: "empty", children: empty })) : items.map((item, idx) => {
301
301
  const isDragging = draggingId != null && item.id === draggingId;
302
302
  const 눌림 = !!onRowClick && (isRowClickable ? isRowClickable(item) : true);
303
303
  const isExpanded = expandedId != null && (expandedId instanceof Set ? expandedId.has(item.id) : item.id === expandedId);
@@ -24,7 +24,12 @@ export default function Tabs({ items = [], value, onChange, className = '', size
24
24
  const uid = useId();
25
25
  const { t } = useTranslation();
26
26
  const listRef = useRef(null);
27
- const activeKey = value ?? items[0]?.key;
27
+ /* value 가 없거나 items 에 없으면 **켜진 첫 탭**을 고른 것으로 본다. 전에는 무조건 첫 탭이라, 첫 탭이 꺼져 있으면
28
+ 꺼진 탭의 판이 보이고 Tab 자리(tabIndex 0)가 꺼진 탭 하나뿐이어서 키보드로 탭 목록에 닿을 수 없었다. */
29
+ const 켜진첫키 = items.find(item => !item.disabled)?.key;
30
+ const activeKey = items.some(item => item.key === value) ? value : (켜진첫키 ?? items[0]?.key);
31
+ /* Tab 자리 — 고른 탭이 꺼져 있으면(부모가 꺼진 탭을 value 로 줌) 켜진 첫 탭이 받는다 */
32
+ const 탭자리키 = items.find(item => item.key === activeKey && !item.disabled)?.key ?? 켜진첫키;
28
33
  /** 지금 화면에 선 **켜진 탭들** — 옮기는 자리는 전부 이 목록 기준이다 */
29
34
  const 켜진탭들 = () => Array.from(listRef.current?.querySelectorAll('[role="tab"]:not(:disabled)') ?? []);
30
35
  const focusTabAt = (index, tabs = 켜진탭들()) => {
@@ -58,5 +63,5 @@ export default function Tabs({ items = [], value, onChange, className = '', size
58
63
  focusTabAt(tabs.length - 1, tabs);
59
64
  };
60
65
  const activeItem = items.find(item => item.key === activeKey);
61
- return (_jsxs("div", { className: `tabs ${className}`.trim(), children: [_jsx("div", { ref: listRef, className: `tabs__list tabs__list--${size}`, role: "tablist", "aria-label": t('tabs.ariaLabel', '탭'), children: items.map(item => (_jsx("button", { id: `tabs__tab-${uid}-${item.key}`, type: "button", role: "tab", "aria-selected": item.key === activeKey, "aria-controls": `tabs__panel-${uid}-${item.key}`, disabled: item.disabled, tabIndex: item.key === activeKey ? 0 : -1, className: `tabs__tab body-2${item.key === activeKey ? ' tabs__tab--active' : ''}`, onClick: () => onChange?.(item.key), onKeyDown: handleKeyDown, children: item.label }, item.key))) }), activeItem && (_jsx("div", { id: `tabs__panel-${uid}-${activeItem.key}`, role: "tabpanel", "aria-labelledby": `tabs__tab-${uid}-${activeItem.key}`, tabIndex: 0, className: "tabs__panel", children: activeItem.panel }, activeItem.key))] }));
66
+ return (_jsxs("div", { className: `tabs ${className}`.trim(), children: [_jsx("div", { ref: listRef, className: `tabs__list tabs__list--${size}`, role: "tablist", "aria-label": t('tabs.ariaLabel', '탭'), children: items.map(item => (_jsx("button", { id: `tabs__tab-${uid}-${item.key}`, type: "button", role: "tab", "aria-selected": item.key === activeKey, "aria-controls": `tabs__panel-${uid}-${item.key}`, disabled: item.disabled, tabIndex: item.key === 탭자리키 ? 0 : -1, className: `tabs__tab body-2${item.key === activeKey ? ' tabs__tab--active' : ''}`, onClick: () => onChange?.(item.key), onKeyDown: handleKeyDown, children: item.label }, item.key))) }), activeItem && (_jsx("div", { id: `tabs__panel-${uid}-${activeItem.key}`, role: "tabpanel", "aria-labelledby": `tabs__tab-${uid}-${activeItem.key}`, tabIndex: 0, className: "tabs__panel", children: activeItem.panel }, activeItem.key))] }));
62
67
  }
@@ -84,7 +84,7 @@ export default function MenuTooltip({ columns = [], onClose, className = '', siz
84
84
  }
85
85
  catch (err) { }
86
86
  }, [onClose]);
87
- return (_jsxs("div", { className: `tooltip-menu tooltip-menu--${size} ${className}`, children: [(stack.length > 1 || current.title) && (_jsxs("div", { className: "tooltip-menu__header", children: [stack.length > 1 ? (_jsx(Button, { size: itemSize, type: "dot", icon: "chevron-left", ariaLabel: t('common.back', '뒤로'), onClick: goBack, variant: "transparent-gray", useBorder: false })) : null, _jsx("div", { className: "tooltip-menu__title button-1", children: current.title })] })), _jsx("div", { className: "tooltip-menu__columns", ref: itemsWrapRef, children: current.columns && current.columns.map((col, ci) => (_jsxs("div", { className: "tooltip-menu__column", children: [col.title ? _jsx("div", { className: "tooltip-menu__column-title body-3", children: col.title }) : null, _jsx("div", { className: "tooltip-menu__items", children: (col.items || []).map((item, idx) => (_jsx(Button, { className: "tooltip-menu__item", size: itemSize, type: item.label ? 'bar' : 'dot', icon: item.selected ? 'check' : (item.icon || 'none'), rightIcon: Array.isArray(item.children) && item.children.length
87
+ return (_jsxs("div", { className: `tooltip-menu tooltip-menu--${size} ${className}`, children: [(stack.length > 1 || current.title) && (_jsxs("div", { className: "tooltip-menu__header", children: [stack.length > 1 ? (_jsx(Button, { htmlType: "button", size: itemSize, type: "dot", icon: "chevron-left", ariaLabel: t('common.back', '뒤로'), onClick: goBack, variant: "transparent-gray", useBorder: false })) : null, _jsx("div", { className: "tooltip-menu__title button-1", children: current.title })] })), _jsx("div", { className: "tooltip-menu__columns", ref: itemsWrapRef, children: current.columns && current.columns.map((col, ci) => (_jsxs("div", { className: "tooltip-menu__column", children: [col.title ? _jsx("div", { className: "tooltip-menu__column-title body-3", children: col.title }) : null, _jsx("div", { className: "tooltip-menu__items", children: (col.items || []).map((item, idx) => (_jsx(Button, { htmlType: "button", className: "tooltip-menu__item", size: itemSize, type: item.label ? 'bar' : 'dot', icon: item.selected ? 'check' : (item.icon || 'none'), rightIcon: Array.isArray(item.children) && item.children.length
88
88
  ? 'chevron-right'
89
89
  : item.badgeCount
90
90
  ? _jsx(Badge, { type: "dot", position: "inline", number: Math.min(item.badgeCount, 99), size: 24, "aria-label": `${item.badgeCount}` })
@@ -47,7 +47,7 @@ export default function TextBoxTooltip(props) {
47
47
  '--tt-line-height': `${lineHeight}px`,
48
48
  '--tt-marker-size': '28px',
49
49
  };
50
- const inner = (_jsxs("div", { className: `tooltip-textbox__root ${theme === 'dark' ? 'is-dark' : ''}`, children: [_jsxs("div", { className: "tooltip-textbox__box", children: [label && _jsx("div", { className: "tooltip-textbox__label body-2b", children: label }), _jsx("div", { className: "tooltip-textbox__text body-1", style: textStyle, children: lines.map((ln, idx) => (_jsxs("div", { className: "tooltip-textbox__line", children: [lines.length > 1 ? (_jsx("div", { className: "tooltip-textbox__marker", children: _jsx("span", { className: "tooltip-textbox__line-number button-1", children: idx + 1 }) })) : null, _jsx("span", { className: "tooltip-textbox__line-text", children: ln })] }, idx))) })] }), _jsxs("div", { className: "tooltip-textbox__buttons", children: [_jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: 'minus', onClick: decrease, ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: 'plus', onClick: increase, ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease }), _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(Button, { size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: 'x', onClick: handleClose, ariaLabel: t('common.close', '닫기'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })] }));
50
+ const inner = (_jsxs("div", { className: `tooltip-textbox__root ${theme === 'dark' ? 'is-dark' : ''}`, children: [_jsxs("div", { className: "tooltip-textbox__box", children: [label && _jsx("div", { className: "tooltip-textbox__label body-2b", children: label }), _jsx("div", { className: "tooltip-textbox__text body-1", style: textStyle, children: lines.map((ln, idx) => (_jsxs("div", { className: "tooltip-textbox__line", children: [lines.length > 1 ? (_jsx("div", { className: "tooltip-textbox__marker", children: _jsx("span", { className: "tooltip-textbox__line-number button-1", children: idx + 1 }) })) : null, _jsx("span", { className: "tooltip-textbox__line-text", children: ln })] }, idx))) })] }), _jsxs("div", { className: "tooltip-textbox__buttons", children: [_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: 'minus', onClick: decrease, ariaLabel: t('training.fontSizeDecrease', '글자 크기 감소'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canDecrease }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: 'plus', onClick: increase, ariaLabel: t('training.fontSizeIncrease', '글자 크기 증가'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray', disabled: !canIncrease }), _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(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", useBorder: false, width: 48, icon: 'x', onClick: handleClose, ariaLabel: t('common.close', '닫기'), variant: theme === 'dark' ? 'transparent-white' : 'transparent-gray' })] })] }));
51
51
  if (anchorRect) {
52
52
  return (_jsx(Tooltip, { anchorRect: anchorRect, className: `tooltip-textbox ${theme === 'dark' ? 'is-dark' : ''}`, onClose: onClose, closeOnOutsideClick: closeOnOutsideClick, isMobilePopup: isMobilePopup,
53
53
  /* 이름 없는 `role="dialog"` 는 "대화상자" 로만 읽힌다 — 보이는 라벨을 쓴다. */
@@ -4,6 +4,7 @@ import { useCallback, useEffect, useRef, useState, useLayoutEffect } from 'react
4
4
  import { createPortal } from 'react-dom';
5
5
  import './Tooltip.css';
6
6
  import { FOCUSABLE_SELECTOR } from '../../hooks/useFocusTrap';
7
+ import { pushEsc } from '../../hooks/escStack';
7
8
  /** @public */
8
9
  export default function Tooltip(props) {
9
10
  const { anchorRect, anchorEl, offset = 20, edgeMargin = 20, onClose, closeOnOutsideClick = true, className = '', role = 'dialog', ariaLabel = '', children, backdrop = false, isMobilePopup = false, useBorder = true, header, } = props || {};
@@ -242,11 +243,6 @@ export default function Tooltip(props) {
242
243
  }
243
244
  };
244
245
  const handleKeyDown = (e) => {
245
- if (e.key === 'Escape') {
246
- e.stopPropagation();
247
- startClose();
248
- return;
249
- }
250
246
  if (e.key === 'Tab') {
251
247
  const el = ref.current;
252
248
  if (!el)
@@ -285,6 +281,18 @@ export default function Tooltip(props) {
285
281
  document.removeEventListener('keydown', handleKeyDown);
286
282
  };
287
283
  }, [onClose, closeOnOutsideClick, startClose, isInline]);
284
+ /* ESC — **공유 ESC 스택**에 올린다. 전에는 document 에서 직접 들었는데, 스택(Popup)도 같은 document
285
+ 리스너라 stopPropagation 이 서로를 못 막아 팝업 안 툴팁에서 Esc 한 번에 툴팁과 팝업이 함께 닫혔다.
286
+ startClose 는 closing 이 바뀔 때마다 새로 만들어지므로 ref 로 부른다 — 다시 올리면 스택 차례가
287
+ 뒤섞인다. 닫을 곳(onClose)이 없는 툴팁은 올리지 않는다(올리면 Esc 를 삼켜 아래 팝업이 안 닫힌다). */
288
+ const startCloseRef = useRef(startClose);
289
+ useEffect(() => { startCloseRef.current = startClose; });
290
+ const hasOnClose = !!onClose;
291
+ useEffect(() => {
292
+ if (isInline || !hasOnClose)
293
+ return;
294
+ return pushEsc(() => startCloseRef.current());
295
+ }, [isInline, hasOnClose]);
288
296
  // Allow external callers to force-close all tooltips by dispatching
289
297
  // a `speechear-close-tooltips` CustomEvent on `document`.
290
298
  useEffect(() => {
@@ -74,7 +74,7 @@ export default function NoiseSettingsTooltip({ anchorRect, onClose }) {
74
74
  }
75
75
  catch (e) { }
76
76
  }, [noisePlayer]);
77
- return (_jsx(Tooltip, { anchorRect: anchorRect, offset: 12, onClose: onClose, ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), isMobilePopup: false, closeOnOutsideClick: false, header: _jsxs("div", { className: "noise-settings__header", children: [_jsx("span", { children: t('training.trainingSettingsTitle', '훈련 설정') }), _jsx(Button, { size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, ariaLabel: t('common.close', '닫기'), onClick: onClose })] }), children: _jsxs(Panel, { className: "noise-settings", useBorder: false, labelWidth: 72, children: [_jsx(SliderPanelRow, { title: t('training.playVolumeTitle', '전체 소리'), valueLabel: `${volPct}%`, value: volPct, min: 0, max: 100, step: 10, size: sliderSize, onChange: handlePlayVolumeChange }), _jsx(SliderPanelRow, { title: t('training.playSpeedTitle', '재생 속도'), valueLabel: `${playbackRate.toFixed(2)}x`, value: playbackRate, min: PLAY_SPEED_MIN, max: PLAY_SPEED_MAX, step: PLAY_SPEED_STEP, size: sliderSize, onChange: handlePlaySpeedChange }), _jsx(PanelRow, { variant: "custom", label: t('training.noiseTypeTitle', '소음 종류'), useFullWidth: true, children: _jsx(SegmentedControl, { ariaLabel: t('training.noiseTypeTitle', '소음 종류'), size: 40, shape: "rounded", options: [
77
+ return (_jsx(Tooltip, { anchorRect: anchorRect, offset: 12, onClose: onClose, ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), isMobilePopup: false, closeOnOutsideClick: false, header: _jsxs("div", { className: "noise-settings__header", children: [_jsx("span", { children: t('training.trainingSettingsTitle', '훈련 설정') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, ariaLabel: t('common.close', '닫기'), onClick: onClose })] }), children: _jsxs(Panel, { className: "noise-settings", useBorder: false, labelWidth: 72, children: [_jsx(SliderPanelRow, { title: t('training.playVolumeTitle', '전체 소리'), valueLabel: `${volPct}%`, value: volPct, min: 0, max: 100, step: 10, size: sliderSize, onChange: handlePlayVolumeChange }), _jsx(SliderPanelRow, { title: t('training.playSpeedTitle', '재생 속도'), valueLabel: `${playbackRate.toFixed(2)}x`, value: playbackRate, min: PLAY_SPEED_MIN, max: PLAY_SPEED_MAX, step: PLAY_SPEED_STEP, size: sliderSize, onChange: handlePlaySpeedChange }), _jsx(PanelRow, { variant: "custom", label: t('training.noiseTypeTitle', '소음 종류'), useFullWidth: true, children: _jsx(SegmentedControl, { ariaLabel: t('training.noiseTypeTitle', '소음 종류'), size: 40, shape: "rounded", options: [
78
78
  { key: NONE_KEY, label: t('training.noise.type.none', '없음') },
79
79
  ...getNoiseTypeOptions(t).map(opt => ({ key: opt.value, label: opt.label })),
80
80
  ], value: noiseEnabled ? noiseType : NONE_KEY, onChange: handleTypeChange }) }), _jsx(SliderPanelRow, { title: t('training.noiseRatioTitle', '소음 비율'), valueLabel: t('training.noiseRatioValue', '{{ratio}}배', { ratio: ratio.toFixed(1) }), value: ratio, min: 0, max: NOISE_RATIO_MAX, step: NOISE_RATIO_STEP, size: sliderSize, onChange: handleRatioChange })] }) }));
@@ -40,5 +40,5 @@ export default function TrainingBlock(props) {
40
40
  : undefined, onMore: typeof onMore === 'function'
41
41
  ? (e) => onMore(itemForBtn, e)
42
42
  : undefined }, itemForBtn?.id != null ? `${type?.key || 't'}-${String(itemForBtn.id)}` : `${type?.key || 't'}-${idx}`));
43
- }) }), total > BTN_COUNT_MAX && visible < total && (_jsx("div", { className: "training-block__more", children: _jsx(Button, { size: 48, type: "bar", icon: "plus", onClick: loadMore, ariaLabel: t('common.loadMore', '더보기'), label: t('common.loadMore', '더보기'), variant: "main" }) }))] }));
43
+ }) }), total > BTN_COUNT_MAX && visible < total && (_jsx("div", { className: "training-block__more", children: _jsx(Button, { htmlType: "button", size: 48, type: "bar", icon: "plus", onClick: loadMore, ariaLabel: t('common.loadMore', '더보기'), label: t('common.loadMore', '더보기'), variant: "main" }) }))] }));
44
44
  }
@@ -178,7 +178,7 @@ export default function TrainingBlockButton(props) {
178
178
  const countText = countLabelOverride !== undefined ? countLabelOverride : countLabel;
179
179
  // 정해진 문항수가 없으면(빈 값) 카운트 요소 자체를 그리지 않는다.
180
180
  return countText ? (_jsx("div", { className: `training-block-button__count ${isSmall ? 'caption-2' : 'caption-1'}`, children: countText })) : null;
181
- })(), _jsx("div", { className: "training-block-button__chevron", children: _jsx(Icon, { icon: "chevron-right", label: t('training.next', '다음'), className: "training-block-button__chevron-icon", imgClassName: "training-block-button__chevron-icon-image", maskClassName: "training-block-button__chevron-icon-mask", svgClassName: "training-block-button__chevron-icon-svg", size: isSmall ? 12 : 20 }) })] }), (isList || isSmall) && previewUrls.length > 0 && (_jsx("div", { ref: previewBtnRef, className: "training-block-button__preview", "data-tooltip-ignore": true, onClick: handlePreviewAreaClick, onPointerEnter: handlePreviewHoverOn, onPointerLeave: handlePreviewHoverClose, children: _jsx(Button, { size: isSmall ? 28 : 32, type: "dot", icon: playing ? 'pause' : 'play', variant: isSmall ? 'transparent-lightGray' : 'default', useBorder: false, selected: playing, ariaLabel: playing ? t('training.stop', '정지') : t('training.preview', '미리 듣기'), onClick: handlePreview }) }))] }), onMore && (_jsx("div", { className: "training-block-button__more", "data-tooltip-ignore": true, onClick: e => { e.stopPropagation(); onMore(e); }, children: _jsx(Button, { size: 40, type: "dot", icon: "more-horizontal", variant: "transparent-lightGray", useBorder: false, ariaLabel: t('common.more', '더보기') }) }))] }), previewHover && (_jsx(InfoTooltip, { anchorRect: previewAnchorRect, text: playing ? t('training.stop', '정지') : t('training.preview', '미리 듣기'), offset: 8, onClose: handlePreviewHoverClose })), btnHover && !open && useDescriptionTooltip && (tooltipItems ? (_jsx(DescriptionTooltip, { anchorRect: btnAnchorRect, items: tooltipItems, activeIndex: playingIndex, onClose: handleBtnHoverClose })) : topic?.subTitle ? (_jsx(InfoTooltip, { anchorRect: btnAnchorRect, text: topic.subTitle, offset: 8, onClose: handleBtnHoverClose })) : null), open && renderPopup && renderPopup({
181
+ })(), _jsx("div", { className: "training-block-button__chevron", children: _jsx(Icon, { icon: "chevron-right", label: t('training.next', '다음'), className: "training-block-button__chevron-icon", imgClassName: "training-block-button__chevron-icon-image", maskClassName: "training-block-button__chevron-icon-mask", svgClassName: "training-block-button__chevron-icon-svg", size: isSmall ? 12 : 20 }) })] }), (isList || isSmall) && previewUrls.length > 0 && (_jsx("div", { ref: previewBtnRef, className: "training-block-button__preview", "data-tooltip-ignore": true, onClick: handlePreviewAreaClick, onPointerEnter: handlePreviewHoverOn, onPointerLeave: handlePreviewHoverClose, children: _jsx(Button, { htmlType: "button", size: isSmall ? 28 : 32, type: "dot", icon: playing ? 'pause' : 'play', variant: isSmall ? 'transparent-lightGray' : 'default', useBorder: false, selected: playing, ariaLabel: playing ? t('training.stop', '정지') : t('training.preview', '미리 듣기'), onClick: handlePreview }) }))] }), onMore && (_jsx("div", { className: "training-block-button__more", "data-tooltip-ignore": true, onClick: e => { e.stopPropagation(); onMore(e); }, children: _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "more-horizontal", variant: "transparent-lightGray", useBorder: false, ariaLabel: t('common.more', '더보기') }) }))] }), previewHover && (_jsx(InfoTooltip, { anchorRect: previewAnchorRect, text: playing ? t('training.stop', '정지') : t('training.preview', '미리 듣기'), offset: 8, onClose: handlePreviewHoverClose })), btnHover && !open && useDescriptionTooltip && (tooltipItems ? (_jsx(DescriptionTooltip, { anchorRect: btnAnchorRect, items: tooltipItems, activeIndex: playingIndex, onClose: handleBtnHoverClose })) : topic?.subTitle ? (_jsx(InfoTooltip, { anchorRect: btnAnchorRect, text: topic.subTitle, offset: 8, onClose: handleBtnHoverClose })) : null), open && renderPopup && renderPopup({
182
182
  trainingData: topic,
183
183
  typeKey: type?.key ?? '',
184
184
  onClose: handleClose,
@@ -10,7 +10,7 @@ export default function TrainingCardGroup({ title, count, collapsed = false, onT
10
10
  /* 접을 수 있는가 — `onToggle` 유무가 그것을 말한다. 새 prop 을 두지 않는다. */
11
11
  const 접을수있다 = onToggle != null;
12
12
  const cls = cx('training-card-group', dragOver && 'training-card-group--dragover', collapsed && 'training-card-group--collapsed', 접을수있다 && 'training-card-group--collapsible', className);
13
- return (_jsxs("div", { className: cls, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, children: [_jsxs("header", { className: "training-card-group__header", children: [접을수있다 && (_jsx(Button, { size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-right' : 'chevron-down', ariaLabel: collapsed ? t('common.expand', '펼치기') : t('common.collapse', '접기'), className: "training-card-group__chevron", onClick: onToggle })), typeof title === 'string'
13
+ return (_jsxs("div", { className: cls, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, children: [_jsxs("header", { className: "training-card-group__header", children: [접을수있다 && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-right' : 'chevron-down', ariaLabel: collapsed ? t('common.expand', '펼치기') : t('common.collapse', '접기'), className: "training-card-group__chevron", onClick: onToggle })), typeof title === 'string'
14
14
  ? _jsx("span", { className: "training-card-group__title h5b", children: title })
15
15
  : title, count !== undefined && _jsx("span", { className: "training-card-group__count body-3b", children: count }), (sort || actions != null) && (_jsxs("span", { className: "training-card-group__actions", onClick: e => e.stopPropagation(), children: [sort && (_jsx(Dropdown, { size: 40, options: sort.options, value: sort.value, placeholder: sort.placeholder, onChange: sort.onChange })), actions] }))] }), !collapsed && (empty
16
16
  ? _jsx("div", { className: "training-card-group__empty body-3", children: emptyContent })
@@ -5,6 +5,7 @@ import { PAGE_POPUP_CLOSE_MS } from './pagePopupTiming';
5
5
  import { useTranslation } from 'react-i18next';
6
6
  import { createPortal } from "react-dom";
7
7
  import { useFocusTrap, FOCUSABLE_SELECTOR } from '../../hooks/useFocusTrap';
8
+ import { pushEsc } from '../../hooks/escStack';
8
9
  import Toast from '../Toast';
9
10
  import Button from '../Button';
10
11
  import './TrainingPagePopup.css';
@@ -122,17 +123,29 @@ function TrainingPagePopupInner(props) {
122
123
  // 이 판에만 거는 설정 — 마운트 때 한 번 붙잡는다(부모가 매 렌더 새 함수를 넘겨도 다시 걸지 않게).
123
124
  // 있으면 이 판은 저장을 건너뛴다(mergeAndSaveSettings 참고).
124
125
  const noiseOverrideRef = useRef(noiseOverride);
126
+ /* 불러오기가 끝났나 · 끝나기 전에 바뀐 것. 끝나기 전의 변경은 **저장하지 않고 모아 두었다가** 불러온
127
+ 값 위에 얹어 한 번 저장한다. 전에는 enabled:false 초기값 위에 합쳐 곧바로 저장해(소음 꺼짐이 DB 에
128
+ 남아 다음 판부터 소음이 안 났다) 늦게 온 불러오기가 그 변경을 다시 덮었다. */
129
+ const noiseLoadedRef = useRef(false);
130
+ const pendingNoiseRef = useRef(null);
125
131
  // 계정/고객 저장소에서 초기값 로드 (앱이 configureNoiseSettingsStore로 주입)
126
132
  useEffect(() => {
127
133
  let cancelled = false;
128
- void loadNoiseSettings().then((saved) => {
134
+ const apply = (saved) => {
129
135
  if (cancelled)
130
136
  return;
137
+ noiseLoadedRef.current = true;
131
138
  // 저장된 설정이 있으면 병합, 없으면 진짜 기본값으로 확정 — 어느 쪽이든 이제부터 재생 허용.
132
139
  // mergeNoiseSettings — noiseRatio 가 없는 옛 기록은 기본값 비율(1)에 가리지 않고 level 에서 옮긴다.
133
- const merged = saved ? mergeNoiseSettings(noiseSettingsRef.current, saved) : { ...DEFAULT_NOISE_SETTINGS };
140
+ // 바탕은 **처음 값**이다 — 끝나기 전 변경은 pending 으로 따로 얹는다(아래).
141
+ const loaded = saved ? mergeNoiseSettings({ ...DEFAULT_NOISE_SETTINGS, enabled: false }, saved) : { ...DEFAULT_NOISE_SETTINGS };
142
+ const pending = pendingNoiseRef.current;
143
+ pendingNoiseRef.current = null;
144
+ const merged = pending ? mergeNoiseSettings(loaded, pending) : loaded;
134
145
  noiseSettingsRef.current = merged;
135
146
  setNoisePart(merged);
147
+ if (pending && !noiseOverrideRef.current)
148
+ saveNoiseSettings(merged);
136
149
  reportAppliedNoiseSettings(merged);
137
150
  // override 는 병합이 끝난 **뒤에** 덮는다 — 로드가 늦게 와도 override 가 이긴다.
138
151
  const override = noiseOverrideRef.current;
@@ -143,7 +156,8 @@ function TrainingPagePopupInner(props) {
143
156
  setNoisePart(applied);
144
157
  reportAppliedNoiseSettings(applied);
145
158
  }
146
- });
159
+ };
160
+ void loadNoiseSettings().then(apply);
147
161
  return () => { cancelled = true; };
148
162
  }, []);
149
163
  // 오디오 레벨 미터용 analyser (오디오 플레이어가 재생 시 등록)
@@ -160,6 +174,12 @@ function TrainingPagePopupInner(props) {
160
174
  const merged = mergeNoiseSettings(noiseSettingsRef.current, next);
161
175
  noiseSettingsRef.current = merged;
162
176
  setNoisePart(merged);
177
+ if (!noiseLoadedRef.current) {
178
+ // 불러오기 전 — 화면에는 곧바로 걸되 저장은 불러온 뒤로 미룬다(위 apply).
179
+ pendingNoiseRef.current = { ...(pendingNoiseRef.current ?? {}), ...next };
180
+ reportAppliedNoiseSettings(merged);
181
+ return;
182
+ }
163
183
  // noiseOverride 판은 저장하지 않는다 — 판 중에 바꾼 것도 이 판에만.
164
184
  if (!noiseOverrideRef.current)
165
185
  saveNoiseSettings(merged);
@@ -199,31 +219,39 @@ function TrainingPagePopupInner(props) {
199
219
  }, []);
200
220
  useEffect(() => () => { if (closeTimerRef.current)
201
221
  clearTimeout(closeTimerRef.current); }, []);
202
- // Focus first focusable element when popup opens. 가둠은 useFocusTrap 이 한다.
222
+ /* 열 때 첫 칸으로 포커스 · 닫힐 때 연 곳으로 되돌림 — **마운트 때 한 번만.** 가둠은 useFocusTrap 이 한다.
223
+ 전에는 이 효과가 onBack 에 걸려 있어 부모가 새 onBack 을 넘길 때마다 다시 돌아 **훈련 중 포커스를 첫 칸으로
224
+ 끌어왔고**, 닫을 때 포커스를 되돌리는 코드는 없어 뒤 화면에서 포커스를 잃었다. */
225
+ const onBackRef = useRef(onBack);
226
+ useEffect(() => { onBackRef.current = onBack; });
203
227
  useEffect(() => {
204
228
  const panel = panelRef.current;
205
- if (!panel)
206
- return;
207
- const getFocusableEls = () => Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => !el.closest('[aria-hidden="true"]'));
208
- // Focus first focusable element
209
- const firstEl = getFocusableEls()[0];
210
- if (firstEl) {
211
- try {
212
- firstEl.focus();
229
+ const previouslyFocused = document.activeElement;
230
+ if (panel) {
231
+ const firstEl = Array.from(panel.querySelectorAll(FOCUSABLE_SELECTOR)).filter(el => !el.closest('[aria-hidden="true"]'))[0];
232
+ if (firstEl) {
233
+ try {
234
+ firstEl.focus();
235
+ }
236
+ catch (e) { }
213
237
  }
214
- catch (e) { }
215
238
  }
216
- const handleKeyDown = (e) => {
217
- if (e.key === 'Escape') {
218
- e.preventDefault();
219
- scheduleClose(() => { if (typeof onBack === 'function')
220
- onBack(); });
221
- return;
239
+ return () => {
240
+ // 포커스가 이 창 안이거나 갈 곳을 잃었을 때만 — 이미 다른 창으로 옮겨 갔으면 빼앗지 않는다(Popup 과 같다)
241
+ const active = document.activeElement;
242
+ if (!active || active === document.body || (panel && panel.contains(active))) {
243
+ try {
244
+ previouslyFocused?.focus?.();
245
+ }
246
+ catch (e) { }
222
247
  }
223
248
  };
224
- panel.addEventListener('keydown', handleKeyDown);
225
- return () => panel.removeEventListener('keydown', handleKeyDown);
226
- }, [onBack, scheduleClose]);
249
+ }, []);
250
+ /* ESC — **공유 ESC 스택**에 올린다(Popup·메뉴·툴팁과 같은 스택). 전에는 판(panel)의 네이티브 keydown 만 들었는데
251
+ 판에 tabIndex 가 없어, 문제 글·그림을 눌러 포커스가 body 로 가면 Esc 가 먹지 않았고, 훈련 창이 다른 팝업 위에
252
+ 떠 있을 때는 그 팝업의 document 리스너가 가려진 아래 팝업을 닫았다. */
253
+ useEffect(() => pushEsc(() => scheduleClose(() => { const back = onBackRef.current; if (typeof back === 'function')
254
+ back(); })), [scheduleClose]);
227
255
  /* Tab 가둠은 훅에 맡긴다 — 여기 있던 열두 줄은 `useFocusTrap` 의 것과 같은
228
256
  내용이었는데 **고르개 문자열만 제 안에 따로 박혀 있었다**(공백 없는
229
257
  `FOCUSABLE_SELECTORS`). 훅 머리말이 "네 곳을 모았다"고 적어 둔 그 넷 중
@@ -790,7 +818,7 @@ function TrainingPagePopupInner(props) {
790
818
  catch (e) { }
791
819
  },
792
820
  onClose: handleResultDone,
793
- })), (resultOverlay?.isEmpty && (_jsx("div", { className: "page-popup page-popup--result-empty", role: "dialog", "aria-modal": "true", "aria-label": t('training.result.eyebrow', '훈련 완료'), children: _jsxs("div", { className: "page-popup__panel page-popup__panel--center", children: [_jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className: "page-popup__content page-popup__content--center", children: _jsx("div", { className: "page-popup__empty", children: t('training.result.eyebrow', '훈련 완료') }) }) }), _jsx("div", { className: "page-popup__bottom", children: _jsx(Button, { variant: "main", size: 48, onClick: handleResultDone, label: t('training.result.done', '완료') }) })] }) }))), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
821
+ })), (resultOverlay?.isEmpty && (_jsx("div", { className: "page-popup page-popup--result-empty", role: "dialog", "aria-modal": "true", "aria-label": t('training.result.eyebrow', '훈련 완료'), children: _jsxs("div", { className: "page-popup__panel page-popup__panel--center", children: [_jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className: "page-popup__content page-popup__content--center", children: _jsx("div", { className: "page-popup__empty", children: t('training.result.eyebrow', '훈련 완료') }) }) }), _jsx("div", { className: "page-popup__bottom", children: _jsx(Button, { htmlType: "button", variant: "main", size: 48, onClick: handleResultDone, label: t('training.result.done', '완료') }) })] }) }))), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
794
822
  const portalContent = (_jsx(NoisePlayerContext.Provider, { value: noiseContextValue, children: jsx }));
795
823
  if (typeof document !== 'undefined' && document.body)
796
824
  return createPortal(portalContent, document.body);
@@ -56,5 +56,5 @@ export default function TrainingPagePopupBottom({ useNoiseSettingBtn = false, on
56
56
  setOpen(true);
57
57
  }, [open, onSettings]);
58
58
  const handleToggleMeters = useCallback(() => setShowMeters(prev => !prev), []);
59
- return (_jsxs("div", { className: "pp-bottom", children: [_jsxs("div", { className: "pp-bottom__left", children: [useNoiseSettingBtn ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "pp-bottom__settings--desktop", children: _jsx("div", { ref: desktopRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { size: 56, type: "bar", icon: 'settings', onClick: handleDesktopClick, selected: open, ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), label: t('training.trainingSettingsTitle', '훈련 설정'), minWidth: 140 }) }) }), _jsx("div", { className: "pp-bottom__settings--mobile", children: _jsx("div", { ref: mobileRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { size: 56, type: "dot", icon: 'settings', onClick: handleMobileClick, selected: open, ariaLabel: t('training.settings', '설정'), minWidth: 52 }) }) })] })) : null, useNoiseSettingBtn && (_jsx(Button, { size: 40, type: "dot", shape: "pill", icon: "audio-lines", onClick: handleToggleMeters, selected: showMeters, ariaLabel: t('training.meter.toggle', '오디오 미터 표시') })), useNoiseSettingBtn && showMeters && (_jsxs("div", { className: "pp-bottom__meters", children: [_jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.sentence', '재생') }), _jsx(AudioMeter, { analyser: noisePlayer?.mainAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.sentence', '재생') })] }), _jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.noise', '소음') }), _jsx(AudioMeter, { analyser: noisePlayer?.noiseAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.noise', '소음') })] })] }))] }), _jsxs("div", { className: "pp-bottom__right", children: [useResultBtn && (_jsx(Button, { size: 56, type: "bar", icon: "check", onClick: typeof onResult === "function" ? onResult : onNext, ariaLabel: t('training.viewResult', '결과보기'), label: t('training.viewResult', '결과보기'), variant: "main", disabled: !isBtnActive })), useNextBtn && (_jsx(Button, { size: 56, type: "bar", rightIcon: "chevron-right", align: "right", onClick: onNext, ariaLabel: t('training.nextQuestion', '다음 문제'), label: t('training.nextQuestion', '다음 문제'), variant: "main", disabled: !isBtnActive }))] }), useNoiseSettingBtn && open && (_jsx(NoiseSettingsTooltip, { anchorRect: anchorRect, onClose: handleCloseTooltip }))] }));
59
+ return (_jsxs("div", { className: "pp-bottom", children: [_jsxs("div", { className: "pp-bottom__left", children: [useNoiseSettingBtn ? (_jsxs(_Fragment, { children: [_jsx("div", { className: "pp-bottom__settings--desktop", children: _jsx("div", { ref: desktopRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "bar", icon: 'settings', onClick: handleDesktopClick, selected: open, ariaLabel: t('training.trainingSettingsTitle', '훈련 설정'), label: t('training.trainingSettingsTitle', '훈련 설정'), minWidth: 140 }) }) }), _jsx("div", { className: "pp-bottom__settings--mobile", children: _jsx("div", { ref: mobileRef, className: "pp-bottom__tooltip-trigger", "data-tooltip-ignore": true, children: _jsx(Button, { htmlType: "button", size: 56, type: "dot", icon: 'settings', onClick: handleMobileClick, selected: open, ariaLabel: t('training.settings', '설정'), minWidth: 52 }) }) })] })) : null, useNoiseSettingBtn && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", shape: "pill", icon: "audio-lines", onClick: handleToggleMeters, selected: showMeters, ariaLabel: t('training.meter.toggle', '오디오 미터 표시') })), useNoiseSettingBtn && showMeters && (_jsxs("div", { className: "pp-bottom__meters", children: [_jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.sentence', '재생') }), _jsx(AudioMeter, { analyser: noisePlayer?.mainAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.sentence', '재생') })] }), _jsxs("div", { className: "pp-bottom__meter", children: [_jsx("span", { className: "pp-bottom__meter-label caption-1", children: t('training.meter.noise', '소음') }), _jsx(AudioMeter, { analyser: noisePlayer?.noiseAnalyser ?? null, orientation: "horizontal", length: 112, label: "db", labelPos: "left", ariaLabel: t('training.meter.noise', '소음') })] })] }))] }), _jsxs("div", { className: "pp-bottom__right", children: [useResultBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", icon: "check", onClick: typeof onResult === "function" ? onResult : onNext, ariaLabel: t('training.viewResult', '결과보기'), label: t('training.viewResult', '결과보기'), variant: "main", disabled: !isBtnActive })), useNextBtn && (_jsx(Button, { htmlType: "button", size: 56, type: "bar", rightIcon: "chevron-right", align: "right", onClick: onNext, ariaLabel: t('training.nextQuestion', '다음 문제'), label: t('training.nextQuestion', '다음 문제'), variant: "main", disabled: !isBtnActive }))] }), useNoiseSettingBtn && open && (_jsx(NoiseSettingsTooltip, { anchorRect: anchorRect, onClose: handleCloseTooltip }))] }));
60
60
  }
@@ -37,5 +37,5 @@ export default function TrainingPagePopupTitle({ title, subtitle, desc, depth, i
37
37
  }
38
38
  return base;
39
39
  }, [wrapperStyle]);
40
- return (_jsxs("header", { className: "pp-title training-popup-title", children: [_jsxs("div", { className: "pp-title__left", children: [resolvedBack ? (_jsx(Button, { size: 48, type: "dot", icon: "chevron-left", ariaLabel: t('common.back', '뒤로'), onClick: resolvedBack, useBorder: false, iconSize: 'large', iconColor: "--color-black", variant: "transparent-gray" })) : null, _jsx("div", { children: icon ? (_jsx(CategoryIcon, { icon: icon, label: title, tooltip: iconTooltip, wrapperStyle: iconWrapperStyle })) : null }), _jsxs("div", { className: "pp-title__text", children: [resolvedDepth ? _jsx("span", { className: "pp-title__depth caption-1", children: resolvedDepth }) : null, _jsx("span", { className: `pp-title__title h2${hasName ? '' : ' pp-title__title--empty'}`, children: resolvedTitle }), desc ? _jsx("span", { className: "pp-title__description caption-1", children: desc }) : null] })] }), _jsx(ProgressIndicator, { max: Math.max(0, pageCount ?? 0), index: Math.max(0, pageIndex ?? 0) })] }));
40
+ return (_jsxs("header", { className: "pp-title training-popup-title", children: [_jsxs("div", { className: "pp-title__left", children: [resolvedBack ? (_jsx(Button, { htmlType: "button", size: 48, type: "dot", icon: "chevron-left", ariaLabel: t('common.back', '뒤로'), onClick: resolvedBack, useBorder: false, iconSize: 'large', iconColor: "--color-black", variant: "transparent-gray" })) : null, _jsx("div", { children: icon ? (_jsx(CategoryIcon, { icon: icon, label: title, tooltip: iconTooltip, wrapperStyle: iconWrapperStyle })) : null }), _jsxs("div", { className: "pp-title__text", children: [resolvedDepth ? _jsx("span", { className: "pp-title__depth caption-1", children: resolvedDepth }) : null, _jsx("span", { className: `pp-title__title h2${hasName ? '' : ' pp-title__title--empty'}`, children: resolvedTitle }), desc ? _jsx("span", { className: "pp-title__description caption-1", children: desc }) : null] })] }), _jsx(ProgressIndicator, { max: Math.max(0, pageCount ?? 0), index: Math.max(0, pageIndex ?? 0) })] }));
41
41
  }
@@ -163,8 +163,18 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
163
163
  const multiSources = Array.isArray(sources) && sources.length > 0 ? sources : (Array.isArray(segments) && segments.length > 0 ? segments : null);
164
164
  const sourceButtonCount = Math.max(Array.isArray(multiSources) ? multiSources.length : 0, requestedSources.length);
165
165
  // resolveMapping 제거됨 — JSON 파일 의존성 없이 resolveSoundPath(code, bucketPath) 로 직접 경로 결정
166
+ /* 떠났나 — 불러오기(주소 받기·길이 재기·canplay·파형 대기)는 몇 초 걸리는데 그 사이 다음 문항으로 넘기거나
167
+ 팝업을 닫아도 흐름이 계속 돌아, **떼어진 <audio> 에서 play()** 가 불려 이전 문장이 조작 단추 없이 끝까지
168
+ 울렸다. 재생 직전에 본다. */
169
+ const mountedRef = useRef(true);
170
+ useEffect(() => {
171
+ mountedRef.current = true;
172
+ return () => { mountedRef.current = false; };
173
+ }, []);
166
174
  const tryPlayWithRetry = useCallback(async (a, attempts = 3, delayMs = 50) => {
167
175
  for (let i = 0; i < attempts; i++) {
176
+ if (!mountedRef.current)
177
+ return false;
168
178
  try {
169
179
  await a.play();
170
180
  return true;
@@ -485,13 +495,14 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
485
495
  setIsFetchingUrl(false);
486
496
  }
487
497
  };
498
+ /** 소스를 하나라도 얻었으면 true, 못 얻었으면 false, 이미 다른 불러오기가 돌면 null. */
488
499
  const fetchAndPrepare = async (playAfterLoad = false, showSpinner = true) => {
489
500
  const a = audioRef.current;
490
501
  const candidates = soundSrc ? (Array.isArray(soundSrc) ? soundSrc : [soundSrc]) : [];
491
502
  if (!candidates || candidates.length === 0 || !candidates.some(Boolean))
492
- return;
503
+ return false;
493
504
  if (fetchingRef.current)
494
- return;
505
+ return null;
495
506
  fetchingRef.current = true;
496
507
  if (showSpinner)
497
508
  setIsFetchingUrl(true);
@@ -591,6 +602,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
591
602
  setCurrentIndex(0);
592
603
  set구간표시(true);
593
604
  }
605
+ return finalSources.length > 0;
594
606
  }
595
607
  finally {
596
608
  fetchingRef.current = false;
@@ -705,7 +717,16 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
705
717
  catch (e) { }
706
718
  }
707
719
  setStateLocal("load");
708
- await fetchAndPrepare(true);
720
+ const 얻음 = await fetchAndPrepare(true);
721
+ /* 쓸 소스를 하나도 못 얻으면(경로를 못 정한 코드뿐 등) 'load' 에 갇혀 듣기 단추가 사라지고 아무 조작도
722
+ 안 됐다 — 알리고 누를 수 있는 상태로 되돌린다. */
723
+ if (얻음 === false && mountedRef.current) {
724
+ try {
725
+ setNoticeMessage(t('training.audioFetchError', '사운드 소스를 가져오지 못했습니다.'));
726
+ }
727
+ catch (_) { }
728
+ setStateLocal('active');
729
+ }
709
730
  return;
710
731
  }
711
732
  if (stateLocal === "load")
@@ -1009,10 +1030,10 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1009
1030
  const fillClass = cx('audio-player__fill', fillVisibleFlag ? 'is-visible' : '', fillExpandedFlag ? 'is-expanded' : '', fillPausedFlag ? 'is-paused' : '', isFillOutAnimating ? 'is-out' : '');
1010
1031
  return (_jsxs("div", { className: `audio-player ${isDisabled ? 'is-disabled' : ''}${className ? ` ${className}` : ''}`, role: "group", "aria-label": typeof label === 'string' ? label : undefined, children: [_jsxs("div", { className: `audio-player__panel ${displayedPlaying ? "is-playing" : ""} ${isFetchingUrl ? "is-loading" : ""} ${stateLocal !== "default" ? "is-revealed" : ""}`, children: [isFetchingUrl ? (_jsxs("div", { className: "audio-player__loading", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "audio-player__loading-spinner", "aria-hidden": "true" }), _jsx("span", { className: "audio-player__loading-text", children: STATUS_MESSAGES[fetchDebugStatus] })] })) : null, stateLocal === "active" ? (_jsx("button", { className: "audio-player__button", type: "button", onClick: () => { hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
1011
1032
  // 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
1012
- onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
1033
+ onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
1013
1034
  handleJumpTo(idx);
1014
1035
  }
1015
- catch (e) { } }, ariaLabel: t('training.sourcePlay', { n: idx + 1 }), variant: "transparent-white", selected: 구간표시 && currentIndex === idx, disabled: stateLocal !== 'playMode' || !(Array.isArray(sources) && sources[idx]), showTooltipInfo: true }, idx))) })) : null, (!Array.isArray(sources) || sources.length <= 1) ? (_jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "repeat", onClick: handleReplay, ariaLabel: t('common.replay', '다시듣기'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })) : null, _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-forward", onClick: handleNext, ariaLabel: t('training.next', '다음'), width: "60px", variant: "transparent-white", "aria-hidden": true, tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "check", onClick: handleEnd, ariaLabel: t('common.done', '완료'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })] }), _jsxs("div", { className: "audio-player__slider", children: [_jsx("div", { className: fillClass, ref: fillRef, style: { left: `${fillLeftPercent}%` }, "aria-hidden": "true" }), (showSoundWaveForm || showNoiseWaveForm) && (_jsx(WaveForm, { segments: currentSegments, progress: progress, totalDuration: totalDuration, onSegmentClick: handleJumpTo, waveProgressRef: waveProgressRef, showSoundWaveForm: showSoundWaveForm, noiseSrc: (showNoiseWaveForm && noisePlayer?.settings.enabled && noiseVolume > 0) ? activeNoiseUrl : null, noiseRatio: noisePlayer ? noiseRatioOf(noisePlayer.settings) : 0, noiseSpeed: noisePlayer?.settings.speed ?? 1,
1036
+ catch (e) { } }, ariaLabel: t('training.sourcePlay', { n: idx + 1 }), variant: "transparent-white", selected: 구간표시 && currentIndex === idx, disabled: stateLocal !== 'playMode' || !(Array.isArray(sources) && sources[idx]), showTooltipInfo: true }, idx))) })) : null, (!Array.isArray(sources) || sources.length <= 1) ? (_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "repeat", onClick: handleReplay, ariaLabel: t('common.replay', '다시듣기'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })) : null, _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-forward", onClick: handleNext, ariaLabel: t('training.next', '다음'), width: "60px", variant: "transparent-white", "aria-hidden": true, tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "check", onClick: handleEnd, ariaLabel: t('common.done', '완료'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })] }), _jsxs("div", { className: "audio-player__slider", children: [_jsx("div", { className: fillClass, ref: fillRef, style: { left: `${fillLeftPercent}%` }, "aria-hidden": "true" }), (showSoundWaveForm || showNoiseWaveForm) && (_jsx(WaveForm, { segments: currentSegments, progress: progress, totalDuration: totalDuration, onSegmentClick: handleJumpTo, waveProgressRef: waveProgressRef, showSoundWaveForm: showSoundWaveForm, noiseSrc: (showNoiseWaveForm && noisePlayer?.settings.enabled && noiseVolume > 0) ? activeNoiseUrl : null, noiseRatio: noisePlayer ? noiseRatioOf(noisePlayer.settings) : 0, noiseSpeed: noisePlayer?.settings.speed ?? 1,
1016
1037
  /* 그림은 "문장 = 기준" — 문제 소리(M÷R)가 아니라 전체 소리 M 으로 문장을 그리고 소음을 R 배로 키운다 */
1017
1038
  mainVolume: mainVolume, onReadyChange: handleWaveReadyChange }))] })] }), _jsx("audio", { ref: audioRef, crossOrigin: "anonymous", preload: "auto" })] }), !showSoundWaveForm && (_jsxs("p", { className: "audio-player__no-waveform caption-1", children: [_jsx(Icon, { icon: "triangle-alert", size: 14 }), t('training.noSoundWaveform', '소리 비교 문제는 웨이브폼을 표현하지 않습니다.')] })), _jsx(Toast, { message: noticeMessage, position: noticePosition, onClose: handleClearNotice })] }));
1018
1039
  }
@@ -113,5 +113,5 @@ export default function TrainingStepConfirm({ index, stepIndex, isLast = false,
113
113
  때문이다 — 나머지 둘(choices 바뀜·페이지 전환)은 빈 Set 으로 되돌리는 자리라
114
114
  저 effect 의 `size === 0` 가드에 걸려 어차피 아무것도 안 했다. */
115
115
  const btnsClassName = `training-step__buttons${Array.isArray(choices) && choices.length > 3 ? ' training-step__buttons--wrap-3' : ''}`;
116
- return (_jsx(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, useTitleFaded: useTitleFaded, children: _jsx("div", { className: btnsClassName, children: Array.isArray(choices) && choices.length > 0 ? (choices.map((c, i) => (_jsx(Button, { size: 56, type: "bar", onClick: () => handleSelect(i), ariaLabel: String(c), label: c, useLabel: true, icon: choiceIcons ? choiceIcons[String(c)] : undefined, variant: 'default', disabled: currentState === 'disabled', selected: multiSelect ? selectedSet.has(c) : selectedAnswer === c, useDisabledCovered: useDisabledCovered, minWidth: 0 }, String(c))))) : null }) }));
116
+ return (_jsx(TrainingStep, { index: index, stepIndex: stepIndex, isLast: isLast, title: resolvedTitle, useTitleFaded: useTitleFaded, children: _jsx("div", { className: btnsClassName, children: Array.isArray(choices) && choices.length > 0 ? (choices.map((c, i) => (_jsx(Button, { htmlType: "button", size: 56, type: "bar", onClick: () => handleSelect(i), ariaLabel: String(c), label: c, useLabel: true, icon: choiceIcons ? choiceIcons[String(c)] : undefined, variant: 'default', disabled: currentState === 'disabled', selected: multiSelect ? selectedSet.has(c) : selectedAnswer === c, useDisabledCovered: useDisabledCovered, minWidth: 0 }, String(c))))) : null }) }));
117
117
  }
@@ -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
  }