@ihabdevteam/core 0.149.0 → 0.151.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 (219) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/components.cjs.js +199 -186
  3. package/dist/components.esm.css +5 -5
  4. package/dist/hooks.cjs.js +5 -1
  5. package/dist/i18n.cjs.js +3 -3
  6. package/dist/ihabdevteam-core-tokens.css +1 -1
  7. package/dist/ihabdevteam-core.cjs.js +200 -187
  8. package/dist/ihabdevteam-core.css +1 -1
  9. package/dist/ihabdevteam-core.d.ts +407 -69
  10. package/dist/packages/core/src/components/Accordion.d.ts +6 -0
  11. package/dist/packages/core/src/components/Accordion.js +5 -2
  12. package/dist/packages/core/src/components/Alert.d.ts +7 -1
  13. package/dist/packages/core/src/components/Alert.js +12 -3
  14. package/dist/packages/core/src/components/BarChart.js +7 -2
  15. package/dist/packages/core/src/components/Board.d.ts +5 -2
  16. package/dist/packages/core/src/components/Board.js +9 -3
  17. package/dist/packages/core/src/components/BottomNav.js +5 -4
  18. package/dist/packages/core/src/components/Breadcrumb.css +9 -0
  19. package/dist/packages/core/src/components/Breadcrumb.d.ts +12 -1
  20. package/dist/packages/core/src/components/Breadcrumb.js +8 -2
  21. package/dist/packages/core/src/components/BulkBar.css +2 -0
  22. package/dist/packages/core/src/components/BulkBar.js +21 -2
  23. package/dist/packages/core/src/components/Button.d.ts +12 -2
  24. package/dist/packages/core/src/components/Button.js +70 -13
  25. package/dist/packages/core/src/components/Calendar.d.ts +6 -1
  26. package/dist/packages/core/src/components/Calendar.js +3 -3
  27. package/dist/packages/core/src/components/DatePicker.d.ts +7 -1
  28. package/dist/packages/core/src/components/DatePicker.js +25 -15
  29. package/dist/packages/core/src/components/Dropdown.d.ts +1 -0
  30. package/dist/packages/core/src/components/Dropdown.js +7 -5
  31. package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.d.ts +12 -1
  32. package/dist/packages/core/src/components/Effect/EffectPartyPopperHost.js +25 -8
  33. package/dist/packages/core/src/components/EmptyState.d.ts +5 -1
  34. package/dist/packages/core/src/components/EmptyState.js +4 -1
  35. package/dist/packages/core/src/components/ErrorState.d.ts +5 -2
  36. package/dist/packages/core/src/components/ErrorState.js +3 -1
  37. package/dist/packages/core/src/components/FileUpload.d.ts +9 -1
  38. package/dist/packages/core/src/components/FileUpload.js +8 -2
  39. package/dist/packages/core/src/components/FilterSectionInfo.js +14 -2
  40. package/dist/packages/core/src/components/Header.js +8 -5
  41. package/dist/packages/core/src/components/HearingDeviceInfo.js +2 -1
  42. package/dist/packages/core/src/components/Icon.d.ts +2 -1
  43. package/dist/packages/core/src/components/Icon.js +4 -1
  44. package/dist/packages/core/src/components/Loader.css +28 -0
  45. package/dist/packages/core/src/components/Loader.d.ts +5 -1
  46. package/dist/packages/core/src/components/Loader.js +4 -2
  47. package/dist/packages/core/src/components/NoticeBox.d.ts +1 -1
  48. package/dist/packages/core/src/components/NoticeBox.js +15 -6
  49. package/dist/packages/core/src/components/PanelRow.js +2 -1
  50. package/dist/packages/core/src/components/PassCard/PassCard.d.ts +8 -2
  51. package/dist/packages/core/src/components/PassCard/PassCard.js +25 -10
  52. package/dist/packages/core/src/components/PassCard/passCardProduct.d.ts +1 -1
  53. package/dist/packages/core/src/components/PassCard/passCardProduct.js +11 -2
  54. package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +11 -2
  55. package/dist/packages/core/src/components/Popup/ConfirmPopup.js +18 -3
  56. package/dist/packages/core/src/components/Popup/DeviceSideTable.js +10 -8
  57. package/dist/packages/core/src/components/Popup/ListPopup.js +6 -6
  58. package/dist/packages/core/src/components/Popup/LoadTablePopup.css +7 -0
  59. package/dist/packages/core/src/components/Popup/LoadTablePopup.js +27 -5
  60. package/dist/packages/core/src/components/Popup/Popup.d.ts +8 -0
  61. package/dist/packages/core/src/components/Popup/Popup.js +6 -3
  62. package/dist/packages/core/src/components/Popup/SaveTablePopup.d.ts +5 -1
  63. package/dist/packages/core/src/components/Popup/TrainingResultPopup.css +3 -0
  64. package/dist/packages/core/src/components/Popup/TrainingResultPopup.d.ts +5 -0
  65. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +10 -4
  66. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.css +9 -0
  67. package/dist/packages/core/src/components/ProgressIndicator.css +5 -0
  68. package/dist/packages/core/src/components/ProgressIndicator.d.ts +3 -1
  69. package/dist/packages/core/src/components/ProgressIndicator.js +5 -3
  70. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.d.ts +4 -1
  71. package/dist/packages/core/src/components/PromoBannerBg/promoBannerBg.js +27 -15
  72. package/dist/packages/core/src/components/Rating.d.ts +8 -2
  73. package/dist/packages/core/src/components/Rating.js +22 -8
  74. package/dist/packages/core/src/components/Report/ReportDocument.d.ts +10 -7
  75. package/dist/packages/core/src/components/Report/ReportDocument.js +39 -13
  76. package/dist/packages/core/src/components/RingChart.d.ts +1 -1
  77. package/dist/packages/core/src/components/RingChart.js +1 -1
  78. package/dist/packages/core/src/components/SegmentedControl.d.ts +4 -0
  79. package/dist/packages/core/src/components/SegmentedControl.js +9 -1
  80. package/dist/packages/core/src/components/ShortcutBadge.css +16 -1
  81. package/dist/packages/core/src/components/ShortcutBadge.d.ts +1 -0
  82. package/dist/packages/core/src/components/SideNav.js +8 -2
  83. package/dist/packages/core/src/components/SummaryStrip.d.ts +1 -1
  84. package/dist/packages/core/src/components/SummaryStrip.js +2 -2
  85. package/dist/packages/core/src/components/Table.d.ts +1 -1
  86. package/dist/packages/core/src/components/Table.js +4 -1
  87. package/dist/packages/core/src/components/Tabs.d.ts +1 -0
  88. package/dist/packages/core/src/components/Tabs.js +4 -1
  89. package/dist/packages/core/src/components/TextField.css +5 -6
  90. package/dist/packages/core/src/components/TextField.js +46 -3
  91. package/dist/packages/core/src/components/Toast.js +16 -2
  92. package/dist/packages/core/src/components/Tooltip/Tooltip.css +3 -1
  93. package/dist/packages/core/src/components/Tooltip/Tooltip.js +5 -2
  94. package/dist/packages/core/src/components/Tooltip/tooltipConstants.d.ts +10 -2
  95. package/dist/packages/core/src/components/Tooltip/tooltipConstants.js +10 -2
  96. package/dist/packages/core/src/components/TopBanner.d.ts +1 -1
  97. package/dist/packages/core/src/components/TopBanner.js +66 -8
  98. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.d.ts +1 -0
  99. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +65 -15
  100. package/dist/packages/core/src/components/Training/NoisePlayerContext.d.ts +3 -2
  101. package/dist/packages/core/src/components/Training/NoisePlayerContext.js +10 -4
  102. package/dist/packages/core/src/components/Training/TrainingBlockButton.css +7 -0
  103. package/dist/packages/core/src/components/Training/TrainingBlockButton.js +3 -1
  104. package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +18 -5
  105. package/dist/packages/core/src/components/Training/TrainingPagePopup.js +93 -28
  106. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.d.ts +1 -0
  107. package/dist/packages/core/src/components/Training/TrainingPagePopupBottom.js +16 -3
  108. package/dist/packages/core/src/components/Training/TrainingStepAudioPlayer.js +29 -11
  109. package/dist/packages/core/src/components/Training/TrainingStepCard.js +19 -3
  110. package/dist/packages/core/src/components/Training/TrainingStepTitle.js +15 -1
  111. package/dist/packages/core/src/components/Training/TrainingWordCard.css +16 -0
  112. package/dist/packages/core/src/components/Training/TrainingWordCard.d.ts +6 -0
  113. package/dist/packages/core/src/components/Training/TrainingWordCard.js +7 -2
  114. package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +2 -2
  115. package/dist/packages/core/src/components/Training/trainingMissStats.d.ts +10 -0
  116. package/dist/packages/core/src/components/Training/trainingMissStats.js +19 -1
  117. package/dist/packages/core/src/components/Training/useFloatingFocus.d.ts +13 -0
  118. package/dist/packages/core/src/components/Training/useFloatingFocus.js +59 -0
  119. package/dist/packages/core/src/components/Training/useNoiseFade.js +20 -11
  120. package/dist/packages/core/src/components/Training/useProgressTicker.d.ts +0 -18
  121. package/dist/packages/core/src/components/Training/useProgressTicker.js +59 -14
  122. package/dist/packages/core/src/components/Training/useTTSPlayback.d.ts +1 -1
  123. package/dist/packages/core/src/components/Training/useTTSPlayback.js +56 -25
  124. package/dist/packages/core/src/components/Training/useWaveReady.d.ts +4 -1
  125. package/dist/packages/core/src/components/Training/useWaveReady.js +17 -8
  126. package/dist/packages/core/src/components/UpdateAlert.css +4 -2
  127. package/dist/packages/core/src/components/UpdateAlert.d.ts +3 -1
  128. package/dist/packages/core/src/components/UpdateAlert.js +21 -1
  129. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.d.ts +2 -0
  130. package/dist/packages/core/src/components/Word/PhonemeSelectBlock.js +12 -4
  131. package/dist/packages/core/src/components/Word/WordSearchResultBlock.d.ts +6 -0
  132. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +26 -13
  133. package/dist/packages/core/src/components/Word/WordTableBlock.js +24 -21
  134. package/dist/packages/core/src/components/Word/WordTableThumbnail.js +1 -1
  135. package/dist/packages/core/src/components/Word/useWordTableDisplaySettings.js +7 -10
  136. package/dist/packages/core/src/components/Word/useWordTablePrint.d.ts +1 -1
  137. package/dist/packages/core/src/components/Word/useWordTablePrint.js +9 -3
  138. package/dist/packages/core/src/components/Word/wordScoreStats.d.ts +14 -1
  139. package/dist/packages/core/src/components/Word/wordScoreStats.js +7 -3
  140. package/dist/packages/core/src/components/Word/wordTablePrint.js +15 -3
  141. package/dist/packages/core/src/components/Word/wordTableReview.d.ts +58 -1
  142. package/dist/packages/core/src/components/Word/wordTableReview.js +75 -17
  143. package/dist/packages/core/src/components/Word/wordTableSettings.d.ts +9 -0
  144. package/dist/packages/core/src/components/Word/wordTableSettings.js +11 -0
  145. package/dist/packages/core/src/components/WordTable.css +10 -0
  146. package/dist/packages/core/src/components/WordTable.js +39 -26
  147. package/dist/packages/core/src/components/barChartTypes.d.ts +2 -2
  148. package/dist/packages/core/src/components/buttonTypes.d.ts +8 -0
  149. package/dist/packages/core/src/components/updateAlertStack.d.ts +2 -0
  150. package/dist/packages/core/src/components/updateAlertStack.js +68 -0
  151. package/dist/packages/core/src/components/useCalendarMonth.d.ts +4 -1
  152. package/dist/packages/core/src/components/useCalendarMonth.js +6 -3
  153. package/dist/packages/core/src/components/useComboboxSuggestions.d.ts +7 -0
  154. package/dist/packages/core/src/components/useComboboxSuggestions.js +21 -2
  155. package/dist/packages/core/src/components/wordTableLayout.d.ts +4 -0
  156. package/dist/packages/core/src/components/wordTableLayout.js +6 -0
  157. package/dist/packages/core/src/components.d.ts +2 -1
  158. package/dist/packages/core/src/components.js +1 -1
  159. package/dist/packages/core/src/hooks/bodyScrollLock.js +8 -2
  160. package/dist/packages/core/src/hooks/focusReturn.d.ts +12 -0
  161. package/dist/packages/core/src/hooks/focusReturn.js +49 -0
  162. package/dist/packages/core/src/hooks/touchToggle.d.ts +22 -0
  163. package/dist/packages/core/src/hooks/touchToggle.js +114 -0
  164. package/dist/packages/core/src/hooks/useAnchoredMenu.d.ts +12 -3
  165. package/dist/packages/core/src/hooks/useAnchoredMenu.js +37 -3
  166. package/dist/packages/core/src/hooks/useDailyTrainingSelection.d.ts +4 -2
  167. package/dist/packages/core/src/hooks/useDailyTrainingSelection.js +23 -11
  168. package/dist/packages/core/src/hooks/useIcon.js +29 -5
  169. package/dist/packages/core/src/hooks/useLucideIcon.d.ts +4 -0
  170. package/dist/packages/core/src/hooks/useLucideIcon.js +4 -0
  171. package/dist/packages/core/src/hooks/usePendingPress.d.ts +9 -0
  172. package/dist/packages/core/src/hooks/usePendingPress.js +26 -0
  173. package/dist/packages/core/src/hooks/usePrefersReducedMotion.d.ts +15 -0
  174. package/dist/packages/core/src/hooks/usePrefersReducedMotion.js +26 -0
  175. package/dist/packages/core/src/hooks/useRelativeDay.d.ts +2 -2
  176. package/dist/packages/core/src/hooks/useRelativeDay.js +12 -5
  177. package/dist/packages/core/src/hooks/useTablePaging.d.ts +27 -2
  178. package/dist/packages/core/src/hooks/useTablePaging.js +37 -5
  179. package/dist/packages/core/src/hooks/useWordAttributeFilter.d.ts +7 -0
  180. package/dist/packages/core/src/hooks/useWordAttributeFilter.js +25 -0
  181. package/dist/packages/core/src/i18n/index.js +9 -1
  182. package/dist/packages/core/src/i18n/locales/en.json +24 -2
  183. package/dist/packages/core/src/i18n/locales/ko.json +24 -2
  184. package/dist/packages/core/src/tokens.css +5 -0
  185. package/dist/packages/core/src/types/wordSearch.d.ts +12 -0
  186. package/dist/packages/core/src/utils/csv.d.ts +7 -2
  187. package/dist/packages/core/src/utils/csv.js +12 -5
  188. package/dist/packages/core/src/utils/dailySelectionUtils.js +44 -14
  189. package/dist/packages/core/src/utils/date.d.ts +8 -0
  190. package/dist/packages/core/src/utils/date.js +44 -10
  191. package/dist/packages/core/src/utils/format.d.ts +1 -1
  192. package/dist/packages/core/src/utils/format.js +6 -7
  193. package/dist/packages/core/src/utils/noiseSettingsStore.d.ts +7 -3
  194. package/dist/packages/core/src/utils/phonemeConvert.d.ts +9 -0
  195. package/dist/packages/core/src/utils/phonemeConvert.js +11 -0
  196. package/dist/packages/core/src/utils/phonemePairSearch.d.ts +7 -1
  197. package/dist/packages/core/src/utils/phonemePairSearch.js +45 -21
  198. package/dist/packages/core/src/utils/safeHref.d.ts +8 -0
  199. package/dist/packages/core/src/utils/safeHref.js +24 -0
  200. package/dist/packages/core/src/utils/savedWordTables.d.ts +19 -3
  201. package/dist/packages/core/src/utils/savedWordTables.js +27 -19
  202. package/dist/packages/core/src/utils/textSort.d.ts +9 -0
  203. package/dist/packages/core/src/utils/textSort.js +37 -1
  204. package/dist/packages/core/src/utils/wordAttributeFilterCodes.d.ts +31 -0
  205. package/dist/packages/core/src/utils/wordAttributeFilterCodes.js +163 -0
  206. package/dist/packages/core/src/utils/wordContrast.d.ts +4 -2
  207. package/dist/packages/core/src/utils/wordContrast.js +8 -2
  208. package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +5 -2
  209. package/dist/packages/core/src/utils/wordTrainingGenerators.js +52 -2
  210. package/dist/packages/core/src/utils/wordTrainingPool.js +2 -2
  211. package/dist/packages/core/src/utils/wordTrainingRuntime.js +15 -7
  212. package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.d.ts +1 -1
  213. package/dist/packages/core/src/utils/wordTrainingRuntimeTypes.js +5 -4
  214. package/dist/packages/core/src/utils/wornDate.d.ts +12 -0
  215. package/dist/packages/core/src/utils/wornDate.js +15 -0
  216. package/dist/packages/core/src/version.d.ts +1 -1
  217. package/dist/packages/core/src/version.js +1 -1
  218. package/dist/utils.cjs.js +2 -2
  219. package/package.json +1 -1
