@ihabdevteam/core 0.324.0 → 0.326.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.
@@ -1678,7 +1678,7 @@ export declare function copyText(text: string): Promise<boolean>;
1678
1678
  *
1679
1679
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1680
1680
  */
1681
- export declare const CORE_VERSION = "0.324.0";
1681
+ export declare const CORE_VERSION = "0.326.0";
1682
1682
 
1683
1683
  /** @public */
1684
1684
  declare const coreDefault: Record<string, unknown>;
@@ -2,6 +2,7 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useCallback, useMemo, useState, useRef } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
+ import { yearMonthLabel } from '../utils/date';
5
6
  import Icon from './Icon';
6
7
  import { formatTimeHM } from '../utils/format';
7
8
  import { todayYmdOr, formatDateLocale } from '../utils/date';
@@ -55,7 +56,7 @@ export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDay
55
56
  setMonth(m => m + 1); }, [month]);
56
57
  const fmtDate = (d) => `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
57
58
  const isToday = (d) => fmtDate(d) === todayYmd;
58
- return (_jsxs("div", { children: [_jsxs("div", { className: "calendar-nav", children: [_jsx("button", { type: "button", className: "calendar-nav__button", onClick: prev, "aria-label": t('calendar.prevMonth', '이전 달'), children: _jsx(Icon, { icon: "chevron-left", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsx("span", { className: "calendar-nav__title h5", "aria-live": "polite", children: t('calendar.yearMonth', { year, month: month + 1 }) }), _jsx("button", { type: "button", className: "calendar-nav__button", onClick: next, "aria-label": t('calendar.nextMonth', '다음 달'), children: _jsx(Icon, { icon: "chevron-right", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsxs("div", { className: "calendar-nav__legend", children: [_jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--main" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.visited', '방문') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--sub" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.scheduled', '예약') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--disabled" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.noVisit', '미방문') })] })] }), _jsxs("div", { className: "calendar-grid", children: [DOW.map(d => _jsx("div", { className: "calendar-day-header body-3", children: d }, d)), cells.map((d, i) => {
59
+ return (_jsxs("div", { children: [_jsxs("div", { className: "calendar-nav", children: [_jsx("button", { type: "button", className: "calendar-nav__button", onClick: prev, "aria-label": t('calendar.prevMonth', '이전 달'), children: _jsx(Icon, { icon: "chevron-left", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsx("span", { className: "calendar-nav__title h5", "aria-live": "polite", children: yearMonthLabel(t, i18n.language, year, month + 1) }), _jsx("button", { type: "button", className: "calendar-nav__button", onClick: next, "aria-label": t('calendar.nextMonth', '다음 달'), children: _jsx(Icon, { icon: "chevron-right", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsxs("div", { className: "calendar-nav__legend", children: [_jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--main" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.visited', '방문') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--sub" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.scheduled', '예약') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--disabled" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.noVisit', '미방문') })] })] }), _jsxs("div", { className: "calendar-grid", children: [DOW.map(d => _jsx("div", { className: "calendar-day-header body-3", children: d }, d)), cells.map((d, i) => {
59
60
  if (d === null)
60
61
  return _jsx("div", { className: "calendar-cell empty" }, `empty-${i}`);
61
62
  const day = d;
@@ -8,6 +8,7 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  import { cx } from '../utils/cx';
9
9
  import { useCallback, useEffect, useId, useMemo, useRef, useState } from 'react';
10
10
  import { useTranslation } from 'react-i18next';
11
+ import { yearMonthLabel } from '../utils/date';
11
12
  import { createPortal } from 'react-dom';
12
13
  import { todayYmdOr } from '../utils/date';
13
14
  import Calendar from 'lucide-react/dist/esm/icons/calendar.mjs';
@@ -48,7 +49,7 @@ function buildYMOptions(viewY, ymLabel, min, max) {
48
49
  return opts;
49
50
  }
50
51
  export default function DatePicker({ value, onChange, size = 48, min, max, disabled, placeholder: placeholderProp, className, today, }) {
51
- const { t } = useTranslation();
52
+ const { t, i18n } = useTranslation();
52
53
  /* 기본 안내 문구도 사전에서 — 예전에는 '날짜 선택' 이 박혀 있어 영어 화면에서도 한국어였다.
53
54
  달력 판의 이름(`datePicker.chooseDate`)과 같은 말이라 같은 열쇠를 쓴다. */
54
55
  const placeholder = placeholderProp ?? t('datePicker.chooseDate', '날짜 선택');
@@ -254,7 +255,7 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
254
255
  for (let d = 1; d <= daysInMonth(viewY, viewM); d++)
255
256
  cells.push(d);
256
257
  const iconSize = size === 64 ? 20 : size === 40 ? 16 : 18;
257
- const ymOptions = buildYMOptions(viewY, (year, month) => t('calendar.yearMonth', { year, month, defaultValue: '{{year}}년 {{month}}월' }), min, max);
258
+ const ymOptions = buildYMOptions(viewY, (year, month) => yearMonthLabel(t, i18n.language, year, month), min, max);
258
259
  const ymValue = `${viewY}-${String(viewM + 1).padStart(2, '0')}`;
259
260
  const dropdown = open ? createPortal(_jsxs("div", { ref: menuRef, id: panelId, className: "date-picker__dropdown", style: dropdownStyle, "data-tooltip-ignore": true, role: "dialog", "aria-label": t('datePicker.chooseDate', '날짜 선택'), tabIndex: -1, children: [_jsxs("div", { className: "date-picker__nav", children: [_jsx("button", { type: "button", className: "date-picker__nav-btn", onClick: prevMonth, "aria-label": t('calendar.prevMonth', '이전 달'), children: _jsx(ChevronLeft, { size: 16, strokeWidth: 2 }) }), _jsx(Dropdown, { className: "date-picker__nav-dropdown", ariaLabel: t('datePicker.selectYearMonth', '연월 선택'), options: ymOptions, value: ymValue, onChange: (v) => {
260
261
  const [y, m] = v.split('-');
@@ -6,9 +6,21 @@
6
6
  // autoPrint: 문서 로드 후 자동으로 인쇄 대화상자를 띄운다.
7
7
  // (win.onload를 밖에서 나중에 붙이면 document.write+close가 이미 load를 발생시켜
8
8
  // 못 잡는 경우가 있어, 문서 자신의 <script> 안에서 직접 호출해 타이밍을 보장한다)
9
+ /** **글 속**에 넣을 때 — `<title>` 안, 마디 사이 */
9
10
  function escapeHtml(s) {
10
11
  return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
11
12
  }
13
+ /**
14
+ * **속성 값**에 넣을 때 — 따옴표까지 막는다.
15
+ *
16
+ * 글 속에서는 `"` 가 아무것도 안 깨지만 **속성 안에서는 값을 끝내 버린다.** `lang` 을 박으면서
17
+ * `escapeHtml` 을 그대로 썼다가 제 못에 걸렸다(2026-10-14): `ko" onload="x` 를 넣으니
18
+ * `<html lang="ko" onload="x" …>` 로 **새 속성이 생겼다.** 둘은 다른 자리이고, 같은 함수로
19
+ * 덮으면 다음 사람도 같은 자리를 밟는다 — 그래서 이름을 갈라 둔다.
20
+ */
21
+ function escapeAttr(s) {
22
+ return escapeHtml(s).replace(/"/g, '&quot;').replace(/'/g, '&#39;');
23
+ }
12
24
  /**
13
25
  * 파일명(문서 title·다운로드 파일명)에 쓰이는 사용자 입력값(단어표 이름 등)에서 Windows/macOS
14
26
  * 파일시스템에서 문제되는 문자(\ / : * ? " < > |)를 밑줄로 치환한다. 없으면 "PDF로 저장"·
@@ -85,6 +97,26 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
85
97
  흰 종이에 흰 글자로 인쇄·새 창·내려받기가 나갔다. 속성이 없으면 "auto" 로 취급돼 OS 다크 모드에서 팝업만 어두워지던 것도
86
98
  이것으로 막힌다. 화면 테마 값은 문서 글자에 끼우지 않는다(주입 걱정도 없다). */
87
99
  const theme = 'light';
100
+ /**
101
+ * **인쇄 문서에도 말을 적는다** — 말귀_1 실측(2026-10-14): 이 문서의 `lang` 이 **빈 채**로
102
+ * 나갔다(앱 제 인쇄 길은 `ko` 를 박는다). 낭독기 발음·글꼴 고르기·줄나눔이 그 값을 본다.
103
+ *
104
+ * **산 문서에서 읽는다.** 코어 `i18n` 이 처음 뜰 때(`syncHtmlLang(getSavedLocale())`)와
105
+ * 말이 바뀔 때(`languageChanged`) 둘 다 `document.documentElement.lang` 을 세워 두므로,
106
+ * 거기서 가져오면 늘 지금 말이다. 이 조각은 **들임이 하나도 없는** 것이 값이라
107
+ * (어디서든 부를 수 있다) `i18n` 을 끌어들이지 않는다.
108
+ *
109
+ * 비어 있으면 **안 적는다** — 모르는 말을 지어 적느니 없는 편이 낫다. 다만 코어가 세우는
110
+ * 자리가 있으므로 비는 것은 그 자리가 깨졌다는 뜻이고, `wordTablePrintLang` 못이 운다.
111
+ */
112
+ const docLang = (() => {
113
+ try {
114
+ return document.documentElement.lang || '';
115
+ }
116
+ catch {
117
+ return '';
118
+ }
119
+ })();
88
120
  /* 종이 맨 위에 찍는 한 줄(누구의·언제·무슨 표). **격자만 인쇄되므로** 이것이 없으면
89
121
  뽑아 둔 채점지가 며칠 뒤 누구 것인지 알 수 없는 종이가 된다. 쪽마다 찍는다 —
90
122
  여러 장이 섞이면 첫 장에만 있는 이름은 소용이 없다. */
@@ -123,7 +155,7 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
123
155
  얹는 것과 같은 결의 처리다. */
124
156
  /* eslint-disable no-useless-escape -- 아래 문자열의 `<\/script>`는 실제 <script> 태그로 파싱되어 삽입되므로, 이스케이프가 없으면 바깥 <script> 태그가 조기 종료된다 */
125
157
  return `<!DOCTYPE html>
126
- <html data-theme="${theme}">
158
+ <html${docLang ? ` lang="${escapeAttr(docLang)}"` : ''} data-theme="${theme}">
127
159
  <head>
128
160
  <meta charset="utf-8">
129
161
  ${fileName ? `<title>${escapeHtml(fileName)}</title>` : ''}
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { useState, useEffect, useMemo, useCallback } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
+ import { yearMonthLabel } from '../utils/date';
4
5
  import { firstMondayOfMonth, todayWeekValue, monthOptionsNow, weekLabel } from './barChartData';
5
6
  /**
6
7
  * BarChart 기간 상태/옵션/네비게이션을 캡슐화한 훅.
@@ -8,7 +9,7 @@ import { firstMondayOfMonth, todayWeekValue, monthOptionsNow, weekLabel } from '
8
9
  * @public
9
10
  */
10
11
  export function useBarChartControl(data) {
11
- const { t } = useTranslation();
12
+ const { t, i18n } = useTranslation();
12
13
  const [period, setPeriod] = useState('week');
13
14
  // 달 옵션·오늘은 자료가 바뀔 때 다시 센다 — 모듈 로드 때 한 번 세면 자정을 넘긴 탭에서 이번 달·오늘 주가 옛날에 굳었다
14
15
  const DATA_MONTH_OPTIONS = useMemo(() => monthOptionsNow().filter(o => o.value in data), [data]);
@@ -25,7 +26,7 @@ export function useBarChartControl(data) {
25
26
  return opts;
26
27
  }, [DATA_MONTH_OPTIONS]);
27
28
  const weekOptionLabel = useCallback((o) => {
28
- const m = t('calendar.yearMonth', { year: o.year, month: o.month });
29
+ const m = yearMonthLabel(t, i18n.language, o.year, o.month);
29
30
  return `${m} ${weekLabel(t, o.week)}`;
30
31
  }, [t]);
31
32
  const monthOptions = useMemo(() => DATA_MONTH_OPTIONS.map(o => ({
@@ -115,7 +115,6 @@
115
115
  "Sat"
116
116
  ],
117
117
  "weekLabel": "Week {{ord}}",
118
- "yearMonth": "{{month}}/{{year}}",
119
118
  "prevMonth": "Previous month",
120
119
  "addOnDate": "Add a visit on {{date}}",
121
120
  "nextMonth": "Next month",
@@ -120,7 +120,6 @@
120
120
  "일곱째"
121
121
  ],
122
122
  "weekLabel": "{{ord}}주",
123
- "yearMonth": "{{year}}년 {{month}}월",
124
123
  "prevMonth": "이전 달",
125
124
  "addOnDate": "{{date}}에 방문 등록",
126
125
  "nextMonth": "다음 달",
@@ -12,6 +12,33 @@ export declare function formatDateLongKo(iso: string): string;
12
12
  * @param lang useTranslation()의 i18n.language
13
13
  */
14
14
  export declare function formatDateLocale(iso: string, lang: string): string;
15
+ /**
16
+ * **연·월 이름표** — `2026년 10월` · `October 2026`. @public
17
+ *
18
+ * 사장님 지시(2026-10-14). 전에는 사전 글귀(`calendar.yearMonth`)가 영어에서
19
+ * `{{month}}/{{year}}` 이라 **`10/2026`** 으로 떴다(말귀_1 이 훈련 현황 도구줄에서 봤다).
20
+ * 달 이름은 틀로는 못 만든다 — 열두 이름을 두 말로 적거나, 브라우저가 든 것을 쓰거나다.
21
+ * **브라우저 쪽을 쓴다**: 한 줄이고 두 말이 같이 맞고, 말이 늘어도 따라온다.
22
+ *
23
+ * `month` 는 **1 부터**다(화면이 쓰는 꼴). `Date` 의 0 부터와 헷갈리기 쉬워 여기서 받아
24
+ * 한 번만 내린다 — 부르는 쪽 셋이 저마다 `+1`·`-1` 을 하면 그중 하나가 어긋난다.
25
+ *
26
+ * **사전에 그 열쇠가 있으면 그쪽이 이긴다.** 그 글귀는 부르는 쪽이 덮으라고 열어 둔
27
+ * 자리다(`DatePicker` 머리글). 코어 사전에서는 뺐으므로, 안 덮으면 여기로 온다.
28
+ */
29
+ export declare function formatYearMonth(year: number, month: number, lang: string): string;
30
+ /**
31
+ * **연·월 이름표 — 사전이 있으면 사전, 없으면 브라우저.** @public
32
+ *
33
+ * `calendar.yearMonth` 는 **부르는 쪽이 덮으라고 열어 둔 자리**다(`DatePicker` 머리글).
34
+ * 코어 사전에서는 뺐으므로 안 덮으면 `formatYearMonth` 로 온다.
35
+ *
36
+ * **이 가름을 한 자리에 둔다.** 쓰는 곳이 셋(`Calendar` 머리줄 · `DatePicker` 연월 고르개 ·
37
+ * 막대그래프 이름표)인데 저마다 적으면 그중 하나가 안 따라온다 — 이 저장소에서 여러 번 겪은
38
+ * 꼴이다. `defaultValue: ''` 로 묻는 까닭은 `exists()` 없이도 "사전에 있나" 를 가를 수
39
+ * 있어서다(없으면 빈 글자가 온다).
40
+ */
41
+ export declare function yearMonthLabel(t: (key: string, opts?: Record<string, unknown>) => string, lang: string, year: number, month: number): string;
15
42
  /** formatDateLongKo의 로케일 인식 버전 — en: "January 15, 2024, 03:30 PM" */
16
43
  export declare function formatDateLongLocale(iso: string, lang: string): string;
17
44
  /** "2024. 01. 15. 오후 03:30" (2-digit month + time) */
@@ -75,6 +75,43 @@ export function formatDateLocale(iso, lang) {
75
75
  return '';
76
76
  return d.toLocaleDateString(locale, { timeZone: KST, year: 'numeric', month: 'short', day: 'numeric' });
77
77
  }
78
+ /**
79
+ * **연·월 이름표** — `2026년 10월` · `October 2026`. @public
80
+ *
81
+ * 사장님 지시(2026-10-14). 전에는 사전 글귀(`calendar.yearMonth`)가 영어에서
82
+ * `{{month}}/{{year}}` 이라 **`10/2026`** 으로 떴다(말귀_1 이 훈련 현황 도구줄에서 봤다).
83
+ * 달 이름은 틀로는 못 만든다 — 열두 이름을 두 말로 적거나, 브라우저가 든 것을 쓰거나다.
84
+ * **브라우저 쪽을 쓴다**: 한 줄이고 두 말이 같이 맞고, 말이 늘어도 따라온다.
85
+ *
86
+ * `month` 는 **1 부터**다(화면이 쓰는 꼴). `Date` 의 0 부터와 헷갈리기 쉬워 여기서 받아
87
+ * 한 번만 내린다 — 부르는 쪽 셋이 저마다 `+1`·`-1` 을 하면 그중 하나가 어긋난다.
88
+ *
89
+ * **사전에 그 열쇠가 있으면 그쪽이 이긴다.** 그 글귀는 부르는 쪽이 덮으라고 열어 둔
90
+ * 자리다(`DatePicker` 머리글). 코어 사전에서는 뺐으므로, 안 덮으면 여기로 온다.
91
+ */
92
+ export function formatYearMonth(year, month, lang) {
93
+ /* 달이 범위 밖이면 `Date` 가 해를 넘겨 버린다(13월 → 다음 해 1월). 조용히 딴 해를
94
+ 보여 주느니 받은 그대로 적는다. */
95
+ if (!Number.isFinite(year) || !Number.isFinite(month) || month < 1 || month > 12)
96
+ return `${year}-${month}`;
97
+ const locale = lang && lang.startsWith('ko') ? 'ko-KR' : lang || 'en-US';
98
+ return new Intl.DateTimeFormat(locale, { year: 'numeric', month: 'long' }).format(new Date(year, month - 1, 1));
99
+ }
100
+ /**
101
+ * **연·월 이름표 — 사전이 있으면 사전, 없으면 브라우저.** @public
102
+ *
103
+ * `calendar.yearMonth` 는 **부르는 쪽이 덮으라고 열어 둔 자리**다(`DatePicker` 머리글).
104
+ * 코어 사전에서는 뺐으므로 안 덮으면 `formatYearMonth` 로 온다.
105
+ *
106
+ * **이 가름을 한 자리에 둔다.** 쓰는 곳이 셋(`Calendar` 머리줄 · `DatePicker` 연월 고르개 ·
107
+ * 막대그래프 이름표)인데 저마다 적으면 그중 하나가 안 따라온다 — 이 저장소에서 여러 번 겪은
108
+ * 꼴이다. `defaultValue: ''` 로 묻는 까닭은 `exists()` 없이도 "사전에 있나" 를 가를 수
109
+ * 있어서다(없으면 빈 글자가 온다).
110
+ */
111
+ export function yearMonthLabel(t, lang, year, month) {
112
+ const fromDict = t('calendar.yearMonth', { year, month, defaultValue: '' });
113
+ return fromDict || formatYearMonth(year, month, lang);
114
+ }
78
115
  /** formatDateLongKo의 로케일 인식 버전 — en: "January 15, 2024, 03:30 PM" */
79
116
  export function formatDateLongLocale(iso, lang) {
80
117
  const locale = lang.startsWith('ko') ? 'ko-KR' : 'en-US';
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.324.0";
49
+ export declare const CORE_VERSION = "0.326.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.324.0';
49
+ export const CORE_VERSION = '0.326.0';