@ihabdevteam/core 0.177.0 → 0.178.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.
@@ -1063,7 +1063,7 @@ export declare interface ChartPoint {
1063
1063
  * 공유하도록 두 컴포넌트가 내부적으로 사용한다.
1064
1064
  * @public
1065
1065
  */
1066
- export declare function ChartSection({ title, titleClassName, icon, disc, headRight, headSegment, headMenuItems, collapseBelow, onClick, ariaLabel, tooltip, totalNum, totalUnit, totalLabel, subRow, className, inGrid, children }: ChartSectionProps): JSX.Element;
1066
+ export declare function ChartSection({ title, titleClassName, icon, disc, headRight, headSegment, headMenuItems, collapseBelow, onClick, ariaLabel, tooltip, totalNum, totalUnit, totalLabel, subRow, subRowAlign, className, inGrid, children }: ChartSectionProps): JSX.Element;
1067
1067
 
1068
1068
  /** @public */
1069
1069
  export declare interface ChartSectionProps {
@@ -1109,6 +1109,15 @@ export declare interface ChartSectionProps {
1109
1109
  tooltip?: string;
1110
1110
  /** 총계(ChartTotal) + 범례(ChartLegend)를 담는 서브로우 */
1111
1111
  subRow?: ReactNode;
1112
+ /** 서브로우에 선 것들의 **세로 맞춤** — `'start'`(기본) · `'center'` · `'end'`.
1113
+ *
1114
+ * 기본이 `'start'` 인 것은 **범례** 때문이다. 수(설명 한 줄 + 큰 수, 두 줄)와 범례가 함께 서면
1115
+ * 범례는 첫 줄부터 읽히는 편이 낫다. 그런데 그 자리에 **32px 짜리 고르개** 를 넣으면 위에 붙어
1116
+ * 아래가 29px 비고, 수에 견주어 떠 보인다(말귀 실측 2026-09-17: 서브로우 61.4px · 고르개 32px).
1117
+ * 그때 `'center'` 로 준다 — 수가 가장 높으므로 수의 자리는 안 바뀌고 낮은 것만 가운데로 온다.
1118
+ *
1119
+ * 기본을 안 바꾼 것은 이미 선 화면 셋(말귀·소리방향성·kscan)의 범례가 함께 움직이기 때문이다. */
1120
+ subRowAlign?: 'start' | 'center' | 'end';
1112
1121
  /** 섹션 루트에 추가할 클래스 */
1113
1122
  className?: string;
1114
1123
  /**
@@ -1576,7 +1585,7 @@ export declare function copyText(text: string): Promise<boolean>;
1576
1585
  *
1577
1586
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
1578
1587
  */
1579
- export declare const CORE_VERSION = "0.177.0";
1588
+ export declare const CORE_VERSION = "0.178.0";
1580
1589
 
1581
1590
  /** @public */
1582
1591
  declare const coreDefault: Record<string, unknown>;
@@ -1903,10 +1912,13 @@ export declare interface DeviceRow {
1903
1912
 
1904
1913
  declare type DeviceSide = '좌' | '우';
1905
1914
 
1906
- export declare function DeviceSideTable({ rows, onSelect, onDelete, onWear, onRelease, onAdd }: DeviceSideTableProps): JSX.Element;
1915
+ export declare function DeviceSideTable({ rows, loading, onSelect, onDelete, onWear, onRelease, onAdd }: DeviceSideTableProps): JSX.Element;
1907
1916
 
1908
1917
  export declare interface DeviceSideTableProps {
1909
1918
  rows: DeviceRow[];
1919
+ /** 서버에서 줄을 기다리는 중 — `Table` 의 `loading` 을 그대로 쓴다(스피너 한 줄).
1920
+ * 안 주면 기다리는 동안 **빈 표**를 그렸다가 줄이 오면 늘어난다(말귀 기기 탭 실측 2026-09-17). */
1921
+ loading?: boolean;
1910
1922
  /** 줄을 누르면 그 기기로 등록 폼을 채워 연다 */
1911
1923
  onSelect: (row: DeviceRow) => void;
1912
1924
  /**
@@ -5793,6 +5805,15 @@ export declare interface SegmentedControlProps {
5793
5805
  useBG?: boolean;
5794
5806
  /** 외부에서 정해진 폭을 버튼들이 균등 분할해 가득 채움 */
5795
5807
  fullWidth?: boolean;
5808
+ /** 칸 하나의 **최소 폭**(px) — 크기별 기본(24:48 · 32:80 · 40:80 · 48:96)을 덮는다.
5809
+ *
5810
+ * 칸 이름 길이가 제각각인 자리에서 쓴다. 기본 폭은 짧은 이름도 갑갑하지 않게 잡은 값이라,
5811
+ * '상세분석 · 로데이터' 처럼 이름이 긴 고르개는 더 벌려야 하고, 소비자가 그때마다 제 CSS 로
5812
+ * 덮던 자리다(말귀 두 곳 실측 2026-09-17).
5813
+ *
5814
+ * 바닥을 **깔면 짧은 이름이 헐렁해진다** — 한 고르개 안에서 이름 길이가 크게 다르면
5815
+ * 차라리 주지 않는 편이 낫다(칸은 제 글자만큼 벌어진다). */
5816
+ minButtonWidth?: number;
5796
5817
  /** 화면에 보이는 라벨 없이 스크린리더용 이름을 지정할 때 사용. 없으면 일반 라벨(segmentedControl.ariaLabel)로 대체 */
5797
5818
  ariaLabel?: string;
5798
5819
  }
@@ -406,5 +406,11 @@ export default function BarChart({ size = 'medium', barWidth, titleKey, data, co
406
406
  /* 부호는 숫자 쪽에 붙인다 — `[^\d]+` 로 가르면 '-7%p' 의 '-' 가 단위로 넘어가 h5 로 작아졌다(변동률) */
407
407
  (totalFormat(total).match(/([+-]?\d[\d.,]*|[^\d]+)/g) ?? []).map((part, i) => (/\d/.test(part)
408
408
  ? _jsx("span", { className: "barchart-total__num h0", children: part }, i)
409
- : _jsx("span", { className: "barchart-total__unit h5", children: part }, i)))) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "barchart-total__num h0", children: total }), totalUnitKey && _jsx("span", { className: "barchart-total__unit h5", children: t(totalUnitKey) })] })) })) : (_jsxs(ChartTotal, { children: [_jsx("span", { className: "barchart-total__num h0", children: total }), _jsx("span", { className: "barchart-total__unit h5", children: t(labelAKey) }), totalUnitKey && _jsx("span", { className: "barchart-total__unit h5", children: t(totalUnitKey) })] }))) : _jsx("span", {}), _jsx(ChartLegend, { items: legendItems, className: "caption-1" })] }), children: loading ? (_jsx("div", { className: "manager-bar-chart-loading", children: _jsx(Loader, { size: 32 }) })) : (_jsx(BarChartCanvas, { data: bars, colorA: colorA, colorB: labelBKey ? colorB : undefined, labelA: t(labelAKey), labelB: labelBKey ? t(labelBKey) : undefined, barWidth: barWidth ?? sizePreset.barWidth, barGap: barGap, baseline: baseline, height: sizePreset.height, maxOverride: rate ? 100 : undefined, tightMax: tightMax, seriesColors: series?.map(s => s.color), seriesLabels: series?.map(s => s.label), rateLabels: rate ? { total: t('trainingStatus.today.tooltipTotal', '전체'), done: t('trainingStatus.today.tooltipDone', '완료') } : undefined, grouped: 나란히 })) }));
409
+ : _jsx("span", { className: "barchart-total__unit h5", children: part }, i)))) : (_jsxs(_Fragment, { children: [_jsx("span", { className: "barchart-total__num h0", children: total }), totalUnitKey && _jsx("span", { className: "barchart-total__unit h5", children: t(totalUnitKey) })] })) })) : (_jsxs(ChartTotal, { children: [_jsx("span", { className: "barchart-total__num h0", children: total }), _jsx("span", { className: "barchart-total__unit h5", children: t(labelAKey) }), totalUnitKey && _jsx("span", { className: "barchart-total__unit h5", children: t(totalUnitKey) })] }))) : _jsx("span", {}), _jsx(ChartLegend, { items: legendItems, className: "caption-1" })] }), children: loading ? (
410
+ /* **뼈대 먼저, 그 안에서 기다린다**(사용자 지시 2026-09-17). 기다림 상자를 따로 두면
411
+ 자료가 오는 순간 카드 키가 달라져 격자가 한꺼번에 내려앉는다 — 말귀 실측: 상자 186px
412
+ → 막대 156px 로 카드마다 30px, 섹션 372 → 342px.
413
+ 그래서 **그려질 것과 같은 뼈대**(칸 자리 + 축 한 줄)를 세우고 그 안에 스피너를 둔다.
414
+ 키는 막대 자리(`sizePreset.height`)에서 오므로 크기 보기(size)가 달라도 따라온다. */
415
+ _jsx("div", { className: "manager-bar-chart-wrap manager-bar-chart-loading", "aria-busy": "true", children: _jsx("div", { className: "manager-bar-chart-inner", children: _jsxs("div", { className: "manager-bar-chart-area manager-bar-chart-area--loading", children: [_jsx("div", { className: "manager-bar-chart-plot manager-bar-chart-plot--loading", style: { height: sizePreset.height }, children: _jsx(Loader, { size: 32 }) }), _jsx("div", { className: "manager-bar-chart-x-axis", "aria-hidden": "true", children: _jsxs("span", { className: "manager-bar-chart-x-axis__label", children: [_jsx("span", { className: "caption-1 manager-bar-chart-x-axis__main", children: "\u00A0" }), bars.some(b => !!b.sublabel) && _jsx("span", { className: "caption-1", children: "\u00A0" })] }) })] }) }) })) : (_jsx(BarChartCanvas, { data: bars, colorA: colorA, colorB: labelBKey ? colorB : undefined, labelA: t(labelAKey), labelB: labelBKey ? t(labelBKey) : undefined, barWidth: barWidth ?? sizePreset.barWidth, barGap: barGap, baseline: baseline, height: sizePreset.height, maxOverride: rate ? 100 : undefined, tightMax: tightMax, seriesColors: series?.map(s => s.color), seriesLabels: series?.map(s => s.label), rateLabels: rate ? { total: t('trainingStatus.today.tooltipTotal', '전체'), done: t('trainingStatus.today.tooltipDone', '완료') } : undefined, grouped: 나란히 })) }));
410
416
  }
