@simplysm/angular 14.1.8 → 14.1.10

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.
@@ -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;CACjE;AAkXD,eAAO,MAAM,qBAAqB,EAAE,MAAM,CAAC,MAAM,gBAAgB,EAAE,oBAAoB,CActF,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
- const nonFormatChars = format.match(/[^X]/g)?.filter((v, i, a) => a.indexOf(v) === i);
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.map((ch) => ch === "]" || ch === "\\" || ch === "^" || ch === "-" ? "\\" + ch : ch).join("");
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
- onInputPaste(event: ClipboardEvent): void;
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":"AAgBA,OAAO,EAEL,KAAK,gBAAgB,EACtB,MAAM,8BAA8B,CAAC;;AAEtC,qBAoRa,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;IAElF,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAsD;IAE/E,WAAW,yCAA+C;IAE1D,WAAW,kDAA+D;IAE1E,YAAY,yCAOT;IAEH,gBAAgB,qDAMb;;IA2CH,OAAO,CAAC,KAAK,EAAE,KAAK,GAAG,IAAI;IAc3B,MAAM,CAAC,KAAK,EAAE,UAAU,GAAG,IAAI;IAO/B,YAAY,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI;yCAxH9B,WAAW;2CAAX,WAAW;CA2IvB"}
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)); })("paste", function SdTextfield_Conditional_3_Template_input_paste_0_listener($event) { i0.ɵɵrestoreView(_r2); const ctx_r0 = i0.ɵɵnextContext(); return i0.ɵɵresetView(ctx_r0.onInputPaste($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)); });
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
- // 편집(포커스) 중에는 DOM되쓰면 사용자 입력(세그먼트·캐럿·IME 조합)이 깨지므로 생략
131
- if (document.activeElement === inputEl)
132
- return;
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
- const inputEl = event.target;
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
- const parsed = this._handler().parse(inputEl.value, { format: this.format() });
166
- if (parsed == null) {
167
- inputEl.value = this.controlValue();
168
- return;
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.value.set(parsed);
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
- onInputPaste(event) {
179
- // preventDefault 기본 붙여넣기를 막으므로, 포커스 동기화를 건너뛰는 effect 대신
180
- // 핸들러에서 직접 input.value 모델 기준값으로 되써야 화면에 반영됨 (onBlur 와 동일 패턴)
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", "paste", "blur", "type"]], template: function SdTextfield_Template(rf, ctx) { if (rf & 1) {
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
- (paste)="onInputPaste($event)"
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: 298 }); })();
300
+ (() => { (typeof ngDevMode === "undefined" || ngDevMode) && i0.ɵsetClassDebugInfo(SdTextfield, { className: "SdTextfield", filePath: "packages/angular/src/controls/input/sd-textfield.ts", lineNumber: 300 }); })();
@@ -3,5 +3,5 @@ import type { WritableSignal } from "@angular/core";
3
3
  * WritableSignal의 값이 in-place mutation된 후 소비자에게 변경을 알린다.
4
4
  * shallow copy로 새 참조를 생성하여 signal을 업데이트한다.
5
5
  */
6
- export declare function mark<T extends object>(sig: WritableSignal<T>): void;
6
+ export declare function mark<T extends object | undefined>(sig: WritableSignal<T>): void;
7
7
  //# sourceMappingURL=mark.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"mark.d.ts","sourceRoot":"","sources":["../../src/core/mark.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD;;;GAGG;AACH,wBAAgB,IAAI,CAAC,CAAC,SAAS,MAAM,EAAE,GAAG,EAAE,cAAc,CAAC,CAAC,CAAC,GAAG,IAAI,CAEnE"}
