@ihabdevteam/core 0.348.0 → 0.350.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.
@@ -1754,7 +1754,7 @@ export declare function copyText(text: string): Promise<boolean>;
1754
1754
  *
1755
1755
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1756
1756
  */
1757
- export declare const CORE_VERSION = "0.348.0";
1757
+ export declare const CORE_VERSION = "0.350.0";
1758
1758
 
1759
1759
  /** @public */
1760
1760
  declare const coreDefault: Record<string, unknown>;
@@ -4551,7 +4551,18 @@ export declare type PanelRowSegmented = Base & {
4551
4551
 
4552
4552
  declare type PanelRowText = Base & {
4553
4553
  variant: 'text';
4554
- value: string;
4554
+ /**
4555
+ * 오른쪽에 **그대로 그려지는 것**. 글자가 흔하지만 마디도 받는다.
4556
+ *
4557
+ * 넓힌 까닭(말귀 청 2026-10-20): 고객 상세의 연락처 줄을 `<a href="tel:">` 로 만들어 폰에서
4558
+ * 번호를 눌러 바로 걸게 하려는데 `string` 이라 못 넣었다. 그 줄만 앱이 손으로 그리면 **한
4559
+ * 패널 안에서 간격·글자 크기가 갈린다** — 그래서 받는 쪽을 넓힌다. 같은 파일의
4560
+ * `helperText`·`buttonLabel` 이 이미 `ReactNode` 라 꼴도 낯설지 않다.
4561
+ *
4562
+ * **글자로 다루는 자리는 없다** — 그리는 한 곳(`panel-row__text`)에서 그대로 내보낸다.
4563
+ * `valueColor` 는 그 `<span>` 에 걸리므로, 마디가 제 색을 가지면 그것이 이긴다.
4564
+ */
4565
+ value: ReactNode;
4555
4566
  valueColor?: string;
4556
4567
  };
4557
4568
 
@@ -4,12 +4,42 @@
4
4
  }
5
5
 
6
6
  /* Trigger */
7
+ /* **칸 하나에 글 자리와 달력 단추.** 한때 `<button>` 하나였는데, 손으로 칠 수 있어야 해서
8
+ 입력칸으로 바꿨다 — 입력칸은 자식을 못 가지므로 아이콘을 밖으로 내고 둘을 이 상자가 겹친다. */
9
+ .date-picker__field {
10
+ position: relative;
11
+ width: 100%;
12
+ display: flex;
13
+ align-items: center;
14
+ }
15
+ /* 아이콘 단추는 글 자리 **위에** 뜬다 — 칸을 좁히면 글이 아이콘 밑으로 들어간다.
16
+ 글 자리의 오른쪽 여백이 그만큼 비워 준다(아래 `padding-right`). */
17
+ .date-picker__icon-btn {
18
+ position: absolute;
19
+ right: 8px;
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: center;
23
+ /* **누를 자리를 아이콘보다 넓게.** 그림은 18~20px 인데 손가락은 그만큼 정확하지 않다 —
24
+ `rawButtonHitArea` 가 바닥 24px 을 센다. 자리는 `position: absolute` 라 칸 키를 안 밀고,
25
+ `right` 를 12 → 8 로 당겨 넓힌 만큼 그림이 제자리에 남는다. */
26
+ min-width: 32px;
27
+ min-height: 32px;
28
+ padding: 0;
29
+ border: none;
30
+ background: none;
31
+ color: inherit;
32
+ cursor: pointer;
33
+ }
34
+ .date-picker__icon-btn:disabled { cursor: default; }
35
+
7
36
  .date-picker__trigger {
8
37
  width: 100%;
9
38
  display: flex;
10
39
  align-items: center;
11
40
  justify-content: space-between;
12
- padding: 0 12px;
41
+ /* 오른쪽은 아이콘 자리를 비운다 — 안 비우면 긴 날짜가 아이콘 밑으로 들어간다 */
42
+ padding: 0 40px 0 12px;
13
43
  box-sizing: border-box;
14
44
  border: 1px solid var(--color-whiteLine);
15
45
  background: var(--color-deepWhite);
@@ -44,8 +74,12 @@
44
74
  cursor: not-allowed;
45
75
  }
46
76
 
