@ihabdevteam/core 0.177.0 → 0.179.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 +62 -0
- package/dist/components.cjs.js +42 -42
- package/dist/components.esm.css +3 -3
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +45 -45
- package/dist/ihabdevteam-core.d.ts +47 -3
- package/dist/packages/core/src/components/BarChart.js +7 -1
- package/dist/packages/core/src/components/Button.css +16 -0
- package/dist/packages/core/src/components/Button.js +3 -2
- package/dist/packages/core/src/components/ChartSection.css +14 -3
- package/dist/packages/core/src/components/ChartSection.d.ts +10 -1
- package/dist/packages/core/src/components/ChartSection.js +11 -4
- 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/Popup/Popup.css +10 -1
- package/dist/packages/core/src/components/Popup/Popup.d.ts +10 -0
- package/dist/packages/core/src/components/Popup/Popup.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/components/Training/TrainingBlock.css +20 -0
- package/dist/packages/core/src/components/Training/TrainingPagePopup.css +6 -0
- package/dist/packages/core/src/components/Training/TrainingPagePopup.d.ts +4 -0
- package/dist/packages/core/src/components/Training/TrainingPagePopup.js +3 -3
- package/dist/packages/core/src/components/buttonTypes.d.ts +9 -0
- 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
|
@@ -847,6 +847,15 @@ export declare interface ButtonProps {
|
|
|
847
847
|
ariaLabel?: string;
|
|
848
848
|
variant?: ButtonVariant;
|
|
849
849
|
align?: 'center' | 'left' | 'right' | 'grid';
|
|
850
|
+
/** 라벨을 **줄바꿈하지 않는다**(`white-space: nowrap`).
|
|
851
|
+
*
|
|
852
|
+
* 기본은 줄바꿈이다 — 표 칸처럼 좁은 자리에서 라벨이 길면 두 줄로 흐르는 편이 잘려 안 보이는 것보다 낫다.
|
|
853
|
+
* 하지만 '문자'·'카카오' 처럼 **두 글자가 한 낱말**인 단추는 갈리면 우습다. 그 자리에 켠다.
|
|
854
|
+
* 소비자 둘이 `.button__label-text { white-space: nowrap }` 로 각자 칠하고 있었다(2026-09-17 말귀 전수).
|
|
855
|
+
*
|
|
856
|
+
* 기본을 nowrap 으로 바꾸지 않은 까닭: 두 줄 라벨(`subLabel`·설명 붙은 단추)이 이미 선 화면이 있고,
|
|
857
|
+
* 좁은 자리에서 nowrap 은 **넘쳐 잘린다**(줄바꿈은 키만 늘어난다). 어느 쪽이 나은지는 그 자리가 안다. */
|
|
858
|
+
nowrap?: boolean;
|
|
850
859
|
selected?: boolean;
|
|
851
860
|
toggleable?: boolean;
|
|
852
861
|
/**
|
|
@@ -1063,7 +1072,7 @@ export declare interface ChartPoint {
|
|
|
1063
1072
|
* 공유하도록 두 컴포넌트가 내부적으로 사용한다.
|
|
1064
1073
|
* @public
|
|
1065
1074
|
*/
|
|
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;
|
|
1075
|
+
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
1076
|
|
|
1068
1077
|
/** @public */
|
|
1069
1078
|
export declare interface ChartSectionProps {
|
|
@@ -1109,6 +1118,15 @@ export declare interface ChartSectionProps {
|
|
|
1109
1118
|
tooltip?: string;
|
|
1110
1119
|
/** 총계(ChartTotal) + 범례(ChartLegend)를 담는 서브로우 */
|
|
1111
1120
|
subRow?: ReactNode;
|
|
1121
|
+
/** 서브로우에 선 것들의 **세로 맞춤** — `'start'`(기본) · `'center'` · `'end'`.
|
|
1122
|
+
*
|
|
1123
|
+
* 기본이 `'start'` 인 것은 **범례** 때문이다. 수(설명 한 줄 + 큰 수, 두 줄)와 범례가 함께 서면
|
|
1124
|
+
* 범례는 첫 줄부터 읽히는 편이 낫다. 그런데 그 자리에 **32px 짜리 고르개** 를 넣으면 위에 붙어
|
|
1125
|
+
* 아래가 29px 비고, 수에 견주어 떠 보인다(말귀 실측 2026-09-17: 서브로우 61.4px · 고르개 32px).
|
|
1126
|
+
* 그때 `'center'` 로 준다 — 수가 가장 높으므로 수의 자리는 안 바뀌고 낮은 것만 가운데로 온다.
|
|
1127
|
+
*
|
|
1128
|
+
* 기본을 안 바꾼 것은 이미 선 화면 셋(말귀·소리방향성·kscan)의 범례가 함께 움직이기 때문이다. */
|
|
1129
|
+
subRowAlign?: 'start' | 'center' | 'end';
|
|
1112
1130
|
/** 섹션 루트에 추가할 클래스 */
|
|
1113
1131
|
className?: string;
|
|
1114
1132
|
/**
|
|
@@ -1576,7 +1594,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1576
1594
|
*
|
|
1577
1595
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1578
1596
|
*/
|
|
1579
|
-
export declare const CORE_VERSION = "0.
|
|
1597
|
+
export declare const CORE_VERSION = "0.179.0";
|
|
1580
1598
|
|
|
1581
1599
|
/** @public */
|
|
1582
1600
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -1903,10 +1921,13 @@ export declare interface DeviceRow {
|
|
|
1903
1921
|
|
|
1904
1922
|
declare type DeviceSide = '좌' | '우';
|
|
1905
1923
|
|
|
1906
|
-
export declare function DeviceSideTable({ rows, onSelect, onDelete, onWear, onRelease, onAdd }: DeviceSideTableProps): JSX.Element;
|
|
1924
|
+
export declare function DeviceSideTable({ rows, loading, onSelect, onDelete, onWear, onRelease, onAdd }: DeviceSideTableProps): JSX.Element;
|
|
1907
1925
|
|
|
1908
1926
|
export declare interface DeviceSideTableProps {
|
|
1909
1927
|
rows: DeviceRow[];
|
|
1928
|
+
/** 서버에서 줄을 기다리는 중 — `Table` 의 `loading` 을 그대로 쓴다(스피너 한 줄).
|
|
1929
|
+
* 안 주면 기다리는 동안 **빈 표**를 그렸다가 줄이 오면 늘어난다(말귀 기기 탭 실측 2026-09-17). */
|
|
1930
|
+
loading?: boolean;
|
|
1910
1931
|
/** 줄을 누르면 그 기기로 등록 폼을 채워 연다 */
|
|
1911
1932
|
onSelect: (row: DeviceRow) => void;
|
|
1912
1933
|
/**
|
|
@@ -4515,6 +4536,16 @@ export declare interface PopupProps {
|
|
|
4515
4536
|
footer?: ReactNode | ((opts: PopupOptions) => ReactNode);
|
|
4516
4537
|
/** 커스텀 footer 래퍼에 추가할 클래스 (정렬·간격 override용) */
|
|
4517
4538
|
footerClassName?: string;
|
|
4539
|
+
/** 바닥 줄의 가로 배치 — `'center'`(기본) · `'fill'` · `'between'`.
|
|
4540
|
+
*
|
|
4541
|
+
* - `'center'`: 지금까지와 같다(가운데 모음).
|
|
4542
|
+
* - `'fill'`: 바닥의 것들이 폭을 **똑같이 나눈다** — 단추 둘을 반반 세우는 창.
|
|
4543
|
+
* - `'between'`: 양 끝으로 밀고(왼쪽 보조 · 오른쪽 주액션) 양옆에 `--spacing-xl` 을 둔다.
|
|
4544
|
+
* 끝으로 붙은 것이 창 테두리에 닿아 보이던 자리를 코어가 쥔다.
|
|
4545
|
+
*
|
|
4546
|
+
* `buttons` 배열을 쓸 때도 듣는다(그쪽은 `max-width: 200px` 제약이 따로 있어 `'fill'` 은 그 안에서 나눈다).
|
|
4547
|
+
* 세로는 이제 늘 가운데다(0.179.0) — 소비자 셋이 이 자리를 각자 칠하고 있었다. */
|
|
4548
|
+
footerLayout?: 'center' | 'fill' | 'between';
|
|
4518
4549
|
onClose?: () => void;
|
|
4519
4550
|
/** true면 X 버튼/ESC로 닫는 것을 막는다 — 이미 시작된 서버 요청(확인 버튼 클릭 등)이
|
|
4520
4551
|
* 진행 중일 때, "취소했다고 믿었는데 요청은 계속 진행돼 완료 후 결과가 반영되는" 상황을
|
|
@@ -5793,6 +5824,15 @@ export declare interface SegmentedControlProps {
|
|
|
5793
5824
|
useBG?: boolean;
|
|
5794
5825
|
/** 외부에서 정해진 폭을 버튼들이 균등 분할해 가득 채움 */
|
|
5795
5826
|
fullWidth?: boolean;
|
|
5827
|
+
/** 칸 하나의 **최소 폭**(px) — 크기별 기본(24:48 · 32:80 · 40:80 · 48:96)을 덮는다.
|
|
5828
|
+
*
|
|
5829
|
+
* 칸 이름 길이가 제각각인 자리에서 쓴다. 기본 폭은 짧은 이름도 갑갑하지 않게 잡은 값이라,
|
|
5830
|
+
* '상세분석 · 로데이터' 처럼 이름이 긴 고르개는 더 벌려야 하고, 소비자가 그때마다 제 CSS 로
|
|
5831
|
+
* 덮던 자리다(말귀 두 곳 실측 2026-09-17).
|
|
5832
|
+
*
|
|
5833
|
+
* 바닥을 **깔면 짧은 이름이 헐렁해진다** — 한 고르개 안에서 이름 길이가 크게 다르면
|
|
5834
|
+
* 차라리 주지 않는 편이 낫다(칸은 제 글자만큼 벌어진다). */
|
|
5835
|
+
minButtonWidth?: number;
|
|
5796
5836
|
/** 화면에 보이는 라벨 없이 스크린리더용 이름을 지정할 때 사용. 없으면 일반 라벨(segmentedControl.ariaLabel)로 대체 */
|
|
5797
5837
|
ariaLabel?: string;
|
|
5798
5838
|
}
|
|
@@ -7852,6 +7892,10 @@ export declare type TrainingPagePopupProps = {
|
|
|
7852
7892
|
}) => ReactNode);
|
|
7853
7893
|
/** 대화상자 이름(낭독기가 읽는 이름). 주면 title·label·시나리오 제목보다 이긴다 — 보이는 제목과 다른 이름이 필요할 때. 없으면 보이는 제목. */
|
|
7854
7894
|
ariaLabel?: string;
|
|
7895
|
+
/** 속 통의 폭·여백 제약을 푼다 — 기본은 `max-width: 800px` + `--spacing-page` 여백이다.
|
|
7896
|
+
* 안에 넣는 화면이 **제 폭과 여백을 그대로 쓰는** 쓰임에서 켠다(표 한 판을 꽉 채우는 창 등).
|
|
7897
|
+
* 소비자 둘이 `.page-popup__content { padding: 0; max-width: none }` 로 각자 풀고 있었다(2026-09-17). */
|
|
7898
|
+
contentFull?: boolean;
|
|
7855
7899
|
label?: string;
|
|
7856
7900
|
buildResults?: (answers: Record<string, unknown>, meta?: Record<string, unknown>) => unknown;
|
|
7857
7901
|
onResult?: (result?: unknown) => unknown;
|
|
@@ -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
|
}
|
|
@@ -76,6 +76,22 @@
|
|
|
76
76
|
gap: 3px;
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/* 라벨 줄바꿈 금지(nowrap) — '문자'·'카카오' 처럼 두 글자가 한 낱말인 단추가 표 칸에서 갈리던 자리 */
|
|
80
|
+
.button--nowrap .button__label-text {
|
|
81
|
+
white-space: nowrap;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/* align='left' 는 **교차축까지** 왼쪽이다 — 두 줄 라벨(제목+설명)이 가운데로 모여
|
|
85
|
+
소비자 둘이 `align-items: flex-start` 를 다시 칠하고 있었다(2026-09-17 말귀 전수).
|
|
86
|
+
한 줄 라벨에서는 가로 자리만 바뀐다(세로 가운데는 그대로) — `--has-sub` 는 세로 쌓임이라 그때만 교차축이 가로다. */
|
|
87
|
+
.button--align-left .button__label-text {
|
|
88
|
+
justify-content: flex-start;
|
|
89
|
+
}
|
|
90
|
+
.button--align-left .button__label-text--has-sub {
|
|
91
|
+
align-items: flex-start;
|
|
92
|
+
text-align: left;
|
|
93
|
+
}
|
|
94
|
+
|
|
79
95
|
.button__count {
|
|
80
96
|
margin-left: 4px;
|
|
81
97
|
color: var(--color-main);
|
|
@@ -10,7 +10,7 @@ import { isTouchPointer, recentlyTouched, useTouchToggle } from '../hooks/touchT
|
|
|
10
10
|
/* **ref 는 forwardRef 로 받는다** — React 19 는 ref 를 props 로 넘기지만 peer 는 16.8~ 라, React 18(kscan) 에서는 함수 컴포넌트에 준
|
|
11
11
|
ref 가 버려진다. 게다가 전에는 안쪽 ref(`btnRef`)가 props 에서 온 ref 를 덮어써 19 에서도 부르는 쪽 ref 가 비었다. 두 ref 를 잇는다. */
|
|
12
12
|
function ButtonInner(props, forwardedRef) {
|
|
13
|
-
const { size = 48, type: explicitType, htmlType, href, target, rel, icon, rightIcon, label: labelProp, count, subLabel, children, ariaLabel, variant = 'default', align = 'center', selected, toggleable = false, ariaExpanded, ariaControls, disabled = false, useLabel = true, useBorder = true, useBG = true, useDisabledCovered = false, useDisabledInvisible = false, width, minWidth, onClick, onDisabledClick, showTooltipInfo = false, tooltipInfoText = null, tooltipDescription = null, tooltipOnly = false, iconSize = 'medium', iconColor, shape = 'rounded', color, className, ...rest } = props;
|
|
13
|
+
const { size = 48, type: explicitType, htmlType, href, target, rel, icon, rightIcon, label: labelProp, count, subLabel, children, ariaLabel, variant = 'default', align = 'center', nowrap = false, selected, toggleable = false, ariaExpanded, ariaControls, disabled = false, useLabel = true, useBorder = true, useBG = true, useDisabledCovered = false, useDisabledInvisible = false, width, minWidth, onClick, onDisabledClick, showTooltipInfo = false, tooltipInfoText = null, tooltipDescription = null, tooltipOnly = false, iconSize = 'medium', iconColor, shape = 'rounded', color, className, ...rest } = props;
|
|
14
14
|
const sizeKey = typeof size === 'string' && isNaN(Number(size))
|
|
15
15
|
? size.toLowerCase()
|
|
16
16
|
: String(Number(size) || 48);
|
|
@@ -28,6 +28,7 @@ function ButtonInner(props, forwardedRef) {
|
|
|
28
28
|
: variant;
|
|
29
29
|
const variantClass = effectiveVariant ? `button--variant-${effectiveVariant}` : '';
|
|
30
30
|
const alignClass = align ? `button--align-${align}` : '';
|
|
31
|
+
const nowrapClass = nowrap ? 'button--nowrap' : '';
|
|
31
32
|
const shapeClass = shape ? `button--shape-${shape}` : '';
|
|
32
33
|
const resolvedIconColor = typeof iconColor === 'string' && iconColor.startsWith('--')
|
|
33
34
|
? `var(${iconColor})`
|
|
@@ -115,7 +116,7 @@ function ButtonInner(props, forwardedRef) {
|
|
|
115
116
|
: (toggleable || typeof selected === 'boolean')
|
|
116
117
|
? (isSelected ? 'true' : 'false')
|
|
117
118
|
: rest['aria-pressed'];
|
|
118
|
-
const classes = cx('button', sizeBase, sizeTypeClass, variantClass, alignClass, shapeClass, isSelected ? 'is-selected' : null, !useBG ? 'button--no-bg' : null, tooltipOnly ? 'button--tooltip-only' : null, className);
|
|
119
|
+
const classes = cx('button', sizeBase, sizeTypeClass, variantClass, alignClass, nowrapClass, shapeClass, isSelected ? 'is-selected' : null, !useBG ? 'button--no-bg' : null, tooltipOnly ? 'button--tooltip-only' : null, className);
|
|
119
120
|
const iconClassName = `button__icon button__icon--${iconSize}`;
|
|
120
121
|
const subLabels = Array.isArray(subLabel) ? subLabel : (subLabel !== undefined ? [subLabel] : []);
|
|
121
122
|
const renderLabelContent = () => {
|
|
@@ -117,12 +117,19 @@
|
|
|
117
117
|
gap: var(--spacing-xs);
|
|
118
118
|
}
|
|
119
119
|
|
|
120
|
-
/* 차트 영역 로딩
|
|
121
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
/* 체크박스 칸은 폭을 늘릴 것이 없다 — 손잡이를 주면 잡히는데 아무 일도
|
|
@@ -86,7 +86,16 @@
|
|
|
86
86
|
.popup-content__inner {
|
|
87
87
|
padding-bottom: 20px;
|
|
88
88
|
}
|
|
89
|
-
|
|
89
|
+
/* 세로는 가운데 — 안 적어 두면 stretch 라, 키가 다른 것(보조 글월 + 단추)을 나란히 두면 늘어난다.
|
|
90
|
+
소비자 셋이 이 자리를 각자 `align-items: center` 로 칠하고 있었다(2026-09-17 말귀 전수). */
|
|
91
|
+
.popup-footer { padding: 0 20px 20px; display:flex; gap:8px; justify-content:center; align-items:center }
|
|
92
|
+
/* 바닥 배치(footerLayout) */
|
|
93
|
+
.popup-footer--fill > * { flex: 1 }
|
|
94
|
+
.popup-footer--between {
|
|
95
|
+
justify-content: space-between;
|
|
96
|
+
padding-left: var(--spacing-xl);
|
|
97
|
+
padding-right: var(--spacing-xl);
|
|
98
|
+
}
|
|
90
99
|
/* 표준 buttons 배열만 폭/정렬 제약 — 커스텀 footer는 자유 배치 */
|
|
91
100
|
.popup-footer--buttons > * { max-width: 200px; flex: 1; }
|
|
92
101
|
.popup--closing { opacity: 0; transition: opacity 260ms ease }
|
|
@@ -38,6 +38,16 @@ export interface PopupProps {
|
|
|
38
38
|
footer?: ReactNode | ((opts: PopupOptions) => ReactNode);
|
|
39
39
|
/** 커스텀 footer 래퍼에 추가할 클래스 (정렬·간격 override용) */
|
|
40
40
|
footerClassName?: string;
|
|
41
|
+
/** 바닥 줄의 가로 배치 — `'center'`(기본) · `'fill'` · `'between'`.
|
|
42
|
+
*
|
|
43
|
+
* - `'center'`: 지금까지와 같다(가운데 모음).
|
|
44
|
+
* - `'fill'`: 바닥의 것들이 폭을 **똑같이 나눈다** — 단추 둘을 반반 세우는 창.
|
|
45
|
+
* - `'between'`: 양 끝으로 밀고(왼쪽 보조 · 오른쪽 주액션) 양옆에 `--spacing-xl` 을 둔다.
|
|
46
|
+
* 끝으로 붙은 것이 창 테두리에 닿아 보이던 자리를 코어가 쥔다.
|
|
47
|
+
*
|
|
48
|
+
* `buttons` 배열을 쓸 때도 듣는다(그쪽은 `max-width: 200px` 제약이 따로 있어 `'fill'` 은 그 안에서 나눈다).
|
|
49
|
+
* 세로는 이제 늘 가운데다(0.179.0) — 소비자 셋이 이 자리를 각자 칠하고 있었다. */
|
|
50
|
+
footerLayout?: 'center' | 'fill' | 'between';
|
|
41
51
|
onClose?: () => void;
|
|
42
52
|
/** true면 X 버튼/ESC로 닫는 것을 막는다 — 이미 시작된 서버 요청(확인 버튼 클릭 등)이
|
|
43
53
|
* 진행 중일 때, "취소했다고 믿었는데 요청은 계속 진행돼 완료 후 결과가 반영되는" 상황을
|
|
@@ -15,7 +15,7 @@ const ANIM_MS = 260;
|
|
|
15
15
|
/** @public */
|
|
16
16
|
export default function Popup(props) {
|
|
17
17
|
const { t } = useTranslation();
|
|
18
|
-
const { title, subtitle, headerActions, children, buttons, footer, footerClassName = "", onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, layer = 'modal', height, fillBody = false, resetScrollKey } = props || {};
|
|
18
|
+
const { title, subtitle, headerActions, children, buttons, footer, footerClassName = "", footerLayout = 'center', onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, layer = 'modal', height, fillBody = false, resetScrollKey } = props || {};
|
|
19
19
|
const [closing, setClosing] = useState(false);
|
|
20
20
|
// 닫힘 애니메이션 중(260ms)엔 버튼이 시각적으로만 흐려질 뿐 클릭은 계속 가능해,
|
|
21
21
|
// 그 사이 같은 버튼을 연타하면 cb(확인/완료 콜백)가 여러 번 예약되어 중복 실행된다.
|
|
@@ -211,7 +211,7 @@ export default function Popup(props) {
|
|
|
211
211
|
e.preventDefault(); }, children: _jsxs("div", { ref: panelRef, className: `popup-panel${size ? ` popup-panel--${size}` : ''}${height != null ? ' popup-panel--has-height' : ''}${fillBody ? ' popup-panel--fill-body' : ''}`,
|
|
212
212
|
/* 높이는 **CSS 변수로** 넘긴다 — 인라인 height 로 박으면 앱의 미디어 규칙(좁은 화면만 다른 높이)이 못 이겼다.
|
|
213
213
|
max-height(CSS)가 height 보다 이기므로 화면보다 크게 줘도 화면에 맞춰진다 */
|
|
214
|
-
style: height != null ? { '--popup-height': typeof height === 'number' ? `${height}px` : height } : undefined, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && useTitle && title ? titleId : undefined, "aria-label": !inline && !(useTitle && title) ? (ariaLabel ?? (typeof title === 'string' ? title : undefined)) : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { htmlType: "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, { htmlType: "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:
|
|
214
|
+
style: height != null ? { '--popup-height': typeof height === 'number' ? `${height}px` : height } : undefined, tabIndex: -1, role: inline ? undefined : 'dialog', "aria-modal": inline ? undefined : true, "aria-labelledby": !inline && useTitle && title ? titleId : undefined, "aria-label": !inline && !(useTitle && title) ? (ariaLabel ?? (typeof title === 'string' ? title : undefined)) : undefined, children: [!useTitle && (headerActions || onClose) ? (_jsxs("div", { className: "popup-close-float", children: [headerActions, onClose && _jsx(Button, { htmlType: "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, { htmlType: "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${footerLayout !== 'center' ? ` popup-footer--${footerLayout}` : ''}`, children: buttons.map((btn, i) => (_jsx(Button, { htmlType: "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${footerLayout !== 'center' ? ` popup-footer--${footerLayout}` : ''}${footerClassName ? ` ${footerClassName}` : ''}`, children: typeof footer === 'function' ? footer({ animateThen, closeWithAnimation }) : footer })) : null] }) }));
|
|
215
215
|
if (inline || typeof document === 'undefined')
|
|
216
216
|
return el;
|
|
217
217
|
return createPortal(el, document.body);
|
|
@@ -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;
|
|
@@ -16,6 +16,26 @@
|
|
|
16
16
|
gap: 10px;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
/* 폰에서는 **두 열**이다(2026-09-18, 말귀 청).
|
|
20
|
+
전에는 `flex-wrap` 과 단추의 `width: calc(50% - 5px)` 로 두 열을 노렸는데, 둘 합이 폭과 **딱 같아서**
|
|
21
|
+
테두리·반올림 한 픽셀에 밀려 한 열로 떨어졌다. 소비자는 그래서 `.page .training-list` 로 특이도를
|
|
22
|
+
못박아 격자를 다시 깔고 있었다 — 그 앞에 앱 이름을 세운 것은 코어와 같은 셈수(0,1,0)로는 청크 차례가
|
|
23
|
+
승자를 정했기 때문이다. 코어가 제 화면 규칙을 쥐면 그 두 규칙이 걷힌다.
|
|
24
|
+
감싸개를 한 겹 두는 화면도 있어(단어훈련) 셀을 채우는 규칙은 **손자까지** 본다. */
|
|
25
|
+
@media (max-width: 767px) {
|
|
26
|
+
.training-list {
|
|
27
|
+
display: grid;
|
|
28
|
+
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
29
|
+
}
|
|
30
|
+
.training-list > * {
|
|
31
|
+
min-width: 0;
|
|
32
|
+
}
|
|
33
|
+
.training-list > .training-block-button,
|
|
34
|
+
.training-list > * > .training-block-button {
|
|
35
|
+
width: 100%;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
19
39
|
.training-block__more {
|
|
20
40
|
display: flex;
|
|
21
41
|
justify-content: center;
|
|
@@ -154,6 +154,12 @@
|
|
|
154
154
|
min-width: 0;
|
|
155
155
|
}
|
|
156
156
|
|
|
157
|
+
/* 속 제약을 푼 통(contentFull) — 안에 넣는 화면이 제 폭·여백을 그대로 쓴다 */
|
|
158
|
+
.page-popup__content--full {
|
|
159
|
+
padding: 0;
|
|
160
|
+
max-width: none;
|
|
161
|
+
}
|
|
162
|
+
|
|
157
163
|
.page-popup__content--center {
|
|
158
164
|
margin-top: auto;
|
|
159
165
|
margin-bottom: auto;
|
|
@@ -67,6 +67,10 @@ export type TrainingPagePopupProps = {
|
|
|
67
67
|
}) => ReactNode);
|
|
68
68
|
/** 대화상자 이름(낭독기가 읽는 이름). 주면 title·label·시나리오 제목보다 이긴다 — 보이는 제목과 다른 이름이 필요할 때. 없으면 보이는 제목. */
|
|
69
69
|
ariaLabel?: string;
|
|
70
|
+
/** 속 통의 폭·여백 제약을 푼다 — 기본은 `max-width: 800px` + `--spacing-page` 여백이다.
|
|
71
|
+
* 안에 넣는 화면이 **제 폭과 여백을 그대로 쓰는** 쓰임에서 켠다(표 한 판을 꽉 채우는 창 등).
|
|
72
|
+
* 소비자 둘이 `.page-popup__content { padding: 0; max-width: none }` 로 각자 풀고 있었다(2026-09-17). */
|
|
73
|
+
contentFull?: boolean;
|
|
70
74
|
label?: string;
|
|
71
75
|
buildResults?: (answers: Record<string, unknown>, meta?: Record<string, unknown>) => unknown;
|
|
72
76
|
onResult?: (result?: unknown) => unknown;
|
|
@@ -855,7 +855,7 @@ function TrainingPagePopupInner(props) {
|
|
|
855
855
|
const scenarioContent = hasScenario
|
|
856
856
|
? scenarioPages.length > 0
|
|
857
857
|
? _jsx("div", { className: "training-steps", children: currentScenarioSteps.map((step, index) => renderScenarioStep(step, index)) }, `replay-${replayCount}`)
|
|
858
|
-
: (_jsx("div", { className:
|
|
858
|
+
: (_jsx("div", { className: `page-popup__content page-popup__content--center${props.contentFull ? ' page-popup__content--full' : ''}`, children: _jsx("div", { className: "pp-empty", children: scenario?.emptyMessage || t('training.noQuestions', '문제가 없습니다.') }) }))
|
|
859
859
|
: null;
|
|
860
860
|
const childrenContent = isChildrenMode
|
|
861
861
|
? (_jsxs(TrainingPagePopupContext.Provider, { value: childrenContextValue, children: [currentChildrenPage?.pageTop ? (_jsx("div", { className: "training-page-top", children: currentChildrenPage.pageTop })) : null, _jsx("div", { className: "training-steps", children: compoundChildrenSteps.map((child, index) => {
|
|
@@ -905,7 +905,7 @@ function TrainingPagePopupInner(props) {
|
|
|
905
905
|
아무 데도 닿지 않았다** — 낭독기는 이것을 그냥 대화상자로 읽고 가상 커서가
|
|
906
906
|
뒤 화면으로 새어 나간다(Tab 가둠은 `useFocusTrap` 이 따로 하고 있었다).
|
|
907
907
|
둘을 한 요소에 모은다. */
|
|
908
|
-
_jsxs("div", { className: `page-popup${closing ? ' page-popup--closing' : ''}`, role: "dialog", "aria-modal": "true", "aria-label": resolvedTitle, children: [_jsxs("div", { ref: panelRef, className: `page-popup__panel ${!measured ? 'page-popup__panel--measuring' : ''} ${mode === 'center' ? 'page-popup__panel--center' : mode === 'top' ? 'page-popup__panel--top' : ''}`, children: [_jsx(TrainingPagePopupTitle, { title: titleForHeader, subtitle: hasScenario ? (scenarioMeta.subtitle || scenarioMeta.label) : (Array.isArray(depth) ? depth.join(' / ') : undefined), desc: !hasScenario ? subTitle : undefined, depth: (hasScenario ? scenarioMeta.depth : depth), icon: hasScenario ? scenarioMeta.icon : icon, wrapperStyle: hasScenario ? scenarioMeta.wrapperStyle : wrapperStyle, iconTooltip: hasScenario ? scenarioMeta.iconTooltip : iconTooltip, progressMax: effectivePageCount, progressIndex: effectivePageIndex, pageIndex: effectivePageIndex, pageCount: effectivePageCount, onBack: handleCloseBack, onClose: handleCloseBack }), _jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className: `page-popup__content ${mode === "center" ? "page-popup__content--center" : "page-popup__content--top"}`, children: isChildrenMode ? childrenContent : (hasScenario ? scenarioContent : (typeof children === 'function' ? children({ registerOnNext, registerOnResult: registerOnResult, panelRef, setResultOverlay }) : children)) }) }), _jsx(TrainingPagePopupBottom, { onSettings: onSettings, onNext: handleNext, onResult: handleViewResults, useNoiseSettingBtn: Boolean(hasScenario ? (scenarioMeta.useNoiseSetting ?? true) : useNoiseSetting), stepIndex: effectiveStepIndex, stepCount: effectiveStepCount, pageIndex: effectivePageIndex, pageCount: effectivePageCount })] }), (resultOverlay && !resultOverlay.isEmpty && (renderResultPopup ?? defaultRenderResult)({
|
|
908
|
+
_jsxs("div", { className: `page-popup${closing ? ' page-popup--closing' : ''}`, role: "dialog", "aria-modal": "true", "aria-label": resolvedTitle, children: [_jsxs("div", { ref: panelRef, className: `page-popup__panel ${!measured ? 'page-popup__panel--measuring' : ''} ${mode === 'center' ? 'page-popup__panel--center' : mode === 'top' ? 'page-popup__panel--top' : ''}`, children: [_jsx(TrainingPagePopupTitle, { title: titleForHeader, subtitle: hasScenario ? (scenarioMeta.subtitle || scenarioMeta.label) : (Array.isArray(depth) ? depth.join(' / ') : undefined), desc: !hasScenario ? subTitle : undefined, depth: (hasScenario ? scenarioMeta.depth : depth), icon: hasScenario ? scenarioMeta.icon : icon, wrapperStyle: hasScenario ? scenarioMeta.wrapperStyle : wrapperStyle, iconTooltip: hasScenario ? scenarioMeta.iconTooltip : iconTooltip, progressMax: effectivePageCount, progressIndex: effectivePageIndex, pageIndex: effectivePageIndex, pageCount: effectivePageCount, onBack: handleCloseBack, onClose: handleCloseBack }), _jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className: `page-popup__content ${mode === "center" ? "page-popup__content--center" : "page-popup__content--top"}${props.contentFull ? ' page-popup__content--full' : ''}`, children: isChildrenMode ? childrenContent : (hasScenario ? scenarioContent : (typeof children === 'function' ? children({ registerOnNext, registerOnResult: registerOnResult, panelRef, setResultOverlay }) : children)) }) }), _jsx(TrainingPagePopupBottom, { onSettings: onSettings, onNext: handleNext, onResult: handleViewResults, useNoiseSettingBtn: Boolean(hasScenario ? (scenarioMeta.useNoiseSetting ?? true) : useNoiseSetting), stepIndex: effectiveStepIndex, stepCount: effectiveStepCount, pageIndex: effectivePageIndex, pageCount: effectivePageCount })] }), (resultOverlay && !resultOverlay.isEmpty && (renderResultPopup ?? defaultRenderResult)({
|
|
909
909
|
score: resultOverlay.score,
|
|
910
910
|
results: (resultOverlay.results || resultOverlay.answers || []),
|
|
911
911
|
contextsLength: resultOverlay.results ? resultOverlay.results.length : (resultOverlay.answers ? resultOverlay.answers.length : 0),
|
|
@@ -941,7 +941,7 @@ function TrainingPagePopupInner(props) {
|
|
|
941
941
|
},
|
|
942
942
|
onClose: handleResultDone,
|
|
943
943
|
pending: resultOverlay === 결과계산중,
|
|
944
|
-
})), (resultOverlay?.isEmpty && (_jsx("div", { className: "page-popup page-popup--result-empty", role: "dialog", "aria-modal": "true", "aria-label": t('training.result.eyebrow', '훈련 완료'), children: _jsxs("div", { className: "page-popup__panel page-popup__panel--center", children: [_jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className:
|
|
944
|
+
})), (resultOverlay?.isEmpty && (_jsx("div", { className: "page-popup page-popup--result-empty", role: "dialog", "aria-modal": "true", "aria-label": t('training.result.eyebrow', '훈련 완료'), children: _jsxs("div", { className: "page-popup__panel page-popup__panel--center", children: [_jsx("div", { className: "page-popup__scroll", children: _jsx("div", { className: `page-popup__content page-popup__content--center${props.contentFull ? ' page-popup__content--full' : ''}`, children: _jsx("div", { className: "page-popup__empty", children: t('training.result.eyebrow', '훈련 완료') }) }) }), _jsx("div", { className: "page-popup__bottom", children: _jsx(Button, { htmlType: "button", variant: "main", size: 48, onClick: handleResultDone, label: t('training.result.done', '완료') }) })] }) }))), _jsx(Toast, { message: noticeMessage, position: "top", onClose: handleNoticeClose })] }));
|
|
945
945
|
const portalContent = (_jsx(NoisePlayerContext.Provider, { value: noiseContextValue, children: jsx }));
|
|
946
946
|
if (typeof document !== 'undefined' && document.body)
|
|
947
947
|
return createPortal(portalContent, document.body);
|
|
@@ -64,6 +64,15 @@ export interface ButtonProps {
|
|
|
64
64
|
ariaLabel?: string;
|
|
65
65
|
variant?: ButtonVariant;
|
|
66
66
|
align?: 'center' | 'left' | 'right' | 'grid';
|
|
67
|
+
/** 라벨을 **줄바꿈하지 않는다**(`white-space: nowrap`).
|
|
68
|
+
*
|
|
69
|
+
* 기본은 줄바꿈이다 — 표 칸처럼 좁은 자리에서 라벨이 길면 두 줄로 흐르는 편이 잘려 안 보이는 것보다 낫다.
|
|
70
|
+
* 하지만 '문자'·'카카오' 처럼 **두 글자가 한 낱말**인 단추는 갈리면 우습다. 그 자리에 켠다.
|
|
71
|
+
* 소비자 둘이 `.button__label-text { white-space: nowrap }` 로 각자 칠하고 있었다(2026-09-17 말귀 전수).
|
|
72
|
+
*
|
|
73
|
+
* 기본을 nowrap 으로 바꾸지 않은 까닭: 두 줄 라벨(`subLabel`·설명 붙은 단추)이 이미 선 화면이 있고,
|
|
74
|
+
* 좁은 자리에서 nowrap 은 **넘쳐 잘린다**(줄바꿈은 키만 늘어난다). 어느 쪽이 나은지는 그 자리가 안다. */
|
|
75
|
+
nowrap?: boolean;
|
|
67
76
|
selected?: boolean;
|
|
68
77
|
toggleable?: boolean;
|
|
69
78
|
/**
|