@ihabdevteam/core 0.132.0 → 0.134.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 (95) hide show
  1. package/CHANGELOG.md +129 -0
  2. package/dist/components.cjs.js +177 -177
  3. package/dist/components.esm.css +1 -1
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +2 -2
  6. package/dist/ihabdevteam-core.cjs.js +182 -182
  7. package/dist/ihabdevteam-core.d.ts +8 -13
  8. package/dist/packages/core/src/components/BarChart.js +2 -2
  9. package/dist/packages/core/src/components/BottomNav.js +3 -3
  10. package/dist/packages/core/src/components/Breadcrumb.js +5 -1
  11. package/dist/packages/core/src/components/BulkBar.js +1 -1
  12. package/dist/packages/core/src/components/ChoiceChips.js +1 -1
  13. package/dist/packages/core/src/components/CodeBox.js +1 -1
  14. package/dist/packages/core/src/components/DatePicker.js +16 -3
  15. package/dist/packages/core/src/components/Dialog.js +32 -9
  16. package/dist/packages/core/src/components/Dropdown.js +13 -3
  17. package/dist/packages/core/src/components/FileUpload.js +8 -7
  18. package/dist/packages/core/src/components/FilterPanel.js +8 -5
  19. package/dist/packages/core/src/components/FilterSectionInfo.js +1 -1
  20. package/dist/packages/core/src/components/Header.css +2 -0
  21. package/dist/packages/core/src/components/Header.js +4 -1
  22. package/dist/packages/core/src/components/HearingDeviceInfo.js +1 -1
  23. package/dist/packages/core/src/components/LandingHeader.js +18 -1
  24. package/dist/packages/core/src/components/PageTitle.js +1 -1
  25. package/dist/packages/core/src/components/Pagination.js +6 -3
  26. package/dist/packages/core/src/components/Panel.d.ts +1 -1
  27. package/dist/packages/core/src/components/Panel.js +4 -1
  28. package/dist/packages/core/src/components/PanelRow.js +3 -3
  29. package/dist/packages/core/src/components/PassCard/passCardProduct.js +6 -0
  30. package/dist/packages/core/src/components/PaymentPlan.d.ts +6 -0
  31. package/dist/packages/core/src/components/PaymentPlan.js +6 -4
  32. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +2 -2
  33. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +2 -2
  34. package/dist/packages/core/src/components/Popup/Popup.js +15 -3
  35. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +1 -1
  36. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +1 -1
  37. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +3 -3
  38. package/dist/packages/core/src/components/PromoBanner.js +1 -1
  39. package/dist/packages/core/src/components/SegmentedControl.js +1 -1
  40. package/dist/packages/core/src/components/SideNav.js +2 -2
  41. package/dist/packages/core/src/components/SoundCompass.js +10 -1
  42. package/dist/packages/core/src/components/Table.js +2 -2
  43. package/dist/packages/core/src/components/Tabs.js +7 -2
  44. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +8 -3
  45. package/dist/packages/core/src/components/Tooltip/TextBoxTooltip.js +1 -1
  46. package/dist/packages/core/src/components/Tooltip/Tooltip.js +13 -5
  47. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +1 -1
  48. package/dist/packages/core/src/components/Training/TrainingBlock.js +1 -1
  49. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +1 -1
  50. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -1
  51. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +51 -23
  52. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +1 -1
  53. package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +1 -1
  54. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +26 -5
  55. package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +1 -1
  56. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +2 -2
  57. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +1 -1
  58. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
  59. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +1 -1
  60. package/dist/packages/core/src/components/Training/TrainingWordCard.js +11 -6
  61. package/dist/packages/core/src/components/Training/useAudioPreview.js +7 -3
  62. package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -1
  63. package/dist/packages/core/src/components/Training/useTTSPlayback.js +9 -0
  64. package/dist/packages/core/src/components/UpdateAlert.js +1 -1
  65. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +2 -2
  66. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +6 -5
  67. package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
  68. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  69. package/dist/packages/core/src/components/WordTable.js +6 -6
  70. package/dist/packages/core/src/components/useToastLifecycle.js +8 -1
  71. package/dist/packages/core/src/hooks/useAnchoredMenu.js +10 -9
  72. package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +0 -11
  73. package/dist/packages/core/src/hooks/useDocumentTitle.js +16 -3
  74. package/dist/packages/core/src/hooks/useFocusTrap.js +6 -2
  75. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +9 -4
  76. package/dist/packages/core/src/hooks/wordTrainingFilterModel.d.ts +1 -1
  77. package/dist/packages/core/src/hooks/wordTrainingFilterModel.js +7 -2
  78. package/dist/packages/core/src/i18n/locales/en.json +11 -0
  79. package/dist/packages/core/src/i18n/locales/ko.json +11 -0
  80. package/dist/packages/core/src/utils/audioPeaks.js +13 -5
  81. package/dist/packages/core/src/utils/csv.js +3 -1
  82. package/dist/packages/core/src/utils/format.js +17 -1
  83. package/dist/packages/core/src/utils/koreanPronunciation.js +7 -0
  84. package/dist/packages/core/src/utils/koreanSyllable.js +2 -1
  85. package/dist/packages/core/src/utils/pressable.js +4 -0
  86. package/dist/packages/core/src/utils/savedWordTables.js +2 -1
  87. package/dist/packages/core/src/utils/textSort.d.ts +0 -10
  88. package/dist/packages/core/src/utils/textSort.js +7 -2
  89. package/dist/packages/core/src/utils/wordTrainingPool.d.ts +7 -0
  90. package/dist/packages/core/src/utils/wordTrainingPool.js +30 -0
  91. package/dist/packages/core/src/utils/wordTrainingRuntime.js +55 -17
  92. package/dist/packages/core/src/version.d.ts +1 -1
  93. package/dist/packages/core/src/version.js +1 -1
  94. package/dist/utils.cjs.js +2 -2
  95. package/package.json +1 -1
