@simplysm/angular 14.1.9 → 14.1.11
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/dist/controls/input/sd-textfield-type-handlers.d.ts +5 -0
- package/dist/controls/input/sd-textfield-type-handlers.d.ts.map +1 -1
- package/dist/controls/input/sd-textfield-type-handlers.js +25 -2
- package/dist/controls/input/sd-textfield.d.ts +6 -1
- package/dist/controls/input/sd-textfield.d.ts.map +1 -1
- package/dist/controls/input/sd-textfield.js +64 -33
- package/package.json +7 -7
- package/src/controls/input/sd-textfield-type-handlers.ts +32 -6
- package/src/controls/input/sd-textfield.ts +65 -29
|
@@ -41,6 +41,11 @@ export interface TextfieldTypeHandler {
|
|
|
41
41
|
toControlValue(value: unknown, opts: TextfieldFormatOpts): string;
|
|
42
42
|
toDisplayText(value: unknown, opts: TextfieldDisplayOpts): string | undefined;
|
|
43
43
|
validate(value: unknown, opts: TextfieldValidateOpts): string[];
|
|
44
|
+
/**
|
|
45
|
+
* parse 는 실패했으나 계속 입력하면 유효해질 수 있는 "완성 전 진행" 입력인지 여부.
|
|
46
|
+
* true 면 onInput 이 DOM 을 되쓰지 않아 사용자가 입력을 이어갈 수 있다 (예: number "-", "12.").
|
|
47
|
+
*/
|
|
48
|
+
isIncomplete?(raw: string, opts: TextfieldParseOpts): boolean;
|
|
44
49
|
}
|
|
45
50
|
export declare const textfieldTypeHandlers: Record<keyof SdTextfieldTypes, TextfieldTypeHandler>;
|
|
46
51
|
//# sourceMappingURL=sd-textfield-type-handlers.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sd-textfield-type-handlers.d.ts","sourceRoot":"","sources":["../../../src/controls/input/sd-textfield-type-handlers.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,QAAQ,EACR,QAAQ,EAGR,IAAI,EACL,MAAM,uBAAuB,CAAC;AAI/B,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,QAAQ,CAAC;IACjB,OAAO,EAAE,QAAQ,CAAC;IAClB,MAAM,EAAE,QAAQ,CAAC;IACjB,UAAU,EAAE,QAAQ,CAAC;IACrB,cAAc,EAAE,QAAQ,CAAC;IACzB,MAAM,EAAE,IAAI,CAAC;IACb,UAAU,EAAE,IAAI,CAAC;CAClB,CAAC;AAEF,eAAO,MAAM,gBAAgB,EAAE,CAAC,MAAM,gBAAgB,CAAC,EActD,CAAC;AAMF,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,mBAAmB;IAClC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,oBAAoB;IACnC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,cAAc,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,MAAM,CAAC;IAClE,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC;IACtD,cAAc,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,mBAAmB,GAAG,MAAM,CAAC;IAClE,aAAa,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,oBAAoB,GAAG,MAAM,GAAG,SAAS,CAAC;IAC9E,QAAQ,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,qBAAqB,GAAG,MAAM,EAAE,CAAC;
|
|
1
|
+
{"version":3,"file":"sd-textfield-type-handlers.d.ts","sourceRoot":"","sources":["../../../src/controls/input/sd-textfield-type-handlers.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,QAAQ,EACR,QAAQ,EAGR,IAAI,EACL,MAAM,uBAAuB,CAAC;AAI/B,MAAM,MAAM,gBAAgB,GAAG;IAC7B,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,MAAM,CAAC;IACf,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;IAChB,OAAO,EAAE,MAAM,CAAC;IAChB,QAAQ,EAAE,MAAM,CAAC;IACjB,MAAM,EAAE,QAAQ,CAAC;IACjB,OAAO,EAAE,QAAQ,CAAC;IAClB,MAAM,EAAE,QAAQ,CAAC;IACjB,UAAU,EAAE,QAAQ,CAAC;IACrB,cAAc,EAAE,QAAQ,CAAC;IACzB,MAAM,EAAE,IAAI,CAAC;IACb,UAAU,EAAE,IAAI,CAAC;CAClB,CAAC;AAEF,eAAO,MAAM,gBAAgB,EAAE,CAAC,MAAM,gBAAgB,CAAC,EActD,CAAC;AAMF,MAAM,WAAW,kBAAkB;IACjC,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,mBAAmB;IAClC,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,oBAAoB;IACnC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,MAAM,WAAW,qBAAqB;IACpC,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,GAAG,CAAC,EAAE,OAAO,CAAC;IACd,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,MAAM,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,oBAAoB;IACnC,QAAQ,CAAC,WAAW,EAAE,MAAM,CAAC;IAC7B,cAAc,CAAC,YAAY,EAAE,MAAM,GAAG,SAAS,GAAG,MAAM,GAAG,MAAM,CAAC;IAClE,KAAK,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC;IACtD,cAAc,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,mBAAmB,GAAG,MAAM,CAAC;IAClE,aAAa,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,oBAAoB,GAAG,MAAM,GAAG,SAAS,CAAC;IAC9E,QAAQ,CAAC,KAAK,EAAE,OAAO,EAAE,IAAI,EAAE,qBAAqB,GAAG,MAAM,EAAE,CAAC;IAChE;;;OAGG;IACH,YAAY,CAAC,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,kBAAkB,GAAG,OAAO,CAAC;CAC/D;AAuYD,eAAO,MAAM,qBAAqB,EAAE,MAAM,CAAC,MAAM,gBAAgB,EAAE,oBAAoB,CActF,CAAC"}
|
|
@@ -82,6 +82,13 @@ function createNumberHandler() {
|
|
|
82
82
|
const result = Number.parseFloat(inputValue);
|
|
83
83
|
return Number.isNaN(result) ? undefined : result;
|
|
84
84
|
},
|
|
85
|
+
isIncomplete(raw) {
|
|
86
|
+
const inputValue = raw.replace(/[^0-9-.]/g, "");
|
|
87
|
+
// 부호·소수점 형식은 갖췄으나 아직 완성되지 않은 진행 상태 ("-", "12.", ".", "-.")
|
|
88
|
+
// Number("12.")===12 이므로 endsWith(".") 를 별도 판정 (parse 의 실패 조건과 정합)
|
|
89
|
+
return (/^-?\d*\.?\d*$/.test(inputValue)
|
|
90
|
+
&& (Number.isNaN(Number(inputValue)) || inputValue.endsWith(".")));
|
|
91
|
+
},
|
|
85
92
|
toControlValue(value, opts) {
|
|
86
93
|
const numValue = value;
|
|
87
94
|
return opts.useNumberComma !== false
|
|
@@ -130,9 +137,25 @@ function createFormatHandler() {
|
|
|
130
137
|
const format = opts.format;
|
|
131
138
|
if (format == null || format === "")
|
|
132
139
|
return raw;
|
|
133
|
-
|
|
140
|
+
// 위치기반: raw 길이가 마스크(대안 중 하나)의 길이와 같으면 X 자리 문자만 추출.
|
|
141
|
+
// 리터럴이 데이터 문자와 겹쳐도(예: "2025-XXXX") 안전 (toControlValue 와 대칭).
|
|
142
|
+
const formatItems = format.split("|");
|
|
143
|
+
for (const formatItem of formatItems) {
|
|
144
|
+
if (raw.length === formatItem.length) {
|
|
145
|
+
let result = "";
|
|
146
|
+
for (let i = 0; i < formatItem.length; i++) {
|
|
147
|
+
if (formatItem[i] === "X")
|
|
148
|
+
result += raw[i];
|
|
149
|
+
}
|
|
150
|
+
return result;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// 폴백: 길이 불일치(구분자 생략 등)면 리터럴 문자를 전역 제거.
|
|
154
|
+
const nonFormatChars = format.match(/[^X|]/g)?.filter((v, i, a) => a.indexOf(v) === i);
|
|
134
155
|
if (nonFormatChars != null && nonFormatChars.length > 0) {
|
|
135
|
-
const escaped = nonFormatChars
|
|
156
|
+
const escaped = nonFormatChars
|
|
157
|
+
.map((ch) => (ch === "]" || ch === "\\" || ch === "^" || ch === "-" ? "\\" + ch : ch))
|
|
158
|
+
.join("");
|
|
136
159
|
return raw.replace(new RegExp(`[${escaped}]`, "g"), "");
|
|
137
160
|
}
|
|
138
161
|
return raw;
|
|
@@ -26,15 +26,20 @@ export declare class SdTextfield<K extends keyof SdTextfieldTypes> {
|
|
|
26
26
|
size: import("@angular/core").InputSignal<"sm" | "lg" | undefined>;
|
|
27
27
|
theme: import("@angular/core").InputSignal<"info" | "success" | "primary" | "secondary" | "warning" | "danger" | "gray" | "blue-gray" | undefined>;
|
|
28
28
|
private readonly _inputElRef;
|
|
29
|
+
private readonly _nativeInvalidMessage;
|
|
30
|
+
private _composing;
|
|
29
31
|
private readonly _handler;
|
|
30
32
|
controlType: import("@angular/core").Signal<string>;
|
|
31
33
|
controlStep: import("@angular/core").Signal<string | number>;
|
|
32
34
|
controlValue: import("@angular/core").Signal<string>;
|
|
33
35
|
controlValueText: import("@angular/core").Signal<string | undefined>;
|
|
34
36
|
constructor();
|
|
37
|
+
onCompositionStart(): void;
|
|
38
|
+
onCompositionEnd(event: CompositionEvent): void;
|
|
35
39
|
onInput(event: Event): void;
|
|
40
|
+
private _applyInput;
|
|
36
41
|
onBlur(event: FocusEvent): void;
|
|
37
|
-
|
|
42
|
+
private _syncNativeValidity;
|
|
38
43
|
static ɵfac: i0.ɵɵFactoryDeclaration<SdTextfield<any>, never>;
|
|
39
44
|
static ɵcmp: i0.ɵɵComponentDeclaration<SdTextfield<any>, "sd-textfield", never, { "value": { "alias": "value"; "required": false; "isSignal": true; }; "type": { "alias": "type"; "required": true; "isSignal": true; }; "placeholder": { "alias": "placeholder"; "required": false; "isSignal": true; }; "title": { "alias": "title"; "required": false; "isSignal": true; }; "inputStyle": { "alias": "inputStyle"; "required": false; "isSignal": true; }; "inputClass": { "alias": "inputClass"; "required": false; "isSignal": true; }; "disabled": { "alias": "disabled"; "required": false; "isSignal": true; }; "readonly": { "alias": "readonly"; "required": false; "isSignal": true; }; "required": { "alias": "required"; "required": false; "isSignal": true; }; "min": { "alias": "min"; "required": false; "isSignal": true; }; "max": { "alias": "max"; "required": false; "isSignal": true; }; "minlength": { "alias": "minlength"; "required": false; "isSignal": true; }; "maxlength": { "alias": "maxlength"; "required": false; "isSignal": true; }; "pattern": { "alias": "pattern"; "required": false; "isSignal": true; }; "validatorFn": { "alias": "validatorFn"; "required": false; "isSignal": true; }; "format": { "alias": "format"; "required": false; "isSignal": true; }; "step": { "alias": "step"; "required": false; "isSignal": true; }; "autocomplete": { "alias": "autocomplete"; "required": false; "isSignal": true; }; "useNumberComma": { "alias": "useNumberComma"; "required": false; "isSignal": true; }; "minDigits": { "alias": "minDigits"; "required": false; "isSignal": true; }; "inline": { "alias": "inline"; "required": false; "isSignal": true; }; "inset": { "alias": "inset"; "required": false; "isSignal": true; }; "size": { "alias": "size"; "required": false; "isSignal": true; }; "theme": { "alias": "theme"; "required": false; "isSignal": true; }; }, { "value": "valueChange"; }, never, never, true, never>;
|
|
40
45
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"sd-textfield.d.ts","sourceRoot":"","sources":["../../../src/controls/input/sd-textfield.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"sd-textfield.d.ts","sourceRoot":"","sources":["../../../src/controls/input/sd-textfield.ts"],"names":[],"mappings":"AAiBA,OAAO,EAEL,KAAK,gBAAgB,EACtB,MAAM,8BAA8B,CAAC;;AAEtC,qBAqRa,WAAW,CAAC,CAAC,SAAS,MAAM,gBAAgB;IACvD,KAAK,uEAAgC;IAErC,IAAI,yCAAuB;IAC3B,WAAW,0DAAmB;IAC9B,KAAK,0DAAmB;IACxB,UAAU,0DAAmB;IAC7B,UAAU,0DAAmB;IAE7B,QAAQ,qEAAiD;IACzD,QAAQ,qEAAiD;IAEzD,QAAQ,qEAAiD;IACzD,GAAG,uEAAgC;IACnC,GAAG,uEAAgC;IACnC,SAAS,0DAAmB;IAC5B,SAAS,0DAAmB;IAC5B,OAAO,0DAAmB;IAC1B,WAAW,+CAAiB,gBAAgB,CAAC,CAAC,CAAC,GAAG,SAAS,KAAK,MAAM,GAAG,SAAS,eAAI;IACtF,MAAM,0DAAmB;IAEzB,IAAI,0DAAmB;IACvB,YAAY,0DAAmB;IAC/B,cAAc,qEAAgD;IAC9D,SAAS,0DAAmB;IAE5B,MAAM,qEAAiD;IACvD,KAAK,qEAAiD;IACtD,IAAI,+DAAwB;IAC5B,KAAK,8IAED;IAEJ,OAAO,CAAC,QAAQ,CAAC,WAAW,CAAsD;IAGlF,OAAO,CAAC,QAAQ,CAAC,qBAAqB,CAAc;IAGpD,OAAO,CAAC,UAAU,CAAS;IAE3B,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAsD;IAE/E,WAAW,yCAA+C;IAE1D,WAAW,kDAA+D;IAE1E,YAAY,yCAOT;IAEH,gBAAgB,qDAMb;;IAmEH,kBAAkB,IAAI,IAAI;IAI1B,gBAAgB,CAAC,KAAK,EAAE,gBAAgB,GAAG,IAAI;IAK/C,OAAO,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI;IAM3B,OAAO,CAAC,WAAW;IAiBnB,MAAM,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI;IAQ/B,OAAO,CAAC,mBAAmB;yCAzKhB,WAAW;2CAAX,WAAW;CA6KvB"}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { isPlatformBrowser } from "@angular/common";
|
|
2
|
-
import { booleanAttribute, ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, input, model, PLATFORM_ID, viewChild, ViewEncapsulation, } from "@angular/core";
|
|
2
|
+
import { booleanAttribute, ChangeDetectionStrategy, Component, computed, effect, ElementRef, inject, input, model, PLATFORM_ID, signal, viewChild, ViewEncapsulation, } from "@angular/core";
|
|
3
3
|
import { setupInvalid } from "../../core/validation/setupInvalid";
|
|
4
4
|
import { textfieldTypeHandlers, } from "./sd-textfield-type-handlers";
|
|
5
5
|
import * as i0 from "@angular/core";
|
|
@@ -41,7 +41,7 @@ function SdTextfield_Conditional_2_Template(rf, ctx) { if (rf & 1) {
|
|
|
41
41
|
function SdTextfield_Conditional_3_Template(rf, ctx) { if (rf & 1) {
|
|
42
42
|
const _r2 = i0.ɵɵgetCurrentView();
|
|
43
43
|
i0.ɵɵdomElementStart(0, "input", 4, 0);
|
|
44
|
-
i0.ɵɵdomListener("input", function SdTextfield_Conditional_3_Template_input_input_0_listener($event) { i0.ɵɵrestoreView(_r2); const ctx_r0 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r0.onInput($event)); })("
|
|
44
|
+
i0.ɵɵdomListener("input", function SdTextfield_Conditional_3_Template_input_input_0_listener($event) { i0.ɵɵrestoreView(_r2); const ctx_r0 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r0.onInput($event)); })("compositionstart", function SdTextfield_Conditional_3_Template_input_compositionstart_0_listener() { i0.ɵɵrestoreView(_r2); const ctx_r0 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r0.onCompositionStart()); })("compositionend", function SdTextfield_Conditional_3_Template_input_compositionend_0_listener($event) { i0.ɵɵrestoreView(_r2); const ctx_r0 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r0.onCompositionEnd($event)); })("blur", function SdTextfield_Conditional_3_Template_input_blur_0_listener($event) { i0.ɵɵrestoreView(_r2); const ctx_r0 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r0.onBlur($event)); });
|
|
45
45
|
i0.ɵɵdomElementEnd();
|
|
46
46
|
} if (rf & 2) {
|
|
47
47
|
const ctx_r0 = i0.ɵɵnextContext();
|
|
@@ -95,6 +95,11 @@ export class SdTextfield {
|
|
|
95
95
|
...(ngDevMode ? [undefined, { debugName: "theme" }] : /* istanbul ignore next */ []));
|
|
96
96
|
_inputElRef = viewChild("inputEl", /* @ts-ignore */
|
|
97
97
|
...(ngDevMode ? [{ debugName: "_inputElRef" }] : /* istanbul ignore next */ []));
|
|
98
|
+
// 실제 input 의 브라우저 native 제약 위반(이메일 형식·날짜 미완성 등) 메시지. onInput/onBlur 에서 갱신.
|
|
99
|
+
_nativeInvalidMessage = signal("", /* @ts-ignore */
|
|
100
|
+
...(ngDevMode ? [{ debugName: "_nativeInvalidMessage" }] : /* istanbul ignore next */ []));
|
|
101
|
+
// IME 조합(한글 등) 중 여부. 조합 중에는 미완성 자모가 model 로 흐르지 않도록 갱신을 보류.
|
|
102
|
+
_composing = false;
|
|
98
103
|
_handler = computed(() => textfieldTypeHandlers[this.type()], /* @ts-ignore */
|
|
99
104
|
...(ngDevMode ? [{ debugName: "_handler" }] : /* istanbul ignore next */ []));
|
|
100
105
|
controlType = computed(() => this._handler().controlType, /* @ts-ignore */
|
|
@@ -127,15 +132,31 @@ export class SdTextfield {
|
|
|
127
132
|
const inputEl = this._inputElRef()?.nativeElement;
|
|
128
133
|
if (inputEl == null)
|
|
129
134
|
return;
|
|
130
|
-
// 편집(포커스)
|
|
131
|
-
|
|
132
|
-
|
|
135
|
+
// 편집(포커스) 중이라도 변경 origin 을 구분한다.
|
|
136
|
+
// 사용자 입력이 만든 DOM(정규화 차이·진행 중 입력 포함)은 보호하고,
|
|
137
|
+
// 모델만 외부에서 바뀐 경우(프로그래밍적 리셋·prefill·표시옵션 변경)는 되써서 반영한다.
|
|
138
|
+
if (document.activeElement === inputEl) {
|
|
139
|
+
const domParsed = this._handler().parse(inputEl.value, { format: this.format() });
|
|
140
|
+
// DOM raw 가 현재 모델의 유효한 표현이면(사용자 입력 유래) 되쓰지 않아 캐럿·IME 보호
|
|
141
|
+
const domReflectsModel = domParsed != null
|
|
142
|
+
&& this._handler().toControlValue(domParsed, {
|
|
143
|
+
useNumberComma: this.useNumberComma(),
|
|
144
|
+
format: this.format(),
|
|
145
|
+
}) === controlValue;
|
|
146
|
+
// 계속 입력하면 유효해질 진행 중 입력(number "12.")도 보호
|
|
147
|
+
const incomplete = this._handler().isIncomplete?.(inputEl.value, { format: this.format() }) === true;
|
|
148
|
+
if (domReflectsModel || incomplete)
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
133
151
|
if (inputEl.value !== controlValue) {
|
|
134
152
|
inputEl.value = controlValue;
|
|
135
153
|
}
|
|
136
154
|
});
|
|
137
155
|
}
|
|
138
156
|
setupInvalid(() => {
|
|
157
|
+
// 비활성·읽기전용 필드는 사용자가 편집할 수 없으므로 검증에서 제외 (네이티브 disabled 동작과 동일)
|
|
158
|
+
if (this.disabled() || this.readonly())
|
|
159
|
+
return "";
|
|
139
160
|
const value = this.value();
|
|
140
161
|
const handlerErrors = this._handler().validate(value, {
|
|
141
162
|
required: this.required(),
|
|
@@ -153,46 +174,55 @@ export class SdTextfield {
|
|
|
153
174
|
errorMessages.push(message);
|
|
154
175
|
}
|
|
155
176
|
}
|
|
177
|
+
// 브라우저 native 제약 위반(이메일 형식·날짜 미완성 등)을 우리 검증(빨간점)에 합류
|
|
178
|
+
const nativeMessage = this._nativeInvalidMessage();
|
|
179
|
+
if (nativeMessage !== "") {
|
|
180
|
+
errorMessages.push(nativeMessage);
|
|
181
|
+
}
|
|
156
182
|
return errorMessages.join("\r\n");
|
|
157
183
|
});
|
|
158
184
|
}
|
|
185
|
+
onCompositionStart() {
|
|
186
|
+
this._composing = true;
|
|
187
|
+
}
|
|
188
|
+
onCompositionEnd(event) {
|
|
189
|
+
this._composing = false;
|
|
190
|
+
this._applyInput(event.target);
|
|
191
|
+
}
|
|
159
192
|
onInput(event) {
|
|
160
|
-
|
|
193
|
+
// 조합 중 input 은 무시하고, 완성 시 onCompositionEnd 에서 한 번만 반영
|
|
194
|
+
if (this._composing)
|
|
195
|
+
return;
|
|
196
|
+
this._applyInput(event.target);
|
|
197
|
+
}
|
|
198
|
+
_applyInput(inputEl) {
|
|
161
199
|
if (inputEl.value === "") {
|
|
162
200
|
this.value.set(undefined);
|
|
163
|
-
return;
|
|
164
201
|
}
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
202
|
+
else {
|
|
203
|
+
const parsed = this._handler().parse(inputEl.value, { format: this.format() });
|
|
204
|
+
if (parsed == null) {
|
|
205
|
+
// 계속 입력하면 유효해질 진행 중 입력(number "12.", "-")은 되쓰지 않아 입력을 이어가게 함
|
|
206
|
+
if (this._handler().isIncomplete?.(inputEl.value, { format: this.format() }) !== true) {
|
|
207
|
+
inputEl.value = this.controlValue();
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
this.value.set(parsed);
|
|
212
|
+
}
|
|
169
213
|
}
|
|
170
|
-
this.
|
|
214
|
+
this._syncNativeValidity(inputEl);
|
|
171
215
|
}
|
|
172
216
|
onBlur(event) {
|
|
173
217
|
// 편집 종료 시 모델 기준값으로 동기화 (number 콤마 포맷 반영, date 미완성 입력 정리)
|
|
174
218
|
// date 미완성 입력은 input.value 가 "" 라 비교 없이 무조건 되써야 남은 세그먼트가 정리됨
|
|
175
219
|
const inputEl = event.target;
|
|
176
220
|
inputEl.value = this.controlValue();
|
|
221
|
+
this._syncNativeValidity(inputEl);
|
|
177
222
|
}
|
|
178
|
-
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
event.preventDefault();
|
|
182
|
-
const inputEl = event.target;
|
|
183
|
-
const text = event.clipboardData?.getData("text/plain").trim();
|
|
184
|
-
if (text == null || text === "") {
|
|
185
|
-
this.value.set(undefined);
|
|
186
|
-
inputEl.value = "";
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
const parsed = this._handler().parse(text, { format: this.format() });
|
|
190
|
-
if (parsed == null) {
|
|
191
|
-
inputEl.value = this.controlValue();
|
|
192
|
-
return;
|
|
193
|
-
}
|
|
194
|
-
this.value.set(parsed);
|
|
195
|
-
inputEl.value = this.controlValue();
|
|
223
|
+
_syncNativeValidity(inputEl) {
|
|
224
|
+
// 실제 input 의 브라우저 native 제약 위반을 signal 로 반영 → setupInvalid 콜백이 검증에 합류
|
|
225
|
+
this._nativeInvalidMessage.set(inputEl.validity.valid ? "" : inputEl.validationMessage);
|
|
196
226
|
}
|
|
197
227
|
static ɵfac = function SdTextfield_Factory(__ngFactoryType__) { return new (__ngFactoryType__ || SdTextfield)(); };
|
|
198
228
|
static ɵcmp = /*@__PURE__*/ i0.ɵɵdefineComponent({ type: SdTextfield, selectors: [["sd-textfield"]], viewQuery: function SdTextfield_Query(rf, ctx) { if (rf & 1) {
|
|
@@ -201,7 +231,7 @@ export class SdTextfield {
|
|
|
201
231
|
i0.ɵɵqueryAdvance();
|
|
202
232
|
} }, hostVars: 7, hostBindings: function SdTextfield_HostBindings(rf, ctx) { if (rf & 2) {
|
|
203
233
|
i0.ɵɵattribute("data-sd-type", ctx.type())("data-sd-disabled", ctx.disabled())("data-sd-readonly", ctx.readonly())("data-sd-inline", ctx.inline())("data-sd-inset", ctx.inset())("data-sd-size", ctx.size())("data-sd-theme", ctx.theme());
|
|
204
|
-
} }, inputs: { value: [1, "value"], type: [1, "type"], placeholder: [1, "placeholder"], title: [1, "title"], inputStyle: [1, "inputStyle"], inputClass: [1, "inputClass"], disabled: [1, "disabled"], readonly: [1, "readonly"], required: [1, "required"], min: [1, "min"], max: [1, "max"], minlength: [1, "minlength"], maxlength: [1, "maxlength"], pattern: [1, "pattern"], validatorFn: [1, "validatorFn"], format: [1, "format"], step: [1, "step"], autocomplete: [1, "autocomplete"], useNumberComma: [1, "useNumberComma"], minDigits: [1, "minDigits"], inline: [1, "inline"], inset: [1, "inset"], size: [1, "size"], theme: [1, "theme"] }, outputs: { value: "valueChange" }, decls: 4, vars: 9, consts: [["inputEl", ""], [1, "tx-trans-light"], [3, "style", "class", "type"], [1, "tx-trans-lighter"], [3, "input", "
|
|
234
|
+
} }, inputs: { value: [1, "value"], type: [1, "type"], placeholder: [1, "placeholder"], title: [1, "title"], inputStyle: [1, "inputStyle"], inputClass: [1, "inputClass"], disabled: [1, "disabled"], readonly: [1, "readonly"], required: [1, "required"], min: [1, "min"], max: [1, "max"], minlength: [1, "minlength"], maxlength: [1, "maxlength"], pattern: [1, "pattern"], validatorFn: [1, "validatorFn"], format: [1, "format"], step: [1, "step"], autocomplete: [1, "autocomplete"], useNumberComma: [1, "useNumberComma"], minDigits: [1, "minDigits"], inline: [1, "inline"], inset: [1, "inset"], size: [1, "size"], theme: [1, "theme"] }, outputs: { value: "valueChange" }, decls: 4, vars: 9, consts: [["inputEl", ""], [1, "tx-trans-light"], [3, "style", "class", "type"], [1, "tx-trans-lighter"], [3, "input", "compositionstart", "compositionend", "blur", "type"]], template: function SdTextfield_Template(rf, ctx) { if (rf & 1) {
|
|
205
235
|
i0.ɵɵdomElementStart(0, "div");
|
|
206
236
|
i0.ɵɵconditionalCreate(1, SdTextfield_Conditional_1_Template, 2, 0, "span", 1)(2, SdTextfield_Conditional_2_Template, 3, 1);
|
|
207
237
|
i0.ɵɵdomElementEnd();
|
|
@@ -252,7 +282,8 @@ export class SdTextfield {
|
|
|
252
282
|
[attr.autocomplete]="autocomplete()"
|
|
253
283
|
[attr.step]="controlStep()"
|
|
254
284
|
(input)="onInput($event)"
|
|
255
|
-
(
|
|
285
|
+
(compositionstart)="onCompositionStart()"
|
|
286
|
+
(compositionend)="onCompositionEnd($event)"
|
|
256
287
|
(blur)="onBlur($event)"
|
|
257
288
|
/>
|
|
258
289
|
}
|
|
@@ -266,4 +297,4 @@ export class SdTextfield {
|
|
|
266
297
|
"[attr.data-sd-theme]": "theme()",
|
|
267
298
|
}, styles: ["/*@function to-rgb($oklch-color) {\n @return color.to-space($oklch-color, rgb);\n}*/\n/*$colors: (\n red: to-rgb(oklch(0.704 0.191 22.216)),\n orange: to-rgb(oklch(0.75 0.183 55.934)),\n amber: to-rgb(oklch(0.828 0.189 84.429)),\n yellow: to-rgb(oklch(0.852 0.199 91.936)),\n lime: to-rgb(oklch(0.841 0.238 128.85)),\n green: to-rgb(oklch(0.792 0.209 151.711)),\n emerald: to-rgb(oklch(0.765 0.177 163.223)),\n teal: to-rgb(oklch(0.777 0.152 181.912)),\n cyan: to-rgb(oklch(0.789 0.154 211.53)),\n sky: to-rgb(oklch(0.746 0.16 232.661)),\n blue: to-rgb(oklch(0.707 0.165 254.624)),\n indigo: to-rgb(oklch(0.673 0.182 276.935)),\n violet: to-rgb(oklch(0.702 0.183 293.541)),\n purple: to-rgb(oklch(0.714 0.203 305.504)),\n fuchsia: to-rgb(oklch(0.74 0.238 322.16)),\n pink: to-rgb(oklch(0.718 0.202 349.761)),\n rose: to-rgb(oklch(0.712 0.194 13.428)),\n slate: to-rgb(oklch(0.704 0.04 256.788)),\n gray: to-rgb(oklch(0.707 0.022 261.325)),\n zinc: to-rgb(oklch(0.705 0.015 286.067)),\n neutral: to-rgb(oklch(0.708 0 0)),\n stone: to-rgb(oklch(0.709 0.01 56.259)),\n);*/\nsd-textfield {\n display: block;\n position: relative;\n}\nsd-textfield > input,\nsd-textfield > ._contents {\n display: block;\n padding: var(--gap-sm) var(--gap-default);\n border: 1px solid transparent;\n font-size: var(--font-size-default);\n font-family: var(--font-family);\n font-variant-numeric: tabular-nums;\n line-height: var(--line-height);\n color: var(--text-trans-default);\n font-family: var(--font-family-field);\n overflow: auto;\n width: 100%;\n border: 1px solid var(--border-color-light);\n border-radius: var(--border-radius-default);\n background: var(--theme-secondary-lightest);\n}\nsd-textfield > input:focus,\nsd-textfield > ._contents:focus {\n outline: none;\n border-color: var(--theme-primary-default);\n}\nsd-textfield > input[type=date], sd-textfield > input[type=month], sd-textfield > input[type=datetime-local],\nsd-textfield > ._contents[type=date],\nsd-textfield > ._contents[type=month],\nsd-textfield > ._contents[type=datetime-local] {\n padding-top: calc(var(--gap-sm) - 1px);\n padding-bottom: calc(var(--gap-sm) - 1px);\n}\nsd-textfield > input::-webkit-scrollbar,\nsd-textfield > ._contents::-webkit-scrollbar {\n display: none;\n}\nsd-textfield > input::-webkit-input-placeholder,\nsd-textfield > ._contents::-webkit-input-placeholder {\n color: var(--text-trans-lighter);\n}\nsd-textfield > input::-webkit-outer-spin-button, sd-textfield > input::-webkit-inner-spin-button,\nsd-textfield > ._contents::-webkit-outer-spin-button,\nsd-textfield > ._contents::-webkit-inner-spin-button {\n -webkit-appearance: none;\n margin: 0;\n}\nsd-textfield > input::-webkit-calendar-picker-indicator,\nsd-textfield > ._contents::-webkit-calendar-picker-indicator {\n cursor: pointer;\n margin: auto;\n}\nsd-textfield > ._contents {\n display: none;\n}\nsd-textfield > ._contents > pre {\n font-family: inherit;\n}\nsd-textfield[data-sd-type=number] > input,\nsd-textfield[data-sd-type=number] > ._contents {\n text-align: right;\n}\nsd-textfield[data-sd-theme=gray] > input,\nsd-textfield[data-sd-theme=gray] > ._contents {\n background: var(--theme-gray-lightest);\n}\nsd-textfield[data-sd-theme=blue-gray] > input,\nsd-textfield[data-sd-theme=blue-gray] > ._contents {\n background: var(--theme-blue-gray-lightest);\n}\nsd-textfield[data-sd-theme=primary] > input,\nsd-textfield[data-sd-theme=primary] > ._contents {\n background: var(--theme-primary-lightest);\n}\nsd-textfield[data-sd-theme=secondary] > input,\nsd-textfield[data-sd-theme=secondary] > ._contents {\n background: var(--theme-secondary-lightest);\n}\nsd-textfield[data-sd-theme=info] > input,\nsd-textfield[data-sd-theme=info] > ._contents {\n background: var(--theme-info-lightest);\n}\nsd-textfield[data-sd-theme=success] > input,\nsd-textfield[data-sd-theme=success] > ._contents {\n background: var(--theme-success-lightest);\n}\nsd-textfield[data-sd-theme=warning] > input,\nsd-textfield[data-sd-theme=warning] > ._contents {\n background: var(--theme-warning-lightest);\n}\nsd-textfield[data-sd-theme=danger] > input,\nsd-textfield[data-sd-theme=danger] > ._contents {\n background: var(--theme-danger-lightest);\n}\nsd-textfield[data-sd-size=sm] > input,\nsd-textfield[data-sd-size=sm] > ._contents {\n padding: var(--gap-xs) var(--gap-sm);\n}\nsd-textfield[data-sd-size=sm] > input[type=date], sd-textfield[data-sd-size=sm] > input[type=month], sd-textfield[data-sd-size=sm] > input[type=datetime-local], sd-textfield[data-sd-size=sm] > input[type=color],\nsd-textfield[data-sd-size=sm] > ._contents[type=date],\nsd-textfield[data-sd-size=sm] > ._contents[type=month],\nsd-textfield[data-sd-size=sm] > ._contents[type=datetime-local],\nsd-textfield[data-sd-size=sm] > ._contents[type=color] {\n padding-top: calc(var(--gap-xs) - 1px);\n padding-bottom: calc(var(--gap-xs) - 1px);\n}\nsd-textfield[data-sd-size=lg] > input,\nsd-textfield[data-sd-size=lg] > ._contents {\n padding: var(--gap-default) var(--gap-lg);\n}\nsd-textfield[data-sd-size=lg] > input[type=date], sd-textfield[data-sd-size=lg] > input[type=month], sd-textfield[data-sd-size=lg] > input[type=datetime-local], sd-textfield[data-sd-size=lg] > input[type=color],\nsd-textfield[data-sd-size=lg] > ._contents[type=date],\nsd-textfield[data-sd-size=lg] > ._contents[type=month],\nsd-textfield[data-sd-size=lg] > ._contents[type=datetime-local],\nsd-textfield[data-sd-size=lg] > ._contents[type=color] {\n padding-top: calc(var(--gap-default) - 1px);\n padding-bottom: calc(var(--gap-default) - 1px);\n}\nsd-textfield[data-sd-inline=true] {\n display: inline-block;\n vertical-align: top;\n}\nsd-textfield[data-sd-inline=true] > input,\nsd-textfield[data-sd-inline=true] > ._contents {\n width: auto;\n vertical-align: top;\n}\nsd-textfield[data-sd-inset=true] > ._contents {\n display: block;\n}\nsd-textfield[data-sd-inset=true] > input {\n position: absolute;\n top: 0;\n left: 0;\n}\nsd-textfield[data-sd-inset=true] > input,\nsd-textfield[data-sd-inset=true] > ._contents {\n width: 100%;\n border: none;\n border-radius: 0;\n}\nsd-textfield[data-sd-inset=true] > input:focus {\n outline: 1px solid var(--theme-primary-default);\n outline-offset: -1px;\n}\nsd-textfield[data-sd-inset=true][data-sd-type=month] > input,\nsd-textfield[data-sd-inset=true][data-sd-type=month] > ._contents {\n min-width: 8.25em;\n}\nsd-textfield[data-sd-inset=true][data-sd-type=date] > input,\nsd-textfield[data-sd-inset=true][data-sd-type=date] > ._contents {\n min-width: 8.25em;\n}\nsd-textfield[data-sd-inset=true][data-sd-type=datetime-local] > input,\nsd-textfield[data-sd-inset=true][data-sd-type=datetime-local] > ._contents {\n min-width: 14em;\n}\nsd-textfield[data-sd-inset=true][data-sd-type=year] > input,\nsd-textfield[data-sd-inset=true][data-sd-type=year] > ._contents {\n min-width: 4em;\n}\nsd-textfield[data-sd-inset=true][data-sd-type=color] > input,\nsd-textfield[data-sd-inset=true][data-sd-type=color] > ._contents {\n height: calc(var(--font-size-default) * var(--line-height-strip-unit) + var(--gap-sm) * 2);\n}\nsd-textfield[data-sd-inset=true][data-sd-type=color][data-sd-size=sm] > input,\nsd-textfield[data-sd-inset=true][data-sd-type=color][data-sd-size=sm] > ._contents {\n height: calc(var(--font-size-default) * var(--line-height-strip-unit) + var(--gap-xs) * 2);\n}\nsd-textfield[data-sd-inset=true][data-sd-type=color][data-sd-size=lg] > input,\nsd-textfield[data-sd-inset=true][data-sd-type=color][data-sd-size=lg] > ._contents {\n height: calc(var(--font-size-default) * var(--line-height-strip-unit) + var(--gap-default) * 2);\n}\nsd-textfield[data-sd-disabled=true] > ._contents {\n display: block;\n background: var(--trans-lightest);\n color: var(--text-trans-light);\n}\nsd-textfield[data-sd-disabled=true][data-sd-inset=true] > ._contents {\n background: var(--control-color);\n color: var(--text-trans-default);\n}\nsd-textfield[data-sd-readonly=true] > ._contents {\n display: block;\n}"] }]
|
|
268
299
|
}], () => [], { value: [{ type: i0.Input, args: [{ isSignal: true, alias: "value", required: false }] }, { type: i0.Output, args: ["valueChange"] }], type: [{ type: i0.Input, args: [{ isSignal: true, alias: "type", required: true }] }], placeholder: [{ type: i0.Input, args: [{ isSignal: true, alias: "placeholder", required: false }] }], title: [{ type: i0.Input, args: [{ isSignal: true, alias: "title", required: false }] }], inputStyle: [{ type: i0.Input, args: [{ isSignal: true, alias: "inputStyle", required: false }] }], inputClass: [{ type: i0.Input, args: [{ isSignal: true, alias: "inputClass", required: false }] }], disabled: [{ type: i0.Input, args: [{ isSignal: true, alias: "disabled", required: false }] }], readonly: [{ type: i0.Input, args: [{ isSignal: true, alias: "readonly", required: false }] }], required: [{ type: i0.Input, args: [{ isSignal: true, alias: "required", required: false }] }], min: [{ type: i0.Input, args: [{ isSignal: true, alias: "min", required: false }] }], max: [{ type: i0.Input, args: [{ isSignal: true, alias: "max", required: false }] }], minlength: [{ type: i0.Input, args: [{ isSignal: true, alias: "minlength", required: false }] }], maxlength: [{ type: i0.Input, args: [{ isSignal: true, alias: "maxlength", required: false }] }], pattern: [{ type: i0.Input, args: [{ isSignal: true, alias: "pattern", required: false }] }], validatorFn: [{ type: i0.Input, args: [{ isSignal: true, alias: "validatorFn", required: false }] }], format: [{ type: i0.Input, args: [{ isSignal: true, alias: "format", required: false }] }], step: [{ type: i0.Input, args: [{ isSignal: true, alias: "step", required: false }] }], autocomplete: [{ type: i0.Input, args: [{ isSignal: true, alias: "autocomplete", required: false }] }], useNumberComma: [{ type: i0.Input, args: [{ isSignal: true, alias: "useNumberComma", required: false }] }], minDigits: [{ type: i0.Input, args: [{ isSignal: true, alias: "minDigits", required: false }] }], inline: [{ type: i0.Input, args: [{ isSignal: true, alias: "inline", required: false }] }], inset: [{ type: i0.Input, args: [{ isSignal: true, alias: "inset", required: false }] }], size: [{ type: i0.Input, args: [{ isSignal: true, alias: "size", required: false }] }], theme: [{ type: i0.Input, args: [{ isSignal: true, alias: "theme", required: false }] }], _inputElRef: [{ type: i0.ViewChild, args: ["inputEl", { isSignal: true }] }] }); })();
|
|
269
|
-
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(SdTextfield, { className: "SdTextfield", filePath: "packages/angular/src/controls/input/sd-textfield.ts", lineNumber:
|
|
300
|
+
(() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(SdTextfield, { className: "SdTextfield", filePath: "packages/angular/src/controls/input/sd-textfield.ts", lineNumber: 300 }); })();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@simplysm/angular",
|
|
3
|
-
"version": "14.1.
|
|
3
|
+
"version": "14.1.11",
|
|
4
4
|
"description": "심플리즘 패키지 - Angular",
|
|
5
5
|
"author": "심플리즘",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -39,6 +39,10 @@
|
|
|
39
39
|
"@angular/service-worker": "^22.0.4",
|
|
40
40
|
"@ng-icons/core": "^33.3.0",
|
|
41
41
|
"@ng-icons/tabler-icons": "^33.3.0",
|
|
42
|
+
"@simplysm/core-browser": "14.1.11",
|
|
43
|
+
"@simplysm/core-common": "14.1.11",
|
|
44
|
+
"@simplysm/service-client": "14.1.11",
|
|
45
|
+
"@simplysm/service-common": "14.1.11",
|
|
42
46
|
"@tiptap/core": "^3.27.1",
|
|
43
47
|
"@tiptap/extension-color": "^3.27.1",
|
|
44
48
|
"@tiptap/extension-document": "^3.27.1",
|
|
@@ -57,14 +61,10 @@
|
|
|
57
61
|
"html-to-image": "^1.11.13",
|
|
58
62
|
"jspdf": "^4.2.1",
|
|
59
63
|
"rxjs": "^7.8.2",
|
|
60
|
-
"tabbable": "^6.5.0"
|
|
61
|
-
"@simplysm/core-browser": "14.1.9",
|
|
62
|
-
"@simplysm/core-common": "14.1.9",
|
|
63
|
-
"@simplysm/service-client": "14.1.9",
|
|
64
|
-
"@simplysm/service-common": "14.1.9"
|
|
64
|
+
"tabbable": "^6.5.0"
|
|
65
65
|
},
|
|
66
66
|
"devDependencies": {
|
|
67
67
|
"@angular/compiler": "^22.0.4"
|
|
68
68
|
},
|
|
69
69
|
"scripts": {}
|
|
70
|
-
}
|
|
70
|
+
}
|
|
@@ -74,6 +74,11 @@ export interface TextfieldTypeHandler {
|
|
|
74
74
|
toControlValue(value: unknown, opts: TextfieldFormatOpts): string;
|
|
75
75
|
toDisplayText(value: unknown, opts: TextfieldDisplayOpts): string | undefined;
|
|
76
76
|
validate(value: unknown, opts: TextfieldValidateOpts): string[];
|
|
77
|
+
/**
|
|
78
|
+
* parse 는 실패했으나 계속 입력하면 유효해질 수 있는 "완성 전 진행" 입력인지 여부.
|
|
79
|
+
* true 면 onInput 이 DOM 을 되쓰지 않아 사용자가 입력을 이어갈 수 있다 (예: number "-", "12.").
|
|
80
|
+
*/
|
|
81
|
+
isIncomplete?(raw: string, opts: TextfieldParseOpts): boolean;
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
// endregion
|
|
@@ -153,6 +158,15 @@ function createNumberHandler(): TextfieldTypeHandler {
|
|
|
153
158
|
const result = Number.parseFloat(inputValue);
|
|
154
159
|
return Number.isNaN(result) ? undefined : result;
|
|
155
160
|
},
|
|
161
|
+
isIncomplete(raw) {
|
|
162
|
+
const inputValue = raw.replace(/[^0-9-.]/g, "");
|
|
163
|
+
// 부호·소수점 형식은 갖췄으나 아직 완성되지 않은 진행 상태 ("-", "12.", ".", "-.")
|
|
164
|
+
// Number("12.")===12 이므로 endsWith(".") 를 별도 판정 (parse 의 실패 조건과 정합)
|
|
165
|
+
return (
|
|
166
|
+
/^-?\d*\.?\d*$/.test(inputValue)
|
|
167
|
+
&& (Number.isNaN(Number(inputValue)) || inputValue.endsWith("."))
|
|
168
|
+
);
|
|
169
|
+
},
|
|
156
170
|
toControlValue(value, opts) {
|
|
157
171
|
const numValue = value as number;
|
|
158
172
|
return opts.useNumberComma !== false
|
|
@@ -207,13 +221,25 @@ function createFormatHandler(): TextfieldTypeHandler {
|
|
|
207
221
|
const format = opts.format;
|
|
208
222
|
if (format == null || format === "") return raw;
|
|
209
223
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
);
|
|
224
|
+
// 위치기반: raw 길이가 마스크(대안 중 하나)의 길이와 같으면 X 자리 문자만 추출.
|
|
225
|
+
// 리터럴이 데이터 문자와 겹쳐도(예: "2025-XXXX") 안전 (toControlValue 와 대칭).
|
|
226
|
+
const formatItems = format.split("|");
|
|
227
|
+
for (const formatItem of formatItems) {
|
|
228
|
+
if (raw.length === formatItem.length) {
|
|
229
|
+
let result = "";
|
|
230
|
+
for (let i = 0; i < formatItem.length; i++) {
|
|
231
|
+
if (formatItem[i] === "X") result += raw[i];
|
|
232
|
+
}
|
|
233
|
+
return result;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 폴백: 길이 불일치(구분자 생략 등)면 리터럴 문자를 전역 제거.
|
|
238
|
+
const nonFormatChars = format.match(/[^X|]/g)?.filter((v, i, a) => a.indexOf(v) === i);
|
|
213
239
|
if (nonFormatChars != null && nonFormatChars.length > 0) {
|
|
214
|
-
const escaped = nonFormatChars
|
|
215
|
-
ch === "]" || ch === "\\" || ch === "^" || ch === "-" ? "\\" + ch : ch
|
|
216
|
-
|
|
240
|
+
const escaped = nonFormatChars
|
|
241
|
+
.map((ch) => (ch === "]" || ch === "\\" || ch === "^" || ch === "-" ? "\\" + ch : ch))
|
|
242
|
+
.join("");
|
|
217
243
|
return raw.replace(new RegExp(`[${escaped}]`, "g"), "");
|
|
218
244
|
}
|
|
219
245
|
return raw;
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
input,
|
|
11
11
|
model,
|
|
12
12
|
PLATFORM_ID,
|
|
13
|
+
signal,
|
|
13
14
|
viewChild,
|
|
14
15
|
ViewEncapsulation,
|
|
15
16
|
} from "@angular/core";
|
|
@@ -58,7 +59,8 @@ import {
|
|
|
58
59
|
[attr.autocomplete]="autocomplete()"
|
|
59
60
|
[attr.step]="controlStep()"
|
|
60
61
|
(input)="onInput($event)"
|
|
61
|
-
(
|
|
62
|
+
(compositionstart)="onCompositionStart()"
|
|
63
|
+
(compositionend)="onCompositionEnd($event)"
|
|
62
64
|
(blur)="onBlur($event)"
|
|
63
65
|
/>
|
|
64
66
|
}
|
|
@@ -330,6 +332,12 @@ export class SdTextfield<K extends keyof SdTextfieldTypes> {
|
|
|
330
332
|
|
|
331
333
|
private readonly _inputElRef = viewChild<ElementRef<HTMLInputElement>>("inputEl");
|
|
332
334
|
|
|
335
|
+
// 실제 input 의 브라우저 native 제약 위반(이메일 형식·날짜 미완성 등) 메시지. onInput/onBlur 에서 갱신.
|
|
336
|
+
private readonly _nativeInvalidMessage = signal("");
|
|
337
|
+
|
|
338
|
+
// IME 조합(한글 등) 중 여부. 조합 중에는 미완성 자모가 model 로 흐르지 않도록 갱신을 보류.
|
|
339
|
+
private _composing = false;
|
|
340
|
+
|
|
333
341
|
private readonly _handler = computed(() => textfieldTypeHandlers[this.type()]);
|
|
334
342
|
|
|
335
343
|
controlType = computed(() => this._handler().controlType);
|
|
@@ -360,8 +368,23 @@ export class SdTextfield<K extends keyof SdTextfieldTypes> {
|
|
|
360
368
|
const inputEl = this._inputElRef()?.nativeElement;
|
|
361
369
|
if (inputEl == null) return;
|
|
362
370
|
|
|
363
|
-
// 편집(포커스)
|
|
364
|
-
|
|
371
|
+
// 편집(포커스) 중이라도 변경 origin 을 구분한다.
|
|
372
|
+
// 사용자 입력이 만든 DOM(정규화 차이·진행 중 입력 포함)은 보호하고,
|
|
373
|
+
// 모델만 외부에서 바뀐 경우(프로그래밍적 리셋·prefill·표시옵션 변경)는 되써서 반영한다.
|
|
374
|
+
if (document.activeElement === inputEl) {
|
|
375
|
+
const domParsed = this._handler().parse(inputEl.value, { format: this.format() });
|
|
376
|
+
// DOM raw 가 현재 모델의 유효한 표현이면(사용자 입력 유래) 되쓰지 않아 캐럿·IME 보호
|
|
377
|
+
const domReflectsModel =
|
|
378
|
+
domParsed != null
|
|
379
|
+
&& this._handler().toControlValue(domParsed, {
|
|
380
|
+
useNumberComma: this.useNumberComma(),
|
|
381
|
+
format: this.format(),
|
|
382
|
+
}) === controlValue;
|
|
383
|
+
// 계속 입력하면 유효해질 진행 중 입력(number "12.")도 보호
|
|
384
|
+
const incomplete =
|
|
385
|
+
this._handler().isIncomplete?.(inputEl.value, { format: this.format() }) === true;
|
|
386
|
+
if (domReflectsModel || incomplete) return;
|
|
387
|
+
}
|
|
365
388
|
|
|
366
389
|
if (inputEl.value !== controlValue) {
|
|
367
390
|
inputEl.value = controlValue;
|
|
@@ -370,6 +393,9 @@ export class SdTextfield<K extends keyof SdTextfieldTypes> {
|
|
|
370
393
|
}
|
|
371
394
|
|
|
372
395
|
setupInvalid(() => {
|
|
396
|
+
// 비활성·읽기전용 필드는 사용자가 편집할 수 없으므로 검증에서 제외 (네이티브 disabled 동작과 동일)
|
|
397
|
+
if (this.disabled() || this.readonly()) return "";
|
|
398
|
+
|
|
373
399
|
const value = this.value();
|
|
374
400
|
const handlerErrors = this._handler().validate(value, {
|
|
375
401
|
required: this.required(),
|
|
@@ -390,22 +416,46 @@ export class SdTextfield<K extends keyof SdTextfieldTypes> {
|
|
|
390
416
|
}
|
|
391
417
|
}
|
|
392
418
|
|
|
419
|
+
// 브라우저 native 제약 위반(이메일 형식·날짜 미완성 등)을 우리 검증(빨간점)에 합류
|
|
420
|
+
const nativeMessage = this._nativeInvalidMessage();
|
|
421
|
+
if (nativeMessage !== "") {
|
|
422
|
+
errorMessages.push(nativeMessage);
|
|
423
|
+
}
|
|
424
|
+
|
|
393
425
|
return errorMessages.join("\r\n");
|
|
394
426
|
});
|
|
395
427
|
}
|
|
396
428
|
|
|
429
|
+
onCompositionStart(): void {
|
|
430
|
+
this._composing = true;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
onCompositionEnd(event: CompositionEvent): void {
|
|
434
|
+
this._composing = false;
|
|
435
|
+
this._applyInput(event.target as HTMLInputElement);
|
|
436
|
+
}
|
|
437
|
+
|
|
397
438
|
onInput(event: Event): void {
|
|
398
|
-
|
|
439
|
+
// 조합 중 input 은 무시하고, 완성 시 onCompositionEnd 에서 한 번만 반영
|
|
440
|
+
if (this._composing) return;
|
|
441
|
+
this._applyInput(event.target as HTMLInputElement);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
private _applyInput(inputEl: HTMLInputElement): void {
|
|
399
445
|
if (inputEl.value === "") {
|
|
400
446
|
this.value.set(undefined);
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
447
|
+
} else {
|
|
448
|
+
const parsed = this._handler().parse(inputEl.value, { format: this.format() });
|
|
449
|
+
if (parsed == null) {
|
|
450
|
+
// 계속 입력하면 유효해질 진행 중 입력(number "12.", "-")은 되쓰지 않아 입력을 이어가게 함
|
|
451
|
+
if (this._handler().isIncomplete?.(inputEl.value, { format: this.format() }) !== true) {
|
|
452
|
+
inputEl.value = this.controlValue();
|
|
453
|
+
}
|
|
454
|
+
} else {
|
|
455
|
+
this.value.set(parsed as SdTextfieldTypes[K]);
|
|
456
|
+
}
|
|
407
457
|
}
|
|
408
|
-
this.
|
|
458
|
+
this._syncNativeValidity(inputEl);
|
|
409
459
|
}
|
|
410
460
|
|
|
411
461
|
onBlur(event: FocusEvent): void {
|
|
@@ -413,25 +463,11 @@ export class SdTextfield<K extends keyof SdTextfieldTypes> {
|
|
|
413
463
|
// date 미완성 입력은 input.value 가 "" 라 비교 없이 무조건 되써야 남은 세그먼트가 정리됨
|
|
414
464
|
const inputEl = event.target as HTMLInputElement;
|
|
415
465
|
inputEl.value = this.controlValue();
|
|
466
|
+
this._syncNativeValidity(inputEl);
|
|
416
467
|
}
|
|
417
468
|
|
|
418
|
-
|
|
419
|
-
//
|
|
420
|
-
|
|
421
|
-
event.preventDefault();
|
|
422
|
-
const inputEl = event.target as HTMLInputElement;
|
|
423
|
-
const text = event.clipboardData?.getData("text/plain").trim();
|
|
424
|
-
if (text == null || text === "") {
|
|
425
|
-
this.value.set(undefined);
|
|
426
|
-
inputEl.value = "";
|
|
427
|
-
return;
|
|
428
|
-
}
|
|
429
|
-
const parsed = this._handler().parse(text, { format: this.format() });
|
|
430
|
-
if (parsed == null) {
|
|
431
|
-
inputEl.value = this.controlValue();
|
|
432
|
-
return;
|
|
433
|
-
}
|
|
434
|
-
this.value.set(parsed as SdTextfieldTypes[K] | undefined);
|
|
435
|
-
inputEl.value = this.controlValue();
|
|
469
|
+
private _syncNativeValidity(inputEl: HTMLInputElement): void {
|
|
470
|
+
// 실제 input 의 브라우저 native 제약 위반을 signal 로 반영 → setupInvalid 콜백이 검증에 합류
|
|
471
|
+
this._nativeInvalidMessage.set(inputEl.validity.valid ? "" : inputEl.validationMessage);
|
|
436
472
|
}
|
|
437
473
|
}
|