@ihabdevteam/core 0.38.0 → 0.39.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.
Files changed (43) hide show
  1. package/dist/components.cjs.js +15 -15
  2. package/dist/hooks.cjs.js +1 -788
  3. package/dist/ihabdevteam-core.cjs.js +32 -32
  4. package/dist/ihabdevteam-core.d.ts +45 -5
  5. package/dist/packages/core/src/components/BottomNav.js +1 -1
  6. package/dist/packages/core/src/components/DatePicker.js +1 -1
  7. package/dist/packages/core/src/components/Dropdown.js +1 -1
  8. package/dist/packages/core/src/components/FilterPanel.js +2 -1
  9. package/dist/packages/core/src/components/Header.js +1 -1
  10. package/dist/packages/core/src/components/Panel.d.ts +3 -133
  11. package/dist/packages/core/src/components/Panel.js +2 -140
  12. package/dist/packages/core/src/components/PanelRow.d.ts +145 -0
  13. package/dist/packages/core/src/components/PanelRow.js +144 -0
  14. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +1 -54
  15. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +2 -15
  16. package/dist/packages/core/src/components/useAnchoredMenu.d.ts +8 -26
  17. package/dist/packages/core/src/components/useAnchoredMenu.js +7 -70
  18. package/dist/packages/core/src/components/useSoundCompassExam.d.ts +8 -51
  19. package/dist/packages/core/src/components/useSoundCompassExam.js +10 -128
  20. package/dist/packages/core/src/components.d.ts +4 -4
  21. package/dist/packages/core/src/components.js +2 -2
  22. package/dist/packages/core/src/hooks/useAnchoredMenu.d.ts +28 -0
  23. package/dist/packages/core/src/hooks/useAnchoredMenu.js +72 -0
  24. package/dist/packages/core/src/hooks/useSoundCompassExam.d.ts +54 -0
  25. package/dist/packages/core/src/hooks/useSoundCompassExam.js +128 -0
  26. package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +2 -0
  27. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +2 -1
  28. package/dist/packages/core/src/hooks/useWordTrainingDataGeneration.d.ts +2 -8
  29. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -6
  30. package/dist/packages/core/src/hooks.d.ts +4 -0
  31. package/dist/packages/core/src/hooks.js +6 -0
  32. package/dist/packages/core/src/types/filterPanel.d.ts +15 -0
  33. package/dist/packages/core/src/types/training.d.ts +20 -0
  34. package/dist/packages/core/src/types/training.js +1 -0
  35. package/dist/packages/core/src/utils/audioPeaks.d.ts +2 -0
  36. package/dist/packages/core/src/utils/audioPeaks.js +67 -0
  37. package/dist/packages/core/src/utils/noiseSettingsStore.d.ts +1 -1
  38. package/dist/packages/core/src/utils/wordTrainingGroups.d.ts +1 -1
  39. package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.d.ts +14 -0
  40. package/dist/packages/core/src/version.d.ts +1 -1
  41. package/dist/packages/core/src/version.js +1 -1
  42. package/dist/utils.cjs.js +1 -1
  43. package/package.json +1 -1