@@ -117,12 +117,19 @@
117
117
  gap: var(--spacing-xs);
118
118
  }
119
119
 
120
- /* 차트 영역 로딩 스피너 — 헤더/범례는 유지, 막대 자리에만 표시 */
121
- .manager-bar-chart-loading {
120
+ /* 차트 영역 로딩 — 머리·범례는 그대로 두고 **그려질 것과 같은 뼈대** 안에서 기다린다.
121
+ 전에는 이 상자에 min-height: 186px 을 박아 뒀는데, 막대가 들어서면 156px 이라 자료가 오는
122
+ 순간 카드마다 30px 씩 줄어 격자가 한꺼번에 내려앉았다(말귀 실측 2026-09-17). 이제 키는
123
+ 차트가 쥔 값(막대 자리 높이 · 링 지름)에서 오므로 여기서 박지 않는다. */
124
+ .manager-bar-chart-plot--loading,
125
+ .ring-chart--loading {
122
126
  display: flex;
123
127
  justify-content: center;
124
128
  align-items: center;
125
- min-height: 186px;
129
+ }
130
+ /* 축 자리만 잡는 줄 — 글자가 없어도 한 줄 높이를 가진다 */
131
+ .manager-bar-chart-area--loading .manager-bar-chart-x-axis {
132
+ justify-content: center;
126
133
  }
127
134
 
128
135
  .barchart-sub-row {
@@ -131,6 +138,10 @@
131
138
  align-items: flex-start;
132
139
  margin-bottom: var(--spacing-l);
133
140
  }
141
+ /* 서브로우 세로 맞춤(subRowAlign) — 수보다 낮은 것(32px 고르개 등)이 위에 붙어 떠 보일 때 쓴다.
142
+ 수가 가장 높으므로 수의 자리는 안 바뀐다. */
143
+ .barchart-sub-row--center { align-items: center; }
144
+ .barchart-sub-row--end { align-items: flex-end; }
134
145
  .barchart-total {
135
146
  display: flex;
136
147
  align-items: baseline;
@@ -79,6 +79,15 @@ export interface ChartSectionProps {
79
79
  tooltip?: string;
80
80
  /** 총계(ChartTotal) + 범례(ChartLegend)를 담는 서브로우 */
81
81
  subRow?: ReactNode;
82
+ /** 서브로우에 선 것들의 **세로 맞춤** — `'start'`(기본) · `'center'` · `'end'`.
83
+ *
84
+ * 기본이 `'start'` 인 것은 **범례** 때문이다. 수(설명 한 줄 + 큰 수, 두 줄)와 범례가 함께 서면
85
+ * 범례는 첫 줄부터 읽히는 편이 낫다. 그런데 그 자리에 **32px 짜리 고르개** 를 넣으면 위에 붙어
86
+ * 아래가 29px 비고, 수에 견주어 떠 보인다(말귀 실측 2026-09-17: 서브로우 61.4px · 고르개 32px).
87
+ * 그때 `'center'` 로 준다 — 수가 가장 높으므로 수의 자리는 안 바뀌고 낮은 것만 가운데로 온다.
88
+ *
89
+ * 기본을 안 바꾼 것은 이미 선 화면 셋(말귀·소리방향성·kscan)의 범례가 함께 움직이기 때문이다. */
90
+ subRowAlign?: 'start' | 'center' | 'end';
82
91
  /** 섹션 루트에 추가할 클래스 */
83
92
  className?: string;
84
93
  /**
@@ -96,4 +105,4 @@ export interface ChartSectionProps {
96
105
  * 공유하도록 두 컴포넌트가 내부적으로 사용한다.
97
106
  * @public
98
107
  */
99
- export default function ChartSection({ title, titleClassName, icon, disc, headRight, headSegment, headMenuItems, collapseBelow, onClick, ariaLabel, tooltip, totalNum, totalUnit, totalLabel, subRow, className, inGrid, children }: ChartSectionProps): import("react").JSX.Element;
108
+ export default function ChartSection({ title, titleClassName, icon, disc, headRight, headSegment, headMenuItems, collapseBelow, onClick, ariaLabel, tooltip, totalNum, totalUnit, totalLabel, subRow, subRowAlign, className, inGrid, children }: ChartSectionProps): import("react").JSX.Element;
@@ -9,6 +9,7 @@ import SegmentedControl from './SegmentedControl';
9
9
  import Tooltip from './Tooltip/Tooltip';
10
10
  import TooltipInfo from './Tooltip/InfoTooltip';
11
11
  import { useHoverAnchor } from '../hooks/useHoverAnchor';
12
+ import { useIsoLayoutEffect } from '../hooks/useIsoLayoutEffect';
12
13
  import MenuTooltip from './Tooltip/MenuTooltip';
13
14
  /** 차트 범례 — 색상 점 + 라벨 목록. BarChart/RingChart 섹션이 공유. @public */
14
15
  export function ChartLegend({ items, className }) {
@@ -27,7 +28,7 @@ export function ChartTotal({ label, children }) {
27
28
  * 공유하도록 두 컴포넌트가 내부적으로 사용한다.
28
29
  * @public
29
30
  */
30
- export default function ChartSection({ title, titleClassName = 'h3', icon, disc, headRight, headSegment, headMenuItems, collapseBelow = 420, onClick, ariaLabel, tooltip, totalNum, totalUnit, totalLabel, subRow, className, inGrid = false, children }) {
31
+ export default function ChartSection({ title, titleClassName = 'h3', icon, disc, headRight, headSegment, headMenuItems, collapseBelow = 420, onClick, ariaLabel, tooltip, totalNum, totalUnit, totalLabel, subRow, subRowAlign = 'start', className, inGrid = false, children }) {
31
32
  const { t } = useTranslation();
32
33
  const secRef = useRef(null);
33
34
  /* 설명 툴팁 — StatChart 와 같은 결(카드 사각형 기준, 포인터가 나가거나 초점이 빠지면 닫힘).
@@ -36,9 +37,15 @@ export default function ChartSection({ title, titleClassName = 'h3', icon, disc,
36
37
  const 설명보이기 = useCallback(() => { if (tooltip && secRef.current)
37
38
  설명열기(secRef.current); }, [tooltip, 설명열기]);
38
39
  /* 좁은지는 **카드 제 폭** 으로 잰다 — 부르는 쪽이 격자 칸 폭으로 대신 재면 두 칸을 차지하는 카드에서 틀린다.
39
- ResizeObserver 가 없는 환경(구형·SSR)에서는 접지 않는다(넓은 쪽이 기본). */
40
+ ResizeObserver 가 없는 환경(구형·SSR)에서는 접지 않는다(넓은 쪽이 기본).
41
+
42
+ **첫 재기는 그리기 전에 끝나야 한다**(그래서 layout effect 다). `useEffect` 로 두었더니
43
+ 좁은 카드도 첫 프레임은 고르개를 **편 채** 그려졌다가 다음 프레임에 '⋯' 로 접혔고, 그 한
44
+ 프레임 동안 머리가 한 줄 늘어 카드가 부풀었다 — 말귀 실측 2026-09-17: 머리 32 → 70 → 32px,
45
+ 섹션 342 → 380 → 342px, 페이지 바닥이 38px 출렁(layout-shift 로 잡힘).
46
+ 뒤따르는 폭 변화는 ResizeObserver 가 그대로 따라간다. */
40
47
  const [좁음, set좁음] = useState(false);
41
- useEffect(() => {
48
+ useIsoLayoutEffect(() => {
42
49
  const el = secRef.current;
43
50
  if (!el || !headSegment)
44
51
  return;
@@ -106,5 +113,5 @@ export default function ChartSection({ title, titleClassName = 'h3', icon, disc,
106
113
  bottom: 설명자리.bottom,
107
114
  width: 설명자리.width,
108
115
  height: 설명자리.height,
109
- }, text: tooltip })), _jsxs("div", { className: "manager-section__head", children: [_jsxs("div", { className: "manager-section__title-row", children: [icon && _jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "manager-section__icon" }), _jsx("div", { className: ['manager-section__title', titleClassName].filter(Boolean).join(' '), children: title })] }), 머리오른쪽, disc && _jsx("div", { className: "manager-section__disc body-3", children: disc })] }), (수줄 || subRow) && _jsxs("div", { className: "barchart-sub-row", children: [수줄, subRow] }), children, 메뉴자리 && (_jsx(Tooltip, { anchorRect: 메뉴자리, onClose: 메뉴닫기, ariaLabel: headSegment?.ariaLabel ?? t('tooltip.menu', '메뉴'), children: _jsx(MenuTooltip, { columns: [{ items: 메뉴항목 }], onClose: 메뉴닫기, size: "small" }) }))] }));
116
+ }, text: tooltip })), _jsxs("div", { className: "manager-section__head", children: [_jsxs("div", { className: "manager-section__title-row", children: [icon && _jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "manager-section__icon" }), _jsx("div", { className: ['manager-section__title', titleClassName].filter(Boolean).join(' '), children: title })] }), 머리오른쪽, disc && _jsx("div", { className: "manager-section__disc body-3", children: disc })] }), (수줄 || subRow) && (_jsxs("div", { className: ['barchart-sub-row', subRowAlign !== 'start' && `barchart-sub-row--${subRowAlign}`].filter(Boolean).join(' '), children: [수줄, subRow] })), children, 메뉴자리 && (_jsx(Tooltip, { anchorRect: 메뉴자리, onClose: 메뉴닫기, ariaLabel: headSegment?.ariaLabel ?? t('tooltip.menu', '메뉴'), children: _jsx(MenuTooltip, { columns: [{ items: 메뉴항목 }], onClose: 메뉴닫기, size: "small" }) }))] }));
110
117
  }
