@ihabdevteam/core 0.132.0 → 0.133.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (71) hide show
  1. package/CHANGELOG.md +65 -0
  2. package/dist/components.cjs.js +178 -178
  3. package/dist/hooks.cjs.js +1 -1
  4. package/dist/i18n.cjs.js +1 -1
  5. package/dist/ihabdevteam-core.cjs.js +182 -182
  6. package/dist/ihabdevteam-core.d.ts +1 -12
  7. package/dist/packages/core/src/components/BarChart.js +2 -2
  8. package/dist/packages/core/src/components/BottomNav.js +3 -3
  9. package/dist/packages/core/src/components/BulkBar.js +1 -1
  10. package/dist/packages/core/src/components/ChoiceChips.js +1 -1
  11. package/dist/packages/core/src/components/CodeBox.js +1 -1
  12. package/dist/packages/core/src/components/DatePicker.js +16 -3
  13. package/dist/packages/core/src/components/Dropdown.js +13 -3
  14. package/dist/packages/core/src/components/FileUpload.js +8 -7
  15. package/dist/packages/core/src/components/FilterPanel.js +8 -5
  16. package/dist/packages/core/src/components/FilterSectionInfo.js +1 -1
  17. package/dist/packages/core/src/components/Header.js +1 -1
  18. package/dist/packages/core/src/components/HearingDeviceInfo.js +1 -1
  19. package/dist/packages/core/src/components/PageTitle.js +1 -1
  20. package/dist/packages/core/src/components/Pagination.js +1 -1
  21. package/dist/packages/core/src/components/PanelRow.js +3 -3
  22. package/dist/packages/core/src/components/PaymentPlan.js +1 -1
  23. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +2 -2
  24. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +2 -2
  25. package/dist/packages/core/src/components/Popup/Popup.js +15 -3
  26. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +1 -1
  27. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +1 -1
  28. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +3 -3
  29. package/dist/packages/core/src/components/SegmentedControl.js +1 -1
  30. package/dist/packages/core/src/components/SideNav.js +1 -1
  31. package/dist/packages/core/src/components/Table.js +2 -2
  32. package/dist/packages/core/src/components/Tabs.js +7 -2
  33. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +1 -1
  34. package/dist/packages/core/src/components/Tooltip/TextBoxTooltip.js +1 -1
  35. package/dist/packages/core/src/components/Tooltip/Tooltip.js +13 -5
  36. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +1 -1
  37. package/dist/packages/core/src/components/Training/TrainingBlock.js +1 -1
  38. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +1 -1
  39. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -1
  40. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +51 -23
  41. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +1 -1
  42. package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +1 -1
  43. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +26 -5
  44. package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +1 -1
  45. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +2 -2
  46. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +1 -1
  47. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
  48. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +1 -1
  49. package/dist/packages/core/src/components/Training/TrainingWordCard.js +11 -6
  50. package/dist/packages/core/src/components/Training/useAudioPreview.js +7 -3
  51. package/dist/packages/core/src/components/UpdateAlert.js +1 -1
  52. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +2 -2
  53. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -3
  54. package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
  55. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  56. package/dist/packages/core/src/components/WordTable.js +6 -6
  57. package/dist/packages/core/src/components/useToastLifecycle.js +8 -1
  58. package/dist/packages/core/src/hooks/useAnchoredMenu.js +10 -9
  59. package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +0 -11
  60. package/dist/packages/core/src/hooks/useDocumentTitle.js +16 -3
  61. package/dist/packages/core/src/hooks/useFocusTrap.js +6 -2
  62. package/dist/packages/core/src/utils/audioPeaks.js +13 -5
  63. package/dist/packages/core/src/utils/csv.js +3 -1
  64. package/dist/packages/core/src/utils/koreanSyllable.js +2 -1
  65. package/dist/packages/core/src/utils/textSort.d.ts +0 -10
  66. package/dist/packages/core/src/utils/textSort.js +7 -2
  67. package/dist/packages/core/src/utils/wordTrainingRuntime.js +28 -4
  68. package/dist/packages/core/src/version.d.ts +1 -1
  69. package/dist/packages/core/src/version.js +1 -1
  70. package/dist/utils.cjs.js +1 -1
  71. package/package.json +1 -1
@@ -1369,7 +1369,7 @@ export declare function copyText(text: string): Promise<boolean>;
1369
1369
  *
1370
1370
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1371
1371
  */
1372
- export declare const CORE_VERSION = "0.132.0";
1372
+ export declare const CORE_VERSION = "0.133.0";
1373
1373
 
1374
1374
  /** @public */
1375
1375
  declare const coreDefault: Record<string, unknown>;
@@ -7883,17 +7883,6 @@ export declare function useDeviceManagement(): {
7883
7883
  handleSelectFromList: (form: DeviceForm) => void;
7884
7884
  };
7885
7885
 
7886
- /**
7887
- * 현재 화면의 제목을 `document.title`에 반영한다 — `'<제목> · <앱 이름>'`.
7888
- *
7889
- * SPA는 라우트를 옮겨도 문서 제목이 그대로라, 브라우저 탭·히스토리·북마크로 위치를
7890
- * 구분할 수 없고 화면낭독기도 이동을 인지하지 못한다(WCAG 2.4.2).
7891
- * 언마운트하면 이전 제목으로 되돌린다.
7892
- *
7893
- * @param title 페이지 제목. 비어 있거나 문자열이 아니면 아무것도 하지 않는다.
7894
- *
7895
- * @public
7896
- */
7897
7886
  export declare function useDocumentTitle(title?: string | null): void;
7898
7887
 
7899
7888
  /**
@@ -97,7 +97,7 @@ export function BarChartControls({ control }) {
97
97
  주 보기에서 낭독기·툴팁이 실제 동작과 다른 말을 한다(말귀 검수 실측). */
98
98
  const prevLabel = period === 'week' ? t('calendar.prevWeek', '이전 주') : t('calendar.prevMonth', '이전 달');
99
99
  const nextLabel = period === 'week' ? t('calendar.nextWeek', '다음 주') : t('calendar.nextMonth', '다음 달');
100
- return (_jsxs("div", { className: "manager-section-controls", children: [_jsx(Button, { size: 32, type: "dot", icon: "chevron-left", ariaLabel: prevLabel, showTooltipInfo: prevLabel, disabled: activeIdx === activeOptions.length - 1, onClick: handlePrev }), _jsx(Dropdown, { value: range, onChange: setRange, options: activeOptions, size: 40, className: "month-dropdown", ariaLabel: t('calendar.rangeSelect', '기간 선택') }), _jsx(Button, { size: 32, type: "dot", icon: "chevron-right", ariaLabel: nextLabel, showTooltipInfo: nextLabel, disabled: activeIdx <= 0, onClick: handleNext }), _jsx(SegmentedControl, { ariaLabel: t('calendar.periodSelect', '기간 단위'), options: periodOptions, value: period, onChange: (k) => setPeriod(k), size: 32 })] }));
100
+ return (_jsxs("div", { className: "manager-section-controls", children: [_jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "chevron-left", ariaLabel: prevLabel, showTooltipInfo: prevLabel, disabled: activeIdx === activeOptions.length - 1, onClick: handlePrev }), _jsx(Dropdown, { value: range, onChange: setRange, options: activeOptions, size: 40, className: "month-dropdown", ariaLabel: t('calendar.rangeSelect', '기간 선택') }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "chevron-right", ariaLabel: nextLabel, showTooltipInfo: nextLabel, disabled: activeIdx <= 0, onClick: handleNext }), _jsx(SegmentedControl, { ariaLabel: t('calendar.periodSelect', '기간 단위'), options: periodOptions, value: period, onChange: (k) => setPeriod(k), size: 32 })] }));
101
101
  }
