@ihabdevteam/core 0.141.0 → 0.142.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 (28) hide show
  1. package/CHANGELOG.md +29 -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 +21 -9
  7. package/dist/packages/core/src/components/BulkBar.js +15 -4
  8. package/dist/packages/core/src/components/PaymentPlan.d.ts +2 -2
  9. package/dist/packages/core/src/components/Popup/Popup.d.ts +5 -5
  10. package/dist/packages/core/src/components/Popup/Popup.js +6 -7
  11. package/dist/packages/core/src/components/Popup/customerDetailUtils.d.ts +1 -1
  12. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +1 -1
  13. package/dist/packages/core/src/components/Tooltip/Tooltip.js +6 -0
  14. package/dist/packages/core/src/components/Training/TrainingBlock.js +2 -1
  15. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +4 -0
  16. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +4 -1
  17. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +3 -1
  18. package/dist/packages/core/src/hooks/inputModality.d.ts +2 -0
  19. package/dist/packages/core/src/hooks/inputModality.js +31 -0
  20. package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +12 -0
  21. package/dist/packages/core/src/hooks/useDocumentTitle.js +7 -6
  22. package/dist/packages/core/src/hooks/useEditableList.js +3 -3
  23. package/dist/packages/core/src/utils/textSort.d.ts +10 -0
  24. package/dist/packages/core/src/utils/textSort.js +16 -11
  25. package/dist/packages/core/src/version.d.ts +1 -1
  26. package/dist/packages/core/src/version.js +1 -1
  27. package/dist/utils.cjs.js +1 -1
  28. 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.141.0";
1379
+ export declare const CORE_VERSION = "0.142.0";
1380
1380
 
1381
1381
  /** @public */
1382
1382
  declare const coreDefault: Record<string, unknown>;
@@ -2206,7 +2206,7 @@ export declare function formatDateLongLocale(iso: string, lang: string): string;
2206
2206
  export declare function formatDatetimeKo(iso: string): string;
2207
2207
 
2208
2208
  /**
2209
- * 초 → "M분 S초"(또는 "M분"·"S초"). 없거나 0 이하면 '-'.
2209
+ * 초 → "M분 S초"(또는 "M분"·"S초"). 초 단위로 반올림한 뒤 나눈다 — 없거나(숫자가 아님) 반올림해 0 이하(0.5초 미만 포함)면 '-'.
2210
2210
  *
2211
2211
  * 문구는 지금 언어로(`customers.duration.*`, 사전에 없으면 한국어). `durationStr` 를 주면
2212
2212
  * 그 함수가 만든 글을 그대로 쓴다 — `calcAge` 의 `ageStr` 와 같은 결.
@@ -3807,13 +3807,13 @@ export declare interface PaymentPlanTier {
3807
3807
  highlight?: boolean;
3808
3808
  /**
3809
3809
  * 할인 배지에 '오픈 특가'를 함께 얹는다 — **표시 전용**.
3810
- * 계정당 1회 제한은 라벨의 '오픈 특가' 문자열로 판정하므로, 이 플래그만으로는
3810
+ * 계정당 1회 제한은 `onceOnly`(없으면 라벨의 '오픈 특가' 문자열)로 판정하므로, 이 플래그만으로는
3811
3811
  * 구매 횟수가 제한되지 않는다(같은 프로모션이되 1회 제한 대상은 아닌 tier용).
3812
3812
  */
3813
3813
  openPromo?: boolean;
3814
3814
  /**
3815
3815
  * **계정당 1회만 살 수 있는 tier**(오픈 특가 30일 등). 이미 산 것(`ownedCodes`)이면 숨기고 기본 tier 를 카드로 올린다.
3816
- * 안 주면 예전처럼 라벨에 '오픈 특가' 가 있는지로 판정한다 — 그런데 라벨은 번역되므로 영어 화면에서는 판정이 실패해
3816
+ * 안 주면 라벨에 '오픈 특가' 가 있는지로 판정한다 — 그런데 라벨은 번역되므로 영어 화면에서는 판정이 실패해
3817
3817
  * 이미 산 특가 카드가 남고 정가 30일권을 살 길이 없어진다. 번역되는 라벨 대신 이 값을 넘긴다.
3818
3818
  */
3819
3819
  onceOnly?: boolean;
