@ihabdevteam/core 0.144.0 → 0.146.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 (36) hide show
  1. package/CHANGELOG.md +59 -0
  2. package/dist/components.cjs.js +175 -175
  3. package/dist/components.esm.css +2 -2
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +1 -1
  6. package/dist/ihabdevteam-core.cjs.js +182 -182
  7. package/dist/ihabdevteam-core.d.ts +3 -1
  8. package/dist/packages/core/src/components/ActionCard.js +10 -2
  9. package/dist/packages/core/src/components/Dropdown.js +22 -3
  10. package/dist/packages/core/src/components/LandingHeader.js +12 -5
  11. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +17 -4
  12. package/dist/packages/core/src/components/Popup/ListPopup.js +1 -0
  13. package/dist/packages/core/src/components/Popup/Popup.js +4 -0
  14. package/dist/packages/core/src/components/Report/ReportDocument.css +11 -0
  15. package/dist/packages/core/src/components/Report/ReportDocument.js +11 -5
  16. package/dist/packages/core/src/components/Section.js +2 -1
  17. package/dist/packages/core/src/components/Table.js +18 -8
  18. package/dist/packages/core/src/components/Toast.css +5 -0
  19. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +2 -0
  20. package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +11 -1
  21. package/dist/packages/core/src/components/useToastLifecycle.d.ts +1 -0
  22. package/dist/packages/core/src/components/useToastLifecycle.js +3 -1
  23. package/dist/packages/core/src/hooks/useTablePaging.js +4 -3
  24. package/dist/packages/core/src/utils/textSort.d.ts +4 -4
  25. package/dist/packages/core/src/utils/textSort.js +56 -28
  26. package/dist/packages/core/src/utils/trainingItemResults.js +3 -1
  27. package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +2 -0
  28. package/dist/packages/core/src/utils/wordTrainingGenerators.js +5 -2
  29. package/dist/packages/core/src/utils/wordTrainingPool.d.ts +1 -0
  30. package/dist/packages/core/src/utils/wordTrainingPool.js +20 -6
  31. package/dist/packages/core/src/utils/wordTrainingRuntime.js +35 -14
  32. package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.js +4 -3
  33. package/dist/packages/core/src/version.d.ts +1 -1
  34. package/dist/packages/core/src/version.js +1 -1
  35. package/dist/utils.cjs.js +2 -2
  36. package/package.json +1 -1
@@ -1386,7 +1386,7 @@ export declare function copyText(text: string): Promise<boolean>;
1386
1386
  *
1387
1387
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1388
1388
  */
1389
- export declare const CORE_VERSION = "0.144.0";
1389
+ export declare const CORE_VERSION = "0.146.0";
1390
1390
 
1391
1391
  /** @public */
1392
1392
  declare const coreDefault: Record<string, unknown>;
