@ihabdevteam/core 0.131.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 (90) hide show
  1. package/CHANGELOG.md +108 -0
  2. package/dist/components.cjs.js +178 -178
  3. package/dist/components.esm.css +2 -2
  4. package/dist/hooks.cjs.js +1 -1
  5. package/dist/i18n.cjs.js +1 -1
  6. package/dist/ihabdevteam-core.cjs.js +181 -181
  7. package/dist/ihabdevteam-core.d.ts +55 -17
  8. package/dist/packages/core/src/components/Accordion.js +4 -1
  9. package/dist/packages/core/src/components/Badge.d.ts +5 -0
  10. package/dist/packages/core/src/components/Badge.js +3 -3
  11. package/dist/packages/core/src/components/BarChart.d.ts +1 -1
  12. package/dist/packages/core/src/components/BarChart.js +25 -14
  13. package/dist/packages/core/src/components/BottomNav.js +3 -3
  14. package/dist/packages/core/src/components/BulkBar.js +1 -1
  15. package/dist/packages/core/src/components/Calendar.js +4 -0
  16. package/dist/packages/core/src/components/ChartSection.css +16 -0
  17. package/dist/packages/core/src/components/ChartSection.d.ts +8 -1
  18. package/dist/packages/core/src/components/ChartSection.js +2 -2
  19. package/dist/packages/core/src/components/ChoiceChips.js +1 -1
  20. package/dist/packages/core/src/components/CodeBox.js +1 -1
  21. package/dist/packages/core/src/components/DatePicker.js +16 -3
  22. package/dist/packages/core/src/components/Dropdown.js +13 -3
  23. package/dist/packages/core/src/components/FileUpload.js +8 -7
  24. package/dist/packages/core/src/components/FilterPanel.js +8 -5
  25. package/dist/packages/core/src/components/FilterSectionInfo.js +1 -1
  26. package/dist/packages/core/src/components/Header.js +1 -1
  27. package/dist/packages/core/src/components/HearingDeviceInfo.js +1 -1
  28. package/dist/packages/core/src/components/PageTitle.css +6 -0
  29. package/dist/packages/core/src/components/PageTitle.d.ts +9 -1
  30. package/dist/packages/core/src/components/PageTitle.js +3 -3
  31. package/dist/packages/core/src/components/Pagination.js +1 -1
  32. package/dist/packages/core/src/components/PanelRow.js +3 -3
  33. package/dist/packages/core/src/components/PaymentPlan.js +1 -1
  34. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +2 -2
  35. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +2 -2
  36. package/dist/packages/core/src/components/Popup/Popup.d.ts +8 -0
  37. package/dist/packages/core/src/components/Popup/Popup.js +17 -3
  38. package/dist/packages/core/src/components/Popup/SaveTablePopup.js +1 -1
  39. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +1 -1
  40. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +3 -3
  41. package/dist/packages/core/src/components/RingChart.d.ts +3 -1
  42. package/dist/packages/core/src/components/RingChart.js +2 -2
  43. package/dist/packages/core/src/components/SegmentedControl.js +1 -1
  44. package/dist/packages/core/src/components/SideNav.js +27 -10
  45. package/dist/packages/core/src/components/Table.d.ts +17 -1
  46. package/dist/packages/core/src/components/Table.js +15 -9
  47. package/dist/packages/core/src/components/Tabs.js +7 -2
  48. package/dist/packages/core/src/components/TextField.js +34 -5
  49. package/dist/packages/core/src/components/Tooltip/MenuTooltip.js +1 -1
  50. package/dist/packages/core/src/components/Tooltip/TextBoxTooltip.js +1 -1
  51. package/dist/packages/core/src/components/Tooltip/Tooltip.js +13 -5
  52. package/dist/packages/core/src/components/Training/NoiseSettingsTooltip.js +1 -1
  53. package/dist/packages/core/src/components/Training/TrainingBlock.js +1 -1
  54. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +1 -1
  55. package/dist/packages/core/src/components/Training/TrainingCardGroup.js +1 -1
  56. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +51 -23
  57. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +1 -1
  58. package/dist/packages/core/src/components/Training/TrainingPagePopupTitle.js +1 -1
  59. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +26 -5
  60. package/dist/packages/core/src/components/Training/TrainingStepConfirm.js +1 -1
  61. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +2 -2
  62. package/dist/packages/core/src/components/Training/TrainingStepOrderList.js +1 -1
  63. package/dist/packages/core/src/components/Training/TrainingStepTextBox.js +1 -1
  64. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +1 -1
  65. package/dist/packages/core/src/components/Training/TrainingWordCard.js +11 -6
  66. package/dist/packages/core/src/components/Training/useAudioPreview.js +7 -3
  67. package/dist/packages/core/src/components/UpdateAlert.js +1 -1
  68. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +2 -2
  69. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -3
  70. package/dist/packages/core/src/components/Word/WordTableBlock.js +2 -2
  71. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  72. package/dist/packages/core/src/components/WordTable.js +6 -6
  73. package/dist/packages/core/src/components/barChartTypes.d.ts +2 -0
  74. package/dist/packages/core/src/components/useComboboxSuggestions.d.ts +1 -0
  75. package/dist/packages/core/src/components/useComboboxSuggestions.js +12 -6
  76. package/dist/packages/core/src/components/useToastLifecycle.js +8 -1
  77. package/dist/packages/core/src/hooks/useAnchoredMenu.js +10 -9
  78. package/dist/packages/core/src/hooks/useDocumentTitle.d.ts +0 -11
  79. package/dist/packages/core/src/hooks/useDocumentTitle.js +16 -3
  80. package/dist/packages/core/src/hooks/useFocusTrap.js +6 -2
  81. package/dist/packages/core/src/utils/audioPeaks.js +13 -5
  82. package/dist/packages/core/src/utils/csv.js +3 -1
  83. package/dist/packages/core/src/utils/koreanSyllable.js +2 -1
  84. package/dist/packages/core/src/utils/textSort.d.ts +0 -10
  85. package/dist/packages/core/src/utils/textSort.js +7 -2
  86. package/dist/packages/core/src/utils/wordTrainingRuntime.js +28 -4
  87. package/dist/packages/core/src/version.d.ts +1 -1
  88. package/dist/packages/core/src/version.js +1 -1
  89. package/dist/utils.cjs.js +1 -1
  90. package/package.json +1 -1