@@ -4135,12 +4135,12 @@ export declare interface PopupProps {
4135
4135
  /**
4136
4136
  * 닫기 직전에 불린다. false를 반환하면 닫기를 중단한다(닫힘 애니메이션도 시작하지 않음).
4137
4137
  *
4138
- * "입력 중인데 정말 닫을까요?" 같은 확인 절차용. onClose에서 막으면 안 되는 이유 —
4139
- * onClose는 이미 애니메이션이 시작된 뒤(animateThen의 setTimeout 안)에 불리므로,
4140
- * 거기서 닫기를 취소해도 팝업은 closingRef=true인 채로 남아 이후 ESC·X가 전부
4141
- * 무시된다(영구히 못 닫는 상태). 그래서 판단은 반드시 이 시점에 해야 한다.
4138
+ * "입력 중인데 정말 닫을까요?" 같은 확인 절차용. onClose에서 막지 않는 이유 —
4139
+ * onClose는 이미 닫힘 애니메이션(260ms)이 끝난 뒤(animateThen의 setTimeout 안)에 불리므로,
4140
+ * 거기서 닫기를 취소하면 창이 한 번 사라졌다가 되살아난다(animateThen 은 콜백 뒤에도 창이
4141
+ * 남아 있으면 되살린다). 사라지지 않고 그대로 머물게 하려면 이 시점에 판단한다.
4142
4142
  *
4143
- * 미지정이면 기존 동작과 완전히 동일하다.
4143
+ * 미지정이면 묻지 않고 닫는다.
4144
4144
  */
4145
4145
  beforeClose?: () => boolean;
4146
4146
  /**
@@ -7920,6 +7920,18 @@ export declare function useDeviceManagement(): {
7920
7920
  handleSelectFromList: (form: DeviceForm) => void;
7921
7921
  };
7922
7922
 
7923
+ /**
7924
+ * 현재 화면의 제목을 `document.title`에 반영한다 — `'<제목> · <앱 이름>'`.
7925
+ *
7926
+ * SPA는 라우트를 옮겨도 문서 제목이 그대로라, 브라우저 탭·히스토리·북마크로 위치를
7927
+ * 구분할 수 없고 화면낭독기도 이동을 인지하지 못한다(WCAG 2.4.2).
7928
+ * 여러 화면이 함께 걸면 제목이 쌓여 가장 나중에 건 것이 보이고, 언마운트하면
7929
+ * 남은 것 중 가장 나중에 건 제목으로(남은 것이 없으면 처음 제목으로) 되돌린다.
7930
+ *
7931
+ * @param title 페이지 제목. 비어 있거나 문자열이 아니면 아무것도 하지 않는다.
7932
+ *
7933
+ * @public
7934
+ */
7923
7935
  export declare function useDocumentTitle(title?: string | null): void;
7924
7936
 
7925
7937
  /**
@@ -56,10 +56,21 @@ export default function BulkBar({ count, actions, onClear, clearLabel: clearLabe
56
56
  return;
57
57
  const obs = new ResizeObserver(check);
58
58
  obs.observe(el);
59
- // 창 폭이 바뀌면 기억한 가로 칸 폭은 옛 것이다 — 버리고 다시 잰다
60
- const 창바뀜 = () => { 가로칸폭Ref.current = null; check(); };
61
- window.addEventListener('resize', 창바뀜);
62
- return () => { obs.disconnect(); window.removeEventListener('resize', 창바뀜); };
59
+ /* 막대 자체의 폭이 바뀌면(창 크기뿐 아니라 옆 메뉴 접기로 본문 열이 넓어질 때도) 기억한 가로 칸 폭은 옛 것이다 — 버리고 다시
60
+ 잰다. 안 그러면 넘친 순간의 좁은 폭을 들고 있어 넓어져도 쌓임이 영영 안 풀렸다. 단추 배치로는 막대 폭이 안 바뀐다. */
61
+ const 막대 = el.parentElement;
62
+ let 막대폭 = 막대?.clientWidth ?? 0;
63
+ const 막대obs = new ResizeObserver(() => {
64
+ const w = 막대?.clientWidth ?? 0;
65
+ if (w === 막대폭)
66
+ return;
67
+ 막대폭 = w;
68
+ 가로칸폭Ref.current = null;
69
+ check();
70
+ });
71
+ if (막대)
72
+ 막대obs.observe(막대);
73
+ return () => { obs.disconnect(); 막대obs.disconnect(); };
63
74
  }, [content, count]);