@@ -7397,6 +7397,8 @@ export declare type TrainingPlan = {
7397
7397
  declare interface TrainingProblem {
7398
7398
  question: string;
7399
7399
  distractors: string[];
7400
+ /** 탈락 문제에서 빠진 조각(합성어 부분 탈락이 채운다 — 앞뒤로 같은 조각이 있으면 remain 만으로는 어느 쪽인지 모른다) */
7401
+ dropped?: string;
7400
7402
  }
7401
7403
 
7402
7404
  /** @public */
@@ -1,4 +1,6 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { useId } from 'react';
2
4
  import { cx } from '../utils/cx';
3
5
  import Icon from './Icon';
4
6
  import './ActionCard.css';
@@ -15,14 +17,20 @@ import './ActionCard.css';
15
17
  * @public
16
18
  */
17
19
  export default function ActionCard({ icon, title, description, cta, tone = 'main', disabled = false, onClick, className = '', }) {
20
+ /* 이름은 제목, 설명·행선지(cta)는 설명으로 잇는다 — aria-label={title} 이 안의 글자를 덮어 "확인 중..." 같은 cta 변화와
21
+ 설명이 낭독기에서 사라졌다(kscan 홈) */
22
+ const uid = useId();
23
+ const titleId = `${uid}-title`;
24
+ const descId = `${uid}-desc`;
25
+ const ctaId = `${uid}-cta`;
18
26
  const activate = () => {
19
27
  if (!disabled)
20
28
  onClick?.();
21
29
  };
22
- return (_jsx("section", { role: "button", tabIndex: disabled ? -1 : 0, "aria-disabled": disabled || undefined, "aria-label": title, onClick: activate, onKeyDown: (e) => {
30
+ return (_jsx("section", { role: "button", tabIndex: disabled ? -1 : 0, "aria-disabled": disabled || undefined, "aria-labelledby": titleId, "aria-describedby": `${descId} ${ctaId}`, onClick: activate, onKeyDown: (e) => {
23
31
  if (e.key === 'Enter' || e.key === ' ') {
24
32
  e.preventDefault();
25
33
  activate();
26
34
  }
27
- }, className: cx('action-card', `action-card--${tone}`, disabled && 'action-card--disabled', className), children: _jsxs("div", { className: "action-card__body", children: [_jsx("span", { "aria-hidden": true, className: "action-card__chip", children: _jsx(Icon, { icon: icon, size: 34 }) }), _jsxs("div", { className: "action-card__text", children: [_jsx("h2", { className: "action-card__title", children: title }), _jsx("p", { className: "action-card__desc", children: description })] }), _jsxs("div", { className: "action-card__cta", children: [_jsx("span", { children: cta }), _jsx("span", { "aria-hidden": true, className: "action-card__arrow", children: _jsx(Icon, { icon: "arrow-right", size: 18 }) })] })] }) }));
35
+ }, className: cx('action-card', `action-card--${tone}`, disabled && 'action-card--disabled', className), children: _jsxs("div", { className: "action-card__body", children: [_jsx("span", { "aria-hidden": true, className: "action-card__chip", children: _jsx(Icon, { icon: icon, size: 34 }) }), _jsxs("div", { className: "action-card__text", children: [_jsx("h2", { id: titleId, className: "action-card__title", children: title }), _jsx("p", { id: descId, className: "action-card__desc", children: description })] }), _jsxs("div", { className: "action-card__cta", children: [_jsx("span", { id: ctaId, children: cta }), _jsx("span", { "aria-hidden": true, className: "action-card__arrow", children: _jsx(Icon, { icon: "arrow-right", size: 18 }) })] })] }) }));
28
36
  }
@@ -41,7 +41,7 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
41
41
  useEffect(() => {
42
42
  if (open) {
43
43
  const idx = options.findIndex(o => o.value === value);
44
- setActiveIdx(idx >= 0 ? idx : 0);
44
+ setActiveIdx(idx >= 0 ? idx : (options.length ? 0 : -1)); // 빈 목록이면 가리킬 칸이 없다
45
45
  requestAnimationFrame(() => {
46
46
  const menu = menuRef.current;
47
47
  if (!menu)
@@ -60,6 +60,20 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
60
60
  // eslint-disable-next-line react-hooks/exhaustive-deps
61
61
  }, [open]);
62
62
  /* eslint-enable react-hooks/set-state-in-effect */
63
+ /* 열린 동안 목록 **길이** 가 바뀌어 가리킴이 목록 밖이면(불러오는 중 [] 로 열었다 목록 도착 · 목록이 줄어듦) 선택값 자리로
64
+ 다시 잡는다 — 전에는 목록 밖 칸에서 Enter 가 던지거나(말귀 어드민 상품 고르개), 도착한 목록의 첫 항목을 골랐다.
65
+ 목록 안이면 그대로 둔다(인라인 배열로 다시 그려질 때 가리킴이 튀지 않게 한 위 효과의 뜻). */
66
+ useEffect(() => {
67
+ if (!open)
68
+ return;
69
+ setActiveIdx(i => {
70
+ if (i >= 0 && i < options.length)
71
+ return i;
72
+ const idx = options.findIndex(o => o.value === value);
73
+ return idx >= 0 ? idx : (options.length ? 0 : -1);
74
+ });
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, [open, options.length]);
63
77
  const toggle = useCallback(() => { if (!disabled)
64
78
  rawToggle(); }, [disabled, rawToggle]);
65
79
  const select = useCallback((opt) => {
@@ -105,9 +119,14 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
105
119
  return prev >= 0 ? prev : i;
106
120
  });
107
121
  }
108
- else if (e.key === 'Enter' && activeIdx >= 0) {
122
+ else if (e.key === 'Enter' || e.key === ' ') {
123
+ /* 열린 목록에서 Space 도 가리킨 항목을 고른다(네이티브 select·APG 와 같게) — 전에는 단추 기본 동작(keyup→click)이 목록을
124
+ 닫기만 해 고른 것이 사라졌다(소리방향성 출력 장치). 가리킨 것이 없으면 닫기만 한다. */
109
125
  e.preventDefault();
110
- select(options[activeIdx]);
126
+ if (options[activeIdx])
127
+ select(options[activeIdx]);
128
+ else if (e.key === ' ')
129
+ setOpen(false);
111
130
  }
112
131
  }, [disabled, open, options, activeIdx, select, setOpen]);
113
132
  const menu = open ? (_jsx("div", { ref: menuRef, id: listId, className: "dropdown__menu", "data-tooltip-ignore": true, role: "listbox", style: menuStyle, onMouseLeave: handleMenuLeave,
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useId, useState } from 'react';
3
+ import { useEffect, useId, useRef, useState } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import { pushEsc } from '../hooks/escStack';
6
6
  import './LandingHeader.css';
@@ -27,12 +27,19 @@ export default function LandingHeader({ logo, logoAlt = '', logoHref, links = []
27
27
  // 서랍 id 를 고정 문자열로 두면 한 화면에 헤더가 둘일 때(미리보기·놀이터 견본) id 가 겹쳐
28
28
  // 햄버거의 aria-controls 가 엉뚱한 서랍을 가리킨다 — 헤더마다 따로 짓는다.
29
29
  const drawerId = useId();
30
+ const toggleRef = useRef(null);
30
31
  /* 서랍이 열려 있으면 Esc 로 닫는다(공유 ESC 스택 — 맨 위 하나만). 넓은 화면(961px~, 서랍이 CSS 로 숨는 폭)으로 가면
31
32
  닫는다 — 안 닫으면 open 이 남아 좁은 폭으로 돌아오는 순간 서랍이 다시 펼쳐졌다. */
32
33
  useEffect(() => {
33
34
  if (!open)
34
35
  return;
35
- const 풀기 = pushEsc(() => setOpen(false));
36
+ // 서랍 안에 포커스가 있으면 햄버거로 돌려 준다 — 서랍이 hidden 이 되며 포커스가 숨은 링크에 남아 갈 곳을 잃었다
37
+ const 풀기 = pushEsc(() => {
38
+ const 서랍 = document.getElementById(drawerId);
39
+ if (서랍 && 서랍.contains(document.activeElement))
40
+ toggleRef.current?.focus();
41
+ setOpen(false);
42
+ });
36
43
  const mq = typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(min-width: 961px)') : null;
37
44
  const 넓어짐 = (e) => { if (e.matches)
38
45
  setOpen(false); };
@@ -42,7 +49,7 @@ export default function LandingHeader({ logo, logoAlt = '', logoHref, links = []
42
49
  mq.addEventListener?.('change', 넓어짐);
43
50
  }
44
51
  return () => { 풀기(); mq?.removeEventListener?.('change', 넓어짐); };
45
- }, [open]);
52
+ }, [open, drawerId]);
46
53
  const innerClass = containerClassName
47
54
  ? `${containerClassName} landing-header__inner`
48
55
  : 'landing-header__inner';
@@ -58,7 +65,7 @@ export default function LandingHeader({ logo, logoAlt = '', logoHref, links = []
58
65
  return logoHref
59
66
  ? _jsx("a", { href: logoHref, className: "landing-header__brand", children: brand })
60
67
  : _jsx("div", { className: "landing-header__brand", children: brand });
61
- })(), links.length > 0 && (_jsx("nav", { className: "landing-header__links", children: links.map(item => (_jsx("span", { children: link(item) }, item.href))) })), _jsxs("div", { className: "landing-header__actions", children: [actions, links.length > 0 && (_jsx("button", { type: "button", onClick: () => setOpen(v => !v), "aria-expanded": open, "aria-controls": drawerId, "aria-label": open ? menuLabel.close : menuLabel.open, className: open
68
+ })(), links.length > 0 && (_jsx("nav", { className: "landing-header__links", children: links.map((item, i) => (_jsx("span", { children: link(item) }, `${i}:${item.href}`))) })), _jsxs("div", { className: "landing-header__actions", children: [actions, links.length > 0 && (_jsx("button", { ref: toggleRef, type: "button", onClick: () => setOpen(v => !v), "aria-expanded": open, "aria-controls": drawerId, "aria-label": open ? menuLabel.close : menuLabel.open, className: open
62
69
  ? 'landing-header__toggle landing-header__toggle--open'
63
- : 'landing-header__toggle', children: _jsxs("span", { className: "landing-header__burger", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }) }))] })] }), links.length > 0 && (_jsx("nav", { id: drawerId, hidden: !open, className: "landing-header__drawer", children: _jsx("div", { className: containerClassName ?? undefined, children: links.map(item => (_jsx("span", { className: "landing-header__drawer-item", children: link(item, () => setOpen(false)) }, item.href))) }) }))] }));
70
+ : 'landing-header__toggle', children: _jsxs("span", { className: "landing-header__burger", children: [_jsx("span", {}), _jsx("span", {}), _jsx("span", {})] }) }))] })] }), links.length > 0 && (_jsx("nav", { id: drawerId, hidden: !open, className: "landing-header__drawer", children: _jsx("div", { className: containerClassName ?? undefined, children: links.map((item, i) => (_jsx("span", { className: "landing-header__drawer-item", children: link(item, () => setOpen(false)) }, `${i}:${item.href}`))) }) }))] }));
64
71
  }
