@ihabdevteam/core 0.131.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 (34) hide show
  1. package/CHANGELOG.md +43 -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 +1 -1
  6. package/dist/ihabdevteam-core.cjs.js +182 -182
  7. package/dist/ihabdevteam-core.d.ts +55 -6
  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/Calendar.js +4 -0
  14. package/dist/packages/core/src/components/ChartSection.css +16 -0
  15. package/dist/packages/core/src/components/ChartSection.d.ts +8 -1
  16. package/dist/packages/core/src/components/ChartSection.js +2 -2
  17. package/dist/packages/core/src/components/PageTitle.css +6 -0
  18. package/dist/packages/core/src/components/PageTitle.d.ts +9 -1
  19. package/dist/packages/core/src/components/PageTitle.js +2 -2
  20. package/dist/packages/core/src/components/Popup/Popup.d.ts +8 -0
  21. package/dist/packages/core/src/components/Popup/Popup.js +4 -2
  22. package/dist/packages/core/src/components/RingChart.d.ts +3 -1
  23. package/dist/packages/core/src/components/RingChart.js +2 -2
  24. package/dist/packages/core/src/components/SideNav.js +27 -10
  25. package/dist/packages/core/src/components/Table.d.ts +17 -1
  26. package/dist/packages/core/src/components/Table.js +15 -9
  27. package/dist/packages/core/src/components/TextField.js +34 -5
  28. package/dist/packages/core/src/components/barChartTypes.d.ts +2 -0
  29. package/dist/packages/core/src/components/useComboboxSuggestions.d.ts +1 -0
  30. package/dist/packages/core/src/components/useComboboxSuggestions.js +12 -6
  31. package/dist/packages/core/src/version.d.ts +1 -1
  32. package/dist/packages/core/src/version.js +1 -1
  33. package/dist/utils.cjs.js +1 -1
  34. package/package.json +1 -1
@@ -230,6 +230,11 @@ export declare interface BadgeProps {
230
230
  * bar 는 주면 그 이름으로 읽힌다(안 주면 글자 그대로 읽힌다).
231
231
  */
232
232
  'aria-label'?: string;
233
+ /**
234
+ * 낭독기에서 숨긴다(dot·bar 모두). 개수를 곁의 단추 이름에 넣었을 때 쓴다 — 예: SideNav·BottomNav 는
235
+ * 단추 이름을 "숙제, 3개" 로 짓고 뱃지는 숨긴다. 주면 `aria-label` 보다 이긴다.
236
+ */
237
+ 'aria-hidden'?: boolean;
233
238
  label?: string;
234
239
  children?: ReactNode;
235
240
  /**
@@ -290,7 +295,7 @@ export declare type BadgeType = 'bar' | 'dot';
290
295
  * - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(일~토) 막대
291
296
  * 원시 렌더링은 내부 BarChartCanvas가 담당한다.
292
297
  */
293
- export declare function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, tightMax, avgMode, totalFormat, series, loading, onDetail, }: BarChartProps): JSX.Element;
298
+ export declare function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, tightMax, avgMode, totalFormat, series, loading, onDetail, inGrid, }: BarChartProps): JSX.Element;
294
299
 
295
300
  /** @public */
