@ihabdevteam/core 0.178.0 → 0.180.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 +47 -0
- package/dist/components.cjs.js +14 -14
- 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 +176 -176
- package/dist/ihabdevteam-core.d.ts +50 -3
- 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/Panel.css +19 -0
- package/dist/packages/core/src/components/PanelRow.d.ts +9 -0
- package/dist/packages/core/src/components/PanelRow.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/Section.css +4 -0
- package/dist/packages/core/src/components/Section.d.ts +12 -1
- package/dist/packages/core/src/components/Section.js +2 -2
- 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/Training/WordTablePopup.d.ts +5 -1
- package/dist/packages/core/src/components/Training/WordTablePopup.js +2 -2
- package/dist/packages/core/src/components/buttonTypes.d.ts +9 -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
|
/**
|
|
@@ -1585,7 +1594,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1585
1594
|
*
|
|
1586
1595
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1587
1596
|
*/
|
|
1588
|
-
export declare const CORE_VERSION = "0.
|
|
1597
|
+
export declare const CORE_VERSION = "0.180.0";
|
|
1589
1598
|
|
|
1590
1599
|
/** @public */
|
|
1591
1600
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -3779,6 +3788,15 @@ declare type PanelRowChildren = {
|
|
|
3779
3788
|
children?: ReactNode;
|
|
3780
3789
|
align?: 'horizontal' | 'vertical';
|
|
3781
3790
|
icon?: ReactNode;
|
|
3791
|
+
/** 라벨을 고정 폭으로 두고 **오른쪽이 남는 폭을 채운다** — 그 안의 입력칸도 함께 늘어난다.
|
|
3792
|
+
*
|
|
3793
|
+
* 기본은 오른쪽이 **제 폭만** 차지한다(`flex: 0 0 auto`). 그래서 `TextField`·`Dropdown`·`form-input` 을
|
|
3794
|
+
* children 으로 넣으면 줄마다 오른쪽 끝이 들쭉날쭉했고, 소비자가 `.panel-row__right { display: flex }` +
|
|
3795
|
+
* `> * { flex: 1 }` 을 손으로 붙여 왔다 — 같은 한 줄이 말귀 앱 넷 · 어드민 셋에 흩어져 있었다(2026-09-18 전수).
|
|
3796
|
+
*
|
|
3797
|
+
* `variant='input'`·`'dropdown'` 행은 늘 이 꼴이고, `'text'`·`'custom'` 은 같은 이름의 prop 으로 켠다 —
|
|
3798
|
+
* children 모드에만 통로가 없었다. 라벨 폭은 `Panel labelWidth` 로 정한다(기본 120px). */
|
|
3799
|
+
useFullWidth?: boolean;
|
|
3782
3800
|
};
|
|
3783
3801
|
|
|
3784
3802
|
declare type PanelRowCustom = Base & {
|
|
@@ -4527,6 +4545,16 @@ export declare interface PopupProps {
|
|
|
4527
4545
|
footer?: ReactNode | ((opts: PopupOptions) => ReactNode);
|
|
4528
4546
|
/** 커스텀 footer 래퍼에 추가할 클래스 (정렬·간격 override용) */
|
|
4529
4547
|
footerClassName?: string;
|
|
4548
|
+
/** 바닥 줄의 가로 배치 — `'center'`(기본) · `'fill'` · `'between'`.
|
|
4549
|
+
*
|
|
4550
|
+
* - `'center'`: 지금까지와 같다(가운데 모음).
|
|
4551
|
+
* - `'fill'`: 바닥의 것들이 폭을 **똑같이 나눈다** — 단추 둘을 반반 세우는 창.
|
|
4552
|
+
* - `'between'`: 양 끝으로 밀고(왼쪽 보조 · 오른쪽 주액션) 양옆에 `--spacing-xl` 을 둔다.
|
|
4553
|
+
* 끝으로 붙은 것이 창 테두리에 닿아 보이던 자리를 코어가 쥔다.
|
|
4554
|
+
*
|
|
4555
|
+
* `buttons` 배열을 쓸 때도 듣는다(그쪽은 `max-width: 200px` 제약이 따로 있어 `'fill'` 은 그 안에서 나눈다).
|
|
4556
|
+
* 세로는 이제 늘 가운데다(0.179.0) — 소비자 셋이 이 자리를 각자 칠하고 있었다. */
|
|
4557
|
+
footerLayout?: 'center' | 'fill' | 'between';
|
|
4530
4558
|
onClose?: () => void;
|
|
4531
4559
|
/** true면 X 버튼/ESC로 닫는 것을 막는다 — 이미 시작된 서버 요청(확인 버튼 클릭 등)이
|
|
4532
4560
|
* 진행 중일 때, "취소했다고 믿었는데 요청은 계속 진행돼 완료 후 결과가 반영되는" 상황을
|
|
@@ -5731,7 +5759,7 @@ export declare interface SearchSummaryProps {
|
|
|
5731
5759
|
export declare type SearchSummaryStatus = 'ready' | 'guide' | 'error';
|
|
5732
5760
|
|
|
5733
5761
|
/** @public */
|
|
5734
|
-
export declare function Section({ type, title, headingLevel, icon, action, disc, children, className, style }: SectionProps): JSX.Element;
|
|
5762
|
+
export declare function Section({ type, title, headingLevel, icon, action, disc, children, inGrid, className, style }: SectionProps): JSX.Element;
|
|
5735
5763
|
|
|
5736
5764
|
/**
|
|
5737
5765
|
* 섹션·페이지의 머리 — 머리말 · 제목 · 설명.
|
|
@@ -5779,6 +5807,17 @@ export declare interface SectionProps {
|
|
|
5779
5807
|
action?: ReactNode;
|
|
5780
5808
|
disc?: ReactNode;
|
|
5781
5809
|
children?: ReactNode;
|
|
5810
|
+
/**
|
|
5811
|
+
* CSS 격자(한 줄에 여러 칸) 안에 세울 때 켠다 — 아래 여백(`--spacing-page`)을 푼다.
|
|
5812
|
+
*
|
|
5813
|
+
* `Section` 은 **세로로 이어 놓는 것**을 전제로 아래 여백을 두고, 마지막 것만 `:last-child` 로 뺀다.
|
|
5814
|
+
* 격자에 넣으면 그 규칙이 **마지막 칸에만** 걸려 넷 중 셋만 여백을 달고 선다 — 카드 바닥선이 어긋나고
|
|
5815
|
+
* 줄 사이 간격도 달라 보인다(말귀 문장훈련 분석 2×2 실측 2026-09-18: 카드 506 vs 566px).
|
|
5816
|
+
* 격자에서는 칸 사이를 `gap` 이 맡으므로 이 여백은 할 일이 없다.
|
|
5817
|
+
*
|
|
5818
|
+
* `ChartSection inGrid` 와 같은 뜻이다(그쪽은 최소 폭·바닥 맞춤도 함께 푼다).
|
|
5819
|
+
*/
|
|
5820
|
+
inGrid?: boolean;
|
|
5782
5821
|
className?: string;
|
|
5783
5822
|
style?: CSSProperties;
|
|
5784
5823
|
}
|
|
@@ -7873,6 +7912,10 @@ export declare type TrainingPagePopupProps = {
|
|
|
7873
7912
|
}) => ReactNode);
|
|
7874
7913
|
/** 대화상자 이름(낭독기가 읽는 이름). 주면 title·label·시나리오 제목보다 이긴다 — 보이는 제목과 다른 이름이 필요할 때. 없으면 보이는 제목. */
|
|
7875
7914
|
ariaLabel?: string;
|
|
7915
|
+
/** 속 통의 폭·여백 제약을 푼다 — 기본은 `max-width: 800px` + `--spacing-page` 여백이다.
|
|
7916
|
+
* 안에 넣는 화면이 **제 폭과 여백을 그대로 쓰는** 쓰임에서 켠다(표 한 판을 꽉 채우는 창 등).
|
|
7917
|
+
* 소비자 둘이 `.page-popup__content { padding: 0; max-width: none }` 로 각자 풀고 있었다(2026-09-17). */
|
|
7918
|
+
contentFull?: boolean;
|
|
7876
7919
|
label?: string;
|
|
7877
7920
|
buildResults?: (answers: Record<string, unknown>, meta?: Record<string, unknown>) => unknown;
|
|
7878
7921
|
onResult?: (result?: unknown) => unknown;
|
|
@@ -9719,7 +9762,7 @@ export declare type WordTableOrientation = 'landscape' | 'portrait';
|
|
|
9719
9762
|
* 표 내부에는 제목을 두지 않아 팝업 헤더 제목과 중복되지 않는다.
|
|
9720
9763
|
* @public
|
|
9721
9764
|
*/
|
|
9722
|
-
export declare function WordTablePopup({ items, words, title, subtitle, emptyText, buttons, renderButtons, renderFooter, footerClassName, printFileName, showSettings, autoPrint, className, onClose, scoring, onScoreChange, printHeader, beforeClose }: WordTablePopupProps): JSX.Element;
|
|
9765
|
+
export declare function WordTablePopup({ items, words, title, subtitle, emptyText, buttons, renderButtons, renderFooter, footerClassName, footerLayout, printFileName, showSettings, autoPrint, className, onClose, scoring, onScoreChange, printHeader, beforeClose }: WordTablePopupProps): JSX.Element;
|
|
9723
9766
|
|
|
9724
9767
|
/** @public */
|
|
9725
9768
|
export declare interface WordTablePopupActions {
|
|
@@ -9751,6 +9794,10 @@ export declare interface WordTablePopupProps {
|
|
|
9751
9794
|
renderFooter?: (actions: WordTablePopupActions) => PopupProps['footer'];
|
|
9752
9795
|
/** renderFooter 사용 시 footer 래퍼에 추가할 클래스(정렬·간격 override용). */
|
|
9753
9796
|
footerClassName?: string;
|
|
9797
|
+
/** 바닥 줄의 가로 배치 — `Popup footerLayout` 을 그대로 내려보낸다(`'center'` 기본 · `'fill'` · `'between'`).
|
|
9798
|
+
* 이 감싸개만 `footerClassName` 을 소비자에게 열고 있어, 여기 없으면 **이 창만** 앱 CSS 로 정렬을 칠해야 했다
|
|
9799
|
+
* (말귀 단어표 미리보기 창, 2026-09-18). */
|
|
9800
|
+
footerLayout?: PopupProps['footerLayout'];
|
|
9754
9801
|
/** 지정하면 하단에 "인쇄하기"·"새 창으로 보기" 버튼을 추가하고, 이 값을 PDF 저장 기본 파일명으로 쓴다. */
|
|
9755
9802
|
printFileName?: string;
|
|
9756
9803
|
/** 표 위에 '표 설정'(구성·용지 방향·외곽선 등) FilterPanel을 노출한다. */
|
|
@@ -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 = () => {
|
|
@@ -236,6 +236,25 @@
|
|
|
236
236
|
flex: 1;
|
|
237
237
|
min-width: 0;
|
|
238
238
|
}
|
|
239
|
+
/* 오른쪽 칸이 남은 폭을 채우는 줄(useFullWidth) — **그 안의 것도 함께 늘어난다.**
|
|
240
|
+
오른쪽 칸만 늘리면 입력칸은 제 폭에 머물러 줄마다 끝이 들쭉날쭉했고, 소비자 일곱 자리가 이
|
|
241
|
+
`flex: 1` 한 줄을 각자 붙이고 있었다(말귀 앱 넷 · 어드민 셋, 2026-09-18 전수).
|
|
242
|
+
직계만 본다 — 안쪽 어디까지 늘릴지는 그 입력칸이 정한다(`TextField` 는 제 감싸개를 채운다). */
|
|
243
|
+
.panel-row--fill .panel-row__right {
|
|
244
|
+
display: flex;
|
|
245
|
+
align-items: center;
|
|
246
|
+
}
|
|
247
|
+
.panel-row--fill .panel-row__right > * {
|
|
248
|
+
flex: 1;
|
|
249
|
+
min-width: 0;
|
|
250
|
+
}
|
|
251
|
+
/* 입력칸을 감싸개 없이 줄에 바로 넣는 자리도 같이 본다(앱이 `.panel-row > .text-field` 를 덮던 꼴) */
|
|
252
|
+
.panel-row--fill > .text-field,
|
|
253
|
+
.panel-row--fill > .dropdown,
|
|
254
|
+
.panel-row--fill > .form-input {
|
|
255
|
+
flex: 1;
|
|
256
|
+
min-width: 0;
|
|
257
|
+
}
|
|
239
258
|
.panel-row--clickable {
|
|
240
259
|
cursor: pointer;
|
|
241
260
|
transition: background 0.1s, box-shadow 0.1s;
|
|
@@ -130,6 +130,15 @@ export type PanelRowChildren = {
|
|
|
130
130
|
children?: ReactNode;
|
|
131
131
|
align?: 'horizontal' | 'vertical';
|
|
132
132
|
icon?: ReactNode;
|
|
133
|
+
/** 라벨을 고정 폭으로 두고 **오른쪽이 남는 폭을 채운다** — 그 안의 입력칸도 함께 늘어난다.
|
|
134
|
+
*
|
|
135
|
+
* 기본은 오른쪽이 **제 폭만** 차지한다(`flex: 0 0 auto`). 그래서 `TextField`·`Dropdown`·`form-input` 을
|
|
136
|
+
* children 으로 넣으면 줄마다 오른쪽 끝이 들쭉날쭉했고, 소비자가 `.panel-row__right { display: flex }` +
|
|
137
|
+
* `> * { flex: 1 }` 을 손으로 붙여 왔다 — 같은 한 줄이 말귀 앱 넷 · 어드민 셋에 흩어져 있었다(2026-09-18 전수).
|
|
138
|
+
*
|
|
139
|
+
* `variant='input'`·`'dropdown'` 행은 늘 이 꼴이고, `'text'`·`'custom'` 은 같은 이름의 prop 으로 켠다 —
|
|
140
|
+
* children 모드에만 통로가 없었다. 라벨 폭은 `Panel labelWidth` 로 정한다(기본 120px). */
|
|
141
|
+
useFullWidth?: boolean;
|
|
133
142
|
};
|
|
134
143
|
export type PanelRowProps = PanelRowToggle | PanelRowSegmented | PanelRowDropdown | PanelRowButton | PanelRowText | PanelRowInput | PanelRowCustom | PanelRowDescription | PanelRowEmpty | PanelRowLoading | PanelRowChildren;
|
|
135
144
|
export declare function PanelRow(props: PanelRowProps): import("react").JSX.Element;
|
|
@@ -27,7 +27,7 @@ export function PanelRow(props) {
|
|
|
27
27
|
const uid = useId();
|
|
28
28
|
// children 기반 하위 호환 모드
|
|
29
29
|
if (!('variant' in props)) {
|
|
30
|
-
const { label, sub, onClick, children, align = 'horizontal', icon } = props;
|
|
30
|
+
const { label, sub, onClick, children, align = 'horizontal', icon, useFullWidth = false } = props;
|
|
31
31
|
const handleKeyDown = (e) => {
|
|
32
32
|
if (!onClick)
|
|
33
33
|
return;
|
|
@@ -40,7 +40,7 @@ export function PanelRow(props) {
|
|
|
40
40
|
onClick();
|
|
41
41
|
}
|
|
42
42
|
};
|
|
43
|
-
return (_jsxs("div", { className: cx('panel-row', align === 'vertical' ? 'panel-row--vertical' : '', onClick ? 'panel-row--clickable' : ''), onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, children: [icon != null && _jsx("div", { className: "panel-row__icon", children: icon }), label != null && (_jsxs("div", { className: "panel-row__left", children: [_jsx("div", { className: "panel-row__label body-3", children: label }), sub && _jsx("div", { className: "panel-row__sub caption-1", children: sub })] })), _jsx("div", { className: "panel-row__right", children: children })] }));
|
|
43
|
+
return (_jsxs("div", { className: cx('panel-row', align === 'vertical' ? 'panel-row--vertical' : '', useFullWidth ? 'panel-row--label-fixed panel-row--fill' : '', onClick ? 'panel-row--clickable' : ''), onClick: onClick, onKeyDown: onClick ? handleKeyDown : undefined, role: onClick ? 'button' : undefined, tabIndex: onClick ? 0 : undefined, children: [icon != null && _jsx("div", { className: "panel-row__icon", children: icon }), label != null && (_jsxs("div", { className: "panel-row__left", children: [_jsx("div", { className: "panel-row__label body-3", children: label }), sub && _jsx("div", { className: "panel-row__sub caption-1", children: sub })] })), _jsx("div", { className: "panel-row__right", children: children })] }));
|
|
44
44
|
}
|
|
45
45
|
// 설명 전용 행 — 라벨/우측 구조 없이 문단만 렌더
|
|
46
46
|
if (props.variant === 'description') {
|
|
@@ -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);
|
|
@@ -15,8 +15,19 @@ export interface SectionProps {
|
|
|
15
15
|
action?: ReactNode;
|
|
16
16
|
disc?: ReactNode;
|
|
17
17
|
children?: ReactNode;
|
|
18
|
+
/**
|
|
19
|
+
* CSS 격자(한 줄에 여러 칸) 안에 세울 때 켠다 — 아래 여백(`--spacing-page`)을 푼다.
|
|
20
|
+
*
|
|
21
|
+
* `Section` 은 **세로로 이어 놓는 것**을 전제로 아래 여백을 두고, 마지막 것만 `:last-child` 로 뺀다.
|
|
22
|
+
* 격자에 넣으면 그 규칙이 **마지막 칸에만** 걸려 넷 중 셋만 여백을 달고 선다 — 카드 바닥선이 어긋나고
|
|
23
|
+
* 줄 사이 간격도 달라 보인다(말귀 문장훈련 분석 2×2 실측 2026-09-18: 카드 506 vs 566px).
|
|
24
|
+
* 격자에서는 칸 사이를 `gap` 이 맡으므로 이 여백은 할 일이 없다.
|
|
25
|
+
*
|
|
26
|
+
* `ChartSection inGrid` 와 같은 뜻이다(그쪽은 최소 폭·바닥 맞춤도 함께 푼다).
|
|
27
|
+
*/
|
|
28
|
+
inGrid?: boolean;
|
|
18
29
|
className?: string;
|
|
19
30
|
style?: CSSProperties;
|
|
20
31
|
}
|
|
21
32
|
/** @public */
|
|
22
|
-
export default function Section({ type, title, headingLevel, icon, action, disc, children, className, style }: SectionProps): import("react").JSX.Element;
|
|
33
|
+
export default function Section({ type, title, headingLevel, icon, action, disc, children, inGrid, className, style }: SectionProps): import("react").JSX.Element;
|
|
@@ -8,7 +8,7 @@ const TITLE_CLASS = {
|
|
|
8
8
|
popup: 'h5',
|
|
9
9
|
};
|
|
10
10
|
/** @public */
|
|
11
|
-
export default function Section({ type = 'page', title, headingLevel, icon, action, disc, children, className, style }) {
|
|
11
|
+
export default function Section({ type = 'page', title, headingLevel, icon, action, disc, children, inGrid = false, className, style }) {
|
|
12
12
|
const titleClass = TITLE_CLASS[type];
|
|
13
13
|
// action·icon 만 줘도 머리를 그린다 — 제목·설명이 없으면 추가 단추가 조용히 버려졌다
|
|
14
14
|
const hasHeader = title || disc || action || icon;
|
|
@@ -16,5 +16,5 @@ export default function Section({ type = 'page', title, headingLevel, icon, acti
|
|
|
16
16
|
부르는 쪽이 정한다 — 안 주면 예전 그대로 div. 모양은 클래스(h3/h4/h5)가 정하고
|
|
17
17
|
제목 태그의 기본 여백은 .section__title 의 margin:0 이 지운다. */
|
|
18
18
|
const TitleTag = headingLevel ? `h${headingLevel}` : 'div';
|
|
19
|
-
return (_jsxs("div", { className: cx('section', `section--${type}`, className), style: style, children: [hasHeader && (_jsxs("div", { className: "section__header", children: [_jsxs("div", { className: "section__title-row", children: [icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "section__icon" })), _jsxs("div", { className: "section__title-group", children: [title && _jsx(TitleTag, { className: `section__title ${titleClass}`, children: title }), disc && _jsx("div", { className: "section__disc body-3", children: disc })] })] }), action && _jsx("div", { className: "section__action", children: action })] })), children] }));
|
|
19
|
+
return (_jsxs("div", { className: cx('section', `section--${type}`, inGrid && 'section--in-grid', className), style: style, children: [hasHeader && (_jsxs("div", { className: "section__header", children: [_jsxs("div", { className: "section__title-row", children: [icon && (_jsx(Icon, { icon: icon, label: typeof title === 'string' ? title : undefined, className: "section__icon" })), _jsxs("div", { className: "section__title-group", children: [title && _jsx(TitleTag, { className: `section__title ${titleClass}`, children: title }), disc && _jsx("div", { className: "section__disc body-3", children: disc })] })] }), action && _jsx("div", { className: "section__action", children: action })] })), children] }));
|
|
20
20
|
}
|
|
@@ -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);
|
|
@@ -32,6 +32,10 @@ export interface WordTablePopupProps {
|
|
|
32
32
|
renderFooter?: (actions: WordTablePopupActions) => PopupProps['footer'];
|
|
33
33
|
/** renderFooter 사용 시 footer 래퍼에 추가할 클래스(정렬·간격 override용). */
|
|
34
34
|
footerClassName?: string;
|
|
35
|
+
/** 바닥 줄의 가로 배치 — `Popup footerLayout` 을 그대로 내려보낸다(`'center'` 기본 · `'fill'` · `'between'`).
|
|
36
|
+
* 이 감싸개만 `footerClassName` 을 소비자에게 열고 있어, 여기 없으면 **이 창만** 앱 CSS 로 정렬을 칠해야 했다
|
|
37
|
+
* (말귀 단어표 미리보기 창, 2026-09-18). */
|
|
38
|
+
footerLayout?: PopupProps['footerLayout'];
|
|
35
39
|
/** 지정하면 하단에 "인쇄하기"·"새 창으로 보기" 버튼을 추가하고, 이 값을 PDF 저장 기본 파일명으로 쓴다. */
|
|
36
40
|
printFileName?: string;
|
|
37
41
|
/** 표 위에 '표 설정'(구성·용지 방향·외곽선 등) FilterPanel을 노출한다. */
|
|
@@ -58,4 +62,4 @@ export interface WordTablePopupProps {
|
|
|
58
62
|
* 표 내부에는 제목을 두지 않아 팝업 헤더 제목과 중복되지 않는다.
|
|
59
63
|
* @public
|
|
60
64
|
*/
|
|
61
|
-
export default function WordTablePopup({ items, words, title, subtitle, emptyText, buttons, renderButtons, renderFooter, footerClassName, printFileName, showSettings, autoPrint, className, onClose, scoring, onScoreChange, printHeader, beforeClose }: WordTablePopupProps): import("react").JSX.Element;
|
|
65
|
+
export default function WordTablePopup({ items, words, title, subtitle, emptyText, buttons, renderButtons, renderFooter, footerClassName, footerLayout, printFileName, showSettings, autoPrint, className, onClose, scoring, onScoreChange, printHeader, beforeClose }: WordTablePopupProps): import("react").JSX.Element;
|
|
@@ -19,7 +19,7 @@ import { useToastState } from '../../hooks/useToastState';
|
|
|
19
19
|
* 표 내부에는 제목을 두지 않아 팝업 헤더 제목과 중복되지 않는다.
|
|
20
20
|
* @public
|
|
21
21
|
*/
|
|
22
|
-
export default function WordTablePopup({ items, words, title, subtitle, emptyText, buttons, renderButtons, renderFooter, footerClassName, printFileName, showSettings, autoPrint, className, onClose, scoring = false, onScoreChange, printHeader, beforeClose }) {
|
|
22
|
+
export default function WordTablePopup({ items, words, title, subtitle, emptyText, buttons, renderButtons, renderFooter, footerClassName, footerLayout, printFileName, showSettings, autoPrint, className, onClose, scoring = false, onScoreChange, printHeader, beforeClose }) {
|
|
23
23
|
const { t } = useTranslation();
|
|
24
24
|
// items 우선, 없으면 문자열 words → 한 단어=한 칸으로 매핑
|
|
25
25
|
const tableItems = useMemo(() => {
|
|
@@ -115,5 +115,5 @@ export default function WordTablePopup({ items, words, title, subtitle, emptyTex
|
|
|
115
115
|
}, 100);
|
|
116
116
|
return () => window.clearTimeout(id);
|
|
117
117
|
}, [autoPrint]);
|
|
118
|
-
return (_jsxs(Popup, { title: title ?? t('keyWords.title', '주요 단어'), subtitle: subtitle, size: "large", className: cx('word-table-popup', className), buttons: footerButtons, footer: customFooter, footerClassName: footerClassName, beforeClose: beforeClose, onClose: onClose, children: [tableItems.length > 0 ? (_jsxs("div", { className: "word-table-popup__body", children: [showSettings && (_jsx(TableFilterPanel, { sections: filterSections, selected: filterSelected, onSelect: handleSelect })), isPaginated ? (_jsx("div", { className: "word-table-popup__pages", children: pages.map((page, i) => (_jsx(WordTable, { items: page, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition, scoring: scoring, scoreIndexOffset: pageStart[i], marks: 표에줄채점, onMarkClick: handleMarkClick }, i))) })) : (_jsx("div", { className: "word-table-popup__table", children: _jsx(WordTable, { items: tableItems, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: contentRef, fitHeight: true, scoring: scoring, marks: 표에줄채점, onMarkClick: handleMarkClick }) }))] })) : (_jsx("p", { className: "word-table-popup__empty body-3", children: emptyText ?? t('keyWords.empty', '추출된 주요 단어가 없습니다.') })), _jsx(Toast, { message: toastMsg, position: "top", onClose: handleToastClose }, toastKey)] }));
|
|
118
|
+
return (_jsxs(Popup, { title: title ?? t('keyWords.title', '주요 단어'), subtitle: subtitle, size: "large", className: cx('word-table-popup', className), buttons: footerButtons, footer: customFooter, footerClassName: footerClassName, footerLayout: footerLayout, beforeClose: beforeClose, onClose: onClose, children: [tableItems.length > 0 ? (_jsxs("div", { className: "word-table-popup__body", children: [showSettings && (_jsx(TableFilterPanel, { sections: filterSections, selected: filterSelected, onSelect: handleSelect })), isPaginated ? (_jsx("div", { className: "word-table-popup__pages", children: pages.map((page, i) => (_jsx(WordTable, { items: page, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: el => { pageRefs.current[i] = el; }, pageNumber: { current: i + 1, total: pages.length }, pageNumberPosition: pageNumberPosition, scoring: scoring, scoreIndexOffset: pageStart[i], marks: 표에줄채점, onMarkClick: handleMarkClick }, i))) })) : (_jsx("div", { className: "word-table-popup__table", children: _jsx(WordTable, { items: tableItems, orientation: settings.orientation, contentTypes: settings.contentTypes, cutLine: settings.cutLine, showOutline: settings.showOutline, showDivLine: settings.showDivLine, count: settings.count, contentRef: contentRef, fitHeight: true, scoring: scoring, marks: 표에줄채점, onMarkClick: handleMarkClick }) }))] })) : (_jsx("p", { className: "word-table-popup__empty body-3", children: emptyText ?? t('keyWords.empty', '추출된 주요 단어가 없습니다.') })), _jsx(Toast, { message: toastMsg, position: "top", onClose: handleToastClose }, toastKey)] }));
|
|
119
119
|
}
|
|
@@ -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
|
/**
|