47
- .date-picker__placeholder { color: var(--color-lightGray); white-space: nowrap; }
48
- .date-picker__value { color: var(--color-darkGray); white-space: nowrap; }
77
+ /* **안내글과 값의 색.** 트리거가 `<button>` 이던 때는 안쪽 `<span>` 둘
78
+ (`__placeholder`·`__value`)이 쥐고 있었다. 손으로 칠 수 있게 입력칸이 되면서 그 둘이
79
+ 사라졌으므로, 같은 색을 입력칸 제 자리로 옮긴다 — 안 옮기면 안내글이 값과 같은 진한
80
+ 색으로 떠서 **빈 칸이 채워진 것처럼 보인다.** */
81
+ .date-picker__trigger { color: var(--color-darkGray); white-space: nowrap; }
82
+ .date-picker__trigger::placeholder { color: var(--color-lightGray); opacity: 1; }
49
83
 
50
84
  .date-picker__icon {
51
85
  flex-shrink: 0;
@@ -84,8 +118,27 @@
84
118
  justify-content: space-between;
85
119
  margin-bottom: 10px;
86
120
  }
121
+ /* **연·월은 한 묶음으로 가운데.** 저마다 `flex: 1` 이던 때는 둘이 양쪽 끝으로 벌어져
122
+ 한 날짜를 가리키는 둘이 서로 남처럼 보였다. 묶음이 남는 자리를 다 먹고(`flex: 1`),
123
+ 그 안에서 둘은 **제 글자만큼만** 차지한 채 가운데 모인다. */
124
+ .date-picker__nav-ym {
125
+ flex: 1;
126
+ display: flex;
127
+ align-items: center;
128
+ justify-content: center;
129
+ gap: 4px;
130
+ min-width: 0;
131
+ }
87
132
  /* Nav Dropdown (Dropdown 컴포넌트를 nav 타이틀로 사용) */