@@ -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
  }
@@ -32,3 +32,9 @@
32
32
  음수 여백으로 왼쪽을 당겨, 아이콘 단추의 제 여백 때문에 제목이 페이지의 다른
33
33
  제목들보다 오른쪽으로 밀려 보이는 것을 막는다(글자 시작선을 맞춘다). */
34
34
  .page-title__back { margin-left: -8px; margin-right: -4px; flex-shrink: 0; }
35
+
36
+ /* wrap — 오른쪽이 넓으면 줄을 접는다. 제목 칸의 바닥을 320px 로 두되 **제 그릇보다 크지 않게**(min) —
37
+ 그냥 320px 이면 좁은 화면(세로 배치, 그릇 280px)에서 제목이 그릇을 넘쳐 오른쪽이 잘린다
38
+ (ChartSection 의 min-width 320px 가 똑같이 그랬다). */
39
+ .page-title--wrap { flex-wrap: wrap; }
40
+ .page-title--wrap .page-title__main { min-width: min(320px, 100%); }
@@ -29,7 +29,15 @@ type PageTitleProps = {
29
29
  * title 이 문자열이면 이 값은 필요 없다.
30
30
  */
31
31
  documentTitle?: string;
32
+ /**
33
+ * 오른쪽(`rightAction`)이 넓어 제목 칸을 짓누를 때 **줄을 접어** 오른쪽을 아래로 내린다. 기본 false(한 줄 유지).
34
+ *
35
+ * 한 줄로 두면 제목 칸이 `min-width: 0` 까지 줄어, 탭 일곱 같은 넓은 오른쪽이 제목을 한 글자씩 세로로
36
+ * 밀어낸다(말귀 고객 상세 1135px 실측). 켜면 제목 칸에 320px(좁으면 제 폭) 바닥을 주고, 그 바닥이
37
+ * 안 들어가면 오른쪽이 다음 줄로 간다.
38
+ */
39
+ wrap?: boolean;
32
40
  };
33
41
  /** @public */
