@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.
- package/CHANGELOG.md +129 -0
- package/dist/components.cjs.js +177 -177
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +2 -2
- package/dist/ihabdevteam-core.cjs.js +182 -182
- package/dist/ihabdevteam-core.d.ts +8 -13
- package/dist/packages/core/src/components/BarChart.js +2 -2
- package/dist/packages/core/src/components/BottomNav.js +3 -3
- package/dist/packages/core/src/components/Breadcrumb.js +5 -1
- package/dist/packages/core/src/components/BulkBar.js +1 -1
- package/dist/packages/core/src/components/ChoiceChips.js +1 -1
- package/dist/packages/core/src/components/CodeBox.js +1 -1
- package/dist/packages/core/src/components/DatePicker.js +16 -3
- package/dist/packages/core/src/components/Dialog.js +32 -9
- package/dist/packages/core/src/components/Dropdown.js +13 -3
- package/dist/packages/core/src/components/FileUpload.js +8 -7
- package/dist/packages/core/src/components/FilterPanel.js +8 -5
- package/dist/packages/core/src/components/FilterSectionInfo.js +1 -1
- package/dist/packages/core/src/components/Header.css +2 -0
- package/dist/packages/core/src/components/Header.js +4 -1
- package/dist/packages/core/src/components/HearingDeviceInfo.js +1 -1
- package/dist/packages/core/src/components/LandingHeader.js +18 -1
- package/dist/packages/core/src/components/PageTitle.js +1 -1
- package/dist/packages/core/src/components/Pagination.js +6 -3
- package/dist/packages/core/src/components/Panel.d.ts +1 -1
- package/dist/packages/core/src/components/Panel.js +4 -1
- package/dist/packages/core/src/components/PanelRow.js +3 -3
- package/dist/packages/core/src/components/PassCard/passCardProduct.js +6 -0
- package/dist/packages/core/src/components/PaymentPlan.d.ts +6 -0
- package/dist/packages/core/src/components/PaymentPlan.js +6 -4
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +2 -2
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +2 -2
- package/dist/packages/core/src/components/Popup/Popup.js +15 -3
- package/dist/packages/core/src/components/Popup/SaveTablePopup.js +1 -1
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +1 -1
- package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +3 -3
- package/dist/packages/core/src/components/PromoBanner.js +1 -1
- package/dist/packages/core/src/components/SegmentedControl.js +1 -1
- package/dist/packages/core/src/components/SideNav.js +2 -2
- package/dist/packages/core/src/components/SoundCompass.js +10 -1
- package/dist/packages/core/src/components/Table.js +2 -2
- package/dist/packages/core/src/components/Tabs.js +7 -2
- package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +8 -3
- package/dist/packages/core/src/components/Tooltip/TextBoxTooltip.js +1 -1
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +13 -5
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingBlock.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +51 -23
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +26 -5
- package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingStepTitle.js +1 -1
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +11 -6
- package/dist/packages/core/src/components/Training/useAudioPreview.js +7 -3
- package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -1
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +9 -0
- package/dist/packages/core/src/components/UpdateAlert.js +1 -1
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +2 -2
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +6 -5
- package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
- package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
- package/dist/packages/core/src/components/WordTable.js +6 -6
- package/dist/packages/core/src/components/useToastLifecycle.js +8 -1
- package/dist/packages/core/src/hooks/useAnchoredMenu.js +10 -9
- package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +0 -11
- package/dist/packages/core/src/hooks/useDocumentTitle.js +16 -3
- package/dist/packages/core/src/hooks/useFocusTrap.js +6 -2
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +9 -4
- package/dist/packages/core/src/hooks/wordTrainingFilterModel.d.ts +1 -1
- package/dist/packages/core/src/hooks/wordTrainingFilterModel.js +7 -2
- package/dist/packages/core/src/i18n/locales/en.json +11 -0
- package/dist/packages/core/src/i18n/locales/ko.json +11 -0
- package/dist/packages/core/src/utils/audioPeaks.js +13 -5
- package/dist/packages/core/src/utils/csv.js +3 -1
- package/dist/packages/core/src/utils/format.js +17 -1
- package/dist/packages/core/src/utils/koreanPronunciation.js +7 -0
- package/dist/packages/core/src/utils/koreanSyllable.js +2 -1
- package/dist/packages/core/src/utils/pressable.js +4 -0
- package/dist/packages/core/src/utils/savedWordTables.js +2 -1
- package/dist/packages/core/src/utils/textSort.d.ts +0 -10
- package/dist/packages/core/src/utils/textSort.js +7 -2
- package/dist/packages/core/src/utils/wordTrainingPool.d.ts +7 -0
- package/dist/packages/core/src/utils/wordTrainingPool.js +30 -0
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +55 -17
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +2 -2
- 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
|
|
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
|
-
|
|
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 =
|
|
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 () => {
|
|
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,
|
|
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":
|
|
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
|
-
|
|
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 ===
|
|
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-
|
|
91
|
-
: undefined, label: item.label,
|
|
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 })
|