@ihabdevteam/core 0.189.0 → 0.191.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 +55 -0
- package/dist/components.cjs.js +171 -171
- package/dist/components.esm.css +1 -1
- package/dist/hooks.cjs.js +1 -1
- package/dist/i18n.cjs.js +1 -1
- package/dist/ihabdevteam-core.cjs.js +179 -179
- package/dist/ihabdevteam-core.d.ts +50 -2
- package/dist/packages/core/src/components/LineChart.d.ts +8 -1
- package/dist/packages/core/src/components/PanelRow.d.ts +4 -0
- package/dist/packages/core/src/components/PanelRow.js +1 -1
- package/dist/packages/core/src/components/Popup/Popup.css +38 -0
- package/dist/packages/core/src/components/Popup/Popup.d.ts +16 -0
- package/dist/packages/core/src/components/Popup/Popup.js +5 -2
- package/dist/packages/core/src/components/TextField.d.ts +21 -0
- package/dist/packages/core/src/components/TextField.js +24 -4
- 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
|
@@ -1617,7 +1617,7 @@ export declare function copyText(text: string): Promise<boolean>;
|
|
|
1617
1617
|
*
|
|
1618
1618
|
* 값은 손으로 적고 `test/coreVersion.test.ts` 가 package.json 과 어긋나면 운다.
|
|
1619
1619
|
*/
|
|
1620
|
-
export declare const CORE_VERSION = "0.
|
|
1620
|
+
export declare const CORE_VERSION = "0.191.0";
|
|
1621
1621
|
|
|
1622
1622
|
/** @public */
|
|
1623
1623
|
declare const coreDefault: Record<string, unknown>;
|
|
@@ -3217,7 +3217,14 @@ export declare interface LineChartProps {
|
|
|
3217
3217
|
x: number;
|
|
3218
3218
|
label: string;
|
|
3219
3219
|
}[];
|
|
3220
|
-
/**
|
|
3220
|
+
/**
|
|
3221
|
+
* 세로축 이름 — **안 주면 아무것도 안 그린다**(코어는 부르는 쪽의 사전을 모른다).
|
|
3222
|
+
*
|
|
3223
|
+
* **재는 것이 정답률이 아니면 반드시 준다.** 이름이 없으면 보는 사람이 정답률로 읽는다
|
|
3224
|
+
* (말귀 실측: 순서화는 '전부 맞힘' 비율인데 정답률로 읽혀 "문장별로는 정답률이 낮은데 왜
|
|
3225
|
+
* 전체 정답률은 높아?" 라는 물음이 나왔다 — 2026-09-17). 늘 같은 말이 붙는 화면이라면
|
|
3226
|
+
* 감싸개에서 기본값을 들어 주는 편이 낫다.
|
|
3227
|
+
*/
|
|
3221
3228
|
axisTitle?: ReactNode;
|
|
3222
3229
|
/** 선·점 색. 안 주면 주 색 */
|
|
3223
3230
|
color?: string;
|
|
@@ -3999,6 +4006,10 @@ declare type PanelRowInput = Base & {
|
|
|
3999
4006
|
/** Enter로 확정(제출 등). TextField가 IME 조합 중 Enter는 걸러 주므로 한글 입력에서도 안전하다. */
|
|
4000
4007
|
onEnter?: () => void;
|
|
4001
4008
|
placeholder?: string;
|
|
4009
|
+
/** 비추기만 하는 추천값 — Tab(앞으로)·Space 로 받는다. 자세한 것은 `TextField` 의 `ghostText`. */
|
|
4010
|
+
ghostText?: string;
|
|
4011
|
+
/** 추천값 뒤 꼬리말 — 안 주면 코어 사전의 것 */
|
|
4012
|
+
ghostHint?: string;
|
|
4002
4013
|
inputMode?: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
|
|
4003
4014
|
readOnly?: boolean;
|
|
4004
4015
|
disabled?: boolean;
|
|
@@ -4704,6 +4715,22 @@ export declare interface PopupProps {
|
|
|
4704
4715
|
};
|
|
4705
4716
|
/** 커스텀 footer 래퍼에 추가할 클래스 (정렬·간격 override용) */
|
|
4706
4717
|
footerClassName?: string;
|
|
4718
|
+
/**
|
|
4719
|
+
* 바닥 줄 **왼쪽 덩이** — 보조 단추(아이콘 단추 여럿)가 서는 자리.
|
|
4720
|
+
*
|
|
4721
|
+
* `footerLayout: 'between'` 은 **직계 자식만** 양끝으로 민다. 그래서 "왼쪽 여럿 · 오른쪽 여럿" 을
|
|
4722
|
+
* 만들려면 부르는 쪽이 덩이 둘을 손수 지어야 했고, 그 규칙이 창마다 한 벌씩 생겼다(말귀 다섯 쌍,
|
|
4723
|
+
* 쌍마다 한 글자도 안 달랐다 — 2026-09-24). 바닥 줄의 자리 잡기는 **창의 일**이라 여기로 들인다.
|
|
4724
|
+
*
|
|
4725
|
+
* 덩이 안쪽 간격은 코어가 정한다 — 왼쪽은 촘촘히(`--spacing-xs`), 오른쪽은 한 눈금 넓게(`--spacing-s`),
|
|
4726
|
+
* 두 덩이 사이는 그보다 넓게(`--spacing-l`). 그래야 좁아져 줄이 바뀌어도 **두 덩이가 섞여 보이지 않는다.**
|
|
4727
|
+
* 좁으면 덩이째 줄을 바꾸고 오른쪽 덩이는 그 줄에서도 오른쪽 끝에 선다.
|
|
4728
|
+
*
|
|
4729
|
+
* 둘 중 하나만 줘도 된다. 주면 `footer`·`buttons` 보다 이쪽이 그려진다.
|
|
4730
|
+
*/
|
|
4731
|
+
footerStart?: ReactNode | ((opts: PopupOptions) => ReactNode);
|
|
4732
|
+
/** 바닥 줄 **오른쪽 덩이** — 주 단추가 서는 자리. 자세한 것은 `footerStart`. */
|
|
4733
|
+
footerEnd?: ReactNode | ((opts: PopupOptions) => ReactNode);
|
|
4707
4734
|
/** 바닥 줄의 가로 배치 — `'center'`(기본) · `'fill'` · `'between'`.
|
|
4708
4735
|
*
|
|
4709
4736
|
* - `'center'`: 지금까지와 같다(가운데 모음).
|
|
@@ -7156,6 +7183,27 @@ export declare interface TextFieldProps {
|
|
|
7156
7183
|
required?: boolean;
|
|
7157
7184
|
/** placeholder 텍스트 */
|
|
7158
7185
|
placeholder?: string;
|
|
7186
|
+
/**
|
|
7187
|
+
* **비추기만 하는 추천값**(ghost-text) — 규칙으로 유도한 값을 자리표시로 비추고, 칸이 비어 있을 때
|
|
7188
|
+
* **Tab(앞으로) · Space** 로 받아 적는다.
|
|
7189
|
+
*
|
|
7190
|
+
* 자동으로 채우지 않는다 — 규칙 변환은 완벽하지 않아 **사람이 보고 확정한다.** 그래서 값이 아니라
|
|
7191
|
+
* 자리표시로만 있다가, 사람이 "받겠다" 는 키를 눌러야 값이 된다.
|
|
7192
|
+
*
|
|
7193
|
+
* 받는 때는 셋이 다 맞을 때뿐이다:
|
|
7194
|
+
* - **Tab(앞으로) 이거나 Space.** `Shift+Tab` 은 안 받는다 — 뒤 칸으로 가려던 사람이 값을 얻는다.
|
|
7195
|
+
* - **칸이 비어 있다**(공백만 친 것도 빈 것으로 본다). 손으로 적은 것을 덮지 않는다.
|
|
7196
|
+
* - 비출 추천이 있다.
|
|
7197
|
+
*
|
|
7198
|
+
* 받은 키는 **이 칸이 먹는다**(`preventDefault`) — 그래서 그 Tab 으로 초점이 옮겨 가지 않고,
|
|
7199
|
+
* 그 눌림은 `onKeyDown` 으로도 안 간다. 한 번 더 누르면 칸이 찼으므로 평소대로 넘어간다.
|
|
7200
|
+
*
|
|
7201
|
+
* 앱마다 이 세 줄이 손으로 적혀 있었다(말귀 두 자리 — 훈련 이름·표준 발음, 2026-09-25).
|
|
7202
|
+
* 무엇을 비추고 어떻게 받는지는 **칸의 행동**이지 그 칸을 놓는 화면의 일이 아니다.
|
|
7203
|
+
*/
|
|
7204
|
+
ghostText?: string;
|
|
7205
|
+
/** 추천값 뒤에 붙는 꼬리말 — 안 주면 코어 사전의 " (Tab 또는 space 입력시 자동 입력)" */
|
|
7206
|
+
ghostHint?: string;
|
|
7159
7207
|
/** 입력 타입 */
|
|
7160
7208
|
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
|
|
7161
7209
|
/** 비활성 상태 */
|
|
@@ -40,7 +40,14 @@ export interface LineChartProps {
|
|
|
40
40
|
x: number;
|
|
41
41
|
label: string;
|
|
42
42
|
}[];
|
|
43
|
-
/**
|
|
43
|
+
/**
|
|
44
|
+
* 세로축 이름 — **안 주면 아무것도 안 그린다**(코어는 부르는 쪽의 사전을 모른다).
|
|
45
|
+
*
|
|
46
|
+
* **재는 것이 정답률이 아니면 반드시 준다.** 이름이 없으면 보는 사람이 정답률로 읽는다
|
|
47
|
+
* (말귀 실측: 순서화는 '전부 맞힘' 비율인데 정답률로 읽혀 "문장별로는 정답률이 낮은데 왜
|
|
48
|
+
* 전체 정답률은 높아?" 라는 물음이 나왔다 — 2026-09-17). 늘 같은 말이 붙는 화면이라면
|
|
49
|
+
* 감싸개에서 기본값을 들어 주는 편이 낫다.
|
|
50
|
+
*/
|
|
44
51
|
axisTitle?: ReactNode;
|
|
45
52
|
/** 선·점 색. 안 주면 주 색 */
|
|
46
53
|
color?: string;
|
|
@@ -90,6 +90,10 @@ export type PanelRowInput = Base & {
|
|
|
90
90
|
/** Enter로 확정(제출 등). TextField가 IME 조합 중 Enter는 걸러 주므로 한글 입력에서도 안전하다. */
|
|
91
91
|
onEnter?: () => void;
|
|
92
92
|
placeholder?: string;
|
|
93
|
+
/** 비추기만 하는 추천값 — Tab(앞으로)·Space 로 받는다. 자세한 것은 `TextField` 의 `ghostText`. */
|
|
94
|
+
ghostText?: string;
|
|
95
|
+
/** 추천값 뒤 꼬리말 — 안 주면 코어 사전의 것 */
|
|
96
|
+
ghostHint?: string;
|
|
93
97
|
inputMode?: 'none' | 'text' | 'decimal' | 'numeric' | 'tel' | 'search' | 'email' | 'url';
|
|
94
98
|
readOnly?: boolean;
|
|
95
99
|
disabled?: boolean;
|
|
@@ -111,7 +111,7 @@ export function PanelRow(props) {
|
|
|
111
111
|
}
|
|
112
112
|
else if (variant === 'input') {
|
|
113
113
|
const p = props;
|
|
114
|
-
right = (_jsxs("div", { className: `panel-row__input-wrap${useFullWidth ? ' panel-row__input-wrap--full' : ''}`, children: [_jsx(TextField, { value: p.value, onChange: p.onChange ?? noop, onKeyDown: p.onKeyDown, onEnter: p.onEnter, placeholder: p.placeholder, inputMode: p.inputMode, readOnly: p.readOnly, disabled: p.disabled, type: p.type, autoComplete: p.autoComplete, maxLength: p.maxLength, showCount: p.showCount, multiline: p.multiline, rows: p.rows, fullWidth: useFullWidth, error: error, ariaLabel: label, required: required }), p.helperText && _jsx("div", { className: "panel-row__helper caption-1", children: p.helperText })] }));
|
|
114
|
+
right = (_jsxs("div", { className: `panel-row__input-wrap${useFullWidth ? ' panel-row__input-wrap--full' : ''}`, children: [_jsx(TextField, { value: p.value, onChange: p.onChange ?? noop, onKeyDown: p.onKeyDown, onEnter: p.onEnter, placeholder: p.placeholder, ghostText: p.ghostText, ghostHint: p.ghostHint, inputMode: p.inputMode, readOnly: p.readOnly, disabled: p.disabled, type: p.type, autoComplete: p.autoComplete, maxLength: p.maxLength, showCount: p.showCount, multiline: p.multiline, rows: p.rows, fullWidth: useFullWidth, error: error, ariaLabel: label, required: required }), p.helperText && _jsx("div", { className: "panel-row__helper caption-1", children: p.helperText })] }));
|
|
115
115
|
}
|
|
116
116
|
else if (variant === 'custom') {
|
|
117
117
|
const p = props;
|
|
@@ -110,6 +110,44 @@
|
|
|
110
110
|
`flex: 1` 의 기본 바닥(min-width: auto)은 글자 길이만큼 버틴다 — 0 으로 내려 줄이 바뀌기 전에
|
|
111
111
|
먼저 줄어들게 하고, 그래도 모자라면 위의 `flex-wrap` 이 아랫줄로 내린다. */
|
|
112
112
|
.popup-footer--buttons > * { max-width: 200px; flex: 1 1 auto; min-width: 0; }
|
|
113
|
+
|
|
114
|
+
/* **바닥 줄의 두 덩이**(`footerStart` · `footerEnd`) — 왼쪽 보조, 오른쪽 주 단추.
|
|
115
|
+
창마다 한 벌씩 베껴 적히던 규칙이다(말귀 다섯 쌍, 쌍마다 한 글자도 안 달랐다 — 2026-09-24).
|
|
116
|
+
|
|
117
|
+
간격은 **안쪽보다 사이가 넓다**(xs·s < l). 좁아져 줄이 바뀌어도 두 덩이가 한 덩이로 섞여
|
|
118
|
+
보이지 않게 하는 것이 이 차이의 일이다. 줄은 바꾸되(넘쳐 잘리는 것보다 낫다 — 0.185.0 에서
|
|
119
|
+
바닥이 잘려 못 누르던 자리를 겪었다) 오른쪽 덩이는 새 줄에서도 오른쪽 끝에 선다. */
|
|
120
|
+
.popup-footer--bar {
|
|
121
|
+
justify-content: space-between;
|
|
122
|
+
gap: var(--spacing-l);
|
|
123
|
+
flex-wrap: wrap;
|
|
124
|
+
}
|
|
125
|
+
.popup-footer__start {
|
|
126
|
+
display: flex;
|
|
127
|
+
align-items: center;
|
|
128
|
+
gap: var(--spacing-xs);
|
|
129
|
+
flex-wrap: wrap;
|
|
130
|
+
/* 글자가 길어도 줄이 바뀌기 전에 먼저 줄어들게 — 기본값(auto)은 글자 길이만큼 버틴다 */
|
|
131
|
+
min-width: 0;
|
|
132
|
+
}
|
|
133
|
+
.popup-footer__end {
|
|
134
|
+
display: flex;
|
|
135
|
+
align-items: center;
|
|
136
|
+
gap: var(--spacing-s);
|
|
137
|
+
flex-wrap: wrap;
|
|
138
|
+
/* 왼쪽이 비었거나 줄이 바뀌어도 오른쪽 끝에 선다 */
|
|
139
|
+
margin-left: auto;
|
|
140
|
+
justify-content: flex-end;
|
|
141
|
+
min-width: 0;
|
|
142
|
+
}
|
|
143
|
+
/* **덩이째 줄을 바꾸고도 넘치면 안쪽에서 한 번 더 바꾼다.** 단추는 제 최소 폭(120px)으로 버티므로
|
|
144
|
+
덩이에 `flex-shrink: 0` 을 두면 그대로 창 밖으로 나가 잘린다 — 375px 에서 오른쪽 단추 둘이
|
|
145
|
+
25px 넘쳐 잘렸다(실측 2026-09-24). 0.185.0 에서 바닥이 잘려 못 누르던 그 꼴이다.
|
|
146
|
+
최소 폭을 0 으로 내려, 줄이 바뀌든 줄어들든 **잘리지는 않게** 한다. */
|
|
147
|
+
.popup-footer__start > *,
|
|
148
|
+
.popup-footer__end > * {
|
|
149
|
+
min-width: 0;
|
|
150
|
+
}
|
|
113
151
|
.popup--closing { opacity: 0; transition: opacity 260ms ease }
|
|
114
152
|
|
|
115
153
|
@media (max-width: 767px) {
|
|
@@ -52,6 +52,22 @@ export interface PopupProps {
|
|
|
52
52
|
};
|
|
53
53
|
/** 커스텀 footer 래퍼에 추가할 클래스 (정렬·간격 override용) */
|
|
54
54
|
footerClassName?: string;
|
|
55
|
+
/**
|
|
56
|
+
* 바닥 줄 **왼쪽 덩이** — 보조 단추(아이콘 단추 여럿)가 서는 자리.
|
|
57
|
+
*
|
|
58
|
+
* `footerLayout: 'between'` 은 **직계 자식만** 양끝으로 민다. 그래서 "왼쪽 여럿 · 오른쪽 여럿" 을
|
|
59
|
+
* 만들려면 부르는 쪽이 덩이 둘을 손수 지어야 했고, 그 규칙이 창마다 한 벌씩 생겼다(말귀 다섯 쌍,
|
|
60
|
+
* 쌍마다 한 글자도 안 달랐다 — 2026-09-24). 바닥 줄의 자리 잡기는 **창의 일**이라 여기로 들인다.
|
|
61
|
+
*
|
|
62
|
+
* 덩이 안쪽 간격은 코어가 정한다 — 왼쪽은 촘촘히(`--spacing-xs`), 오른쪽은 한 눈금 넓게(`--spacing-s`),
|
|
63
|
+
* 두 덩이 사이는 그보다 넓게(`--spacing-l`). 그래야 좁아져 줄이 바뀌어도 **두 덩이가 섞여 보이지 않는다.**
|
|
64
|
+
* 좁으면 덩이째 줄을 바꾸고 오른쪽 덩이는 그 줄에서도 오른쪽 끝에 선다.
|
|
65
|
+
*
|
|
66
|
+
* 둘 중 하나만 줘도 된다. 주면 `footer`·`buttons` 보다 이쪽이 그려진다.
|
|
67
|
+
*/
|
|
68
|
+
footerStart?: ReactNode | ((opts: PopupOptions) => ReactNode);
|
|
69
|
+
/** 바닥 줄 **오른쪽 덩이** — 주 단추가 서는 자리. 자세한 것은 `footerStart`. */
|
|
70
|
+
footerEnd?: ReactNode | ((opts: PopupOptions) => ReactNode);
|
|
55
71
|
/** 바닥 줄의 가로 배치 — `'center'`(기본) · `'fill'` · `'between'`.
|
|
56
72
|
*
|
|
57
73
|
* - `'center'`: 지금까지와 같다(가운데 모음).
|
|
@@ -16,7 +16,7 @@ const ANIM_MS = 260;
|
|
|
16
16
|
/** @public */
|
|
17
17
|
export default function Popup(props) {
|
|
18
18
|
const { t } = useTranslation();
|
|
19
|
-
const { title, subtitle, headerActions, headerSegment, 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
|
+
const { title, subtitle, headerActions, headerSegment, children, buttons, footer, footerStart, footerEnd, footerClassName = "", footerLayout = 'center', onClose, closeDisabled = false, beforeClose, closeOnBackdrop = true, className = "", ariaLabel, useTitle = true, inline = false, size, layer = 'modal', height, fillBody = false, resetScrollKey } = props || {};
|
|
20
20
|
const [closing, setClosing] = useState(false);
|
|
21
21
|
// 닫힘 애니메이션 중(260ms)엔 버튼이 시각적으로만 흐려질 뿐 클릭은 계속 가능해,
|
|
22
22
|
// 그 사이 같은 버튼을 연타하면 cb(확인/완료 콜백)가 여러 번 예약되어 중복 실행된다.
|
|
@@ -212,7 +212,10 @@ export default function Popup(props) {
|
|
|
212
212
|
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' : ''}`,
|
|
213
213
|
/* 높이는 **CSS 변수로** 넘긴다 — 인라인 height 로 박으면 앱의 미디어 규칙(좁은 화면만 다른 높이)이 못 이겼다.
|
|
214
214
|
max-height(CSS)가 height 보다 이기므로 화면보다 크게 줘도 화면에 맞춰진다 */
|
|
215
|
-
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 })] }), headerSegment && (_jsx("div", { className: "popup-header-segment", children: _jsx(SegmentedControl, { size: 40, ariaLabel: headerSegment.ariaLabel, options: headerSegment.options.map(o => ({ key: o.key, label: o.label, disabled: o.disabled })), value: headerSegment.value, onChange: headerSegment.onChange }) })), _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 }) }),
|
|
215
|
+
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 })] }), headerSegment && (_jsx("div", { className: "popup-header-segment", children: _jsx(SegmentedControl, { size: 40, ariaLabel: headerSegment.ariaLabel, options: headerSegment.options.map(o => ({ key: o.key, label: o.label, disabled: o.disabled })), value: headerSegment.value, onChange: headerSegment.onChange }) })), _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 }) }), (footerStart || footerEnd) ? (
|
|
216
|
+
/* 왼쪽·오른쪽 덩이 — 빈 덩이도 그린다(자리를 지켜야 오른쪽이 끝에 선다).
|
|
217
|
+
`footerLayout` 은 보지 않는다: 이 줄 자체가 배치다. */
|
|
218
|
+
_jsxs("div", { className: `popup-footer popup-footer--bar${footerClassName ? ` ${footerClassName}` : ''}`, children: [_jsx("div", { className: "popup-footer__start", children: typeof footerStart === 'function' ? footerStart({ animateThen, closeWithAnimation }) : footerStart }), _jsx("div", { className: "popup-footer__end", children: typeof footerEnd === 'function' ? footerEnd({ animateThen, closeWithAnimation }) : footerEnd })] })) : 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] }) }));
|
|
216
219
|
if (inline || typeof document === 'undefined')
|
|
217
220
|
return el;
|
|
218
221
|
return createPortal(el, document.body);
|
|
@@ -20,6 +20,27 @@ export interface TextFieldProps {
|
|
|
20
20
|
required?: boolean;
|
|
21
21
|
/** placeholder 텍스트 */
|
|
22
22
|
placeholder?: string;
|
|
23
|
+
/**
|
|
24
|
+
* **비추기만 하는 추천값**(ghost-text) — 규칙으로 유도한 값을 자리표시로 비추고, 칸이 비어 있을 때
|
|
25
|
+
* **Tab(앞으로) · Space** 로 받아 적는다.
|
|
26
|
+
*
|
|
27
|
+
* 자동으로 채우지 않는다 — 규칙 변환은 완벽하지 않아 **사람이 보고 확정한다.** 그래서 값이 아니라
|
|
28
|
+
* 자리표시로만 있다가, 사람이 "받겠다" 는 키를 눌러야 값이 된다.
|
|
29
|
+
*
|
|
30
|
+
* 받는 때는 셋이 다 맞을 때뿐이다:
|
|
31
|
+
* - **Tab(앞으로) 이거나 Space.** `Shift+Tab` 은 안 받는다 — 뒤 칸으로 가려던 사람이 값을 얻는다.
|
|
32
|
+
* - **칸이 비어 있다**(공백만 친 것도 빈 것으로 본다). 손으로 적은 것을 덮지 않는다.
|
|
33
|
+
* - 비출 추천이 있다.
|
|
34
|
+
*
|
|
35
|
+
* 받은 키는 **이 칸이 먹는다**(`preventDefault`) — 그래서 그 Tab 으로 초점이 옮겨 가지 않고,
|
|
36
|
+
* 그 눌림은 `onKeyDown` 으로도 안 간다. 한 번 더 누르면 칸이 찼으므로 평소대로 넘어간다.
|
|
37
|
+
*
|
|
38
|
+
* 앱마다 이 세 줄이 손으로 적혀 있었다(말귀 두 자리 — 훈련 이름·표준 발음, 2026-09-25).
|
|
39
|
+
* 무엇을 비추고 어떻게 받는지는 **칸의 행동**이지 그 칸을 놓는 화면의 일이 아니다.
|
|
40
|
+
*/
|
|
41
|
+
ghostText?: string;
|
|
42
|
+
/** 추천값 뒤에 붙는 꼬리말 — 안 주면 코어 사전의 " (Tab 또는 space 입력시 자동 입력)" */
|
|
43
|
+
ghostHint?: string;
|
|
23
44
|
/** 입력 타입 */
|
|
24
45
|
type?: 'text' | 'email' | 'password' | 'number' | 'tel' | 'url';
|
|
25
46
|
/** 비활성 상태 */
|
|
@@ -13,7 +13,7 @@ import { useIsoLayoutEffect } from '../hooks/useIsoLayoutEffect';
|
|
|
13
13
|
import './TextField.css';
|
|
14
14
|
/** @public */
|
|
15
15
|
export default function TextField(props) {
|
|
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
|
+
const { value, defaultValue, name, inputRef, onChange, label, ariaLabel, required, placeholder, ghostText, ghostHint, 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;
|
|
17
17
|
// 읽기 전용·꺼진 칸은 후보로 값을 바꾸지 못한다 — 전에는 ↓·Enter·후보 누름으로 onChange 가 나갔다
|
|
18
18
|
const suggestions = readOnly || disabled ? undefined : suggestionsProp;
|
|
19
19
|
const { t } = useTranslation();
|
|
@@ -93,6 +93,11 @@ export default function TextField(props) {
|
|
|
93
93
|
inputRef.current = node;
|
|
94
94
|
}, [inputRef]);
|
|
95
95
|
/* eslint-enable react-hooks/immutability */
|
|
96
|
+
/* 추천이 있으면 자리표시가 **추천 + 받는 법**이 된다. 추천이 없으면 그 칸의 평소 자리표시로 물러선다.
|
|
97
|
+
(네이티브 자리표시는 칸이 비었을 때만 보이므로 "비었을 때만" 은 따로 가릴 것이 없다.) */
|
|
98
|
+
const 자리표시 = ghostText
|
|
99
|
+
? `${ghostText}${ghostHint ?? t('wordTable.save.autoFillHint', ' (Tab 또는 space 입력시 자동 입력)')}`
|
|
100
|
+
: placeholder;
|
|
96
101
|
const isControlled = value !== undefined;
|
|
97
102
|
/* 비밀번호는 value 를 React 에 넘기지 않는다 — 위 useEffect 주석 참고 */
|
|
98
103
|
const isPasswordControlled = isControlled && type === 'password';
|
|
@@ -141,7 +146,7 @@ export default function TextField(props) {
|
|
|
141
146
|
// 단 비밀번호는 둘 다 넘기지 않는다 — 어느 쪽이든 HTML value 속성에 평문이 박힌다
|
|
142
147
|
...(isPasswordControlled ? {} : isControlled ? { value } : { defaultValue }),
|
|
143
148
|
ref: setNodeRef,
|
|
144
|
-
placeholder
|
|
149
|
+
placeholder: 자리표시,
|
|
145
150
|
name,
|
|
146
151
|
disabled,
|
|
147
152
|
readOnly,
|
|
@@ -202,6 +207,21 @@ export default function TextField(props) {
|
|
|
202
207
|
/* 후보를 가리킨 채 누른 Enter 는 **후보를 고르는** 키다(아래 combobox.handleKeyDown
|
|
203
208
|
과 같은 조건). 예전에는 그 Enter 가 onEnter 까지 불러, 후보를 고르는 순간 검색·제출이
|
|
204
209
|
한 번 더 나갔다. 고르는 Enter 는 폼 제출도 막는다. */
|
|
210
|
+
/* **비추던 추천을 받아 적는다.** Tab(앞으로)·Space 이고, 칸이 비었고(공백만 친 것도 빈 것),
|
|
211
|
+
비출 추천이 있을 때만. 받은 키는 이 칸이 먹으므로 초점이 옮겨 가지 않고 `onKeyDown` 으로도
|
|
212
|
+
안 간다 — 한 번 더 누르면 칸이 찼으니 평소대로 넘어간다. 조합 중(한글 중간 글자)에는 손대지
|
|
213
|
+
않는다: 그때의 Space 는 글자를 맺는 키다. */
|
|
214
|
+
const 추천받는키 = ((e.key === 'Tab' && !e.shiftKey) || e.key === ' ') && !e.nativeEvent.isComposing;
|
|
215
|
+
if (ghostText && 추천받는키 && !readOnly && !disabled && !(e.currentTarget.value ?? '').trim()) {
|
|
216
|
+
e.preventDefault();
|
|
217
|
+
const node = e.currentTarget;
|
|
218
|
+
if (!isControlled) {
|
|
219
|
+
node.value = ghostText;
|
|
220
|
+
setInnerLen(ghostText.length);
|
|
221
|
+
}
|
|
222
|
+
onChange?.(ghostText);
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
205
225
|
const 후보고르는엔터 = !!suggestions && e.key === 'Enter' && !e.nativeEvent.isComposing && combobox.activeIdx >= 0;
|
|
206
226
|
if (후보고르는엔터)
|
|
207
227
|
e.preventDefault();
|
|
@@ -229,8 +249,8 @@ export default function TextField(props) {
|
|
|
229
249
|
// variant="display"는 값과 placeholder 글자 크기가 크게 달라 네이티브 ::placeholder로는
|
|
230
250
|
// 세로 중앙을 못 맞춘다. 전용 오버레이 div를 flex로 정중앙 정렬하고,
|
|
231
251
|
// 값이 있으면(:placeholder-shown 해제) CSS로 숨긴다.
|
|
232
|
-
const showPlaceholderOverlay = variant === 'display' && !newline && !multiline &&
|
|
233
|
-
const renderInputArea = () => (_jsxs(_Fragment, { children: [renderInput(), showPlaceholderOverlay && (_jsx("span", { className: "text-field__placeholder", "aria-hidden": "true", children:
|
|
252
|
+
const showPlaceholderOverlay = variant === 'display' && !newline && !multiline && !!자리표시;
|
|
253
|
+
const renderInputArea = () => (_jsxs(_Fragment, { children: [renderInput(), showPlaceholderOverlay && (_jsx("span", { className: "text-field__placeholder", "aria-hidden": "true", children: 자리표시 }))] }));
|
|
234
254
|
return (_jsxs("div", { ref: rootRef, className: cls, children: [label && (_jsxs("label", { htmlFor: inputId, className: "text-field__label caption-1", children: [label, required && _jsx("span", { className: "text-field__required", children: "*" })] })), _jsxs("div", { className: "text-field__wrap", children: [leftIcon && (_jsx("span", { className: "text-field__icon text-field__icon--left", children: _jsx(Icon, { icon: leftIcon, label: "", className: "", svgClassName: "text-field__icon-svg", size: iconSize }) })), renderInputArea(), maxLength != null && showCount && (_jsxs("span", { className: `text-field__count caption-2${isOver ? ' text-field__count--over' : ''}`, children: [len, "/", maxLength] })), showClear && (_jsx(Button, { size: 24, type: "dot", icon: "x", variant: "transparent-gray", useBorder: false, className: "text-field__clear", ariaLabel: t('textField.clearAriaLabel', '입력값 지우기'),
|
|
235
255
|
// 폼 안에서 쓰일 때 기본값(submit)으로 눌려 폼이 제출되지 않도록.
|
|
236
256
|
htmlType: "button",
|