@ihabdevteam/core 0.143.0 → 0.144.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 (34) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/components.cjs.js +185 -180
  3. package/dist/hooks.cjs.js +1 -1
  4. package/dist/i18n.cjs.js +1 -1
  5. package/dist/ihabdevteam-core.cjs.js +191 -186
  6. package/dist/ihabdevteam-core.d.ts +6 -2
  7. package/dist/packages/core/src/components/Badge.js +3 -1
  8. package/dist/packages/core/src/components/BulkBar.js +1 -1
  9. package/dist/packages/core/src/components/Button.js +2 -1
  10. package/dist/packages/core/src/components/Calendar.js +9 -6
  11. package/dist/packages/core/src/components/DatePicker.js +4 -3
  12. package/dist/packages/core/src/components/Dropdown.d.ts +3 -1
  13. package/dist/packages/core/src/components/Dropdown.js +4 -3
  14. package/dist/packages/core/src/components/Loader.js +6 -1
  15. package/dist/packages/core/src/components/Pagination.js +1 -1
  16. package/dist/packages/core/src/components/PanelRow.js +9 -5
  17. package/dist/packages/core/src/components/Popup/Popup.js +21 -4
  18. package/dist/packages/core/src/components/ProgressBar.js +1 -1
  19. package/dist/packages/core/src/components/SideNav.js +1 -1
  20. package/dist/packages/core/src/components/Slider.js +10 -1
  21. package/dist/packages/core/src/components/Table.js +2 -0
  22. package/dist/packages/core/src/components/Toolbar.js +3 -1
  23. package/dist/packages/core/src/components/Tooltip/Tooltip.js +6 -1
  24. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +1 -1
  25. package/dist/packages/core/src/components/Word/wordTablePrint.js +27 -8
  26. package/dist/packages/core/src/components/useCalendarMonth.js +3 -2
  27. package/dist/packages/core/src/components/useToastLifecycle.js +5 -0
  28. package/dist/packages/core/src/hooks/useVisitHistory.d.ts +2 -0
  29. package/dist/packages/core/src/hooks/useVisitHistory.js +5 -6
  30. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -4
  31. package/dist/packages/core/src/version.d.ts +1 -1
  32. package/dist/packages/core/src/version.js +1 -1
  33. package/dist/utils.cjs.js +1 -1
  34. package/package.json +1 -1
@@ -1386,7 +1386,7 @@ export declare function copyText(text: string): Promise<boolean>;
1386
1386
  *
1387
1387
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1388
1388
  */
1389
- export declare const CORE_VERSION = "0.143.0";
1389
+ export declare const CORE_VERSION = "0.144.0";
1390
1390
 
1391
1391
  /** @public */
1392
1392
  declare const coreDefault: Record<string, unknown>;
@@ -1830,7 +1830,7 @@ export declare interface DraftItem<T> {
1830
1830
  export declare function draftValues<T>(draft: readonly DraftItem<T>[]): T[];
1831
1831
 
1832
1832
  /** @public */
1833
- export declare function Dropdown({ options, value, onChange, placeholder, disabled, block, size, error, className, ariaLabel }: DropdownProps): JSX.Element;
1833
+ export declare function Dropdown({ options, value, onChange, placeholder, disabled, block, size, error, className, ariaLabel, ariaDescribedBy }: DropdownProps): JSX.Element;
1834
1834
 
1835
1835
  export declare type DropdownItem = {
1836
1836
  options: {
@@ -1867,6 +1867,8 @@ export declare interface DropdownProps {
1867
1867
  className?: string;
1868
1868
  /** 화면에 보이는 라벨 없이 스크린리더용 이름을 지정할 때 사용 */
1869
1869
  ariaLabel?: string;
1870
+ /** 트리거를 설명할 요소 id(들) — 오류 문구 등. 고른 값은 이것과 함께 설명으로 따로 읽힌다. */
1871
+ ariaDescribedBy?: string;
1870
1872
  }
1871
1873
 
1872
1874
  /**
@@ -6524,6 +6526,8 @@ export declare const toBase64UrlSafe: typeof toStorageName;
6524
6526
  export declare function toChoseong(text: string): string;
6525
6527
 
6526
6528
  /**
6529
+ * 오늘의 **KST** 연·월·일(두 자리 월·일). 방문 폼의 기본 날짜 — 소비자가 이 칸을 KST 벽시계로 읽어 저장하므로, 기기 달력이면
6530
+ * 해외 기기에서 손대지 않고 등록한 방문이 하루 전으로 저장됐다(해외 기기 공식 지원).
6527
6531
  * @public
6528
6532
  */
6529
6533
  export declare const today: () => {
@@ -49,7 +49,9 @@ export default function Badge(props) {
49
49
  내게** 한다 — 그래야 이벤트의 target·currentTarget 도 실제와 같다. */
50
50
  e.preventDefault();
51
51
  e.currentTarget.click();
52
- } : undefined, "aria-label": ariaHidden ? undefined : ariaLabel ?? defaultLabel, "aria-hidden": ariaHidden || undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, children: [number !== undefined && number, number === undefined && dotIcon === 'check' && _jsx(CheckIcon, {}), number === undefined && dotIcon === 'remove' && _jsx(RemoveIcon, {}), number === undefined && dotIcon === 'minus' && _jsx(MinusIcon, {})] }));
52
+ } : undefined, "aria-label": ariaHidden ? undefined : ariaLabel ?? defaultLabel, "aria-hidden": ariaHidden || undefined,
53
+ // 누를 수 없는 점도 이름이 있으면 그림(img)으로 — 역할 없는 span 의 aria-label 은 낭독기가 버려 "선택됨" 이 안 들렸다(bar 와 같게)
54
+ role: onClick ? 'button' : (ariaHidden ? undefined : 'img'), tabIndex: onClick ? 0 : undefined, children: [number !== undefined && number, number === undefined && dotIcon === 'check' && _jsx(CheckIcon, {}), number === undefined && dotIcon === 'remove' && _jsx(RemoveIcon, {}), number === undefined && dotIcon === 'minus' && _jsx(MinusIcon, {})] }));
53
55
  }
54
56
  // ── bar (텍스트) ────────────────────────────────────────────────────────
55
57
  const { label, children, variant = 'default', fill = 'filled', minWidth, iconLeft, iconRight, color } = props;
@@ -77,5 +77,5 @@ export default function BulkBar({ count, actions, onClear, clearLabel: clearLabe
77
77
  단추가 Tab 순서와 낭독기에 남아, 보이지 않는 '선택 해제'·'삭제' 에 포커스가 갔다.
78
78
  inert 로 포커스·낭독기에서 함께 뺀다(보이는 모양은 CSS 그대로). aria-hidden 은
79
79
  inert 를 모르는 오래된 브라우저를 위한 덧댐이다. */
80
- inert: !show || undefined, "aria-hidden": show ? undefined : true, children: [_jsx("span", { ref: contentRef, className: "bulk-bar__count body-2", children: content ?? _jsx("b", { children: t('common.selectedCount', { count }) }) }), max !== undefined && (_jsx(Badge, { size: "small", fill: "soft", variant: "main", label: `${count} / ${max}` })), _jsxs("div", { className: `bulk-bar__actions${stacked ? ' bulk-bar__actions--stacked' : ''}`, children: [_jsx(Button, { htmlType: "button", size: 40, label: clearLabel, onClick: onClear }), actions.map((a, i) => (_jsx(Button, { htmlType: "button", size: 40, variant: a.variant ?? 'default', icon: a.icon, label: a.label, onClick: a.onClick }, i)))] })] }));
80
+ inert: !show || undefined, "aria-hidden": show ? undefined : true, children: [_jsx("span", { ref: contentRef, className: "bulk-bar__count body-2", role: "status", children: content ?? _jsx("b", { children: t('common.selectedCount', { count }) }) }), max !== undefined && (_jsx(Badge, { size: "small", fill: "soft", variant: "main", label: `${count} / ${max}` })), _jsxs("div", { className: `bulk-bar__actions${stacked ? ' bulk-bar__actions--stacked' : ''}`, children: [_jsx(Button, { htmlType: "button", size: 40, label: clearLabel, onClick: onClear }), actions.map((a, i) => (_jsx(Button, { htmlType: "button", size: 40, variant: a.variant ?? 'default', icon: a.icon, label: a.label, onClick: a.onClick }, i)))] })] }));
81
81
  }
