@ihabdevteam/core 0.37.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 (53) hide show
  1. package/dist/components.cjs.js +19 -19
  2. package/dist/components.esm.css +1 -1
  3. package/dist/hooks.cjs.js +1 -788
  4. package/dist/i18n.cjs.js +9 -9
  5. package/dist/ihabdevteam-core.cjs.js +178 -178
  6. package/dist/ihabdevteam-core.d.ts +74 -8
  7. package/dist/packages/core/src/components/BottomNav.js +1 -1
  8. package/dist/packages/core/src/components/DatePicker.js +1 -1
  9. package/dist/packages/core/src/components/Dropdown.js +1 -1
  10. package/dist/packages/core/src/components/FilterPanel.js +2 -1
  11. package/dist/packages/core/src/components/Header.js +1 -1
  12. package/dist/packages/core/src/components/PageTitle.css +7 -0
  13. package/dist/packages/core/src/components/PageTitle.d.ts +13 -1
  14. package/dist/packages/core/src/components/PageTitle.js +11 -2
  15. package/dist/packages/core/src/components/Panel.d.ts +3 -133
  16. package/dist/packages/core/src/components/Panel.js +2 -140
  17. package/dist/packages/core/src/components/PanelRow.d.ts +145 -0
  18. package/dist/packages/core/src/components/PanelRow.js +144 -0
  19. package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +16 -2
  20. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +19 -2
  21. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +1 -54
  22. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +2 -15
  23. package/dist/packages/core/src/components/Word/wordTablePrint.js +7 -1
  24. package/dist/packages/core/src/components/useAnchoredMenu.d.ts +8 -26
  25. package/dist/packages/core/src/components/useAnchoredMenu.js +7 -70
  26. package/dist/packages/core/src/components/useSoundCompassExam.d.ts +8 -51
  27. package/dist/packages/core/src/components/useSoundCompassExam.js +10 -128
  28. package/dist/packages/core/src/components.d.ts +4 -4
  29. package/dist/packages/core/src/components.js +2 -2
  30. package/dist/packages/core/src/hooks/useAnchoredMenu.d.ts +28 -0
  31. package/dist/packages/core/src/hooks/useAnchoredMenu.js +72 -0
  32. package/dist/packages/core/src/hooks/useSoundCompassExam.d.ts +54 -0
  33. package/dist/packages/core/src/hooks/useSoundCompassExam.js +128 -0
  34. package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +2 -0
  35. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +2 -1
  36. package/dist/packages/core/src/hooks/useWordTrainingDataGeneration.d.ts +2 -8
  37. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -6
  38. package/dist/packages/core/src/hooks.d.ts +4 -0
  39. package/dist/packages/core/src/hooks.js +6 -0
  40. package/dist/packages/core/src/i18n/locales/en.json +8 -0
  41. package/dist/packages/core/src/i18n/locales/ko.json +8 -0
  42. package/dist/packages/core/src/types/filterPanel.d.ts +15 -0
  43. package/dist/packages/core/src/types/training.d.ts +20 -0
  44. package/dist/packages/core/src/types/training.js +1 -0
  45. package/dist/packages/core/src/utils/audioPeaks.d.ts +2 -0
  46. package/dist/packages/core/src/utils/audioPeaks.js +67 -0
  47. package/dist/packages/core/src/utils/noiseSettingsStore.d.ts +1 -1
  48. package/dist/packages/core/src/utils/wordTrainingGroups.d.ts +1 -1
  49. package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.d.ts +14 -0
  50. package/dist/packages/core/src/version.d.ts +1 -1
  51. package/dist/packages/core/src/version.js +1 -1
  52. package/dist/utils.cjs.js +1 -1
  53. package/package.json +1 -1
@@ -389,6 +389,17 @@ export declare interface BarData {
389
389
  parts?: number[];
390
390
  }
391
391
 