64
75
  return (_jsxs("div", { className: `bulk-bar ${show ? 'bulk-bar--visible' : ''}`,
65
76
  /* 숨은 막대는 CSS 로 투명(opacity 0)할 뿐 DOM 에 그대로 있다 — 예전에는 그 안의
@@ -16,13 +16,13 @@ export interface PaymentPlanTier {
16
16
  highlight?: boolean;
17
17
  /**
18
18
  * 할인 배지에 '오픈 특가'를 함께 얹는다 — **표시 전용**.
19
- * 계정당 1회 제한은 라벨의 '오픈 특가' 문자열로 판정하므로, 이 플래그만으로는
19
+ * 계정당 1회 제한은 `onceOnly`(없으면 라벨의 '오픈 특가' 문자열)로 판정하므로, 이 플래그만으로는
20
20
  * 구매 횟수가 제한되지 않는다(같은 프로모션이되 1회 제한 대상은 아닌 tier용).
21
21
  */
22
22
  openPromo?: boolean;
23
23
  /**
24
24
  * **계정당 1회만 살 수 있는 tier**(오픈 특가 30일 등). 이미 산 것(`ownedCodes`)이면 숨기고 기본 tier 를 카드로 올린다.
25
- * 안 주면 예전처럼 라벨에 '오픈 특가' 가 있는지로 판정한다 — 그런데 라벨은 번역되므로 영어 화면에서는 판정이 실패해
25
+ * 안 주면 라벨에 '오픈 특가' 가 있는지로 판정한다 — 그런데 라벨은 번역되므로 영어 화면에서는 판정이 실패해
26
26
  * 이미 산 특가 카드가 남고 정가 30일권을 살 길이 없어진다. 번역되는 라벨 대신 이 값을 넘긴다.
27
27
  */
28
28
  onceOnly?: boolean;
@@ -38,12 +38,12 @@ export interface PopupProps {
38
38
  /**
39
39
  * 닫기 직전에 불린다. false를 반환하면 닫기를 중단한다(닫힘 애니메이션도 시작하지 않음).
40
40
  *
41
- * "입력 중인데 정말 닫을까요?" 같은 확인 절차용. onClose에서 막으면 안 되는 이유 —
42
- * onClose는 이미 애니메이션이 시작된 뒤(animateThen의 setTimeout 안)에 불리므로,
43
- * 거기서 닫기를 취소해도 팝업은 closingRef=true인 채로 남아 이후 ESC·X가 전부
44
- * 무시된다(영구히 못 닫는 상태). 그래서 판단은 반드시 이 시점에 해야 한다.
41
+ * "입력 중인데 정말 닫을까요?" 같은 확인 절차용. onClose에서 막지 않는 이유 —
42
+ * onClose는 이미 닫힘 애니메이션(260ms)이 끝난 뒤(animateThen의 setTimeout 안)에 불리므로,
43
+ * 거기서 닫기를 취소하면 창이 한 번 사라졌다가 되살아난다(animateThen 은 콜백 뒤에도 창이
44
+ * 남아 있으면 되살린다). 사라지지 않고 그대로 머물게 하려면 이 시점에 판단한다.
45
45
  *
46
- * 미지정이면 기존 동작과 완전히 동일하다.
46
+ * 미지정이면 묻지 않고 닫는다.
47
47
  */
48
48
  beforeClose?: () => boolean;
49
49
  /**
@@ -81,7 +81,8 @@ export default function Popup(props) {
81
81
  // 포커스 관리 — 이전까진 팝업이 열려도 포커스가 배경(주로 body)에 남아있어서,
82
82
  // 키보드 사용자가 Tab을 눌러도 팝업 밖 배경 요소로 이동해버리고(포커스 트랩 없음),
83
83
  // 스크린리더는 열린 다이얼로그를 아예 인지하지 못했다. 열릴 때 패널(또는 첫 포커스
84
- // 가능 요소)로 포커스를 옮기고, 닫히면 원래 포커스였던 요소로 되돌린다.
84
+ // 가능 요소)로 포커스를 옮기고, 닫히면 — 포커스가 아직 이 창 안이거나 body 일 때만 —
85
+ // 원래 포커스였던 요소로 되돌린다(다른 창으로 옮겨 갔으면 그대로 둔다).
85
86
  // 단, 자식이 자체 autoFocus를 쓰는 경우(SaveTablePopup 등)는 이미 그쪽으로 포커스가
86
87
  // 가 있을 것이므로(브라우저가 커밋 시점에 처리) 뺏지 않는다.
87
88
  useEffect(() => {
@@ -130,13 +131,11 @@ export default function Popup(props) {
130
131
  되살리기();
131
132
  return;
132
133
  }
133
- // 부모가 치울 틈(렌더·커밋)을 준 뒤에도 남아 있으면 되살린다
134
- const 남았으면 = () => { setTimeout(되살리기, 120); };
134
+ /* 약속을 돌려준 콜백만 **끝난 뒤에도 남아 있으면** 되살린다. 동기 콜백(undefined)은 되살리지 않는다 — 부모가 저장을
135
+ 따로 기다렸다 닫는 흔한 꼴(onSave 를 부르고 약속은 안 돌려줌)에서 기다리는 사이 창이 다시 보이고 저장 단추가 다시
136
+ 눌려 두 번 저장됐다. 저장 실패에 창을 되살리고 싶으면 콜백이 그 약속을 돌려주면 된다. */
135
137
  if (결과 && typeof 결과.then === 'function') {
136
- 결과.then(남았으면, 되살리기);
137
- }
138
- else {
139
- 남았으면();
138
+ 결과.then(() => { setTimeout(되살리기, 120); }, 되살리기);
140
139
  }
141
140
  }, ANIM_MS);
142
141
  }, [되살리기]);
