@ihabdevteam/core 0.138.0 → 0.140.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 +58 -0
- package/dist/components.cjs.js +172 -172
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +20 -20
- package/dist/ihabdevteam-core.d.ts +1 -1
- package/dist/packages/core/src/components/BarChart.js +21 -1
- package/dist/packages/core/src/components/BottomNav.js +4 -2
- package/dist/packages/core/src/components/DatePicker.js +8 -3
- package/dist/packages/core/src/components/Dropdown.js +1 -1
- package/dist/packages/core/src/components/PassCard/PassCard.js +9 -5
- package/dist/packages/core/src/components/Popup/Popup.js +26 -3
- package/dist/packages/core/src/components/Table.js +3 -0
- package/dist/packages/core/src/components/Toast.js +6 -1
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +30 -6
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +10 -1
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +8 -1
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +2 -1
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +1 -1
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +4 -0
- package/dist/packages/core/src/components/barChartTypes.js +3 -2
- package/dist/packages/core/src/components/useComboboxSuggestions.js +5 -1
- package/dist/packages/core/src/hooks/escStack.js +3 -0
- package/dist/packages/core/src/hooks/useAnchoredMenu.js +3 -0
- package/dist/packages/core/src/utils/speak.js +9 -0
- 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
|
@@ -1376,7 +1376,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1376
1376
|
*
|
|
1377
1377
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1378
1378
|
*/
|
|
1379
|
-
export declare const CORE_VERSION = "0.
|
|
1379
|
+
export declare const CORE_VERSION = "0.140.0";
|
|
1380
1380
|
|
|
1381
1381
|
/** @public */
|
|
1382
1382
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import './BarChart.css';
|
|
4
|
-
import { useState, useMemo, useCallback } from 'react';
|
|
4
|
+
import { useState, useMemo, useCallback, useEffect } from 'react';
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
6
|
import Dropdown from './Dropdown';
|
|
7
7
|
import SegmentedControl from './SegmentedControl';
|
|
@@ -40,6 +40,26 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, h
|
|
|
40
40
|
}, [padded]);
|
|
41
41
|
const [tooltip, setTooltip] = useState(null);
|
|
42
42
|
const handleBarLeave = useCallback(() => setTooltip(null), []);
|
|
43
|
+
/* 떠 있는 동안 **스크롤하거나 막대 밖을 누르면** 닫는다 — 터치에서는 톡이 호환 mouseenter 로 띄우고 mouseleave 는 다른 곳을
|
|
44
|
+
눌러야 와서, 스크롤해도 제자리에 떠 있었고 누를 수 없는 곳을 톡해도 안 닫혔다(말귀 훈련 현황·매니저 화면, 태블릿). */
|
|
45
|
+
const 떠있음 = tooltip != null;
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
if (!떠있음)
|
|
48
|
+
return;
|
|
49
|
+
const 닫기 = () => setTooltip(null);
|
|
50
|
+
const 바깥누름 = (e) => {
|
|
51
|
+
const t = e.target;
|
|
52
|
+
if (t && typeof t.closest === 'function' && t.closest('.manager-bar-chart-col'))
|
|
53
|
+
return;
|
|
54
|
+
setTooltip(null);
|
|
55
|
+
};
|
|
56
|
+
window.addEventListener('scroll', 닫기, true);
|
|
57
|
+
document.addEventListener('pointerdown', 바깥누름, true);
|
|
58
|
+
return () => {
|
|
59
|
+
window.removeEventListener('scroll', 닫기, true);
|
|
60
|
+
document.removeEventListener('pointerdown', 바깥누름, true);
|
|
61
|
+
};
|
|
62
|
+
}, [떠있음]);
|
|
43
63
|
return (_jsxs("div", { className: "manager-bar-chart-wrap", children: [_jsx("div", { className: "manager-bar-chart-inner", children: _jsx("div", { className: "manager-bar-chart-scroll", children: _jsxs("div", { className: "manager-bar-chart-area", children: [_jsxs("div", { className: "manager-bar-chart-plot", style: { height: h }, children: [ticks.slice(0, -1).map((t, i) => (
|
|
44
64
|
/* 눈금선은 **적힌 값의 높이**에 둔다 — 가운데 값을 반올림(최대 3 → '2')하면서 자리는 절반(1.5)에 두어 선과 숫자가 어긋났다 */
|
|
45
65
|
_jsx("div", { className: "manager-bar-chart-gridline", style: { top: (1 - t / padded) * h }, children: _jsx("span", { className: "manager-bar-chart-gridline__label caption-1", children: t }) }, i))), data.map((d, i) => {
|
|
@@ -97,8 +97,10 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
|
|
|
97
97
|
}], [hiddenItems, moreItems, activeKey, onChange, handleCloseMore]);
|
|
98
98
|
// "…" 단추 배지 — 메뉴 안에서만 보이는 개수(넘친 항목 + 유틸)를 닫힌 채로도 알린다.
|
|
99
99
|
const moreBadge = [...hiddenItems, ...moreItems].reduce((sum, it) => sum + badgeOf(it.badgeCount), 0);
|
|
100
|
-
return (_jsxs("nav", { className: "bottom-nav", "aria-label": t('nav.bottomNav', '하단 네비게이션'), children: [onAppModeChange && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__left", children: _jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: modeIcon, ariaLabel: modeLabel, className: "bottom-nav-item", onClick: handleModeChange }) }), _jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" })] })), _jsx("div", { className: "bottom-nav__items", ref: itemsRef, children: visibleItems.map((it) => {
|
|
100
|
+
return (_jsxs("nav", { className: "bottom-nav", "aria-label": t('nav.bottomNav', '하단 네비게이션'), children: [onAppModeChange && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__left", children: _jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: modeIcon, ariaLabel: modeLabel, className: "bottom-nav-item", onClick: handleModeChange }) }), _jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" })] })), _jsx("div", { className: "bottom-nav__items", ref: itemsRef, children: visibleItems.map((it, i) => {
|
|
101
101
|
const count = badgeOf(it.badgeCount);
|
|
102
|
-
return (
|
|
102
|
+
return (
|
|
103
|
+
// key 는 선택 prop 이다 — 없으면 자리 번호로(형제 key 가 모두 undefined 가 되던 것)
|
|
104
|
+
_jsxs("span", { className: "bottom-nav__slot", children: [_jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: it.icon, ariaLabel: nameWithCount(it.label, count), selected: it.key === activeKey, disabled: it.state === 'disabled', className: "bottom-nav-item", onClick: () => it.key && onChange?.(it.key) }), count > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(count, 99), size: 24 }) }))] }, it.key ?? `__slot-${i}`));
|
|
103
105
|
}) }), showMore && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" }), _jsx("div", { className: "bottom-nav__right", children: _jsxs("div", { ref: moreRef, className: "bottom-nav__slot", children: [_jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: activeMore?.icon ?? 'ellipsis', ariaLabel: nameWithCount(activeMore?.label ?? t('nav.more', '더보기'), moreBadge), selected: !!activeMore, className: "bottom-nav-item", onClick: handleToggleMore, "aria-expanded": moreOpen, "aria-controls": moreOpen ? moreMenuId : undefined }), moreBadge > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(moreBadge, 99), size: 24 }) })), moreOpen && createPortal(_jsx("div", { id: moreMenuId, role: "group", "aria-label": t('nav.moreMenu', '더보기 메뉴'), className: "tooltip-content", style: menuStyle, ref: menuRef, children: _jsx(MenuTooltip, { columns: moreColumns, onClose: handleCloseMore }) }), document.body)] }) })] }))] }));
|
|
104
106
|
}
|
|
@@ -142,7 +142,7 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
|
|
|
142
142
|
useEffect(() => {
|
|
143
143
|
활성일설정((d) => Math.min(d, daysInMonth(viewY, viewM)));
|
|
144
144
|
}, [viewY, viewM]);
|
|
145
|
-
/* 옮길 곳을 날짜로 셈한다 —
|
|
145
|
+
/* 옮길 곳을 날짜로 셈한다 — 날 넘김은 Date 에 맡기고, 달 넘김은 아래에서 말일로 조인다.
|
|
146
146
|
min/max 밖으로 나가면 그 끝으로 잡는다. */
|
|
147
147
|
const 옮기기 = useCallback((다음) => {
|
|
148
148
|
let iso = toISO(다음.getFullYear(), 다음.getMonth(), 다음.getDate());
|
|
@@ -159,7 +159,12 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
|
|
|
159
159
|
const 격자키 = useCallback((e) => {
|
|
160
160
|
const 기준 = new Date(viewY, viewM, 활성일);
|
|
161
161
|
const 하루 = (n) => { 기준.setDate(기준.getDate() + n); 옮기기(기준); };
|
|
162
|
-
|
|
162
|
+
// 달 넘김은 **목표 달의 말일로 조인다** — setMonth 는 말일에서 넘쳐 1/31 → 3/3, 3/31 에서 앞 달로 못 가고, 2028-02-29 → 2029-03-01 이 됐다
|
|
163
|
+
const 달 = (n) => {
|
|
164
|
+
const 총 = 기준.getFullYear() * 12 + 기준.getMonth() + n;
|
|
165
|
+
const y = Math.floor(총 / 12), m = 총 - y * 12;
|
|
166
|
+
옮기기(new Date(y, m, Math.min(기준.getDate(), daysInMonth(y, m))));
|
|
167
|
+
};
|
|
163
168
|
switch (e.key) {
|
|
164
169
|
case 'ArrowLeft':
|
|
165
170
|
하루(-1);
|
|
@@ -220,7 +225,7 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
|
|
|
220
225
|
const iconSize = size === 64 ? 20 : size === 40 ? 16 : 18;
|
|
221
226
|
const ymOptions = buildYMOptions(viewY, (year, month) => t('calendar.yearMonth', { year, month, defaultValue: '{{year}}년 {{month}}월' }), min, max);
|
|
222
227
|
const ymValue = `${viewY}-${String(viewM + 1).padStart(2, '0')}`;
|
|
223
|
-
const dropdown = open ? createPortal(_jsxs("div", { ref: menuRef, className: "date-picker__dropdown", style: dropdownStyle, role: "dialog", "aria-label": t('datePicker.chooseDate', '날짜 선택'), tabIndex: -1, children: [_jsxs("div", { className: "date-picker__nav", children: [_jsx("button", { type: "button", className: "date-picker__nav-btn", onClick: prevMonth, "aria-label": t('calendar.prevMonth', '이전 달'), children: _jsx(ChevronLeft, { size: 16, strokeWidth: 2 }) }), _jsx(Dropdown, { className: "date-picker__nav-dropdown", ariaLabel: t('datePicker.selectYearMonth', '연월 선택'), options: ymOptions, value: ymValue, onChange: (v) => {
|
|
228
|
+
const dropdown = open ? createPortal(_jsxs("div", { ref: menuRef, className: "date-picker__dropdown", style: dropdownStyle, "data-tooltip-ignore": true, role: "dialog", "aria-label": t('datePicker.chooseDate', '날짜 선택'), tabIndex: -1, children: [_jsxs("div", { className: "date-picker__nav", children: [_jsx("button", { type: "button", className: "date-picker__nav-btn", onClick: prevMonth, "aria-label": t('calendar.prevMonth', '이전 달'), children: _jsx(ChevronLeft, { size: 16, strokeWidth: 2 }) }), _jsx(Dropdown, { className: "date-picker__nav-dropdown", ariaLabel: t('datePicker.selectYearMonth', '연월 선택'), options: ymOptions, value: ymValue, onChange: (v) => {
|
|
224
229
|
const [y, m] = v.split('-');
|
|
225
230
|
setViewYM(+y, +m - 1);
|
|
226
231
|
}, block: true, size: 40 }), _jsx("button", { type: "button", className: "date-picker__nav-btn", onClick: nextMonth, "aria-label": t('calendar.nextMonth', '다음 달'), children: _jsx(ChevronRight, { size: 16, strokeWidth: 2 }) })] }), _jsxs("div", { className: "date-picker__grid", ref: 격자Ref, onKeyDown: 격자키, children: [DOW.map(d => (_jsx("div", { className: "date-picker__dow caption-1", children: d }, d))), cells.map((d, i) => {
|
|
@@ -105,7 +105,7 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
|
|
|
105
105
|
select(options[activeIdx]);
|
|
106
106
|
}
|
|
107
107
|
}, [disabled, open, options, activeIdx, select, setOpen]);
|
|
108
|
-
const menu = open ? (_jsx("div", { ref: menuRef, id: listId, className: "dropdown__menu", role: "listbox", style: menuStyle, onMouseLeave: handleMenuLeave,
|
|
108
|
+
const menu = open ? (_jsx("div", { ref: menuRef, id: listId, className: "dropdown__menu", "data-tooltip-ignore": true, role: "listbox", style: menuStyle, onMouseLeave: handleMenuLeave,
|
|
109
109
|
/* 메뉴 빈 곳·스크롤 막대를 눌러도 트리거에서 포커스가 안 떠나게 — 아래 트리거 onBlur 가 메뉴를 닫지 않도록 */
|
|
110
110
|
onMouseDown: e => e.preventDefault(), children: options.map((opt, i) => (_jsxs("button", { id: `${listId}-option-${i}`, type: "button", role: "option", "aria-selected": opt.value === value, className: cx('dropdown__option body-2', opt.value === value ? 'dropdown__option--active' : '', i === activeIdx ? 'dropdown__option--active' : '', opt.disabled ? 'dropdown__option--disabled' : ''),
|
|
111
111
|
// 선택은 click 에서 한다. onMouseDown 만 두면 마우스로는 눌리지만 스크린리더가
|
|
@@ -4,6 +4,7 @@ import { useRef, useState, useCallback, useLayoutEffect, useMemo } from 'react';
|
|
|
4
4
|
import { useTranslation } from 'react-i18next';
|
|
5
5
|
import i18n from '../../i18n';
|
|
6
6
|
import { toWonSymbol } from '../../utils/format';
|
|
7
|
+
import { getTodayKst } from '../../utils/date';
|
|
7
8
|
import { derivePassCard, deriveDurationDays } from './passCardProduct';
|
|
8
9
|
import './PassCard.css';
|
|
9
10
|
import Tooltip from '../Tooltip/Tooltip';
|
|
@@ -131,14 +132,17 @@ export default function PassCard({ product, showPrice = false, plan, type, endDa
|
|
|
131
132
|
// new Date(endDate)는 UTC 자정으로 파싱된다 — "지금"과 초 단위로 그냥 빼면
|
|
132
133
|
// 시간대 차이(KST는 UTC+9)만큼 없는 시:분이 생겨난 것처럼 보인다. 실제로
|
|
133
134
|
// 갖고 있는 정밀도(하루 단위)만큼만 계산해 자정 기준 날짜 차이로 스냅한다.
|
|
135
|
+
//
|
|
136
|
+
// 날짜 차이는 **KST 달력날** 로 센다 — 기기 시간대 자정끼리 빼면 한국 밖 기기(또는 KST 00:00~08:59 의 UTC 기기)에서
|
|
137
|
+
// 하루 틀렸고, 같은 이용권을 KST 로 세는 매니저 화면과 남은 날이 달랐다.
|
|
134
138
|
const end = new Date(endDate).getTime();
|
|
135
139
|
if (Number.isNaN(end))
|
|
136
140
|
return null;
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
const
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
const 끝 = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(endDate));
|
|
142
|
+
const 오늘 = /^(\d{4})-(\d{2})-(\d{2})/.exec(getTodayKst());
|
|
143
|
+
const days = 끝 && 오늘
|
|
144
|
+
? Math.round((Date.UTC(+끝[1], +끝[2] - 1, +끝[3]) - Date.UTC(+오늘[1], +오늘[2] - 1, +오늘[3])) / 86_400_000)
|
|
145
|
+
: Math.round((end - Date.now()) / 86_400_000);
|
|
142
146
|
remaining = Math.max(0, days) * 86400;
|
|
143
147
|
}
|
|
144
148
|
else if (state === 'expired') {
|
|
@@ -105,18 +105,41 @@ export default function Popup(props) {
|
|
|
105
105
|
/* Tab 순환을 패널 안으로 가둔다 — 네 곳에 있던 같은 열두 줄을 훅 한 벌로 모았다.
|
|
106
106
|
inline 으로 쓸 때는 다이얼로그가 아니라 걸지 않는다(예전과 같다). */
|
|
107
107
|
useFocusTrap(panelRef, !inline);
|
|
108
|
+
/* 닫힘 애니메이션 뒤 콜백을 부른다. **콜백이 던지거나 거절되면, 또는 콜백 뒤에도 이 창이 그대로 있으면 되살린다** —
|
|
109
|
+
전에는 닫는 중 표시만 남아 투명한 막이 화면을 덮고(클릭을 가로챔) ESC·X·바탕·다시 저장이 모두 무시돼 페이지를 쓸 수
|
|
110
|
+
없었다(말귀 업체 정보·훈련 묶음 수정 저장이 실패해 부모가 창을 열어 둘 때). 성공하면 부모가 곧 치우므로 되살릴 일이 없다. */
|
|
111
|
+
const 살아있음Ref = useRef(true);
|
|
112
|
+
useEffect(() => { 살아있음Ref.current = true; return () => { 살아있음Ref.current = false; }; }, []);
|
|
113
|
+
const 되살리기 = useCallback(() => {
|
|
114
|
+
if (!살아있음Ref.current || !closingRef.current)
|
|
115
|
+
return;
|
|
116
|
+
closingRef.current = false;
|
|
117
|
+
setClosing(false);
|
|
118
|
+
}, []);
|
|
108
119
|
const animateThen = useCallback((cb) => {
|
|
109
120
|
if (closingRef.current)
|
|
110
121
|
return;
|
|
111
122
|
closingRef.current = true;
|
|
112
123
|
setClosing(true);
|
|
113
124
|
setTimeout(() => {
|
|
125
|
+
let 결과;
|
|
114
126
|
try {
|
|
115
|
-
cb
|
|
127
|
+
결과 = cb ? cb() : undefined;
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
되살리기();
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
// 부모가 치울 틈(렌더·커밋)을 준 뒤에도 남아 있으면 되살린다
|
|
134
|
+
const 남았으면 = () => { setTimeout(되살리기, 120); };
|
|
135
|
+
if (결과 && typeof 결과.then === 'function') {
|
|
136
|
+
결과.then(남았으면, 되살리기);
|
|
137
|
+
}
|
|
138
|
+
else {
|
|
139
|
+
남았으면();
|
|
116
140
|
}
|
|
117
|
-
catch (e) { }
|
|
118
141
|
}, ANIM_MS);
|
|
119
|
-
}, []);
|
|
142
|
+
}, [되살리기]);
|
|
120
143
|
const closeWithAnimation = useCallback(() => {
|
|
121
144
|
if (closeDisabled)
|
|
122
145
|
return;
|
|
@@ -129,6 +129,7 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
129
129
|
// addEventListener에 전달한 것과 동일한 함수 identity로 제거해야 하므로 자기 참조가 필요하다
|
|
130
130
|
// eslint-disable-next-line react-hooks/immutability
|
|
131
131
|
window.removeEventListener('pointerup', onResizeEnd);
|
|
132
|
+
window.removeEventListener('pointercancel', onResizeEnd);
|
|
132
133
|
document.body.style.cursor = '';
|
|
133
134
|
document.body.style.userSelect = '';
|
|
134
135
|
// 조절 결과를 localStorage에 저장
|
|
@@ -159,6 +160,8 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
159
160
|
setColWidths(measured);
|
|
160
161
|
window.addEventListener('pointermove', onResizeMove);
|
|
161
162
|
window.addEventListener('pointerup', onResizeEnd);
|
|
163
|
+
// 터치 조절이 시스템 제스처 등으로 취소되면(pointercancel) 끝낸다 — 안 들으면 리스너가 남아 다음 스크롤 손짓이 폭을 바꿨다
|
|
164
|
+
window.addEventListener('pointercancel', onResizeEnd);
|
|
162
165
|
document.body.style.cursor = 'col-resize';
|
|
163
166
|
document.body.style.userSelect = 'none';
|
|
164
167
|
}, [onResizeMove, onResizeEnd, resizeMode, fields]);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
3
|
+
import { useRef } from 'react';
|
|
3
4
|
import { createPortal } from 'react-dom';
|
|
4
5
|
import { useToastLifecycle } from './useToastLifecycle';
|
|
5
6
|
import './Toast.css';
|
|
@@ -8,6 +9,9 @@ export default function Toast({ message = '', position = 'top', type = 'normal',
|
|
|
8
9
|
onClose = () => { }, duration = 2000, // 0 = persistent until message cleared
|
|
9
10
|
}) {
|
|
10
11
|
const { mounted, showing, popupRef, pause, resume } = useToastLifecycle({ message, position, duration, onClose });
|
|
12
|
+
/* 톡(터치)은 멈추지 않는다 — 톡하면 호환 mouseenter 로 멈추고 mouseleave 는 다른 곳을 톡하기 전까지 안 와 알림이 사라지지 않았다.
|
|
13
|
+
마우스 머무름·키보드 포커스는 그대로 멈춘다. 마지막 포인터 종류를 pointerenter 로 기억해 가른다. */
|
|
14
|
+
const 마지막포인터 = useRef(null);
|
|
11
15
|
if (!mounted)
|
|
12
16
|
return null;
|
|
13
17
|
const normalizedType = type === 'notice' || type === 'warning' ? type : 'normal';
|
|
@@ -17,7 +21,8 @@ onClose = () => { }, duration = 2000, // 0 = persistent until message cleared
|
|
|
17
21
|
`duration={0}` 으로 아예 안 사라지게 하는 길은 전부터 있었지만 그건 부르는
|
|
18
22
|
쪽이 미리 정하는 것이고, 읽는 사람이 **그 자리에서** 멈출 수는 없었다.
|
|
19
23
|
다시 움직일 때는 **남은 시간부터** 돈다 — 처음부터가 아니다. */
|
|
20
|
-
_jsx("div", { ref: popupRef, className: `toast toast--${position} ${showing ? 'is-visible' : ''}`, role: "status", "aria-live": "polite",
|
|
24
|
+
_jsx("div", { ref: popupRef, className: `toast toast--${position} ${showing ? 'is-visible' : ''}`, role: "status", "aria-live": "polite", onPointerEnter: (e) => { 마지막포인터.current = e.pointerType; }, onMouseEnter: () => { if (마지막포인터.current !== 'touch')
|
|
25
|
+
pause(); }, onMouseLeave: resume, onFocusCapture: pause, onBlurCapture: resume, children: _jsx("div", { className: `toast__inner toast__inner--${normalizedType} body-2`, children: message }) }));
|
|
21
26
|
if (typeof document !== 'undefined') {
|
|
22
27
|
return createPortal(el, document.body);
|
|
23
28
|
}
|
|
@@ -139,13 +139,27 @@ export default function Tooltip(props) {
|
|
|
139
139
|
if (isMobile && isMobilePopup)
|
|
140
140
|
return;
|
|
141
141
|
const onWindowChange = () => computePosition(true);
|
|
142
|
+
/* 고정 사각형(anchorRect)으로 띄운 툴팁은 스크롤해도 앵커를 따라갈 수 없다 — 같은 자리를 다시 계산해 **허공에** 남았다
|
|
143
|
+
(목록을 굴리면 카드 메뉴가 카드에서 떨어져 뜬 채). 닫을 곳(onClose)이 있으면 닫는다. 툴팁 안 스크롤은 제외. */
|
|
144
|
+
const onScroll = (e) => {
|
|
145
|
+
if (!anchorEl && onClose) {
|
|
146
|
+
const t = e.target;
|
|
147
|
+
if (t && ref.current && ref.current.contains(t))
|
|
148
|
+
return;
|
|
149
|
+
startCloseRef.current(true);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
onWindowChange();
|
|
153
|
+
};
|
|
142
154
|
window.addEventListener('resize', onWindowChange);
|
|
143
|
-
document.addEventListener('scroll',
|
|
155
|
+
document.addEventListener('scroll', onScroll, { passive: true, capture: true });
|
|
144
156
|
return () => {
|
|
145
157
|
window.removeEventListener('resize', onWindowChange);
|
|
146
|
-
document.removeEventListener('scroll',
|
|
158
|
+
document.removeEventListener('scroll', onScroll, { capture: true });
|
|
147
159
|
};
|
|
148
|
-
|
|
160
|
+
// 닫기는 아래 startCloseRef(최신 startClose)로 부른다
|
|
161
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
162
|
+
}, [anchorRect, anchorEl, onClose, offset, children, edgeMargin, isInline, isMobile, isMobilePopup, computePosition]);
|
|
149
163
|
// detect mobile (small screens) and update behavior
|
|
150
164
|
useEffect(() => {
|
|
151
165
|
// window 는 있는데 matchMedia 가 없는 환경이 있다(테스트 러너·일부 임베디드 웹뷰).
|
|
@@ -193,17 +207,27 @@ export default function Tooltip(props) {
|
|
|
193
207
|
if (immediate) {
|
|
194
208
|
// Synchronously close without waiting for the closing animation.
|
|
195
209
|
setClosing(true);
|
|
210
|
+
// onClose 가 던지면 닫는 중 표시를 되돌린다 — 투명한 채 남아 이후 닫기가 모두 무시됐다
|
|
196
211
|
try {
|
|
197
212
|
if (onClose)
|
|
198
213
|
onClose();
|
|
199
214
|
}
|
|
200
|
-
catch (err) {
|
|
215
|
+
catch (err) {
|
|
216
|
+
closingRef.current = false;
|
|
217
|
+
setClosing(false);
|
|
218
|
+
}
|
|
201
219
|
return;
|
|
202
220
|
}
|
|
203
221
|
setClosing(true);
|
|
204
222
|
setTimeout(() => {
|
|
205
|
-
|
|
206
|
-
onClose
|
|
223
|
+
try {
|
|
224
|
+
if (onClose)
|
|
225
|
+
onClose();
|
|
226
|
+
}
|
|
227
|
+
catch (err) {
|
|
228
|
+
closingRef.current = false;
|
|
229
|
+
setClosing(false);
|
|
230
|
+
}
|
|
207
231
|
}, 200);
|
|
208
232
|
}, [closing, onClose, setClosing]);
|
|
209
233
|
useEffect(() => {
|
|
@@ -218,7 +218,16 @@ function TrainingPagePopupInner(props) {
|
|
|
218
218
|
return;
|
|
219
219
|
closingRef.current = true;
|
|
220
220
|
setClosing(true);
|
|
221
|
-
|
|
221
|
+
// 닫기 콜백(onBack 등)이 던지면 닫는 중 표시를 되돌린다 — 굳은 채 Tab 가둠·ESC 가 남고 다시 닫을 수 없었다
|
|
222
|
+
closeTimerRef.current = setTimeout(() => {
|
|
223
|
+
try {
|
|
224
|
+
fn();
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
closingRef.current = false;
|
|
228
|
+
setClosing(false);
|
|
229
|
+
}
|
|
230
|
+
}, PAGE_POPUP_CLOSE_MS);
|
|
222
231
|
}, []);
|
|
223
232
|
useEffect(() => () => { if (closeTimerRef.current)
|
|
224
233
|
clearTimeout(closeTimerRef.current); }, []);
|
|
@@ -770,7 +770,14 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
|
|
|
770
770
|
catch (e) { }
|
|
771
771
|
}
|
|
772
772
|
setStateLocal("load");
|
|
773
|
-
|
|
773
|
+
// 앱이 준 경로 규칙(resolvePath 등)이 던져도 못 얻은 것과 같이 다룬다 — 거절이 흘러 'load' 에 갇혔다
|
|
774
|
+
let 얻음;
|
|
775
|
+
try {
|
|
776
|
+
얻음 = await fetchAndPrepare(true);
|
|
777
|
+
}
|
|
778
|
+
catch (e) {
|
|
779
|
+
얻음 = false;
|
|
780
|
+
}
|
|
774
781
|
/* 쓸 소스를 하나도 못 얻으면(경로를 못 정한 코드뿐 등) 'load' 에 갇혀 듣기 단추가 사라지고 아무 조작도
|
|
775
782
|
안 됐다 — 알리고 누를 수 있는 상태로 되돌린다. */
|
|
776
783
|
if (얻음 === false && mountedRef.current) {
|
|
@@ -28,7 +28,8 @@ export default function TrainingStepConfirmWithText({ index, stepIndex, isLast =
|
|
|
28
28
|
}
|
|
29
29
|
// 데모 선택이 바뀌면 따른다(useState 초기값만 쓰여 undefined → true 로 바꿔도 강조가 안 켜졌다)
|
|
30
30
|
const [prevDemo, setPrevDemo] = useState(demoSelected);
|
|
31
|
-
|
|
31
|
+
// Object.is — NaN 이 오면 `!==` 가 늘 참이라 렌더 중 갱신이 끝없이 돌았다
|
|
32
|
+
if (!Object.is(prevDemo, demoSelected)) {
|
|
32
33
|
setPrevDemo(demoSelected);
|
|
33
34
|
setSelectedAnswer(demoSelected ?? null);
|
|
34
35
|
}
|
|
@@ -30,7 +30,7 @@ const TrainingWordCard = forwardRef(function TrainingWordCard({ item, disabled =
|
|
|
30
30
|
// 플립 트리거(showBack)는 현재 값으로 두되, 앞면에 그릴 내용은 '마지막으로 채워졌던' 값을 유지한다.
|
|
31
31
|
const [front, setFront] = useState({ label, subLabel, colloquialLabel, homonum: item.homonum });
|
|
32
32
|
// 낱말이 있으면 넷을 **통째로** 견준다 — 낱말만 보면 같은 낱말에 발음이 새로 오거나 동음이의어 번호가 바뀌어도 앞 값이 남았다
|
|
33
|
-
if (label && (front.label !== label || front.subLabel !== subLabel || front.colloquialLabel !== colloquialLabel || front.homonum
|
|
33
|
+
if (label && (front.label !== label || front.subLabel !== subLabel || front.colloquialLabel !== colloquialLabel || !Object.is(front.homonum, item.homonum))) {
|
|
34
34
|
setFront({ label, subLabel, colloquialLabel, homonum: item.homonum });
|
|
35
35
|
}
|
|
36
36
|
// ── 재생 상태 ──
|
|
@@ -130,6 +130,10 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
|
|
|
130
130
|
}
|
|
131
131
|
catch (e) { }
|
|
132
132
|
};
|
|
133
|
+
/* 합성이 실패하면(synthesis-failed·not-allowed·language-unavailable) end 없이 error 만 온다 — 끝난 것과 같이 정리한다.
|
|
134
|
+
전에는 재생 상태·소음이 그대로 남고 handleEnd·onDone 이 안 불려 문항이 넘어가지 않았다. 정지·다시 시작이 올린 번호의
|
|
135
|
+
error(취소)는 위 onend 와 같이 넘긴다. */
|
|
136
|
+
utt.onerror = () => { utt.onend?.(new Event('end')); };
|
|
133
137
|
utt.onstart = () => {
|
|
134
138
|
try {
|
|
135
139
|
if (ttsRafRef.current) {
|
|
@@ -6,7 +6,8 @@ export function getControlDateRange(control) {
|
|
|
6
6
|
const now = new Date();
|
|
7
7
|
const y = m ? Number(m[1]) : now.getFullYear();
|
|
8
8
|
const mo = m ? Number(m[2]) : now.getMonth() + 1;
|
|
9
|
-
|
|
9
|
+
// 끝은 그날의 **마지막 밀리초까지** — 23:59:59.000 이면 마지막 999ms 에 남은 기록이 목록에서 빠졌다
|
|
10
|
+
return { from: new Date(y, mo - 1, 1, 0, 0, 0), to: new Date(y, mo, 0, 23, 59, 59, 999) };
|
|
10
11
|
}
|
|
11
12
|
const opt = control.weekRanges.find(o => o.value === control.weekRange);
|
|
12
13
|
if (!opt) {
|
|
@@ -17,6 +18,6 @@ export function getControlDateRange(control) {
|
|
|
17
18
|
const monday = new Date(opt.year, opt.month - 1, firstMon + opt.week * 7, 0, 0, 0);
|
|
18
19
|
const sunday = new Date(monday);
|
|
19
20
|
sunday.setDate(monday.getDate() + 6);
|
|
20
|
-
sunday.setHours(23, 59, 59);
|
|
21
|
+
sunday.setHours(23, 59, 59, 999);
|
|
21
22
|
return { from: monday, to: sunday };
|
|
22
23
|
}
|
|
@@ -41,6 +41,10 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
|
|
|
41
41
|
blurTimerRef.current = setTimeout(() => setOpen(false), 120);
|
|
42
42
|
}, []);
|
|
43
43
|
const handleKeyDown = useCallback((e) => {
|
|
44
|
+
/* 한글 조합 중 키는 **조합 몫** 이다 — 조합을 끝내는 키는 조합 중(229) 한 번 + 끝난 뒤 한 번 온다. 조합 중 ↓ 까지 세면 첫 후보를
|
|
45
|
+
건너뛰었고, 조합 중 Esc 가 목록을 닫아 이어 오는 Esc 가 팝업까지 닫았다(입력하던 것도 잃음). Enter 는 전부터 막았다. */
|
|
46
|
+
if (e.nativeEvent.isComposing || e.keyCode === 229)
|
|
47
|
+
return;
|
|
44
48
|
if (e.key === 'ArrowDown') {
|
|
45
49
|
e.preventDefault();
|
|
46
50
|
setOpen(true);
|
|
@@ -52,7 +56,7 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
|
|
|
52
56
|
}
|
|
53
57
|
else if (e.key === 'Enter') {
|
|
54
58
|
// 한글 조합을 끝내는 Enter는 선택이 아니다 — 조합 중엔 제안을 고르지 않는다
|
|
55
|
-
if (safeIdx >= 0
|
|
59
|
+
if (safeIdx >= 0)
|
|
56
60
|
handleSelect(filtered[safeIdx]);
|
|
57
61
|
}
|
|
58
62
|
else if (e.key === 'Escape') {
|
|
@@ -13,6 +13,9 @@ function onKey(e) {
|
|
|
13
13
|
return;
|
|
14
14
|
if (e.defaultPrevented)
|
|
15
15
|
return; // 내부 컨트롤이 이미 소비함
|
|
16
|
+
// 한글 조합(한자 후보창 등) 중 Esc 는 조합을 취소하는 키다 — 창을 닫지 않는다
|
|
17
|
+
if (e.isComposing || e.keyCode === 229)
|
|
18
|
+
return;
|
|
16
19
|
const top = stack[stack.length - 1];
|
|
17
20
|
if (!top)
|
|
18
21
|
return;
|
|
@@ -32,6 +32,8 @@ export function useAnchoredMenu({ computeStyle, onScroll = 'close', onResize = '
|
|
|
32
32
|
setOpen(false);
|
|
33
33
|
};
|
|
34
34
|
document.addEventListener('mousedown', handleOutsideClick);
|
|
35
|
+
// iOS 는 누를 수 없는 요소를 톡하면 mousedown 을 안 보낸다 — pointerdown 으로도 닫는다(Tooltip·CategoryIcon 과 같게)
|
|
36
|
+
document.addEventListener('pointerdown', handleOutsideClick);
|
|
35
37
|
const handleScroll = (e) => {
|
|
36
38
|
if (menuRef.current?.contains(e.target))
|
|
37
39
|
return;
|
|
@@ -52,6 +54,7 @@ export function useAnchoredMenu({ computeStyle, onScroll = 'close', onResize = '
|
|
|
52
54
|
window.addEventListener('resize', handleResize);
|
|
53
55
|
return () => {
|
|
54
56
|
document.removeEventListener('mousedown', handleOutsideClick);
|
|
57
|
+
document.removeEventListener('pointerdown', handleOutsideClick);
|
|
55
58
|
if (onScroll !== 'ignore')
|
|
56
59
|
window.removeEventListener('scroll', handleScroll, true);
|
|
57
60
|
if (onResize !== 'ignore')
|
|
@@ -32,6 +32,15 @@ export function speak(text, { onEnd, rate = 0.9 } = {}) {
|
|
|
32
32
|
catch (e) { }
|
|
33
33
|
}
|
|
34
34
|
: null;
|
|
35
|
+
// 합성이 실패하면 end 없이 error 만 온다 — 끝난 것으로 알린다(안 그러면 onEnd 를 기다리는 쪽이 멈춘 채)
|
|
36
|
+
u.onerror = onEnd
|
|
37
|
+
? function () {
|
|
38
|
+
try {
|
|
39
|
+
onEnd();
|
|
40
|
+
}
|
|
41
|
+
catch (e) { }
|
|
42
|
+
}
|
|
43
|
+
: null;
|
|
35
44
|
synth.speak(u);
|
|
36
45
|
};
|
|
37
46
|
if (synth.getVoices().length > 0) {
|