@@ -91,9 +91,10 @@ export default function Button(props) {
91
91
  }, [disabledProp]);
92
92
  const isSelected = typeof selected === 'boolean' ? Boolean(selected) : internalSelected;
93
93
  // toggleable 또는 controlled selected가 있을 때만 aria-pressed 설정
94
+ // 그 밖에는 소비자가 넘긴 aria-pressed 를 그대로 둔다(아이콘이 바뀌는 재생/멈춤 단추 등) — 전에는 undefined 로 덮었다
94
95
  const ariaPressed = (toggleable || typeof selected === 'boolean')
95
96
  ? (isSelected ? 'true' : 'false')
96
- : undefined;
97
+ : rest['aria-pressed'];
97
98
  const classes = cx('button', sizeBase, sizeTypeClass, variantClass, alignClass, shapeClass, isSelected ? 'is-selected' : null, !useBG ? 'button--no-bg' : null, tooltipOnly ? 'button--tooltip-only' : null, className);
98
99
  const iconClassName = `button__icon button__icon--${iconSize}`;
99
100
  const subLabels = Array.isArray(subLabel) ? subLabel : (subLabel !== undefined ? [subLabel] : []);
@@ -4,6 +4,7 @@ import { useCallback, useMemo, useState, useRef } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import Icon from './Icon';
6
6
  import { formatTimeHM } from '../utils/format';
7
+ import { getTodayKst } from '../utils/date';
7
8
  import './Calendar.css';
8
9
  import { pressable } from '../utils/pressable';
9
10
  function daysInMonth(year, month) { return new Date(year, month + 1, 0).getDate(); }
