@ihabdevteam/core 0.321.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 +617 -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 +108 -66
- 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/HearingDeviceInfo.d.ts +5 -5
- package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
- 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.d.ts +2 -4
- package/dist/packages/core/src/components/PanelRow.js +8 -15
- 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/DeviceListPopup.d.ts +4 -8
- package/dist/packages/core/src/components/Popup/DeviceListPopup.js +1 -1
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +19 -26
- package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +10 -10
- 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.d.ts +7 -4
- 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 +12 -12
- 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 +83 -83
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +17 -17
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +128 -128
- 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 +13 -13
- 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.d.ts +1 -1
- package/dist/packages/core/src/components/Training/oneShotSound.js +14 -14
- package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +2 -2
- package/dist/packages/core/src/components/Training/trainingMissStats.js +22 -22
- 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 +18 -18
- 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 +17 -17
- package/dist/packages/core/src/components/Word/wordTablePrint.js +10 -10
- package/dist/packages/core/src/components/Word/wordTableRecommend.js +13 -13
- 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 +22 -7
- 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.d.ts +1 -1
- package/dist/packages/core/src/hooks/inputModality.js +14 -14
- 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/useDeviceManagement.d.ts +5 -17
- package/dist/packages/core/src/hooks/useDeviceManagement.js +20 -8
- 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.d.ts +3 -3
- package/dist/packages/core/src/hooks/usePageResetOnChange.js +6 -6
- 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 +7 -7
- package/dist/packages/core/src/hooks/useTablePaging.js +40 -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.d.ts +3 -1
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +14 -12
- 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.d.ts +2 -2
- package/dist/packages/core/src/utils/devWarn.js +7 -7
- 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.d.ts +1 -1
- package/dist/packages/core/src/utils/filterPanelSummary.js +16 -16
- 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.d.ts +2 -2
- package/dist/packages/core/src/utils/navScroll.js +6 -6
- 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/pressable.d.ts +1 -1
- package/dist/packages/core/src/utils/pressable.js +3 -3
- 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/sessionTab.d.ts +7 -7
- package/dist/packages/core/src/utils/sessionTab.js +9 -9
- package/dist/packages/core/src/utils/speak.js +12 -12
- package/dist/packages/core/src/utils/textSort.d.ts +5 -5
- package/dist/packages/core/src/utils/textSort.js +47 -47
- 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.d.ts +2 -2
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +48 -48
- 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
|
@@ -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
|
}
|
|
@@ -25,30 +25,30 @@ import './SectionHeadFloat.css';
|
|
|
25
25
|
*/
|
|
26
26
|
export default function SectionHeadFloat({ children, className }) {
|
|
27
27
|
const ref = useRef(null);
|
|
28
|
-
const [
|
|
29
|
-
useEffect(() => {
|
|
28
|
+
const [parent, setParent] = useState(null);
|
|
29
|
+
useEffect(() => { setParent(ref.current?.parentElement ?? null); }, []);
|
|
30
30
|
useEffect(() => {
|
|
31
31
|
const el = ref.current;
|
|
32
|
-
if (!el ||
|
|
32
|
+
if (!el || !parent)
|
|
33
33
|
return;
|
|
34
34
|
/* 흐름으로 돌아간 좁은 화면에서는 비울 자리가 없다 — 그때 폭을 적어 두면 제목이 공연히 좁아진다.
|
|
35
35
|
`position` 을 보고 가른다(문턱을 두 군데 적지 않으려고 — CSS 가 정본이다). */
|
|
36
|
-
const
|
|
37
|
-
const
|
|
38
|
-
const w =
|
|
36
|
+
const apply = () => {
|
|
37
|
+
const isFloating = typeof window !== 'undefined' && window.getComputedStyle(el).position === 'absolute';
|
|
38
|
+
const w = isFloating ? Math.ceil(el.getBoundingClientRect().width) : 0;
|
|
39
39
|
if (w > 0)
|
|
40
|
-
|
|
40
|
+
parent.style.setProperty('--section-head-float-w', `${w}px`);
|
|
41
41
|
else
|
|
42
|
-
|
|
42
|
+
parent.style.removeProperty('--section-head-float-w');
|
|
43
43
|
};
|
|
44
|
-
|
|
44
|
+
apply();
|
|
45
45
|
if (typeof ResizeObserver === 'undefined')
|
|
46
|
-
return () => {
|
|
46
|
+
return () => { parent.style.removeProperty('--section-head-float-w'); };
|
|
47
47
|
/* 줄 자신과 카드를 함께 본다 — 단추 글자가 바뀌면 줄이, 창이 좁아지면 카드가 먼저 움직인다 */
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return () => {
|
|
52
|
-
}, [
|
|
48
|
+
const observer = new ResizeObserver(() => apply());
|
|
49
|
+
observer.observe(el);
|
|
50
|
+
observer.observe(parent);
|
|
51
|
+
return () => { observer.disconnect(); parent.style.removeProperty('--section-head-float-w'); };
|
|
52
|
+
}, [parent, children]);
|
|
53
53
|
return _jsx("div", { ref: ref, className: cx('section-head-float', className), children: children });
|
|
54
54
|
}
|
|
@@ -18,7 +18,7 @@ import { devWarnOnce, isDev } from '../utils/devWarn';
|
|
|
18
18
|
* 멀쩡하다) 콘솔이 울기만 하고, 정작 부딪힌 자리가 묻힌다. 둘 이상 **함께 서 있을 때만**
|
|
19
19
|
* 알린다. 열쇠에 보기 이름을 넣어, 서로 다른 부딪힘은 각각 한 번씩 알린다.
|
|
20
20
|
*/
|
|
21
|
-
const
|
|
21
|
+
const unlabeledGroups = new Map();
|
|
22
22
|
/** @public */
|
|
23
23
|
export default function SegmentedControl(props) {
|
|
24
24
|
const { t } = useTranslation();
|
|
@@ -31,69 +31,69 @@ export default function SegmentedControl(props) {
|
|
|
31
31
|
아무 일도 안 일어난다. 실측으로 칸 수만큼 Tab 자리였고 ArrowRight 는 죽어 있었다.
|
|
32
32
|
라디오에서는 **고름이 포커스를 따라간다** — 화살표로 옮기면 그 자리가 곧 선택이다.
|
|
33
33
|
못 고르는 칸(disabled)은 건너뛰고, 끝에서는 돈다. */
|
|
34
|
-
const
|
|
35
|
-
const
|
|
36
|
-
const
|
|
34
|
+
const groupRef = useRef(null);
|
|
35
|
+
const selectable = options.filter((o) => !o.disabled);
|
|
36
|
+
const uid = useId();
|
|
37
37
|
/* 보기 이름을 이어 **이 고르개가 무엇인지** 적는다 — 콘솔만 보고 찾아갈 수 있게.
|
|
38
38
|
원시 글자라 아래 useEffect 가 다시 그릴 때마다 돌지 않는다(배열을 그냥 넣으면 돈다). */
|
|
39
|
-
const
|
|
39
|
+
const signature = options.map((o) => o.key).join('·');
|
|
40
40
|
useEffect(() => {
|
|
41
41
|
if (!isDev || (ariaLabel != null && ariaLabel !== ''))
|
|
42
42
|
return;
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
const
|
|
46
|
-
devWarnOnce(`segmented-control-unnamed:${
|
|
43
|
+
unlabeledGroups.set(uid, signature || '(보기 없음)');
|
|
44
|
+
if (unlabeledGroups.size > 1) {
|
|
45
|
+
const all = [...unlabeledGroups.values()].sort();
|
|
46
|
+
devWarnOnce(`segmented-control-unnamed:${all.join('|')}`, `[ihab/SegmentedControl] 이름 없는 라디오그룹이 한 화면에 ${unlabeledGroups.size} 개 서 있습니다 — ` +
|
|
47
47
|
`낭독기는 모두 "선택 탭" 으로 읽어 어느 것이 무엇인지 가릴 수 없습니다. ariaLabel 을 주세요. ` +
|
|
48
|
-
`지금 선 것(보기 이름으로 적음): ${
|
|
48
|
+
`지금 선 것(보기 이름으로 적음): ${all.join(' / ')}`);
|
|
49
49
|
}
|
|
50
|
-
return () => {
|
|
51
|
-
}, [ariaLabel,
|
|
52
|
-
const
|
|
53
|
-
if (
|
|
50
|
+
return () => { unlabeledGroups.delete(uid); };
|
|
51
|
+
}, [ariaLabel, signature, uid]);
|
|
52
|
+
const moveBy = (step) => {
|
|
53
|
+
if (!selectable.length)
|
|
54
54
|
return;
|
|
55
|
-
const
|
|
56
|
-
const
|
|
57
|
-
:
|
|
58
|
-
:
|
|
59
|
-
if (
|
|
55
|
+
const current = Math.max(0, selectable.findIndex((o) => o.key === value));
|
|
56
|
+
const next = step === 'first' ? selectable[0]
|
|
57
|
+
: step === 'last' ? selectable[selectable.length - 1]
|
|
58
|
+
: selectable[(current + step + selectable.length) % selectable.length];
|
|
59
|
+
if (!next)
|
|
60
60
|
return;
|
|
61
61
|
/* 이미 그 자리여도 포커스는 옮긴다 — 칸이 하나뿐일 때 멈춘 것처럼 보이지 않게 */
|
|
62
|
-
onChange(
|
|
63
|
-
const
|
|
64
|
-
.find((el) => el.dataset.segkey ===
|
|
65
|
-
|
|
62
|
+
onChange(next.key);
|
|
63
|
+
const buttons = Array.from(groupRef.current?.querySelectorAll('[data-segkey]') ?? [])
|
|
64
|
+
.find((el) => el.dataset.segkey === next.key);
|
|
65
|
+
buttons?.focus();
|
|
66
66
|
};
|
|
67
|
-
const
|
|
67
|
+
const onKeyDown = (e) => {
|
|
68
68
|
switch (e.key) {
|
|
69
69
|
case 'ArrowRight':
|
|
70
70
|
case 'ArrowDown':
|
|
71
|
-
|
|
71
|
+
moveBy(1);
|
|
72
72
|
break;
|
|
73
73
|
case 'ArrowLeft':
|
|
74
74
|
case 'ArrowUp':
|
|
75
|
-
|
|
75
|
+
moveBy(-1);
|
|
76
76
|
break;
|
|
77
77
|
case 'Home':
|
|
78
|
-
|
|
78
|
+
moveBy('first');
|
|
79
79
|
break;
|
|
80
80
|
case 'End':
|
|
81
|
-
|
|
81
|
+
moveBy('last');
|
|
82
82
|
break;
|
|
83
83
|
default: return;
|
|
84
84
|
}
|
|
85
85
|
e.preventDefault();
|
|
86
86
|
};
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
if (
|
|
87
|
+
const labelOf = (opt) => {
|
|
88
|
+
const isText = (v) => typeof v === 'string' || typeof v === 'number';
|
|
89
|
+
if (!isText(opt.label) || opt.count == null || opt.count === false || !isText(opt.count))
|
|
90
90
|
return undefined;
|
|
91
91
|
return `${opt.label}, ${opt.count}`;
|
|
92
92
|
};
|
|
93
93
|
/* Tab 자리를 받을 한 칸 — 고른 것이 있으면 그것, 없으면 고를 수 있는 첫 칸 */
|
|
94
|
-
const
|
|
95
|
-
return (_jsx("div", { ref:
|
|
94
|
+
const tabKey = selectable.some((o) => o.key === value) ? value : selectable[0]?.key;
|
|
95
|
+
return (_jsx("div", { ref: groupRef, onKeyDown: onKeyDown, className: cx('segmented-control', `segmented-control--${shape}`, `segmented-control--${size}`, fullWidth && 'segmented-control--full', !useBG && 'segmented-control--no-bg', minButtonWidth != null && 'segmented-control--min', className), style: minButtonWidth != null ? { ['--segmented-min-button-w']: `${minButtonWidth}px` } : undefined, role: "radiogroup", "aria-label": ariaLabel ?? t('segmentedControl.ariaLabel', '선택 탭'), children: options.map((opt) => (_jsx(Button, { htmlType: "button", role: "radio", "aria-checked": opt.key === value, "data-segkey": opt.key, tabIndex: opt.key === tabKey ? 0 : -1, label: opt.label, count: opt.count,
|
|
96
96
|
/* 수를 이름에 넣는다 — 글자 이름표는 단추의 aria-label 이 되어 내용(수)이 이름 계산에서 빠졌다("전체" 로만 읽힘) */
|
|
97
|
-
ariaLabel:
|
|
97
|
+
ariaLabel: labelOf(opt), disabled: opt.disabled, className: opt.key === value ? 'is-selected' : '', onClick: () => { if (!opt.disabled)
|
|
98
98
|
onChange(opt.key); }, size: size, shape: shape, useBorder: false, useBG: false }, opt.key))) }));
|
|
99
99
|
}
|
|
@@ -22,9 +22,9 @@ function SideNavFooter(props) {
|
|
|
22
22
|
넷 서 있었다 — 눌러도 아무 일이 없으니 사람은 고장으로 읽고, 낭독기에는
|
|
23
23
|
'알림 단추' 라고 읽히는데 실제로는 아무 데도 못 간다.
|
|
24
24
|
**없는 단추가 죽은 단추보다 낫다.** */
|
|
25
|
-
const
|
|
25
|
+
const hasAny = onDebug || onNotification || onSetting || onHelp || onFeedback || onAccount;
|
|
26
26
|
// 하나도 없으면 바닥 자체를 비운다 — 빈 채로 두면 여백(margin-top + padding)만 남는다.
|
|
27
|
-
if (
|
|
27
|
+
if (!hasAny)
|
|
28
28
|
return null;
|
|
29
29
|
return (_jsxs("div", { className: "side-nav__footer", children: [onDebug && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "terminal", ariaLabel: t('nav.debug', '디버그'), onClick: onDebug, selected: activeKey === 'debug' })), onNotification && (_jsxs("span", { className: "side-nav__notification-wrap", children: [_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "bell", ariaLabel: nameWithCount(t, t('nav.notifications', '알림'), unreadCount), onClick: onNotification, selected: activeKey === 'notifications' }), !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true }))] })), onSetting && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "settings", ariaLabel: t('nav.settings', '설정'), onClick: onSetting, selected: activeKey === 'settings' })), onHelp && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "message-circle-question-mark", ariaLabel: t('nav.help', '도움말'), onClick: onHelp, selected: activeKey === 'help' })), onFeedback && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "bug", ariaLabel: t('nav.feedback', '버그신고'), onClick: onFeedback, selected: activeKey === 'feedback' })), onAccount && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "user-round", ariaLabel: t('nav.account', '계정'), onClick: onAccount, selected: activeKey === 'account' }))] }));
|
|
30
30
|
}
|
|
@@ -65,76 +65,76 @@ export default function SideNav(props) {
|
|
|
65
65
|
const handleModeSwitch = useCallback(() => onAppModeChange?.(isManager ? 'malgwi' : 'manager'), [onAppModeChange, isManager]);
|
|
66
66
|
/* **떠 있는 막대의 자리와 길이** — 구르는 통(이 `<nav>`)을 재어 셈한다. 네이티브 막대는
|
|
67
67
|
폭을 정할 수 없고 자리를 먹어 항목을 밀어내므로(2026-09-25 실측: 오른쪽만 23px) 직접 그린다. */
|
|
68
|
-
const
|
|
69
|
-
const [
|
|
68
|
+
const containerRef = useRef(null);
|
|
69
|
+
const [thumb, setThumb] = useState(null);
|
|
70
70
|
useEffect(() => {
|
|
71
|
-
const el =
|
|
71
|
+
const el = containerRef.current;
|
|
72
72
|
if (!el)
|
|
73
73
|
return;
|
|
74
74
|
/* **굴림 한 번에 한 번만 센다**(말귀 요청 (115), 2026-10-10). 전에는 `scroll` 마다 바로 `setState` 라
|
|
75
75
|
굴리는 동안 틀마다 여러 번 다시 그렸다. 틀에 한 번으로 모으고(rAF), **값이 그대로면 상태를 안
|
|
76
76
|
건드린다** — 같은 수를 새 객체로 넘기면 React 는 바뀐 줄 안다. */
|
|
77
|
-
let
|
|
78
|
-
const
|
|
77
|
+
let rafId = null;
|
|
78
|
+
const compute = () => {
|
|
79
79
|
const { scrollHeight, clientHeight, scrollTop } = el;
|
|
80
80
|
/* 넘치지 않으면 그리지 않는다 — 없는 스크롤을 알릴 까닭이 없다 */
|
|
81
81
|
if (scrollHeight <= clientHeight + 1) {
|
|
82
|
-
|
|
82
|
+
setThumb(prev => (prev === null ? prev : null));
|
|
83
83
|
return;
|
|
84
84
|
}
|
|
85
|
-
const
|
|
86
|
-
const
|
|
87
|
-
const
|
|
88
|
-
const
|
|
89
|
-
|
|
85
|
+
const thumbHeight = Math.max(24, (clientHeight / scrollHeight) * clientHeight);
|
|
86
|
+
const trackRemain = clientHeight - thumbHeight;
|
|
87
|
+
const scrollRemain = scrollHeight - clientHeight;
|
|
88
|
+
const next = { top: Math.round((scrollTop / scrollRemain) * trackRemain), height: Math.round(thumbHeight) };
|
|
89
|
+
setThumb(prev => (prev && prev.top === next.top && prev.height === next.height ? prev : next));
|
|
90
90
|
};
|
|
91
|
-
const
|
|
91
|
+
const measure = () => {
|
|
92
92
|
if (typeof requestAnimationFrame === 'undefined') {
|
|
93
|
-
|
|
93
|
+
compute();
|
|
94
94
|
return;
|
|
95
95
|
}
|
|
96
|
-
if (
|
|
96
|
+
if (rafId !== null)
|
|
97
97
|
return;
|
|
98
|
-
|
|
98
|
+
rafId = requestAnimationFrame(() => { rafId = null; compute(); });
|
|
99
99
|
};
|
|
100
|
-
|
|
101
|
-
el.addEventListener('scroll',
|
|
100
|
+
compute();
|
|
101
|
+
el.addEventListener('scroll', measure, { passive: true });
|
|
102
102
|
/* 항목이 늘거나 창이 바뀌면 길이도 달라진다 */
|
|
103
|
-
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(
|
|
103
|
+
const ro = typeof ResizeObserver !== 'undefined' ? new ResizeObserver(measure) : null;
|
|
104
104
|
ro?.observe(el);
|
|
105
105
|
for (const c of Array.from(el.children))
|
|
106
106
|
ro?.observe(c);
|
|
107
107
|
return () => {
|
|
108
|
-
if (
|
|
109
|
-
cancelAnimationFrame(
|
|
110
|
-
el.removeEventListener('scroll',
|
|
108
|
+
if (rafId !== null)
|
|
109
|
+
cancelAnimationFrame(rafId);
|
|
110
|
+
el.removeEventListener('scroll', measure);
|
|
111
111
|
ro?.disconnect();
|
|
112
112
|
};
|
|
113
113
|
}, [items]);
|
|
114
114
|
/* **막대를 잡아 끌 수 있다.** 손에 잡히게 그려 놓고 안 잡히면 그 꼴이 거짓말이 된다.
|
|
115
115
|
통의 한 픽셀은 막대의 `scrollHeight / clientHeight` 배다 — 그 비로 옮긴다. */
|
|
116
|
-
const
|
|
117
|
-
const
|
|
118
|
-
const el =
|
|
116
|
+
const dragRef = useRef(null);
|
|
117
|
+
const onThumbDown = useCallback((e) => {
|
|
118
|
+
const el = containerRef.current;
|
|
119
119
|
if (!el)
|
|
120
120
|
return;
|
|
121
121
|
e.preventDefault();
|
|
122
|
-
|
|
122
|
+
dragRef.current = { y: e.clientY, top: el.scrollTop };
|
|
123
123
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
124
124
|
}, []);
|
|
125
|
-
const
|
|
126
|
-
const el =
|
|
127
|
-
const
|
|
128
|
-
if (!el ||
|
|
125
|
+
const onThumbMove = useCallback((e) => {
|
|
126
|
+
const el = containerRef.current;
|
|
127
|
+
const held = dragRef.current;
|
|
128
|
+
if (!el || !held)
|
|
129
129
|
return;
|
|
130
|
-
el.scrollTop =
|
|
130
|
+
el.scrollTop = held.top + (e.clientY - held.y) * (el.scrollHeight / el.clientHeight);
|
|
131
131
|
}, []);
|
|
132
|
-
const
|
|
133
|
-
|
|
132
|
+
const onThumbUp = useCallback((e) => {
|
|
133
|
+
dragRef.current = null;
|
|
134
134
|
if (e.currentTarget.hasPointerCapture(e.pointerId))
|
|
135
135
|
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
136
136
|
}, []);
|
|
137
|
-
return (_jsxs("nav", { ref:
|
|
137
|
+
return (_jsxs("nav", { ref: containerRef, "aria-label": t('nav.mainMenu', '메인 메뉴'), className: "side-nav__menu", children: [thumb && (_jsx("div", { className: "side-nav__scroll", "aria-hidden": "true", children: _jsx("div", { className: "side-nav__scroll-thumb", style: { top: thumb.top, height: thumb.height }, onPointerDown: onThumbDown, onPointerMove: onThumbMove, onPointerUp: onThumbUp, onPointerCancel: onThumbUp }) })), _jsxs("div", { className: "side-nav__menu-list", children: [(items || [])
|
|
138
138
|
.filter((it) => !it.section && (it.key || it.label))
|
|
139
139
|
.map((it, idx) => (_jsxs(React.Fragment, { children: [it.divider && _jsx("div", { className: "side-nav__separator" }), _jsx(SideNavButton, { item: it, active: !!it.key && it.key === activeKey, state: it.disabled ? 'disabled' : undefined, onClick: (_key, btnEl, fromPointer) => {
|
|
140
140
|
if (it.disabled)
|
|
@@ -174,8 +174,8 @@ export default function SideNav(props) {
|
|
|
174
174
|
const supportsMatches = typeof headerEl.matches === 'function';
|
|
175
175
|
/* :hover 는 **호버가 되는 기기에서만** 믿는다 — iPad 는 톡한 요소에 :hover 가 붙어 남아, "마우스가 레일 위에 있다" 로
|
|
176
176
|
읽혀 강제 접힘을 건너뛰었다(메뉴를 골라도 레일이 펼친 채 본문을 가림) */
|
|
177
|
-
const
|
|
178
|
-
const isHovered =
|
|
177
|
+
const hasHover = window.matchMedia('(hover: hover)').matches;
|
|
178
|
+
const isHovered = hasHover && supportsMatches ? headerEl.matches(':hover') : false;
|
|
179
179
|
if (!isHovered) {
|
|
180
180
|
headerEl.classList.add('header--force-collapsed');
|
|
181
181
|
const onEnter = () => {
|
|
@@ -87,7 +87,7 @@ export default function Slider({ value, onChange, min = 0, max = 100, step = 1,
|
|
|
87
87
|
}
|
|
88
88
|
/* 낭독기가 들을 값 글자 — 숫자만 넘기면 "1.25x"·"위"·"3 / 7" 이 "1.25"·"0"·"3" 으로 들렸다. 보이는 라벨과 같은 글자,
|
|
89
89
|
라벨이 없으면 끝값의 minLabel·maxLabel. 둘 다 아니면 숫자 그대로(undefined). */
|
|
90
|
-
const
|
|
90
|
+
const valueFormatter = formatValue
|
|
91
91
|
? formatValue(clamped)
|
|
92
92
|
: label === 'percent' ? `${Math.round(max > min ? ((clamped - min) / (max - min)) * 100 : 0)}%`
|
|
93
93
|
: label === 'count' ? `${clamped} / ${max}`
|
|
@@ -95,7 +95,7 @@ export default function Slider({ value, onChange, min = 0, max = 100, step = 1,
|
|
|
95
95
|
: clamped === max && maxLabel ? maxLabel
|
|
96
96
|
: undefined;
|
|
97
97
|
// 루트가 아닐 때(label/showMinMax로 감싸질 때)는 className을 바깥 wrapper가 대신 가져간다
|
|
98
|
-
const input = (_jsx("input", { type: "range", className: cx('slider', `slider--${orientation}`, isStatic && 'slider--static', !label && !showMinMax && className), min: min, max: max, step: step, value: clamped, disabled: disabled, readOnly: isStatic, tabIndex: isStatic ? -1 : undefined, "aria-label": ariaLabel, "aria-valuetext":
|
|
98
|
+
const input = (_jsx("input", { type: "range", className: cx('slider', `slider--${orientation}`, isStatic && 'slider--static', !label && !showMinMax && className), min: min, max: max, step: step, value: clamped, disabled: disabled, readOnly: isStatic, tabIndex: isStatic ? -1 : undefined, "aria-label": ariaLabel, "aria-valuetext": valueFormatter, "aria-orientation": orientation, onChange: handleChange, style: style }));
|
|
99
99
|
if (!label && !showMinMax)
|
|
100
100
|
return input;
|
|
101
101
|
// 트랙 양 끝 최소·최대값 텍스트 — 세로는 채움 방향(아래→위)에 맞춰 위=max/아래=min 순서로 배치
|
|
@@ -16,7 +16,7 @@ import './SoundCompass.css';
|
|
|
16
16
|
const lerp = (a, b, t) => a + (b - a) * t;
|
|
17
17
|
/** @public */
|
|
18
18
|
export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSpeakerId = null, playedIds, selectedId = null, answerId = null, showFeedback = false, pulseSelected = false, pulseSelectedDuration, pulseScale, clickable = false, onSelectSpeaker, buttonSize = 40, showIcon = true, onCenterClick, centerLabel, centerDisabled = false, centerAttention = false, shortcutAnchor = 'top-right', shortcutScale, scaleShortcut = false, }) {
|
|
19
|
-
const { t:
|
|
19
|
+
const { t: translate } = useTranslation();
|
|
20
20
|
const t = Math.max(0, Math.min(1, viewT));
|
|
21
21
|
const CENTER = size / 2;
|
|
22
22
|
// ── 탑뷰 좌표 ──────────────────────────────────────────────────────────
|
|
@@ -123,10 +123,10 @@ export default function SoundCompass({ speakers, size = 300, viewT = 0, activeSp
|
|
|
123
123
|
const played = useMemo(() => new Set(playedIds ?? []), [playedIds]);
|
|
124
124
|
const stateLabel = (state) => ({
|
|
125
125
|
idle: '',
|
|
126
|
-
active:
|
|
127
|
-
played:
|
|
128
|
-
correct:
|
|
129
|
-
wrong:
|
|
126
|
+
active: translate('soundCompass.state.active', '소리 나는 중'),
|
|
127
|
+
played: translate('soundCompass.state.played', '들음'),
|
|
128
|
+
correct: translate('soundCompass.state.correct', '정답'),
|
|
129
|
+
wrong: translate('soundCompass.state.wrong', '오답'),
|
|
130
130
|
})[state];
|
|
131
131
|
const getSpeakerState = (sp) => {
|
|
132
132
|
if (activeSpeakerId === sp.id)
|