@ihabdevteam/core 0.318.0 → 0.322.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 (76) hide show
  1. package/CHANGELOG.md +336 -0
  2. package/dist/components.cjs.js +22 -22
  3. package/dist/components.esm.css +2 -2
  4. package/dist/hooks.cjs.js +3 -3
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core.cjs.js +188 -188
  7. package/dist/ihabdevteam-core.d.ts +144 -63
  8. package/dist/packages/core/src/components/HearingDeviceInfo.d.ts +5 -5
  9. package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
  10. package/dist/packages/core/src/components/PanelRow.d.ts +2 -4
  11. package/dist/packages/core/src/components/PanelRow.js +2 -2
  12. package/dist/packages/core/src/components/Popup/DeviceListPopup.d.ts +4 -8
  13. package/dist/packages/core/src/components/Popup/DeviceListPopup.js +1 -1
  14. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -3
  15. package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
  16. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +3 -3
  17. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +7 -4
  18. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.css +19 -0
  19. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.d.ts +16 -1
  20. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +8 -6
  21. package/dist/packages/core/src/components/Training/TrainingBlock.js +2 -2
  22. package/dist/packages/core/src/components/Training/TrainingCardGroup.d.ts +20 -1
  23. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +2 -2
  24. package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +15 -0
  25. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +4 -4
  26. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +16 -1
  27. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +2 -2
  28. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +2 -2
  29. package/dist/packages/core/src/components/Training/TrainingStepCardOrder.js +14 -1
  30. package/dist/packages/core/src/components/Training/TrainingStepConfirm.d.ts +3 -1
  31. package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +42 -30
  32. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +2 -2
  33. package/dist/packages/core/src/components/Training/oneShotSound.d.ts +1 -1
  34. package/dist/packages/core/src/components/Training/oneShotSound.js +3 -3
  35. package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +60 -10
  36. package/dist/packages/core/src/components/Training/trainingMissStats.js +2 -2
  37. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +9 -9
  38. package/dist/packages/core/src/components/Word/WordTableBlock.css +25 -0
  39. package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
  40. package/dist/packages/core/src/components/Word/wordScoreStats.js +2 -2
  41. package/dist/packages/core/src/components/Word/wordTableRecommend.js +2 -2
  42. package/dist/packages/core/src/components/wordTableLayout.d.ts +5 -3
  43. package/dist/packages/core/src/hooks/inputModality.d.ts +1 -1
  44. package/dist/packages/core/src/hooks/inputModality.js +1 -1
  45. package/dist/packages/core/src/hooks/useDeviceManagement.d.ts +5 -17
  46. package/dist/packages/core/src/hooks/useDeviceManagement.js +20 -8
  47. package/dist/packages/core/src/hooks/usePageResetOnChange.d.ts +3 -3
  48. package/dist/packages/core/src/hooks/usePageResetOnChange.js +6 -6
  49. package/dist/packages/core/src/hooks/useTablePaging.d.ts +16 -5
  50. package/dist/packages/core/src/hooks/useTablePaging.js +20 -9
  51. package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +10 -1
  52. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +11 -2
  53. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +8 -4
  54. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +44 -7
  55. package/dist/packages/core/src/i18n/locales/en.json +8 -5
  56. package/dist/packages/core/src/i18n/locales/ko.json +8 -5
  57. package/dist/packages/core/src/utils/devWarn.d.ts +2 -2
  58. package/dist/packages/core/src/utils/devWarn.js +5 -5
  59. package/dist/packages/core/src/utils/filterPanelSummary.d.ts +1 -1
  60. package/dist/packages/core/src/utils/filterPanelSummary.js +4 -4
  61. package/dist/packages/core/src/utils/navScroll.d.ts +2 -2
  62. package/dist/packages/core/src/utils/navScroll.js +4 -4
  63. package/dist/packages/core/src/utils/phonemePairSearch.js +10 -3
  64. package/dist/packages/core/src/utils/pressable.d.ts +1 -1
  65. package/dist/packages/core/src/utils/pressable.js +3 -3
  66. package/dist/packages/core/src/utils/sessionTab.d.ts +7 -7
  67. package/dist/packages/core/src/utils/sessionTab.js +9 -9
  68. package/dist/packages/core/src/utils/textSort.d.ts +15 -5
  69. package/dist/packages/core/src/utils/textSort.js +19 -9
  70. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +37 -0
  71. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +60 -3
  72. package/dist/packages/core/src/utils/wordTrainingPool.js +3 -2
  73. package/dist/packages/core/src/version.d.ts +1 -1
  74. package/dist/packages/core/src/version.js +1 -1
  75. package/dist/utils.cjs.js +7 -3
  76. package/package.json +2 -1
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { cx } from '../../utils/cx';
4
- import { 한글순 } from '../../utils/textSort';
4
+ import { compareKorean } from '../../utils/textSort';
5
5
  import { useEscToClose } from '../../hooks/useEscToClose';
6
6
  import { useState, useCallback, useMemo, useEffect } from 'react';
7
7
  import { useTranslation } from 'react-i18next';
@@ -86,7 +86,7 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
86
86
  };
87
87
  }, [q]);