@@ -0,0 +1,144 @@
1
+ 'use client';
2
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
+ import { cx } from '../utils/cx';
4
+ import { useId } from 'react';
5
+ import { useTranslation } from 'react-i18next';
6
+ import Toggle from './Toggle';
7
+ import SegmentedControl from './SegmentedControl';
8
+ import Dropdown from './Dropdown';
9
+ import Button from './Button';
10
+ import Badge from './Badge';
11
+ import { deviceKindVariant } from '../utils/deviceKind';
12
+ import TextField from './TextField';
13
+ import Loader from './Loader';
14
+ import './Panel.css';
15
+ const noop = () => { };
16
+ export function PanelRow(props) {
17
+ // toggle 행의 라벨-스위치 연결용 id — 모든 variant에서 훅 호출 순서를 유지하기 위해
18
+ // 조건부 return들보다 앞에서 무조건 호출한다 (사용하지 않는 variant에서는 그냥 버려짐)
19
+ const uid = useId();
20
+ // children 기반 하위 호환 모드
21
+ if (!('variant' in props)) {
22
+ const { label, sub, onClick, children, align = 'horizontal', icon } = props;
23
+ const handleKeyDown = (e) => {
24
+ if (!onClick)
25
+ return;
26
+ // 우측 children 안의 버튼/토글 등이 자체적으로 Enter/Space를 처리하므로,
27
+ // 행 자기 자신에서 발생한 키 입력만 행 클릭으로 취급한다
28
+ if (e.target !== e.currentTarget)
29
+ return;
30
+ if (e.key === 'Enter' || e.key === ' ') {
31
+ e.preventDefault();
32
+ onClick();
33
+ }
34
+ };
35
+ return (_jsxs("div", { className: cx('panel-row', align === 'vertical' ? 'panel-row--vertical' : '', onClick ? 'panel-row--clickable' : ''), onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, children: [icon != null && _jsx("div", { className: "panel-row__icon", children: icon }), label != null && (_jsxs("div", { className: "panel-row__left", children: [_jsx("div", { className: "panel-row__label body-3", children: label }), sub && _jsx("div", { className: "panel-row__sub caption-1", children: sub })] })), _jsx("div", { className: "panel-row__right", children: children })] }));
36
+ }
37
+ // 설명 전용 행 — 라벨/우측 구조 없이 문단만 렌더
38
+ if (props.variant === 'description') {
39
+ const p = props;
40
+ return (_jsx("div", { className: "panel-row panel-row--block", children: _jsx("p", { className: "panel-row__description body-3", children: p.children }) }));
41
+ }
42
+ // 빈 상태 — 라벨 없이 중앙 정렬된 한 줄
43
+ if (props.variant === 'empty') {
44
+ const p = props;
45
+ return _jsx("div", { className: "panel-row panel-row--empty body-3", children: p.children });
46
+ }
47
+ // 로딩 — 중앙 스피너 1개
48
+ if (props.variant === 'loading') {
49
+ return _jsx("div", { className: "panel-row panel-row--loading", children: _jsx(Loader, { size: 28 }) });
50
+ }
51
+ // custom 행에 label이 없으면 좌/우 분할 없이 children을 그대로 꽉 채워 렌더
52
+ // (CodeBox처럼 라벨과 짝지을 수 없는 큰 블록 콘텐츠를 행 구조 안에 넣을 때 사용)
53
+ if (props.variant === 'custom' && !props.label) {
54
+ const p = props;
55
+ return (_jsx("div", { className: "panel-row panel-row--block", children: p.children }));
56
+ }
57
+ const { variant, label, sub, onClick, required, error, align = 'horizontal', useFullWidth = false, icon } = props;
58
+ let right = null;
59
+ // toggle 행만 왼쪽 라벨(panel-row__label)을 <label htmlFor>로 렌더해 스위치와 프로그래밍적으로
60
+ // 연결한다 — 그 전엔 시각적으로만 옆에 붙어있어 라벨 텍스트를 클릭해도 토글이 안 되고
61
+ // 스크린리더도 이 스위치가 뭘 위한 건지 안내하지 못했다.
62
+ const toggleId = variant === 'toggle' ? `panel-toggle-${uid}` : undefined;
63
+ if (variant === 'toggle') {
64
+ const p = props;
65
+ right = _jsx(Toggle, { checked: p.checked, onChange: p.onChange, id: toggleId });
66
+ }
67
+ else if (variant === 'segmented') {
68
+ const p = props;
69
+ // dropdown 행과 동일하게, PanelRow 왼쪽 라벨은 <label htmlFor>가 아닌 일반 div라서
70
+ // SegmentedControl에 ariaLabel로 넘겨야 스크린리더가 이름 없는 라디오그룹으로 안내하지 않는다.
71
+ right = _jsx(SegmentedControl, { options: p.options, value: p.value, onChange: p.onChange, size: p.size ?? 40, ariaLabel: label });
72
+ }
73
+ else if (variant === 'dropdown') {
74
+ const p = props;
75
+ const dropdowns = p.dropdowns?.length ? (_jsx("div", { className: `panel-row__dropdown-group${useFullWidth ? ' panel-row__dropdown-group--full' : ''}`, children: p.dropdowns.map((d, i) => (_jsx("div", { className: useFullWidth ? 'panel-row__dropdown-item--full' : undefined, children: _jsx(Dropdown, { options: d.options, value: d.value, onChange: d.onChange, placeholder: d.placeholder, block: useFullWidth, error: !!error,
76
+ // 한 행에 여러 개가 놓이므로 행 라벨만 넘기면 셋 다 "생년월일" 이라는 같은 이름이 된다.
77
+ // aria-label 이 있으면 스크린리더는 트리거의 화면 텍스트(년/월/일)를 읽지 않으므로
78
+ // 사용자는 어느 것이 무엇인지 구별할 수 없다 — placeholder 를 붙여 구별되게 한다.
79
+ ariaLabel: d.ariaLabel ?? ([label, d.placeholder].filter(Boolean).join(' ') || undefined) }) }, i))) })) : (
80
+ // PanelRow 왼쪽 라벨(panel-row__label)은 <label htmlFor>가 아니라 그냥 div라서 트리거 버튼과
81
+ // 프로그래밍적으로 연결되지 않는다 — ariaLabel로 직접 이름을 넘긴다 (TextField의 ariaLabel과 동일 패턴)
82
+ _jsx(Dropdown, { options: p.options ?? [], value: p.value ?? '', onChange: p.onChange ?? noop, block: useFullWidth, error: !!error, ariaLabel: label }));
83
+ right = error ? (_jsxs("div", { className: `panel-row__dropdown-error-wrap${useFullWidth ? ' panel-row__dropdown-error-wrap--full' : ''}`, children: [dropdowns, _jsx("span", { className: "caption-1 panel-row__error", role: "alert", children: error })] })) : dropdowns;
84
+ }
85
+ else if (variant === 'button') {
86
+ const p = props;
87
+ if (p.buttons?.length) {
88
+ right = (_jsx("div", { className: "panel-row__button-group", children: p.buttons.map((btn, i) => (_jsx(Button, { size: btn.btnSize ?? 40, variant: btn.btnVariant ?? 'default', disabled: btn.disabled, onClick: btn.onClick, children: btn.label }, i))) }));
89
+ }
90
+ else {
91
+ right = (_jsx(Button, { size: p.btnSize ?? 40, variant: p.btnVariant ?? 'default', disabled: p.disabled, onClick: p.onClick, children: p.buttonLabel }));
92
+ }
93
+ }
94
+ else if (variant === 'text') {
95
+ const p = props;
96
+ right = _jsx("span", { className: `panel-row__text body-2${useFullWidth ? ' panel-row__text--full' : ''}`, style: p.valueColor ? { color: p.valueColor } : undefined, children: p.value });
97
+ }
98
+ else if (variant === 'input') {
99
+ const p = props;
100
+ right = (_jsxs("div", { className: `panel-row__input-wrap${useFullWidth ? ' panel-row__input-wrap--full' : ''}`, children: [_jsx(TextField, { value: p.value, onChange: p.onChange ?? noop, onKeyDown: p.onKeyDown, onEnter: p.onEnter, placeholder: p.placeholder, inputMode: p.inputMode, readOnly: p.readOnly, disabled: p.disabled, type: p.type, autoComplete: p.autoComplete, maxLength: p.maxLength, showCount: p.showCount, multiline: p.multiline, rows: p.rows, fullWidth: useFullWidth, error: error, ariaLabel: label, required: required }), p.helperText && _jsx("div", { className: "panel-row__helper caption-1", children: p.helperText })] }));
101
+ }
102
+ else if (variant === 'custom') {
103
+ const p = props;
104
+ right = p.children;
105
+ }
106
+ const isLabelFixed = variant === 'input' || variant === 'dropdown' || ((variant === 'text' || variant === 'custom') && useFullWidth);
107
+ // 위 children 모드와 동일한 키보드 처리 — 이쪽 경로에만 빠져 있어서,
108
+ // variant 모드에 onClick을 주면 조용히 마우스 전용이 된다(현재 사용처는 없지만 예방).
109
+ const handleKeyDown = (e) => {
110
+ if (!onClick)
111
+ return;
112
+ if (e.target !== e.currentTarget)
113
+ return;
114
+ if (e.key === 'Enter' || e.key === ' ') {
115
+ e.preventDefault();
116
+ onClick();
117
+ }
118
+ };
119
+ return (_jsxs("div", { className: cx('panel-row', align === 'vertical' ? 'panel-row--vertical' : '', isLabelFixed ? 'panel-row--label-fixed' : '', variant === 'text' ? 'panel-row--text' : '', onClick ? 'panel-row--clickable' : ''), onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, children: [icon != null && _jsx("div", { className: "panel-row__icon", children: icon }), _jsxs("div", { className: "panel-row__left", children: [toggleId ? (_jsxs("label", { htmlFor: toggleId, className: "panel-row__label body-3", children: [label, required && _jsx("span", { className: "required-mark", children: "*" })] })) : (_jsxs("div", { className: "panel-row__label body-3", children: [label, required && _jsx("span", { className: "required-mark", children: "*" })] })), sub && _jsx("div", { className: "panel-row__sub caption-1", children: sub })] }), _jsx("div", { className: "panel-row__right", children: right })] }));
120
+ }
121
+ /**
122
+ * 제목 아래 한 줄로 세울 곁정보 — 브랜드·착용형태.
123
+ *
124
+ * 제목으로 이미 쓴 값과 빈 값, 그리고 서로 같은 값을 걷어 낸다. 걷어 내지 않으면
125
+ * 브랜드도 형태도 '기타' 인 기기가 **'기타 · 기타'** 로 떴다(같은 말이 두 번).
126
+ */
127
+ function 메타(d) {
128
+ const 제목 = d.productName || d.brand;
129
+ const 조각 = [d.brand, d.fitType].filter((v) => !!v && v !== 제목);
130
+ return [...new Set(조각)].join(' · ');
131
+ }
132
+ export function PanelRowDevice({ devices, onRelease }) {
133
+ const { t } = useTranslation();
134
+ const SIDES = ['좌', '우'];
135
+ return (_jsx("div", { className: "panel-row panel-row--device", children: SIDES.map(side => {
136
+ const d = devices[side];
137
+ const sideLabel = side === '좌' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
138
+ return (_jsxs("div", { className: "panel-row__device-column", children: [_jsxs("div", { className: "panel-row__device-head", children: [_jsx("span", { className: "panel-row__device-side body-2b", children: sideLabel }), d && onRelease && (
139
+ /* 표의 줄 끝 단추와 같은 모양·같은 말 — 하는 일도 같다.
140
+ 이력에서 지우는 것이 아니라 '지금 끼고 있음' 만 내린다
141
+ (잠시 안 씀·수리 보냄 같은 경우가 실제로 있다). */
142
+ _jsx(Button, { size: 32, type: "dot", icon: "x", ariaLabel: t('customers.device.unwear', '착용 해제'), showTooltipInfo: t('customers.device.unwear', '착용 해제'), onClick: () => onRelease(side) }))] }), d ? (_jsxs("div", { className: "panel-row__device-info", children: [_jsxs("span", { className: "panel-row__device-kind", children: [d.device && _jsx(Badge, { size: "small", variant: deviceKindVariant(d.device), fill: "soft", label: d.device }), 메타(d) && _jsx("span", { className: "panel-row__device-detail body-3", children: 메타(d) })] }), _jsx("span", { className: "panel-row__device-name body-2b", children: d.productName || d.brand }), _jsx("span", { className: "panel-row__device-date caption-1", children: t('customers.device.wornSince', { date: `${d.wornYear}.${d.wornMonth}.${d.wornDay}` }) })] })) : (_jsx("span", { className: "panel-row__device-empty body-3", children: t('customers.device.notRegistered', '미등록') }))] }, side));
143
+ }) }));
144
+ }
@@ -1,66 +1,13 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
- import { getErrorMessage } from '../../utils/errorMessage';
4
+ import { computePeaksFromUrl } from '../../utils/audioPeaks';
5
5
  import './TrainingStepAudioPlayer.css';
