@ihabdevteam/core 0.130.0 → 0.132.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 (43) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/dist/components.cjs.js +176 -176
  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.cjs.js +181 -181
  7. package/dist/ihabdevteam-core.d.ts +69 -20
  8. package/dist/packages/core/src/components/Accordion.js +4 -1
  9. package/dist/packages/core/src/components/Badge.d.ts +5 -0
  10. package/dist/packages/core/src/components/Badge.js +3 -3
  11. package/dist/packages/core/src/components/BarChart.d.ts +1 -1
  12. package/dist/packages/core/src/components/BarChart.js +24 -13
  13. package/dist/packages/core/src/components/BottomNav.css +15 -0
  14. package/dist/packages/core/src/components/BottomNav.d.ts +6 -0
  15. package/dist/packages/core/src/components/BottomNav.js +12 -1
  16. package/dist/packages/core/src/components/Calendar.js +4 -0
  17. package/dist/packages/core/src/components/ChartSection.css +16 -0
  18. package/dist/packages/core/src/components/ChartSection.d.ts +8 -1
  19. package/dist/packages/core/src/components/ChartSection.js +2 -2
  20. package/dist/packages/core/src/components/Header.js +3 -1
  21. package/dist/packages/core/src/components/PageTitle.css +6 -0
  22. package/dist/packages/core/src/components/PageTitle.d.ts +9 -1
  23. package/dist/packages/core/src/components/PageTitle.js +2 -2
  24. package/dist/packages/core/src/components/PassCard/PassCardBg.css +33 -0
  25. package/dist/packages/core/src/components/Popup/Popup.d.ts +8 -0
  26. package/dist/packages/core/src/components/Popup/Popup.js +4 -2
  27. package/dist/packages/core/src/components/RingChart.d.ts +3 -1
  28. package/dist/packages/core/src/components/RingChart.js +2 -2
  29. package/dist/packages/core/src/components/SideNav.js +27 -10
  30. package/dist/packages/core/src/components/Table.d.ts +17 -1
  31. package/dist/packages/core/src/components/Table.js +15 -9
  32. package/dist/packages/core/src/components/TextField.js +34 -5
  33. package/dist/packages/core/src/components/barChartTypes.d.ts +2 -0
  34. package/dist/packages/core/src/components/useComboboxSuggestions.d.ts +1 -0
  35. package/dist/packages/core/src/components/useComboboxSuggestions.js +12 -6
  36. package/dist/packages/core/src/i18n/locales/en.json +2 -1
  37. package/dist/packages/core/src/i18n/locales/ko.json +2 -1
  38. package/dist/packages/core/src/utils/CategoryData.d.ts +12 -40
  39. package/dist/packages/core/src/utils/CategoryData.js +13 -8
  40. package/dist/packages/core/src/version.d.ts +1 -1
  41. package/dist/packages/core/src/version.js +1 -1
  42. package/dist/utils.cjs.js +1 -1
  43. package/package.json +1 -1
@@ -37,3 +37,36 @@
37
37
  -webkit-mask: var(--symbol-url) center / contain no-repeat;
38
38
  mask: var(--symbol-url) center / contain no-repeat;
39
39
  }