88
- .date-picker__nav-dropdown { flex: 1; margin: 0 4px; }
133
+ .date-picker__nav-dropdown { flex: 0 1 auto; min-width: 0; }
134
+ /* **글자만큼만 쥔다.** `Dropdown` 의 트리거는 `width: 100%` 에 `min-width: 120px` 라
135
+ `2023년`(54px)·`6월`(24px)인데도 둘 다 120px 로 서서, 가운데로 묶어도 **둘이 떨어져
136
+ 보였다.** 여기서만 내린다 — 고르개 자체의 밑값은 폼 칸에 설 때 필요한 값이다. */
137
+ .date-picker__nav-dropdown .dropdown__trigger {
138
+ width: auto !important;
139
+ min-width: 0 !important;
140
+ padding: 0 4px 0 8px !important;
141
+ }
89
142
  .date-picker__nav-dropdown .dropdown__trigger {
90
143
  height: 32px !important;
91
144
  border: none !important;
@@ -8,9 +8,8 @@ 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';
12
11
  import { createPortal } from 'react-dom';
13
- import { todayYmdOr } from '../utils/date';
12
+ import { todayYmdOr, intlLocale } from '../utils/date';
14
13
  import Calendar from 'lucide-react/dist/esm/icons/calendar.mjs';
15
14
  import ChevronLeft from 'lucide-react/dist/esm/icons/chevron-left.mjs';
16
15
  import ChevronRight from 'lucide-react/dist/esm/icons/chevron-right.mjs';
@@ -30,21 +29,71 @@ function formatDisplay(iso) {
30
29
  const [y, m, d] = iso.split('-');
31
30
  return `${y}. ${m}. ${d}.`;
32
31
  }
32
+ /**
33
+ * **손으로 친 글을 날짜로 읽는다** — `formatDisplay` 의 짝.
34
+ *
35
+ * 숫자만 남겨서 본다. 그래서 `2026. 10. 20.` · `2026-10-20` · `2026/10/20` · `20261020` 이
36
+ * 모두 같은 날이 된다 — 사람이 어떤 구분자를 칠지 미리 못 정한다.
37
+ *
38
+ * **없는 날은 안 받는다.** `Date` 는 2026-02-30 을 3월 2일로 넘겨 버리므로, 지은 날짜가
39
+ * 받은 숫자와 같은지 되본다. 안 되보면 2월 30일을 치고 3월 2일이 골라져도 모른다.
40
+ */
41
+ function parseTyped(text) {
42
+ const digits = text.replace(/\D/g, '');
43
+ if (digits.length !== 8)
44
+ return null;
45
+ const y = +digits.slice(0, 4), m = +digits.slice(4, 6), d = +digits.slice(6, 8);
46
+ if (m < 1 || m > 12 || d < 1 || d > 31)
47
+ return null;
48
+ const made = new Date(y, m - 1, d);
49
+ if (made.getFullYear() !== y || made.getMonth() !== m - 1 || made.getDate() !== d)
50
+ return null;
51
+ return toISO(y, m - 1, d);
52
+ }
33
53
  /* 연월 이름은 `연월이름` 으로 받는다 — 예전에는 `${y}년 ${m + 1}월` 로 박혀 있어 영어 화면에서도
34
54
  한국어 연월이 떴다. 부르는 쪽이 사전(`calendar.yearMonth`, Calendar 와 같은 열쇠)을 넘긴다. */
35
- function buildYMOptions(viewY, ymLabel, min, max) {
36
- const startY = min ? +min.split('-')[0] : viewY - 10;
37
- const endY = max ? +max.split('-')[0] : viewY + 10;
55
+ /**
56
+ * **연·월을 따로 고른다.**
57
+ *
58
+ * 한때 `2026년 10월` 을 한 고르개에 252줄로 늘어놓았다(사장님 지시 2026-10-20: 둘로 나눌 것).
59
+ * 한 해 앞으로 가려면 열두 줄을 지나야 했고, 목록이 길어 어디쯤인지도 안 보였다.
60
+ *
61
+ * **자르는 규칙은 그대로다.** `min`·`max` 가 있으면 그 밖의 해·달은 아예 안 내놓는다 —
62
+ * 달 목록은 **고른 해에 따라** 달라진다(`min = 2026-10` 이면 2026년에는 10·11·12월만).
63
+ */
64
+ /**
65
+ * **앞뒤 범위는 `오늘` 을 기준으로 잡는다 — 보고 있는 해가 아니라.**
66
+ *
67
+ * 한때 `viewY ± 10` 이었다. 두 가지가 틀렸다 — ① 열 해뿐이라 **생년월일을 못 고른다**
68
+ * (사장님 2026-10-20: 맨 위로 올려도 2013년이다), ② 기준이 보고 있는 해라 1950년으로 가면
69
+ * 목록이 1830년까지 **따라 흘러간다.**
70
+ *
71
+ * 그래서 **바닥은 1900년으로 못 박고, 꼭대기는 오늘에서 열 해**다(사장님: 1900년까지만
72
+ * 나오면 된다). 바닥을 숫자로 박는 것은 여기서 맞다 — **최근 해**를 박으면 세월이 지나며
73
+ * 그 해가 범위 끝이 되지만, 바닥은 멀어지기만 한다. 꼭대기만 오늘을 따라간다.
74
+ *
75
+ * 보고 있는 해는 **반드시 들어간다**(`Math.min`/`Math.max`). 밖에서 범위 밖 `value` 가
76
+ * 오면 고르개가 제 값을 목록에서 못 찾아 빈 이름으로 떨어진다.
77
+ */
78
+ const FIRST_YEAR = 1900;
79
+ function yearOptions(viewY, thisY, label, min, max) {
80
+ const startY = min ? +min.split('-')[0] : Math.min(FIRST_YEAR, viewY);
81
+ const endY = max ? +max.split('-')[0] : Math.max(thisY + 10, viewY);
38
82
  const opts = [];
39
- for (let y = startY; y <= endY; y++) {
40
- for (let m = 0; m < 12; m++) {
41
- const ym = `${y}-${String(m + 1).padStart(2, '0')}`;
42
- if (min && ym < min.slice(0, 7))
43
- continue;
44
- if (max && ym > max.slice(0, 7))
45
- continue;
46
- opts.push({ value: ym, label: ymLabel(y, m + 1) });
47
- }
83
+ for (let y = startY; y <= endY; y++)
84
+ opts.push({ value: String(y), label: label(y) });
85
+ return opts;
86
+ }
87
+ /** 고른 해에 **실제로 고를 수 있는** 달. 해가 바뀌면 이 목록도 바뀐다. */
88
+ function monthOptions(year, label, min, max) {
89
+ const opts = [];
90
+ for (let m = 1; m <= 12; m++) {
91
+ const ym = `${year}-${String(m).padStart(2, '0')}`;
92
+ if (min && ym < min.slice(0, 7))
93
+ continue;
94
+ if (max && ym > max.slice(0, 7))
95
+ continue;
96
+ opts.push({ value: String(m), label: label(year, m) });
48
97
  }
49
98
  return opts;
50
99
  }
@@ -255,12 +304,62 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
255
304
  for (let d = 1; d <= daysInMonth(viewY, viewM); d++)
256
305
  cells.push(d);
257
306
  const iconSize = size === 64 ? 20 : size === 40 ? 16 : 18;
258
- const ymOptions = buildYMOptions(viewY, (year, month) => yearMonthLabel(t, i18n.language, year, month), min, max);
259
- const ymValue = `${viewY}-${String(viewM + 1).padStart(2, '0')}`;
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) => {
261
- const [y, m] = v.split('-');
262
- setViewYM(+y, +m - 1);
263
- }, block: true, size: 40 }), _jsx("button", { type: "button", className: "date-picker__nav-btn", onClick: nextMonth, "aria-label": t('calendar.nextMonth', '다음 달'), children: _jsx(ChevronRight, { size: 16, strokeWidth: 2 }) })] }), _jsxs("div", { className: "date-picker__grid", ref: gridRef, onKeyDown: onGridKeyDown, children: [DOW.map(d => (_jsx("div", { className: "date-picker__dow caption-1", children: d }, d))), cells.map((d, i) => {
307
+ /* 이름은 브라우저가 짓는다 — 한국어 `2026년` · `10월`, 영어 `2026` · `October`.
308
+ 연월을 한 덩어리로 짓던 `yearMonthLabel` 은 **달력 머리줄(Calendar)** 이 여전히 쓴다. */
309
+ const locale = intlLocale(i18n.language);
310
+ const yearName = (y) => new Intl.DateTimeFormat(locale, { year: 'numeric' }).format(new Date(y, 0, 1));
311
+ const monthName = (y, m) => new Intl.DateTimeFormat(locale, { month: 'long' }).format(new Date(y, m - 1, 1));
312
+ const years = yearOptions(viewY, +todayIso.slice(0, 4), yearName, min, max);
313
+ const months = monthOptions(viewY, monthName, min, max);
314
+ /** 해를 바꿀 때 — 보던 달이 새 해의 범위 밖이면 **가장 가까운 달로 당긴다.**
315
+ * 안 당기면 `min = 2026-10` 에서 2026년을 골랐을 때 1월이 그대로 남아 빈 달력이 선다. */
316
+ const pickYear = (y) => {
317
+ const allowed = monthOptions(y, monthName, min, max);
318
+ if (allowed.length === 0)
319
+ return;
320
+ const stay = allowed.some((o) => +o.value === viewM + 1);
321
+ setViewYM(y, stay ? viewM : +allowed[0].value - 1);
322
+ };
323
+ /**
324
+ * **치는 동안의 글** — 안 치고 있으면 `null` 이고, 그때는 고른 값을 보인다.
325
+ *
326
+ * 치는 글을 곧바로 `value` 로 돌리지 않는 까닭: `2026` 까지 쳤을 때는 아직 날짜가 아니다.
327
+ * 그 사이에 고른 값을 바꾸면 **부르는 쪽이 반쪽짜리 날짜를 받는다.** 다 치면 그때 돌린다.
328
+ */
329
+ const [typed, setTyped] = useState(null);
330
+ /* 밖에서 값이 바뀌면 치던 글을 접는다 — 안 접으면 남이 고친 날짜가 안 보인다 */
331
+ useEffect(() => { setTyped(null); }, [value]);
332
+ const onTyped = useCallback((text) => {
333
+ setTyped(text);
334
+ const iso = parseTyped(text);
335
+ if (!iso)
336
+ return;
337
+ if (min && iso < min)
338
+ return;
339
+ if (max && iso > max)
340
+ return;
341
+ onChange(iso);
342
+ const [y, m] = iso.split('-');
343
+ setViewYM(+y, +m - 1);
344
+ }, [min, max, onChange, setViewYM]);
345
+ /** 다 못 친 글은 **버린다** — 고른 값으로 되돌린다. 반쪽 글을 남겨 두면 날짜처럼 보인다 */
346
+ const commitTyped = useCallback(() => { setTyped(null); }, []);
347
+ const onFieldKeyDown = useCallback((e) => {
348
+ if (e.key === 'Enter') {
349
+ e.preventDefault();
350
+ commitTyped();
351
+ setOpen(false);
352
+ }
353
+ else if (e.key === 'Escape') {
354
+ commitTyped();
355
+ setOpen(false);
356
+ }
357
+ else if (e.key === 'ArrowDown' && !open) {
358
+ e.preventDefault();
359
+ setOpen(true);
360
+ }
361
+ }, [commitTyped, setOpen, open]);
362
+ 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 }) }), _jsxs("div", { className: "date-picker__nav-ym", children: [_jsx(Dropdown, { className: "date-picker__nav-dropdown", ariaLabel: t('datePicker.selectYear', '연 선택'), options: years, value: String(viewY), onChange: (v) => pickYear(+v), size: 40 }), _jsx(Dropdown, { className: "date-picker__nav-dropdown", ariaLabel: t('datePicker.selectMonth', '월 선택'), options: months, value: String(viewM + 1), onChange: (v) => setViewYM(viewY, +v - 1), size: 40 })] }), _jsx("button", { type: "button", className: "date-picker__nav-btn", onClick: nextMonth, "aria-label": t('calendar.nextMonth', '다음 달'), children: _jsx(ChevronRight, { size: 16, strokeWidth: 2 }) })] }), _jsxs("div", { className: "date-picker__grid", ref: gridRef, onKeyDown: onGridKeyDown, children: [DOW.map(d => (_jsx("div", { className: "date-picker__dow caption-1", children: d }, d))), cells.map((d, i) => {
264
363
  if (d === null)
265
364
  return _jsx("div", {}, `e${i}`);
266
365
  const iso = toISO(viewY, viewM, d);
@@ -270,5 +369,5 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
270
369
  const col = (i % 7);
271
370
  return (_jsx("button", { type: "button", disabled: dis, "data-day": d, tabIndex: d === tabIndexOf ? 0 : -1, "aria-current": isToday ? 'date' : undefined, onClick: () => !dis && selectDay(d), className: cx('date-picker__day caption-1', isSelected && 'date-picker__day--selected', isToday && !isSelected && 'date-picker__day--today', dis && 'date-picker__day--disabled', col === 0 && 'date-picker__day--sun', col === 6 && 'date-picker__day--sat'), children: d }, d));
272
371
  })] })] }), document.body) : null;