6
6
  /**
7
7
  * waveform 바 최소 픽셀 — 렌더 높이가 이 값 이하이면 바를 그리지 않는다(문장·소음 공통).
8
8
  * 그려지는 바는 항상 이 값을 넘는 높이만 표시하며(가로 폭은 BAR_WIDTH=3px), 그 이하는 숨긴다.
9
9
  */
10
10
  const MIN_BAR_PX = 4;
11
- // windowSeconds: 지정 시 파일의 처음 N초만 사용(소음을 문장 재생시간만큼만 그릴 때).
12
- // 컴포넌트 상태를 참조하지 않는 순수 계산이라 모듈 스코프로 분리 — 매 렌더 재생성되지 않는다.
13
- async function decodeAndComputePeaks(arrayBuffer, bars, windowSeconds) {
14
- try {
15
- const AudioCtxConstructor = window.AudioContext || window.webkitAudioContext;
16
- const audioCtx = new AudioCtxConstructor();
17
- const decoded = await audioCtx.decodeAudioData(arrayBuffer.slice(0));
18
- const channelData = decoded.numberOfChannels > 0 ? decoded.getChannelData(0) : null;
19
- if (!channelData)
20
- return null;
21
- const total = (windowSeconds && windowSeconds > 0)
22
- ? Math.min(channelData.length, Math.floor(windowSeconds * decoded.sampleRate))
23
- : channelData.length;
24
- const usableLen = Math.max(bars, total);
25
- const blockSize = Math.floor(usableLen / bars) || 1;
26
- const peaks = new Array(bars).fill(0).map((_, i) => {
27
- const start = i * blockSize;
28
- const end = Math.min(start + blockSize, usableLen);
29
- let max = 0;
30
- for (let j = start; j < end; j++) {
31
- const v = Math.abs(channelData[j]);
32
- if (v > max)
33
- max = v;
34
- }
35
- return max;
36
- });
37
- try {
38
- audioCtx.close && audioCtx.close();
39
- }
40
- catch (e) { }
41
- // 절대 진폭(0~1, full-scale 기준) 반환 — 문장/소음이 같은 기준을 쓰게 해 dBFS 미터와 일치.
42
- return peaks;
43
- }
44
- catch (e) {
45
- return null;
46
- }
47
- }
48
- async function computePeaksFromUrl(url, bars, windowSeconds) {
49
- try {
50
- const resp = await fetch(url);
51
- const arrayBuffer = await resp.arrayBuffer();
52
- return await decodeAndComputePeaks(arrayBuffer, bars, windowSeconds);
53
- }
54
- catch (e) {
55
- try {
56
- const errMsg = getErrorMessage(e);
57
- if (import.meta.env?.DEV)
58
- console.log('WaveForm: computePeaksFromUrl failed', { url, err: errMsg });
59
- }
60
- catch (ee) { }
61
- return null;
62
- }
63
- }
64
11
  export default function WaveForm(props) {
65
12
  const { src, segments, progress = 0, waveProgressRef, noiseSrc, noiseLevel = 0, noiseSpeed = 1, totalDuration = 0, mainVolume = 1, onReadyChange, showSoundWaveForm = true } = props;
66
13
  const waveformRef = useRef(null);
@@ -1,18 +1,5 @@
1
- /** @public */
2
- export type CoreNoiseType = 'defaultNoise' | 'street' | 'restaurant' | 'buzz';
3
- /** @public */
4
- export type CoreNoiseSettings = {
5
- enabled: boolean;
6
- type: CoreNoiseType;
7
- /** 0–9. 0이면 소음 비활성화. (소음 음원을 +7dB 증폭한 뒤 상한을 4→9로 확장 — 실제 volume = level/9) */
8
- level: number;
9
- /** 소음 재생 속도(playbackRate). 0.75–1.25, 기본 1. */
10
- speed: number;
11
- /** 문장(메인) 재생 소리(볼륨). 0–1, 기본 1. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
12
- mainVolume: number;
13
- /** 문장(메인) 재생 속도(playbackRate). 0.5–1.5, 기본 1. 계정 설정("훈련" 패널)과 훈련 화면 툴팁이 함께 영속화한다. */
14
- mainSpeed: number;
15
- };
1
+ import type { CoreNoiseSettings } from '../../types/training';
2
+ export type { CoreNoiseType, CoreNoiseSettings } from '../../types/training';
16
3
  /** @public */
17
4
  export declare const LEVEL_MAX = 9;
18
5
  /** @public */
@@ -1,28 +1,10 @@
1
- import { type CSSProperties, type RefObject } from 'react';
2
- export interface UseAnchoredMenuOptions {
3
- /** 트리거의 getBoundingClientRect()로부터 메뉴에 적용할 style을 계산 */
4
- computeStyle: (triggerRect: DOMRect) => CSSProperties;
5
- /** 열려있는 동안 스크롤 발생 시 동작 (메뉴 내부 스크롤은 항상 무시) — 기본 'close' */
6
- onScroll?: 'close' | 'reposition' | 'ignore';
7
- /** 열려있는 동안 윈도우 리사이즈 발생 시 동작 — 기본 'close' */
8
- onResize?: 'close' | 'reposition' | 'ignore';
9
- /** 바깥클릭 판정에서 제외할 CSS selector (예: 메뉴 안에 중첩 포탈되는 다른 드롭다운) */
10
- ignoreOutsideClickSelector?: string;
11
- }
12
- export interface UseAnchoredMenuResult<Trigger extends HTMLElement, Menu extends HTMLElement> {
13
- open: boolean;
14
- setOpen: (open: boolean) => void;
15
- toggle: () => void;
16
- close: () => void;
17
- /** 트리거 기준으로 계산된 메뉴 style — position:'fixed' 등은 computeStyle이 직접 반환 */
18
- style: CSSProperties;
19
- triggerRef: RefObject<Trigger | null>;
20
- menuRef: RefObject<Menu | null>;
21
- }
22
1
  /**
23
- * 트리거 요소에 fixed 포지션으로 앵커링되는 메뉴/드롭다운/팝오버의 공용 로직.
24
- * 열림상태 + 위치 계산 + 바깥클릭/스크롤/리사이즈 처리를 담당하고, 렌더링(포탈 등)은
25
- * 사용하는 쪽에서 담당한다. Dropdown, DatePicker, BottomNav의 "더보기" 메뉴가 이 훅을 쓴다.
26
- * @public
2
+ * 옛 자리 — 실제 몸은 `hooks/useAnchoredMenu.ts` 에 있다.
3
+ *
4
+ * 네 컴포넌트(Dropdown · DatePicker · Header · BottomNav)가 함께 쓰는 훅이라
5
+ * 한 컴포넌트 곁이 아니라 `hooks/` 가 제자리다. 다만
6
+ * `@ihabdevteam/core/components/useAnchoredMenu` 로 가져가는 곳이 있을 수 있어
7
+ * 이 자리를 남겨 둔다.
27
8
  */
28
- export declare function useAnchoredMenu<Trigger extends HTMLElement = HTMLElement, Menu extends HTMLElement = HTMLElement>({ computeStyle, onScroll, onResize, ignoreOutsideClickSelector, }: UseAnchoredMenuOptions): UseAnchoredMenuResult<Trigger, Menu>;
9
+ export { useAnchoredMenu } from '../hooks/useAnchoredMenu';
10
+ export type { UseAnchoredMenuOptions, UseAnchoredMenuResult } from '../hooks/useAnchoredMenu';
@@ -1,72 +1,9 @@
1
- 'use client';
2
- import { useCallback, useEffect, useRef, useState } from 'react';
3
1
  /**
4
- * 트리거 요소에 fixed 포지션으로 앵커링되는 메뉴/드롭다운/팝오버의 공용 로직.
5
- * 열림상태 + 위치 계산 + 바깥클릭/스크롤/리사이즈 처리를 담당하고, 렌더링(포탈 등)은
6
- * 사용하는 쪽에서 담당한다. Dropdown, DatePicker, BottomNav의 "더보기" 메뉴가 이 훅을 쓴다.
7
- * @public
2
+ * 옛 자리 — 실제 몸은 `hooks/useAnchoredMenu.ts` 에 있다.
3
+ *
4
+ * 네 컴포넌트(Dropdown · DatePicker · Header · BottomNav)가 함께 쓰는 훅이라
5
+ * 한 컴포넌트 곁이 아니라 `hooks/` 가 제자리다. 다만
6
+ * `@ihabdevteam/core/components/useAnchoredMenu` 로 가져가는 곳이 있을 수 있어
7
+ * 이 자리를 남겨 둔다.
8
8
  */
9
- export function useAnchoredMenu({ computeStyle, onScroll = 'close', onResize = 'close', ignoreOutsideClickSelector, }) {
10
- const [open, setOpen] = useState(false);
11
- const [style, setStyle] = useState({});
12
- const triggerRef = useRef(null);
13
- const menuRef = useRef(null);
14
- const recalc = useCallback(() => {
15
- if (!triggerRef.current)
16
- return;
17
- setStyle(computeStyle(triggerRef.current.getBoundingClientRect()));
18
- }, [computeStyle]);
19
- useEffect(() => {
20
- if (!open)
21
- return;
22
- recalc();
23
- const handleOutsideClick = (e) => {
24
- const target = e.target;
25
- if (ignoreOutsideClickSelector && target.closest?.(ignoreOutsideClickSelector))
26
- return;
27
- if (triggerRef.current?.contains(target))
28
- return;
29
- if (menuRef.current?.contains(target))
30
- return;
31
- setOpen(false);
32
- };
33
- document.addEventListener('mousedown', handleOutsideClick);
34
- const handleScroll = (e) => {
35
- if (menuRef.current?.contains(e.target))
36
- return;
37
- if (onScroll === 'reposition')
38
- recalc();
39
- else if (onScroll === 'close')
40
- setOpen(false);
41
- };
42
- if (onScroll !== 'ignore')
43
- window.addEventListener('scroll', handleScroll, true);
44
- const handleResize = () => {
45
- if (onResize === 'reposition')
46
- recalc();
47
- else if (onResize === 'close')
48
- setOpen(false);
49
- };
50
- if (onResize !== 'ignore')
51
- window.addEventListener('resize', handleResize);
52
- // ESC로 닫기 — 이 훅을 쓰는 컴포넌트가 자체 키보드 핸들링(예: Dropdown의 화살표 탐색)을
53
- // 따로 두더라도 무해하게 중복 호출될 뿐이므로, 여기서 공통으로 처리해 DatePicker/BottomNav
54
- // 더보기 메뉴처럼 자체 ESC 처리가 없던 소비자도 함께 혜택을 받게 한다.
55
- const handleKeyDown = (e) => {
56
- if (e.key === 'Escape')
57
- setOpen(false);
58
- };
59
- document.addEventListener('keydown', handleKeyDown);
60
- return () => {
61
- document.removeEventListener('mousedown', handleOutsideClick);
62
- if (onScroll !== 'ignore')
63
- window.removeEventListener('scroll', handleScroll, true);
64
- if (onResize !== 'ignore')
65
- window.removeEventListener('resize', handleResize);
66
- document.removeEventListener('keydown', handleKeyDown);
67
- };
68
- }, [open, recalc, onScroll, onResize, ignoreOutsideClickSelector]);
69
- const toggle = useCallback(() => setOpen(o => !o), []);
70
- const close = useCallback(() => setOpen(false), []);
71
- return { open, setOpen, toggle, close, style, triggerRef, menuRef };
72
- }
9
+ export { useAnchoredMenu } from '../hooks/useAnchoredMenu';
@@ -1,54 +1,11 @@
1
- import type { SpeakerPos } from '../types/audio';
2
1
  /**
3
- * @public
4
- * SoundCompass 오케스트레이션 참조 훅.
2
+ * 옛 자리 — 실제 몸은 `hooks/useSoundCompassExam.ts` 에 있다.
5
3
  *
6
- * SoundCompass 자체는 제어형(표현) 컴포넌트라 순서·타이밍·점수·진행을 갖지 않는다.
7
- * 이 훅이 그 "부모(검사 컨트롤러)" 역할의 기본 구현을 제공한다 —
8
- * 큐 셔플, 문제 재생 타이밍, 선택 수집, 피드백, 진행/점수, 완료 콜백.
9
- *
10
- * 반환한 `bind`를 `<SoundCompass {...exam.bind} />`처럼 펼쳐 넣으면 된다.
11
- * 다른 동작이 필요하면 이 훅을 복사·수정하거나, SoundCompass의 제어 props를
12
- * 직접 구동해도 된다.
4
+ * 이것은 **머리 없는 오케스트레이터**다. `SoundCompass` 는 시키는 대로 그리기만 하고
5
+ * 순서·타이밍·점수·진행은 이 훅이 진다(SoundCompass.md 에 그렇게 적혀 있다).
6
+ * 코어의 어느 컴포넌트도 이것을 쓰지 않는다 — 소비자가 쓴다. 그러니 컴포넌트 곁이
7
+ * 아니라 `hooks/` 가 제자리다. 소리방향성이
8
+ * `@ihabdevteam/core/components/useSoundCompassExam` 로 가져가고 있어 이 자리를 남긴다.
13
9
  */
14
- export type SoundCompassMode = 'playback' | 'practice' | 'exam';
15
- export interface UseSoundCompassExamOptions {
16
- /** 동작 모드. 기본 'playback' */
17
- mode?: SoundCompassMode;
18
- /** 마운트/리셋 시 자동 시작. 기본 false */
19
- autoStart?: boolean;
20
- /** 스피커 소리를 재생해야 할 때 호출 (오디오는 호출자 담당) */
21
- onPlaySpeaker?: (speaker: SpeakerPos) => void;
22
- /** 모든 문제/재생 완료 시 호출 */
23
- onComplete?: (result: {
24
- score: number;
25
- total: number;
26
- }) => void;
27
- /** 문제당 소리 재생 후 선택 대기까지 지연(ms). 기본 1100 */
28
- playMs?: number;
29
- /** practice 모드 피드백 표시 시간(ms). 기본 1500 */
30
- feedbackMs?: number;
31
- }
32
- export type SoundCompassPhase = 'idle' | 'playing' | 'waiting' | 'feedback' | 'done';
33
- export interface SoundCompassExam {
34
- /** `<SoundCompass {...bind} />` 로 펼쳐 넣는 제어 props */
35
- bind: {
36
- activeSpeakerId: string | null;
37
- playedIds: string[];
38
- selectedId: string | null;
39
- answerId: string | null;
40
- showFeedback: boolean;
41
- clickable: boolean;
42
- onSelectSpeaker: (id: string) => void;
43
- };
44
- index: number;
45
- total: number;
46
- score: number;
47
- phase: SoundCompassPhase;
48
- done: boolean;
49
- /** 시작(또는 재시작) */
50
- start: () => void;
51
- /** idle 상태로 리셋 */
52
- reset: () => void;
53
- }
54
- export declare function useSoundCompassExam(speakers: SpeakerPos[], { mode, autoStart, onPlaySpeaker, onComplete, playMs, feedbackMs, }?: UseSoundCompassExamOptions): SoundCompassExam;
10
+ export { useSoundCompassExam } from '../hooks/useSoundCompassExam';
11
+ export type { SoundCompassMode, SoundCompassPhase, SoundCompassExam, UseSoundCompassExamOptions } from '../hooks/useSoundCompassExam';
@@ -1,128 +1,10 @@
1
- 'use client';
2
- import { useCallback, useEffect, useRef, useState } from 'react';
3
- import { shuffle } from '../utils/array';
4
- export function useSoundCompassExam(speakers, { mode = 'playback', autoStart = false, onPlaySpeaker, onComplete, playMs = 1100, feedbackMs = 1500, } = {}) {
5
- const [queue, setQueue] = useState([]);
6
- const [index, setIndex] = useState(0);
7
- const [phase, setPhase] = useState('idle');
8
- const [activeId, setActiveId] = useState(null);
9
- const [playedIds, setPlayedIds] = useState([]);
10
- const [selectedId, setSelectedId] = useState(null);
11
- const [score, setScore] = useState(0);
12
- // updater 밖에서 최신 score를 읽기 위한 미러 — setState updater 안에서 onComplete를
13
- // 호출하면(부수효과) StrictMode에서 updater가 2회 실행돼 완료 콜백(점수 기록)이 중복된다.
14
- const scoreRef = useRef(0);
15
- const setScoreTracked = useCallback((next) => {
16
- scoreRef.current = typeof next === 'function' ? next(scoreRef.current) : next;
17
- setScore(scoreRef.current);
18
- }, []);
19
- // 최신 콜백/타이머 참조 (effect 재실행 없이 사용)
20
- const onPlayRef = useRef(onPlaySpeaker);
21
- const onDoneRef = useRef(onComplete);
22
- useEffect(() => { onPlayRef.current = onPlaySpeaker; onDoneRef.current = onComplete; }, [onPlaySpeaker, onComplete]);
23
- const timers = useRef([]);
24
- const clearTimers = () => { timers.current.forEach(clearTimeout); timers.current = []; };
25
- const after = (ms, fn) => { timers.current.push(setTimeout(fn, ms)); };
26
- useEffect(() => () => clearTimers(), []);
27
- const answerId = queue[index]?.id ?? null;
28
- // ── playback: 모든 스피커를 순서대로 ──────────────────────────────────
29
- const runPlayback = useCallback(() => {
30
- clearTimers();
31
- setPhase('playing');
32
- setPlayedIds([]);
33
- setSelectedId(null);
34
- setScoreTracked(0);
35
- let acc = 0;
36
- speakers.forEach((sp, i) => {
37
- after(acc, () => { setIndex(i); setActiveId(sp.id); onPlayRef.current?.(sp); });
38
- after(acc + 1000, () => { setActiveId(null); setPlayedIds(prev => [...prev, sp.id]); });
39
- acc += 1300;
40
- });
41
- after(acc, () => { setPhase('done'); onDoneRef.current?.({ score: 0, total: speakers.length }); });
42
- }, [speakers, setScoreTracked]);
43
- // ── practice / exam: 문제 재생 → 선택 대기 ────────────────────────────
44
- const playQuestion = useCallback((q, i) => {
45
- if (i >= q.length) {
46
- setPhase('done');
47
- onDoneRef.current?.({ score: scoreRef.current, total: q.length });
48
- return;
49
- }
50
- setIndex(i);
51
- setSelectedId(null);
52
- setPhase('playing');
53
- setActiveId(q[i].id);
54
- onPlayRef.current?.(q[i]);
55
- after(playMs, () => { setActiveId(null); setPhase('waiting'); });
56
- }, [playMs]);
57
- const runExam = useCallback(() => {
58
- clearTimers();
59
- const q = shuffle(speakers);
60
- setQueue(q);
61
- setScoreTracked(0);
62
- setPlayedIds([]);
63
- playQuestion(q, 0);
64
- }, [speakers, playQuestion, setScoreTracked]);
65
- const start = useCallback(() => {
66
- if (mode === 'playback') {
67
- setQueue(speakers);
68
- runPlayback();
69
- }
70
- else
71
- runExam();
72
- }, [mode, speakers, runPlayback, runExam]);
73
- const reset = useCallback(() => {
74
- clearTimers();
75
- setPhase('idle');
76
- setActiveId(null);
77
- setPlayedIds([]);
78
- setSelectedId(null);
79
- setIndex(0);
80
- setScoreTracked(0);
81
- setQueue([]);
82
- }, [setScoreTracked]);
83
- // autoStart / 입력 변경 시 재시작 — start/reset이 여러 상태를 동기 초기화하지만 의도된 동작이다
84
- /* eslint-disable react-hooks/set-state-in-effect */
85
- useEffect(() => {
86
- if (autoStart)
87
- start();
88
- else
89
- reset();
90
- // eslint-disable-next-line react-hooks/exhaustive-deps
91
- }, [mode, speakers.length, autoStart]);
92
- /* eslint-enable react-hooks/set-state-in-effect */
93
- const onSelectSpeaker = useCallback((id) => {
94
- if (phase !== 'waiting' || mode === 'playback')
95
- return;
96
- setSelectedId(id);
97
- const correct = id === queue[index]?.id;
98
- if (correct)
99
- setScoreTracked(s => s + 1);
100
- if (mode === 'exam') {
101
- setPhase('playing');
102
- after(400, () => playQuestion(queue, index + 1));
103
- }
104
- else {
105
- setPhase('feedback');
106
- after(feedbackMs, () => playQuestion(queue, index + 1));
107
- }
108
- }, [phase, mode, queue, index, playQuestion, feedbackMs, setScoreTracked]);
109
- const total = mode === 'playback' ? speakers.length : queue.length;
110
- return {
111
- bind: {
112
- activeSpeakerId: activeId,
113
- playedIds,
114
- selectedId,
115
- answerId,
116
- showFeedback: phase === 'feedback',
117
- clickable: phase === 'waiting',
118
- onSelectSpeaker,
119
- },
120
- index,
121
- total,
122
- score,
123
- phase,
124
- done: phase === 'done',
125
- start,
126
- reset,
127
- };
128
- }
1
+ /**
2
+ * 옛 자리 — 실제 몸은 `hooks/useSoundCompassExam.ts` 에 있다.
3
+ *
4
+ * 이것은 **머리 없는 오케스트레이터**다. `SoundCompass` 는 시키는 대로 그리기만 하고
5
+ * 순서·타이밍·점수·진행은 이 훅이 진다(SoundCompass.md 에 그렇게 적혀 있다).
6
+ * 코어의 어느 컴포넌트도 이것을 쓰지 않는다 — 소비자가 쓴다. 그러니 컴포넌트 곁이
7
+ * 아니라 `hooks/` 가 제자리다. 소리방향성이
8
+ * `@ihabdevteam/core/components/useSoundCompassExam` 로 가져가고 있어 이 자리를 남긴다.
9
+ */
10
+ export { useSoundCompassExam } from '../hooks/useSoundCompassExam';
@@ -63,8 +63,8 @@ export { default as PassCardBgLeftColorGrid, passCardBgLeftColorGridTheme } from
63
63
  export type { PassCardBgLeftColorGridProps } from './components/PassCard/PassCardBgLeftColorGrid';
64
64
  export { default as SoundCompass } from './components/SoundCompass';
65
65
  export type { SoundCompassProps, SpeakerState } from './components/SoundCompass';
66
- export { useSoundCompassExam } from './components/useSoundCompassExam';
67
- export type { SoundCompassMode, SoundCompassPhase, SoundCompassExam, UseSoundCompassExamOptions } from './components/useSoundCompassExam';
66
+ export { useSoundCompassExam } from './hooks/useSoundCompassExam';
67
+ export type { SoundCompassMode, SoundCompassPhase, SoundCompassExam, UseSoundCompassExamOptions } from './hooks/useSoundCompassExam';
68
68
  export type { SpeakerPos } from './types/audio';
69
69
  export { default as SideNav } from './components/SideNav';
70
70
  export { default as Button } from './components/Button';
@@ -126,8 +126,8 @@ export type { TextFieldProps } from './components/TextField';
126
126
  export { useComboboxSuggestions } from './components/useComboboxSuggestions';
127
127
  export { default as Dropdown } from './components/Dropdown';
128
128
  export type { DropdownProps, DropdownOption } from './components/Dropdown';
129
- export { useAnchoredMenu } from './components/useAnchoredMenu';
130
- export type { UseAnchoredMenuOptions, UseAnchoredMenuResult } from './components/useAnchoredMenu';
129
+ export { useAnchoredMenu } from './hooks/useAnchoredMenu';
130
+ export type { UseAnchoredMenuOptions, UseAnchoredMenuResult } from './hooks/useAnchoredMenu';
131
131
  export { default as Toggle } from './components/Toggle';
132
132
  export type { ToggleProps } from './components/Toggle';
133
133
  export { default as Checkbox } from './components/Checkbox';
@@ -49,7 +49,7 @@ export { default as PassCardBgTwoCircle, passCardBgTwoCircleTheme } from './comp
49
49
  export { default as PassCardBgIHAB, passCardBgIhabTheme } from './components/PassCard/PassCardBgIHAB';
50
50
  export { default as PassCardBgLeftColorGrid, passCardBgLeftColorGridTheme } from './components/PassCard/PassCardBgLeftColorGrid';
51
51
  export { default as SoundCompass } from './components/SoundCompass';
52
- export { useSoundCompassExam } from './components/useSoundCompassExam';
52
+ export { useSoundCompassExam } from './hooks/useSoundCompassExam';
53
53
  export { default as SideNav } from './components/SideNav';
54
54
  export { default as Button } from './components/Button';
55
55
  export { default as Badge } from './components/Badge';
@@ -83,7 +83,7 @@ export { default as SegmentedControl } from './components/SegmentedControl';
83
83
  export { default as TextField } from './components/TextField';
84
84
  export { useComboboxSuggestions } from './components/useComboboxSuggestions';
85
85
  export { default as Dropdown } from './components/Dropdown';
86
- export { useAnchoredMenu } from './components/useAnchoredMenu';
86
+ export { useAnchoredMenu } from './hooks/useAnchoredMenu';
87
87
  export { default as Toggle } from './components/Toggle';
88
88
  export { default as Checkbox } from './components/Checkbox';
89
89
  export { default as BulkBar } from './components/BulkBar';
@@ -0,0 +1,28 @@
1
+ import { type CSSProperties, type RefObject } from 'react';
2
+ export interface UseAnchoredMenuOptions {
3
+ /** 트리거의 getBoundingClientRect()로부터 메뉴에 적용할 style을 계산 */
4
+ computeStyle: (triggerRect: DOMRect) => CSSProperties;
5
+ /** 열려있는 동안 스크롤 발생 시 동작 (메뉴 내부 스크롤은 항상 무시) — 기본 'close' */
6
+ onScroll?: 'close' | 'reposition' | 'ignore';
7
+ /** 열려있는 동안 윈도우 리사이즈 발생 시 동작 — 기본 'close' */
8
+ onResize?: 'close' | 'reposition' | 'ignore';
9
+ /** 바깥클릭 판정에서 제외할 CSS selector (예: 메뉴 안에 중첩 포탈되는 다른 드롭다운) */
10
+ ignoreOutsideClickSelector?: string;
11
+ }
12
+ export interface UseAnchoredMenuResult<Trigger extends HTMLElement, Menu extends HTMLElement> {
13
+ open: boolean;
14
+ setOpen: (open: boolean) => void;
15
+ toggle: () => void;
16
+ close: () => void;
17
+ /** 트리거 기준으로 계산된 메뉴 style — position:'fixed' 등은 computeStyle이 직접 반환 */
18
+ style: CSSProperties;
19
+ triggerRef: RefObject<Trigger | null>;
20
+ menuRef: RefObject<Menu | null>;
21
+ }
22
+ /**
23
+ * 트리거 요소에 fixed 포지션으로 앵커링되는 메뉴/드롭다운/팝오버의 공용 로직.
24
+ * 열림상태 + 위치 계산 + 바깥클릭/스크롤/리사이즈 처리를 담당하고, 렌더링(포탈 등)은
25
+ * 사용하는 쪽에서 담당한다. Dropdown, DatePicker, BottomNav의 "더보기" 메뉴가 이 훅을 쓴다.
26
+ * @public
27
+ */
28
+ export declare function useAnchoredMenu<Trigger extends HTMLElement = HTMLElement, Menu extends HTMLElement = HTMLElement>({ computeStyle, onScroll, onResize, ignoreOutsideClickSelector, }: UseAnchoredMenuOptions): UseAnchoredMenuResult<Trigger, Menu>;