@sd-angular/core 19.0.45 → 19.0.47

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/README.md CHANGED
@@ -120,7 +120,9 @@ export const appConfig: ApplicationConfig = {
120
120
  | `--sd-view-label-color` | `var(--sd-black400)` | Label chỉ-đọc (`sd-view`, control ở `viewed`, `sd-section-item`), class `.sd-view-label` |
121
121
  | `--sd-view-value-color` | `var(--sd-black500)` | Giá trị chỉ-đọc, class `.sd-view-value` |
122
122
 
123
- `theme()` còn ánh xạ token Material của form field, option và datepicker sang palette này (`assets/scss/themes/_material-tokens.scss`): viền/label/caret khi focus và option/ngày đang chọn dùng `--sd-primary`, viền lúc nghỉ dùng `--sd-black200`, field lỗi chỉ đổi viền sang `--sd-error` (label giữ màu thường). Đổi `primary` trong `theme()` là các trạng thái này đổi theo.
123
+ `theme()` còn ánh xạ token Material của form field, option và datepicker sang palette này (`assets/scss/themes/_material-tokens.scss`): label/caret/mũi tên select khi focus và option/ngày đang chọn dùng `--sd-primary`. Đổi `primary` trong `theme()` là các trạng thái này đổi theo.
124
+
125
+ Viền (outline) của form field luôn là `--sd-black200` ở mọi trạng thái — nghỉ, hover, focus (chỉ dày hơn) và lỗi (`assets/scss/core/form.scss`, `!important`). Field lỗi báo bằng label, message và dấu `*` màu đỏ; riêng field `hideInlineError` bị lỗi (không có message) có viền `--sd-error`.
124
126
 
125
127
  ### Custom Theme / Tuỳ chỉnh theme
126
128
 
@@ -11,8 +11,22 @@
11
11
  color: map.get(color.$color_map, 'error');
12
12
  }
13
13
 
14
- // Màu viền / label / caret theo trạng thái (thường, focus, lỗi): token Material ánh xạ sang palette
15
- // Core trong theme() — xem themes/_material-tokens.scss.
14
+ // Xử lý màu border nhạt bớt
15
+ // why: viền xám ở MỌI trạng thái (nghỉ, hover, focus, lỗi) — focus chỉ dày hơn, lỗi báo bằng label và
16
+ // message đỏ. 19.0.45 từng đổi viền focus sang primary và viền lỗi sang đỏ; 19.0.46 trả lại giao diện
17
+ // cũ vì các app đang dùng không được báo trước thay đổi này.
18
+ .mat-mdc-notch-piece {
19
+ &.mdc-notched-outline__leading {
20
+ border-color: map.get(color.$color_map, 'black200') !important;
21
+ }
22
+ &.mdc-notched-outline__notch {
23
+ border-top-color: map.get(color.$color_map, 'black200') !important;
24
+ border-bottom-color: map.get(color.$color_map, 'black200') !important;
25
+ }
26
+ &.mdc-notched-outline__trailing {
27
+ border-color: map.get(color.$color_map, 'black200') !important;
28
+ }
29
+ }
16
30
 
17
31
  // Xử lý icon
18
32
  .sd-suffix-icon {
@@ -113,12 +127,27 @@
113
127
  }
114
128
  }
115
129
 
116
- // Bỏ padding nếu hideInlineError. Viền đỏ khi lỗi đến từ token `error-outline-color` trong theme(),
117
- // như mọi field khác.
130
+ // Bỏ padding nếu hideInlineError
118
131
  &.hide-inline-error {
119
132
  .mat-mdc-form-field-subscript-wrapper {
120
133
  display: none;
121
134
  }
135
+
136
+ // why: không có message lỗi bên dưới nên field lỗi tô viền đỏ — ngoại lệ duy nhất của viền xám ở trên.
137
+ &.mat-form-field-invalid {
138
+ .mat-mdc-notch-piece {
139
+ &.mdc-notched-outline__leading {
140
+ border-color: map.get(color.$color_map, 'error') !important;
141
+ }
142
+ &.mdc-notched-outline__notch {
143
+ border-top-color: map.get(color.$color_map, 'error') !important;
144
+ border-bottom-color: map.get(color.$color_map, 'error') !important;
145
+ }
146
+ &.mdc-notched-outline__trailing {
147
+ border-color: map.get(color.$color_map, 'error') !important;
148
+ }
149
+ }
150
+ }
122
151
  }