@@ -12,11 +13,13 @@ function firstDayOfMonth(year, month) { return new Date(year, month, 1).getDay()
12
13
  export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDayClick, }) {
13
14
  const { t } = useTranslation();
14
15
  const DOW = useMemo(() => t('calendar.dow', { returnObjects: true }), [t]);
15
- const [year, setYear] = useState(new Date().getFullYear());
16
- const [month, setMonth] = useState(new Date().getMonth());
16
+ /* '오늘' 은 KST 의 날 — 방문 날짜가 KST 달력(toKstYmd)이라, 기기 달력으로 열면 해외 기기에서 오늘 방문이 안 보이는 달에 있고
17
+ 오늘 표시가 다른 날에 붙었다(해외 기기 공식 지원) */
18
+ const 오늘Ymd = getTodayKst();
19
+ const [year, setYear] = useState(() => Number(오늘Ymd.slice(0, 4)));
20
+ const [month, setMonth] = useState(() => Number(오늘Ymd.slice(5, 7)) - 1);
17
21
  const [dragOverDate, setDragOverDate] = useState(null);
18
22
  const dragVisitId = useRef(null);
19
- const today = new Date();
20
23
  const visitsByDate = useMemo(() => {
21
24
  const map = {};
22
25
  visits.forEach((v) => {
@@ -51,15 +54,15 @@ export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDay
51
54
  else
52
55
  setMonth(m => m + 1); }, [month]);
53
56
  const fmtDate = (d) => `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
54
- const isToday = (d) => today.getFullYear() === year && today.getMonth() === month && today.getDate() === d;
55
- 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", 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) => {
57
+ const isToday = (d) => fmtDate(d) === 오늘Ymd;
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) => {
56
59
  if (d === null)
57
60
  return _jsx("div", { className: "calendar-cell empty" }, `empty-${i}`);
58
61
  const day = d;
59
62
  const dateKey = fmtDate(day);
60
63
  const dayVisits = visitsByDate[dateKey] ?? [];
61
64
  const isDragOver = dragOverDate === dateKey;
62
- return (_jsxs("div", { className: `calendar-cell${isToday(day) ? ' today' : ''}${isDragOver ? ' drag-over' : ''}${onDayClick ? ' calendar-cell--clickable' : ''}`, role: onDayClick ? 'button' : undefined, tabIndex: onDayClick ? 0 : undefined, "aria-label": onDayClick ? t('calendar.addOnDate', { date: dateKey }) : undefined, onClick: onDayClick ? () => onDayClick(dateKey) : undefined,
65
+ return (_jsxs("div", { className: `calendar-cell${isToday(day) ? ' today' : ''}${isDragOver ? ' drag-over' : ''}${onDayClick ? ' calendar-cell--clickable' : ''}`, role: onDayClick ? 'button' : undefined, tabIndex: onDayClick ? 0 : undefined, "aria-label": onDayClick ? t('calendar.addOnDate', { date: dateKey }) : undefined, "aria-current": isToday(day) ? 'date' : undefined, onClick: onDayClick ? () => onDayClick(dateKey) : undefined,
63
66
  /* `<div role="button">` 은 브라우저가 Enter·Space 를 눌림으로 바꿔 주지 않는다 */
64
67
  onKeyDown: onDayClick ? (e) => {
65
68
  /* 칸 안의 방문 표시에서 누른 Enter 가 올라온 것이면 넘긴다 — 마우스 click 은 표시가
@@ -9,6 +9,7 @@ import { cx } from '../utils/cx';
9
9
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
10
10
  import { useTranslation } from 'react-i18next';
11
11
  import { createPortal } from 'react-dom';
12
+ import { getTodayKst } from '../utils/date';
12
13
  import Calendar from 'lucide-react/dist/esm/icons/calendar.mjs';
13
14
  import ChevronLeft from 'lucide-react/dist/esm/icons/chevron-left.mjs';
14
15
  import ChevronRight from 'lucide-react/dist/esm/icons/chevron-right.mjs';
@@ -55,7 +56,8 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
55
56
  영어 화면의 날짜 고르개만 한국어 요일이 떴다 — 같은 저장소의 `Calendar` 는
56
57
  진작 `calendar.dow` 를 쓰고 있었고, 사전에는 영어(`Sun`·`Mon`…)도 들어 있었다. */
57
58
  const DOW = useMemo(() => t('calendar.dow', { returnObjects: true }), [t]);
58
- const today = new Date();
59
+ // 오늘 표시·열 때 포커스는 KST 의 날 — 기기 달력이면 해외 기기에서 하루 어긋나 min=오늘 달력이 앞 달로 열렸다
60
+ const 오늘ISO = getTodayKst();
59
61
  const { viewY, viewM, prevMonth, nextMonth, setViewYM } = useCalendarMonth(value);
60
62
  const computeStyle = useCallback((rect) => {
61
63
  const spaceBelow = window.innerHeight - rect.bottom;
@@ -122,7 +124,6 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
122
124
  useEffect(() => {
123
125
  if (!open)
124
126
  return;
125
- const 오늘ISO = toISO(today.getFullYear(), today.getMonth(), today.getDate());
126
127
  const 첫자리 = value && value.slice(0, 7) === `${viewY}-${String(viewM + 1).padStart(2, '0')}` ? +value.slice(8)
127
128
  : 오늘ISO.slice(0, 7) === `${viewY}-${String(viewM + 1).padStart(2, '0')}` ? +오늘ISO.slice(8)
128
129
  : 1;
@@ -237,7 +238,7 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
237
238
  return _jsx("div", {}, `e${i}`);
238
239
  const iso = toISO(viewY, viewM, d);
239
240
  const isSelected = iso === value;
240
- const isToday = iso === toISO(today.getFullYear(), today.getMonth(), today.getDate());
241
+ const isToday = iso === 오늘ISO;
241
242
  const dis = isDisabledDay(d);
242
243
  const col = (i % 7);
243
244
  return (_jsx("button", { type: "button", disabled: dis, "data-day": d, tabIndex: d === 탭자리 ? 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));
@@ -18,6 +18,8 @@ export interface DropdownProps {
18
18
  className?: string;
19
19
  /** 화면에 보이는 라벨 없이 스크린리더용 이름을 지정할 때 사용 */
20
20
  ariaLabel?: string;
21
+ /** 트리거를 설명할 요소 id(들) — 오류 문구 등. 고른 값은 이것과 함께 설명으로 따로 읽힌다. */
22
+ ariaDescribedBy?: string;
21
23
  }
22
24
  /** @public */
23
- export default function Dropdown({ options, value, onChange, placeholder, disabled, block, size, error, className, ariaLabel }: DropdownProps): import("react").JSX.Element;
25
+ export default function Dropdown({ options, value, onChange, placeholder, disabled, block, size, error, className, ariaLabel, ariaDescribedBy }: DropdownProps): import("react").JSX.Element;
@@ -7,10 +7,11 @@ import { useTranslation } from 'react-i18next';
7
7
  import { useAnchoredMenu } from '../hooks/useAnchoredMenu';
8
8
  import './Dropdown.css';
9
9
  /** @public */
10
- export default function Dropdown({ options, value, onChange, placeholder, disabled = false, block = false, size = 48, error = false, className = '', ariaLabel }) {
10
+ export default function Dropdown({ options, value, onChange, placeholder, disabled = false, block = false, size = 48, error = false, className = '', ariaLabel, ariaDescribedBy }) {
11
11
  const { t } = useTranslation();
12
12
  const resolvedPlaceholder = placeholder ?? t('dropdown.placeholder', '선택');
13
13
  const listId = useId();
14
+ const valueId = useId();
14
15
  const containerRef = useRef(null);
15
16
  const computeStyle = useCallback((rect) => {
16
17
  // 아래쪽 공간이 메뉴 최대 높이(260px, Dropdown.css)보다 부족하고 위쪽에 공간이
@@ -117,12 +118,12 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
117
118
  // mousedown 에서는 기본 동작(포커스 이동 → 트리거 blur → 메뉴 닫힘)만 막아,
118
119
  // click 이 도달하기 전에 메뉴가 사라지지 않게 한다.
119
120
  onMouseDown: e => e.preventDefault(), onClick: () => select(opt), onMouseEnter: () => setActiveIdx(i), disabled: opt.disabled, children: [_jsx("span", { className: "dropdown__option-check", children: opt.value === value && (_jsx("svg", { viewBox: "0 0 14 14", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "2 7 5.5 10.5 12 3.5" }) })) }), opt.label] }, opt.value))) })) : null;
120
- return (_jsxs("div", { ref: containerRef, className: cx('dropdown', `dropdown--s${size}`, open ? 'dropdown--open' : '', block ? 'dropdown--block' : '', error ? 'dropdown--error' : '', className), onKeyDown: handleKeyDown, children: [_jsxs("button", { ref: triggerRef, type: "button", className: "dropdown__trigger", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listId : undefined, "aria-activedescendant": open && activeIdx >= 0 ? `${listId}-option-${activeIdx}` : undefined, "aria-label": ariaLabel, "aria-invalid": error || undefined, disabled: disabled, onClick: toggle,
121
+ return (_jsxs("div", { ref: containerRef, className: cx('dropdown', `dropdown--s${size}`, open ? 'dropdown--open' : '', block ? 'dropdown--block' : '', error ? 'dropdown--error' : '', className), onKeyDown: handleKeyDown, children: [_jsxs("button", { ref: triggerRef, type: "button", className: "dropdown__trigger", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listId : undefined, "aria-activedescendant": open && activeIdx >= 0 ? `${listId}-option-${activeIdx}` : undefined, "aria-label": ariaLabel, "aria-describedby": [ariaLabel && selected ? valueId : '', ariaDescribedBy ?? ''].filter(Boolean).join(' ') || undefined, "aria-invalid": error || undefined, disabled: disabled, onClick: toggle,
121
122
  /* Tab 으로 포커스가 떠나면 닫는다 — 포커스는 트리거에 두고 aria-activedescendant 로 가리키는 목록이라, 떠난 뒤
122
123
  열린 채 남으면 화살표·Enter 가 더는 메뉴로 가지 않는 죽은 메뉴가 된다. 항목·메뉴를 누르는 것은 위에서
123
124
  mousedown 기본 동작을 막아 blur 가 나지 않는다. */
124
125
  onBlur: e => { if (open && !menuRef.current?.contains(e.relatedTarget))
125
- setOpen(false); }, children: [_jsx("span", { className: `dropdown__trigger-label body-2${!selected ? ' dropdown__trigger-label--placeholder' : ''}`, children: selected ? selected.label : resolvedPlaceholder }), _jsx("svg", { className: "dropdown__chevron", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "4 6 8 10 12 6" }) })] }), typeof document !== 'undefined' && createPortal(menu,
126
+ setOpen(false); }, children: [_jsx("span", { id: valueId, className: `dropdown__trigger-label body-2${!selected ? ' dropdown__trigger-label--placeholder' : ''}`, children: selected ? selected.label : resolvedPlaceholder }), _jsx("svg", { className: "dropdown__chevron", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "4 6 8 10 12 6" }) })] }), typeof document !== 'undefined' && createPortal(menu,
126
127
  // 코어 CSS를 .ihabdevteam-core-scope 로 스코프한 소비자(플레이그라운드 등)에서도
127
128
  // body로 portal된 메뉴가 스타일을 잃지 않도록, 가까운 scope 안으로 portal
128
129
  // createPortal의 target은 렌더링 시점에 동기적으로 결정되어야 하므로 ref 읽기를 effect로 옮길 수 없다
@@ -2,6 +2,11 @@
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import './Loader.css';
5
+ /** 눈에는 안 보이고 낭독기에는 읽히는 글자 */
6
+ const 보이지않게 = {
7
+ position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden',
8
+ clipPath: 'inset(50%)', whiteSpace: 'nowrap', border: 0,
9
+ };
5
10
  /** @public */
6
11
  export default function Loader(props) {
7
12
  const { t } = useTranslation();
@@ -13,5 +18,5 @@ export default function Loader(props) {
13
18
  return (_jsx("div", { className: "loader-bar", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": pct, "aria-label": name, children: _jsx("div", { className: "loader-bar__inner", style: { width: `${pct}%` } }) }));
14
19
  }
15
20
  const dim = typeof size === 'number' ? `${size}px` : size;
16
- return (_jsx("div", { className: "loader-spinner", style: { width: dim, height: dim }, role: "status", "aria-label": name, children: _jsxs("svg", { className: "loader-spinner__svg", viewBox: "0 0 50 50", width: size, height: size, children: [_jsx("circle", { className: "loader-spinner__bg", cx: "25", cy: "25", r: "20", fill: "none", strokeWidth: "4" }), _jsx("circle", { className: "loader-spinner__fg", cx: "25", cy: "25", r: "20", fill: "none", strokeWidth: "4", strokeLinecap: "round" })] }) }));
21
+ return (_jsxs("div", { className: "loader-spinner", style: { width: dim, height: dim }, role: "status", "aria-label": name, children: [_jsx("span", { style: 보이지않게, children: name }), _jsxs("svg", { className: "loader-spinner__svg", viewBox: "0 0 50 50", width: size, height: size, children: [_jsx("circle", { className: "loader-spinner__bg", cx: "25", cy: "25", r: "20", fill: "none", strokeWidth: "4" }), _jsx("circle", { className: "loader-spinner__fg", cx: "25", cy: "25", r: "20", fill: "none", strokeWidth: "4", strokeLinecap: "round" })] })] }));
17
22
  }
@@ -19,5 +19,5 @@ export default function Pagination({ page, totalPages, onChange, prevLabel, next
19
19
  const 마지막 = Math.max(1, totalPages);
20
20
  const handlePrev = () => onChange(Math.max(1, Math.min(마지막, page - 1)));
21
21
  const handleNext = () => onChange(Math.min(마지막, page + 1));
22
- return (_jsxs("div", { className: cx('pagination', className), children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-left", ariaLabel: resolvedPrev, disabled: page <= 1, onClick: handlePrev }), _jsxs("span", { className: "pagination__info body-3", children: [Math.min(Math.max(1, page), 마지막), " / ", 마지막] }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-right", ariaLabel: resolvedNext, disabled: page >= totalPages, onClick: handleNext })] }));
22
+ return (_jsxs("div", { className: cx('pagination', className), children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-left", ariaLabel: resolvedPrev, disabled: page <= 1, onClick: handlePrev }), _jsxs("span", { className: "pagination__info body-3", "aria-live": "polite", "aria-atomic": "true", children: [Math.min(Math.max(1, page), 마지막), " / ", 마지막] }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-right", ariaLabel: resolvedNext, disabled: page >= totalPages, onClick: handleNext })] }));
23
23
  }
@@ -71,6 +71,8 @@ export function PanelRow(props) {
71
71
  // 연결한다 — 그 전엔 시각적으로만 옆에 붙어있어 라벨 텍스트를 클릭해도 토글이 안 되고
72
72
  // 스크린리더도 이 스위치가 뭘 위한 건지 안내하지 못했다.
73
73
  const toggleId = variant === 'toggle' ? `panel-toggle-${uid}` : undefined;
74
+ // 오류 문구 id — 드롭다운 행은 트리거가 이 문구를 설명으로 잇는다(aria-invalid 만으로는 무엇이 틀렸는지 안 들린다)
75
+ const errorId = error ? `panel-error-${uid}` : undefined;
74
76
  if (variant === 'toggle') {
75
77
  const p = props;
76
78
  right = _jsx(Toggle, { checked: p.checked, onChange: p.onChange, id: toggleId });
@@ -83,15 +85,15 @@ export function PanelRow(props) {
83
85
  }
84
86
  else if (variant === 'dropdown') {
85
87
  const p = props;
86
- const dropdowns = p.dropdowns?.length ? (_jsx("div", { className: `panel-row__dropdown-group${useFullWidth ? ' panel-row__dropdown-group--full' : ''}`, children: p.dropdowns.map((d, i) => (_jsx("div", { className: useFullWidth ? 'panel-row__dropdown-item--full' : undefined, children: _jsx(Dropdown, { options: d.options, value: d.value, onChange: d.onChange, placeholder: d.placeholder, block: useFullWidth, error: !!error,
88
+ const dropdowns = p.dropdowns?.length ? (_jsx("div", { className: `panel-row__dropdown-group${useFullWidth ? ' panel-row__dropdown-group--full' : ''}`, children: p.dropdowns.map((d, i) => (_jsx("div", { className: useFullWidth ? 'panel-row__dropdown-item--full' : undefined, children: _jsx(Dropdown, { options: d.options, value: d.value, onChange: d.onChange, placeholder: d.placeholder, block: useFullWidth, error: !!error, ariaDescribedBy: errorId,
87
89
  // 한 행에 여러 개가 놓이므로 행 라벨만 넘기면 셋 다 "생년월일" 이라는 같은 이름이 된다.
88
90
  // aria-label 이 있으면 스크린리더는 트리거의 화면 텍스트(년/월/일)를 읽지 않으므로
89
91
  // 사용자는 어느 것이 무엇인지 구별할 수 없다 — placeholder 를 붙여 구별되게 한다.
90
92
  ariaLabel: d.ariaLabel ?? ([label, d.placeholder].filter(Boolean).join(' ') || undefined) }) }, i))) })) : (
91
93
  // PanelRow 왼쪽 라벨(panel-row__label)은 <label htmlFor>가 아니라 그냥 div라서 트리거 버튼과
92
94
  // 프로그래밍적으로 연결되지 않는다 — ariaLabel로 직접 이름을 넘긴다 (TextField의 ariaLabel과 동일 패턴)
93
- _jsx(Dropdown, { options: p.options ?? [], value: p.value ?? '', onChange: p.onChange ?? noop, block: useFullWidth, error: !!error, ariaLabel: label }));
94
- right = error ? (_jsxs("div", { className: `panel-row__dropdown-error-wrap${useFullWidth ? ' panel-row__dropdown-error-wrap--full' : ''}`, children: [dropdowns, _jsx("span", { className: "caption-1 panel-row__error", role: "alert", children: error })] })) : dropdowns;
95
+ _jsx(Dropdown, { options: p.options ?? [], value: p.value ?? '', onChange: p.onChange ?? noop, block: useFullWidth, error: !!error, ariaLabel: label, ariaDescribedBy: errorId }));
96
+ right = error ? (_jsxs("div", { className: `panel-row__dropdown-error-wrap${useFullWidth ? ' panel-row__dropdown-error-wrap--full' : ''}`, children: [dropdowns, _jsx("span", { id: errorId, className: "caption-1 panel-row__error", role: "alert", children: error })] })) : dropdowns;
95
97
  }
96
98
  else if (variant === 'button') {
97
99
  const p = props;
@@ -117,7 +119,7 @@ export function PanelRow(props) {
117
119
  /* error 는 Base 에 있어 **모든 variant** 가 받는다 — 그런데 input(TextField)·dropdown 만 문구를 그리고 나머지는 받아
118
120
  버렸다(소리방향성 결과 저장 폴더: custom 행에 error 를 줬는데 빨간 상자만 뜨고 까닭이 안 보였다). dropdown 과 같은 모양으로 붙인다. */
119
121
  if (error && variant !== 'input' && variant !== 'dropdown') {
120
- right = (_jsxs("div", { className: `panel-row__dropdown-error-wrap${useFullWidth ? ' panel-row__dropdown-error-wrap--full' : ''}`, children: [right, _jsx("span", { className: "caption-1 panel-row__error", role: "alert", children: error })] }));
122
+ right = (_jsxs("div", { className: `panel-row__dropdown-error-wrap${useFullWidth ? ' panel-row__dropdown-error-wrap--full' : ''}`, children: [right, _jsx("span", { id: errorId, className: "caption-1 panel-row__error", role: "alert", children: error })] }));
121
123
  }
122
124
  const isLabelFixed = variant === 'input' || variant === 'dropdown' || ((variant === 'text' || variant === 'custom') && useFullWidth);
123
125
  // 위 children 모드와 동일한 키보드 처리 — 이쪽 경로에만 빠져 있어서,
@@ -155,6 +157,8 @@ export function PanelRowDevice({ devices, onRelease, emptyLabel }) {
155
157
  /* 표의 줄 끝 단추와 같은 모양·같은 말 — 하는 일도 같다.
156
158
  이력에서 지우는 것이 아니라 '지금 끼고 있음' 만 내린다
157
159
  (잠시 안 씀·수리 보냄 같은 경우가 실제로 있다). */
158
- _jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "x", ariaLabel: t('customers.device.unwear', '착용 해제'), showTooltipInfo: t('customers.device.unwear', '착용 해제'), onClick: () => onRelease(side) }))] }), d ? (_jsxs("div", { className: "panel-row__device-info", children: [_jsxs("span", { className: "panel-row__device-kind", children: [d.device && _jsx(Badge, { size: "small", variant: deviceKindVariant(d.device), fill: "soft", label: 기기종류이름(d.device, t) }), 메타(d) && _jsx("span", { className: "panel-row__device-detail body-3", children: 메타(d) })] }), _jsx("span", { className: "panel-row__device-name body-2b", children: d.productName || d.brand }), _jsx("span", { className: "panel-row__device-date caption-1", children: t('customers.device.wornSince', { date: `${d.wornYear}.${d.wornMonth}.${d.wornDay}` }) })] })) : (_jsx("span", { className: "panel-row__device-empty body-3", children: emptyLabel ?? t('customers.device.notRegistered', '미등록') }))] }, side));
160
+ _jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "x",
161
+ // 좌·우 두 단추가 같은 이름이라 어느 쪽 기기를 내리는지 안 들렸다 — 이름에만 쪽을 붙인다(툴팁은 그대로)
162
+ ariaLabel: `${sideLabel} ${t('customers.device.unwear', '착용 해제')}`, showTooltipInfo: t('customers.device.unwear', '착용 해제'), onClick: () => onRelease(side) }))] }), d ? (_jsxs("div", { className: "panel-row__device-info", children: [_jsxs("span", { className: "panel-row__device-kind", children: [d.device && _jsx(Badge, { size: "small", variant: deviceKindVariant(d.device), fill: "soft", label: 기기종류이름(d.device, t) }), 메타(d) && _jsx("span", { className: "panel-row__device-detail body-3", children: 메타(d) })] }), _jsx("span", { className: "panel-row__device-name body-2b", children: d.productName || d.brand }), _jsx("span", { className: "panel-row__device-date caption-1", children: t('customers.device.wornSince', { date: `${d.wornYear}.${d.wornMonth}.${d.wornDay}` }) })] })) : (_jsx("span", { className: "panel-row__device-empty body-3", children: emptyLabel ?? t('customers.device.notRegistered', '미등록') }))] }, side));
159
163
  }) }));
160
164
  }
@@ -113,19 +113,34 @@ export default function Popup(props) {
113
113
  전에는 닫는 중 표시만 남아 투명한 막이 화면을 덮고(클릭을 가로챔) ESC·X·바탕·다시 저장이 모두 무시돼 페이지를 쓸 수
114
114
  없었다(말귀 업체 정보·훈련 묶음 수정 저장이 실패해 부모가 창을 열어 둘 때). 성공하면 부모가 곧 치우므로 되살릴 일이 없다. */
115
115
  const 살아있음Ref = useRef(true);
116
- useEffect(() => { 살아있음Ref.current = true; return () => { 살아있음Ref.current = false; }; }, []);
116
+ /* 닫기(X·ESC·바탕)가 걸어 둔 타이머 — 닫는 애니메이션(260ms) 안에 부모가 창을 치우면 **떠난 뒤에 onClose** 가 불렸다(닫으며
117
+ 다음 창을 여는 흐름에서 늦은 setX(null) 이 새 창을 닫을 수 있다). 떠날 때 걷는다. 소비자가 넘긴 animateThen(cb) 은
118
+ "삭제·저장 확정" 일 수 있어 걷지 않는다(결정 전). */
119
+ const 닫기타이머Ref = useRef(null);
120
+ useEffect(() => {
121
+ 살아있음Ref.current = true;
122
+ return () => {
123
+ 살아있음Ref.current = false;
124
+ if (닫기타이머Ref.current) {
125
+ clearTimeout(닫기타이머Ref.current);
126
+ 닫기타이머Ref.current = null;
127
+ }
128
+ };
129
+ }, []);
117
130
  const 되살리기 = useCallback(() => {
118
131
  if (!살아있음Ref.current || !closingRef.current)
119
132
  return;
120
133
  closingRef.current = false;
121
134
  setClosing(false);
122
135
  }, []);
123
- const animateThen = useCallback((cb) => {
136
+ const animateThen = useCallback((cb, 닫기뿐 = false) => {
124
137
  if (closingRef.current)
125
138
  return;
126
139
  closingRef.current = true;
127
140
  setClosing(true);
128
- setTimeout(() => {
141
+ const id = setTimeout(() => {
142
+ if (닫기타이머Ref.current === id)
143
+ 닫기타이머Ref.current = null;
129
144
  let 결과;
130
145
  try {
131
146
  결과 = cb ? cb() : undefined;
@@ -141,6 +156,8 @@ export default function Popup(props) {
141
156
  결과.then(() => { setTimeout(되살리기, 120); }, 되살리기);
142
157
  }
143
158
  }, ANIM_MS);
159
+ if (닫기뿐)
160
+ 닫기타이머Ref.current = id;
144
161
  }, [되살리기]);
145
162
  const closeWithAnimation = useCallback(() => {
146
163
  if (closeDisabled)
@@ -148,7 +165,7 @@ export default function Popup(props) {
148
165
  // animateThen보다 먼저 물어본다 — 애니메이션이 시작된 뒤엔 되돌릴 수 없다(위 주석 참고)
149
166
  if (beforeClose && beforeClose() === false)
150
167
  return;
151
- animateThen(onClose);
168
+ animateThen(onClose, true);
152
169
  }, [animateThen, onClose, closeDisabled, beforeClose]);
153
170
  // ESC 닫기 — 공유 스택에 등록(최상단 팝업만 닫힘). onClose가 있고 portal 모드일 때만.
154
171
  // 핸들러는 ref로 최신화해 스택 재등록(churn) 없이 1회만 push 한다.
@@ -46,7 +46,7 @@ export default function ProgressBar({ value, max = 100, size = 8, color, trackCo
46
46
  rootStyle['--progress-bar-fill'] = color;
47
47
  if (trackColor)
48
48
  rootStyle['--progress-bar-track'] = trackColor;
49
- return (_jsxs("div", { className: cx('progress-bar', `progress-bar--${size}`, `progress-bar--label-${pos}`, label && `progress-bar--align-${labelAlign}`, label && labelOnFill && 'progress-bar--label-on-fill', className), style: rootStyle, children: [_jsx("div", { className: "progress-bar__track", role: "progressbar", "aria-valuenow": clamped, "aria-valuemin": 0, "aria-valuemax": shownMax, "aria-label": ariaLabel, children: _jsx("div", { className: "progress-bar__fill", style: { width: `${pct}%` } }) }), label && (_jsx("span", { className: "progress-bar__label caption-1b", children: formatValue
49
+ return (_jsxs("div", { className: cx('progress-bar', `progress-bar--${size}`, `progress-bar--label-${pos}`, label && `progress-bar--align-${labelAlign}`, label && labelOnFill && 'progress-bar--label-on-fill', className), style: rootStyle, children: [_jsx("div", { className: "progress-bar__track", role: "progressbar", "aria-valuenow": clamped, "aria-valuemin": 0, "aria-valuemax": shownMax, "aria-valuetext": !label ? undefined : formatValue ? formatValue(clamped) : label === 'percent' ? undefined : label === 'raw' ? String(clamped) : `${clamped} / ${shownMax}`, "aria-label": ariaLabel, children: _jsx("div", { className: "progress-bar__fill", style: { width: `${pct}%` } }) }), label && (_jsx("span", { className: "progress-bar__label caption-1b", children: formatValue
50
50
  ? formatValue(clamped)
51
51
  : label === 'percent' ? `${Math.round(pct)}%`
52
52
  : label === 'raw' ? String(clamped)
@@ -51,7 +51,7 @@ function SideNavButton(props) {
51
51
  }
52
52
  onClick?.(key, e.currentTarget, fromPointer);
53
53
  }, [isDisabled, key, onClick]);
54
- return (_jsxs("button", { type: "button", className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? nameWithCount(t, text, badgeCount) : 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', '불러오는 중…') }))] }));
54
+ 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', '불러오는 중…') }))] }));
55
55
  }
56
56
  /** @public */
57
57
  export default function SideNav(props) {
@@ -85,8 +85,17 @@ export default function Slider({ value, onChange, min = 0, max = 100, step = 1,
85
85
  style.backgroundPosition = positions.join(', ');
86
86
  style.backgroundRepeat = 'no-repeat';
87
87
  }
88
+ /* 낭독기가 들을 값 글자 — 숫자만 넘기면 "1.25x"·"위"·"3 / 7" 이 "1.25"·"0"·"3" 으로 들렸다. 보이는 라벨과 같은 글자,
89
+ 라벨이 없으면 끝값의 minLabel·maxLabel. 둘 다 아니면 숫자 그대로(undefined). */
90
+ const 값글자 = formatValue
91
+ ? formatValue(clamped)
92
+ : label === 'percent' ? `${Math.round(max > min ? ((clamped - min) / (max - min)) * 100 : 0)}%`
93
+ : label === 'count' ? `${clamped} / ${max}`
94
+ : clamped === min && minLabel ? minLabel
95
+ : clamped === max && maxLabel ? maxLabel
96
+ : undefined;
88
97
  // 루트가 아닐 때(label/showMinMax로 감싸질 때)는 className을 바깥 wrapper가 대신 가져간다
89
- const input = (_jsx("input", { type: "range", className: cx('slider', `slider--${orientation}`, isStatic && 'slider--static', !label && !showMinMax && className), min: min, max: max, step: step, value: clamped, disabled: disabled, readOnly: isStatic, tabIndex: isStatic ? -1 : undefined, "aria-label": ariaLabel, "aria-orientation": orientation, onChange: handleChange, style: style }));
98
+ const input = (_jsx("input", { type: "range", className: cx('slider', `slider--${orientation}`, isStatic && 'slider--static', !label && !showMinMax && className), min: min, max: max, step: step, value: clamped, disabled: disabled, readOnly: isStatic, tabIndex: isStatic ? -1 : undefined, "aria-label": ariaLabel, "aria-valuetext": 값글자, "aria-orientation": orientation, onChange: handleChange, style: style }));
90
99
  if (!label && !showMinMax)
91
100
  return input;
92
101
  // 트랙 양 끝 최소·최대값 텍스트 — 세로는 채움 방향(아래→위)에 맞춰 위=max/아래=min 순서로 배치
@@ -248,6 +248,8 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
248
248
  if (dragRef.current) {
249
249
  window.removeEventListener('pointermove', onResizeMove);
250
250
  window.removeEventListener('pointerup', onResizeEnd);
251
+ // 나중에 더한 pointercancel 도 뗀다 — 남으면 태블릿 스크롤 손짓(pointercancel)에 떠난 표의 옛 폭이 저장소를 덮었다
252
+ window.removeEventListener('pointercancel', onResizeEnd);
251
253
  document.body.style.cursor = '';
252
254
  document.body.style.userSelect = '';
253
255
  }
@@ -9,7 +9,9 @@ import SegmentedControl from './SegmentedControl';
9
9
  import './Toolbar.css';
10
10
  export default function Toolbar({ count, search, segment, filters, pageSize, pagination, actions, className, }) {
11
11
  const { t } = useTranslation();
12
- const countNode = count != null && (_jsxs("p", { className: "toolbar__count body-1", children: [count.prefix ?? t('common.totalPrefix', '총 '), _jsx("span", { className: "toolbar__count-highlight", children: count.value }), count.unit, count.suffix] }));
12
+ const countNode = count != null && (
13
+ // 검색어를 칠 때 바뀌는 결과 수를 낭독기에 알린다(전에는 조용했다)
14
+ _jsxs("p", { className: "toolbar__count body-1", role: "status", "aria-atomic": "true", children: [count.prefix ?? t('common.totalPrefix', '총 '), _jsx("span", { className: "toolbar__count-highlight", children: count.value }), count.unit, count.suffix] }));
13
15
  // 좌측 그룹: count → segment. 우측 그룹: search → filters(정렬 등) → pageSize → pagination → actions
14
16
  const hasRight = !!(search || filters?.length || pageSize || pagination || actions);
15
17
  return (_jsxs("div", { className: cx('toolbar', className), children: [_jsxs("div", { className: "toolbar__row", children: [countNode, segment && (Array.isArray(segment) ? segment : [segment]).map((seg, i) => (_jsx(SegmentedControl, { size: 40, ...seg }, seg.ariaLabel ?? i)))] }), hasRight && (_jsx("div", { className: "toolbar__row", children: _jsxs("div", { className: "toolbar__row-end", children: [search && (_jsx(TextField, { className: "toolbar__search", size: 40, value: search.value, onChange: search.onChange, placeholder: search.placeholder ?? t('common.search', '검색'),
@@ -201,6 +201,7 @@ export default function Tooltip(props) {
201
201
  // 배경 자신의 onClick 과 document 바깥클릭 감지가 함께 걸려 실제로 onClose 가 두 번
202
202
  // 불렸다. ref 로 "이미 닫는 중"을 즉시 표시해 한 번만 지나가게 한다(Popup 과 같은 방식).
203
203
  const closingRef = useRef(false);
204
+ const 닫기타이머Ref = useRef(null);
204
205
  const startClose = useCallback((immediate = false) => {
205
206
  /* 닫기를 넘길 곳(onClose)이 없으면 아무것도 하지 않는다. 예전에는 그래도 is-closing 을
206
207
  붙여, 부모가 치우지 않는 툴팁이 **흐려진 채 사라진 것처럼** 그 자리에 남았다
@@ -225,7 +226,9 @@ export default function Tooltip(props) {
225
226
  return;
226
227
  }
227
228
  setClosing(true);
228
- setTimeout(() => {
229
+ // 떠날 때 걷는다 — ESC 로 닫는 200ms 안에 부모가 치우면 떠난 뒤에 onClose 가 불렸다
230
+ 닫기타이머Ref.current = setTimeout(() => {
231
+ 닫기타이머Ref.current = null;
229
232
  try {
230
233
  if (onClose)
231
234
  onClose();
@@ -236,6 +239,8 @@ export default function Tooltip(props) {
236
239
  }
237
240
  }, 200);
238
241
  }, [closing, onClose, setClosing]);
242
+ useEffect(() => () => { if (닫기타이머Ref.current)
243
+ clearTimeout(닫기타이머Ref.current); }, []);
239
244
  useEffect(() => {
240
245
  /* 제자리(글 흐름 안)에 그린 툴팁은 떠 있는 층이 아니라 본문의 한 조각이다 — document 의
241
246
  바깥 누름·Esc·Tab 을 듣지 않는다. 들으면 화면 아무 데나 누르거나 Esc 를 칠 때마다
@@ -1144,7 +1144,7 @@ export default function TrainingStepAudioPlayer({ btnLabel, soundSrc, bucketPath
1144
1144
  // 키보드로 눌렀을 때만 초점을 옮긴다 — 마우스로 누른 사람에게 '재생 / 멈춤' 초점 툴팁이 뜨지 않게
1145
1145
  onClick: () => { 듣기초점옮기기Ref.current = 키보드로눌렀나(); hideListenTip(); handleToggle(); }, "aria-label": typeof label === 'string' ? label : t('training.listen', '듣기'), onMouseEnter: showListenTip, onMouseLeave: hideListenTip,
1146
1146
  // 키보드로 이 버튼에 오면 마우스와 동일하게 안내를 띄운다
1147
- onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
1147
+ onFocus: showListenTip, onBlur: hideListenTip, children: _jsx("span", { ref: listenBtnRef, className: "audio-player__button-triangle-wrap", children: _jsx("svg", { className: "audio-player__button-triangle", viewBox: "0 0 24 24", "aria-hidden": "true", children: _jsx("path", { ref: triPathRef, d: "M13.2 7.7 L16.5 9.85 Q20.2 12 16.5 14.15 L10 18.3 Q7 19.7 7 16.3 L7 7.7 Q7 4.3 10 5.7 L13.2 7.7" }) }) }) })) : null, listenBtnRect && (_jsx(InfoTooltip, { anchorRect: listenBtnRect, text: typeof label === 'string' && label ? label : t('training.listenLabel', '문장 듣기'), offset: 8 })), _jsxs("div", { className: `audio-player__play ${stateLocal === "playMode" || displayedPlaying ? "is-visible" : ""}`, inert: !(stateLocal === "playMode" || displayedPlaying) || undefined, children: [_jsxs("div", { ref: btnsRef, className: "audio-player__buttons", children: [_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-back", onClick: handlePrev, ariaLabel: t('training.prev', '이전'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: displayedPlaying ? "pause" : "play", onClick: handleToggle, ariaLabel: t('common.playPause', '재생 / 멈춤'), "aria-pressed": displayedPlaying, width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "square", onClick: handleStop, ariaLabel: t('training.stop', '정지'), width: "60px", variant: "transparent-white", "aria-hidden": "true", tabIndex: -1, disabled: stateLocal !== 'playMode' }), sourceButtonCount > 1 ? (_jsx("div", { ref: sourceButtonsRef, className: "audio-player__source-buttons", children: Array.from({ length: sourceButtonCount }).map((_, idx) => (_jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "pill", useBorder: false, label: String(idx + 1), onClick: () => { try {
1148
1148
  handleJumpTo(idx);
1149
1149
  }
1150
1150
  catch (e) { } }, ariaLabel: t('training.sourcePlay', { n: idx + 1 }), variant: "transparent-white", selected: 구간표시 && currentIndex === idx, disabled: stateLocal !== 'playMode' || !(Array.isArray(sources) && sources[idx]), showTooltipInfo: true }, idx))) })) : null, (!Array.isArray(sources) || sources.length <= 1) ? (_jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "repeat", onClick: handleReplay, ariaLabel: t('common.replay', '다시듣기'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })) : null, _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "skip-forward", onClick: handleNext, ariaLabel: t('training.next', '다음'), width: "60px", variant: "transparent-white", "aria-hidden": true, tabIndex: -1, disabled: stateLocal !== 'playMode' }), _jsx(Button, { htmlType: "button", size: 32, showTooltipInfo: true, type: "dot", shape: "pill", useBorder: false, icon: "check", onClick: handleEnd, ariaLabel: t('common.done', '완료'), width: "60px", variant: "transparent-white", disabled: stateLocal !== 'playMode' })] }), _jsxs("div", { className: "audio-player__slider", children: [_jsx("div", { className: fillClass, ref: fillRef, style: { left: `${fillLeftPercent}%` }, "aria-hidden": "true" }), (showSoundWaveForm || showNoiseWaveForm) && (_jsx(WaveForm, { segments: currentSegments, progress: progress, totalDuration: totalDuration, onSegmentClick: handleJumpTo, waveProgressRef: waveProgressRef, showSoundWaveForm: showSoundWaveForm, noiseSrc: (showNoiseWaveForm && noisePlayer?.settings.enabled && noiseVolume > 0) ? activeNoiseUrl : null, noiseRatio: noisePlayer ? noiseRatioOf(noisePlayer.settings) : 0, noiseSpeed: noisePlayer?.settings.speed ?? 1,
@@ -19,6 +19,9 @@ export function sanitizeFileName(name) {
19
19
  }
20
20
  /** 아직 되돌리지 못한 탭 제목 되돌리기(인쇄가 끝나기 전 다음 인쇄가 올 때) */
21
21
  let 제목되돌리기 = null;
22
+ /* 진행 중인 인쇄 — 그림을 기다리는(최대 3초) 사이 다시 누르면 숨김 iframe 이 둘 생겨 인쇄 대화상자가 두 번 떴다(autoPrint 대기 중 '인쇄').
23
+ afterprint 가 안 오는 브라우저에서 영영 막히지 않게 10초가 지나면 새 인쇄를 받는다. */
24
+ let 인쇄중 = null;
22
25
  /**
23
26
  * 캡처한 콘텐츠 엘리먼트들을 화면 밖 숨김 iframe에서 인쇄한다. 전역 @media print(모든
24
27
  * .word-table 노출)나 팝업 차단의 간섭 없이 넘긴 엘리먼트만 격리 인쇄한다. WordTableBlock의
@@ -28,6 +31,8 @@ let 제목되돌리기 = null;
28
31
  export function printWordTableElements(elements, paperW, paperH, fileName = '', header = '') {
29
32
  if (elements.length === 0)
30
33
  return;
34
+ if (인쇄중 && 인쇄중.iframe.isConnected && Date.now() - 인쇄중.at < 10_000)
35
+ return;
31
36
  const safeFileName = fileName ? sanitizeFileName(fileName) : '';
32
37
  // "PDF로 저장" 기본 파일명은 iframe이 아니라 최상위 탭(document)의 title에서 온다.
33
38
  /* 탭 제목은 **어느 쪽 afterprint 가 와도** 되돌린다 — 브라우저에 따라 iframe 인쇄의 print 이벤트가 그 프레임에만 가서,
@@ -55,10 +60,15 @@ export function printWordTableElements(elements, paperW, paperH, fileName = '',
55
60
  iframe.remove();
56
61
  return;
57
62
  }
63
+ 인쇄중 = { iframe, at: Date.now() };
58
64
  doc.open();
59
65
  doc.write(captureHtml(elements, paperW, paperH, true, safeFileName, header));
60
66
  doc.close();
61
- iframe.contentWindow?.addEventListener('afterprint', () => { restore?.(); setTimeout(() => iframe.remove(), 0); });
67
+ iframe.contentWindow?.addEventListener('afterprint', () => {
68
+ restore?.();
69
+ setTimeout(() => { iframe.remove(); if (인쇄중?.iframe === iframe)
70
+ 인쇄중 = null; }, 0);
71
+ });
62
72
  }
63
73
  // 인쇄 시 PDF로 저장할 때 OS가 제안하는 기본 파일명은 문서 <title>에서 온다.
64
74
  export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '', header = '') {
@@ -71,11 +81,10 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
71
81
  return [];
72
82
  }
73
83
  }).join('\n');
74
- // styles.css의 다크 테마는 <html data-theme="..."> 속성으로 켜진다. 새 문서는 이 속성이
75
- // 없어 "auto"로 취급되고, OS가 다크 모드면 프로그램상 라이트 테마여도 팝업만 다크로 보였다.
76
- // 속성 값을 문서 글자에 그대로 끼우므로 **아는 세 값만** — 누가 data-theme 에 `"><script>` 를 넣어도 인쇄·새 창·내려받기 문서에 새지 않게
77
- const 읽은테마 = document.documentElement.getAttribute('data-theme');
78
- const theme = 읽은테마 === 'dark' || 읽은테마 === 'auto' ? 읽은테마 : 'light';
84
+ /* 종이는 늘 흰색(#fff)이라 문서 테마는 **light 로 고정**한다. 화면의 dark 를 옮기면 낱말 색(--color-black)이 밝은 색이 되어
85
+ 흰 종이에 흰 글자로 인쇄·새 창·내려받기가 나갔다. 속성이 없으면 "auto" 로 취급돼 OS 다크 모드에서 팝업만 어두워지던 것도
86
+ 이것으로 막힌다. 화면 테마 값은 문서 글자에 끼우지 않는다(주입 걱정도 없다). */
87
+ const theme = 'light';
79
88
  /* 종이 맨 위에 찍는 한 줄(누구의·언제·무슨 표). **격자만 인쇄되므로** 이것이 없으면
80
89
  뽑아 둔 채점지가 며칠 뒤 누구 것인지 알 수 없는 종이가 된다. 쪽마다 찍는다 —
81
90
  여러 장이 섞이면 첫 장에만 있는 이름은 소용이 없다. */
@@ -92,6 +101,11 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
92
101
  복사할 때 지우면 인쇄·새 창·내려받기가 한꺼번에 맞는다. */
93
102
  clone.querySelectorAll('.word-table-cell__score-slot:not(.word-table-cell__score-slot--on)')
94
103
  .forEach(s => s.remove());
104
+ /* 화면에서 아직 불러오는 중인 그림은 스피너 + 인라인 opacity:0 이다 — 그대로 옮기면 인쇄 문서에서 그림이 다 받아져도
105
+ 아무도 opacity 를 되돌리지 않아 **빈칸으로 인쇄**되고, 새 창·내려받은 HTML 에서는 스피너가 영영 돈다(말귀 내 단어표
106
+ '인쇄' 는 열자마자 100ms 뒤 인쇄해 처음 보는 표는 거의 늘 이랬다). */
107
+ clone.querySelectorAll('.word-table-cell__spinner').forEach(s => s.remove());
108
+ clone.querySelectorAll('img').forEach(i => i.style.removeProperty('opacity'));
95
109
  return `<div class="paper-wrap">${headHtml}${clone.outerHTML}</div>`;
96
110
  }).join('\n');
97
111
  /* 이 꾸러미의 CSS 는 전부 `.ihabdevteam-core-scope` **아래**에 산다(styles.css 스코핑).
@@ -144,11 +158,16 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
144
158
  .word-table-block__content 클론) 전부 숨겨진 채 인쇄된다. 여기서 복원한다. */
145
159
  body * { visibility: visible; }
146
160
  html, body { background: #fff; overflow: visible; padding: 0; gap: 0; }
161
+ /* 종이 한 장 = 한 쪽. height:100% 는 앱에 html·body 높이가 없으면 auto 로 풀려 종이가 내용만큼 커지고(2장 → 4쪽),
162
+ 세로·머리글이면 넘친 만큼 끝에 빈 쪽이 붙었다. 뷰포트 높이로 자르고 격자는 남은 칸만 쓴다. 마지막 종이는
163
+ body 의 마지막 자식이 아니라(닫기 단추·script) :last-child 가 안 맞았다 — :last-of-type. (Chromium PDF 로 7가지 확인) */
147
164
  .paper-wrap {
148
- box-shadow: none; border: none; transform: none !important; width: 100% !important; height: 100% !important;
165
+ box-shadow: none; border: none; transform: none !important; width: 100% !important; height: 100vh !important;
166
+ display: flex; flex-direction: column; overflow: hidden;
149
167
  page-break-after: always; break-after: page;
150
168
  }
151
- .paper-wrap:last-child { page-break-after: auto; break-after: auto; }
169
+ .paper-wrap > .word-table-block__content { flex: 1 1 0; min-height: 0; height: auto; }
170
+ .paper-wrap:last-of-type { page-break-after: auto; break-after: auto; }
152
171
  .word-table-cell__play-button { display: none; }
153
172
  .new-window-close { display: none; }
154
173
  }
@@ -1,5 +1,6 @@
1
1
  'use client';
2
2
  import { useCallback, useEffect, useRef, useState } from 'react';
3
+ import { getTodayKst } from '../utils/date';
3
4
  /**
4
5
  * "현재 보고 있는 연/월" 상태 + 이전/다음 달 이동. DatePicker의 달력 네비게이션에서 쓴다.
5
6
  * @public
@@ -10,8 +11,8 @@ export function useCalendarMonth(value) {
10
11
  const [y, m] = value.split('-');
11
12
  return { y: +y, m: +m - 1 };
12
13
  }
13
- const today = new Date();
14
- return { y: today.getFullYear(), m: today.getMonth() };
14
+ const [y, m] = getTodayKst().split('-'); // 값이 없으면 KST 오늘의 달(해외 기기 공식 지원)
15
+ return { y: +y, m: +m - 1 };
15
16
  };
16
17
  const [viewY, setViewY] = useState(() => initYM().y);
17
18
  const [viewM, setViewM] = useState(() => initYM().m);
@@ -99,6 +99,11 @@ export function useToastLifecycle({ message, position, duration, onClose }) {
99
99
  clearTimeout(hideTimerRef.current);
100
100
  hideTimerRef.current = null;
101
101
  }
102
+ // 사라지는 중(hide 가 울리며 건 exit)도 — 남으면 떠난 뒤에 onClose 가 불렸다(아래 첫 등장 갈래의 정리와 같게)
103
+ if (exitTimerRef.current) {
104
+ clearTimeout(exitTimerRef.current);
105
+ exitTimerRef.current = null;
106
+ }
102
107
  };
103
108
  }
104
109
  // 처음 등장할 때만 슬라이드인 애니메이션 실행