@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
|
@@ -26,13 +26,13 @@ export default function Tabs({ items = [], value, onChange, className = '', size
|
|
|
26
26
|
const listRef = useRef(null);
|
|
27
27
|
/* value 가 없거나 items 에 없으면 **켜진 첫 탭**을 고른 것으로 본다. 전에는 무조건 첫 탭이라, 첫 탭이 꺼져 있으면
|
|
28
28
|
꺼진 탭의 판이 보이고 Tab 자리(tabIndex 0)가 꺼진 탭 하나뿐이어서 키보드로 탭 목록에 닿을 수 없었다. */
|
|
29
|
-
const
|
|
30
|
-
const activeKey = items.some(item => item.key === value) ? value : (
|
|
29
|
+
const firstEnabledKey = items.find(item => !item.disabled)?.key;
|
|
30
|
+
const activeKey = items.some(item => item.key === value) ? value : (firstEnabledKey ?? items[0]?.key);
|
|
31
31
|
/* Tab 자리 — 고른 탭이 꺼져 있으면(부모가 꺼진 탭을 value 로 줌) 켜진 첫 탭이 받는다 */
|
|
32
|
-
const
|
|
32
|
+
const tabStopKey = items.find(item => item.key === activeKey && !item.disabled)?.key ?? firstEnabledKey;
|
|
33
33
|
/** 지금 화면에 선 **켜진 탭들** — 옮기는 자리는 전부 이 목록 기준이다 */
|
|
34
|
-
const
|
|
35
|
-
const focusTabAt = (index, tabs =
|
|
34
|
+
const enabledTabs = () => Array.from(listRef.current?.querySelectorAll('[role="tab"]:not(:disabled)') ?? []);
|
|
35
|
+
const focusTabAt = (index, tabs = enabledTabs()) => {
|
|
36
36
|
if (tabs.length === 0)
|
|
37
37
|
return;
|
|
38
38
|
const clamped = ((index % tabs.length) + tabs.length) % tabs.length;
|
|
@@ -47,23 +47,23 @@ export default function Tabs({ items = [], value, onChange, className = '', size
|
|
|
47
47
|
* 지금 눌린 탭이 **켜진 것들 안에서 몇 번째인지**를 세어 거기서 옮긴다.
|
|
48
48
|
*/
|
|
49
49
|
const handleKeyDown = (e) => {
|
|
50
|
-
const
|
|
51
|
-
if (
|
|
50
|
+
const MOVE_KEYS = ['ArrowRight', 'ArrowDown', 'ArrowLeft', 'ArrowUp', 'Home', 'End'];
|
|
51
|
+
if (!MOVE_KEYS.includes(e.key))
|
|
52
52
|
return;
|
|
53
53
|
e.preventDefault();
|
|
54
|
-
const tabs =
|
|
55
|
-
const
|
|
54
|
+
const tabs = enabledTabs();
|
|
55
|
+
const current = tabs.indexOf(e.currentTarget);
|
|
56
56
|
if (e.key === 'ArrowRight' || e.key === 'ArrowDown')
|
|
57
|
-
focusTabAt(
|
|
57
|
+
focusTabAt(current + 1, tabs);
|
|
58
58
|
else if (e.key === 'ArrowLeft' || e.key === 'ArrowUp')
|
|
59
|
-
focusTabAt(
|
|
59
|
+
focusTabAt(current - 1, tabs);
|
|
60
60
|
else if (e.key === 'Home')
|
|
61
61
|
focusTabAt(0, tabs);
|
|
62
62
|
else
|
|
63
63
|
focusTabAt(tabs.length - 1, tabs);
|
|
64
64
|
};
|
|
65
65
|
const activeItem = items.find(item => item.key === activeKey);
|
|
66
|
-
return (_jsxs("div", { className: `tabs ${className}`.trim(), children: [_jsx("div", { ref: listRef, className: `tabs__list tabs__list--${size}`, role: "tablist", "aria-label": t('tabs.ariaLabel', '탭'), children: items.map(item => (_jsx("button", { id: `tabs__tab-${uid}-${item.key}`, type: "button", role: "tab", "aria-selected": item.key === activeKey, "aria-controls": `tabs__panel-${uid}-${item.key}`, disabled: item.disabled, tabIndex: item.key ===
|
|
66
|
+
return (_jsxs("div", { className: `tabs ${className}`.trim(), children: [_jsx("div", { ref: listRef, className: `tabs__list tabs__list--${size}`, role: "tablist", "aria-label": t('tabs.ariaLabel', '탭'), children: items.map(item => (_jsx("button", { id: `tabs__tab-${uid}-${item.key}`, type: "button", role: "tab", "aria-selected": item.key === activeKey, "aria-controls": `tabs__panel-${uid}-${item.key}`, disabled: item.disabled, tabIndex: item.key === tabStopKey ? 0 : -1, className: `tabs__tab body-2${item.key === activeKey ? ' tabs__tab--active' : ''}`,
|
|
67
67
|
/* 값이 바뀔 때만 onChange(2026-09-15 결정) — 지금 탭을 다시 누르면 아무 일도 없다 */
|
|
68
68
|
onClick: () => { if (item.key !== value)
|
|
69
69
|
onChange?.(item.key); }, onKeyDown: handleKeyDown, children: item.label }, item.key))) }), activeItem && (_jsx("div", { id: `tabs__panel-${uid}-${activeItem.key}`, role: "tabpanel", "aria-labelledby": `tabs__tab-${uid}-${activeItem.key}`, tabIndex: 0, className: "tabs__panel", children: activeItem.panel }, activeItem.key))] }));
|
|
@@ -46,61 +46,61 @@ export default function TextField(props) {
|
|
|
46
46
|
autoActiveFirst,
|
|
47
47
|
});
|
|
48
48
|
/* 후보 목록이 지금 DOM 에 있는가 — 아래 렌더 조건과 **같은 식**이어야 한다 */
|
|
49
|
-
const
|
|
49
|
+
const listOpen = !!suggestions && combobox.open && combobox.filtered.length > 0;
|
|
50
50
|
/* 후보 목록은 **body 로 빼서** 칸 좌표에 fixed 로 띄운다 — 칸 아래 absolute 로 두었더니 팝업 본문(overflow: auto)에
|
|
51
51
|
잘려 창 아래쪽 칸(훈련 묶음의 그룹 이름)에서 후보가 보이지 않았다. Dropdown 메뉴와 같은 방식(층·data-tooltip-ignore). */
|
|
52
52
|
const rootRef = useRef(null);
|
|
53
53
|
const [listStyle, setListStyle] = useState(null);
|
|
54
|
-
const
|
|
54
|
+
const measureListRect = useCallback(() => {
|
|
55
55
|
const root = rootRef.current;
|
|
56
56
|
if (!root || typeof window === 'undefined')
|
|
57
57
|
return;
|
|
58
58
|
const r = root.getBoundingClientRect();
|
|
59
|
-
const
|
|
60
|
-
const
|
|
61
|
-
const
|
|
59
|
+
const maxHeight = 220 + 8 + 2; // max-height + padding + 테두리
|
|
60
|
+
const spaceBelow = window.innerHeight - r.bottom;
|
|
61
|
+
const openUpward = spaceBelow < maxHeight + 8 && r.top > maxHeight + 8;
|
|
62
62
|
setListStyle({
|
|
63
63
|
position: 'fixed',
|
|
64
64
|
left: r.left,
|
|
65
65
|
width: r.width,
|
|
66
|
-
...(
|
|
66
|
+
...(openUpward ? { bottom: window.innerHeight - r.top + 8 } : { top: r.bottom + 8 }),
|
|
67
67
|
zIndex: anchoredMenuZIndex(root),
|
|
68
68
|
});
|
|
69
69
|
}, []);
|
|
70
70
|
useIsoLayoutEffect(() => {
|
|
71
|
-
if (
|
|
71
|
+
if (!listOpen)
|
|
72
72
|
return;
|
|
73
|
-
|
|
73
|
+
measureListRect();
|
|
74
74
|
/* 굴리거나 창 크기가 바뀌면 칸을 따라간다 — 목록 안을 굴리는 것은 무시 */
|
|
75
75
|
/* **틀에 한 번으로 모은다** — (115) 와 같은 꼴, 셋째 자리(2026-10-10, 코어 자체 조사). `목록자리재기` 는
|
|
76
76
|
`getBoundingClientRect()` 로 레이아웃을 읽고 상태를 바꾸는데, 이 듣는 이는 **문서 전체의 굴림을
|
|
77
77
|
capture 로** 받는다 — 목록이 뜬 동안 굴리면 틀마다 여러 번 돌았다. 여기는 **닫는 길이 없다**(자리만
|
|
78
78
|
따라간다) — 미뤄도 되는 일뿐이다. */
|
|
79
|
-
let
|
|
80
|
-
const
|
|
79
|
+
let rafId = null;
|
|
80
|
+
const reposition = () => {
|
|
81
81
|
if (typeof requestAnimationFrame === 'undefined') {
|
|
82
|
-
|
|
82
|
+
measureListRect();
|
|
83
83
|
return;
|
|
84
84
|
}
|
|
85
|
-
if (
|
|
85
|
+
if (rafId !== null)
|
|
86
86
|
return;
|
|
87
|
-
|
|
87
|
+
rafId = requestAnimationFrame(() => { rafId = null; measureListRect(); });
|
|
88
88
|
};
|
|
89
|
-
const
|
|
89
|
+
const onScroll = (e) => {
|
|
90
90
|
const t = e.target;
|
|
91
91
|
if (t && t.nodeType === 1 && t.closest?.('.text-field__suggestions'))
|
|
92
92
|
return;
|
|
93
|
-
|
|
93
|
+
reposition();
|
|
94
94
|
};
|
|
95
|
-
window.addEventListener('scroll',
|
|
96
|
-
window.addEventListener('resize',
|
|
95
|
+
window.addEventListener('scroll', onScroll, true);
|
|
96
|
+
window.addEventListener('resize', reposition);
|
|
97
97
|
return () => {
|
|
98
|
-
if (
|
|
99
|
-
cancelAnimationFrame(
|
|
100
|
-
window.removeEventListener('scroll',
|
|
101
|
-
window.removeEventListener('resize',
|
|
98
|
+
if (rafId !== null)
|
|
99
|
+
cancelAnimationFrame(rafId);
|
|
100
|
+
window.removeEventListener('scroll', onScroll, true);
|
|
101
|
+
window.removeEventListener('resize', reposition);
|
|
102
102
|
};
|
|
103
|
-
}, [
|
|
103
|
+
}, [listOpen, measureListRect]);
|
|
104
104
|
// 외부 inputRef + 내부 autoRef 동시 연결 — 외부에서 전달받은 ref 객체(.current)에 노드를 채우는
|
|
105
105
|
// React의 표준 ref 병합/전달(forwarding) 패턴이며, 이 외에 구현할 방법이 없다
|
|
106
106
|
/* eslint-disable react-hooks/immutability */
|
|
@@ -114,7 +114,7 @@ export default function TextField(props) {
|
|
|
114
114
|
/* eslint-enable react-hooks/immutability */
|
|
115
115
|
/* 추천이 있으면 자리표시가 **추천 + 받는 법**이 된다. 추천이 없으면 그 칸의 평소 자리표시로 물러선다.
|
|
116
116
|
(네이티브 자리표시는 칸이 비었을 때만 보이므로 "비었을 때만" 은 따로 가릴 것이 없다.) */
|
|
117
|
-
const
|
|
117
|
+
const placeholderGhost = ghostText
|
|
118
118
|
? `${ghostText}${ghostHint ?? t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)')}`
|
|
119
119
|
: placeholder;
|
|
120
120
|
const isControlled = value !== undefined;
|
|
@@ -165,7 +165,7 @@ export default function TextField(props) {
|
|
|
165
165
|
// 단 비밀번호는 둘 다 넘기지 않는다 — 어느 쪽이든 HTML value 속성에 평문이 박힌다
|
|
166
166
|
...(isPasswordControlled ? {} : isControlled ? { value } : { defaultValue }),
|
|
167
167
|
ref: setNodeRef,
|
|
168
|
-
placeholder:
|
|
168
|
+
placeholder: placeholderGhost,
|
|
169
169
|
name,
|
|
170
170
|
disabled,
|
|
171
171
|
readOnly,
|
|
@@ -193,14 +193,14 @@ export default function TextField(props) {
|
|
|
193
193
|
"맞는 항목이 없습니다" 를 **잇따라** 말한다(말귀 실측). 바로 아래
|
|
194
194
|
`aria-controls` 에 이미 건 가드를 여기만 안 걸고 있었다: 한 결정이 세 속성으로
|
|
195
195
|
갈라져 둘만 고쳐진 꼴이다. 셋이 같은 식을 본다. */
|
|
196
|
-
'aria-expanded': suggestions ?
|
|
196
|
+
'aria-expanded': suggestions ? listOpen : undefined,
|
|
197
197
|
/* **목록이 실제로 그려졌을 때만** 가리킨다. 예전에는 `suggestions` 만 있으면 늘
|
|
198
198
|
걸어 두었는데, 목록은 열려 있고 걸린 후보가 있을 때만 그려진다(아래 렌더 조건).
|
|
199
199
|
그래서 닫힌 콤보박스가 **없는 id 를 가리키고** 있었다 — 놀이터에서 실측으로
|
|
200
200
|
`aria-controls=_r_7l_` 이 어느 원소도 안 가리켰다. 낭독기는 그 관계를 못 풀고
|
|
201
201
|
조용히 버린다. */
|
|
202
|
-
'aria-controls':
|
|
203
|
-
'aria-activedescendant':
|
|
202
|
+
'aria-controls': listOpen ? combobox.listId : undefined,
|
|
203
|
+
'aria-activedescendant': listOpen && combobox.activeIdx >= 0 ? `${combobox.listId}-option-${combobox.activeIdx}` : undefined,
|
|
204
204
|
onChange: (e) => {
|
|
205
205
|
const node = e.target;
|
|
206
206
|
if (!isControlled)
|
|
@@ -237,8 +237,8 @@ export default function TextField(props) {
|
|
|
237
237
|
비출 추천이 있을 때만. 받은 키는 이 칸이 먹으므로 초점이 옮겨 가지 않고 `onKeyDown` 으로도
|
|
238
238
|
안 간다 — 한 번 더 누르면 칸이 찼으니 평소대로 넘어간다. 조합 중(한글 중간 글자)에는 손대지
|
|
239
239
|
않는다: 그때의 Space 는 글자를 맺는 키다. */
|
|
240
|
-
const
|
|
241
|
-
if (ghostText &&
|
|
240
|
+
const acceptsSuggestion = ((e.key === 'Tab' && !e.shiftKey) || e.key === ' ') && !e.nativeEvent.isComposing;
|
|
241
|
+
if (ghostText && acceptsSuggestion && !readOnly && !disabled && !(e.currentTarget.value ?? '').trim()) {
|
|
242
242
|
e.preventDefault();
|
|
243
243
|
const node = e.currentTarget;
|
|
244
244
|
if (!isControlled) {
|
|
@@ -248,8 +248,8 @@ export default function TextField(props) {
|
|
|
248
248
|
onChange?.(ghostText);
|
|
249
249
|
return;
|
|
250
250
|
}
|
|
251
|
-
const
|
|
252
|
-
if (
|
|
251
|
+
const entryPicksCandidate = !!suggestions && e.key === 'Enter' && !e.nativeEvent.isComposing && combobox.activeIdx >= 0;
|
|
252
|
+
if (entryPicksCandidate)
|
|
253
253
|
e.preventDefault();
|
|
254
254
|
else if (onEnter && e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
|
255
255
|
e.preventDefault();
|
|
@@ -275,8 +275,8 @@ export default function TextField(props) {
|
|
|
275
275
|
// variant="display"는 값과 placeholder 글자 크기가 크게 달라 네이티브 ::placeholder로는
|
|
276
276
|
// 세로 중앙을 못 맞춘다. 전용 오버레이 div를 flex로 정중앙 정렬하고,
|
|
277
277
|
// 값이 있으면(:placeholder-shown 해제) CSS로 숨긴다.
|
|
278
|
-
const showPlaceholderOverlay = variant === 'display' && !newline && !multiline &&
|
|
279
|
-
const renderInputArea = () => (_jsxs(_Fragment, { children: [renderInput(), showPlaceholderOverlay && (_jsx("span", { className: "text-field__placeholder", "aria-hidden": "true", children:
|
|
278
|
+
const showPlaceholderOverlay = variant === 'display' && !newline && !multiline && !!placeholderGhost;
|
|
279
|
+
const renderInputArea = () => (_jsxs(_Fragment, { children: [renderInput(), showPlaceholderOverlay && (_jsx("span", { className: "text-field__placeholder", "aria-hidden": "true", children: placeholderGhost }))] }));
|
|
280
280
|
return (_jsxs("div", { ref: rootRef, className: cls, children: [label && (_jsxs("label", { htmlFor: inputId, className: "text-field__label caption-1", children: [label, required && _jsx("span", { className: "text-field__required", children: "*" })] })), _jsxs("div", { className: "text-field__wrap", children: [leftIcon && (_jsx("span", { className: "text-field__icon text-field__icon--left", children: _jsx(Icon, { icon: leftIcon, label: "", className: "", svgClassName: "text-field__icon-svg", size: iconSize }) })), renderInputArea(), maxLength != null && showCount && (_jsxs("span", { className: `text-field__count caption-2${isOver ? ' text-field__count--over' : ''}`, children: [len, "/", maxLength] })), showClear && (_jsx(Button, { size: 24, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, className: "text-field__clear", ariaLabel: t('textField.clearAriaLabel', '입력값 지우기'),
|
|
281
281
|
// 폼 안에서 쓰일 때 기본값(submit)으로 눌려 폼이 제출되지 않도록.
|
|
282
282
|
htmlType: "button",
|
|
@@ -294,7 +294,7 @@ export default function TextField(props) {
|
|
|
294
294
|
onChange?.('');
|
|
295
295
|
}, tabIndex: -1 })), rightIcon && !showClear && (_jsx("span", { className: "text-field__icon text-field__icon--right", children: _jsx(Icon, { icon: rightIcon, label: "", className: "", svgClassName: "text-field__icon-svg", size: iconSize }) }))] }), !!suggestions && (_jsx("span", { className: "text-field__status", role: "status", "aria-live": "polite", children: combobox.open && (value ?? '').length > 0 && combobox.filtered.length === 0
|
|
296
296
|
? t('common.noMatches', '맞는 항목이 없습니다')
|
|
297
|
-
: '' })),
|
|
297
|
+
: '' })), listOpen && (typeof document === 'undefined' ? null : createPortal(_jsx("ul", { id: combobox.listId, className: "text-field__suggestions", role: "listbox", style: listStyle ?? { position: 'fixed', visibility: 'hidden' }, "data-tooltip-ignore": true, children: combobox.filtered.map((s, i) => (_jsx("li", { id: `${combobox.listId}-option-${i}`, role: "option", "aria-selected": i === combobox.activeIdx, className: `text-field__suggestion body-2${i === combobox.activeIdx ? ' is-active' : ''}`,
|
|
298
298
|
// 선택은 click 에서 한다. onMouseDown 만 두면 마우스로는 눌리지만 스크린리더가
|
|
299
299
|
// 이 option 을 활성화할 때는 click 만 발생해 아무 일도 일어나지 않는다.
|
|
300
300
|
// mousedown 에서는 기본 동작(포커스 이동 → 입력 blur)을 막아, click 이 도달하기
|
|
@@ -11,20 +11,20 @@ onClose = () => { }, duration = 2000, // 0 = persistent until message cleared
|
|
|
11
11
|
const { mounted, showing, popupRef, pause, resume } = useToastLifecycle({ message, position, duration, onClose });
|
|
12
12
|
/* 톡(터치)은 멈추지 않는다 — 톡하면 호환 mouseenter 로 멈추고 mouseleave 는 다른 곳을 톡하기 전까지 안 와 알림이 사라지지 않았다.
|
|
13
13
|
마우스 머무름·키보드 포커스는 그대로 멈춘다. 마지막 포인터 종류를 pointerenter 로 기억해 가른다. */
|
|
14
|
-
const
|
|
14
|
+
const lastPointerType = useRef(null);
|
|
15
15
|
/* **첫 문구가 안 읽혔다** — live 영역(role=status)이 문구와 한꺼번에 문서에 들어오면 낭독기는 "바뀐 것" 으로 보지 않는다.
|
|
16
16
|
영역을 먼저 붙이고, 다음 틱(등장 애니메이션이 시작되는 두 프레임 뒤)에 문구를 접근 트리에 넣는다.
|
|
17
17
|
문구 노드는 처음부터 두고 `aria-hidden` 만 푼다 — 글자를 늦게 넣으면 문구를 곧바로 찾는 시험(getByText·status 의
|
|
18
18
|
textContent)이 깨지고, 늘 비어 있는 status 영역을 따로 두면 getByRole('status') 가 둘을 찾는다. 이미 떠 있는 동안 바뀐 문구는 곧바로 읽힌다. */
|
|
19
|
-
const [
|
|
19
|
+
const [announced, setAnnounced] = useState(false);
|
|
20
20
|
useEffect(() => {
|
|
21
21
|
// 등장(showing)을 따라 한 번 켜고, 내려가면 끈다 — 외부(애니메이션 수명) 상태와의 의도된 동기화
|
|
22
22
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
23
23
|
if (showing)
|
|
24
|
-
|
|
24
|
+
setAnnounced(true);
|
|
25
25
|
// eslint-disable-next-line react-hooks/set-state-in-effect
|
|
26
26
|
else if (!mounted)
|
|
27
|
-
|
|
27
|
+
setAnnounced(false);
|
|
28
28
|
}, [showing, mounted]);
|
|
29
29
|
if (!mounted)
|
|
30
30
|
return null;
|
|
@@ -35,8 +35,8 @@ onClose = () => { }, duration = 2000, // 0 = persistent until message cleared
|
|
|
35
35
|
`duration={0}` 으로 아예 안 사라지게 하는 길은 전부터 있었지만 그건 부르는
|
|
36
36
|
쪽이 미리 정하는 것이고, 읽는 사람이 **그 자리에서** 멈출 수는 없었다.
|
|
37
37
|
다시 움직일 때는 **남은 시간부터** 돈다 — 처음부터가 아니다. */
|
|
38
|
-
_jsx("div", { ref: popupRef, className: `toast toast--${position} ${showing ? 'is-visible' : ''}`, role: "status", "aria-live": "polite", onPointerEnter: (e) => {
|
|
39
|
-
pause(); }, onMouseLeave: resume, onFocusCapture: pause, onBlurCapture: resume, children: _jsx("div", { className: `toast__inner toast__inner--${normalizedType} body-2`, "aria-hidden":
|
|
38
|
+
_jsx("div", { ref: popupRef, className: `toast toast--${position} ${showing ? 'is-visible' : ''}`, role: "status", "aria-live": "polite", onPointerEnter: (e) => { lastPointerType.current = e.pointerType; }, onMouseEnter: () => { if (lastPointerType.current !== 'touch')
|
|
39
|
+
pause(); }, onMouseLeave: resume, onFocusCapture: pause, onBlurCapture: resume, children: _jsx("div", { className: `toast__inner toast__inner--${normalizedType} body-2`, "aria-hidden": announced || showing ? undefined : true, children: _jsx("span", { className: "toast__text", children: message }) }) }));
|
|
40
40
|
if (typeof document !== 'undefined') {
|
|
41
41
|
return createPortal(el, document.body);
|
|
42
42
|
}
|
|
@@ -26,8 +26,8 @@ export default function MenuTooltip({ columns = [], onClose, className = '', siz
|
|
|
26
26
|
return out;
|
|
27
27
|
}, [columns, path]);
|
|
28
28
|
const current = stack[stack.length - 1];
|
|
29
|
-
const
|
|
30
|
-
const goBack = useCallback(() => setPath((p) => p.slice(0, Math.max(0, Math.min(p.length,
|
|
29
|
+
const visibleDepth = stack.length - 1;
|
|
30
|
+
const goBack = useCallback(() => setPath((p) => p.slice(0, Math.max(0, Math.min(p.length, visibleDepth) - 1))), [visibleDepth]);
|
|
31
31
|
// 키보드 포커스 — Tooltip은 body로 포털되므로, 포커스를 옮겨주지 않으면 ⋯ 버튼을 누른
|
|
32
32
|
// 키보드 사용자가 메뉴에 닿으려면 페이지 끝까지 Tab 해야 한다(사실상 도달 불가).
|
|
33
33
|
// Tooltip에 이미 있는 ESC·Tab 트랩과 짝을 이루도록 첫 항목으로 포커스를 넣고,
|
|
@@ -65,7 +65,7 @@ export default function MenuTooltip({ columns = [], onClose, className = '', siz
|
|
|
65
65
|
if (!item)
|
|
66
66
|
return;
|
|
67
67
|
if (Array.isArray(item.children) && item.children.length) {
|
|
68
|
-
setPath((p) => [...p.slice(0,
|
|
68
|
+
setPath((p) => [...p.slice(0, visibleDepth), { ci, ii }]);
|
|
69
69
|
return;
|
|
70
70
|
}
|
|
71
71
|
try {
|
|
@@ -80,7 +80,7 @@ export default function MenuTooltip({ columns = [], onClose, className = '', siz
|
|
|
80
80
|
onClose();
|
|
81
81
|
}
|
|
82
82
|
catch (err) { }
|
|
83
|
-
}, [onClose,
|
|
83
|
+
}, [onClose, visibleDepth]);
|
|
84
84
|
return (_jsxs("div", { className: `tooltip-menu tooltip-menu--${size} ${className}`, children: [(stack.length > 1 || current.title) && (_jsxs("div", { className: "tooltip-menu__header", children: [stack.length > 1 ? (_jsx(Button, { htmlType: "button", size: itemSize, type: "dot", icon: "chevron-left", ariaLabel: t('common.back', '뒤로'), onClick: goBack, variant: "transparent-gray", useBorder: false })) : null, _jsx("div", { className: "tooltip-menu__title button-1", children: current.title })] })), _jsx("div", { className: "tooltip-menu__columns", ref: itemsWrapRef, children: current.columns && current.columns.map((col, ci) => (_jsxs("div", { className: "tooltip-menu__column", children: [col.title ? _jsx("div", { className: "tooltip-menu__column-title body-3", children: col.title }) : null, _jsx("div", { className: "tooltip-menu__items", children: (col.items || []).map((item, idx) => (_jsxs(Fragment, { children: [item.separatorBefore && idx > 0 && _jsx("div", { className: "tooltip-menu__separator", role: "separator" }), _jsx(Button, { htmlType: "button", className: "tooltip-menu__item", size: itemSize, type: item.label ? 'bar' : 'dot', icon: item.selected ? 'check' : (item.icon || 'none'), rightIcon: Array.isArray(item.children) && item.children.length
|
|
85
85
|
? 'chevron-right'
|
|
86
86
|
: item.badgeCount
|
|
@@ -144,15 +144,15 @@ export default function Tooltip(props) {
|
|
|
144
144
|
굴림을 **capture** 로 받아, 굴리는 동안 틀마다 여러 번 `computePosition` 이 돌았다(거기서 레이아웃을
|
|
145
145
|
읽는다). **닫는 길은 안 미룬다** — 아래 `onScroll` 의 닫기는 그대로 즉시다. 미루면 앵커에서 떨어진
|
|
146
146
|
툴팁이 한 틀 더 허공에 남는다. 미루는 것은 *자리* 뿐이다. */
|
|
147
|
-
let
|
|
147
|
+
let rafId = null;
|
|
148
148
|
const onWindowChange = () => {
|
|
149
149
|
if (typeof requestAnimationFrame === 'undefined') {
|
|
150
150
|
computePosition(true);
|
|
151
151
|
return;
|
|
152
152
|
}
|
|
153
|
-
if (
|
|
153
|
+
if (rafId !== null)
|
|
154
154
|
return;
|
|
155
|
-
|
|
155
|
+
rafId = requestAnimationFrame(() => { rafId = null; computePosition(true); });
|
|
156
156
|
};
|
|
157
157
|
/* 고정 사각형(anchorRect)으로 띄운 툴팁은 스크롤해도 앵커를 따라갈 수 없다 — 같은 자리를 다시 계산해 **허공에** 남았다
|
|
158
158
|
(목록을 굴리면 카드 메뉴가 카드에서 떨어져 뜬 채). 닫을 곳(onClose)이 있으면 닫는다. 툴팁 안 스크롤은 제외. */
|
|
@@ -175,8 +175,8 @@ export default function Tooltip(props) {
|
|
|
175
175
|
window.addEventListener('resize', onWindowChange);
|
|
176
176
|
document.addEventListener('scroll', onScroll, { passive: true, capture: true });
|
|
177
177
|
return () => {
|
|
178
|
-
if (
|
|
179
|
-
cancelAnimationFrame(
|
|
178
|
+
if (rafId !== null)
|
|
179
|
+
cancelAnimationFrame(rafId);
|
|
180
180
|
window.removeEventListener('resize', onWindowChange);
|
|
181
181
|
document.removeEventListener('scroll', onScroll, { capture: true });
|
|
182
182
|
};
|
|
@@ -218,9 +218,9 @@ export default function Tooltip(props) {
|
|
|
218
218
|
// 배경 자신의 onClick 과 document 바깥클릭 감지가 함께 걸려 실제로 onClose 가 두 번
|
|
219
219
|
// 불렸다. ref 로 "이미 닫는 중"을 즉시 표시해 한 번만 지나가게 한다(Popup 과 같은 방식).
|
|
220
220
|
const closingRef = useRef(false);
|
|
221
|
-
const
|
|
221
|
+
const closeTimerRef = useRef(null);
|
|
222
222
|
/* '동작 줄이기' 면 닫기 대기를 0 으로(페이드가 없으니 기다릴 까닭도 없다) */
|
|
223
|
-
const
|
|
223
|
+
const reducedMotion = usePrefersReducedMotion();
|
|
224
224
|
const startClose = useCallback((immediate = false) => {
|
|
225
225
|
/* 닫기를 넘길 곳(onClose)이 없으면 아무것도 하지 않는다. 예전에는 그래도 is-closing 을
|
|
226
226
|
붙여, 부모가 치우지 않는 툴팁이 **흐려진 채 사라진 것처럼** 그 자리에 남았다
|
|
@@ -246,8 +246,8 @@ export default function Tooltip(props) {
|
|
|
246
246
|
}
|
|
247
247
|
setClosing(true);
|
|
248
248
|
// 떠날 때 걷는다 — ESC 로 닫는 200ms 안에 부모가 치우면 떠난 뒤에 onClose 가 불렸다
|
|
249
|
-
|
|
250
|
-
|
|
249
|
+
closeTimerRef.current = setTimeout(() => {
|
|
250
|
+
closeTimerRef.current = null;
|
|
251
251
|
try {
|
|
252
252
|
if (onClose)
|
|
253
253
|
onClose();
|
|
@@ -256,10 +256,10 @@ export default function Tooltip(props) {
|
|
|
256
256
|
closingRef.current = false;
|
|
257
257
|
setClosing(false);
|
|
258
258
|
}
|
|
259
|
-
},
|
|
260
|
-
}, [closing, onClose, setClosing,
|
|
261
|
-
useEffect(() => () => { if (
|
|
262
|
-
clearTimeout(
|
|
259
|
+
}, reducedMotion ? 0 : 200);
|
|
260
|
+
}, [closing, onClose, setClosing, reducedMotion]);
|
|
261
|
+
useEffect(() => () => { if (closeTimerRef.current)
|
|
262
|
+
clearTimeout(closeTimerRef.current); }, []);
|
|
263
263
|
useEffect(() => {
|
|
264
264
|
/* 제자리(글 흐름 안)에 그린 툴팁은 떠 있는 층이 아니라 본문의 한 조각이다 — document 의
|
|
265
265
|
바깥 누름·Esc·Tab 을 듣지 않는다. 들으면 화면 아무 데나 누르거나 Esc 를 칠 때마다
|
|
@@ -372,25 +372,25 @@ export default function Tooltip(props) {
|
|
|
372
372
|
**조작이 있을 때만** 옮긴다 — 글만 있는 풍선(`InfoTooltip`·`DescriptionTooltip`)에서
|
|
373
373
|
초점을 뺏으면 읽던 자리를 잃는다. **재고 난 뒤**에 옮긴다: 재기 전에는
|
|
374
374
|
`visibility: hidden` 이라 초점이 안 선다. */
|
|
375
|
-
const
|
|
375
|
+
const focusBeforeOpenRef = useRef(null);
|
|
376
376
|
useEffect(() => {
|
|
377
377
|
if (isInline || focusOnOpen === false || !measured)
|
|
378
378
|
return;
|
|
379
379
|
const el = ref.current;
|
|
380
380
|
if (!el)
|
|
381
381
|
return;
|
|
382
|
-
const
|
|
383
|
-
if (
|
|
382
|
+
const firstFocusable = el.querySelector(FOCUSABLE_SELECTOR);
|
|
383
|
+
if (!firstFocusable)
|
|
384
384
|
return;
|
|
385
|
-
|
|
386
|
-
|
|
385
|
+
focusBeforeOpenRef.current = document.activeElement;
|
|
386
|
+
firstFocusable.focus();
|
|
387
387
|
return () => {
|
|
388
|
-
const
|
|
389
|
-
|
|
388
|
+
const returnTo = focusBeforeOpenRef.current;
|
|
389
|
+
focusBeforeOpenRef.current = null;
|
|
390
390
|
/* 닫을 때 **열었던 자리로 돌려준다** — 안 돌려주면 초점이 `body` 로 떨어져
|
|
391
391
|
자판 쓰는 사람이 처음부터 다시 Tab 해야 한다. 그 사이 사라진 마디면 안 옮긴다. */
|
|
392
|
-
if (
|
|
393
|
-
|
|
392
|
+
if (returnTo && document.contains(returnTo))
|
|
393
|
+
returnTo.focus();
|
|
394
394
|
};
|
|
395
395
|
}, [isInline, focusOnOpen, measured]);
|
|
396
396
|
/* 이름 없는 `role="dialog"` 는 낭독기가 "대화상자" 라고만 읽는다 — 무슨 창인지 알 수 없다.
|
|
@@ -32,15 +32,15 @@ export default function TrainingTooltip({ plan, groups: givenGroups, anchorRect,
|
|
|
32
32
|
(111) 을 넣으며 한 번 "찾은 자료가 있어야" 로 바꿨다가 옛 못 넷이 울었다 — 풀에 없는 항목만 든
|
|
33
33
|
계획에도 상자는 떠야 한다(타일 호버가 그 상자를 기다린다). 두 길의 "빈 묶음" 기준이 다른 까닭이다. */
|
|
34
34
|
return translateTrainingTypes(t).map(type => {
|
|
35
|
-
const
|
|
35
|
+
const picked = (plan?.items ?? []).filter(i => i.trainingType === type.key);
|
|
36
36
|
return {
|
|
37
37
|
key: type.key,
|
|
38
38
|
label: type.label,
|
|
39
39
|
type,
|
|
40
|
-
|
|
41
|
-
data:
|
|
40
|
+
pickedCount: picked.length,
|
|
41
|
+
data: picked.map(findTrainingData).filter((td) => td != null),
|
|
42
42
|
};
|
|
43
|
-
}).filter(g => g
|
|
43
|
+
}).filter(g => g.pickedCount > 0);
|
|
44
44
|
}, [givenGroups, plan?.items, t]);
|
|
45
45
|
if (groups.length === 0)
|
|
46
46
|
return null;
|
|
@@ -27,71 +27,71 @@ export default function TopBanner({ children, interval = 5000, dotTone, classNam
|
|
|
27
27
|
/* 터치 — 누르는 동안과 뗀 뒤 5초는 멈춘다(배너 밖을 톡하면 곧바로 다시 돈다). 전에는 톡의 호환 mouseenter 로 멈추고 mouseleave 가
|
|
28
28
|
안 와 영영 멈추거나, 누르고 읽는 사이 장이 넘어갔다. 마지막 포인터가 터치면 mouseenter 는 멈춤으로 치지 않는다. */
|
|
29
29
|
const [touched, setTouched] = useState(false);
|
|
30
|
-
const
|
|
31
|
-
const
|
|
30
|
+
const lastPointerType = useRef(null);
|
|
31
|
+
const touchTimer = useRef(null);
|
|
32
32
|
const sectionRef = useRef(null);
|
|
33
33
|
/* '동작 줄이기' — 저절로 넘어가는 장은 움직임이고 읽는 사이 내용이 바뀐다(WCAG 2.2.2). 자동 롤링을 끈다 */
|
|
34
|
-
const
|
|
34
|
+
const reducedMotion = usePrefersReducedMotion();
|
|
35
35
|
const paused = hovered || focused || touched;
|
|
36
36
|
/* 슬라이드 수가 줄면 **지금 장을 범위 안으로 조인다** — `index % count` 는 3장 중 셋째(2)를 보다 2장이 되면 첫째(0)로 튀었다 */
|
|
37
37
|
const current = count > 0 ? Math.min(index, count - 1) : 0;
|
|
38
38
|
useEffect(() => {
|
|
39
|
-
if (count < 2 || interval <= 0 || paused ||
|
|
39
|
+
if (count < 2 || interval <= 0 || paused || reducedMotion)
|
|
40
40
|
return;
|
|
41
41
|
const id = window.setInterval(() => setIndex(i => (Math.min(i, count - 1) + 1) % count), interval);
|
|
42
42
|
return () => window.clearInterval(id);
|
|
43
|
-
}, [count, interval, paused,
|
|
44
|
-
const
|
|
45
|
-
if (
|
|
46
|
-
window.clearTimeout(
|
|
47
|
-
|
|
43
|
+
}, [count, interval, paused, reducedMotion]);
|
|
44
|
+
const scheduleTouchResume = (ms) => {
|
|
45
|
+
if (touchTimer.current != null)
|
|
46
|
+
window.clearTimeout(touchTimer.current);
|
|
47
|
+
touchTimer.current = window.setTimeout(() => { touchTimer.current = null; setTouched(false); }, ms);
|
|
48
48
|
};
|
|
49
|
-
useEffect(() => () => { if (
|
|
50
|
-
window.clearTimeout(
|
|
49
|
+
useEffect(() => () => { if (touchTimer.current != null)
|
|
50
|
+
window.clearTimeout(touchTimer.current); }, []);
|
|
51
51
|
/* 배너 밖을 톡하면 곧바로 다시 돈다 */
|
|
52
52
|
useEffect(() => {
|
|
53
53
|
if (!touched)
|
|
54
54
|
return;
|
|
55
|
-
const
|
|
55
|
+
const onOutsidePress = (e) => {
|
|
56
56
|
if (e.pointerType !== 'touch')
|
|
57
57
|
return;
|
|
58
58
|
if (sectionRef.current && sectionRef.current.contains(e.target))
|
|
59
59
|
return;
|
|
60
|
-
if (
|
|
61
|
-
window.clearTimeout(
|
|
62
|
-
|
|
60
|
+
if (touchTimer.current != null) {
|
|
61
|
+
window.clearTimeout(touchTimer.current);
|
|
62
|
+
touchTimer.current = null;
|
|
63
63
|
}
|
|
64
64
|
setTouched(false);
|
|
65
65
|
};
|
|
66
|
-
document.addEventListener('pointerdown',
|
|
67
|
-
return () => document.removeEventListener('pointerdown',
|
|
66
|
+
document.addEventListener('pointerdown', onOutsidePress, true);
|
|
67
|
+
return () => document.removeEventListener('pointerdown', onOutsidePress, true);
|
|
68
68
|
}, [touched]);
|
|
69
69
|
if (count === 0)
|
|
70
70
|
return null;
|
|
71
|
-
return (_jsxs("section", { ref: sectionRef, className: cx('top-banner', className), "aria-label": ariaLabel, "aria-roledescription": "carousel", onPointerEnter: (e) => {
|
|
72
|
-
|
|
71
|
+
return (_jsxs("section", { ref: sectionRef, className: cx('top-banner', className), "aria-label": ariaLabel, "aria-roledescription": "carousel", onPointerEnter: (e) => { lastPointerType.current = e.pointerType; }, onPointerDown: (e) => {
|
|
72
|
+
lastPointerType.current = e.pointerType;
|
|
73
73
|
if (e.pointerType !== 'touch')
|
|
74
74
|
return;
|
|
75
|
-
if (
|
|
76
|
-
window.clearTimeout(
|
|
77
|
-
|
|
75
|
+
if (touchTimer.current != null) {
|
|
76
|
+
window.clearTimeout(touchTimer.current);
|
|
77
|
+
touchTimer.current = null;
|
|
78
78
|
}
|
|
79
79
|
setTouched(true);
|
|
80
80
|
}, onPointerUp: (e) => { if (e.pointerType === 'touch')
|
|
81
|
-
|
|
82
|
-
|
|
81
|
+
scheduleTouchResume(5000); }, onPointerCancel: (e) => { if (e.pointerType === 'touch')
|
|
82
|
+
scheduleTouchResume(5000); }, onMouseEnter: () => { if (lastPointerType.current !== 'touch')
|
|
83
83
|
setHovered(true); }, onMouseLeave: () => setHovered(false),
|
|
84
84
|
/* 포커스 멈춤은 **보이는 포커스(:focus-visible)** 일 때만 — 마우스로 누른 단추(점·배너 단추)에 남은 포커스로 멈추면
|
|
85
85
|
마우스가 떠난 뒤에도 영영 멈춰 있었다. 키보드로 들어온 포커스는 그대로 멈춘다(0545ddf) */
|
|
86
86
|
onFocus: (e) => {
|
|
87
|
-
let
|
|
87
|
+
let alive = true;
|
|
88
88
|
try {
|
|
89
|
-
|
|
89
|
+
alive = e.target.matches(':focus-visible');
|
|
90
90
|
}
|
|
91
91
|
catch {
|
|
92
|
-
|
|
92
|
+
alive = true;
|
|
93
93
|
}
|
|
94
|
-
setFocused(
|
|
94
|
+
setFocused(alive);
|
|
95
95
|
}, onBlur: (e) => { if (!e.currentTarget.contains(e.relatedTarget))
|
|
96
96
|
setFocused(false); }, children: [_jsx("div", { className: "top-banner__viewport", children: _jsx("div", { className: "top-banner__track", style: { transform: `translateX(-${current * 100}%)` }, children: slides.map((slide, i) => (_jsx("div", { className: "top-banner__slide", "aria-hidden": i !== current, ...(i !== current ? { inert: true } : {}), children: slide }, i))) }) }), count > 1 && (_jsx("div", { className: `top-banner__dots${(dotTone?.[current] ?? 'light') === 'dark' ? ' top-banner__dots--dark' : ''}`, children: slides.map((_, i) => (_jsx("button", { type: "button", className: `top-banner__dot${i === current ? ' top-banner__dot--active' : ''}`, "aria-label": t('topBanner.dotAriaLabel', {
|
|
97
97
|
count, index: i + 1, defaultValue: `${count}개 중 ${i + 1}번째 배너`,
|
|
@@ -197,16 +197,16 @@ export default function WaveForm(props) {
|
|
|
197
197
|
* 소리는 가운데 절반만 차지한다. 뜸이 0 이면 `{ lead: 0, sound: 전부, tail: 0 }` 이라
|
|
198
198
|
* 아래 셈이 **전과 똑같이** 돈다.
|
|
199
199
|
*/
|
|
200
|
-
const
|
|
200
|
+
const bandCounts = useMemo(() => padBarSpans({ bars: barsCountForMix, trackDurationSec, leadInMs, tailMs }), [barsCountForMix, trackDurationSec, leadInMs, tailMs]);
|
|
201
201
|
const bars = useMemo(() => Array.from({ length: barsCountForMix }).map((_, i) => {
|
|
202
202
|
/* 뜸 구간 막대는 소리가 없다 — 봉우리 0. 그 자리는 **소음 파형이 채운다**(아래 바닥값).
|
|
203
203
|
소리 구간은 봉우리를 그 폭에 다시 고른다(전에는 막대 번호가 곧 봉우리 번호였다). */
|
|
204
|
-
const
|
|
205
|
-
const
|
|
206
|
-
? Math.min(barsCountForMix - 1, Math.round(((i -
|
|
204
|
+
const inSound = i >= bandCounts.lead && i < bandCounts.lead + bandCounts.sound;
|
|
205
|
+
const peakIndex = bandCounts.sound > 0 && inSound
|
|
206
|
+
? Math.min(barsCountForMix - 1, Math.round(((i - bandCounts.lead) / bandCounts.sound) * barsCountForMix))
|
|
207
207
|
: -1;
|
|
208
|
-
const peak =
|
|
209
|
-
? (wavePeaks && wavePeaks[
|
|
208
|
+
const peak = inSound
|
|
209
|
+
? (wavePeaks && wavePeaks[peakIndex] != null ? wavePeaks[peakIndex] : null)
|
|
210
210
|
: (wavePeaks ? 0 : null);
|
|
211
211
|
const eff = peak == null ? null : Math.min(1, (peak / sentenceMaxPeak) * mainAmp);
|
|
212
212
|
// 로딩 시 placeholder(0.15); 계산 후엔 렌더 높이(waveBarPx)가 MIN_BAR_PX 이하면 숨김(0).
|
|
@@ -233,14 +233,14 @@ export default function WaveForm(props) {
|
|
|
233
233
|
거기는 정말로 아무 소리도 안 난다.
|
|
234
234
|
작은 막대를 숨기는 옛 규칙 하나만 남는다(소음이 있든 없든 같다). */
|
|
235
235
|
const val = eff == null ? 0.15 : (belowMin ? 0 : waveBarVal(eff));
|
|
236
|
-
const
|
|
236
|
+
const hidden = belowMin;
|
|
237
237
|
// 가로 위치 비율(0=왼쪽 lightMain ~ 1=오른쪽 lightSub) — 바 색을 위치에 따라 믹스
|
|
238
238
|
const mix = barsCountForMix > 1 ? i / (barsCountForMix - 1) : 0;
|
|
239
239
|
const style = { ['--val']: val, ['--i']: i, ['--mix']: mix };
|
|
240
240
|
// MIN_BAR_PX 이하로 숨겨지는 바는 최초 등장 애니메이션에서도 잠깐 비쳐 보이지 않도록 애니메이션 자체를 건너뛴다
|
|
241
|
-
const className =
|
|
241
|
+
const className = hidden ? 'audio-player__bar audio-player__bar--hidden' : 'audio-player__bar';
|
|
242
242
|
return _jsx("div", { className: className, style: style }, i);
|
|
243
|
-
}), [barsCountForMix, wavePeaks, sentenceMaxPeak, mainAmp, waveHeightPx,
|
|
243
|
+
}), [barsCountForMix, wavePeaks, sentenceMaxPeak, mainAmp, waveHeightPx, bandCounts]);
|
|
244
244
|
// 초기 progress CSS 변수 값 (React 상태 기준 — RAF 루프가 이후 직접 갱신합니다)
|
|
245
245
|
const progressStyle = { '--progress': String(Math.max(0, Math.min(1, progress))) };
|
|
246
246
|
// 컨테이너 실제 폭에 맞춰 정밀 계산한 바 간격 — outer에 걸어 .audio-player__wave-inner 등
|
|
@@ -292,10 +292,10 @@ export default function WaveForm(props) {
|
|
|
292
292
|
* **정말로** 문장보다 커질 때만 자라고, 그때는 그 큰 막대가 꼭대기에 닿는다.
|
|
293
293
|
*/
|
|
294
294
|
/* 가장 큰 소음 막대가 `M × R` 이므로((89)) 그것이 곧 그릴 최대다 — 소음이 아직 안 왔으면 1 */
|
|
295
|
-
const
|
|
295
|
+
const drawMax = (!noiseSrc || !noisePeaks || !noisePeaks.length) ? 1 : Math.max(1, noiseAmp);
|
|
296
296
|
const outerStyle = {
|
|
297
297
|
'--bar-gap': `${barGap}px`,
|
|
298
|
-
'--wave-h': `${waveBarPx(
|
|
298
|
+
'--wave-h': `${waveBarPx(drawMax)}px`,
|
|
299
299
|
'--wave-h1': `${WAVE_H1_PX}px`,
|
|
300
300
|
};
|
|
301
301
|
const noiseReady = !!noisePeaks;
|
|
@@ -32,7 +32,7 @@ export default function CategoryIcon({ icon, label, tooltip, wrapperStyle, color
|
|
|
32
32
|
* 서면 (105) 의 반대 거짓이다. 그래서 **말풍선이 설 때만** 이 표를 붙이고, 호버 테두리는
|
|
33
33
|
* `--interactive` 에만 산다. 보여 줄 글이 없으면(`tipText` 없음) 올려도 아무 일이 없으니 표도 없다.
|
|
34
34
|
*/
|
|
35
|
-
const
|
|
35
|
+
const interactive = useHover && Boolean(tipText);
|
|
36
36
|
const [anchorRect, setAnchorRect] = useState(null);
|
|
37
37
|
useLayoutEffect(() => {
|
|
38
38
|
if (hover)
|
|
@@ -85,5 +85,5 @@ export default function CategoryIcon({ icon, label, tooltip, wrapperStyle, color
|
|
|
85
85
|
setHover(v => !v);
|
|
86
86
|
}
|
|
87
87
|
}, []);
|
|
88
|
-
return (_jsxs("span", { ref: ref, className: cx('category-icon__wrap',
|
|
88
|
+
return (_jsxs("span", { ref: ref, className: cx('category-icon__wrap', interactive && 'category-icon__wrap--interactive'), onPointerEnter: handleHoverOn, onPointerLeave: handleHoverOff, onFocus: handleHoverOn, onBlur: handleHoverOff, onKeyDown: handleKeyDown, role: "button", tabIndex: 0, "aria-label": typeof tipText === 'string' ? t('common.descriptionFor', { name: tipText, defaultValue: `${tipText} 설명` }) : undefined, "aria-haspopup": !!tipText, "aria-expanded": hover, "data-tooltip-ignore": true, children: [_jsx(Icon, { icon: icon, label: label, className: cls, imgClassName: "category-icon__image", maskClassName: "category-icon__mask", svgClassName: "category-icon__svg", wrapperStyle: resolvedWrapperStyle, size: svgSize }), hover && tipText && (_jsx(InfoTooltip, { anchorRect: anchorRect, text: typeof tipText === 'string' ? tipText : undefined, offset: 8, onClose: handleTooltipClose, className: "category-icon__tooltip", ariaLabel: typeof tipText === 'string' ? tipText : undefined }))] }));
|
|
89
89
|
}
|