@@ -22,6 +22,12 @@ export default function ConfirmPopup(props) {
22
22
  동안 한 번 더 누르면 onConfirm 이 또 불렸다(말귀 단어표 삭제·이용권 삭제가 두 번 나가 성공 뒤 실패 토스트).
23
23
  닫기로 끝나면 잠금을 풀지 않는다 — 창이 사라지는 중이다. 닫지 않고 끝나면(closeOnConfirm=false·실패) 풀고, 처리 중에
24
24
  단추가 꺼지며 body 로 떨어진 포커스를 누른 단추로 되돌린다. */
25
+ /* closeOnConfirm=false 로 두고 콜백이 넘겨받은 close() 를 **스스로** 부르면(문서가 권하는 "성공했을 때만 닫기") 그것도 닫힘으로
26
+ 센다 — 전에는 closeOnConfirm(false) 만 돌려 닫히는 260ms 동안 잠금이 풀려, 한 번 더 누르면 확인이 두 번 나갔다. */
27
+ const 닫기를센다 = (closeWithAnimation) => {
28
+ let 닫았음 = false;
29
+ return { close: () => { 닫았음 = true; closeWithAnimation(); }, 닫았나: () => 닫았음 };
30
+ };
25
31
  const 한번만 = async (일) => {
26
32
  if (busyRef.current)
27
33
  return;
@@ -32,6 +38,11 @@ export default function ConfirmPopup(props) {
32
38
  try {
33
39
  닫는중 = await 일();
34
40
  }
41
+ catch (e) {
42
+ /* 거절은 창을 남기고 잠금을 푸는 것으로 끝낸다(계약) — 받아 주지 않으면 처리되지 않은 거절로 새어 Next 개발 오버레이가 떴다 */
43
+ if (typeof console !== 'undefined')
44
+ console.error(e);
45
+ }
35
46
  finally {
36
47
  if (!닫는중) {
37
48
  busyRef.current = false;
@@ -58,11 +69,12 @@ export default function ConfirmPopup(props) {
58
69
  // 두 번째는 엉뚱한 실패로 돌아온다.
59
70
  onClick: async ({ closeWithAnimation }) => {
60
71
  await 한번만(async () => {
61
- await extraAction.onClick(closeWithAnimation);
72
+ const 닫기 = 닫기를센다(closeWithAnimation);
73
+ await extraAction.onClick(닫기.close);
62
74
  const 닫음 = extraAction.closeOnClick ?? true;
63
75
  if (닫음)
64
76
  closeWithAnimation();
65
- return 닫음;
77
+ return 닫음 || 닫기.닫았나();
66
78
  });
67
79
  },
68
80
  }] : []),
@@ -78,10 +90,11 @@ export default function ConfirmPopup(props) {
78
90
  disabled: confirmDisabled || busy,
79
91
  onClick: async ({ closeWithAnimation }) => {
80
92
  await 한번만(async () => {
81
- await onConfirm(closeWithAnimation);
93
+ const 닫기 = 닫기를센다(closeWithAnimation);
94
+ await onConfirm(닫기.close);
82
95
  if (closeOnConfirm)
83
96
  closeWithAnimation();
84
- return closeOnConfirm;
97
+ return closeOnConfirm || 닫기.닫았나();
85
98
  });
86
99
  },
87
100
  },
@@ -66,6 +66,7 @@ export default function ListPopup(props) {
66
66
  setSortKey(key);
67
67
  setSortAsc(true);
68
68
  }
69
+ setPage(1); // 차례가 바뀌면 첫 쪽으로 — 3쪽에서 머리를 누르면 3쪽에 남아 새 차례의 앞을 못 봤다
69
70
  }, [sortKey]);
70
71
  // countLabel("총 {n}명")을 Toolbar count의 prefix/suffix로 분해
71
72
  const countParts = useMemo(() => {
@@ -134,6 +134,10 @@ export default function Popup(props) {
134
134
  setClosing(false);
135
135
  }, []);
136
136
  const animateThen = useCallback((cb, 닫기뿐 = false) => {
137
+ /* 이미 떠난 창을 닫으려는 부름(ConfirmPopup 의 onConfirm 안에서 부모가 창을 치운 뒤 closeWithAnimation)은 받지 않는다 —
138
+ 정리가 끝난 뒤 걸린 타이머라 걷히지 않고 260ms 뒤 **취소 경로의 onClose** 가 불렸다(kscan 이 closeOnConfirm=false 로 우회). */
139
+ if (닫기뿐 && !살아있음Ref.current)
140
+ return;
137
141
  if (closingRef.current)
138
142
  return;
139
143
  closingRef.current = true;
@@ -131,4 +131,15 @@
131
131
  .report-doc__page:last-child {
132
132
  break-after: auto;
133
133
  }
134
+
135
+ /* 숨은 재기 사본(자 + 모든 블록)이 인쇄에서도 흐름 높이를 늘려 끝에 빈 쪽이 붙었다(1장 → 2쪽). 높이를 0 으로 접되, 안의 자는
136
+ 줄어들지 않게 한다 — 자가 0 이 되면 ResizeObserver 가 다시 재어 모두 한 장에 몰린다(Chromium 확인). */
137
+ .report-doc__measure {
138
+ height: 0;
139
+ overflow: hidden;
140
+ }
141
+
142
+ .report-doc__measure > * {
143
+ flex-shrink: 0;
144
+ }
134
145
  }
@@ -96,18 +96,23 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
96
96
  재지 않았다 — 자의 높이(자크기)를 의존성에 두어 크기가 생기면 다시 잰다. */
97
97
  /* 머리·바닥 높이는 **갈래마다** 잰다 — 제목이 있는 갈래와 없는 갈래는 머리 높이가 달라, 첫 갈래 것 하나로 재면 제목 있는
98
98
  뒤 갈래의 장이 넘쳤다. */
99
+ /* 높이는 **소수 그대로** 잰다 — offsetHeight 는 정수로 반올림해, 소수 높이 블록이 모이면 반올림 합은 예산과 같은데 실제 장은
100
+ 종이보다 3px 넘쳤고(mm 규격의 자 1016.69px 는 1017 로 올림) 인쇄에서는 그 1px 로 쪽이 하나 더 생겼다(Chromium PDF 확인).
101
+ rect 가 0 인 환경(레이아웃 없는 시험)에서는 offsetHeight 로 물러선다. */
102
+ const 높이 = (node) => node.getBoundingClientRect().height || node.offsetHeight;
99
103
  const chromeOf = new Map();
100
104
  chromeRef.current?.querySelectorAll('[data-chrome-section]').forEach((node) => {
101
- chromeOf.set(node.dataset.chromeSection ?? '', node.offsetHeight);
105
+ chromeOf.set(node.dataset.chromeSection ?? '', 높이(node));
102
106
  });
103
- const budgetFor = (section) => ruler.offsetHeight - (chromeOf.get(section ?? '') ?? 0);
104
- const heights = Array.from(el.children).map((c) => c.offsetHeight);
107
+ const budgetFor = (section) => 높이(ruler) - (chromeOf.get(section ?? '') ?? 0);
108
+ const heights = Array.from(el.children).map((c) => 높이(c));
105
109
  // 이웃한 같은 group 을 한 덩어리로 묶는다.
106
110
  const groups = [];
107
111
  blocks.forEach((b, i) => {
108
112
  const last = groups[groups.length - 1];
109
113
  const prev = last ? blocks[last[0]] : undefined;
110
- const sameGroup = prev && b.group != null && prev.group === b.group;
114
+ // 갈래(section)가 다르면 같은 group 값이어도 한 덩어리가 아니다 — 묶이면 "값이 바뀌는 자리에서 반드시 새 장"(JSDoc)이 깨졌다
115
+ const sameGroup = prev && b.group != null && prev.group === b.group && prev.section === b.section;
111
116
  if (sameGroup)
112
117
  last.push(i);
113
118
  else
@@ -149,7 +154,8 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
149
154
  setMeasured({ shape, pages: packed });
150
155
  // blocks 는 위 shape 로 대신 본다.
151
156
  // eslint-disable-next-line react-hooks/exhaustive-deps
152
- }, [shape, 자크기]);
157
+ // 장 규격이 바뀌면 글이 다시 흘러 블록 높이가 달라진다 — 자(세로) 높이는 그대로라 자크기만으로는 다시 재지 않아 장이 넘쳤다
158
+ }, [shape, 자크기, pageWidth, pageHeight, margin]);
153
159
  /* 번들러마다 SVG import 결과가 다르다 — Vite 는 문자열, Next 는 `{ src }` 객체다.
154
160
  문자열만 보면 Next 앱에서 fallback 경로(`/logo/...`)로 떨어져 404 가 난다
155
161
  (kscan 결과지의 바닥 로고가 실제로 깨졌다). 둘 다 받는다. */
@@ -10,7 +10,8 @@ const TITLE_CLASS = {
10
10
  /** @public */
11
11
  export default function Section({ type = 'page', title, headingLevel, icon, action, disc, children, className, style }) {
12
12
  const titleClass = TITLE_CLASS[type];
13
- const hasHeader = title || disc;
13
+ // action·icon 만 줘도 머리를 그린다 — 제목·설명이 없으면 추가 단추가 조용히 버려졌다
14
+ const hasHeader = title || disc || action || icon;
14
15
  /* 제목이 맨 div 라 낭독기의 제목 목록에 안 잡혔다. 단계는 페이지 구조에 달려 있어
15
16
  부르는 쪽이 정한다 — 안 주면 예전 그대로 div. 모양은 클래스(h3/h4/h5)가 정하고
16
17
  제목 태그의 기본 여백은 .section__title 의 margin:0 이 지운다. */
@@ -105,9 +105,12 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
105
105
  const d = dragRef.current;
106
106
  if (!d)
107
107
  return;
108
- setColWidths(prev => {
109
- if (!prev)
110
- return prev;
108
+ /* 저장할 값(colWidthsRef)을 **여기서 곧바로** 맞춘다 — 렌더 뒤 효과에서야 갱신돼, 마지막 이동 직후 손을 떼면 화면은 새 폭인데
109
+ 저장소에는 한 박자 전 폭이 남았다(window pointermove 는 React 밖이라 렌더가 밀린다). */
110
+ const prev = colWidthsRef.current;
111
+ if (!prev)
112
+ return;
113
+ {
111
114
  const copy = [...prev];
112
115
  if (d.next != null && d.startWNext != null) {
113
116
  // fit — 이동량을 두 컬럼이 나눠 갖는다. 둘 다 최소폭 밑으로 내려가지 않도록
@@ -120,8 +123,9 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
120
123
  else {
121
124
  copy[d.index] = Math.max(MIN_COL_W, d.startW + (e.clientX - d.startX));
122
125
  }
123
- return copy;
124
- });
126
+ colWidthsRef.current = copy;
127
+ setColWidths(copy);
128
+ }
125
129
  }, []);
126
130
  const onResizeEnd = useCallback(() => {
127
131
  dragRef.current = null;
@@ -157,6 +161,7 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
157
161
  dragRef.current = next >= 0
158
162
  ? { index, startX: e.clientX, startW: measured[index], next, startWNext: measured[next] }
159
163
  : { index, startX: e.clientX, startW: measured[index] };
164
+ colWidthsRef.current = measured;
160
165
  setColWidths(measured);
161
166
  window.addEventListener('pointermove', onResizeMove);
162
167
  window.addEventListener('pointerup', onResizeEnd);
@@ -194,7 +199,10 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
194
199
  if (c)
195
200
  w = Math.max(w, c.getBoundingClientRect().width);
196
201
  }
197
- return Math.max(MIN_COL_W, Math.ceil(w));
202
+ /* 고정 px 열은 정해 둔 값 그대로 — 최소 폭(MIN_COL_W)으로 올린 값을 고정 합에 넣으면 격자에는 원래 값(40px)으로 나가
203
+ 그만큼(8px) 표 오른쪽이 비었다(말귀 어드민 버그 탭·쿠폰) */
204
+ const 고정값 = isFixedPxRef.current(i) ? parseFloat(defaultColsRef.current[i]) : NaN;
205
+ return Number.isFinite(고정값) ? 고정값 : Math.max(MIN_COL_W, Math.ceil(w));
198
206
  });
199
207
  /* 격자가 실제로 채우는 폭 — `clientWidth` 는 안쪽 여백을 포함하므로 뺀다
200
208
  (되돌리기 단추 자리로 40px 을 비워 두었다). */
@@ -319,10 +327,12 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
319
327
  const isDragging = draggingId != null && item.id === draggingId;
320
328
  const 눌림 = !!onRowClick && (isRowClickable ? isRowClickable(item) : true);
321
329
  const isExpanded = expandedId != null && (expandedId instanceof Set ? expandedId.has(item.id) : item.id === expandedId);
322
- return (_jsx(TableRow, { className: `${눌림 ? ' table-row--clickable' : ''}${onRowDragStart ? ' table-row--draggable' : ''}${isDragging ? ' table-row--dragging' : ''}`.trim(), draggable: !!onRowDragStart,
330
+ return (_jsx(TableRow
331
+ // 타입까지 열쇠에 — id 없는 줄(idx 0)과 id:0, 1 과 '1' 이 같은 열쇠가 되어 목록을 바꾼 뒤 옛 줄이 남았다
332
+ , { className: `${눌림 ? ' table-row--clickable' : ''}${onRowDragStart ? ' table-row--draggable' : ''}${isDragging ? ' table-row--dragging' : ''}`.trim(), draggable: !!onRowDragStart,
323
333
  // 옆의 핸들러들처럼 있을 때만 넘긴다. 항상 함수를 만들어 넘기면
324
334
  // TableRow 가 "클릭 가능"으로 보고 role="button" · tabIndex=0 을 붙여,
325
335
  // 클릭 동작이 없는 표의 모든 행이 버튼으로 읽히고 탭 정지점이 된다.
326
- onClick: 눌림 ? () => onRowClick(item) : undefined, onMouseEnter: onRowMouseEnter ? (e) => onRowMouseEnter(item, e) : undefined, onMouseLeave: onRowMouseLeave ? (e) => onRowMouseLeave(item, e) : undefined, onDragStart: onRowDragStart ? (e) => onRowDragStart(item, e) : undefined, onDragEnd: onRowDragEnd ? (e) => onRowDragEnd(item, e) : undefined, expandedContent: isExpanded && renderExpandedRow ? renderExpandedRow(item) : undefined, children: renderRow(item).map((cell, i) => (_jsx(TableCell, { className: cell.className, style: cell.style, children: cell.content }, i))) }, String(item.id ?? idx)));
336
+ onClick: 눌림 ? () => onRowClick(item) : undefined, onMouseEnter: onRowMouseEnter ? (e) => onRowMouseEnter(item, e) : undefined, onMouseLeave: onRowMouseLeave ? (e) => onRowMouseLeave(item, e) : undefined, onDragStart: onRowDragStart ? (e) => onRowDragStart(item, e) : undefined, onDragEnd: onRowDragEnd ? (e) => onRowDragEnd(item, e) : undefined, expandedContent: isExpanded && renderExpandedRow ? renderExpandedRow(item) : undefined, children: renderRow(item).map((cell, i) => (_jsx(TableCell, { className: cell.className, style: cell.style, children: cell.content }, i))) }, item.id != null ? `${typeof item.id}:${item.id}` : `__idx:${idx}`));
327
337
  }) })] }))] }) }));