@@ -27,7 +27,7 @@ export declare function visitItemKey(v: {
27
27
  minute: string;
28
28
  }): string;
29
29
  /**
30
- * 초 → "M분 S초"(또는 "M분"·"S초"). 없거나 0 이하면 '-'.
30
+ * 초 → "M분 S초"(또는 "M분"·"S초"). 초 단위로 반올림한 뒤 나눈다 — 없거나(숫자가 아님) 반올림해 0 이하(0.5초 미만 포함)면 '-'.
31
31
  *
32
32
  * 문구는 지금 언어로(`customers.duration.*`, 사전에 없으면 한국어). `durationStr` 를 주면
33
33
  * 그 함수가 만든 글을 그대로 쓴다 — `calcAge` 의 `ageStr` 와 같은 결.
@@ -71,7 +71,7 @@ export function visitItemKey(v) {
71
71
  return `${v.year}-${v.month}-${v.day}T${v.hour}:${v.minute}`;
72
72
  }
73
73
  /**
74
- * 초 → "M분 S초"(또는 "M분"·"S초"). 없거나 0 이하면 '-'.
74
+ * 초 → "M분 S초"(또는 "M분"·"S초"). 초 단위로 반올림한 뒤 나눈다 — 없거나(숫자가 아님) 반올림해 0 이하(0.5초 미만 포함)면 '-'.
75
75
  *
76
76
  * 문구는 지금 언어로(`customers.duration.*`, 사전에 없으면 한국어). `durationStr` 를 주면
77
77
  * 그 함수가 만든 글을 그대로 쓴다 — `calcAge` 의 `ageStr` 와 같은 결.
@@ -146,6 +146,12 @@ export default function Tooltip(props) {
146
146
  const t = e.target;
147
147
  if (t && ref.current && ref.current.contains(t))
148
148
  return;
149
+ // 툴팁에서 body 로 빠진 메뉴·달력(data-tooltip-ignore)을 굴리는 것도 툴팁 안의 일이다 — 열자마자 선택값으로 굴리면 닫혔다
150
+ try {
151
+ if (t && t.closest && t.closest('[data-tooltip-ignore]'))
152
+ return;
153
+ }
154
+ catch (err) { }
149
155
  startCloseRef.current(true);
150
156
  return;
151
157
  }
@@ -7,6 +7,7 @@ import Section from '../Section';
7
7
  import './TrainingBlock.css';
8
8
  import TrainingBlockButton from './TrainingBlockButton';
9
9
  import { useWindowEvent } from '../../hooks/useWindowEvent';
10
+ import { 키보드로눌렀나 } from '../../hooks/inputModality';
10
11
  const BTN_COUNT_MAX = 20;
11
12
  /** @public */
