@ihabdevteam/core 0.139.0 → 0.141.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/dist/components.cjs.js +176 -176
  3. package/dist/hooks.cjs.js +1 -1
  4. package/dist/i18n.cjs.js +1 -1
  5. package/dist/ihabdevteam-core.cjs.js +181 -181
  6. package/dist/ihabdevteam-core.d.ts +1 -1
  7. package/dist/packages/core/src/components/BarChart.js +21 -1
  8. package/dist/packages/core/src/components/BottomNav.js +4 -2
  9. package/dist/packages/core/src/components/BulkBar.js +26 -3
  10. package/dist/packages/core/src/components/DatePicker.js +8 -3
  11. package/dist/packages/core/src/components/Dropdown.js +1 -1
  12. package/dist/packages/core/src/components/PassCard/PassCard.js +9 -5
  13. package/dist/packages/core/src/components/Table.js +3 -0
  14. package/dist/packages/core/src/components/Toast.js +6 -1
  15. package/dist/packages/core/src/components/Tooltip/Tooltip.js +17 -3
  16. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +2 -1
  17. package/dist/packages/core/src/components/Training/TrainingWordCard.js +1 -1
  18. package/dist/packages/core/src/components/Training/useTTSPlayback.js +2 -1
  19. package/dist/packages/core/src/components/Word/wordTablePrint.js +3 -1
  20. package/dist/packages/core/src/components/barChartTypes.js +3 -2
  21. package/dist/packages/core/src/components/useComboboxSuggestions.js +5 -1
  22. package/dist/packages/core/src/hooks/escStack.js +3 -0
  23. package/dist/packages/core/src/hooks/useAnchoredMenu.js +3 -0
  24. package/dist/packages/core/src/hooks/useTablePaging.js +2 -1
  25. package/dist/packages/core/src/utils/theme.js +5 -2
  26. package/dist/packages/core/src/version.d.ts +1 -1
  27. package/dist/packages/core/src/version.js +1 -1
  28. package/dist/utils.cjs.js +2 -2
  29. 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.139.0";
1379
+ export declare const CORE_VERSION = "0.141.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 (_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));
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
  }