328
338
  }
@@ -57,6 +57,11 @@
57
57
  transition: transform 260ms cubic-bezier(.22,.9,.3,1), opacity 180ms ease-out;
58
58
  opacity:0;
59
59
  }
60
+ /* 보이지 않는 동안(들어오기 전 두 프레임·사라지는 중)은 누름을 통과시킨다 — 투명한 알림이 아래 단추의 클릭을 삼켰다 */
61
+ .toast:not(.is-visible) .toast__inner{
62
+ pointer-events:none;
63
+ }
64
+
60
65
  .toast--top.is-visible{
61
66
  transform:translateX(-50%) translateY(0);
62
67
  opacity:1;
@@ -850,6 +850,8 @@ function TrainingPagePopupInner(props) {
850
850
  results: (resultOverlay.results || resultOverlay.answers || []),
851
851
  contextsLength: resultOverlay.results ? resultOverlay.results.length : (resultOverlay.answers ? resultOverlay.answers.length : 0),
852
852
  onReplay: () => {
853
+ // 다시 푸는 판의 소요 시간은 지금부터 — 마운트 때 한 번만 찍어 앞 판 시간·결과 창을 보던 시간까지 섞였다(60초가 660초)
854
+ completionStartRef.current = Date.now();
853
855
  결과창닫기();
854
856
  setReplayCount((n) => n + 1);
855
857
  if (hasScenario) {
@@ -1,6 +1,6 @@
1
1
  'use client';
2
2
  import { jsx as _jsx } from "react/jsx-runtime";
3
- import { useCallback, useMemo, useState, useEffect } from 'react';
3
+ import { useCallback, useMemo, useState, useEffect, useRef } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import TrainingStep from './TrainingStep';
6
6
  import TrainingWordCard from './TrainingWordCard';
@@ -29,6 +29,16 @@ export default function TrainingStepCardOrder({ index, stepIndex, isLast = false
29
29
  // eslint-disable-next-line react-hooks/exhaustive-deps
30
30
  }, [choicesKey]);
31
31
  /* eslint-enable react-hooks/set-state-in-effect */
32
+ /* 쪽 전환: stepIndex 가 이 단계 이상이었다가 미만으로 돌아오면(다음 문제가 시작됨) 고른 순서를 비운다(Confirm 과 같게) —
33
+ 보기가 같은 다음 문항(같은 세 낱말·섞은 배치가 같음)에서 앞 문항의 순서가 남아 고른 적 없는 카드까지 채점됐다. */
34
+ const prevStepIndexRef = useRef(undefined);
35
+ useEffect(() => {
36
+ const prev = prevStepIndexRef.current;
37
+ if (typeof prev === 'number' && typeof stepIndex === 'number' && typeof index === 'number' && prev >= index && stepIndex < index) {
38
+ setSelectedOrder(Array.from({ length: choiceCount }, () => null));
39
+ }
40
+ prevStepIndexRef.current = stepIndex;
41
+ }, [stepIndex, index, choiceCount]);
32
42
  const nextRank = useMemo(() => selectedOrder.filter(v => v !== null).length + 1, [selectedOrder]);
33
43
  /* 다음 배열은 updater **밖에서** 셈하고 알린다 — updater 안에서 onChange·onDone 을 부르면 StrictMode 에서 한 번
34
44
  클릭에 두 번씩 나갔다. 풀 때도 알린다(전에는 풀면 부모가 옛 순서를 그대로 들고 있었다). */
@@ -19,6 +19,7 @@ export interface UseToastLifecycleResult {
19
19
  /** 멈춘 시계를 **남은 시간부터** 다시 돌린다 — 처음부터가 아니다. */
20
20
  resume: () => void;
21
21
  }
22
+ export declare const EXIT_ANIM_MS = 260;
22
23
  /**
23
24
  * Toast의 등장/퇴장 슬라이드 애니메이션 + 자동 닫힘 타이머 상태 관리.
24
25
  * @public
@@ -1,6 +1,8 @@
1
1
  'use client';
2
2
  import { useCallback, useEffect, useRef, useState } from 'react';
3
- const EXIT_ANIM_MS = 500;
3
+ /* Toast.css 의 퇴장 전환 중 가장 긴 것(transform 260ms)과 같게 — 500 이면 투명해진 알림이 240ms 더 붙어 화면 맨 위 띠의 클릭을
4
+ 삼켰다(Chromium 실측: 사라진 뒤 누른 단추가 안 눌림). 시험이 CSS 값과 대조한다. */
5
+ export const EXIT_ANIM_MS = 260;
4
6
  /**
5
7
  * Toast의 등장/퇴장 슬라이드 애니메이션 + 자동 닫힘 타이머 상태 관리.
6
8
  * @public
@@ -46,9 +46,10 @@ export function useTablePaging(rows, resetOn, opts) {
46
46
  const total = Math.max(1, Math.ceil(rows.length / Number(pageSize)));
47
47
  /* 행이 줄어 범위를 넘으면 **상태도** 끝 쪽으로 맞춘다 — 보이는 쪽만 조이고 상태를 두면, 필터로 줄었다가 다시 늘 때 예전
48
48
  5쪽으로 튀었다. 렌더 중에 맞춘다(위 resetOn 과 같은 꼴). */
49
- if (page > total)
50
- setPage(total);
51
- const 이쪽 = Math.min(page, total);
49
+ // 1 보다 작은 쪽(setPage(0)·-1)도 막는다 — 0 은 빈 쪽, -1 은 음수 slice 로 엉뚱한 줄이 나왔다
50
+ if (page > total || page < 1 || !Number.isFinite(page))
51
+ setPage(Math.min(Math.max(1, Number.isFinite(page) ? page : 1), total));
52
+ const 이쪽 = Math.min(Math.max(1, Number.isFinite(page) ? page : 1), total);
52
53
  const pageRows = useMemo(() => rows.slice((이쪽 - 1) * Number(pageSize), 이쪽 * Number(pageSize)), [rows, 이쪽, pageSize]);
53
54
  return {
54
55
  pageRows,
@@ -1,13 +1,13 @@
1
1
  /** 글자 둘을 한국어 차례로 견준다 */
2
2
  export declare function 한글순(a: string, b: string): number;
3
3
  /**
4
- * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 나머지는 글자 속 숫자까지 수로.**
4
+ * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 글자 속 숫자도 수로.**
5
5
  *
6
6
  * 표의 칸은 무엇이든 올 수 있어서 늘 글자로 견주면 **숫자 칸이 망가진다**
7
- * (`10` 이 `9` 앞에 선다). 그래서 둘 다 온전한 숫자(수, 또는 숫자만 든 글자)면 숫자로 견주고,
8
- * 그 밖에는 한국어 차례로 견주되 글자 안의 숫자는 수로 읽는다(`회차 9` 가 `회차 10` 앞).
7
+ * (`10` 이 `9` 앞에 선다). 값을 숫자 조각과 글자 조각으로 나눠 앞에서부터 견준다 —
8
+ * `회차 9` 가 `회차 10` 앞, `12.25%` 가 `12.5%` 앞, `-10 dB` 가 `-5 dB` 앞. 숫자 조각은 글자 조각 앞에 선다.
9
9
  *
10
- * 비어 있는 값(`null`·`undefined`)은 빈 글자로 본다 — 부르는 쪽이 이미
10
+ * 비어 있는 값(`null`·`undefined`·NaN)은 빈 글자로 본다 — 부르는 쪽이 이미
11
11
  * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
12
12
  */
13
13
  export declare function 칸순(a: unknown, b: unknown): number;
@@ -22,39 +22,67 @@ const 콜레이터 = (() => {
22
22
  export function 한글순(a, b) {
23
23
  return 콜레이터().compare(a, b);
24
24
  }
25
- const 숫자글자 = /^-?\d+(\.\d+)?$/;
26
- /* 칸의 글자 비교는 **안의 숫자도 수로** 본다 — 숫자 칸끼리는 수로, 섞인 칸은 글자로 견주면 '9'<'10'(수)·'10'<'10분'(글자)·
27
- '10분'<'9'(글자) 로 돌고 돌아, 같은 행을 들어온 차례만 바꿔도 정렬 결과가 달랐다. 수를 수로 읽는 콜레이터는
28
- 숫자끼리의 차례와 같은 쪽을 낸다. */
29
- const 칸콜레이터 = (() => {
30
- let 것 = null;
31
- return () => {
32
- if (!것)
33
- 것 = new Intl.Collator('ko', { numeric: true });
34
- return 것;
35
- };
36
- })();
25
+ const 조각열쇠 = new Map();
26
+ function 조각내기(s) {
27
+ const 있음 = 조각열쇠.get(s);
28
+ if (있음)
29
+ return 있음;
30
+ const out = [];
31
+ // 뒤돌아보기(?<=) 는 iOS 15 Safari 가 못 읽어 모듈째 깨지므로 쓰지 않는다 — 빼기표 앞 글자를 손으로 본다
32
+ const re = /-?\d+(?:\.\d+)?/g;
33
+ let 끝 = 0;
34
+ for (let m = re.exec(s); m; m = re.exec(s)) {
35
+ let 시작 = m.index;
36
+ let 수 = m[0];
37
+ if (수[0] === '-' && 시작 > 0 && !/\s/.test(s[시작 - 1])) {
38
+ 시작 += 1;
39
+ 수 = 수.slice(1);
40
+ }
41
+ if (시작 > 끝)
42
+ out.push(s.slice(끝, 시작));
43
+ out.push(Number(수));
44
+ 끝 = m.index + m[0].length;
45
+ }
46
+ if (끝 < s.length)
47
+ out.push(s.slice(끝));
48
+ if (조각열쇠.size > 5000)
49
+ 조각열쇠.clear();
50
+ 조각열쇠.set(s, out);
51
+ return out;
52
+ }
37
53
  /**
38
- * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 나머지는 글자 속 숫자까지 수로.**
54
+ * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 글자 속 숫자도 수로.**
39
55
  *
40
56
  * 표의 칸은 무엇이든 올 수 있어서 늘 글자로 견주면 **숫자 칸이 망가진다**
41
- * (`10` 이 `9` 앞에 선다). 그래서 둘 다 온전한 숫자(수, 또는 숫자만 든 글자)면 숫자로 견주고,
42
- * 그 밖에는 한국어 차례로 견주되 글자 안의 숫자는 수로 읽는다(`회차 9` 가 `회차 10` 앞).
57
+ * (`10` 이 `9` 앞에 선다). 값을 숫자 조각과 글자 조각으로 나눠 앞에서부터 견준다 —
58
+ * `회차 9` 가 `회차 10` 앞, `12.25%` 가 `12.5%` 앞, `-10 dB` 가 `-5 dB` 앞. 숫자 조각은 글자 조각 앞에 선다.
43
59
  *
44
- * 비어 있는 값(`null`·`undefined`)은 빈 글자로 본다 — 부르는 쪽이 이미
60
+ * 비어 있는 값(`null`·`undefined`·NaN)은 빈 글자로 본다 — 부르는 쪽이 이미
45
61
  * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
46
62
  */
47
63
  export function 칸순(a, b) {
48
- /* NaN 은 숫자가 아닌 것으로 — a - b 가 NaN 이면 정렬 비교가 어긋나 줄이 뒤섞였다. 글자로 온 숫자('10'·'9')도
49
- 둘 다 온전한 숫자면 숫자로 견준다(전에는 '10' 이 '9' 앞에 섰다). */
50
- const 수 = (v) => (typeof v === 'number' && !Number.isNaN(v) ? v : typeof v === 'string' && 숫자글자.test(v.trim()) ? Number(v) : null);
51
- const na = 수(a), nb = 수(b);
52
- if (na !== null && nb !== null)
53
- return na - nb;
54
- const sa = a == null ? '' : String(a), sb = b == null ? '' : String(b);
55
- /* 소수점·음수가 든 글자('12.5%'·'-10 dB')는 수로 읽는 콜레이터가 틀린다 — 점 뒤를 따로 떼어 '12.5' 가 '12.25' 뒤에,
56
- 빼기표를 버려 '-5' 가 '-10' 뒤에 섰다. 그런 칸은 한국어 글자 차례로 둔다. 선행 0 도 수로 읽으면 'A007'='A7' 이 되어 글자로. */
57
- if (/\d\.\d|-\d|(^|\D)0\d/.test(sa) || /\d\.\d|-\d|(^|\D)0\d/.test(sb))
58
- return 한글순(sa, sb);
59
- return 칸콜레이터().compare(sa, sb);
64
+ const 글 = (v) => (v == null || (typeof v === 'number' && Number.isNaN(v)) ? '' : String(v).trim());
65
+ const sa = 글(a), sb = 글(b);
66
+ if (sa === sb)
67
+ return 0;
68
+ const ka = 조각내기(sa), kb = 조각내기(sb);
69
+ const n = Math.min(ka.length, kb.length);
70
+ for (let i = 0; i < n; i++) {
71
+ const x = ka[i], y = kb[i];
72
+ if (typeof x === 'number' && typeof y === 'number') {
73
+ if (x !== y)
74
+ return x - y;
75
+ continue;
76
+ }
77
+ if (typeof x === 'number')
78
+ return -1;
79
+ if (typeof y === 'number')
80
+ return 1;
81
+ const c = 한글순(x, y);
82
+ if (c !== 0)
83
+ return c;
84
+ }
85
+ if (ka.length !== kb.length)
86
+ return ka.length - kb.length;
87
+ return 한글순(sa, sb);
60
88
  }
@@ -55,7 +55,9 @@ export function toItemResults(raw) {
55
55
  /* 물음이 담긴 칸 이름이 유형마다 다르다 — 문장 훈련 넷(소음 하 듣기·문장 순서화·
56
56
  짧은/긴 이야기)은 `sentence` 에, 낱말 훈련은 `word` 에 담는다. `sentence` 를
57
57
  빠뜨렸더니 대면 세션 기록의 item_id 가 전부 null 이었다(2026-08-27 실측). */
58
- question: text(r.question) ?? text(r.sentence) ?? text(r.word) ?? text(r.targetSyllable),
58
+ /* 단어 조작(합성·대치·탈락)·확인형은 물음을 `text` 에 담는다 — 빠져 기록의 question 이 null 이라 오답 패널에서 통째로
59
+ 빠졌는데, 결과 창(resolveProblem)은 text 를 읽어 보여 두 화면이 어긋났다. 결과 창과 같은 칸들을 본다. */
60
+ question: text(r.question) ?? text(r.q) ?? text(r.sentence) ?? text(r.text) ?? text(r.prompt) ?? text(r.word) ?? text(r.targetSyllable),
59
61
  answer: text(r.answer),
60
62
  selected: text(r.selectedText) ?? text(r.selected),
61
63
  correct: itemCorrectness(r),
@@ -75,6 +75,8 @@ export declare function syllableContrasts(cs: Syllable, part: 'cho' | 'jung' | '
75
75
  export interface TrainingProblem {
76
76
  question: string;
77
77
  distractors: string[];
78
+ /** 탈락 문제에서 빠진 조각(합성어 부분 탈락이 채운다 — 앞뒤로 같은 조각이 있으면 remain 만으로는 어느 쪽인지 모른다) */
79
+ dropped?: string;
78
80
  }
79
81
  /**
80
82
  * @public 2음절 단어 → [문제단어, 남는 1음절(정답), 대립쌍 오답 2]. 정답·오답 모두 '실단어'(wordSet)만 인정.
@@ -210,7 +210,10 @@ export function primarySelPart(comp) {
210
210
  /** @public 음절(분해값 s)이 comp의 선택 파트들과 모두 일치하는지(=선택 음소를 가진 음절). */
211
211
  export function syllMatchesComp(s, comp) {
212
212
  const ok = (arr, val) => !hasSel(arr) || asArr(arr).includes(val);
213
- return ok(comp.cho, CHOSEONG[s.cho]) && ok(comp.jung, JUNGSEONG[s.jung]) && ok(comp.jong, JONGSEONG[s.jong]);
213
+ /* '받침 없음'(jongNone)도 본다 — 풀 필터(wordTrainingPool)는 jongNone 이면 받침 없는 음절만 맞다고 보는데 여기만 무시해, 초성 ㅎ +
214
+ 받침 없음으로 골라도 음절 바꾸기가 '해협' 의 '협' 을 바꿀 음절로 골랐다. jongNone 이면 jong 선택은 보지 않는다(풀과 같게). */
215
+ const jongOk = comp.jongNone ? s.jong === 0 : ok(comp.jong, JONGSEONG[s.jong]);
216
+ return ok(comp.cho, CHOSEONG[s.cho]) && ok(comp.jung, JUNGSEONG[s.jung]) && jongOk;
214
217
  }
215
218
  /**
216
219
  * @public 발음 모드에서 comp 매칭에 쓸 음절 배열. 표준발음(pronMap)의 음절수가 철자와 같으면 발음 음절로,
@@ -407,7 +410,7 @@ export function buildDropCompoundProblem(word, comp, pronMap, phonemeView, wordS
407
410
  break;
408
411
  }
409
412
  if (wrongs.length >= 2)
410
- return { question: word, distractors: [remain, ...wrongs] };
413
+ return { question: word, distractors: [remain, ...wrongs], dropped };
411
414
  }
412
415
  }
413
416
  return null;
@@ -25,6 +25,7 @@ export declare function pickOne<T>(arr: T[]): T | undefined;
25
25
  /** 부분 Fisher-Yates: n개만 선택 — arr.length 전체를 교환하지 않고 n번만 교환(대용량 풀 성능용, 공용 sample과 별개). */
26
26
  export declare function sample<T>(arr: T[], n: number): T[];
27
27
  export declare function getPronMap(): Map<string, string>;
28
+ export declare function pronOfWord(word: string): string;
28
29
  /**
29
30
  * 후보군에서 n개를 뽑되, 이미 선택된 것들(또는 usedPron으로 미리 배제한 것들)과
30
31
  * 발음이 겹치지 않도록 그리디하게 샘플링한다.