@ihabdevteam/core 0.128.0 → 0.130.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 (115) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/components.cjs.js +177 -177
  3. package/dist/components.esm.css +2 -2
  4. package/dist/hooks.cjs.js +1 -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 +182 -182
  8. package/dist/ihabdevteam-core.css +1 -1
  9. package/dist/ihabdevteam-core.d.ts +268 -66
  10. package/dist/packages/core/src/components/AudioMeter.js +29 -15
  11. package/dist/packages/core/src/components/Avatar.js +6 -2
  12. package/dist/packages/core/src/components/Badge.d.ts +5 -1
  13. package/dist/packages/core/src/components/Badge.js +19 -5
  14. package/dist/packages/core/src/components/BulkBar.d.ts +2 -1
  15. package/dist/packages/core/src/components/BulkBar.js +9 -2
  16. package/dist/packages/core/src/components/Calendar.js +1 -1
  17. package/dist/packages/core/src/components/ColorPicker.d.ts +3 -1
  18. package/dist/packages/core/src/components/ColorPicker.js +4 -2
  19. package/dist/packages/core/src/components/DatePicker.d.ts +2 -1
  20. package/dist/packages/core/src/components/DatePicker.js +9 -4
  21. package/dist/packages/core/src/components/Effect/EffectPartyPopper.d.ts +9 -0
  22. package/dist/packages/core/src/components/Effect/EffectPartyPopper.js +30 -25
  23. package/dist/packages/core/src/components/EmptyState.js +3 -1
  24. package/dist/packages/core/src/components/ErrorState.js +3 -1
  25. package/dist/packages/core/src/components/FilterPanel.js +7 -0
  26. package/dist/packages/core/src/components/Header.d.ts +6 -0
  27. package/dist/packages/core/src/components/Header.js +25 -1
  28. package/dist/packages/core/src/components/Icon.css +22 -0
  29. package/dist/packages/core/src/components/Icon.d.ts +7 -3
  30. package/dist/packages/core/src/components/Icon.js +7 -1
  31. package/dist/packages/core/src/components/IconGridMenu.css +4 -2
  32. package/dist/packages/core/src/components/IconGridMenu.d.ts +1 -1
  33. package/dist/packages/core/src/components/IconGridMenu.js +6 -3
  34. package/dist/packages/core/src/components/LandingHeader.js +6 -3
  35. package/dist/packages/core/src/components/Loader.d.ts +1 -1
  36. package/dist/packages/core/src/components/Loader.js +7 -2
  37. package/dist/packages/core/src/components/NoticeBox.d.ts +5 -0
  38. package/dist/packages/core/src/components/NoticeBox.js +3 -1
  39. package/dist/packages/core/src/components/Pagination.d.ts +2 -2
  40. package/dist/packages/core/src/components/Pagination.js +2 -2
  41. package/dist/packages/core/src/components/PanelRow.js +5 -1
  42. package/dist/packages/core/src/components/PassCard/PassCard.d.ts +10 -1
  43. package/dist/packages/core/src/components/PaymentPlan.d.ts +8 -1
  44. package/dist/packages/core/src/components/Popup/ConfirmPopup.d.ts +1 -1
  45. package/dist/packages/core/src/components/Popup/ListPopup.d.ts +8 -1
  46. package/dist/packages/core/src/components/Popup/ListPopup.js +17 -2
  47. package/dist/packages/core/src/components/Popup/Popup.css +1 -1
  48. package/dist/packages/core/src/components/Popup/Popup.d.ts +1 -1
  49. package/dist/packages/core/src/components/Popup/TrainingResultPopup.js +3 -2
  50. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.d.ts +4 -1
  51. package/dist/packages/core/src/components/Popup/WordDataUpdatePopup.js +8 -3
  52. package/dist/packages/core/src/components/Popup/customerDetailUtils.d.ts +7 -2
  53. package/dist/packages/core/src/components/Popup/customerDetailUtils.js +20 -3
  54. package/dist/packages/core/src/components/ProgressIndicator.d.ts +2 -5
  55. package/dist/packages/core/src/components/PromoBanner.js +23 -6
  56. package/dist/packages/core/src/components/RadioGroup.d.ts +3 -1
  57. package/dist/packages/core/src/components/RadioGroup.js +2 -2
  58. package/dist/packages/core/src/components/Report/ReportDocument.d.ts +4 -1
  59. package/dist/packages/core/src/components/Report/ReportDocument.js +4 -1
  60. package/dist/packages/core/src/components/Section.d.ts +7 -1
  61. package/dist/packages/core/src/components/Section.js +6 -2
  62. package/dist/packages/core/src/components/Table.d.ts +1 -1
  63. package/dist/packages/core/src/components/TextField.css +2 -1
  64. package/dist/packages/core/src/components/TextField.js +10 -2
  65. package/dist/packages/core/src/components/Toggle.js +4 -1
  66. package/dist/packages/core/src/components/Tooltip/DebugTooltip.d.ts +1 -6
  67. package/dist/packages/core/src/components/Tooltip/DebugTooltip.js +4 -7
  68. package/dist/packages/core/src/components/Tooltip/Tooltip.js +11 -1
  69. package/dist/packages/core/src/components/Training/AudioPlayerWaveForm.js +3 -3
  70. package/dist/packages/core/src/components/Training/TrainingBlock.d.ts +8 -3
  71. package/dist/packages/core/src/components/Training/TrainingStepConfirmWithText.js +10 -11
  72. package/dist/packages/core/src/components/Training/trainingBlockButtonTypes.d.ts +4 -4
  73. package/dist/packages/core/src/components/Training/trainingScenario.d.ts +6 -2
  74. package/dist/packages/core/src/components/Word/WordSearchResultBlock.js +3 -1
  75. package/dist/packages/core/src/components/Word/WordTableBlock.js +6 -0
  76. package/dist/packages/core/src/components/Word/WordTableButton.d.ts +2 -2
  77. package/dist/packages/core/src/components/Word/WordTableButton.js +1 -1
  78. package/dist/packages/core/src/components/Word/wordTableBlockTypes.d.ts +1 -1
  79. package/dist/packages/core/src/components/Word/wordTableScoring.d.ts +1 -4
  80. package/dist/packages/core/src/components/Word/wordTableScoring.js +1 -4
  81. package/dist/packages/core/src/components/WordTable.d.ts +1 -1
  82. package/dist/packages/core/src/components/WordTable.js +3 -1
  83. package/dist/packages/core/src/components.d.ts +3 -0
  84. package/dist/packages/core/src/components.js +5 -0
  85. package/dist/packages/core/src/hooks/useDeviceManagement.js +11 -2
  86. package/dist/packages/core/src/hooks/useFontScale.js +3 -1
  87. package/dist/packages/core/src/hooks/useLucideIcon.d.ts +3 -4
  88. package/dist/packages/core/src/hooks/useLucideIcon.js +3 -4
  89. package/dist/packages/core/src/hooks/usePasswordChange.js +20 -7
  90. package/dist/packages/core/src/hooks/useVisitAllPanel.d.ts +7 -0
  91. package/dist/packages/core/src/hooks/useVisitAllPanel.js +8 -0
  92. package/dist/packages/core/src/hooks/useWordMeanings.js +3 -0
  93. package/dist/packages/core/src/hooks/useWordTrainingFilter.d.ts +5 -0
  94. package/dist/packages/core/src/hooks/useWordTrainingFilter.js +23 -33
  95. package/dist/packages/core/src/i18n/locales/en.json +32 -5
  96. package/dist/packages/core/src/i18n/locales/ko.json +32 -5
  97. package/dist/packages/core/src/tokens.css +2 -1
  98. package/dist/packages/core/src/utils/CategoryData.js +2 -2
  99. package/dist/packages/core/src/utils/assets.js +7 -3
  100. package/dist/packages/core/src/utils/clipboard.js +16 -2
  101. package/dist/packages/core/src/utils/errorMessage.d.ts +2 -1
  102. package/dist/packages/core/src/utils/errorMessage.js +19 -2
  103. package/dist/packages/core/src/utils/getTrainingData.d.ts +8 -1
  104. package/dist/packages/core/src/utils/getTrainingData.js +8 -1
  105. package/dist/packages/core/src/utils/koreanSyllable.d.ts +15 -12
  106. package/dist/packages/core/src/utils/koreanSyllable.js +16 -13
  107. package/dist/packages/core/src/utils/lucideIconSet.d.ts +4 -3
  108. package/dist/packages/core/src/utils/lucideIconSet.js +4 -3
  109. package/dist/packages/core/src/utils/ttsResolver.d.ts +5 -0
  110. package/dist/packages/core/src/utils/wordTrainingGenerators.d.ts +3 -3
  111. package/dist/packages/core/src/utils/wordTrainingGenerators.js +28 -7
  112. package/dist/packages/core/src/version.d.ts +1 -1
  113. package/dist/packages/core/src/version.js +1 -1
  114. package/dist/utils.cjs.js +2 -2
  115. package/package.json +4 -2