273
- return (_jsxs("div", { className: cx('date-picker', `date-picker--s${size}`, open && 'date-picker--open', className), children: [_jsxs("button", { ref: triggerRef, type: "button", className: "date-picker__trigger body-2", onClick: () => !disabled && toggle(), disabled: disabled, "aria-haspopup": "true", "aria-expanded": open, children: [_jsx("span", { className: value ? 'date-picker__value' : 'date-picker__placeholder', children: value ? formatDisplay(value) : placeholder }), _jsx(Calendar, { size: iconSize, className: "date-picker__icon", strokeWidth: 1.5 })] }), dropdown] }));
372
+ return (_jsxs("div", { className: cx('date-picker', `date-picker--s${size}`, open && 'date-picker--open', className), children: [_jsxs("div", { className: "date-picker__field", children: [_jsx("input", { ref: triggerRef, type: "text", className: "date-picker__trigger body-2", value: typed ?? formatDisplay(value), placeholder: placeholder, onChange: (e) => onTyped(e.target.value), onClick: () => !disabled && !open && toggle(), onKeyDown: onFieldKeyDown, onBlur: commitTyped, disabled: disabled, "aria-haspopup": "dialog", "aria-expanded": open, inputMode: "numeric", autoComplete: "off" }), _jsx("button", { type: "button", className: "date-picker__icon-btn", onClick: () => !disabled && toggle(), disabled: disabled, tabIndex: -1, "aria-label": placeholder, "aria-haspopup": "dialog", "aria-expanded": open, children: _jsx(Calendar, { size: iconSize, className: "date-picker__icon", strokeWidth: 1.5 }) })] }), dropdown] }));
274
373
  }