@@ -15,19 +15,39 @@ export default function BulkBar({ count, actions, onClear, clearLabel: clearLabe
15
15
  자식들이 실제로 같은 줄에 있는지(offsetTop)를 본다. */
16
16
  const contentRef = useRef(null);
17
17
  const [stacked, setStacked] = useState(false);
18
+ /* 세로로 쌓으면 단추 칸이 좁아져 내용 칸이 넓어지고, 넓어진 칸에서 한 줄에 들어가 다시 가로로 돌리면 칸이 좁아져 또 넘친다 —
19
+ 문턱이 하나라 **매 프레임 오갔다**(말귀 단어 고름 막대, 칩 7~12개). 되돌릴 때는 "가로일 때의 칸 폭" 에 한 줄로 들어가는지로
20
+ 본다(여유). 가로일 때 잰 칸 폭을 기억해 둔다. */
21
+ const stackedRef = useRef(false);
22
+ const 가로칸폭Ref = useRef(null);
18
23
  useEffect(() => {
19
24
  const el = contentRef.current;
20
25
  if (!el)
21
26
  return;
27
+ const 두기 = (v) => { stackedRef.current = v; setStacked(v); };
22
28
  const check = () => {
23
29
  const row = el.firstElementChild ?? el;
24
30
  const kids = Array.from(row.children);
25
31
  if (kids.length < 2) {
26
- setStacked(false);
32
+ 두기(false);
27
33
  return;
28
34
  }
29
35
  const top = kids[0].offsetTop;
30
- setStacked(kids.some(k => k.offsetTop !== top));
36
+ const 넘침 = kids.some(k => k.offsetTop !== top);
37
+ if (!stackedRef.current) {
38
+ 가로칸폭Ref.current = el.clientWidth;
39
+ if (넘침)
40
+ 두기(true);
41
+ return;
42
+ }
43
+ if (넘침)
44
+ return;
45
+ // 쌓인 채 한 줄에 들어간다 — 가로로 돌렸을 때의 칸에도 들어갈 때만 되돌린다
46
+ const first = kids[0], last = kids[kids.length - 1];
47
+ const 한줄폭 = last.offsetLeft + last.offsetWidth - first.offsetLeft;
48
+ const 가로폭 = 가로칸폭Ref.current;
49
+ if (가로폭 == null || 한줄폭 <= 가로폭)
50
+ 두기(false);
31
51
  };
32
52
  check();
33
53
  // ResizeObserver 가 없는 환경(구형 브라우저·SSR·테스트 러너)에서는 생성자 호출이
@@ -36,7 +56,10 @@ export default function BulkBar({ count, actions, onClear, clearLabel: clearLabe
36
56
  return;
37
57
  const obs = new ResizeObserver(check);
38
58
  obs.observe(el);
39
- return () => obs.disconnect();
59
+ // 창 폭이 바뀌면 기억한 가로 칸 폭은 옛 것이다 — 버리고 다시 잰다
60
+ const 창바뀜 = () => { 가로칸폭Ref.current = null; check(); };
61
+ window.addEventListener('resize', 창바뀜);
62
+ return () => { obs.disconnect(); window.removeEventListener('resize', 창바뀜); };
40
63
  }, [content, count]);
41
64
  return (_jsxs("div", { className: `bulk-bar ${show ? 'bulk-bar--visible' : ''}`,
42
65
  /* 숨은 막대는 CSS 로 투명(opacity 0)할 뿐 DOM 에 그대로 있다 — 예전에는 그 안의
@@ -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
- /* 옮길 곳을 날짜로 셈한다 — 달·해 넘김을 Date 에 맡긴다(윤달·말일이 공짜다).
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
- const 달 = (n) => { 기준.setMonth(기준.getMonth() + n); 옮기기(기준); };
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 today = new Date();
138
- today.setHours(0, 0, 0, 0);
139
- const endDay = new Date(end);
140
- endDay.setHours(0, 0, 0, 0);
141
- const days = Math.round((endDay.getTime() - today.getTime()) / 86_400_000);
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') {
@@ -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", onMouseEnter: pause, onMouseLeave: resume, onFocusCapture: pause, onBlurCapture: resume, children: _jsx("div", { className: `toast__inner toast__inner--${normalizedType} body-2`, children: message }) }));
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', onWindowChange, { passive: true, capture: true });
155
+ document.addEventListener('scroll', onScroll, { passive: true, capture: true });
144
156
  return () => {
145
157
  window.removeEventListener('resize', onWindowChange);
146
- document.removeEventListener('scroll', onWindowChange, { capture: true });
158
+ document.removeEventListener('scroll', onScroll, { capture: true });
147
159
  };
148
- }, [anchorRect, offset, children, edgeMargin, isInline, isMobile, isMobilePopup, computePosition]);
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 가 없는 환경이 있다(테스트 러너·일부 임베디드 웹뷰).
@@ -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
- if (prevDemo !== demoSelected) {
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 !== item.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
  // ── 재생 상태 ──
@@ -93,7 +93,8 @@ export function useTTSPlayback({ ttsText, playbackRate, mainVolume, fillRef, wav
93
93
  const elapsed = performance.now() - start;
94
94
  const est = ttsEstimatedDurationRef.current || 1200;
95
95
  const p = Math.min(1, elapsed / est);
96
- setProgress(p);
96
+ // 매 프레임 상태로 올리지 않는다 — 진행 표시는 아래 DOM 에 바로 쓰고(오디오 경로 useProgressTicker 와 같게), 상태는 시작(0)·끝(1)만.
97
+ // 전에는 재생 중 플레이어·파형 트리가 초당 60번 다시 그려졌다.
97
98
  try {
98
99
  if (fillRef.current)
99
100
  fillRef.current.style.left = `${p * 100}%`;
@@ -73,7 +73,9 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
73
73
  }).join('\n');
74
74
  // styles.css의 다크 테마는 <html data-theme="..."> 속성으로 켜진다. 새 문서는 이 속성이
75
75
  // 없어 "auto"로 취급되고, OS가 다크 모드면 프로그램상 라이트 테마여도 팝업만 다크로 보였다.
76
- const theme = document.documentElement.getAttribute('data-theme') || 'light';
76
+ // 속성 값을 문서 글자에 그대로 끼우므로 **아는 세 값만** — 누가 data-theme 에 `"><script>` 를 넣어도 인쇄·새 창·내려받기 문서에 새지 않게
77
+ const 읽은테마 = document.documentElement.getAttribute('data-theme');
78
+ const theme = 읽은테마 === 'dark' || 읽은테마 === 'auto' ? 읽은테마 : 'light';
77
79
  /* 종이 맨 위에 찍는 한 줄(누구의·언제·무슨 표). **격자만 인쇄되므로** 이것이 없으면
78
80
  뽑아 둔 채점지가 며칠 뒤 누구 것인지 알 수 없는 종이가 된다. 쪽마다 찍는다 —
79
81
  여러 장이 섞이면 첫 장에만 있는 이름은 소용이 없다. */
@@ -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
- return { from: new Date(y, mo - 1, 1, 0, 0, 0), to: new Date(y, mo, 0, 23, 59, 59) };
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 && !e.nativeEvent.isComposing)
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')
@@ -33,7 +33,8 @@ export function useTablePaging(rows, resetOn, opts) {
33
33
  못 본다. 렌더 중에 되돌린다(저장소의 다른 곳들과 같은 꼴: 값이 바뀐 그 렌더에서
34
34
  바로 맞춰야 한 박자 늦은 화면이 안 뜬다). */
35
35
  const [본것, set본것] = useState(resetOn);
36
- if (본것 !== resetOn) {
36
+ // Object.is — NaN 이면 `!==` 가 늘 참이라 렌더가 끝나지 않았다. 객체는 렌더마다 새로 만들면 매번 바뀐 것이다(원시값을 넘길 것)
37
+ if (!Object.is(본것, resetOn)) {
37
38
  set본것(resetOn);
38
39
  if (page !== 1)
39
40
  setPage(1);
@@ -16,9 +16,12 @@ export function getSavedTheme() {
16
16
  return DEFAULT_THEME;
17
17
  }
18
18
  export function applyTheme(mode) {
19
+ /* 세 값만 받는다 — 설정은 서버(JSON)에서 오기도 해서 타입만 믿고 그대로 박으면, 이 속성을 글자로 옮겨 짓는 인쇄 문서에
20
+ `"><script>` 가 끼어들었다. 모르는 값은 기본(light)으로. */
21
+ const 값 = mode === 'light' || mode === 'dark' || mode === 'auto' ? mode : DEFAULT_THEME;
19
22
  try {
20
- document.documentElement.setAttribute('data-theme', mode);
21
- localStorage.setItem(STORAGE_KEY, mode);
23
+ document.documentElement.setAttribute('data-theme', 값);
24
+ localStorage.setItem(STORAGE_KEY, 값);
22
25
  }
23
26
  catch { }
24
27
  }
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.139.0";
49
+ export declare const CORE_VERSION = "0.141.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.139.0';
49
+ export const CORE_VERSION = '0.141.0';