@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.
- package/CHANGELOG.md +60 -0
- package/dist/components.cjs.js +185 -180
- package/dist/components.esm.css +2 -2
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +191 -186
- package/dist/ihabdevteam-core.d.ts +6 -2
- package/dist/packages/core/src/components/Badge.js +3 -1
- package/dist/packages/core/src/components/BulkBar.js +1 -1
- package/dist/packages/core/src/components/Button.js +2 -1
- package/dist/packages/core/src/components/Calendar.js +9 -6
- package/dist/packages/core/src/components/DatePicker.js +4 -3
- package/dist/packages/core/src/components/Dropdown.d.ts +3 -1
- package/dist/packages/core/src/components/Dropdown.js +26 -6
- package/dist/packages/core/src/components/Loader.js +6 -1
- package/dist/packages/core/src/components/Pagination.js +1 -1
- package/dist/packages/core/src/components/PanelRow.js +9 -5
- package/dist/packages/core/src/components/Popup/Popup.js +21 -4
- package/dist/packages/core/src/components/ProgressBar.js +1 -1
- package/dist/packages/core/src/components/Report/ReportDocument.css +11 -0
- package/dist/packages/core/src/components/Report/ReportDocument.js +11 -5
- package/dist/packages/core/src/components/SideNav.js +1 -1
- package/dist/packages/core/src/components/Slider.js +10 -1
- package/dist/packages/core/src/components/Table.js +2 -0
- package/dist/packages/core/src/components/Toast.css +5 -0
- package/dist/packages/core/src/components/Toolbar.js +3 -1
- package/dist/packages/core/src/components/Tooltip/Tooltip.js +6 -1
- package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +1 -1
- package/dist/packages/core/src/components/Word/wordTablePrint.js +27 -8
- package/dist/packages/core/src/components/useCalendarMonth.js +3 -2
- package/dist/packages/core/src/components/useToastLifecycle.d.ts +1 -0
- package/dist/packages/core/src/components/useToastLifecycle.js +8 -1
- package/dist/packages/core/src/hooks/useVisitHistory.d.ts +2 -0
- package/dist/packages/core/src/hooks/useVisitHistory.js +5 -6
- package/dist/packages/core/src/hooks/useWordTrainingFilter.js +7 -4
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +1 -1
- 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.
|
|
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,
|
|
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
|
-
:
|
|
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
|
-
|
|
16
|
-
|
|
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) =>
|
|
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
|
-
|
|
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 ===
|
|
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'
|
|
122
|
+
else if (e.key === 'Enter' || e.key === ' ') {
|
|
123
|
+
/* 열린 목록에서 Space 도 가리킨 항목을 고른다(네이티브 select·APG 와 같게) — 전에는 단추 기본 동작(keyup→click)이 목록을
|
|
124
|
+
닫기만 해 고른 것이 사라졌다(소리방향성 출력 장치). 가리킨 것이 없으면 닫기만 한다. */
|
|
108
125
|
e.preventDefault();
|
|
109
|
-
|
|
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 (
|
|
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",
|
|
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
|
-
|
|
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
|
|
105
|
+
chromeOf.set(node.dataset.chromeSection ?? '', 높이(node));
|
|
102
106
|
});
|
|
103
|
-
const budgetFor = (section) => ruler
|
|
104
|
-
const heights = Array.from(el.children).map((c) => c
|
|
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
|
-
|
|
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
|
-
|
|
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 && (
|
|
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
|
-
|
|
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,
|