34
- export default function PageTitle({ title, sub, rightAction, titleAction, badge, badgeVariant, documentTitle, onBack, backLabel }: PageTitleProps): import("react").JSX.Element;
42
+ export default function PageTitle({ title, sub, rightAction, titleAction, badge, badgeVariant, documentTitle, onBack, backLabel, wrap }: PageTitleProps): import("react").JSX.Element;
35
43
  export {};
@@ -5,15 +5,15 @@ import Badge from './Badge';
5
5
  import Button from './Button';
6
6
  import { useDocumentTitle } from '../hooks/useDocumentTitle';
7
7
  /** @public */
8
- export default function PageTitle({ title, sub, rightAction, titleAction, badge, badgeVariant = 'default', documentTitle, onBack, backLabel }) {
8
+ export default function PageTitle({ title, sub, rightAction, titleAction, badge, badgeVariant = 'default', documentTitle, onBack, backLabel, wrap = false }) {
9
9
  const { t } = useTranslation();
10
10
  // 페이지 제목이 곧 문서 제목이 되도록 맞춘다(WCAG 2.4.2).
11
11
  useDocumentTitle(typeof title === 'string' ? title : (documentTitle ?? null));
12
12
  const 뒤로이름 = backLabel ?? t('common.back', '뒤로');
13
- return (_jsxs("div", { className: "page-title", children: [_jsxs("div", { className: "page-title__main", children: [_jsxs("div", { className: "page-title__title-row", children: [onBack && (
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
  }
@@ -73,6 +73,14 @@ export interface PopupProps {
73
73
  * 더 넓거나 무제한이 필요하면 className으로 `.popup-panel` max-width를 직접 override.
74
74
  */
75
75
  size?: 'small' | 'medium' | 'large';
76
+ /**
77
+ * 패널 높이를 못 박는다(숫자는 px, 문자열은 CSS 길이 그대로). 미지정 시 내용만큼(기본).
78
+ *
79
+ * 탭을 옮길 때마다 내용 높이가 달라 창이 위아래로 뛰는 팝업에 쓴다. **화면보다 크면 화면에
80
+ * 맞춘다** — 패널의 `max-height`(데스크톱 `100% - 60px`, 좁은 화면은 하단 내비 몫까지 뺀 값)가
81
+ * 그대로 걸리므로 창이 화면 밖으로 나가지 않는다. 넘치는 내용은 본문이 스스로 굴린다.
82
+ */
83
+ height?: number | string;
76
84
  /**
77
85
  * 이 값이 바뀌면 본문(.popup-content) 스크롤을 맨 위로 되돌린다.
78
86
  * 탭/모드 전환처럼 내부 콘텐츠가 통째로 바뀌는 팝업에서, 스크롤을 내린 채
@@ -13,7 +13,7 @@ const ANIM_MS = 260;
13
13
  /** @public */
14
14
  export default function Popup(props) {
15
15
  const { t } = useTranslation();
16
- const { title, subtitle, headerActions, children, buttons, footer, footerClassName = "", onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, resetScrollKey } = props || {};
16
+ const { title, subtitle, headerActions, children, buttons, footer, footerClassName = "", onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, height, resetScrollKey } = props || {};
17
17
  const [closing, setClosing] = useState(false);
18
18
  // 닫힘 애니메이션 중(260ms)엔 버튼이 시각적으로만 흐려질 뿐 클릭은 계속 가능해,
19
19
  // 그 사이 같은 버튼을 연타하면 cb(확인/완료 콜백)가 여러 번 예약되어 중복 실행된다.
@@ -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,7 +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}` : ''}`, 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] }) }));
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}` : ''}`,
163
+ /* max-height(CSS)가 height 보다 이기므로 화면보다 크게 줘도 화면에 맞춰진다 */
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] }) }));
151
165
  if (inline || typeof document === 'undefined')
152
166
  return el;
153
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
  }
@@ -58,6 +58,8 @@ export interface RingChartProps {
58
58
  loading?: boolean;
59
59
  /** 섹션 루트에 추가할 클래스 (titleKey 지정 시에만 사용) */
60
60
  className?: string;
61
+ /** CSS 격자 칸 안에 세운다 — 아래 여백·최소 폭을 풀고 링을 카드 바닥에 맞춘다. ChartSection `inGrid` 참고(titleKey 지정 시에만 사용) */
62
+ inGrid?: boolean;
61
63
  }