@@ -21,6 +21,9 @@ export interface DeviceRow {
21
21
  }
22
22
  export interface DeviceSideTableProps {
23
23
  rows: DeviceRow[];
24
+ /** 서버에서 줄을 기다리는 중 — `Table` 의 `loading` 을 그대로 쓴다(스피너 한 줄).
25
+ * 안 주면 기다리는 동안 **빈 표**를 그렸다가 줄이 오면 늘어난다(말귀 기기 탭 실측 2026-09-17). */
26
+ loading?: boolean;
24
27
  /** 줄을 누르면 그 기기로 등록 폼을 채워 연다 */
25
28
  onSelect: (row: DeviceRow) => void;
26
29
  /**
@@ -38,4 +41,4 @@ export interface DeviceSideTableProps {
38
41
  /** 툴바 오른쪽 끝의 '기기 추가' */
39
42
  onAdd?: () => void;
40
43
  }
41
- export default function DeviceSideTable({ rows, onSelect, onDelete, onWear, onRelease, onAdd }: DeviceSideTableProps): import("react").JSX.Element;
44
+ export default function DeviceSideTable({ rows, loading, onSelect, onDelete, onWear, onRelease, onAdd }: DeviceSideTableProps): import("react").JSX.Element;
@@ -29,7 +29,7 @@ function 정렬값(r, key) {
29
29
  /* 월·일이 null 로 오는 옛 기록이 있다 — padStart 에서 표가 통째로 터졌다 */
30
30
  return wornDateSortKey(r.wornYear, r.wornMonth, r.wornDay);
31
31
  }
