@ihabdevteam/core 0.150.0 → 0.151.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 +51 -0
- package/dist/components.cjs.js +199 -186
- package/dist/components.esm.css +5 -5
- package/dist/hooks.cjs.js +5 -1
- package/dist/i18n.cjs.js +3 -3
- package/dist/ihabdevteam-core-tokens.css +1 -1
- package/dist/ihabdevteam-core.cjs.js +200 -187
- package/dist/ihabdevteam-core.css +1 -1
- package/dist/ihabdevteam-core.d.ts +405 -67
- package/dist/packages/core/src/components/Accordion.d.ts +6 -0
- package/dist/packages/core/src/components/Accordion.js +5 -2
- package/dist/packages/core/src/components/Alert.d.ts +7 -1
- package/dist/packages/core/src/components/Alert.js +12 -3
- package/dist/packages/core/src/components/BarChart.js +7 -2
- package/dist/packages/core/src/components/Board.d.ts +5 -2
- package/dist/packages/core/src/components/Board.js +9 -3
- package/dist/packages/core/src/components/BottomNav.js +5 -4
- package/dist/packages/core/src/components/Breadcrumb.css +9 -0
- package/dist/packages/core/src/components/Breadcrumb.d.ts +12 -1
- package/dist/packages/core/src/components/Breadcrumb.js +8 -2
- package/dist/packages/core/src/components/BulkBar.css +2 -0
- package/dist/packages/core/src/components/BulkBar.js +21 -2
- package/dist/packages/core/src/components/Button.d.ts +12 -2
- package/dist/packages/core/src/components/Button.js +70 -13
- package/dist/packages/core/src/components/Calendar.d.ts +6 -1
- package/dist/packages/core/src/components/Calendar.js +3 -3
- package/dist/packages/core/src/components/DatePicker.d.ts +7 -1
- package/dist/packages/core/src/components/DatePicker.js +25 -15
- package/dist/packages/core/src/components/Dropdown.d.ts +1 -0
- package/dist/packages/core/src/components/Dropdown.js +7 -5
- package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.d.ts +12 -1
- package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +25 -8
- package/dist/packages/core/src/components/EmptyState.d.ts +5 -1
- package/dist/packages/core/src/components/EmptyState.js +4 -1
- package/dist/packages/core/src/components/ErrorState.d.ts +5 -2
- package/dist/packages/core/src/components/ErrorState.js +3 -1
- package/dist/packages/core/src/components/FileUpload.d.ts +9 -1
- package/dist/packages/core/src/components/FileUpload.js +8 -2
- package/dist/packages/core/src/components/FilterSectionInfo.js +14 -2
- package/dist/packages/core/src/components/Header.js +8 -5
- package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -1
- package/dist/packages/core/src/components/Icon.d.ts +2 -1
- package/dist/packages/core/src/components/Icon.js +4 -1
- package/dist/packages/core/src/components/Loader.css +28 -0
- package/dist/packages/core/src/components/Loader.d.ts +5 -1
- package/dist/packages/core/src/components/Loader.js +4 -2
- package/dist/packages/core/src/components/NoticeBox.d.ts +1 -1
- package/dist/packages/core/src/components/NoticeBox.js +15 -6
- package/dist/packages/core/src/components/PanelRow.js +2 -1
- package/dist/packages/core/src/components/PassCard/PassCard.d.ts +8 -2
- package/dist/packages/core/src/components/PassCard/PassCard.js +25 -10
- package/dist/packages/core/src/components/PassCard/passCardProduct.d.ts +1 -1
- package/dist/packages/core/src/components/PassCard/passCardProduct.js +11 -2
- package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +11 -2
- package/dist/packages/core/src/components/Popup/ConfirmPopup.js +18 -3
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +10 -8
- package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
- package/dist/packages/core/src/components/Popup/LoadTablePopup.css +7 -0
- package/dist/packages/core/src/components/Popup/LoadTablePopup.js +27 -5
- package/dist/packages/core/src/components/Popup/Popup.d.ts +8 -0
- package/dist/packages/core/src/components/Popup/Popup.js +6 -3
- package/dist/packages/core/src/components/Popup/SaveTablePopup.d.ts +5 -1
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.css +3 -0
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.d.ts +5 -0
- package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +10 -4
- package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.css +9 -0
- package/dist/packages/core/src/components/ProgressIndicator.css +5 -0
- package/dist/packages/core/src/components/ProgressIndicator.d.ts +3 -1
- package/dist/packages/core/src/components/ProgressIndicator.js +5 -3
- package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.d.ts +4 -1
- package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +27 -15
- package/dist/packages/core/src/components/Rating.d.ts +8 -2
- package/dist/packages/core/src/components/Rating.js +22 -8
- package/dist/packages/core/src/components/Report/ReportDocument.d.ts +10 -7
- package/dist/packages/core/src/components/Report/ReportDocument.js +39 -13
- package/dist/packages/core/src/components/RingChart.d.ts +1 -1
- package/dist/packages/core/src/components/RingChart.js +1 -1
- package/dist/packages/core/src/components/SegmentedControl.d.ts +4 -0
- package/dist/packages/core/src/components/SegmentedControl.js +9 -1
- package/dist/packages/core/src/components/ShortcutBadge.css +16 -1
- package/dist/packages/core/src/components/ShortcutBadge.d.ts +1 -0
- package/dist/packages/core/src/components/SideNav.js +8 -2
- package/dist/packages/core/src/components/SummaryStrip.d.ts +1 -1
- package/dist/packages/core/src/components/SummaryStrip.js +2 -2
- package/dist/packages/core/src/components/Table.d.ts +1 -1
- package/dist/packages/core/src/components/Table.js +4 -1
- package/dist/packages/core/src/components/Tabs.d.ts +1 -0
- package/dist/packages/core/src/components/Tabs.js +4 -1
- package/dist/packages/core/src/components/TextField.css +5 -6
- package/dist/packages/core/src/components/TextField.js +46 -3
- package/dist/packages/core/src/components/Toast.js +16 -2
- package/dist/packages/core/src/components/Tooltip/Tooltip.css +3 -1
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +5 -2
- package/dist/packages/core/src/components/Tooltip/tooltipConstants.d.ts +10 -2
- package/dist/packages/core/src/components/Tooltip/tooltipConstants.js +10 -2
- package/dist/packages/core/src/components/TopBanner.d.ts +1 -1
- package/dist/packages/core/src/components/TopBanner.js +66 -8
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.d.ts +1 -0
- package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +65 -15
- package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +3 -2
- package/dist/packages/core/src/components/Training/NoisePlayerContext.js +10 -4
- package/dist/packages/core/src/components/Training/TrainingBlockButton.css +7 -0
- package/dist/packages/core/src/components/Training/TrainingBlockButton.js +3 -1
- package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +18 -5
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +93 -28
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +1 -0
- package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +16 -3
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +25 -9
- package/dist/packages/core/src/components/Training/TrainingStepCard.js +19 -3
- package/dist/packages/core/src/components/Training/TrainingStepTitle.js +15 -1
- package/dist/packages/core/src/components/Training/TrainingWordCard.css +16 -0
- package/dist/packages/core/src/components/Training/TrainingWordCard.d.ts +6 -0
- package/dist/packages/core/src/components/Training/TrainingWordCard.js +7 -2
- package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +2 -2
- package/dist/packages/core/src/components/Training/trainingMissStats.d.ts +10 -0
- package/dist/packages/core/src/components/Training/trainingMissStats.js +19 -1
- package/dist/packages/core/src/components/Training/useFloatingFocus.d.ts +13 -0
- package/dist/packages/core/src/components/Training/useFloatingFocus.js +59 -0
- package/dist/packages/core/src/components/Training/useNoiseFade.js +20 -11
- package/dist/packages/core/src/components/Training/useProgressTicker.d.ts +0 -18
- package/dist/packages/core/src/components/Training/useProgressTicker.js +59 -14
- package/dist/packages/core/src/components/Training/useTTSPlayback.d.ts +1 -1
- package/dist/packages/core/src/components/Training/useTTSPlayback.js +56 -25
- package/dist/packages/core/src/components/Training/useWaveReady.d.ts +4 -1
- package/dist/packages/core/src/components/Training/useWaveReady.js +17 -8
- package/dist/packages/core/src/components/UpdateAlert.css +4 -2
- package/dist/packages/core/src/components/UpdateAlert.d.ts +3 -1
- package/dist/packages/core/src/components/UpdateAlert.js +21 -1
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.d.ts +2 -0
- package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +12 -4
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.d.ts +6 -0
- package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +26 -13
- package/dist/packages/core/src/components/Word/WordTableBlock.js +24 -21
- package/dist/packages/core/src/components/Word/WordTableThumbnail.js +1 -1
- package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -10
- package/dist/packages/core/src/components/Word/useWordTablePrint.d.ts +1 -1
- package/dist/packages/core/src/components/Word/useWordTablePrint.js +9 -3
- package/dist/packages/core/src/components/Word/wordScoreStats.d.ts +14 -1
- package/dist/packages/core/src/components/Word/wordScoreStats.js +7 -3
- package/dist/packages/core/src/components/Word/wordTablePrint.js +15 -3
- package/dist/packages/core/src/components/Word/wordTableReview.d.ts +58 -1
- package/dist/packages/core/src/components/Word/wordTableReview.js +75 -17
- package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +9 -0
- package/dist/packages/core/src/components/Word/wordTableSettings.js +11 -0
- package/dist/packages/core/src/components/WordTable.css +10 -0
- package/dist/packages/core/src/components/WordTable.js +39 -26
- package/dist/packages/core/src/components/barChartTypes.d.ts +2 -2
- package/dist/packages/core/src/components/buttonTypes.d.ts +8 -0
- package/dist/packages/core/src/components/updateAlertStack.d.ts +2 -0
- package/dist/packages/core/src/components/updateAlertStack.js +68 -0
- package/dist/packages/core/src/components/useCalendarMonth.d.ts +4 -1
- package/dist/packages/core/src/components/useCalendarMonth.js +6 -3
- package/dist/packages/core/src/components/useComboboxSuggestions.d.ts +7 -0
- package/dist/packages/core/src/components/useComboboxSuggestions.js +21 -2
- package/dist/packages/core/src/components/wordTableLayout.d.ts +4 -0
- package/dist/packages/core/src/components/wordTableLayout.js +6 -0
- package/dist/packages/core/src/components.d.ts +2 -1
- package/dist/packages/core/src/components.js +1 -1
- package/dist/packages/core/src/hooks/bodyScrollLock.js +8 -2
- package/dist/packages/core/src/hooks/focusReturn.d.ts +12 -0
- package/dist/packages/core/src/hooks/focusReturn.js +49 -0
- package/dist/packages/core/src/hooks/touchToggle.d.ts +22 -0
- package/dist/packages/core/src/hooks/touchToggle.js +114 -0
- package/dist/packages/core/src/hooks/useAnchoredMenu.d.ts +12 -3
- package/dist/packages/core/src/hooks/useAnchoredMenu.js +37 -3
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.d.ts +4 -2
- package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +23 -11
- package/dist/packages/core/src/hooks/useIcon.js +29 -5
- package/dist/packages/core/src/hooks/useLucideIcon.d.ts +4 -0
- package/dist/packages/core/src/hooks/useLucideIcon.js +4 -0
- package/dist/packages/core/src/hooks/usePendingPress.d.ts +9 -0
- package/dist/packages/core/src/hooks/usePendingPress.js +26 -0
- package/dist/packages/core/src/hooks/usePrefersReducedMotion.d.ts +15 -0
- package/dist/packages/core/src/hooks/usePrefersReducedMotion.js +26 -0
- package/dist/packages/core/src/hooks/useTablePaging.d.ts +27 -2
- package/dist/packages/core/src/hooks/useTablePaging.js +37 -5
- package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +7 -0
- package/dist/packages/core/src/hooks/useWordAttributeFilter.js +25 -0
- package/dist/packages/core/src/i18n/index.js +9 -1
- package/dist/packages/core/src/i18n/locales/en.json +24 -2
- package/dist/packages/core/src/i18n/locales/ko.json +24 -2
- package/dist/packages/core/src/tokens.css +5 -0
- package/dist/packages/core/src/types/wordSearch.d.ts +12 -0
- package/dist/packages/core/src/utils/csv.d.ts +7 -2
- package/dist/packages/core/src/utils/csv.js +12 -5
- package/dist/packages/core/src/utils/dailySelectionUtils.js +44 -14
- package/dist/packages/core/src/utils/date.d.ts +8 -0
- package/dist/packages/core/src/utils/date.js +44 -10
- package/dist/packages/core/src/utils/format.d.ts +1 -1
- package/dist/packages/core/src/utils/format.js +6 -7
- package/dist/packages/core/src/utils/noiseSettingsStore.d.ts +7 -3
- package/dist/packages/core/src/utils/phonemeConvert.d.ts +9 -0
- package/dist/packages/core/src/utils/phonemeConvert.js +11 -0
- package/dist/packages/core/src/utils/phonemePairSearch.d.ts +7 -1
- package/dist/packages/core/src/utils/phonemePairSearch.js +45 -21
- package/dist/packages/core/src/utils/safeHref.d.ts +8 -0
- package/dist/packages/core/src/utils/safeHref.js +24 -0
- package/dist/packages/core/src/utils/savedWordTables.d.ts +19 -3
- package/dist/packages/core/src/utils/savedWordTables.js +27 -19
- package/dist/packages/core/src/utils/textSort.d.ts +9 -0
- package/dist/packages/core/src/utils/textSort.js +37 -1
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +31 -0
- package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +163 -0
- package/dist/packages/core/src/utils/wordContrast.d.ts +4 -2
- package/dist/packages/core/src/utils/wordContrast.js +8 -2
- package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +5 -2
- package/dist/packages/core/src/utils/wordTrainingGenerators.js +52 -2
- package/dist/packages/core/src/utils/wordTrainingPool.js +2 -2
- package/dist/packages/core/src/utils/wordTrainingRuntime.js +15 -7
- package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.d.ts +1 -1
- package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.js +5 -4
- package/dist/packages/core/src/utils/wornDate.d.ts +12 -0
- package/dist/packages/core/src/utils/wornDate.js +15 -0
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +2 -2
- package/package.json +1 -1
|
@@ -52,8 +52,14 @@ function lockBodyScrollImpl(withPadding) {
|
|
|
52
52
|
paddingLockCount = Math.max(0, paddingLockCount - 1);
|
|
53
53
|
// withPadding 오버레이가 모두 닫히면 즉시 여백을 복원한다 — 남아 있는 일반 팝업은
|
|
54
54
|
// scrollbar-gutter 여백을 없앨 이유가 없다.
|
|
55
|
-
|
|
56
|
-
|
|
55
|
+
/* 원래 값이 없었으면 속성을 **지운다** — '' 대입은 "되돌림" 이 아니라 "빈 값을 쓴다" 로 읽혀, 인라인 스타일을 다루는
|
|
56
|
+
도구·다른 코드가 남은 선언으로 볼 수 있었다 */
|
|
57
|
+
if (paddingLockCount === 0) {
|
|
58
|
+
if (prevPaddingRight)
|
|
59
|
+
document.body.style.paddingRight = prevPaddingRight;
|
|
60
|
+
else
|
|
61
|
+
document.body.style.removeProperty('padding-right');
|
|
62
|
+
}
|
|
57
63
|
}
|
|
58
64
|
if (lockCount === 0 && hasDoc) {
|
|
59
65
|
document.body.style.overflow = prevOverflow;
|
|
@@ -2,3 +2,15 @@
|
|
|
2
2
|
export declare function rememberFocusReturn(panel: Element | null, previouslyFocused: HTMLElement | null): void;
|
|
3
3
|
/** 실제로 돌려줄 곳 — 문서에 붙어 있으면 그대로, 떼어졌으면 그것을 품었던 창이 적어 둔 곳을 따라간다 */
|
|
4
4
|
export declare function resolveFocusReturn(previouslyFocused: HTMLElement | null): HTMLElement | null;
|
|
5
|
+
/**
|
|
6
|
+
* 사라지거나 inert 가 되는 영역(BulkBar·닫힌 Alert·NoticeBox·UpdateAlert) **안에 포커스가 있을 때** 바깥으로 옮긴다 —
|
|
7
|
+
* 그냥 두면 포커스가 body 로 떨어져 다음 Tab 이 문서 맨 앞에서 시작했다.
|
|
8
|
+
*
|
|
9
|
+
* 1. 그 영역을 연 요소(`opener` — 영역이 나타날 때 포커스였던 곳)가 아직 받을 수 있으면 그곳
|
|
10
|
+
* 2. 없으면 문서 순서로 **영역 바로 앞의** 포커스 받을 수 있는 요소
|
|
11
|
+
* 3. 앞에 없으면 뒤의 첫 요소
|
|
12
|
+
*
|
|
13
|
+
* `container` 는 아직 문서에 붙어 있어야 순서를 잰다 — 떼기 전에 부르거나 `anchor`(자리를 대신 잴 요소)를 준다.
|
|
14
|
+
* 옮긴 요소를 돌려준다(못 옮기면 null).
|
|
15
|
+
*/
|
|
16
|
+
export declare function moveFocusOutside(container: Element | null, opener?: Element | null, anchor?: Element | null): HTMLElement | null;
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { FOCUSABLE_SELECTOR } from './useFocusTrap';
|
|
1
3
|
/**
|
|
2
4
|
* 창(Popup·Dialog·TrainingPagePopup)이 닫힐 때 포커스를 돌려줄 곳 — 창끼리 한 표를 쓴다.
|
|
3
5
|
*
|
|
@@ -25,3 +27,50 @@ export function resolveFocusReturn(previouslyFocused) {
|
|
|
25
27
|
}
|
|
26
28
|
return 대상;
|
|
27
29
|
}
|
|
30
|
+
/* 포커스를 받을 수 있는가 — 붙어 있고, 막히지 않았고, inert·aria-hidden·hidden 아래가 아니다(레이아웃은 재지 않는다: 시험 환경에 없다) */
|
|
31
|
+
function 받을수있나(el) {
|
|
32
|
+
if (!el || !el.isConnected || el === document.body)
|
|
33
|
+
return false;
|
|
34
|
+
if (typeof el.focus !== 'function')
|
|
35
|
+
return false;
|
|
36
|
+
if (el.disabled)
|
|
37
|
+
return false;
|
|
38
|
+
if (el instanceof HTMLInputElement && el.type === 'hidden')
|
|
39
|
+
return false;
|
|
40
|
+
if (el.closest('[inert],[aria-hidden="true"],[hidden]'))
|
|
41
|
+
return false;
|
|
42
|
+
return true;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* 사라지거나 inert 가 되는 영역(BulkBar·닫힌 Alert·NoticeBox·UpdateAlert) **안에 포커스가 있을 때** 바깥으로 옮긴다 —
|
|
46
|
+
* 그냥 두면 포커스가 body 로 떨어져 다음 Tab 이 문서 맨 앞에서 시작했다.
|
|
47
|
+
*
|
|
48
|
+
* 1. 그 영역을 연 요소(`opener` — 영역이 나타날 때 포커스였던 곳)가 아직 받을 수 있으면 그곳
|
|
49
|
+
* 2. 없으면 문서 순서로 **영역 바로 앞의** 포커스 받을 수 있는 요소
|
|
50
|
+
* 3. 앞에 없으면 뒤의 첫 요소
|
|
51
|
+
*
|
|
52
|
+
* `container` 는 아직 문서에 붙어 있어야 순서를 잰다 — 떼기 전에 부르거나 `anchor`(자리를 대신 잴 요소)를 준다.
|
|
53
|
+
* 옮긴 요소를 돌려준다(못 옮기면 null).
|
|
54
|
+
*/
|
|
55
|
+
export function moveFocusOutside(container, opener, anchor) {
|
|
56
|
+
const 기준 = container?.isConnected ? container : anchor?.isConnected ? anchor : null;
|
|
57
|
+
if (받을수있나(opener) && !(container && container.contains(opener))) {
|
|
58
|
+
opener.focus();
|
|
59
|
+
return opener;
|
|
60
|
+
}
|
|
61
|
+
if (!기준)
|
|
62
|
+
return null;
|
|
63
|
+
const 모두 = Array.from(document.querySelectorAll(FOCUSABLE_SELECTOR))
|
|
64
|
+
.filter((el) => !(container && container.contains(el)) && 받을수있나(el));
|
|
65
|
+
let 앞 = null;
|
|
66
|
+
let 뒤 = null;
|
|
67
|
+
for (const el of 모두) {
|
|
68
|
+
if (기준.compareDocumentPosition(el) & Node.DOCUMENT_POSITION_PRECEDING)
|
|
69
|
+
앞 = el;
|
|
70
|
+
else if (!뒤)
|
|
71
|
+
뒤 = el;
|
|
72
|
+
}
|
|
73
|
+
const 대상 = 앞 ?? 뒤;
|
|
74
|
+
대상?.focus();
|
|
75
|
+
return 대상;
|
|
76
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type RefObject } from 'react';
|
|
2
|
+
/** 터치 포인터 이벤트인가 */
|
|
3
|
+
export declare function isTouchPointer(e: {
|
|
4
|
+
pointerType?: string;
|
|
5
|
+
} | null | undefined): boolean;
|
|
6
|
+
/** 방금(1초 안) 화면을 톡했나 — 톡 뒤 따라오는 focus·호환 마우스 이벤트를 거를 때 */
|
|
7
|
+
export declare function recentlyTouched(ms?: number): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* 톡으로 뒤집는 손잡이. `open` 은 지금 열림, `setOpen(next, el)` 은 열기(el 은 앵커)·닫기.
|
|
10
|
+
* `rootRef` 는 트리거를 품은 요소 — 이 밖(툴팁 판 `.tooltip-content` 도 제외)을 톡하면 닫는다.
|
|
11
|
+
*
|
|
12
|
+
* - `touchPress()` — 지금 누름이 이 트리거 위의 **톡**이었으면 뒤집고 true. 켜진 트리거는 click 에서, 막힌 단추(click 이 안 온다)는
|
|
13
|
+
* pointerup 에서 부른다. click 에서 뒤집는 까닭: pointerup 에서 열면 뒤따르는 호환 click 을 툴팁이 "바깥 누름" 으로 받아 곧바로 닫았다.
|
|
14
|
+
* - 누를 때의 열림은 **문서 캡처 단계에서 먼저** 적어 둔다 — 열린 툴팁의 "바깥 누름 닫기" 가 같은 누름에 먼저 닫아도 다시 열리지 않게.
|
|
15
|
+
* - 막힌 native 단추에는 포인터 이벤트조차 안 오는 브라우저가 있다 — `rootRef` 의 부모(감싼 요소)에서 touchend 로 받는다(`fallbackOnParent`).
|
|
16
|
+
*/
|
|
17
|
+
export declare function useTouchToggle(open: boolean, setOpen: (next: boolean, el: Element | null) => void, rootRef: RefObject<Element | null>, { enabled, fallbackOnParent }?: {
|
|
18
|
+
enabled?: boolean;
|
|
19
|
+
fallbackOnParent?: boolean;
|
|
20
|
+
}): {
|
|
21
|
+
touchPress: (el?: Element | null) => boolean;
|
|
22
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, useEffect, useRef } from 'react';
|
|
3
|
+
import { useLatestRef } from './useLatestRef';
|
|
4
|
+
/*
|
|
5
|
+
* 터치에서는 **톡으로 열고 닫는** 툴팁(내부).
|
|
6
|
+
*
|
|
7
|
+
* 마우스용으로 짠 호버 툴팁(pointerenter 열기 · pointerleave 닫기 · focus 열기)은 터치에서 이렇게 된다 —
|
|
8
|
+
* 톡하면 pointerenter 로 열리고 손을 떼자마자 pointerleave 로 닫힌다(터치 포인터는 hover 가 없어 떼면 반드시 leave 가 온다).
|
|
9
|
+
* Android Chrome 은 이어서 단추에 초점을 줘 focus 로 **다시 열리고**, iOS 는 초점을 안 줘 닫힌 채다. 결국 막힌 단추의 사유·점수 칩
|
|
10
|
+
* 설명·필터 설명(?)·낱말 정보(i)를 터치 기기에서는 읽을 수 없었다.
|
|
11
|
+
*
|
|
12
|
+
* 마우스 동작은 그대로 두고, pointerType 이 touch 일 때만: 들어오고 나감은 무시 · 뗄 때 뒤집기 · 방금 톡으로 온 focus 는 무시 ·
|
|
13
|
+
* 바깥을 톡하면 닫기.
|
|
14
|
+
*/
|
|
15
|
+
let 마지막터치 = 0;
|
|
16
|
+
let 붙음 = false;
|
|
17
|
+
function 붙이기() {
|
|
18
|
+
if (붙음 || typeof document === 'undefined')
|
|
19
|
+
return;
|
|
20
|
+
붙음 = true;
|
|
21
|
+
document.addEventListener('pointerdown', (e) => { if (e.pointerType === 'touch')
|
|
22
|
+
마지막터치 = Date.now(); }, true);
|
|
23
|
+
document.addEventListener('touchstart', () => { 마지막터치 = Date.now(); }, { capture: true, passive: true });
|
|
24
|
+
}
|
|
25
|
+
붙이기();
|
|
26
|
+
/** 터치 포인터 이벤트인가 */
|
|
27
|
+
export function isTouchPointer(e) {
|
|
28
|
+
return !!e && e.pointerType === 'touch';
|
|
29
|
+
}
|
|
30
|
+
/** 방금(1초 안) 화면을 톡했나 — 톡 뒤 따라오는 focus·호환 마우스 이벤트를 거를 때 */
|
|
31
|
+
export function recentlyTouched(ms = 1000) {
|
|
32
|
+
붙이기();
|
|
33
|
+
return Date.now() - 마지막터치 < ms;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* 톡으로 뒤집는 손잡이. `open` 은 지금 열림, `setOpen(next, el)` 은 열기(el 은 앵커)·닫기.
|
|
37
|
+
* `rootRef` 는 트리거를 품은 요소 — 이 밖(툴팁 판 `.tooltip-content` 도 제외)을 톡하면 닫는다.
|
|
38
|
+
*
|
|
39
|
+
* - `touchPress()` — 지금 누름이 이 트리거 위의 **톡**이었으면 뒤집고 true. 켜진 트리거는 click 에서, 막힌 단추(click 이 안 온다)는
|
|
40
|
+
* pointerup 에서 부른다. click 에서 뒤집는 까닭: pointerup 에서 열면 뒤따르는 호환 click 을 툴팁이 "바깥 누름" 으로 받아 곧바로 닫았다.
|
|
41
|
+
* - 누를 때의 열림은 **문서 캡처 단계에서 먼저** 적어 둔다 — 열린 툴팁의 "바깥 누름 닫기" 가 같은 누름에 먼저 닫아도 다시 열리지 않게.
|
|
42
|
+
* - 막힌 native 단추에는 포인터 이벤트조차 안 오는 브라우저가 있다 — `rootRef` 의 부모(감싼 요소)에서 touchend 로 받는다(`fallbackOnParent`).
|
|
43
|
+
*/
|
|
44
|
+
export function useTouchToggle(open, setOpen, rootRef, { enabled = true, fallbackOnParent = false } = {}) {
|
|
45
|
+
const openRef = useLatestRef(open);
|
|
46
|
+
const setOpenRef = useLatestRef(setOpen);
|
|
47
|
+
/** 이 트리거 위 톡 누름 때의 열림(null 이면 톡 누름이 아니었다) */
|
|
48
|
+
const 누를때열림 = useRef(null);
|
|
49
|
+
const 뒤집은시각 = useRef(0);
|
|
50
|
+
useEffect(() => {
|
|
51
|
+
if (!enabled || typeof document === 'undefined')
|
|
52
|
+
return;
|
|
53
|
+
const 누름 = (ev) => {
|
|
54
|
+
const pe = ev;
|
|
55
|
+
const t = ev.target;
|
|
56
|
+
const root = rootRef.current;
|
|
57
|
+
const 안 = !!(t && root && root.contains(t));
|
|
58
|
+
if (pe.pointerType !== 'touch') {
|
|
59
|
+
if (안)
|
|
60
|
+
누를때열림.current = null;
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
if (안) {
|
|
64
|
+
누를때열림.current = openRef.current;
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
누를때열림.current = null;
|
|
68
|
+
if (openRef.current && !(t && t.closest?.('.tooltip-content')))
|
|
69
|
+
setOpenRef.current(false, root);
|
|
70
|
+
};
|
|
71
|
+
document.addEventListener('pointerdown', 누름, true);
|
|
72
|
+
return () => document.removeEventListener('pointerdown', 누름, true);
|
|
73
|
+
}, [enabled, rootRef, openRef, setOpenRef]);
|
|
74
|
+
const touchPress = useCallback((el) => {
|
|
75
|
+
const 전 = 누를때열림.current;
|
|
76
|
+
if (전 === null)
|
|
77
|
+
return false;
|
|
78
|
+
누를때열림.current = null;
|
|
79
|
+
뒤집은시각.current = Date.now();
|
|
80
|
+
setOpenRef.current(!전, el ?? rootRef.current);
|
|
81
|
+
return true;
|
|
82
|
+
}, [rootRef, setOpenRef]);
|
|
83
|
+
/* 막힌 native 단추 — 포인터 이벤트가 안 오면 감싼 요소에서 톡(touchstart→touchend)을 받아 자리로 가린다 */
|
|
84
|
+
useEffect(() => {
|
|
85
|
+
if (!enabled || !fallbackOnParent)
|
|
86
|
+
return;
|
|
87
|
+
const root = rootRef.current;
|
|
88
|
+
const parent = root?.parentElement;
|
|
89
|
+
if (!root || !parent)
|
|
90
|
+
return;
|
|
91
|
+
let 시작때열림 = null;
|
|
92
|
+
const 안에있나 = (e) => {
|
|
93
|
+
const p = e.changedTouches?.[0];
|
|
94
|
+
if (!p)
|
|
95
|
+
return false;
|
|
96
|
+
const r = root.getBoundingClientRect();
|
|
97
|
+
return p.clientX >= r.left && p.clientX <= r.right && p.clientY >= r.top && p.clientY <= r.bottom;
|
|
98
|
+
};
|
|
99
|
+
const 시작 = (e) => { 시작때열림 = 안에있나(e) ? openRef.current : null; };
|
|
100
|
+
const 끝 = (e) => {
|
|
101
|
+
const 전 = 시작때열림;
|
|
102
|
+
시작때열림 = null;
|
|
103
|
+
if (전 === null || Date.now() - 뒤집은시각.current < 700 || !안에있나(e))
|
|
104
|
+
return;
|
|
105
|
+
누를때열림.current = null;
|
|
106
|
+
뒤집은시각.current = Date.now();
|
|
107
|
+
setOpenRef.current(!전, root);
|
|
108
|
+
};
|
|
109
|
+
parent.addEventListener('touchstart', 시작, { passive: true });
|
|
110
|
+
parent.addEventListener('touchend', 끝);
|
|
111
|
+
return () => { parent.removeEventListener('touchstart', 시작); parent.removeEventListener('touchend', 끝); };
|
|
112
|
+
}, [enabled, fallbackOnParent, rootRef, openRef, setOpenRef]);
|
|
113
|
+
return { touchPress };
|
|
114
|
+
}
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { type CSSProperties, type RefObject } from 'react';
|
|
2
2
|
export interface UseAnchoredMenuOptions {
|
|
3
|
-
/** 트리거의 getBoundingClientRect()로부터 메뉴에 적용할 style을 계산 */
|
|
4
|
-
computeStyle: (triggerRect: DOMRect) => CSSProperties;
|
|
3
|
+
/** 트리거의 getBoundingClientRect()로부터 메뉴에 적용할 style을 계산. 둘째 인자는 트리거 요소(층 계산 `anchoredMenuZIndex` 등에 쓴다) */
|
|
4
|
+
computeStyle: (triggerRect: DOMRect, trigger: HTMLElement) => CSSProperties;
|
|
5
5
|
/** 열려있는 동안 스크롤 발생 시 동작 (메뉴 내부 스크롤은 항상 무시) — 기본 'close' */
|
|
6
6
|
onScroll?: 'close' | 'reposition' | 'ignore';
|
|
7
|
-
/** 열려있는 동안 윈도우
|
|
7
|
+
/** 열려있는 동안 윈도우 리사이즈·기기 회전(orientationchange) 발생 시 동작 — 기본 'close' */
|
|
8
8
|
onResize?: 'close' | 'reposition' | 'ignore';
|
|
9
9
|
/** 바깥클릭 판정에서 제외할 CSS selector (예: 메뉴 안에 중첩 포탈되는 다른 드롭다운) */
|
|
10
10
|
ignoreOutsideClickSelector?: string;
|
|
@@ -19,6 +19,15 @@ export interface UseAnchoredMenuResult<Trigger extends HTMLElement, Menu extends
|
|
|
19
19
|
triggerRef: RefObject<Trigger | null>;
|
|
20
20
|
menuRef: RefObject<Menu | null>;
|
|
21
21
|
}
|
|
22
|
+
/**
|
|
23
|
+
* body 로 빠지는 메뉴(Dropdown·DatePicker·Header·BottomNav 더보기)의 층.
|
|
24
|
+
*
|
|
25
|
+
* 전에는 9999·10000 을 박아 **토스트·알림 위** 에 떴다. 토큰 서열은
|
|
26
|
+
* 페이지 < BulkBar(`--z-bulk-bar`) < 메뉴(`--z-menu`) < 창(Popup·Dialog `--z-modal`) < 툴팁 < 알림·토스트 다.
|
|
27
|
+
* 다만 창·훈련 팝업·툴팁·달력 **안에서** 연 메뉴는 그 층보다 위여야 한다 — 메뉴는 body 로 빠지므로
|
|
28
|
+
* 트리거 조상 가운데 가장 높은 z-index 바로 위(없으면 `--z-menu`)로 둔다. 그 층들은 모두 알림·토스트 아래다.
|
|
29
|
+
*/
|
|
30
|
+
export declare function anchoredMenuZIndex(trigger: Element | null | undefined): string;
|
|
22
31
|
/**
|
|
23
32
|
* 트리거 요소에 fixed 포지션으로 앵커링되는 메뉴/드롭다운/팝오버의 공용 로직.
|
|
24
33
|
* 열림상태 + 위치 계산 + 바깥클릭/스크롤/리사이즈 처리를 담당하고, 렌더링(포탈 등)은
|
|
@@ -1,6 +1,25 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
3
|
import { pushEsc } from './escStack';
|
|
4
|
+
/**
|
|
5
|
+
* body 로 빠지는 메뉴(Dropdown·DatePicker·Header·BottomNav 더보기)의 층.
|
|
6
|
+
*
|
|
7
|
+
* 전에는 9999·10000 을 박아 **토스트·알림 위** 에 떴다. 토큰 서열은
|
|
8
|
+
* 페이지 < BulkBar(`--z-bulk-bar`) < 메뉴(`--z-menu`) < 창(Popup·Dialog `--z-modal`) < 툴팁 < 알림·토스트 다.
|
|
9
|
+
* 다만 창·훈련 팝업·툴팁·달력 **안에서** 연 메뉴는 그 층보다 위여야 한다 — 메뉴는 body 로 빠지므로
|
|
10
|
+
* 트리거 조상 가운데 가장 높은 z-index 바로 위(없으면 `--z-menu`)로 둔다. 그 층들은 모두 알림·토스트 아래다.
|
|
11
|
+
*/
|
|
12
|
+
export function anchoredMenuZIndex(trigger) {
|
|
13
|
+
let 가장높은 = 0;
|
|
14
|
+
if (trigger && typeof window !== 'undefined' && typeof window.getComputedStyle === 'function') {
|
|
15
|
+
for (let el = trigger; el && el !== document.body && el !== document.documentElement; el = el.parentElement) {
|
|
16
|
+
const z = parseInt(window.getComputedStyle(el).zIndex, 10);
|
|
17
|
+
if (!Number.isNaN(z) && z > 가장높은)
|
|
18
|
+
가장높은 = z;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return 가장높은 > 0 ? `max(var(--z-menu, 1500), ${가장높은 + 1})` : 'var(--z-menu, 1500)';
|
|
22
|
+
}
|
|
4
23
|
/**
|
|
5
24
|
* 트리거 요소에 fixed 포지션으로 앵커링되는 메뉴/드롭다운/팝오버의 공용 로직.
|
|
6
25
|
* 열림상태 + 위치 계산 + 바깥클릭/스크롤/리사이즈 처리를 담당하고, 렌더링(포탈 등)은
|
|
@@ -15,7 +34,7 @@ export function useAnchoredMenu({ computeStyle, onScroll = 'close', onResize = '
|
|
|
15
34
|
const recalc = useCallback(() => {
|
|
16
35
|
if (!triggerRef.current)
|
|
17
36
|
return;
|
|
18
|
-
setStyle(computeStyle(triggerRef.current.getBoundingClientRect()));
|
|
37
|
+
setStyle(computeStyle(triggerRef.current.getBoundingClientRect(), triggerRef.current));
|
|
19
38
|
}, [computeStyle]);
|
|
20
39
|
useEffect(() => {
|
|
21
40
|
if (!open)
|
|
@@ -50,15 +69,30 @@ export function useAnchoredMenu({ computeStyle, onScroll = 'close', onResize = '
|
|
|
50
69
|
else if (onResize === 'close')
|
|
51
70
|
setOpen(false);
|
|
52
71
|
};
|
|
53
|
-
|
|
72
|
+
/* 회전(orientationchange)도 함께 — iOS Safari 는 회전 직후 resize 가 늦거나 옛 크기로 오는 일이 있어 회전 뒤 한 번 더 잰다 */
|
|
73
|
+
let 회전타이머 = null;
|
|
74
|
+
const handleOrientation = () => {
|
|
75
|
+
handleResize();
|
|
76
|
+
if (회전타이머)
|
|
77
|
+
clearTimeout(회전타이머);
|
|
78
|
+
회전타이머 = setTimeout(() => { 회전타이머 = null; if (onResize === 'reposition')
|
|
79
|
+
recalc(); }, 250);
|
|
80
|
+
};
|
|
81
|
+
if (onResize !== 'ignore') {
|
|
54
82
|
window.addEventListener('resize', handleResize);
|
|
83
|
+
window.addEventListener('orientationchange', handleOrientation);
|
|
84
|
+
}
|
|
55
85
|
return () => {
|
|
56
86
|
document.removeEventListener('mousedown', handleOutsideClick);
|
|
57
87
|
document.removeEventListener('pointerdown', handleOutsideClick);
|
|
58
88
|
if (onScroll !== 'ignore')
|
|
59
89
|
window.removeEventListener('scroll', handleScroll, true);
|
|
60
|
-
if (onResize !== 'ignore')
|
|
90
|
+
if (onResize !== 'ignore') {
|
|
61
91
|
window.removeEventListener('resize', handleResize);
|
|
92
|
+
window.removeEventListener('orientationchange', handleOrientation);
|
|
93
|
+
}
|
|
94
|
+
if (회전타이머)
|
|
95
|
+
clearTimeout(회전타이머);
|
|
62
96
|
};
|
|
63
97
|
}, [open, recalc, onScroll, onResize, ignoreOutsideClickSelector]);
|
|
64
98
|
/* ESC 로 닫기 — **공유 ESC 스택**에 올린다(Popup 과 같은 스택). 전에는 document 에서 따로 들어,
|
|
@@ -2,8 +2,10 @@ import type { TrainingItem } from '../types/training';
|
|
|
2
2
|
/**
|
|
3
3
|
* 오늘/어제 훈련 선택을 (user, 날짜) 결정적 시드로 계산하는 훅(영속 저장 없음).
|
|
4
4
|
*
|
|
5
|
-
* - resolveSelection은 시드 기반이라 기기/세션 무관 동일
|
|
6
|
-
*
|
|
5
|
+
* - resolveSelection은 (사용자, 날짜, 유형) 시드 기반이라 기기/세션 무관 동일 결과. 유형마다 시드가 따로라 한 유형 자료가
|
|
6
|
+
* 바뀌어도 다른 유형 세트는 그대로
|
|
7
|
+
* - 오늘 세트는 후보가 충분하면 어제 세트와 겹치지 않는다(어제 보기는 어제 실제로 뜬 세트와 같다)
|
|
8
|
+
* - 데이터 미로딩 시 ensureTrainingDataLoaded() 대기 후 재계산(받는 중에는 새로 부르지 않는다)
|
|
7
9
|
* - 폴링(250ms): 훈련 데이터가 비동기로 채워질 때까지 재시도
|
|
8
10
|
*/
|
|
9
11
|
/**
|
|
@@ -7,8 +7,10 @@ import { hasItems, resolveSelection, } from '../utils/dailySelectionUtils';
|
|
|
7
7
|
/**
|
|
8
8
|
* 오늘/어제 훈련 선택을 (user, 날짜) 결정적 시드로 계산하는 훅(영속 저장 없음).
|
|
9
9
|
*
|
|
10
|
-
* - resolveSelection은 시드 기반이라 기기/세션 무관 동일
|
|
11
|
-
*
|
|
10
|
+
* - resolveSelection은 (사용자, 날짜, 유형) 시드 기반이라 기기/세션 무관 동일 결과. 유형마다 시드가 따로라 한 유형 자료가
|
|
11
|
+
* 바뀌어도 다른 유형 세트는 그대로
|
|
12
|
+
* - 오늘 세트는 후보가 충분하면 어제 세트와 겹치지 않는다(어제 보기는 어제 실제로 뜬 세트와 같다)
|
|
13
|
+
* - 데이터 미로딩 시 ensureTrainingDataLoaded() 대기 후 재계산(받는 중에는 새로 부르지 않는다)
|
|
12
14
|
* - 폴링(250ms): 훈련 데이터가 비동기로 채워질 때까지 재시도
|
|
13
15
|
*/
|
|
14
16
|
/**
|
|
@@ -49,6 +51,8 @@ export function useDailyTrainingSelection(userId) {
|
|
|
49
51
|
let 적용됨 = false;
|
|
50
52
|
let timer;
|
|
51
53
|
let attempts = 0;
|
|
54
|
+
let 받는중 = null;
|
|
55
|
+
let 다음받기 = 0; // 이 attempts 에 이르면 자료 받기를 다시 청한다(2초 = 8틱마다)
|
|
52
56
|
const MAX_ATTEMPTS = 40; // 250ms * 40 = 10초 후 폴링 중단(풀이 영영 비는 경우 무한루프 방지)
|
|
53
57
|
const stop = () => { if (timer !== undefined) {
|
|
54
58
|
clearInterval(timer);
|
|
@@ -86,11 +90,22 @@ export function useDailyTrainingSelection(userId) {
|
|
|
86
90
|
}
|
|
87
91
|
/* 캐시 miss → 데이터 로드 후 새 선택. 로드는 **2초마다 한 번만** 다시 청한다 — 250ms 마다 부르면 받기가 곧바로 실패하는
|
|
88
92
|
배포(형식 오류)에서 10초에 850KB 자료를 40번 받았다. 그 사이 폴링은 선택만 다시 계산한다. */
|
|
89
|
-
|
|
93
|
+
/* **받는 중이면 이 틱은 건너뛴다** — 받기는 3초까지 기다리는데 250ms 틱이 그 사이 attempts 가 안 오른 채 들어와
|
|
94
|
+
ensureTrainingDataLoaded 를 매번 새로 불러, 끝나지 않은 받기마다 3초 타이머가 열두 개씩 쌓였다. 진행 중인 약속을 하나만 둔다. */
|
|
95
|
+
if (받는중) {
|
|
96
|
+
if (++attempts >= MAX_ATTEMPTS)
|
|
97
|
+
포기();
|
|
98
|
+
return;
|
|
99
|
+
} // 건너뛴 틱도 10초 시한에 센다
|
|
100
|
+
if (attempts >= 다음받기) {
|
|
101
|
+
다음받기 = attempts + 8;
|
|
102
|
+
받는중 = ensureTrainingDataLoaded(3000).catch(() => false);
|
|
90
103
|
try {
|
|
91
|
-
await
|
|
104
|
+
await 받는중;
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
받는중 = null;
|
|
92
108
|
}
|
|
93
|
-
catch { }
|
|
94
109
|
if (cancelled)
|
|
95
110
|
return;
|
|
96
111
|
}
|
|
@@ -102,13 +117,10 @@ export function useDailyTrainingSelection(userId) {
|
|
|
102
117
|
// 데이터가 끝내 차지 않으면 일정 시도 후 중단(빈 선택이라도 로딩 종료)
|
|
103
118
|
// 포기한다. hasData 를 반드시 false 로 되돌린다 — userId 가 바뀌어 다시 돌 때,
|
|
104
119
|
// 앞 사람 것이 성공했다면 true 로 남아 있어 "자료가 있다" 고 거짓말하게 된다.
|
|
105
|
-
if (++attempts >= MAX_ATTEMPTS)
|
|
106
|
-
|
|
107
|
-
setHasData(false);
|
|
108
|
-
cancelled = true;
|
|
109
|
-
stop();
|
|
110
|
-
}
|
|
120
|
+
if (++attempts >= MAX_ATTEMPTS)
|
|
121
|
+
포기();
|
|
111
122
|
};
|
|
123
|
+
function 포기() { setLoaded(true); setHasData(false); cancelled = true; stop(); }
|
|
112
124
|
/* 포기한 뒤(10초)라도 자료가 도착하면 다시 계산한다 — 느린 회선에서 13초 걸려 받은 회원은 새로고침 전까지 "자료를 못 찾음" 에 남았다 */
|
|
113
125
|
const 자료도착 = () => {
|
|
114
126
|
if (떠남 || 적용됨)
|
|
@@ -2,14 +2,38 @@
|
|
|
2
2
|
import { useState, useEffect } from 'react';
|
|
3
3
|
import DOMPurify from 'dompurify';
|
|
4
4
|
import { resolveAssetUrl } from '../utils';
|
|
5
|
+
/* 속성 값이 **문서 밖을 가리키는** url( 을 품었나. `url(#그라데이션)`·`url(#clip)` 처럼 같은 SVG 안의 조각만 가리키는 것은 둔다 —
|
|
6
|
+
코어 로고 서른여섯 장이 clip-path·fill 로 그것을 쓴다(걷으면 인라인으로 그린 로고가 깨진다).
|
|
7
|
+
CSS 이스케이프로 url 을 숨긴 꼴(역슬래시 + 괄호)도 걷는다 — 아이콘 속성에 그런 값을 쓸 까닭이 없다. */
|
|
8
|
+
function 바깥url있나(value) {
|
|
9
|
+
if (value.includes('\\') && value.includes('('))
|
|
10
|
+
return true;
|
|
11
|
+
if (!/url\s*\(/i.test(value))
|
|
12
|
+
return false;
|
|
13
|
+
const 조각만뺀 = value.replace(/url\(\s*(['"]?)#[^)'"]*\1\s*\)/gi, '');
|
|
14
|
+
return /url\s*\(/i.test(조각만뺀);
|
|
15
|
+
}
|
|
5
16
|
function sanitizeSvg(raw) {
|
|
6
17
|
if (typeof window === 'undefined')
|
|
7
18
|
return '';
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
19
|
+
/* 인라인 SVG 에서 **바깥 자원을 부르는 길** 을 걷는다 — `<style>`(@import·background:url) 과 바깥 url( 이 든 속성(style·fill·filter…).
|
|
20
|
+
그대로 두면 아이콘 한 장이 제3자 주소로 요청을 보내거나(추적) CSS 로 화면을 덮을 수 있었다.
|
|
21
|
+
훅은 DOMPurify 전역에 붙으므로 이 한 번의 sanitize 동안만 걸고 곧바로 뗀다(앱의 다른 DOMPurify 쓰임에 새지 않게). */
|
|
22
|
+
const 훅 = (_node, data) => {
|
|
23
|
+
if (typeof data.attrValue === 'string' && 바깥url있나(data.attrValue))
|
|
24
|
+
data.keepAttr = false;
|
|
25
|
+
};
|
|
26
|
+
DOMPurify.addHook('uponSanitizeAttribute', 훅);
|
|
27
|
+
try {
|
|
28
|
+
return DOMPurify.sanitize(raw, {
|
|
29
|
+
USE_PROFILES: { svg: true, svgFilters: true },
|
|
30
|
+
FORBID_TAGS: ['script', 'use', 'style'],
|
|
31
|
+
FORBID_ATTR: ['xlink:href', 'href'],
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
DOMPurify.removeHook('uponSanitizeAttribute', 훅);
|
|
36
|
+
}
|
|
13
37
|
}
|
|
14
38
|
/** @public */
|
|
15
39
|
export default function useIcon(icon) {
|
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
* 바로 나옵니다. 목록에 없는 이름은 `null` 을 돌려줍니다 — 받아 오지 않으며,
|
|
6
6
|
* 개발 모드에서 이름당 한 번 경고를 남깁니다. URL·에셋 경로도 `null` 입니다.
|
|
7
7
|
*
|
|
8
|
+
* **`.svg` 경로는 파일 이름으로 lucide 를 먼저 찾는다** — `'/images/brand/check.svg'` 는 `'check'` 로 읽혀 lucide 체크가
|
|
9
|
+
* 그려진다(파일을 받지 않음). 목록에 그 이름이 없을 때만 Icon 이 파일을 받는다. 옛 브랜드 SVG 경로를 넘기던 화면과 모양을
|
|
10
|
+
* 맞추려고 **일부러 두는 동작**이다(2026-09-15 결정: 유지). 파일 그림 그대로가 필요하면 lucide 에 없는 파일 이름을 쓴다.
|
|
11
|
+
*
|
|
8
12
|
* @example
|
|
9
13
|
* const HomeIcon = useLucideIcon('home')
|
|
10
14
|
* return HomeIcon ? <HomeIcon size={20} /> : null
|
|
@@ -43,6 +43,10 @@ const lazyCache = new Map();
|
|
|
43
43
|
* 바로 나옵니다. 목록에 없는 이름은 `null` 을 돌려줍니다 — 받아 오지 않으며,
|
|
44
44
|
* 개발 모드에서 이름당 한 번 경고를 남깁니다. URL·에셋 경로도 `null` 입니다.
|
|
45
45
|
*
|
|
46
|
+
* **`.svg` 경로는 파일 이름으로 lucide 를 먼저 찾는다** — `'/images/brand/check.svg'` 는 `'check'` 로 읽혀 lucide 체크가
|
|
47
|
+
* 그려진다(파일을 받지 않음). 목록에 그 이름이 없을 때만 Icon 이 파일을 받는다. 옛 브랜드 SVG 경로를 넘기던 화면과 모양을
|
|
48
|
+
* 맞추려고 **일부러 두는 동작**이다(2026-09-15 결정: 유지). 파일 그림 그대로가 필요하면 lucide 에 없는 파일 이름을 쓴다.
|
|
49
|
+
*
|
|
46
50
|
* @example
|
|
47
51
|
* const HomeIcon = useLucideIcon('home')
|
|
48
52
|
* return HomeIcon ? <HomeIcon size={20} /> : null
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @internal 누름 처리기가 **약속(Promise)을 돌려주면 끝날 때까지 다시 누르지 못하게** 잠근다.
|
|
3
|
+
* ErrorState 의 onRetry·EmptyState 의 action.onClick 이 쓴다 — 느린 다시 불러오기에서 두 번 눌러 요청이 두 번 나갔다.
|
|
4
|
+
* 약속이 아니면 지금처럼 매번 부른다. 실패(reject)해도 풀린다. 공개 export 아님.
|
|
5
|
+
*/
|
|
6
|
+
export declare function usePendingPress(fn?: () => unknown): {
|
|
7
|
+
pending: boolean;
|
|
8
|
+
press: () => void;
|
|
9
|
+
};
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
/**
|
|
4
|
+
* @internal 누름 처리기가 **약속(Promise)을 돌려주면 끝날 때까지 다시 누르지 못하게** 잠근다.
|
|
5
|
+
* ErrorState 의 onRetry·EmptyState 의 action.onClick 이 쓴다 — 느린 다시 불러오기에서 두 번 눌러 요청이 두 번 나갔다.
|
|
6
|
+
* 약속이 아니면 지금처럼 매번 부른다. 실패(reject)해도 풀린다. 공개 export 아님.
|
|
7
|
+
*/
|
|
8
|
+
export function usePendingPress(fn) {
|
|
9
|
+
const [pending, setPending] = useState(false);
|
|
10
|
+
const 진행중 = useRef(false);
|
|
11
|
+
const 붙어있음 = useRef(true);
|
|
12
|
+
useEffect(() => { 붙어있음.current = true; return () => { 붙어있음.current = false; }; }, []);
|
|
13
|
+
const press = useCallback(() => {
|
|
14
|
+
if (!fn || 진행중.current)
|
|
15
|
+
return;
|
|
16
|
+
const r = fn();
|
|
17
|
+
if (r && typeof r.then === 'function') {
|
|
18
|
+
진행중.current = true;
|
|
19
|
+
setPending(true);
|
|
20
|
+
const 풀기 = () => { 진행중.current = false; if (붙어있음.current)
|
|
21
|
+
setPending(false); };
|
|
22
|
+
r.then(풀기, 풀기);
|
|
23
|
+
}
|
|
24
|
+
}, [fn]);
|
|
25
|
+
return { pending, press };
|
|
26
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** OS '동작 줄이기'(prefers-reduced-motion: reduce) 미디어 쿼리 */
|
|
2
|
+
export declare const REDUCED_MOTION_QUERY = "(prefers-reduced-motion: reduce)";
|
|
3
|
+
/**
|
|
4
|
+
* 지금 '동작 줄이기' 가 켜져 있는가 — 부르는 순간의 값(콜백 안에서 타이머 길이를 정할 때).
|
|
5
|
+
* `matchMedia` 가 없는 환경(서버·오래된 시험 환경)에서는 false.
|
|
6
|
+
*/
|
|
7
|
+
export declare function prefersReducedMotion(): boolean;
|
|
8
|
+
/**
|
|
9
|
+
* '동작 줄이기' 설정을 따라가는 훅(바뀌면 다시 그린다).
|
|
10
|
+
*
|
|
11
|
+
* CSS 의 전역 규칙(styles.css)은 animation·transition 을 0 으로 줄이지만, **JS 가 기다리는 닫기 대기**
|
|
12
|
+
* (Popup 260ms·Tooltip 200ms·TrainingPagePopup 160ms)는 못 줄인다 — 움직임은 없는데 창이 그만큼 늦게 사라지고
|
|
13
|
+
* 그동안 투명한 막이 누름을 가로챘다. 그 대기를 이 값으로 0 으로 만든다.
|
|
14
|
+
*/
|
|
15
|
+
export declare function usePrefersReducedMotion(): boolean;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
import useMediaQuery from './useMediaQuery';
|
|
3
|
+
/** OS '동작 줄이기'(prefers-reduced-motion: reduce) 미디어 쿼리 */
|
|
4
|
+
export const REDUCED_MOTION_QUERY = '(prefers-reduced-motion: reduce)';
|
|
5
|
+
/**
|
|
6
|
+
* 지금 '동작 줄이기' 가 켜져 있는가 — 부르는 순간의 값(콜백 안에서 타이머 길이를 정할 때).
|
|
7
|
+
* `matchMedia` 가 없는 환경(서버·오래된 시험 환경)에서는 false.
|
|
8
|
+
*/
|
|
9
|
+
export function prefersReducedMotion() {
|
|
10
|
+
try {
|
|
11
|
+
return typeof window !== 'undefined' && typeof window.matchMedia === 'function' && window.matchMedia(REDUCED_MOTION_QUERY).matches;
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return false;
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* '동작 줄이기' 설정을 따라가는 훅(바뀌면 다시 그린다).
|
|
19
|
+
*
|
|
20
|
+
* CSS 의 전역 규칙(styles.css)은 animation·transition 을 0 으로 줄이지만, **JS 가 기다리는 닫기 대기**
|
|
21
|
+
* (Popup 260ms·Tooltip 200ms·TrainingPagePopup 160ms)는 못 줄인다 — 움직임은 없는데 창이 그만큼 늦게 사라지고
|
|
22
|
+
* 그동안 투명한 막이 누름을 가로챘다. 그 대기를 이 값으로 0 으로 만든다.
|
|
23
|
+
*/
|
|
24
|
+
export function usePrefersReducedMotion() {
|
|
25
|
+
return useMediaQuery(REDUCED_MOTION_QUERY);
|
|
26
|
+
}
|
|
@@ -42,8 +42,32 @@ export declare function useTablePaging<T>(rows: readonly T[], resetOn?: unknown,
|
|
|
42
42
|
};
|
|
43
43
|
setPage: import("react").Dispatch<import("react").SetStateAction<number>>;
|
|
44
44
|
};
|
|
45
|
-
/**
|
|
46
|
-
export
|
|
45
|
+
/** `useTableSort` 의 선택 인자. @public */
|
|
46
|
+
export interface TableSortOptions<K extends string> {
|
|
47
|
+
/** 머리를 눌러 정렬이 바뀔 때 바뀐 정렬로 불린다 — 쪽을 따로 들고 있는 표가 첫 쪽으로 돌아가는 자리 등. */
|
|
48
|
+
onSortChange?: (sort: {
|
|
49
|
+
key: K;
|
|
50
|
+
asc: boolean;
|
|
51
|
+
}) => void;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* 머리행 정렬 상태 — 같은 칸을 다시 누르면 방향이 뒤집힌다.
|
|
55
|
+
*
|
|
56
|
+
* **정렬이 바뀌면 첫 쪽으로** — `useTablePaging` 의 `resetOn` 에 돌려받은 `정렬` 을 그대로 넣는다.
|
|
57
|
+
* `정렬` 은 누를 때만 새 객체가 되므로(렌더마다 새로 만들지 않는다) 그 자체가 열쇠다. 쪽을 따로 들고 있으면 `onSortChange` 에서 되돌린다.
|
|
58
|
+
*
|
|
59
|
+
* `sortRows(rows, 값)` 은 지금 정렬로 줄을 세운 **새 배열**을 준다(정렬이 없으면 받은 배열 그대로) — `칸순`(숫자는 수로,
|
|
60
|
+
* 한글·영문은 사람 차례) 으로 견주고 **빈 값(null·undefined·'')은 오름·내림 모두 맨 뒤**(ListPopup 과 같은 규칙).
|
|
61
|
+
*
|
|
62
|
+
* @example
|
|
63
|
+
* const { 정렬, 칸, sortRows } = useTableSort<'name' | 'joined'>()
|
|
64
|
+
* const 세운줄 = useMemo(() => sortRows(rows, (r, key) => r[key]), [rows, sortRows])
|
|
65
|
+
* const { pageRows, pageSizeProps, paginationProps } = useTablePaging(세운줄, 정렬) // 정렬이 바뀌면 첫 쪽
|
|
66
|
+
* <Table fields={[칸('이름', 'name'), 칸('가입일', 'joined')]} … />
|
|
67
|
+
*
|
|
68
|
+
* @public
|
|
69
|
+
*/
|
|
70
|
+
export declare function useTableSort<K extends string>(opts?: TableSortOptions<K>): {
|
|
47
71
|
정렬: {
|
|
48
72
|
key: K;
|
|
49
73
|
asc: boolean;
|
|
@@ -53,4 +77,5 @@ export declare function useTableSort<K extends string>(): {
|
|
|
53
77
|
sortDir: "desc" | "asc" | null;
|
|
54
78
|
onClick: () => void;
|
|
55
79
|
};
|
|
80
|
+
sortRows: <R>(rows: readonly R[], 값: (row: R, key: K) => unknown) => readonly R[];
|
|
56
81
|
};
|