62
64
  /**
63
65
  * 도넛 링 차트 — 전체 대비 완료 비율을 원형 호로 표현.
@@ -65,4 +67,4 @@ export interface RingChartProps {
65
67
  * 미지정 시에는 헤더 없는 순수 링만 렌더된다(크기/색상 데모 등).
66
68
  * @public
67
69
  */
68
- export default function RingChart({ titleKey, headRight, icon, showTotal, totalLabelKey, legend, loading, className, ...ringProps }: RingChartProps): import("react").JSX.Element;
70
+ export default function RingChart({ titleKey, headRight, icon, showTotal, totalLabelKey, legend, loading, className, inGrid, ...ringProps }: RingChartProps): import("react").JSX.Element;
@@ -40,7 +40,7 @@ function RingChartCanvas({ done, total, segments, color = 'var(--color-main)', s
40
40
  * 미지정 시에는 헤더 없는 순수 링만 렌더된다(크기/색상 데모 등).
41
41
  * @public
42
42
  */
43
- export default function RingChart({ titleKey, headRight, icon, showTotal, totalLabelKey, legend, loading, className, ...ringProps }) {
43
+ export default function RingChart({ titleKey, headRight, icon, showTotal, totalLabelKey, legend, loading, className, inGrid = false, ...ringProps }) {
44
44
  const { t } = useTranslation();
45
45
  if (!titleKey)
46
46
  return _jsx(RingChartCanvas, { ...ringProps });
@@ -53,5 +53,5 @@ export default function RingChart({ titleKey, headRight, icon, showTotal, totalL
53
53
  .filter(Boolean).join(' ') || undefined;
54
54
  // StatChart(.manager-stat-card__icon)와 동일한 톤 — headRight 자리에 렌더(둘 중 하나만 사용)
55
55
  const iconNode = icon && (_jsx("span", { className: "chart-section-icon", children: _jsx(Icon, { icon: icon, className: "chart-section-icon-inner" }) }));
56
- return (_jsx(ChartSection, { className: sectionClassName, title: t(titleKey), titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: iconNode || (isSmall ? undefined : headRight), subRow: (showTotal || legend) && _jsxs(_Fragment, { children: [showTotal ? (_jsxs(ChartTotal, { label: !isSmall && totalLabelKey ? t(totalLabelKey) : undefined, children: [_jsx("span", { className: "barchart-total__num h0", children: pct }), _jsx("span", { className: "barchart-total__unit h5", children: "%" })] })) : _jsx("span", {}), legend && _jsx(ChartLegend, { items: legend })] }), children: loading ? (_jsx("div", { className: "manager-bar-chart-loading", children: _jsx(Loader, { size: 32 }) })) : (_jsx("div", { className: "ring-chart-section-body ring-chart-section-body--offset", children: _jsx(RingChartCanvas, { ...ringProps, hideCenter: isSmall || ringProps.hideCenter }) })) }));
56
+ return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, title: t(titleKey), titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: iconNode || (isSmall ? undefined : headRight), subRow: (showTotal || legend) && _jsxs(_Fragment, { children: [showTotal ? (_jsxs(ChartTotal, { label: !isSmall && totalLabelKey ? t(totalLabelKey) : undefined, children: [_jsx("span", { className: "barchart-total__num h0", children: pct }), _jsx("span", { className: "barchart-total__unit h5", children: "%" })] })) : _jsx("span", {}), legend && _jsx(ChartLegend, { items: legend })] }), children: loading ? (_jsx("div", { className: "manager-bar-chart-loading", children: _jsx(Loader, { size: 32 }) })) : (_jsx("div", { className: "ring-chart-section-body ring-chart-section-body--offset", children: _jsx(RingChartCanvas, { ...ringProps, hideCenter: isSmall || ringProps.hideCenter }) })) }));
57
57
  }
@@ -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
  }
@@ -6,6 +6,10 @@ import Button from './Button';
6
6
  import Badge from './Badge';
7
7
  import React, { useCallback } from 'react';
8
8
  import { useTranslation } from 'react-i18next';
9
+ /** 단추 이름에 개수를 붙인다 — 뱃지는 aria-hidden 이라 개수는 이름으로만 전해진다(BottomNav 와 같은 문구). */
10
+ function nameWithCount(t, label, count) {
11
+ return count && count > 0 ? t('nav.badgeLabel', { label, count, defaultValue: '{{label}}, {{count}}개' }) : label;
12
+ }
9
13
  function SideNavFooter(props) {
10
14
  const { t } = useTranslation();
11
15
  const { activeKey, unreadCount, onDebug, onNotification, onSetting, onHelp, onAccount } = props || {};
@@ -20,9 +24,10 @@ function SideNavFooter(props) {
20
24
  // 하나도 없으면 바닥 자체를 비운다 — 빈 채로 두면 여백(margin-top + padding)만 남는다.
21
25
  if (!그릴것)
22
26
  return null;
23
- 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: t('nav.notifications', '알림'), onClick: onNotification, selected: activeKey === 'notifications' }), !!unreadCount && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24 }))] })), 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' }))] }));
24
28
  }
