@ihabdevteam/core 0.143.0 → 0.144.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +35 -0
- package/dist/components.cjs.js +185 -180
- 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 +4 -3
- 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/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/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.js +5 -0
- 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.144.0";
|
|
1390
1390
|
|
|
1391
1391
|
/** @public */
|
|
1392
1392
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -1830,7 +1830,7 @@ export declare interface DraftItem<T> {
|
|
|
1830
1830
|
export declare function draftValues<T>(draft: readonly DraftItem<T>[]): T[];
|
|
1831
1831
|
|
|
1832
1832
|
/** @public */
|
|
1833
|
-
export declare function Dropdown({ options, value, onChange, placeholder, disabled, block, size, error, className, ariaLabel }: DropdownProps): JSX.Element;
|
|
1833
|
+
export declare function Dropdown({ options, value, onChange, placeholder, disabled, block, size, error, className, ariaLabel, ariaDescribedBy }: DropdownProps): JSX.Element;
|
|
1834
1834
|
|
|
1835
1835
|
export declare type DropdownItem = {
|
|
1836
1836
|
options: {
|
|
@@ -1867,6 +1867,8 @@ export declare interface DropdownProps {
|
|
|
1867
1867
|
className?: string;
|
|
1868
1868
|
/** 화면에 보이는 라벨 없이 스크린리더용 이름을 지정할 때 사용 */
|
|
1869
1869
|
ariaLabel?: string;
|
|
1870
|
+
/** 트리거를 설명할 요소 id(들) — 오류 문구 등. 고른 값은 이것과 함께 설명으로 따로 읽힌다. */
|
|
1871
|
+
ariaDescribedBy?: string;
|
|
1870
1872
|
}
|
|
1871
1873
|
|
|
1872
1874
|
/**
|
|
@@ -6524,6 +6526,8 @@ export declare const toBase64UrlSafe: typeof toStorageName;
|
|
|
6524
6526
|
export declare function toChoseong(text: string): string;
|
|
6525
6527
|
|
|
6526
6528
|
/**
|
|
6529
|
+
* 오늘의 **KST** 연·월·일(두 자리 월·일). 방문 폼의 기본 날짜 — 소비자가 이 칸을 KST 벽시계로 읽어 저장하므로, 기기 달력이면
|
|
6530
|
+
* 해외 기기에서 손대지 않고 등록한 방문이 하루 전으로 저장됐다(해외 기기 공식 지원).
|
|
6527
6531
|
* @public
|
|
6528
6532
|
*/
|
|
6529
6533
|
export declare const today: () => {
|
|
@@ -49,7 +49,9 @@ export default function Badge(props) {
|
|
|
49
49
|
내게** 한다 — 그래야 이벤트의 target·currentTarget 도 실제와 같다. */
|
|
50
50
|
e.preventDefault();
|
|
51
51
|
e.currentTarget.click();
|
|
52
|
-
} : undefined, "aria-label": ariaHidden ? undefined : ariaLabel ?? defaultLabel, "aria-hidden": ariaHidden || undefined,
|
|
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)보다 부족하고 위쪽에 공간이
|
|
@@ -117,12 +118,12 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
|
|
|
117
118
|
// mousedown 에서는 기본 동작(포커스 이동 → 트리거 blur → 메뉴 닫힘)만 막아,
|
|
118
119
|
// click 이 도달하기 전에 메뉴가 사라지지 않게 한다.
|
|
119
120
|
onMouseDown: e => e.preventDefault(), onClick: () => select(opt), onMouseEnter: () => setActiveIdx(i), disabled: opt.disabled, children: [_jsx("span", { className: "dropdown__option-check", children: opt.value === value && (_jsx("svg", { viewBox: "0 0 14 14", fill: "none", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "2 7 5.5 10.5 12 3.5" }) })) }), opt.label] }, opt.value))) })) : null;
|
|
120
|
-
return (_jsxs("div", { ref: containerRef, className: cx('dropdown', `dropdown--s${size}`, open ? 'dropdown--open' : '', block ? 'dropdown--block' : '', error ? 'dropdown--error' : '', className), onKeyDown: handleKeyDown, children: [_jsxs("button", { ref: triggerRef, type: "button", className: "dropdown__trigger", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listId : undefined, "aria-activedescendant": open && activeIdx >= 0 ? `${listId}-option-${activeIdx}` : undefined, "aria-label": ariaLabel, "aria-invalid": error || undefined, disabled: disabled, onClick: toggle,
|
|
121
|
+
return (_jsxs("div", { ref: containerRef, className: cx('dropdown', `dropdown--s${size}`, open ? 'dropdown--open' : '', block ? 'dropdown--block' : '', error ? 'dropdown--error' : '', className), onKeyDown: handleKeyDown, children: [_jsxs("button", { ref: triggerRef, type: "button", className: "dropdown__trigger", "aria-haspopup": "listbox", "aria-expanded": open, "aria-controls": open ? listId : undefined, "aria-activedescendant": open && activeIdx >= 0 ? `${listId}-option-${activeIdx}` : undefined, "aria-label": ariaLabel, "aria-describedby": [ariaLabel && selected ? valueId : '', ariaDescribedBy ?? ''].filter(Boolean).join(' ') || undefined, "aria-invalid": error || undefined, disabled: disabled, onClick: toggle,
|
|
121
122
|
/* Tab 으로 포커스가 떠나면 닫는다 — 포커스는 트리거에 두고 aria-activedescendant 로 가리키는 목록이라, 떠난 뒤
|
|
122
123
|
열린 채 남으면 화살표·Enter 가 더는 메뉴로 가지 않는 죽은 메뉴가 된다. 항목·메뉴를 누르는 것은 위에서
|
|
123
124
|
mousedown 기본 동작을 막아 blur 가 나지 않는다. */
|
|
124
125
|
onBlur: e => { if (open && !menuRef.current?.contains(e.relatedTarget))
|
|
125
|
-
setOpen(false); }, children: [_jsx("span", { className: `dropdown__trigger-label body-2${!selected ? ' dropdown__trigger-label--placeholder' : ''}`, children: selected ? selected.label : resolvedPlaceholder }), _jsx("svg", { className: "dropdown__chevron", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "4 6 8 10 12 6" }) })] }), typeof document !== 'undefined' && createPortal(menu,
|
|
126
|
+
setOpen(false); }, children: [_jsx("span", { id: valueId, className: `dropdown__trigger-label body-2${!selected ? ' dropdown__trigger-label--placeholder' : ''}`, children: selected ? selected.label : resolvedPlaceholder }), _jsx("svg", { className: "dropdown__chevron", viewBox: "0 0 16 16", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: _jsx("polyline", { points: "4 6 8 10 12 6" }) })] }), typeof document !== 'undefined' && createPortal(menu,
|
|
126
127
|
// 코어 CSS를 .ihabdevteam-core-scope 로 스코프한 소비자(플레이그라운드 등)에서도
|
|
127
128
|
// body로 portal된 메뉴가 스타일을 잃지 않도록, 가까운 scope 안으로 portal
|
|
128
129
|
// createPortal의 target은 렌더링 시점에 동기적으로 결정되어야 하므로 ref 읽기를 effect로 옮길 수 없다
|
|
@@ -2,6 +2,11 @@
|
|
|
2
2
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
3
3
|
import { useTranslation } from 'react-i18next';
|
|
4
4
|
import './Loader.css';
|
|
5
|
+
/** 눈에는 안 보이고 낭독기에는 읽히는 글자 */
|
|
6
|
+
const 보이지않게 = {
|
|
7
|
+
position: 'absolute', width: 1, height: 1, margin: -1, padding: 0, overflow: 'hidden',
|
|
8
|
+
clipPath: 'inset(50%)', whiteSpace: 'nowrap', border: 0,
|
|
9
|
+
};
|
|
5
10
|
/** @public */
|
|
6
11
|
export default function Loader(props) {
|
|
7
12
|
const { t } = useTranslation();
|
|
@@ -13,5 +18,5 @@ export default function Loader(props) {
|
|
|
13
18
|
return (_jsx("div", { className: "loader-bar", role: "progressbar", "aria-valuemin": 0, "aria-valuemax": 100, "aria-valuenow": pct, "aria-label": name, children: _jsx("div", { className: "loader-bar__inner", style: { width: `${pct}%` } }) }));
|
|
14
19
|
}
|
|
15
20
|
const dim = typeof size === 'number' ? `${size}px` : size;
|
|
16
|
-
return (
|
|
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)
|
|
@@ -51,7 +51,7 @@ function SideNavButton(props) {
|
|
|
51
51
|
}
|
|
52
52
|
onClick?.(key, e.currentTarget, fromPointer);
|
|
53
53
|
}, [isDisabled, key, onClick]);
|
|
54
|
-
return (_jsxs("button", { type: "button", className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? nameWithCount(t, text, badgeCount) : undefined, style: style, "aria-disabled": isDisabled, children: [iconElement, _jsx("span", { className: `side-nav__label button-1 f`, style: labelStyle, children: text }), badgeCount > 0 && (_jsx(Badge, { type: "dot", position: "inline", number: Math.min(badgeCount, 99), size: 24, "aria-hidden": true })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", role: "img", "aria-label": t('loader.loading', '불러오는 중…') }))] }));
|
|
54
|
+
return (_jsxs("button", { type: "button", className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? nameWithCount(t, text, badgeCount) : undefined, "aria-current": active ? 'page' : undefined, style: style, "aria-disabled": isDisabled, children: [iconElement, _jsx("span", { className: `side-nav__label button-1 f`, style: labelStyle, children: text }), badgeCount > 0 && (_jsx(Badge, { type: "dot", position: "inline", number: Math.min(badgeCount, 99), size: 24, "aria-hidden": true })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", role: "img", "aria-label": t('loader.loading', '불러오는 중…') }))] }));
|
|
55
55
|
}
|
|
56
56
|
/** @public */
|
|
57
57
|
export default function SideNav(props) {
|
|
@@ -85,8 +85,17 @@ export default function Slider({ value, onChange, min = 0, max = 100, step = 1,
|
|
|
85
85
|
style.backgroundPosition = positions.join(', ');
|
|
86
86
|
style.backgroundRepeat = 'no-repeat';
|
|
87
87
|
}
|
|
88
|
+
/* 낭독기가 들을 값 글자 — 숫자만 넘기면 "1.25x"·"위"·"3 / 7" 이 "1.25"·"0"·"3" 으로 들렸다. 보이는 라벨과 같은 글자,
|
|
89
|
+
라벨이 없으면 끝값의 minLabel·maxLabel. 둘 다 아니면 숫자 그대로(undefined). */
|
|
90
|
+
const 값글자 = formatValue
|
|
91
|
+
? formatValue(clamped)
|
|
92
|
+
: label === 'percent' ? `${Math.round(max > min ? ((clamped - min) / (max - min)) * 100 : 0)}%`
|
|
93
|
+
: label === 'count' ? `${clamped} / ${max}`
|
|
94
|
+
: clamped === min && minLabel ? minLabel
|
|
95
|
+
: clamped === max && maxLabel ? maxLabel
|
|
96
|
+
: undefined;
|
|
88
97
|
// 루트가 아닐 때(label/showMinMax로 감싸질 때)는 className을 바깥 wrapper가 대신 가져간다
|
|
89
|
-
const input = (_jsx("input", { type: "range", className: cx('slider', `slider--${orientation}`, isStatic && 'slider--static', !label && !showMinMax && className), min: min, max: max, step: step, value: clamped, disabled: disabled, readOnly: isStatic, tabIndex: isStatic ? -1 : undefined, "aria-label": ariaLabel, "aria-orientation": orientation, onChange: handleChange, style: style }));
|
|
98
|
+
const input = (_jsx("input", { type: "range", className: cx('slider', `slider--${orientation}`, isStatic && 'slider--static', !label && !showMinMax && className), min: min, max: max, step: step, value: clamped, disabled: disabled, readOnly: isStatic, tabIndex: isStatic ? -1 : undefined, "aria-label": ariaLabel, "aria-valuetext": 값글자, "aria-orientation": orientation, onChange: handleChange, style: style }));
|
|
90
99
|
if (!label && !showMinMax)
|
|
91
100
|
return input;
|
|
92
101
|
// 트랙 양 끝 최소·최대값 텍스트 — 세로는 채움 방향(아래→위)에 맞춰 위=max/아래=min 순서로 배치
|
|
@@ -248,6 +248,8 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
|
|
|
248
248
|
if (dragRef.current) {
|
|
249
249
|
window.removeEventListener('pointermove', onResizeMove);
|
|
250
250
|
window.removeEventListener('pointerup', onResizeEnd);
|
|
251
|
+
// 나중에 더한 pointercancel 도 뗀다 — 남으면 태블릿 스크롤 손짓(pointercancel)에 떠난 표의 옛 폭이 저장소를 덮었다
|
|
252
|
+
window.removeEventListener('pointercancel', onResizeEnd);
|
|
251
253
|
document.body.style.cursor = '';
|
|
252
254
|
document.body.style.userSelect = '';
|
|
253
255
|
}
|
|
@@ -9,7 +9,9 @@ import SegmentedControl from './SegmentedControl';
|
|
|
9
9
|
import './Toolbar.css';
|
|
10
10
|
export default function Toolbar({ count, search, segment, filters, pageSize, pagination, actions, className, }) {
|
|
11
11
|
const { t } = useTranslation();
|
|
12
|
-
const countNode = count != null && (
|
|
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,
|
|
@@ -19,6 +19,9 @@ export function sanitizeFileName(name) {
|
|
|
19
19
|
}
|
|
20
20
|
/** 아직 되돌리지 못한 탭 제목 되돌리기(인쇄가 끝나기 전 다음 인쇄가 올 때) */
|
|
21
21
|
let 제목되돌리기 = null;
|
|
22
|
+
/* 진행 중인 인쇄 — 그림을 기다리는(최대 3초) 사이 다시 누르면 숨김 iframe 이 둘 생겨 인쇄 대화상자가 두 번 떴다(autoPrint 대기 중 '인쇄').
|
|
23
|
+
afterprint 가 안 오는 브라우저에서 영영 막히지 않게 10초가 지나면 새 인쇄를 받는다. */
|
|
24
|
+
let 인쇄중 = null;
|
|
22
25
|
/**
|
|
23
26
|
* 캡처한 콘텐츠 엘리먼트들을 화면 밖 숨김 iframe에서 인쇄한다. 전역 @media print(모든
|
|
24
27
|
* .word-table 노출)나 팝업 차단의 간섭 없이 넘긴 엘리먼트만 격리 인쇄한다. WordTableBlock의
|
|
@@ -28,6 +31,8 @@ let 제목되돌리기 = null;
|
|
|
28
31
|
export function printWordTableElements(elements, paperW, paperH, fileName = '', header = '') {
|
|
29
32
|
if (elements.length === 0)
|
|
30
33
|
return;
|
|
34
|
+
if (인쇄중 && 인쇄중.iframe.isConnected && Date.now() - 인쇄중.at < 10_000)
|
|
35
|
+
return;
|
|
31
36
|
const safeFileName = fileName ? sanitizeFileName(fileName) : '';
|
|
32
37
|
// "PDF로 저장" 기본 파일명은 iframe이 아니라 최상위 탭(document)의 title에서 온다.
|
|
33
38
|
/* 탭 제목은 **어느 쪽 afterprint 가 와도** 되돌린다 — 브라우저에 따라 iframe 인쇄의 print 이벤트가 그 프레임에만 가서,
|
|
@@ -55,10 +60,15 @@ export function printWordTableElements(elements, paperW, paperH, fileName = '',
|
|
|
55
60
|
iframe.remove();
|
|
56
61
|
return;
|
|
57
62
|
}
|
|
63
|
+
인쇄중 = { iframe, at: Date.now() };
|
|
58
64
|
doc.open();
|
|
59
65
|
doc.write(captureHtml(elements, paperW, paperH, true, safeFileName, header));
|
|
60
66
|
doc.close();
|
|
61
|
-
iframe.contentWindow?.addEventListener('afterprint', () => {
|
|
67
|
+
iframe.contentWindow?.addEventListener('afterprint', () => {
|
|
68
|
+
restore?.();
|
|
69
|
+
setTimeout(() => { iframe.remove(); if (인쇄중?.iframe === iframe)
|
|
70
|
+
인쇄중 = null; }, 0);
|
|
71
|
+
});
|
|
62
72
|
}
|
|
63
73
|
// 인쇄 시 PDF로 저장할 때 OS가 제안하는 기본 파일명은 문서 <title>에서 온다.
|
|
64
74
|
export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '', header = '') {
|
|
@@ -71,11 +81,10 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
|
|
|
71
81
|
return [];
|
|
72
82
|
}
|
|
73
83
|
}).join('\n');
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
78
|
-
const theme = 읽은테마 === 'dark' || 읽은테마 === 'auto' ? 읽은테마 : 'light';
|
|
84
|
+
/* 종이는 늘 흰색(#fff)이라 문서 테마는 **light 로 고정**한다. 화면의 dark 를 옮기면 낱말 색(--color-black)이 밝은 색이 되어
|
|
85
|
+
흰 종이에 흰 글자로 인쇄·새 창·내려받기가 나갔다. 속성이 없으면 "auto" 로 취급돼 OS 다크 모드에서 팝업만 어두워지던 것도
|
|
86
|
+
이것으로 막힌다. 화면 테마 값은 문서 글자에 끼우지 않는다(주입 걱정도 없다). */
|
|
87
|
+
const theme = 'light';
|
|
79
88
|
/* 종이 맨 위에 찍는 한 줄(누구의·언제·무슨 표). **격자만 인쇄되므로** 이것이 없으면
|
|
80
89
|
뽑아 둔 채점지가 며칠 뒤 누구 것인지 알 수 없는 종이가 된다. 쪽마다 찍는다 —
|
|
81
90
|
여러 장이 섞이면 첫 장에만 있는 이름은 소용이 없다. */
|
|
@@ -92,6 +101,11 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
|
|
|
92
101
|
복사할 때 지우면 인쇄·새 창·내려받기가 한꺼번에 맞는다. */
|
|
93
102
|
clone.querySelectorAll('.word-table-cell__score-slot:not(.word-table-cell__score-slot--on)')
|
|
94
103
|
.forEach(s => s.remove());
|
|
104
|
+
/* 화면에서 아직 불러오는 중인 그림은 스피너 + 인라인 opacity:0 이다 — 그대로 옮기면 인쇄 문서에서 그림이 다 받아져도
|
|
105
|
+
아무도 opacity 를 되돌리지 않아 **빈칸으로 인쇄**되고, 새 창·내려받은 HTML 에서는 스피너가 영영 돈다(말귀 내 단어표
|
|
106
|
+
'인쇄' 는 열자마자 100ms 뒤 인쇄해 처음 보는 표는 거의 늘 이랬다). */
|
|
107
|
+
clone.querySelectorAll('.word-table-cell__spinner').forEach(s => s.remove());
|
|
108
|
+
clone.querySelectorAll('img').forEach(i => i.style.removeProperty('opacity'));
|
|
95
109
|
return `<div class="paper-wrap">${headHtml}${clone.outerHTML}</div>`;
|
|
96
110
|
}).join('\n');
|
|
97
111
|
/* 이 꾸러미의 CSS 는 전부 `.ihabdevteam-core-scope` **아래**에 산다(styles.css 스코핑).
|
|
@@ -144,11 +158,16 @@ export function captureHtml(els, paperW, paperH, autoPrint = false, fileName = '
|
|
|
144
158
|
.word-table-block__content 클론) 전부 숨겨진 채 인쇄된다. 여기서 복원한다. */
|
|
145
159
|
body * { visibility: visible; }
|
|
146
160
|
html, body { background: #fff; overflow: visible; padding: 0; gap: 0; }
|
|
161
|
+
/* 종이 한 장 = 한 쪽. height:100% 는 앱에 html·body 높이가 없으면 auto 로 풀려 종이가 내용만큼 커지고(2장 → 4쪽),
|
|
162
|
+
세로·머리글이면 넘친 만큼 끝에 빈 쪽이 붙었다. 뷰포트 높이로 자르고 격자는 남은 칸만 쓴다. 마지막 종이는
|
|
163
|
+
body 의 마지막 자식이 아니라(닫기 단추·script) :last-child 가 안 맞았다 — :last-of-type. (Chromium PDF 로 7가지 확인) */
|
|
147
164
|
.paper-wrap {
|
|
148
|
-
box-shadow: none; border: none; transform: none !important; width: 100% !important; height:
|
|
165
|
+
box-shadow: none; border: none; transform: none !important; width: 100% !important; height: 100vh !important;
|
|
166
|
+
display: flex; flex-direction: column; overflow: hidden;
|
|
149
167
|
page-break-after: always; break-after: page;
|
|
150
168
|
}
|
|
151
|
-
.paper-wrap
|
|
169
|
+
.paper-wrap > .word-table-block__content { flex: 1 1 0; min-height: 0; height: auto; }
|
|
170
|
+
.paper-wrap:last-of-type { page-break-after: auto; break-after: auto; }
|
|
152
171
|
.word-table-cell__play-button { display: none; }
|
|
153
172
|
.new-window-close { display: none; }
|
|
154
173
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { getTodayKst } from '../utils/date';
|
|
3
4
|
/**
|
|
4
5
|
* "현재 보고 있는 연/월" 상태 + 이전/다음 달 이동. DatePicker의 달력 네비게이션에서 쓴다.
|
|
5
6
|
* @public
|
|
@@ -10,8 +11,8 @@ export function useCalendarMonth(value) {
|
|
|
10
11
|
const [y, m] = value.split('-');
|
|
11
12
|
return { y: +y, m: +m - 1 };
|
|
12
13
|
}
|
|
13
|
-
const
|
|
14
|
-
return { y:
|
|
14
|
+
const [y, m] = getTodayKst().split('-'); // 값이 없으면 KST 오늘의 달(해외 기기 공식 지원)
|
|
15
|
+
return { y: +y, m: +m - 1 };
|
|
15
16
|
};
|
|
16
17
|
const [viewY, setViewY] = useState(() => initYM().y);
|
|
17
18
|
const [viewM, setViewM] = useState(() => initYM().m);
|
|
@@ -99,6 +99,11 @@ export function useToastLifecycle({ message, position, duration, onClose }) {
|
|
|
99
99
|
clearTimeout(hideTimerRef.current);
|
|
100
100
|
hideTimerRef.current = null;
|
|
101
101
|
}
|
|
102
|
+
// 사라지는 중(hide 가 울리며 건 exit)도 — 남으면 떠난 뒤에 onClose 가 불렸다(아래 첫 등장 갈래의 정리와 같게)
|
|
103
|
+
if (exitTimerRef.current) {
|
|
104
|
+
clearTimeout(exitTimerRef.current);
|
|
105
|
+
exitTimerRef.current = null;
|
|
106
|
+
}
|
|
102
107
|
};
|
|
103
108
|
}
|
|
104
109
|
// 처음 등장할 때만 슬라이드인 애니메이션 실행
|