102
102
  /**
103
103
  * 한 주(월요일부터 7일)의 날마다 — 그 날의 자료와 미래 여부. 달을 넘는 날은 그 달
@@ -239,7 +239,7 @@ export default function BarChart({ size = 'medium', barWidth, titleKey, data, co
239
239
  // size='large' — 카드가 가로 폭을 거의 100% 차지(1열)
240
240
  const isLarge = size === 'large';
241
241
  const sectionClassName = isSmall ? 'manager-section--compact' : isLarge ? 'manager-section--full' : undefined;
242
- return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, title: t(titleKey), titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: isSmall ? undefined : _jsxs(_Fragment, { children: [!hideControls && _jsx(BarChartControls, { control: control }), onDetail && (_jsx("span", { className: "manager-bar-chart-detail-btn", children: _jsx(Button, { size: 32, type: "dot", icon: "list", ariaLabel: t('trainingStatus.detail', '상세보기'), onClick: onDetail }) }))] }), subRow: _jsxs(_Fragment, { children: [showTotal ? ((rate || avgMode || (!!totalLabelKey && !!totalFormat)) ? (
242
+ return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, title: t(titleKey), titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: isSmall ? undefined : _jsxs(_Fragment, { children: [!hideControls && _jsx(BarChartControls, { control: control }), onDetail && (_jsx("span", { className: "manager-bar-chart-detail-btn", children: _jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "list", ariaLabel: t('trainingStatus.detail', '상세보기'), onClick: onDetail }) }))] }), subRow: _jsxs(_Fragment, { children: [showTotal ? ((rate || avgMode || (!!totalLabelKey && !!totalFormat)) ? (
243
243
  // 진행률/평균 모드 또는 totalLabelKey+totalFormat 조합: 라벨(윗줄) + 값(아랫줄). size='small'은 라벨 생략
244
244
  _jsx(ChartTotal, { label: isSmall ? undefined : `${t(control.period === 'week' ? 'manager.chart.weeklyPrefix' : 'manager.chart.monthlyPrefix')} ${t(totalLabelKey ?? labelAKey)}`, children: totalFormat ? (
245
245
  // 숫자는 h0, 단위(시간/분/초 등)는 h5로 분리
@@ -97,8 +97,8 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
97
97
  }], [hiddenItems, moreItems, activeKey, onChange, handleCloseMore]);
98
98
  // "…" 단추 배지 — 메뉴 안에서만 보이는 개수(넘친 항목 + 유틸)를 닫힌 채로도 알린다.
99
99
  const moreBadge = [...hiddenItems, ...moreItems].reduce((sum, it) => sum + badgeOf(it.badgeCount), 0);
100
- return (_jsxs("nav", { className: "bottom-nav", "aria-label": t('nav.bottomNav', '하단 네비게이션'), children: [onAppModeChange && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__left", children: _jsx(Button, { size: 52, type: "dot", icon: modeIcon, ariaLabel: modeLabel, className: "bottom-nav-item", onClick: handleModeChange }) }), _jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" })] })), _jsx("div", { className: "bottom-nav__items", ref: itemsRef, children: visibleItems.map((it) => {
100
+ return (_jsxs("nav", { className: "bottom-nav", "aria-label": t('nav.bottomNav', '하단 네비게이션'), children: [onAppModeChange && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__left", children: _jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: modeIcon, ariaLabel: modeLabel, className: "bottom-nav-item", onClick: handleModeChange }) }), _jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" })] })), _jsx("div", { className: "bottom-nav__items", ref: itemsRef, children: visibleItems.map((it) => {
101
101
  const count = badgeOf(it.badgeCount);
102
- return (_jsxs("span", { className: "bottom-nav__slot", children: [_jsx(Button, { size: 52, type: "dot", icon: it.icon, ariaLabel: nameWithCount(it.label, count), selected: it.key === activeKey, disabled: it.state === 'disabled', className: "bottom-nav-item", onClick: () => it.key && onChange?.(it.key) }), count > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(count, 99), size: 24 }) }))] }, it.key));
103
- }) }), showMore && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" }), _jsx("div", { className: "bottom-nav__right", children: _jsxs("div", { ref: moreRef, className: "bottom-nav__slot", children: [_jsx(Button, { size: 52, type: "dot", icon: activeMore?.icon ?? 'ellipsis', ariaLabel: nameWithCount(activeMore?.label ?? t('nav.more', '더보기'), moreBadge), selected: !!activeMore, className: "bottom-nav-item", onClick: handleToggleMore, "aria-expanded": moreOpen, "aria-controls": moreOpen ? moreMenuId : undefined }), moreBadge > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(moreBadge, 99), size: 24 }) })), moreOpen && createPortal(_jsx("div", { id: moreMenuId, role: "group", "aria-label": t('nav.moreMenu', '더보기 메뉴'), className: "tooltip-content", style: menuStyle, ref: menuRef, children: _jsx(MenuTooltip, { columns: moreColumns, onClose: handleCloseMore }) }), document.body)] }) })] }))] }));
102
+ return (_jsxs("span", { className: "bottom-nav__slot", children: [_jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: it.icon, ariaLabel: nameWithCount(it.label, count), selected: it.key === activeKey, disabled: it.state === 'disabled', className: "bottom-nav-item", onClick: () => it.key && onChange?.(it.key) }), count > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(count, 99), size: 24 }) }))] }, it.key));
103
+ }) }), showMore && (_jsxs(_Fragment, { children: [_jsx("div", { className: "bottom-nav__divider", "aria-hidden": "true" }), _jsx("div", { className: "bottom-nav__right", children: _jsxs("div", { ref: moreRef, className: "bottom-nav__slot", children: [_jsx(Button, { htmlType: "button", size: 52, type: "dot", icon: activeMore?.icon ?? 'ellipsis', ariaLabel: nameWithCount(activeMore?.label ?? t('nav.more', '더보기'), moreBadge), selected: !!activeMore, className: "bottom-nav-item", onClick: handleToggleMore, "aria-expanded": moreOpen, "aria-controls": moreOpen ? moreMenuId : undefined }), moreBadge > 0 && (_jsx("span", { "aria-hidden": "true", children: _jsx(Badge, { type: "dot", position: "top-right", number: Math.min(moreBadge, 99), size: 24 }) })), moreOpen && createPortal(_jsx("div", { id: moreMenuId, role: "group", "aria-label": t('nav.moreMenu', '더보기 메뉴'), className: "tooltip-content", style: menuStyle, ref: menuRef, children: _jsx(MenuTooltip, { columns: moreColumns, onClose: handleCloseMore }) }), document.body)] }) })] }))] }));
104
104
  }
@@ -43,5 +43,5 @@ export default function BulkBar({ count, actions, onClear, clearLabel: clearLabe
43
43
  단추가 Tab 순서와 낭독기에 남아, 보이지 않는 '선택 해제'·'삭제' 에 포커스가 갔다.
44
44
  inert 로 포커스·낭독기에서 함께 뺀다(보이는 모양은 CSS 그대로). aria-hidden 은
45
45
  inert 를 모르는 오래된 브라우저를 위한 덧댐이다. */
46
- 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, { size: 40, label: clearLabel, onClick: onClear }), actions.map((a, i) => (_jsx(Button, { size: 40, variant: a.variant ?? 'default', icon: a.icon, label: a.label, onClick: a.onClick }, i)))] })] }));
46
+ 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)))] })] }));
47
47
  }
@@ -4,5 +4,5 @@ import './ChoiceChips.css';
4
4
  /** @public */
5
5
  export default function ChoiceChips({ subs = [], selected, onSelect, size = 'medium' }) {
6
6
  const buttonSize = size === 'small' ? 32 : 40;
7
- return (_jsx("div", { className: `choice-chips choice-chips--${size}`, children: _jsx("div", { className: "choice-chips__list", children: subs.map((s) => (_jsx(Button, { size: buttonSize, type: "bar", label: s.label, selected: selected === s.key, className: "choice-chips__button", onClick: () => onSelect?.(s.key) }, s.key))) }) }));
7
+ return (_jsx("div", { className: `choice-chips choice-chips--${size}`, children: _jsx("div", { className: "choice-chips__list", children: subs.map((s) => (_jsx(Button, { htmlType: "button", size: buttonSize, type: "bar", label: s.label, selected: selected === s.key, className: "choice-chips__button", onClick: () => onSelect?.(s.key) }, s.key))) }) }));
8
8
  }
@@ -128,5 +128,5 @@ export default function CodeBox({ children, language = 'bash', title = '', showC
128
128
  };
129
129
  const tokens = tokenize(codeText, language);
130
130
  const tokenTypo = size === 'small' ? 'caption-1' : 'body-3';
131
- return (_jsxs("div", { className: cx('code-box', size === 'small' ? 'code-box--small' : '', className), children: [(title || showCopy) && (_jsxs("div", { className: "code-box__header", role: "toolbar", "aria-label": t('common.codeBlock', '코드 블록'), children: [_jsx("div", { className: "code-box__title caption-1", children: title }), showCopy && (_jsx(Button, { className: "code-box__copy", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: copied ? 'check' : 'copy', onClick: handleCopy, ariaLabel: copied ? t('common.copied', '복사됨') : t('common.copyCode', '코드 복사') }))] })), _jsx("pre", { className: "code-box__inner", "data-lang": language, children: _jsx("code", { children: tokens.map((t, i) => (_jsx("span", { className: `code-token ${t.type} ${tokenTypo}`, children: t.content }, i))) }) })] }));
131
+ return (_jsxs("div", { className: cx('code-box', size === 'small' ? 'code-box--small' : '', className), children: [(title || showCopy) && (_jsxs("div", { className: "code-box__header", role: "toolbar", "aria-label": t('common.codeBlock', '코드 블록'), children: [_jsx("div", { className: "code-box__title caption-1", children: title }), showCopy && (_jsx(Button, { htmlType: "button", className: "code-box__copy", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: copied ? 'check' : 'copy', onClick: handleCopy, ariaLabel: copied ? t('common.copied', '복사됨') : t('common.copyCode', '코드 복사') }))] })), _jsx("pre", { className: "code-box__inner", "data-lang": language, children: _jsx("code", { children: tokens.map((t, i) => (_jsx("span", { className: `code-token ${t.type} ${tokenTypo}`, children: t.content }, i))) }) })] }));
132
132
  }
@@ -95,8 +95,10 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
95
95
  if (!open)
96
96
  return;
97
97
  const 판 = menuRef.current;
98
- const 갈곳 = 판?.querySelector('.date-picker__day--selected') ??
99
- 판?.querySelector('.date-picker__day--today') ??
98
+ const 갈곳 = 판?.querySelector('.date-picker__day--selected:not(:disabled)') ??
99
+ 판?.querySelector('.date-picker__day--today:not(:disabled)') ??
100
+ /* 오늘이 min/max 밖이라 꺼져 있으면 focus() 가 실패해 트리거에 남았다 — 켜진 첫 날로 */
101
+ 판?.querySelector('.date-picker__day:not(:disabled)') ??
100
102
  판?.querySelector(FOCUSABLE_SELECTOR) ??
101
103
  판;
102
104
  갈곳?.focus?.();
@@ -199,6 +201,17 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
199
201
  return true;
200
202
  return false;
201
203
  };
204
+ /* Tab 을 받을 날 — 활성일이 min/max 밖이라 꺼져 있으면 켜진 첫 날로. 꺼진 단추는 Tab 을 못 받아
205
+ 격자에 Tab 자리가 **하나도 없어** 키보드로 달력에 들어갈 수 없었다(값 없음 + min=내일). */
206
+ const 탭자리 = (() => {
207
+ const 끝 = daysInMonth(viewY, viewM);
208
+ if (활성일 >= 1 && 활성일 <= 끝 && !isDisabledDay(활성일))
209
+ return 활성일;
210
+ for (let d = 1; d <= 끝; d++)
211
+ if (!isDisabledDay(d))
212
+ return d;
213
+ return -1;
214
+ })();
202
215
  const cells = [];
203
216
  for (let i = 0; i < firstDay(viewY, viewM); i++)
204
217
  cells.push(null);
@@ -218,7 +231,7 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
218
231
  const isToday = iso === toISO(today.getFullYear(), today.getMonth(), today.getDate());
219
232
  const dis = isDisabledDay(d);
220
233
  const col = (i % 7);
221
- 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));
234
+ 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));
222
235
  })] })] }), document.body) : null;
