@ihabdevteam/core 0.321.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 (56) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/dist/components.cjs.js +3 -3
  3. package/dist/hooks.cjs.js +3 -3
  4. package/dist/i18n.cjs.js +1 -1
  5. package/dist/ihabdevteam-core.cjs.js +5 -5
  6. package/dist/ihabdevteam-core.d.ts +64 -60
  7. package/dist/packages/core/src/components/HearingDeviceInfo.d.ts +5 -5
  8. package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -2
  9. package/dist/packages/core/src/components/PanelRow.d.ts +2 -4
  10. package/dist/packages/core/src/components/PanelRow.js +2 -2
  11. package/dist/packages/core/src/components/Popup/DeviceListPopup.d.ts +4 -8
  12. package/dist/packages/core/src/components/Popup/DeviceListPopup.js +1 -1
  13. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +3 -3
  14. package/dist/packages/core/src/components/Popup/ListPopup.js +3 -3
  15. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +3 -3
  16. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +7 -4
  17. package/dist/packages/core/src/components/Training/TrainingBlock.js +2 -2
  18. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +2 -2
  19. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +2 -2
  20. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +2 -2
  21. package/dist/packages/core/src/components/Training/oneShotSound.d.ts +1 -1
  22. package/dist/packages/core/src/components/Training/oneShotSound.js +3 -3
  23. package/dist/packages/core/src/components/Training/stepAnswerable.d.ts +2 -2
  24. package/dist/packages/core/src/components/Training/trainingMissStats.js +2 -2
  25. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +9 -9
  26. package/dist/packages/core/src/components/Word/wordScoreStats.js +2 -2
  27. package/dist/packages/core/src/components/Word/wordTableRecommend.js +2 -2
  28. package/dist/packages/core/src/components/wordTableLayout.d.ts +5 -3
  29. package/dist/packages/core/src/hooks/inputModality.d.ts +1 -1
  30. package/dist/packages/core/src/hooks/inputModality.js +1 -1
  31. package/dist/packages/core/src/hooks/useDeviceManagement.d.ts +5 -17
  32. package/dist/packages/core/src/hooks/useDeviceManagement.js +20 -8
  33. package/dist/packages/core/src/hooks/usePageResetOnChange.d.ts +3 -3
  34. package/dist/packages/core/src/hooks/usePageResetOnChange.js +6 -6
  35. package/dist/packages/core/src/hooks/useTablePaging.d.ts +16 -5
  36. package/dist/packages/core/src/hooks/useTablePaging.js +20 -9
  37. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +3 -1
  38. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -5
  39. package/dist/packages/core/src/utils/devWarn.d.ts +2 -2
  40. package/dist/packages/core/src/utils/devWarn.js +5 -5
  41. package/dist/packages/core/src/utils/filterPanelSummary.d.ts +1 -1
  42. package/dist/packages/core/src/utils/filterPanelSummary.js +4 -4
  43. package/dist/packages/core/src/utils/navScroll.d.ts +2 -2
  44. package/dist/packages/core/src/utils/navScroll.js +4 -4
  45. package/dist/packages/core/src/utils/pressable.d.ts +1 -1
  46. package/dist/packages/core/src/utils/pressable.js +3 -3
  47. package/dist/packages/core/src/utils/sessionTab.d.ts +7 -7
  48. package/dist/packages/core/src/utils/sessionTab.js +9 -9
  49. package/dist/packages/core/src/utils/textSort.d.ts +15 -5
  50. package/dist/packages/core/src/utils/textSort.js +19 -9
  51. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +2 -2
  52. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +3 -3
  53. package/dist/packages/core/src/version.d.ts +1 -1
  54. package/dist/packages/core/src/version.js +1 -1
  55. package/dist/utils.cjs.js +1 -1
  56. package/package.json +1 -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
  }
@@ -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
  }