392
+ /**
393
+ * 패널 **한 줄** — `Panel` 이 담는 그릇이라면 이쪽은 그 안에 서는 줄이다.
394
+ *
395
+ * `Panel.tsx` 에서 갈라 나왔다. 한 파일에 컴포넌트 넷(Panel · PanelGrid · PanelRow ·
396
+ * PanelRowDevice)과 타입 열다섯이 함께 있었는데, 그릇을 고치러 들어온 사람이 줄의
397
+ * 12갈래 판별 합집합을 지나야 했다. 이 저장소가 이미 쓰는 방식이다
398
+ * (`trainingStepAudioPlayerTypes.ts` · `audioPlaybackRules.ts`).
399
+ *
400
+ * `Panel.tsx` 가 이것들을 다시 내보내므로 `@ihabdevteam/core/components/Panel` 에서
401
+ * 가져가던 곳은 그대로 돌아간다.
402
+ */
392
403
  declare type Base = {
393
404
  label?: string;
394
405
  sub?: string;
@@ -527,6 +538,7 @@ export declare function buildDropFinalConsonantProblem(word: string, _comp: Comp
527
538
  export declare function buildGradeSection(t: TFunction, keys: WordAttributeFilterKeys, opts?: {
528
539
  allLabel?: string;
529
540
  labelAction?: ReactNode;
541
+ labelInfo?: FilterPanelSection['labelInfo'];
530
542
  }): FilterPanelSection;
531
543
 
532
544
  /**
@@ -597,6 +609,7 @@ export declare function buildRemoveConsonantProblem(word: string, _comp: Composi
597
609
  export declare function buildSelectSection(t: TFunction, opts?: {
598
610
  includeStructure?: boolean;
599
611
  labelAction?: ReactNode;
612
+ labelInfo?: FilterPanelSection['labelInfo'];
600
613
  }): FilterPanelSection;
601
614
 
602
615
  /** 위치 p를 와일드카드('*')로 마스킹한 "나머지 음절이 같은" 2·3음절 실단어 그룹(O(1) 조회). */
@@ -1167,11 +1180,25 @@ export declare interface ConfirmPopupProps {
1167
1180
  confirmDisabled?: boolean;
1168
1181
  /** true면 취소 버튼을 숨긴다(단순 안내·닫기 전용). 기본 false */
1169
1182
  hideCancel?: boolean;
1170
- /** 확인/취소 왼쪽에 놓는 부가 액션(삭제 등). 클릭 후 팝업이 닫힌다. */
1183
+ /**
1184
+ * 확인/취소 왼쪽에 놓는 부가 액션(삭제·연결 해제 등).
1185
+ *
1186
+ * 계약은 `onConfirm` 과 같다 — `close` 를 받고 `closeOnClick` 으로 닫힘을 고른다.
1187
+ * **실패할 수 있는 비동기 동작**이 여기 오기 때문이다. 곁 단추는 파괴적인 일을
1188
+ * 맡는 자리인데(주 단추는 '그대로 두기' 여야 한다), 예전에는 누르면 무조건 닫혀
1189
+ * 실패해도 까닭을 그 자리에 못 보였다 — 사람은 눌렀는데 아무 말 없이 창이 사라진
1190
+ * 것만 본다. 성공했을 때만 닫으려면 `closeOnClick: false` 로 두고 `close()` 를
1191
+ * 직접 부른다.
1192
+ */
1171
1193
  extraAction?: {
1172
1194
  label: ReactNode;
1173
1195
  variant?: ButtonVariant;
1174
- onClick: () => void;
1196
+ /** `close` 를 받는다 — 성공했을 때만 닫는 식으로 쓴다(async 지원). */
1197
+ onClick: (close: () => void) => void | Promise<void>;
1198
+ /** false면 자동으로 닫지 않음 — onClick이 받은 close로 닫힘 시점을 제어. 기본 true */
1199
+ closeOnClick?: boolean;
1200
+ /** 곁 단추만 따로 비활성 (처리 중 등). 팝업 전체의 busy 와는 별개로 얹힌다. */
1201
+ disabled?: boolean;
1175
1202
  };
1176
1203
  /**
1177
1204
  * 확인 클릭 시 실행. `close`로 직접 닫을 수 있음(async 지원).
@@ -1268,14 +1295,19 @@ export declare function copyText(text: string): Promise<boolean>;
1268
1295
  *
1269
1296
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1270
1297
  */
1271
- export declare const CORE_VERSION = "0.37.0";
1298
+ export declare const CORE_VERSION = "0.39.0";
1272
1299
 
1273
1300
  /** @public */
1274
1301
  declare const coreDefault: Record<string, unknown>;
1275
1302
  export default coreDefault;
1276
1303
 
1277
- /** @public */
1278
- declare type CoreNoiseSettings = {
1304
+ /**
1305
+ * 훈련 화면의 소음/재생 설정. `NoisePlayerContext` 가 들고 다니고
1306
+ * `utils/noiseSettingsStore` 가 앱이 주입한 콜백으로 영속화한다.
1307
+ * 두 계층이 함께 아는 모양이라 여기(아래 계층)에 둔다.
1308
+ * @public
1309
+ */
1310
+ export declare type CoreNoiseSettings = {
1279
1311
  enabled: boolean;
1280
1312
  type: CoreNoiseType;
1281
1313
  /** 0–9. 0이면 소음 비활성화. (소음 음원을 +7dB 증폭한 뒤 상한을 4→9로 확장 — 실제 volume = level/9) */
@@ -1288,8 +1320,8 @@ declare type CoreNoiseSettings = {
1288
1320
  mainSpeed: number;
1289
1321
  };
1290
1322
 
1291
- /** @public */
1292
- declare type CoreNoiseType = 'defaultNoise' | 'street' | 'restaurant' | 'buzz';
1323
+ /** 소음 음원 갈래. @public */
1324
+ export declare type CoreNoiseType = 'defaultNoise' | 'street' | 'restaurant' | 'buzz';
1293
1325
 
1294
1326
  /** @public */
1295
1327
  export declare function CoreScope({ as: Component, className, children, storage, ...rest }: CoreScopeProps): JSX.Element;
@@ -1919,6 +1951,21 @@ export declare interface FilterPanelSection {
1919
1951
  /** 가로 배치에서 이 섹션을 한 줄 전체(자체 줄)로 배치 — 인라인 흐름에서 분리 */
1920
1952
  block?: boolean;
1921
1953
  labelAction?: ReactNode;
1954
+ /**
1955
+ * 라벨 옆 "?" 설명 버튼을 **서술로** 지정한다 — `labelAction` 에 요소를 직접 넣는 대신
1956
+ * 내용만 준다. FilterPanel 이 `FilterSectionInfo` 로 그린다.
1957
+ *
1958
+ * 왜 두 갈래인가: 훅은 화면을 그리면 안 된다. 섹션을 만들어 주는 훅
1959
+ * (`useWordTrainingFilter`)이 예전에는 `createElement(FilterSectionInfo, …)` 로
1960
+ * 요소를 만들었는데, 그러려면 **훅이 컴포넌트를 올려다봐야** 했다. 이 슬롯을 쓰면
1961
+ * 훅은 내용만 넘기고 그리는 일은 컴포넌트가 한다.
1962
+ * `labelAction` 은 그대로 둔다 — 호출부가 제 요소를 넣고 싶을 때가 있다.
1963
+ * 둘 다 있으면 `labelAction` 이 이긴다.
1964
+ */
1965
+ labelInfo?: {
1966
+ title?: string;
1967
+ items: string[];
1968
+ };
1922
1969
  items?: (FilterPanelItem | string)[];
1923
1970
  groups?: (FilterPanelItem | string)[][];
1924
1971
  /** 'note': 선택 불가능한 안내 텍스트 섹션 — content를 그대로 렌더링한다(예: 입력 구문 사용법). */
@@ -2982,13 +3029,25 @@ export declare type PageProps = {
2982
3029
  export declare function pageStartIndexes(pageLengths: readonly number[]): number[];
2983
3030
 
2984
3031
  /** @public */
2985
- export declare function PageTitle({ title, sub, rightAction, titleAction: _titleAction, badge, badgeVariant, documentTitle }: PageTitleProps): JSX.Element;
3032
+ export declare function PageTitle({ title, sub, rightAction, titleAction, badge, badgeVariant, documentTitle, onBack, backLabel }: PageTitleProps): JSX.Element;
2986
3033
 
2987
3034
  declare type PageTitleProps = {
2988
3035
  title: ReactNode;
2989
3036
  sub?: ReactNode;
2990
3037
  rightAction?: ReactNode;
3038
+ /** 제목 오른쪽(뱃지 옆)에 끼우는 것. */
2991
3039
  titleAction?: ReactNode;
3040
+ /**
3041
+ * 주면 제목 **앞에** 뒤로가기를 그린다.
3042
+ *
3043
+ * 이것이 없을 때는 `title` 에 JSX 를 넘겨 화살표를 끼우는 수밖에 없었는데, 그러면
3044
+ * 아래 `useDocumentTitle` 이 문자열을 못 받아 `documentTitle` 을 따로 줘야 했다.
3045
+ * 뒤로가 필요한 화면마다 그 두 가지를 다시 하게 된다. `onBack` 으로 넘기면
3046
+ * `title` 은 문자열 그대로여도 되므로 문서 제목이 저절로 붙는다.
3047
+ */
3048
+ onBack?: () => void;
3049
+ /** 뒤로가기의 이름(낭독기·툴팁). 기본 `t('common.back')`. */
3050
+ backLabel?: string;
2992
3051
  badge?: string;
2993
3052
  badgeVariant?: string;
2994
3053
  /**
@@ -8659,6 +8718,13 @@ export declare interface WordTrainingFilterCaps {
8659
8718
  /** @public 훈련 유형이 속한 상위 그룹(카테고리). 앱의 STATIC_TYPE_GROUPS와 동일 분류. */
8660
8719
  declare type WordTrainingGroup = 'discrimination' | 'synthesis' | 'deletion' | 'substitution' | 'attention';
8661
8720
 
8721
+ /**
8722
+ * 훈련 갈래 한 줄 — 열쇠와 화면에 보일 이름표.
8723
+ * `hooks/useWordTrainingDataGeneration` 이 만들어 내보내고
8724
+ * `utils/wordTrainingGroups` 가 묶을 때 읽는다. 두 계층이 함께 아는 모양이라
8725
+ * 열쇠 타입이 있는 여기(아래 계층)에 둔다.
8726
+ * @public
8727
+ */
8662
8728
  export declare interface WordTrainingType {
8663
8729
  key: WordTrainingTypeKey;
8664
8730
  label: string;
@@ -9,7 +9,7 @@ import MenuTooltip from './Tooltip/MenuTooltip';
9
9
  // Tooltip.tsx만 이 CSS를 import하는데 BottomNav는 그 컴포넌트를 거치지 않아,
10
10
  // 프로덕션에서 청크가 갈리면 스타일 없이 렌더될 수 있다.
11
11
  import './Tooltip/Tooltip.css';
12
- import { useAnchoredMenu } from './useAnchoredMenu';
12
+ import { useAnchoredMenu } from '../hooks/useAnchoredMenu';
13
13
  import { useTranslation } from 'react-i18next';
14
14
  const BTN_SIZE = 52;
15
15
  const BTN_GAP = 10;
@@ -13,7 +13,7 @@ import Calendar from 'lucide-react/dist/esm/icons/calendar.mjs';
13
13
  import ChevronLeft from 'lucide-react/dist/esm/icons/chevron-left.mjs';
14
14
  import ChevronRight from 'lucide-react/dist/esm/icons/chevron-right.mjs';
15
15
  import Dropdown from './Dropdown';
16
- import { useAnchoredMenu } from './useAnchoredMenu';
16
+ import { useAnchoredMenu } from '../hooks/useAnchoredMenu';
17
17
  import { useCalendarMonth } from './useCalendarMonth';
18
18
  import { FOCUSABLE_SELECTOR } from '../hooks/useFocusTrap';
19
19
  import './DatePicker.css';
@@ -4,7 +4,7 @@ import { cx } from '../utils/cx';
4
4
  import { useCallback, useEffect, useId, useRef, useState } from 'react';
5
5
  import { createPortal } from 'react-dom';
6
6
  import { useTranslation } from 'react-i18next';
7
- import { useAnchoredMenu } from './useAnchoredMenu';
7
+ import { useAnchoredMenu } from '../hooks/useAnchoredMenu';
8
8
  import './Dropdown.css';
9
9
  /** @public */
10
10
  export default function Dropdown({ options, value, onChange, placeholder, disabled = false, block = false, size = 48, error = false, className = '', ariaLabel }) {
@@ -10,6 +10,7 @@ import TextField from './TextField';
10
10
  import Dropdown from './Dropdown';
11
11
  export { FILTER_ALL_VALUE } from '../types/filterPanel';
12
12
  import { FILTER_ALL_VALUE } from '../types/filterPanel';
13
+ import FilterSectionInfo from './FilterSectionInfo';
13
14
  /** @public */
14
15
  export default function FilterPanel({ title, headerAction, collapsible = false, defaultCollapsed = false, collapsed: controlledCollapsed, onCollapsedChange, footer, sections, selected = {}, onSelect, align, sectionAlign, className, }) {
15
16
  // align 우선. 없으면 구 prop(sectionAlign) 매핑, 그것도 없으면 inline 기본.
@@ -49,7 +50,7 @@ export default function FilterPanel({ title, headerAction, collapsible = false,
49
50
  const isSelected = Array.isArray(sel) ? sel.includes(value) : sel === value;
50
51
  return (_jsx(Button, { size: "keyboard", label: itemLabels ?? displayLabel ?? value, type: section.itemType ?? (itemLabels ? 'bar' : value.length === 1 ? 'dot' : undefined), selected: isSelected, disabled: disabled, shape: shape, showTooltipInfo: !!tooltip, tooltipInfoText: tooltip, tooltipDescription: descriptionTooltip, onClick: () => onSelect?.(value, section.label) }, value));
51
52
  };
52
- return (_jsxs(Fragment, { children: [section.groupLabel && (_jsxs("div", { className: "filter-panel__group-label-row", children: [_jsx("div", { className: "filter-panel__group-label body-2", children: section.groupLabel }), section.groupAction] })), _jsxs("div", { className: cx('filter-panel__section', section.layout === 'row' ? 'filter-panel__section--row' : '', section.block ? 'filter-panel__section--block' : ''), children: [_jsxs("div", { className: "filter-panel__section-label-row", children: [_jsx("span", { className: "filter-panel__section-label body-3", children: section.displayLabel ?? section.label }), section.labelAction] }), section.type === 'note' ? (_jsx("div", { className: "filter-panel__note body-3", children: section.content })) : section.type === 'input' ? (_jsx(TextField, { className: "filter-panel__textfield", placeholder: section.placeholder, ariaLabel: section.displayLabel ?? section.label, value: selected[section.label] ?? '', onChange: (value) => onSelect?.(value, section.label), error: section.error })) : (!section.multiple && section.display === 'segmented') ? (_jsx(SegmentedControl
53
+ return (_jsxs(Fragment, { children: [section.groupLabel && (_jsxs("div", { className: "filter-panel__group-label-row", children: [_jsx("div", { className: "filter-panel__group-label body-2", children: section.groupLabel }), section.groupAction] })), _jsxs("div", { className: cx('filter-panel__section', section.layout === 'row' ? 'filter-panel__section--row' : '', section.block ? 'filter-panel__section--block' : ''), children: [_jsxs("div", { className: "filter-panel__section-label-row", children: [_jsx("span", { className: "filter-panel__section-label body-3", children: section.displayLabel ?? section.label }), section.labelAction ?? (section.labelInfo ? _jsx(FilterSectionInfo, { ...section.labelInfo }) : null)] }), section.type === 'note' ? (_jsx("div", { className: "filter-panel__note body-3", children: section.content })) : section.type === 'input' ? (_jsx(TextField, { className: "filter-panel__textfield", placeholder: section.placeholder, ariaLabel: section.displayLabel ?? section.label, value: selected[section.label] ?? '', onChange: (value) => onSelect?.(value, section.label), error: section.error })) : (!section.multiple && section.display === 'segmented') ? (_jsx(SegmentedControl
53
54
  /* 바로 위 TextField 처럼 섹션 이름을 그대로 쓴다. 안 주면 라디오그룹이
54
55
  기본값 '선택 탭'으로 읽혀, 필터가 여럿인 화면에서 어느 그룹인지
55
56
  구분이 안 된다(단어표 검색 설정은 세그먼트가 열 개 가까이 된다). */
@@ -6,7 +6,7 @@ import { useMediaQuery } from '../hooks';
6
6
  import BottomNav from './BottomNav';
7
7
  import Button from './Button';
8
8
  import MenuTooltip from './Tooltip/MenuTooltip';
9
- import { useAnchoredMenu } from './useAnchoredMenu';
9
+ import { useAnchoredMenu } from '../hooks/useAnchoredMenu';
10
10
  import { createPortal } from 'react-dom';
11
11
  // 메뉴 표면(.tooltip-content) 정의 — Tooltip.tsx를 거치지 않고 클래스만 쓰므로 명시적으로.
12
12
  import './Tooltip/Tooltip.css';
@@ -25,3 +25,10 @@
25
25
  align-self: flex-start;
26
26
  }
27
27
  }
28
+
29
+ /* 뒤로가기는 제목 앞에 붙는다. 줄이 flex(align-items:center)라 세로 가운데는 저절로
30
+ 맞고, 여기서는 가로 간격만 좁힌다 — 줄의 기본 간격(12px)은 제목과 뱃지 사이의 것이고,
31
+ 화살표와 제목은 한 덩어리로 읽혀야 하므로 그보다 붙어 있어야 한다.
32
+ 음수 여백으로 왼쪽을 당겨, 아이콘 단추의 제 여백 때문에 제목이 페이지의 다른
33
+ 제목들보다 오른쪽으로 밀려 보이는 것을 막는다(글자 시작선을 맞춘다). */
34
+ .page-title__back { margin-left: -8px; margin-right: -4px; flex-shrink: 0; }
@@ -4,7 +4,19 @@ type PageTitleProps = {
4
4
  title: ReactNode;
5
5
  sub?: ReactNode;
6
6
  rightAction?: ReactNode;
7
+ /** 제목 오른쪽(뱃지 옆)에 끼우는 것. */
7
8
  titleAction?: ReactNode;
9
+ /**
10
+ * 주면 제목 **앞에** 뒤로가기를 그린다.
11
+ *
12
+ * 이것이 없을 때는 `title` 에 JSX 를 넘겨 화살표를 끼우는 수밖에 없었는데, 그러면
13
+ * 아래 `useDocumentTitle` 이 문자열을 못 받아 `documentTitle` 을 따로 줘야 했다.
14
+ * 뒤로가 필요한 화면마다 그 두 가지를 다시 하게 된다. `onBack` 으로 넘기면
15
+ * `title` 은 문자열 그대로여도 되므로 문서 제목이 저절로 붙는다.
16
+ */
17
+ onBack?: () => void;
18
+ /** 뒤로가기의 이름(낭독기·툴팁). 기본 `t('common.back')`. */
19
+ backLabel?: string;
8
20
  badge?: string;
9
21
  badgeVariant?: string;
10
22
  /**
@@ -19,5 +31,5 @@ type PageTitleProps = {
19
31
  documentTitle?: string;
20
32
  };
21
33
  /** @public */
22
- export default function PageTitle({ title, sub, rightAction, titleAction: _titleAction, badge, badgeVariant, documentTitle }: PageTitleProps): import("react").JSX.Element;
34
+ export default function PageTitle({ title, sub, rightAction, titleAction, badge, badgeVariant, documentTitle, onBack, backLabel }: PageTitleProps): import("react").JSX.Element;
23
35
  export {};
@@ -1,10 +1,19 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useTranslation } from 'react-i18next';
2
3
  import './PageTitle.css';
3
4
  import Badge from './Badge';
5
+ import Button from './Button';
4
6
  import { useDocumentTitle } from '../hooks/useDocumentTitle';
5
7
  /** @public */
6
- export default function PageTitle({ title, sub, rightAction, titleAction: _titleAction, badge, badgeVariant = 'default', documentTitle }) {
8
+ export default function PageTitle({ title, sub, rightAction, titleAction, badge, badgeVariant = 'default', documentTitle, onBack, backLabel }) {
9
+ const { t } = useTranslation();
7
10
  // 페이지 제목이 곧 문서 제목이 되도록 맞춘다(WCAG 2.4.2).
8
11
  useDocumentTitle(typeof title === 'string' ? title : (documentTitle ?? null));
9
- return (_jsxs("div", { className: "page-title", children: [_jsxs("div", { className: "page-title__main", children: [_jsxs("div", { className: "page-title__title-row", children: [_jsx("h1", { className: "page-title__title h1", children: title }), badge && _jsx("div", { className: "page-title__badge", children: _jsx(Badge, { label: badge, size: "small", variant: badgeVariant, fill: "outline" }) })] }), sub && _jsx("p", { className: "page-title__sub body-2", children: sub })] }), rightAction ? _jsx("div", { className: "page-title__action", children: rightAction }) : null] }));
12
+ const 뒤로이름 = backLabel ?? t('common.back', '뒤로');
13
+ return (_jsxs("div", { className: "page-title", children: [_jsxs("div", { className: "page-title__main", children: [_jsxs("div", { className: "page-title__title-row", children: [onBack && (
14
+ /* 글자 없이 화살표만 둔다 — 제목 옆에서 '뒤로' 라고 적으면 제목과 경쟁한다.
15
+ 그만큼 과녁을 크게(48) 잡는다. 이름은 낭독기용으로 따로 준다. */
16
+ _jsx(Button, { className: "page-title__back", size: 48, type: "dot", icon: "chevron-left", iconSize: "large", useBorder: false,
17
+ /* 역할 이름을 쓴다 — `--color-black` 은 다크에서 이름과 실제 색이 뒤집힌다. */
18
+ iconColor: "var(--color-text-primary)", ariaLabel: 뒤로이름, showTooltipInfo: 뒤로이름, onClick: onBack })), _jsx("h1", { className: "page-title__title h1", children: title }), badge && _jsx("div", { className: "page-title__badge", children: _jsx(Badge, { label: badge, size: "small", variant: badgeVariant, fill: "outline" }) }), titleAction ? _jsx("div", { className: "page-title__title-action", children: titleAction }) : null] }), sub && _jsx("p", { className: "page-title__sub body-2", children: sub })] }), rightAction ? _jsx("div", { className: "page-title__action", children: rightAction }) : null] }));
10
19
  }
@@ -1,5 +1,4 @@
1
- import type { ReactNode, KeyboardEvent } from 'react';
2
- import type { DeviceEntry, DeviceSide } from './HearingDeviceInfo';
1
+ import type { ReactNode } from 'react';
3
2
  import './Panel.css';
4
3
  type PanelSize = 'small' | 'medium' | 'large';
5
4
  interface PanelProps {
@@ -51,134 +50,5 @@ export declare function PanelGrid({ cols, children }: {
51
50
  cols?: number;
52
51
  children: ReactNode;
53
52
  }): import("react").JSX.Element;
54
- type Base = {
55
- label?: string;
56
- sub?: string;
57
- onClick?: () => void;
58
- required?: boolean;
59
- error?: string;
60
- align?: 'horizontal' | 'vertical';
61
- useFullWidth?: boolean;
62
- icon?: ReactNode;
63
- };
64
- export type PanelRowToggle = Base & {
65
- variant: 'toggle';
66
- checked: boolean;
67
- onChange: (v: boolean) => void;
68
- };
69
- export type PanelRowSegmented = Base & {
70
- variant: 'segmented';
71
- options: {
72
- key: string;
73
- label: string;
74
- }[];
75
- value: string;
76
- onChange: (v: string) => void;
77
- size?: 32 | 40 | 48;
78
- };
79
- export type DropdownItem = {
80
- options: {
81
- value: string;
82
- label: string;
83
- }[];
84
- value: string;
85
- onChange: (v: string) => void;
86
- placeholder?: string;
87
- /**
88
- * 이 드롭다운 하나의 이름. 미지정 시 "행 라벨 + placeholder" 로 조합한다
89
- * (생년월일 + 년 → "생년월일 년").
90
- */
91
- ariaLabel?: string;
92
- };
93
- export type PanelRowDropdown = Base & {
94
- variant: 'dropdown';
95
- options?: {
96
- value: string;
97
- label: string;
98
- }[];
99
- value?: string;
100
- onChange?: (v: string) => void;
101
- dropdowns?: DropdownItem[];
102
- };
103
- export type ButtonItem = {
104
- label: ReactNode;
105
- btnVariant?: string;
106
- btnSize?: number;
107
- disabled?: boolean;
108
- onClick: () => void;
109
- };
110
- export type PanelRowButton = Base & {
111
- variant: 'button';
112
- buttonLabel?: ReactNode;
113
- btnVariant?: string;
114
- btnSize?: number;
115
- disabled?: boolean;
116
- onClick?: () => void;
117
- buttons?: ButtonItem[];
118
- };
119
- export type PanelRowText = Base & {
120
- variant: 'text';
121
- value: string;
122
- valueColor?: string;
123
- };
124
- export type PanelRowInput = Base & {
125
- variant: 'input';
126
- value: string;
127
- onChange?: (v: string) => void;
128
- onKeyDown?: (e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
129
- /** Enter로 확정(제출 등). TextField가 IME 조합 중 Enter는 걸러 주므로 한글 입력에서도 안전하다. */
130
- onEnter?: () => void;
131
- placeholder?: string;
132
- inputMode?: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
133
- readOnly?: boolean;
134
- disabled?: boolean;
135
- type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
136
- autoComplete?: string;
137
- helperText?: string;
138
- /** 최대 글자 수 — 설정 시 입력 제한 + 박스 안쪽 글자수 카운터 표시 */
139
- maxLength?: number;
140
- /** 글자수 카운터 표시 여부 (maxLength 설정 시, 기본 true) */
141
- showCount?: boolean;
142
- /** 여러 줄 입력(textarea) 모드 */
143
- multiline?: boolean;
144
- /** multiline 모드 기본 행 수 */
145
- rows?: number;
146
- };
147
- export type PanelRowCustom = Base & {
148
- variant: 'custom';
149
- children: ReactNode;
150
- };
151
- /** 라벨/우측 분리 없이 설명 문단을 통으로 표시하는 행 */
152
- export type PanelRowDescription = {
153
- variant: 'description';
154
- children: ReactNode;
155
- };
156
- /** 빈 상태 표현 — Panel의 `empty` prop이 내부적으로 이 variant로 렌더한다. 단독으로도 사용 가능. */
157
- export type PanelRowEmpty = {
158
- variant: 'empty';
159
- children: ReactNode;
160
- };
161
- /** 로딩 스피너 1개만 중앙에 표시 — Panel의 `loading` prop이 내부적으로 이 variant로 렌더한다. */
162
- export type PanelRowLoading = {
163
- variant: 'loading';
164
- };
165
- export type PanelRowChildren = {
166
- label?: ReactNode;
167
- sub?: string;
168
- onClick?: () => void;
169
- children?: ReactNode;
170
- align?: 'horizontal' | 'vertical';
171
- icon?: ReactNode;
172
- };
173
- export type PanelRowProps = PanelRowToggle | PanelRowSegmented | PanelRowDropdown | PanelRowButton | PanelRowText | PanelRowInput | PanelRowCustom | PanelRowDescription | PanelRowEmpty | PanelRowLoading | PanelRowChildren;
174
- export declare function PanelRow(props: PanelRowProps): import("react").JSX.Element;
175
- export type { DeviceSide };
176
- export interface PanelRowDeviceProps {
177
- devices: {
178
- 좌: DeviceEntry | null;
179
- 우: DeviceEntry | null;
180
- };
181
- /** 있으면 각 쪽 이름 옆에 '착용 해제'가 선다 — 그 쪽의 '지금 착용중' 만 내린다(이력은 남는다) */
182
- onRelease?: (side: DeviceSide) => void;
183
- }
184
- export declare function PanelRowDevice({ devices, onRelease }: PanelRowDeviceProps): import("react").JSX.Element;
53
+ export { PanelRow, PanelRowDevice } from './PanelRow';
54
+ export type { PanelRowToggle, PanelRowSegmented, PanelRowDropdown, PanelRowButton, PanelRowText, PanelRowInput, PanelRowCustom, PanelRowDescription, PanelRowEmpty, PanelRowLoading, PanelRowChildren, PanelRowProps, PanelRowDeviceProps, DropdownItem, ButtonItem, DeviceSide, } from './PanelRow';
@@ -1,19 +1,9 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
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';
4
+ import { PanelRow } from './PanelRow';
11
5
  import Icon from './Icon';
12
- import { deviceKindVariant } from '../utils/deviceKind';
13
- import TextField from './TextField';
14
- import Loader from './Loader';
15
6
  import './Panel.css';
16
- const noop = () => { };
17
7
  export default function Panel({ title, icon, disc, actions, size = 'medium', minHeight, maxWidth, className, scrollable, useBorder = true, useBg = true, useRowBorder = true, labelWidth, form = false, loading, empty, children }) {
18
8
  const cls = cx('panel', `panel--${size}`, form ? 'panel--form' : '',
19
9
  /* minHeight 패널은 마지막 행 아래 빈 공간이 생길 수 있으므로 행 하단 구분선 사용 */
@@ -32,132 +22,4 @@ export default function Panel({ title, icon, disc, actions, size = 'medium', min
32
22
  export function PanelGrid({ cols = 2, children }) {
33
23
  return (_jsx("div", { className: "panel-grid", style: { '--panel-grid-cols': cols }, children: children }));
34
24
  }
35
- export function PanelRow(props) {
36
- // toggle 행의 라벨-스위치 연결용 id — 모든 variant에서 훅 호출 순서를 유지하기 위해
37
- // 조건부 return들보다 앞에서 무조건 호출한다 (사용하지 않는 variant에서는 그냥 버려짐)
38
- const uid = useId();
39
- // children 기반 하위 호환 모드
40
- if (!('variant' in props)) {
41
- const { label, sub, onClick, children, align = 'horizontal', icon } = props;
42
- const handleKeyDown = (e) => {
43
- if (!onClick)
44
- return;
45
- // 우측 children 안의 버튼/토글 등이 자체적으로 Enter/Space를 처리하므로,
46
- // 행 자기 자신에서 발생한 키 입력만 행 클릭으로 취급한다
47
- if (e.target !== e.currentTarget)
48
- return;
49
- if (e.key === 'Enter' || e.key === ' ') {
50
- e.preventDefault();
51
- onClick();
52
- }
53
- };
54
- 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 })] }));
55
- }
56
- // 설명 전용 행 — 라벨/우측 구조 없이 문단만 렌더
57
- if (props.variant === 'description') {
58
- const p = props;
59
- return (_jsx("div", { className: "panel-row panel-row--block", children: _jsx("p", { className: "panel-row__description body-3", children: p.children }) }));
60
- }
61
- // 빈 상태 — 라벨 없이 중앙 정렬된 한 줄
62
- if (props.variant === 'empty') {
63
- const p = props;
64
- return _jsx("div", { className: "panel-row panel-row--empty body-3", children: p.children });
65
- }
66
- // 로딩 — 중앙 스피너 1개
67
- if (props.variant === 'loading') {
68
- return _jsx("div", { className: "panel-row panel-row--loading", children: _jsx(Loader, { size: 28 }) });
69
- }
70
- // custom 행에 label이 없으면 좌/우 분할 없이 children을 그대로 꽉 채워 렌더
71
- // (CodeBox처럼 라벨과 짝지을 수 없는 큰 블록 콘텐츠를 행 구조 안에 넣을 때 사용)
72
- if (props.variant === 'custom' && !props.label) {
73
- const p = props;
74
- return (_jsx("div", { className: "panel-row panel-row--block", children: p.children }));
75
- }
76
- const { variant, label, sub, onClick, required, error, align = 'horizontal', useFullWidth = false, icon } = props;
77
- let right = null;
78
- // toggle 행만 왼쪽 라벨(panel-row__label)을 <label htmlFor>로 렌더해 스위치와 프로그래밍적으로
79
- // 연결한다 — 그 전엔 시각적으로만 옆에 붙어있어 라벨 텍스트를 클릭해도 토글이 안 되고
80
- // 스크린리더도 이 스위치가 뭘 위한 건지 안내하지 못했다.
81
- const toggleId = variant === 'toggle' ? `panel-toggle-${uid}` : undefined;
82
- if (variant === 'toggle') {
83
- const p = props;
84
- right = _jsx(Toggle, { checked: p.checked, onChange: p.onChange, id: toggleId });
85
- }
86
- else if (variant === 'segmented') {
87
- const p = props;
88
- // dropdown 행과 동일하게, PanelRow 왼쪽 라벨은 <label htmlFor>가 아닌 일반 div라서
89
- // SegmentedControl에 ariaLabel로 넘겨야 스크린리더가 이름 없는 라디오그룹으로 안내하지 않는다.
90
- right = _jsx(SegmentedControl, { options: p.options, value: p.value, onChange: p.onChange, size: p.size ?? 40, ariaLabel: label });
91
- }
92
- else if (variant === 'dropdown') {
93
- const p = props;
94
- 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,
95
- // 한 행에 여러 개가 놓이므로 행 라벨만 넘기면 셋 다 "생년월일" 이라는 같은 이름이 된다.
96
- // aria-label 이 있으면 스크린리더는 트리거의 화면 텍스트(년/월/일)를 읽지 않으므로
97
- // 사용자는 어느 것이 무엇인지 구별할 수 없다 — placeholder 를 붙여 구별되게 한다.
98
- ariaLabel: d.ariaLabel ?? ([label, d.placeholder].filter(Boolean).join(' ') || undefined) }) }, i))) })) : (
99
- // PanelRow 왼쪽 라벨(panel-row__label)은 <label htmlFor>가 아니라 그냥 div라서 트리거 버튼과
100
- // 프로그래밍적으로 연결되지 않는다 — ariaLabel로 직접 이름을 넘긴다 (TextField의 ariaLabel과 동일 패턴)
101
- _jsx(Dropdown, { options: p.options ?? [], value: p.value ?? '', onChange: p.onChange ?? noop, block: useFullWidth, error: !!error, ariaLabel: label }));
102
- 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;
103
- }
104
- else if (variant === 'button') {
105
- const p = props;
106
- if (p.buttons?.length) {
107
- 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))) }));
108
- }
109
- else {
110
- right = (_jsx(Button, { size: p.btnSize ?? 40, variant: p.btnVariant ?? 'default', disabled: p.disabled, onClick: p.onClick, children: p.buttonLabel }));
111
- }
112
- }
113
- else if (variant === 'text') {
114
- const p = props;
115
- right = _jsx("span", { className: `panel-row__text body-2${useFullWidth ? ' panel-row__text--full' : ''}`, style: p.valueColor ? { color: p.valueColor } : undefined, children: p.value });
116
- }
117
- else if (variant === 'input') {
118
- const p = props;
119
- 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 })] }));
120
- }
121
- else if (variant === 'custom') {
122
- const p = props;
123
- right = p.children;
124
- }
125
- const isLabelFixed = variant === 'input' || variant === 'dropdown' || ((variant === 'text' || variant === 'custom') && useFullWidth);
126
- // 위 children 모드와 동일한 키보드 처리 — 이쪽 경로에만 빠져 있어서,
127
- // variant 모드에 onClick을 주면 조용히 마우스 전용이 된다(현재 사용처는 없지만 예방).
128
- const handleKeyDown = (e) => {
129
- if (!onClick)
130
- return;
131
- if (e.target !== e.currentTarget)
132
- return;
133
- if (e.key === 'Enter' || e.key === ' ') {
134
- e.preventDefault();
135
- onClick();
136
- }
137
- };
138
- 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 })] }));
139
- }
140
- /**
141
- * 제목 아래 한 줄로 세울 곁정보 — 브랜드·착용형태.
142
- *
143
- * 제목으로 이미 쓴 값과 빈 값, 그리고 서로 같은 값을 걷어 낸다. 걷어 내지 않으면
144
- * 브랜드도 형태도 '기타' 인 기기가 **'기타 · 기타'** 로 떴다(같은 말이 두 번).
145
- */
146
- function 메타(d) {
147
- const 제목 = d.productName || d.brand;
148
- const 조각 = [d.brand, d.fitType].filter((v) => !!v && v !== 제목);
149
- return [...new Set(조각)].join(' · ');
150
- }
151
- export function PanelRowDevice({ devices, onRelease }) {
152
- const { t } = useTranslation();
153
- const SIDES = ['좌', '우'];
154
- return (_jsx("div", { className: "panel-row panel-row--device", children: SIDES.map(side => {
155
- const d = devices[side];
156
- const sideLabel = side === '좌' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
157
- 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 && (
158
- /* 표의 줄 끝 단추와 같은 모양·같은 말 — 하는 일도 같다.
159
- 이력에서 지우는 것이 아니라 '지금 끼고 있음' 만 내린다
160
- (잠시 안 씀·수리 보냄 같은 경우가 실제로 있다). */
161
- _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));
162
- }) }));
163
- }
25
+ export { PanelRow, PanelRowDevice } from './PanelRow'; // 갈라 나간 뒤에도 이 경로로 나간다