@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.
- package/CHANGELOG.md +29 -0
- package/dist/components.cjs.js +176 -176
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +181 -181
- package/dist/ihabdevteam-core.d.ts +21 -9
- package/dist/packages/core/src/components/BulkBar.js +15 -4
- package/dist/packages/core/src/components/PaymentPlan.d.ts +2 -2
- package/dist/packages/core/src/components/Popup/Popup.d.ts +5 -5
- package/dist/packages/core/src/components/Popup/Popup.js +6 -7
- package/dist/packages/core/src/components/Popup/customerDetailUtils.d.ts +1 -1
- package/dist/packages/core/src/components/Popup/customerDetailUtils.js +1 -1
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +6 -0
- package/dist/packages/core/src/components/Training/TrainingBlock.js +2 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +4 -0
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +4 -1
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +3 -1
- package/dist/packages/core/src/hooks/inputModality.d.ts +2 -0
- package/dist/packages/core/src/hooks/inputModality.js +31 -0
- package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +12 -0
- package/dist/packages/core/src/hooks/useDocumentTitle.js +7 -6
- package/dist/packages/core/src/hooks/useEditableList.js +3 -3
- package/dist/packages/core/src/utils/textSort.d.ts +10 -0
- package/dist/packages/core/src/utils/textSort.js +16 -11
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +1 -1
- package/package.json +1 -1
|
@@ -1376,7 +1376,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1376
1376
|
*
|
|
1377
1377
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1378
1378
|
*/
|
|
1379
|
-
export declare const CORE_VERSION = "0.
|
|
1379
|
+
export declare const CORE_VERSION = "0.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는 이미
|
|
4140
|
-
* 거기서 닫기를
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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는 이미
|
|
43
|
-
* 거기서 닫기를
|
|
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
|
-
|
|
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",
|
|
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
|
-
|
|
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,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
|
|
31
|
-
//
|
|
32
|
-
//
|
|
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
|
-
|
|
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
|
}
|