@ihabdevteam/core 0.176.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.
- package/CHANGELOG.md +52 -0
- package/dist/components.cjs.js +178 -178
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +34 -34
- package/dist/ihabdevteam-core.d.ts +32 -5
- package/dist/packages/core/src/components/BarChart.js +7 -1
- package/dist/packages/core/src/components/ChartSection.css +15 -28
- package/dist/packages/core/src/components/ChartSection.d.ts +18 -3
- package/dist/packages/core/src/components/ChartSection.js +17 -11
- package/dist/packages/core/src/components/Popup/DeviceSideTable.d.ts +4 -1
- package/dist/packages/core/src/components/Popup/DeviceSideTable.js +2 -2
- package/dist/packages/core/src/components/RingChart.js +7 -1
- package/dist/packages/core/src/components/SegmentedControl.css +4 -1
- package/dist/packages/core/src/components/SegmentedControl.d.ts +9 -0
- package/dist/packages/core/src/components/SegmentedControl.js +2 -2
- package/dist/packages/core/src/components/TextField.js +2 -3
- package/dist/packages/core/src/hooks/useIsoLayoutEffect.d.ts +16 -0
- package/dist/packages/core/src/hooks/useIsoLayoutEffect.js +17 -0
- package/dist/packages/core/src/version.d.ts +1 -1
- package/dist/packages/core/src/version.js +1 -1
- package/dist/utils.cjs.js +1 -1
- package/package.json +1 -1
|
@@ -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 {
|
|
@@ -1091,8 +1091,14 @@ export declare interface ChartSectionProps {
|
|
|
1091
1091
|
onClick?: () => void;
|
|
1092
1092
|
/** 누르는 카드의 이름(낭독기) — 안 주면 제목이 글자일 때 그것을 쓴다 */
|
|
1093
1093
|
ariaLabel?: string;
|
|
1094
|
-
/**
|
|
1095
|
-
*
|
|
1094
|
+
/** 제목(그리고 `disc`) **아래 서브로우**에 세우는 수(예: 정답률 83) — 차트 카드의 '월간 진행률 평균 / 21 %' 와 **같은 꼴**(`ChartTotal`).
|
|
1095
|
+
* 위 줄이 설명(`totalLabel`, body-3), 아래 줄이 큰 수(h0)와 단위(`totalUnit`, h5)다.
|
|
1096
|
+
*
|
|
1097
|
+
* 0.175.0~0.176.0 은 이것을 **머리 오른쪽에 한 줄로** 세웠다. 사용자가 그 꼴을 보고 "차트 카드의 그 형태에서
|
|
1098
|
+
* 설명 한 줄만 더 붙는 것" 으로 정했다(2026-09-17) — 그래서 자리를 서브로우로 옮겼다. **prop 이름은 그대로** 두었으니
|
|
1099
|
+
* 넘기던 소비자는 고칠 것이 없다.
|
|
1100
|
+
*
|
|
1101
|
+
* `subRow` 를 함께 주면 이 수가 **그 앞** 에 선다(한 `.barchart-sub-row` 안에서 수는 왼쪽, 범례는 오른쪽 — BarChart 와 같은 결). */
|
|
1096
1102
|
totalNum?: ReactNode;
|
|
1097
1103
|
/** 수 뒤 단위(`%` 등) — `totalNum` 과 함께 쓴다 */
|
|
1098
1104
|
totalUnit?: ReactNode;
|
|
@@ -1103,6 +1109,15 @@ export declare interface ChartSectionProps {
|
|
|
1103
1109
|
tooltip?: string;
|
|
1104
1110
|
/** 총계(ChartTotal) + 범례(ChartLegend)를 담는 서브로우 */
|
|
1105
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';
|
|
1106
1121
|
/** 섹션 루트에 추가할 클래스 */
|
|
1107
1122
|
className?: string;
|
|
1108
1123
|
/**
|
|
@@ -1570,7 +1585,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1570
1585
|
*
|
|
1571
1586
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1572
1587
|
*/
|
|
1573
|
-
export declare const CORE_VERSION = "0.
|
|
1588
|
+
export declare const CORE_VERSION = "0.178.0";
|
|
1574
1589
|
|
|
1575
1590
|
/** @public */
|
|
1576
1591
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -1897,10 +1912,13 @@ export declare interface DeviceRow {
|
|
|
1897
1912
|
|
|
1898
1913
|
declare type DeviceSide = '좌' | '우';
|
|
1899
1914
|
|
|
1900
|
-
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;
|
|
1901
1916
|
|
|
1902
1917
|
export declare interface DeviceSideTableProps {
|
|
1903
1918
|
rows: DeviceRow[];
|
|
1919
|
+
/** 서버에서 줄을 기다리는 중 — `Table` 의 `loading` 을 그대로 쓴다(스피너 한 줄).
|
|
1920
|
+
* 안 주면 기다리는 동안 **빈 표**를 그렸다가 줄이 오면 늘어난다(말귀 기기 탭 실측 2026-09-17). */
|
|
1921
|
+
loading?: boolean;
|
|
1904
1922
|
/** 줄을 누르면 그 기기로 등록 폼을 채워 연다 */
|
|
1905
1923
|
onSelect: (row: DeviceRow) => void;
|
|
1906
1924
|
/**
|
|
@@ -5787,6 +5805,15 @@ export declare interface SegmentedControlProps {
|
|
|
5787
5805
|
useBG?: boolean;
|
|
5788
5806
|
/** 외부에서 정해진 폭을 버튼들이 균등 분할해 가득 채움 */
|
|
5789
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;
|
|
5790
5817
|
/** 화면에 보이는 라벨 없이 스크린리더용 이름을 지정할 때 사용. 없으면 일반 라벨(segmentedControl.ariaLabel)로 대체 */
|
|
5791
5818
|
ariaLabel?: string;
|
|
5792
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 ? (
|
|
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
|
}
|
|
@@ -65,31 +65,7 @@
|
|
|
65
65
|
color: var(--color-gray);
|
|
66
66
|
margin: 0;
|
|
67
67
|
}
|
|
68
|
-
/* 머리
|
|
69
|
-
.manager-section__head-total {
|
|
70
|
-
display: flex;
|
|
71
|
-
align-items: baseline;
|
|
72
|
-
gap: var(--spacing-xs);
|
|
73
|
-
min-width: 0;
|
|
74
|
-
}
|
|
75
|
-
.manager-section__head-total-label {
|
|
76
|
-
color: var(--color-gray);
|
|
77
|
-
min-width: 0;
|
|
78
|
-
}
|
|
79
|
-
.manager-section__head-total-value {
|
|
80
|
-
display: flex;
|
|
81
|
-
align-items: baseline;
|
|
82
|
-
gap: 2px;
|
|
83
|
-
white-space: nowrap;
|
|
84
|
-
flex-shrink: 0;
|
|
85
|
-
}
|
|
86
|
-
.manager-section__head-total-num {
|
|
87
|
-
color: var(--color-black);
|
|
88
|
-
}
|
|
89
|
-
.manager-section__head-total-unit {
|
|
90
|
-
color: var(--color-gray);
|
|
91
|
-
}
|
|
92
|
-
|
|
68
|
+
/* 머리 오른쪽 묶음 — 고르개와 headRight. 수는 아래 서브로우(.barchart-total)로 내려갔다 */
|
|
93
69
|
.manager-section__head-right {
|
|
94
70
|
display: flex;
|
|
95
71
|
align-items: center;
|
|
@@ -141,12 +117,19 @@
|
|
|
141
117
|
gap: var(--spacing-xs);
|
|
142
118
|
}
|
|
143
119
|
|
|
144
|
-
/* 차트 영역 로딩
|
|
145
|
-
|
|
120
|
+
/* 차트 영역 로딩 — 머리·범례는 그대로 두고 **그려질 것과 같은 뼈대** 안에서 기다린다.
|
|
121
|
+
전에는 이 상자에 min-height: 186px 을 박아 뒀는데, 막대가 들어서면 156px 이라 자료가 오는
|
|
122
|
+
순간 카드마다 30px 씩 줄어 격자가 한꺼번에 내려앉았다(말귀 실측 2026-09-17). 이제 키는
|
|
123
|
+
차트가 쥔 값(막대 자리 높이 · 링 지름)에서 오므로 여기서 박지 않는다. */
|
|
124
|
+
.manager-bar-chart-plot--loading,
|
|
125
|
+
.ring-chart--loading {
|
|
146
126
|
display: flex;
|
|
147
127
|
justify-content: center;
|
|
148
128
|
align-items: center;
|
|
149
|
-
|
|
129
|
+
}
|
|
130
|
+
/* 축 자리만 잡는 줄 — 글자가 없어도 한 줄 높이를 가진다 */
|
|
131
|
+
.manager-bar-chart-area--loading .manager-bar-chart-x-axis {
|
|
132
|
+
justify-content: center;
|
|
150
133
|
}
|
|
151
134
|
|
|
152
135
|
.barchart-sub-row {
|
|
@@ -155,6 +138,10 @@
|
|
|
155
138
|
align-items: flex-start;
|
|
156
139
|
margin-bottom: var(--spacing-l);
|
|
157
140
|
}
|
|
141
|
+
/* 서브로우 세로 맞춤(subRowAlign) — 수보다 낮은 것(32px 고르개 등)이 위에 붙어 떠 보일 때 쓴다.
|
|
142
|
+
수가 가장 높으므로 수의 자리는 안 바뀐다. */
|
|
143
|
+
.barchart-sub-row--center { align-items: center; }
|
|
144
|
+
.barchart-sub-row--end { align-items: flex-end; }
|
|
158
145
|
.barchart-total {
|
|
159
146
|
display: flex;
|
|
160
147
|
align-items: baseline;
|
|
@@ -61,8 +61,14 @@ export interface ChartSectionProps {
|
|
|
61
61
|
onClick?: () => void;
|
|
62
62
|
/** 누르는 카드의 이름(낭독기) — 안 주면 제목이 글자일 때 그것을 쓴다 */
|
|
63
63
|
ariaLabel?: string;
|
|
64
|
-
/**
|
|
65
|
-
*
|
|
64
|
+
/** 제목(그리고 `disc`) **아래 서브로우**에 세우는 수(예: 정답률 83) — 차트 카드의 '월간 진행률 평균 / 21 %' 와 **같은 꼴**(`ChartTotal`).
|
|
65
|
+
* 위 줄이 설명(`totalLabel`, body-3), 아래 줄이 큰 수(h0)와 단위(`totalUnit`, h5)다.
|
|
66
|
+
*
|
|
67
|
+
* 0.175.0~0.176.0 은 이것을 **머리 오른쪽에 한 줄로** 세웠다. 사용자가 그 꼴을 보고 "차트 카드의 그 형태에서
|
|
68
|
+
* 설명 한 줄만 더 붙는 것" 으로 정했다(2026-09-17) — 그래서 자리를 서브로우로 옮겼다. **prop 이름은 그대로** 두었으니
|
|
69
|
+
* 넘기던 소비자는 고칠 것이 없다.
|
|
70
|
+
*
|
|
71
|
+
* `subRow` 를 함께 주면 이 수가 **그 앞** 에 선다(한 `.barchart-sub-row` 안에서 수는 왼쪽, 범례는 오른쪽 — BarChart 와 같은 결). */
|
|
66
72
|
totalNum?: ReactNode;
|
|
67
73
|
/** 수 뒤 단위(`%` 등) — `totalNum` 과 함께 쓴다 */
|
|
68
74
|
totalUnit?: ReactNode;
|
|
@@ -73,6 +79,15 @@ export interface ChartSectionProps {
|
|
|
73
79
|
tooltip?: string;
|
|
74
80
|
/** 총계(ChartTotal) + 범례(ChartLegend)를 담는 서브로우 */
|
|
75
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';
|
|
76
91
|
/** 섹션 루트에 추가할 클래스 */
|
|
77
92
|
className?: string;
|
|
78
93
|
/**
|
|
@@ -90,4 +105,4 @@ export interface ChartSectionProps {
|
|
|
90
105
|
* 공유하도록 두 컴포넌트가 내부적으로 사용한다.
|
|
91
106
|
* @public
|
|
92
107
|
*/
|
|
93
|
-
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
|
-
|
|
48
|
+
useIsoLayoutEffect(() => {
|
|
42
49
|
const el = secRef.current;
|
|
43
50
|
if (!el || !headSegment)
|
|
44
51
|
return;
|
|
@@ -93,13 +100,12 @@ export default function ChartSection({ title, titleClassName = 'h3', icon, disc,
|
|
|
93
100
|
}
|
|
94
101
|
}, [onClick]);
|
|
95
102
|
const 오른쪽 = typeof headRight === 'function' ? headRight({ collapsed: 접힘 }) : headRight;
|
|
96
|
-
/*
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
const
|
|
102
|
-
const 머리오른쪽 = (오른쪽 || headSegment || 머리수) ? (_jsxs("div", { className: "manager-section__head-right", children: [headSegment && (접힘 ? (_jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "ellipsis", ariaLabel: headSegment.ariaLabel ?? t('common.more', '더보기'), showTooltipInfo: headSegment.ariaLabel ?? t('common.more', '더보기'), "aria-haspopup": "menu", "aria-expanded": !!메뉴자리, onClick: 메뉴열기 })) : (_jsx(SegmentedControl, { size: 32, ariaLabel: headSegment.ariaLabel, options: headSegment.options.map(o => ({ key: o.key, label: o.label, disabled: o.disabled })), value: headSegment.value, onChange: headSegment.onChange }))), 머리수, 오른쪽] })) : null;
|
|
103
|
+
/* 제목(과 `disc`) 아래 수 — 차트 카드(BarChart·RingChart)가 쓰는 `ChartTotal` 그 꼴을 그대로 쓴다.
|
|
104
|
+
같은 자리에 두 모양이 생기면 카드마다 눈금이 달라진다(설명 `disc` 를 머리 아래 한 줄로 모은 것과 같은 까닭).
|
|
105
|
+
0.176.0 까지는 머리 오른쪽에 한 줄로 섰다 — 사용자가 차트 카드의 그 꼴로 정했다(2026-09-17). */
|
|
106
|
+
const 수줄 = (totalNum != null || totalLabel) ? (_jsxs(ChartTotal, { label: totalLabel, children: [totalNum != null && _jsx("span", { className: "barchart-total__num h0", children: totalNum }), totalUnit && _jsx("span", { className: "barchart-total__unit h5", children: totalUnit })] })) : null;
|
|
107
|
+
/* 머리 오른쪽에는 **고르개와 `headRight` 만** 남는다 — 고르개가 먼저, 상세보기 단추가 오른쪽 끝(사용자 지시 2026-09-17) */
|
|
108
|
+
const 머리오른쪽 = (오른쪽 || headSegment) ? (_jsxs("div", { className: "manager-section__head-right", children: [headSegment && (접힘 ? (_jsx(Button, { htmlType: "button", size: 32, type: "dot", icon: "ellipsis", ariaLabel: headSegment.ariaLabel ?? t('common.more', '더보기'), showTooltipInfo: headSegment.ariaLabel ?? t('common.more', '더보기'), "aria-haspopup": "menu", "aria-expanded": !!메뉴자리, onClick: 메뉴열기 })) : (_jsx(SegmentedControl, { size: 32, ariaLabel: headSegment.ariaLabel, options: headSegment.options.map(o => ({ key: o.key, label: o.label, disabled: o.disabled })), value: headSegment.value, onChange: headSegment.onChange }))), 오른쪽] })) : null;
|
|
103
109
|
return (_jsxs("section", { ref: secRef, className: ['manager-section', inGrid && 'manager-section--grid', onClick && 'manager-section--clickable', className].filter(Boolean).join(' '), onClick: onClick ? 카드누름 : undefined, onKeyDown: onClick ? 카드키 : undefined, onPointerEnter: tooltip ? 설명보이기 : undefined, onPointerLeave: tooltip ? 설명닫기 : undefined, onFocus: tooltip ? 설명보이기 : undefined, onBlur: tooltip ? 설명닫기 : undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, "aria-label": onClick ? (ariaLabel ?? (typeof title === 'string' ? title : undefined)) : undefined, children: [tooltip && 설명자리 && (_jsx(TooltipInfo, { anchorRect: {
|
|
104
110
|
left: 설명자리.left,
|
|
105
111
|
top: 설명자리.top + 21,
|
|
@@ -107,5 +113,5 @@ export default function ChartSection({ title, titleClassName = 'h3', icon, disc,
|
|
|
107
113
|
bottom: 설명자리.bottom,
|
|
108
114
|
width: 설명자리.width,
|
|
109
115
|
height: 설명자리.height,
|
|
110
|
-
}, 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 &&
|
|
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" }) }))] }));
|
|
111
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 ? (
|
|
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:
|
|
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,
|
|
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;
|