@@ -79,7 +79,18 @@ export type PanelRowButton = Base & {
79
79
  };
80
80
  export type PanelRowText = Base & {
81
81
  variant: 'text';
82
- value: string;
82
+ /**
83
+ * 오른쪽에 **그대로 그려지는 것**. 글자가 흔하지만 마디도 받는다.
84
+ *
85
+ * 넓힌 까닭(말귀 청 2026-10-20): 고객 상세의 연락처 줄을 `<a href="tel:">` 로 만들어 폰에서
86
+ * 번호를 눌러 바로 걸게 하려는데 `string` 이라 못 넣었다. 그 줄만 앱이 손으로 그리면 **한
87
+ * 패널 안에서 간격·글자 크기가 갈린다** — 그래서 받는 쪽을 넓힌다. 같은 파일의
88
+ * `helperText`·`buttonLabel` 이 이미 `ReactNode` 라 꼴도 낯설지 않다.
89
+ *
90
+ * **글자로 다루는 자리는 없다** — 그리는 한 곳(`panel-row__text`)에서 그대로 내보낸다.
91
+ * `valueColor` 는 그 `<span>` 에 걸리므로, 마디가 제 색을 가지면 그것이 이긴다.
92
+ */
93
+ value: ReactNode;
83
94
  valueColor?: string;
84
95
  };
85
96
  export type PanelRowInput = Base & {