@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
@@ -18,6 +18,7 @@ import ChevronDown from 'lucide-react/dist/esm/icons/chevron-down.mjs';
18
18
  import Button from './Button';
19
19
  import Icon from './Icon';
20
20
  import Loader from './Loader';
21
+ import useMediaQuery from '../hooks/useMediaQuery';
21
22
  function TableCell({ children, className, style }) {
22
23
  return _jsx("div", { className: className ? `table-cell ${className}` : 'table-cell', style: style, children: children });
23
24
  }
@@ -62,8 +63,12 @@ function TableField({ children, onClick, className, sortDir, onResizeStart }) {
62
63
  return (_jsxs("div", { onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, "aria-sort": sortDir ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined, className: cx('table-field body-3', className, sortDir ? 'table-field--sorted' : '', onResizeStart ? 'table-field--resizable' : ''), children: [children, sortDir && _jsx(SortArrow, { dir: sortDir }), onResizeStart && (_jsx("span", { className: "table-field__resizer", onPointerDown: onResizeStart, onClick: e => e.stopPropagation(), "aria-hidden": "true" }))] }));
63
64
  }
64
65
  /** @public */
65
- export default function Table({ fields = [], headerGroups, items = [], renderRow, onRowClick, onRowMouseEnter, onRowMouseLeave, onRowDragStart, onRowDragEnd, draggingId, gridTemplateColumns, resizable = false, resizeMode = 'grow', resizeStorageKey, size = 'small', title, icon, count, collapsed = false, onToggleCollapse, headerActions, empty, loading, dragOver = false, onDragOver, onDragLeave, onDrop, renderExpandedRow, expandedId, }) {
66
+ export default function Table({ fields = [], headerGroups, items = [], renderRow, onRowClick, isRowClickable, onRowMouseEnter, onRowMouseLeave, onRowDragStart, onRowDragEnd, draggingId, gridTemplateColumns, mobileGridTemplateColumns, resizable = false, resizeMode = 'grow', resizeStorageKey, size = 'small', title, icon, count, collapsed = false, onToggleCollapse, headerActions, empty, loading, dragOver = false, onDragOver, onDragLeave, onDrop, renderExpandedRow, expandedId, }) {
66
67
  const { t } = useTranslation();
68
+ /* 좁은 화면 틀이 걸리면 폭 조절을 통째로 끈다 — 손잡이·초기화 단추·저장. 훅은 늘 부른다(순서 고정). */
69
+ const 좁은화면 = useMediaQuery('(max-width: 767px)');
70
+ const 좁은틀 = mobileGridTemplateColumns && 좁은화면 ? mobileGridTemplateColumns : null;
71
+ const 폭조절 = resizable && !좁은틀;
67
72
  // ── 컬럼 폭 드래그 리사이즈 (resizable) ──
68
73
  const MIN_COL_W = 48;
69
74
  const storeKey = resizeStorageKey ? `table-cols:${resizeStorageKey}` : null;
@@ -277,28 +282,29 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
277
282
  const widthUnit = (i) => (resizeMode === 'fit' && !isFixedPx(i)) ? 'fr' : 'px';
278
283
  const canApplyWidths = colWidths && colWidths.length === fields.length
279
284
  && (resizeMode === 'fit' || items.length > 0);
280
- const effectiveGrid = canApplyWidths
285
+ const effectiveGrid = 좁은틀 ?? (canApplyWidths
281
286
  ? fields.map((f, i) => (f.resizable === false && defaultCols[i]) ? defaultCols[i] : `${colWidths[i]}${widthUnit(i)}`).join(' ')
282
- : gridTemplateColumns;
287
+ : gridTemplateColumns);
283
288
  const gridStyle = effectiveGrid
284
289
  ? { '--table-grid-cols': effectiveGrid }
285
290
  : undefined;
286
291
  const hasHeader = title !== undefined;
287
- return (_jsx("div", { className: `table-wrap table-wrap--${size}${resizable ? ' 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 === ' ') {
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 === ' ') {
288
293
  e.preventDefault();
289
294
  onToggleCollapse();
290
- } } : 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', resizable && '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,
291
296
  /* fit 모드는 오른쪽 이웃에서 폭을 덜어 오므로, 덜어 올 컬럼이 없는 자리에는
292
297
  손잡이를 두지 않는다 — 두면 그 컬럼만 grow처럼 넓어져 표가 밀린다. */
293
- onResizeStart: resizable && i < fields.length - 1 && f.resizable !== false
298
+ onResizeStart: 폭조절 && i < fields.length - 1 && f.resizable !== false
294
299
  && (resizeMode !== 'fit' || fields.some((g, j) => j > i && g.resizable !== false))
295
- ? (e) => startResize(i, e) : undefined, children: f.label }, i))) }), resizable && (_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) => {
296
301
  const isDragging = draggingId != null && item.id === draggingId;
302
+ const 눌림 = !!onRowClick && (isRowClickable ? isRowClickable(item) : true);
297
303
  const isExpanded = expandedId != null && (expandedId instanceof Set ? expandedId.has(item.id) : item.id === expandedId);
298
- return (_jsx(TableRow, { className: `${onRowClick ? ' table-row--clickable' : ''}${onRowDragStart ? ' table-row--draggable' : ''}${isDragging ? ' table-row--dragging' : ''}`.trim(), draggable: !!onRowDragStart,
304
+ return (_jsx(TableRow, { className: `${눌림 ? ' table-row--clickable' : ''}${onRowDragStart ? ' table-row--draggable' : ''}${isDragging ? ' table-row--dragging' : ''}`.trim(), draggable: !!onRowDragStart,
299
305
  // 옆의 핸들러들처럼 있을 때만 넘긴다. 항상 함수를 만들어 넘기면
300
306
  // TableRow 가 "클릭 가능"으로 보고 role="button" · tabIndex=0 을 붙여,
301
307
  // 클릭 동작이 없는 표의 모든 행이 버튼으로 읽히고 탭 정지점이 된다.
302
- onClick: onRowClick ? () => onRowClick(item) : undefined, onMouseEnter: onRowMouseEnter ? (e) => onRowMouseEnter(item, e) : undefined, onMouseLeave: onRowMouseLeave ? (e) => onRowMouseLeave(item, e) : undefined, onDragStart: onRowDragStart ? (e) => onRowDragStart(item, e) : undefined, onDragEnd: onRowDragEnd ? (e) => onRowDragEnd(item, e) : undefined, expandedContent: isExpanded && renderExpandedRow ? renderExpandedRow(item) : undefined, children: renderRow(item).map((cell, i) => (_jsx(TableCell, { className: cell.className, style: cell.style, children: cell.content }, i))) }, String(item.id ?? idx)));
308
+ onClick: 눌림 ? () => onRowClick(item) : undefined, onMouseEnter: onRowMouseEnter ? (e) => onRowMouseEnter(item, e) : undefined, onMouseLeave: onRowMouseLeave ? (e) => onRowMouseLeave(item, e) : undefined, onDragStart: onRowDragStart ? (e) => onRowDragStart(item, e) : undefined, onDragEnd: onRowDragEnd ? (e) => onRowDragEnd(item, e) : undefined, expandedContent: isExpanded && renderExpandedRow ? renderExpandedRow(item) : undefined, children: renderRow(item).map((cell, i) => (_jsx(TableCell, { className: cell.className, style: cell.style, children: cell.content }, i))) }, String(item.id ?? idx)));
303
309
  }) })] }))] }) }));
304
310
  }
@@ -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
  }
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { cx } from '../utils/cx';
4
- import { useId, useEffect, useRef, useCallback } from 'react';
4
+ import { useId, useEffect, useLayoutEffect, useRef, useCallback, useState } from 'react';
5
5
  import { devWarnOnce } from '../utils/devWarn';
6
6
  import { useTranslation } from 'react-i18next';
7
7
  import Icon from './Icon';
@@ -53,7 +53,15 @@ export default function TextField(props) {
53
53
  const isControlled = value !== undefined;
54
54
  /* 비밀번호는 value 를 React 에 넘기지 않는다 — 위 useEffect 주석 참고 */
55
55
  const isPasswordControlled = isControlled && type === 'password';
56
- const len = (value ?? '').length;
56
+ /* 비제어 칸은 value 가 없으니 글자 수를 따로 센다 — 전에는 value 로만 세어 defaultValue 칸의 카운터는
57
+ 0, 지우기 단추는 안 보이고, 자동 높이는 늘지 않았다. */
58
+ const [innerLen, setInnerLen] = useState((defaultValue ?? '').length);
59
+ const len = isControlled ? (value ?? '').length : innerLen;
60
+ /* 비밀번호 controlled 칸이 부모가 거절·정규화한 값을 따라가게 — 아래 useEffect 는 value 가 바뀔 때만
61
+ 돌아, 부모가 같은 값을 다시 두면(공백을 걷는 등) 화면 칸에 친 글자가 남았다. React 가 일반 칸에
62
+ 해 주는 되돌림(restoreControlledState)을 onChange 뒤 한 박자에 흉내 낸다. */
63
+ const valueRef = useRef(value);
64
+ useLayoutEffect(() => { valueRef.current = value; });
57
65
  /*
58
66
  * React 는 controlled input 의 값을 node.defaultValue(= HTML value 속성)에 동기화한다.
59
67
  * 일반 입력에서는 무해하지만 비밀번호에서는 그 속성이 outerHTML 직렬화 경로
@@ -80,7 +88,7 @@ export default function TextField(props) {
80
88
  if (el.scrollHeight > el.clientHeight) {
81
89
  el.style.height = `${el.scrollHeight}px`;
82
90
  }
83
- }, [value, newline]);
91
+ }, [value, innerLen, newline]);
84
92
  const isOver = maxLength != null && len > maxLength;
85
93
  const iconSize = size === 32 ? 16 : 20;
86
94
  const cls = cx('text-field', `text-field--s${size}`, variant === 'display' ? 'text-field--display' : '', fullWidth ? 'text-field--full-width' : '', disabled ? 'text-field--disabled' : '', readOnly ? 'text-field--readonly' : '', error ? 'text-field--error' : '', leftIcon ? 'text-field--has-left-icon' : '', (rightIcon || clearable) ? 'text-field--has-right-icon' : '', className);
@@ -119,7 +127,21 @@ export default function TextField(props) {
119
127
  조용히 버린다. */
120
128
  'aria-controls': 목록떴나 ? combobox.listId : undefined,
121
129
  'aria-activedescendant': 목록떴나 && combobox.activeIdx >= 0 ? `${combobox.listId}-option-${combobox.activeIdx}` : undefined,
122
- onChange: (e) => onChange?.(e.target.value),
130
+ onChange: (e) => {
131
+ const node = e.target;
132
+ if (!isControlled)
133
+ setInnerLen(node.value.length);
134
+ onChange?.(node.value);
135
+ if (suggestions)
136
+ combobox.handleInput();
137
+ if (isPasswordControlled) {
138
+ queueMicrotask(() => {
139
+ const next = valueRef.current ?? '';
140
+ if (node.value !== next)
141
+ node.value = next;
142
+ });
143
+ }
144
+ },
123
145
  onFocus: ((e) => {
124
146
  onFocus?.(e);
125
147
  if (suggestions)
@@ -171,7 +193,14 @@ export default function TextField(props) {
171
193
  // 지우는 일은 click 에서 한다 — mousedown 에서만 처리하면 물리적 마우스 누름
172
194
  // 외에는 아무 반응이 없어, 이름표(aria-label)만 달린 못 누르는 버튼이 된다
173
195
  // (스크린리더의 활성화는 click 을 보낸다).
174
- onClick: () => onChange?.(''), tabIndex: -1 })), rightIcon && !showClear && (_jsx("span", { className: "text-field__icon text-field__icon--right", children: _jsx(Icon, { icon: rightIcon, label: "", className: "", svgClassName: "text-field__icon-svg", size: iconSize }) }))] }), suggestions && combobox.open && combobox.filtered.length > 0 && (_jsx("ul", { id: combobox.listId, className: "text-field__suggestions", role: "listbox", children: combobox.filtered.map((s, i) => (_jsx("li", { id: `${combobox.listId}-option-${i}`, role: "option", "aria-selected": i === combobox.activeIdx, className: `text-field__suggestion body-2${i === combobox.activeIdx ? ' is-active' : ''}`,
196
+ onClick: () => {
197
+ // 비제어 칸은 화면 값도 직접 비운다(onChange 만 부르면 글자가 그대로 남는다).
198
+ if (!isControlled && autoRef.current) {
199
+ autoRef.current.value = '';
200
+ setInnerLen(0);
201
+ }
202
+ onChange?.('');
203
+ }, tabIndex: -1 })), rightIcon && !showClear && (_jsx("span", { className: "text-field__icon text-field__icon--right", children: _jsx(Icon, { icon: rightIcon, label: "", className: "", svgClassName: "text-field__icon-svg", size: iconSize }) }))] }), suggestions && combobox.open && combobox.filtered.length > 0 && (_jsx("ul", { id: combobox.listId, className: "text-field__suggestions", role: "listbox", children: combobox.filtered.map((s, i) => (_jsx("li", { id: `${combobox.listId}-option-${i}`, role: "option", "aria-selected": i === combobox.activeIdx, className: `text-field__suggestion body-2${i === combobox.activeIdx ? ' is-active' : ''}`,
175
204
  // 선택은 click 에서 한다. onMouseDown 만 두면 마우스로는 눌리지만 스크린리더가
176
205
  // 이 option 을 활성화할 때는 click 만 발생해 아무 일도 일어나지 않는다.
177
206
  // mousedown 에서는 기본 동작(포커스 이동 → 입력 blur)을 막아, click 이 도달하기
@@ -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
  }