@@ -20,6 +20,12 @@ export interface PaymentPlanTier {
20
20
  * 구매 횟수가 제한되지 않는다(같은 프로모션이되 1회 제한 대상은 아닌 tier용).
21
21
  */
22
22
  openPromo?: boolean;
23
+ /**
24
+ * **계정당 1회만 살 수 있는 tier**(오픈 특가 30일 등). 이미 산 것(`ownedCodes`)이면 숨기고 기본 tier 를 카드로 올린다.
25
+ * 안 주면 예전처럼 라벨에 '오픈 특가' 가 있는지로 판정한다 — 그런데 라벨은 번역되므로 영어 화면에서는 판정이 실패해
26
+ * 이미 산 특가 카드가 남고 정가 30일권을 살 길이 없어진다. 번역되는 라벨 대신 이 값을 넘긴다.
27
+ */
28
+ onceOnly?: boolean;
23
29
  [k: string]: unknown;
24
30
  }
25
31
  /** @public */
@@ -43,7 +43,8 @@ export default function PaymentPlan(props) {
43
43
  const { plan, onSelect, period = "monthly", ownedCodes } = props || {};
44
44
  const owned = (code) => !!code && !!ownedCodes?.includes(code);
45
45
  // 이 플랜의 오픈특가 tier를 이미 보유(구매)했는지 — 그러면 기본 tier를 카드로 승격
46
- const openSoldOut = (plan.tiers || []).some(tr => /오픈\s*특가/.test(tr.label ?? '') && owned(tr.code));
46
+ const isOnceOnlyTier = (tr) => tr.onceOnly ?? /오픈\s*특가/.test(tr.label ?? '');
47
+ const openSoldOut = (plan.tiers || []).some(tr => isOnceOnlyTier(tr) && owned(tr.code));
47
48
  // 할인 tier 가 정가를 취소선으로 이미 보여 주면, 같은 값을 가진 정가 행은 같은 상품을 두 번
48
49
  // 늘어놓는 셈이 된다(30일 이용권 ₩10,000 행 + 30일 ₩10,000→₩5,000 카드). 그 행은 숨긴다.
49
50
  const coveredByDiscount = (tier) => {
@@ -61,7 +62,8 @@ export default function PaymentPlan(props) {
61
62
  const tierPriceOf = (tier) => (tier.prices ? tier.prices[period] : tier.price);
62
63
  const isFree = (plan.tiers || []).length > 0 && (plan.tiers || []).every(tier => {
63
64
  const price = tierPriceOf(tier);
64
- return price === "무료" || price === freePrice || price === "0원";
65
+ // 숫자가 모두 0 인 가격도 무료다 — '₩0'·'$0.00' 처럼 로케일마다 표기가 달라 '무료'·'0원' 글자만 보면 영어 화면에서 놓쳤다
66
+ return price === "무료" || price === freePrice || price === "0원" || (typeof price === 'string' && /\d/.test(price) && !/[1-9]/.test(price));
65
67
  });
66
68
  // 맞춤 견적(문의) 플랜 — 가격이 '문의'(ko) 또는 로케일 견적 문구
67
69
  const isInquiry = (plan.tiers || []).length > 0 && (plan.tiers || []).every(tier => {
@@ -79,13 +81,13 @@ export default function PaymentPlan(props) {
79
81
  null) : isInquiry ? (
80
82
  /* 문의는 실제로 할 일이 있는 행동이라 버튼을 둔다 — 페이지 하단 문의 버튼과
81
83
  같은 공용 Button(variant=main)으로 맞춘다. */
82
- _jsx(Button, { className: "payment-plan__contact-button", label: t('payment.plan.contact', { defaultValue: '문의하기' }), variant: "main", size: 56, onClick: () => plan.tiers?.[0] && onSelect?.({ ...plan.tiers[0], productId: plan.id, productTitle: plan.title }) })) : (_jsx(_Fragment, { children: _jsx("div", { className: "payment-plan__pricing-list", children: (plan.tiers || []).map((tier) => {
84
+ _jsx(Button, { htmlType: "button", className: "payment-plan__contact-button", label: t('payment.plan.contact', { defaultValue: '문의하기' }), variant: "main", size: 56, onClick: () => plan.tiers?.[0] && onSelect?.({ ...plan.tiers[0], productId: plan.id, productTitle: plan.title }) })) : (_jsx(_Fragment, { children: _jsx("div", { className: "payment-plan__pricing-list", children: (plan.tiers || []).map((tier) => {
83
85
  /* 라벨이 없을 때의 대체말도 **화면에 뜨는 글**이다 — 사전에서 가져온다.
84
86
  아래 정규식의 '오픈 특가' 는 다르다. 그건 앱이 넘겨준 **데이터를
85
87
  읽는** 것이라 한국어 그대로여야 한다. */
86
88
  const rawLabel = tier.label || 기본이용권;
87
89
  // 라벨의 '오픈 특가'는 계정당 1회 제한의 표시다 — 숨김·소진 판정에만 쓴다.
88
- const isOnceOnly = /오픈\s*특가/.test(rawLabel);
90
+ const isOnceOnly = isOnceOnlyTier(tier);
89
91
  // 배지 문구는 별도 — 같은 프로모션이되 1회 제한은 없는 tier(1년권)도 함께 얹는다.
90
92
  const showOpenPromo = isOnceOnly || tier.openPromo === true;
91
93
  const planText = rawLabel.replace(/^오픈\s*특가\s*/, '') || 기본이용권;
@@ -40,7 +40,7 @@ export default function DeviceSideTable({ rows, onSelect, onDelete, onWear, onRe
40
40
  }, [rows, 정렬]);
41
41
  /* 개수 보기·쪽넘김은 방문 표와 같은 것을 쓴다 — 같은 자리에서 같은 뜻으로 읽히게 */
42
42
  const { pageRows, pageSizeProps, paginationProps } = useTablePaging(sortedRows, 정렬);
43
- return (_jsxs("div", { className: "dlp-side-table", children: [_jsx(Toolbar, { count: { value: rows.length, unit: t('customers.device.unit', '개') }, pageSize: pageSizeProps, pagination: paginationProps, actions: onAdd && (_jsx(Button, { size: 40, type: "bar", variant: "main", icon: "plus", label: t('customers.device.addButton', '기기 추가'), onClick: onAdd })) }), _jsx(Table, { resizable: true, resizeMode: "fit", resizeStorageKey: "customer-device-history-v2", fields: [
43
+ return (_jsxs("div", { className: "dlp-side-table", children: [_jsx(Toolbar, { count: { value: rows.length, unit: t('customers.device.unit', '개') }, pageSize: pageSizeProps, pagination: paginationProps, actions: onAdd && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", variant: "main", icon: "plus", label: t('customers.device.addButton', '기기 추가'), onClick: onAdd })) }), _jsx(Table, { resizable: true, resizeMode: "fit", resizeStorageKey: "customer-device-history-v2", fields: [
44
44
  /* 맨 왼쪽 — 지금 끼고 있는 것만 좌/우를 적는다. 이 열만 훑으면 "오늘 이 사람이
45
45
  무엇을 쓰고 있나" 가 한눈에 보인다(나머지 줄은 비어 있어 눈에 안 걸린다). */
46
46
  /* 체크박스 칸은 폭을 늘릴 것이 없다 — 손잡이를 주면 잡히는데 아무 일도
@@ -83,7 +83,7 @@ export default function DeviceSideTable({ rows, onSelect, onDelete, onWear, onRe
83
83
  못 하는 일을 내밀지 않는다. 눌림은 줄로 새지 않게 막는다(줄을 누르면
84
84
  수정 창이 열린다). */
85
85
  ...(onDelete ? [{
86
- content: (_jsx("span", { className: "dlp-row-actions", children: _jsx(Button, { size: 28, type: "bar", variant: "transparent-gray", label: t('common.delete', '삭제'), onClick: e => { e?.stopPropagation(); onDelete(row); } }) })),
86
+ content: (_jsx("span", { className: "dlp-row-actions", children: _jsx(Button, { htmlType: "button", size: 28, type: "bar", variant: "transparent-gray", label: t('common.delete', '삭제'), onClick: e => { e?.stopPropagation(); onDelete(row); } }) })),
87
87
  }] : []),
88
88
  ], onRowClick: ({ row }) => onSelect(row),
89
89
  /* Table 이 이미 TableRow variant="empty" 로 감싼다 — div 를 한 겹 더 씌우면
@@ -38,7 +38,7 @@ const DeleteButton = ({ onClick, ariaLabel }) => {
38
38
  /* 화면낭독기가 읽는 이름이라 사전에서 온다 — 여기 박아 두면 영어로 쓰는 사람에게
39
39
  이 버튼만 '삭제' 로 읽힌다. */
40
40
  const { t } = useTranslation();
41
- return (_jsx(Button, { size: 32, type: "dot", icon: "x", variant: "transparent-lightGray", className: "load-table-popup__delete", onClick: onClick, ariaLabel: ariaLabel ?? t('common.delete', '삭제'), showTooltipInfo: true }));
41
+ return (_jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "x", variant: "transparent-lightGray", className: "load-table-popup__delete", onClick: onClick, ariaLabel: ariaLabel ?? t('common.delete', '삭제'), showTooltipInfo: true }));
42
42
  };
43
43
  export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, onDelete: onDeleteProp }) {
44
44
  const { t } = useTranslation();
@@ -81,7 +81,7 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
81
81
  animateThen(() => onLoad(table));
82
82
  }, [tables, selected, onLoad]);
83
83
  const headerActions = useMemo(() => tables.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Dropdown, { options: VIEW_OPTIONS, value: viewMode, onChange: handleSetViewMode }), _jsx(Dropdown, { options: SORT_OPTIONS, value: sortKey, onChange: handleSetSortKey })] })) : undefined, [tables.length, viewMode, sortKey, handleSetViewMode, handleSetSortKey, VIEW_OPTIONS, SORT_OPTIONS]);
84
- return (_jsx(Popup, { className: "popup--load-table", title: t('wordTable.loadTable.title', '저장된 단어 표 불러오기'), headerActions: headerActions, onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs("div", { className: "load-table-popup__footer-actions", children: [_jsx(Button, { size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation, width: "fit-content" }), _jsx(Button, { size: 48, type: "bar", variant: "main", label: t('wordTable.loadTable.loadButton', '불러오기'), width: "fit-content", disabled: !selected, onClick: () => handleLoadSelected(animateThen) })] })), children: _jsx("div", { className: "load-table-popup__body", children: tables.length === 0 ? (_jsx("p", { className: "load-table-popup__empty body-2", children: t('wordTable.loadTable.empty', '저장된 단어 표가 없습니다.') })) : (_jsxs(_Fragment, { children: [viewMode === 'grid' && (_jsx("ul", { className: "load-table-popup__grid", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__card${selected === table.id ? ' load-table-popup__card--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') }), _jsx(TableThumbnail, { table: table }), _jsxs("div", { className: "load-table-popup__card-info", children: [_jsx("span", { className: "load-table-popup__card-name body-2", children: table.name }), _jsxs("span", { className: "load-table-popup__card-meta caption-1", children: [formatDate(table.savedAt), " \u00B7 ", table.items.length, "\uAC1C"] })] })] }, table.id))) })), viewMode === 'list' && (_jsx("ul", { className: "load-table-popup__list", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__row${selected === table.id ? ' load-table-popup__row--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx("span", { className: "load-table-popup__row-check", children: selected === table.id && (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", children: _jsx("polyline", { points: "4 13 9 18 20 6", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" }) })) }), _jsxs("div", { className: "load-table-popup__row-main", children: [_jsx("span", { className: "load-table-popup__row-name body-2", children: table.name }), _jsx("div", { className: "load-table-popup__row-words", children: table.items.map((item, i) => (_jsx("span", { className: "load-table-popup__row-word caption-1", children: item.words.length >= 2
84
+ return (_jsx(Popup, { className: "popup--load-table", title: t('wordTable.loadTable.title', '저장된 단어 표 불러오기'), headerActions: headerActions, onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs("div", { className: "load-table-popup__footer-actions", children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation, width: "fit-content" }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: t('wordTable.loadTable.loadButton', '불러오기'), width: "fit-content", disabled: !selected, onClick: () => handleLoadSelected(animateThen) })] })), children: _jsx("div", { className: "load-table-popup__body", children: tables.length === 0 ? (_jsx("p", { className: "load-table-popup__empty body-2", children: t('wordTable.loadTable.empty', '저장된 단어 표가 없습니다.') })) : (_jsxs(_Fragment, { children: [viewMode === 'grid' && (_jsx("ul", { className: "load-table-popup__grid", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__card${selected === table.id ? ' load-table-popup__card--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') }), _jsx(TableThumbnail, { table: table }), _jsxs("div", { className: "load-table-popup__card-info", children: [_jsx("span", { className: "load-table-popup__card-name body-2", children: table.name }), _jsxs("span", { className: "load-table-popup__card-meta caption-1", children: [formatDate(table.savedAt), " \u00B7 ", table.items.length, "\uAC1C"] })] })] }, table.id))) })), viewMode === 'list' && (_jsx("ul", { className: "load-table-popup__list", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__row${selected === table.id ? ' load-table-popup__row--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx("span", { className: "load-table-popup__row-check", children: selected === table.id && (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", children: _jsx("polyline", { points: "4 13 9 18 20 6", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" }) })) }), _jsxs("div", { className: "load-table-popup__row-main", children: [_jsx("span", { className: "load-table-popup__row-name body-2", children: table.name }), _jsx("div", { className: "load-table-popup__row-words", children: table.items.map((item, i) => (_jsx("span", { className: "load-table-popup__row-word caption-1", children: item.words.length >= 2
85
85
  ? `${item.words[0].label} · ${item.words[1].label}`
86
86
  : item.words[0]?.label ?? item.id }, i))) })] }), _jsxs("span", { className: "load-table-popup__row-count caption-1", children: [table.items.length, "\uAC1C"] }), _jsx("span", { className: "load-table-popup__row-date caption-1", children: formatDate(table.savedAt) }), _jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') })] }, table.id))) }))] })) }) }));
87
87
  }
@@ -93,7 +93,14 @@ export default function Popup(props) {
93
93
  const first = panel.querySelector(FOCUSABLE_SELECTOR);
94
94
  (first ?? panel).focus();
95
95
  }
96
- return () => { previouslyFocused?.focus?.(); };
96
+ return () => {
97
+ /* 포커스가 **아직 이 창 안이거나 갈 곳을 잃었을 때만** 되돌린다. 닫으면서 다음 창을 여는 흐름
98
+ (미리보기 → 삭제 확인)에서는 닫힘 애니메이션(260ms) 뒤에 여기가 돌 때 포커스가 이미 새 창에 있는데,
99
+ 전에는 그것을 뒤 화면의 여는 단추로 빼앗아 Tab 가둠이 풀리고 Enter 가 뒤 화면을 눌렀다. */
100
+ const active = document.activeElement;
101
+ if (!active || active === document.body || (panel && panel.contains(active)))
102
+ previouslyFocused?.focus?.();
103
+ };
97
104
  }, [inline]);
98
105
  /* Tab 순환을 패널 안으로 가둔다 — 네 곳에 있던 같은 열두 줄을 훅 한 벌로 모았다.
99
106
  inline 으로 쓸 때는 다이얼로그가 아니라 걸지 않는다(예전과 같다). */
@@ -147,9 +154,14 @@ export default function Popup(props) {
147
154
  if (pressed && e.target === e.currentTarget)
148
155
  closeWithAnimation();
149
156
  };
150
- const el = (_jsx("div", { className: `popup ${inline ? 'popup--inline' : ''} ${closing ? 'popup--closing' : ''} ${className}`, onPointerDown: backdropEnabled ? onBackdropPointerDown : undefined, onClick: backdropEnabled ? onBackdropClick : undefined, children: _jsxs("div", { ref: panelRef, className: `popup-panel${size ? ` popup-panel--${size}` : ''}`,
157
+ const el = (_jsx("div", { className: `popup ${inline ? 'popup--inline' : ''} ${closing ? 'popup--closing' : ''} ${className}`, onPointerDown: backdropEnabled ? onBackdropPointerDown : undefined, onClick: backdropEnabled ? onBackdropClick : undefined,
158
+ /* 바탕을 눌러도 포커스는 판 안에 둔다 — 닫히지 않는 바탕(closeOnBackdrop={false}·closeDisabled·beforeClose 가
159
+ 거절)을 누르면 포커스가 body 로 떨어져, 이어서 누른 Tab 이 뒤 화면으로 나갔다(가둠은 판 안의 키만 듣는다).
160
+ mousedown 의 기본 동작(포커스 옮김)만 막고 click 은 그대로 온다. */
161
+ onMouseDown: inline ? undefined : (e) => { if (e.target === e.currentTarget)
162
+ e.preventDefault(); }, children: _jsxs("div", { ref: panelRef, className: `popup-panel${size ? ` popup-panel--${size}` : ''}`,
151
163
  /* max-height(CSS)가 height 보다 이기므로 화면보다 크게 줘도 화면에 맞춰진다 */
152
- style: height != null ? { height } : undefined, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && title ? titleId : undefined, "aria-label": !inline && !title && ariaLabel ? ariaLabel : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') })] })) : (title || headerActions || onClose) ? (_jsxs("div", { className: `popup-header${subtitle ? ' popup-header--with-subtitle' : ''}`, children: [_jsxs("div", { className: "popup-header-main", children: [_jsx("span", { id: titleId, className: "popup-title popup-title--left h2", children: title }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), _jsxs("div", { className: "popup-header-actions", children: [headerActions, onClose && (_jsx(Button, { size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') }))] })] })) : null, _jsx("div", { className: "popup-content", ref: contentRef, tabIndex: contentScrollable ? 0 : undefined, children: _jsx("div", { className: "popup-content__inner", children: typeof children === 'function' ? children({ animateThen, closeWithAnimation }) : children }) }), buttons?.length ? (_jsx("div", { className: "popup-footer popup-footer--buttons", children: buttons.map((btn, i) => (_jsx(Button, { size: 48, variant: btn.variant ?? 'default', label: btn.label, disabled: btn.disabled, onClick: () => btn.onClick?.({ animateThen, closeWithAnimation }) }, i))) })) : footer ? (_jsx("div", { className: `popup-footer${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
164
+ style: height != null ? { height } : undefined, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && title ? titleId : undefined, "aria-label": !inline && !title && ariaLabel ? ariaLabel : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') })] })) : (title || headerActions || onClose) ? (_jsxs("div", { className: `popup-header${subtitle ? ' popup-header--with-subtitle' : ''}`, children: [_jsxs("div", { className: "popup-header-main", children: [_jsx("span", { id: titleId, className: "popup-title popup-title--left h2", children: title }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), _jsxs("div", { className: "popup-header-actions", children: [headerActions, onClose && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') }))] })] })) : null, _jsx("div", { className: "popup-content", ref: contentRef, tabIndex: contentScrollable ? 0 : undefined, children: _jsx("div", { className: "popup-content__inner", children: typeof children === 'function' ? children({ animateThen, closeWithAnimation }) : children }) }), buttons?.length ? (_jsx("div", { className: "popup-footer popup-footer--buttons", children: buttons.map((btn, i) => (_jsx(Button, { htmlType: "button", size: 48, variant: btn.variant ?? 'default', label: btn.label, disabled: btn.disabled, onClick: () => btn.onClick?.({ animateThen, closeWithAnimation }) }, i))) })) : footer ? (_jsx("div", { className: `popup-footer${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
153
165
  if (inline || typeof document === 'undefined')
154
166
  return el;
155
167
  return createPortal(el, document.body);
@@ -46,7 +46,7 @@ export default function SaveTablePopup({ title, saveLabel, defaultName = '', def
46
46
  if (name.trim() && !isDuplicateName)
47
47
  onSave(name.trim(), description.trim() || undefined);
48
48
  }, [name, description, isDuplicateName, onSave]);
49
- return (_jsx(Popup, { className: "popup--save-table", title: title ?? t('wordTable.save.title', '단어표 저장하기'), onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx(Button, { size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation }), _jsx(Button, { size: 48, type: "bar", variant: "main", label: saveLabel ?? t('common.save', '저장'), disabled: !name.trim() || isDuplicateName, onClick: () => handleSave(animateThen) })] })), children: _jsxs("div", { className: "save-table-popup__body", children: [preview && (_jsx("div", { ref: containerRef, className: "save-table-popup__preview-wrap", style: { height: previewState.fixedH }, children: _jsx("div", { className: "save-table-popup__preview", style: {
49
+ return (_jsx(Popup, { className: "popup--save-table", title: title ?? t('wordTable.save.title', '단어표 저장하기'), onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: saveLabel ?? t('common.save', '저장'), disabled: !name.trim() || isDuplicateName, onClick: () => handleSave(animateThen) })] })), children: _jsxs("div", { className: "save-table-popup__body", children: [preview && (_jsx("div", { ref: containerRef, className: "save-table-popup__preview-wrap", style: { height: previewState.fixedH }, children: _jsx("div", { className: "save-table-popup__preview", style: {
50
50
  width: previewW, height: previewH,
51
51
  transform: `scale(${previewState.scale})`,
52
52
  top: previewState.offsetY,
@@ -101,5 +101,5 @@ export default function TrainingResultPopup(props) {
101
101
  /today · /training · /my-word-training · /my-homework). 화면에 보이는 그
102
102
  제목을 그대로 접근명으로 쓴다. 새 문구를 만들면 눈으로 보는 이름과 읽어
103
103
  주는 이름이 갈라진다. */
104
- ariaLabel: t('training.result.title', '훈련을 모두 마쳤습니다.'), className: "popup--training-result", footerClassName: "trp-result__foot", footer: ({ animateThen, closeWithAnimation }) => (_jsxs("div", { className: "trp-actions", children: [_jsx(Button, { size: 56, type: "bar", label: t('training.result.replay', '다시하기'), onClick: () => animateThen(onReplay) }), _jsx(Button, { size: 56, type: "bar", label: t('training.result.done', '완료'), variant: "main", onClick: closeWithAnimation })] })), children: () => (_jsxs("div", { className: "trp-result is-on", children: [_jsxs("header", { className: "trp-result__head", children: [_jsx("p", { className: "trp-result__eyebrow caption-1", children: t('training.result.eyebrow', '훈련 완료') }), _jsx("h1", { className: "trp-result__title h2", children: t('training.result.title', '훈련을 모두 마쳤습니다.') }), subtitle && _jsx("p", { className: "trp-result__subtitle body-3", children: subtitle }), _jsx("div", { className: "trp-score", children: _jsxs("div", { className: "trp-score__figure", "aria-label": t('training.result.scoreAria', { correct, total }), children: [_jsx("span", { className: "trp-score__big", children: correct }), _jsx("span", { className: "trp-score__sep", children: "/" }), _jsx("span", { className: "trp-score__total", children: total })] }) })] }), rows.length > 0 && (_jsx("section", { className: "trp-result__body", children: _jsxs("div", { className: `result-table${hasAnswerCols ? ' result-table--answer' : ''}`, role: "table", "aria-label": t('training.result.tableAriaLabel', '문항별 판정'), children: [_jsxs("div", { className: "result-table__head caption-1", role: "row", children: [_jsx("span", { role: "columnheader", children: t('common.col.problem', '문제') }), hasAnswerCols && _jsx("span", { role: "columnheader", children: t('training.result.colAnswerSelected', '정답 · 선택') }), _jsx("span", { className: "col-right", role: "columnheader", children: t('training.result.colJudge', '판정') })] }), rows.map((r, i) => (_jsxs("div", { className: "result-table__row", role: "row", children: [_jsxs("div", { className: "trp-row__problem", children: [_jsx("span", { className: "trp-row__no caption-1", children: String(i + 1).padStart(2, '0') }), _jsx("div", { className: "trp-row__q body-2", children: r.problem })] }), hasAnswerCols && (_jsxs("div", { className: "trp-row__answers", children: [r.answer !== null && (_jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: "trp-row__answer-label answer-correct caption-1", children: t('training.result.colAnswer', '정답') }), _jsx("span", { className: "trp-row__answer-value body-2", children: r.answer })] })), _jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: `trp-row__answer-label answer-selected caption-1 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: t('training.result.colSelected', '선택') }), _jsx("span", { className: `trp-row__answer-value body-2 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: r.selectedText ?? '—' })] })] })), _jsx("div", { className: "trp-row__judge", children: _jsx(Badge, { variant: r.variant, label: r.label, fill: "soft", minWidth: 80 }) })] }, i)))] }) }))] })) }));
104
+ ariaLabel: t('training.result.title', '훈련을 모두 마쳤습니다.'), className: "popup--training-result", footerClassName: "trp-result__foot", footer: ({ animateThen, closeWithAnimation }) => (_jsxs("div", { className: "trp-actions", children: [_jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.replay', '다시하기'), onClick: () => animateThen(onReplay) }), _jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.done', '완료'), variant: "main", onClick: closeWithAnimation })] })), children: () => (_jsxs("div", { className: "trp-result is-on", children: [_jsxs("header", { className: "trp-result__head", children: [_jsx("p", { className: "trp-result__eyebrow caption-1", children: t('training.result.eyebrow', '훈련 완료') }), _jsx("h1", { className: "trp-result__title h2", children: t('training.result.title', '훈련을 모두 마쳤습니다.') }), subtitle && _jsx("p", { className: "trp-result__subtitle body-3", children: subtitle }), _jsx("div", { className: "trp-score", children: _jsxs("div", { className: "trp-score__figure", "aria-label": t('training.result.scoreAria', { correct, total }), children: [_jsx("span", { className: "trp-score__big", children: correct }), _jsx("span", { className: "trp-score__sep", children: "/" }), _jsx("span", { className: "trp-score__total", children: total })] }) })] }), rows.length > 0 && (_jsx("section", { className: "trp-result__body", children: _jsxs("div", { className: `result-table${hasAnswerCols ? ' result-table--answer' : ''}`, role: "table", "aria-label": t('training.result.tableAriaLabel', '문항별 판정'), children: [_jsxs("div", { className: "result-table__head caption-1", role: "row", children: [_jsx("span", { role: "columnheader", children: t('common.col.problem', '문제') }), hasAnswerCols && _jsx("span", { role: "columnheader", children: t('training.result.colAnswerSelected', '정답 · 선택') }), _jsx("span", { className: "col-right", role: "columnheader", children: t('training.result.colJudge', '판정') })] }), rows.map((r, i) => (_jsxs("div", { className: "result-table__row", role: "row", children: [_jsxs("div", { className: "trp-row__problem", children: [_jsx("span", { className: "trp-row__no caption-1", children: String(i + 1).padStart(2, '0') }), _jsx("div", { className: "trp-row__q body-2", children: r.problem })] }), hasAnswerCols && (_jsxs("div", { className: "trp-row__answers", children: [r.answer !== null && (_jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: "trp-row__answer-label answer-correct caption-1", children: t('training.result.colAnswer', '정답') }), _jsx("span", { className: "trp-row__answer-value body-2", children: r.answer })] })), _jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: `trp-row__answer-label answer-selected caption-1 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: t('training.result.colSelected', '선택') }), _jsx("span", { className: `trp-row__answer-value body-2 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: r.selectedText ?? '—' })] })] })), _jsx("div", { className: "trp-row__judge", children: _jsx(Badge, { variant: r.variant, label: r.label, fill: "soft", minWidth: 80 }) })] }, i)))] }) }))] })) }));
105
105
  }