@@ -56,9 +56,11 @@ export interface WordTrainingFilterCaps {
56
56
  * 등급 고르개의 설명 줄들 — 지금 언어로. 사전에 배열이 없으면 한국어 원문.
57
57
  *
58
58
  * **공개 API 가 아니다**(대응표에 `internal`) — 코어 안에서만 쓰고, 내보내는 것은 못이 읽기 때문이다.
59
+ * 그래도 **이름은 영어다** — `internal` 이라도 배포 `d.ts` 에는 그대로 나가 소비자 편집기에 보인다
60
+ * (사용자 지시 2026-10-13. 소비자 사용처 0 이라 옛 이름을 남기지 않았다 — 말귀의 같은 이름 함수는 제 지역 것이다).
59
61
  * 앱은 제 열쇠(`wordSearch.filter.gradeInfo`)와 제 받는 함수를 따로 갖는다.
60
62
  */
61
- export declare function 등급설명(t: TFunction): string[];
63
+ export declare function gradeDescriptions(t: TFunction): string[];
62
64
  export declare function useWordTrainingFilter(groupKey: string, caps: WordTrainingFilterCaps, opts?: {
63
65
  resetOnMount?: boolean;
64
66
  resetKey?: string;
@@ -78,7 +78,7 @@ function readComposition(saved) {
78
78
  * @public
79
79
  */
80
80
  /** 사전이 배열을 안 주면 한국어 원문으로 물러선다 — 빈 줄을 띄우지 않는다((205)) */
81
- const 등급설명기본 = [
81
+ const GRADE_DESCRIPTIONS_KO = [
82
82
  '1등급 : 학령전기에 습득하여 평생에 걸쳐 일상 언어생활을 하는 데 사용되는 어휘',
83
83
  '2등급 : 초등학교 1-2학년 난이도 어휘',
84
84
  '3등급 : 초등학교 3-4학년 난이도 어휘',
@@ -89,11 +89,13 @@ const 등급설명기본 = [
89
89
  * 등급 고르개의 설명 줄들 — 지금 언어로. 사전에 배열이 없으면 한국어 원문.
90
90
  *
91
91
  * **공개 API 가 아니다**(대응표에 `internal`) — 코어 안에서만 쓰고, 내보내는 것은 못이 읽기 때문이다.
92
+ * 그래도 **이름은 영어다** — `internal` 이라도 배포 `d.ts` 에는 그대로 나가 소비자 편집기에 보인다
93
+ * (사용자 지시 2026-10-13. 소비자 사용처 0 이라 옛 이름을 남기지 않았다 — 말귀의 같은 이름 함수는 제 지역 것이다).
92
94
  * 앱은 제 열쇠(`wordSearch.filter.gradeInfo`)와 제 받는 함수를 따로 갖는다.
93
95
  */
94
- export function 등급설명(t) {
95
- const v = t('wordPairs.filter.difficultyInfo', { returnObjects: true, defaultValue: 등급설명기본 });
96
- return Array.isArray(v) && v.length > 0 && v.every(x => typeof x === 'string') ? v : 등급설명기본;
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;
97
99
  }
98
100
  export function useWordTrainingFilter(groupKey, caps, opts) {
99
101
  const { t, i18n } = useTranslation();
@@ -450,7 +452,7 @@ export function useWordTrainingFilter(groupKey, caps, opts) {
450
452
  사전이 배열을 안 주면(옛 사전을 덮어쓴 소비자 등) **한국어 원문으로 물러선다** — 빈 줄을
451
453
  띄우지 않는다. 재 보고 적는다: `returnObjects` 는 배열을 그대로 주고 `defaultValue` 도
452
454
  배열로 물러선다(i18next 26.4.1). */
453
- labelInfo: infoAction(등급설명(t)),
455
+ labelInfo: infoAction(gradeDescriptions(t)),
454
456
  }),
455
457
  label: LABELS.gradeLabel,
456
458
  });
@@ -1,5 +1,5 @@
1
1
  export declare const isDev: boolean;
2
- /** `키` 하나당 한 번만 찍는다. 운영 빌드에서는 아무 일도 하지 않는다. */
3
- export declare function devWarnOnce(키: string, ...말: unknown[]): void;
2
+ /** `key` 하나당 한 번만 찍는다. 운영 빌드에서는 아무 일도 하지 않는다. */
3
+ export declare function devWarnOnce(key: string, ...args: unknown[]): void;
4
4
  /** 시험용 — 기록을 비운다. 운영 코드에서 부를 일은 없다. */
5
5
  export declare function __resetDevWarnOnce(): void;
@@ -27,12 +27,12 @@ export const isDev = (() => {
27
27
  return false;
28
28
  }
29
29
  })();
30
- /** `키` 하나당 한 번만 찍는다. 운영 빌드에서는 아무 일도 하지 않는다. */
31
- export function devWarnOnce(키, ...말) {
32
- if (!isDev || 찍은것.has(키))
30
+ /** `key` 하나당 한 번만 찍는다. 운영 빌드에서는 아무 일도 하지 않는다. */
31
+ export function devWarnOnce(key, ...args) {
32
+ if (!isDev || 찍은것.has(key))
33
33
  return;
34
- 찍은것.add(키);
35
- console.warn(...말);
34
+ 찍은것.add(key);
35
+ console.warn(...args);
36
36
  }
37
37
  /** 시험용 — 기록을 비운다. 운영 코드에서 부를 일은 없다. */
38
38
  export function __resetDevWarnOnce() {
@@ -11,4 +11,4 @@ import type { FilterPanelSection } from '../types/filterPanel';
11
11
  * 안 담는 절: `note`(고르는 절이 아니다) · `input`(빈 칸을 '전체'라 말하면 거짓이다) ·
12
12
  * 고른 것이 없는데 '전체' 보기도 없는 절(말할 것이 없다).
13
13
  */
14
- export declare function buildFilterSummary(sections: readonly FilterPanelSection[], selected: Record<string, string | string[]> | undefined, 전체말: string): string;
14
+ export declare function buildFilterSummary(sections: readonly FilterPanelSection[], selected: Record<string, string | string[]> | undefined, allLabel: string): string;
@@ -11,13 +11,13 @@ import { FILTER_ALL_VALUE } from '../types/filterPanel';
11
11
  * 안 담는 절: `note`(고르는 절이 아니다) · `input`(빈 칸을 '전체'라 말하면 거짓이다) ·
12
12
  * 고른 것이 없는데 '전체' 보기도 없는 절(말할 것이 없다).
13
13
  */
14
- export function buildFilterSummary(sections, selected, 전체말) {
14
+ export function buildFilterSummary(sections, selected, allLabel) {
15
15
  const 조각들 = [];
16
16
  for (const section of sections ?? []) {
17
17
  if (section.type === 'note' || section.type === 'input')
18
18
  continue;
19
19
  const 이름 = section.displayLabel ?? section.label;
20
- const 값 = 값말(section, selected?.[section.label], 전체말);
20
+ const 값 = 값말(section, selected?.[section.label], allLabel);
21
21
  if (값 === null)
22
22
  continue;
23
23
  조각들.push(`${이름} ${값}`);
@@ -25,12 +25,12 @@ export function buildFilterSummary(sections, selected, 전체말) {
25
25
  return 조각들.join(' · ');
26
26
  }
27
27
  /** 한 절이 무엇으로 정해져 있는지 — 말할 것이 없으면 `null` */
28
- function 값말(section, sel, 전체말) {
28
+ function 값말(section, sel, allLabel) {
29
29
  const 고른것 = Array.isArray(sel) ? sel : sel === undefined ? [] : [sel];
30
30
  /* '전체' 는 두 꼴로 온다 — 아무것도 안 고른 것과 '전체' 보기를 고른 것. 같은 말로 읽는다. */
31
31
  const 실제 = 고른것.filter(v => v !== FILTER_ALL_VALUE);
32
32
  if (실제.length === 0) {
33
- return section.allOption ? (section.allLabel ?? 전체말) : null;
33
+ return section.allOption ? (section.allLabel ?? allLabel) : null;
34
34
  }
35
35
  return 실제.map(v => 보기말(section, v)).join(', ');
36
36
  }
@@ -1,6 +1,6 @@
1
1
  import type { RefObject } from 'react';
2
2
  /** 소비자가 통을 안 알려 줄 때 찾아 보는 이름 — 옛 동작 유지(말귀 셸의 본문). @internal */
3
- export declare const 기본통고르개 = ".content-area";
3
+ export declare const DEFAULT_SCROLL_SELECTOR = ".content-area";
4
4
  /**
5
5
  * 갈래를 고른 뒤 본문을 맨 위로 — 내비(SideNav·BottomNav)가 함께 쓴다. @internal
6
6
  *
@@ -8,4 +8,4 @@ export declare const 기본통고르개 = ".content-area";
8
8
  * 그것이 **아무 일도 하지 않는다** — 그때는 조용히 죽는 대신 개발 모드에 한 번 알린다.
9
9
  * 코어가 `.content-area`(소비자 클래스 이름)를 붙잡고 있던 자리라, 부르는 쪽이 통을 알려 주는 것이 정본이다.
10
10
  */
11
- export declare function scrollNavTargetToTop(scrollContainer?: string | RefObject<HTMLElement | null>, 어디서?: string): void;
11
+ export declare function scrollNavTargetToTop(scrollContainer?: string | RefObject<HTMLElement | null>, from?: string): void;
@@ -1,6 +1,6 @@
1
1
  import { devWarnOnce } from './devWarn';
2
2
  /** 소비자가 통을 안 알려 줄 때 찾아 보는 이름 — 옛 동작 유지(말귀 셸의 본문). @internal */
3
- export const 기본통고르개 = '.content-area';
3
+ export const DEFAULT_SCROLL_SELECTOR = '.content-area';
4
4
  /**
5
5
  * 갈래를 고른 뒤 본문을 맨 위로 — 내비(SideNav·BottomNav)가 함께 쓴다. @internal
6
6
  *
@@ -8,18 +8,18 @@ export const 기본통고르개 = '.content-area';
8
8
  * 그것이 **아무 일도 하지 않는다** — 그때는 조용히 죽는 대신 개발 모드에 한 번 알린다.
9
9
  * 코어가 `.content-area`(소비자 클래스 이름)를 붙잡고 있던 자리라, 부르는 쪽이 통을 알려 주는 것이 정본이다.
10
10
  */
11
- export function scrollNavTargetToTop(scrollContainer, 어디서 = 'Nav') {
11
+ export function scrollNavTargetToTop(scrollContainer, from = 'Nav') {
12
12
  try {
13
13
  const el = typeof scrollContainer === 'string'
14
14
  ? document.querySelector(scrollContainer)
15
- : scrollContainer?.current ?? document.querySelector(기본통고르개);
15
+ : scrollContainer?.current ?? document.querySelector(DEFAULT_SCROLL_SELECTOR);
16
16
  if (el) {
17
17
  el.scrollTop = 0;
18
18
  return;
19
19
  }
20
20
  const 문서 = document.scrollingElement ?? document.documentElement;
21
21
  if (문서 && 문서.scrollHeight <= 문서.clientHeight) {
22
- devWarnOnce('nav-scroll-container', `[${어디서}] 본문을 맨 위로 되돌릴 통을 못 찾았다(기본 ${기본통고르개}) — 창도 구를 수 없어 아무 일도 하지 않는다. scrollContainer 로 통을 알려 준다.`);
22
+ devWarnOnce('nav-scroll-container', `[${from}] 본문을 맨 위로 되돌릴 통을 못 찾았다(기본 ${DEFAULT_SCROLL_SELECTOR}) — 창도 구를 수 없어 아무 일도 하지 않는다. scrollContainer 로 통을 알려 준다.`);
23
23
  }
24
24
  window.scrollTo({ top: 0 });
25
25
  }
@@ -17,7 +17,7 @@
17
17
  export declare function pressable<E extends {
18
18
  key: string;
19
19
  preventDefault: () => void;
20
- }>(onPress?: (e: E) => void, 옵션?: {
20
+ }>(onPress?: (e: E) => void, options?: {
21
21
  role?: 'button' | 'option';
22
22
  selected?: boolean;
23
23
  }): {};
@@ -14,13 +14,13 @@
14
14
  * 진짜 `<button>` 을 쓸 수 있으면 그쪽이 낫다. 이건 이미 `<div>`·`<li>` 로 짜인
15
15
  * 자리를 **모양을 안 바꾸고** 눌리게 만드는 길이다.
16
16
  */
17
- export function pressable(onPress, 옵션) {
17
+ export function pressable(onPress, options) {
18
18
  if (!onPress)
19
19
  return {};
20
20
  return {
21
- role: 옵션?.role ?? 'button',
21
+ role: options?.role ?? 'button',
22
22
  tabIndex: 0,
23
- ...(옵션?.selected === undefined ? {} : { 'aria-selected': 옵션.selected }),
23
+ ...(options?.selected === undefined ? {} : { 'aria-selected': options.selected }),
24
24
  onKeyDown: (e) => {
25
25
  /* **이 자리 자신에서 난 키만** 받는다. 안에 든 진짜 단추(랜딩 띠의 action 등)에서 누른 Enter 가 올라오면
26
26
  전에는 여기서 preventDefault 해 그 단추의 click 을 막고 이 자리의 onPress 를 대신 불렀다. */
@@ -20,17 +20,17 @@ export interface TabSessionLike {
20
20
  * 여기서 막으면 이 탭이 로그인된 채로 남는다 — 막는 것보다 나쁜 쪽이다.
21
21
  *
22
22
  * ```ts
23
- * onAuthStateChange((event, 받은것) => {
23
+ * onAuthStateChange((event, incoming) => {
24
24
  * getSession().then(({ data }) => {
25
- * const 이탭것 = data.session
26
- * if (!isSessionForThisTab(받은것, 이탭것)) return // 다른 탭에서 온 것 — 무시
27
- * …이탭것 으로 상태를 맞춘다
25
+ * const thisTab = data.session
26
+ * if (!isSessionForThisTab(incoming, thisTab)) return // 다른 탭에서 온 것 — 무시
27
+ * …thisTab 으로 상태를 맞춘다
28
28
  * })
29
29
  * })
30
30
  * ```
31
31
  *
32
- * @param 받은것 이벤트가 실어 온 세션(없을 수 있다)
33
- * @param 이탭것 `getSession()` 이 이 탭 저장소에서 다시 읽은 세션
32
+ * @param incoming 이벤트가 실어 온 세션(없을 수 있다)
33
+ * @param thisTab `getSession()` 이 이 탭 저장소에서 다시 읽은 세션
34
34
  * @public
35
35
  */
36
- export declare function isSessionForThisTab(받은것: TabSessionLike | null | undefined, 이탭것: TabSessionLike | null | undefined): boolean;
36
+ export declare function isSessionForThisTab(incoming: TabSessionLike | null | undefined, thisTab: TabSessionLike | null | undefined): boolean;
@@ -16,21 +16,21 @@
16
16
  * 여기서 막으면 이 탭이 로그인된 채로 남는다 — 막는 것보다 나쁜 쪽이다.
17
17
  *
18
18
  * ```ts
19
- * onAuthStateChange((event, 받은것) => {
19
+ * onAuthStateChange((event, incoming) => {
20
20
  * getSession().then(({ data }) => {
21
- * const 이탭것 = data.session
22
- * if (!isSessionForThisTab(받은것, 이탭것)) return // 다른 탭에서 온 것 — 무시
23
- * …이탭것 으로 상태를 맞춘다
21
+ * const thisTab = data.session
22
+ * if (!isSessionForThisTab(incoming, thisTab)) return // 다른 탭에서 온 것 — 무시
23
+ * …thisTab 으로 상태를 맞춘다
24
24
  * })
25
25
  * })
26
26
  * ```
27
27
  *
28
- * @param 받은것 이벤트가 실어 온 세션(없을 수 있다)
29
- * @param 이탭것 `getSession()` 이 이 탭 저장소에서 다시 읽은 세션
28
+ * @param incoming 이벤트가 실어 온 세션(없을 수 있다)
29
+ * @param thisTab `getSession()` 이 이 탭 저장소에서 다시 읽은 세션
30
30
  * @public
31
31
  */
32
- export function isSessionForThisTab(받은것, 이탭것) {
33
- if (!받은것)
32
+ export function isSessionForThisTab(incoming, thisTab) {
33
+ if (!incoming)
34
34
  return true;
35
- return !!이탭것 && 이탭것.access_token === 받은것.access_token;
35
+ return !!thisTab && thisTab.access_token === incoming.access_token;
36
36
  }
@@ -1,5 +1,5 @@
1
1
  /** 글자 둘을 한국어 차례로 견준다 */
2
- export declare function 한글순(a: string, b: string): number;
2
+ export declare function compareKorean(a: string, b: string): number;
3
3
  /**
4
4
  * 표의 한 칸끼리 견준다 — **숫자는 숫자로, 글자 속 숫자도 수로.**
5
5
  *
@@ -13,10 +13,20 @@ export declare function 한글순(a: string, b: string): number;
13
13
  * 비어 있는 값(`null`·`undefined`·NaN)은 빈 글자로 본다 — 부르는 쪽이 이미
14
14
  * `?? ''` 로 채워 넘기던 것과 같은 뜻이다.
15
15
  */
16
- export declare function 칸순(a: unknown, b: unknown): number;
16
+ export declare function compareCell(a: unknown, b: unknown): number;
17
17
  /**
18
- * 표 정렬 한 번의 비교 — 칸순에 방향을 입히되 **빈 값은 오름·내림 모두 맨 뒤.**
19
- * 방향을 `-칸순` 으로 뒤집기만 하면 빈 값이 오름차순 맨 앞·내림차순 맨 뒤로 오가, 표마다 빈 줄이 다른 끝에 섰다.
18
+ * @deprecated **`compareCell` 을 쓴다 — 같은 함수다.**
19
+ *
20
+ * 2026-10-13 공개 이름을 영어로 옮기며(사장님 지시) 옛 이름을 남겼다. 말귀가
21
+ * `import { 칸순 } from '@ihabdevteam/core/utils/textSort'` 를 **다섯 줄**에서
22
+ * 쓰고 있어 한 번에 걷으면 그쪽이 통째로 빨개진다. 옮긴 뒤 알려 주면 걷는다.
23
+ *
24
+ * 같은 꼴로 남겨 둔 것이 또 있다 — `useTableSort()` 의 `정렬`·`칸`.
25
+ */
26
+ export declare const 칸순: typeof compareCell;
27
+ /**
28
+ * 표 정렬 한 번의 비교 — compareCell에 방향을 입히되 **빈 값은 오름·내림 모두 맨 뒤.**
29
+ * 방향을 `-compareCell` 으로 뒤집기만 하면 빈 값이 오름차순 맨 앞·내림차순 맨 뒤로 오가, 표마다 빈 줄이 다른 끝에 섰다.
20
30
  * ListPopup 과 useTableSort().sortRows 가 함께 쓴다.
21
31
  */
22
- export declare function 칸순빈값뒤(a: unknown, b: unknown, asc: boolean): number;
32
+ export declare function compareCellBlankLast(a: unknown, b: unknown, asc: boolean): number;