@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.
Files changed (104) hide show
  1. package/dist/controls/button/sd-anchor.d.ts +1 -1
  2. package/dist/controls/button/sd-anchor.d.ts.map +1 -1
  3. package/dist/controls/button/sd-anchor.js +2 -2
  4. package/dist/controls/button/sd-button.d.ts +1 -1
  5. package/dist/controls/button/sd-button.d.ts.map +1 -1
  6. package/dist/controls/button/sd-button.js +2 -2
  7. package/dist/controls/checkbox/sd-checkbox.d.ts +1 -1
  8. package/dist/controls/checkbox/sd-checkbox.d.ts.map +1 -1
  9. package/dist/controls/checkbox/sd-checkbox.js +2 -2
  10. package/dist/controls/checkbox/sd-switch.d.ts +1 -1
  11. package/dist/controls/checkbox/sd-switch.d.ts.map +1 -1
  12. package/dist/controls/checkbox/sd-switch.js +2 -2
  13. package/dist/controls/dropdown/sd-dropdown.d.ts +3 -0
  14. package/dist/controls/dropdown/sd-dropdown.d.ts.map +1 -1
  15. package/dist/controls/dropdown/sd-dropdown.js +29 -9
  16. package/dist/controls/input/sd-textarea.d.ts +1 -1
  17. package/dist/controls/input/sd-textarea.d.ts.map +1 -1
  18. package/dist/controls/input/sd-textarea.js +2 -2
  19. package/dist/controls/input/sd-textfield-type-handlers.js +1 -1
  20. package/dist/controls/input/sd-textfield.d.ts +1 -1
  21. package/dist/controls/input/sd-textfield.d.ts.map +1 -1
  22. package/dist/controls/input/sd-textfield.js +9 -10
  23. package/dist/controls/list/sd-list.d.ts.map +1 -1
  24. package/dist/controls/list/sd-list.js +4 -6
  25. package/dist/controls/select/sd-select-button.d.ts.map +1 -1
  26. package/dist/controls/select/sd-select-button.js +3 -5
  27. package/dist/controls/select/sd-select.js +2 -2
  28. package/dist/core/error-handler/sd-global-error-handler.plugin.js +1 -1
  29. package/dist/core/modal/sd-modal.provider.js +1 -1
  30. package/dist/core/provideSdAngular.d.ts.map +1 -1
  31. package/dist/core/provideSdAngular.js +1 -13
  32. package/dist/core/setupBgTheme.d.ts +1 -1
  33. package/dist/core/setupBgTheme.d.ts.map +1 -1
  34. package/dist/core/toast/sd-toast.js +2 -2
  35. package/dist/core/toast/sd-toast.provider.d.ts +1 -1
  36. package/dist/core/toast/sd-toast.provider.d.ts.map +1 -1
  37. package/dist/core/toast/sd-toast.provider.js +1 -1
  38. package/dist/data/crud/sd-base-container.js +2 -2
  39. package/dist/data/sheet/injectSheetDomAccessor.d.ts +1 -0
  40. package/dist/data/sheet/injectSheetDomAccessor.d.ts.map +1 -1
  41. package/dist/data/sheet/injectSheetDomAccessor.js +7 -0
  42. package/dist/data/sheet/injectSheetDragSelection.d.ts +18 -0
  43. package/dist/data/sheet/injectSheetDragSelection.d.ts.map +1 -0
  44. package/dist/data/sheet/injectSheetDragSelection.js +193 -0
  45. package/dist/data/sheet/sd-sheet.d.ts +3 -0
  46. package/dist/data/sheet/sd-sheet.d.ts.map +1 -1
  47. package/dist/data/sheet/sd-sheet.js +17 -6
  48. package/dist/features/editor/sd-tiptap-editor.js +2 -2
  49. package/dist/features/theme/sd-theme-provider.d.ts +0 -2
  50. package/dist/features/theme/sd-theme-provider.d.ts.map +1 -1
  51. package/dist/features/theme/sd-theme-provider.js +0 -5
  52. package/dist/features/theme/sd-theme-selector.d.ts.map +1 -1
  53. package/dist/features/theme/sd-theme-selector.js +5 -22
  54. package/dist/features/visual/sd-label.d.ts +1 -1
  55. package/dist/features/visual/sd-label.d.ts.map +1 -1
  56. package/dist/features/visual/sd-label.js +2 -2
  57. package/dist/features/visual/sd-note.d.ts +1 -1
  58. package/dist/features/visual/sd-note.d.ts.map +1 -1
  59. package/dist/features/visual/sd-note.js +2 -2
  60. package/dist/features/visual/sd-progress.d.ts +1 -1
  61. package/dist/features/visual/sd-progress.d.ts.map +1 -1
  62. package/dist/features/visual/sd-progress.js +2 -2
  63. package/dist/layout/sidebar/sd-sidebar.d.ts.map +1 -1
  64. package/dist/layout/sidebar/sd-sidebar.js +3 -5
  65. package/dist/layout/topbar/sd-topbar.d.ts.map +1 -1
  66. package/dist/layout/topbar/sd-topbar.js +3 -3
  67. package/package.json +5 -5
  68. package/scss/commons/_mixins.scss +1 -1
  69. package/scss/commons/_theme-variables.scss +0 -8
  70. package/scss/commons/_variables.scss +114 -121
  71. package/scss/themes/_variables-blueprint.scss +65 -63
  72. package/scss/themes/_variables-ide-dark.scss +166 -144
  73. package/src/controls/button/sd-anchor.ts +3 -3
  74. package/src/controls/button/sd-button.ts +1 -3
  75. package/src/controls/checkbox/sd-checkbox.ts +2 -10
  76. package/src/controls/checkbox/sd-switch.ts +2 -4
  77. package/src/controls/dropdown/sd-dropdown.ts +33 -9
  78. package/src/controls/input/sd-textarea.ts +2 -4
  79. package/src/controls/input/sd-textfield-type-handlers.ts +1 -1
  80. package/src/controls/input/sd-textfield.ts +9 -12
  81. package/src/controls/list/sd-list.ts +3 -5
  82. package/src/controls/select/sd-select-button.ts +2 -4
  83. package/src/controls/select/sd-select.ts +1 -1
  84. package/src/core/error-handler/sd-global-error-handler.plugin.ts +1 -1
  85. package/src/core/modal/sd-modal.provider.ts +1 -1
  86. package/src/core/provideSdAngular.ts +1 -15
  87. package/src/core/setupBgTheme.ts +1 -2
  88. package/src/core/toast/sd-toast.provider.ts +5 -3
  89. package/src/data/crud/sd-base-container.ts +1 -1
  90. package/src/data/sheet/injectSheetDomAccessor.ts +7 -0
  91. package/src/data/sheet/injectSheetDragSelection.ts +226 -0
  92. package/src/data/sheet/sd-sheet.ts +16 -3
  93. package/src/features/editor/sd-tiptap-editor.ts +1 -1
  94. package/src/features/theme/sd-theme-provider.ts +0 -9
  95. package/src/features/theme/sd-theme-selector.ts +1 -10
  96. package/src/features/visual/sd-label.ts +1 -3
  97. package/src/features/visual/sd-note.ts +1 -3
  98. package/src/features/visual/sd-progress.ts +2 -2
  99. package/src/layout/sidebar/sd-sidebar.ts +2 -3
  100. package/src/layout/topbar/sd-topbar.ts +1 -0
  101. package/styles.css +278 -567
  102. package/scss/commons/_colors.scss +0 -301
  103. package/scss/commons/_density.scss +0 -23
  104. 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-control);
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
- private _mouseoverEl?: HTMLElement;
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
- this._mouseoverEl = (event as MouseEvent).target as HTMLElement;
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 HTMLElement &&
267
+ mouseoverEl instanceof Element &&
252
268
  (contentEl.contains(mouseoverEl) === true || popupEl.contains(mouseoverEl) === true)