25
29
  function SideNavButton(props) {
30
+ const { t } = useTranslation();
26
31
  const { item, itemKey, label, icon, active, onClick, style, state, labelStyle } = props || {};
27
32
  const key = item?.key ?? itemKey ?? '';
28
33
  const text = (item?.label ?? label ?? '');
@@ -34,13 +39,19 @@ function SideNavButton(props) {
34
39
  const handleClick = useCallback((e) => {
35
40
  if (isDisabled)
36
41
  return;
37
- try {
38
- e.currentTarget.blur();
42
+ /* 포커스는 **포인터로 눌렀을 때만** 걷는다. 걷는 까닭은 마우스로 누른 뒤 :focus 틴트가 남고 태블릿
43
+ 레일이 :focus-within 으로 펼친 채 남기 때문인데, 키보드로 Enter 를 누른 사람까지 걷으면 포커스가
44
+ body 로 떨어져 메뉴 처음부터 Tab 을 다시 밟아야 했다. 키보드가 낸 click 은 detail 이 0 이다. */
45
+ const fromPointer = e.detail > 0;
46
+ if (fromPointer) {
47
+ try {
48
+ e.currentTarget.blur();
49
+ }
50
+ catch (err) { }
39
51
  }
40
- catch (err) { }
41
- onClick?.(key, e.currentTarget);
52
+ onClick?.(key, e.currentTarget, fromPointer);
42
53
  }, [isDisabled, key, onClick]);
43
- return (_jsxs("button", { className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? text : 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-label": `${badgeCount}` })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", "aria-label": "loading" }))] }));
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", "aria-label": "loading" }))] }));
44
55
  }
45
56
  /** @public */