88
88
  /* 정렬 비교자 — flat/그룹 뷰가 공유한다.
89
- **글자 견주기는 `한글순`(콜레이터 하나) 으로만 한다**(말귀 요청 (113), 2026-10-10). 전에는 '시작 우선'
89
+ **글자 견주기는 `compareKorean`(콜레이터 하나) 으로만 한다**(말귀 요청 (113), 2026-10-10). 전에는 '시작 우선'
90
90
  갈래만 콜레이터를 쓰고 **여섯 갈래는 `localeCompare(…, 'ko')`** 였다 — 그 부름은 비교마다 콜레이터를
91
91
  새로 만든다. 하필 기본 정렬이 `ganada` 라 **주된 길이 느린 쪽**이었다(말귀 실측 6,357개: 30.3ms → 8.1ms).
92
92
  이 파일에 콜레이터가 하나 더 있었던 것(`new Intl.Collator('ko')`)도 걷었다 — 같은 결정이 두 자리에
@@ -104,14 +104,14 @@ export default function WordSearchResultBlock({ titleIcon, resultCount, resultUn
104
104
  // 시작 우선 — 검색어로 시작하는 단어를 위로. 앞 q 글자만 잘라 매칭하면 "첫 음절부터 일치"만
105
105
  // 남는다(초성 검색도 동일: 'ㄴ' → '나무'가 위). 동순위는 가나다.
106
106
  case 'match': {
107
- return 시작순위(a) - 시작순위(b) || 한글순(label(a), label(b));
107
+ return 시작순위(a) - 시작순위(b) || compareKorean(label(a), label(b));
108
108
  }
109
- case 'ganada': return 한글순(label(a), label(b));
110
- case 'ganada_desc': return 한글순(label(b), label(a));
111
- case 'syllable': return syll(a) - syll(b) || 한글순(label(a), label(b));
112
- case 'syllable_desc': return syll(b) - syll(a) || 한글순(label(a), label(b));
113
- case 'grade': return grade(a) - grade(b) || 한글순(label(a), label(b));
114
- case 'grade_desc': return grade(b) - grade(a) || 한글순(label(a), label(b));
109
+ case 'ganada': return compareKorean(label(a), label(b));
110
+ case 'ganada_desc': return compareKorean(label(b), label(a));
111
+ case 'syllable': return syll(a) - syll(b) || compareKorean(label(a), label(b));
112
+ case 'syllable_desc': return syll(b) - syll(a) || compareKorean(label(a), label(b));
113
+ case 'grade': return grade(a) - grade(b) || compareKorean(label(a), label(b));
114
+ case 'grade_desc': return grade(b) - grade(a) || compareKorean(label(a), label(b));
115
115
  // 받은 순서 — Array.prototype.sort 는 안정 정렬이라 0 이면 받은 차례가 남는다
116
116
  default: return 0;
117
117
  }
@@ -7,6 +7,19 @@
7
7
  background: transparent;
8
8
  }
9
9
 
10
+ /* ── 표를 손보는 단추들 (표 바로 아래) — (210) ───────────
11
+ 간격은 Section 의 flex gap 이 맡는다(`__meta` 와 같은 규칙 — 제 margin 을 더하면 이중 여백).
12
+ 아래 `__actions` 와 같은 꼴로 선다: 모자라면 아랫줄로, 폰에서는 두 칸 격자. */
13
+ .word-table-block__table-actions {
14
+ display: flex;
15
+ gap: var(--spacing-xs);
16
+ flex-wrap: wrap;
17
+ /* **가운데**((211), 사장님 지시 *"가운데 배치"*). 종이(표)는 가운데 서는데 그 아래 줄만
18
+ 왼쪽 끝에 붙어 한 덩이로 안 읽혔다(말귀 실측 1403 폭: 줄 폭 1272, 단추 둘이 왼끝).
19
+ 아래 액션 줄은 `space-between` 이라 그대로 둔다 — 거기는 양끝에 갈라 세우는 줄이다. */
20
+ justify-content: center;
21
+ }
22
+
10
23
  /* ── 액션 버튼 ─────────────────────────────────────────── */
11
24
  .word-table-block__actions {
12
25
  display: flex;
@@ -53,6 +66,18 @@
53
66
  min-width: 0;
54
67
  width: 100%;
55
68
  }
69
+ /* (210) 표 밑 줄도 같은 꼴 — **@media 를 따로 열지 않는다.** 처음 위에 따로 열었다가
70
+ `wordTableBlockActionsLeftExtra` 가 울었다: 그 못은 `css.slice(0, indexOf('@media'))` 로
71
+ 넓은 쪽을 자르는데, 앞에 `@media` 가 하나 더 생기면 **자르는 자리가 앞당겨져** 넓은 쪽
72
+ 규칙을 못 찾는다. 못이 글의 차례에 기대고 있었고, 내가 그 차례를 바꿨다. */
73
+ .word-table-block__table-actions {
74
+ display: grid;
75
+ grid-template-columns: repeat(2, 1fr);
76
+ }
77
+ .word-table-block__table-actions .button {
78
+ min-width: 0;
79
+ width: 100%;
80
+ }
56
81
  }
57
82
 
58
83
  /* ── 팝업 내 미리보기 ───────────────────────────────────── */
@@ -334,7 +334,7 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
334
334
  (위 `onReorder` 와 같은 셈이다. 쪽이 몇으로 나뉘었는지는 부르는 쪽 일이 아니다). */