253
269
  ) {
254
- const tabbable = popupEl.findFirstTabbableChild();
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-control);
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
- // 부호·소수점 형식은 갖췄으나 아직 완성되지 않은 진행 상태 ("-", "12.", ".", "-.")
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-control);
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 제약 위반(이메일 형식·날짜 미완성 등) 메시지. onInput/onBlur 에서 갱신.
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
- // 모델만 외부에서 바뀐 경우(프로그래밍적 리셋·prefill·표시옵션 변경)는 되써서 반영한다.
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 가 현재 모델의 유효한 표현이면(사용자 입력 유래) 되쓰지 않아 캐럿·IME 보호
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
- // 비활성·읽기전용 필드는 사용자가 편집할 없으므로 검증에서 제외 (네이티브 disabled 동작과 동일)
403
- if (this.disabled() || this.readonly()) return "";
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-control);
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-control);
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-control);
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 가 정상 경로. 단 백그라운드 탭·transition 인터럽트 시 미발화하므로
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);
@@ -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" | "secondary" | SdToastSeverity | "gray" | "blue-gray";
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>>(input: SdToastInput<T>): Promise<Parameters<T["close"]["emit"]>[0] | undefined> {
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
- // 타이머·리스너 정리. 외부 파괴 경로(_dismissToast/_destroyToast)에서도 호출되어
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-control">
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
- "class": "flex-column fill",
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-control);
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-control);
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();