@@ -13,8 +13,10 @@ const VALUE_UPDATE_MS = 250;
13
13
  // 실제 dB SPL(물리 음압)은 기기·볼륨 캘리브레이션 없이는 알 수 없어, 대신 임의 기준점
14
14
  // 대비 상대값임을 라벨(dBFS가 아닌 dB)과 부호로 드러낸다.
15
15
  // 부호는 항상 표시(우측 정렬 + 고정폭 박스라 자릿수가 바뀌어도 숫자 끝 위치는 고정된다).
16
+ // 기준점 대비 상대 dB 를 표시 범위로 조이고 반올림한 값 — 글자와 aria-valuenow 가 같은 수를 쓴다.
17
+ const relDbValue = (dbfs, refDb, displayMin, displayMax) => Math.round(Math.min(displayMax, Math.max(displayMin, dbfs - refDb)));
16
18
  const formatRelDb = (dbfs, refDb, displayMin, displayMax) => {
17
- const rel = Math.round(Math.min(displayMax, Math.max(displayMin, dbfs - refDb)));
19
+ const rel = relDbValue(dbfs, refDb, displayMin, displayMax);
18
20
  const sign = rel < 0 ? '-' : '+';
19
21
  return `${sign}${Math.abs(rel)} dB`;
20
22
  };