40
+
41
+ /* ── PassCardBg 컴포넌트 자체의 변형(default · blue · dark) ─────────────────
42
+ 9종 배경은 각자 파일에 모양을 두고, 이 기본 컴포넌트의 원 셋과 변형은 규칙이 없어
43
+ 세 변형이 똑같이(빈 바탕으로) 그려졌다(2026-09-14, 사용자가 정하도록 맡김).
44
+ **새 색은 쓰지 않는다** — 원 구도는 PassCardBgTwoCircle 과 같게(큰 원 우상단 · 작은 원
45
+ 우하단)에 좌하단 원 하나를 더하고, 색은 브랜드·중립 토큰만 쓴다. 만료 카드
46
+ (.pass-card--expired)가 --color-main/sub 를 회색으로 바꾸는 규칙도 그대로 따라온다. */
47
+ .pass-card-bg__circle {
48
+ position: absolute;
49
+ border-radius: 50%;
50
+ opacity: var(--pass-card-symbol-opacity, 0.75);
51
+ }
52
+ .pass-card-bg__circle--1 { width: 340px; height: 340px; top: -140px; right: -150px; }
53
+ .pass-card-bg__circle--2 { width: 220px; height: 220px; bottom: -90px; right: -50px; }
54
+ .pass-card-bg__circle--3 { width: 140px; height: 140px; bottom: -70px; left: -40px; }
55
+
56
+ /* default — 흰 바탕 + main · sub 원(TwoCircle 과 같은 결) */
57
+ .pass-card-bg--default { background: var(--color-pureWhite); }
58
+ .pass-card-bg--default .pass-card-bg__circle--1 { background: var(--color-main); }
59
+ .pass-card-bg--default .pass-card-bg__circle--2 { background: var(--color-sub); }
60
+ .pass-card-bg--default .pass-card-bg__circle--3 { background: var(--color-main-a20); }
61
+
62
+ /* blue — 짙은 main 바탕 + 흰 반투명 원 */
63
+ .pass-card-bg--blue { background: var(--color-deepMain); }
64
+ .pass-card-bg--blue .pass-card-bg__circle--1 { background: var(--color-white-a30); }
65
+ .pass-card-bg--blue .pass-card-bg__circle--2 { background: var(--color-white-a10); }
66
+ .pass-card-bg--blue .pass-card-bg__circle--3 { background: var(--color-white-a10); }
67
+
68
+ /* dark — 검은 바탕 + main · sub 원 */
69
+ .pass-card-bg--dark { background: var(--color-softBlack); }
70
+ .pass-card-bg--dark .pass-card-bg__circle--1 { background: var(--color-main); }
71
+ .pass-card-bg--dark .pass-card-bg__circle--2 { background: var(--color-sub); }
72
+ .pass-card-bg--dark .pass-card-bg__circle--3 { background: var(--color-white-a10); }
@@ -73,6 +73,14 @@ export interface PopupProps {
73
73
  * 더 넓거나 무제한이 필요하면 className으로 `.popup-panel` max-width를 직접 override.
74
74
  */
75
75
  size?: 'small' | 'medium' | 'large';
76
+ /**
77
+ * 패널 높이를 못 박는다(숫자는 px, 문자열은 CSS 길이 그대로). 미지정 시 내용만큼(기본).
78
+ *
79
+ * 탭을 옮길 때마다 내용 높이가 달라 창이 위아래로 뛰는 팝업에 쓴다. **화면보다 크면 화면에
80
+ * 맞춘다** — 패널의 `max-height`(데스크톱 `100% - 60px`, 좁은 화면은 하단 내비 몫까지 뺀 값)가
81
+ * 그대로 걸리므로 창이 화면 밖으로 나가지 않는다. 넘치는 내용은 본문이 스스로 굴린다.
82
+ */
83
+ height?: number | string;
76
84
  /**
77
85
  * 이 값이 바뀌면 본문(.popup-content) 스크롤을 맨 위로 되돌린다.
78
86
  * 탭/모드 전환처럼 내부 콘텐츠가 통째로 바뀌는 팝업에서, 스크롤을 내린 채
@@ -13,7 +13,7 @@ const ANIM_MS = 260;
13
13
  /** @public */
14
14
  export default function Popup(props) {
15
15
  const { t } = useTranslation();
16
- const { title, subtitle, headerActions, children, buttons, footer, footerClassName = "", onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, resetScrollKey } = props || {};
16
+ const { title, subtitle, headerActions, children, buttons, footer, footerClassName = "", onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, height, resetScrollKey } = props || {};
17
17
  const [closing, setClosing] = useState(false);
18
18
  // 닫힘 애니메이션 중(260ms)엔 버튼이 시각적으로만 흐려질 뿐 클릭은 계속 가능해,
19
19
  // 그 사이 같은 버튼을 연타하면 cb(확인/완료 콜백)가 여러 번 예약되어 중복 실행된다.
@@ -147,7 +147,9 @@ export default function Popup(props) {
147
147
  if (pressed && e.target === e.currentTarget)
148
148
  closeWithAnimation();
149
149
  };
150
- const el = (_jsx("div", { className: `popup ${inline ? 'popup--inline' : ''} ${closing ? 'popup--closing' : ''} ${className}`, onPointerDown: backdropEnabled ? onBackdropPointerDown : undefined, onClick: backdropEnabled ? onBackdropClick : undefined, children: _jsxs("div", { ref: panelRef, className: `popup-panel${size ? ` popup-panel--${size}` : ''}`, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && title ? titleId : undefined, "aria-label": !inline && !title && ariaLabel ? ariaLabel : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') })] })) : (title || headerActions || onClose) ? (_jsxs("div", { className: `popup-header${subtitle ? ' popup-header--with-subtitle' : ''}`, children: [_jsxs("div", { className: "popup-header-main", children: [_jsx("span", { id: titleId, className: "popup-title popup-title--left h2", children: title }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), _jsxs("div", { className: "popup-header-actions", children: [headerActions, onClose && (_jsx(Button, { size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') }))] })] })) : null, _jsx("div", { className: "popup-content", ref: contentRef, tabIndex: contentScrollable ? 0 : undefined, children: _jsx("div", { className: "popup-content__inner", children: typeof children === 'function' ? children({ animateThen, closeWithAnimation }) : children }) }), buttons?.length ? (_jsx("div", { className: "popup-footer popup-footer--buttons", children: buttons.map((btn, i) => (_jsx(Button, { size: 48, variant: btn.variant ?? 'default', label: btn.label, disabled: btn.disabled, onClick: () => btn.onClick?.({ animateThen, closeWithAnimation }) }, i))) })) : footer ? (_jsx("div", { className: `popup-footer${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
150
+ const el = (_jsx("div", { className: `popup ${inline ? 'popup--inline' : ''} ${closing ? 'popup--closing' : ''} ${className}`, onPointerDown: backdropEnabled ? onBackdropPointerDown : undefined, onClick: backdropEnabled ? onBackdropClick : undefined, children: _jsxs("div", { ref: panelRef, className: `popup-panel${size ? ` popup-panel--${size}` : ''}`,
151
+ /* max-height(CSS)가 height 보다 이기므로 화면보다 크게 줘도 화면에 맞춰진다 */
152
+ style: height != null ? { height } : undefined, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && title ? titleId : undefined, "aria-label": !inline && !title && ariaLabel ? ariaLabel : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') })] })) : (title || headerActions || onClose) ? (_jsxs("div", { className: `popup-header${subtitle ? ' popup-header--with-subtitle' : ''}`, children: [_jsxs("div", { className: "popup-header-main", children: [_jsx("span", { id: titleId, className: "popup-title popup-title--left h2", children: title }), subtitle && _jsx("span", { className: "popup-subtitle body-3", children: subtitle })] }), _jsxs("div", { className: "popup-header-actions", children: [headerActions, onClose && (_jsx(Button, { size: 40, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, onClick: closeWithAnimation, disabled: closeDisabled, ariaLabel: t('common.close', '닫기') }))] })] })) : null, _jsx("div", { className: "popup-content", ref: contentRef, tabIndex: contentScrollable ? 0 : undefined, children: _jsx("div", { className: "popup-content__inner", children: typeof children === 'function' ? children({ animateThen, closeWithAnimation }) : children }) }), buttons?.length ? (_jsx("div", { className: "popup-footer popup-footer--buttons", children: buttons.map((btn, i) => (_jsx(Button, { size: 48, variant: btn.variant ?? 'default', label: btn.label, disabled: btn.disabled, onClick: () => btn.onClick?.({ animateThen, closeWithAnimation }) }, i))) })) : footer ? (_jsx("div", { className: `popup-footer${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
151
153
  if (inline || typeof document === 'undefined')
152
154
  return el;
153
155
  return createPortal(el, document.body);
@@ -58,6 +58,8 @@ export interface RingChartProps {
58
58
  loading?: boolean;
59
59
  /** 섹션 루트에 추가할 클래스 (titleKey 지정 시에만 사용) */
60
60
  className?: string;
61
+ /** CSS 격자 칸 안에 세운다 — 아래 여백·최소 폭을 풀고 링을 카드 바닥에 맞춘다. ChartSection `inGrid` 참고(titleKey 지정 시에만 사용) */
62
+ inGrid?: boolean;
61
63
  }
62
64
  /**
63
65
  * 도넛 링 차트 — 전체 대비 완료 비율을 원형 호로 표현.
@@ -65,4 +67,4 @@ export interface RingChartProps {
65
67
  * 미지정 시에는 헤더 없는 순수 링만 렌더된다(크기/색상 데모 등).
66
68
  * @public
67
69
  */
68
- export default function RingChart({ titleKey, headRight, icon, showTotal, totalLabelKey, legend, loading, className, ...ringProps }: RingChartProps): import("react").JSX.Element;
70
+ export default function RingChart({ titleKey, headRight, icon, showTotal, totalLabelKey, legend, loading, className, inGrid, ...ringProps }: RingChartProps): import("react").JSX.Element;
@@ -40,7 +40,7 @@ function RingChartCanvas({ done, total, segments, color = 'var(--color-main)', s
40
40
  * 미지정 시에는 헤더 없는 순수 링만 렌더된다(크기/색상 데모 등).
41
41
  * @public
42
42
  */
43
- export default function RingChart({ titleKey, headRight, icon, showTotal, totalLabelKey, legend, loading, className, ...ringProps }) {
43
+ export default function RingChart({ titleKey, headRight, icon, showTotal, totalLabelKey, legend, loading, className, inGrid = false, ...ringProps }) {
44
44
  const { t } = useTranslation();
45
45
  if (!titleKey)
46
46
  return _jsx(RingChartCanvas, { ...ringProps });
@@ -53,5 +53,5 @@ export default function RingChart({ titleKey, headRight, icon, showTotal, totalL
53
53
  .filter(Boolean).join(' ') || undefined;
54
54
  // StatChart(.manager-stat-card__icon)와 동일한 톤 — headRight 자리에 렌더(둘 중 하나만 사용)
55
55
  const iconNode = icon && (_jsx("span", { className: "chart-section-icon", children: _jsx(Icon, { icon: icon, className: "chart-section-icon-inner" }) }));
56
- return (_jsx(ChartSection, { className: sectionClassName, title: t(titleKey), titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: iconNode || (isSmall ? undefined : headRight), subRow: (showTotal || legend) && _jsxs(_Fragment, { children: [showTotal ? (_jsxs(ChartTotal, { label: !isSmall && totalLabelKey ? t(totalLabelKey) : undefined, children: [_jsx("span", { className: "barchart-total__num h0", children: pct }), _jsx("span", { className: "barchart-total__unit h5", children: "%" })] })) : _jsx("span", {}), legend && _jsx(ChartLegend, { items: legend })] }), children: loading ? (_jsx("div", { className: "manager-bar-chart-loading", children: _jsx(Loader, { size: 32 }) })) : (_jsx("div", { className: "ring-chart-section-body ring-chart-section-body--offset", children: _jsx(RingChartCanvas, { ...ringProps, hideCenter: isSmall || ringProps.hideCenter }) })) }));
56
+ return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, title: t(titleKey), titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: iconNode || (isSmall ? undefined : headRight), subRow: (showTotal || legend) && _jsxs(_Fragment, { children: [showTotal ? (_jsxs(ChartTotal, { label: !isSmall && totalLabelKey ? t(totalLabelKey) : undefined, children: [_jsx("span", { className: "barchart-total__num h0", children: pct }), _jsx("span", { className: "barchart-total__unit h5", children: "%" })] })) : _jsx("span", {}), legend && _jsx(ChartLegend, { items: legend })] }), children: loading ? (_jsx("div", { className: "manager-bar-chart-loading", children: _jsx(Loader, { size: 32 }) })) : (_jsx("div", { className: "ring-chart-section-body ring-chart-section-body--offset", children: _jsx(RingChartCanvas, { ...ringProps, hideCenter: isSmall || ringProps.hideCenter }) })) }));
57
57
  }
@@ -6,6 +6,10 @@ import Button from './Button';
6
6
  import Badge from './Badge';
7
7
  import React, { useCallback } from 'react';
8
8
  import { useTranslation } from 'react-i18next';
9
+ /** 단추 이름에 개수를 붙인다 — 뱃지는 aria-hidden 이라 개수는 이름으로만 전해진다(BottomNav 와 같은 문구). */
10
+ function nameWithCount(t, label, count) {
11
+ return count && count > 0 ? t('nav.badgeLabel', { label, count, defaultValue: '{{label}}, {{count}}개' }) : label;
12
+ }
9
13
  function SideNavFooter(props) {
10
14
  const { t } = useTranslation();
11
15
  const { activeKey, unreadCount, onDebug, onNotification, onSetting, onHelp, onAccount } = props || {};
@@ -20,9 +24,10 @@ function SideNavFooter(props) {
20
24
  // 하나도 없으면 바닥 자체를 비운다 — 빈 채로 두면 여백(margin-top + padding)만 남는다.
21
25
  if (!그릴것)
22
26
  return null;
23
- return (_jsxs("div", { className: "side-nav__footer", children: [onDebug && (_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "terminal", ariaLabel: t('nav.debug', '디버그'), onClick: onDebug, selected: activeKey === 'debug' })), onNotification && (_jsxs("span", { className: "side-nav__notification-wrap", children: [_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "bell", ariaLabel: t('nav.notifications', '알림'), onClick: onNotification, selected: activeKey === 'notifications' }), !!unreadCount && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24 }))] })), onSetting && (_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "settings", ariaLabel: t('nav.settings', '설정'), onClick: onSetting, selected: activeKey === 'settings' })), onHelp && (_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "message-circle-question-mark", ariaLabel: t('nav.help', '도움말'), onClick: onHelp, selected: activeKey === 'help' })), onAccount && (_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "user-round", ariaLabel: t('nav.account', '계정'), onClick: onAccount, selected: activeKey === 'account' }))] }));
27
+ return (_jsxs("div", { className: "side-nav__footer", children: [onDebug && (_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "terminal", ariaLabel: t('nav.debug', '디버그'), onClick: onDebug, selected: activeKey === 'debug' })), onNotification && (_jsxs("span", { className: "side-nav__notification-wrap", children: [_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "bell", ariaLabel: nameWithCount(t, t('nav.notifications', '알림'), unreadCount), onClick: onNotification, selected: activeKey === 'notifications' }), !!unreadCount && unreadCount > 0 && (_jsx(Badge, { type: "dot", number: Math.min(unreadCount, 99), size: 24, "aria-hidden": true }))] })), onSetting && (_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "settings", ariaLabel: t('nav.settings', '설정'), onClick: onSetting, selected: activeKey === 'settings' })), onHelp && (_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "message-circle-question-mark", ariaLabel: t('nav.help', '도움말'), onClick: onHelp, selected: activeKey === 'help' })), onAccount && (_jsx(Button, { size: 48, showTooltipInfo: true, type: "dot", shape: "pill", variant: variant, icon: "user-round", ariaLabel: t('nav.account', '계정'), onClick: onAccount, selected: activeKey === 'account' }))] }));
24
28
  }
