@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
|
@@ -23,13 +23,13 @@ export default function BulkBar({ count, actions, onClear, clearLabel: clearLabe
|
|
|
23
23
|
const prevShowRef = useRef(show);
|
|
24
24
|
useLayoutEffect(() => {
|
|
25
25
|
const bar = barRef.current;
|
|
26
|
-
const
|
|
26
|
+
const before = prevShowRef.current;
|
|
27
27
|
prevShowRef.current = show;
|
|
28
|
-
if (show &&
|
|
28
|
+
if (show && !before) {
|
|
29
29
|
const a = typeof document !== 'undefined' ? document.activeElement : null;
|
|
30
30
|
openerRef.current = a && a !== document.body && !bar?.contains(a) ? a : null;
|
|
31
31
|
}
|
|
32
|
-
else if (!show &&
|
|
32
|
+
else if (!show && before && bar && bar.contains(document.activeElement)) {
|
|
33
33
|
moveFocusOutside(bar, openerRef.current);
|
|
34
34
|
}
|
|
35
35
|
}, [show]);
|
|
@@ -38,35 +38,35 @@ export default function BulkBar({ count, actions, onClear, clearLabel: clearLabe
|
|
|
38
38
|
문턱이 하나라 **매 프레임 오갔다**(말귀 단어 고름 막대, 칩 7~12개). 되돌릴 때는 "가로일 때의 칸 폭" 에 한 줄로 들어가는지로
|
|
39
39
|
본다(여유). 가로일 때 잰 칸 폭을 기억해 둔다. */
|
|
40
40
|
const stackedRef = useRef(false);
|
|
41
|
-
const
|
|
41
|
+
const rowWidthRef = useRef(null);
|
|
42
42
|
useEffect(() => {
|
|
43
43
|
const el = contentRef.current;
|
|
44
44
|
if (!el)
|
|
45
45
|
return;
|
|
46
|
-
const
|
|
46
|
+
const setStackedBoth = (v) => { stackedRef.current = v; setStacked(v); };
|
|
47
47
|
const check = () => {
|
|
48
48
|
const row = el.firstElementChild ?? el;
|
|
49
49
|
const kids = Array.from(row.children);
|
|
50
50
|
if (kids.length < 2) {
|
|
51
|
-
|
|
51
|
+
setStackedBoth(false);
|
|
52
52
|
return;
|
|
53
53
|
}
|
|
54
54
|
const top = kids[0].offsetTop;
|
|
55
|
-
const
|
|
55
|
+
const wrapped = kids.some(k => k.offsetTop !== top);
|
|
56
56
|
if (!stackedRef.current) {
|
|
57
|
-
|
|
58
|
-
if (
|
|
59
|
-
|
|
57
|
+
rowWidthRef.current = el.clientWidth;
|
|
58
|
+
if (wrapped)
|
|
59
|
+
setStackedBoth(true);
|
|
60
60
|
return;
|
|
61
61
|
}
|
|
62
|
-
if (
|
|
62
|
+
if (wrapped)
|
|
63
63
|
return;
|
|
64
64
|
// 쌓인 채 한 줄에 들어간다 — 가로로 돌렸을 때의 칸에도 들어갈 때만 되돌린다
|
|
65
65
|
const first = kids[0], last = kids[kids.length - 1];
|
|
66
|
-
const
|
|
67
|
-
const
|
|
68
|
-
if (
|
|
69
|
-
|
|
66
|
+
const rowWidth = last.offsetLeft + last.offsetWidth - first.offsetLeft;
|
|
67
|
+
const storedRowWidth = rowWidthRef.current;
|
|
68
|
+
if (storedRowWidth == null || rowWidth <= storedRowWidth)
|
|
69
|
+
setStackedBoth(false);
|
|
70
70
|
};
|
|
71
71
|
check();
|
|
72
72
|
// ResizeObserver 가 없는 환경(구형 브라우저·SSR·테스트 러너)에서는 생성자 호출이
|
|
@@ -77,19 +77,19 @@ export default function BulkBar({ count, actions, onClear, clearLabel: clearLabe
|
|
|
77
77
|
obs.observe(el);
|
|
78
78
|
/* 막대 자체의 폭이 바뀌면(창 크기뿐 아니라 옆 메뉴 접기로 본문 열이 넓어질 때도) 기억한 가로 칸 폭은 옛 것이다 — 버리고 다시
|
|
79
79
|
잰다. 안 그러면 넘친 순간의 좁은 폭을 들고 있어 넓어져도 쌓임이 영영 안 풀렸다. 단추 배치로는 막대 폭이 안 바뀐다. */
|
|
80
|
-
const
|
|
81
|
-
let
|
|
82
|
-
const
|
|
83
|
-
const w =
|
|
84
|
-
if (w ===
|
|
80
|
+
const bar = el.parentElement;
|
|
81
|
+
let barWidth = bar?.clientWidth ?? 0;
|
|
82
|
+
const barObserver = new ResizeObserver(() => {
|
|
83
|
+
const w = bar?.clientWidth ?? 0;
|
|
84
|
+
if (w === barWidth)
|
|
85
85
|
return;
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
barWidth = w;
|
|
87
|
+
rowWidthRef.current = null;
|
|
88
88
|
check();
|
|
89
89
|
});
|
|
90
|
-
if (
|
|
91
|
-
|
|
92
|
-
return () => { obs.disconnect();
|
|
90
|
+
if (bar)
|
|
91
|
+
barObserver.observe(bar);
|
|
92
|
+
return () => { obs.disconnect(); barObserver.disconnect(); };
|
|
93
93
|
}, [content, count]);
|
|
94
94
|
return (_jsxs("div", { ref: barRef, className: `bulk-bar ${show ? 'bulk-bar--visible' : ''}`,
|
|
95
95
|
/* 숨은 막대는 CSS 로 투명(opacity 0)할 뿐 DOM 에 그대로 있다 — 예전에는 그 안의
|
|
@@ -74,11 +74,11 @@ function ButtonInner(props, forwardedRef) {
|
|
|
74
74
|
}
|
|
75
75
|
}, [htmlType, label]);
|
|
76
76
|
/* 안쪽 ref 와 부르는 쪽 ref(객체·함수)를 한 요소에 함께 건다 */
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
const
|
|
77
|
+
const outerRef = useRef(forwardedRef);
|
|
78
|
+
outerRef.current = forwardedRef;
|
|
79
|
+
const attachEl = useCallback((el) => {
|
|
80
80
|
btnRef.current = el;
|
|
81
|
-
const r =
|
|
81
|
+
const r = outerRef.current;
|
|
82
82
|
if (typeof r === 'function')
|
|
83
83
|
r(el);
|
|
84
84
|
else if (r)
|
|
@@ -114,14 +114,14 @@ function ButtonInner(props, forwardedRef) {
|
|
|
114
114
|
if (prevDisabledRef.current && !disabledProp && btnRef.current) {
|
|
115
115
|
const btn = btnRef.current;
|
|
116
116
|
/* 마우스로 잡힌 초점만 푼다 — 키보드로 누른 단추(모두 듣기 등)가 막혔다 풀리는 순간 무조건 blur 해 초점이 body 로 빠졌다 */
|
|
117
|
-
let
|
|
117
|
+
let focusByMouse = false;
|
|
118
118
|
try {
|
|
119
|
-
|
|
119
|
+
focusByMouse = btn.matches(':focus:not(:focus-visible)');
|
|
120
120
|
}
|
|
121
121
|
catch {
|
|
122
|
-
|
|
122
|
+
focusByMouse = false;
|
|
123
123
|
}
|
|
124
|
-
if (
|
|
124
|
+
if (focusByMouse)
|
|
125
125
|
btn.blur();
|
|
126
126
|
btn.style.pointerEvents = 'none';
|
|
127
127
|
const id = requestAnimationFrame(() => {
|
|
@@ -157,7 +157,7 @@ function ButtonInner(props, forwardedRef) {
|
|
|
157
157
|
};
|
|
158
158
|
/* 떠날 때 비제어 선택 표시를 푼다 — **toggleable 은 제외**(눌러 켠 것이 포인터가 떠나면 꺼졌다).
|
|
159
159
|
blur 도 이 정리를 쓰지만 소비자의 onMouseLeave 는 부르지 않는다(포커스를 잃은 것은 마우스가 떠난 것이 아니다). */
|
|
160
|
-
const
|
|
160
|
+
const onLeaveCleanup = (e) => {
|
|
161
161
|
if (typeof selected !== 'boolean' && !toggleable)
|
|
162
162
|
setInternalSelected(false);
|
|
163
163
|
e.currentTarget.blur();
|
|
@@ -167,14 +167,14 @@ function ButtonInner(props, forwardedRef) {
|
|
|
167
167
|
return;
|
|
168
168
|
if (typeof rest.onMouseLeave === 'function')
|
|
169
169
|
rest.onMouseLeave(e);
|
|
170
|
-
|
|
170
|
+
onLeaveCleanup(e);
|
|
171
171
|
};
|
|
172
172
|
const handleClickInternal = (e) => {
|
|
173
173
|
if (tooltipOnly && hasTooltip)
|
|
174
|
-
|
|
174
|
+
touchToggle.touchPress(e.currentTarget);
|
|
175
175
|
if (disabledProp) {
|
|
176
176
|
if (hasTooltip)
|
|
177
|
-
|
|
177
|
+
touchToggle.touchPress(e.currentTarget);
|
|
178
178
|
/* native disabled 가 아닌 막힌 단추(초점 유지·onDisabledClick)는 click 이 온다 — 폼 안이면 제출되지 않게 기본 동작을 막는다 */
|
|
179
179
|
e.preventDefault();
|
|
180
180
|
onDisabledClick?.(e);
|
|
@@ -205,7 +205,7 @@ function ButtonInner(props, forwardedRef) {
|
|
|
205
205
|
**막힌 단추·tooltipOnly 칩**은 톡으로 열고 닫는다(막힌 단추는 click 이 안 와 pointerup, 포인터조차 안 오면 감싼 요소의 touchend).
|
|
206
206
|
누르는 단추(삭제·재생 등)는 터치에서 툴팁을 띄우지 않는다 — 누르면 할 일을 하고 끝난다. 마우스 동작은 그대로. */
|
|
207
207
|
const hasTooltip = Boolean(showTooltipInfo || tooltipDescription);
|
|
208
|
-
const
|
|
208
|
+
const touchToggle = useTouchToggle(tooltipVisible, (next) => { if (next)
|
|
209
209
|
showTooltipFn();
|
|
210
210
|
else
|
|
211
211
|
hideTooltipFn(); }, btnRef, { enabled: hasTooltip && (disabledProp || tooltipOnly), fallbackOnParent: hasTooltip && disabledProp });
|
|
@@ -262,7 +262,7 @@ function ButtonInner(props, forwardedRef) {
|
|
|
262
262
|
/* href 가 있고 비활성이 아니면 링크로 렌더한다. */
|
|
263
263
|
const isLink = !!href && !disabledProp;
|
|
264
264
|
const Root = (isLink ? 'a' : 'button');
|
|
265
|
-
return (_jsxs(_Fragment, { children: [_jsxs(Root, { ...restProps, ref:
|
|
265
|
+
return (_jsxs(_Fragment, { children: [_jsxs(Root, { ...restProps, ref: attachEl, ...(isLink
|
|
266
266
|
? { href, target, rel: rel ?? (target === '_blank' ? 'noreferrer' : undefined) }
|
|
267
267
|
: { type: htmlType ?? 'button', disabled: disabledProp && !onDisabledClick && !hasFocus }), className: classes, "aria-pressed": ariaPressed, "aria-expanded": typeof ariaExpanded === 'boolean' ? ariaExpanded : rest['aria-expanded'], "aria-controls": ariaControls ?? rest['aria-controls'], "aria-label": ariaLabelString, "aria-disabled": disabledProp && (isLink || !!onDisabledClick || hasFocus) ? true : undefined, "aria-describedby": ariaDescribedBy, style: style, onClick: handleClickInternal, onMouseLeave: handleMouseLeave, onPointerEnter: (e) => {
|
|
268
268
|
if (!isTouchPointer(e))
|
|
@@ -277,7 +277,7 @@ function ButtonInner(props, forwardedRef) {
|
|
|
277
277
|
}, onPointerUp: (e) => {
|
|
278
278
|
// 막힌 단추는 click 이 오지 않는다 — 톡을 뗄 때 뒤집는다
|
|
279
279
|
if (disabledProp && hasTooltip)
|
|
280
|
-
|
|
280
|
+
touchToggle.touchPress(e.currentTarget);
|
|
281
281
|
if (!disabledProp && typeof rest.onPointerUp === 'function')
|
|
282
282
|
rest.onPointerUp(e);
|
|
283
283
|
}, onFocus: (e) => {
|
|
@@ -290,7 +290,7 @@ function ButtonInner(props, forwardedRef) {
|
|
|
290
290
|
}, onBlur: (e) => {
|
|
291
291
|
setHasFocus(false);
|
|
292
292
|
if (!disabledProp)
|
|
293
|
-
|
|
293
|
+
onLeaveCleanup(e);
|
|
294
294
|
hideTooltipFn();
|
|
295
295
|
if (!disabledProp && typeof rest.onBlur === 'function')
|
|
296
296
|
rest.onBlur(e);
|
|
@@ -15,9 +15,9 @@ export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDay
|
|
|
15
15
|
const DOW = useMemo(() => t('calendar.dow', { returnObjects: true }), [t]);
|
|
16
16
|
/* '오늘' 은 KST 의 날 — 방문 날짜가 KST 달력(toKstYmd)이라, 기기 달력으로 열면 해외 기기에서 오늘 방문이 안 보이는 달에 있고
|
|
17
17
|
오늘 표시가 다른 날에 붙었다(해외 기기 공식 지원) */
|
|
18
|
-
const
|
|
19
|
-
const [year, setYear] = useState(() => Number(
|
|
20
|
-
const [month, setMonth] = useState(() => Number(
|
|
18
|
+
const todayYmd = todayYmdOr(today);
|
|
19
|
+
const [year, setYear] = useState(() => Number(todayYmd.slice(0, 4)));
|
|
20
|
+
const [month, setMonth] = useState(() => Number(todayYmd.slice(5, 7)) - 1);
|
|
21
21
|
const [dragOverDate, setDragOverDate] = useState(null);
|
|
22
22
|
const dragVisitId = useRef(null);
|
|
23
23
|
const visitsByDate = useMemo(() => {
|
|
@@ -54,7 +54,7 @@ export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDay
|
|
|
54
54
|
else
|
|
55
55
|
setMonth(m => m + 1); }, [month]);
|
|
56
56
|
const fmtDate = (d) => `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
|
|
57
|
-
const isToday = (d) => fmtDate(d) ===
|
|
57
|
+
const isToday = (d) => fmtDate(d) === todayYmd;
|
|
58
58
|
return (_jsxs("div", { children: [_jsxs("div", { className: "calendar-nav", children: [_jsx("button", { type: "button", className: "calendar-nav__button", onClick: prev, "aria-label": t('calendar.prevMonth', '이전 달'), children: _jsx(Icon, { icon: "chevron-left", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsx("span", { className: "calendar-nav__title h5", "aria-live": "polite", children: t('calendar.yearMonth', { year, month: month + 1 }) }), _jsx("button", { type: "button", className: "calendar-nav__button", onClick: next, "aria-label": t('calendar.nextMonth', '다음 달'), children: _jsx(Icon, { icon: "chevron-right", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsxs("div", { className: "calendar-nav__legend", children: [_jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--main" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.visited', '방문') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--sub" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.scheduled', '예약') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--disabled" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.noVisit', '미방문') })] })] }), _jsxs("div", { className: "calendar-grid", children: [DOW.map(d => _jsx("div", { className: "calendar-day-header body-3", children: d }, d)), cells.map((d, i) => {
|
|
59
59
|
if (d === null)
|
|
60
60
|
return _jsx("div", { className: "calendar-cell empty" }, `empty-${i}`);
|
|
@@ -62,7 +62,7 @@ export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDay
|
|
|
62
62
|
const dateKey = fmtDate(day);
|
|
63
63
|
const dayVisits = visitsByDate[dateKey] ?? [];
|
|
64
64
|
/* 낭독기에 나가는 '며칠' — 보는 locale 을 따라 글자꼴만 바뀌고 가리키는 날은 KST 하나다(utils/date 머리글) */
|
|
65
|
-
const
|
|
65
|
+
const dateLabel = formatDateLocale(dateKey, i18n.language);
|
|
66
66
|
const isDragOver = dragOverDate === dateKey;
|
|
67
67
|
return (_jsxs("div", { className: `calendar-cell${isToday(day) ? ' today' : ''}${isDragOver ? ' drag-over' : ''}${onDayClick ? ' calendar-cell--clickable' : ''}`, role: onDayClick ? 'button' : undefined, tabIndex: onDayClick ? 0 : undefined, "aria-label": onDayClick ? t('calendar.addOnDate', { date: dateKey }) : undefined, "aria-current": isToday(day) ? 'date' : undefined, onClick: onDayClick ? () => onDayClick(dateKey) : undefined,
|
|
68
68
|
/* `<div role="button">` 은 브라우저가 Enter·Space 를 눌림으로 바꿔 주지 않는다 */
|
|
@@ -93,20 +93,20 @@ export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDay
|
|
|
93
93
|
드문 자리가 아니다: 375 에서 칸이 37px, **1189 에서도 111px** 이라 둘 다
|
|
94
94
|
이 꼴이 선다. 넓은 칸의 이름표는 멀쩡해서 그쪽만 보면 안 보인다.
|
|
95
95
|
갈래가 하나면 그 갈래 말로, 섞였으면 갈라 센다. */
|
|
96
|
-
const
|
|
97
|
-
{ key: 'visited',
|
|
98
|
-
|
|
99
|
-
{ key: 'scheduled',
|
|
100
|
-
|
|
101
|
-
{ key: 'noVisit',
|
|
102
|
-
|
|
103
|
-
].filter(g => g
|
|
96
|
+
const kinds = [
|
|
97
|
+
{ key: 'visited', label: t('calendar.legend.visited', '방문'),
|
|
98
|
+
count: dayVisits.filter(v => (v.status ?? 'visited') === 'visited').length },
|
|
99
|
+
{ key: 'scheduled', label: t('calendar.legend.scheduled', '예약'),
|
|
100
|
+
count: dayVisits.filter(v => v.status === 'scheduled').length },
|
|
101
|
+
{ key: 'noVisit', label: t('calendar.legend.noVisit', '미방문'),
|
|
102
|
+
count: dayVisits.filter(v => v.status === 'noVisit').length },
|
|
103
|
+
].filter(g => g.count > 0);
|
|
104
104
|
/* 방문뿐인 날은 전 글귀 그대로 간다 — 그 열쇠의 뜻("방문 N건")이 실제로 맞는
|
|
105
105
|
자리이고, 부르는 쪽이 덮어 둔 글도 그대로 산다. */
|
|
106
|
-
const
|
|
106
|
+
const singleKind = kinds.length === 1 && kinds[0].key === 'visited'
|
|
107
107
|
? t('calendar.visitCount', { count: dayVisits.length, defaultValue: '방문 {{count}}건' })
|
|
108
|
-
:
|
|
109
|
-
return (_jsxs("span", { className: "calendar-cell__visit-count caption-1", "aria-label": `${
|
|
108
|
+
: kinds.map(g => t('calendar.statusCount', { label: g.label, count: g.count, defaultValue: '{{label}} {{count}}건' })).join(' · ');
|
|
109
|
+
return (_jsxs("span", { className: "calendar-cell__visit-count caption-1", "aria-label": `${dateLabel} ${singleKind}`, children: [kinds.map(g => (_jsx("span", { className: `calendar-cell__visit-dot calendar-cell__visit-dot--${g.key}`, "aria-hidden": "true" }, g.key))), dayVisits.length] }));
|
|
110
110
|
})(), _jsx("div", { className: "calendar-cell__badges", children: dayVisits.map((v, vi) => (_jsxs("div", { draggable: !!onVisitMove, className: `calendar-visit-badge${v.status === 'noVisit' ? ' calendar-visit-badge--no-visit' : v.status === 'scheduled' ? ' calendar-visit-badge--scheduled' : ''}${onVisitClick ? ' calendar-visit-badge--clickable' : ''}${onVisitMove ? ' calendar-visit-badge--draggable' : ''}`, onDragStart: onVisitMove ? (e) => {
|
|
111
111
|
dragVisitId.current = v.id;
|
|
112
112
|
e.dataTransfer.effectAllowed = 'move';
|
|
@@ -118,7 +118,7 @@ export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDay
|
|
|
118
118
|
// 이름을 대체하므로 낭독기에는 "12시 55분 지한 예약" 만 가고, 어느 날 것인지는
|
|
119
119
|
// 안 간다. 격자 꼴(`gridcell`)로 날을 함께 읽히는 길도 있지만 그건 화살표
|
|
120
120
|
// 이동까지 약속하는 것이라, 지금은 이름 안에 날을 적는다(말귀 요청 (60)②).
|
|
121
|
-
|
|
121
|
+
dateLabel,
|
|
122
122
|
v.time ? formatTimeHM(v.time, t) : null,
|
|
123
123
|
v.customer,
|
|
124
124
|
t(v.status === 'noVisit' ? 'calendar.legend.noVisit'
|
|
@@ -34,9 +34,9 @@ export default function ChartSection({ title, titleClassName = 'h3', titleLevel
|
|
|
34
34
|
const secRef = useRef(null);
|
|
35
35
|
/* 설명 툴팁 — StatChart 와 같은 결(카드 사각형 기준, 포인터가 나가거나 초점이 빠지면 닫힘).
|
|
36
36
|
hover 전용이면 키보드로는 볼 수 없어 초점에도 연다. */
|
|
37
|
-
const { anchorRect:
|
|
38
|
-
const
|
|
39
|
-
|
|
37
|
+
const { anchorRect: tipRect, openAt: openTip, close: closeTip } = useHoverAnchor();
|
|
38
|
+
const showTip = useCallback(() => { if (tooltip && secRef.current)
|
|
39
|
+
openTip(secRef.current); }, [tooltip, openTip]);
|
|
40
40
|
/* 좁은지는 **카드 제 폭** 으로 잰다 — 부르는 쪽이 격자 칸 폭으로 대신 재면 두 칸을 차지하는 카드에서 틀린다.
|
|
41
41
|
ResizeObserver 가 없는 환경(구형·SSR)에서는 접지 않는다(넓은 쪽이 기본).
|
|
42
42
|
|
|
@@ -45,29 +45,29 @@ export default function ChartSection({ title, titleClassName = 'h3', titleLevel
|
|
|
45
45
|
프레임 동안 머리가 한 줄 늘어 카드가 부풀었다 — 말귀 실측 2026-09-17: 머리 32 → 70 → 32px,
|
|
46
46
|
섹션 342 → 380 → 342px, 페이지 바닥이 38px 출렁(layout-shift 로 잡힘).
|
|
47
47
|
뒤따르는 폭 변화는 ResizeObserver 가 그대로 따라간다. */
|
|
48
|
-
const [
|
|
48
|
+
const [isNarrow, setIsNarrow] = useState(false);
|
|
49
49
|
useIsoLayoutEffect(() => {
|
|
50
50
|
const el = secRef.current;
|
|
51
51
|
if (!el || !headSegment)
|
|
52
52
|
return;
|
|
53
|
-
const
|
|
54
|
-
|
|
53
|
+
const measure = (w) => setIsNarrow(w > 0 && w < collapseBelow);
|
|
54
|
+
measure(el.clientWidth);
|
|
55
55
|
if (typeof ResizeObserver === 'undefined')
|
|
56
56
|
return;
|
|
57
|
-
const ro = new ResizeObserver(entries =>
|
|
57
|
+
const ro = new ResizeObserver(entries => measure(entries[0].contentRect.width));
|
|
58
58
|
ro.observe(el);
|
|
59
59
|
return () => ro.disconnect();
|
|
60
60
|
}, [headSegment, collapseBelow]);
|
|
61
|
-
const [
|
|
62
|
-
const
|
|
63
|
-
const
|
|
61
|
+
const [menuRect, setMenuRect] = useState(null);
|
|
62
|
+
const closeMenu = useCallback(() => setMenuRect(null), []);
|
|
63
|
+
const openMenu = useCallback((e) => {
|
|
64
64
|
const r = e.currentTarget.getBoundingClientRect();
|
|
65
|
-
|
|
65
|
+
setMenuRect(prev => (prev ? null : { left: r.left, right: r.right, top: r.top, bottom: r.bottom, width: r.width, height: r.height, x: r.x, y: r.y }));
|
|
66
66
|
}, []);
|
|
67
|
-
const
|
|
68
|
-
useEffect(() => { if (
|
|
69
|
-
|
|
70
|
-
const
|
|
67
|
+
const collapsed = !!headSegment && isNarrow;
|
|
68
|
+
useEffect(() => { if (!collapsed)
|
|
69
|
+
setMenuRect(null); }, [collapsed]);
|
|
70
|
+
const menuItems = headSegment
|
|
71
71
|
? [
|
|
72
72
|
...headSegment.options.map(o => ({
|
|
73
73
|
label: o.label,
|
|
@@ -82,7 +82,7 @@ export default function ChartSection({ title, titleClassName = 'h3', titleLevel
|
|
|
82
82
|
]
|
|
83
83
|
: [];
|
|
84
84
|
/* 누르는 카드 — 머리의 단추·고르개를 눌렀을 때까지 카드가 열리면 안 된다(그 누름은 제 일을 한다) */
|
|
85
|
-
const
|
|
85
|
+
const onCardClick = useCallback((e) => {
|
|
86
86
|
if (!onClick)
|
|
87
87
|
return;
|
|
88
88
|
const t = e.target;
|
|
@@ -90,7 +90,7 @@ export default function ChartSection({ title, titleClassName = 'h3', titleLevel
|
|
|
90
90
|
return;
|
|
91
91
|
onClick();
|
|
92
92
|
}, [onClick]);
|
|
93
|
-
const
|
|
93
|
+
const onCardKeyDown = useCallback((e) => {
|
|
94
94
|
if (!onClick)
|
|
95
95
|
return;
|
|
96
96
|
if (e.target !== e.currentTarget)
|
|
@@ -100,21 +100,21 @@ export default function ChartSection({ title, titleClassName = 'h3', titleLevel
|
|
|
100
100
|
onClick();
|
|
101
101
|
}
|
|
102
102
|
}, [onClick]);
|
|
103
|
-
const
|
|
103
|
+
const rightNode = typeof headRight === 'function' ? headRight({ collapsed: collapsed }) : headRight;
|
|
104
104
|
/* 제목(과 `disc`) 아래 수 — 차트 카드(BarChart·RingChart)가 쓰는 `ChartTotal` 그 꼴을 그대로 쓴다.
|
|
105
105
|
같은 자리에 두 모양이 생기면 카드마다 눈금이 달라진다(설명 `disc` 를 머리 아래 한 줄로 모은 것과 같은 까닭).
|
|
106
106
|
0.176.0 까지는 머리 오른쪽에 한 줄로 섰다 — 사용자가 차트 카드의 그 꼴로 정했다(2026-09-17). */
|
|
107
|
-
const
|
|
107
|
+
const totalRow = (totalNum != null || totalLabel) ? (_jsxs(ChartTotal, { label: totalLabel, children: [totalNum != null && _jsx("span", { className: "barchart-total__num h0", children: totalNum }), totalUnit && _jsx("span", { className: "barchart-total__unit h5", children: totalUnit })] })) : null;
|
|
108
108
|
/* 머리 오른쪽에는 **고르개와 `headRight` 만** 남는다 — 고르개가 먼저, 상세보기 단추가 오른쪽 끝(사용자 지시 2026-09-17) */
|
|
109
|
-
const
|
|
110
|
-
const
|
|
111
|
-
const
|
|
112
|
-
return (_jsxs("section", { ref: secRef, className: ['manager-section', inGrid && 'manager-section--grid', onClick && 'manager-section--clickable', className].filter(Boolean).join(' '), onClick: onClick ?
|
|
113
|
-
left:
|
|
114
|
-
top:
|
|
115
|
-
right:
|
|
116
|
-
bottom:
|
|
117
|
-
width:
|
|
118
|
-
height:
|
|
119
|
-
}, text: tooltip })), headFloat && _jsx(SectionHeadFloat, { children: headFloat }), _jsxs("div", { className: "manager-section__head", children: [_jsxs("div", { className: "manager-section__title-row", children: [icon && _jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "manager-section__icon" }), _jsx(
|
|
109
|
+
const headRightNode = (rightNode || headSegment) ? (_jsxs("div", { className: "manager-section__head-right", children: [headSegment && (collapsed ? (_jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "ellipsis", ariaLabel: headSegment.ariaLabel ?? t('common.more', '더보기'), showTooltipInfo: headSegment.ariaLabel ?? t('common.more', '더보기'), "aria-haspopup": "menu", "aria-expanded": !!menuRect, onClick: openMenu })) : (_jsx(SegmentedControl, { size: 32, ariaLabel: headSegment.ariaLabel, options: headSegment.options.map(o => ({ key: o.key, label: o.label, disabled: o.disabled })), value: headSegment.value, onChange: headSegment.onChange }))), rightNode] })) : null;
|
|
110
|
+
const titleId = useId();
|
|
111
|
+
const TitleTag = titleLevel ? `h${titleLevel}` : 'div';
|
|
112
|
+
return (_jsxs("section", { ref: secRef, className: ['manager-section', inGrid && 'manager-section--grid', onClick && 'manager-section--clickable', className].filter(Boolean).join(' '), onClick: onClick ? onCardClick : undefined, onKeyDown: onClick ? onCardKeyDown : undefined, onPointerEnter: tooltip ? showTip : undefined, onPointerLeave: tooltip ? closeTip : undefined, onFocus: tooltip ? showTip : undefined, onBlur: tooltip ? closeTip : undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ? (ariaLabel ?? (typeof title === 'string' ? title : undefined)) : undefined, "aria-labelledby": !onClick && title != null ? titleId : undefined, children: [tooltip && tipRect && (_jsx(TooltipInfo, { anchorRect: {
|
|
113
|
+
left: tipRect.left,
|
|
114
|
+
top: tipRect.top + 21,
|
|
115
|
+
right: tipRect.right,
|
|
116
|
+
bottom: tipRect.bottom,
|
|
117
|
+
width: tipRect.width,
|
|
118
|
+
height: tipRect.height,
|
|
119
|
+
}, text: tooltip })), headFloat && _jsx(SectionHeadFloat, { children: headFloat }), _jsxs("div", { className: "manager-section__head", children: [_jsxs("div", { className: "manager-section__title-row", children: [icon && _jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "manager-section__icon" }), _jsx(TitleTag, { id: titleId, className: ['manager-section__title', titleClassName].filter(Boolean).join(' '), children: title })] }), headRightNode, disc && _jsx("div", { className: "manager-section__disc body-3", children: disc })] }), (totalRow || subRow) && (_jsxs("div", { className: ['barchart-sub-row', subRowAlign !== 'start' && `barchart-sub-row--${subRowAlign}`].filter(Boolean).join(' '), children: [totalRow, subRow] })), children, menuRect && (_jsx(Tooltip, { anchorRect: menuRect, onClose: closeMenu, ariaLabel: headSegment?.ariaLabel ?? t('tooltip.menu', '메뉴'), children: _jsx(MenuTooltip, { columns: [{ items: menuItems }], onClose: closeMenu, size: "small" }) }))] }));
|
|
120
120
|
}
|
|
@@ -6,7 +6,7 @@ import './CountTile.css';
|
|
|
6
6
|
import Icon from './Icon';
|
|
7
7
|
import TrainingTooltip, { TRAINING_TOOLTIP_HOVER_DELAY_MS } from './Tooltip/TrainingTooltip';
|
|
8
8
|
/** 타일과 툴팁 사이 틈(12px)을 건너는 동안 툴팁을 살려 두는 시간 */
|
|
9
|
-
const
|
|
9
|
+
const CROSS_GAP_MS = 200;
|
|
10
10
|
/**
|
|
11
11
|
* 72×72 정사각 타일. 좌상단에 아이콘, 우하단에 숫자를 배치한다.
|
|
12
12
|
* 훈련 카드의 타입별 문항 수 표시 등에 사용.
|
|
@@ -25,69 +25,69 @@ export default function CountTile({ icon, count, title, plan, tooltip = true, ho
|
|
|
25
25
|
* 반응하는 것처럼 안 보였다.** 호버 모양은 `--interactive` 가 맡고 `--clickable` 은 커서만
|
|
26
26
|
* 지킨다 — 누르는 타일과 얹는 타일이 **같은 호버**를 쓰고 커서만 갈린다.
|
|
27
27
|
*/
|
|
28
|
-
const
|
|
29
|
-
const cls = cx('count-tile',
|
|
28
|
+
const interactive = Boolean(onClick) || Boolean(onMouseEnter) || (tooltip && Boolean(plan));
|
|
29
|
+
const cls = cx('count-tile', interactive && 'count-tile--interactive', interactive && hoverAccent && 'count-tile--accent', onClick && 'count-tile--clickable', className);
|
|
30
30
|
const ref = useRef(null);
|
|
31
31
|
const [anchorRect, setAnchorRect] = useState(null);
|
|
32
|
-
const
|
|
33
|
-
const
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
if (
|
|
37
|
-
clearTimeout(
|
|
38
|
-
|
|
32
|
+
const openTimer = useRef(null);
|
|
33
|
+
const closeTimer = useRef(null);
|
|
34
|
+
const isOpen = Boolean(anchorRect && plan && tooltip);
|
|
35
|
+
const clearTimers = useCallback(() => {
|
|
36
|
+
if (openTimer.current) {
|
|
37
|
+
clearTimeout(openTimer.current);
|
|
38
|
+
openTimer.current = null;
|
|
39
39
|
}
|
|
40
|
-
if (
|
|
41
|
-
clearTimeout(
|
|
42
|
-
|
|
40
|
+
if (closeTimer.current) {
|
|
41
|
+
clearTimeout(closeTimer.current);
|
|
42
|
+
closeTimer.current = null;
|
|
43
43
|
}
|
|
44
44
|
}, []);
|
|
45
|
-
const
|
|
45
|
+
const close = useCallback(() => { clearTimers(); setAnchorRect(null); }, [clearTimers]);
|
|
46
46
|
/* 늦춰서 연다 — 타일이 열 개 붙어 서는 자리라, 지나가기만 해도 뜨면 줄줄이 번쩍인다. */
|
|
47
|
-
const
|
|
47
|
+
const open = useCallback(() => {
|
|
48
48
|
if (!tooltip || !plan)
|
|
49
49
|
return;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
clearTimers();
|
|
51
|
+
openTimer.current = setTimeout(() => {
|
|
52
|
+
openTimer.current = null;
|
|
53
53
|
setAnchorRect(ref.current?.getBoundingClientRect() ?? null);
|
|
54
54
|
}, TRAINING_TOOLTIP_HOVER_DELAY_MS);
|
|
55
|
-
}, [tooltip, plan,
|
|
55
|
+
}, [tooltip, plan, clearTimers]);
|
|
56
56
|
/**
|
|
57
57
|
* **바로 닫지 않는다.** 툴팁은 타일에서 12px 떨어져 뜨므로, 타일을 벗어나자마자 닫으면
|
|
58
58
|
* 그 틈을 건너는 동안 사라져 **툴팁 안의 갈래 아이콘에 마우스를 올릴 수가 없다** — 그
|
|
59
59
|
* 아이콘에 다시 뜨는 작은 이름표가 이 툴팁을 다는 까닭의 절반이다(사용자 지시 2026-09-23).
|
|
60
60
|
* 잠깐 두고, 그 사이 툴팁 안으로 들어오면 아래 지킴이가 예약을 무른다.
|
|
61
61
|
*/
|
|
62
|
-
const
|
|
63
|
-
if (
|
|
64
|
-
clearTimeout(
|
|
65
|
-
|
|
62
|
+
const scheduleClose = useCallback(() => {
|
|
63
|
+
if (openTimer.current) {
|
|
64
|
+
clearTimeout(openTimer.current);
|
|
65
|
+
openTimer.current = null;
|
|
66
66
|
}
|
|
67
|
-
if (
|
|
67
|
+
if (closeTimer.current)
|
|
68
68
|
return;
|
|
69
|
-
|
|
69
|
+
closeTimer.current = setTimeout(() => { closeTimer.current = null; setAnchorRect(null); }, CROSS_GAP_MS);
|
|
70
70
|
}, []);
|
|
71
71
|
/* 떠난 뒤에 타이머가 터지면 아무도 안 보는 자리에 툴팁이 남는다 */
|
|
72
|
-
useEffect(() => () => {
|
|
72
|
+
useEffect(() => () => { clearTimers(); }, [clearTimers]);
|
|
73
73
|
useEffect(() => { if (!tooltip || !plan)
|
|
74
|
-
|
|
74
|
+
close(); }, [tooltip, plan, close]);
|
|
75
75
|
/* 툴팁이 떠 있는 동안 **포인터가 어디에 있는지** 본다 — 타일이나 툴팁 안이면 살려 두고,
|
|
76
76
|
둘 다 아니면 닫기를 예약한다. 툴팁은 포털로 body 끝에 나가 타일의 자식이 아니라서,
|
|
77
77
|
타일에 건 mouseleave 만으로는 "툴팁으로 건너갔다" 를 알 수 없다. */
|
|
78
78
|
useEffect(() => {
|
|
79
|
-
if (
|
|
79
|
+
if (!isOpen)
|
|
80
80
|
return;
|
|
81
|
-
const
|
|
82
|
-
const
|
|
83
|
-
|
|
81
|
+
const isKeepAlive = (t) => t instanceof Element && (ref.current?.contains(t) || Boolean(t.closest('.tooltip-training')));
|
|
82
|
+
const onPointerOut = (e) => { if (isKeepAlive(e.target)) {
|
|
83
|
+
clearTimers();
|
|
84
84
|
}
|
|
85
85
|
else
|
|
86
|
-
|
|
87
|
-
document.addEventListener('pointerover',
|
|
88
|
-
return () => document.removeEventListener('pointerover',
|
|
89
|
-
}, [
|
|
90
|
-
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: ref, className: cls, onClick: onClick ? (e) => {
|
|
86
|
+
scheduleClose(); };
|
|
87
|
+
document.addEventListener('pointerover', onPointerOut, true);
|
|
88
|
+
return () => document.removeEventListener('pointerover', onPointerOut, true);
|
|
89
|
+
}, [isOpen, clearTimers, scheduleClose]);
|
|
90
|
+
return (_jsxs(_Fragment, { children: [_jsxs("div", { ref: ref, className: cls, onClick: onClick ? (e) => { close(); onClick(e); } : undefined,
|
|
91
91
|
/* role="button" + tabIndex 만 있고 키 처리가 없으면, 키보드 사용자는 여기 멈추고도
|
|
92
92
|
누를 수가 없다 — `<div>` 는 브라우저가 Enter·Space 를 대신 처리해 주지 않는다.
|
|
93
93
|
Space 의 기본 스크롤도 막는다(2026-08-28, Badge·Accordion 과 같은 처리). */
|
|
@@ -107,7 +107,7 @@ export default function CountTile({ icon, count, title, plan, tooltip = true, ho
|
|
|
107
107
|
(pointerdown 에서 닫는 것으로는 못 막는다 — 그 이벤트가 mouseenter 보다 **먼저** 와서,
|
|
108
108
|
닫은 뒤에 다시 열린다.) */
|
|
109
109
|
onPointerEnter: (e) => { if (e.pointerType === 'touch' || e.pointerType === 'pen')
|
|
110
|
-
|
|
110
|
+
close();
|
|
111
111
|
else
|
|
112
|
-
|
|
112
|
+
open(); }, onMouseEnter: onMouseEnter, onMouseLeave: (e) => { scheduleClose(); onMouseLeave?.(e); }, role: onClick ? 'button' : 'img', tabIndex: onClick ? 0 : undefined, "aria-label": `${title} ${count}`, children: [_jsx(Icon, { icon: icon, size: 20, className: "count-tile__icon" }), _jsx("span", { className: "count-tile__count h5", "aria-hidden": "true", children: count })] }), anchorRect && plan && _jsx(TrainingTooltip, { plan: plan, anchorRect: anchorRect, onClose: close })] }));
|
|
113
113
|
}
|