@simplysm/angular 14.2.2 → 14.2.3
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/button/sd-anchor.d.ts +1 -1
- package/dist/controls/button/sd-anchor.d.ts.map +1 -1
- package/dist/controls/button/sd-anchor.js +2 -2
- package/dist/controls/button/sd-button.d.ts +1 -1
- package/dist/controls/button/sd-button.d.ts.map +1 -1
- package/dist/controls/button/sd-button.js +2 -2
- package/dist/controls/checkbox/sd-checkbox.d.ts +1 -1
- package/dist/controls/checkbox/sd-checkbox.d.ts.map +1 -1
- package/dist/controls/checkbox/sd-checkbox.js +2 -2
- package/dist/controls/checkbox/sd-switch.d.ts +1 -1
- package/dist/controls/checkbox/sd-switch.d.ts.map +1 -1
- package/dist/controls/checkbox/sd-switch.js +2 -2
- package/dist/controls/dropdown/sd-dropdown.d.ts +3 -0
- package/dist/controls/dropdown/sd-dropdown.d.ts.map +1 -1
- package/dist/controls/dropdown/sd-dropdown.js +29 -9
- package/dist/controls/input/sd-textarea.d.ts +1 -1
- package/dist/controls/input/sd-textarea.d.ts.map +1 -1
- package/dist/controls/input/sd-textarea.js +2 -2
- package/dist/controls/input/sd-textfield-type-handlers.js +1 -1
- package/dist/controls/input/sd-textfield.d.ts +1 -1
- package/dist/controls/input/sd-textfield.d.ts.map +1 -1
- package/dist/controls/input/sd-textfield.js +9 -10
- package/dist/controls/list/sd-list.d.ts.map +1 -1
- package/dist/controls/list/sd-list.js +4 -6
- package/dist/controls/select/sd-select-button.d.ts.map +1 -1
- package/dist/controls/select/sd-select-button.js +3 -5
- package/dist/controls/select/sd-select.js +2 -2
- package/dist/core/error-handler/sd-global-error-handler.plugin.js +1 -1
- package/dist/core/modal/sd-modal.provider.js +1 -1
- package/dist/core/provideSdAngular.d.ts.map +1 -1
- package/dist/core/provideSdAngular.js +1 -13
- package/dist/core/setupBgTheme.d.ts +1 -1
- package/dist/core/setupBgTheme.d.ts.map +1 -1
- package/dist/core/toast/sd-toast.js +2 -2
- package/dist/core/toast/sd-toast.provider.d.ts +1 -1
- package/dist/core/toast/sd-toast.provider.d.ts.map +1 -1
- package/dist/core/toast/sd-toast.provider.js +1 -1
- package/dist/data/crud/sd-base-container.js +2 -2
- package/dist/data/sheet/injectSheetDomAccessor.d.ts +1 -0
- package/dist/data/sheet/injectSheetDomAccessor.d.ts.map +1 -1
- package/dist/data/sheet/injectSheetDomAccessor.js +7 -0
- package/dist/data/sheet/injectSheetDragSelection.d.ts +18 -0
- package/dist/data/sheet/injectSheetDragSelection.d.ts.map +1 -0
- package/dist/data/sheet/injectSheetDragSelection.js +193 -0
- package/dist/data/sheet/sd-sheet.d.ts +3 -0
- package/dist/data/sheet/sd-sheet.d.ts.map +1 -1
- package/dist/data/sheet/sd-sheet.js +17 -6
- package/dist/features/editor/sd-tiptap-editor.js +2 -2
- package/dist/features/theme/sd-theme-provider.d.ts +0 -2
- package/dist/features/theme/sd-theme-provider.d.ts.map +1 -1
- package/dist/features/theme/sd-theme-provider.js +0 -5
- package/dist/features/theme/sd-theme-selector.d.ts.map +1 -1
- package/dist/features/theme/sd-theme-selector.js +5 -22
- package/dist/features/visual/sd-label.d.ts +1 -1
- package/dist/features/visual/sd-label.d.ts.map +1 -1
- package/dist/features/visual/sd-label.js +2 -2
- package/dist/features/visual/sd-note.d.ts +1 -1
- package/dist/features/visual/sd-note.d.ts.map +1 -1
- package/dist/features/visual/sd-note.js +2 -2
- package/dist/features/visual/sd-progress.d.ts +1 -1
- package/dist/features/visual/sd-progress.d.ts.map +1 -1
- package/dist/features/visual/sd-progress.js +2 -2
- package/dist/layout/sidebar/sd-sidebar.d.ts.map +1 -1
- package/dist/layout/sidebar/sd-sidebar.js +3 -5
- package/dist/layout/topbar/sd-topbar.d.ts.map +1 -1
- package/dist/layout/topbar/sd-topbar.js +3 -3
- package/package.json +5 -5
- package/scss/commons/_mixins.scss +1 -1
- package/scss/commons/_theme-variables.scss +0 -8
- package/scss/commons/_variables.scss +114 -121
- package/scss/themes/_variables-blueprint.scss +65 -63
- package/scss/themes/_variables-ide-dark.scss +166 -144
- package/src/controls/button/sd-anchor.ts +3 -3
- package/src/controls/button/sd-button.ts +1 -3
- package/src/controls/checkbox/sd-checkbox.ts +2 -10
- package/src/controls/checkbox/sd-switch.ts +2 -4
- package/src/controls/dropdown/sd-dropdown.ts +33 -9
- package/src/controls/input/sd-textarea.ts +2 -4
- package/src/controls/input/sd-textfield-type-handlers.ts +1 -1
- package/src/controls/input/sd-textfield.ts +9 -12
- package/src/controls/list/sd-list.ts +3 -5
- package/src/controls/select/sd-select-button.ts +2 -4
- package/src/controls/select/sd-select.ts +1 -1
- package/src/core/error-handler/sd-global-error-handler.plugin.ts +1 -1
- package/src/core/modal/sd-modal.provider.ts +1 -1
- package/src/core/provideSdAngular.ts +1 -15
- package/src/core/setupBgTheme.ts +1 -2
- package/src/core/toast/sd-toast.provider.ts +5 -3
- package/src/data/crud/sd-base-container.ts +1 -1
- package/src/data/sheet/injectSheetDomAccessor.ts +7 -0
- package/src/data/sheet/injectSheetDragSelection.ts +226 -0
- package/src/data/sheet/sd-sheet.ts +16 -3
- package/src/features/editor/sd-tiptap-editor.ts +1 -1
- package/src/features/theme/sd-theme-provider.ts +0 -9
- package/src/features/theme/sd-theme-selector.ts +1 -10
- package/src/features/visual/sd-label.ts +1 -3
- package/src/features/visual/sd-note.ts +1 -3
- package/src/features/visual/sd-progress.ts +2 -2
- package/src/layout/sidebar/sd-sidebar.ts +2 -3
- package/src/layout/topbar/sd-topbar.ts +1 -0
- package/styles.css +278 -567
- package/scss/commons/_colors.scss +0 -301
- package/scss/commons/_density.scss +0 -23
- package/scss/sd-tokens.md +0 -132
|
@@ -56,7 +56,7 @@ import { setupModelHook } from "../../core/setupModelHook";
|
|
|
56
56
|
height: calc(var(--sd-line-height) - var(--sd-gap-xs));
|
|
57
57
|
border-radius: 100%;
|
|
58
58
|
|
|
59
|
-
background-color: var(--sd-bg-
|
|
59
|
+
background-color: var(--sd-bg-knob);
|
|
60
60
|
|
|
61
61
|
transition: transform var(--sd-animation-duration);
|
|
62
62
|
}
|
|
@@ -123,9 +123,7 @@ export class SdSwitch {
|
|
|
123
123
|
inset = input(false, { transform: booleanAttribute });
|
|
124
124
|
|
|
125
125
|
size = input<"sm" | "lg">();
|
|
126
|
-
theme = input<
|
|
127
|
-
"primary" | "secondary" | "info" | "success" | "warning" | "danger" | "gray" | "blue-gray"
|
|
128
|
-
>();
|
|
126
|
+
theme = input<"primary" | "info" | "success" | "warning" | "danger" | "gray" | "blue-gray">();
|
|
129
127
|
|
|
130
128
|
constructor() {
|
|
131
129
|
setupModelHook(this.value, this.canChangeFn);
|
|
@@ -42,7 +42,8 @@ export class SdDropdown {
|
|
|
42
42
|
});
|
|
43
43
|
|
|
44
44
|
private _popupEl?: HTMLElement;
|
|
45
|
-
|
|
45
|
+
/** `<svg>` 등 비-HTML 요소도 hover 대상이 되므로 `Element` 로 받는다. */
|
|
46
|
+
private _mouseoverEl?: Element;
|
|
46
47
|
private _backdropEl?: HTMLElement;
|
|
47
48
|
private readonly _isMobile;
|
|
48
49
|
private readonly _mql: MediaQueryList;
|
|
@@ -88,7 +89,8 @@ export class SdDropdown {
|
|
|
88
89
|
this._onDocumentScrollCapture(event);
|
|
89
90
|
};
|
|
90
91
|
const onMouseover = (event: Event) => {
|
|
91
|
-
|
|
92
|
+
const target = (event as MouseEvent).target;
|
|
93
|
+
this._mouseoverEl = target instanceof Element ? target : undefined;
|
|
92
94
|
};
|
|
93
95
|
const onBlur = (event: Event) => {
|
|
94
96
|
this._onDocumentBlurCapture(event as FocusEvent);
|
|
@@ -245,18 +247,27 @@ export class SdDropdown {
|
|
|
245
247
|
}
|
|
246
248
|
}
|
|
247
249
|
|
|
250
|
+
// 팝업 내부 요소가 disabled 로 전환되며 포커스를 잃은 경우.
|
|
251
|
+
// 브라우저가 렌더 도중 동기 blur 를 발사하는 경로이며, 사용자가 팝업을 벗어난 것이 아니다.
|
|
252
|
+
// 여기서 팝업을 닫으면 렌더 중 signal 쓰기가 되어 NG0600 이 발생한다.
|
|
253
|
+
const blurredEl = event.target;
|
|
254
|
+
if (
|
|
255
|
+
relatedTarget == null &&
|
|
256
|
+
blurredEl instanceof HTMLElement &&
|
|
257
|
+
(contentEl.contains(blurredEl) === true || popupEl.contains(blurredEl) === true) &&
|
|
258
|
+
blurredEl.matches(":disabled")
|
|
259
|
+
) {
|
|
260
|
+
this._restoreFocus();
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
263
|
+
|
|
248
264
|
const mouseoverEl = this._mouseoverEl;
|
|
249
265
|
if (
|
|
250
266
|
relatedTarget == null &&
|
|
251
|
-
mouseoverEl instanceof
|
|
267
|
+
mouseoverEl instanceof Element &&
|
|
252
268
|
(contentEl.contains(mouseoverEl) === true || popupEl.contains(mouseoverEl) === true)
|
|
253
269
|
) {
|
|
254
|
-
|
|
255
|
-
if (tabbable != null) {
|
|
256
|
-
tabbable.focus();
|
|
257
|
-
} else {
|
|
258
|
-
contentEl.focus();
|
|
259
|
-
}
|
|
270
|
+
this._restoreFocus();
|
|
260
271
|
return;
|
|
261
272
|
}
|
|
262
273
|
|
|
@@ -265,6 +276,19 @@ export class SdDropdown {
|
|
|
265
276
|
}
|
|
266
277
|
}
|
|
267
278
|
|
|
279
|
+
/** 팝업이 열린 상태를 유지해야 할 때 포커스를 팝업 안으로 되돌린다. */
|
|
280
|
+
private _restoreFocus(): void {
|
|
281
|
+
const popupEl = this._popupEl;
|
|
282
|
+
if (popupEl == null) return;
|
|
283
|
+
|
|
284
|
+
const tabbable = popupEl.findFirstTabbableChild();
|
|
285
|
+
if (tabbable != null) {
|
|
286
|
+
tabbable.focus();
|
|
287
|
+
} else {
|
|
288
|
+
this._elRef.nativeElement.focus();
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
268
292
|
private _removePopup(): void {
|
|
269
293
|
if (this._backdropEl != null) {
|
|
270
294
|
this._backdropEl.remove();
|
|
@@ -160,7 +160,7 @@ import { setupInvalid } from "../../core/validation/setupInvalid";
|
|
|
160
160
|
// inset(시트 셀 등)의 disabled 는 일반 콘텐츠처럼 표시(현행 유지)
|
|
161
161
|
&[data-sd-inset="true"] {
|
|
162
162
|
> ._contents {
|
|
163
|
-
background-color: var(--sd-bg-
|
|
163
|
+
background-color: var(--sd-bg-content);
|
|
164
164
|
color: var(--sd-tx-default);
|
|
165
165
|
}
|
|
166
166
|
}
|
|
@@ -190,9 +190,7 @@ export class SdTextarea {
|
|
|
190
190
|
inset = input(false, { transform: booleanAttribute });
|
|
191
191
|
size = input<"sm" | "lg">();
|
|
192
192
|
validatorFn = input<(value: string | undefined) => string | undefined>();
|
|
193
|
-
theme = input<
|
|
194
|
-
"primary" | "secondary" | "info" | "success" | "warning" | "danger" | "gray" | "blue-gray"
|
|
195
|
-
>();
|
|
193
|
+
theme = input<"primary" | "info" | "success" | "warning" | "danger" | "gray" | "blue-gray">();
|
|
196
194
|
inputStyle = input<string>();
|
|
197
195
|
inputClass = input<string>();
|
|
198
196
|
|
|
@@ -160,7 +160,7 @@ function createNumberHandler(): TextfieldTypeHandler {
|
|
|
160
160
|
},
|
|
161
161
|
isIncomplete(raw) {
|
|
162
162
|
const inputValue = raw.replace(/[^0-9-.]/g, "");
|
|
163
|
-
//
|
|
163
|
+
// 부호, 소수점 형식은 갖췄으나 아직 완성되지 않은 진행 상태 ("-", "12.", ".", "-.")
|
|
164
164
|
// Number("12.")===12 이므로 endsWith(".") 를 별도 판정 (parse 의 실패 조건과 정합)
|
|
165
165
|
return (
|
|
166
166
|
/^-?\d*\.?\d*$/.test(inputValue)
|
|
@@ -273,7 +273,7 @@ import { textfieldTypeHandlers, type SdTextfieldTypes } from "./sd-textfield-typ
|
|
|
273
273
|
// inset(시트 셀 등)의 disabled 는 일반 콘텐츠처럼 표시(현행 유지)
|
|
274
274
|
&[data-sd-inset="true"] {
|
|
275
275
|
> ._contents {
|
|
276
|
-
background-color: var(--sd-bg-
|
|
276
|
+
background-color: var(--sd-bg-content);
|
|
277
277
|
color: var(--sd-tx-default);
|
|
278
278
|
}
|
|
279
279
|
}
|
|
@@ -326,13 +326,11 @@ export class SdTextfield<K extends keyof SdTextfieldTypes> {
|
|
|
326
326
|
inline = input(false, { transform: booleanAttribute });
|
|
327
327
|
inset = input(false, { transform: booleanAttribute });
|
|
328
328
|
size = input<"sm" | "lg">();
|
|
329
|
-
theme = input<
|
|
330
|
-
"primary" | "secondary" | "info" | "success" | "warning" | "danger" | "gray" | "blue-gray"
|
|
331
|
-
>();
|
|
329
|
+
theme = input<"primary" | "info" | "success" | "warning" | "danger" | "gray" | "blue-gray">();
|
|
332
330
|
|
|
333
331
|
private readonly _inputElRef = viewChild<ElementRef<HTMLInputElement>>("inputEl");
|
|
334
332
|
|
|
335
|
-
// 실제 input 의 브라우저 native 제약 위반(이메일
|
|
333
|
+
// 실제 input 의 브라우저 native 제약 위반(이메일 형식, 날짜 미완성 등) 메시지. onInput/onBlur 에서 갱신.
|
|
336
334
|
private readonly _nativeInvalidMessage = signal("");
|
|
337
335
|
|
|
338
336
|
// IME 조합(한글 등) 중 여부. 조합 중에는 미완성 자모가 model 로 흐르지 않도록 갱신을 보류.
|
|
@@ -375,11 +373,11 @@ export class SdTextfield<K extends keyof SdTextfieldTypes> {
|
|
|
375
373
|
if (this._composing) return;
|
|
376
374
|
|
|
377
375
|
// 편집(포커스) 중이라도 변경 origin 을 구분한다.
|
|
378
|
-
// 사용자 입력이 만든 DOM(정규화
|
|
379
|
-
// 모델만 외부에서 바뀐 경우(프로그래밍적
|
|
376
|
+
// 사용자 입력이 만든 DOM(정규화 차이, 진행 중 입력 포함)은 보호하고,
|
|
377
|
+
// 모델만 외부에서 바뀐 경우(프로그래밍적 리셋, prefill, 표시옵션 변경)는 되써서 반영한다.
|
|
380
378
|
if (document.activeElement === inputEl) {
|
|
381
379
|
const domParsed = this._handler().parse(inputEl.value, { format: this.format() });
|
|
382
|
-
// DOM raw 가 현재 모델의 유효한 표현이면(사용자 입력 유래) 되쓰지 않아
|
|
380
|
+
// DOM raw 가 현재 모델의 유효한 표현이면(사용자 입력 유래) 되쓰지 않아 캐럿, IME 보호
|
|
383
381
|
const domReflectsModel =
|
|
384
382
|
domParsed != null &&
|
|
385
383
|
this._handler().toControlValue(domParsed, {
|
|
@@ -399,9 +397,8 @@ export class SdTextfield<K extends keyof SdTextfieldTypes> {
|
|
|
399
397
|
}
|
|
400
398
|
|
|
401
399
|
setupInvalid(() => {
|
|
402
|
-
//
|
|
403
|
-
|
|
404
|
-
|
|
400
|
+
// 검증은 편집 가능 여부와 무관하다. disabled, readonly 여도 값이 제약을 어기면 invalid 다.
|
|
401
|
+
// 값이 무의미한 맥락이면 소비자가 제약(required 등)을 함께 내려야 한다.
|
|
405
402
|
const value = this.value();
|
|
406
403
|
const handlerErrors = this._handler().validate(value, {
|
|
407
404
|
required: this.required(),
|
|
@@ -422,7 +419,7 @@ export class SdTextfield<K extends keyof SdTextfieldTypes> {
|
|
|
422
419
|
}
|
|
423
420
|
}
|
|
424
421
|
|
|
425
|
-
// 브라우저 native 제약 위반(이메일
|
|
422
|
+
// 브라우저 native 제약 위반(이메일 형식, 날짜 미완성 등)을 우리 검증(빨간점)에 합류
|
|
426
423
|
const nativeMessage = this._nativeInvalidMessage();
|
|
427
424
|
if (nativeMessage !== "") {
|
|
428
425
|
errorMessages.push(nativeMessage);
|
|
@@ -12,16 +12,14 @@ import {
|
|
|
12
12
|
encapsulation: ViewEncapsulation.None,
|
|
13
13
|
standalone: true,
|
|
14
14
|
imports: [],
|
|
15
|
-
template: `
|
|
16
|
-
<ng-content />
|
|
17
|
-
`,
|
|
15
|
+
template: ` <ng-content /> `,
|
|
18
16
|
styles: [
|
|
19
17
|
/* language=SCSS */ `
|
|
20
18
|
sd-list {
|
|
21
19
|
display: flex;
|
|
22
20
|
flex-direction: column;
|
|
23
21
|
user-select: none;
|
|
24
|
-
background-color: var(--sd-bg-
|
|
22
|
+
background-color: var(--sd-bg-content);
|
|
25
23
|
border: 1px solid var(--sd-bd-strong);
|
|
26
24
|
border-radius: var(--sd-radius-default);
|
|
27
25
|
padding: var(--sd-gap-sm);
|
|
@@ -36,7 +34,7 @@ import {
|
|
|
36
34
|
}
|
|
37
35
|
}
|
|
38
36
|
|
|
39
|
-
// 아이템 내부에 중첩된 리스트는 카드 외형(
|
|
37
|
+
// 아이템 내부에 중첩된 리스트는 카드 외형(테두리, 라운드, 배경)을 제거
|
|
40
38
|
sd-list-item sd-list {
|
|
41
39
|
border-color: transparent;
|
|
42
40
|
border-radius: 0;
|
|
@@ -7,14 +7,12 @@ import { setupRipple } from "../../core/ripple/setupRipple";
|
|
|
7
7
|
encapsulation: ViewEncapsulation.None,
|
|
8
8
|
standalone: true,
|
|
9
9
|
imports: [],
|
|
10
|
-
template: `
|
|
11
|
-
<ng-content />
|
|
12
|
-
`,
|
|
10
|
+
template: ` <ng-content /> `,
|
|
13
11
|
styles: [
|
|
14
12
|
/* language=SCSS */ `
|
|
15
13
|
sd-select-button {
|
|
16
14
|
display: block;
|
|
17
|
-
background-color: var(--sd-bg-
|
|
15
|
+
background-color: var(--sd-bg-content);
|
|
18
16
|
font-weight: bold;
|
|
19
17
|
cursor: pointer;
|
|
20
18
|
color: var(--sd-tx-primary);
|
|
@@ -231,7 +231,7 @@ export type SelectModeValue<T> = {
|
|
|
231
231
|
|
|
232
232
|
// inset(시트 셀 등)의 disabled 는 일반 콘텐츠처럼 표시(현행 유지)
|
|
233
233
|
&[data-sd-disabled="true"] > sd-dropdown {
|
|
234
|
-
background-color: var(--sd-bg-
|
|
234
|
+
background-color: var(--sd-bg-content);
|
|
235
235
|
|
|
236
236
|
> ._sd-select-control {
|
|
237
237
|
color: var(--sd-tx-default);
|
|
@@ -21,7 +21,7 @@ export class SdGlobalErrorHandlerPlugin implements ErrorHandler {
|
|
|
21
21
|
private _hasDisplayedError = false;
|
|
22
22
|
|
|
23
23
|
handleError(event: unknown) {
|
|
24
|
-
// SSR(프리렌더) 가드: 서버에는 화면
|
|
24
|
+
// SSR(프리렌더) 가드: 서버에는 화면 오버레이, 브라우저 전역 이벤트 타입이 없음 — 로그만 남김
|
|
25
25
|
if (!this._isBrowser) {
|
|
26
26
|
logger.error(event);
|
|
27
27
|
return false;
|
|
@@ -226,7 +226,7 @@ export class SdModalProvider {
|
|
|
226
226
|
// transition duration 확인 후 대기 또는 즉시 destroy
|
|
227
227
|
const duration = parseFloat(getComputedStyle(modalEl).transitionDuration || "0");
|
|
228
228
|
if (duration > 0) {
|
|
229
|
-
// transitionend 가 정상 경로. 단 백그라운드
|
|
229
|
+
// transitionend 가 정상 경로. 단 백그라운드 탭, transition 인터럽트 시 미발화하므로
|
|
230
230
|
// duration(초→ms) + 여유 100ms 후 발화하는 setTimeout 폴백을 함께 걸어 destroy 누락을 방지.
|
|
231
231
|
modalEl.addEventListener("transitionend", doDestroy, { once: true });
|
|
232
232
|
setTimeout(doDestroy, duration * 1000 + 100);
|
|
@@ -49,7 +49,7 @@ export function provideSdAngular(opt: { clientName: string }): EnvironmentProvid
|
|
|
49
49
|
strokeWidth: 1.5,
|
|
50
50
|
}),
|
|
51
51
|
provideEnvironmentInitializer(() => {
|
|
52
|
-
// SSR(프리렌더) 가드: 테마
|
|
52
|
+
// SSR(프리렌더) 가드: 테마 저장, 복원은 브라우저 전용
|
|
53
53
|
if (!isPlatformBrowser(inject(PLATFORM_ID))) return;
|
|
54
54
|
|
|
55
55
|
const sdTheme = inject(SdThemeProvider);
|
|
@@ -77,20 +77,6 @@ export function provideSdAngular(opt: { clientName: string }): EnvironmentProvid
|
|
|
77
77
|
}
|
|
78
78
|
});
|
|
79
79
|
|
|
80
|
-
const savedDensity = sdLocalStorage.get("sd-theme-density");
|
|
81
|
-
if (savedDensity === "compact" || savedDensity === "normal") {
|
|
82
|
-
sdTheme.density.set(savedDensity);
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
let prevDensity = sdTheme.density();
|
|
86
|
-
effect(() => {
|
|
87
|
-
const density = sdTheme.density();
|
|
88
|
-
if (density !== prevDensity) {
|
|
89
|
-
sdLocalStorage.set("sd-theme-density", density);
|
|
90
|
-
prevDensity = density;
|
|
91
|
-
}
|
|
92
|
-
});
|
|
93
|
-
|
|
94
80
|
const savedFontSize = sdLocalStorage.get("sd-theme-font-size");
|
|
95
81
|
if (savedFontSize != null) {
|
|
96
82
|
sdTheme.fontSize.set(savedFontSize);
|
package/src/core/setupBgTheme.ts
CHANGED
|
@@ -2,8 +2,7 @@ import { effect, inject, PLATFORM_ID } from "@angular/core";
|
|
|
2
2
|
import { isPlatformBrowser } from "@angular/common";
|
|
3
3
|
|
|
4
4
|
export function setupBgTheme(options?: {
|
|
5
|
-
theme?:
|
|
6
|
-
"primary" | "secondary" | "info" | "success" | "warning" | "danger" | "gray" | "blue-gray";
|
|
5
|
+
theme?: "primary" | "info" | "success" | "warning" | "danger" | "gray" | "blue-gray";
|
|
7
6
|
}): void {
|
|
8
7
|
// SSR(프리렌더) 가드: document 반영은 브라우저 전용
|
|
9
8
|
if (!isPlatformBrowser(inject(PLATFORM_ID))) return;
|
|
@@ -20,7 +20,7 @@ import { SdSystemLogProvider } from "../config/sd-system-log.provider";
|
|
|
20
20
|
import "@simplysm/core-browser";
|
|
21
21
|
|
|
22
22
|
export type SdToastSeverity = "info" | "success" | "warning" | "danger";
|
|
23
|
-
export type SdToastTheme = "primary" |
|
|
23
|
+
export type SdToastTheme = "primary" | SdToastSeverity | "gray" | "blue-gray";
|
|
24
24
|
|
|
25
25
|
export interface SdToastContentDef<O> {
|
|
26
26
|
close: OutputEmitterRef<O | undefined>;
|
|
@@ -91,7 +91,9 @@ export class SdToastProvider {
|
|
|
91
91
|
return this._show("danger", message, useProgress);
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
notify<T extends SdToastContentDef<any>>(
|
|
94
|
+
notify<T extends SdToastContentDef<any>>(
|
|
95
|
+
input: SdToastInput<T>,
|
|
96
|
+
): Promise<Parameters<T["close"]["emit"]>[0] | undefined> {
|
|
95
97
|
return new Promise((resolve) => {
|
|
96
98
|
// overlap 모드: 기존 토스트 모두 제거
|
|
97
99
|
if (this.overlap()) {
|
|
@@ -241,7 +243,7 @@ export class SdToastProvider {
|
|
|
241
243
|
let dismissPending = false;
|
|
242
244
|
let dismissed = false;
|
|
243
245
|
|
|
244
|
-
//
|
|
246
|
+
// 타이머, 리스너 정리. 외부 파괴 경로(_dismissToast/_destroyToast)에서도 호출되어
|
|
245
247
|
// 죽은 toastRef 에 대해 타이머가 깨어나는 race 와 리스너 누수를 막는다.
|
|
246
248
|
const cleanup = () => {
|
|
247
249
|
dismissed = true;
|
|
@@ -55,7 +55,7 @@ import { NgTemplateOutlet } from "@angular/common";
|
|
|
55
55
|
</sd-busy-container>
|
|
56
56
|
|
|
57
57
|
<ng-template #content>
|
|
58
|
-
<div class="flex-column fill bg-
|
|
58
|
+
<div class="flex-column fill bg-content">
|
|
59
59
|
@if (commandTplRef()) {
|
|
60
60
|
<div class="p-sm-default flex-row gap-default bdb bdb-soft">
|
|
61
61
|
<ng-template [ngTemplateOutlet]="commandTplRef()" />
|
|
@@ -51,6 +51,12 @@ export function injectSheetDomAccessor() {
|
|
|
51
51
|
return tbody.querySelector<HTMLTableRowElement>(`tr[data-r='${r}']`);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
function getRows(): HTMLTableRowElement[] {
|
|
55
|
+
const tbody = getTable().querySelector("tbody");
|
|
56
|
+
if (tbody == null) return [];
|
|
57
|
+
return Array.from(tbody.querySelectorAll<HTMLTableRowElement>("tr[data-r]"));
|
|
58
|
+
}
|
|
59
|
+
|
|
54
60
|
function getCell(r: number, c: number): HTMLTableCellElement | null {
|
|
55
61
|
const row = getRow(r);
|
|
56
62
|
if (row == null) return null;
|
|
@@ -69,6 +75,7 @@ export function injectSheetDomAccessor() {
|
|
|
69
75
|
getSelectRowIndicators,
|
|
70
76
|
getColumnResizeIndicator,
|
|
71
77
|
getRow,
|
|
78
|
+
getRows,
|
|
72
79
|
getCell,
|
|
73
80
|
};
|
|
74
81
|
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { DestroyRef, inject, type Signal } from "@angular/core";
|
|
2
|
+
|
|
3
|
+
export function injectSheetDragSelection<TItem>(options: {
|
|
4
|
+
domAccessor: {
|
|
5
|
+
getRows(): HTMLTableRowElement[];
|
|
6
|
+
getContainer(): HTMLElement;
|
|
7
|
+
getTHead(): HTMLTableSectionElement;
|
|
8
|
+
};
|
|
9
|
+
displayItems: Signal<TItem[]>;
|
|
10
|
+
selection: {
|
|
11
|
+
isSelected(item: TItem): boolean;
|
|
12
|
+
getSelectable(item: TItem): true | string | undefined;
|
|
13
|
+
select(item: TItem): void;
|
|
14
|
+
deselect(item: TItem): void;
|
|
15
|
+
};
|
|
16
|
+
}) {
|
|
17
|
+
const destroyRef = inject(DestroyRef);
|
|
18
|
+
|
|
19
|
+
let dragCleanup: (() => void) | null = null;
|
|
20
|
+
|
|
21
|
+
destroyRef.onDestroy(() => {
|
|
22
|
+
dragCleanup?.();
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// 벗어난 1px 당 초당 스크롤 px, 그리고 초당 스크롤 상한
|
|
26
|
+
const AUTO_SCROLL_SPEED_PER_PX = 8;
|
|
27
|
+
const AUTO_SCROLL_MAX_SPEED = 2000;
|
|
28
|
+
|
|
29
|
+
// 고정 헤더에 가려지거나 컨테이너 밖에 있는 행은 소비자에게 안 보인다
|
|
30
|
+
function visibleBand(): { top: number; bottom: number } {
|
|
31
|
+
const containerRect = options.domAccessor.getContainer().getBoundingClientRect();
|
|
32
|
+
return {
|
|
33
|
+
top: containerRect.top + options.domAccessor.getTHead().offsetHeight,
|
|
34
|
+
bottom: containerRect.bottom,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function rowIndexOf(rowEl: HTMLTableRowElement): number {
|
|
39
|
+
const attr = rowEl.getAttribute("data-r");
|
|
40
|
+
const rowIdx = attr == null ? Number.NaN : parseInt(attr, 10);
|
|
41
|
+
if (Number.isNaN(rowIdx)) {
|
|
42
|
+
throw new Error(`시트 행 인덱스를 읽을 수 없습니다: data-r=${attr}`);
|
|
43
|
+
}
|
|
44
|
+
return rowIdx;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// 보이는 행 중 clientY 가 속한 행. 위아래로 벗어나면 가장 가까운 끝 행으로 맞춘다
|
|
48
|
+
function resolveRowIndex(clientY: number): number | null {
|
|
49
|
+
const band = visibleBand();
|
|
50
|
+
const visibleRows = options.domAccessor.getRows().filter((rowEl) => {
|
|
51
|
+
const rect = rowEl.getBoundingClientRect();
|
|
52
|
+
return rect.bottom > band.top && rect.top < band.bottom;
|
|
53
|
+
});
|
|
54
|
+
if (visibleRows.length === 0) return null;
|
|
55
|
+
|
|
56
|
+
const firstRect = visibleRows[0].getBoundingClientRect();
|
|
57
|
+
const lastRect = visibleRows[visibleRows.length - 1].getBoundingClientRect();
|
|
58
|
+
if (clientY < Math.max(firstRect.top, band.top)) return rowIndexOf(visibleRows[0]);
|
|
59
|
+
if (clientY >= Math.min(lastRect.bottom, band.bottom)) {
|
|
60
|
+
return rowIndexOf(visibleRows[visibleRows.length - 1]);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
for (const rowEl of visibleRows) {
|
|
64
|
+
const rect = rowEl.getBoundingClientRect();
|
|
65
|
+
if (clientY < rect.top || clientY >= rect.bottom) continue;
|
|
66
|
+
return rowIndexOf(rowEl);
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 가장자리를 넘어선 거리. 위로 벗어나면 음수, 아래로 벗어나면 양수
|
|
72
|
+
function overshootOf(clientY: number): number {
|
|
73
|
+
const band = visibleBand();
|
|
74
|
+
if (clientY < band.top) return clientY - band.top;
|
|
75
|
+
if (clientY >= band.bottom) return clientY - band.bottom;
|
|
76
|
+
return 0;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function onSelectorPointerDown(event: PointerEvent, r: number): void {
|
|
80
|
+
if (event.button !== 0) return;
|
|
81
|
+
if (event.shiftKey) return;
|
|
82
|
+
if (event.pointerType === "touch") return;
|
|
83
|
+
|
|
84
|
+
dragCleanup?.();
|
|
85
|
+
|
|
86
|
+
const startItem = options.displayItems()[r];
|
|
87
|
+
if (startItem == null) return;
|
|
88
|
+
|
|
89
|
+
// 시작 행을 누른 결과 상태가 곧 페인트 목표. 체크박스 자체 토글보다 먼저 스냅샷해야 뒤집히지 않는다
|
|
90
|
+
const isSelectTarget = !options.selection.isSelected(startItem);
|
|
91
|
+
const startEl = event.currentTarget;
|
|
92
|
+
let dragged = false;
|
|
93
|
+
|
|
94
|
+
// 칠하기 전 상태. 드래그를 되돌려 범위를 벗어난 행은 이 값으로 복원한다
|
|
95
|
+
const stateBeforePaint = new Map<number, boolean>();
|
|
96
|
+
|
|
97
|
+
const setSelected = (rowIdx: number, selected: boolean): void => {
|
|
98
|
+
const item = options.displayItems()[rowIdx];
|
|
99
|
+
if (item == null) return;
|
|
100
|
+
if (options.selection.getSelectable(item) !== true) return;
|
|
101
|
+
|
|
102
|
+
if (selected) {
|
|
103
|
+
options.selection.select(item);
|
|
104
|
+
} else {
|
|
105
|
+
options.selection.deselect(item);
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
const paint = (rowIdx: number): void => {
|
|
110
|
+
const item = options.displayItems()[rowIdx];
|
|
111
|
+
if (item == null) return;
|
|
112
|
+
if (options.selection.getSelectable(item) !== true) return;
|
|
113
|
+
|
|
114
|
+
if (!stateBeforePaint.has(rowIdx)) {
|
|
115
|
+
stateBeforePaint.set(rowIdx, options.selection.isSelected(item));
|
|
116
|
+
}
|
|
117
|
+
setSelected(rowIdx, isSelectTarget);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// 시작 행부터 현재 행까지가 칠할 범위. 범위를 벗어난 행은 원래대로 되돌린다
|
|
121
|
+
const applyRange = (currentRow: number): void => {
|
|
122
|
+
const from = Math.min(r, currentRow);
|
|
123
|
+
const to = Math.max(r, currentRow);
|
|
124
|
+
|
|
125
|
+
for (const rowIdx of [...stateBeforePaint.keys()]) {
|
|
126
|
+
if (rowIdx >= from && rowIdx <= to) continue;
|
|
127
|
+
setSelected(rowIdx, stateBeforePaint.get(rowIdx)!);
|
|
128
|
+
stateBeforePaint.delete(rowIdx);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (let i = from; i <= to; i++) {
|
|
132
|
+
paint(i);
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
const applyRangeAt = (clientY: number): void => {
|
|
137
|
+
const rowIdx = resolveRowIndex(clientY);
|
|
138
|
+
if (rowIdx == null) return;
|
|
139
|
+
if (rowIdx === r && !dragged) return;
|
|
140
|
+
|
|
141
|
+
dragged = true;
|
|
142
|
+
applyRange(rowIdx);
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
// 가장자리를 넘긴 동안 벗어난 거리에 비례해 스크롤하며 계속 칠한다
|
|
146
|
+
let pointerY = event.clientY;
|
|
147
|
+
let rafId: number | null = null;
|
|
148
|
+
let lastFrameTime: number | null = null;
|
|
149
|
+
|
|
150
|
+
const autoScrollFrame = (time: number): void => {
|
|
151
|
+
const overshoot = overshootOf(pointerY);
|
|
152
|
+
if (overshoot === 0) {
|
|
153
|
+
rafId = null;
|
|
154
|
+
lastFrameTime = null;
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const elapsedSec = lastFrameTime == null ? 0 : (time - lastFrameTime) / 1000;
|
|
159
|
+
lastFrameTime = time;
|
|
160
|
+
|
|
161
|
+
const speed =
|
|
162
|
+
Math.sign(overshoot) *
|
|
163
|
+
Math.min(Math.abs(overshoot) * AUTO_SCROLL_SPEED_PER_PX, AUTO_SCROLL_MAX_SPEED);
|
|
164
|
+
options.domAccessor.getContainer().scrollTop += speed * elapsedSec;
|
|
165
|
+
applyRangeAt(pointerY);
|
|
166
|
+
|
|
167
|
+
rafId = requestAnimationFrame(autoScrollFrame);
|
|
168
|
+
};
|
|
169
|
+
|
|
170
|
+
const onPointerMove = (e: PointerEvent): void => {
|
|
171
|
+
pointerY = e.clientY;
|
|
172
|
+
applyRangeAt(pointerY);
|
|
173
|
+
|
|
174
|
+
if (overshootOf(pointerY) !== 0 && rafId == null) {
|
|
175
|
+
rafId = requestAnimationFrame(autoScrollFrame);
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
|
|
179
|
+
const onPointerUp = (): void => {
|
|
180
|
+
dragCleanup?.();
|
|
181
|
+
if (!dragged) return;
|
|
182
|
+
|
|
183
|
+
// 시작 행은 이미 페인트로 확정됐으므로 SdCheckbox 자체 토글이 되돌리지 못하게 막는다.
|
|
184
|
+
// capture 로 걸어야 SdCheckbox 의 host (click) 보다 먼저 실행된다
|
|
185
|
+
if (!(startEl instanceof HTMLElement)) return;
|
|
186
|
+
|
|
187
|
+
const blockClick = (e: Event): void => {
|
|
188
|
+
e.preventDefault();
|
|
189
|
+
e.stopImmediatePropagation();
|
|
190
|
+
};
|
|
191
|
+
startEl.addEventListener("click", blockClick, { once: true, capture: true });
|
|
192
|
+
|
|
193
|
+
// 다른 행에서 뗀 드래그는 시작 체크박스에 click 이 오지 않는다.
|
|
194
|
+
// 남겨두면 이후의 정상 클릭 1회를 삼키므로 이번 턴이 지나면 걷어낸다
|
|
195
|
+
setTimeout(() => {
|
|
196
|
+
startEl.removeEventListener("click", blockClick, { capture: true });
|
|
197
|
+
});
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
// 드래그 중 브라우저 텍스트 선택 억제.
|
|
201
|
+
// pointerdown 을 preventDefault 하면 클릭 포커스까지 사라져 shift 범위 선택의 기준 행이 깨지므로,
|
|
202
|
+
// 선택 시작 자체만 막는다
|
|
203
|
+
const blockSelectStart = (e: Event): void => {
|
|
204
|
+
e.preventDefault();
|
|
205
|
+
};
|
|
206
|
+
document.addEventListener("selectstart", blockSelectStart);
|
|
207
|
+
|
|
208
|
+
document.addEventListener("pointermove", onPointerMove);
|
|
209
|
+
document.addEventListener("pointerup", onPointerUp);
|
|
210
|
+
// 브라우저발 강제 종료(창 전환, 시스템 제스처)도 뗀 것과 동일하게 확정 처리
|
|
211
|
+
document.addEventListener("pointercancel", onPointerUp);
|
|
212
|
+
dragCleanup = () => {
|
|
213
|
+
if (rafId != null) {
|
|
214
|
+
cancelAnimationFrame(rafId);
|
|
215
|
+
rafId = null;
|
|
216
|
+
}
|
|
217
|
+
document.removeEventListener("selectstart", blockSelectStart);
|
|
218
|
+
document.removeEventListener("pointermove", onPointerMove);
|
|
219
|
+
document.removeEventListener("pointerup", onPointerUp);
|
|
220
|
+
document.removeEventListener("pointercancel", onPointerUp);
|
|
221
|
+
dragCleanup = null;
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return { onSelectorPointerDown };
|
|
226
|
+
}
|
|
@@ -46,6 +46,7 @@ import { useSheetDisplayPipeline } from "./useSheetDisplayPipeline";
|
|
|
46
46
|
import { useSheetCellStyling } from "./useSheetCellStyling";
|
|
47
47
|
import { useSheetFocusIndicator } from "./useSheetFocusIndicator";
|
|
48
48
|
import { injectSheetSelectRowIndicator } from "./injectSheetSelectRowIndicator";
|
|
49
|
+
import { injectSheetDragSelection } from "./injectSheetDragSelection";
|
|
49
50
|
import { SdModalProvider } from "../../core/modal/sd-modal.provider";
|
|
50
51
|
import { SdSheetConfigModal } from "./sd-sheet-config.modal";
|
|
51
52
|
import { SdEvents } from "../../core/events/sd-events";
|
|
@@ -68,7 +69,7 @@ import { SdEvents } from "../../core/events/sd-events";
|
|
|
68
69
|
{ directive: SdEvents, outputs: ["keydown.capture", "focus.capture", "blur.capture"] },
|
|
69
70
|
],
|
|
70
71
|
host: {
|
|
71
|
-
|
|
72
|
+
class: "flex-column fill",
|
|
72
73
|
"[attr.data-sd-inset]": "inset()",
|
|
73
74
|
"[attr.data-sd-focus-mode]": "focusMode()",
|
|
74
75
|
"(keydown.capture)": "onKeydownCapture($event)",
|
|
@@ -262,6 +263,7 @@ import { SdEvents } from "../../core/events/sd-events";
|
|
|
262
263
|
[canChangeFn]="selection.getCanChangeFn(item)"
|
|
263
264
|
(valueChange)="selection.toggle(item)"
|
|
264
265
|
(mousedown)="onSelectorMouseDown($event, rowIdx)"
|
|
266
|
+
(pointerdown)="onSelectorPointerDown($event, rowIdx)"
|
|
265
267
|
[inline]="true"
|
|
266
268
|
[theme]="'white'"
|
|
267
269
|
[disabled]="_selectable !== true"
|
|
@@ -370,7 +372,7 @@ import { SdEvents } from "../../core/events/sd-events";
|
|
|
370
372
|
border-radius: $border-radius;
|
|
371
373
|
|
|
372
374
|
> ._tool {
|
|
373
|
-
background-color: var(--sd-bg-
|
|
375
|
+
background-color: var(--sd-bg-canvas);
|
|
374
376
|
border-top-left-radius: $border-radius;
|
|
375
377
|
border-top-right-radius: $border-radius;
|
|
376
378
|
border-bottom: 1px solid $border-color-dark;
|
|
@@ -501,7 +503,7 @@ import { SdEvents } from "../../core/events/sd-events";
|
|
|
501
503
|
|
|
502
504
|
> tbody > tr > td {
|
|
503
505
|
font-family: var(--sd-font-family-field);
|
|
504
|
-
background-color: var(--sd-bg-
|
|
506
|
+
background-color: var(--sd-bg-content);
|
|
505
507
|
vertical-align: top;
|
|
506
508
|
|
|
507
509
|
&._fixed {
|
|
@@ -717,6 +719,13 @@ export class SdSheet<TItem> {
|
|
|
717
719
|
trackByFn: this.trackByFn,
|
|
718
720
|
});
|
|
719
721
|
|
|
722
|
+
// Drag(paint) selection
|
|
723
|
+
private readonly _dragSelection = injectSheetDragSelection<TItem>({
|
|
724
|
+
domAccessor: this.domAccessor,
|
|
725
|
+
displayItems: this.displayItems,
|
|
726
|
+
selection: this.selection,
|
|
727
|
+
});
|
|
728
|
+
|
|
720
729
|
// Icons
|
|
721
730
|
icons = {
|
|
722
731
|
tablerSettings,
|
|
@@ -853,6 +862,10 @@ export class SdSheet<TItem> {
|
|
|
853
862
|
});
|
|
854
863
|
}
|
|
855
864
|
|
|
865
|
+
onSelectorPointerDown(event: PointerEvent, r: number): void {
|
|
866
|
+
this._dragSelection.onSelectorPointerDown(event, r);
|
|
867
|
+
}
|
|
868
|
+
|
|
856
869
|
onSelectorMouseDown(event: MouseEvent, r: number): void {
|
|
857
870
|
if (!event.shiftKey) return;
|
|
858
871
|
event.preventDefault();
|