@ihabdevteam/core 0.322.0 → 0.323.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 +517 -0
- package/dist/components.cjs.js +183 -183
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +3 -3
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core.cjs.js +187 -187
- package/dist/ihabdevteam-core.d.ts +54 -16
- package/dist/packages/core/src/components/Accordion.js +3 -3
- package/dist/packages/core/src/components/Alert.js +2 -2
- package/dist/packages/core/src/components/BarChart.js +120 -119
- package/dist/packages/core/src/components/BottomNav.js +18 -18
- package/dist/packages/core/src/components/BulkBar.js +25 -25
- package/dist/packages/core/src/components/Button.js +16 -16
- package/dist/packages/core/src/components/Calendar.js +17 -17
- package/dist/packages/core/src/components/ChartSection.js +30 -30
- package/dist/packages/core/src/components/CountTile.js +38 -38
- package/dist/packages/core/src/components/DatePicker.js +59 -59
- package/dist/packages/core/src/components/Dialog.js +14 -14
- package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +8 -8
- package/dist/packages/core/src/components/FilterPanel.js +6 -6
- package/dist/packages/core/src/components/FilterSectionInfo.js +6 -6
- package/dist/packages/core/src/components/GameIntro.js +1 -1
- package/dist/packages/core/src/components/Icon.js +2 -2
- package/dist/packages/core/src/components/ItemActions.js +16 -16
- package/dist/packages/core/src/components/LandingHeader.js +6 -6
- package/dist/packages/core/src/components/LineChart.js +24 -24
- package/dist/packages/core/src/components/Loader.js +5 -5
- package/dist/packages/core/src/components/NoticeBox.js +3 -3
- package/dist/packages/core/src/components/PageTitle.js +2 -2
- package/dist/packages/core/src/components/Pagination.js +4 -4
- package/dist/packages/core/src/components/Panel.js +2 -2
- package/dist/packages/core/src/components/PanelRow.js +6 -13
- package/dist/packages/core/src/components/PassCard/PassCard.js +9 -9
- package/dist/packages/core/src/components/PassCard/passCardProduct.js +2 -2
- package/dist/packages/core/src/components/PaymentPlan.js +5 -5
- package/dist/packages/core/src/components/Popup/ConfirmPopup.js +29 -29
- package/dist/packages/core/src/components/Popup/CouponInputPopup.js +2 -2
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +18 -25
- package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +7 -7
- package/dist/packages/core/src/components/Popup/Popup.js +27 -27
- package/dist/packages/core/src/components/Popup/SaveTablePopup.js +5 -5
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +4 -4
- package/dist/packages/core/src/components/Popup/customerDetailUtils.js +18 -18
- package/dist/packages/core/src/components/Popup/useDiscardGuard.js +4 -4
- package/dist/packages/core/src/components/ProgressIndicator.js +5 -5
- package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +16 -16
- package/dist/packages/core/src/components/Rating.js +20 -20
- package/dist/packages/core/src/components/Report/ReportDocument.js +21 -21
- package/dist/packages/core/src/components/RingChart.js +5 -5
- package/dist/packages/core/src/components/SectionHeadFloat.js +15 -15
- package/dist/packages/core/src/components/SegmentedControl.js +34 -34
- package/dist/packages/core/src/components/SideNav.js +37 -37
- package/dist/packages/core/src/components/Slider.js +2 -2
- package/dist/packages/core/src/components/SoundCompass.js +5 -5
- package/dist/packages/core/src/components/Table.js +257 -98
- package/dist/packages/core/src/components/Tabs.js +12 -12
- package/dist/packages/core/src/components/TextField.js +34 -34
- package/dist/packages/core/src/components/Toast.js +6 -6
- package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +4 -4
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +22 -22
- package/dist/packages/core/src/components/Tooltip/TrainingTooltip.js +4 -4
- package/dist/packages/core/src/components/TopBanner.js +28 -28
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +11 -11
- package/dist/packages/core/src/components/Training/CategoryIcon.js +2 -2
- package/dist/packages/core/src/components/Training/NoisePlayerContext.js +12 -12
- package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingBlock.js +11 -11
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +10 -10
- package/dist/packages/core/src/components/Training/TrainingBlockGrid.js +21 -21
- package/dist/packages/core/src/components/Training/TrainingCard.css +13 -1
- package/dist/packages/core/src/components/Training/TrainingCard.js +2 -2
- package/dist/packages/core/src/components/Training/TrainingCardGroup.js +6 -6
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +81 -81
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +127 -127
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +18 -18
- package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +4 -4
- package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +12 -12
- package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +12 -12
- package/dist/packages/core/src/components/Training/TrainingStepTitle.js +4 -4
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +17 -17
- package/dist/packages/core/src/components/Training/WordTablePopup.js +5 -5
- package/dist/packages/core/src/components/Training/audioPlaybackRules.js +8 -8
- package/dist/packages/core/src/components/Training/audioTimeline.js +25 -25
- package/dist/packages/core/src/components/Training/oneShotSound.js +13 -13
- package/dist/packages/core/src/components/Training/trainingMissStats.js +20 -20
- package/dist/packages/core/src/components/Training/trainingPopupUtils.js +7 -7
- package/dist/packages/core/src/components/Training/useAudioPreview.js +15 -15
- package/dist/packages/core/src/components/Training/useFloatingFocus.js +14 -14
- package/dist/packages/core/src/components/Training/useNoiseFade.js +22 -22
- package/dist/packages/core/src/components/Training/useObjectUrls.js +2 -2
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.d.ts +2 -2
- package/dist/packages/core/src/components/Training/usePlaybackSequencer.js +23 -23
- package/dist/packages/core/src/components/Training/useProgressTicker.js +33 -33
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +17 -17
- package/dist/packages/core/src/components/Training/useWaveReady.js +11 -11
- package/dist/packages/core/src/components/UpdateAlert.js +6 -6
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +7 -1
- package/dist/packages/core/src/components/Word/TableFilterPanel.js +3 -3
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +10 -10
- package/dist/packages/core/src/components/Word/WordTableBlock.js +28 -28
- package/dist/packages/core/src/components/Word/WordTableButton.css +16 -2
- package/dist/packages/core/src/components/Word/WordTableThumbnail.css +6 -1
- package/dist/packages/core/src/components/Word/generatedDraft.js +14 -14
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -7
- package/dist/packages/core/src/components/Word/wordScoreStats.js +15 -15
- package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
- package/dist/packages/core/src/components/Word/wordTableRecommend.js +11 -11
- package/dist/packages/core/src/components/Word/wordTableReview.js +28 -28
- package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +8 -11
- package/dist/packages/core/src/components/Word/wordTableSettings.js +48 -36
- package/dist/packages/core/src/components/WordTable.js +130 -130
- package/dist/packages/core/src/components/barChartData.d.ts +16 -1
- package/dist/packages/core/src/components/barChartData.js +49 -29
- package/dist/packages/core/src/components/barChartTypes.js +8 -8
- package/dist/packages/core/src/components/tableTrackList.js +11 -11
- package/dist/packages/core/src/components/updateAlertStack.js +28 -28
- package/dist/packages/core/src/components/useBarChartControl.js +4 -6
- package/dist/packages/core/src/components/useComboboxSuggestions.js +17 -17
- package/dist/packages/core/src/components/useToastLifecycle.js +18 -18
- package/dist/packages/core/src/components/wordTableLayout.d.ts +17 -4
- package/dist/packages/core/src/components/wordTableLayout.js +5 -5
- package/dist/packages/core/src/context/I18nProvider.js +6 -6
- package/dist/packages/core/src/hooks/focusReturn.js +27 -27
- package/dist/packages/core/src/hooks/inputModality.js +13 -13
- package/dist/packages/core/src/hooks/touchToggle.js +39 -39
- package/dist/packages/core/src/hooks/useAnchoredMenu.js +30 -30
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +28 -28
- package/dist/packages/core/src/hooks/useDocumentTitle.js +10 -10
- package/dist/packages/core/src/hooks/useEditableList.js +4 -4
- package/dist/packages/core/src/hooks/useFocusTrap.js +2 -2
- package/dist/packages/core/src/hooks/useFontScale.js +6 -6
- package/dist/packages/core/src/hooks/useIcon.js +7 -7
- package/dist/packages/core/src/hooks/useListControls.js +2 -2
- package/dist/packages/core/src/hooks/usePageResetOnChange.js +3 -3
- package/dist/packages/core/src/hooks/usePasswordChange.js +4 -4
- package/dist/packages/core/src/hooks/usePendingPress.js +7 -7
- package/dist/packages/core/src/hooks/useRelativeDay.js +4 -4
- package/dist/packages/core/src/hooks/useSoundCompassExam.js +6 -6
- package/dist/packages/core/src/hooks/useTablePaging.d.ts +0 -11
- package/dist/packages/core/src/hooks/useTablePaging.js +29 -34
- package/dist/packages/core/src/hooks/useTrainingSessionLog.js +35 -35
- package/dist/packages/core/src/hooks/useVisitSchedule.js +4 -4
- package/dist/packages/core/src/hooks/useWordAttributeFilter.js +14 -14
- package/dist/packages/core/src/hooks/useWordMeanings.js +5 -5
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -7
- package/dist/packages/core/src/i18n/locales/en.json +13 -2
- package/dist/packages/core/src/i18n/locales/ko.json +12 -1
- package/dist/packages/core/src/types/wordSearch.d.ts +9 -0
- package/dist/packages/core/src/utils/audioMeter.js +5 -5
- package/dist/packages/core/src/utils/audioPeaks.d.ts +1 -1
- package/dist/packages/core/src/utils/audioPeaks.js +18 -18
- package/dist/packages/core/src/utils/blockGrid.js +4 -4
- package/dist/packages/core/src/utils/chunkSkew.js +9 -9
- package/dist/packages/core/src/utils/clipboard.js +3 -3
- package/dist/packages/core/src/utils/dailySelectionUtils.js +12 -12
- package/dist/packages/core/src/utils/date.js +12 -12
- package/dist/packages/core/src/utils/devWarn.js +4 -4
- package/dist/packages/core/src/utils/deviceKind.d.ts +37 -7
- package/dist/packages/core/src/utils/deviceKind.js +53 -10
- package/dist/packages/core/src/utils/errorMessage.js +7 -7
- package/dist/packages/core/src/utils/filterPanelSummary.js +14 -14
- package/dist/packages/core/src/utils/format.js +4 -4
- package/dist/packages/core/src/utils/getTrainingData.js +9 -9
- package/dist/packages/core/src/utils/imageConfig.js +17 -17
- package/dist/packages/core/src/utils/koreanSyllable.js +18 -18
- package/dist/packages/core/src/utils/navScroll.js +2 -2
- package/dist/packages/core/src/utils/phonemeConvert.d.ts +7 -0
- package/dist/packages/core/src/utils/phonemeConvert.js +19 -10
- package/dist/packages/core/src/utils/phonemeGroups.js +2 -2
- package/dist/packages/core/src/utils/phonemePairSearch.d.ts +1 -0
- package/dist/packages/core/src/utils/phonemePairSearch.js +118 -102
- package/dist/packages/core/src/utils/phonemeSectionCodes.d.ts +52 -0
- package/dist/packages/core/src/utils/phonemeSectionCodes.js +76 -0
- package/dist/packages/core/src/utils/prefetchChunk.js +6 -6
- package/dist/packages/core/src/utils/safeHref.js +4 -4
- package/dist/packages/core/src/utils/savedWordTables.js +5 -5
- package/dist/packages/core/src/utils/speak.js +12 -12
- package/dist/packages/core/src/utils/textSort.d.ts +0 -10
- package/dist/packages/core/src/utils/textSort.js +38 -48
- package/dist/packages/core/src/utils/theme.js +3 -3
- package/dist/packages/core/src/utils/trainingItemResults.js +3 -3
- package/dist/packages/core/src/utils/ttsConfig.js +2 -2
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +45 -45
- package/dist/packages/core/src/utils/wordContrast.js +3 -3
- package/dist/packages/core/src/utils/wordPairIndex.js +4 -4
- package/dist/packages/core/src/utils/wordTrainingGenerators.js +4 -4
- package/dist/packages/core/src/utils/wordTrainingPool.js +15 -15
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +6 -6
- package/dist/packages/core/src/utils/wornDate.d.ts +3 -3
- package/dist/packages/core/src/utils/wornDate.js +14 -14
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +4 -4
- package/package.json +1 -1
|
@@ -38,13 +38,13 @@ export default function Popup(props) {
|
|
|
38
38
|
if (inline)
|
|
39
39
|
return;
|
|
40
40
|
if (!title && !ariaLabel && import.meta.env?.DEV) {
|
|
41
|
-
const
|
|
42
|
-
const
|
|
41
|
+
const bodyText = panelRef.current?.textContent?.replace(/\s+/g, ' ').trim().slice(0, 60) ?? '';
|
|
42
|
+
const marks = [
|
|
43
43
|
className ? `class="${className}"` : null,
|
|
44
|
-
|
|
44
|
+
bodyText ? `본문 "${bodyText}${bodyText.length >= 60 ? '…' : ''}"` : null,
|
|
45
45
|
].filter(Boolean).join(' · ');
|
|
46
46
|
console.warn(`[Popup] 제목도 ariaLabel도 없어 다이얼로그에 접근명이 없습니다 — ariaLabel을 주세요.` +
|
|
47
|
-
(
|
|
47
|
+
(marks ? ` (${marks})` : ' (단서 없음 — 빈 팝업)'));
|
|
48
48
|
}
|
|
49
49
|
}, [inline, title, ariaLabel, className]);
|
|
50
50
|
// resetScrollKey가 바뀌면(탭/모드 전환 등) 본문 스크롤을 맨 위로 되돌린다 —
|
|
@@ -114,59 +114,59 @@ export default function Popup(props) {
|
|
|
114
114
|
/* 닫힘 애니메이션 뒤 콜백을 부른다. **콜백이 던지거나 거절되면, 또는 약속을 돌려준 콜백이 끝난 뒤에도 이 창이 그대로 있으면 되살린다** —
|
|
115
115
|
전에는 닫는 중 표시만 남아 투명한 막이 화면을 덮고(클릭을 가로챔) ESC·X·바탕·다시 저장이 모두 무시돼 페이지를 쓸 수
|
|
116
116
|
없었다(말귀 업체 정보·훈련 묶음 수정 저장이 실패해 부모가 창을 열어 둘 때). 성공하면 부모가 곧 치우므로 되살릴 일이 없다. */
|
|
117
|
-
const
|
|
117
|
+
const aliveRef = useRef(true);
|
|
118
118
|
/* '동작 줄이기' 면 닫기 대기를 0 으로 — CSS 는 움직임을 없애지만 이 타이머는 못 줄여, 움직임 없이 260ms 늦게 사라지며 투명한 막이 누름을 가로챘다 */
|
|
119
|
-
const
|
|
119
|
+
const reducedMotion = usePrefersReducedMotion();
|
|
120
120
|
/* 닫기(X·ESC·바탕)가 걸어 둔 타이머 — 닫는 애니메이션(260ms) 안에 부모가 창을 치우면 **떠난 뒤에 onClose** 가 불렸다(닫으며
|
|
121
121
|
다음 창을 여는 흐름에서 늦은 setX(null) 이 새 창을 닫을 수 있다). 떠날 때 걷는다. 소비자가 넘긴 animateThen(cb) 은
|
|
122
122
|
"삭제·저장 확정" 일 수 있어 걷지 않는다(결정 2026-09-15: 떠난 뒤에도 부른다 — PopupOptions JSDoc). */
|
|
123
|
-
const
|
|
123
|
+
const closeTimerRef = useRef(null);
|
|
124
124
|
useEffect(() => {
|
|
125
|
-
|
|
125
|
+
aliveRef.current = true;
|
|
126
126
|
return () => {
|
|
127
|
-
|
|
128
|
-
if (
|
|
129
|
-
clearTimeout(
|
|
130
|
-
|
|
127
|
+
aliveRef.current = false;
|
|
128
|
+
if (closeTimerRef.current) {
|
|
129
|
+
clearTimeout(closeTimerRef.current);
|
|
130
|
+
closeTimerRef.current = null;
|
|
131
131
|
}
|
|
132
132
|
};
|
|
133
133
|
}, []);
|
|
134
|
-
const
|
|
135
|
-
if (
|
|
134
|
+
const restore = useCallback(() => {
|
|
135
|
+
if (!aliveRef.current || !closingRef.current)
|
|
136
136
|
return;
|
|
137
137
|
closingRef.current = false;
|
|
138
138
|
setClosing(false);
|
|
139
139
|
}, []);
|
|
140
|
-
const animateThen = useCallback((cb,
|
|
140
|
+
const animateThen = useCallback((cb, closeOnly = false) => {
|
|
141
141
|
/* 이미 떠난 창을 닫으려는 부름(ConfirmPopup 의 onConfirm 안에서 부모가 창을 치운 뒤 closeWithAnimation)은 받지 않는다 —
|
|
142
142
|
정리가 끝난 뒤 걸린 타이머라 걷히지 않고 260ms 뒤 **취소 경로의 onClose** 가 불렸다(kscan 이 closeOnConfirm=false 로 우회). */
|
|
143
|
-
if (
|
|
143
|
+
if (closeOnly && !aliveRef.current)
|
|
144
144
|
return;
|
|
145
145
|
if (closingRef.current)
|
|
146
146
|
return;
|
|
147
147
|
closingRef.current = true;
|
|
148
148
|
setClosing(true);
|
|
149
149
|
const id = setTimeout(() => {
|
|
150
|
-
if (
|
|
151
|
-
|
|
152
|
-
let
|
|
150
|
+
if (closeTimerRef.current === id)
|
|
151
|
+
closeTimerRef.current = null;
|
|
152
|
+
let result;
|
|
153
153
|
try {
|
|
154
|
-
|
|
154
|
+
result = cb ? cb() : undefined;
|
|
155
155
|
}
|
|
156
156
|
catch (e) {
|
|
157
|
-
|
|
157
|
+
restore();
|
|
158
158
|
return;
|
|
159
159
|
}
|
|
160
160
|
/* 약속을 돌려준 콜백만 **끝난 뒤에도 남아 있으면** 되살린다. 동기 콜백(undefined)은 되살리지 않는다 — 부모가 저장을
|
|
161
161
|
따로 기다렸다 닫는 흔한 꼴(onSave 를 부르고 약속은 안 돌려줌)에서 기다리는 사이 창이 다시 보이고 저장 단추가 다시
|
|
162
162
|
눌려 두 번 저장됐다. 저장 실패에 창을 되살리고 싶으면 콜백이 그 약속을 돌려주면 된다. */
|
|
163
|
-
if (
|
|
164
|
-
|
|
163
|
+
if (result && typeof result.then === 'function') {
|
|
164
|
+
result.then(() => { setTimeout(restore, 120); }, restore);
|
|
165
165
|
}
|
|
166
|
-
},
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
}, [
|
|
166
|
+
}, reducedMotion ? 0 : ANIM_MS);
|
|
167
|
+
if (closeOnly)
|
|
168
|
+
closeTimerRef.current = id;
|
|
169
|
+
}, [restore, reducedMotion]);
|
|
170
170
|
const closeWithAnimation = useCallback(() => {
|
|
171
171
|
if (closeDisabled)
|
|
172
172
|
return;
|
|
@@ -43,17 +43,17 @@ export default function SaveTablePopup({ title, saveLabel, defaultName = '', def
|
|
|
43
43
|
existingNames.some(n => n.trim().toLowerCase() === name.trim().toLowerCase());
|
|
44
44
|
/* 저장은 **한 번만** — 저장 단추의 닫힘 애니메이션(260ms) 동안 이름칸에서 Enter 를 치면 onSave 가 또 불렸다(Safari 는 단추를
|
|
45
45
|
눌러도 포커스가 칸에 남아 쉽게 난다). */
|
|
46
|
-
const
|
|
46
|
+
const savedRef = useRef(false);
|
|
47
47
|
const handleSave = useCallback((animateThen) => {
|
|
48
|
-
if (
|
|
48
|
+
if (savedRef.current)
|
|
49
49
|
return;
|
|
50
|
-
|
|
50
|
+
savedRef.current = true;
|
|
51
51
|
animateThen(() => onSave(name.trim(), description.trim() || undefined));
|
|
52
52
|
}, [name, description, onSave]);
|
|
53
53
|
const handleEnter = useCallback(() => {
|
|
54
|
-
if (
|
|
54
|
+
if (savedRef.current || !name.trim() || isDuplicateName)
|
|
55
55
|
return;
|
|
56
|
-
|
|
56
|
+
savedRef.current = true;
|
|
57
57
|
onSave(name.trim(), description.trim() || undefined);
|
|
58
58
|
}, [name, description, isDuplicateName, onSave]);
|
|
59
59
|
return (_jsx(Popup, { className: "popup--save-table", title: title ?? t('wordTable.save.title', '단어표 저장하기'), onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: saveLabel ?? t('common.save', '저장'), disabled: !name.trim() || isDuplicateName, onClick: () => handleSave(animateThen) })] })), children: _jsxs("div", { className: "save-table-popup__body", children: [preview && (_jsx("div", { ref: containerRef, className: "save-table-popup__preview-wrap", style: { height: previewState.fixedH }, children: _jsx("div", { className: "save-table-popup__preview", style: {
|
|
@@ -60,8 +60,8 @@ function resolveDecision(it) {
|
|
|
60
60
|
export default function TrainingResultPopup(props) {
|
|
61
61
|
const { score, contextsLength, results = [], subtitle, onReplay, onClose, inline = false, pending = false } = props || {};
|
|
62
62
|
const { t } = useTranslation();
|
|
63
|
-
const
|
|
64
|
-
const
|
|
63
|
+
const reasonId = useId();
|
|
64
|
+
const blockWhilePending = useCallback(() => !pending, [pending]);
|
|
65
65
|
// 축하 컨페티는 정답률 50% 초과일 때만 — 낮은 점수(≤50%)에 축하가 나가지 않게 한다(언어치료 맥락).
|
|
66
66
|
useEffect(() => {
|
|
67
67
|
const total = contextsLength ?? 0;
|
|
@@ -114,7 +114,7 @@ export default function TrainingResultPopup(props) {
|
|
|
114
114
|
주는 이름이 갈라진다. */
|
|
115
115
|
ariaLabel: t('training.result.title', '훈련을 모두 마쳤습니다.'), className: "popup--training-result",
|
|
116
116
|
// 결과 계산 중에는 Esc 로도 완료(닫기)하지 않는다 — '완료' 단추와 같은 까닭
|
|
117
|
-
beforeClose:
|
|
117
|
+
beforeClose: blockWhilePending, footerClassName: "trp-result__foot", footer: ({ animateThen, closeWithAnimation }) => (_jsxs(_Fragment, { children: [pending && _jsx("p", { id: reasonId, className: "trp-actions__reason caption-1", role: "status", children: t('training.result.pendingDone', '결과를 계산하는 중이라 아직 완료할 수 없습니다.') }), _jsxs("div", { className: "trp-actions", children: [_jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.replay', '다시하기'), onClick: () => animateThen(onReplay) }), _jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.done', '완료'), variant: "main", onClick: closeWithAnimation,
|
|
118
118
|
/* 막되 초점은 남긴다(onDisabledClick 이 있으면 native disabled 대신 aria-disabled) — 키보드·낭독기가 까닭을 들을 수 있게 */
|
|
119
|
-
disabled: pending, onDisabledClick: pending ? () => { } : undefined, "aria-describedby": pending ?
|
|
119
|
+
disabled: pending, onDisabledClick: pending ? () => { } : undefined, "aria-describedby": pending ? reasonId : undefined })] })] })), children: () => (_jsxs("div", { className: "trp-result is-on", children: [_jsxs("header", { className: "trp-result__head", children: [_jsx("p", { className: "trp-result__eyebrow caption-1", children: t('training.result.eyebrow', '훈련 완료') }), _jsx("h1", { className: "trp-result__title h2", children: t('training.result.title', '훈련을 모두 마쳤습니다.') }), subtitle && _jsx("p", { className: "trp-result__subtitle body-3", children: subtitle }), _jsx("div", { className: "trp-score", children: _jsxs("div", { className: "trp-score__figure", "aria-label": t('training.result.scoreAria', { correct, total }), children: [_jsx("span", { className: "trp-score__big", children: correct }), _jsx("span", { className: "trp-score__sep", children: "/" }), _jsx("span", { className: "trp-score__total", children: total })] }) })] }), rows.length > 0 && (_jsx("section", { className: "trp-result__body", children: _jsxs("div", { className: `result-table${hasAnswerCols ? ' result-table--answer' : ''}`, role: "table", "aria-label": t('training.result.tableAriaLabel', '문항별 판정'), children: [_jsxs("div", { className: "result-table__head caption-1", role: "row", children: [_jsx("span", { role: "columnheader", children: t('common.col.problem', '문제') }), hasAnswerCols && _jsx("span", { role: "columnheader", children: t('training.result.colAnswerSelected', '정답 · 선택') }), _jsx("span", { className: "col-right", role: "columnheader", children: t('training.result.colJudge', '판정') })] }), rows.map((r, i) => (_jsxs("div", { className: "result-table__row", role: "row", children: [_jsxs("div", { className: "trp-row__problem", children: [_jsx("span", { className: "trp-row__no caption-1", children: String(i + 1).padStart(2, '0') }), _jsx("div", { className: "trp-row__q body-2", children: r.problem })] }), hasAnswerCols && (_jsxs("div", { className: "trp-row__answers", children: [r.answer !== null && (_jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: "trp-row__answer-label answer-correct caption-1", children: t('training.result.colAnswer', '정답') }), _jsx("span", { className: "trp-row__answer-value body-2", children: r.answer })] })), _jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: `trp-row__answer-label answer-selected caption-1 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: t('training.result.colSelected', '선택') }), _jsx("span", { className: `trp-row__answer-value body-2 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: r.selectedText ?? '—' })] })] })), _jsx("div", { className: "trp-row__judge", children: _jsx(Badge, { variant: r.variant, label: r.label, fill: "soft", minWidth: 80 }) })] }, i)))] }) }))] })) }));
|
|
120
120
|
}
|
|
@@ -5,8 +5,8 @@
|
|
|
5
5
|
import i18n from 'i18next';
|
|
6
6
|
import { getTodayKst } from '../../utils/date';
|
|
7
7
|
/** i18n 이 아직 안 섰으면(초기화 전 `i18n.t` 는 undefined) 한국어 기본값을 직접 채운다. */
|
|
8
|
-
function
|
|
9
|
-
return typeof s === 'string' && s ? s :
|
|
8
|
+
function translatedOr(s, fallback, vars) {
|
|
9
|
+
return typeof s === 'string' && s ? s : fallback.replace(/\{\{(\w+)\}\}/g, (_, k) => String(vars[k]));
|
|
10
10
|
}
|
|
11
11
|
export function getDayOptions(year, month, daySuffix) {
|
|
12
12
|
const days = new Date(Number(year), Number(month), 0).getDate();
|
|
@@ -16,16 +16,16 @@ export function getDayOptions(year, month, daySuffix) {
|
|
|
16
16
|
});
|
|
17
17
|
}
|
|
18
18
|
/** 'YYYY.MM.DD' · 'YYYY-MM-DD' · 'YYYYMMDD' 를 숫자 셋으로. 없는 날(2/30 등)이면 null. */
|
|
19
|
-
function
|
|
20
|
-
const
|
|
21
|
-
if (!/^\d{8}$/.test(
|
|
19
|
+
function splitDate(s) {
|
|
20
|
+
const digits = s.trim().replace(/[.-]/g, '');
|
|
21
|
+
if (!/^\d{8}$/.test(digits))
|
|
22
22
|
return null;
|
|
23
|
-
const y = Number(
|
|
24
|
-
if (m < 1 || m > 12 || d < 1 || d >
|
|
23
|
+
const y = Number(digits.slice(0, 4)), m = Number(digits.slice(4, 6)), d = Number(digits.slice(6, 8));
|
|
24
|
+
if (m < 1 || m > 12 || d < 1 || d > lastDayOf(y, m))
|
|
25
25
|
return null;
|
|
26
26
|
return { y, m, d };
|
|
27
27
|
}
|
|
28
|
-
const
|
|
28
|
+
const lastDayOf = (y, m) => new Date(y, m, 0).getDate();
|
|
29
29
|
/**
|
|
30
30
|
* 만 나이 — "N세 M개월" 을 `ageStr` 로 적는다. 못 읽으면 '-'.
|
|
31
31
|
*
|
|
@@ -44,15 +44,15 @@ const 말일 = (y, m) => new Date(y, m, 0).getDate();
|
|
|
44
44
|
export function calcAge(birthDate, ageStr) {
|
|
45
45
|
if (!birthDate)
|
|
46
46
|
return '-';
|
|
47
|
-
const b =
|
|
48
|
-
const t =
|
|
47
|
+
const b = splitDate(birthDate);
|
|
48
|
+
const t = splitDate(getTodayKst());
|
|
49
49
|
if (!b || !t)
|
|
50
50
|
return '-';
|
|
51
51
|
let years = t.y - b.y;
|
|
52
52
|
let months = t.m - b.m;
|
|
53
53
|
// 일(day)까지 비교해야 함 — 월만 비교하면 생일이 아직 안 지난 달엔 나이가 1살 많게 나온다.
|
|
54
54
|
// 이번 달에 그 날이 없으면 말일이 그날이다.
|
|
55
|
-
if (t.d < Math.min(b.d,
|
|
55
|
+
if (t.d < Math.min(b.d, lastDayOf(t.y, t.m)))
|
|
56
56
|
months--;
|
|
57
57
|
if (months < 0) {
|
|
58
58
|
years--;
|
|
@@ -78,17 +78,17 @@ export function visitItemKey(v) {
|
|
|
78
78
|
*/
|
|
79
79
|
export function formatDuration(sec, durationStr) {
|
|
80
80
|
// 초 단위로 반올림한 뒤 나눈다 — 90.5 가 "1분 30.5초", 59.999 가 "59.999초" 로 찍혔다
|
|
81
|
-
const
|
|
82
|
-
if (
|
|
81
|
+
const seconds = Number.isFinite(sec) ? Math.round(sec) : 0;
|
|
82
|
+
if (!seconds || seconds <= 0)
|
|
83
83
|
return '-';
|
|
84
|
-
const m = Math.floor(
|
|
85
|
-
const s =
|
|
84
|
+
const m = Math.floor(seconds / 60);
|
|
85
|
+
const s = seconds % 60;
|
|
86
86
|
if (durationStr)
|
|
87
87
|
return durationStr(m, s);
|
|
88
88
|
// 열쇠를 글자로 적어 둔다 — test/i18nKeysExist 가 사전에 있는지 훑는다
|
|
89
89
|
if (m > 0 && s > 0)
|
|
90
|
-
return
|
|
90
|
+
return translatedOr(i18n.t('customers.duration.minSec', { m, s, defaultValue: '{{m}}분 {{s}}초' }), '{{m}}분 {{s}}초', { m, s });
|
|
91
91
|
if (m > 0)
|
|
92
|
-
return
|
|
93
|
-
return
|
|
92
|
+
return translatedOr(i18n.t('customers.duration.min', { m, defaultValue: '{{m}}분' }), '{{m}}분', { m });
|
|
93
|
+
return translatedOr(i18n.t('customers.duration.sec', { s, defaultValue: '{{s}}초' }), '{{s}}초', { s });
|
|
94
94
|
}
|
|
@@ -37,12 +37,12 @@ import ConfirmPopup from './ConfirmPopup';
|
|
|
37
37
|
export function useDiscardGuard(isDirty, onClose, labels = {}) {
|
|
38
38
|
const { t } = useTranslation();
|
|
39
39
|
const [confirming, setConfirming] = useState(false);
|
|
40
|
-
const
|
|
41
|
-
const allowClose = useCallback(() => {
|
|
40
|
+
const bypassRef = useRef(false);
|
|
41
|
+
const allowClose = useCallback(() => { bypassRef.current = true; }, []);
|
|
42
42
|
const beforeClose = useCallback(() => {
|
|
43
43
|
/* 한 번 쓰고 되돌린다 — 안 되돌리면 그 뒤로 영영 안 묻는다 */
|
|
44
|
-
if (
|
|
45
|
-
|
|
44
|
+
if (bypassRef.current) {
|
|
45
|
+
bypassRef.current = false;
|
|
46
46
|
return true;
|
|
47
47
|
}
|
|
48
48
|
if (isDirty) {
|
|
@@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|
|
4
4
|
import useMediaQuery from '../hooks/useMediaQuery';
|
|
5
5
|
import './ProgressIndicator.css';
|
|
6
6
|
/** 눈에는 안 보이고 낭독기에는 읽히는 글자 */
|
|
7
|
-
const
|
|
7
|
+
const srOnly = {
|
|
8
8
|
position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden',
|
|
9
9
|
clipPath: 'inset(50%)', whiteSpace: 'nowrap', border: 0,
|
|
10
10
|
};
|
|
@@ -48,13 +48,13 @@ export default function ProgressIndicator({ max, index, inactiveBg, inactiveColo
|
|
|
48
48
|
묶고 이름표에 자리를 적는다. 화면이 스스로 넘어가는 자리에서는
|
|
49
49
|
announce 로 role="status" 를 켠다. */
|
|
50
50
|
if (variant === 'dot') {
|
|
51
|
-
return (_jsxs("div", { className: "progress-indicator progress-indicator--dot", style: rootStyle, role: announce ? 'status' : 'img', "aria-label": announce ? undefined : groupLabel, children: [announce && _jsx("span", { style:
|
|
51
|
+
return (_jsxs("div", { className: "progress-indicator progress-indicator--dot", style: rootStyle, role: announce ? 'status' : 'img', "aria-label": announce ? undefined : groupLabel, children: [announce && _jsx("span", { style: srOnly, children: groupLabel }), Array.from({ length: max }).map((_, i) => (_jsx("span", { "aria-hidden": "true", className: `progress-indicator-dot--dot${i < idx ? ' done' : i === idx ? ' active' : ''}` }, i)))] }));
|
|
52
52
|
}
|
|
53
53
|
/* 번호·모바일 모드의 알리는 자리 — 전에는 announce 가 점 모드에서만 먹었다. 목록 곁 형제로 두고 absolute 라 배치에 안 끼어든다. */
|
|
54
|
-
const
|
|
54
|
+
const liveRegion = announce ? _jsx("span", { role: "status", style: srOnly, children: groupLabel }) : null;
|
|
55
55
|
// 모바일: 현재 단계만 표시 (n / max 형태)
|
|
56
56
|
if (isMobile) {
|
|
57
|
-
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "progress-indicator progress-indicator--mobile", style: rootStyle, role: "list", "aria-label": groupLabel, children: _jsxs("span", { role: "listitem", className: "progress-indicator-dot active body-3", children: [idx + 1, " / ", max] }) }),
|
|
57
|
+
return (_jsxs(_Fragment, { children: [_jsx("div", { className: "progress-indicator progress-indicator--mobile", style: rootStyle, role: "list", "aria-label": groupLabel, children: _jsxs("span", { role: "listitem", className: "progress-indicator-dot active body-3", children: [idx + 1, " / ", max] }) }), liveRegion] }));
|
|
58
58
|
}
|
|
59
|
-
return (_jsxs(_Fragment, { children: [
|
|
59
|
+
return (_jsxs(_Fragment, { children: [liveRegion, _jsx("div", { className: "progress-indicator", style: rootStyle, role: "list", "aria-label": groupLabel, children: Array.from({ length: max }).map((_, i) => (_jsx("span", { role: "listitem", "aria-current": i === idx ? 'step' : undefined, className: `progress-indicator-dot ${i < idx ? ' done' : i === idx ? ' active' : ''} body-3`, children: i + 1 }, i))) })] }));
|
|
60
60
|
}
|
|
@@ -2,9 +2,9 @@ import i18n from '../../i18n';
|
|
|
2
2
|
/* 라벨은 **읽는 때의 언어**로 — 전에는 한국어가 박혀 영어 화면의 배경 고르개(어드민 공지 폼·놀이터)도 한국어였다.
|
|
3
3
|
배열·항목 모양(key·label·textTone)은 그대로 두고 label 만 읽을 때 사전(`promoBannerBg.<key>`)에서 꺼내는 getter 로 둔다
|
|
4
4
|
— 모듈을 읽은 순간의 언어로 굳지 않고, `{ ...o }` 로 펼치면 그때의 글자가 담긴다. ko 글자는 전과 같다. */
|
|
5
|
-
const
|
|
5
|
+
const bg = (key, ko, textTone) => ({
|
|
6
6
|
key,
|
|
7
|
-
get label() { return i18n.t(`promoBannerBg.${key}`,
|
|
7
|
+
get label() { return i18n.t(`promoBannerBg.${key}`, ko); },
|
|
8
8
|
textTone,
|
|
9
9
|
});
|
|
10
10
|
/**
|
|
@@ -12,20 +12,20 @@ const 배경 = (key, 한국어, textTone) => ({
|
|
|
12
12
|
* @public
|
|
13
13
|
*/
|
|
14
14
|
export const PROMO_BANNER_BG_OPTIONS = [
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
15
|
+
bg('main', '메인 그라디언트', 'light'),
|
|
16
|
+
bg('soft', '소프트', 'dark'),
|
|
17
|
+
bg('sub', '서브 컬러', 'light'),
|
|
18
|
+
bg('deep', '딥 그라디언트', 'light'),
|
|
19
|
+
bg('softSub', '소프트 서브', 'dark'),
|
|
20
|
+
bg('warm', '웜(강조)', 'dark'),
|
|
21
|
+
bg('dark', '다크', 'light'),
|
|
22
|
+
bg('radial', '방사형 그라디언트', 'light'),
|
|
23
|
+
bg('diagonal', '대각선 패턴', 'dark'),
|
|
24
|
+
bg('dots', '도트 패턴', 'dark'),
|
|
25
|
+
bg('outline', '아웃라인', 'dark'),
|
|
26
|
+
bg('success', '성공/완료', 'light'),
|
|
27
|
+
bg('naver', '네이버 블로그 안내', 'light'),
|
|
28
|
+
bg('instagram', '인스타그램 안내', 'light'),
|
|
29
29
|
];
|
|
30
30
|
import PromoBannerBgMain from './PromoBannerBgMain';
|
|
31
31
|
import PromoBannerBgSoft from './PromoBannerBgSoft';
|
|
@@ -17,16 +17,16 @@ export default function Rating({ value, onChange, max = 5, readOnly = false, siz
|
|
|
17
17
|
const [hoverValue, setHoverValue] = useState(null);
|
|
18
18
|
/* 고르는 모드는 별 하나 단위라 **반올림한 값**으로 그리고 옮긴다 — 소수 value(3.4)를 그대로 두면 별 셋 반쯤이 칠해지는데
|
|
19
19
|
어느 라디오도 골라지지 않았고(aria-checked 없음), 화살표는 4.4·2.4 로 옮겨 가 정수 별과 어긋났다. 읽기 전용은 소수 그대로. */
|
|
20
|
-
const
|
|
21
|
-
const displayValue = hoverValue ??
|
|
20
|
+
const current = isReadOnly ? value : (Number.isFinite(value) ? Math.round(value) : 0);
|
|
21
|
+
const displayValue = hoverValue ?? current;
|
|
22
22
|
const iconSize = ICON_SIZE[size];
|
|
23
23
|
/* 값이 바뀔 때만 onChange(2026-09-15 결정) — 소수 value 3.4 에서 별 3 을 누르면 3 으로 바뀌는 것이라 부른다 */
|
|
24
|
-
const
|
|
25
|
-
onChange?.(
|
|
24
|
+
const notify = (next) => { if (next !== value)
|
|
25
|
+
onChange?.(next); };
|
|
26
26
|
const handleClick = (star) => {
|
|
27
27
|
if (isReadOnly)
|
|
28
28
|
return;
|
|
29
|
-
|
|
29
|
+
notify(star);
|
|
30
30
|
};
|
|
31
31
|
const handleMouseEnter = (star) => {
|
|
32
32
|
if (isReadOnly)
|
|
@@ -41,38 +41,38 @@ export default function Rating({ value, onChange, max = 5, readOnly = false, siz
|
|
|
41
41
|
이라 읽어 주는데 화살표가 죽어 있다. 실측으로 별 다섯이 전부 Tab 자리였다.
|
|
42
42
|
별점은 순서가 뜻을 지니므로 **돌지 않는다** — 1에서 왼쪽, 5에서 오른쪽은 그 자리에
|
|
43
43
|
머문다. 아직 안 골랐으면 첫 화살표가 1을 고른다. */
|
|
44
|
-
const
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
.find((el) => el.dataset.star === String(
|
|
50
|
-
|
|
44
|
+
const groupRef = useRef(null);
|
|
45
|
+
const moveTo = (next) => {
|
|
46
|
+
const clamped = Math.min(max, Math.max(1, next));
|
|
47
|
+
notify(clamped);
|
|
48
|
+
const stars = Array.from(groupRef.current?.querySelectorAll('[data-star]') ?? [])
|
|
49
|
+
.find((el) => el.dataset.star === String(clamped));
|
|
50
|
+
stars?.focus();
|
|
51
51
|
};
|
|
52
|
-
const
|
|
52
|
+
const onKeyDown = (e) => {
|
|
53
53
|
if (isReadOnly)
|
|
54
54
|
return;
|
|
55
55
|
switch (e.key) {
|
|
56
56
|
case 'ArrowRight':
|
|
57
57
|
case 'ArrowUp':
|
|
58
|
-
|
|
58
|
+
moveTo((current || 0) + 1);
|
|
59
59
|
break;
|
|
60
60
|
case 'ArrowLeft':
|
|
61
61
|
case 'ArrowDown':
|
|
62
|
-
|
|
62
|
+
moveTo((current || 1) - 1);
|
|
63
63
|
break;
|
|
64
64
|
case 'Home':
|
|
65
|
-
|
|
65
|
+
moveTo(1);
|
|
66
66
|
break;
|
|
67
67
|
case 'End':
|
|
68
|
-
|
|
68
|
+
moveTo(max);
|
|
69
69
|
break;
|
|
70
70
|
default: return;
|
|
71
71
|
}
|
|
72
72
|
e.preventDefault();
|
|
73
73
|
};
|
|
74
74
|
/* Tab 자리를 받을 한 칸 — 고른 별, 없으면 첫 별 */
|
|
75
|
-
const
|
|
75
|
+
const tabValue = current >= 1 && current <= max ? Math.round(current) : 1;
|
|
76
76
|
const handleMouseLeave = () => {
|
|
77
77
|
if (isReadOnly)
|
|
78
78
|
return;
|
|
@@ -83,7 +83,7 @@ export default function Rating({ value, onChange, max = 5, readOnly = false, siz
|
|
|
83
83
|
대체 글자가 없으면 낭독기에는 빈 자리다 — 눈에는 별 넷 반이 보이는데 아무것도
|
|
84
84
|
안 읽힌다. 별점을 보여 주는 것이 이 모드의 유일한 일인데 그것이 사라진다.
|
|
85
85
|
(별을 하나씩 읽히게 하면 "별·별·별…" 이 다섯 번 나올 뿐이라 묶음에 준다.) */
|
|
86
|
-
_jsx("div", { className: `rating rating--${size}${isReadOnly ? ' rating--readOnly' : ''} ${className}`.trim(), ref:
|
|
86
|
+
_jsx("div", { className: `rating rating--${size}${isReadOnly ? ' rating--readOnly' : ''} ${className}`.trim(), ref: groupRef, onKeyDown: onKeyDown, role: isReadOnly ? 'img' : 'radiogroup', "aria-label": isReadOnly
|
|
87
87
|
? t('rating.valueLabel', { value, max })
|
|
88
88
|
: (ariaLabel ?? t('rating.groupLabel', '별점')), onMouseLeave: handleMouseLeave,
|
|
89
89
|
/* 터치를 떼거나 취소되면 미리보기를 푼다 */
|
|
@@ -99,6 +99,6 @@ export default function Rating({ value, onChange, max = 5, readOnly = false, siz
|
|
|
99
99
|
if (isReadOnly) {
|
|
100
100
|
return (_jsx("span", { className: "rating__item", "aria-hidden": "true", children: starNode }, star));
|
|
101
101
|
}
|
|
102
|
-
return (_jsx("button", { type: "button", className: "rating__item rating__item--interactive", role: "radio", "aria-checked":
|
|
102
|
+
return (_jsx("button", { type: "button", className: "rating__item rating__item--interactive", role: "radio", "aria-checked": current === star, "aria-label": t('rating.starLabel', { n: star }), "data-star": star, tabIndex: star === tabValue ? 0 : -1, onClick: () => handleClick(star), onMouseEnter: () => handleMouseEnter(star), children: starNode }, star));
|
|
103
103
|
}) }));
|
|
104
104
|
}
|
|
@@ -55,19 +55,19 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
|
|
|
55
55
|
넘치는데, 이 상자 안에는 포커스 갈 곳이 하나도 없다 — 그러면 키보드만 쓰는
|
|
56
56
|
사람은 오른쪽 절반을 **영영 못 본다**(WCAG 2.1.1). 실측: 겉 560px 에 안 794px.
|
|
57
57
|
늘 `tabIndex=0` 을 걸면 안 넘칠 때도 빈 탭 자리가 하나 생기므로 재서 건다. */
|
|
58
|
-
const [
|
|
58
|
+
const [overflowing, setOverflowing] = useState(false);
|
|
59
59
|
useEffect(() => {
|
|
60
60
|
const el = rootRef.current;
|
|
61
61
|
if (!el)
|
|
62
62
|
return;
|
|
63
|
-
const
|
|
64
|
-
|
|
63
|
+
const measure = () => setOverflowing(el.scrollWidth > el.clientWidth + 1);
|
|
64
|
+
measure();
|
|
65
65
|
// ResizeObserver 가 없는 환경(구형 브라우저·SSR·테스트 러너)에서는 생성자 호출이
|
|
66
66
|
// 그대로 던져 렌더가 통째로 깨진다. 위에서 한 번은 이미 쟀으므로 추적만 포기한다
|
|
67
67
|
// (BottomNav·WordTable 과 같은 처리).
|
|
68
68
|
if (typeof ResizeObserver === 'undefined')
|
|
69
69
|
return;
|
|
70
|
-
const ro = new ResizeObserver(
|
|
70
|
+
const ro = new ResizeObserver(measure);
|
|
71
71
|
ro.observe(el);
|
|
72
72
|
return () => ro.disconnect();
|
|
73
73
|
}, [measured]);
|
|
@@ -81,36 +81,36 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
|
|
|
81
81
|
const shape = blocks.map((b) => `${b.id}\u0000${b.section ?? ''}\u0000${b.group ?? ''}`).join('\u0001');
|
|
82
82
|
const pages = measured && measured.shape === shape ? measured.pages : null;
|
|
83
83
|
/* 자(ruler)의 높이 — 0 에서 크기가 생기면(숨겨져 있다 보이게 됨) 다시 잰다 */
|
|
84
|
-
const [
|
|
84
|
+
const [rulerSize, setRulerSize] = useState(0);
|
|
85
85
|
useEffect(() => {
|
|
86
86
|
const ruler = rulerRef.current;
|
|
87
87
|
if (!ruler || typeof ResizeObserver === 'undefined')
|
|
88
88
|
return;
|
|
89
|
-
const ro = new ResizeObserver(() =>
|
|
89
|
+
const ro = new ResizeObserver(() => setRulerSize(ruler.offsetHeight));
|
|
90
90
|
ro.observe(ruler);
|
|
91
91
|
return () => ro.disconnect();
|
|
92
92
|
}, []);
|
|
93
93
|
/* **내용 높이가 바뀌면 다시 잰다** — 짜임(id·section·group)이 같아도 메모가 길어지거나 글꼴·이미지가 늦게 와 블록 높이가 달라지면
|
|
94
94
|
예전 나눔 그대로 장이 넘쳤다(소리방향성은 id 에 메모 길이를 넣어 짜임을 바꾸는 우회를 썼다 — 그 길도 그대로 된다).
|
|
95
95
|
재기 사본 블록마다 ResizeObserver 를 걸고, 여러 번 울려도 다시 나눔은 rAF 한 번으로 모은다. 없는 환경에서는 지금처럼. */
|
|
96
|
-
const [
|
|
96
|
+
const [remeasureTick, setRemeasureTick] = useState(0);
|
|
97
97
|
useEffect(() => {
|
|
98
98
|
const el = measureRef.current;
|
|
99
99
|
if (!el || typeof ResizeObserver === 'undefined')
|
|
100
100
|
return;
|
|
101
|
-
let
|
|
102
|
-
const
|
|
101
|
+
let frameId = null;
|
|
102
|
+
const defer = typeof requestAnimationFrame === 'function'
|
|
103
103
|
? (f) => requestAnimationFrame(f)
|
|
104
104
|
: (f) => setTimeout(f, 16);
|
|
105
|
-
const
|
|
105
|
+
const undefer = typeof cancelAnimationFrame === 'function' ? cancelAnimationFrame : (id) => clearTimeout(id);
|
|
106
106
|
const ro = new ResizeObserver(() => {
|
|
107
|
-
if (
|
|
107
|
+
if (frameId != null)
|
|
108
108
|
return;
|
|
109
|
-
|
|
109
|
+
frameId = defer(() => { frameId = null; setRemeasureTick((n) => n + 1); });
|
|
110
110
|
});
|
|
111
111
|
Array.from(el.children).forEach((c) => ro.observe(c));
|
|
112
|
-
return () => { ro.disconnect(); if (
|
|
113
|
-
|
|
112
|
+
return () => { ro.disconnect(); if (frameId != null)
|
|
113
|
+
undefer(frameId); };
|
|
114
114
|
}, [shape]);
|
|
115
115
|
useLayoutEffect(() => {
|
|
116
116
|
const el = measureRef.current;
|
|
@@ -124,13 +124,13 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
|
|
|
124
124
|
/* 높이는 **소수 그대로** 잰다 — offsetHeight 는 정수로 반올림해, 소수 높이 블록이 모이면 반올림 합은 예산과 같은데 실제 장은
|
|
125
125
|
종이보다 3px 넘쳤고(mm 규격의 자 1016.69px 는 1017 로 올림) 인쇄에서는 그 1px 로 쪽이 하나 더 생겼다(Chromium PDF 확인).
|
|
126
126
|
rect 가 0 인 환경(레이아웃 없는 시험)에서는 offsetHeight 로 물러선다. */
|
|
127
|
-
const
|
|
127
|
+
const heightOf = (node) => node.getBoundingClientRect().height || node.offsetHeight;
|
|
128
128
|
const chromeOf = new Map();
|
|
129
129
|
chromeRef.current?.querySelectorAll('[data-chrome-section]').forEach((node) => {
|
|
130
|
-
chromeOf.set(node.dataset.chromeSection ?? '',
|
|
130
|
+
chromeOf.set(node.dataset.chromeSection ?? '', heightOf(node));
|
|
131
131
|
});
|
|
132
|
-
const budgetFor = (section) =>
|
|
133
|
-
const heights = Array.from(el.children).map((c) =>
|
|
132
|
+
const budgetFor = (section) => heightOf(ruler) - (chromeOf.get(section ?? '') ?? 0);
|
|
133
|
+
const heights = Array.from(el.children).map((c) => heightOf(c));
|
|
134
134
|
// 이웃한 같은 group 을 한 덩어리로 묶는다.
|
|
135
135
|
const groups = [];
|
|
136
136
|
blocks.forEach((b, i) => {
|
|
@@ -179,11 +179,11 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
|
|
|
179
179
|
});
|
|
180
180
|
flush();
|
|
181
181
|
/* 같은 나눔이면 상태를 그대로 둔다 — 다시 재기(ResizeObserver 첫 울림 등)마다 새 객체로 렌더가 한 번 더 도는 것을 막는다 */
|
|
182
|
-
setMeasured((
|
|
182
|
+
setMeasured((prev) => (prev && prev.shape === shape && JSON.stringify(prev.pages) === JSON.stringify(packed) ? prev : { shape, pages: packed }));
|
|
183
183
|
// blocks 는 위 shape 로 대신 본다.
|
|
184
184
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
185
185
|
// 장 규격이 바뀌면 글이 다시 흘러 블록 높이가 달라진다 — 자(세로) 높이는 그대로라 자크기만으로는 다시 재지 않아 장이 넘쳤다
|
|
186
|
-
}, [shape,
|
|
186
|
+
}, [shape, rulerSize, pageWidth, pageHeight, margin, remeasureTick]);
|
|
187
187
|
/* 번들러마다 SVG import 결과가 다르다 — Vite 는 문자열, Next 는 `{ src }` 객체다.
|
|
188
188
|
문자열만 보면 Next 앱에서 fallback 경로(`/logo/...`)로 떨어져 404 가 난다
|
|
189
189
|
(kscan 결과지의 바닥 로고가 실제로 깨졌다). 둘 다 받는다. */
|
|
@@ -208,7 +208,7 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
|
|
|
208
208
|
return (_jsxs("div", { ref: rootRef, className: cx('report-doc', className), style: vars,
|
|
209
209
|
/* 굴릴 것이 있을 때만 포커스를 받는다. 이름 없이 포커스만 주면 낭독기가
|
|
210
210
|
문서 전체를 읽어 버리므로 역할과 이름을 함께 준다. */
|
|
211
|
-
tabIndex:
|
|
211
|
+
tabIndex: overflowing ? 0 : undefined, role: overflowing ? 'group' : undefined, "aria-label": overflowing ? t('report.previewRegion', '보고서 미리보기') : undefined, children: [_jsxs("div", { className: "report-doc__measure", "aria-hidden": true, children: [_jsx("div", { ref: rulerRef, className: "report-doc__ruler" }), _jsx("div", { ref: chromeRef, children: chromeSections.map((sec) => {
|
|
212
212
|
const c = chrome(1, 1, sec || undefined);
|
|
213
213
|
return _jsxs("div", { "data-chrome-section": sec, children: [c.header, c.footer] }, sec);
|
|
214
214
|
}) }), _jsx("div", { ref: measureRef, className: "report-doc__measure-blocks", children: blocks.map((b) => (_jsx("div", { children: b.content }, b.id))) })] }), pages && (_jsx("div", { className: "report-doc__pages", children: pages.map((idxs, p) => {
|
|
@@ -13,7 +13,7 @@ const RING_SIZE_PRESET = {
|
|
|
13
13
|
/** 원시 링 렌더링 — 도넛 링 하나만 그린다(헤더/legend 없음) */
|
|
14
14
|
/** 비율 → 퍼센트 정수. **다 채우기 전에는 100 을 내지 않는다** — 반올림하면 199/200 이 '100%' 인데 링 끝은 비고 범례는
|
|
15
15
|
* '미완료 1건' 이었다(말귀 숙제 완료율). 같은 까닭으로 0 초과면 0 을 내지 않는다. */
|
|
16
|
-
function
|
|
16
|
+
function toPercent(ratio) {
|
|
17
17
|
if (!Number.isFinite(ratio) || ratio <= 0)
|
|
18
18
|
return 0;
|
|
19
19
|
if (ratio >= 1)
|
|
@@ -43,7 +43,7 @@ function RingChartCanvas({ done, total, segments, color = 'var(--color-main)', s
|
|
|
43
43
|
const pct = (total ?? 0) > 0 ? Math.min((done ?? 0) / total, 1) : 0;
|
|
44
44
|
const filled = circumference * pct;
|
|
45
45
|
const gap = circumference - filled;
|
|
46
|
-
return (_jsxs("div", { className: "ring-chart", style: { width: dim, height: dim }, children: [_jsxs("svg", { width: dim, height: dim, viewBox: `0 0 ${dim} ${dim}`, "aria-hidden": "true", children: [_jsx("circle", { cx: center, cy: center, r: radius, fill: "none", stroke: "var(--color-whiteLine)", strokeWidth: stroke }), pct > 0 && (_jsx("circle", { cx: center, cy: center, r: radius, fill: "none", stroke: color, strokeWidth: stroke, strokeDasharray: `${filled} ${gap}`, strokeLinecap: pct < 0.02 || pct > 0.98 ? 'butt' : 'round', transform: `rotate(-90 ${center} ${center})` }))] }), !hideCenter && (_jsxs("div", { className: `ring-chart__center${hideLabel ? '' : ` ${dim <= 96 ? 'ring-chart__center--small' : size === 'medium' ? 'ring-chart__center--medium' : 'ring-chart__center--large'}`}`, children: [_jsx("span", { className: `ring-chart__pct ${dim <= 96 ? 'h2' : 'h1'}`, children:
|
|
46
|
+
return (_jsxs("div", { className: "ring-chart", style: { width: dim, height: dim }, children: [_jsxs("svg", { width: dim, height: dim, viewBox: `0 0 ${dim} ${dim}`, "aria-hidden": "true", children: [_jsx("circle", { cx: center, cy: center, r: radius, fill: "none", stroke: "var(--color-whiteLine)", strokeWidth: stroke }), pct > 0 && (_jsx("circle", { cx: center, cy: center, r: radius, fill: "none", stroke: color, strokeWidth: stroke, strokeDasharray: `${filled} ${gap}`, strokeLinecap: pct < 0.02 || pct > 0.98 ? 'butt' : 'round', transform: `rotate(-90 ${center} ${center})` }))] }), !hideCenter && (_jsxs("div", { className: `ring-chart__center${hideLabel ? '' : ` ${dim <= 96 ? 'ring-chart__center--small' : size === 'medium' ? 'ring-chart__center--medium' : 'ring-chart__center--large'}`}`, children: [_jsx("span", { className: `ring-chart__pct ${dim <= 96 ? 'h2' : 'h1'}`, children: toPercent(pct) }), !hideLabel && (_jsx("span", { className: `ring-chart__label ${dim <= 96 ? 'caption-2' : 'caption-1'}${dim <= 96 || size === 'medium' ? ' ring-chart__label--tight' : ''}`, children: label }))] }))] }));
|
|
47
47
|
}
|
|
48
48
|
/**
|
|
49
49
|
* 도넛 링 차트 — 전체 대비 완료 비율을 원형 호로 표현.
|
|
@@ -55,11 +55,11 @@ export default function RingChart({ titleKey, titleLevel, headRight, icon, showT
|
|
|
55
55
|
const { t } = useTranslation();
|
|
56
56
|
if (!titleKey)
|
|
57
57
|
return _jsx(RingChartCanvas, { ...ringProps });
|
|
58
|
-
const pct = (ringProps.total ?? 0) > 0 ?
|
|
58
|
+
const pct = (ringProps.total ?? 0) > 0 ? toPercent(Math.min((ringProps.done ?? 0) / ringProps.total, 1)) : 0;
|
|
59
59
|
// size='small' — 대시보드 작은 그리드용 블럭. 컨트롤(headRight)만 생략하고 total/legend는 medium과 동일하게 유지
|
|
60
60
|
const isSmall = ringProps.size === 'small';
|
|
61
61
|
/* 기다림 뼈대의 지름 — 그려질 링과 **같은 셈** 을 쓴다(RingChartCanvas 의 dim) */
|
|
62
|
-
const
|
|
62
|
+
const loaderSize = typeof ringProps.size === 'number' ? ringProps.size : RING_SIZE_PRESET[ringProps.size ?? 'medium'].diameter;
|
|
63
63
|
// size='large' — 카드가 가로 폭을 거의 100% 차지(1열)
|
|
64
64
|
const isLarge = ringProps.size === 'large';
|
|
65
65
|
const sectionClassName = [className, isSmall ? 'manager-section--compact' : isLarge ? 'manager-section--full' : '']
|
|
@@ -70,5 +70,5 @@ export default function RingChart({ titleKey, titleLevel, headRight, icon, showT
|
|
|
70
70
|
/* 링도 같은 병이 있었다 — 기다림 상자 186px, 그려진 링은 지름(medium 120px)이다.
|
|
71
71
|
**그려질 링과 같은 자리·같은 지름**의 뼈대를 세우고 그 안에서 기다린다
|
|
72
72
|
(BarChart 와 같은 결, 사용자 지시 2026-09-17 "뼈대 먼저, 그 안에서 로딩"). */
|
|
73
|
-
_jsx("div", { className: "ring-chart-section-body ring-chart-section-body--offset", children: _jsx("div", { className: "ring-chart ring-chart--loading", style: { width:
|
|
73
|
+
_jsx("div", { className: "ring-chart-section-body ring-chart-section-body--offset", children: _jsx("div", { className: "ring-chart ring-chart--loading", style: { width: loaderSize, height: loaderSize }, "aria-busy": "true", children: _jsx(Loader, { size: 32 }) }) })) : (_jsx("div", { className: "ring-chart-section-body ring-chart-section-body--offset", children: _jsx(RingChartCanvas, { ...ringProps, hideCenter: ringProps.hideCenter ?? isSmall }) })) }));
|
|
74
74
|
}
|