223
236
  return (_jsxs("div", { className: cx('date-picker', `date-picker--s${size}`, open && 'date-picker--open', className), children: [_jsxs("button", { ref: triggerRef, type: "button", className: "date-picker__trigger body-2", onClick: () => !disabled && toggle(), disabled: disabled, "aria-haspopup": "true", "aria-expanded": open, children: [_jsx("span", { className: value ? 'date-picker__value' : 'date-picker__placeholder', children: value ? formatDisplay(value) : placeholder }), _jsx(Calendar, { size: iconSize, className: "date-picker__icon", strokeWidth: 1.5 })] }), dropdown] }));
224
237
  }
@@ -54,7 +54,10 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
54
54
  }
55
55
  });
56
56
  }
57
- }, [open, options, value, menuRef]);
57
+ /* **열릴 때만** 돈다. options·value 까지 걸어 두면, 열린 동안 부모가 다시 그려지며 options 를 인라인 배열로
58
+ 넘길 때마다 화살표로 옮겨 둔 가리킴이 선택값 자리로 되돌아가고 스크롤도 튀었다. */
59
+ // eslint-disable-next-line react-hooks/exhaustive-deps
60
+ }, [open]);
58
61
  /* eslint-enable react-hooks/set-state-in-effect */
59
62
  const toggle = useCallback(() => { if (!disabled)
60
63
  rawToggle(); }, [disabled, rawToggle]);
@@ -102,13 +105,20 @@ export default function Dropdown({ options, value, onChange, placeholder, disabl
102
105
  select(options[activeIdx]);
103
106
  }
104
107
  }, [disabled, open, options, activeIdx, select, setOpen]);