@@ -24,6 +26,7 @@ const formatRelDb = (dbfs, refDb, displayMin, displayMax) => {
24
26
  * @public
25
27
  */
26
28
  export default function AudioMeter({ analyser, audioElement, orientation = 'horizontal', length, label, labelPos, labelAlign = 'right', floorDb = -60, refDb = -35, displayMin = -5, displayMax = 25, className, ariaLabel, }) {
29
+ const rootRef = useRef(null);
27
30
  const fillRef = useRef(null);
28
31
  const valueRef = useRef(null);
29
32
  const rafRef = useRef(null);
@@ -31,12 +34,24 @@ export default function AudioMeter({ analyser, audioElement, orientation = 'hori
31
34
  audioMeter 머리말). resume 뒤 태워지면 그 노드로 다시 돈다. */
32
35
  const [routed, setRouted] = useState(null);
33
36
  useEffect(() => {
37
+ /* role="meter" 는 값이 있어야 미터다 — 예전엔 aria-valuenow·min·max 가 없어 낭독기가
38
+ "미터" 라고만 읽었다. 값은 **글자와 같은 주기(VALUE_UPDATE_MS)** 로만 바꾼다: 매 프레임
39
+ 속성을 바꾸면 낭독기가 쉴 새 없이 값 변화를 잡는다. 리렌더 없이 속성만 고친다(막대와 같은 방식). */
40
+ const writeValue = (dbfs) => {
41
+ const text = formatRelDb(dbfs, refDb, displayMin, displayMax);
42
+ if (valueRef.current)
43
+ valueRef.current.textContent = text;
44
+ const root = rootRef.current;
45
+ if (root) {
46
+ root.setAttribute('aria-valuenow', String(relDbValue(dbfs, refDb, displayMin, displayMax)));
47
+ root.setAttribute('aria-valuetext', text);
48
+ }
49
+ };
34
50
  const node = analyser ?? getElementAnalyser(audioElement) ?? (routed && routed.el === audioElement ? routed.node : null);
35
51
  const fill = fillRef.current;
36
52
  if (!node || !fill) {
37
53
  fill?.style.setProperty('--meter-level', '0');
38
- if (valueRef.current)
39
- valueRef.current.textContent = formatRelDb(floorDb, refDb, displayMin, displayMax);
54
+ writeValue(floorDb);
40
55
  let cancelled = false;
41
56
  if (!analyser && audioElement) {
42
57
  void routeElementAudio(audioElement).then((n) => { if (!cancelled && n)
@@ -66,17 +81,16 @@ export default function AudioMeter({ analyser, audioElement, orientation = 'hori
66
81
  const relDb = dbClamped - refDb;
67
82
  const fillRatio = Math.min(1, Math.max(0, (relDb - displayMin) / (displayMax - displayMin)));
68
83
  fill.style.setProperty('--meter-level', fillRatio.toFixed(3));
69
- if (valueRef.current) {
70
- dbSum += dbClamped;
71
- dbCount += 1;
72
- if (now - lastUpdate >= VALUE_UPDATE_MS) {
73
- const windowAvg = dbSum / dbCount;
74
- displayedDb = displayedDb == null ? windowAvg : displayedDb * 0.6 + windowAvg * 0.4;
75
- valueRef.current.textContent = formatRelDb(displayedDb, refDb, displayMin, displayMax);
76
- dbSum = 0;
77
- dbCount = 0;
78
- lastUpdate = now;
79
- }
84
+ // 글자가 없어도(label 생략) aria-valuenow 는 갱신해야 하므로 평균은 늘 낸다
85
+ dbSum += dbClamped;
86
+ dbCount += 1;
87
+ if (now - lastUpdate >= VALUE_UPDATE_MS) {
88
+ const windowAvg = dbSum / dbCount;
89
+ displayedDb = displayedDb == null ? windowAvg : displayedDb * 0.6 + windowAvg * 0.4;
90
+ writeValue(displayedDb);
91
+ dbSum = 0;
92
+ dbCount = 0;
93
+ lastUpdate = now;
80
94
  }
81
95
  rafRef.current = requestAnimationFrame(tick);
82
96
  };
@@ -95,5 +109,5 @@ export default function AudioMeter({ analyser, audioElement, orientation = 'hori
95
109
  const track = (_jsx("div", { className: "audio-meter__track", style: trackStyle, children: _jsx("div", { ref: fillRef, className: "audio-meter__fill" }) }));
96
110
  // 자리를 지정하지 않으면 방향이 정한다 — 가로 막대 옆(오른쪽), 세로 막대 아래.
97
111
  const pos = labelPos ?? (orientation === 'vertical' ? 'bottom' : 'right');
98
- return (_jsxs("div", { className: cx('audio-meter', `audio-meter--${orientation}`, label && `audio-meter--label-${pos}`, label && `audio-meter--align-${labelAlign}`, className), role: "meter", "aria-label": ariaLabel, children: [track, label && (_jsx("span", { ref: valueRef, className: "audio-meter__value caption-2", children: formatRelDb(floorDb, refDb, displayMin, displayMax) }))] }));
112
+ return (_jsxs("div", { ref: rootRef, className: cx('audio-meter', `audio-meter--${orientation}`, label && `audio-meter--label-${pos}`, label && `audio-meter--align-${labelAlign}`, className), role: "meter", "aria-label": ariaLabel, "aria-valuemin": displayMin, "aria-valuemax": displayMax, "aria-valuenow": relDbValue(floorDb, refDb, displayMin, displayMax), "aria-valuetext": formatRelDb(floorDb, refDb, displayMin, displayMax), children: [track, label && (_jsx("span", { ref: valueRef, className: "audio-meter__value caption-2", children: formatRelDb(floorDb, refDb, displayMin, displayMax) }))] }));
99
113
  }
@@ -22,10 +22,14 @@ function getInitials(name) {
22
22
  */
23
23
  export default function Avatar({ src, name, size = 'md', shape = 'circle', className = '' }) {
24
24
  const { t } = useTranslation();
25
- const [imgFailed, setImgFailed] = useState(false);
25
+ // 실패는 **그 주소에 대해서만** 기억한다. 불리언 하나로 두면 한 번 깨진 뒤 새 src 가 와도
26
+ // 계속 이니셜로 남았다(목록 줄을 재사용하거나 사진을 바꿔 올린 경우). 렌더 중에 비교하므로
27
+ // 이펙트로 되돌리는 한 틀 깜빡임도 없다.
28
+ const [failedSrc, setFailedSrc] = useState(null);
29
+ const imgFailed = !!src && failedSrc === src;
26
30
  const initials = name ? getInitials(name) : '';
27
31
  const showImage = !!src && !imgFailed;
28
32
  const showInitials = !showImage && !!initials;
29
33
  const label = name || t('avatar.ariaLabel', '사용자 아바타');
30
- return (_jsxs("span", { className: `avatar avatar--${size} avatar--${shape} ${className}`.trim(), role: "img", "aria-label": label, children: [showImage && (_jsx("img", { className: "avatar__image", src: src, alt: label, onError: () => setImgFailed(true) })), !showImage && showInitials && (_jsx("span", { className: "avatar__initials", "aria-hidden": "true", children: initials })), !showImage && !showInitials && (_jsx(Icon, { icon: "user-round", size: ICON_SIZE[size], className: "avatar__icon" }))] }));
34
+ return (_jsxs("span", { className: `avatar avatar--${size} avatar--${shape} ${className}`.trim(), role: "img", "aria-label": label, children: [showImage && (_jsx("img", { className: "avatar__image", src: src, alt: label, onError: () => setFailedSrc(src ?? null) })), !showImage && showInitials && (_jsx("span", { className: "avatar__initials", "aria-hidden": "true", children: initials })), !showImage && !showInitials && (_jsx(Icon, { icon: "user-round", size: ICON_SIZE[size], className: "avatar__icon" }))] }));
31
35
  }
@@ -27,6 +27,10 @@ export interface BadgeProps {
27
27
  */
28
28
  size?: 'small' | 'medium' | 'large' | BadgeDotSize;
29
29
  className?: string;
30
+ /**
31
+ * 접근성 이름. dot 은 주지 않으면 모양에 따라 '선택됨'·'제거'·'제외'·'N번째 선택' 으로 짓고,
32
+ * bar 는 주면 그 이름으로 읽힌다(안 주면 글자 그대로 읽힌다).
33
+ */
30
34
  'aria-label'?: string;
31
35
  label?: string;
32
36
  children?: ReactNode;
@@ -54,7 +58,7 @@ export interface BadgeProps {
54
58
  /** 우측 아이콘 (bar 전용). Lucide 아이콘 이름 또는 ReactNode */
55
59
  iconRight?: ReactNode | string;
56
60
  /**
57
- * 아이콘 종류 (dot 전용)
61
+ * 아이콘 종류 (dot 전용). `number` 가 없고 이것도 없으면 `check` 로 그린다.
58
62
  * - `check` — 선택/완료 (파란 체크)
59
63
  * - `remove` — 제거 (빨간 ×)
60
64
  * - `minus` — 비활성/제외 (회색 —)
@@ -1,5 +1,7 @@
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 Icon from './Icon';
4
6
  import './Badge.css';
5
7
  // ── 내부 아이콘 ────────────────────────────────────────────────────────────
@@ -15,17 +17,26 @@ function MinusIcon() {
15
17
  // ── 컴포넌트 ───────────────────────────────────────────────────────────────
16
18
  /** @public */
17
19
  export default function Badge(props) {
20
+ const { t } = useTranslation();
18
21
  const { type = 'bar', size, className = '', 'aria-label': ariaLabel, } = props;
19
22
  // ── dot (원형) ──────────────────────────────────────────────────────────
20
23
  if (type === 'dot') {
21
24
  const { icon, number, position = 'top-right', onClick, animate = false } = props;
22
- const dotVariant = number !== undefined ? 'number' : icon ?? 'check';
25
+ // icon 을 안 주면 check 다 — 클래스(파란 바탕)·기본 이름('선택됨')이 이미 check 로 가는데
26
+ // 그리는 쪽만 `icon === 'check'` 을 봐서 **체크 없는 파란 원**이 나왔다. 셋이 같은 값을 보게 한다.
27
+ const dotIcon = icon ?? 'check';
28
+ const dotVariant = number !== undefined ? 'number' : dotIcon;
23
29
  // dot 크기는 Button과 같은 숫자 스케일 — 문자열이 오면 기본값(32)으로 떨어뜨린다.
24
30
  const dotSize = typeof size === 'number' ? size : 32;
25
31
  const cls = cx('circle-badge', `circle-badge--${dotVariant}`, `circle-badge--${position}`, `circle-badge--${dotSize}`, animate ? 'circle-badge--animate' : '', onClick ? 'circle-badge--clickable' : '', className);
32
+ // 기본 이름을 한국어로 박아 두면 영문 화면에서도 낭독기만 한국어로 읽었다 — 사전을 거친다.
26
33
  const defaultLabel = number !== undefined
27
- ? `${number}번째 선택`
28
- : { check: '선택됨', remove: '제거', minus: '제외' }[icon ?? 'check'];
34
+ ? t('badge.nthSelected', { n: number, defaultValue: '{{n}}번째 선택' })
35
+ : {
36
+ check: t('badge.selected', '선택됨'),
37
+ remove: t('badge.remove', '제거'),
38
+ minus: t('badge.exclude', '제외'),
39
+ }[dotIcon];
29
40
  return (_jsxs("span", { className: cls, onClick: onClick,
30
41
  /* `<span role="button">` 은 브라우저가 Enter·Space 를 눌림으로 바꿔 주지 않는다.
31
42
  tabIndex 만 있으면 키보드 사용자는 **그 자리에 멈추고도 아무것도 못 한다** —
@@ -38,7 +49,7 @@ export default function Badge(props) {
38
49
  내게** 한다 — 그래야 이벤트의 target·currentTarget 도 실제와 같다. */
39
50
  e.preventDefault();
40
51
  e.currentTarget.click();
41
- } : undefined, "aria-label": ariaLabel ?? defaultLabel, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, children: [number !== undefined && number, number === undefined && icon === 'check' && _jsx(CheckIcon, {}), number === undefined && icon === 'remove' && _jsx(RemoveIcon, {}), number === undefined && icon === 'minus' && _jsx(MinusIcon, {})] }));
52
+ } : undefined, "aria-label": ariaLabel ?? defaultLabel, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, children: [number !== undefined && number, number === undefined && dotIcon === 'check' && _jsx(CheckIcon, {}), number === undefined && dotIcon === 'remove' && _jsx(RemoveIcon, {}), number === undefined && dotIcon === 'minus' && _jsx(MinusIcon, {})] }));
42
53
  }
43
54
  // ── bar (텍스트) ────────────────────────────────────────────────────────
44
55
  const { label, children, variant = 'default', fill = 'filled', minWidth, iconLeft, iconRight, color } = props;
@@ -58,5 +69,8 @@ export default function Badge(props) {
58
69
  ...(resolvedColor ? { '--badge-color': resolvedColor } : {}),
59
70
  }
60
71
  : undefined;
61
- return (_jsxs("span", { className: cls, style: style, children: [iconLeft && _jsx(Icon, { icon: iconLeft, size: iconSize, className: "badge__icon" }), label ?? children, iconRight && _jsx(Icon, { icon: iconRight, size: iconSize, className: "badge__icon" })] }));
72
+ return (
73
+ // aria-label 을 받아 놓고 bar 에서는 버렸다 — dot 과 같이 건넨다. 이름만 두면 역할 없는
74
+ // span 이라 낭독기가 무시하므로 이름이 있을 때만 role="img" 를 붙인다(안 주면 예전 그대로).
75
+ _jsxs("span", { className: cls, style: style, "aria-label": ariaLabel, role: ariaLabel ? 'img' : undefined, children: [iconLeft && _jsx(Icon, { icon: iconLeft, size: iconSize, className: "badge__icon" }), label ?? children, iconRight && _jsx(Icon, { icon: iconRight, size: iconSize, className: "badge__icon" })] }));
62
76
  }
@@ -11,6 +11,7 @@ export interface BulkBarProps {
11
11
  count: number;
12
12
  actions: BulkAction[];
13
13
  onClear: () => void;
14
+ /** 선택 해제 단추 글자. 기본은 사전의 `bulkBar.clearSelection`('선택 해제') */
14
15
  clearLabel?: string;
15
16
  /** count > 0 일 때 자동으로 visible */
16
17
  visible?: boolean;
@@ -19,4 +20,4 @@ export interface BulkBarProps {
19
20
  /** 제공하면 액션 버튼 왼쪽에 "count / max" 뱃지를 표시 */
20
21
  max?: number;
21
22
  }
22
- export default function BulkBar({ count, actions, onClear, clearLabel, visible, content, max, }: BulkBarProps): import("react").JSX.Element;
23
+ export default function BulkBar({ count, actions, onClear, clearLabel: clearLabelProp, visible, content, max, }: BulkBarProps): import("react").JSX.Element;
@@ -5,9 +5,11 @@ import { useTranslation } from 'react-i18next';
5
5
  import './BulkBar.css';
6
6
  import Button from './Button';
7
7
  import Badge from './Badge';
8
- export default function BulkBar({ count, actions, onClear, clearLabel = '선택 해제', visible, content, max, }) {
8
+ export default function BulkBar({ count, actions, onClear, clearLabel: clearLabelProp, visible, content, max, }) {
9
9
  const { t } = useTranslation();
10
10
  const show = visible !== undefined ? visible : count > 0;
11
+ /* 기본 문구도 사전에서 — 예전에는 '선택 해제' 가 박혀 영어 화면에서도 한국어였다 */
12
+ const clearLabel = clearLabelProp ?? t('bulkBar.clearSelection', '선택 해제');
11
13
  /* 내용이 한 줄이면 액션을 가로로, 두 줄 이상이면 세로로 둔다.
12
14
  줄바꿈 여부를 높이 문턱값으로 재면 칩 크기나 글꼴이 바뀔 때마다 틀리므로,
13
15
  자식들이 실제로 같은 줄에 있는지(offsetTop)를 본다. */
@@ -36,5 +38,10 @@ export default function BulkBar({ count, actions, onClear, clearLabel = '선택
36
38
  obs.observe(el);
37
39
  return () => obs.disconnect();
38
40
  }, [content, count]);
39
- return (_jsxs("div", { className: `bulk-bar ${show ? 'bulk-bar--visible' : ''}`, children: [_jsx("span", { ref: contentRef, className: "bulk-bar__count body-2", children: content ?? _jsx("b", { children: t('common.selectedCount', { count }) }) }), max !== undefined && (_jsx(Badge, { size: "small", fill: "soft", variant: "main", label: `${count} / ${max}` })), _jsxs("div", { className: `bulk-bar__actions${stacked ? ' bulk-bar__actions--stacked' : ''}`, children: [_jsx(Button, { size: 40, label: clearLabel, onClick: onClear }), actions.map((a, i) => (_jsx(Button, { size: 40, variant: a.variant ?? 'default', icon: a.icon, label: a.label, onClick: a.onClick }, i)))] })] }));
41
+ return (_jsxs("div", { className: `bulk-bar ${show ? 'bulk-bar--visible' : ''}`,
42
+ /* 숨은 막대는 CSS 로 투명(opacity 0)할 뿐 DOM 에 그대로 있다 — 예전에는 그 안의
43
+ 단추가 Tab 순서와 낭독기에 남아, 보이지 않는 '선택 해제'·'삭제' 에 포커스가 갔다.
44
+ inert 로 포커스·낭독기에서 함께 뺀다(보이는 모양은 CSS 그대로). aria-hidden 은
45
+ inert 를 모르는 오래된 브라우저를 위한 덧댐이다. */
46
+ inert: !show || undefined, "aria-hidden": show ? undefined : true, children: [_jsx("span", { ref: contentRef, className: "bulk-bar__count body-2", children: content ?? _jsx("b", { children: t('common.selectedCount', { count }) }) }), max !== undefined && (_jsx(Badge, { size: "small", fill: "soft", variant: "main", label: `${count} / ${max}` })), _jsxs("div", { className: `bulk-bar__actions${stacked ? ' bulk-bar__actions--stacked' : ''}`, children: [_jsx(Button, { size: 40, label: clearLabel, onClick: onClear }), actions.map((a, i) => (_jsx(Button, { size: 40, variant: a.variant ?? 'default', icon: a.icon, label: a.label, onClick: a.onClick }, i)))] })] }));
40
47
  }
@@ -52,7 +52,7 @@ export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDay
52
52
  setMonth(m => m + 1); }, [month]);
53
53
  const fmtDate = (d) => `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
54
54
  const isToday = (d) => today.getFullYear() === year && today.getMonth() === month && today.getDate() === d;
55
- return (_jsxs("div", { children: [_jsxs("div", { className: "calendar-nav", children: [_jsx("button", { className: "calendar-nav__button", onClick: prev, "aria-label": t('calendar.prevMonth', '이전 달'), children: _jsx(Icon, { icon: "chevron-left", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsx("span", { className: "calendar-nav__title h5", children: t('calendar.yearMonth', { year, month: month + 1 }) }), _jsx("button", { className: "calendar-nav__button", onClick: next, "aria-label": t('calendar.nextMonth', '다음 달'), children: _jsx(Icon, { icon: "chevron-right", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsxs("div", { className: "calendar-nav__legend", children: [_jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--main" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.visited', '방문') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--sub" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.scheduled', '예약') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--disabled" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.noVisit', '미방문') })] })] }), _jsxs("div", { className: "calendar-grid", children: [DOW.map(d => _jsx("div", { className: "calendar-day-header body-3", children: d }, d)), cells.map((d, i) => {
55
+ return (_jsxs("div", { children: [_jsxs("div", { className: "calendar-nav", children: [_jsx("button", { type: "button", className: "calendar-nav__button", onClick: prev, "aria-label": t('calendar.prevMonth', '이전 달'), children: _jsx(Icon, { icon: "chevron-left", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsx("span", { className: "calendar-nav__title h5", children: t('calendar.yearMonth', { year, month: month + 1 }) }), _jsx("button", { type: "button", className: "calendar-nav__button", onClick: next, "aria-label": t('calendar.nextMonth', '다음 달'), children: _jsx(Icon, { icon: "chevron-right", label: "", className: "", svgClassName: "calendar-nav__button-icon", size: 18 }) }), _jsxs("div", { className: "calendar-nav__legend", children: [_jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--main" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.visited', '방문') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--sub" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.scheduled', '예약') }), _jsx("span", { className: "calendar-nav__legend-dot calendar-nav__legend-dot--disabled" }), _jsx("span", { className: "caption-1", children: t('calendar.legend.noVisit', '미방문') })] })] }), _jsxs("div", { className: "calendar-grid", children: [DOW.map(d => _jsx("div", { className: "calendar-day-header body-3", children: d }, d)), cells.map((d, i) => {
56
56
  if (d === null)
57
57
  return _jsx("div", { className: "calendar-cell empty" }, `empty-${i}`);
58
58
  const day = d;
@@ -12,6 +12,8 @@ export interface ColorPickerProps {
12
12
  /** 스와치 크기 — Button과 동일한 체계 (32 / 40 / 48), radius도 Button rounded와 동일하게 맞춤 */
13
13
  size?: ColorPickerSize;
14
14
  className?: string;
15
+ /** 묶음의 이름(예: '글자 색') — 낭독기가 무엇의 색을 고르는지 읽는다 */
16
+ ariaLabel?: string;
15
17
  }
16
18
  /** @public */
17
- export default function ColorPicker({ value, onChange, palette, size, className, }: ColorPickerProps): import("react").JSX.Element;
19
+ export default function ColorPicker({ value, onChange, palette, size, className, ariaLabel, }: ColorPickerProps): import("react").JSX.Element;
@@ -14,9 +14,11 @@ const DEFAULT_PALETTE = [
14
14
  '#bfc7d1', // disabled
15
15
  ];
16
16
  /** @public */
17
- export default function ColorPicker({ value, onChange, palette = DEFAULT_PALETTE, size = 40, className = '', }) {
17
+ export default function ColorPicker({ value, onChange, palette = DEFAULT_PALETTE, size = 40, className = '', ariaLabel, }) {
18
18
  const { t } = useTranslation();
19
- return (_jsx("div", { className: `color-picker ${className}`.trim(), role: "group", children: palette.map(hex => {
19
+ return (
20
+ /* role="group" 은 이름이 없으면 낭독기가 묶음을 알리지 않는다 — 이름을 받는다 */
21
+ _jsx("div", { className: `color-picker ${className}`.trim(), role: "group", "aria-label": ariaLabel, children: palette.map(hex => {
20
22
  const isSelected = value?.toLowerCase() === hex.toLowerCase();
21
23
  return (_jsx("button", { type: "button", className: `color-picker__swatch color-picker__swatch--${size}`, style: { backgroundColor: hex }, onClick: () => onChange(hex), "aria-pressed": isSelected, "aria-label": isSelected ? `${hex} ${t('colorPicker.selected', '선택됨')}` : hex, children: isSelected && (_jsx(Icon, { icon: "check", size: CHECK_ICON_SIZE, className: "color-picker__check" })) }, hex));
22
24
  }) }));
@@ -7,7 +7,8 @@ export interface DatePickerProps {
7
7
  min?: string;
8
8
  max?: string;
9
9
  disabled?: boolean;
10
+ /** 값이 없을 때 보이는 문구. 기본은 사전의 `datePicker.chooseDate`('날짜 선택') */
10
11
  placeholder?: string;
11
12
  className?: string;
12
13
  }
13
- export default function DatePicker({ value, onChange, size, min, max, disabled, placeholder, className, }: DatePickerProps): import("react").JSX.Element;
14
+ export default function DatePicker({ value, onChange, size, min, max, disabled, placeholder: placeholderProp, className, }: DatePickerProps): import("react").JSX.Element;
@@ -28,7 +28,9 @@ function formatDisplay(iso) {
28
28
  const [y, m, d] = iso.split('-');
29
29
  return `${y}. ${m}. ${d}.`;
30
30
  }
31
- function buildYMOptions(viewY, min, max) {
31
+ /* 연월 이름은 `연월이름` 으로 받는다 — 예전에는 `${y}년 ${m + 1}월` 로 박혀 있어 영어 화면에서도
32
+ 한국어 연월이 떴다. 부르는 쪽이 사전(`calendar.yearMonth`, Calendar 와 같은 열쇠)을 넘긴다. */
33
+ function buildYMOptions(viewY, 연월이름, min, max) {
32
34
  const startY = min ? +min.split('-')[0] : viewY - 10;
33
35
  const endY = max ? +max.split('-')[0] : viewY + 10;
34
36
  const opts = [];
@@ -39,13 +41,16 @@ function buildYMOptions(viewY, min, max) {
39
41
  continue;
40
42
  if (max && ym > max.slice(0, 7))
41
43
  continue;
42
- opts.push({ value: ym, label: `${y}년 ${m + 1}월` });
44
+ opts.push({ value: ym, label: 연월이름(y, m + 1) });
43
45
  }
44
46
  }
45
47
  return opts;
46
48
  }
47
- export default function DatePicker({ value, onChange, size = 48, min, max, disabled, placeholder = '날짜 선택', className, }) {
49
+ export default function DatePicker({ value, onChange, size = 48, min, max, disabled, placeholder: placeholderProp, className, }) {
48
50
  const { t } = useTranslation();
51
+ /* 기본 안내 문구도 사전에서 — 예전에는 '날짜 선택' 이 박혀 있어 영어 화면에서도 한국어였다.
52
+ 달력 판의 이름(`datePicker.chooseDate`)과 같은 말이라 같은 열쇠를 쓴다. */
53
+ const placeholder = placeholderProp ?? t('datePicker.chooseDate', '날짜 선택');
49
54
  /* 요일은 **사전에서** 가져온다. 예전에는 이 파일에 `['일','월',…]` 로 박혀 있어
50
55
  영어 화면의 날짜 고르개만 한국어 요일이 떴다 — 같은 저장소의 `Calendar` 는
51
56
  진작 `calendar.dow` 를 쓰고 있었고, 사전에는 영어(`Sun`·`Mon`…)도 들어 있었다. */
@@ -200,7 +205,7 @@ export default function DatePicker({ value, onChange, size = 48, min, max, disab
200
205
  for (let d = 1; d <= daysInMonth(viewY, viewM); d++)
201
206
  cells.push(d);
202
207
  const iconSize = size === 64 ? 20 : size === 40 ? 16 : 18;
203
- const ymOptions = buildYMOptions(viewY, min, max);
208
+ const ymOptions = buildYMOptions(viewY, (year, month) => t('calendar.yearMonth', { year, month, defaultValue: '{{year}}년 {{month}}월' }), min, max);
204
209
  const ymValue = `${viewY}-${String(viewM + 1).padStart(2, '0')}`;
205
210
  const dropdown = open ? createPortal(_jsxs("div", { ref: menuRef, className: "date-picker__dropdown", style: dropdownStyle, role: "dialog", "aria-label": t('datePicker.chooseDate', '날짜 선택'), tabIndex: -1, children: [_jsxs("div", { className: "date-picker__nav", children: [_jsx("button", { type: "button", className: "date-picker__nav-btn", onClick: prevMonth, "aria-label": t('calendar.prevMonth', '이전 달'), children: _jsx(ChevronLeft, { size: 16, strokeWidth: 2 }) }), _jsx(Dropdown, { className: "date-picker__nav-dropdown", ariaLabel: t('datePicker.selectYearMonth', '연월 선택'), options: ymOptions, value: ymValue, onChange: (v) => {
206
211
  const [y, m] = v.split('-');
@@ -2,10 +2,19 @@ import "./EffectPartyPopper.css";
2
2
  /** @public */
3
3
  export default function EffectPartyPopper({ active, count, duration, strength, // multiplier for speed/spread
4
4
  onComplete, trigger, }: {
5
+ /** true 인 동안 그린다. false 면 아무것도 안 그린다. */
5
6
  active?: boolean;
7
+ /** 조각 수. 좌우로 반씩 나눈다. 기본 40. */
6
8
  count?: number;
9
+ /**
10
+ * 조각 수명의 기준(ms). 값에 비례해 모든 조각이 더 오래 또는 더 짧게 날다 사라진다 — 기본 2000 에서
11
+ * 조각 하나는 약 1.5~5.5초 난다(조각마다 다르고, strength 가 크면 조금 길다).
12
+ */
7
13
  duration?: number;
14
+ /** 속도·퍼짐 배율. 기본 1. */
8
15
  strength?: number;
16
+ /** 모든 조각이 사라지면 부른다(움직임 최소화 설정이면 바로 부른다). */
9
17
  onComplete?: () => void;
18
+ /** 값이 바뀌면 다시 터진다. */
10
19
  trigger?: number;
11
20
  }): import("react").JSX.Element | null;
@@ -5,6 +5,27 @@ import { createPortal } from "react-dom";
5
5
  import "./EffectPartyPopper.css";
6
6
  // use only the app's two CSS variables for colors
7
7
  const COLORS = ["var(--color-main)", "var(--color-sub)"];
8
+ // `duration` 의 기준값. 조각 수명은 이 값일 때의 모양(대략 1.2~4초)을 그대로 두고 duration 에
9
+ // 비례해 늘거나 준다 — 기본값을 준 화면은 예전과 똑같이 터진다.
10
+ const BASE_DURATION = 2000;
11
+ /**
12
+ * CSS 변수 색(`var(--color-main)`)을 **그 조각 원소 자신** 기준으로 푼다.
13
+ *
14
+ * 예전에는 `document.documentElement` 에서 읽었다. 그런데 스코프 CSS(styles.css)는 토큰을
15
+ * `:root` 가 아니라 `.ihabdevteam-core-scope` 에 두므로, 스코프 안에 붙은 폭죽은 `<html>` 에서
16
+ * 토큰을 못 찾아 늘 폴백색으로 칠해졌다. 원소 자신의 계산된 스타일은 상속된 토큰을 본다.
17
+ */
18
+ function resolveColor(el, color) {
19
+ if (!color.startsWith('var('))
20
+ return color;
21
+ try {
22
+ const varName = color.slice(4, -1).trim();
23
+ return getComputedStyle(el).getPropertyValue(varName).trim() || null;
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
8
29
  /** @public */
9
30
  export default function EffectPartyPopper({ active = false, count = 40, duration = 2000, strength = 1, // multiplier for speed/spread
10
31
  onComplete, trigger = 0, }) {
@@ -127,8 +148,11 @@ onComplete, trigger = 0, }) {
127
148
  const kY = clamp(speedY, 0.8, 2.4);
128
149
  const kX = clamp(speedX, 0.7, 2.2);
129
150
  // initialize particle DOM nodes (they exist but are empty) and start time per particle
151
+ // duration 을 받아 놓고 아무 데도 안 썼다 — 수명에 비례로 건다(기본값이면 배율 1).
152
+ const lifeScale = duration > 0 ? duration / BASE_DURATION : 1;
130
153
  const particles = pieces.map((p) => ({
131
154
  ...p,
155
+ life: p.life * lifeScale,
132
156
  vx: p.vx * kX,
133
157
  vy: p.vy * kY,
134
158
  startTime: start + p.delay,
@@ -164,19 +188,9 @@ onComplete, trigger = 0, }) {
164
188
  node.style.display = "block";
165
189
  // ensure particle is above overlays
166
190
  node.style.zIndex = '100000';
167
- // if color is a CSS variable (e.g. var(--color-main)), resolve it now
168
- try {
169
- if (typeof window !== 'undefined' && typeof p.color === 'string' && p.color.startsWith('var(')) {
170
- const varName = p.color.slice(4, -1).trim();
171
- const resolved = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
172
- if (resolved)
173
- node.style.background = resolved;
174
- else
175
- node.style.background = '#FF0060';
176
- }
177
- }
178
- catch (e) {
179
- // ignore
191
+ // if color is a CSS variable (e.g. var(--color-main)), resolve it now — 조각 원소 기준으로
192
+ if (typeof p.color === 'string' && p.color.startsWith('var(')) {
193
+ node.style.background = resolveColor(node, p.color) ?? '#FF0060';
180
194
  }
181
195
  p._inited = true;
182
196
  }
@@ -241,18 +255,9 @@ onComplete, trigger = 0, }) {
241
255
  s.style.top = '0px';
242
256
  s.style.width = `${p.size}px`;
243
257
  s.style.height = `${Math.max(4, p.size * 0.6)}px`;
244
- // resolve CSS var() color if present
245
- let bg = p.color;
246
- try {
247
- if (typeof bg === 'string' && bg.startsWith('var(')) {
248
- const varName = bg.slice(4, -1).trim();
249
- const resolved = getComputedStyle(document.documentElement).getPropertyValue(varName).trim();
250
- if (resolved)
251
- bg = resolved;
252
- }
253
- }
254
- catch (e) { }
255
- s.style.background = bg;
258
+ // 아직 문서에 안 붙은 원소라 여기서는 색을 못 푼다 — var() 그대로 두고,
259
+ // 조각이 출발할 때(step 의 첫 틀) 붙은 원소 기준으로 푼다.
260
+ s.style.background = p.color;
256
261
  s.style.borderRadius = p.borderRadius;
257
262
  s.style.transform = 'translate3d(0,0,0)';
258
263
  s.style.opacity = '0';
@@ -3,5 +3,7 @@ import { cx } from '../utils/cx';
3
3
  import './EmptyState.css';
4
4
  import Icon from './Icon';
5
5
  export default function EmptyState({ icon, emoji, title, message, hint, action, compact, className }) {
6
- return (_jsxs("div", { className: cx('empty-state', compact && 'empty-state--compact', className), children: [emoji && _jsx("span", { className: "empty-state__emoji", children: emoji }), icon && _jsx(Icon, { icon: icon, className: "empty-state__icon" }), title && _jsx("p", { className: "empty-state__title body-2", children: title }), message && _jsx("p", { className: "empty-state__message body-3", children: message }), hint && _jsx("p", { className: "empty-state__hint caption-1", children: hint }), action && (_jsxs("button", { className: "empty-state__action button button40 button40--bar button--variant-main button--align-center button--shape-rounded", onClick: action.onClick, children: [action.icon && _jsx(Icon, { icon: action.icon, className: "button__icon" }), _jsx("span", { className: "button__label button-1", children: action.label })] }))] }));
6
+ return (_jsxs("div", { className: cx('empty-state', compact && 'empty-state--compact', className), children: [emoji && _jsx("span", { className: "empty-state__emoji", children: emoji }), icon && _jsx(Icon, { icon: icon, className: "empty-state__icon" }), title && _jsx("p", { className: "empty-state__title body-2", children: title }), message && _jsx("p", { className: "empty-state__message body-3", children: message }), hint && _jsx("p", { className: "empty-state__hint caption-1", children: hint }), action && (
7
+ // type="button" — 없으면 폼 안에서 누르는 순간 폼이 제출된다
8
+ _jsxs("button", { type: "button", className: "empty-state__action button button40 button40--bar button--variant-main button--align-center button--shape-rounded", onClick: action.onClick, children: [action.icon && _jsx(Icon, { icon: action.icon, className: "button__icon" }), _jsx("span", { className: "button__label button-1", children: action.label })] }))] }));
7
9
  }
@@ -7,5 +7,7 @@ import './ErrorState.css';
7
7
  import Icon from './Icon';
8
8
  export default function ErrorState({ message, hint, icon = 'triangle-alert', onRetry, retryLabel, className }) {
9
9
  const { t } = useTranslation();
10
- return (_jsxs("div", { className: cx('empty-state', 'error-state', className), role: "alert", children: [_jsx(Icon, { icon: icon, className: "empty-state__icon error-state__icon" }), _jsx("p", { className: "empty-state__message body-3", children: message ?? t('common.errorState.message', { defaultValue: '불러오지 못했습니다.' }) }), hint && _jsx("p", { className: "empty-state__hint caption-1", children: hint }), onRetry && (_jsxs("button", { className: "empty-state__action button button40 button40--bar button--variant-main button--align-center button--shape-rounded", onClick: onRetry, children: [_jsx(Icon, { icon: "rotate-cw", className: "button__icon" }), _jsx("span", { className: "button__label button-1", children: retryLabel ?? t('common.errorState.retry', { defaultValue: '다시 시도' }) })] }))] }));
10
+ return (_jsxs("div", { className: cx('empty-state', 'error-state', className), role: "alert", children: [_jsx(Icon, { icon: icon, className: "empty-state__icon error-state__icon" }), _jsx("p", { className: "empty-state__message body-3", children: message ?? t('common.errorState.message', { defaultValue: '불러오지 못했습니다.' }) }), hint && _jsx("p", { className: "empty-state__hint caption-1", children: hint }), onRetry && (_jsxs("button", {
11
+ // type="button" — 없으면 폼 안에서 다시 시도를 누르는 순간 폼이 제출된다
12
+ type: "button", className: "empty-state__action button button40 button40--bar button--variant-main button--align-center button--shape-rounded", onClick: onRetry, children: [_jsx(Icon, { icon: "rotate-cw", className: "button__icon" }), _jsx("span", { className: "button__label button-1", children: retryLabel ?? t('common.errorState.retry', { defaultValue: '다시 시도' }) })] }))] }));
11
13
  }
@@ -11,6 +11,7 @@ import Dropdown from './Dropdown';
11
11
  export { FILTER_ALL_VALUE } from '../types/filterPanel';
12
12
  import { FILTER_ALL_VALUE } from '../types/filterPanel';
13
13
  import FilterSectionInfo from './FilterSectionInfo';
14
+ import { devWarnOnce } from '../utils/devWarn';
14
15
  /** @public */
15
16
  export default function FilterPanel({ title, headerAction, collapsible = false, defaultCollapsed = false, collapsed: controlledCollapsed, onCollapsedChange, footer, sections, selected = {}, onSelect, align, sectionAlign, className, }) {
16
17
  // align 우선. 없으면 구 prop(sectionAlign) 매핑, 그것도 없으면 inline 기본.
@@ -36,6 +37,12 @@ export default function FilterPanel({ title, headerAction, collapsible = false,
36
37
  // 바로 터진다 — 호출부 데이터 소스(단어 목록 필터링 등) 결함을 여기서까지 전파시키지
37
38
  // 않도록 렌더 직전에 걸러낸다
38
39
  const items = (section.items ?? []).filter((item) => item != null);
40
+ /* "전체" 보기(allOption)는 값 FILTER_ALL_VALUE('전체') 로 알린다. 보기 중에 같은 값 '전체' 가
41
+ 있으면 그 보기를 눌러도 소비자 판정이 "전체" 로 읽고, segmented·dropdown 에서는 열쇠가 겹친다.
42
+ 저장된 선택과의 호환 때문에 값은 못 바꾸므로 개발 중에 알린다. */
43
+ if (section.allOption && items.some((item) => (typeof item === 'string' ? item : item.value) === FILTER_ALL_VALUE)) {
44
+ devWarnOnce(`filter-panel-all-value:${section.label}`, `[FilterPanel] '${section.label}' 절에 allOption 과 값 '${FILTER_ALL_VALUE}' 인 보기가 함께 있다 — 두 선택이 구별되지 않는다. 보기 값을 바꿔라.`);
45
+ }
39
46
  /* 보기 버튼 한 개. 칸으로 나뉜 절(section.groups)과 안 나뉜 절이 같은 버튼을
40
47
  그리는데, 한때 이 25줄을 두 벌 적어 두었다. 새 속성을 하나 붙일 때 한쪽만
41
48
  붙는 것이 자연스러운 모양이라 갈릴 수밖에 없다. */
@@ -21,8 +21,14 @@ export declare function BrandIdentity({ appMode }: {
21
21
  export default function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange }: {
22
22
  activeKey?: string | null;
23
23
  onChange?: (key?: string) => void;
24
+ /** 지금 모드의 내비 항목. 데스크톱은 SideNav, 모바일은 BottomNav 가 이 목록을 그린다. */
24
25
  items?: SideNavItem[];
26
+ /**
27
+ * @deprecated 읽지 않는다 — 지금 모드의 목록을 `items` 로 넘긴다.
28
+ * 모드 전환 때 부르는 쪽이 `items` 를 바꿔 넣으므로 헤더가 두 목록을 따로 들고 있을 까닭이 없다.
29
+ */
25
30
  malgwiItems?: SideNavItem[];
31
+ /** @deprecated 읽지 않는다 — 지금 모드의 목록을 `items` 로 넘긴다(`malgwiItems` 와 같다). */
26
32
  managerItems?: SideNavItem[];
27
33
  unreadCount?: number;
28
34
  onDebug?: () => void;
@@ -43,6 +43,30 @@ export function BrandIdentity({ appMode }) {
43
43
  export default function Header({ activeKey, onChange, items, malgwiItems: _malgwiItems, managerItems: _managerItems, unreadCount, onDebug, onNotification, onAccount, onSetting, onHelp, appMode, onAppModeChange }) {
44
44
  const { t } = useTranslation();
45
45
  const isMobile = useMediaQuery("(max-width: 767px)");
46
+ /* **모바일 하단 내비에 SideNav 항목을 옮겨 적는다.** 예전에는 `SideNavItem` 을 `NavItem` 으로
47
+ 캐스팅만 해 넘겨, 두 타입의 이름이 다른 칸이 조용히 버려졌다 — `disabled` 가 `state` 로
48
+ 안 가서 **비활성 항목이 모바일에서 눌렸고**, `href` 항목은 새 창 대신 `onChange(key)` 로
49
+ 갔다. SideNav 가 거르는 구획(section)·빈 항목도 같이 거른다.
50
+ (`badge` 는 BottomNav 에 받을 자리가 없어 아직 못 옮긴다.) */
51
+ const bottomItems = useMemo(() => (items ?? [])
52
+ .filter(it => !it.section && (it.key || it.label))
53
+ .map(it => ({
54
+ key: it.key,
55
+ label: typeof it.label === 'string' ? it.label : undefined,
56
+ icon: it.icon,
57
+ state: it.disabled ? 'disabled' : undefined,
58
+ })), [items]);
59
+ const handleBottomChange = useCallback((key) => {
60
+ const it = items?.find(i => i.key === key);
61
+ if (it?.disabled)
62
+ return;
63
+ // SideNav 와 같다 — href 가 있으면 앱 안 이동이 아니라 새 창으로 연다.
64
+ if (it?.href) {
65
+ window.open(it.href, '_blank', 'noopener,noreferrer');
66
+ return;
67
+ }
68
+ onChange?.(key);
69
+ }, [items, onChange]);
46
70
  // 모바일 하단 더보기(…) 메뉴에 노출할 유틸(알림·설정·도움말·계정·디버그).
47
71
  // 데스크톱 SideNav 푸터와 동일한 아이콘/키를 사용해 활성표시가 일관되게 반영된다.
48
72
  const bottomMoreItems = useMemo(() => {
@@ -86,7 +110,7 @@ export default function Header({ activeKey, onChange, items, malgwiItems: _malgw
86
110
  onClick: () => { it.onClick?.(); closeUtilMenu(); },
87
111
  })),
88
112
  }], [bottomMoreItems, activeKey, closeUtilMenu]);
89
- 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: items, activeKey: activeKey ?? undefined, onChange: onChange, appMode: appMode, onAppModeChange: onAppModeChange })] }));
113
+ 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 })] }));
90
114
  }
91
115
  /**
92
116
  * `Header`의 alias입니다. 프로젝트 내 `Header`라는 이름이 이미 사용 중일 때 충돌 없이 임포트할 수 있습니다.
@@ -0,0 +1,22 @@
1
+ /* ── Icon 마스크 모드 ────────────────────────────────────────────────
2
+ `.svg` 경로가 SVG 로 응답하면 Icon 은 `<span class="iconRenderer-icon-mask"
3
+ style="--icon-url: url(…)">` 를 그린다. 그런데 이 이름에 규칙이 하나도 없어서
4
+ 크기 0 · 마스크 없음 — **아무것도 안 그려졌다.** Button·SideNav 는 제
5
+ maskClassName 에 같은 규칙을 따로 두어 괜찮았고, 기본 이름으로 쓰는 자리만 비었다.
6
+
7
+ 새 색은 쓰지 않는다 — 글자색(currentColor)으로 칠해 부모의 color 를 따른다.
8
+ 크기는 `size` prop 이 `--icon-size` 로 넘기고, 없으면 lucide 기본과 같은 20px. */
9
+ .iconRenderer-icon-mask {
10
+ display: inline-block;
11
+ width: var(--icon-size, 20px);
12
+ height: var(--icon-size, 20px);
13
+ background-color: currentColor;
14
+ -webkit-mask-image: var(--icon-url);
15
+ -webkit-mask-repeat: no-repeat;
16
+ -webkit-mask-position: center;
17
+ -webkit-mask-size: contain;
18
+ mask-image: var(--icon-url);
19
+ mask-repeat: no-repeat;
20
+ mask-position: center;
21
+ mask-size: contain;
22
+ }
@@ -1,4 +1,5 @@
1
1
  import { type ReactNode, type CSSProperties } from 'react';
2
+ import './Icon.css';
2
3
  /** @public */
3
4
  export interface IconProps {
4
5
  /**
@@ -11,8 +12,8 @@ export interface IconProps {
11
12
  /**
12
13
  * 감싸는 `<span>` 의 클래스. 주면 기본 이름을 **갈아치운다** — 덧붙이지 않는다.
13
14
  *
14
- * 기본값 `iconRenderer-icon` 을 비롯한 네 `iconRenderer-*` 이름은 **패키지에
15
- * 제 규칙이 하나도 없다**(실측: 자기 규칙 0개). 붙잡을 이름일 뿐이고 크기는
15
+ * 기본값 `iconRenderer-icon` 을 비롯한 `iconRenderer-*` 이름은 마스크(`-mask`)를 빼면
16
+ * **패키지에 제 규칙이 없다**. 붙잡을 이름일 뿐이고 크기는
16
17
  * `size`, 그 밖의 모양은 `wrapperStyle` 이나 소비자 CSS 로 간다. 그래서
17
18
  * `className="iconRenderer-icon"` 이라고 적어도 아무 모양도 따라오지 않는다.
18
19
  *
@@ -23,7 +24,10 @@ export interface IconProps {
23
24
  className?: string;
24
25
  /** `<img>` 로 그릴 때의 클래스. 기본 `iconRenderer-icon-image` — 규칙 없음. */
25
26
  imgClassName?: string;
26
- /** 마스크로 그릴 때의 클래스. 기본 `iconRenderer-icon-mask` — 규칙 없음. */
27
+ /**
28
+ * 마스크로 그릴 때의 클래스. 기본 `iconRenderer-icon-mask` — 글자색으로 칠하고 크기는 `size`(없으면 20px).
29
+ * 주면 기본 이름을 갈아치우므로 마스크 규칙(`mask-image: var(--icon-url)` 등)도 그 클래스가 대야 한다.
30
+ */
27
31
  maskClassName?: string;
28
32
  /** 인라인 SVG 로 그릴 때의 클래스. 기본 `iconRenderer-icon-svg` — 규칙 없음. */
29
33
  svgClassName?: string;