46
57
  export default function SideNav(props) {
@@ -49,15 +60,17 @@ export default function SideNav(props) {
49
60
  const handleModeSwitch = useCallback(() => onAppModeChange?.(appMode === 'malgwi' ? 'manager' : 'malgwi'), [onAppModeChange, appMode]);
50
61
  return (_jsxs("nav", { "aria-label": t('nav.mainMenu', '메인 메뉴'), className: "side-nav__menu", children: [_jsxs("div", { className: "side-nav__menu-list", children: [(items || [])
51
62
  .filter((it) => !it.section && (it.key || it.label))
52
- .map((it, idx) => (_jsxs(React.Fragment, { children: [it.divider && _jsx("div", { className: "side-nav__separator" }), _jsx(SideNavButton, { item: it, active: it.key === activeKey, state: it.disabled ? 'disabled' : undefined, onClick: (_key, btnEl) => {
63
+ .map((it, idx) => (_jsxs(React.Fragment, { children: [it.divider && _jsx("div", { className: "side-nav__separator" }), _jsx(SideNavButton, { item: it, active: it.key === activeKey, state: it.disabled ? 'disabled' : undefined, onClick: (_key, btnEl, fromPointer) => {
53
64
  if (it.disabled)
54
65
  return;
55
66
  if (it.href) {
56
67
  window.open(it.href, '_blank', 'noopener,noreferrer');
57
- try {
58
- btnEl?.blur();
68
+ if (fromPointer) {
69
+ try {
70
+ btnEl?.blur();
71
+ }
72
+ catch (err) { }
59
73
  }
60
- catch (err) { }
61
74
  return;
62
75
  }
63
76
  onChange?.(it.key);
@@ -69,6 +82,10 @@ export default function SideNav(props) {
69
82
  window.scrollTo({ top: 0 });
70
83
  }
71
84
  catch (_) { }
85
+ /* 키보드로 고르면 포커스가 레일 안에 남으므로 태블릿 레일도 펼친 채 둔다(:focus-within) —
86
+ 아래 강제 접힘은 마우스가 들어와야 풀리므로 키보드 사용자에게 걸면 레일이 닫힌 채 포커스만 남는다. */
87
+ if (!fromPointer)
88
+ return;
72
89
  try {
73
90
  btnEl?.blur();
74
91
  }
@@ -40,12 +40,28 @@ export type TableProps<T extends {
40
40
  style?: CSSProperties;
41
41
  }>;
42
42
  onRowClick?: (item: T) => void;
43
+ /**
44
+ * 줄마다 누를 수 있는지. false 인 줄은 `onRowClick` 이 안 불리고, 버튼으로 읽히지도(role·Tab 정지점) 않으며,
45
+ * 손가락 커서·hover 강조도 없다. `onRowClick` 이 있을 때만 뜻이 있다. 미지정이면 모든 줄이 눌린다(기본).
46
+ *
47
+ * 예: 방문 기록 표에서 '안 옴' 줄은 열 것이 없다 — CSS 로 커서만 되돌리면 눌리기는 그대로이고 낭독기는 여전히 버튼이라 한다.
48
+ */
49
+ isRowClickable?: (item: T) => boolean;
43
50
  onRowMouseEnter?: (item: T, event: MouseEvent<HTMLDivElement>) => void;
44
51
  onRowMouseLeave?: (item: T, event: MouseEvent<HTMLDivElement>) => void;
45
52
  onRowDragStart?: (item: T, event: DragEvent<HTMLDivElement>) => void;
46
53
  onRowDragEnd?: (item: T, event: DragEvent<HTMLDivElement>) => void;
47
54
  draggingId?: string | number | null;
48
55
  gridTemplateColumns?: string;
56
+ /**
57
+ * 좁은 화면(767px 이하)에서 쓸 열 틀. 주면 그 폭에서는 이 틀을 쓰고 **끌어 둔 폭(저장값)은 무시**한다 —
58
+ * 폭 조절 손잡이와 '칸 너비 초기화' 단추도 숨겨, 좁은 화면에서 끈 값이 넓은 화면의 저장값을 덮지 않게 한다.
59
+ * 넓은 화면으로 돌아오면 저장값이 그대로 돌아온다. 미지정이면 좁은 화면도 넓은 화면과 같다(기본).
60
+ *
61
+ * 열 틀은 인라인 CSS 변수로 들어가므로 앱 CSS 로는 `!important` 없이 못 이긴다 — 그래서 prop 이다.
62
+ * 숨길 칸은 `renderRow` 의 `className`·`fields[].className` 으로 앱이 가린다.
63
+ */
64
+ mobileGridTemplateColumns?: string;
49
65
  /** true면 헤더 경계선을 드래그해 컬럼 폭 조절 가능 */
50
66
  resizable?: boolean;
51
67
  /**
@@ -98,4 +114,4 @@ export type TableProps<T extends {
98
114
  /** @public */
99
115
  export default function Table<T extends {
100
116
  id?: string | number;
101
- }>({ fields, headerGroups, items, renderRow, onRowClick, onRowMouseEnter, onRowMouseLeave, onRowDragStart, onRowDragEnd, draggingId, gridTemplateColumns, resizable, resizeMode, resizeStorageKey, size, title, icon, count, collapsed, onToggleCollapse, headerActions, empty, loading, dragOver, onDragOver, onDragLeave, onDrop, renderExpandedRow, expandedId, }: TableProps<T>): import("react").JSX.Element;
117
+ }>({ fields, headerGroups, items, renderRow, onRowClick, isRowClickable, onRowMouseEnter, onRowMouseLeave, onRowDragStart, onRowDragEnd, draggingId, gridTemplateColumns, mobileGridTemplateColumns, resizable, resizeMode, resizeStorageKey, size, title, icon, count, collapsed, onToggleCollapse, headerActions, empty, loading, dragOver, onDragOver, onDragLeave, onDrop, renderExpandedRow, expandedId, }: TableProps<T>): import("react").JSX.Element;