32
- export default function DeviceSideTable({ rows, onSelect, onDelete, onWear, onRelease, onAdd }) {
32
+ export default function DeviceSideTable({ rows, loading, onSelect, onDelete, onWear, onRelease, onAdd }) {
33
33
  const { t } = useTranslation();
34
34
  const 방향이름 = (side) => side === '좌' ? t('device.direction.left', '좌') : t('device.direction.right', '우');
35
35
  /* 머리행을 누르면 그 칸으로 정렬한다 — 같은 칸을 다시 누르면 방향이 뒤집힌다.
@@ -49,7 +49,7 @@ export default function DeviceSideTable({ rows, onSelect, onDelete, onWear, onRe
49
49
  }, [rows, 정렬]);
50
50
  /* 개수 보기·쪽넘김은 방문 표와 같은 것을 쓴다 — 같은 자리에서 같은 뜻으로 읽히게 */
51
51
  const { pageRows, pageSizeProps, paginationProps } = useTablePaging(sortedRows, 정렬);
52
- return (_jsxs("div", { className: "dlp-side-table", children: [_jsx(Toolbar, { count: { value: rows.length, unit: t('customers.device.unit', '개') }, pageSize: pageSizeProps, pagination: paginationProps, actions: onAdd && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", variant: "main", icon: "plus", label: t('customers.device.addButton', '기기 추가'), onClick: onAdd })) }), _jsx(Table, { resizable: true, resizeMode: "fit", resizeStorageKey: "customer-device-history-v2", fields: [
52
+ return (_jsxs("div", { className: "dlp-side-table", children: [_jsx(Toolbar, { count: { value: rows.length, unit: t('customers.device.unit', '개') }, pageSize: pageSizeProps, pagination: paginationProps, actions: onAdd && (_jsx(Button, { htmlType: "button", size: 40, type: "bar", variant: "main", icon: "plus", label: t('customers.device.addButton', '기기 추가'), onClick: onAdd })) }), _jsx(Table, { loading: loading, resizable: true, resizeMode: "fit", resizeStorageKey: "customer-device-history-v2", fields: [
53
53
  /* 맨 왼쪽 — 지금 끼고 있는 것만 좌/우를 적는다. 이 열만 훑으면 "오늘 이 사람이
54
54
  무엇을 쓰고 있나" 가 한눈에 보인다(나머지 줄은 비어 있어 눈에 안 걸린다). */
55
55
  /* 체크박스 칸은 폭을 늘릴 것이 없다 — 손잡이를 주면 잡히는데 아무 일도
@@ -58,11 +58,17 @@ export default function RingChart({ titleKey, headRight, icon, showTotal, totalL
58
58
  const pct = (ringProps.total ?? 0) > 0 ? 퍼센트(Math.min((ringProps.done ?? 0) / ringProps.total, 1)) : 0;
59
59
  // size='small' — 대시보드 작은 그리드용 블럭. 컨트롤(headRight)만 생략하고 total/legend는 medium과 동일하게 유지
60
60
  const isSmall = ringProps.size === 'small';
61
+ /* 기다림 뼈대의 지름 — 그려질 링과 **같은 셈** 을 쓴다(RingChartCanvas 의 dim) */
62
+ const 기다림지름 = typeof ringProps.size === 'number' ? ringProps.size : RING_SIZE_PRESET[ringProps.size ?? 'medium'].diameter;
61
63
  // size='large' — 카드가 가로 폭을 거의 100% 차지(1열)
62
64
  const isLarge = ringProps.size === 'large';
63
65
  const sectionClassName = [className, isSmall ? 'manager-section--compact' : isLarge ? 'manager-section--full' : '']
64
66
  .filter(Boolean).join(' ') || undefined;
65
67
  // StatChart(.manager-stat-card__icon)와 동일한 톤 — headRight 자리에 렌더(둘 중 하나만 사용)
66
68
  const iconNode = icon && (_jsx("span", { className: "chart-section-icon", children: _jsx(Icon, { icon: icon, className: "chart-section-icon-inner" }) }));
67
- return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, onClick: onClick, ariaLabel: ariaLabel, tooltip: tooltip, 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: ringProps.hideCenter ?? isSmall }) })) }));
69
+ return (_jsx(ChartSection, { className: sectionClassName, inGrid: inGrid, onClick: onClick, ariaLabel: ariaLabel, tooltip: tooltip, 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 ? (
70
+ /* 링도 같은 병이 있었다 — 기다림 상자 186px, 그려진 링은 지름(medium 120px)이다.
71
+ **그려질 링과 같은 자리·같은 지름**의 뼈대를 세우고 그 안에서 기다린다
72
+ (BarChart 와 같은 결, 사용자 지시 2026-09-17 "뼈대 먼저, 그 안에서 로딩"). */
73
+ _jsx("div", { className: "ring-chart-section-body ring-chart-section-body--offset", children: _jsx("div", { className: "ring-chart ring-chart--loading", style: { width: 기다림지름, height: 기다림지름 }, "aria-busy": "true", children: _jsx(Loader, { size: 32 }) }) })) : (_jsx("div", { className: "ring-chart-section-body ring-chart-section-body--offset", children: _jsx(RingChartCanvas, { ...ringProps, hideCenter: ringProps.hideCenter ?? isSmall }) })) }));
68
74
  }
@@ -20,8 +20,11 @@
20
20
 
21
21
  /* 라벨이 짧아도 세그먼트가 지나치게 쪼그라들지 않도록 size별 최소 너비 */
22
22
  .segmented-control .button { min-width: 96px; } /* 기본(48) */
23
+ /* 부르는 쪽이 정한 최소 폭(minButtonWidth) — 칸 이름 길이가 제각각인 카드에서 바닥을 따로 깐다.
24
+ 셈수를 위 규칙들보다 한 겹 높여(.segmented-control.segmented-control) 크기별 기본을 덮는다. */
25
+ .segmented-control.segmented-control--min .button { min-width: var(--segmented-min-button-w); }
23
26
  .segmented-control--24 .button { min-width: 48px; }
24
- .segmented-control--32 .button { min-width: 64px; }
27
+ .segmented-control--32 .button { min-width: 80px; } /* 64 는 갑갑했다 — 같은 화면의 기간 고르개(40)와 같은 눈금(2026-09-17 사용자 지시) */
25
28
  .segmented-control--40 .button { min-width: 80px; }
26
29
  .segmented-control--48 .button { min-width: 96px; }
27
30
 
@@ -24,6 +24,15 @@ export interface SegmentedControlProps {
24
24
  useBG?: boolean;
25
25
  /** 외부에서 정해진 폭을 버튼들이 균등 분할해 가득 채움 */
26
26
  fullWidth?: boolean;
27
+ /** 칸 하나의 **최소 폭**(px) — 크기별 기본(24:48 · 32:80 · 40:80 · 48:96)을 덮는다.
28
+ *
29
+ * 칸 이름 길이가 제각각인 자리에서 쓴다. 기본 폭은 짧은 이름도 갑갑하지 않게 잡은 값이라,
30
+ * '상세분석 · 로데이터' 처럼 이름이 긴 고르개는 더 벌려야 하고, 소비자가 그때마다 제 CSS 로
31
+ * 덮던 자리다(말귀 두 곳 실측 2026-09-17).
32
+ *
33
+ * 바닥을 **깔면 짧은 이름이 헐렁해진다** — 한 고르개 안에서 이름 길이가 크게 다르면
34
+ * 차라리 주지 않는 편이 낫다(칸은 제 글자만큼 벌어진다). */
35
+ minButtonWidth?: number;
27
36
  /** 화면에 보이는 라벨 없이 스크린리더용 이름을 지정할 때 사용. 없으면 일반 라벨(segmentedControl.ariaLabel)로 대체 */
28
37
  ariaLabel?: string;
29
38
  }
@@ -10,7 +10,7 @@ export default function SegmentedControl(props) {
10
10
  const { t } = useTranslation();
11
11
  const { options = [],
12
12
  // 안 주면 **켜진** 첫 칸 — 꺼진 첫 칸이 골라져 있었다(Tabs 와 같게)
13
- value = options.find((o) => !o.disabled)?.key ?? options[0]?.key, onChange = (_v) => { }, className = '', size = 48, shape = 'pill', useBG = true, fullWidth = false, ariaLabel, } = props || {};
13
+ value = options.find((o) => !o.disabled)?.key ?? options[0]?.key, onChange = (_v) => { }, className = '', size = 48, shape = 'pill', useBG = true, fullWidth = false, minButtonWidth, ariaLabel, } = props || {};
14
14
  /* **라디오 무리는 Tab 자리 하나 + 화살표**가 ARIA 규칙이다.
15
15
  `role="radiogroup"`·`role="radio"` 만 붙여 놓고 키를 안 만들면 **안 붙인 것보다
16
16
  나쁘다** — 낭독기가 "라디오 그룹, 3 중 2" 라고 읽어 주니 사람은 화살표를 누르는데
@@ -62,7 +62,7 @@ export default function SegmentedControl(props) {
62
62
  };
63
63
  /* Tab 자리를 받을 한 칸 — 고른 것이 있으면 그것, 없으면 고를 수 있는 첫 칸 */
64
64
  const 탭자리 = 고를수있는.some((o) => o.key === value) ? value : 고를수있는[0]?.key;
65
- return (_jsx("div", { ref: 그룹, onKeyDown: 키, className: cx('segmented-control', `segmented-control--${shape}`, `segmented-control--${size}`, fullWidth && 'segmented-control--full', !useBG && 'segmented-control--no-bg', className), role: "radiogroup", "aria-label": ariaLabel ?? t('segmentedControl.ariaLabel', '선택 탭'), children: options.map((opt) => (_jsx(Button, { htmlType: "button", role: "radio", "aria-checked": opt.key === value, "data-segkey": opt.key, tabIndex: opt.key === 탭자리 ? 0 : -1, label: opt.label, count: opt.count,
65
+ return (_jsx("div", { ref: 그룹, onKeyDown: 키, className: cx('segmented-control', `segmented-control--${shape}`, `segmented-control--${size}`, fullWidth && 'segmented-control--full', !useBG && 'segmented-control--no-bg', minButtonWidth != null && 'segmented-control--min', className), style: minButtonWidth != null ? { ['--segmented-min-button-w']: `${minButtonWidth}px` } : undefined, role: "radiogroup", "aria-label": ariaLabel ?? t('segmentedControl.ariaLabel', '선택 탭'), children: options.map((opt) => (_jsx(Button, { htmlType: "button", role: "radio", "aria-checked": opt.key === value, "data-segkey": opt.key, tabIndex: opt.key === 탭자리 ? 0 : -1, label: opt.label, count: opt.count,
66
66
  /* 수를 이름에 넣는다 — 글자 이름표는 단추의 aria-label 이 되어 내용(수)이 이름 계산에서 빠졌다("전체" 로만 읽힘) */
67
67
  ariaLabel: 이름(opt), disabled: opt.disabled, className: opt.key === value ? 'is-selected' : '', onClick: () => { if (!opt.disabled)
68
68
  onChange(opt.key); }, size: size, shape: shape, useBorder: false, useBG: false }, opt.key))) }));
@@ -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, useLayoutEffect, useRef, useCallback, useState } from 'react';
4
+ import { useId, useEffect, useRef, useCallback, useState } from 'react';
5
5
  import { devWarnOnce } from '../utils/devWarn';
6
6
  import { useTranslation } from 'react-i18next';
7
7
  import { createPortal } from 'react-dom';
@@ -9,9 +9,8 @@ import Icon from './Icon';
9
9
  import { anchoredMenuZIndex } from '../hooks/useAnchoredMenu';
10
10
  import Button from './Button';
11
11
  import { useComboboxSuggestions } from './useComboboxSuggestions';
12
+ import { useIsoLayoutEffect } from '../hooks/useIsoLayoutEffect';
12
13
  import './TextField.css';
13
- /* 서버 렌더에서는 layout effect 가 돌지 않고 React 18 은 경고를 찍는다 — 브라우저에서만 layout, 서버에서는 effect */
14
- const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
15
14
  /** @public */
16
15
  export default function TextField(props) {
17
16
  const { value, defaultValue, name, inputRef, onChange, label, ariaLabel, required, placeholder, type = 'text', disabled = false, readOnly = false, error, helperText, maxLength, size = 48, variant = 'input', fullWidth = false, newline = false, multiline = false, rows = 3, leftIcon, rightIcon, clearable = false, list, showCount = true, className = '', onFocus, onBlur, onKeyDown, onEnter, inputMode, autoComplete, autoFocus, suggestions: suggestionsProp, openOnFocus = false, } = props;
@@ -0,0 +1,16 @@
1
+ import { useEffect } from 'react';
2
+ /**
3
+ * **브라우저에서는 layout effect, 서버에서는 effect.**
4
+ *
5
+ * `useLayoutEffect` 는 서버 렌더에서 돌지 않고 React 는 그 자리에 경고를 찍는다.
6
+ * 그렇다고 `useEffect` 로 두면 **그린 뒤** 에 도니, 첫 프레임이 잘못 그려졌다가
7
+ * 다음 프레임에 고쳐지는 깜빡임이 생긴다 — 재서 그리는 자리(폭·높이·자리)는 그
8
+ * 한 프레임이 화면에 보인다.
9
+ *
10
+ * 그래서 두 말을 다 듣는 한 줄이다. `TextField` 가 먼저 제 파일 안에 두고 썼고,
11
+ * `ChartSection` 이 같은 것을 필요로 해(2026-09-17 말귀 실측: 좁은 카드가 고르개를
12
+ * 편 채 한 프레임 그려져 카드가 38px 출렁) 여기 한 곳으로 모았다.
13
+ *
14
+ * @internal 코어 안에서만 쓴다 — 공개 갈래로 내보내지 않는다.
15
+ */
16
+ export declare const useIsoLayoutEffect: typeof useEffect;
@@ -0,0 +1,17 @@
1
+ 'use client';
2
+ import { useEffect, useLayoutEffect } from 'react';
3
+ /**
4
+ * **브라우저에서는 layout effect, 서버에서는 effect.**
5
+ *
6
+ * `useLayoutEffect` 는 서버 렌더에서 돌지 않고 React 는 그 자리에 경고를 찍는다.
7
+ * 그렇다고 `useEffect` 로 두면 **그린 뒤** 에 도니, 첫 프레임이 잘못 그려졌다가
8
+ * 다음 프레임에 고쳐지는 깜빡임이 생긴다 — 재서 그리는 자리(폭·높이·자리)는 그
9
+ * 한 프레임이 화면에 보인다.
10
+ *
11
+ * 그래서 두 말을 다 듣는 한 줄이다. `TextField` 가 먼저 제 파일 안에 두고 썼고,
12
+ * `ChartSection` 이 같은 것을 필요로 해(2026-09-17 말귀 실측: 좁은 카드가 고르개를
13
+ * 편 채 한 프레임 그려져 카드가 38px 출렁) 여기 한 곳으로 모았다.
14
+ *
15
+ * @internal 코어 안에서만 쓴다 — 공개 갈래로 내보내지 않는다.
16
+ */
17
+ export const useIsoLayoutEffect = typeof window !== 'undefined' ? useLayoutEffect : useEffect;
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export declare const CORE_VERSION = "0.177.0";
49
+ export declare const CORE_VERSION = "0.178.0";
@@ -46,4 +46,4 @@
46
46
  *
47
47
  * 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
48
48
  */
49
- export const CORE_VERSION = '0.177.0';
49
+ export const CORE_VERSION = '0.178.0';