@@ -71,8 +71,8 @@ export default function WordDataUpdatePopup({ title, progress, onDone, onRetry,
71
71
  /* 오류인데 다시 시도할 곳이 없으면 '다시 시도' 가 아무 일도 안 하고, 이 팝업은 X 도
72
72
  Esc 도 없어 **빠져나갈 길이 없었다**. 그때만 닫기 단추로 바꾼다. */
73
73
  isError && onRetry
74
- ? _jsx(Button, { size: 56, variant: "main", onClick: onRetry, children: t('common.retry', '다시 시도') })
74
+ ? _jsx(Button, { htmlType: "button", size: 56, variant: "main", onClick: onRetry, children: t('common.retry', '다시 시도') })
75
75
  : isError
76
- ? _jsx(Button, { size: 56, variant: "main", onClick: onClose ?? onDone, children: t('common.close', '닫기') })
77
- : _jsx(Button, { size: 56, variant: "main", disabled: !isDone, onClick: onDone, children: t('common.done', '완료') }), children: _jsxs("div", { className: "wdup-cards", children: [_jsx(PhaseCard, { icon: _jsx(Database, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseWordDownload', '단어 데이터 다운로드'), state: wordState, subText: wordState === 'active' ? t('wordData.downloadingCount', { n: formatCount(wordCount) }) : undefined }), _jsx(PhaseCard, { icon: _jsx(ImageIcon, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseImageCheck', '이미지 확인'), state: imgState, pct: imgPct, subText: imgSub }), _jsx(PhaseCard, { icon: _jsx(GitMerge, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phasePairAnalysis', '단어쌍 분석'), state: pairState, pct: pairState === 'active' ? (pairPercent ?? 0) : undefined, subText: undefined })] }) }));
76
+ ? _jsx(Button, { htmlType: "button", size: 56, variant: "main", onClick: onClose ?? onDone, children: t('common.close', '닫기') })
77
+ : _jsx(Button, { htmlType: "button", size: 56, variant: "main", disabled: !isDone, onClick: onDone, children: t('common.done', '완료') }), children: _jsxs("div", { className: "wdup-cards", children: [_jsx(PhaseCard, { icon: _jsx(Database, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseWordDownload', '단어 데이터 다운로드'), state: wordState, subText: wordState === 'active' ? t('wordData.downloadingCount', { n: formatCount(wordCount) }) : undefined }), _jsx(PhaseCard, { icon: _jsx(ImageIcon, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseImageCheck', '이미지 확인'), state: imgState, pct: imgPct, subText: imgSub }), _jsx(PhaseCard, { icon: _jsx(GitMerge, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phasePairAnalysis', '단어쌍 분석'), state: pairState, pct: pairState === 'active' ? (pairPercent ?? 0) : undefined, subText: undefined })] }) }));
78
78
  }
@@ -65,7 +65,7 @@ export default function PromoBanner({ title, description, actionLabel, onAction,
65
65
  */
66
66
  const cls = cx('promo-banner', `promo-banner--${size}`, className);
67
67
  const Bg = PROMO_BANNER_BG_COMPONENTS[resolvedBg];
68
- return (_jsxs("div", { ref: rootRef, className: cls, role: "note", children: [_jsx(Bg, {}), _jsxs("div", { className: "promo-banner__text", children: [_jsx("span", { className: `promo-banner__title ${size === 'small' ? 'body-2' : 'h2'}`, children: title }), description && _jsx("span", { className: "promo-banner__description body-3", children: description })] }), actionLabel && onAction && (_jsx(Button, { size: 40, variant: actionVariant ?? resolvedActionVariant, shape: "pill", useBorder: true, onClick: onAction, ariaLabel: actionLabel, ...(isMobile
68
+ return (_jsxs("div", { ref: rootRef, className: cls, role: "note", children: [_jsx(Bg, {}), _jsxs("div", { className: "promo-banner__text", children: [_jsx("span", { className: `promo-banner__title ${size === 'small' ? 'body-2' : 'h2'}`, children: title }), description && _jsx("span", { className: "promo-banner__description body-3", children: description })] }), actionLabel && onAction && (_jsx(Button, { htmlType: "button", size: 40, variant: actionVariant ?? resolvedActionVariant, shape: "pill", useBorder: true, onClick: onAction, ariaLabel: actionLabel, ...(isMobile
69
69
  ? { type: 'dot', icon: 'arrow-right' }
70
70
  : { rightIcon: 'arrow-right', label: actionLabel, minWidth: 140 }) }))] }));
71
71
  }
@@ -54,6 +54,6 @@ export default function SegmentedControl(props) {
54
54
  };
55
55
  /* Tab 자리를 받을 한 칸 — 고른 것이 있으면 그것, 없으면 고를 수 있는 첫 칸 */
56
56
  const 탭자리 = 고를수있는.some((o) => o.key === value) ? value : 고를수있는[0]?.key;
57
- return (_jsx("div", { ref: 그룹, onKeyDown: 키, className: cx('segmented-control', `segmented-control--${shape}`, `segmented-control--${size}`, fullWidth && 'segmented-control--full', !useBG && 'segmented-control--no-bg', className), role: "radiogroup", "aria-label": ariaLabel ?? t('segmentedControl.ariaLabel', '선택 탭'), children: options.map((opt) => (_jsx(Button, { role: "radio", "aria-checked": opt.key === value, "data-segkey": opt.key, tabIndex: opt.key === 탭자리 ? 0 : -1, label: opt.label, count: opt.count, disabled: opt.disabled, className: opt.key === value ? 'is-selected' : '', onClick: () => { if (!opt.disabled)
57
+ return (_jsx("div", { ref: 그룹, onKeyDown: 키, className: cx('segmented-control', `segmented-control--${shape}`, `segmented-control--${size}`, fullWidth && 'segmented-control--full', !useBG && 'segmented-control--no-bg', className), role: "radiogroup", "aria-label": ariaLabel ?? t('segmentedControl.ariaLabel', '선택 탭'), children: options.map((opt) => (_jsx(Button, { htmlType: "button", role: "radio", "aria-checked": opt.key === value, "data-segkey": opt.key, tabIndex: opt.key === 탭자리 ? 0 : -1, label: opt.label, count: opt.count, disabled: opt.disabled, className: opt.key === value ? 'is-selected' : '', onClick: () => { if (!opt.disabled)
58
58
  onChange(opt.key); }, size: size, shape: shape, useBorder: false, useBG: false }, opt.key))) }));
59
59
  }
@@ -24,7 +24,7 @@ function SideNavFooter(props) {
24
24
  // 하나도 없으면 바닥 자체를 비운다 — 빈 채로 두면 여백(margin-top + padding)만 남는다.
25
25
  if (!그릴것)
26
26
  return null;
27
- return (_jsxs("div", { className: "side-nav__footer", children: [onDebug && (_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "terminal", ariaLabel: t('nav.debug', '디버그'), onClick: onDebug, selected: activeKey === 'debug' })), onNotification && (_jsxs("span", { className: "side-nav__notification-wrap", children: [_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "bell", ariaLabel: nameWithCount(t, t('nav.notifications', '알림'), unreadCount), onClick: onNotification, selected: activeKey === 'notifications' }), !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true }))] })), onSetting && (_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "settings", ariaLabel: t('nav.settings', '설정'), onClick: onSetting, selected: activeKey === 'settings' })), onHelp && (_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "message-circle-question-mark", ariaLabel: t('nav.help', '도움말'), onClick: onHelp, selected: activeKey === 'help' })), onAccount && (_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "user-round", ariaLabel: t('nav.account', '계정'), onClick: onAccount, selected: activeKey === 'account' }))] }));
27
+ return (_jsxs("div", { className: "side-nav__footer", children: [onDebug && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "terminal", ariaLabel: t('nav.debug', '디버그'), onClick: onDebug, selected: activeKey === 'debug' })), onNotification && (_jsxs("span", { className: "side-nav__notification-wrap", children: [_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "bell", ariaLabel: nameWithCount(t, t('nav.notifications', '알림'), unreadCount), onClick: onNotification, selected: activeKey === 'notifications' }), !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true }))] })), onSetting && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "settings", ariaLabel: t('nav.settings', '설정'), onClick: onSetting, selected: activeKey === 'settings' })), onHelp && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "message-circle-question-mark", ariaLabel: t('nav.help', '도움말'), onClick: onHelp, selected: activeKey === 'help' })), onAccount && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "user-round", ariaLabel: t('nav.account', '계정'), onClick: onAccount, selected: activeKey === 'account' }))] }));
28
28
  }