296
301
  export declare interface BarChartControl {
@@ -382,6 +387,8 @@ export declare interface BarChartProps {
382
387
  loading?: boolean;
383
388
  /** 지정 시 헤더 우측에 '상세보기' 버튼 표시 → 클릭 콜백 */
384
389
  onDetail?: () => void;
390
+ /** CSS 격자 칸 안에 세운다 — 아래 여백·최소 폭을 풀고 막대를 카드 바닥에 맞춘다. ChartSection `inGrid` 참고. */
391
+ inGrid?: boolean;
385
392
  }
386
393
 
387
394
  /** 시맨틱 사이즈 — 차트 캔버스 높이·막대 너비 프리셋 */
@@ -906,7 +913,7 @@ export declare interface ChartPoint {
906
913
  * 공유하도록 두 컴포넌트가 내부적으로 사용한다.
907
914
  * @public
908
915
  */
909
- export declare function ChartSection({ title, titleClassName, headRight, subRow, className, children }: ChartSectionProps): JSX.Element;
916
+ export declare function ChartSection({ title, titleClassName, headRight, subRow, className, inGrid, children }: ChartSectionProps): JSX.Element;
910
917
 
911
918
  /** @public */
912
919
  export declare interface ChartSectionProps {
@@ -920,6 +927,13 @@ export declare interface ChartSectionProps {
920
927
  subRow?: ReactNode;
921
928
  /** 섹션 루트에 추가할 클래스 */
922
929
  className?: string;
930
+ /**
931
+ * CSS 격자(한 줄에 여러 칸) 안에 세울 때 켠다 — `manager-section--grid`.
932
+ * 아래 여백(14px)·최소 폭(320px)을 풀고 카드를 세로 flex 로 만들어, 같은 줄 카드끼리 높이가 늘어나면
933
+ * 차트 몸통(막대·링·로딩)을 **카드 바닥**에 맞춘다(범례 줄 수가 달라도 막대 바닥이 한 선).
934
+ * 차트가 아닌 카드에 같은 꼴이 필요하면 `manager-section manager-section--grid` 클래스를 그대로 쓴다.
935
+ */
936
+ inGrid?: boolean;
923
937
  children?: ReactNode;
924
938
  }
925
939
 
@@ -1355,7 +1369,7 @@ export declare function copyText(text: string): Promise<boolean>;
1355
1369
  *
1356
1370
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1357
1371
  */
1358
- export declare const CORE_VERSION = "0.131.0";
1372
+ export declare const CORE_VERSION = "0.132.0";
1359
1373
 
1360
1374
  /** @public */
1361
1375
  declare const coreDefault: Record<string, unknown>;
@@ -3246,7 +3260,7 @@ export declare type PageProps = {
3246
3260
  export declare function pageStartIndexes(pageLengths: readonly number[]): number[];
3247
3261
 
3248
3262
  /** @public */
3249
- export declare function PageTitle({ title, sub, rightAction, titleAction, badge, badgeVariant, documentTitle, onBack, backLabel }: PageTitleProps): JSX.Element;
3263
+ export declare function PageTitle({ title, sub, rightAction, titleAction, badge, badgeVariant, documentTitle, onBack, backLabel, wrap }: PageTitleProps): JSX.Element;
3250
3264
 
3251
3265
  declare type PageTitleProps = {
3252
3266
  title: ReactNode;
@@ -3277,6 +3291,14 @@ declare type PageTitleProps = {
3277
3291
  * title 이 문자열이면 이 값은 필요 없다.
3278
3292
  */
3279
3293
  documentTitle?: string;
3294
+ /**
3295
+ * 오른쪽(`rightAction`)이 넓어 제목 칸을 짓누를 때 **줄을 접어** 오른쪽을 아래로 내린다. 기본 false(한 줄 유지).
3296
+ *
3297
+ * 한 줄로 두면 제목 칸이 `min-width: 0` 까지 줄어, 탭 일곱 같은 넓은 오른쪽이 제목을 한 글자씩 세로로
3298
+ * 밀어낸다(말귀 고객 상세 1135px 실측). 켜면 제목 칸에 320px(좁으면 제 폭) 바닥을 주고, 그 바닥이
3299
+ * 안 들어가면 오른쪽이 다음 줄로 간다.
3300
+ */
3301
+ wrap?: boolean;
3280
3302
  };
3281
3303
 
3282
3304
  /**
@@ -4125,6 +4147,14 @@ export declare interface PopupProps {
4125
4147
  * 더 넓거나 무제한이 필요하면 className으로 `.popup-panel` max-width를 직접 override.
4126
4148
  */
4127
4149
  size?: 'small' | 'medium' | 'large';
4150
+ /**
4151
+ * 패널 높이를 못 박는다(숫자는 px, 문자열은 CSS 길이 그대로). 미지정 시 내용만큼(기본).
4152
+ *
4153
+ * 탭을 옮길 때마다 내용 높이가 달라 창이 위아래로 뛰는 팝업에 쓴다. **화면보다 크면 화면에
4154
+ * 맞춘다** — 패널의 `max-height`(데스크톱 `100% - 60px`, 좁은 화면은 하단 내비 몫까지 뺀 값)가
4155
+ * 그대로 걸리므로 창이 화면 밖으로 나가지 않는다. 넘치는 내용은 본문이 스스로 굴린다.
4156
+ */
4157
+ height?: number | string;
4128
4158
  /**
4129
4159
  * 이 값이 바뀌면 본문(.popup-content) 스크롤을 맨 위로 되돌린다.
4130
4160
  * 탭/모드 전환처럼 내부 콘텐츠가 통째로 바뀌는 팝업에서, 스크롤을 내린 채
@@ -4904,7 +4934,7 @@ export declare function reviewTableName(original: string, today: Date): string;
4904
4934
  * 미지정 시에는 헤더 없는 순수 링만 렌더된다(크기/색상 데모 등).
4905
4935
  * @public
4906
4936
  */
4907
- export declare function RingChart({ titleKey, headRight, icon, showTotal, totalLabelKey, legend, loading, className, ...ringProps }: RingChartProps): JSX.Element;
4937
+ export declare function RingChart({ titleKey, headRight, icon, showTotal, totalLabelKey, legend, loading, className, inGrid, ...ringProps }: RingChartProps): JSX.Element;
4908
4938
 
4909
4939
  /** @public */
4910
4940
  export declare interface RingChartProps {
@@ -4955,6 +4985,8 @@ export declare interface RingChartProps {
4955
4985
  loading?: boolean;
4956
4986
  /** 섹션 루트에 추가할 클래스 (titleKey 지정 시에만 사용) */
4957
4987
  className?: string;
4988
+ /** CSS 격자 칸 안에 세운다 — 아래 여백·최소 폭을 풀고 링을 카드 바닥에 맞춘다. ChartSection `inGrid` 참고(titleKey 지정 시에만 사용) */
4989
+ inGrid?: boolean;
4958
4990
  }
4959
4991
 
4960
4992
  /** @public */
@@ -6017,7 +6049,7 @@ export declare type TabItem = {
6017
6049
  /** @public */
6018
6050
  export declare function Table<T extends {
6019
6051
  id?: string | number;
6020
- }>({ 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>): JSX.Element;
6052
+ }>({ 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>): JSX.Element;
6021
6053
 
6022
6054
  /**
6023
6055
  * **다음에 쓸 표를 고른다.**
@@ -6139,12 +6171,28 @@ export declare type TableProps<T extends {
6139
6171
  style?: CSSProperties;
6140
6172
  }>;
6141
6173
  onRowClick?: (item: T) => void;
6174
+ /**
6175
+ * 줄마다 누를 수 있는지. false 인 줄은 `onRowClick` 이 안 불리고, 버튼으로 읽히지도(role·Tab 정지점) 않으며,
6176
+ * 손가락 커서·hover 강조도 없다. `onRowClick` 이 있을 때만 뜻이 있다. 미지정이면 모든 줄이 눌린다(기본).
6177
+ *
6178
+ * 예: 방문 기록 표에서 '안 옴' 줄은 열 것이 없다 — CSS 로 커서만 되돌리면 눌리기는 그대로이고 낭독기는 여전히 버튼이라 한다.
6179
+ */
6180
+ isRowClickable?: (item: T) => boolean;
6142
6181
  onRowMouseEnter?: (item: T, event: MouseEvent_2<HTMLDivElement>) => void;
6143
6182
  onRowMouseLeave?: (item: T, event: MouseEvent_2<HTMLDivElement>) => void;
6144
6183
  onRowDragStart?: (item: T, event: DragEvent_2<HTMLDivElement>) => void;
6145
6184
  onRowDragEnd?: (item: T, event: DragEvent_2<HTMLDivElement>) => void;
6146
6185
  draggingId?: string | number | null;
6147
6186
  gridTemplateColumns?: string;
6187
+ /**
6188
+ * 좁은 화면(767px 이하)에서 쓸 열 틀. 주면 그 폭에서는 이 틀을 쓰고 **끌어 둔 폭(저장값)은 무시**한다 —
6189
+ * 폭 조절 손잡이와 '칸 너비 초기화' 단추도 숨겨, 좁은 화면에서 끈 값이 넓은 화면의 저장값을 덮지 않게 한다.
6190
+ * 넓은 화면으로 돌아오면 저장값이 그대로 돌아온다. 미지정이면 좁은 화면도 넓은 화면과 같다(기본).
6191
+ *
6192
+ * 열 틀은 인라인 CSS 변수로 들어가므로 앱 CSS 로는 `!important` 없이 못 이긴다 — 그래서 prop 이다.
6193
+ * 숨길 칸은 `renderRow` 의 `className`·`fields[].className` 으로 앱이 가린다.
6194
+ */
6195
+ mobileGridTemplateColumns?: string;
6148
6196
  /** true면 헤더 경계선을 드래그해 컬럼 폭 조절 가능 */
6149
6197
  resizable?: boolean;
6150
6198
  /**
@@ -7760,6 +7808,7 @@ export declare function useComboboxSuggestions({ value, onChange, suggestions, o
7760
7808
  filtered: string[];
7761
7809
  handleFocus: () => void;
7762
7810
  handleBlur: () => void;
7811
+ handleInput: () => void;
7763
7812
  handleKeyDown: (e: KeyboardEvent_2<HTMLInputElement | HTMLTextAreaElement>) => void;
7764
7813
  handleSelect: (item: string) => void;
7765
7814
  };
@@ -6,7 +6,10 @@ import Icon from './Icon';
6
6
  import './Accordion.css';
7
7
  function Item({ title, body, open = false, onToggle, avatar, titleClassName = 'body-2', bodyClassName = 'body-2', className = '', }) {
8
8
  const { t } = useTranslation();
9
- return (_jsxs("div", { className: `accordion-item ${open ? 'accordion-item--open' : ''} ${className}`.trim(), children: [_jsxs("div", { className: "accordion-item__header", role: "button", tabIndex: 0, "aria-expanded": open, "data-accordion-header": true, onClick: onToggle, onKeyDown: e => {
9
+ return (
10
+ /* 무리가 role="list" 라 항목은 listitem 이어야 한다 — 없으면 낭독기가 "목록, 항목 0개" 로 읽고
11
+ 목록 안 차례("3개 중 2번째")도 못 준다. */
12
+ _jsxs("div", { className: `accordion-item ${open ? 'accordion-item--open' : ''} ${className}`.trim(), role: "listitem", children: [_jsxs("div", { className: "accordion-item__header", role: "button", tabIndex: 0, "aria-expanded": open, "data-accordion-header": true, onClick: onToggle, onKeyDown: e => {
10
13
  if (e.key === 'Enter' || e.key === ' ') {
11
14
  // div에 role=button을 씌운 것이라 Space의 기본 동작(페이지 스크롤)이 살아 있다.
12
15
  // 막지 않으면 키보드로 항목을 열 때마다 화면이 한 페이지씩 밀려 내려간다.
@@ -32,6 +32,11 @@ export interface BadgeProps {
32
32
  * bar 는 주면 그 이름으로 읽힌다(안 주면 글자 그대로 읽힌다).
33
33
  */
34
34
  'aria-label'?: string;
35
+ /**
36
+ * 낭독기에서 숨긴다(dot·bar 모두). 개수를 곁의 단추 이름에 넣었을 때 쓴다 — 예: SideNav·BottomNav 는
37
+ * 단추 이름을 "숙제, 3개" 로 짓고 뱃지는 숨긴다. 주면 `aria-label` 보다 이긴다.
38
+ */
39
+ 'aria-hidden'?: boolean;
35
40
  label?: string;
36
41
  children?: ReactNode;
37
42
  /**
@@ -18,7 +18,7 @@ function MinusIcon() {
18
18
  /** @public */
19
19
  export default function Badge(props) {
20
20
  const { t } = useTranslation();
21
- const { type = 'bar', size, className = '', 'aria-label': ariaLabel, } = props;
21
+ const { type = 'bar', size, className = '', 'aria-label': ariaLabel, 'aria-hidden': ariaHidden, } = props;
22
22
  // ── dot (원형) ──────────────────────────────────────────────────────────
23
23
  if (type === 'dot') {
24
24
  const { icon, number, position = 'top-right', onClick, animate = false } = props;
@@ -49,7 +49,7 @@ export default function Badge(props) {
49
49
  내게** 한다 — 그래야 이벤트의 target·currentTarget 도 실제와 같다. */
50
50
  e.preventDefault();
51
51
  e.currentTarget.click();
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, {})] }));
52
+ } : undefined, "aria-label": ariaHidden ? undefined : ariaLabel ?? defaultLabel, "aria-hidden": ariaHidden || undefined, 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, {})] }));
53
53
  }
54
54
  // ── bar (텍스트) ────────────────────────────────────────────────────────
55
55
  const { label, children, variant = 'default', fill = 'filled', minWidth, iconLeft, iconRight, color } = props;
@@ -72,5 +72,5 @@ export default function Badge(props) {
72
72
  return (
73
73
  // aria-label 을 받아 놓고 bar 에서는 버렸다 — dot 과 같이 건넨다. 이름만 두면 역할 없는
74
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" })] }));
75
+ _jsxs("span", { className: cls, style: style, "aria-label": ariaHidden ? undefined : ariaLabel, role: ariaLabel && !ariaHidden ? 'img' : undefined, "aria-hidden": ariaHidden || undefined, children: [iconLeft && _jsx(Icon, { icon: iconLeft, size: iconSize, className: "badge__icon" }), label ?? children, iconRight && _jsx(Icon, { icon: iconRight, size: iconSize, className: "badge__icon" })] }));
76
76
  }
@@ -13,4 +13,4 @@ import type { BarChartProps } from './barChartTypes';
13
13
  * - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(일~토) 막대
14
14
  * 원시 렌더링은 내부 BarChartCanvas가 담당한다.
15
15
  */
16
- export default function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, tightMax, avgMode, totalFormat, series, loading, onDetail, }: BarChartProps): import("react").JSX.Element;
16
+ export default function BarChart({ size, barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal, totalUnitKey, totalLabelKey, control: controlProp, hideControls, rate, tightMax, avgMode, totalFormat, series, loading, onDetail, inGrid, }: BarChartProps): import("react").JSX.Element;
@@ -12,13 +12,17 @@ import ChartSection, { ChartTotal, ChartLegend } from './ChartSection';
12
12
  import { firstMondayOfMonth, KO_ORD } from './barChartData';
13
13
  import { useBarChartControl } from './useBarChartControl';
14
14
  // ─── 막대 렌더링 원시 컴포넌트 ───────────────────────────────────
15
+ /** 막대 값 — 수가 아니면(undefined·NaN·Infinity) 0 */
16
+ const 수 = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
15
17
  function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, height = 140, maxOverride, tightMax, seriesColors, seriesLabels, rateLabels }) {
16
18
  // colorB 미지정 시 단일 계열(A) 모드
17
19
  const single = !colorB;
18
20
  const stacked = !!(seriesColors && seriesColors.length > 0);
21
+ /* 값이 비었거나(undefined) 수가 아니면 0 으로 친다. 두 계열인데 한 항목의 `b` 가 비면 Math.max 가 NaN 이 되고
22
+ `NaN || 10` 으로 y축이 데이터와 상관없이 10 에 박혀 막대가 판 위로 넘쳤다(ihab-core-96 이 짚음). */
19
23
  const maxVal = useMemo(() => stacked
20
- ? Math.max(0, ...data.filter(d => d.a !== -1).map(d => (d.parts ?? []).reduce((s, v) => s + Math.max(0, v), 0)))
21
- : Math.max(0, ...data.filter(d => d.a !== -1).flatMap(d => single ? [d.a] : [d.a, d.b])), [data, single, stacked]);
24
+ ? Math.max(0, ...data.filter(d => d.a !== -1).map(d => (d.parts ?? []).reduce((s, v) => s + Math.max(0, 수(v)), 0)))
25
+ : Math.max(0, ...data.filter(d => d.a !== -1).flatMap(d => single ? [수(d.a)] : [수(d.a), 수(d.b)])), [data, single, stacked]);
22
26
  const padded = useMemo(() => maxOverride ?? (tightMax ? (maxVal > 0 ? maxVal : 10) : (Math.ceil(maxVal / 10) * 10 || 10)), [maxVal, maxOverride, tightMax]);
23
27
  const h = height;
24
28
  // 막대 너비 — 여백과 무관하게 막대 '개수'(컬럼 × 계열)가 늘수록 점진 축소(최대 barWidth)
@@ -38,9 +42,11 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, h
38
42
  const handleBarLeave = useCallback(() => setTooltip(null), []);
39
43
  return (_jsxs("div", { className: "manager-bar-chart-wrap", children: [_jsx("div", { className: "manager-bar-chart-inner", children: _jsx("div", { className: "manager-bar-chart-scroll", children: _jsxs("div", { className: "manager-bar-chart-area", children: [_jsxs("div", { className: "manager-bar-chart-plot", style: { height: h }, children: [ticks.slice(0, -1).map((t, i) => (_jsx("div", { className: "manager-bar-chart-gridline", style: { top: i * (h / (ticks.length - 1)) }, children: _jsx("span", { className: "manager-bar-chart-gridline__label caption-1", children: t }) }, i))), data.map((d, i) => {
40
44
  const isFuture = d.a === -1;
41
- const ha = isFuture ? 0 : (d.a / padded) * h;
42
- const hb = isFuture ? 0 : (d.b / padded) * h;
43
- const parts = d.parts ?? [];
45
+ /* 판 높이를 넘지 않는다 — maxOverride(진행률 100)보다 큰 값이 와도 막대가 눈금 위로 삐져나가지 않게 */
46
+ const 높이 = (v) => Math.min(h, Math.max(0, (수(v) / padded) * h));
47
+ const ha = isFuture ? 0 : 높이(d.a);
48
+ const hb = isFuture ? 0 : 높이(d.b);
49
+ const parts = (d.parts ?? []).map(수);
44
50
  return (_jsx("div", { className: "manager-bar-chart-col", style: { cursor: isFuture ? 'default' : 'pointer' }, onMouseEnter: isFuture ? undefined : (e) => {
45
51
  const r = e.currentTarget.getBoundingClientRect();
46
52
  /* 한 줄에 `A: 3 / B: 5 / …` 로 늘어놓던 것을 줄마다 하나로 — 유형이 넷·다섯이면
@@ -53,25 +59,30 @@ function BarChartCanvas({ data, colorA, colorB, labelA, labelB, barWidth = 10, h
53
59
  items = 있는.length ? 있는 : 전부;
54
60
  }
55
61
  else if (rateLabels && !single) {
56
- items = [{ text: `${labelA} ${d.a}%`, color: colorA }, { text: `${labelB} ${d.b}%`, color: colorB }];
62
+ items = [{ text: `${labelA} ${수(d.a)}%`, color: colorA }, { text: `${labelB} ${수(d.b)}%`, color: colorB }];
57
63
  }
58
64
  else if (rateLabels && d.rawB !== undefined) {
59
65
  items = [{ text: `${rateLabels.total} ${d.rawB}` }, { text: `${rateLabels.done} ${d.rawA ?? 0}`, color: colorA }];
60
66
  }
61
67
  else if (single) {
62
- items = [{ text: `${labelA}: ${d.a}`, color: colorA }];
68
+ items = [{ text: `${labelA}: ${수(d.a)}`, color: colorA }];
63
69
  }
64
70
  else {
65
- items = [{ text: `${labelA}: ${d.a}`, color: colorA }, { text: `${labelB}: ${d.b}`, color: colorB }];
71
+ items = [{ text: `${labelA}: ${수(d.a)}`, color: colorA }, { text: `${labelB}: ${수(d.b)}`, color: colorB }];
66
72
  }
67
73
  setTooltip({ rect: { left: r.left, top: r.top, right: r.right, bottom: r.bottom, width: r.width, height: r.height }, title, items });
68
74
  }, onMouseLeave: handleBarLeave, children: !isFuture && (stacked ? (
69
75
  // 계열별 stacked — 아래부터 위로 쌓고, 맨 위 segment만 상단 라운드
70
76
  _jsx("div", { className: "manager-bar-chart-stack", style: 막대굵기, children: (() => {
71
77
  const topK = parts.reduce((acc, v, k) => (v > 0 ? k : acc), -1); // 맨 위(최상단) 세그먼트
72
- return parts.map((v, k) => (v > 0
73
- ? _jsx("div", { className: "manager-bar-chart-stack-segment", style: { height: (v / padded) * h, background: (seriesColors ?? [])[k], borderRadius: k === topK ? '6px 6px 0 0' : undefined } }, k)
74
- : null));
78
+ let 남은 = h; // 쌓은 합도 판 높이를 넘지 않게 위 조각부터 깎는다
79
+ return parts.map((v, k) => {
80
+ if (v <= 0)
81
+ return null;
82
+ const 조각 = Math.min(남은, (v / padded) * h);
83
+ 남은 -= 조각;
84
+ return _jsx("div", { className: "manager-bar-chart-stack-segment", style: { height: 조각, background: (seriesColors ?? [])[k], borderRadius: k === topK ? '6px 6px 0 0' : undefined } }, k);
85
+ });
75
86
  })() })) : (_jsxs(_Fragment, { children: [_jsx("div", { className: "manager-bar-chart-bar", style: { ...막대굵기, height: ha, background: colorA } }), !single && _jsx("div", { className: "manager-bar-chart-bar", style: { ...막대굵기, height: hb, background: colorB } })] }))) }, i));
76
87
  })] }), _jsx("div", { className: "manager-bar-chart-x-axis", children: data.map((d, i) => {
77
88
  const isFuture = d.a === -1; // 아직 도래하지 않은 날 — 글씨 흐리게
@@ -167,7 +178,7 @@ const BAR_SIZE_PRESET = {
167
178
  * - 주(week): 'YYYY년 M월 N째주' 드롭다운 + 그 주의 요일(일~토) 막대
168
179
  * 원시 렌더링은 내부 BarChartCanvas가 담당한다.
169
180
  */
170
- export default function BarChart({ size = 'medium', barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal = false, totalUnitKey, totalLabelKey, control: controlProp, hideControls = false, rate = false, tightMax = false, avgMode = false, totalFormat, series, loading = false, onDetail, }) {
181
+ export default function BarChart({ size = 'medium', barWidth, titleKey, data, colorA, colorB, labelAKey, labelBKey, showTotal = false, totalUnitKey, totalLabelKey, control: controlProp, hideControls = false, rate = false, tightMax = false, avgMode = false, totalFormat, series, loading = false, onDetail, inGrid = false, }) {
171
182
  const { t, i18n } = useTranslation();
172
183
  const sizePreset = BAR_SIZE_PRESET[size];
173
184
  // 외부 control 미지정 시 내부 컨트롤 사용 (단독 차트 하위 호환)
@@ -228,7 +239,7 @@ export default function BarChart({ size = 'medium', barWidth, titleKey, data, co
228
239
  // size='large' — 카드가 가로 폭을 거의 100% 차지(1열)
229
240
  const isLarge = size === 'large';
230
241
  const sectionClassName = isSmall ? 'manager-section--compact' : isLarge ? 'manager-section--full' : undefined;
231
- return (_jsx(ChartSection, { className: sectionClassName, title: t(titleKey), titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: isSmall ? undefined : _jsxs(_Fragment, { children: [!hideControls && _jsx(BarChartControls, { control: control }), onDetail && (_jsx("span", { className: "manager-bar-chart-detail-btn", children: _jsx(Button, { size: 32, type: "dot", icon: "list", ariaLabel: t('trainingStatus.detail', '상세보기'), onClick: onDetail }) }))] }), subRow: _jsxs(_Fragment, { children: [showTotal ? ((rate || avgMode || (!!totalLabelKey && !!totalFormat)) ? (
242
+ return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, title: t(titleKey), titleClassName: isSmall ? 'h5 chart-section-title--small' : undefined, headRight: isSmall ? undefined : _jsxs(_Fragment, { children: [!hideControls && _jsx(BarChartControls, { control: control }), onDetail && (_jsx("span", { className: "manager-bar-chart-detail-btn", children: _jsx(Button, { size: 32, type: "dot", icon: "list", ariaLabel: t('trainingStatus.detail', '상세보기'), onClick: onDetail }) }))] }), subRow: _jsxs(_Fragment, { children: [showTotal ? ((rate || avgMode || (!!totalLabelKey && !!totalFormat)) ? (
232
243
  // 진행률/평균 모드 또는 totalLabelKey+totalFormat 조합: 라벨(윗줄) + 값(아랫줄). size='small'은 라벨 생략
233
244
  _jsx(ChartTotal, { label: isSmall ? undefined : `${t(control.period === 'week' ? 'manager.chart.weeklyPrefix' : 'manager.chart.monthlyPrefix')} ${t(totalLabelKey ?? labelAKey)}`, children: totalFormat ? (
234
245
  // 숫자는 h0, 단위(시간/분/초 등)는 h5로 분리
@@ -62,6 +62,10 @@ export default function Calendar({ visits = [], onVisitClick, onVisitMove, onDay
62
62
  return (_jsxs("div", { className: `calendar-cell${isToday(day) ? ' today' : ''}${isDragOver ? ' drag-over' : ''}${onDayClick ? ' calendar-cell--clickable' : ''}`, role: onDayClick ? 'button' : undefined, tabIndex: onDayClick ? 0 : undefined, "aria-label": onDayClick ? t('calendar.addOnDate', { date: dateKey }) : undefined, onClick: onDayClick ? () => onDayClick(dateKey) : undefined,
63
63
  /* `<div role="button">` 은 브라우저가 Enter·Space 를 눌림으로 바꿔 주지 않는다 */
64
64
  onKeyDown: onDayClick ? (e) => {
65
+ /* 칸 안의 방문 표시에서 누른 Enter 가 올라온 것이면 넘긴다 — 마우스 click 은 표시가
66
+ stopPropagation 하지만 keydown 은 막지 않아, 방문 상세와 '그날 새 방문' 이 함께 열렸다. */
67
+ if (e.target !== e.currentTarget)
68
+ return;
65
69
  if (e.key !== 'Enter' && e.key !== ' ')
66
70
  return;
67
71
  e.preventDefault();
@@ -189,3 +189,19 @@
189
189
  min-width: 0;
190
190
  }
191
191
  }
192
+
193
+ /* inGrid — CSS 격자 칸 안의 카드. 격자가 간격(gap)과 폭을 정하므로 아래 여백·최소 폭을 푼다.
194
+ compact 의 min-width(160px)보다 뒤에 둬야 같은 특이도로 이긴다 — **파일 끝**에 둔다.
195
+ 세로 flex 라서, 같은 줄 카드끼리 늘어난 높이를 차트 몸통 위에 몰아 막대·링이 바닥에 선다
196
+ (말귀 훈련 현황의 네 칸이 범례 줄 수가 달라 막대 바닥이 들쭉날쭉했다). */
197
+ .manager-section--grid {
198
+ margin-bottom: 0;
199
+ min-width: 0;
200
+ display: flex;
201
+ flex-direction: column;
202
+ }
203
+ .manager-section--grid > .manager-bar-chart-wrap,
204
+ .manager-section--grid > .manager-bar-chart-loading,
205
+ .manager-section--grid > .ring-chart-section-body {
206
+ margin-top: auto;
207
+ }
@@ -31,6 +31,13 @@ export interface ChartSectionProps {
31
31
  subRow?: ReactNode;
32
32
  /** 섹션 루트에 추가할 클래스 */
33
33
  className?: string;
34
+ /**
35
+ * CSS 격자(한 줄에 여러 칸) 안에 세울 때 켠다 — `manager-section--grid`.
36
+ * 아래 여백(14px)·최소 폭(320px)을 풀고 카드를 세로 flex 로 만들어, 같은 줄 카드끼리 높이가 늘어나면
37
+ * 차트 몸통(막대·링·로딩)을 **카드 바닥**에 맞춘다(범례 줄 수가 달라도 막대 바닥이 한 선).
38
+ * 차트가 아닌 카드에 같은 꼴이 필요하면 `manager-section manager-section--grid` 클래스를 그대로 쓴다.
39
+ */
40
+ inGrid?: boolean;
34
41
  children?: ReactNode;
35
42
  }
36
43
  /**
@@ -39,4 +46,4 @@ export interface ChartSectionProps {
39
46
  * 공유하도록 두 컴포넌트가 내부적으로 사용한다.
40
47
  * @public
41
48
  */
42
- export default function ChartSection({ title, titleClassName, headRight, subRow, className, children }: ChartSectionProps): import("react").JSX.Element;
49
+ export default function ChartSection({ title, titleClassName, headRight, subRow, className, inGrid, children }: ChartSectionProps): import("react").JSX.Element;
@@ -17,6 +17,6 @@ export function ChartTotal({ label, children }) {
17
17
  * 공유하도록 두 컴포넌트가 내부적으로 사용한다.
18
18
  * @public
19
19
  */
20
- export default function ChartSection({ title, titleClassName = 'h3', headRight, subRow, className, children }) {
21
- return (_jsxs("section", { className: className ? `manager-section ${className}` : 'manager-section', children: [_jsxs("div", { className: "manager-section__head", children: [_jsx("div", { className: titleClassName, children: title }), headRight] }), subRow && _jsx("div", { className: "barchart-sub-row", children: subRow }), children] }));
20
+ export default function ChartSection({ title, titleClassName = 'h3', headRight, subRow, className, inGrid = false, children }) {
21
+ return (_jsxs("section", { className: ['manager-section', inGrid && 'manager-section--grid', className].filter(Boolean).join(' '), children: [_jsxs("div", { className: "manager-section__head", children: [_jsx("div", { className: titleClassName, children: title }), headRight] }), subRow && _jsx("div", { className: "barchart-sub-row", children: subRow }), children] }));
22
22
  }
@@ -32,3 +32,9 @@
32
32
  음수 여백으로 왼쪽을 당겨, 아이콘 단추의 제 여백 때문에 제목이 페이지의 다른
33
33
  제목들보다 오른쪽으로 밀려 보이는 것을 막는다(글자 시작선을 맞춘다). */
34
34
  .page-title__back { margin-left: -8px; margin-right: -4px; flex-shrink: 0; }
35
+
36
+ /* wrap — 오른쪽이 넓으면 줄을 접는다. 제목 칸의 바닥을 320px 로 두되 **제 그릇보다 크지 않게**(min) —
37
+ 그냥 320px 이면 좁은 화면(세로 배치, 그릇 280px)에서 제목이 그릇을 넘쳐 오른쪽이 잘린다
38
+ (ChartSection 의 min-width 320px 가 똑같이 그랬다). */
39
+ .page-title--wrap { flex-wrap: wrap; }
40
+ .page-title--wrap .page-title__main { min-width: min(320px, 100%); }
@@ -29,7 +29,15 @@ type PageTitleProps = {
29
29
  * title 이 문자열이면 이 값은 필요 없다.
30
30
  */
31
31
  documentTitle?: string;
32
+ /**
33
+ * 오른쪽(`rightAction`)이 넓어 제목 칸을 짓누를 때 **줄을 접어** 오른쪽을 아래로 내린다. 기본 false(한 줄 유지).
34
+ *
35
+ * 한 줄로 두면 제목 칸이 `min-width: 0` 까지 줄어, 탭 일곱 같은 넓은 오른쪽이 제목을 한 글자씩 세로로
36
+ * 밀어낸다(말귀 고객 상세 1135px 실측). 켜면 제목 칸에 320px(좁으면 제 폭) 바닥을 주고, 그 바닥이
37
+ * 안 들어가면 오른쪽이 다음 줄로 간다.
38
+ */
39
+ wrap?: boolean;
32
40
  };
33
41
  /** @public */
34
- export default function PageTitle({ title, sub, rightAction, titleAction, badge, badgeVariant, documentTitle, onBack, backLabel }: PageTitleProps): import("react").JSX.Element;
42
+ export default function PageTitle({ title, sub, rightAction, titleAction, badge, badgeVariant, documentTitle, onBack, backLabel, wrap }: PageTitleProps): import("react").JSX.Element;
35
43
  export {};
@@ -5,12 +5,12 @@ import Badge from './Badge';
5
5
  import Button from './Button';
6
6
  import { useDocumentTitle } from '../hooks/useDocumentTitle';
7
7
  /** @public */
8
- export default function PageTitle({ title, sub, rightAction, titleAction, badge, badgeVariant = 'default', documentTitle, onBack, backLabel }) {
8
+ export default function PageTitle({ title, sub, rightAction, titleAction, badge, badgeVariant = 'default', documentTitle, onBack, backLabel, wrap = false }) {
9
9
  const { t } = useTranslation();
10
10
  // 페이지 제목이 곧 문서 제목이 되도록 맞춘다(WCAG 2.4.2).
11
11
  useDocumentTitle(typeof title === 'string' ? title : (documentTitle ?? null));
12
12
  const 뒤로이름 = backLabel ?? t('common.back', '뒤로');
13
- return (_jsxs("div", { className: "page-title", children: [_jsxs("div", { className: "page-title__main", children: [_jsxs("div", { className: "page-title__title-row", children: [onBack && (
13
+ return (_jsxs("div", { className: wrap ? 'page-title page-title--wrap' : 'page-title', children: [_jsxs("div", { className: "page-title__main", children: [_jsxs("div", { className: "page-title__title-row", children: [onBack && (
14
14
  /* 글자 없이 화살표만 둔다 — 제목 옆에서 '뒤로' 라고 적으면 제목과 경쟁한다.
15
15
  그만큼 과녁을 크게(48) 잡는다. 이름은 낭독기용으로 따로 준다. */
16
16
  _jsx(Button, { className: "page-title__back", size: 48, type: "dot", icon: "chevron-left", iconSize: "large", useBorder: false,
@@ -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
  }