12
13
  export default function TrainingBlock(props) {
@@ -34,7 +35,7 @@ export default function TrainingBlock(props) {
34
35
  const 더보기초점Ref = useRef(null);
35
36
  function loadMore() {
36
37
  const 단추 = document.activeElement;
37
- 더보기초점Ref.current = 단추 && 단추.closest?.('.training-block__more') ? visible : null;
38
+ 더보기초점Ref.current = 키보드로눌렀나() && 단추 && 단추.closest?.('.training-block__more') ? visible : null;
38
39
  setVisible((v) => Math.min(total, v + BTN_COUNT_MAX));
39
40
  }
40
41
  useEffect(() => {
@@ -25,6 +25,7 @@ import useTrainingSessionLog from '../../hooks/useTrainingSessionLog';
25
25
  import { logTrainingCompletion } from '../../utils/trainingLogger';
26
26
  import { toItemResults } from '../../utils/trainingItemResults';
27
27
  import { loadNoiseSettings, saveNoiseSettings, reportAppliedNoiseSettings } from '../../utils/noiseSettingsStore';
28
+ import { 키보드로눌렀나 } from '../../hooks/inputModality';
28
29
  // 문장 4종은 각 PagePopup 래퍼에서 useTrainingSessionLog로 이미 기록 → 베이스에서 중복 방지
29
30
  const SENTENCE_TIME_LOGGED_TYPES = new Set(['LISTEN_IN_NOISE', 'SHORT_STORY', 'LONG_STORY', 'ORDERING_SENTENCE']);
30
31
  // renderResultPopup 미지정 시 기본 결과 팝업 — 모든 훈련 타입 공통 폴백
@@ -763,6 +764,9 @@ function TrainingPagePopupInner(props) {
763
764
  앞쪽Ref.current = effectivePageIndex;
764
765
  if (앞 === undefined || 앞 === effectivePageIndex)
765
766
  return;
767
+ // 키보드로 넘겼을 때만 — 마우스로 누른 사람에게 매 문제 '듣기' 초점 안내가 뜨지 않게
768
+ if (!키보드로눌렀나())
769
+ return;
766
770
  const panel = panelRef.current;
767
771
  if (!panel)
768
772
  return;
@@ -17,6 +17,7 @@ import Button from '../Button';
17
17
  import Icon from '../Icon';
18
18
  import InfoTooltip from '../Tooltip/InfoTooltip';
19
19
  import { STATUS_MESSAGES } from './trainingStepAudioPlayerTypes';
20
+ import { 키보드로눌렀나 } from '../../hooks/inputModality';
20
21
  import { measureAudioDuration, waitForCanPlay } from './audioWaits';
21
22
  import { useWaveReady } from './useWaveReady';
22
23
  import { useObjectUrls } from './useObjectUrls';
@@ -1092,7 +1093,9 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1092
1093
  const fillPausedFlag = playbackState === "paused";
1093
1094
  const fillLeftPercent = Math.max(0, Math.min(100, (progress || 0) * 100));
1094
1095
  const fillClass = cx('audio-player__fill', fillVisibleFlag ? 'is-visible' : '', fillExpandedFlag ? 'is-expanded' : '', fillPausedFlag ? 'is-paused' : '', isFillOutAnimating ? 'is-out' : '');
1095
- return (_jsxs("div", { className: `audio-player ${isDisabled ? 'is-disabled' : ''}${className ? ` ${className}` : ''}`, role: "group", "aria-label": typeof label === 'string' ? label : undefined, children: [_jsxs("div", { className: `audio-player__panel ${displayedPlaying ? "is-playing" : ""} ${isFetchingUrl ? "is-loading" : ""} ${stateLocal !== "default" ? "is-revealed" : ""}`, children: [isFetchingUrl ? (_jsxs("div", { className: "audio-player__loading", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "audio-player__loading-spinner", "aria-hidden": "true" }), _jsx("span", { className: "audio-player__loading-text", children: t(`training.audioStatus.${fetchDebugStatus}`, { defaultValue: STATUS_MESSAGES[fetchDebugStatus] }) })] })) : null, stateLocal === "active" ? (_jsx("button", { className: "audio-player__button", type: "button", onClick: (e) => { 듣기초점옮기기Ref.current = document.activeElement === e.currentTarget; hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
1096
+ return (_jsxs("div", { className: `audio-player ${isDisabled ? 'is-disabled' : ''}${className ? ` ${className}` : ''}`, role: "group", "aria-label": typeof label === 'string' ? label : undefined, children: [_jsxs("div", { className: `audio-player__panel ${displayedPlaying ? "is-playing" : ""} ${isFetchingUrl ? "is-loading" : ""} ${stateLocal !== "default" ? "is-revealed" : ""}`, children: [isFetchingUrl ? (_jsxs("div", { className: "audio-player__loading", role: "status", "aria-live": "polite", children: [_jsx("span", { className: "audio-player__loading-spinner", "aria-hidden": "true" }), _jsx("span", { className: "audio-player__loading-text", children: t(`training.audioStatus.${fetchDebugStatus}`, { defaultValue: STATUS_MESSAGES[fetchDebugStatus] }) })] })) : null, stateLocal === "active" ? (_jsx("button", { className: "audio-player__button", type: "button",
1097
+ // 키보드로 눌렀을 때만 초점을 옮긴다 — 마우스로 누른 사람에게 '재생 / 멈춤' 초점 툴팁이 뜨지 않게
1098
+ onClick: () => { 듣기초점옮기기Ref.current = 키보드로눌렀나(); hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
1096
1099
  // 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
1097
1100
  onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
1098
1101
  handleJumpTo(idx);
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import { useTranslation } from 'react-i18next';
5
+ import { 키보드로눌렀나 } from '../../hooks/inputModality';
5
6
  import Button from '../Button';
6
7
  import Toast from '../Toast';
7
8
  import { useFontScale } from '../../hooks';
@@ -34,7 +35,8 @@ export default function TrainingStepTextBox({ context, state = "disabled", theme
34
35
  const 상자Ref = useRef(null);
35
36
  const 펼친뒤초점Ref = useRef(false);
36
37
  const handleActivate = useCallback(() => {
37
- 펼친뒤초점Ref.current = !!상자Ref.current && 상자Ref.current.contains(document.activeElement);
38
+ // 키보드로 눌렀을 때만 — 마우스로 눌렀는데 옮기면 '글자 크기 감소' 초점 툴팁이 떴다
39
+ 펼친뒤초점Ref.current = 키보드로눌렀나() && !!상자Ref.current && 상자Ref.current.contains(document.activeElement);
38
40
  setLocalStatus("active");
39
41
  }, []);
40
42
  const handleNoticeClose = useCallback(() => setNoticeMessage(''), []);
@@ -0,0 +1,2 @@
1
+ /** 마지막 입력이 키보드였으면 true(모르면 false) */
2
+ export declare function 키보드로눌렀나(): boolean;
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+ /*
3
+ * 마지막 입력이 **키보드였나** — 누른 단추가 사라지는 자리에서 초점을 옮길지 가를 때 쓴다.
4
+ *
5
+ * "초점이 누른 단추에 있었나" 로만 보면 Chrome·Edge·안드로이드는 마우스로 눌러도 단추에 초점이 가서, 마우스 사용자에게도
6
+ * 초점을 옮겨 그 단추의 초점 툴팁이 떴다(듣기 → '재생 / 멈춤', 문장 미리보기 → '글자 크기 감소'). Safari 는 누른 단추에 초점을
7
+ * 안 줘 body 에 남는다. 문서 전체에서 키 누름·포인터 누름을 들어 마지막 것을 기억한다(캡처 단계, 한 번만 붙인다).
8
+ */
9
+ let 마지막 = null;
10
+ let 붙음 = false;
11
+ function 붙이기() {
12
+ if (붙음 || typeof document === 'undefined')
13
+ return;
14
+ 붙음 = true;
15
+ document.addEventListener('keydown', (e) => {
16
+ if (e.metaKey || e.ctrlKey || e.altKey)
17
+ return;
18
+ 마지막 = 'keyboard';
19
+ }, true);
20
+ const 포인터 = () => { 마지막 = 'pointer'; };
21
+ document.addEventListener('pointerdown', 포인터, true);
22
+ document.addEventListener('mousedown', 포인터, true);
23
+ document.addEventListener('touchstart', 포인터, { capture: true, passive: true });
24
+ }
25
+ // 불러오는 즉시 듣는다 — 첫 누름부터 알아야 한다
26
+ 붙이기();
27
+ /** 마지막 입력이 키보드였으면 true(모르면 false) */
28
+ export function 키보드로눌렀나() {
29
+ 붙이기();
30
+ return 마지막 === 'keyboard';
31
+ }
@@ -7,5 +7,17 @@
7
7
  * @public
8
8
  */
9
9
  export declare const DocumentTitleSuppressContext: import("react").Context<boolean>;
10
+ /**
11
+ * 현재 화면의 제목을 `document.title`에 반영한다 — `'<제목> · <앱 이름>'`.
12
+ *
13
+ * SPA는 라우트를 옮겨도 문서 제목이 그대로라, 브라우저 탭·히스토리·북마크로 위치를
14
+ * 구분할 수 없고 화면낭독기도 이동을 인지하지 못한다(WCAG 2.4.2).
15
+ * 여러 화면이 함께 걸면 제목이 쌓여 가장 나중에 건 것이 보이고, 언마운트하면
16
+ * 남은 것 중 가장 나중에 건 제목으로(남은 것이 없으면 처음 제목으로) 되돌린다.
17
+ *
18
+ * @param title 페이지 제목. 비어 있거나 문자열이 아니면 아무것도 하지 않는다.
19
+ *
20
+ * @public
21
+ */
10
22
  export declare function useDocumentTitle(title?: string | null): void;
11
23
  export default useDocumentTitle;
@@ -23,22 +23,23 @@ const BASE_TITLE = (() => {
23
23
  * @public
24
24
  */
25
25
  export const DocumentTitleSuppressContext = createContext(false);
26
+ /* 제목을 건 화면들 — 나중에 건 것이 위. 떠날 때 **남은 맨 위 제목**으로 돌린다.
27
+ 전에는 저마다 건 순간의 제목(prev)을 기억했다가 되돌려, A 가 떠 있는 동안 B 가 붙고 A 가 나중에 떠나면
28
+ A 의 prev 로 돌아가 B 의 제목이 지워졌다(말귀 CustomersPage 가 겪고 우회해 둠). */
29
+ const 걸린제목 = [];
30
+ let 처음제목 = null;
26
31
  /**
27
32
  * 현재 화면의 제목을 `document.title`에 반영한다 — `'<제목> · <앱 이름>'`.
28
33
  *
29
34
  * SPA는 라우트를 옮겨도 문서 제목이 그대로라, 브라우저 탭·히스토리·북마크로 위치를
30
35
  * 구분할 수 없고 화면낭독기도 이동을 인지하지 못한다(WCAG 2.4.2).
31
- * 언마운트하면 이전 제목으로 되돌린다.
36
+ * 여러 화면이 함께 걸면 제목이 쌓여 가장 나중에 건 것이 보이고, 언마운트하면
37
+ * 남은 것 중 가장 나중에 건 제목으로(남은 것이 없으면 처음 제목으로) 되돌린다.
32
38
  *
33
39
  * @param title 페이지 제목. 비어 있거나 문자열이 아니면 아무것도 하지 않는다.
34
40
  *
35
41
  * @public
36
42
  */
37
- /* 제목을 건 화면들 — 나중에 건 것이 위. 떠날 때 **남은 맨 위 제목**으로 돌린다.
38
- 전에는 저마다 건 순간의 제목(prev)을 기억했다가 되돌려, A 가 떠 있는 동안 B 가 붙고 A 가 나중에 떠나면
39
- A 의 prev 로 돌아가 B 의 제목이 지워졌다(말귀 CustomersPage 가 겪고 우회해 둠). */
40
- const 걸린제목 = [];
41
- let 처음제목 = null;
42
43
  export function useDocumentTitle(title) {
43
44
  const suppressed = useContext(DocumentTitleSuppressContext);
44
45
  useEffect(() => {
@@ -27,9 +27,9 @@ export function useEditableList(emptyItem, initialList = []) {
27
27
  // 값만 캡처하므로 이 effect가 없으면 이후 prop 변경이 화면에 전혀 반영되지 않는다.
28
28
  // 단, 사용자가 이 목록의 항목을 수정 중(isEditing)일 때 리셋하면 아직 list에 반영되지
29
29
  // 않은 진행 중인 편집(editForm)이 저장 시 조용히 무시될 수 있으므로 그 사이엔 건너뛴다.
30
- // isEditing은 ref로만 참조해 effect가 initialList 변경 시에만 실행되게 한다 — 편집을
31
- // 끝내는 순간(isEditing만 바뀌는 렌더)에는 재실행되지 않아, 방금 커밋된 편집 결과가
32
- // 곧바로 stale한 initialList로 덮어써지는 것도 막는다.
30
+ // effect 는 initialList 와 isEditing 둘 다에 걸려 있어 편집이 끝나는 렌더에서도 돈다 —
31
+ // 취소(cancelEditing)로 끝났으면 그 사이 건너뛴 목록을 받고, commitEdit 으로 끝났으면
32
+ // 방금 커밋한 편집을 옛 initialList 로 덮지 않도록 한 번 건너뛴다(아래 커밋으로끝남Ref).
33
33
  //
34
34
  // 비교는 참조가 아니라 **내용**으로 한다. 호출부가 배열을 인라인으로 넘기면
35
35
  // (useEditableList(EMPTY, [a, b]) — 가장 자연스러운 호출 방식이고, 인자를 생략해
@@ -1,3 +1,13 @@
1
1
  /** 글자 둘을 한국어 차례로 견준다 */
2
2
  export declare function 한글순(a: string, b: string): number;
3
+ /**
4
+ * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 나머지는 글자 속 숫자까지 수로.**
5
+ *
6
+ * 표의 칸은 무엇이든 올 수 있어서 늘 글자로 견주면 **숫자 칸이 망가진다**
7
+ * (`10` 이 `9` 앞에 선다). 그래서 둘 다 온전한 숫자(수, 또는 숫자만 든 글자)면 숫자로 견주고,
8
+ * 그 밖에는 한국어 차례로 견주되 글자 안의 숫자는 수로 읽는다(`회차 9` 가 `회차 10` 앞).
9
+ *
10
+ * 비어 있는 값(`null`·`undefined`)은 빈 글자로 본다 — 부르는 쪽이 이미
11
+ * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
12
+ */
3
13
  export declare function 칸순(a: unknown, b: unknown): number;
@@ -22,16 +22,6 @@ const 콜레이터 = (() => {
22
22
  export function 한글순(a, b) {
23
23
  return 콜레이터().compare(a, b);
24
24
  }
25
- /**
26
- * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 나머지는 글자로.**
27
- *
28
- * 표의 칸은 무엇이든 올 수 있어서 늘 글자로 견주면 **숫자 칸이 망가진다**
29
- * (`10` 이 `9` 앞에 선다). 그래서 둘 다 진짜 숫자일 때만 숫자로 견주고,
30
- * 그 밖에는 한국어 차례를 쓴다.
31
- *
32
- * 비어 있는 값(`null`·`undefined`)은 빈 글자로 본다 — 부르는 쪽이 이미
33
- * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
34
- */
35
25
  const 숫자글자 = /^-?\d+(\.\d+)?$/;
36
26
  /* 칸의 글자 비교는 **안의 숫자도 수로** 본다 — 숫자 칸끼리는 수로, 섞인 칸은 글자로 견주면 '9'<'10'(수)·'10'<'10분'(글자)·
37
27
  '10분'<'9'(글자) 로 돌고 돌아, 같은 행을 들어온 차례만 바꿔도 정렬 결과가 달랐다. 수를 수로 읽는 콜레이터는
@@ -44,6 +34,16 @@ const 칸콜레이터 = (() => {
44
34
  return 것;
45
35
  };
46
36
  })();
37
+ /**
38
+ * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 나머지는 글자 속 숫자까지 수로.**
39
+ *
40
+ * 표의 칸은 무엇이든 올 수 있어서 늘 글자로 견주면 **숫자 칸이 망가진다**
41
+ * (`10` 이 `9` 앞에 선다). 그래서 둘 다 온전한 숫자(수, 또는 숫자만 든 글자)면 숫자로 견주고,
42
+ * 그 밖에는 한국어 차례로 견주되 글자 안의 숫자는 수로 읽는다(`회차 9` 가 `회차 10` 앞).
43
+ *
44
+ * 비어 있는 값(`null`·`undefined`)은 빈 글자로 본다 — 부르는 쪽이 이미
45
+ * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
46
+ */
47
47
  export function 칸순(a, b) {
48
48
  /* NaN 은 숫자가 아닌 것으로 — a - b 가 NaN 이면 정렬 비교가 어긋나 줄이 뒤섞였다. 글자로 온 숫자('10'·'9')도
49
49
  둘 다 온전한 숫자면 숫자로 견준다(전에는 '10' 이 '9' 앞에 섰다). */
@@ -51,5 +51,10 @@ export function 칸순(a, b) {
51
51
  const na = 수(a), nb = 수(b);
52
52
  if (na !== null && nb !== null)
53
53
  return na - nb;
54
- return 칸콜레이터().compare(a == null ? '' : String(a), b == null ? '' : String(b));
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);
55
60
  }
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.141.0";
49
+ export declare const CORE_VERSION = "0.142.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.141.0';
49
+ export const CORE_VERSION = '0.142.0';