123
152
 
124
153
  // Size normal`
@@ -1,20 +1,18 @@
1
1
  // Angular Material component tokens mapped onto the Core palette.
2
2
  //
3
- // why: `core/form.scss` used to force `border-color: var(--sd-black200) !important` on the outline
4
- // pieces in every state, so a focused field only drew a thicker grey outline and an invalid field kept
5
- // a grey outline under a red label. The colours now go through Material's own tokens, declared by
6
- // theme() so every theme scope resolves them against its palette and a parent of a field can still
7
- // override a token for its subtree.
3
+ // why: theme() declares these, so every theme scope resolves them against its palette and a parent of a
4
+ // field can still override a token for its subtree.
8
5
  //
9
- // - Rest / hover / disabled outline: --sd-black200 (the colour the old rule forced).
10
- // - Focus: --sd-primary for the outline, floating label, caret, select arrow and datepicker toggle.
11
- // - Error: only the outline (and the filled active indicator) turns --sd-error. The floating label and
12
- // the select arrow keep the colour of a valid field; the error message, error icon and required
13
- // marker stay red.
6
+ // - Outline: not here. `core/form.scss` forces `--sd-black200` with `!important` on the outline pieces in
7
+ // every state (red only for an invalid `hideInlineError` field). 19.0.45 moved the outline onto the
8
+ // focus/error tokens (primary while focused, red when invalid); 19.0.46 restored the grey outline.
9
+ // - Focus: --sd-primary for the floating label, caret, select arrow, datepicker toggle and the filled
10
+ // active indicator.
11
+ // - Error: Material's own error colours (the Material 2 warn palette of `material-theme.scss`): red label,
12
+ // caret and select arrow; the filled active indicator turns --sd-error.
14
13
  // - Options, checkmarks and datepicker: selected state in --sd-primary.
15
14
  //
16
15
  // Token names are the Material 19 ones (`--mdc-*-text-field-*`, `--mat-{full,minimal}-pseudo-checkbox-*`).
17
- // The label references fall back to the Material 2 theme values of `material-theme.scss`.
18
16
 