@@ -5,6 +5,12 @@ export interface AccordionItem {
5
5
  title: ReactNode;
6
6
  body: ReactNode;
7
7
  avatar?: ReactNode;
8
+ /**
9
+ * 항목의 변하지 않는 이름. 주면 펼침 상태가 **자리 번호가 아니라 이 id 에** 묶인다 —
10
+ * 목록 앞에 항목이 더해지거나 빠져도(새 공지가 맨 위에 붙는 등) 펼친 항목이 그대로 펼쳐져 있다.
11
+ * 안 주면 지금처럼 자리 번호로 묶인다. 같은 목록 안에서 겹치지 않아야 한다.
12
+ */
13
+ id?: string;
8
14
  }
9
15
  /** @public */
10
16
  export interface AccordionProps {
@@ -31,6 +31,9 @@ function Item({ title, body, open = false, onToggle, avatar, titleClassName = 'b
31
31
  // ── Accordion ─────────────────────────────────────────────────────────────
32
32
  /** @public */
33
33
  export default function Accordion({ items = [], defaultOpenIndex = 0, size = 72, className = '', bodyClassName, }) {
34
- const [openIndex, setOpenIndex] = useState(defaultOpenIndex);
35
- return (_jsx("div", { className: `accordion-group accordion-group--s${size} ${className}`.trim(), role: "list", children: items.map((item, i) => (_jsx(Item, { title: item.title, body: item.body, avatar: item.avatar, open: i === openIndex, onToggle: () => setOpenIndex(i === openIndex ? -1 : i), bodyClassName: bodyClassName }, i))) }));
34
+ /* 펼침을 자리 번호로만 들고 있어, 앞에 항목이 더해지면 **다른 항목이** 펼쳐졌다. id 가 있는 항목을 누르면 id 로 기억한다.
35
+ 처음(아직 누르기 전)은 defaultOpenIndex 자리 — 항목이 늦게 와도 그 자리가 열린다(예전과 같다). */
36
+ const [열림, set열림] = useState({ index: defaultOpenIndex });
37
+ const 열렸나 = (item, i) => 열림.id != null && item.id != null ? item.id === 열림.id : 열림.id == null && i === 열림.index;
38
+ return (_jsx("div", { className: `accordion-group accordion-group--s${size} ${className}`.trim(), role: "list", children: items.map((item, i) => (_jsx(Item, { title: item.title, body: item.body, avatar: item.avatar, open: 열렸나(item, i), onToggle: () => set열림(열렸나(item, i) ? { index: -1 } : { index: i, id: item.id }), bodyClassName: bodyClassName }, item.id ?? i))) }));
36
39
  }
@@ -12,7 +12,13 @@ export interface AlertProps {
12
12
  /** 닫기(×) 버튼 표시 여부 */
13
13
  dismissible?: boolean;
14
14
  onDismiss?: () => void;
15
+ /**
16
+ * 낭독기에 알리는 방식. 안 주면 톤이 정한다 — `danger`·`warning` 은 `'assertive'`(`role="alert"`, 하던 말을 끊고 읽음),
17
+ * `info`·`success` 는 `'polite'`(`role="status"`, 하던 말 뒤에 읽음).
18
+ * `'off'` 는 live 역할을 두지 않는다 — 화면에 늘 놓인 안내(설정 화면 머리의 주의 글 등)가 열 때마다 끼어들어 읽히지 않게.
19
+ */
20
+ live?: 'assertive' | 'polite' | 'off';
15
21
  className?: string;
16
22
  }
17
23
  /** @public */
18
- export default function Alert({ tone, title, message, children, dismissible, onDismiss, className, }: AlertProps): import("react").JSX.Element | null;
24
+ export default function Alert({ tone, title, message, children, dismissible, onDismiss, live, className, }: AlertProps): import("react").JSX.Element | null;
@@ -1,7 +1,8 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { cx } from '../utils/cx';
4
- import { useCallback, useState } from 'react';
4
+ import { useCallback, useRef, useState } from 'react';
5
+ import { moveFocusOutside } from '../hooks/focusReturn';
5
6
  import { useTranslation } from 'react-i18next';
6
7
  import Icon from './Icon';
7
8
  import './Alert.css';
@@ -12,17 +13,25 @@ const TONE_ICON = {
12
13
  danger: 'alert-circle',
13
14
  };
14
15
  /** @public */
15
- export default function Alert({ tone = 'info', title, message, children, dismissible = false, onDismiss, className = '', }) {
16
+ export default function Alert({ tone = 'info', title, message, children, dismissible = false, onDismiss, live, className = '', }) {
16
17
  const { t } = useTranslation();
17
18
  const [dismissed, setDismissed] = useState(false);
19
+ const rootRef = useRef(null);
18
20
  const handleDismiss = useCallback(() => {
21
+ /* 닫기 단추에 있던 포커스가 상자와 함께 사라져 body 로 떨어졌다 — 떼기 전에 상자 앞의 포커스 받을 곳으로 옮긴다 */
22
+ const root = rootRef.current;
23
+ if (root && root.contains(document.activeElement))
24
+ moveFocusOutside(root);
19
25
  setDismissed(true);
20
26
  onDismiss?.();
21
27
  }, [onDismiss]);
22
28
  if (dismissed)
23
29
  return null;
24
30
  const cls = cx('alert', `alert--${tone}`, className);
31
+ /* 톤과 상관없이 늘 role="alert" 였다 — "저장했습니다"(success)·안내(info)까지 하던 말을 끊고 읽혔다 */
32
+ const 방식 = live ?? (tone === 'danger' || tone === 'warning' ? 'assertive' : 'polite');
33
+ const role = 방식 === 'assertive' ? 'alert' : 방식 === 'polite' ? 'status' : undefined;
25
34
  const hasTitle = Boolean(title);
26
35
  const hasMessage = Boolean(message || children);
27
- return (_jsxs("div", { className: cls, role: "alert", children: [_jsxs("div", { className: "alert__body", children: [_jsxs("div", { className: "alert__header", children: [_jsx(Icon, { icon: TONE_ICON[tone], size: 20, className: "alert__icon" }), hasTitle && _jsx("span", { className: "alert__title body-2", children: title }), !hasTitle && hasMessage && _jsx("div", { className: "alert__message body-3", children: message || children })] }), hasTitle && hasMessage && (_jsx("div", { className: "alert__message alert__message--indented body-3", children: message || children }))] }), dismissible && (_jsx("button", { type: "button", className: "alert__dismiss", onClick: handleDismiss, "aria-label": t('common.close', '닫기'), children: "\u00D7" }))] }));
36
+ return (_jsxs("div", { ref: rootRef, className: cls, role: role, children: [_jsxs("div", { className: "alert__body", children: [_jsxs("div", { className: "alert__header", children: [_jsx(Icon, { icon: TONE_ICON[tone], size: 20, className: "alert__icon" }), hasTitle && _jsx("span", { className: "alert__title body-2", children: title }), !hasTitle && hasMessage && _jsx("div", { className: "alert__message body-3", children: message || children })] }), hasTitle && hasMessage && (_jsx("div", { className: "alert__message alert__message--indented body-3", children: message || children }))] }), dismissible && (_jsx("button", { type: "button", className: "alert__dismiss", onClick: handleDismiss, "aria-label": t('common.close', '닫기'), children: "\u00D7" }))] }));
28
37
  }
@@ -30,7 +30,8 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, h
30
30
  const barsPerCol = stacked ? 1 : (single ? 1 : 2);
31
31
  const effBarWidth = useMemo(() => {
32
32
  const cols = Math.max(1, data.length);
33
- return Math.max(8, Math.min(barWidth, Math.round(120 / (cols * barsPerCol) + 6)));
33
+ /* 하한 2px — 8 이던 때는 barWidth 6(말귀 훈련 현황의 가는 막대)을 넘겨도 8 로 올라갔다. 자동 축소 식은 6 밑으로 안 내려간다 */
34
+ return Math.max(2, Math.min(barWidth, Math.round(120 / (cols * barsPerCol) + 6)));
34
35
  }, [data.length, barsPerCol, barWidth]);
35
36
  /* 굵기는 인라인 flex/maxWidth 가 아니라 CSS 변수로 박는다 — 소비자가 `!important`
36
37
  없이 CSS 로 덮을 수 있게. 읽는 규칙은 BarChart.css 의 `.manager-bar-chart-bar`·`-stack`. */
@@ -249,7 +250,11 @@ export default function BarChart({ size = 'medium', barWidth, titleKey, data, co
249
250
  : elapsed.reduce((s, d) => s + (d.a > 0 ? d.a : 0), 0);
250
251
  return Math.round(sum / elapsed.length);
251
252
  }
252
- return bars.reduce((s, d) => s + (d.a > 0 ? d.a : 0) + (labelBKey && d.b > 0 ? d.b : 0), 0);
253
+ /* 월 보기 합계는 **그 달 날짜만** — 주 막대는 월요일 시작 7일이라 달 경계를 넘는다(마지막 주는 다음 달 날, 첫 월요일 앞 날은
254
+ 앞 달 막대). 막대 합으로 내면 8월 합계에 9/1~9/6 이 들고 8/1~8/2 가 빠져, 상세 목록(달력 달)의 건수와 어긋났다.
255
+ 주 보기는 그 주 막대의 합 그대로. */
256
+ const 합칠것 = control.period === 'month' ? (data[control.monthRange]?.daily ?? []) : bars;
257
+ return 합칠것.reduce((s, d) => s + (d.a > 0 ? d.a : 0) + (labelBKey && d.b > 0 ? d.b : 0), 0);
253
258
  }, [bars, rawBars, rate, avgMode, labelBKey, control.period, control.monthRange, data]);
254
259
  const legendItems = series && series.length > 0
255
260
  ? series.map(s => ({ color: s.color, label: s.label }))
@@ -23,7 +23,7 @@ export interface BoardProps<T> {
23
23
  title?: ReactNode;
24
24
  /** 우측 상단 영역 — 검색 입력 등. 상태는 소비자가 소유한다 */
25
25
  toolbar?: ReactNode;
26
- /** rows가 비었을 때 표시할 내용 */
26
+ /** rows가 비었을 때 표시할 내용. 기본은 i18n `board.empty`(ko '등록된 항목이 없습니다.') */
27
27
  empty?: ReactNode;
28
28
  /** 로딩 중 표시할 내용 — 지정 시 rows 대신 렌더 */
29
29
  loading?: ReactNode;
@@ -33,7 +33,10 @@ export interface BoardProps<T> {
33
33
  }
34
34
  /**
35
35
  * 게시판형 목록 표. 상태를 갖지 않는 표현 컴포넌트이므로 검색·페이지네이션은
36
- * `toolbar`로 주입하고 필터링된 `rows`를 넘긴다. 서버 렌더링 환경에서도 그대로 쓸 수 있다.
36
+ * `toolbar`로 주입하고 필터링된 `rows`를 넘긴다.
37
+ *
38
+ * 기본 빈 문구는 i18n(`board.empty`)을 따른다 — 전에는 한국어가 박혀 영어 화면에서도 한국어였다. 그래서 i18n 훅을 쓰는
39
+ * 클라이언트 컴포넌트('use client')다(서버 렌더링(SSR)은 그대로 된다).
37
40
  * @public
38
41
  */
39
42
  export default function Board<T>({ columns, rows, rowKey, title, toolbar, empty, loading, caption, className, }: BoardProps<T>): import("react").JSX.Element;
@@ -1,12 +1,18 @@
1
+ 'use client';
1
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
3
  import { cx } from '../utils/cx';
4
+ import { useTranslation } from 'react-i18next';
3
5
  import './Board.css';
4
6
  /**
5
7
  * 게시판형 목록 표. 상태를 갖지 않는 표현 컴포넌트이므로 검색·페이지네이션은
6
- * `toolbar`로 주입하고 필터링된 `rows`를 넘긴다. 서버 렌더링 환경에서도 그대로 쓸 수 있다.
8
+ * `toolbar`로 주입하고 필터링된 `rows`를 넘긴다.
9
+ *
10
+ * 기본 빈 문구는 i18n(`board.empty`)을 따른다 — 전에는 한국어가 박혀 영어 화면에서도 한국어였다. 그래서 i18n 훅을 쓰는
11
+ * 클라이언트 컴포넌트('use client')다(서버 렌더링(SSR)은 그대로 된다).
7
12
  * @public
8
13
  */
9
- export default function Board({ columns, rows, rowKey, title, toolbar, empty = '등록된 항목이 없습니다.', loading, caption, className = '', }) {
14
+ export default function Board({ columns, rows, rowKey, title, toolbar, empty, loading, caption, className = '', }) {
15
+ const { t } = useTranslation();
10
16
  const showEmpty = !loading && rows.length === 0;
11
- return (_jsxs("div", { className: cx('board', className), children: [title || toolbar ? (_jsxs("div", { className: "board__head", children: [title ? _jsx("h2", { className: "board__title", children: title }) : _jsx("span", {}), toolbar ? _jsx("div", { className: "board__toolbar", children: toolbar }) : null] })) : null, _jsxs("table", { className: "board__table", children: [caption ? _jsx("caption", { className: "board__caption", children: caption }) : null, _jsx("thead", { children: _jsx("tr", { children: columns.map((col) => (_jsx("th", { scope: "col", className: cx('board__th', `board__cell--${col.headerAlign ?? col.align ?? 'left'}`), style: col.width ? { width: col.width } : undefined, children: col.header }, col.key))) }) }), _jsx("tbody", { children: loading ? (_jsx("tr", { children: _jsx("td", { className: "board__state", colSpan: columns.length, children: loading }) })) : showEmpty ? (_jsx("tr", { children: _jsx("td", { className: "board__state", colSpan: columns.length, children: empty }) })) : (rows.map((row, i) => (_jsx("tr", { className: "board__row", children: columns.map((col) => (_jsx("td", { className: cx('board__td', `board__cell--${col.align ?? 'left'}`), children: col.render(row, i) }, col.key))) }, rowKey(row, i))))) })] })] }));
17
+ return (_jsxs("div", { className: cx('board', className), children: [title || toolbar ? (_jsxs("div", { className: "board__head", children: [title ? _jsx("h2", { className: "board__title", children: title }) : _jsx("span", {}), toolbar ? _jsx("div", { className: "board__toolbar", children: toolbar }) : null] })) : null, _jsxs("table", { className: "board__table", children: [caption ? _jsx("caption", { className: "board__caption", children: caption }) : null, _jsx("thead", { children: _jsx("tr", { children: columns.map((col) => (_jsx("th", { scope: "col", className: cx('board__th', `board__cell--${col.headerAlign ?? col.align ?? 'left'}`), style: col.width ? { width: col.width } : undefined, children: col.header }, col.key))) }) }), _jsx("tbody", { children: loading ? (_jsx("tr", { children: _jsx("td", { className: "board__state", colSpan: columns.length, children: loading }) })) : showEmpty ? (_jsx("tr", { children: _jsx("td", { className: "board__state", colSpan: columns.length, children: empty ?? t('board.empty', '등록된 항목이 없습니다.') }) })) : (rows.map((row, i) => (_jsx("tr", { className: "board__row", children: columns.map((col) => (_jsx("td", { className: cx('board__td', `board__cell--${col.align ?? 'left'}`), children: col.render(row, i) }, col.key))) }, rowKey(row, i))))) })] })] }));
12
18
  }
@@ -10,7 +10,7 @@ import MenuTooltip from './Tooltip/MenuTooltip';
10
10
  // Tooltip.tsx만 이 CSS를 import하는데 BottomNav는 그 컴포넌트를 거치지 않아,
11
11
  // 프로덕션에서 청크가 갈리면 스타일 없이 렌더될 수 있다.
12
12
  import './Tooltip/Tooltip.css';
13
- import { useAnchoredMenu } from '../hooks/useAnchoredMenu';
13
+ import { useAnchoredMenu, anchoredMenuZIndex } from '../hooks/useAnchoredMenu';
14
14
  import { useTranslation } from 'react-i18next';
15
15
  const BTN_SIZE = 52;
16
16
  const BTN_GAP = 10;
@@ -59,17 +59,18 @@ export default function BottomNav({ items = [], activeKey, onChange, appMode, on
59
59
  // 넘친 nav 항목이 있거나 유틸(moreItems)이 있으면 더보기(…) 버튼을 노출
60
60
  const showMore = overflow || moreItems.length > 0;
61
61
  useEffect(() => { 더보기섰음Ref.current = showMore; });
62
- const computeMenuStyle = useCallback((rect) => ({
62
+ const computeMenuStyle = useCallback((rect, trigger) => ({
63
63
  position: 'fixed',
64
64
  bottom: window.innerHeight - rect.top + 8,
65
65
  right: window.innerWidth - rect.right,
66
- zIndex: 10000,
66
+ zIndex: anchoredMenuZIndex(trigger),
67
67
  }), []);
68
68
  const moreMenuId = useId();
69
69
  const { open: moreOpen, toggle: handleToggleMore, close: handleCloseMore, style: menuStyle, triggerRef: moreRef, menuRef } = useAnchoredMenu({
70
70
  computeStyle: computeMenuStyle,
71
71
  onScroll: 'ignore',
72
- onResize: 'ignore',
72
+ /* 기기를 돌리면(가로↔세로) 옛 좌표에 남아 메뉴가 엉뚱한 곳·화면 밖에 떴다 — 다시 잰다 */
73
+ onResize: 'reposition',
73
74
  });
74
75
  /* 단추 이름에 개수를 붙인다 — 배지는 aria-hidden 이라 개수는 이름으로만 전해진다. */
75
76
  const nameWithCount = useCallback((label, count) => count > 0 ? t('nav.badgeLabel', { label, count, defaultValue: '{{label}}, {{count}}개' }) : label, [t]);
@@ -54,6 +54,15 @@
54
54
  color: var(--color-darkGray);
55
55
  }
56
56
 
57
+ /* 갈 곳이 없는 중간 항목 — 링크·단추와 같은 색이되 누를 수 있어 보이지 않게(밑줄·손 모양 없음) */
58
+ .breadcrumb__text {
59
+ color: var(--color-gray);
60
+ }
61
+
62
+ a.breadcrumb__link {
63
+ text-decoration: none;
64
+ }
65
+
57
66
  .breadcrumb__separator {
58
67
  display: inline-flex;
59
68
  align-items: center;
@@ -2,12 +2,23 @@ import './Breadcrumb.css';
2
2
  /** @public */
3
3
  export interface BreadcrumbItem {
4
4
  label: string;
5
+ /** 누르면 부른다 — 주면 단추(`href` 도 주면 링크에 건다). 마지막 항목(지금 쪽)에서는 무시한다. */
5
6
  onClick?: () => void;
7
+ /** 주면 링크(`<a>`)로 그린다. 마지막 항목(지금 쪽)에서는 무시한다. `onClick`·`href` 가 둘 다 없는 중간 항목은 누를 수 없는 글자다. */
8
+ href?: string;
9
+ /** 선택. 링크의 target(`href` 와 함께). */
10
+ target?: string;
11
+ /** 선택. 링크의 rel. `target="_blank"` 이고 안 주면 `noopener noreferrer`. */
12
+ rel?: string;
6
13
  }
7
14
  /** @public */
8
15
  export interface BreadcrumbProps {
9
16
  items: BreadcrumbItem[];
10
17
  className?: string;
11
18
  }
12
- /** @public */
19
+ /**
20
+ * 이동 경로. 마지막 항목은 지금 쪽(`aria-current="page"`, 누를 수 없는 글자 — onClick·href 무시).
21
+ * 앞 항목은 `href` 가 있으면 링크, `onClick` 만 있으면 단추, 둘 다 없으면 글자다.
22
+ * @public
23
+ */
13
24
  export default function Breadcrumb({ items, className }: BreadcrumbProps): import("react").JSX.Element;
@@ -3,13 +3,19 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { useTranslation } from 'react-i18next';
4
4
  import './Breadcrumb.css';
5
5
  import Icon from './Icon';
6
- /** @public */
6
+ /**
7
+ * 이동 경로. 마지막 항목은 지금 쪽(`aria-current="page"`, 누를 수 없는 글자 — onClick·href 무시).
8
+ * 앞 항목은 `href` 가 있으면 링크, `onClick` 만 있으면 단추, 둘 다 없으면 글자다.
9
+ * @public
10
+ */
7
11
  export default function Breadcrumb({ items, className = '' }) {
8
12
  const { t } = useTranslation();
9
13
  return (
10
14
  /* 영어로 박혀 있어 한국어 화면에서도 낭독기가 "breadcrumb" 이라고 읽었다 — 사전을 탄다 */
11
15
  _jsx("nav", { className: `breadcrumb ${className}`.trim(), "aria-label": t('breadcrumb.ariaLabel', '이동 경로'), children: _jsx("ol", { className: "breadcrumb__list", children: items.map((item, index) => {
12
16
  const isLast = index === items.length - 1;
13
- return (_jsxs("li", { className: "breadcrumb__item", children: [isLast ? (_jsx("span", { className: "breadcrumb__current body-3", "aria-current": "page", children: item.label })) : (_jsx("button", { type: "button", className: "breadcrumb__link body-3", onClick: item.onClick, children: item.label })), !isLast && (_jsx(Icon, { icon: "chevron-right", size: 14, className: "breadcrumb__separator" }))] }, index));
17
+ return (_jsxs("li", { className: "breadcrumb__item", children: [isLast ? (_jsx("span", { className: "breadcrumb__current body-3", "aria-current": "page", children: item.label })) : item.href ? (_jsx("a", { className: "breadcrumb__link body-3", href: item.href, target: item.target, rel: item.rel ?? (item.target === '_blank' ? 'noopener noreferrer' : undefined), onClick: item.onClick, children: item.label })) : item.onClick ? (_jsx("button", { type: "button", className: "breadcrumb__link body-3", onClick: item.onClick, children: item.label })) : (
18
+ /* 갈 곳이 없는 중간 항목은 글자 — 전에는 늘 단추라 눌러도 아무 일 없는 단추가 Tab 자리를 차지했다 */
19
+ _jsx("span", { className: "breadcrumb__text body-3", children: item.label })), !isLast && (_jsx(Icon, { icon: "chevron-right", size: 14, className: "breadcrumb__separator" }))] }, index));
14
20
  }) }) }));
15
21
  }
@@ -1,6 +1,8 @@
1
1
  .bulk-bar {
2
2
  position: sticky;
3
3
  bottom: 24px;
4
+ /* 층을 준다 — 없으면(auto) 뒤에 오는 position 있는 내용(표 머리·칩)이 막대 위로 그려졌다. 메뉴·창 아래(tokens.css 서열) */
5
+ z-index: var(--z-bulk-bar, 600);
4
6
  display: flex;
5
7
  align-items: center;
6
8
  justify-content: space-between;
@@ -1,6 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
3
- import { useEffect, useRef, useState } from 'react';
3
+ import { useEffect, useLayoutEffect, useRef, useState } from 'react';
4
+ import { moveFocusOutside } from '../hooks/focusReturn';
4
5
  import { useTranslation } from 'react-i18next';
5
6
  import './BulkBar.css';
6
7
  import Button from './Button';
@@ -14,6 +15,24 @@ export default function BulkBar({ count, actions, onClear, clearLabel: clearLabe
14
15
  줄바꿈 여부를 높이 문턱값으로 재면 칩 크기나 글꼴이 바뀔 때마다 틀리므로,
15
16
  자식들이 실제로 같은 줄에 있는지(offsetTop)를 본다. */
16
17
  const contentRef = useRef(null);
18
+ /* 개수가 0 이 되어 inert 가 될 때 포커스가 안(선택 해제·삭제 단추)에 있으면 body 로 떨어졌다 — 바를 연 요소(바가 나타날 때
19
+ 포커스였던 곳, 대개 방금 누른 체크박스)로, 없으면 바 바로 앞의 포커스 받을 수 있는 요소로 옮긴다.
20
+ inert 가 걸린 뒤 브라우저가 포커스를 치우기 전(레이아웃 효과)에 옮긴다. */
21
+ const barRef = useRef(null);
22
+ const openerRef = useRef(null);
23
+ const prevShowRef = useRef(show);
24
+ useLayoutEffect(() => {
25
+ const bar = barRef.current;
26
+ const 전 = prevShowRef.current;
27
+ prevShowRef.current = show;
28
+ if (show && !전) {
29
+ const a = typeof document !== 'undefined' ? document.activeElement : null;
30
+ openerRef.current = a && a !== document.body && !bar?.contains(a) ? a : null;
31
+ }
32
+ else if (!show && 전 && bar && bar.contains(document.activeElement)) {
33
+ moveFocusOutside(bar, openerRef.current);
34
+ }
35
+ }, [show]);
17
36
  const [stacked, setStacked] = useState(false);
18
37
  /* 세로로 쌓으면 단추 칸이 좁아져 내용 칸이 넓어지고, 넓어진 칸에서 한 줄에 들어가 다시 가로로 돌리면 칸이 좁아져 또 넘친다 —
19
38
  문턱이 하나라 **매 프레임 오갔다**(말귀 단어 고름 막대, 칩 7~12개). 되돌릴 때는 "가로일 때의 칸 폭" 에 한 줄로 들어가는지로
@@ -72,7 +91,7 @@ export default function BulkBar({ count, actions, onClear, clearLabel: clearLabe
72
91
  막대obs.observe(막대);
73
92
  return () => { obs.disconnect(); 막대obs.disconnect(); };
74
93
  }, [content, count]);
75
- return (_jsxs("div", { className: `bulk-bar ${show ? 'bulk-bar--visible' : ''}`,
94
+ return (_jsxs("div", { ref: barRef, className: `bulk-bar ${show ? 'bulk-bar--visible' : ''}`,
76
95
  /* 숨은 막대는 CSS 로 투명(opacity 0)할 뿐 DOM 에 그대로 있다 — 예전에는 그 안의
77
96
  단추가 Tab 순서와 낭독기에 남아, 보이지 않는 '선택 해제'·'삭제' 에 포커스가 갔다.
78
97
  inert 로 포커스·낭독기에서 함께 뺀다(보이는 모양은 CSS 그대로). aria-hidden 은
@@ -1,5 +1,15 @@
1
+ import { type ReactElement, type RefAttributes } from 'react';
1
2
  import './Button.css';
2
3
  export type { ButtonVariant, ButtonLayoutType, ButtonShape, ButtonProps } from './buttonTypes';
3
4
  import type { ButtonProps } from './buttonTypes';
4
- /** @public */
5
- export default function Button(props: ButtonProps): import("react").JSX.Element;
5
+ /**
6
+ * 단추(또는 `href` 가 있으면 링크). `ref` 를 주면 그 요소(`HTMLButtonElement`, 링크면 `HTMLAnchorElement`)에 닿는다 —
7
+ * forwardRef 라 React 16.8~18 에서도 된다.
8
+ * @public
9
+ */
10
+ type ButtonComponent = {
11
+ (props: ButtonProps & RefAttributes<HTMLButtonElement | HTMLAnchorElement>): ReactElement | null;
12
+ displayName?: string;
13
+ };
14
+ declare const Button: ButtonComponent;
15
+ export default Button;
@@ -1,14 +1,16 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
3
3
  import { cx } from '../utils/cx';
4
- import { useCallback, useEffect, useRef, useState } from 'react';
4
+ import { forwardRef, useCallback, useEffect, useId, useRef, useState } from 'react';
5
5
  import './Button.css';
6
6
  import Icon from './Icon';
7
7
  import InfoTooltip from "./Tooltip/InfoTooltip";
8
8
  import DescriptionTooltip from "./Tooltip/DescriptionTooltip";
9
- /** @public */
10
- export default function Button(props) {
11
- const { size = 48, type: explicitType, htmlType, href, target, rel, icon, rightIcon, label: labelProp, count, subLabel, children, ariaLabel, variant = 'default', align = 'center', selected, toggleable = false, disabled = false, useLabel = true, useBorder = true, useBG = true, useDisabledCovered = false, useDisabledInvisible = false, width, minWidth, onClick, onDisabledClick, showTooltipInfo = false, tooltipInfoText = null, tooltipDescription = null, tooltipOnly = false, iconSize = 'medium', iconColor, shape = 'rounded', color, className, ...rest } = props;
9
+ import { isTouchPointer, recentlyTouched, useTouchToggle } from '../hooks/touchToggle';
10
+ /* **ref 는 forwardRef 로 받는다** — React 19 는 ref 를 props 로 넘기지만 peer 는 16.8~ 라, React 18(kscan) 에서는 함수 컴포넌트에 준
11
+ ref 가 버려진다. 게다가 전에는 안쪽 ref(`btnRef`)가 props 에서 온 ref 를 덮어써 19 에서도 부르는 쪽 ref 가 비었다. 두 ref 를 잇는다. */
12
+ function ButtonInner(props, forwardedRef) {
13
+ const { size = 48, type: explicitType, htmlType, href, target, rel, icon, rightIcon, label: labelProp, count, subLabel, children, ariaLabel, variant = 'default', align = 'center', selected, toggleable = false, ariaExpanded, ariaControls, disabled = false, useLabel = true, useBorder = true, useBG = true, useDisabledCovered = false, useDisabledInvisible = false, width, minWidth, onClick, onDisabledClick, showTooltipInfo = false, tooltipInfoText = null, tooltipDescription = null, tooltipOnly = false, iconSize = 'medium', iconColor, shape = 'rounded', color, className, ...rest } = props;
12
14
  const sizeKey = typeof size === 'string' && isNaN(Number(size))
13
15
  ? size.toLowerCase()
14
16
  : String(Number(size) || 48);
@@ -44,11 +46,27 @@ export default function Button(props) {
44
46
  const type = explicitType ?? defaultType;
45
47
  const sizeTypeClass = `${sizeBase}--${type}`;
46
48
  const btnRef = useRef(null);
49
+ /* 안쪽 ref 와 부르는 쪽 ref(객체·함수)를 한 요소에 함께 건다 */
50
+ const 바깥ref = useRef(forwardedRef);
51
+ 바깥ref.current = forwardedRef;
52
+ const 요소잇기 = useCallback((el) => {
53
+ btnRef.current = el;
54
+ const r = 바깥ref.current;
55
+ if (typeof r === 'function')
56
+ r(el);
57
+ else if (r)
58
+ r.current = el;
59
+ }, []);
47
60
  const prevDisabledRef = useRef(disabledProp);
48
61
  const [internalSelected, setInternalSelected] = useState(Boolean(selected));
49
62
  const [tooltipVisible, setTooltipVisible] = useState(false);
50
63
  const [anchorRect, setAnchorRect] = useState(null);
51
64
  const handleTooltipClose = useCallback(() => setTooltipVisible(false), []);
65
+ /* 초점을 가진 채 막히는 단추 — native `disabled` 를 걸면 브라우저가 초점을 body 로 뺀다(쪽 넘김 끝 쪽·순서 목록 위로·
66
+ 글자 크기 끝값처럼 **누른 결과로 그 단추가 막히는** 자리). 초점이 있는 동안에는 `aria-disabled` 로만 막고,
67
+ 초점을 잃으면(blur) native 로 돌아간다. */
68
+ const [hasFocus, setHasFocus] = useState(false);
69
+ const describeId = useId();
52
70
  /* 예전에는 "열린 채로 disabled 가 되면 즉시 닫기" 가 여기 있었다. 그때는
53
71
  막히는 순간 `pointer-events: none` 이 걸려 **pointerleave 가 안 와** 툴팁이
54
72
  영영 붙어 있었기 때문이다. 이제 포인터를 안 끄므로 leave 가 제대로 오고,
@@ -92,9 +110,11 @@ export default function Button(props) {
92
110
  const isSelected = typeof selected === 'boolean' ? Boolean(selected) : internalSelected;
93
111
  // toggleable 또는 controlled selected가 있을 때만 aria-pressed 설정
94
112
  // 그 밖에는 소비자가 넘긴 aria-pressed 를 그대로 둔다(아이콘이 바뀌는 재생/멈춤 단추 등) — 전에는 undefined 로 덮었다
95
- const ariaPressed = (toggleable || typeof selected === 'boolean')
96
- ? (isSelected ? 'true' : 'false')
97
- : rest['aria-pressed'];
113
+ /* 여닫이(ariaExpanded)는 눌림이 아니라 펼침으로 알린다 — 둘을 함께 달면 "눌림, 펼쳐짐" 으로 겹쳐 읽힌다 */
114
+ const ariaPressed = typeof ariaExpanded === 'boolean' ? undefined
115
+ : (toggleable || typeof selected === 'boolean')
116
+ ? (isSelected ? 'true' : 'false')
117
+ : rest['aria-pressed'];
98
118
  const classes = cx('button', sizeBase, sizeTypeClass, variantClass, alignClass, shapeClass, isSelected ? 'is-selected' : null, !useBG ? 'button--no-bg' : null, tooltipOnly ? 'button--tooltip-only' : null, className);
99
119
  const iconClassName = `button__icon button__icon--${iconSize}`;
100
120
  const subLabels = Array.isArray(subLabel) ? subLabel : (subLabel !== undefined ? [subLabel] : []);
@@ -123,7 +143,13 @@ export default function Button(props) {
123
143
  떠남정리(e);
124
144
  };
125
145
  const handleClickInternal = (e) => {
146
+ if (tooltipOnly && hasTooltip)
147
+ 톡토글.touchPress(e.currentTarget);
126
148
  if (disabledProp) {
149
+ if (hasTooltip)
150
+ 톡토글.touchPress(e.currentTarget);
151
+ /* native disabled 가 아닌 막힌 단추(초점 유지·onDisabledClick)는 click 이 온다 — 폼 안이면 제출되지 않게 기본 동작을 막는다 */
152
+ e.preventDefault();
127
153
  onDisabledClick?.(e);
128
154
  return;
129
155
  }
@@ -148,6 +174,14 @@ export default function Button(props) {
148
174
  setAnchorRect(null);
149
175
  }
150
176
  }, [showTooltipInfo, tooltipDescription]);
177
+ /* 터치 — 호버 툴팁이 톡하면 열렸다 떼자마자 닫혀(Android 는 초점으로 다시 열림) 막힌 단추의 사유·칩 설명을 읽을 수 없었다.
178
+ **막힌 단추·tooltipOnly 칩**은 톡으로 열고 닫는다(막힌 단추는 click 이 안 와 pointerup, 포인터조차 안 오면 감싼 요소의 touchend).
179
+ 누르는 단추(삭제·재생 등)는 터치에서 툴팁을 띄우지 않는다 — 누르면 할 일을 하고 끝난다. 마우스 동작은 그대로. */
180
+ const hasTooltip = Boolean(showTooltipInfo || tooltipDescription);
181
+ const 톡토글 = useTouchToggle(tooltipVisible, (next) => { if (next)
182
+ showTooltipFn();
183
+ else
184
+ hideTooltipFn(); }, btnRef, { enabled: hasTooltip && (disabledProp || tooltipOnly), fallbackOnParent: hasTooltip && disabledProp });
151
185
  // restProps에서 Button이 직접 처리하는 props 제거
152
186
  const restProps = { ...rest };
153
187
  delete restProps.disabled;
@@ -159,6 +193,7 @@ export default function Button(props) {
159
193
  delete restProps.onMouseLeave;
160
194
  delete restProps.onPointerEnter;
161
195
  delete restProps.onPointerLeave;
196
+ delete restProps.onPointerUp;
162
197
  delete restProps.onFocus;
163
198
  delete restProps.onBlur;
164
199
  if (disabledProp) {
@@ -180,6 +215,13 @@ export default function Button(props) {
180
215
  const ariaLabelString = typeof ariaLabel === 'string' ? ariaLabel
181
216
  : typeof label === 'string' ? label
182
217
  : tooltipText;
218
+ /* 툴팁 문구를 설명(aria-describedby)으로 잇는다 — 막힌 단추의 사유·칩의 설명이 마우스로만 보이고 낭독되지 않았다.
219
+ 이름과 같은 문구(아이콘 단추의 이름 겸 툴팁)는 두 번 읽히므로 잇지 않는다. */
220
+ const describeText = tooltipDescription
221
+ ? [tooltipDescription.title, ...(tooltipDescription.items ?? [])].filter(Boolean).join('. ')
222
+ : showTooltipInfo && tooltipText && tooltipText !== ariaLabelString ? tooltipText : '';
223
+ const consumerDescribedBy = typeof rest['aria-describedby'] === 'string' ? rest['aria-describedby'] : undefined;
224
+ const ariaDescribedBy = [consumerDescribedBy, describeText ? describeId : null].filter(Boolean).join(' ') || undefined;
183
225
  const renderIcon = (iconVal, label) => (_jsx(Icon, { icon: iconVal, label: label ?? ariaLabelString, className: iconClassName, imgClassName: "button__icon-img", maskClassName: "button__icon-mask", svgClassName: "button__icon-svg" }));
184
226
  const renderContent = () => {
185
227
  if (type === 'dot') {
@@ -193,29 +235,44 @@ export default function Button(props) {
193
235
  /* href 가 있고 비활성이 아니면 링크로 렌더한다. */
194
236
  const isLink = !!href && !disabledProp;
195
237
  const Root = (isLink ? 'a' : 'button');
196
- return (_jsxs(_Fragment, { children: [_jsx(Root, { ...restProps, ref: btnRef, ...(isLink
238
+ return (_jsxs(_Fragment, { children: [_jsxs(Root, { ...restProps, ref: 요소잇기, ...(isLink
197
239
  ? { href, target, rel: rel ?? (target === '_blank' ? 'noreferrer' : undefined) }
198
- : { type: htmlType, disabled: disabledProp && !onDisabledClick }), className: classes, "aria-pressed": ariaPressed, "aria-label": ariaLabelString, "aria-disabled": disabledProp && (isLink || !!onDisabledClick) ? true : undefined, style: style, onClick: handleClickInternal, onMouseLeave: handleMouseLeave, onPointerEnter: (e) => {
199
- showTooltipFn();
240
+ : { type: htmlType, disabled: disabledProp && !onDisabledClick && !hasFocus }), className: classes, "aria-pressed": ariaPressed, "aria-expanded": typeof ariaExpanded === 'boolean' ? ariaExpanded : rest['aria-expanded'], "aria-controls": ariaControls ?? rest['aria-controls'], "aria-label": ariaLabelString, "aria-disabled": disabledProp && (isLink || !!onDisabledClick || hasFocus) ? true : undefined, "aria-describedby": ariaDescribedBy, style: style, onClick: handleClickInternal, onMouseLeave: handleMouseLeave, onPointerEnter: (e) => {
241
+ if (!isTouchPointer(e))
242
+ showTooltipFn();
200
243
  if (!disabledProp && typeof rest.onPointerEnter === 'function')
201
244
  rest.onPointerEnter(e);
202
245
  }, onPointerLeave: (e) => {
203
- hideTooltipFn();
246
+ if (!isTouchPointer(e))
247
+ hideTooltipFn();
204
248
  if (!disabledProp && typeof rest.onPointerLeave === 'function')
205
249
  rest.onPointerLeave(e);
250
+ }, onPointerUp: (e) => {
251
+ // 막힌 단추는 click 이 오지 않는다 — 톡을 뗄 때 뒤집는다
252
+ if (disabledProp && hasTooltip)
253
+ 톡토글.touchPress(e.currentTarget);
254
+ if (!disabledProp && typeof rest.onPointerUp === 'function')
255
+ rest.onPointerUp(e);
206
256
  }, onFocus: (e) => {
207
- showTooltipFn();
257
+ setHasFocus(true);
258
+ // 톡으로 온 초점(Android)은 툴팁을 열지 않는다 — 톡 토글이 정한다
259
+ if (!recentlyTouched())
260
+ showTooltipFn();
208
261
  if (!disabledProp && typeof rest.onFocus === 'function')
209
262
  rest.onFocus(e);
210
263
  }, onBlur: (e) => {
264
+ setHasFocus(false);
211
265
  if (!disabledProp)
212
266
  떠남정리(e);
213
267
  hideTooltipFn();
214
268
  if (!disabledProp && typeof rest.onBlur === 'function')
215
269
  rest.onBlur(e);
216
- }, children: renderContent() }), tooltipVisible && tooltipDescription
270
+ }, children: [renderContent(), describeText ? _jsx("span", { id: describeId, hidden: true, "aria-label": describeText }) : null] }), tooltipVisible && tooltipDescription
217
271
  ? _jsx(DescriptionTooltip, { anchorRect: anchorRect, title: tooltipDescription.title, items: tooltipDescription.items, onClose: handleTooltipClose })
218
272
  : tooltipVisible && tooltipText
219
273
  ? _jsx(InfoTooltip, { anchorRect: anchorRect, text: tooltipText, onClose: handleTooltipClose })
220
274
  : null] }));
221
275
  }
276
+ const Button = forwardRef(ButtonInner);
277
+ Button.displayName = 'Button';
278
+ export default Button;
@@ -11,7 +11,7 @@ export type Visit = {
11
11
  status?: VisitStatus;
12
12
  };
13
13
  /** @public */
14
- export default function Calendar({ visits, onVisitClick, onVisitMove, onDayClick, }: {
14
+ export default function Calendar({ visits, onVisitClick, onVisitMove, onDayClick, today, }: {
15
15
  visits?: Visit[];
16
16
  onVisitClick?: (visit: Visit) => void;
17
17
  onVisitMove?: (visitId: string | number, newDate: string) => void;
@@ -20,4 +20,9 @@ export default function Calendar({ visits, onVisitClick, onVisitMove, onDayClick
20
20
  * 주지 않으면 칸은 눌리지 않고 손 모양도 뜨지 않는다(못 하는 일을 내밀지 않는다).
21
21
  */
22
22
  onDayClick?: (date: string) => void;
23
+ /**
24
+ * 선택. '오늘' 로 쓸 날 'YYYY-MM-DD' — 처음 여는 달과 오늘 표시가 이 날을 따른다.
25
+ * 로컬 앱·SSR 처럼 부르는 쪽이 오늘을 정하는 자리에서 넘긴다. 없거나 꼴이 틀리면 KST 오늘(기본).
26
+ */
27
+ today?: string;
23
28
  }): import("react").JSX.Element;
@@ -4,18 +4,18 @@ import { useCallback, useMemo, useState, useRef } from 'react';
4
4
  import { useTranslation } from 'react-i18next';
5
5
  import Icon from './Icon';
6
6
  import { formatTimeHM } from '../utils/format';
7
- import { getTodayKst } from '../utils/date';
7
+ import { todayYmdOr } from '../utils/date';
8
8
  import './Calendar.css';
9
9
  import { pressable } from '../utils/pressable';
10
10
  function daysInMonth(year, month) { return new Date(year, month + 1, 0).getDate(); }
11
11
  function firstDayOfMonth(year, month) { return new Date(year, month, 1).getDay(); }
12
12
  /** @public */
13
- export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDayClick, }) {
13
+ export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDayClick, today, }) {
14
14
  const { t } = useTranslation();
15
15
  const DOW = useMemo(() => t('calendar.dow', { returnObjects: true }), [t]);
16
16
  /* '오늘' 은 KST 의 날 — 방문 날짜가 KST 달력(toKstYmd)이라, 기기 달력으로 열면 해외 기기에서 오늘 방문이 안 보이는 달에 있고
17
17
  오늘 표시가 다른 날에 붙었다(해외 기기 공식 지원) */
18
- const 오늘Ymd = getTodayKst();
18
+ const 오늘Ymd = todayYmdOr(today);
19
19
  const [year, setYear] = useState(() => Number(오늘Ymd.slice(0, 4)));
20
20
  const [month, setMonth] = useState(() => Number(오늘Ymd.slice(5, 7)) - 1);
21
21
  const [dragOverDate, setDragOverDate] = useState(null);
@@ -10,5 +10,11 @@ export interface DatePickerProps {
10
10
  /** 값이 없을 때 보이는 문구. 기본은 사전의 `datePicker.chooseDate`('날짜 선택') */
11
11
  placeholder?: string;
12
12
  className?: string;
13
+ /**
14
+ * 선택. '오늘' 로 쓸 날 'YYYY-MM-DD' — 오늘 표시, 값 없이 열 때의 달·포커스가 이 날을 따른다.
15
+ * 로컬 앱·SSR 처럼 부르는 쪽이 오늘을 정하는 자리에서 넘긴다. 없거나 꼴이 틀리면 KST 오늘(기본).
16
+ * (min·max 는 이 값과 따로다 — '오늘부터' 를 막으려면 min 에도 같은 날을 넘긴다.)
17
+ */
18
+ today?: string;
13
19
  }
14
- export default function DatePicker({ value, onChange, size, min, max, disabled, placeholder: placeholderProp, className, }: DatePickerProps): import("react").JSX.Element;
20
+ export default function DatePicker({ value, onChange, size, min, max, disabled, placeholder: placeholderProp, className, today, }: DatePickerProps): import("react").JSX.Element;