25
29
  function SideNavButton(props) {
30
+ const { t } = useTranslation();
26
31
  const { item, itemKey, label, icon, active, onClick, style, state, labelStyle } = props || {};
27
32
  const key = item?.key ?? itemKey ?? '';
28
33
  const text = (item?.label ?? label ?? '');
@@ -34,13 +39,19 @@ function SideNavButton(props) {
34
39
  const handleClick = useCallback((e) => {
35
40
  if (isDisabled)
36
41
  return;
37
- try {
38
- e.currentTarget.blur();
42
+ /* 포커스는 **포인터로 눌렀을 때만** 걷는다. 걷는 까닭은 마우스로 누른 뒤 :focus 틴트가 남고 태블릿
43
+ 레일이 :focus-within 으로 펼친 채 남기 때문인데, 키보드로 Enter 를 누른 사람까지 걷으면 포커스가
44
+ body 로 떨어져 메뉴 처음부터 Tab 을 다시 밟아야 했다. 키보드가 낸 click 은 detail 이 0 이다. */
45
+ const fromPointer = e.detail > 0;
46
+ if (fromPointer) {
47
+ try {
48
+ e.currentTarget.blur();
49
+ }
50
+ catch (err) { }
39
51
  }
40
- catch (err) { }
41
- onClick?.(key, e.currentTarget);
52
+ onClick?.(key, e.currentTarget, fromPointer);
42
53
  }, [isDisabled, key, onClick]);
43
- return (_jsxs("button", { className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? text : undefined, style: style, "aria-disabled": isDisabled, children: [iconElement, _jsx("span", { className: `side-nav__label button-1 f`, style: labelStyle, children: text }), badgeCount > 0 && (_jsx(Badge, { type: "dot", position: "inline", number: Math.min(badgeCount, 99), size: 24, "aria-label": `${badgeCount}` })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", "aria-label": "loading" }))] }));
54
+ return (_jsxs("button", { type: "button", className: btnClass, onClick: handleClick, "aria-label": typeof text === 'string' ? nameWithCount(t, text, badgeCount) : undefined, style: style, "aria-disabled": isDisabled, children: [iconElement, _jsx("span", { className: `side-nav__label button-1 f`, style: labelStyle, children: text }), badgeCount > 0 && (_jsx(Badge, { type: "dot", position: "inline", number: Math.min(badgeCount, 99), size: 24, "aria-hidden": true })), state === 'loading' && (_jsx("span", { className: "side-nav__spinner", "aria-label": "loading" }))] }));
44
55
  }
45
56
  /** @public */
46
57
  export default function SideNav(props) {
@@ -49,15 +60,17 @@ export default function SideNav(props) {
49
60
  const handleModeSwitch = useCallback(() => onAppModeChange?.(appMode === 'malgwi' ? 'manager' : 'malgwi'), [onAppModeChange, appMode]);
50
61
  return (_jsxs("nav", { "aria-label": t('nav.mainMenu', '메인 메뉴'), className: "side-nav__menu", children: [_jsxs("div", { className: "side-nav__menu-list", children: [(items || [])
51
62
  .filter((it) => !it.section && (it.key || it.label))
52
- .map((it, idx) => (_jsxs(React.Fragment, { children: [it.divider && _jsx("div", { className: "side-nav__separator" }), _jsx(SideNavButton, { item: it, active: it.key === activeKey, state: it.disabled ? 'disabled' : undefined, onClick: (_key, btnEl) => {
63
+ .map((it, idx) => (_jsxs(React.Fragment, { children: [it.divider && _jsx("div", { className: "side-nav__separator" }), _jsx(SideNavButton, { item: it, active: it.key === activeKey, state: it.disabled ? 'disabled' : undefined, onClick: (_key, btnEl, fromPointer) => {
53
64
  if (it.disabled)
54
65
  return;
55
66
  if (it.href) {
56
67
  window.open(it.href, '_blank', 'noopener,noreferrer');
57
- try {
58
- btnEl?.blur();
68
+ if (fromPointer) {
69
+ try {
70
+ btnEl?.blur();
71
+ }
72
+ catch (err) { }
59
73
  }
60
- catch (err) { }
61
74
  return;
62
75
  }
63
76
  onChange?.(it.key);
@@ -69,6 +82,10 @@ export default function SideNav(props) {
69
82
  window.scrollTo({ top: 0 });
70
83
  }
71
84
  catch (_) { }
85
+ /* 키보드로 고르면 포커스가 레일 안에 남으므로 태블릿 레일도 펼친 채 둔다(:focus-within) —
86
+ 아래 강제 접힘은 마우스가 들어와야 풀리므로 키보드 사용자에게 걸면 레일이 닫힌 채 포커스만 남는다. */
87
+ if (!fromPointer)
88
+ return;
72
89
  try {
73
90
  btnEl?.blur();
74
91
  }
@@ -40,12 +40,28 @@ export type TableProps<T extends {
40
40
  style?: CSSProperties;
41
41
  }>;
42
42
  onRowClick?: (item: T) => void;
43
+ /**
44
+ * 줄마다 누를 수 있는지. false 인 줄은 `onRowClick` 이 안 불리고, 버튼으로 읽히지도(role·Tab 정지점) 않으며,
45
+ * 손가락 커서·hover 강조도 없다. `onRowClick` 이 있을 때만 뜻이 있다. 미지정이면 모든 줄이 눌린다(기본).
46
+ *
47
+ * 예: 방문 기록 표에서 '안 옴' 줄은 열 것이 없다 — CSS 로 커서만 되돌리면 눌리기는 그대로이고 낭독기는 여전히 버튼이라 한다.
48
+ */
49
+ isRowClickable?: (item: T) => boolean;
43
50
  onRowMouseEnter?: (item: T, event: MouseEvent<HTMLDivElement>) => void;
44
51
  onRowMouseLeave?: (item: T, event: MouseEvent<HTMLDivElement>) => void;
45
52
  onRowDragStart?: (item: T, event: DragEvent<HTMLDivElement>) => void;
46
53
  onRowDragEnd?: (item: T, event: DragEvent<HTMLDivElement>) => void;
47
54
  draggingId?: string | number | null;
48
55
  gridTemplateColumns?: string;
56
+ /**
57
+ * 좁은 화면(767px 이하)에서 쓸 열 틀. 주면 그 폭에서는 이 틀을 쓰고 **끌어 둔 폭(저장값)은 무시**한다 —
58
+ * 폭 조절 손잡이와 '칸 너비 초기화' 단추도 숨겨, 좁은 화면에서 끈 값이 넓은 화면의 저장값을 덮지 않게 한다.
59
+ * 넓은 화면으로 돌아오면 저장값이 그대로 돌아온다. 미지정이면 좁은 화면도 넓은 화면과 같다(기본).
60
+ *
61
+ * 열 틀은 인라인 CSS 변수로 들어가므로 앱 CSS 로는 `!important` 없이 못 이긴다 — 그래서 prop 이다.
62
+ * 숨길 칸은 `renderRow` 의 `className`·`fields[].className` 으로 앱이 가린다.
63
+ */
64
+ mobileGridTemplateColumns?: string;
49
65
  /** true면 헤더 경계선을 드래그해 컬럼 폭 조절 가능 */
50
66
  resizable?: boolean;
51
67
  /**
@@ -98,4 +114,4 @@ export type TableProps<T extends {
98
114
  /** @public */
99
115
  export default function Table<T extends {
100
116
  id?: string | number;
101
- }>({ fields, headerGroups, items, renderRow, onRowClick, onRowMouseEnter, onRowMouseLeave, onRowDragStart, onRowDragEnd, draggingId, gridTemplateColumns, resizable, resizeMode, resizeStorageKey, size, title, icon, count, collapsed, onToggleCollapse, headerActions, empty, loading, dragOver, onDragOver, onDragLeave, onDrop, renderExpandedRow, expandedId, }: TableProps<T>): import("react").JSX.Element;
117
+ }>({ fields, headerGroups, items, renderRow, onRowClick, isRowClickable, onRowMouseEnter, onRowMouseLeave, onRowDragStart, onRowDragEnd, draggingId, gridTemplateColumns, mobileGridTemplateColumns, resizable, resizeMode, resizeStorageKey, size, title, icon, count, collapsed, onToggleCollapse, headerActions, empty, loading, dragOver, onDragOver, onDragLeave, onDrop, renderExpandedRow, expandedId, }: TableProps<T>): import("react").JSX.Element;
@@ -18,6 +18,7 @@ import ChevronDown from 'lucide-react/dist/esm/icons/chevron-down.mjs';
18
18
  import Button from './Button';
19
19
  import Icon from './Icon';
20
20
  import Loader from './Loader';
21
+ import useMediaQuery from '../hooks/useMediaQuery';
21
22
  function TableCell({ children, className, style }) {
22
23
  return _jsx("div", { className: className ? `table-cell ${className}` : 'table-cell', style: style, children: children });
23
24
  }
@@ -62,8 +63,12 @@ function TableField({ children, onClick, className, sortDir, onResizeStart }) {
62
63
  return (_jsxs("div", { onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, "aria-sort": sortDir ? (sortDir === 'asc' ? 'ascending' : 'descending') : undefined, className: cx('table-field body-3', className, sortDir ? 'table-field--sorted' : '', onResizeStart ? 'table-field--resizable' : ''), children: [children, sortDir && _jsx(SortArrow, { dir: sortDir }), onResizeStart && (_jsx("span", { className: "table-field__resizer", onPointerDown: onResizeStart, onClick: e => e.stopPropagation(), "aria-hidden": "true" }))] }));
63
64
  }
64
65
  /** @public */
65
- export default function Table({ fields = [], headerGroups, items = [], renderRow, onRowClick, onRowMouseEnter, onRowMouseLeave, onRowDragStart, onRowDragEnd, draggingId, gridTemplateColumns, resizable = false, resizeMode = 'grow', resizeStorageKey, size = 'small', title, icon, count, collapsed = false, onToggleCollapse, headerActions, empty, loading, dragOver = false, onDragOver, onDragLeave, onDrop, renderExpandedRow, expandedId, }) {
66
+ export default function Table({ fields = [], headerGroups, items = [], renderRow, onRowClick, isRowClickable, onRowMouseEnter, onRowMouseLeave, onRowDragStart, onRowDragEnd, draggingId, gridTemplateColumns, mobileGridTemplateColumns, resizable = false, resizeMode = 'grow', resizeStorageKey, size = 'small', title, icon, count, collapsed = false, onToggleCollapse, headerActions, empty, loading, dragOver = false, onDragOver, onDragLeave, onDrop, renderExpandedRow, expandedId, }) {
66
67
  const { t } = useTranslation();
68
+ /* 좁은 화면 틀이 걸리면 폭 조절을 통째로 끈다 — 손잡이·초기화 단추·저장. 훅은 늘 부른다(순서 고정). */
69
+ const 좁은화면 = useMediaQuery('(max-width: 767px)');
70
+ const 좁은틀 = mobileGridTemplateColumns && 좁은화면 ? mobileGridTemplateColumns : null;
71
+ const 폭조절 = resizable && !좁은틀;
67
72
  // ── 컬럼 폭 드래그 리사이즈 (resizable) ──
68
73
  const MIN_COL_W = 48;
69
74
  const storeKey = resizeStorageKey ? `table-cols:${resizeStorageKey}` : null;
@@ -277,28 +282,29 @@ export default function Table({ fields = [], headerGroups, items = [], renderRow
277
282
  const widthUnit = (i) => (resizeMode === 'fit' && !isFixedPx(i)) ? 'fr' : 'px';
278
283
  const canApplyWidths = colWidths && colWidths.length === fields.length
279
284
  && (resizeMode === 'fit' || items.length > 0);
280
- const effectiveGrid = canApplyWidths
285
+ const effectiveGrid = 좁은틀 ?? (canApplyWidths
281
286
  ? fields.map((f, i) => (f.resizable === false && defaultCols[i]) ? defaultCols[i] : `${colWidths[i]}${widthUnit(i)}`).join(' ')
282
- : gridTemplateColumns;
287
+ : gridTemplateColumns);
283
288
  const gridStyle = effectiveGrid
284
289
  ? { '--table-grid-cols': effectiveGrid }
285
290
  : undefined;
286
291
  const hasHeader = title !== undefined;
287
- return (_jsx("div", { className: `table-wrap table-wrap--${size}${resizable ? ' table-wrap--resizable' : ''}${dragOver ? ' table-wrap--dragover' : ''}${collapsed ? ' table-wrap--collapsed' : ''}`, style: gridStyle, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, children: _jsxs("div", { className: "table body-2", children: [hasHeader && (_jsxs("div", { className: "table-header", onClick: onToggleCollapse, role: onToggleCollapse ? 'button' : undefined, tabIndex: onToggleCollapse ? 0 : undefined, "aria-expanded": onToggleCollapse ? !collapsed : undefined, onKeyDown: onToggleCollapse ? (e) => { if (e.key === 'Enter' || e.key === ' ') {
292
+ return (_jsx("div", { className: `table-wrap table-wrap--${size}${폭조절 ? ' table-wrap--resizable' : ''}${dragOver ? ' table-wrap--dragover' : ''}${collapsed ? ' table-wrap--collapsed' : ''}`, style: gridStyle, onDragOver: onDragOver, onDragLeave: onDragLeave, onDrop: onDrop, children: _jsxs("div", { className: "table body-2", children: [hasHeader && (_jsxs("div", { className: "table-header", onClick: onToggleCollapse, role: onToggleCollapse ? 'button' : undefined, tabIndex: onToggleCollapse ? 0 : undefined, "aria-expanded": onToggleCollapse ? !collapsed : undefined, onKeyDown: onToggleCollapse ? (e) => { if (e.key === 'Enter' || e.key === ' ') {
288
293
  e.preventDefault();
289
294
  onToggleCollapse();
290
- } } : undefined, children: [onToggleCollapse && (_jsx(Button, { size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-right' : 'chevron-down', ariaLabel: collapsed ? t('common.expand', '펼치기') : t('common.collapse', '접기'), className: "table-header__chevron" })), icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "table-header__icon" })), _jsx("span", { className: "table-header__title h5b", children: title }), count !== undefined && _jsx("span", { className: "table-header__count body-3b", children: count }), headerActions && (_jsx("span", { className: "table-header__actions", onClick: e => e.stopPropagation(), children: headerActions }))] })), !collapsed && (_jsxs(_Fragment, { children: [headerGroups && headerGroups.length > 0 && (_jsx("div", { className: "table-fields table-fields--groups", children: headerGroups.map((g, i) => (_jsx("div", { className: cx('table-field body-3', g.className), style: { gridColumn: `span ${g.span}` }, children: g.label }, i))) })), _jsxs("div", { className: cx('table-fields-strip', resizable && 'table-fields-strip--resizable'), children: [_jsx("div", { className: "table-fields", ref: fieldsRef, children: fields.map((f, i) => (_jsx(TableField, { onClick: f.onClick, className: f.className, sortDir: f.sortDir,
295
+ } } : undefined, children: [onToggleCollapse && (_jsx(Button, { size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: collapsed ? 'chevron-right' : 'chevron-down', ariaLabel: collapsed ? t('common.expand', '펼치기') : t('common.collapse', '접기'), className: "table-header__chevron" })), icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "table-header__icon" })), _jsx("span", { className: "table-header__title h5b", children: title }), count !== undefined && _jsx("span", { className: "table-header__count body-3b", children: count }), headerActions && (_jsx("span", { className: "table-header__actions", onClick: e => e.stopPropagation(), children: headerActions }))] })), !collapsed && (_jsxs(_Fragment, { children: [headerGroups && headerGroups.length > 0 && (_jsx("div", { className: "table-fields table-fields--groups", children: headerGroups.map((g, i) => (_jsx("div", { className: cx('table-field body-3', g.className), style: { gridColumn: `span ${g.span}` }, children: g.label }, i))) })), _jsxs("div", { className: cx('table-fields-strip', 폭조절 && 'table-fields-strip--resizable'), children: [_jsx("div", { className: "table-fields", ref: fieldsRef, children: fields.map((f, i) => (_jsx(TableField, { onClick: f.onClick, className: f.className, sortDir: f.sortDir,
291
296
  /* fit 모드는 오른쪽 이웃에서 폭을 덜어 오므로, 덜어 올 컬럼이 없는 자리에는
292
297
  손잡이를 두지 않는다 — 두면 그 컬럼만 grow처럼 넓어져 표가 밀린다. */
293
- onResizeStart: resizable && i < fields.length - 1 && f.resizable !== false
298
+ onResizeStart: 폭조절 && i < fields.length - 1 && f.resizable !== false
294
299
  && (resizeMode !== 'fit' || fields.some((g, j) => j > i && g.resizable !== false))
295
- ? (e) => startResize(i, e) : undefined, children: f.label }, i))) }), resizable && (_jsx(Button, { size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: "chevrons-left-right-ellipsis", ariaLabel: t('table.resetColumns', '칸 너비 초기화'), showTooltipInfo: t('table.resetColumns', '칸 너비 초기화'), className: "table-fields__reset", onClick: 자동맞춤 }))] }), _jsx("div", { className: "table-body", children: loading ? (_jsx(TableRow, { variant: "loading" })) : items.length === 0 && empty ? (_jsx(TableRow, { variant: "empty", children: empty })) : items.map((item, idx) => {
300
+ ? (e) => startResize(i, e) : undefined, children: f.label }, i))) }), 폭조절 && (_jsx(Button, { size: 40, type: "dot", variant: "transparent-gray", useBorder: false, icon: "chevrons-left-right-ellipsis", ariaLabel: t('table.resetColumns', '칸 너비 초기화'), showTooltipInfo: t('table.resetColumns', '칸 너비 초기화'), className: "table-fields__reset", onClick: 자동맞춤 }))] }), _jsx("div", { className: "table-body", children: loading ? (_jsx(TableRow, { variant: "loading" })) : items.length === 0 && empty ? (_jsx(TableRow, { variant: "empty", children: empty })) : items.map((item, idx) => {
296
301
  const isDragging = draggingId != null && item.id === draggingId;
302
+ const 눌림 = !!onRowClick && (isRowClickable ? isRowClickable(item) : true);
297
303
  const isExpanded = expandedId != null && (expandedId instanceof Set ? expandedId.has(item.id) : item.id === expandedId);
298
- return (_jsx(TableRow, { className: `${onRowClick ? ' table-row--clickable' : ''}${onRowDragStart ? ' table-row--draggable' : ''}${isDragging ? ' table-row--dragging' : ''}`.trim(), draggable: !!onRowDragStart,
304
+ return (_jsx(TableRow, { className: `${눌림 ? ' table-row--clickable' : ''}${onRowDragStart ? ' table-row--draggable' : ''}${isDragging ? ' table-row--dragging' : ''}`.trim(), draggable: !!onRowDragStart,
299
305
  // 옆의 핸들러들처럼 있을 때만 넘긴다. 항상 함수를 만들어 넘기면
300
306
  // TableRow 가 "클릭 가능"으로 보고 role="button" · tabIndex=0 을 붙여,
301
307
  // 클릭 동작이 없는 표의 모든 행이 버튼으로 읽히고 탭 정지점이 된다.
302
- onClick: onRowClick ? () => onRowClick(item) : undefined, onMouseEnter: onRowMouseEnter ? (e) => onRowMouseEnter(item, e) : undefined, onMouseLeave: onRowMouseLeave ? (e) => onRowMouseLeave(item, e) : undefined, onDragStart: onRowDragStart ? (e) => onRowDragStart(item, e) : undefined, onDragEnd: onRowDragEnd ? (e) => onRowDragEnd(item, e) : undefined, expandedContent: isExpanded && renderExpandedRow ? renderExpandedRow(item) : undefined, children: renderRow(item).map((cell, i) => (_jsx(TableCell, { className: cell.className, style: cell.style, children: cell.content }, i))) }, String(item.id ?? idx)));
308
+ onClick: 눌림 ? () => onRowClick(item) : undefined, onMouseEnter: onRowMouseEnter ? (e) => onRowMouseEnter(item, e) : undefined, onMouseLeave: onRowMouseLeave ? (e) => onRowMouseLeave(item, e) : undefined, onDragStart: onRowDragStart ? (e) => onRowDragStart(item, e) : undefined, onDragEnd: onRowDragEnd ? (e) => onRowDragEnd(item, e) : undefined, expandedContent: isExpanded && renderExpandedRow ? renderExpandedRow(item) : undefined, children: renderRow(item).map((cell, i) => (_jsx(TableCell, { className: cell.className, style: cell.style, children: cell.content }, i))) }, String(item.id ?? idx)));
303
309
  }) })] }))] }) }));
304
310
  }
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
3
3
  import { cx } from '../utils/cx';
4
- import { useId, useEffect, useRef, useCallback } from 'react';
4
+ import { useId, useEffect, useLayoutEffect, useRef, useCallback, useState } from 'react';
5
5
  import { devWarnOnce } from '../utils/devWarn';
6
6
  import { useTranslation } from 'react-i18next';
7
7
  import Icon from './Icon';
@@ -53,7 +53,15 @@ export default function TextField(props) {
53
53
  const isControlled = value !== undefined;
54
54
  /* 비밀번호는 value 를 React 에 넘기지 않는다 — 위 useEffect 주석 참고 */
55
55
  const isPasswordControlled = isControlled && type === 'password';
56
- const len = (value ?? '').length;
56
+ /* 비제어 칸은 value 가 없으니 글자 수를 따로 센다 — 전에는 value 로만 세어 defaultValue 칸의 카운터는
57
+ 0, 지우기 단추는 안 보이고, 자동 높이는 늘지 않았다. */
58
+ const [innerLen, setInnerLen] = useState((defaultValue ?? '').length);
59
+ const len = isControlled ? (value ?? '').length : innerLen;
60
+ /* 비밀번호 controlled 칸이 부모가 거절·정규화한 값을 따라가게 — 아래 useEffect 는 value 가 바뀔 때만
61
+ 돌아, 부모가 같은 값을 다시 두면(공백을 걷는 등) 화면 칸에 친 글자가 남았다. React 가 일반 칸에
62
+ 해 주는 되돌림(restoreControlledState)을 onChange 뒤 한 박자에 흉내 낸다. */
63
+ const valueRef = useRef(value);
64
+ useLayoutEffect(() => { valueRef.current = value; });
57
65
  /*
58
66
  * React 는 controlled input 의 값을 node.defaultValue(= HTML value 속성)에 동기화한다.
59
67
  * 일반 입력에서는 무해하지만 비밀번호에서는 그 속성이 outerHTML 직렬화 경로
@@ -80,7 +88,7 @@ export default function TextField(props) {
80
88
  if (el.scrollHeight > el.clientHeight) {
81
89
  el.style.height = `${el.scrollHeight}px`;
82
90
  }
83
- }, [value, newline]);
91
+ }, [value, innerLen, newline]);
84
92
  const isOver = maxLength != null && len > maxLength;
85
93
  const iconSize = size === 32 ? 16 : 20;
86
94
  const cls = cx('text-field', `text-field--s${size}`, variant === 'display' ? 'text-field--display' : '', fullWidth ? 'text-field--full-width' : '', disabled ? 'text-field--disabled' : '', readOnly ? 'text-field--readonly' : '', error ? 'text-field--error' : '', leftIcon ? 'text-field--has-left-icon' : '', (rightIcon || clearable) ? 'text-field--has-right-icon' : '', className);
@@ -119,7 +127,21 @@ export default function TextField(props) {
119
127
  조용히 버린다. */
120
128
  'aria-controls': 목록떴나 ? combobox.listId : undefined,
121
129
  'aria-activedescendant': 목록떴나 && combobox.activeIdx >= 0 ? `${combobox.listId}-option-${combobox.activeIdx}` : undefined,
122
- onChange: (e) => onChange?.(e.target.value),
130
+ onChange: (e) => {
131
+ const node = e.target;
132
+ if (!isControlled)
133
+ setInnerLen(node.value.length);
134
+ onChange?.(node.value);
135
+ if (suggestions)
136
+ combobox.handleInput();
137
+ if (isPasswordControlled) {
138
+ queueMicrotask(() => {
139
+ const next = valueRef.current ?? '';
140
+ if (node.value !== next)
141
+ node.value = next;
142
+ });
143
+ }
144
+ },
123
145
  onFocus: ((e) => {
124
146
  onFocus?.(e);
125
147
  if (suggestions)
@@ -171,7 +193,14 @@ export default function TextField(props) {
171
193
  // 지우는 일은 click 에서 한다 — mousedown 에서만 처리하면 물리적 마우스 누름
172
194
  // 외에는 아무 반응이 없어, 이름표(aria-label)만 달린 못 누르는 버튼이 된다
173
195
  // (스크린리더의 활성화는 click 을 보낸다).
174
- onClick: () => onChange?.(''), tabIndex: -1 })), rightIcon && !showClear && (_jsx("span", { className: "text-field__icon text-field__icon--right", children: _jsx(Icon, { icon: rightIcon, label: "", className: "", svgClassName: "text-field__icon-svg", size: iconSize }) }))] }), suggestions && combobox.open && combobox.filtered.length > 0 && (_jsx("ul", { id: combobox.listId, className: "text-field__suggestions", role: "listbox", children: combobox.filtered.map((s, i) => (_jsx("li", { id: `${combobox.listId}-option-${i}`, role: "option", "aria-selected": i === combobox.activeIdx, className: `text-field__suggestion body-2${i === combobox.activeIdx ? ' is-active' : ''}`,
196
+ onClick: () => {
197
+ // 비제어 칸은 화면 값도 직접 비운다(onChange 만 부르면 글자가 그대로 남는다).
198
+ if (!isControlled && autoRef.current) {
199
+ autoRef.current.value = '';
200
+ setInnerLen(0);
201
+ }
202
+ onChange?.('');
203
+ }, tabIndex: -1 })), rightIcon && !showClear && (_jsx("span", { className: "text-field__icon text-field__icon--right", children: _jsx(Icon, { icon: rightIcon, label: "", className: "", svgClassName: "text-field__icon-svg", size: iconSize }) }))] }), suggestions && combobox.open && combobox.filtered.length > 0 && (_jsx("ul", { id: combobox.listId, className: "text-field__suggestions", role: "listbox", children: combobox.filtered.map((s, i) => (_jsx("li", { id: `${combobox.listId}-option-${i}`, role: "option", "aria-selected": i === combobox.activeIdx, className: `text-field__suggestion body-2${i === combobox.activeIdx ? ' is-active' : ''}`,
175
204
  // 선택은 click 에서 한다. onMouseDown 만 두면 마우스로는 눌리지만 스크린리더가
176
205
  // 이 option 을 활성화할 때는 click 만 발생해 아무 일도 일어나지 않는다.
177
206
  // mousedown 에서는 기본 동작(포커스 이동 → 입력 blur)을 막아, click 이 도달하기
@@ -85,6 +85,8 @@ export interface BarChartProps {
85
85
  loading?: boolean;
86
86
  /** 지정 시 헤더 우측에 '상세보기' 버튼 표시 → 클릭 콜백 */
87
87
  onDetail?: () => void;
88
+ /** CSS 격자 칸 안에 세운다 — 아래 여백·최소 폭을 풀고 막대를 카드 바닥에 맞춘다. ChartSection `inGrid` 참고. */
89
+ inGrid?: boolean;
88
90
  }
89
91
  /** 현재 컨트롤 선택(주/월)의 날짜 범위 — 상세보기(기간별 리스트)용 */
90
92
  export declare function getControlDateRange(control: BarChartControl): {
@@ -17,6 +17,7 @@ export declare function useComboboxSuggestions({ value, onChange, suggestions, o
17
17
  filtered: string[];
18
18
  handleFocus: () => void;
19
19
  handleBlur: () => void;
20
+ handleInput: () => void;
20
21
  handleKeyDown: (e: KeyboardEvent<HTMLInputElement | HTMLTextAreaElement>) => void;
21
22
  handleSelect: (item: string) => void;
22
23
  };
@@ -25,11 +25,17 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
25
25
  setActiveIdx(-1); }, [open]);
26
26
  useEffect(() => () => { if (blurTimerRef.current)
27
27
  clearTimeout(blurTimerRef.current); }, []);
28
+ /* 입력으로 후보가 줄면 가리키던 번호가 목록 밖에 남는다 — 그 채로 Enter 를 누르면 filtered[3] 같은
29
+ 없는 칸을 골라 onChange(undefined) 가 나갔다. 밖이면 '안 가리킴' 으로 본다. */
30
+ const safeIdx = activeIdx < filtered.length ? activeIdx : -1;
28
31
  const handleSelect = useCallback((item) => {
29
32
  onChange?.(item);
30
33
  setOpen(false);
31
34
  }, [onChange]);
32
35
  const handleFocus = useCallback(() => setOpen(true), []);
36
+ /* 글자를 치면 목록을 다시 연다 — 고르거나 Esc 로 닫은 뒤에는 포커스를 뺐다 넣어야만 열렸다.
37
+ 새 글자에는 새 후보가 서므로 가리킴도 처음으로 돌린다. */
38
+ const handleInput = useCallback(() => { setOpen(true); setActiveIdx(-1); }, []);
33
39
  // blur가 리스트 항목의 onMouseDown보다 먼저 발생하는 걸 막기 위한 딜레이
34
40
  const handleBlur = useCallback(() => {
35
41
  blurTimerRef.current = setTimeout(() => setOpen(false), 120);
@@ -38,16 +44,16 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
38
44
  if (e.key === 'ArrowDown') {
39
45
  e.preventDefault();
40
46
  setOpen(true);
41
- setActiveIdx(i => Math.min(filtered.length - 1, i + 1));
47
+ setActiveIdx(i => Math.min(filtered.length - 1, (i < filtered.length ? i : -1) + 1));
42
48
  }
43
49
  else if (e.key === 'ArrowUp') {
44
50
  e.preventDefault();
45
- setActiveIdx(i => Math.max(-1, i - 1));
51
+ setActiveIdx(i => Math.max(-1, (i < filtered.length ? i : filtered.length) - 1));
46
52
  }
47
53
  else if (e.key === 'Enter') {
48
54
  // 한글 조합을 끝내는 Enter는 선택이 아니다 — 조합 중엔 제안을 고르지 않는다
49
- if (activeIdx >= 0 && !e.nativeEvent.isComposing)
50
- handleSelect(filtered[activeIdx]);
55
+ if (safeIdx >= 0 && !e.nativeEvent.isComposing)
56
+ handleSelect(filtered[safeIdx]);
51
57
  }
52
58
  else if (e.key === 'Escape') {
53
59
  if (open) {
@@ -55,6 +61,6 @@ export function useComboboxSuggestions({ value, onChange, suggestions, openOnFoc
55
61
  setOpen(false);
56
62
  } // 제안이 열렸을 때만 소비(상위 팝업 ESC보다 우선하지 않도록)
57
63
  }
58
- }, [filtered, activeIdx, open, handleSelect]);
59
- return { listId, open, activeIdx, filtered, handleFocus, handleBlur, handleKeyDown, handleSelect };
64
+ }, [filtered, safeIdx, open, handleSelect]);
65
+ return { listId, open, activeIdx: safeIdx, filtered, handleFocus, handleBlur, handleInput, handleKeyDown, handleSelect };
60
66
  }
@@ -173,7 +173,8 @@
173
173
  "wordTable": "Word Table Creator",
174
174
  "company": "Company Info",
175
175
  "more": "More",
176
- "moreMenu": "More menu"
176
+ "moreMenu": "More menu",
177
+ "badgeLabel": "{{label}}, {{count}}"
177
178
  },
178
179
  "settings": {
179
180
  "language": {
@@ -171,7 +171,8 @@
171
171
  "wordTable": "단어표 만들기",
172
172
  "company": "업체정보",
173
173
  "more": "더보기",
174
- "moreMenu": "더보기 메뉴"
174
+ "moreMenu": "더보기 메뉴",
175
+ "badgeLabel": "{{label}}, {{count}}개"
175
176
  },
176
177
  "settings": {
177
178
  "language": {
@@ -6,28 +6,22 @@ export declare const TRAINING_TYPES: {
6
6
  icon: string;
7
7
  desc: string;
8
8
  }[];
9
- /** @public */
10
- export declare const TOPIC_TYPES: ({
11
- key: string;
12
- label: string;
13
- icon: string;
14
- color: string;
15
- subs: {
16
- key: string;
17
- label: string;
18
- }[];
19
- desc?: undefined;
20
- } | {
9
+ /**
10
+ * 주제·세부 주제 목록(열쇠·이름·아이콘·색). **설명(desc)은 두지 않는다** — 코어·앱 어디에서도 주제 설명을
11
+ * 그리지 않고, 한때 7개 주제에 붙어 있던 '소음 환경 속 문장 이해 훈련' 은 훈련 유형(소음 하 문장 듣기)의
12
+ * 설명을 옮겨 붙인 값이었다(2026-09-14 걷음). 화면에 보이는 설명은 훈련 유형(`TRAINING_TYPES`)의 것이다.
13
+ * @public
14
+ */
15
+ export declare const TOPIC_TYPES: {
21
16
  key: string;
22
17
  label: string;
23
18
  icon: string;
24
- desc: string;
25
19
  color: string;
26
20
  subs: {
27
21
  key: string;
28
22
  label: string;
29
23
  }[];
30
- })[];
24
+ }[];
31
25
  /** 훈련 유형 i18n 키 매핑 */
32
26
  export declare const TRAINING_TYPE_I18N_KEY: Record<string, string>;
33
27
  /** 번역된 훈련 유형 레이블/설명 반환 */
@@ -38,7 +32,7 @@ export declare function translateTrainingTypes(t: TFunction): {
38
32
  desc: string;
39
33
  }[];
40
34
  /** 번역된 토픽/서브토픽 레이블 반환 — 컴포넌트에서 useMemo와 함께 사용 */
41
- export declare function translateTopicTypes(t: TFunction): ({
35
+ export declare function translateTopicTypes(t: TFunction): {
42
36
  label: string;
43
37
  subs: {
44
38
  label: string;
@@ -47,18 +41,7 @@ export declare function translateTopicTypes(t: TFunction): ({
47
41
  key: string;
48
42
  icon: string;
49
43
  color: string;
50
- desc?: undefined;
51
- } | {
52
- label: string;
53
- subs: {
54
- label: string;
55
- key: string;
56
- }[];
57
- key: string;
58
- icon: string;
59
- desc: string;
60
- color: string;
61
- })[];
44
+ }[];
62
45
  declare const _default: {
63
46
  TRAINING_TYPES: {
64
47
  key: string;
@@ -66,7 +49,7 @@ declare const _default: {
66
49
  icon: string;
67
50
  desc: string;
68
51
  }[];
69
- TOPIC_TYPES: ({
52
+ TOPIC_TYPES: {
70
53
  key: string;
71
54
  label: string;
72
55
  icon: string;
@@ -75,17 +58,6 @@ declare const _default: {
75
58
  key: string;
76
59
  label: string;
77
60
  }[];
78
- desc?: undefined;
79
- } | {
80
- key: string;
81
- label: string;
82
- icon: string;
83
- desc: string;
84
- color: string;
85
- subs: {
86
- key: string;
87
- label: string;
88
- }[];
89
- })[];
61
+ }[];
90
62
  };
91
63
  export default _default;