19
17
  $-text-field-prefixes: (
20
18
  outlined: '--mdc-outlined-text-field-',
@@ -24,20 +22,8 @@ $-text-field-prefixes: (
24
22
  @mixin emit() {
25
23
  @each $appearance, $prefix in $-text-field-prefixes {
26
24
  #{$prefix}caret-color: var(--sd-primary);
27
- #{$prefix}error-caret-color: var(--sd-primary);
28
25
  #{$prefix}focus-label-text-color: var(--sd-primary);
29
- #{$prefix}error-focus-label-text-color: var(--sd-primary);
30
- #{$prefix}error-label-text-color: var(#{$prefix}label-text-color, rgba(0, 0, 0, 0.6));
31
- #{$prefix}error-hover-label-text-color: var(#{$prefix}hover-label-text-color, rgba(0, 0, 0, 0.87));
32
- @if $appearance == outlined {
33
- #{$prefix}outline-color: var(--sd-black200);
34
- #{$prefix}hover-outline-color: var(--sd-black200);
35
- #{$prefix}disabled-outline-color: var(--sd-black200);
36
- #{$prefix}focus-outline-color: var(--sd-primary);
37
- #{$prefix}error-outline-color: var(--sd-error);
38
- #{$prefix}error-hover-outline-color: var(--sd-error);
39
- #{$prefix}error-focus-outline-color: var(--sd-error);
40
- } @else {
26
+ @if $appearance == filled {
41
27
  #{$prefix}focus-active-indicator-color: var(--sd-primary);
42
28
  #{$prefix}error-active-indicator-color: var(--sd-error);
43
29
  #{$prefix}error-hover-active-indicator-color: var(--sd-error);
@@ -46,7 +32,6 @@ $-text-field-prefixes: (
46
32
  }
47
33
  --mat-form-field-focus-select-arrow-color: var(--sd-primary);
48
34
  --mat-select-focused-arrow-color: var(--sd-primary);
49
- --mat-select-invalid-arrow-color: var(--mat-select-enabled-arrow-color, rgba(0, 0, 0, 0.54));
50
35
  --mat-datepicker-toggle-active-state-icon-color: var(--sd-primary);
51
36
 
52
37
  --mat-option-selected-state-label-text-color: var(--sd-primary);
@@ -66,8 +66,34 @@ export declare class SdUploadFile<TArgs = any> {
66
66
  */
67
67
  getFiles: () => Promise<File[]>;
68
68
  /**
69
- * Thực hiện upload các file và gán giá trị, output trả ra mảng idOrKeys.
70
- * HÀM NÀY BẮT BUỘC PHẢI GỌI TRƯỚC KHI GỌI VỀ SERVER.
69
+ * Upload the local files referenced by the model, then replace their temporary
70
+ * keys with server IDs. Call and await this method before submitting the model.
71
+ *
72
+ * Files are read from the model and local snapshots/cache, so pending or failed metadata
73
+ * requests do not omit files. Each distinct local key is uploaded once per
74
+ * batch; duplicate occurrences retain their positions and share the returned ID.
75
+ * Server IDs and URLs are preserved even when they have no preview metadata.
76
+ *
77
+ * Concurrent calls share the active batch. Changes made while it is pending
78
+ * are preserved: removed keys stay removed, reordering is retained, and newly
79
+ * added local keys remain pending for a subsequent call. After destruction, no
80
+ * new upload starts and an in-flight upload cannot update the model.
81
+ *
82
+ * @returns A promise resolving to void after the batch finishes. The preview
83
+ * metadata refresh may still be pending. With no local files this is a no-op.
84
+ * A missing upload handler displays the configuration error and resolves
85
+ * without changing the model or cache.
86
+ * @throws The upload handler's error, without changing the model or cache.
87
+ * @throws Error if the handler does not return exactly one non-empty string ID
88
+ * per distinct local key; the model and cache are retained in that case.
89
+ *
90
+ * @example
91
+ * import { SdUploadFile } from '@sd-angular/core/components/upload-file';
92
+ *
93
+ * async function submit(control: SdUploadFile) {
94
+ * await control.upload();
95
+ * return control.model(); // Server IDs/URLs for the uploaded batch.
96
+ * }
71
97
  */
72
98
  upload: () => Promise<void>;
73
99
  /**
@@ -199,6 +199,9 @@ class SdUploadFile {
199
199
  #canvas2 = `C${Utilities.generateUuid()}`;
200
200
  #name = Utilities.generateUuid();
201
201
  #formGroup;
202
+ #uploadPromise;
203
+ #destroyed = false;
204
+ #localFiles = new Map();
202
205
  formControl = new SdFormControl();
203
206
  // why: component là OnPush nhưng template đọc formControl.touched/errors như thuộc tính
204
207
  // THƯỜNG (không reactive). Khi cha submit (markAllAsTouched) hoặc validator gắn lỗi, không
@@ -324,19 +327,34 @@ class SdUploadFile {
324
327
  }
325
328
  });
326
329
  // Reactive sync for model changes
327
- effect(() => {
328
- const values = this.model() || [];
330
+ effect(onCleanup => {
331
+ const modelValue = this.model();
332
+ const values = modelValue || [];
333
+ let active = true;
334
+ let cancelReaders;
335
+ // why: model có thể đổi trước lần effect kế tiếp; cleanup thôi chưa chặn được
336
+ // Promise cũ resolve trong khoảng đó. Destroy cũng phải chặn mọi emit/ghi state.
337
+ const isCurrent = () => active && !this.#destroyed && this.model() === modelValue;
338
+ onCleanup(() => {
339
+ active = false;
340
+ cancelReaders?.();
341
+ });
329
342
  this.formControl.setValue(values);
330
343
  if (Array.isArray(values)) {
331
344
  this.#details(values).then(previewFiles => {
345
+ if (!isCurrent())
346
+ return;
332
347
  this.previewFiles.set(previewFiles);
333
348
  this.filesChanged.emit(this.previewFiles()
334
349
  .map(e => e.file || e.idOrKey || e.src)
335
350
  .filter(val => !!val));
336
- this.#preview();
351
+ // why: consumer của filesChanged có thể đổi model hoặc destroy đồng bộ.
352
+ if (isCurrent())
353
+ cancelReaders = this.#preview(isCurrent);
337
354
  });
338
355
  }
339
356
  else {
357
+ this.#localFiles.clear();
340
358
  this.previewFiles.set([]);
341
359
  }
342
360
  });
@@ -364,6 +382,8 @@ class SdUploadFile {
364
382
  });
365
383
  // Cleanup on destroy
366
384
  this.#destroyRef.onDestroy(() => {
385
+ this.#destroyed = true;
386
+ this.#localFiles.clear();
367
387
  this.#revokePreviewBlobURLs();
368
388
  this.#formGroup?.removeControl(this.#name);
369
389
  });
@@ -495,17 +515,28 @@ class SdUploadFile {
495
515
  }
496
516
  return null;
497
517
  };
498
- #preview = () => {
518
+ #preview = (isCurrent) => {
519
+ const readers = [];
499
520
  for (const previewFile of this.previewFiles().filter(e => e.file)) {
500
521
  const reader = new FileReader();
501
522
  reader.onload = evt => {
523
+ if (!isCurrent())
524
+ return;
502
525
  previewFile.previewSrc = evt.target.result;
503
526
  previewFile.isPreviewImage = previewFile.file?.type.split('/')[0] === 'image' && previewFile.file?.type !== 'image/tiff';
504
527
  // trigger signal update
505
528
  this.previewFiles.set([...this.previewFiles()]);
506
529
  };
530
+ readers.push(reader);
507
531
  reader.readAsDataURL(previewFile.file);
508
532
  }
533
+ return () => {
534
+ for (const reader of readers) {
535
+ reader.onload = null;
536
+ if (reader.readyState === FileReader.LOADING)
537
+ reader.abort();
538
+ }
539
+ };
509
540
  };
510
541
  // ─── Public Event Handlers ────────────────────────────────────────────
511
542
  onUpload = () => {
@@ -535,6 +566,7 @@ class SdUploadFile {
535
566
  };
536
567
  #uploadFile = async (originFiles) => {
537
568
  const currentModel = [...(this.model() || [])];
569
+ const pickedFiles = new Map();
538
570
  if (originFiles.length + currentModel.length > this.max()) {
539
571
  this.#notifyService.warning(this.#i18n.t('core.component.upload-file.too-many-files'));
540
572
  return;
@@ -551,9 +583,15 @@ class SdUploadFile {
551
583
  }
552
584
  const hashedKey = this.#uploadFileService.add(file);
553
585
  if (hashedKey) {
586
+ pickedFiles.set(hashedKey, file);
554
587
  currentModel.push(hashedKey);
555
588
  }
556
589
  }
590
+ // why: giữ batch riêng qua từng await validate/resize; cache chung và effect của
591
+ // model cũ có thể mất hash trước khi picker hoàn tất. Chỉ nhận ownership khi commit.
592
+ for (const [key, file] of pickedFiles)
593
+ this.#localFiles.set(key, file);
594
+ this.#snapshotFiles(currentModel);
557
595
  this.model.set(currentModel);
558
596
  this.#touch();
559
597
  };
@@ -685,50 +723,81 @@ class SdUploadFile {
685
723
  return items.map(e => e?.file).filter(file => !!file);
686
724
  };
687
725
  /**
688
- * Thực hiện upload các file và gán giá trị, output trả ra mảng idOrKeys.
689
- * HÀM NÀY BẮT BUỘC PHẢI GỌI TRƯỚC KHI GỌI VỀ SERVER.
726
+ * Upload the local files referenced by the model, then replace their temporary
727
+ * keys with server IDs. Call and await this method before submitting the model.
728
+ *
729
+ * Files are read from the model and local snapshots/cache, so pending or failed metadata
730
+ * requests do not omit files. Each distinct local key is uploaded once per
731
+ * batch; duplicate occurrences retain their positions and share the returned ID.
732
+ * Server IDs and URLs are preserved even when they have no preview metadata.
733
+ *
734
+ * Concurrent calls share the active batch. Changes made while it is pending
735
+ * are preserved: removed keys stay removed, reordering is retained, and newly
736
+ * added local keys remain pending for a subsequent call. After destruction, no
737
+ * new upload starts and an in-flight upload cannot update the model.
738
+ *
739
+ * @returns A promise resolving to void after the batch finishes. The preview
740
+ * metadata refresh may still be pending. With no local files this is a no-op.
741
+ * A missing upload handler displays the configuration error and resolves
742
+ * without changing the model or cache.
743
+ * @throws The upload handler's error, without changing the model or cache.
744
+ * @throws Error if the handler does not return exactly one non-empty string ID
745
+ * per distinct local key; the model and cache are retained in that case.
746
+ *
747
+ * @example
748
+ * import { SdUploadFile } from '@sd-angular/core/components/upload-file';
749
+ *
750
+ * async function submit(control: SdUploadFile) {
751
+ * await control.upload();
752
+ * return control.model(); // Server IDs/URLs for the uploaded batch.
753
+ * }
690
754
  */
691
- upload = async () => {
692
- const items = this.previewFiles()?.filter(val => !!val) || [];
693
- if (!Array.isArray(items)) {
694
- this.model.set([]);
755
+ upload = () => {
756
+ if (this.#destroyed)
757
+ return Promise.resolve();
758
+ if (!this.#uploadPromise) {
759
+ // why: hai lần submit đồng thời phải dùng chung batch, tránh upload trùng File.
760
+ this.#uploadPromise = this.#uploadPendingFiles().finally(() => {
761
+ this.#uploadPromise = undefined;
762
+ });
763
+ }
764
+ return this.#uploadPromise;
765
+ };
766
+ #uploadPendingFiles = async () => {
767
+ const pending = [
768
+ ...new Map(this.#snapshotFiles(this.model())
769
+ .filter(entry => entry.file)
770
+ .map(entry => [entry.key, entry])).values(),
771
+ ];
772
+ if (!pending.length)
773
+ return;
774
+ const uploadHandler = this.#getUploadHandler();
775
+ if (!uploadHandler) {
776
+ const key = this.key();
777
+ this.#notifyService.error(key === undefined
778
+ ? this.#i18n.t('core.component.upload-file.no-configuration')
779
+ : this.#i18n.t('core.component.upload-file.no-configuration-key', { key }));
695
780
  return;
696
781
  }
697
- const results = [];
698
- const uploadedFiles = items.map(e => e?.file).filter(file => !!file);
699
- let idOrKeys = [];
700
- if (uploadedFiles.length) {
701
- const uploadHandler = this.#getUploadHandler();
702
- if (uploadHandler) {
703
- idOrKeys = await uploadHandler(uploadedFiles, this.args()).then(results => {
704
- for (const uploadedFile of uploadedFiles) {
705
- this.#uploadFileService.remove(uploadedFile);
706
- }
707
- return results;
708
- });
709
- }
710
- else {
711
- const key = this.key();
712
- this.#notifyService.error(key === undefined
713
- ? this.#i18n.t('core.component.upload-file.no-configuration')
714
- : this.#i18n.t('core.component.upload-file.no-configuration-key', { key }));
715
- }
782
+ const idOrKeys = await uploadHandler(pending.map(entry => entry.file), this.args());
783
+ if (!Array.isArray(idOrKeys) ||
784
+ idOrKeys.length !== pending.length ||
785
+ pending.some((_, index) => typeof idOrKeys[index] !== 'string' || !idOrKeys[index])) {
786
+ throw new Error('[sd-upload-file] Upload must return one non-empty string ID per file.');
716
787
  }
717
- let idx = 0;
718
- for (const item of items) {
719
- if (!item.file) {
720
- if (item.idOrKey) {
721
- results.push(item.idOrKey);
722
- }
723
- }
724
- else {
725
- if (idOrKeys?.[idx]) {
726
- results.push(idOrKeys[idx]);
727
- idx++;
728
- }
729
- }
788
+ const replacements = new Map(pending.map((entry, index) => [entry.key, idOrKeys[index]]));
789
+ for (const entry of pending) {
790
+ this.#uploadFileService.remove(entry.key.toString());
791
+ this.#localFiles.delete(entry.key.toString());
792
+ }
793
+ if (this.#destroyed)
794
+ return;
795
+ // why: thay đúng hash trên model MỚI NHẤT, không phục hồi file đã xoá hoặc
796
+ // ghi đè các thao tác thêm/sắp xếp trong lúc upload đang chờ.
797
+ const values = this.model();
798
+ if (Array.isArray(values)) {
799
+ this.model.set(values.map(key => replacements.get(key) ?? key));
730
800
  }
731
- this.model.set(results);
732
801
  };
733
802
  /**
734
803
  * Mở popup preview ảnh.
@@ -771,20 +840,23 @@ class SdUploadFile {
771
840
  * cần render ra ảnh/tệp tương ứng.
772
841
  */
773
842
  #details = async (keys) => {
774
- if (!Array.isArray(keys) || !keys.length) {
843
+ const entries = this.#snapshotFiles(keys);
844
+ if (!entries.length) {
775
845
  return [];
776
846
  }
777
- const idOrKeys = keys.filter(key => !!key && !this.#isCdn(key) && !this.#isHashedKey(key));
847
+ const idOrKeys = entries.filter(entry => !this.#isCdn(entry.key) && !entry.file).map(entry => entry.key);
778
848
  let detailsList = [];
779
849
  const detailsHandler = this.#getDetailsHandler();
780
850
  if (idOrKeys.length && detailsHandler) {
781
- detailsList = await detailsHandler(idOrKeys, this.args()).catch(error => {
851
+ try {
852
+ detailsList = await detailsHandler(idOrKeys, this.args());
853
+ }
854
+ catch (error) {
782
855
  console.error(error);
783
- return [];
784
- });
856
+ }
785
857
  }
786
858
  const results = [];
787
- for (const key of keys.filter(val => !!val)) {
859
+ for (const { key, file } of entries) {
788
860
  if (this.#isCdn(key)) {
789
861
  const cdn = key?.toString();
790
862
  const extension = this.#getExtension(cdn);
@@ -799,9 +871,7 @@ class SdUploadFile {
799
871
  extension,
800
872
  });
801
873
  }
802
- else if (this.#isHashedKey(key)) {
803
- const hashedKey = key?.toString();
804
- const file = this.#uploadFileService.get(hashedKey);
874
+ else if (file) {
805
875
  const lastDot = file.name.lastIndexOf('.');
806
876
  const extension = file.name.substring(lastDot + 1);
807
877
  const isPreviewImage = (file.type.split('/')[0] === 'image' && file.type !== 'image/tiff') || IsImage(extension);
@@ -842,10 +912,26 @@ class SdUploadFile {
842
912
  return false;
843
913
  return idOrKey.startsWith('http');
844
914
  };
845
- #isHashedKey = (idOrKey) => {
846
- if (typeof idOrKey !== 'string')
847
- return false;
848
- return this.#uploadFileService.isHashedKey(idOrKey);
915
+ #snapshotFiles = (keys) => {
916
+ const values = Array.isArray(keys) ? keys : [];
917
+ const activeKeys = new Set(values);
918
+ for (const key of this.#localFiles.keys()) {
919
+ if (!activeKeys.has(key))
920
+ this.#localFiles.delete(key);
921
+ }
922
+ // why: cache có thể bị upload xoá trong lúc chờ details; giữ File theo snapshot
923
+ // của từng ô và request, kể cả khi ô khác upload cùng hash rồi xoá cache chung.
924
+ return values
925
+ .filter(key => !!key)
926
+ .map(key => {
927
+ let file = null;
928
+ if (typeof key === 'string' && !this.#isCdn(key)) {
929
+ file = this.#localFiles.get(key) ?? this.#uploadFileService.get(key);
930
+ if (file)
931
+ this.#localFiles.set(key, file);
932
+ }
933
+ return { key, file };
934
+ });
849
935
  };
850
936
  #getExtension = (cdn) => {
851
937
  if (!cdn)