@ihabdevteam/core 0.171.0 → 0.173.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.
@@ -1556,7 +1556,7 @@ export declare function copyText(text: string): Promise<boolean>;
1556
1556
  *
1557
1557
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1558
1558
  */
1559
- export declare const CORE_VERSION = "0.171.0";
1559
+ export declare const CORE_VERSION = "0.173.0";
1560
1560
 
1561
1561
  /** @public */
1562
1562
  declare const coreDefault: Record<string, unknown>;
@@ -2869,6 +2869,9 @@ export declare function isThisWeekPick({ rank, voteCount, quota }: {
2869
2869
  /** @public */
2870
2870
  export declare function isTrainingDataLoaded(): boolean;
2871
2871
 
2872
+ /** 연락처가 `formatPhone` 이 끝까지 채워 낸 모양인가 — 규칙은 `PHONE_REGEX`. @public */
2873
+ export declare function isValidPhone(phone: string): boolean;
2874
+
2872
2875
  /** @public 단어가 해당 타입의 문제(정답) 단어로 구조적으로 유효한가. 제약 없는 타입은 항상 true. */
2873
2876
  export declare function isValidQuestionWord(typeKey: WordTrainingTypeKey, word: string): boolean;
2874
2877
 
@@ -4167,6 +4170,25 @@ export declare interface PersonCardProps {
4167
4170
  className?: string;
4168
4171
  }
4169
4172
 
4173
+ /**
4174
+ * 연락처가 **`formatPhone` 이 끝까지 채워 낸 모양** 인가.
4175
+ *
4176
+ * 만드는 쪽(`formatPhone`)은 코어인데 받는 쪽(형식 검사)은 소비자마다 한 벌씩 두고 있었다 —
4177
+ * 말귀 회원앱·어드민에 같은 정규식이 한 벌씩 있었고, 둘이 같은지는 짝 시험이 지켰지만
4178
+ * **코어가 만든 모양과 맞는지는 아무도 안 쟀다.** 실제로 코어가 050X·0303(앞자리 네 자리)을
4179
+ * 받았을 때 그 두 벌도 손으로 함께 고쳐야 했다. 그래서 짝을 여기 둔다 — 아래 시험이
4180
+ * "formatPhone 이 끝까지 채운 값은 늘 통과한다" 를 못 박는다.
4181
+ *
4182
+ * `formatPhone` 은 **덜 채운 입력을 그대로 돌려준다**(`010-12`). 그래서 빈값 검사만으로는
4183
+ * 모자라고, 이 함수가 그 자리를 맡는다.
4184
+ *
4185
+ * 받는 꼴 — 휴대폰·지역번호(02·0NN)·050X 안심번호·0303 착신전환·**1588 등 대표번호**.
4186
+ * 대표번호는 `formatPhone` 이 `1588-1234` 로 만들어 내므로 여기서도 받는다
4187
+ * (0 으로 시작하는 것만 받던 소비자 정규식은 그 번호를 거절했다 — 코어가 만든 모양을 코어가 거절하는 꼴).
4188
+ * @public
4189
+ */
4190
+ export declare const PHONE_REGEX: RegExp;
4191
+
4170
4192
  /**
4171
4193
  * 훈련할 음절(초성/모음/종성)을 고르는 FilterPanel 셸(shell) — 섹션 자체는 훈련 타입별 조건(caps)에
4172
4194
  * 따라 호출부(useWordTrainingFilter)가 구성해 넘긴다. WordTrainingFilterPanels의 [훈련 음절] 패널이 원 출처.
@@ -5904,7 +5926,6 @@ export declare type ShuffledItem = {
5904
5926
 
5905
5927
  declare type Side = '좌' | '우';
5906
5928
 
5907
- /** @public */
5908
5929
  export declare function SideNav(props: SideNavProps): React_2.JSX.Element;
5909
5930
 
5910
5931
  /** @public */
@@ -5935,6 +5956,14 @@ export declare interface SideNavProps {
5935
5956
  appMode?: 'malgwi' | 'manager';
5936
5957
  /** 앱 모드 전환 콜백 */
5937
5958
  onAppModeChange?: (mode: 'malgwi' | 'manager') => void;
5959
+ /**
5960
+ * 갈래를 고를 때 **맨 위로 되돌릴 본문 스크롤 통** — CSS 고르개나 ref.
5961
+ *
5962
+ * 안 주면 `.content-area` 를 찾고(코어가 **소비자 클래스 이름을 붙잡고 있던 자리** 다 — 말귀가 그 이름을 바꾸는 날
5963
+ * 화면은 멀쩡한데 내비를 눌러도 스크롤이 안 돌아갔다), 그것도 없으면 창을 굴린다.
5964
+ * 본문이 `body` 가 아니라 제 통에서 구르는 셸(body 가 `overflow: hidden`)이면 **반드시 준다** — 창 굴리기는 그 앱에서 아무 일도 하지 않는다.
5965
+ */
5966
+ scrollContainer?: string | RefObject<HTMLElement | null>;
5938
5967
  }
5939
5968
 
5940
5969
  /**
@@ -1,5 +1,5 @@
1
1
  import './SideNav.css';
2
- import React, { type ReactNode } from 'react';
2
+ import React, { type ReactNode, type RefObject } from 'react';
3
3
  /** @public */
4
4
  export interface SideNavItem {
5
5
  key?: string;
@@ -27,6 +27,13 @@ export interface SideNavProps {
27
27
  appMode?: 'malgwi' | 'manager';
28
28
  /** 앱 모드 전환 콜백 */
29
29
  onAppModeChange?: (mode: 'malgwi' | 'manager') => void;
30
+ /**
31
+ * 갈래를 고를 때 **맨 위로 되돌릴 본문 스크롤 통** — CSS 고르개나 ref.
32
+ *
33
+ * 안 주면 `.content-area` 를 찾고(코어가 **소비자 클래스 이름을 붙잡고 있던 자리** 다 — 말귀가 그 이름을 바꾸는 날
34
+ * 화면은 멀쩡한데 내비를 눌러도 스크롤이 안 돌아갔다), 그것도 없으면 창을 굴린다.
35
+ * 본문이 `body` 가 아니라 제 통에서 구르는 셸(body 가 `overflow: hidden`)이면 **반드시 준다** — 창 굴리기는 그 앱에서 아무 일도 하지 않는다.
36
+ */
37
+ scrollContainer?: string | RefObject<HTMLElement | null>;
30
38
  }
31
- /** @public */
32
39
  export default function SideNav(props: SideNavProps): React.JSX.Element;
@@ -7,6 +7,7 @@ import Badge from './Badge';
7
7
  import React, { useCallback } from 'react';
8
8
  import { useTranslation } from 'react-i18next';
9
9
  import { isSafeOpenHref } from '../utils/safeHref';
10
+ import { devWarnOnce } from '../utils/devWarn';
10
11
  /** 단추 이름에 개수를 붙인다 — 뱃지는 aria-hidden 이라 개수는 이름으로만 전해진다(BottomNav 와 같은 문구). */
11
12
  function nameWithCount(t, label, count) {
12
13
  return count && count > 0 ? t('nav.badgeLabel', { label, count, defaultValue: '{{label}}, {{count}}개' }) : label;
@@ -55,9 +56,33 @@ function SideNavButton(props) {
55
56
  return (_jsxs("button", { type: "button", className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? nameWithCount(t, text, badgeCount) : undefined, "aria-current": active ? 'page' : undefined, style: style, "aria-disabled": isDisabled, children: [iconElement, _jsx("span", { className: `side-nav__label button-1 f`, style: labelStyle, children: text }), badgeCount > 0 && (_jsx(Badge, { type: "dot", position: "inline", number: Math.min(badgeCount, 99), size: 24, "aria-hidden": true })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", role: "img", "aria-label": t('loader.loading', '불러오는 중…') }))] }));
56
57
  }
57
58
  /** @public */
59
+ /** 기본 고르개 — 소비자가 `scrollContainer` 를 안 줄 때 찾아 보는 이름(옛 동작 유지). @internal */
60
+ const 기본통고르개 = '.content-area';
61
+ /**
62
+ * 갈래를 고른 뒤 본문을 맨 위로. 통을 못 찾으면 창을 굴리는데, 본문이 제 통에서 구르는 셸에서는
63
+ * 그것이 **아무 일도 하지 않는다** — 그때는 조용히 죽는 대신 개발 모드에 한 번 알린다.
64
+ */
65
+ function 맨위로(scrollContainer) {
66
+ try {
67
+ const el = typeof scrollContainer === 'string'
68
+ ? document.querySelector(scrollContainer)
69
+ : scrollContainer?.current ?? document.querySelector(기본통고르개);
70
+ if (el) {
71
+ el.scrollTop = 0;
72
+ return;
73
+ }
74
+ const 문서 = document.scrollingElement ?? document.documentElement;
75
+ /* 창도 구를 수 없으면(본문이 다른 통에서 구른다) 이 기능은 조용히 죽는다 — 그 사실을 알린다 */
76
+ if (문서 && 문서.scrollHeight <= 문서.clientHeight) {
77
+ devWarnOnce('side-nav-scroll-container', `[SideNav] 본문을 맨 위로 되돌릴 통을 못 찾았다(기본 ${기본통고르개}) — 창도 구를 수 없어 아무 일도 하지 않는다. scrollContainer 로 통을 알려 준다.`);
78
+ }
79
+ window.scrollTo({ top: 0 });
80
+ }
81
+ catch (_) { }
82
+ }
58
83
  export default function SideNav(props) {
59
84
  const { t } = useTranslation();
60
- const { items = [], activeKey, onChange, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange } = props || {};
85
+ const { items = [], activeKey, onChange, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange, scrollContainer } = props || {};
61
86
  /* 모드를 안 주면 말귀로 본다 — Header(브랜드)·BottomNav 와 같게. 전에는 여기만 매니저로 봐 같은 화면에서
62
87
  데스크톱은 '말귀로 이동', 모바일은 '말귀 매니저' 로 서로 반대를 가리켰다. */
63
88
  const isManager = appMode === 'manager';
@@ -80,14 +105,7 @@ export default function SideNav(props) {
80
105
  return;
81
106
  }
82
107
  onChange?.(it.key);
83
- try {
84
- const contentEl = document.querySelector('.content-area');
85
- if (contentEl)
86
- contentEl.scrollTop = 0;
87
- else
88
- window.scrollTo({ top: 0 });
89
- }
90
- catch (_) { }
108
+ 맨위로(scrollContainer);
91
109
  /* 키보드로 고르면 포커스가 레일 안에 남으므로 태블릿 레일도 펼친 채 둔다(:focus-within) —
92
110
  아래 강제 접힘은 마우스가 들어와야 풀리므로 키보드 사용자에게 걸면 레일이 닫힌 채 포커스만 남는다. */
93
111
  if (!fromPointer)
@@ -3,6 +3,26 @@
3
3
  * 입력에 대시가 있든 없든, 어떤 포맷이든 처리.
4
4
  */
5
5
  export declare function formatPhone(raw: string): string;
6
+ /**
7
+ * 연락처가 **`formatPhone` 이 끝까지 채워 낸 모양** 인가.
8
+ *
9
+ * 만드는 쪽(`formatPhone`)은 코어인데 받는 쪽(형식 검사)은 소비자마다 한 벌씩 두고 있었다 —
10
+ * 말귀 회원앱·어드민에 같은 정규식이 한 벌씩 있었고, 둘이 같은지는 짝 시험이 지켰지만
11
+ * **코어가 만든 모양과 맞는지는 아무도 안 쟀다.** 실제로 코어가 050X·0303(앞자리 네 자리)을
12
+ * 받았을 때 그 두 벌도 손으로 함께 고쳐야 했다. 그래서 짝을 여기 둔다 — 아래 시험이
13
+ * "formatPhone 이 끝까지 채운 값은 늘 통과한다" 를 못 박는다.
14
+ *
15
+ * `formatPhone` 은 **덜 채운 입력을 그대로 돌려준다**(`010-12`). 그래서 빈값 검사만으로는
16
+ * 모자라고, 이 함수가 그 자리를 맡는다.
17
+ *
18
+ * 받는 꼴 — 휴대폰·지역번호(02·0NN)·050X 안심번호·0303 착신전환·**1588 등 대표번호**.
19
+ * 대표번호는 `formatPhone` 이 `1588-1234` 로 만들어 내므로 여기서도 받는다
20
+ * (0 으로 시작하는 것만 받던 소비자 정규식은 그 번호를 거절했다 — 코어가 만든 모양을 코어가 거절하는 꼴).
21
+ * @public
22
+ */
23
+ export declare const PHONE_REGEX: RegExp;
24
+ /** 연락처가 `formatPhone` 이 끝까지 채워 낸 모양인가 — 규칙은 `PHONE_REGEX`. @public */
25
+ export declare function isValidPhone(phone: string): boolean;
6
26
  /**
7
27
  * 날짜를 앱 표준 형식 YYYY.MM.DD 로 정규화.
8
28
  * 지원 입력: YYYYMMDD / YYYY-MM-DD / YYYY.MM.DD / ISO datetime 문자열
@@ -46,6 +46,28 @@ export function formatPhone(raw) {
46
46
  // 끝 4자리를 먼저 고정하고 중간 그룹을 나머지 길이로 계산한다.
47
47
  return `${d.slice(0, 3)}-${d.slice(3, d.length - 4)}-${d.slice(d.length - 4)}`;
48
48
  }
49
+ /**
50
+ * 연락처가 **`formatPhone` 이 끝까지 채워 낸 모양** 인가.
51
+ *
52
+ * 만드는 쪽(`formatPhone`)은 코어인데 받는 쪽(형식 검사)은 소비자마다 한 벌씩 두고 있었다 —
53
+ * 말귀 회원앱·어드민에 같은 정규식이 한 벌씩 있었고, 둘이 같은지는 짝 시험이 지켰지만
54
+ * **코어가 만든 모양과 맞는지는 아무도 안 쟀다.** 실제로 코어가 050X·0303(앞자리 네 자리)을
55
+ * 받았을 때 그 두 벌도 손으로 함께 고쳐야 했다. 그래서 짝을 여기 둔다 — 아래 시험이
56
+ * "formatPhone 이 끝까지 채운 값은 늘 통과한다" 를 못 박는다.
57
+ *
58
+ * `formatPhone` 은 **덜 채운 입력을 그대로 돌려준다**(`010-12`). 그래서 빈값 검사만으로는
59
+ * 모자라고, 이 함수가 그 자리를 맡는다.
60
+ *
61
+ * 받는 꼴 — 휴대폰·지역번호(02·0NN)·050X 안심번호·0303 착신전환·**1588 등 대표번호**.
62
+ * 대표번호는 `formatPhone` 이 `1588-1234` 로 만들어 내므로 여기서도 받는다
63
+ * (0 으로 시작하는 것만 받던 소비자 정규식은 그 번호를 거절했다 — 코어가 만든 모양을 코어가 거절하는 꼴).
64
+ * @public
65
+ */
66
+ export const PHONE_REGEX = /^(?:(?:0\d{1,2}|050\d|0303)-\d{3,4}-\d{4}|1[5-9]\d{2}-\d{4})$/;
67
+ /** 연락처가 `formatPhone` 이 끝까지 채워 낸 모양인가 — 규칙은 `PHONE_REGEX`. @public */
68
+ export function isValidPhone(phone) {
69
+ return PHONE_REGEX.test(phone);
70
+ }
49
71
  /**
50
72
  * 날짜를 앱 표준 형식 YYYY.MM.DD 로 정규화.
51
73
  * 지원 입력: YYYYMMDD / YYYY-MM-DD / YYYY.MM.DD / ISO datetime 문자열
@@ -36,7 +36,7 @@ export { createTTSUrlResolver } from './utils/ttsResolver';
36
36
  export type { CreateTTSResolverOptions } from './utils/ttsResolver';
37
37
  export { toStorageName, toBase64UrlSafe, officialSoundPath } from './utils/encoding';
38
38
  export { copyText } from './utils/clipboard';
39
- export { formatPhone, normalizeDate, dateToDb, formatTimeHM, formatCount } from './utils/format';
39
+ export { formatPhone, PHONE_REGEX, isValidPhone, normalizeDate, dateToDb, formatTimeHM, formatCount } from './utils/format';
40
40
  export { configureTrainingCategories, getTopicTypes, getTrainingTypes } from './utils/categories';
41
41
  export type { TopicType, TopicSubType, TrainingType, TrainingCategoryConfig } from './utils/categories';
42
42
  export { formatYmd, formatRelativeTime, getTodayKst, getTodayKstDate, kstStartOfDay, parseLocalYmd, toKstYmd, toKstHm, formatDateKo, formatDateLongKo, formatDatetimeKo, formatDateLocale, formatDateLongLocale } from './utils/date';
@@ -25,7 +25,7 @@ export { configureNoiseSettingsStore, loadNoiseSettings, saveNoiseSettings, repo
25
25
  export { createTTSUrlResolver } from './utils/ttsResolver';
26
26
  export { toStorageName, toBase64UrlSafe, officialSoundPath } from './utils/encoding';
27
27
  export { copyText } from './utils/clipboard';
28
- export { formatPhone, normalizeDate, dateToDb, formatTimeHM, formatCount } from './utils/format';
28
+ export { formatPhone, PHONE_REGEX, isValidPhone, normalizeDate, dateToDb, formatTimeHM, formatCount } from './utils/format';
29
29
  export { configureTrainingCategories, getTopicTypes, getTrainingTypes } from './utils/categories';
30
30
  export { formatYmd, formatRelativeTime, getTodayKst, getTodayKstDate, kstStartOfDay, parseLocalYmd, toKstYmd, toKstHm, formatDateKo, formatDateLongKo, formatDatetimeKo, formatDateLocale, formatDateLongLocale } from './utils/date';
31
31
  export { getSavedTheme, applyTheme, initTheme } from './utils/theme';
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.171.0";
49
+ export declare const CORE_VERSION = "0.173.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.171.0';
49
+ export const CORE_VERSION = '0.173.0';