29
29
  function SideNavButton(props) {
30
30
  const { t } = useTranslation();
@@ -51,7 +51,7 @@ function SideNavButton(props) {
51
51
  }
52
52
  onClick?.(key, e.currentTarget, fromPointer);
53
53
  }, [isDisabled, key, onClick]);
54
- return (_jsxs("button", { type: "button", className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? nameWithCount(t, text, badgeCount) : undefined, style: style, "aria-disabled": isDisabled, children: [iconElement, _jsx("span", { className: `side-nav__label button-1 f`, style: labelStyle, children: text }), badgeCount > 0 && (_jsx(Badge, { type: "dot", position: "inline", number: Math.min(badgeCount, 99), size: 24, "aria-hidden": true })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", "aria-label": "loading" }))] }));
54
+ return (_jsxs("button", { type: "button", className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? nameWithCount(t, text, badgeCount) : undefined, style: style, "aria-disabled": isDisabled, children: [iconElement, _jsx("span", { className: `side-nav__label button-1 f`, style: labelStyle, children: text }), badgeCount > 0 && (_jsx(Badge, { type: "dot", position: "inline", number: Math.min(badgeCount, 99), size: 24, "aria-hidden": true })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", role: "img", "aria-label": t('loader.loading', '불러오는 중…') }))] }));
55
55
  }
56
56
  /** @public */
57
57
  export default function SideNav(props) {
@@ -6,6 +6,7 @@
6
6
  'use client';
7
7
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  import { useMemo } from 'react';
9
+ import { useTranslation } from 'react-i18next';
9
10
  import Check from 'lucide-react/dist/esm/icons/check.mjs';
10
11
  import Volume2 from 'lucide-react/dist/esm/icons/volume-2.mjs';
11
12
  import User from 'lucide-react/dist/esm/icons/user.mjs';
@@ -15,6 +16,7 @@ import './SoundCompass.css';
15
16
  const lerp = (a, b, t) => a + (b - a) * t;
16
17
  /** @public */
17
18
  export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSpeakerId = null, playedIds, selectedId = null, answerId = null, showFeedback = false, pulseSelected = false, pulseSelectedDuration, pulseScale, clickable = false, onSelectSpeaker, buttonSize = 40, showIcon = true, onCenterClick, centerLabel, centerDisabled = false, centerAttention = false, shortcutAnchor = 'top-right', shortcutScale, scaleShortcut = false, }) {
19
+ const { t: 번역 } = useTranslation();
18
20
  const t = Math.max(0, Math.min(1, viewT));
19
21
  const CENTER = size / 2;
20
22
  // ── 탑뷰 좌표 ──────────────────────────────────────────────────────────
@@ -119,6 +121,13 @@ export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSp
119
121
  }
120
122
  // ── 스피커 시각 상태를 props에서 파생 ──────────────────────────────────
121
123
  const played = useMemo(() => new Set(playedIds ?? []), [playedIds]);
124
+ const stateLabel = (state) => ({
125
+ idle: '',
126
+ active: 번역('soundCompass.state.active', '소리 나는 중'),
127
+ played: 번역('soundCompass.state.played', '들음'),
128
+ correct: 번역('soundCompass.state.correct', '정답'),
129
+ wrong: 번역('soundCompass.state.wrong', '오답'),
130
+ })[state];
122
131
  const getSpeakerState = (sp) => {
123
132
  if (activeSpeakerId === sp.id)
124
133
  return 'active';
@@ -209,6 +218,6 @@ export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSp
209
218
  ...(pulseScale !== undefined
210
219
  ? { ['--sc-pulse-scale']: String(pulseScale) }
211
220
  : null),
212
- } })), _jsxs("button", { type: "button", tabIndex: clickable ? 0 : -1, "aria-disabled": clickable ? undefined : true, onClick: clickable ? () => onSelectSpeaker?.(sp.id) : undefined, className: `sound-compass__speaker-btn sound-compass__speaker-btn--${state}`, style: { width: BTN, height: BTN }, "aria-label": sp.label, children: [withIcon && getStateIcon(state, iconSize), withNumber && (_jsx("span", { className: "sound-compass__speaker-number", style: { fontSize: showIcon === true ? numberSize : numberOnlySize }, children: idx + 1 }))] }), sp.shortcut && (_jsx(ShortcutBadge, { keys: sp.shortcut, anchor: shortcutAnchor, className: "sound-compass__speaker-shortcut" }))] }, sp.id));
221
+ } })), _jsxs("button", { type: "button", tabIndex: clickable ? 0 : -1, "aria-disabled": clickable ? undefined : true, onClick: clickable ? () => onSelectSpeaker?.(sp.id) : undefined, className: `sound-compass__speaker-btn sound-compass__speaker-btn--${state}`, style: { width: BTN, height: BTN }, "aria-label": stateLabel(state) ? `${sp.label}, ${stateLabel(state)}` : sp.label, "aria-pressed": selectedId != null ? sp.id === selectedId : undefined, children: [withIcon && getStateIcon(state, iconSize), withNumber && (_jsx("span", { className: "sound-compass__speaker-number", style: { fontSize: showIcon === true ? numberSize : numberOnlySize }, children: idx + 1 }))] }), sp.shortcut && (_jsx(ShortcutBadge, { keys: sp.shortcut, anchor: shortcutAnchor, className: "sound-compass__speaker-shortcut" }))] }, sp.id));
213
222
  })] }) }));
