@ihabdevteam/core 0.143.0 → 0.145.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 (39) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/components.cjs.js +185 -180
  3. package/dist/components.esm.css +2 -2
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +1 -1
  6. package/dist/ihabdevteam-core.cjs.js +191 -186
  7. package/dist/ihabdevteam-core.d.ts +6 -2
  8. package/dist/packages/core/src/components/Badge.js +3 -1
  9. package/dist/packages/core/src/components/BulkBar.js +1 -1
  10. package/dist/packages/core/src/components/Button.js +2 -1
  11. package/dist/packages/core/src/components/Calendar.js +9 -6
  12. package/dist/packages/core/src/components/DatePicker.js +4 -3
  13. package/dist/packages/core/src/components/Dropdown.d.ts +3 -1
  14. package/dist/packages/core/src/components/Dropdown.js +26 -6
  15. package/dist/packages/core/src/components/Loader.js +6 -1
  16. package/dist/packages/core/src/components/Pagination.js +1 -1
  17. package/dist/packages/core/src/components/PanelRow.js +9 -5
  18. package/dist/packages/core/src/components/Popup/Popup.js +21 -4
  19. package/dist/packages/core/src/components/ProgressBar.js +1 -1
  20. package/dist/packages/core/src/components/Report/ReportDocument.css +11 -0
  21. package/dist/packages/core/src/components/Report/ReportDocument.js +11 -5
  22. package/dist/packages/core/src/components/SideNav.js +1 -1
  23. package/dist/packages/core/src/components/Slider.js +10 -1
  24. package/dist/packages/core/src/components/Table.js +2 -0
  25. package/dist/packages/core/src/components/Toast.css +5 -0
  26. package/dist/packages/core/src/components/Toolbar.js +3 -1
  27. package/dist/packages/core/src/components/Tooltip/Tooltip.js +6 -1
  28. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +1 -1
  29. package/dist/packages/core/src/components/Word/wordTablePrint.js +27 -8
  30. package/dist/packages/core/src/components/useCalendarMonth.js +3 -2
  31. package/dist/packages/core/src/components/useToastLifecycle.d.ts +1 -0
  32. package/dist/packages/core/src/components/useToastLifecycle.js +8 -1
  33. package/dist/packages/core/src/hooks/useVisitHistory.d.ts +2 -0
  34. package/dist/packages/core/src/hooks/useVisitHistory.js +5 -6
  35. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -4
  36. package/dist/packages/core/src/version.d.ts +1 -1
  37. package/dist/packages/core/src/version.js +1 -1
  38. package/dist/utils.cjs.js +1 -1
  39. 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.145.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)보다 부족하고 위쪽에 공간이