1
+ {"version":3,"file":"mark.d.ts","sourceRoot":"","sources":["../../src/core/mark.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,eAAe,CAAC;AAEpD;;;GAGG;AACH,wBAAgB,IAAI,CAAC,CAAC,SAAS,MAAM,GAAG,SAAS,EAAE,GAAG,EAAE,cAAc,CAAC,CAAC,CAAC,GAAG,IAAI,CAM/E"}
package/dist/core/mark.js CHANGED
@@ -3,5 +3,10 @@
3
3
  * shallow copy로 새 참조를 생성하여 signal을 업데이트한다.
4
4
  */
5
5
  export function mark(sig) {
6
- sig.update((v) => (Array.isArray(v) ? [...v] : { ...v }));
6
+ sig.update((v) => {
7
+ // undefined는 복제할 대상이 없다 — 그대로 둔다(통지할 변경 없음).
8
+ if (v == null)
9
+ return v;
10
+ return (Array.isArray(v) ? [...v] : { ...v });
11
+ });
7
12
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@simplysm/angular",
3
- "version": "14.1.8",
3
+ "version": "14.1.10",
4
4
  "description": "심플리즘 패키지 - Angular",
5
5
  "author": "심플리즘",
6
6
  "license": "Apache-2.0",
@@ -12,6 +12,13 @@
12
12
  "type": "module",
13
13
  "main": "./dist/index.js",
14
14
  "types": "./dist/index.d.ts",
15
+ "typesVersions": {
16
+ "*": {
17
+ "client": [
18
+ "./src/client.d.ts"
19
+ ]
20
+ }
21
+ },
15
22
  "style": "./styles.css",
16
23
  "files": [
17
24
  "dist",
@@ -32,6 +39,10 @@
32
39
  "@angular/service-worker": "^22.0.4",
33
40
  "@ng-icons/core": "^33.3.0",
34
41
  "@ng-icons/tabler-icons": "^33.3.0",
42
+ "@simplysm/core-browser": "14.1.9",
43
+ "@simplysm/core-common": "14.1.9",
44
+ "@simplysm/service-client": "14.1.9",
45
+ "@simplysm/service-common": "14.1.9",
35
46
  "@tiptap/core": "^3.27.1",
36
47
  "@tiptap/extension-color": "^3.27.1",
37
48
  "@tiptap/extension-document": "^3.27.1",
@@ -50,14 +61,10 @@
50
61
  "html-to-image": "^1.11.13",
51
62
  "jspdf": "^4.2.1",
52
63
  "rxjs": "^7.8.2",
53
- "tabbable": "^6.5.0",
54
- "@simplysm/core-browser": "14.1.8",
55
- "@simplysm/service-client": "14.1.8",
56
- "@simplysm/service-common": "14.1.8",
57
- "@simplysm/core-common": "14.1.8"
64
+ "tabbable": "^6.5.0"
58
65
  },
59
66
  "devDependencies": {
60
67
  "@angular/compiler": "^22.0.4"
61
68
  },
62
69
  "scripts": {}
63
- }
70
+ }
@@ -0,0 +1,63 @@
1
+ // sd-cli(esbuild) 클라이언트 빌드가 지원하는 비-JS 모듈의 타입 선언.
2
+ // vite/client 대응물 — 소비처는 src/sd-env.d.ts 에서
3
+ // `/// <reference types="@simplysm/angular/client" />` 로 이 선언들을 끌어온다.
4
+
5
+ // 스타일 (side-effect import)
6
+ declare module "*.scss" {}
7
+ declare module "*.css" {}
8
+
9
+ // 스타일 (inline 문자열)
10
+ declare module "*.scss?inline" {
11
+ const content: string;
12
+ export default content;
13
+ }
14
+
15
+ // 자산 (esbuild file loader → URL 문자열)
16
+ declare module "*.woff" {
17
+ const url: string;
18
+ export default url;
19
+ }
20
+ declare module "*.woff2" {
21
+ const url: string;
22
+ export default url;
23
+ }
24
+ declare module "*.ttf" {
25
+ const url: string;
26
+ export default url;
27
+ }
28
+ declare module "*.eot" {
29
+ const url: string;
30
+ export default url;
31
+ }
32
+ declare module "*.otf" {
33
+ const url: string;
34
+ export default url;
35
+ }
36
+ declare module "*.svg" {
37
+ const url: string;
38
+ export default url;
39
+ }
40
+ declare module "*.png" {
41
+ const url: string;
42
+ export default url;
43
+ }
44
+ declare module "*.jpg" {
45
+ const url: string;
46
+ export default url;
47
+ }
48
+ declare module "*.jpeg" {
49
+ const url: string;
50
+ export default url;
51
+ }
52
+ declare module "*.gif" {
53
+ const url: string;
54
+ export default url;
55
+ }
56
+ declare module "*.webp" {
57
+ const url: string;
58
+ export default url;
59
+ }
60
+ declare module "*.ico" {
61
+ const url: string;
62
+ export default url;
63
+ }
@@ -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
- const nonFormatChars = format.match(/[^X]/g)?.filter(
211
- (v, i, a) => a.indexOf(v) === i,
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.map((ch) =>
215
- ch === "]" || ch === "\\" || ch === "^" || ch === "-" ? "\\" + ch : ch,
216
- ).join("");
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
- (paste)="onInputPaste($event)"
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
- // 편집(포커스) 중에는 DOM되쓰면 사용자 입력(세그먼트·캐럿·IME 조합)이 깨지므로 생략
364
- if (document.activeElement === inputEl) return;
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
- const inputEl = event.target as HTMLInputElement;
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
- return;
402
- }
403
- const parsed = this._handler().parse(inputEl.value, { format: this.format() });
404
- if (parsed == null) {
405
- inputEl.value = this.controlValue();
406
- return;
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.value.set(parsed as SdTextfieldTypes[K]);
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
- onInputPaste(event: ClipboardEvent): void {
419
- // preventDefault 기본 붙여넣기를 막으므로, 포커스 동기화를 건너뛰는 effect 대신
420
- // 핸들러에서 직접 input.value 모델 기준값으로 되써야 화면에 반영됨 (onBlur 와 동일 패턴)
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
  }
package/src/core/mark.ts CHANGED
@@ -4,6 +4,10 @@ import type { WritableSignal } from "@angular/core";
4
4
  * WritableSignal의 값이 in-place mutation된 후 소비자에게 변경을 알린다.
5
5
  * shallow copy로 새 참조를 생성하여 signal을 업데이트한다.
6
6
  */
7
- export function mark<T extends object>(sig: WritableSignal<T>): void {
8
- sig.update((v) => (Array.isArray(v) ? [...(v as unknown[])] : { ...v }) as T);
7
+ export function mark<T extends object | undefined>(sig: WritableSignal<T>): void {
8
+ sig.update((v) => {
9
+ // undefined는 복제할 대상이 없다 — 그대로 둔다(통지할 변경 없음).
10
+ if (v == null) return v;
11
+ return (Array.isArray(v) ? [...(v as unknown[])] : { ...v }) as T;
12
+ });
9
13
  }
package/src/scss.d.ts DELETED
@@ -1,6 +0,0 @@
1
- declare module "*.scss" {}
2
-
3
- declare module "*.scss?inline" {
4
- const content: string;
5
- export default content;
6
- }