214
223
  }
@@ -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,9 +84,14 @@ 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
- ? _jsx(Badge, { type: "dot", position: "inline", number: Math.min(item.badgeCount, 99), size: 24, "aria-label": `${item.badgeCount}` })
91
- : undefined, label: item.label, ariaLabel: item.label, onClick: () => handleClick(item), selected: item.selected, disabled: item.disabled, showTooltipInfo: item.tooltip || false, useBorder: false, align: "left" }, idx))) })] }, ci))) })] }));
90
+ ? _jsx(Badge, { type: "dot", position: "inline", number: Math.min(item.badgeCount, 99), size: 24, "aria-hidden": true })
91
+ : undefined, label: item.label,
92
+ /* 개수는 이름에 붙인다 — ariaLabel 이 글자뿐이라 배지 수가 이름에서 빠져 "알림" 으로만 읽혔다
93
+ (SideNav·BottomNav 와 같은 nav.badgeLabel "알림, 5개"). */
94
+ ariaLabel: item.label && item.badgeCount && item.badgeCount > 0
95
+ ? t('nav.badgeLabel', { label: item.label, count: item.badgeCount, defaultValue: '{{label}}, {{count}}개' })
96
+ : item.label, onClick: () => handleClick(item), selected: item.selected, disabled: item.disabled, showTooltipInfo: item.tooltip || false, useBorder: false, align: "left" }, idx))) })] }, ci))) })] }));
92
97
  }
@@ -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 })