@@ -40,7 +41,7 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
40
41
  useEffect(() => {
41
42
  if (open) {
42
43
  const idx = options.findIndex(o => o.value === value);
43
- setActiveIdx(idx >= 0 ? idx : 0);
44
+ setActiveIdx(idx >= 0 ? idx : (options.length ? 0 : -1)); // 빈 목록이면 가리킬 칸이 없다
44
45
  requestAnimationFrame(() => {
45
46
  const menu = menuRef.current;
46
47
  if (!menu)
@@ -59,6 +60,20 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
59
60
  // eslint-disable-next-line react-hooks/exhaustive-deps
60
61
  }, [open]);
61
62
  /* eslint-enable react-hooks/set-state-in-effect */
63
+ /* 열린 동안 목록 **길이** 가 바뀌어 가리킴이 목록 밖이면(불러오는 중 [] 로 열었다 목록 도착 · 목록이 줄어듦) 선택값 자리로
64
+ 다시 잡는다 — 전에는 목록 밖 칸에서 Enter 가 던지거나(말귀 어드민 상품 고르개), 도착한 목록의 첫 항목을 골랐다.
65
+ 목록 안이면 그대로 둔다(인라인 배열로 다시 그려질 때 가리킴이 튀지 않게 한 위 효과의 뜻). */
66
+ useEffect(() => {
67
+ if (!open)
68
+ return;
69
+ setActiveIdx(i => {
70
+ if (i >= 0 && i < options.length)
71
+ return i;
72
+ const idx = options.findIndex(o => o.value === value);
73
+ return idx >= 0 ? idx : (options.length ? 0 : -1);
74
+ });
75
+ // eslint-disable-next-line react-hooks/exhaustive-deps
76
+ }, [open, options.length]);
62
77
  const toggle = useCallback(() => { if (!disabled)
63
78
  rawToggle(); }, [disabled, rawToggle]);
64
79
  const select = useCallback((opt) => {
@@ -104,9 +119,14 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
104
119
  return prev >= 0 ? prev : i;
105
120
  });
106
121
  }
107
- else if (e.key === 'Enter' && activeIdx >= 0) {
122
+ else if (e.key === 'Enter' || e.key === ' ') {
123
+ /* 열린 목록에서 Space 도 가리킨 항목을 고른다(네이티브 select·APG 와 같게) — 전에는 단추 기본 동작(keyup→click)이 목록을
124
+ 닫기만 해 고른 것이 사라졌다(소리방향성 출력 장치). 가리킨 것이 없으면 닫기만 한다. */
108
125
  e.preventDefault();
109
- select(options[activeIdx]);
126
+ if (options[activeIdx])
127
+ select(options[activeIdx]);
128
+ else if (e.key === ' ')
129
+ setOpen(false);
110
130
  }
111
131
  }, [disabled, open, options, activeIdx, select, setOpen]);
112
132
  const menu = open ? (_jsx("div", { ref: menuRef, id: listId, className: "dropdown__menu", "data-tooltip-ignore": true, role: "listbox", style: menuStyle, onMouseLeave: handleMenuLeave,
@@ -117,12 +137,12 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
117
137
  // mousedown 에서는 기본 동작(포커스 이동 → 트리거 blur → 메뉴 닫힘)만 막아,
118
138
  // click 이 도달하기 전에 메뉴가 사라지지 않게 한다.
119
139
  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,
140
+ 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
141
  /* Tab 으로 포커스가 떠나면 닫는다 — 포커스는 트리거에 두고 aria-activedescendant 로 가리키는 목록이라, 떠난 뒤
122
142
  열린 채 남으면 화살표·Enter 가 더는 메뉴로 가지 않는 죽은 메뉴가 된다. 항목·메뉴를 누르는 것은 위에서
123
143
  mousedown 기본 동작을 막아 blur 가 나지 않는다. */
124
144
  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,
145
+ 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
146
  // 코어 CSS를 .ihabdevteam-core-scope 로 스코프한 소비자(플레이그라운드 등)에서도
127
147
  // body로 portal된 메뉴가 스타일을 잃지 않도록, 가까운 scope 안으로 portal
128
148
  // 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)
@@ -131,4 +131,15 @@
131
131
  .report-doc__page:last-child {
132
132
  break-after: auto;
133
133
  }
134
+
135
+ /* 숨은 재기 사본(자 + 모든 블록)이 인쇄에서도 흐름 높이를 늘려 끝에 빈 쪽이 붙었다(1장 → 2쪽). 높이를 0 으로 접되, 안의 자는
136
+ 줄어들지 않게 한다 — 자가 0 이 되면 ResizeObserver 가 다시 재어 모두 한 장에 몰린다(Chromium 확인). */
137
+ .report-doc__measure {
138
+ height: 0;
139
+ overflow: hidden;
140
+ }
141
+
142
+ .report-doc__measure > * {
143
+ flex-shrink: 0;
144
+ }
134
145
  }
@@ -96,18 +96,23 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
96
96
  재지 않았다 — 자의 높이(자크기)를 의존성에 두어 크기가 생기면 다시 잰다. */
97
97
  /* 머리·바닥 높이는 **갈래마다** 잰다 — 제목이 있는 갈래와 없는 갈래는 머리 높이가 달라, 첫 갈래 것 하나로 재면 제목 있는
98
98
  뒤 갈래의 장이 넘쳤다. */
99
+ /* 높이는 **소수 그대로** 잰다 — offsetHeight 는 정수로 반올림해, 소수 높이 블록이 모이면 반올림 합은 예산과 같은데 실제 장은
100
+ 종이보다 3px 넘쳤고(mm 규격의 자 1016.69px 는 1017 로 올림) 인쇄에서는 그 1px 로 쪽이 하나 더 생겼다(Chromium PDF 확인).
101
+ rect 가 0 인 환경(레이아웃 없는 시험)에서는 offsetHeight 로 물러선다. */
102
+ const 높이 = (node) => node.getBoundingClientRect().height || node.offsetHeight;
99
103
  const chromeOf = new Map();
100
104
  chromeRef.current?.querySelectorAll('[data-chrome-section]').forEach((node) => {
101
- chromeOf.set(node.dataset.chromeSection ?? '', node.offsetHeight);
105
+ chromeOf.set(node.dataset.chromeSection ?? '', 높이(node));
102
106
  });
103
- const budgetFor = (section) => ruler.offsetHeight - (chromeOf.get(section ?? '') ?? 0);
104
- const heights = Array.from(el.children).map((c) => c.offsetHeight);
107
+ const budgetFor = (section) => 높이(ruler) - (chromeOf.get(section ?? '') ?? 0);
108
+ const heights = Array.from(el.children).map((c) => 높이(c));
105
109
  // 이웃한 같은 group 을 한 덩어리로 묶는다.
106
110
  const groups = [];
107
111
  blocks.forEach((b, i) => {
108
112
  const last = groups[groups.length - 1];
109
113
  const prev = last ? blocks[last[0]] : undefined;
110
- const sameGroup = prev && b.group != null && prev.group === b.group;
114
+ // 갈래(section)가 다르면 같은 group 값이어도 한 덩어리가 아니다 — 묶이면 "값이 바뀌는 자리에서 반드시 새 장"(JSDoc)이 깨졌다
115
+ const sameGroup = prev && b.group != null && prev.group === b.group && prev.section === b.section;
111
116
  if (sameGroup)
112
117
  last.push(i);
113
118
  else
@@ -149,7 +154,8 @@ export default function ReportDocument({ blocks, sectionTitles, logo, footer, pa
149
154
  setMeasured({ shape, pages: packed });
150
155
  // blocks 는 위 shape 로 대신 본다.
151
156
  // eslint-disable-next-line react-hooks/exhaustive-deps
152
- }, [shape, 자크기]);
157
+ // 장 규격이 바뀌면 글이 다시 흘러 블록 높이가 달라진다 — 자(세로) 높이는 그대로라 자크기만으로는 다시 재지 않아 장이 넘쳤다
158
+ }, [shape, 자크기, pageWidth, pageHeight, margin]);
153
159
  /* 번들러마다 SVG import 결과가 다르다 — Vite 는 문자열, Next 는 `{ src }` 객체다.
154
160
  문자열만 보면 Next 앱에서 fallback 경로(`/logo/...`)로 떨어져 404 가 난다
155
161
  (kscan 결과지의 바닥 로고가 실제로 깨졌다). 둘 다 받는다. */
@@ -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
  }
@@ -57,6 +57,11 @@
57
57
  transition: transform 260ms cubic-bezier(.22,.9,.3,1), opacity 180ms ease-out;
58
58
  opacity:0;
59
59
  }
60
+ /* 보이지 않는 동안(들어오기 전 두 프레임·사라지는 중)은 누름을 통과시킨다 — 투명한 알림이 아래 단추의 클릭을 삼켰다 */
61
+ .toast:not(.is-visible) .toast__inner{
62
+ pointer-events:none;
63
+ }
64
+
60
65
  .toast--top.is-visible{
61
66
  transform:translateX(-50%) translateY(0);
62
67
  opacity:1;
@@ -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,