@ihabdevteam/core 0.139.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.
@@ -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.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 (_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
  }
@@ -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
  // ── 재생 상태 ──
@@ -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')
@@ -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.140.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.140.0';