105
- const menu = open ? (_jsx("div", { ref: menuRef, id: listId, className: "dropdown__menu", role: "listbox", style: menuStyle, onMouseLeave: handleMenuLeave, children: options.map((opt, i) => (_jsxs("button", { id: `${listId}-option-${i}`, type: "button", role: "option", "aria-selected": opt.value === value, className: cx('dropdown__option body-2', opt.value === value ? 'dropdown__option--active' : '', i === activeIdx ? 'dropdown__option--active' : '', opt.disabled ? 'dropdown__option--disabled' : ''),
108
+ const menu = open ? (_jsx("div", { ref: menuRef, id: listId, className: "dropdown__menu", role: "listbox", style: menuStyle, onMouseLeave: handleMenuLeave,
109
+ /* 메뉴 빈 곳·스크롤 막대를 눌러도 트리거에서 포커스가 안 떠나게 — 아래 트리거 onBlur 가 메뉴를 닫지 않도록 */
110
+ onMouseDown: e => e.preventDefault(), children: options.map((opt, i) => (_jsxs("button", { id: `${listId}-option-${i}`, type: "button", role: "option", "aria-selected": opt.value === value, className: cx('dropdown__option body-2', opt.value === value ? 'dropdown__option--active' : '', i === activeIdx ? 'dropdown__option--active' : '', opt.disabled ? 'dropdown__option--disabled' : ''),
106
111
  // 선택은 click 에서 한다. onMouseDown 만 두면 마우스로는 눌리지만 스크린리더가
107
112
  // 항목을 활성화할 때는 click 만 발생해 **아무 일도 일어나지 않는다**.
108
113
  // mousedown 에서는 기본 동작(포커스 이동 → 트리거 blur → 메뉴 닫힘)만 막아,
109
114
  // click 이 도달하기 전에 메뉴가 사라지지 않게 한다.
110
115
  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;
111
- 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, 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,
116
+ 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,
117
+ /* Tab 으로 포커스가 떠나면 닫는다 — 포커스는 트리거에 두고 aria-activedescendant 로 가리키는 목록이라, 떠난 뒤
118
+ 열린 채 남으면 화살표·Enter 가 더는 메뉴로 가지 않는 죽은 메뉴가 된다. 항목·메뉴를 누르는 것은 위에서
119
+ mousedown 기본 동작을 막아 blur 가 나지 않는다. */
120
+ onBlur: e => { if (open && !menuRef.current?.contains(e.relatedTarget))
121
+ 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,
112
122
  // 코어 CSS를 .ihabdevteam-core-scope 로 스코프한 소비자(플레이그라운드 등)에서도
113
123
  // body로 portal된 메뉴가 스타일을 잃지 않도록, 가까운 scope 안으로 portal
114
124
  // createPortal의 target은 렌더링 시점에 동기적으로 결정되어야 하므로 ref 읽기를 effect로 옮길 수 없다
@@ -92,16 +92,17 @@ export default function FileUpload({ onFilesSelected, accept, multiple = false,
92
92
  inputRef.current?.click();
93
93
  };
94
94
  const handleRemove = (index) => {
95
- if (onRemove) {
96
- onRemove(index);
97
- return;
98
- }
95
+ /* onRemove 를 주면 알림은 그쪽 한 번으로 끝낸다(onFilesSelected 로 이중으로 알리지 않는다). 다만 비제어 모드에서는
96
+ **보이는 목록도 줄인다** — 전에는 줄이기 전에 돌아가 버려 뺀 파일이 화면에 남았다. 제어 모드(files 를 부모가
97
+ 쥠)는 부모가 onRemove 에서 줄인다. */
98
+ onRemove?.(index);
99
99
  if (controlledFiles === undefined) {
100
- /* 뺐다는 것도 알린다. 목록만 줄이고 가만히 있으면 **호출부 손에는 그 파일이
101
- 그대로 남아** 사용자가 뺀 것이 올라간다. */
102
100
  const next = files.filter((_, i) => i !== index);
103
101
  setInternalFiles(next);
104
- onFilesSelected?.(next);
102
+ /* onRemove 가 없으면 뺐다는 것을 이 길로 알린다. 목록만 줄이고 가만히 있으면 **호출부 손에는 그 파일이
103
+ 그대로 남아** 사용자가 뺀 것이 올라간다. */
104
+ if (!onRemove)
105
+ onFilesSelected?.(next);
105
106
  }
106
107
  };
107
108
  return (_jsxs("div", { className: `file-upload ${className}`.trim(), children: [_jsxs("div", { className: cx('file-upload__dropzone', isDragging && 'file-upload__dropzone--dragging', disabled && 'file-upload__dropzone--disabled'), role: "button", tabIndex: disabled ? -1 : 0, "aria-disabled": disabled, onClick: handleBrowseClick, onKeyDown: e => { if (e.key === 'Enter' || e.key === ' ') {
@@ -25,7 +25,7 @@ export default function FilterPanel({ title, headerAction, collapsible = false,
25
25
  onCollapsedChange?.(next);
26
26
  };
27
27
  const showHeader = title || headerAction || collapsible;
28
- return (_jsxs("div", { className: cx('filter-panel', collapsible ? 'filter-panel--collapsible' : '', collapsed ? 'filter-panel--collapsed' : '', className), children: [showHeader && (_jsxs("div", { className: "filter-panel__header", children: [_jsxs("div", { className: "filter-panel__title-row", children: [collapsible && (_jsx(Button, { size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-down' : 'chevron-up',
28
+ return (_jsxs("div", { className: cx('filter-panel', collapsible ? 'filter-panel--collapsible' : '', collapsed ? 'filter-panel--collapsed' : '', className), children: [showHeader && (_jsxs("div", { className: "filter-panel__header", children: [_jsxs("div", { className: "filter-panel__title-row", children: [collapsible && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-down' : 'chevron-up',
29
29
  /* Button은 aria-label을 자기 ariaLabel prop으로 덮어쓴다({...rest} 뒤에
30
30
  aria-label={ariaLabelString}을 다시 쓴다) — kebab으로 넘기면 undefined가
31
31
  되어 이름이 통째로 사라진다. 저장소의 다른 호출부는 모두 ariaLabel을 쓴다.
@@ -55,7 +55,7 @@ export default function FilterPanel({ title, headerAction, collapsible = false,
55
55
  const descriptionTooltip = typeof item !== 'string' ? item.descriptionTooltip : undefined;
56
56
  const shape = typeof item !== 'string' ? item.shape : undefined;
57
57
  const isSelected = Array.isArray(sel) ? sel.includes(value) : sel === value;
58
- return (_jsx(Button, { size: "keyboard", label: itemLabels ?? displayLabel ?? value, type: section.itemType ?? (itemLabels ? 'bar' : value.length === 1 ? 'dot' : undefined), selected: isSelected, disabled: disabled, shape: shape, showTooltipInfo: !!tooltip, tooltipInfoText: tooltip, tooltipDescription: descriptionTooltip, onClick: () => onSelect?.(value, section.label) }, value));
58
+ return (_jsx(Button, { htmlType: "button", size: "keyboard", label: itemLabels ?? displayLabel ?? value, type: section.itemType ?? (itemLabels ? 'bar' : value.length === 1 ? 'dot' : undefined), selected: isSelected, disabled: disabled, shape: shape, showTooltipInfo: !!tooltip, tooltipInfoText: tooltip, tooltipDescription: descriptionTooltip, onClick: () => onSelect?.(value, section.label) }, value));
59
59
  };
60
60
  return (_jsxs(Fragment, { children: [section.groupLabel && (_jsxs("div", { className: "filter-panel__group-label-row", children: [_jsx("div", { className: "filter-panel__group-label body-2", children: section.groupLabel }), section.groupAction] })), _jsxs("div", { className: cx('filter-panel__section', section.layout === 'row' ? 'filter-panel__section--row' : '', section.block ? 'filter-panel__section--block' : ''), children: [_jsxs("div", { className: "filter-panel__section-label-row", children: [_jsx("span", { className: "filter-panel__section-label body-3", children: section.displayLabel ?? section.label }), section.labelAction ?? (section.labelInfo ? _jsx(FilterSectionInfo, { ...section.labelInfo }) : null)] }), section.type === 'note' ? (_jsx("div", { className: "filter-panel__note body-3", children: section.content })) : section.type === 'input' ? (_jsx(TextField, { className: "filter-panel__textfield", placeholder: section.placeholder, ariaLabel: section.displayLabel ?? section.label, value: selected[section.label] ?? '', onChange: (value) => onSelect?.(value, section.label), error: section.error })) : (!section.multiple && section.display === 'segmented') ? (_jsx(SegmentedControl
61
61
  /* 바로 위 TextField 처럼 섹션 이름을 그대로 쓴다. 안 주면 라디오그룹이
@@ -73,7 +73,10 @@ export default function FilterPanel({ title, headerAction, collapsible = false,
73
73
  const disabled = typeof item !== 'string' ? item.disabled : undefined;
74
74
  return { key: value, label: displayLabel ?? value, disabled };
75
75
  }),
76
- ], value: isAllSelected && section.allOption ? FILTER_ALL_VALUE : selected[section.label], onChange: (value) => onSelect?.(value, section.label), size: 32 })) : (!section.multiple && section.display === 'dropdown') ? (_jsx(Dropdown, { className: "filter-panel__dropdown", size: 40, options: [
76
+ ],
77
+ /* 아무것도 안 골랐으면 빈 값 — undefined 를 넘기면 SegmentedControl 의 기본값(첫 보기)이 걸려, 상태는
78
+ 비었는데 첫 보기가 골라진 것처럼 보였다(필터는 걸리지 않았는데 걸린 듯). */
79
+ value: isAllSelected && section.allOption ? FILTER_ALL_VALUE : selected[section.label] ?? '', onChange: (value) => onSelect?.(value, section.label), size: 32 })) : (!section.multiple && section.display === 'dropdown') ? (_jsx(Dropdown, { className: "filter-panel__dropdown", size: 40, options: [
77
80
  ...(section.allOption ? [{ value: FILTER_ALL_VALUE, label: section.allLabel ?? t('common.all', '전체') }] : []),
78
81
  ...items.map(item => {
79
82
  const value = typeof item === 'string' ? item : item.value;
@@ -81,8 +84,8 @@ export default function FilterPanel({ title, headerAction, collapsible = false,
81
84
  const disabled = typeof item !== 'string' ? item.disabled : undefined;
82
85
  return { value, label: displayLabel ?? value, disabled };
83
86
  }),
84
- ], value: isAllSelected && section.allOption ? FILTER_ALL_VALUE : selected[section.label], onChange: (value) => onSelect?.(value, section.label) })) : (_jsxs("div", { className: section.groups ? 'filter-panel__item-groups' : 'filter-panel__items', children: [section.allOption && !section.groups && (_jsx(Button, { size: "keyboard", label: section.allLabel ?? t('common.all', '전체'), selected: isAllSelected, disabled: section.allOptionDisabled, onClick: () => onSelect?.(FILTER_ALL_VALUE, section.label) })), section.groups
85
- ? section.groups.map((group, gi) => (_jsxs("div", { className: "filter-panel__item-group", children: [gi === 0 && section.allOption && (_jsx(Button, { size: "keyboard", label: section.allLabel ?? t('common.all', '전체'), selected: isAllSelected, disabled: section.allOptionDisabled, onClick: () => onSelect?.(FILTER_ALL_VALUE, section.label) })), group
87
+ ], value: isAllSelected && section.allOption ? FILTER_ALL_VALUE : selected[section.label], onChange: (value) => onSelect?.(value, section.label) })) : (_jsxs("div", { className: section.groups ? 'filter-panel__item-groups' : 'filter-panel__items', children: [section.allOption && !section.groups && (_jsx(Button, { htmlType: "button", size: "keyboard", label: section.allLabel ?? t('common.all', '전체'), selected: isAllSelected, disabled: section.allOptionDisabled, onClick: () => onSelect?.(FILTER_ALL_VALUE, section.label) })), section.groups
88
+ ? section.groups.map((group, gi) => (_jsxs("div", { className: "filter-panel__item-group", children: [gi === 0 && section.allOption && (_jsx(Button, { htmlType: "button", size: "keyboard", label: section.allLabel ?? t('common.all', '전체'), selected: isAllSelected, disabled: section.allOptionDisabled, onClick: () => onSelect?.(FILTER_ALL_VALUE, section.label) })), group
86
89
  .filter((item) => item != null)
87
90
  .map(renderItemButton)] }, gi)))
88
91
  : items.map(renderItemButton)] }))] })] }, section.label));
@@ -11,5 +11,5 @@ import { useHoverAnchor } from '../hooks/useHoverAnchor';
11
11
  export default function FilterSectionInfo({ title, items }) {
12
12
  const { t } = useTranslation();
13
13
  const { anchorRect, openAt, close: handleClose } = useHoverAnchor();
14
- return (_jsxs(_Fragment, { children: [_jsx(Button, { size: 24, type: "dot", icon: "info", variant: "transparent-gray", useBorder: false, selected: !!anchorRect, ariaLabel: t('common.info', '설명'), onPointerEnter: (e) => openAt(e.currentTarget), onPointerLeave: handleClose, onFocus: (e) => openAt(e.currentTarget), onBlur: handleClose }), anchorRect && (_jsx(DescriptionTooltip, { title: title, items: items, showIndex: items.length > 1, anchorRect: anchorRect, onClose: handleClose }))] }));
14
+ return (_jsxs(_Fragment, { children: [_jsx(Button, { htmlType: "button", size: 24, type: "dot", icon: "info", variant: "transparent-gray", useBorder: false, selected: !!anchorRect, ariaLabel: t('common.info', '설명'), onPointerEnter: (e) => openAt(e.currentTarget), onPointerLeave: handleClose, onFocus: (e) => openAt(e.currentTarget), onBlur: handleClose }), anchorRect && (_jsx(DescriptionTooltip, { title: title, items: items, showIndex: items.length > 1, anchorRect: anchorRect, onClose: handleClose }))] }));
15
15
  }
@@ -112,7 +112,7 @@ export default function Header({ activeKey, onChange, items, malgwiItems: _malgw
112
112
  onClick: () => { it.onClick?.(); closeUtilMenu(); },
113
113
  })),
114
114
  }], [bottomMoreItems, activeKey, closeUtilMenu]);
115
- return (_jsxs("aside", { className: `header${appMode === 'manager' ? ' header--manager' : ''}`, children: [_jsxs("div", { className: "header__bi", children: [_jsx(BrandIdentity, { appMode: appMode }), _jsxs("div", { className: "header__menu-button", ref: utilTriggerRef, children: [_jsx(Button, { size: 48, type: "dot", shape: "pill", variant: "default", icon: 'menu', ariaLabel: t('header.menuOpen', '메뉴 열기'), onClick: toggleUtilMenu, "aria-expanded": utilOpen, "aria-controls": utilOpen ? utilMenuId : undefined }), utilOpen && createPortal(_jsx("div", { id: utilMenuId, role: "group", "aria-label": t('header.utilMenu', '유틸리티 메뉴'), className: "tooltip-content", style: utilMenuStyle, ref: utilMenuRef, children: _jsx(MenuTooltip, { columns: utilColumns, onClose: closeUtilMenu }) }), document.body)] })] }), _jsx(SideNav, { items: items, activeKey: activeKey ?? undefined, onChange: onChange, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp, appMode: appMode, onAppModeChange: onAppModeChange }), isMobile && _jsx(BottomNav, { items: bottomItems, activeKey: activeKey ?? undefined, onChange: handleBottomChange, appMode: appMode, onAppModeChange: onAppModeChange })] }));
115
+ return (_jsxs("aside", { className: `header${appMode === 'manager' ? ' header--manager' : ''}`, children: [_jsxs("div", { className: "header__bi", children: [_jsx(BrandIdentity, { appMode: appMode }), _jsxs("div", { className: "header__menu-button", ref: utilTriggerRef, children: [_jsx(Button, { htmlType: "button", size: 48, type: "dot", shape: "pill", variant: "default", icon: 'menu', ariaLabel: t('header.menuOpen', '메뉴 열기'), onClick: toggleUtilMenu, "aria-expanded": utilOpen, "aria-controls": utilOpen ? utilMenuId : undefined }), utilOpen && createPortal(_jsx("div", { id: utilMenuId, role: "group", "aria-label": t('header.utilMenu', '유틸리티 메뉴'), className: "tooltip-content", style: utilMenuStyle, ref: utilMenuRef, children: _jsx(MenuTooltip, { columns: utilColumns, onClose: closeUtilMenu }) }), document.body)] })] }), _jsx(SideNav, { items: items, activeKey: activeKey ?? undefined, onChange: onChange, unreadCount: unreadCount, onDebug: onDebug, onNotification: onNotification, onAccount: onAccount, onSetting: onSetting, onHelp: onHelp, appMode: appMode, onAppModeChange: onAppModeChange }), isMobile && _jsx(BottomNav, { items: bottomItems, activeKey: activeKey ?? undefined, onChange: handleBottomChange, appMode: appMode, onAppModeChange: onAppModeChange })] }));
116
116
  }
117
117
  /**
118
118
  * `Header`의 alias입니다. 프로젝트 내 `Header`라는 이름이 이미 사용 중일 때 충돌 없이 임포트할 수 있습니다.
@@ -8,6 +8,6 @@ export default function HearingDeviceInfo({ devices, onAddDevice, onOpenList, on
8
8
  return (_jsxs("div", { className: "hearing-device-info", children: [_jsx("div", { className: "hearing-device-info__title body-3", children: t('customers.device.title', '청각재활기기') }), _jsx("div", { className: "hearing-device-info__row", children: ['좌', '우'].map(side => {
9
9
  const d = devices[side];
10
10
  const sideLabel = side === '좌' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
11
- return (_jsxs("div", { className: "hearing-device-info__side", children: [_jsx("span", { className: "hearing-device-info__side-label body-3", children: sideLabel }), d ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "hearing-device-info__device", children: [_jsx("span", { className: "hearing-device-info__device-name body-3", children: d.brand }), _jsxs("span", { className: "hearing-device-info__device-detail", children: [d.device, d.fitType ? ` · ${d.fitType}` : ''] }), d.productName && _jsx("span", { className: "hearing-device-info__device-product", children: d.productName }), _jsxs("span", { className: "hearing-device-info__device-date", children: [d.wornYear, ".", d.wornMonth, ".", d.wornDay] })] }), _jsxs("div", { className: "hearing-device-info__actions", children: [_jsx(Button, { size: 40, type: "dot", icon: "plus", onClick: () => onAddDevice(side), showTooltipInfo: t('device.addNew', '새 기기 추가'), ariaLabel: t('device.addNew', '새 기기 추가') }), _jsx(Button, { size: 40, type: "dot", icon: "list", onClick: () => onOpenList(side), showTooltipInfo: t('device.selectFromList', '목록에서 선택'), ariaLabel: t('device.selectFromList', '목록에서 선택') }), _jsx(Button, { size: 40, type: "dot", icon: "x", variant: "danger", onClick: () => onRemoveDevice(side), showTooltipInfo: t('device.removeDevice', '착용 해제'), ariaLabel: t('device.removeDevice', '착용 해제') })] })] })) : (_jsxs("div", { className: "hearing-device-info__actions", children: [_jsx(Button, { size: 40, type: "dot", icon: "plus", onClick: () => onAddDevice(side), showTooltipInfo: t('device.addNew', '새 기기 추가'), ariaLabel: t('device.addNew', '새 기기 추가') }), _jsx(Button, { size: 40, type: "dot", icon: "list", onClick: () => onOpenList(side), showTooltipInfo: t('device.selectFromList', '목록에서 선택'), ariaLabel: t('device.selectFromList', '목록에서 선택') })] }))] }, side));
11
+ return (_jsxs("div", { className: "hearing-device-info__side", children: [_jsx("span", { className: "hearing-device-info__side-label body-3", children: sideLabel }), d ? (_jsxs(_Fragment, { children: [_jsxs("div", { className: "hearing-device-info__device", children: [_jsx("span", { className: "hearing-device-info__device-name body-3", children: d.brand }), _jsxs("span", { className: "hearing-device-info__device-detail", children: [d.device, d.fitType ? ` · ${d.fitType}` : ''] }), d.productName && _jsx("span", { className: "hearing-device-info__device-product", children: d.productName }), _jsxs("span", { className: "hearing-device-info__device-date", children: [d.wornYear, ".", d.wornMonth, ".", d.wornDay] })] }), _jsxs("div", { className: "hearing-device-info__actions", children: [_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "plus", onClick: () => onAddDevice(side), showTooltipInfo: t('device.addNew', '새 기기 추가'), ariaLabel: t('device.addNew', '새 기기 추가') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "list", onClick: () => onOpenList(side), showTooltipInfo: t('device.selectFromList', '목록에서 선택'), ariaLabel: t('device.selectFromList', '목록에서 선택') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "danger", onClick: () => onRemoveDevice(side), showTooltipInfo: t('device.removeDevice', '착용 해제'), ariaLabel: t('device.removeDevice', '착용 해제') })] })] })) : (_jsxs("div", { className: "hearing-device-info__actions", children: [_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "plus", onClick: () => onAddDevice(side), showTooltipInfo: t('device.addNew', '새 기기 추가'), ariaLabel: t('device.addNew', '새 기기 추가') }), _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "list", onClick: () => onOpenList(side), showTooltipInfo: t('device.selectFromList', '목록에서 선택'), ariaLabel: t('device.selectFromList', '목록에서 선택') })] }))] }, side));
12
12
  }) })] }));
13
13
  }
@@ -13,7 +13,7 @@ export default function PageTitle({ title, sub, rightAction, titleAction, badge,
13
13
  return (_jsxs("div", { className: wrap ? 'page-title page-title--wrap' : 'page-title', children: [_jsxs("div", { className: "page-title__main", children: [_jsxs("div", { className: "page-title__title-row", children: [onBack && (
14
14
  /* 글자 없이 화살표만 둔다 — 제목 옆에서 '뒤로' 라고 적으면 제목과 경쟁한다.
15
15
  그만큼 과녁을 크게(48) 잡는다. 이름은 낭독기용으로 따로 준다. */
16
- _jsx(Button, { className: "page-title__back", size: 48, type: "dot", icon: "chevron-left", iconSize: "large", useBorder: false,
16
+ _jsx(Button, { htmlType: "button", className: "page-title__back", size: 48, type: "dot", icon: "chevron-left", iconSize: "large", useBorder: false,
17
17
  /* 역할 이름을 쓴다 — `--color-black` 은 다크에서 이름과 실제 색이 뒤집힌다. */
18
18
  iconColor: "var(--color-text-primary)", ariaLabel: 뒤로이름, showTooltipInfo: 뒤로이름, onClick: onBack })), _jsx("h1", { className: "page-title__title h1", children: title }), badge && _jsx("div", { className: "page-title__badge", children: _jsx(Badge, { label: badge, size: "small", variant: badgeVariant, fill: "outline" }) }), titleAction ? _jsx("div", { className: "page-title__title-action", children: titleAction }) : null] }), sub && _jsx("p", { className: "page-title__sub body-2", children: sub })] }), rightAction ? _jsx("div", { className: "page-title__action", children: rightAction }) : null] }));
19
19
  }
@@ -16,5 +16,5 @@ export default function Pagination({ page, totalPages, onChange, prevLabel, next
16
16
  const resolvedNext = nextLabel ?? t('common.nextPage', '다음 페이지');
17
17
  const handlePrev = () => onChange(Math.max(1, page - 1));
18
18
  const handleNext = () => onChange(Math.min(totalPages, page + 1));
19
- return (_jsxs("div", { className: cx('pagination', className), children: [_jsx(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: [page, " / ", totalPages] }), _jsx(Button, { size: 32, type: "dot", shape: "rounded", icon: "chevron-right", ariaLabel: resolvedNext, disabled: page >= totalPages, onClick: handleNext })] }));
19
+ 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: [page, " / ", totalPages] }), _jsx(Button, { htmlType: "button", size: 32, type: "dot", shape: "rounded", icon: "chevron-right", ariaLabel: resolvedNext, disabled: page >= totalPages, onClick: handleNext })] }));
20
20
  }
@@ -89,10 +89,10 @@ export function PanelRow(props) {
89
89
  else if (variant === 'button') {
90
90
  const p = props;
91
91
  if (p.buttons?.length) {
92
- right = (_jsx("div", { className: "panel-row__button-group", children: p.buttons.map((btn, i) => (_jsx(Button, { size: btn.btnSize ?? 40, variant: btn.btnVariant ?? 'default', disabled: btn.disabled, onClick: btn.onClick, children: btn.label }, i))) }));
92
+ right = (_jsx("div", { className: "panel-row__button-group", children: p.buttons.map((btn, i) => (_jsx(Button, { htmlType: "button", size: btn.btnSize ?? 40, variant: btn.btnVariant ?? 'default', disabled: btn.disabled, onClick: btn.onClick, children: btn.label }, i))) }));
93
93
  }
94
94
  else {
95
- right = (_jsx(Button, { size: p.btnSize ?? 40, variant: p.btnVariant ?? 'default', disabled: p.disabled, onClick: p.onClick, children: p.buttonLabel }));
95
+ right = (_jsx(Button, { htmlType: "button", size: p.btnSize ?? 40, variant: p.btnVariant ?? 'default', disabled: p.disabled, onClick: p.onClick, children: p.buttonLabel }));
96
96
  }
97
97
  }
98
98
  else if (variant === 'text') {
@@ -143,6 +143,6 @@ export function PanelRowDevice({ devices, onRelease, emptyLabel }) {
143
143
  /* 표의 줄 끝 단추와 같은 모양·같은 말 — 하는 일도 같다.
144
144
  이력에서 지우는 것이 아니라 '지금 끼고 있음' 만 내린다
145
145
  (잠시 안 씀·수리 보냄 같은 경우가 실제로 있다). */
146
- _jsx(Button, { size: 32, type: "dot", icon: "x", ariaLabel: t('customers.device.unwear', '착용 해제'), showTooltipInfo: t('customers.device.unwear', '착용 해제'), onClick: () => onRelease(side) }))] }), d ? (_jsxs("div", { className: "panel-row__device-info", children: [_jsxs("span", { className: "panel-row__device-kind", children: [d.device && _jsx(Badge, { size: "small", variant: deviceKindVariant(d.device), fill: "soft", label: d.device }), 메타(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));
146
+ _jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "x", ariaLabel: t('customers.device.unwear', '착용 해제'), showTooltipInfo: t('customers.device.unwear', '착용 해제'), onClick: () => onRelease(side) }))] }), d ? (_jsxs("div", { className: "panel-row__device-info", children: [_jsxs("span", { className: "panel-row__device-kind", children: [d.device && _jsx(Badge, { size: "small", variant: deviceKindVariant(d.device), fill: "soft", label: d.device }), 메타(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));
147
147
  }) }));
148
148
  }
@@ -79,7 +79,7 @@ export default function PaymentPlan(props) {
79
79
  null) : isInquiry ? (
80
80
  /* 문의는 실제로 할 일이 있는 행동이라 버튼을 둔다 — 페이지 하단 문의 버튼과
81
81
  같은 공용 Button(variant=main)으로 맞춘다. */
82
- _jsx(Button, { className: "payment-plan__contact-button", label: t('payment.plan.contact', { defaultValue: '문의하기' }), variant: "main", size: 56, onClick: () => plan.tiers?.[0] && onSelect?.({ ...plan.tiers[0], productId: plan.id, productTitle: plan.title }) })) : (_jsx(_Fragment, { children: _jsx("div", { className: "payment-plan__pricing-list", children: (plan.tiers || []).map((tier) => {
82
+ _jsx(Button, { htmlType: "button", className: "payment-plan__contact-button", label: t('payment.plan.contact', { defaultValue: '문의하기' }), variant: "main", size: 56, onClick: () => plan.tiers?.[0] && onSelect?.({ ...plan.tiers[0], productId: plan.id, productTitle: plan.title }) })) : (_jsx(_Fragment, { children: _jsx("div", { className: "payment-plan__pricing-list", children: (plan.tiers || []).map((tier) => {
83
83
  /* 라벨이 없을 때의 대체말도 **화면에 뜨는 글**이다 — 사전에서 가져온다.
84
84
  아래 정규식의 '오픈 특가' 는 다르다. 그건 앱이 넘겨준 **데이터를
85
85
  읽는** 것이라 한국어 그대로여야 한다. */
@@ -40,7 +40,7 @@ export default function DeviceSideTable({ rows, onSelect, onDelete, onWear, onRe
40
40
  }, [rows, 정렬]);
41
41
  /* 개수 보기·쪽넘김은 방문 표와 같은 것을 쓴다 — 같은 자리에서 같은 뜻으로 읽히게 */
42
42
  const { pageRows, pageSizeProps, paginationProps } = useTablePaging(sortedRows, 정렬);
43
- return (_jsxs("div", { className: "dlp-side-table", children: [_jsx(Toolbar, { count: { value: rows.length, unit: t('customers.device.unit', '개') }, pageSize: pageSizeProps, pagination: paginationProps, actions: onAdd && (_jsx(Button, { size: 40, type: "bar", variant: "main", icon: "plus", label: t('customers.device.addButton', '기기 추가'), onClick: onAdd })) }), _jsx(Table, { resizable: true, resizeMode: "fit", resizeStorageKey: "customer-device-history-v2", fields: [
43
+ return (_jsxs("div", { className: "dlp-side-table", children: [_jsx(Toolbar, { count: { value: rows.length, unit: t('customers.device.unit', '개') }, pageSize: pageSizeProps, pagination: paginationProps, actions: onAdd && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", variant: "main", icon: "plus", label: t('customers.device.addButton', '기기 추가'), onClick: onAdd })) }), _jsx(Table, { resizable: true, resizeMode: "fit", resizeStorageKey: "customer-device-history-v2", fields: [
44
44
  /* 맨 왼쪽 — 지금 끼고 있는 것만 좌/우를 적는다. 이 열만 훑으면 "오늘 이 사람이
45
45
  무엇을 쓰고 있나" 가 한눈에 보인다(나머지 줄은 비어 있어 눈에 안 걸린다). */
46
46
  /* 체크박스 칸은 폭을 늘릴 것이 없다 — 손잡이를 주면 잡히는데 아무 일도
@@ -83,7 +83,7 @@ export default function DeviceSideTable({ rows, onSelect, onDelete, onWear, onRe
83
83
  못 하는 일을 내밀지 않는다. 눌림은 줄로 새지 않게 막는다(줄을 누르면
84
84
  수정 창이 열린다). */
85
85
  ...(onDelete ? [{
86
- content: (_jsx("span", { className: "dlp-row-actions", children: _jsx(Button, { size: 28, type: "bar", variant: "transparent-gray", label: t('common.delete', '삭제'), onClick: e => { e?.stopPropagation(); onDelete(row); } }) })),
86
+ content: (_jsx("span", { className: "dlp-row-actions", children: _jsx(Button, { htmlType: "button", size: 28, type: "bar", variant: "transparent-gray", label: t('common.delete', '삭제'), onClick: e => { e?.stopPropagation(); onDelete(row); } }) })),
87
87
  }] : []),
88
88
  ], onRowClick: ({ row }) => onSelect(row),
89
89
  /* Table 이 이미 TableRow variant="empty" 로 감싼다 — div 를 한 겹 더 씌우면
@@ -38,7 +38,7 @@ const DeleteButton = ({ onClick, ariaLabel }) => {
38
38
  /* 화면낭독기가 읽는 이름이라 사전에서 온다 — 여기 박아 두면 영어로 쓰는 사람에게
39
39
  이 버튼만 '삭제' 로 읽힌다. */
40
40
  const { t } = useTranslation();
41
- return (_jsx(Button, { size: 32, type: "dot", icon: "x", variant: "transparent-lightGray", className: "load-table-popup__delete", onClick: onClick, ariaLabel: ariaLabel ?? t('common.delete', '삭제'), showTooltipInfo: true }));
41
+ return (_jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "x", variant: "transparent-lightGray", className: "load-table-popup__delete", onClick: onClick, ariaLabel: ariaLabel ?? t('common.delete', '삭제'), showTooltipInfo: true }));
42
42
  };
43
43
  export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, onDelete: onDeleteProp }) {
44
44
  const { t } = useTranslation();
@@ -81,7 +81,7 @@ export default function LoadTablePopup({ onLoad, onCancel, tables: tablesProp, o
81
81
  animateThen(() => onLoad(table));
82
82
  }, [tables, selected, onLoad]);
83
83
  const headerActions = useMemo(() => tables.length > 0 ? (_jsxs(_Fragment, { children: [_jsx(Dropdown, { options: VIEW_OPTIONS, value: viewMode, onChange: handleSetViewMode }), _jsx(Dropdown, { options: SORT_OPTIONS, value: sortKey, onChange: handleSetSortKey })] })) : undefined, [tables.length, viewMode, sortKey, handleSetViewMode, handleSetSortKey, VIEW_OPTIONS, SORT_OPTIONS]);
84
- return (_jsx(Popup, { className: "popup--load-table", title: t('wordTable.loadTable.title', '저장된 단어 표 불러오기'), headerActions: headerActions, onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs("div", { className: "load-table-popup__footer-actions", children: [_jsx(Button, { size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation, width: "fit-content" }), _jsx(Button, { size: 48, type: "bar", variant: "main", label: t('wordTable.loadTable.loadButton', '불러오기'), width: "fit-content", disabled: !selected, onClick: () => handleLoadSelected(animateThen) })] })), children: _jsx("div", { className: "load-table-popup__body", children: tables.length === 0 ? (_jsx("p", { className: "load-table-popup__empty body-2", children: t('wordTable.loadTable.empty', '저장된 단어 표가 없습니다.') })) : (_jsxs(_Fragment, { children: [viewMode === 'grid' && (_jsx("ul", { className: "load-table-popup__grid", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__card${selected === table.id ? ' load-table-popup__card--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') }), _jsx(TableThumbnail, { table: table }), _jsxs("div", { className: "load-table-popup__card-info", children: [_jsx("span", { className: "load-table-popup__card-name body-2", children: table.name }), _jsxs("span", { className: "load-table-popup__card-meta caption-1", children: [formatDate(table.savedAt), " \u00B7 ", table.items.length, "\uAC1C"] })] })] }, table.id))) })), viewMode === 'list' && (_jsx("ul", { className: "load-table-popup__list", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__row${selected === table.id ? ' load-table-popup__row--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx("span", { className: "load-table-popup__row-check", children: selected === table.id && (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", children: _jsx("polyline", { points: "4 13 9 18 20 6", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" }) })) }), _jsxs("div", { className: "load-table-popup__row-main", children: [_jsx("span", { className: "load-table-popup__row-name body-2", children: table.name }), _jsx("div", { className: "load-table-popup__row-words", children: table.items.map((item, i) => (_jsx("span", { className: "load-table-popup__row-word caption-1", children: item.words.length >= 2
84
+ return (_jsx(Popup, { className: "popup--load-table", title: t('wordTable.loadTable.title', '저장된 단어 표 불러오기'), headerActions: headerActions, onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs("div", { className: "load-table-popup__footer-actions", children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation, width: "fit-content" }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: t('wordTable.loadTable.loadButton', '불러오기'), width: "fit-content", disabled: !selected, onClick: () => handleLoadSelected(animateThen) })] })), children: _jsx("div", { className: "load-table-popup__body", children: tables.length === 0 ? (_jsx("p", { className: "load-table-popup__empty body-2", children: t('wordTable.loadTable.empty', '저장된 단어 표가 없습니다.') })) : (_jsxs(_Fragment, { children: [viewMode === 'grid' && (_jsx("ul", { className: "load-table-popup__grid", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__card${selected === table.id ? ' load-table-popup__card--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') }), _jsx(TableThumbnail, { table: table }), _jsxs("div", { className: "load-table-popup__card-info", children: [_jsx("span", { className: "load-table-popup__card-name body-2", children: table.name }), _jsxs("span", { className: "load-table-popup__card-meta caption-1", children: [formatDate(table.savedAt), " \u00B7 ", table.items.length, "\uAC1C"] })] })] }, table.id))) })), viewMode === 'list' && (_jsx("ul", { className: "load-table-popup__list", children: sorted.map(table => (_jsxs("li", { className: `load-table-popup__row${selected === table.id ? ' load-table-popup__row--selected' : ''}`, onClick: () => setSelected(table.id), ...pressable(() => setSelected(table.id)), children: [_jsx("span", { className: "load-table-popup__row-check", children: selected === table.id && (_jsx("svg", { width: "14", height: "14", viewBox: "0 0 24 24", fill: "none", children: _jsx("polyline", { points: "4 13 9 18 20 6", stroke: "currentColor", strokeWidth: "2.5", strokeLinecap: "round", strokeLinejoin: "round" }) })) }), _jsxs("div", { className: "load-table-popup__row-main", children: [_jsx("span", { className: "load-table-popup__row-name body-2", children: table.name }), _jsx("div", { className: "load-table-popup__row-words", children: table.items.map((item, i) => (_jsx("span", { className: "load-table-popup__row-word caption-1", children: item.words.length >= 2
85
85
  ? `${item.words[0].label} · ${item.words[1].label}`
86
86
  : item.words[0]?.label ?? item.id }, i))) })] }), _jsxs("span", { className: "load-table-popup__row-count caption-1", children: [table.items.length, "\uAC1C"] }), _jsx("span", { className: "load-table-popup__row-date caption-1", children: formatDate(table.savedAt) }), _jsx(DeleteButton, { onClick: e => handleDelete(table.id, e), ariaLabel: t('wordTable.loadTable.deleteAriaLabel', '삭제') })] }, table.id))) }))] })) }) }));
87
87
  }
@@ -93,7 +93,14 @@ export default function Popup(props) {
93
93
  const first = panel.querySelector(FOCUSABLE_SELECTOR);
94
94
  (first ?? panel).focus();
95
95
  }
96
- return () => { previouslyFocused?.focus?.(); };
96
+ return () => {
97
+ /* 포커스가 **아직 이 창 안이거나 갈 곳을 잃었을 때만** 되돌린다. 닫으면서 다음 창을 여는 흐름
98
+ (미리보기 → 삭제 확인)에서는 닫힘 애니메이션(260ms) 뒤에 여기가 돌 때 포커스가 이미 새 창에 있는데,
99
+ 전에는 그것을 뒤 화면의 여는 단추로 빼앗아 Tab 가둠이 풀리고 Enter 가 뒤 화면을 눌렀다. */
100
+ const active = document.activeElement;
101
+ if (!active || active === document.body || (panel && panel.contains(active)))
102
+ previouslyFocused?.focus?.();
103
+ };
97
104
  }, [inline]);
98
105
  /* Tab 순환을 패널 안으로 가둔다 — 네 곳에 있던 같은 열두 줄을 훅 한 벌로 모았다.
99
106
  inline 으로 쓸 때는 다이얼로그가 아니라 걸지 않는다(예전과 같다). */
@@ -147,9 +154,14 @@ export default function Popup(props) {
147
154
  if (pressed && e.target === e.currentTarget)
148
155
  closeWithAnimation();
149
156
  };
150
- const el = (_jsx("div", { className: `popup ${inline ? 'popup--inline' : ''} ${closing ? 'popup--closing' : ''} ${className}`, onPointerDown: backdropEnabled ? onBackdropPointerDown : undefined, onClick: backdropEnabled ? onBackdropClick : undefined, children: _jsxs("div", { ref: panelRef, className: `popup-panel${size ? ` popup-panel--${size}` : ''}`,
157
+ const el = (_jsx("div", { className: `popup ${inline ? 'popup--inline' : ''} ${closing ? 'popup--closing' : ''} ${className}`, onPointerDown: backdropEnabled ? onBackdropPointerDown : undefined, onClick: backdropEnabled ? onBackdropClick : undefined,
158
+ /* 바탕을 눌러도 포커스는 판 안에 둔다 — 닫히지 않는 바탕(closeOnBackdrop={false}·closeDisabled·beforeClose 가
159
+ 거절)을 누르면 포커스가 body 로 떨어져, 이어서 누른 Tab 이 뒤 화면으로 나갔다(가둠은 판 안의 키만 듣는다).
160
+ mousedown 의 기본 동작(포커스 옮김)만 막고 click 은 그대로 온다. */
161
+ onMouseDown: inline ? undefined : (e) => { if (e.target === e.currentTarget)
162
+ e.preventDefault(); }, children: _jsxs("div", { ref: panelRef, className: `popup-panel${size ? ` popup-panel--${size}` : ''}`,
151
163
  /* max-height(CSS)가 height 보다 이기므로 화면보다 크게 줘도 화면에 맞춰진다 */
152
- style: height != null ? { height } : undefined, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && title ? titleId : undefined, "aria-label": !inline && !title && ariaLabel ? ariaLabel : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') })] })) : (title || headerActions || onClose) ? (_jsxs("div", { className: `popup-header${subtitle ? ' popup-header--with-subtitle' : ''}`, children: [_jsxs("div", { className: "popup-header-main", children: [_jsx("span", { id: titleId, className: "popup-title popup-title--left h2", children: title }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), _jsxs("div", { className: "popup-header-actions", children: [headerActions, onClose && (_jsx(Button, { size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') }))] })] })) : null, _jsx("div", { className: "popup-content", ref: contentRef, tabIndex: contentScrollable ? 0 : undefined, children: _jsx("div", { className: "popup-content__inner", children: typeof children === 'function' ? children({ animateThen, closeWithAnimation }) : children }) }), buttons?.length ? (_jsx("div", { className: "popup-footer popup-footer--buttons", children: buttons.map((btn, i) => (_jsx(Button, { size: 48, variant: btn.variant ?? 'default', label: btn.label, disabled: btn.disabled, onClick: () => btn.onClick?.({ animateThen, closeWithAnimation }) }, i))) })) : footer ? (_jsx("div", { className: `popup-footer${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
164
+ style: height != null ? { height } : undefined, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && title ? titleId : undefined, "aria-label": !inline && !title && ariaLabel ? ariaLabel : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') })] })) : (title || headerActions || onClose) ? (_jsxs("div", { className: `popup-header${subtitle ? ' popup-header--with-subtitle' : ''}`, children: [_jsxs("div", { className: "popup-header-main", children: [_jsx("span", { id: titleId, className: "popup-title popup-title--left h2", children: title }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), _jsxs("div", { className: "popup-header-actions", children: [headerActions, onClose && (_jsx(Button, { htmlType: "button", size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') }))] })] })) : null, _jsx("div", { className: "popup-content", ref: contentRef, tabIndex: contentScrollable ? 0 : undefined, children: _jsx("div", { className: "popup-content__inner", children: typeof children === 'function' ? children({ animateThen, closeWithAnimation }) : children }) }), buttons?.length ? (_jsx("div", { className: "popup-footer popup-footer--buttons", children: buttons.map((btn, i) => (_jsx(Button, { htmlType: "button", size: 48, variant: btn.variant ?? 'default', label: btn.label, disabled: btn.disabled, onClick: () => btn.onClick?.({ animateThen, closeWithAnimation }) }, i))) })) : footer ? (_jsx("div", { className: `popup-footer${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
153
165
  if (inline || typeof document === 'undefined')
154
166
  return el;
155
167
  return createPortal(el, document.body);
@@ -46,7 +46,7 @@ export default function SaveTablePopup({ title, saveLabel, defaultName = '', def
46
46
  if (name.trim() && !isDuplicateName)
47
47
  onSave(name.trim(), description.trim() || undefined);
48
48
  }, [name, description, isDuplicateName, onSave]);
49
- return (_jsx(Popup, { className: "popup--save-table", title: title ?? t('wordTable.save.title', '단어표 저장하기'), onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx(Button, { size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation }), _jsx(Button, { size: 48, type: "bar", variant: "main", label: saveLabel ?? t('common.save', '저장'), disabled: !name.trim() || isDuplicateName, onClick: () => handleSave(animateThen) })] })), children: _jsxs("div", { className: "save-table-popup__body", children: [preview && (_jsx("div", { ref: containerRef, className: "save-table-popup__preview-wrap", style: { height: previewState.fixedH }, children: _jsx("div", { className: "save-table-popup__preview", style: {
49
+ return (_jsx(Popup, { className: "popup--save-table", title: title ?? t('wordTable.save.title', '단어표 저장하기'), onClose: onCancel, footer: ({ animateThen, closeWithAnimation }) => (_jsxs(_Fragment, { children: [_jsx(Button, { htmlType: "button", size: 48, type: "bar", label: t('common.cancel', '취소'), onClick: closeWithAnimation }), _jsx(Button, { htmlType: "button", size: 48, type: "bar", variant: "main", label: saveLabel ?? t('common.save', '저장'), disabled: !name.trim() || isDuplicateName, onClick: () => handleSave(animateThen) })] })), children: _jsxs("div", { className: "save-table-popup__body", children: [preview && (_jsx("div", { ref: containerRef, className: "save-table-popup__preview-wrap", style: { height: previewState.fixedH }, children: _jsx("div", { className: "save-table-popup__preview", style: {
50
50
  width: previewW, height: previewH,
51
51
  transform: `scale(${previewState.scale})`,
52
52
  top: previewState.offsetY,
@@ -101,5 +101,5 @@ export default function TrainingResultPopup(props) {
101
101
  /today · /training · /my-word-training · /my-homework). 화면에 보이는 그
102
102
  제목을 그대로 접근명으로 쓴다. 새 문구를 만들면 눈으로 보는 이름과 읽어
103
103
  주는 이름이 갈라진다. */
104
- ariaLabel: t('training.result.title', '훈련을 모두 마쳤습니다.'), className: "popup--training-result", footerClassName: "trp-result__foot", footer: ({ animateThen, closeWithAnimation }) => (_jsxs("div", { className: "trp-actions", children: [_jsx(Button, { size: 56, type: "bar", label: t('training.result.replay', '다시하기'), onClick: () => animateThen(onReplay) }), _jsx(Button, { size: 56, type: "bar", label: t('training.result.done', '완료'), variant: "main", onClick: closeWithAnimation })] })), children: () => (_jsxs("div", { className: "trp-result is-on", children: [_jsxs("header", { className: "trp-result__head", children: [_jsx("p", { className: "trp-result__eyebrow caption-1", children: t('training.result.eyebrow', '훈련 완료') }), _jsx("h1", { className: "trp-result__title h2", children: t('training.result.title', '훈련을 모두 마쳤습니다.') }), subtitle && _jsx("p", { className: "trp-result__subtitle body-3", children: subtitle }), _jsx("div", { className: "trp-score", children: _jsxs("div", { className: "trp-score__figure", "aria-label": t('training.result.scoreAria', { correct, total }), children: [_jsx("span", { className: "trp-score__big", children: correct }), _jsx("span", { className: "trp-score__sep", children: "/" }), _jsx("span", { className: "trp-score__total", children: total })] }) })] }), rows.length > 0 && (_jsx("section", { className: "trp-result__body", children: _jsxs("div", { className: `result-table${hasAnswerCols ? ' result-table--answer' : ''}`, role: "table", "aria-label": t('training.result.tableAriaLabel', '문항별 판정'), children: [_jsxs("div", { className: "result-table__head caption-1", role: "row", children: [_jsx("span", { role: "columnheader", children: t('common.col.problem', '문제') }), hasAnswerCols && _jsx("span", { role: "columnheader", children: t('training.result.colAnswerSelected', '정답 · 선택') }), _jsx("span", { className: "col-right", role: "columnheader", children: t('training.result.colJudge', '판정') })] }), rows.map((r, i) => (_jsxs("div", { className: "result-table__row", role: "row", children: [_jsxs("div", { className: "trp-row__problem", children: [_jsx("span", { className: "trp-row__no caption-1", children: String(i + 1).padStart(2, '0') }), _jsx("div", { className: "trp-row__q body-2", children: r.problem })] }), hasAnswerCols && (_jsxs("div", { className: "trp-row__answers", children: [r.answer !== null && (_jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: "trp-row__answer-label answer-correct caption-1", children: t('training.result.colAnswer', '정답') }), _jsx("span", { className: "trp-row__answer-value body-2", children: r.answer })] })), _jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: `trp-row__answer-label answer-selected caption-1 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: t('training.result.colSelected', '선택') }), _jsx("span", { className: `trp-row__answer-value body-2 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: r.selectedText ?? '—' })] })] })), _jsx("div", { className: "trp-row__judge", children: _jsx(Badge, { variant: r.variant, label: r.label, fill: "soft", minWidth: 80 }) })] }, i)))] }) }))] })) }));
104
+ ariaLabel: t('training.result.title', '훈련을 모두 마쳤습니다.'), className: "popup--training-result", footerClassName: "trp-result__foot", footer: ({ animateThen, closeWithAnimation }) => (_jsxs("div", { className: "trp-actions", children: [_jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.replay', '다시하기'), onClick: () => animateThen(onReplay) }), _jsx(Button, { htmlType: "button", size: 56, type: "bar", label: t('training.result.done', '완료'), variant: "main", onClick: closeWithAnimation })] })), children: () => (_jsxs("div", { className: "trp-result is-on", children: [_jsxs("header", { className: "trp-result__head", children: [_jsx("p", { className: "trp-result__eyebrow caption-1", children: t('training.result.eyebrow', '훈련 완료') }), _jsx("h1", { className: "trp-result__title h2", children: t('training.result.title', '훈련을 모두 마쳤습니다.') }), subtitle && _jsx("p", { className: "trp-result__subtitle body-3", children: subtitle }), _jsx("div", { className: "trp-score", children: _jsxs("div", { className: "trp-score__figure", "aria-label": t('training.result.scoreAria', { correct, total }), children: [_jsx("span", { className: "trp-score__big", children: correct }), _jsx("span", { className: "trp-score__sep", children: "/" }), _jsx("span", { className: "trp-score__total", children: total })] }) })] }), rows.length > 0 && (_jsx("section", { className: "trp-result__body", children: _jsxs("div", { className: `result-table${hasAnswerCols ? ' result-table--answer' : ''}`, role: "table", "aria-label": t('training.result.tableAriaLabel', '문항별 판정'), children: [_jsxs("div", { className: "result-table__head caption-1", role: "row", children: [_jsx("span", { role: "columnheader", children: t('common.col.problem', '문제') }), hasAnswerCols && _jsx("span", { role: "columnheader", children: t('training.result.colAnswerSelected', '정답 · 선택') }), _jsx("span", { className: "col-right", role: "columnheader", children: t('training.result.colJudge', '판정') })] }), rows.map((r, i) => (_jsxs("div", { className: "result-table__row", role: "row", children: [_jsxs("div", { className: "trp-row__problem", children: [_jsx("span", { className: "trp-row__no caption-1", children: String(i + 1).padStart(2, '0') }), _jsx("div", { className: "trp-row__q body-2", children: r.problem })] }), hasAnswerCols && (_jsxs("div", { className: "trp-row__answers", children: [r.answer !== null && (_jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: "trp-row__answer-label answer-correct caption-1", children: t('training.result.colAnswer', '정답') }), _jsx("span", { className: "trp-row__answer-value body-2", children: r.answer })] })), _jsxs("div", { className: "trp-row__answer-line", children: [_jsx("span", { className: `trp-row__answer-label answer-selected caption-1 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: t('training.result.colSelected', '선택') }), _jsx("span", { className: `trp-row__answer-value body-2 ${r.isCorrect ? 'is-correct' : 'is-wrong'}`, children: r.selectedText ?? '—' })] })] })), _jsx("div", { className: "trp-row__judge", children: _jsx(Badge, { variant: r.variant, label: r.label, fill: "soft", minWidth: 80 }) })] }, i)))] }) }))] })) }));
105
105
  }
@@ -71,8 +71,8 @@ export default function WordDataUpdatePopup({ title, progress, onDone, onRetry,
71
71
  /* 오류인데 다시 시도할 곳이 없으면 '다시 시도' 가 아무 일도 안 하고, 이 팝업은 X 도
72
72
  Esc 도 없어 **빠져나갈 길이 없었다**. 그때만 닫기 단추로 바꾼다. */
73
73
  isError && onRetry
74
- ? _jsx(Button, { size: 56, variant: "main", onClick: onRetry, children: t('common.retry', '다시 시도') })
74
+ ? _jsx(Button, { htmlType: "button", size: 56, variant: "main", onClick: onRetry, children: t('common.retry', '다시 시도') })
75
75
  : isError
76
- ? _jsx(Button, { size: 56, variant: "main", onClick: onClose ?? onDone, children: t('common.close', '닫기') })
77
- : _jsx(Button, { size: 56, variant: "main", disabled: !isDone, onClick: onDone, children: t('common.done', '완료') }), children: _jsxs("div", { className: "wdup-cards", children: [_jsx(PhaseCard, { icon: _jsx(Database, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseWordDownload', '단어 데이터 다운로드'), state: wordState, subText: wordState === 'active' ? t('wordData.downloadingCount', { n: formatCount(wordCount) }) : undefined }), _jsx(PhaseCard, { icon: _jsx(ImageIcon, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseImageCheck', '이미지 확인'), state: imgState, pct: imgPct, subText: imgSub }), _jsx(PhaseCard, { icon: _jsx(GitMerge, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phasePairAnalysis', '단어쌍 분석'), state: pairState, pct: pairState === 'active' ? (pairPercent ?? 0) : undefined, subText: undefined })] }) }));
76
+ ? _jsx(Button, { htmlType: "button", size: 56, variant: "main", onClick: onClose ?? onDone, children: t('common.close', '닫기') })
77
+ : _jsx(Button, { htmlType: "button", size: 56, variant: "main", disabled: !isDone, onClick: onDone, children: t('common.done', '완료') }), children: _jsxs("div", { className: "wdup-cards", children: [_jsx(PhaseCard, { icon: _jsx(Database, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseWordDownload', '단어 데이터 다운로드'), state: wordState, subText: wordState === 'active' ? t('wordData.downloadingCount', { n: formatCount(wordCount) }) : undefined }), _jsx(PhaseCard, { icon: _jsx(ImageIcon, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phaseImageCheck', '이미지 확인'), state: imgState, pct: imgPct, subText: imgSub }), _jsx(PhaseCard, { icon: _jsx(GitMerge, { size: 22, color: "var(--color-main, #29b6c8)", strokeWidth: 1.8 }), label: t('wordData.phasePairAnalysis', '단어쌍 분석'), state: pairState, pct: pairState === 'active' ? (pairPercent ?? 0) : undefined, subText: undefined })] }) }));
78
78
  }
@@ -54,6 +54,6 @@ export default function SegmentedControl(props) {
54
54
  };
55
55
  /* Tab 자리를 받을 한 칸 — 고른 것이 있으면 그것, 없으면 고를 수 있는 첫 칸 */
56
56
  const 탭자리 = 고를수있는.some((o) => o.key === value) ? value : 고를수있는[0]?.key;
57
- return (_jsx("div", { ref: 그룹, onKeyDown: 키, className: cx('segmented-control', `segmented-control--${shape}`, `segmented-control--${size}`, fullWidth && 'segmented-control--full', !useBG && 'segmented-control--no-bg', className), role: "radiogroup", "aria-label": ariaLabel ?? t('segmentedControl.ariaLabel', '선택 탭'), children: options.map((opt) => (_jsx(Button, { role: "radio", "aria-checked": opt.key === value, "data-segkey": opt.key, tabIndex: opt.key === 탭자리 ? 0 : -1, label: opt.label, count: opt.count, disabled: opt.disabled, className: opt.key === value ? 'is-selected' : '', onClick: () => { if (!opt.disabled)
57
+ return (_jsx("div", { ref: 그룹, onKeyDown: 키, className: cx('segmented-control', `segmented-control--${shape}`, `segmented-control--${size}`, fullWidth && 'segmented-control--full', !useBG && 'segmented-control--no-bg', className), role: "radiogroup", "aria-label": ariaLabel ?? t('segmentedControl.ariaLabel', '선택 탭'), children: options.map((opt) => (_jsx(Button, { htmlType: "button", role: "radio", "aria-checked": opt.key === value, "data-segkey": opt.key, tabIndex: opt.key === 탭자리 ? 0 : -1, label: opt.label, count: opt.count, disabled: opt.disabled, className: opt.key === value ? 'is-selected' : '', onClick: () => { if (!opt.disabled)
58
58
  onChange(opt.key); }, size: size, shape: shape, useBorder: false, useBG: false }, opt.key))) }));
59
59
  }
@@ -24,7 +24,7 @@ function SideNavFooter(props) {
24
24
  // 하나도 없으면 바닥 자체를 비운다 — 빈 채로 두면 여백(margin-top + padding)만 남는다.
25
25
  if (!그릴것)
26
26
  return null;
27
- return (_jsxs("div", { className: "side-nav__footer", children: [onDebug && (_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "terminal", ariaLabel: t('nav.debug', '디버그'), onClick: onDebug, selected: activeKey === 'debug' })), onNotification && (_jsxs("span", { className: "side-nav__notification-wrap", children: [_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "bell", ariaLabel: nameWithCount(t, t('nav.notifications', '알림'), unreadCount), onClick: onNotification, selected: activeKey === 'notifications' }), !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true }))] })), onSetting && (_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "settings", ariaLabel: t('nav.settings', '설정'), onClick: onSetting, selected: activeKey === 'settings' })), onHelp && (_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "message-circle-question-mark", ariaLabel: t('nav.help', '도움말'), onClick: onHelp, selected: activeKey === 'help' })), onAccount && (_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "user-round", ariaLabel: t('nav.account', '계정'), onClick: onAccount, selected: activeKey === 'account' }))] }));
27
+ return (_jsxs("div", { className: "side-nav__footer", children: [onDebug && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "terminal", ariaLabel: t('nav.debug', '디버그'), onClick: onDebug, selected: activeKey === 'debug' })), onNotification && (_jsxs("span", { className: "side-nav__notification-wrap", children: [_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "bell", ariaLabel: nameWithCount(t, t('nav.notifications', '알림'), unreadCount), onClick: onNotification, selected: activeKey === 'notifications' }), !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true }))] })), onSetting && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "settings", ariaLabel: t('nav.settings', '설정'), onClick: onSetting, selected: activeKey === 'settings' })), onHelp && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "message-circle-question-mark", ariaLabel: t('nav.help', '도움말'), onClick: onHelp, selected: activeKey === 'help' })), onAccount && (_jsx(Button, { htmlType: "button", size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "user-round", ariaLabel: t('nav.account', '계정'), onClick: onAccount, selected: activeKey === 'account' }))] }));
28
28
  }
29
29
  function SideNavButton(props) {
30
30
  const { t } = useTranslation();