335
335
  renderCellAction: renderCellAction && ((n, item) => renderCellAction(n + i * itemsPerPage, item)), brand: 띠, code: code, contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition }, i))) })) : (_jsx(WordTable, { orientation: orientation, contentTypes: contentTypes, cutLine: cutLine, showOutline: showOutline, showDivLine: showDivLine, count: count, items: isEditable ? undefined : (items ?? []), editCells: isEditable ? visibleCells : undefined, activeCell: activeCell, onActivateCell: (isEditable || onAddItem) ? setActiveCell : undefined, onRemoveCell: isEditable ? handleRemove : undefined, onRemoveItem: !isEditable ? onRemoveItem : undefined, onReorder: !isEditable ? onReorder : undefined,
336
336
  /* 편집 모드에는 얹을 항목이 없다 — 칸을 손으로 채우는 꼴이다 */
337
- renderCellAction: !isEditable ? renderCellAction : undefined, brand: 띠, code: code, showFillerAdd: !!onAddItem, contentRef: contentRef })), inlineNameFields && !hideControls && (_jsxs(Panel, { size: "medium", labelWidth: 64, useBorder: false, useRowBorder: false, className: "word-table-block__meta", children: [_jsx(PanelRow, { variant: "custom", label: t('wordTable.save.nameLabel', '이름'), required: true, align: "vertical", useFullWidth: true, children: _jsx(TextField, { required: true, value: nameValue ?? '', onChange: onNameChange, placeholder: namePlaceholder ? namePlaceholder + t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)') : t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'),
337
+ renderCellAction: !isEditable ? renderCellAction : undefined, brand: 띠, code: code, showFillerAdd: !!onAddItem, contentRef: contentRef })), !hideControls && !saveOnly && (onReset || actionsLeftExtra != null) && (_jsxs("div", { className: "word-table-block__table-actions", children: [onReset && (_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: resolvedResetLabel, onClick: onReset })), actionsLeftExtra] })), inlineNameFields && !hideControls && (_jsxs(Panel, { size: "medium", labelWidth: 64, useBorder: false, useRowBorder: false, className: "word-table-block__meta", children: [_jsx(PanelRow, { variant: "custom", label: t('wordTable.save.nameLabel', '이름'), required: true, align: "vertical", useFullWidth: true, children: _jsx(TextField, { required: true, value: nameValue ?? '', onChange: onNameChange, placeholder: namePlaceholder ? namePlaceholder + t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)') : t('wordTable.save.placeholder', '이 단어표에 대한 제목을 입력하세요.'),
338
338
  /* PanelRow의 라벨은 시각적으로만 붙어 있고 입력과 연결돼 있지 않아, 낭독기에는
339
339
  이름 없는 입력으로 읽혔다. placeholder는 자동완성 안내문("Tab 또는 space…")이
340
340
  섞여 있어 이름으로 부적절하므로 같은 라벨 문구를 넘긴다. */
@@ -349,5 +349,5 @@ export default function WordTableBlock({ title, titleIcon, disc, filterTitle, fi
349
349
  e.preventDefault();
350
350
  onDescriptionChange?.(descriptionPlaceholder ?? '');
351
351
  }
352
- } }) })] }))] }), !hideControls && (_jsxs("div", { className: "word-table-block__actions", children: [_jsxs("div", { className: "word-table-block__actions-left", children: [!saveOnly && onReset && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", label: resolvedResetLabel, onClick: onReset })), !saveOnly && actionsLeftExtra, !saveOnly && onLoad && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", label: t('wordTable.load', '불러오기'), onClick: onLoad })), !saveOnly && _jsx(Button, { htmlType: "button", size: 40, type: "bar", label: t('wordTable.openNewWindow', '새 창으로 보기'), onClick: handleOpenNewWindow })] }), _jsxs("div", { className: "word-table-block__actions-right", children: [onCancel && _jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('common.cancel', '취소'), onClick: onCancel }), !saveOnly && _jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('wordTable.print', '인쇄하기'), onClick: handlePrint }), secondarySaveButtonLabel && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: secondarySaveButtonLabel, onClick: () => inlineNameFields ? openSaveAsPopup() : handleSave(true), disabled: noItems, onDisabledClick: () => { showWarning(t('wordTable.emptyItems', '단어를 1개 이상 추가해주세요.')); } })), _jsx(Button, { htmlType: "button", size: "56", type: "bar", variant: "main", label: saveButtonLabel ?? t('wordTable.saveButton', '저장하기'), onClick: () => handleSave(false), disabled: mainSaveDisabled, onDisabledClick: handleMainDisabledClick })] })] })), _jsx(Toast, { message: toastMessage, type: toastType, onClose: clearToast })] }));
352
+ } }) })] }))] }), !hideControls && (_jsxs("div", { className: "word-table-block__actions", children: [_jsxs("div", { className: "word-table-block__actions-left", children: [!saveOnly && onLoad && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", label: t('wordTable.load', '불러오기'), onClick: onLoad })), !saveOnly && _jsx(Button, { htmlType: "button", size: 40, type: "bar", label: t('wordTable.openNewWindow', '새 창으로 보기'), onClick: handleOpenNewWindow })] }), _jsxs("div", { className: "word-table-block__actions-right", children: [onCancel && _jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('common.cancel', '취소'), onClick: onCancel }), !saveOnly && _jsx(Button, { htmlType: "button", size: "56", type: "bar", label: t('wordTable.print', '인쇄하기'), onClick: handlePrint }), secondarySaveButtonLabel && (_jsx(Button, { htmlType: "button", size: "56", type: "bar", label: secondarySaveButtonLabel, onClick: () => inlineNameFields ? openSaveAsPopup() : handleSave(true), disabled: noItems, onDisabledClick: () => { showWarning(t('wordTable.emptyItems', '단어를 1개 이상 추가해주세요.')); } })), _jsx(Button, { htmlType: "button", size: "56", type: "bar", variant: "main", label: saveButtonLabel ?? t('wordTable.saveButton', '저장하기'), onClick: () => handleSave(false), disabled: mainSaveDisabled, onDisabledClick: handleMainDisabledClick })] })] })), _jsx(Toast, { message: toastMessage, type: toastType, onClose: clearToast })] }));
353
353
  }
@@ -1,4 +1,4 @@
1
- import { 한글순 } from '../../utils/textSort';
1
+ import { compareKorean } from '../../utils/textSort';
2
2
  import { labelOfCellKey } from './wordTableReview';
3
3
  /** △ 를 반 점으로 치는 셈 — 저장할 때 서버가 쓰는 규칙과 같다. */
4
4
  function 점수(o, triangle, marked) {
@@ -46,7 +46,7 @@ export function weakItems(records) {
46
46
  const tried = v.o + v.triangle + v.x;
47
47
  out.push({ label: labelOfCellKey(key), key, tried, o: v.o, triangle: v.triangle, x: v.x, rate: 점수(v.o, v.triangle, tried), latest: v.latest, latestAt: v.latestAt });
48
48
  }
49
- out.sort((a, b) => a.rate - b.rate || b.tried - a.tried || 한글순(a.label, b.label) || 한글순(a.key ?? a.label, b.key ?? b.label));
49
+ out.sort((a, b) => a.rate - b.rate || b.tried - a.tried || compareKorean(a.label, b.label) || compareKorean(a.key ?? a.label, b.key ?? b.label));
50
50
  return out;
51
51
  }
52
52
  /**
@@ -7,7 +7,7 @@
7
7
  *
8
8
  * 화면도 통신도 없다. 표 목록과 약한 낱말만 받아 순서를 매긴다.
9
9
  */
10
- import { 한글순 } from '../../utils/textSort';
10
+ import { compareKorean } from '../../utils/textSort';
11
11
  /**
12
12
  * 약한 낱말을 많이 품은 표부터 내놓는다.
13
13
  *
@@ -44,7 +44,7 @@ export function recommendTables(tables, weakLabels, limit = 5) {
44
44
  }
45
45
  out.sort((a, b) => b.covered.length - a.covered.length
46
46
  || a.totalCells - b.totalCells
47
- || 한글순(a.name, b.name));
47
+ || compareKorean(a.name, b.name));
48
48
  return out.slice(0, Math.max(0, limit));
49
49
  }
50
50
  /**
@@ -67,7 +67,9 @@ export declare const POS_LABEL: Record<string, string>;
67
67
  /** 어종(단어 유형) 약호 → 전체 표기. 데이터의 Class 컬럼은 축약형('고유'/'한자'/'외래'/'혼종')을 쓴다. */
68
68
  export declare const WORD_TYPE_LABEL: Record<string, string>;
69
69
  /** `t` 하나만 받는 작은 꼴 — `TFunction` 을 들여오지 않아 이 모듈이 i18n 에 안 매인다 */
70
- type 번역 = (k: string, o: {
70
+ /** i18next `t` 와 같은 꼴 — **공개 서명(`posLabels`·`wordTypeLabels`)이 받는 자리**라 이름을 영어로 둔다
71
+ * (사용자 지시 2026-10-13). 안쪽 이름은 한글이어도 소비자가 보는 이름은 영어라야 한다. */
72
+ type Translate = (k: string, o: {
71
73
  defaultValue: string;
72
74
  }) => string;
73
75
  /**
@@ -76,12 +78,12 @@ type 번역 = (k: string, o: {
76
78
  * 사전에 없는 약호는 한국어 전체 표기(`POS_LABEL`)로 둔다 — 자료에 새 약호가 와도 빈칸이 안 된다.
77
79
  * @public
78
80
  */
79
- export declare function posLabels(t: 번역): Record<string, string>;
81
+ export declare function posLabels(t: Translate): Record<string, string>;
80
82
  /**
81
83
  * **어종 약호 → 지금 언어의 이름**(`{ '한자': 'Sino-Korean', … }`). 쓰는 법은 `posLabels` 와 같다.
82
84
  * @public
83
85
  */
84
- export declare function wordTypeLabels(t: 번역): Record<string, string>;
86
+ export declare function wordTypeLabels(t: Translate): Record<string, string>;
85
87
  /**
86
88
  * 약호 → i18n 열쇠 조각 표(품사·어종).
87
89
  *
@@ -1,2 +1,2 @@
1
1
  /** 마지막 입력이 키보드였으면 true(모르면 false) */
2
- export declare function 키보드로눌렀나(): boolean;
2
+ export declare function wasKeyboardPress(): boolean;
@@ -25,7 +25,7 @@ function 붙이기() {
25
25
  // 불러오는 즉시 듣는다 — 첫 누름부터 알아야 한다
26
26
  붙이기();
27
27
  /** 마지막 입력이 키보드였으면 true(모르면 false) */
28
- export function 키보드로눌렀나() {
28
+ export function wasKeyboardPress() {
29
29
  붙이기();
30
30
  return 마지막 === 'keyboard';
31
31
  }
@@ -1,5 +1,5 @@
1
1
  import type { DeviceForm } from '../types/device';
2
- type Side = '좌' | '우';
2
+ type Side = 'left' | 'right';
3
3
  /**
4
4
  * 보청기 기기 등록 / 이력 관리 상태를 캡슐화한 훅.
5
5
  */
@@ -11,22 +11,10 @@ export declare function useDeviceManagement(): {
11
11
  setShowDevice: import("react").Dispatch<import("react").SetStateAction<boolean>>;
12
12
  deviceDirection: Side;
13
13
  setDeviceDirection: import("react").Dispatch<import("react").SetStateAction<Side>>;
14
- devices: {
15
- 좌: DeviceForm | null;
16
- 우: DeviceForm | null;
17
- };
18
- setDevices: import("react").Dispatch<import("react").SetStateAction<{
19
- 좌: DeviceForm | null;
20
- 우: DeviceForm | null;
21
- }>>;
22
- deviceHistory: {
23
- 좌: DeviceForm[];
24
- 우: DeviceForm[];
25
- };
26
- setDeviceHistory: import("react").Dispatch<import("react").SetStateAction<{
27
- 좌: DeviceForm[];
28
- 우: DeviceForm[];
29
- }>>;
14
+ devices: Record<Side, DeviceForm | null>;
15
+ setDevices: import("react").Dispatch<import("react").SetStateAction<Record<Side, DeviceForm | null>>>;
16
+ deviceHistory: Record<Side, DeviceForm[]>;
17
+ setDeviceHistory: import("react").Dispatch<import("react").SetStateAction<Record<Side, DeviceForm[]>>>;
30
18
  showDeviceList: boolean;
31
19
  setShowDeviceList: import("react").Dispatch<import("react").SetStateAction<boolean>>;
32
20
  listForSide: Side;
@@ -1,7 +1,17 @@
1
1
  'use client';
2
2
  import { useCallback, useState } from 'react';
3
- /** form.direction 이 '좌'·'우' 가 아니면 null — 캐스팅만 하면 devices['엉뚱한값'] 같은 열쇠가 생긴다 */
4
- const toSide = (d) => (d === '좌' || d === '우' ? d : null);
3
+ /**
4
+ * `form.direction` 이 `'left'`·`'right'` 가 아니면 null — 캐스팅만 하면 `devices['엉뚱한값']` 같은 열쇠가 생긴다.
5
+ *
6
+ * **옛 값(`'좌'`·`'우'`)은 받지 않는다.** 2026-10-13 사장님 결정으로 값을 영어로 옮겼고,
7
+ * 너그럽게 받는 몫은 **소비자가 진다** — 말귀가 DB 와 오가는 길목 한 자리에 세워 두었다
8
+ * (`toDbDirection`/`fromDbDirection`). 여기서 함께 받으면 **두 자리가 같은 일을 하고**,
9
+ * DB 가 다 옮겨진 뒤에도 어느 쪽이 걷혔는지 아무도 모르게 된다.
10
+ *
11
+ * 걸리면 **조용히 `null`** 이다 — 터지지도 찍지도 않는다. 옛 값이 그대로 들어오면
12
+ * 기기가 안 골라지는 꼴로 보이니, 소비자는 **코어에 넘기기 전에** 옮겨 둔다.
13
+ */
14
+ const toSide = (d) => (d === 'left' || d === 'right' ? d : null);
5
15
  /**
6
16
  * 보청기 기기 등록 / 이력 관리 상태를 캡슐화한 훅.
7
17
  */
@@ -10,15 +20,17 @@ const toSide = (d) => (d === '좌' || d === '우' ? d : null);
10
20
  */
11
21
  export function useDeviceManagement() {
12
22
  const [showDevice, setShowDevice] = useState(false);
13
- const [deviceDirection, setDeviceDirection] = useState('좌');
14
- const [devices, setDevices] = useState({ 좌: null, 우: null });
15
- const [deviceHistory, setDeviceHistory] = useState({ 좌: [], 우: [] });
23
+ const [deviceDirection, setDeviceDirection] = useState('left');
24
+ /* 열쇠는 `Side`(= `DeviceSide`) **값**(`'left'`·`'right'`) — 서버의 `side` 열에 오가는 자료다.
25
+ 이름도 값도 영어다 — 값은 2026-10-13 사장님 결정으로 옮겼다(`'좌'`·`'우'` → `'left'`·`'right'`). */
26
+ const [devices, setDevices] = useState({ left: null, right: null });
27
+ const [deviceHistory, setDeviceHistory] = useState({ left: [], right: [] });
16
28
  const [showDeviceList, setShowDeviceList] = useState(false);
17
- const [listForSide, setListForSide] = useState('좌');
29
+ const [listForSide, setListForSide] = useState('left');
18
30
  // form.direction은 각 팝업(DeviceRegistrationPopup의 방향 드롭다운, DeviceListPopup의
19
- // 좌/우 탭)에서 사용자가 실제로 고른 값이다 — deviceDirection/listForSide는 팝업을 열 때의
31
+ // left/right 탭)에서 사용자가 실제로 고른 값이다 — deviceDirection/listForSide는 팝업을 열 때의
20
32
  // 초기값일 뿐이라 팝업 내부에서 방향을 바꾸면 더 이상 실제 선택과 일치하지 않는다.
21
- // 방향이 비었거나 '좌'·'우' 밖이면 **아무것도 바꾸지 않는다** — 예전에는 검사 없이 캐스팅해
33
+ // 방향이 비었거나 'left'·'right' 밖이면 **아무것도 바꾸지 않는다** — 예전에는 검사 없이 캐스팅해
22
34
  // devices·deviceHistory 에 없는 열쇠가 생겼고 prev[side] 가 undefined 라 이력 펼치기에서 터졌다.
23
35
  // 팝업은 닫지 않는다(사용자가 방향을 고르고 다시 제출할 수 있게).
24
36
  const handleDeviceSubmit = useCallback((form) => {
@@ -11,12 +11,12 @@
11
11
  * React 가 이 꼴을 위해 내준 길이다(같은 컴포넌트를 바로 다시 그린다).
12
12
  *
13
13
  * ```ts
14
- * usePageResetOnChange(`${검색어}|${갈래}|${정렬}`, setPage)
14
+ * usePageResetOnChange(`${검색어}|${갈래}|${sort}`, setPage)
15
15
  * const { pageItems, totalPages } = usePagedList(걸러낸것, 쪽크기, page)
16
16
  * ```
17
17
  *
18
- * @param 열쇠 조건을 하나로 이어 붙인 글 — 이것이 달라지면 첫 쪽이다
18
+ * @param key 조건을 하나로 이어 붙인 글 — 이것이 달라지면 첫 쪽이다
19
19
  * @param setPage 쪽 번호를 쥔 쪽에서 준다
20
20
  * @public
21
21
  */
22
- export declare function usePageResetOnChange(열쇠: string, setPage: (n: number) => void): void;
22
+ export declare function usePageResetOnChange(key: string, setPage: (n: number) => void): void;
@@ -13,18 +13,18 @@ import { useState } from 'react';
13
13
  * React 가 이 꼴을 위해 내준 길이다(같은 컴포넌트를 바로 다시 그린다).
14
14
  *
15
15
  * ```ts
16
- * usePageResetOnChange(`${검색어}|${갈래}|${정렬}`, setPage)
16
+ * usePageResetOnChange(`${검색어}|${갈래}|${sort}`, setPage)
17
17
  * const { pageItems, totalPages } = usePagedList(걸러낸것, 쪽크기, page)
18
18
  * ```
19
19
  *
20
- * @param 열쇠 조건을 하나로 이어 붙인 글 — 이것이 달라지면 첫 쪽이다
20
+ * @param key 조건을 하나로 이어 붙인 글 — 이것이 달라지면 첫 쪽이다
21
21
  * @param setPage 쪽 번호를 쥔 쪽에서 준다
22
22
  * @public
23
23
  */
24
- export function usePageResetOnChange(열쇠, setPage) {
25
- const [본열쇠, set본열쇠] = useState(열쇠);
26
- if (본열쇠 !== 열쇠) {
27
- set본열쇠(열쇠);
24
+ export function usePageResetOnChange(key, setPage) {
25
+ const [본열쇠, set본열쇠] = useState(key);
26
+ if (본열쇠 !== key) {
27
+ set본열쇠(key);
28
28
  setPage(1);
29
29
  }
30
30
  }
@@ -56,26 +56,37 @@ export interface TableSortOptions<K extends string> {
56
56
  * **정렬이 바뀌면 첫 쪽으로** — `useTablePaging` 의 `resetOn` 에 돌려받은 `정렬` 을 그대로 넣는다.
57
57
  * `정렬` 은 누를 때만 새 객체가 되므로(렌더마다 새로 만들지 않는다) 그 자체가 열쇠다. 쪽을 따로 들고 있으면 `onSortChange` 에서 되돌린다.
58
58
  *
59
- * `sortRows(rows, 값)` 은 지금 정렬로 줄을 세운 **새 배열**을 준다(정렬이 없으면 받은 배열 그대로) — `칸순`(숫자는 수로,
59
+ * `sortRows(rows, getValue)` 은 지금 정렬로 줄을 세운 **새 배열**을 준다(정렬이 없으면 받은 배열 그대로) — `compareCell`(숫자는 수로,
60
60
  * 한글·영문은 사람 차례) 으로 견주고 **빈 값(null·undefined·'')은 오름·내림 모두 맨 뒤**(ListPopup 과 같은 규칙).
61
61
  *
62
62
  * @example
63
- * const { 정렬, 칸, sortRows } = useTableSort<'name' | 'joined'>()
63
+ * const { sort, field, sortRows } = useTableSort<'name' | 'joined'>()
64
64
  * const 세운줄 = useMemo(() => sortRows(rows, (r, key) => r[key]), [rows, sortRows])
65
- * const { pageRows, pageSizeProps, paginationProps } = useTablePaging(세운줄, 정렬) // 정렬이 바뀌면 첫 쪽
66
- * <Table fields={[칸('이름', 'name'), 칸('가입일', 'joined')]} … />
65
+ * const { pageRows, pageSizeProps, paginationProps } = useTablePaging(세운줄, sort) // 정렬이 바뀌면 첫 쪽
66
+ * <Table fields={[field('이름', 'name'), field('가입일', 'joined')]} … />
67
67
  *
68
68
  * @public
69
69
  */
70
70
  export declare function useTableSort<K extends string>(opts?: TableSortOptions<K>): {
71
+ sort: {
72
+ key: K;
73
+ asc: boolean;
74
+ } | null;
75
+ field: (label: string, key: K) => {
76
+ label: string;
77
+ sortDir: "desc" | "asc" | null;
78
+ onClick: () => void;
79
+ };
80
+ sortRows: <R>(rows: readonly R[], getValue: (row: R, key: K) => unknown) => readonly R[];
81
+ /** @deprecated `sort` 를 쓴다 — 같은 값이다(2026-10-13) */
71
82
  정렬: {
72
83
  key: K;
73
84
  asc: boolean;
74
85
  } | null;
86
+ /** @deprecated `field` 를 쓴다 — 같은 함수다(2026-10-13) */
75
87
  칸: (label: string, key: K) => {
76
88
  label: string;
77
89
  sortDir: "desc" | "asc" | null;
78
90
  onClick: () => void;
79
91
  };
80
- sortRows: <R>(rows: readonly R[], 값: (row: R, key: K) => unknown) => readonly R[];
81
92
  };
@@ -2,7 +2,7 @@
2
2
  import { useCallback, useMemo, useRef, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import { VAP_PAGE_SIZE_OPTIONS } from './useVisitAllPanel';
5
- import { 칸순빈값뒤 } from '../utils/textSort';
5
+ import { compareCellBlankLast } from '../utils/textSort';
6
6
  /**
7
7
  * 표의 쪽넘김 — 개수 보기 · 쪽 · 이번 쪽 줄.
8
8
  *
@@ -67,14 +67,14 @@ export function useTablePaging(rows, resetOn, opts) {
67
67
  * **정렬이 바뀌면 첫 쪽으로** — `useTablePaging` 의 `resetOn` 에 돌려받은 `정렬` 을 그대로 넣는다.
68
68
  * `정렬` 은 누를 때만 새 객체가 되므로(렌더마다 새로 만들지 않는다) 그 자체가 열쇠다. 쪽을 따로 들고 있으면 `onSortChange` 에서 되돌린다.
69
69
  *
70
- * `sortRows(rows, 값)` 은 지금 정렬로 줄을 세운 **새 배열**을 준다(정렬이 없으면 받은 배열 그대로) — `칸순`(숫자는 수로,
70
+ * `sortRows(rows, getValue)` 은 지금 정렬로 줄을 세운 **새 배열**을 준다(정렬이 없으면 받은 배열 그대로) — `compareCell`(숫자는 수로,
71
71
  * 한글·영문은 사람 차례) 으로 견주고 **빈 값(null·undefined·'')은 오름·내림 모두 맨 뒤**(ListPopup 과 같은 규칙).
72
72
  *
73
73
  * @example
74
- * const { 정렬, 칸, sortRows } = useTableSort<'name' | 'joined'>()
74
+ * const { sort, field, sortRows } = useTableSort<'name' | 'joined'>()
75
75
  * const 세운줄 = useMemo(() => sortRows(rows, (r, key) => r[key]), [rows, sortRows])
76
- * const { pageRows, pageSizeProps, paginationProps } = useTablePaging(세운줄, 정렬) // 정렬이 바뀌면 첫 쪽
77
- * <Table fields={[칸('이름', 'name'), 칸('가입일', 'joined')]} … />
76
+ * const { pageRows, pageSizeProps, paginationProps } = useTablePaging(세운줄, sort) // 정렬이 바뀌면 첫 쪽
77
+ * <Table fields={[field('이름', 'name'), field('가입일', 'joined')]} … />
78
78
  *
79
79
  * @public
80
80
  */
@@ -98,12 +98,23 @@ export function useTableSort(opts) {
98
98
  onClick: () => 누름(key),
99
99
  }), [정렬, 누름]);
100
100
  /** 지금 정렬로 세운 새 배열(정렬 없으면 받은 배열 그대로). 빈 값은 오름·내림 모두 맨 뒤. */
101
- const sortRows = useCallback((rows, 값) => {
101
+ const sortRows = useCallback((rows, getValue) => {
102
102
  if (!정렬)
103
103
  return rows;
104
104
  const { key, asc } = 정렬;
105
- return [...rows].sort((a, b) => 칸순빈값뒤(값(a, key), 값(b, key), asc));
105
+ return [...rows].sort((a, b) => compareCellBlankLast(getValue(a, key), getValue(b, key), asc));
106
106
  }, [정렬]);
107
- /* 쪽넘김에 그대로 건넨다 — 차례가 바뀌면 첫 쪽으로 돌아가게 */
108
- return { 정렬, 칸, sortRows };
107
+ /* 쪽넘김에 그대로 건넨다 — 차례가 바뀌면 첫 쪽으로 돌아가게.
108
+ **영어 이름이 정본이다**(`sort`·`field`) — 공개 API 는 영어라야 한다(사용자 지시 2026-10-13).
109
+ 한글 이름(`정렬`·`칸`)은 **같은 것을 가리키는 옛 이름**으로 남긴다: 말귀가 77 줄에서 그 이름으로
110
+ 풀어 쓰고 있어, 한 번에 걷으면 그 저장소가 통째로 빨개진다. 그쪽이 옮긴 뒤 걷는다. */
111
+ return {
112
+ sort: 정렬,
113
+ field: 칸,
114
+ sortRows,
115
+ /** @deprecated `sort` 를 쓴다 — 같은 값이다(2026-10-13) */
116
+ 정렬,
117
+ /** @deprecated `field` 를 쓴다 — 같은 함수다(2026-10-13) */
118
+ 칸,
119
+ };
109
120
  }
@@ -24,7 +24,16 @@ export interface WordAttributeFilterKeys {
24
24
  * 이 훅을 함께 호출해 라벨 문구와 posMap/typeMap(품사·어종 판정 규칙)을 어긋나지 않게 공유한다.
25
25
  */
26
26
  export declare function useWordAttributeFilterKeys(): WordAttributeFilterKeys;
27
- /** @public 등급(1~4등급) FilterPanelSection — 세 화면 공용. labelAction/allLabel은 호출부가 자신의 i18n으로 구성해 넘긴다. */
27
+ /**
28
+ * @public 등급 FilterPanelSection — 세 화면 공용. labelAction/allLabel은 호출부가 자신의 i18n으로 구성해 넘긴다.
29
+ *
30
+ * **맨 끝에 `없음` 이 선다**((205), 2026-10-12) — 등급이 비어 있는 단어도 고를 수 있어야 한다.
31
+ * 맨 끝인 까닭은 **1~4 가 센 순서**이고 `없음` 은 그 줄 밖이기 때문이다(앞에 두면 `1등급` 앞에
32
+ * 숫자 아닌 것이 끼어 읽는 차례가 흔들린다).
33
+ *
34
+ * 이 줄의 글자는 `wordAttributeFilterCodes` 의 사전과 **같아야** 한다 — 거기서 코드로 못 읽으면
35
+ * 필터는 켜져 보이는데 한 단어도 안 걸린다. `test/gradeNoneOption.test.ts` 가 둘을 맞춰 센다.
36
+ */
28
37
  export declare function buildGradeSection(t: TFunction, keys: WordAttributeFilterKeys, opts?: {
29
38
  allLabel?: string;
30
39
  labelAction?: ReactNode;
@@ -31,11 +31,20 @@ export function useWordAttributeFilterKeys() {
31
31
  },
32
32
  }), [t]);
33
33
  }
34
- /** @public 등급(1~4등급) FilterPanelSection — 세 화면 공용. labelAction/allLabel은 호출부가 자신의 i18n으로 구성해 넘긴다. */
34
+ /**
35
+ * @public 등급 FilterPanelSection — 세 화면 공용. labelAction/allLabel은 호출부가 자신의 i18n으로 구성해 넘긴다.
36
+ *
37
+ * **맨 끝에 `없음` 이 선다**((205), 2026-10-12) — 등급이 비어 있는 단어도 고를 수 있어야 한다.
38
+ * 맨 끝인 까닭은 **1~4 가 센 순서**이고 `없음` 은 그 줄 밖이기 때문이다(앞에 두면 `1등급` 앞에
39
+ * 숫자 아닌 것이 끼어 읽는 차례가 흔들린다).
40
+ *
41
+ * 이 줄의 글자는 `wordAttributeFilterCodes` 의 사전과 **같아야** 한다 — 거기서 코드로 못 읽으면
42
+ * 필터는 켜져 보이는데 한 단어도 안 걸린다. `test/gradeNoneOption.test.ts` 가 둘을 맞춰 센다.
43
+ */
35
44
  export function buildGradeSection(t, keys, opts) {
36
45
  return {
37
46
  label: keys.difficulty,
38
- items: [1, 2, 3, 4].map(n => t('wordSearch.filter.grade', { n })),
47
+ items: [...[1, 2, 3, 4].map(n => t('wordSearch.filter.grade', { n })), t('wordSearch.filter.gradeNone', '없음')],
39
48
  multiple: true,
40
49
  allOption: true,
41
50
  allLabel: opts?.allLabel,
@@ -1,3 +1,4 @@
1
+ import type { TFunction } from 'i18next';
1
2
  import { type FilterPanelSection } from '../types/filterPanel';
2
3
  import { type WordComposition } from './wordTrainingFilterModel';
3
4
  export { valueToJamos, NO_CODA, SYLLABLE_MIN, syllableToFilter, charToPronValue, pronToCharValue, convertComposition, EMPTY_COMPOSITION, } from './wordTrainingFilterModel';
@@ -52,11 +53,14 @@ export interface WordTrainingFilterCaps {
52
53
  requireCoda?: boolean;
53
54
  }
54
55
  /**
55
- * 단어 훈련 그룹별 필터 상태.
56
- * @param groupKey 그룹 식별자 (localStorage 키 및 상태 격리에 사용)
57
- * @param caps 그룹이 노출할 설정 (난이도/단어구성). 제외 필터는 항상 노출.
58
- * @public
56
+ * 등급 고르개의 설명 줄들 — 지금 언어로. 사전에 배열이 없으면 한국어 원문.
57
+ *
58
+ * **공개 API 가 아니다**(대응표에 `internal`) — 코어 안에서만 쓰고, 내보내는 것은 못이 읽기 때문이다.
59
+ * 그래도 **이름은 영어다** — `internal` 이라도 배포 `d.ts` 에는 그대로 나가 소비자 편집기에 보인다
60
+ * (사용자 지시 2026-10-13. 소비자 사용처 0 이라 옛 이름을 남기지 않았다 — 말귀의 같은 이름 함수는 제 지역 것이다).
61
+ * 앱은 제 열쇠(`wordSearch.filter.gradeInfo`)와 제 받는 함수를 따로 갖는다.
59
62
  */
63
+ export declare function gradeDescriptions(t: TFunction): string[];
60
64
  export declare function useWordTrainingFilter(groupKey: string, caps: WordTrainingFilterCaps, opts?: {
61
65
  resetOnMount?: boolean;
62
66
  resetKey?: string;
@@ -6,6 +6,9 @@ import { FILTER_ALL_VALUE } from '../types/filterPanel';
6
6
  import { useWindowEvent } from './useWindowEvent';
7
7
  import { useWordAttributeFilterKeys, buildGradeSection, buildPartOfSpeechSection, buildWordTypeSection } from './useWordAttributeFilter';
8
8
  import { matchesFilters } from '../utils/phonemePairSearch';
9
+ import { wordAttributeValueCodeOf, wordAttributeValueLabelOf, isGradeCode } from '../utils/wordAttributeFilterCodes';
10
+ /** 저장된 등급을 되읽을 때 코드 아닌 것은 버린다 — 0.320.0 작업본이 잠깐 `['']` 를 저장했다 */
11
+ const 등급코드만 = (v) => (Array.isArray(v) ? v.filter(isGradeCode) : []);
9
12
  import { hasSel } from '../utils/wordContrast';
10
13
  import { phoneticLabel } from '../utils/phonemeGroups';
11
14
  /* 글자↔발음 변환과 음절 상수는 wordTrainingFilterModel.ts 로 옮겼다(React 없는 순수 계층).
@@ -74,6 +77,26 @@ function readComposition(saved) {
74
77
  * @param caps 그룹이 노출할 설정 (난이도/단어구성). 제외 필터는 항상 노출.
75
78
  * @public
76
79
  */
80
+ /** 사전이 배열을 안 주면 한국어 원문으로 물러선다 — 빈 줄을 띄우지 않는다((205)) */
81
+ const GRADE_DESCRIPTIONS_KO = [
82
+ '1등급 : 학령전기에 습득하여 평생에 걸쳐 일상 언어생활을 하는 데 사용되는 어휘',
83
+ '2등급 : 초등학교 1-2학년 난이도 어휘',
84
+ '3등급 : 초등학교 3-4학년 난이도 어휘',
85
+ '4등급 : 초등학교 5-6학년 난이도 어휘',
86
+ '등급없음 : 국립국어원 표준 어휘 외 고빈도 생활 어휘, 신조어 및 외래어',
87
+ ];
88
+ /**
89
+ * 등급 고르개의 설명 줄들 — 지금 언어로. 사전에 배열이 없으면 한국어 원문.
90
+ *
91
+ * **공개 API 가 아니다**(대응표에 `internal`) — 코어 안에서만 쓰고, 내보내는 것은 못이 읽기 때문이다.
92
+ * 그래도 **이름은 영어다** — `internal` 이라도 배포 `d.ts` 에는 그대로 나가 소비자 편집기에 보인다
93
+ * (사용자 지시 2026-10-13. 소비자 사용처 0 이라 옛 이름을 남기지 않았다 — 말귀의 같은 이름 함수는 제 지역 것이다).
94
+ * 앱은 제 열쇠(`wordSearch.filter.gradeInfo`)와 제 받는 함수를 따로 갖는다.
95
+ */
96
+ export function gradeDescriptions(t) {
97
+ const v = t('wordPairs.filter.difficultyInfo', { returnObjects: true, defaultValue: GRADE_DESCRIPTIONS_KO });
98
+ return Array.isArray(v) && v.length > 0 && v.every(x => typeof x === 'string') ? v : GRADE_DESCRIPTIONS_KO;
99
+ }
77
100
  export function useWordTrainingFilter(groupKey, caps, opts) {
78
101
  const { t, i18n } = useTranslation();
79
102
  // resetOnMount(단어훈련 만들기 등): 진입 시 항상 기본값에서 시작하도록 저장값을 불러오지 않는다.
@@ -122,7 +145,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
122
145
  // 음소 구조 같은 단어만(초성 ㅇ 유무·받침 유무 대립 제외) — 기본 true (reset 시 OFF)
123
146
  const [sameStructureOnly, setSameStructureOnly] = useState(opts?.resetOnMount ? false : (saved?.sameStructureOnly ?? true));
124
147
  // 난이도 등급('1'~'4') 필터 — 배열(다중 선택), 빈 배열은 전체(무제약)
125
- const [gradeFilter, setGradeFilter] = useState(Array.isArray(saved?.gradeFilter) ? saved.gradeFilter : []);
148
+ const [gradeFilter, setGradeFilter] = useState(등급코드만(saved?.gradeFilter));
126
149
  // 품사/어종 필터 — 단어표·대립쌍표와 동일 라벨 배열(다중 선택). caps로 노출 여부 결정(기본 미노출).
127
150
  const 라벨언어들 = Array.from(new Set([i18n.language, 'ko', 'en'].filter(Boolean)));
128
151
  const labelOf = (lng, key) => i18n.getFixedT(lng)(key);
@@ -190,7 +213,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
190
213
  setSameStructureOnly(s?.sameStructureOnly ?? true);
191
214
  // 마운트 때와 같은 규칙 — 여기서만 'direct' 를 'char' 로 떨구고 있었다
192
215
  setPhonemeView(readPhonemeView(s));
193
- setGradeFilter(Array.isArray(s?.gradeFilter) ? s.gradeFilter : []);
216
+ setGradeFilter(등급코드만(s?.gradeFilter));
194
217
  setPosCodes(posCodesOf(s?.posFilter));
195
218
  setTypeCodes(typeCodesOf(s?.wordTypeFilter));
196
219
  }
@@ -418,8 +441,18 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
418
441
  *"국립국어원의 기초 어휘 등급이 결과에 반영됩니다"* 한 줄이라, 1등급이 쉬운 쪽인지
419
442
  어려운 쪽인지도 알 수 없었다. 앱 두 화면에는 이미 이 다섯 줄이 들어가 있어
420
443
  (앱 열쇠 `wordSearch.filter.gradeInfo`), 이 자리만 한 줄로 남아 **세 화면이 다른 말**을
421
- 하고 있었다 — 글자까지 같은 것을 쓴다. */
422
- labelInfo: infoAction([1, 2, 3, 4, 5].map(n => t(`wordPairs.filter.difficultyInfo${n}`))),
444
+ 하고 있었다 — 글자까지 같은 것을 쓴다.
445
+
446
+ **한 열쇠에 배열로 둔다**(말귀 요청, 2026-10-12). 처음엔 `difficultyInfo1`~`5` 로 나눴는데
447
+ 그러면 ① 말귀 못(`deadI18nKeys`)이 **산 열쇠를 죽은 것으로** 센다 — 그 못은 `a.b.${x}` 처럼
448
+ **점 자리에서 잘린 앞머리**를 쓰는 것으로 세는데, 이것은 *마지막 마디 뒤에 숫자를 붙여*
449
+ 안 걸린다. ② **줄 수가 다섯에 못 박힌다** — 배열이면 언어마다 줄 수가 달라도 된다.
450
+ ③ 앱이 같은 꼴(`returnObjects`)을 이미 쓴다.
451
+
452
+ 사전이 배열을 안 주면(옛 사전을 덮어쓴 소비자 등) **한국어 원문으로 물러선다** — 빈 줄을
453
+ 띄우지 않는다. 재 보고 적는다: `returnObjects` 는 배열을 그대로 주고 `defaultValue` 도
454
+ 배열로 물러선다(i18next 26.4.1). */
455
+ labelInfo: infoAction(gradeDescriptions(t)),
423
456
  }),
424
457
  label: LABELS.gradeLabel,
425
458
  });
@@ -479,7 +512,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
479
512
  ...(sameStructureOnly ? [LABELS.sameStructure] : []),
480
513
  ],
481
514
  // 난이도 등급 — 선택된 등급들의 라벨 배열('1등급' 등)
482
- [LABELS.gradeLabel]: gradeFilter.map(g => t('wordSearch.filter.grade', { n: Number(g), defaultValue: '{{n}}등급' })),
515
+ [LABELS.gradeLabel]: gradeFilter.map(g => wordAttributeValueLabelOf('grade', g)),
483
516
  // 품사/어종 — 단어표·대립쌍표와 동일하게 선택된 라벨 배열 그대로 노출
484
517
  ...(caps.partOfSpeech ? { [attrKeys.partOfSpeech]: posFilter } : {}),
485
518
  ...(caps.wordType ? { [attrKeys.wordType]: wordTypeFilter } : {}),
@@ -509,7 +542,11 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
509
542
  setGradeFilter([]);
510
543
  return;
511
544
  }
512
- const n = value.replace(/\D/g, '');
545
+ /* 라벨 → 코드는 사전으로 — 숫자만 남기면 `없음` 이 `''` 가 되어 저장되고 칩은 영영 안 켜졌다
546
+ (말귀_1 실측 2026-10-12). 못 읽는 라벨은 무시한다. */
547
+ const n = wordAttributeValueCodeOf('grade', value);
548
+ if (n == null)
549
+ return;
513
550
  setGradeFilter(prev => prev.includes(n) ? prev.filter(g => g !== n) : [...prev, n]);
514
551
  }
515
552
  else if (sectionLabel === attrKeys.partOfSpeech) {
@@ -584,7 +621,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
584
621
  setExcludeNoImage(s.excludeNoImage);
585
622
  setSameStructureOnly(s.sameStructureOnly ?? true);
586
623
  setPhonemeView(s.phonemeView);
587
- setGradeFilter(Array.isArray(s.gradeFilter) ? s.gradeFilter : []);
624
+ setGradeFilter(등급코드만(s.gradeFilter));
588
625
  if (Array.isArray(s.posFilter))
589
626
  setPosCodes(posCodesOf(s.posFilter));
590
627
  if (Array.isArray(s.wordTypeFilter))
@@ -901,6 +901,7 @@
901
901
  "positionTail": "End",
902
902
  "difficulty": "Grade",
903
903
  "grade": "Grade {{n}}",
904
+ "gradeNone": "None",
904
905
  "syllables": "Syllables",
905
906
  "syllable": "{{n}} Syll.",
906
907
  "syllable5plus": "5+ Syllables",
@@ -1145,11 +1146,13 @@
1145
1146
  ],
1146
1147
  "orderAny": "Any",
1147
1148
  "orderMatch": "Match",
1148
- "difficultyInfo1": "Level 1 — everyday vocabulary acquired before school age and used for life",
1149
- "difficultyInfo2": "Level 2 — difficulty of grades 1–2",
1150
- "difficultyInfo3": "Level 3 — difficulty of grades 3–4",
1151
- "difficultyInfo4": "Level 4 — difficulty of grades 5–6",
1152
- "difficultyInfo5": "No level — frequent everyday words outside the national standard list, plus coinages and loanwords",
1149
+ "difficultyInfo": [
1150
+ "Level 1 — everyday vocabulary acquired before school age and used for life",
1151
+ "Level 2 — difficulty of grades 1–2",
1152
+ "Level 3 — difficulty of grades 3–4",
1153
+ "Level 4 — difficulty of grades 5–6",
1154
+ "No level — frequent everyday words outside the national standard list, plus coinages and loanwords"
1155
+ ],
1153
1156
  "selectLabel": "Select",
1154
1157
  "onlyHasImage": "Only words with images",
1155
1158
  "onlySameStructure": "Only same phoneme structure"