tailjng 0.1.14 → 0.1.15
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/cli/file-operations.js +4 -1
- package/package.json +1 -1
- package/src/lib/components/card/card-crud-complete/complete-crud-card.component.html +10 -2
- package/src/lib/components/card/card-crud-complete/complete-crud-card.component.ts +6 -0
- package/src/lib/components/input/input-textarea/textarea-input.component.html +2 -2
- package/src/lib/components/input/input-textarea/textarea-input.component.ts +10 -3
- package/src/lib/components/select/select-dropdown/dropdown-select.component.html +25 -10
- package/src/lib/components/select/select-dropdown/dropdown-select.component.ts +72 -8
- package/src/lib/components/select/select-dropdown/dropdown-select.types.ts +3 -0
- package/src/lib/components/select/select-dropdown/dropdown-select.util.ts +89 -12
- package/src/lib/components/select/select-multi-dropdown/multi-dropdown-select.component.html +9 -3
- package/src/lib/components/select/select-multi-dropdown/multi-dropdown-select.component.ts +42 -0
- package/src/lib/components/select/select-multi-dropdown/multi-dropdown-select.util.ts +2 -0
- package/src/lib/components/table/table-crud-complete/complete-crud-table.animations.ts +2 -2
- package/src/lib/components/table/table-crud-complete/complete-crud-table.component.html +4 -2
- package/src/lib/components/table/table-crud-complete/complete-crud-table.component.ts +3 -4
- package/src/lib/components/table/table-crud-complete/complete-crud-table.util.ts +13 -2
- package/src/lib/components/toggle-radio/shared/toggle-options.types.ts +9 -9
- package/src/lib/components/toggle-radio/toggle-segment/segment-toggle.component.ts +26 -3
- package/src/lib/components/toggle-radio/toggle-segment/segment-toggle.types.ts +4 -1
package/cli/file-operations.js
CHANGED
|
@@ -189,7 +189,10 @@ async function copyComponentFiles(componentName, componentPath, isDependency = f
|
|
|
189
189
|
|
|
190
190
|
fs.mkdirSync(targetPath, { recursive: true })
|
|
191
191
|
copyDirectoryRecursive(nodeModulesPath, targetPath, projectRoot)
|
|
192
|
-
await copySharedFolderIfNeeded(componentPath, isDependency, { forceSync: true })
|
|
192
|
+
const sharedOk = await copySharedFolderIfNeeded(componentPath, isDependency, { forceSync: true })
|
|
193
|
+
if (!sharedOk) {
|
|
194
|
+
process.exit(1)
|
|
195
|
+
}
|
|
193
196
|
|
|
194
197
|
return true
|
|
195
198
|
}
|
package/package.json
CHANGED
|
@@ -53,9 +53,17 @@
|
|
|
53
53
|
} @else if (displayData.length > 0) {
|
|
54
54
|
|
|
55
55
|
@if (itemTemplate) {
|
|
56
|
-
<div
|
|
56
|
+
<div
|
|
57
|
+
class="grid grid-cols-4 max-[1200px]:grid-cols-3 max-[950px]:grid-cols-2 max-[650px]:grid-cols-1 gap-4 my-4"
|
|
58
|
+
[class.items-stretch]="useStretchCards"
|
|
59
|
+
[class.items-start]="!useStretchCards"
|
|
60
|
+
>
|
|
57
61
|
@for (item of displayData; track trackById($index, item)) {
|
|
58
|
-
<div
|
|
62
|
+
<div
|
|
63
|
+
class="flex w-full flex-col rounded-xl border border-border dark:border-dark-border bg-white dark:bg-foreground shadow-sm hover:shadow-lg hover:border-primary/50 hover:dark:border-primary transition-all duration-300"
|
|
64
|
+
[class.h-full]="useStretchCards"
|
|
65
|
+
[class.self-start]="!useStretchCards"
|
|
66
|
+
>
|
|
59
67
|
<ng-container
|
|
60
68
|
*ngTemplateOutlet="
|
|
61
69
|
itemTemplate;
|
|
@@ -629,6 +629,12 @@ export class JCompleteCrudCardComponent implements OnInit {
|
|
|
629
629
|
|
|
630
630
|
@Input() expandTemplate?: TemplateRef<any>;
|
|
631
631
|
@Input() onExpandData?: (item: any) => Promise<any>;
|
|
632
|
+
/** false = cards no se estiran a la altura de la fila (útil con expand). Por defecto: !expandTemplate */
|
|
633
|
+
@Input() stretchCards?: boolean;
|
|
634
|
+
|
|
635
|
+
get useStretchCards(): boolean {
|
|
636
|
+
return this.stretchCards ?? !this.expandTemplate;
|
|
637
|
+
}
|
|
632
638
|
|
|
633
639
|
expandedItems: { [key: string]: boolean } = {};
|
|
634
640
|
expandData: { [key: string]: any } = {};
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
[value]="value"
|
|
7
7
|
(input)="onInput($event)"
|
|
8
8
|
[required]="required"
|
|
9
|
-
[disabled]="
|
|
9
|
+
[disabled]="isActuallyDisabled"
|
|
10
10
|
[readonly]="isReadonly"
|
|
11
11
|
[rows]="rows"
|
|
12
12
|
(blur)="onBlur()"
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
class="absolute right-2 top-2 flex cursor-pointer items-center justify-center p-0 focus:outline-none"
|
|
21
21
|
[ngClass]="clearButtonClasses"
|
|
22
22
|
(click)="clearInput()"
|
|
23
|
-
[disabled]="
|
|
23
|
+
[disabled]="isActuallyDisabled"
|
|
24
24
|
aria-label="Clear"
|
|
25
25
|
>
|
|
26
26
|
<JIcon
|
|
@@ -47,6 +47,7 @@ export class JTextareaInputComponent implements ControlValueAccessor {
|
|
|
47
47
|
@Input() name?: string;
|
|
48
48
|
@Input() placeholder = '';
|
|
49
49
|
|
|
50
|
+
/** Consumer-driven disabled (e.g. `[isDisabled]="true"`). */
|
|
50
51
|
@Input() isDisabled = false;
|
|
51
52
|
@Input() isReadonly = false;
|
|
52
53
|
@Input() required = false;
|
|
@@ -67,6 +68,7 @@ export class JTextareaInputComponent implements ControlValueAccessor {
|
|
|
67
68
|
@Input() ngClasses: Record<string, boolean> = {};
|
|
68
69
|
|
|
69
70
|
innerValue = '';
|
|
71
|
+
private formDisabled = false;
|
|
70
72
|
|
|
71
73
|
private onChange: (value: string) => void = () => {};
|
|
72
74
|
private onTouched: () => void = () => {};
|
|
@@ -109,7 +111,7 @@ export class JTextareaInputComponent implements ControlValueAccessor {
|
|
|
109
111
|
this.ngClasses,
|
|
110
112
|
{ 'pr-3': !this.clearButton, 'pr-7': this.clearButton },
|
|
111
113
|
{ 'scroll-element': true },
|
|
112
|
-
this.
|
|
114
|
+
this.isActuallyDisabled ? INPUT_DISABLED_OVERLAY_CLASSES : {},
|
|
113
115
|
);
|
|
114
116
|
}
|
|
115
117
|
|
|
@@ -118,7 +120,12 @@ export class JTextareaInputComponent implements ControlValueAccessor {
|
|
|
118
120
|
* @returns Hover and disabled utilities for the clear control.
|
|
119
121
|
*/
|
|
120
122
|
get clearButtonClasses(): Record<string, boolean> {
|
|
121
|
-
return inputActionButtonClasses(this.
|
|
123
|
+
return inputActionButtonClasses(this.isActuallyDisabled);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Final disabled state (input OR reactive-forms). */
|
|
127
|
+
get isActuallyDisabled(): boolean {
|
|
128
|
+
return this.isDisabled || this.formDisabled;
|
|
122
129
|
}
|
|
123
130
|
|
|
124
131
|
/** Tailwind resize utilities keyed by {@link TextareaResize}. */
|
|
@@ -158,7 +165,7 @@ export class JTextareaInputComponent implements ControlValueAccessor {
|
|
|
158
165
|
* @param isDisabled Whether the control is disabled.
|
|
159
166
|
*/
|
|
160
167
|
setDisabledState(isDisabled: boolean): void {
|
|
161
|
-
this.
|
|
168
|
+
this.formDisabled = isDisabled;
|
|
162
169
|
}
|
|
163
170
|
|
|
164
171
|
/**
|
|
@@ -12,14 +12,25 @@
|
|
|
12
12
|
"
|
|
13
13
|
[ngClass]="getButtonNgClass()"
|
|
14
14
|
>
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
15
|
+
@if (selectedLabelHtml) {
|
|
16
|
+
<span
|
|
17
|
+
class="truncate text-black dark:text-white [&_strong]:font-bold"
|
|
18
|
+
[ngClass]="{
|
|
19
|
+
'opacity-70 text-muted-foreground dark:text-dark-muted-foreground':
|
|
20
|
+
selectedValue === null,
|
|
21
|
+
}"
|
|
22
|
+
[innerHTML]="selectedLabelHtml"
|
|
23
|
+
></span>
|
|
24
|
+
} @else {
|
|
25
|
+
<span
|
|
26
|
+
class="truncate text-black dark:text-white"
|
|
27
|
+
[ngClass]="{
|
|
28
|
+
'opacity-70 text-muted-foreground dark:text-dark-muted-foreground':
|
|
29
|
+
selectedValue === null,
|
|
30
|
+
}"
|
|
31
|
+
>{{ selectedLabel }}</span
|
|
32
|
+
>
|
|
33
|
+
}
|
|
23
34
|
|
|
24
35
|
<div class="flex items-center">
|
|
25
36
|
@if (showClear && selectedValue !== null) {
|
|
@@ -159,9 +170,13 @@
|
|
|
159
170
|
}
|
|
160
171
|
|
|
161
172
|
<div
|
|
162
|
-
class="flex items-center break-words whitespace-normal overflow-hidden"
|
|
173
|
+
class="flex items-center break-words whitespace-normal overflow-hidden [&_strong]:font-bold"
|
|
163
174
|
>
|
|
164
|
-
|
|
175
|
+
@if (option.html) {
|
|
176
|
+
<span [innerHTML]="option.html"></span>
|
|
177
|
+
} @else {
|
|
178
|
+
{{ option.text }}
|
|
179
|
+
}
|
|
165
180
|
</div>
|
|
166
181
|
</div>
|
|
167
182
|
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
|
|
1
2
|
import { animate, style, transition, trigger } from '@angular/animations';
|
|
2
3
|
import { CommonModule } from '@angular/common';
|
|
3
4
|
import {
|
|
@@ -47,6 +48,7 @@ import {
|
|
|
47
48
|
readDropdownPaginationMeta,
|
|
48
49
|
rebuildEndpointUrl,
|
|
49
50
|
resetDropdownPanelStyles,
|
|
51
|
+
serializeDropdownFilters,
|
|
50
52
|
} from './dropdown-select.util';
|
|
51
53
|
|
|
52
54
|
export type {
|
|
@@ -71,8 +73,10 @@ export {
|
|
|
71
73
|
readDropdownPaginationMeta,
|
|
72
74
|
rebuildEndpointUrl,
|
|
73
75
|
resetDropdownPanelStyles,
|
|
76
|
+
serializeDropdownFilters,
|
|
74
77
|
resolveOptionLabel,
|
|
75
78
|
resolveOptionLabelFromTemplate,
|
|
79
|
+
resolveOptionLabelHtmlFromTemplate,
|
|
76
80
|
getOptionValueFilterKey,
|
|
77
81
|
canResolveSelectedByFindOne,
|
|
78
82
|
} from './dropdown-select.util';
|
|
@@ -272,6 +276,7 @@ export class JDropdownSelectComponent
|
|
|
272
276
|
isColumnSelectorOpen = false;
|
|
273
277
|
selectedValue: unknown = null;
|
|
274
278
|
selectedLabel = '';
|
|
279
|
+
selectedLabelHtml: string | null = null;
|
|
275
280
|
internalOptions: DropdownOption[] = [];
|
|
276
281
|
filteredOptions: DropdownOption[] = [];
|
|
277
282
|
|
|
@@ -296,6 +301,7 @@ export class JDropdownSelectComponent
|
|
|
296
301
|
|
|
297
302
|
private currentPage = 1;
|
|
298
303
|
private totalPages = 1;
|
|
304
|
+
private defaultFiltersSnapshot = '';
|
|
299
305
|
private resolvedSelectedOption: DropdownOption | null = null;
|
|
300
306
|
private isResolvingSelected = false;
|
|
301
307
|
|
|
@@ -334,6 +340,7 @@ export class JDropdownSelectComponent
|
|
|
334
340
|
this.loadData(true);
|
|
335
341
|
}
|
|
336
342
|
|
|
343
|
+
this.defaultFiltersSnapshot = serializeDropdownFilters(this.defaultFilters);
|
|
337
344
|
this.updateSelectedLabel();
|
|
338
345
|
}
|
|
339
346
|
|
|
@@ -346,6 +353,41 @@ export class JDropdownSelectComponent
|
|
|
346
353
|
if (changes['options']) {
|
|
347
354
|
this.processOptions();
|
|
348
355
|
}
|
|
356
|
+
|
|
357
|
+
if (changes['isDisabled']?.currentValue === true) {
|
|
358
|
+
this.closePanel();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (changes['defaultFilters']) {
|
|
362
|
+
this.syncDefaultFilters();
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Reloads or clears options when dependent `defaultFilters` change. */
|
|
367
|
+
private syncDefaultFilters(): void {
|
|
368
|
+
const snapshot = serializeDropdownFilters(this.defaultFilters);
|
|
369
|
+
if (snapshot === this.defaultFiltersSnapshot) {
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
this.defaultFiltersSnapshot = snapshot;
|
|
374
|
+
this.searchTerm = '';
|
|
375
|
+
this.options = [];
|
|
376
|
+
this.internalOptions = [];
|
|
377
|
+
this.filteredOptions = [];
|
|
378
|
+
|
|
379
|
+
if (this.isColumnSelectorOpen) {
|
|
380
|
+
this.closePanel();
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
if (this.type === 'searchable' && this.endpoint && !this.isDisabled && this.genericService) {
|
|
384
|
+
if (this.loadOnInit) {
|
|
385
|
+
this.loadData(true);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
this.updateSelectedLabel();
|
|
390
|
+
this.cdr.markForCheck();
|
|
349
391
|
}
|
|
350
392
|
|
|
351
393
|
ngOnDestroy(): void {
|
|
@@ -368,7 +410,9 @@ export class JDropdownSelectComponent
|
|
|
368
410
|
* @param value Selected option value or `null`.
|
|
369
411
|
*/
|
|
370
412
|
writeValue(value: unknown): void {
|
|
371
|
-
|
|
413
|
+
const valueChanged = !optionValuesEqual(this.selectedValue, value);
|
|
414
|
+
|
|
415
|
+
if (valueChanged) {
|
|
372
416
|
if (
|
|
373
417
|
value == null ||
|
|
374
418
|
!this.resolvedSelectedOption ||
|
|
@@ -379,9 +423,17 @@ export class JDropdownSelectComponent
|
|
|
379
423
|
}
|
|
380
424
|
|
|
381
425
|
this.selectedValue = value;
|
|
426
|
+
|
|
427
|
+
if (value == null) {
|
|
428
|
+
this.selectedLabel = this.placeholder;
|
|
429
|
+
this.selectedLabelHtml = null;
|
|
430
|
+
this.cdr.markForCheck();
|
|
431
|
+
return;
|
|
432
|
+
}
|
|
433
|
+
|
|
382
434
|
this.updateSelectedLabel();
|
|
383
435
|
|
|
384
|
-
if (
|
|
436
|
+
if (this.type === 'searchable' && !this.findSelectedOption()) {
|
|
385
437
|
this.resolveSelectedOption();
|
|
386
438
|
}
|
|
387
439
|
|
|
@@ -452,6 +504,7 @@ export class JDropdownSelectComponent
|
|
|
452
504
|
selectOption(option: DropdownOption): void {
|
|
453
505
|
this.selectedValue = option.value;
|
|
454
506
|
this.selectedLabel = option.text;
|
|
507
|
+
this.selectedLabelHtml = option.html ?? null;
|
|
455
508
|
this.resolvedSelectedOption = option;
|
|
456
509
|
this.onChange(this.selectedValue);
|
|
457
510
|
this.selectionChange.emit(option.original ?? option.value);
|
|
@@ -465,7 +518,9 @@ export class JDropdownSelectComponent
|
|
|
465
518
|
clearSelection(event: Event): void {
|
|
466
519
|
event.stopPropagation();
|
|
467
520
|
this.resolvedSelectedOption = null;
|
|
468
|
-
this.
|
|
521
|
+
this.selectedValue = null;
|
|
522
|
+
this.selectedLabel = this.placeholder;
|
|
523
|
+
this.selectedLabelHtml = null;
|
|
469
524
|
this.onChange(null);
|
|
470
525
|
this.selectionChange.emit(null);
|
|
471
526
|
}
|
|
@@ -485,12 +540,14 @@ export class JDropdownSelectComponent
|
|
|
485
540
|
updateSelectedLabel(): void {
|
|
486
541
|
if (this.selectedValue === null) {
|
|
487
542
|
this.selectedLabel = this.placeholder;
|
|
543
|
+
this.selectedLabelHtml = null;
|
|
488
544
|
return;
|
|
489
545
|
}
|
|
490
546
|
|
|
491
547
|
const selectedOption = this.findSelectedOption();
|
|
492
548
|
if (selectedOption) {
|
|
493
549
|
this.selectedLabel = selectedOption.text;
|
|
550
|
+
this.selectedLabelHtml = selectedOption.html ?? null;
|
|
494
551
|
this.resolvedSelectedOption = selectedOption;
|
|
495
552
|
return;
|
|
496
553
|
}
|
|
@@ -500,17 +557,17 @@ export class JDropdownSelectComponent
|
|
|
500
557
|
optionValuesEqual(this.resolvedSelectedOption.value, this.selectedValue)
|
|
501
558
|
) {
|
|
502
559
|
this.selectedLabel = this.resolvedSelectedOption.text;
|
|
560
|
+
this.selectedLabelHtml = this.resolvedSelectedOption.html ?? null;
|
|
503
561
|
this.ensureSelectedOptionPinned();
|
|
504
562
|
return;
|
|
505
563
|
}
|
|
506
564
|
|
|
565
|
+
this.selectedLabel = this.placeholder;
|
|
566
|
+
this.selectedLabelHtml = null;
|
|
567
|
+
|
|
507
568
|
if (this.type === 'searchable' && this.resolveSelected) {
|
|
508
569
|
this.resolveSelectedOption();
|
|
509
570
|
}
|
|
510
|
-
|
|
511
|
-
if (!this.selectedLabel || this.selectedLabel === this.placeholder) {
|
|
512
|
-
this.selectedLabel = this.placeholder;
|
|
513
|
-
}
|
|
514
571
|
}
|
|
515
572
|
|
|
516
573
|
/** Keeps a resolved selection visible even if it is not in the current page. */
|
|
@@ -536,12 +593,13 @@ export class JDropdownSelectComponent
|
|
|
536
593
|
|
|
537
594
|
private applyResolvedRecord(record: Record<string, unknown>): void {
|
|
538
595
|
const [option] = this.mapOptions([record]);
|
|
539
|
-
if (!option) {
|
|
596
|
+
if (!option || !optionValuesEqual(option.value, this.selectedValue)) {
|
|
540
597
|
return;
|
|
541
598
|
}
|
|
542
599
|
|
|
543
600
|
this.resolvedSelectedOption = option;
|
|
544
601
|
this.selectedLabel = option.text;
|
|
602
|
+
this.selectedLabelHtml = option.html ?? null;
|
|
545
603
|
this.ensureSelectedOptionPinned();
|
|
546
604
|
this.cdr.detectChanges();
|
|
547
605
|
}
|
|
@@ -825,6 +883,12 @@ export class JDropdownSelectComponent
|
|
|
825
883
|
|
|
826
884
|
if (this.type === 'searchable' && !this.loadOnInit && this.shouldTriggerLoad()) {
|
|
827
885
|
this.loadData(true);
|
|
886
|
+
} else if (
|
|
887
|
+
this.type === 'searchable' &&
|
|
888
|
+
this.endpoint &&
|
|
889
|
+
this.internalOptions.length === 0
|
|
890
|
+
) {
|
|
891
|
+
this.loadData(true);
|
|
828
892
|
}
|
|
829
893
|
|
|
830
894
|
return;
|
|
@@ -7,7 +7,10 @@ export type DropdownSortOrder = 'ASC' | 'DESC';
|
|
|
7
7
|
/** Normalized option shown in the panel list. */
|
|
8
8
|
export type DropdownOption<TOriginal = unknown> = {
|
|
9
9
|
value: unknown;
|
|
10
|
+
/** Plain text label (search, accessibility, fallback). */
|
|
10
11
|
text: string;
|
|
12
|
+
/** Rich label when `optionLabelTemplate` uses `{b:field}` placeholders. */
|
|
13
|
+
html?: string;
|
|
11
14
|
original?: TOriginal;
|
|
12
15
|
};
|
|
13
16
|
|
|
@@ -72,19 +72,64 @@ export function extractOptionsFromResponse(
|
|
|
72
72
|
return Array.isArray(responseData) ? (responseData as unknown[]) : [];
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
function escapeOptionLabelHtml(value: string): string {
|
|
76
|
+
return value
|
|
77
|
+
.replace(/&/g, '&')
|
|
78
|
+
.replace(/</g, '<')
|
|
79
|
+
.replace(/>/g, '>')
|
|
80
|
+
.replace(/"/g, '"')
|
|
81
|
+
.replace(/'/g, ''');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolveTemplatePlaceholder(
|
|
85
|
+
option: Record<string, unknown>,
|
|
86
|
+
rawPath: string,
|
|
87
|
+
): string {
|
|
88
|
+
const path = rawPath.trim().replace(/^b:/, '');
|
|
89
|
+
const value = getNestedValue(option, path);
|
|
90
|
+
if (value == null || value === '') {
|
|
91
|
+
return '';
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return String(value);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Builds the display label from a template like `El valor es {value} de {label}`.
|
|
99
|
+
* Use `{b:field}` for bold segments in the panel (rendered as `<strong>`).
|
|
100
|
+
*/
|
|
76
101
|
export function resolveOptionLabelFromTemplate(
|
|
77
102
|
option: Record<string, unknown>,
|
|
78
103
|
template: string,
|
|
79
104
|
): string {
|
|
80
|
-
return template.replace(/\{([^}]+)\}/g, (_, path: string) =>
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
105
|
+
return template.replace(/\{(?:b:)?([^}]+)\}/g, (_, path: string) =>
|
|
106
|
+
resolveTemplatePlaceholder(option, path),
|
|
107
|
+
);
|
|
108
|
+
}
|
|
85
109
|
|
|
86
|
-
|
|
110
|
+
/**
|
|
111
|
+
* HTML label when the template contains `{b:field}` placeholders.
|
|
112
|
+
* Returns `null` when no bold placeholders are used.
|
|
113
|
+
*/
|
|
114
|
+
export function resolveOptionLabelHtmlFromTemplate(
|
|
115
|
+
option: Record<string, unknown>,
|
|
116
|
+
template: string,
|
|
117
|
+
): string | null {
|
|
118
|
+
if (!template.includes('{b:')) {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
let html = template.replace(/\{b:([^}]+)\}/g, (_, path: string) => {
|
|
123
|
+
const value = resolveTemplatePlaceholder(option, `b:${path}`);
|
|
124
|
+
return value ? `<strong>${escapeOptionLabelHtml(value)}</strong>` : '';
|
|
87
125
|
});
|
|
126
|
+
|
|
127
|
+
html = html.replace(/\{([^}]+)\}/g, (_, path: string) => {
|
|
128
|
+
const value = resolveTemplatePlaceholder(option, path);
|
|
129
|
+
return value ? escapeOptionLabelHtml(value) : '';
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
return html;
|
|
88
133
|
}
|
|
89
134
|
|
|
90
135
|
/** Builds the display label for one option object. */
|
|
@@ -124,11 +169,24 @@ export function mapObjectOptions(
|
|
|
124
169
|
labelSeparator: string,
|
|
125
170
|
optionLabelTemplate?: string,
|
|
126
171
|
): DropdownOption[] {
|
|
127
|
-
return options.map((option) =>
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
172
|
+
return options.map((option) => {
|
|
173
|
+
const text = resolveOptionLabel(
|
|
174
|
+
option,
|
|
175
|
+
optionLabel,
|
|
176
|
+
labelSeparator,
|
|
177
|
+
optionLabelTemplate,
|
|
178
|
+
);
|
|
179
|
+
const html = optionLabelTemplate?.includes('{b:')
|
|
180
|
+
? resolveOptionLabelHtmlFromTemplate(option, optionLabelTemplate) ?? undefined
|
|
181
|
+
: undefined;
|
|
182
|
+
|
|
183
|
+
return {
|
|
184
|
+
value: getNestedValue(option, optionValue),
|
|
185
|
+
text,
|
|
186
|
+
html,
|
|
187
|
+
original: option,
|
|
188
|
+
};
|
|
189
|
+
});
|
|
132
190
|
}
|
|
133
191
|
|
|
134
192
|
/** Local text filter for static dropdown mode. */
|
|
@@ -144,6 +202,25 @@ export function filterOptionsBySearch(
|
|
|
144
202
|
return options.filter((option) => option.text.toLowerCase().includes(normalized));
|
|
145
203
|
}
|
|
146
204
|
|
|
205
|
+
/** Stable JSON key for default filter change detection. */
|
|
206
|
+
export function serializeDropdownFilters(filters: Record<string, unknown>): string {
|
|
207
|
+
const normalized = Object.keys(filters)
|
|
208
|
+
.sort()
|
|
209
|
+
.reduce<Record<string, unknown>>((acc, key) => {
|
|
210
|
+
const value = filters[key];
|
|
211
|
+
|
|
212
|
+
if (Array.isArray(value)) {
|
|
213
|
+
acc[key] = [...value].map(String).sort();
|
|
214
|
+
return acc;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
acc[key] = value;
|
|
218
|
+
return acc;
|
|
219
|
+
}, {});
|
|
220
|
+
|
|
221
|
+
return JSON.stringify(normalized);
|
|
222
|
+
}
|
|
223
|
+
|
|
147
224
|
/** Compares option values loosely (`1` equals `"1"`). */
|
|
148
225
|
export function optionValuesEqual(a: unknown, b: unknown): boolean {
|
|
149
226
|
if (a === b) {
|
package/src/lib/components/select/select-multi-dropdown/multi-dropdown-select.component.html
CHANGED
|
@@ -180,9 +180,15 @@
|
|
|
180
180
|
[ariaHidden]="true"
|
|
181
181
|
/>
|
|
182
182
|
}
|
|
183
|
-
<label
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
<label
|
|
184
|
+
class="text-black dark:text-white text-sm font-medium w-full [&_strong]:font-bold"
|
|
185
|
+
>
|
|
186
|
+
@if (option.html) {
|
|
187
|
+
<span [innerHTML]="option.html"></span>
|
|
188
|
+
} @else {
|
|
189
|
+
{{ option.text }}
|
|
190
|
+
}
|
|
191
|
+
</label>
|
|
186
192
|
</div>
|
|
187
193
|
}
|
|
188
194
|
@if (filteredProcessedOptions.length === 0 && !isLoading) {
|
|
@@ -48,6 +48,7 @@ import {
|
|
|
48
48
|
readDropdownPaginationMeta,
|
|
49
49
|
rebuildEndpointUrl,
|
|
50
50
|
resetDropdownPanelStyles,
|
|
51
|
+
serializeDropdownFilters,
|
|
51
52
|
} from './multi-dropdown-select.util';
|
|
52
53
|
|
|
53
54
|
export type {
|
|
@@ -74,6 +75,9 @@ export {
|
|
|
74
75
|
rebuildEndpointUrl,
|
|
75
76
|
resetDropdownPanelStyles,
|
|
76
77
|
resolveOptionLabel,
|
|
78
|
+
resolveOptionLabelFromTemplate,
|
|
79
|
+
resolveOptionLabelHtmlFromTemplate,
|
|
80
|
+
serializeDropdownFilters,
|
|
77
81
|
} from './multi-dropdown-select.util';
|
|
78
82
|
|
|
79
83
|
/**
|
|
@@ -278,6 +282,7 @@ export class JMultiDropdownSelectComponent
|
|
|
278
282
|
|
|
279
283
|
private currentPage = 1;
|
|
280
284
|
private totalPages = 1;
|
|
285
|
+
private defaultFiltersSnapshot = '';
|
|
281
286
|
private resolvedSelectedOptions = new Map<string, MultiDropdownOption>();
|
|
282
287
|
private isResolvingSelected = false;
|
|
283
288
|
|
|
@@ -321,6 +326,8 @@ export class JMultiDropdownSelectComponent
|
|
|
321
326
|
if (this.type === 'searchable' && this.loadOnInit) {
|
|
322
327
|
this.loadData(true);
|
|
323
328
|
}
|
|
329
|
+
|
|
330
|
+
this.defaultFiltersSnapshot = serializeDropdownFilters(this.defaultFilters);
|
|
324
331
|
}
|
|
325
332
|
|
|
326
333
|
ngAfterViewInit(): void {
|
|
@@ -332,6 +339,41 @@ export class JMultiDropdownSelectComponent
|
|
|
332
339
|
if (changes['options']) {
|
|
333
340
|
this.processOptions();
|
|
334
341
|
}
|
|
342
|
+
|
|
343
|
+
if (changes['isDisabled']?.currentValue === true) {
|
|
344
|
+
this.closePanel();
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (changes['defaultFilters']) {
|
|
348
|
+
this.syncDefaultFilters();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Reloads or clears options when dependent `defaultFilters` change. */
|
|
353
|
+
private syncDefaultFilters(): void {
|
|
354
|
+
const snapshot = serializeDropdownFilters(this.defaultFilters);
|
|
355
|
+
if (snapshot === this.defaultFiltersSnapshot) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
this.defaultFiltersSnapshot = snapshot;
|
|
360
|
+
this.searchTerm = '';
|
|
361
|
+
this.options = [];
|
|
362
|
+
this.processedOptions = [];
|
|
363
|
+
this.filteredProcessedOptions = [];
|
|
364
|
+
|
|
365
|
+
if (this.isDropdownOpen) {
|
|
366
|
+
this.closePanel();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if (this.type === 'searchable' && this.endpoint && !this.isDisabled && this.genericService) {
|
|
370
|
+
if (this.loadOnInit) {
|
|
371
|
+
this.loadData(true);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
this.updateDisplayLabel();
|
|
376
|
+
this.cdr.markForCheck();
|
|
335
377
|
}
|
|
336
378
|
|
|
337
379
|
ngOnDestroy(): void {
|
|
@@ -15,6 +15,8 @@ export {
|
|
|
15
15
|
resetDropdownPanelStyles,
|
|
16
16
|
resolveOptionLabel,
|
|
17
17
|
resolveOptionLabelFromTemplate,
|
|
18
|
+
resolveOptionLabelHtmlFromTemplate,
|
|
19
|
+
serializeDropdownFilters,
|
|
18
20
|
} from '../select-dropdown/dropdown-select.util';
|
|
19
21
|
|
|
20
22
|
/** Coerces numeric strings to numbers so checkbox matching stays stable. */
|
|
@@ -9,8 +9,8 @@ export const crudTableSlideToggleAnimation = trigger('slideToggle', [
|
|
|
9
9
|
|
|
10
10
|
/** Group header expand/collapse animation. */
|
|
11
11
|
export const crudTableGroupSlideToggleAnimation = trigger('groupSlideToggle', [
|
|
12
|
-
state('collapsed', style({ height: '0', opacity: 0
|
|
13
|
-
state('expanded', style({ height: '*', opacity: 1
|
|
12
|
+
state('collapsed', style({ height: '0', opacity: 0 })),
|
|
13
|
+
state('expanded', style({ height: '*', opacity: 1 })),
|
|
14
14
|
transition('collapsed => expanded', [animate('380ms cubic-bezier(0.4, 0, 0.2, 1)')]),
|
|
15
15
|
transition('expanded => collapsed', [animate('300ms cubic-bezier(0.4, 0, 0.6, 1)')]),
|
|
16
16
|
]);
|
|
@@ -130,7 +130,7 @@
|
|
|
130
130
|
size="16"
|
|
131
131
|
iconClass="text-white animate-spin" [ariaHidden]="true" />
|
|
132
132
|
} @else if (
|
|
133
|
-
getSortKey(sortColumn) !== column.key ||
|
|
133
|
+
getSortKey(sortColumn) !== getSortKey(column.key) ||
|
|
134
134
|
sortDirection === "none"
|
|
135
135
|
) {
|
|
136
136
|
<JIcon [icon]="Icons.ChevronsUpDown"
|
|
@@ -656,7 +656,9 @@
|
|
|
656
656
|
>
|
|
657
657
|
<div
|
|
658
658
|
[@groupSlideToggle]="getGroupExpansionState(item)"
|
|
659
|
-
class="j-group-expand-panel
|
|
659
|
+
class="j-group-expand-panel"
|
|
660
|
+
[class.overflow-hidden]="getGroupExpansionState(item) === 'collapsed'"
|
|
661
|
+
[class.overflow-visible]="getGroupExpansionState(item) === 'expanded'"
|
|
660
662
|
>
|
|
661
663
|
<table
|
|
662
664
|
class="j-group-inner-table table-fixed w-full min-w-full border-collapse bg-white dark:bg-foreground"
|
|
@@ -437,7 +437,7 @@ export class JCompleteCrudTableComponent implements OnInit {
|
|
|
437
437
|
this.totalItems = this.data.length;
|
|
438
438
|
}
|
|
439
439
|
|
|
440
|
-
if (response.meta?.sort) {
|
|
440
|
+
if (response.meta?.sort?.by && typeof response.meta.sort.by === 'string') {
|
|
441
441
|
this.sortColumn = response.meta.sort.by;
|
|
442
442
|
this.sortDirection = response.meta.sort.order.toLowerCase() as SortDirection;
|
|
443
443
|
}
|
|
@@ -783,9 +783,8 @@ export class JCompleteCrudTableComponent implements OnInit {
|
|
|
783
783
|
return resolveOptionIcon(icon, data);
|
|
784
784
|
}
|
|
785
785
|
|
|
786
|
-
getDisabled(option: OptionsTable, data: any): boolean
|
|
787
|
-
|
|
788
|
-
}
|
|
786
|
+
getDisabled = (option: OptionsTable, data: any): boolean =>
|
|
787
|
+
resolveOptionDisabled(option, data);
|
|
789
788
|
|
|
790
789
|
getIsVisible(option: OptionsTable, data: any): boolean {
|
|
791
790
|
return resolveOptionVisible(option, data);
|
|
@@ -220,6 +220,18 @@ export function cacheExpandedRowKeys(
|
|
|
220
220
|
|
|
221
221
|
// ── Row styling ──────────────────────────────────────────────────────────────
|
|
222
222
|
|
|
223
|
+
function normalizeRowColorConditionKey(value: unknown): string {
|
|
224
|
+
if (value === null || value === undefined) {
|
|
225
|
+
return 'null';
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
if (typeof value === 'boolean') {
|
|
229
|
+
return String(value);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
return String(value).toUpperCase();
|
|
233
|
+
}
|
|
234
|
+
|
|
223
235
|
export function buildRowNgClass(
|
|
224
236
|
row: Record<string, unknown>,
|
|
225
237
|
columns: TableColumn<any>[],
|
|
@@ -241,8 +253,7 @@ export function buildRowNgClass(
|
|
|
241
253
|
value = (value as Record<string, unknown> | undefined)?.[key];
|
|
242
254
|
}
|
|
243
255
|
|
|
244
|
-
const normalizedValue =
|
|
245
|
-
value === null || value === undefined ? 'null' : String(value).toUpperCase();
|
|
256
|
+
const normalizedValue = normalizeRowColorConditionKey(value);
|
|
246
257
|
const semanticClass = columnWithColor.rowColorCondition?.[normalizedValue] as
|
|
247
258
|
| CrudTableRowColorSemantic
|
|
248
259
|
| undefined;
|
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
export type ToggleOption<T = unknown> = {
|
|
2
|
-
value: T;
|
|
3
|
-
label: string;
|
|
4
|
-
original?: unknown;
|
|
5
|
-
};
|
|
6
|
-
|
|
7
|
-
export type ToggleSortOrder = 'ASC' | 'DESC';
|
|
8
|
-
|
|
9
|
-
export type ToggleRadioLayout = 'vertical' | 'horizontal';
|
|
1
|
+
export type ToggleOption<T = unknown> = {
|
|
2
|
+
value: T;
|
|
3
|
+
label: string;
|
|
4
|
+
original?: unknown;
|
|
5
|
+
};
|
|
6
|
+
|
|
7
|
+
export type ToggleSortOrder = 'ASC' | 'DESC';
|
|
8
|
+
|
|
9
|
+
export type ToggleRadioLayout = 'vertical' | 'horizontal';
|
|
@@ -20,8 +20,9 @@ import {
|
|
|
20
20
|
normalizeToggleOptions,
|
|
21
21
|
} from '../shared/toggle-options.util';
|
|
22
22
|
import { ToggleOption, ToggleSortOrder } from '../shared/toggle-options.types';
|
|
23
|
+
import { ToggleSegmentType } from './segment-toggle.types';
|
|
23
24
|
|
|
24
|
-
export type { ToggleOption, ToggleSortOrder } from './segment-toggle.types';
|
|
25
|
+
export type { ToggleOption, ToggleSortOrder, ToggleSegmentType } from './segment-toggle.types';
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* Segmented single-select control (pill/tab style).
|
|
@@ -56,6 +57,8 @@ export type { ToggleOption, ToggleSortOrder } from './segment-toggle.types';
|
|
|
56
57
|
export class JToggleSegmentComponent implements OnInit, OnChanges, ControlValueAccessor {
|
|
57
58
|
readonly Icons = Icons;
|
|
58
59
|
|
|
60
|
+
/** `static` = local `options` · `searchable` = loads from `endpoint` on init. */
|
|
61
|
+
@Input() type: ToggleSegmentType = 'static';
|
|
59
62
|
@Input() endpoint = '';
|
|
60
63
|
@Input() options: unknown[] = [];
|
|
61
64
|
@Input() optionLabel: string | string[] = 'label';
|
|
@@ -126,7 +129,7 @@ export class JToggleSegmentComponent implements OnInit, OnChanges, ControlValueA
|
|
|
126
129
|
}
|
|
127
130
|
|
|
128
131
|
ngOnInit(): void {
|
|
129
|
-
if (this.
|
|
132
|
+
if (this.shouldLoadFromApi()) {
|
|
130
133
|
this.loadOptionsFromApi();
|
|
131
134
|
return;
|
|
132
135
|
}
|
|
@@ -135,6 +138,21 @@ export class JToggleSegmentComponent implements OnInit, OnChanges, ControlValueA
|
|
|
135
138
|
}
|
|
136
139
|
|
|
137
140
|
ngOnChanges(changes: SimpleChanges): void {
|
|
141
|
+
const loadTriggers = [
|
|
142
|
+
'endpoint',
|
|
143
|
+
'type',
|
|
144
|
+
'loadOnInit',
|
|
145
|
+
'defaultFilters',
|
|
146
|
+
'dataPath',
|
|
147
|
+
'responseKey',
|
|
148
|
+
'sort',
|
|
149
|
+
];
|
|
150
|
+
|
|
151
|
+
if (loadTriggers.some((key) => changes[key]) && this.shouldLoadFromApi()) {
|
|
152
|
+
this.loadOptionsFromApi();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
138
156
|
const optionInputs = [
|
|
139
157
|
'options',
|
|
140
158
|
'optionLabel',
|
|
@@ -144,11 +162,16 @@ export class JToggleSegmentComponent implements OnInit, OnChanges, ControlValueA
|
|
|
144
162
|
'optionLabelFn',
|
|
145
163
|
];
|
|
146
164
|
|
|
147
|
-
if (optionInputs.some((key) => changes[key]) && !this.
|
|
165
|
+
if (optionInputs.some((key) => changes[key]) && !this.shouldLoadFromApi()) {
|
|
148
166
|
this.processOptions();
|
|
149
167
|
}
|
|
150
168
|
}
|
|
151
169
|
|
|
170
|
+
/** Whether options should be fetched from `endpoint`. */
|
|
171
|
+
private shouldLoadFromApi(): boolean {
|
|
172
|
+
return !!this.endpoint && (this.loadOnInit || this.type === 'searchable');
|
|
173
|
+
}
|
|
174
|
+
|
|
152
175
|
setDisabledState(isDisabled: boolean): void {
|
|
153
176
|
this.isComponentDisabled = isDisabled;
|
|
154
177
|
}
|
|
@@ -1 +1,4 @@
|
|
|
1
|
-
export type { ToggleOption, ToggleSortOrder } from '../shared/toggle-options.types';
|
|
1
|
+
export type { ToggleOption, ToggleSortOrder } from '../shared/toggle-options.types';
|
|
2
|
+
|
|
3
|
+
/** `static` = local options · `searchable` = API endpoint (loads on init). */
|
|
4
|
+
export type ToggleSegmentType = 'static' | 'searchable';
|