commons-shared-web-ui 0.0.53 → 0.0.55

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 (26) hide show
  1. package/documentation/smart-form.md +236 -3
  2. package/documentation/time-picker.md +274 -0
  3. package/ng-package.json +3 -1
  4. package/package.json +1 -1
  5. package/src/lib/modules/form-components/form-components.module.ts +2 -1
  6. package/src/lib/modules/form-field-configuration/services/config-schema-tree.service.ts +13 -2
  7. package/src/lib/modules/side-nav/components/side-nav/side-nav.component.scss +1 -1
  8. package/src/lib/modules/smart-form/components/form-field/form-field.component.html +145 -11
  9. package/src/lib/modules/smart-form/components/form-field/form-field.component.scss +98 -0
  10. package/src/lib/modules/smart-form/components/form-field/form-field.component.ts +225 -1
  11. package/src/lib/modules/smart-form/components/form-section/form-section.component.scss +2 -2
  12. package/src/lib/modules/smart-form/components/smart-form/smart-form.component.scss +2 -2
  13. package/src/lib/modules/smart-form/models/form-schema.model.ts +44 -0
  14. package/src/lib/modules/smart-form/smart-form.module.ts +3 -1
  15. package/src/lib/modules/time-picker/components/time-picker/time-picker.component.html +37 -0
  16. package/src/lib/modules/time-picker/components/time-picker/time-picker.component.scss +102 -0
  17. package/src/lib/modules/time-picker/components/time-picker/time-picker.component.ts +178 -0
  18. package/src/lib/modules/time-picker/components/time-wheel-panel/time-wheel-panel.component.html +78 -0
  19. package/src/lib/modules/time-picker/components/time-wheel-panel/time-wheel-panel.component.scss +226 -0
  20. package/src/lib/modules/time-picker/components/time-wheel-panel/time-wheel-panel.component.ts +595 -0
  21. package/src/lib/modules/time-picker/models/time-picker.models.ts +49 -0
  22. package/src/lib/modules/time-picker/time-picker.module.ts +23 -0
  23. package/src/lib/modules/time-picker/time-picker.theme.scss +87 -0
  24. package/src/lib/shared-ui.module.ts +5 -0
  25. package/src/lib/styles/global.scss +10 -0
  26. package/src/public-api.ts +6 -0
@@ -271,20 +271,39 @@
271
271
 
272
272
  <!-- ══ Time Input ════════════════════════════════════════════════════════ -->
273
273
  <div *ngIf="isTimeField" class="field-wrapper d-flex flex-column gap-6" [formGroup]="formGroup">
274
- <label *ngIf="config.label" class="field-label d-block font-poppins c-202124 fs-18px mb-0-5rem">
275
- {{ config.label }}
276
- <span class="required c-DC2626 ml-0-125rem" *ngIf="config.required">*</span>
277
- </label>
274
+ <!-- DEFAULT variant: existing native input — unchanged -->
275
+ <ng-container *ngIf="!isWheelVariant">
276
+ <label *ngIf="config.label" class="field-label d-block font-poppins c-202124 fs-18px mb-0-5rem">
277
+ {{ config.label }}
278
+ <span class="required c-DC2626 ml-0-125rem" *ngIf="config.required">*</span>
279
+ </label>
278
280
 
279
- <div class="input-group position-relative d-flex w-100" [class.readonly]="config.readonly">
280
- <input type="time" class="field-input time-input" [formControlName]="config.name!"
281
- [min]="effectiveMinTime" [max]="effectiveMaxTime"
282
- [class.is-invalid]="errorMessage" [readonly]="config.readonly || config.timeConfig?.inputReadonly">
281
+ <div class="input-group position-relative d-flex w-100" [class.readonly]="config.readonly">
282
+ <input type="time" class="field-input time-input" [formControlName]="config.name!"
283
+ [min]="effectiveMinTime" [max]="effectiveMaxTime"
284
+ [class.is-invalid]="errorMessage" [readonly]="config.readonly || config.timeConfig?.inputReadonly">
283
285
 
284
- <div class="readonly-icons position-absolute d-flex gap-8 pe-none" *ngIf="config.readonly">
285
- <mat-icon class="lock-icon">lock</mat-icon>
286
+ <div class="readonly-icons position-absolute d-flex gap-8 pe-none" *ngIf="config.readonly">
287
+ <mat-icon class="lock-icon">lock</mat-icon>
288
+ </div>
286
289
  </div>
287
- </div>
290
+ </ng-container>
291
+
292
+ <!-- WHEEL variant: drum-roll picker -->
293
+ <lib-time-picker
294
+ *ngIf="isWheelVariant"
295
+ [formControlName]="config.name!"
296
+ [label]="config.label || ''"
297
+ [placeholder]="config.timeConfig?.placeholder || ''"
298
+ [required]="config.required || false"
299
+ [disabled]="config.readonly || false"
300
+ [mode]="config.timeConfig?.mode || '12'"
301
+ [minuteStep]="config.timeConfig?.minuteStep || 1"
302
+ [minTime]="effectiveMinTime || undefined"
303
+ [maxTime]="config.timeConfig?.maxTime"
304
+ [errorMessage]="errorMessage"
305
+ [labels]="timePickerLabels">
306
+ </lib-time-picker>
288
307
 
289
308
  <span class="field-hint c-6B7280 fs-0-75rem" *ngIf="config.hint && !errorMessage">{{ config.hint }}</span>
290
309
  <span class="field-error color-error fs-0-75rem" *ngIf="errorMessage">{{ errorMessage }}</span>
@@ -1193,4 +1212,119 @@
1193
1212
  <span class="field-error color-error fs-0-75rem" *ngIf="errorMessage">{{ errorMessage }}</span>
1194
1213
  </div>
1195
1214
 
1215
+ <!-- ══ Link List Input ════════════════════════════════════════════════════ -->
1216
+ <div *ngIf="isLinkList" class="field-wrapper d-flex flex-column gap-6" [formGroup]="formGroup">
1217
+ <label *ngIf="config.label" class="field-label d-block font-poppins c-202124 fs-18px mb-0-5rem">
1218
+ {{ controller.labels[config.label] || config.label }}
1219
+ <span class="required c-DC2626 ml-0-125rem" *ngIf="config.required">*</span>
1220
+ </label>
1221
+
1222
+ <!-- Top Links list (if listPosition === 'top') -->
1223
+ <div *ngIf="linkListConfig?.listPosition === 'top' && linkListItems.length > 0" class="link-list-container mb-8px">
1224
+ <ul class="link-items-list p-0 m-0"
1225
+ [style.display]="'grid'"
1226
+ [style.gridTemplateColumns]="'repeat(12, 1fr)'"
1227
+ [style.gap]="'12px 16px'">
1228
+ <li *ngFor="let item of linkListItems; let idx = index"
1229
+ class="link-list-item"
1230
+ [style.gridColumn]="'span ' + (linkListConfig?.colSpan || 12)">
1231
+ <!-- Normal display mode -->
1232
+ <ng-container *ngIf="editingLinkIndex !== idx">
1233
+ <span class="link-url-text text-truncate font-weight-500 color-333">{{ getLinkDisplayUrl(item) }}</span>
1234
+ <div class="link-item-actions d-flex align-items-center gap-12px">
1235
+ <button type="button" class="action-btn edit-btn"
1236
+ *ngIf="linkListConfig?.editable !== false" (click)="startEditLink(idx)" [title]="controller.labels['COMMON.ACTIONS.EDIT'] || 'Edit'">
1237
+ <mat-icon>edit</mat-icon>
1238
+ </button>
1239
+ <button type="button" class="action-btn delete-btn"
1240
+ *ngIf="linkListConfig?.deleteable !== false" (click)="deleteLinkItem(idx)" [title]="controller.labels['COMMON.ACTIONS.DELETE'] || 'Delete'">
1241
+ <mat-icon>delete_outline</mat-icon>
1242
+ </button>
1243
+ </div>
1244
+ </ng-container>
1245
+
1246
+ <!-- Inline Edit mode -->
1247
+ <ng-container *ngIf="editingLinkIndex === idx">
1248
+ <div class="edit-input-wrapper d-flex align-items-center gap-8px w-100">
1249
+ <input type="text" class="field-input flex-1 py-4px" [(ngModel)]="editingLinkValue"
1250
+ [ngModelOptions]="{standalone: true}" (keydown.enter)="saveEditLink(idx)" (keydown.escape)="cancelEditLink()"
1251
+ (input)="linkValidationError = ''">
1252
+ <button type="button" class="action-btn save-btn" (click)="saveEditLink(idx)" [title]="controller.labels['COMMON.ACTIONS.SAVE'] || 'Save'">
1253
+ <mat-icon>check</mat-icon>
1254
+ </button>
1255
+ <button type="button" class="action-btn cancel-btn" (click)="cancelEditLink()" [title]="controller.labels['COMMON.ACTIONS.CANCEL'] || 'Cancel'">
1256
+ <mat-icon>close</mat-icon>
1257
+ </button>
1258
+ </div>
1259
+ </ng-container>
1260
+ </li>
1261
+ </ul>
1262
+ </div>
1263
+
1264
+ <!-- Input text + plus icon -->
1265
+ <div class="input-group position-relative d-flex w-100">
1266
+ <input type="text" class="field-input pe-40px" [placeholder]="controller.labels[config.placeholder!] || config.placeholder || controller.labels['COMMON.ENTER_LINK'] || 'Enter link...'"
1267
+ [(ngModel)]="newLinkInputValue" [ngModelOptions]="{standalone: true}" (keydown.enter)="addLinkItem($event)"
1268
+ [disabled]="config.readonly || config.disabled"
1269
+ (input)="linkValidationError = ''">
1270
+
1271
+ <button type="button" class="position-absolute right-8px top-50-percent translate-y-minus-50 d-flex align-items-center justify-content-center b-none bg-none p-0 cursor-pointer color-primary"
1272
+ [disabled]="config.readonly || config.disabled || !newLinkInputValue?.trim()"
1273
+ (click)="addLinkItem()" style="right: 8px; top: 50%; transform: translateY(-50%); z-index: 10;">
1274
+ <mat-icon>add</mat-icon>
1275
+ </button>
1276
+ </div>
1277
+
1278
+ <!-- Validation error message -->
1279
+ <span class="field-error color-error fs-0-75rem mt-4px d-block" *ngIf="linkValidationError">{{ linkValidationError }}</span>
1280
+
1281
+ <!-- Bottom Links list (if listPosition !== 'top') -->
1282
+ <div *ngIf="linkListConfig?.listPosition !== 'top' && linkListItems.length > 0" class="link-list-container mt-8px">
1283
+ <ul class="link-items-list p-0 m-0"
1284
+ [style.display]="'grid'"
1285
+ [style.gridTemplateColumns]="'repeat(12, 1fr)'"
1286
+ [style.gap]="'12px 16px'">
1287
+ <li *ngFor="let item of linkListItems; let idx = index"
1288
+ class="link-list-item"
1289
+ [style.gridColumn]="'span ' + (linkListConfig?.colSpan || 12)">
1290
+ <!-- Normal display mode -->
1291
+ <ng-container *ngIf="editingLinkIndex !== idx">
1292
+ <span class="link-url-text text-truncate font-weight-500 color-333">{{ getLinkDisplayUrl(item) }}</span>
1293
+ <div class="link-item-actions d-flex align-items-center gap-12px">
1294
+ <button type="button" class="action-btn edit-btn"
1295
+ *ngIf="linkListConfig?.editable !== false" (click)="startEditLink(idx)" [title]="controller.labels['COMMON.ACTIONS.EDIT'] || 'Edit'">
1296
+ <mat-icon>edit</mat-icon>
1297
+ </button>
1298
+ <button type="button" class="action-btn delete-btn"
1299
+ *ngIf="linkListConfig?.deleteable !== false" (click)="deleteLinkItem(idx)" [title]="controller.labels['COMMON.ACTIONS.DELETE'] || 'Delete'">
1300
+ <mat-icon>delete_outline</mat-icon>
1301
+ </button>
1302
+ </div>
1303
+ </ng-container>
1304
+
1305
+ <!-- Inline Edit mode -->
1306
+ <ng-container *ngIf="editingLinkIndex === idx">
1307
+ <div class="edit-input-wrapper d-flex align-items-center gap-8px w-100">
1308
+ <input type="text" class="field-input flex-1 py-4px" [(ngModel)]="editingLinkValue"
1309
+ [ngModelOptions]="{standalone: true}" (keydown.enter)="saveEditLink(idx)" (keydown.escape)="cancelEditLink()"
1310
+ (input)="linkValidationError = ''">
1311
+ <button type="button" class="action-btn save-btn" (click)="saveEditLink(idx)" [title]="controller.labels['COMMON.ACTIONS.SAVE'] || 'Save'">
1312
+ <mat-icon>check</mat-icon>
1313
+ </button>
1314
+ <button type="button" class="action-btn cancel-btn" (click)="cancelEditLink()" [title]="controller.labels['COMMON.ACTIONS.CANCEL'] || 'Cancel'">
1315
+ <mat-icon>close</mat-icon>
1316
+ </button>
1317
+ </div>
1318
+ </ng-container>
1319
+ </li>
1320
+ </ul>
1321
+ </div>
1322
+
1323
+ <!-- Hidden real form control -->
1324
+ <input type="hidden" [formControlName]="config.name!">
1325
+
1326
+ <span class="field-hint c-6B7280 fs-0-75rem" *ngIf="config.hint && !errorMessage">{{ controller.labels[config.hint] || config.hint }}</span>
1327
+ <span class="field-error color-error fs-0-75rem" *ngIf="errorMessage">{{ errorMessage }}</span>
1328
+ </div>
1329
+
1196
1330
  </div>
@@ -2011,4 +2011,102 @@ select.field-input {
2011
2011
  .option-content {
2012
2012
  padding-left: 10px;
2013
2013
  }
2014
+ }
2015
+
2016
+ /* ==========================================================================
2017
+ LINK_LIST Field Styling
2018
+ ========================================================================== */
2019
+ .link-list-container {
2020
+ border: none !important;
2021
+ background-color: transparent !important;
2022
+ padding: 0 !important;
2023
+ box-shadow: none !important;
2024
+ overflow: visible !important;
2025
+ }
2026
+
2027
+ .link-items-list {
2028
+ list-style: none;
2029
+
2030
+ @media (max-width: 640px) {
2031
+ grid-template-columns: 1fr !important;
2032
+
2033
+ .link-list-item {
2034
+ grid-column: span 12 !important;
2035
+ }
2036
+ }
2037
+ }
2038
+
2039
+ .link-list-item {
2040
+ display: flex;
2041
+ align-items: center;
2042
+ justify-content: space-between;
2043
+ padding: 10px 16px;
2044
+ background-color: var(--cc-sf-link-item-bg, #f9fafb) !important;
2045
+ border: 1px solid var(--cc-sf-link-item-border, #E5E7EB) !important;
2046
+ border-radius: var(--cc-sf-input-radius, 9px) !important;
2047
+ gap: 12px;
2048
+ min-height: 48px;
2049
+
2050
+ .link-url-text {
2051
+ font-size: 0.875rem;
2052
+ color: var(--cc-sf-link-item-text, #111827);
2053
+ word-break: break-all;
2054
+ }
2055
+ }
2056
+
2057
+ .link-list-item .action-btn {
2058
+ border: none;
2059
+ background: transparent;
2060
+ cursor: pointer;
2061
+ display: inline-flex;
2062
+ align-items: center;
2063
+ justify-content: center;
2064
+ width: 34px;
2065
+ height: 34px;
2066
+ padding: 0;
2067
+ border-radius: 6px;
2068
+ transition: background 0.15s ease, color 0.15s ease;
2069
+
2070
+ mat-icon {
2071
+ font-size: 22px;
2072
+ width: 22px;
2073
+ height: 22px;
2074
+ line-height: 22px;
2075
+ display: flex;
2076
+ align-items: center;
2077
+ justify-content: center;
2078
+ }
2079
+
2080
+ &.edit-btn {
2081
+ color: var(--cc-sf-link-edit-color, #4b5563);
2082
+ &:hover {
2083
+ color: var(--cc-sf-accent-color, #6366F1);
2084
+ background-color: #F3F4F6;
2085
+ }
2086
+ }
2087
+
2088
+ &.delete-btn {
2089
+ color: var(--cc-sf-link-delete-color, #ef4444);
2090
+ &:hover {
2091
+ background-color: var(--cc-sf-error-bg, #FFF5F5);
2092
+ }
2093
+ }
2094
+
2095
+ &.save-btn {
2096
+ color: var(--cc-sf-link-save-color, #16a34a) !important;
2097
+ &:hover {
2098
+ background-color: #ECFDF5;
2099
+ }
2100
+ }
2101
+
2102
+ &.cancel-btn {
2103
+ color: #ef4444 !important; // Red cancel icon color
2104
+ &:hover {
2105
+ background-color: var(--cc-sf-error-bg, #FFF5F5);
2106
+ }
2107
+ }
2108
+ }
2109
+
2110
+ .pe-40px {
2111
+ padding-right: 40px !important;
2014
2112
  }
@@ -8,7 +8,7 @@ import {
8
8
  ValidatorFn,
9
9
  Validators
10
10
  } from '@angular/forms';
11
- import { SectionConfig, FieldConfig, FormLabels, UploadedFile, MediaItem, LocationItem, LocationFieldValue, AttachmentConfig, LibraryUploadConfig, AutocompleteConfig, AutocompleteDisplayField, DropdownSearchConfig } from '../../models/form-schema.model';
11
+ import { SectionConfig, FieldConfig, FormLabels, UploadedFile, MediaItem, LocationItem, LocationFieldValue, AttachmentConfig, LibraryUploadConfig, AutocompleteConfig, AutocompleteDisplayField, DropdownSearchConfig, LinkListConfig } from '../../models/form-schema.model';
12
12
  import { SmartFormController } from '../../services/smart-form-controller.service';
13
13
  import { ExpressionService } from '../../services/expression.service';
14
14
  import { ValidationUtils } from '../../utils/validation.utils';
@@ -16,6 +16,7 @@ import { StringUtils } from '../../../../utils/string.utils';
16
16
  import { Subject, combineLatest, forkJoin, BehaviorSubject, merge } from 'rxjs';
17
17
  import { takeUntil, debounceTime, distinctUntilChanged } from 'rxjs/operators';
18
18
  import { ConfirmationModalConfig } from '../../../confirmation-modal/confirmation-modal.models';
19
+ import { TimePickerLabels } from '../../../time-picker/models/time-picker.models';
19
20
 
20
21
  @Component({
21
22
  selector: 'lib-form-field',
@@ -174,6 +175,12 @@ export class FormFieldComponent implements OnInit, OnDestroy {
174
175
  /** Tracks open accordion panels for standard (non-multiSave) GROUP repeaters. */
175
176
  expandedGroupInstances = new Set<number>();
176
177
 
178
+ // ── LINK_LIST support ──────────────────────────────────────────────────
179
+ newLinkInputValue = '';
180
+ editingLinkIndex: number | null = null;
181
+ editingLinkValue = '';
182
+ linkValidationError = '';
183
+
177
184
  /**
178
185
  * Key used to register the GROUP control on the parent formGroup.
179
186
  * Priority: sectionConfig.name > field.name > camelCase(label) > '__group__'
@@ -1671,6 +1678,23 @@ export class FormFieldComponent implements OnInit, OnDestroy {
1671
1678
  get isNumberField(): boolean { return this.config.type === 'NUMBER_INPUT'; }
1672
1679
  get isDateField(): boolean { return this.config.type === 'DATE'; }
1673
1680
  get isTimeField(): boolean { return this.config.type === 'TIME'; }
1681
+ get isWheelVariant(): boolean {
1682
+ return this.config.type === 'TIME' && this.config.timeConfig?.variant === 'wheel';
1683
+ }
1684
+
1685
+ get timePickerLabels(): TimePickerLabels {
1686
+ const labels = this.controller.labels || {};
1687
+ return {
1688
+ selectTimeTitle: labels['TIME_PICKER.SELECT_TIME'],
1689
+ hourLabel: labels['TIME_PICKER.HOUR'],
1690
+ minuteLabel: labels['TIME_PICKER.MINUTE'],
1691
+ periodLabel: labels['TIME_PICKER.PERIOD'],
1692
+ amLabel: labels['TIME_PICKER.AM'],
1693
+ pmLabel: labels['TIME_PICKER.PM'],
1694
+ confirmLabel: labels['COMMON.ACTIONS.CONFIRM'],
1695
+ cancelLabel: labels['COMMON.ACTIONS.CANCEL'],
1696
+ };
1697
+ }
1674
1698
  get isDropdown(): boolean { return this.config.type === 'DROPDOWN'; }
1675
1699
  get isAutocomplete(): boolean { return this.config.type === 'AUTOCOMPLETE'; }
1676
1700
  get isFileUpload(): boolean { return this.config.type === 'FILE_UPLOAD'; }
@@ -1685,6 +1709,7 @@ export class FormFieldComponent implements OnInit, OnDestroy {
1685
1709
  get isRow(): boolean { return this.config.type === 'ROW'; }
1686
1710
  get isGroup(): boolean { return this.config.type === 'GROUP'; }
1687
1711
  get isLocation(): boolean { return this.config.type === 'LOCATION'; }
1712
+ get isLinkList(): boolean { return this.config.type === 'LINK_LIST'; }
1688
1713
 
1689
1714
  // ── AUTOCOMPLETE helpers ────────────────────────────────────────────────
1690
1715
 
@@ -2778,5 +2803,204 @@ export class FormFieldComponent implements OnInit, OnDestroy {
2778
2803
  this._googleMap.fitBounds(bounds);
2779
2804
  }
2780
2805
  }
2806
+
2807
+ // ── LINK_LIST helpers ──────────────────────────────────────────────────
2808
+ get linkListConfig(): LinkListConfig | undefined {
2809
+ return this.config.linkListConfig;
2810
+ }
2811
+
2812
+ get linkListItems(): any[] {
2813
+ const format = this.linkListConfig?.valueFormat || 'string';
2814
+ const val = this.value;
2815
+ if (!val) return [];
2816
+
2817
+ if (format === 'string') {
2818
+ if (typeof val === 'string') {
2819
+ const sep = this.linkListConfig?.separator || ',';
2820
+ return val.split(sep).map(s => s.trim()).filter(Boolean);
2821
+ }
2822
+ return [];
2823
+ } else if (format === 'array') {
2824
+ return Array.isArray(val) ? val : [];
2825
+ } else if (format === 'object') {
2826
+ return Array.isArray(val) ? val : [];
2827
+ }
2828
+ return [];
2829
+ }
2830
+
2831
+ getLinkDisplayUrl(item: any): string {
2832
+ if (!item) return '';
2833
+ const format = this.linkListConfig?.valueFormat || 'string';
2834
+ if (format === 'object') {
2835
+ const urlKey = this.linkListConfig?.urlKey || 'linkUrl';
2836
+ return item[urlKey] || '';
2837
+ }
2838
+ return String(item);
2839
+ }
2840
+
2841
+ addLinkItem(event?: Event): void {
2842
+ if (event) {
2843
+ event.preventDefault();
2844
+ event.stopPropagation();
2845
+ }
2846
+ const val = this.newLinkInputValue?.trim();
2847
+ if (!val) return;
2848
+
2849
+ // Normalize URL
2850
+ const formattedUrl = /^https?:\/\//i.test(val) ? val : 'https://' + val;
2851
+
2852
+ // Validate URL pattern
2853
+ const patternStr = this.linkListConfig?.pattern || '^https?://([a-zA-Z0-9\\-]+\\.)+[a-zA-Z]{2,}(/\\S*)?$';
2854
+ try {
2855
+ const regex = new RegExp(patternStr, 'i');
2856
+ if (!regex.test(formattedUrl)) {
2857
+ this.linkValidationError = this.linkListConfig?.patternMessage || this.controller.labels['COMMON.INVALID_URL'] || 'Invalid URL format';
2858
+ return;
2859
+ }
2860
+ } catch (e) {
2861
+ console.error('Invalid pattern regex in LinkListConfig', e);
2862
+ }
2863
+
2864
+ this.linkValidationError = '';
2865
+
2866
+ const format = this.linkListConfig?.valueFormat || 'string';
2867
+ const currentItems = [...this.linkListItems];
2868
+
2869
+ if (format === 'string') {
2870
+ currentItems.push(formattedUrl);
2871
+ const sep = this.linkListConfig?.separator || ',';
2872
+ this.updateValue(currentItems.join(sep));
2873
+ } else if (format === 'array') {
2874
+ currentItems.push(formattedUrl);
2875
+ this.updateValue(currentItems);
2876
+ } else if (format === 'object') {
2877
+ const urlKey = this.linkListConfig?.urlKey || 'linkUrl';
2878
+ currentItems.push({ [urlKey]: formattedUrl, id: 0 });
2879
+ this.updateValue(currentItems);
2880
+ }
2881
+
2882
+ this.newLinkInputValue = '';
2883
+ }
2884
+
2885
+ deleteLinkItem(index: number): void {
2886
+ const format = this.linkListConfig?.valueFormat || 'string';
2887
+ const currentItems = [...this.linkListItems];
2888
+ const itemToRemove = currentItems[index];
2889
+
2890
+ const deleteLinkLocal = () => {
2891
+ currentItems.splice(index, 1);
2892
+ if (format === 'string') {
2893
+ const sep = this.linkListConfig?.separator || ',';
2894
+ this.updateValue(currentItems.length > 0 ? currentItems.join(sep) : null);
2895
+ } else if (format === 'array') {
2896
+ this.updateValue(currentItems.length > 0 ? currentItems : null);
2897
+ } else if (format === 'object') {
2898
+ this.updateValue(currentItems.length > 0 ? currentItems : null);
2899
+ }
2900
+ };
2901
+
2902
+ const deleteApiUrl = this.linkListConfig?.deleteApiUrl;
2903
+ if (format === 'object' && itemToRemove && itemToRemove.id && deleteApiUrl) {
2904
+ let resolvedUrl = deleteApiUrl;
2905
+ const idField = this.linkListConfig?.idField || 'id';
2906
+ const itemId = itemToRemove[idField] || '';
2907
+
2908
+ if (resolvedUrl.includes(':id')) {
2909
+ resolvedUrl = resolvedUrl.replace(':id', String(itemId));
2910
+ } else if (resolvedUrl.includes('{id}')) {
2911
+ resolvedUrl = resolvedUrl.replace('{id}', String(itemId));
2912
+ }
2913
+
2914
+ this.http.delete<any>(resolvedUrl, { headers: this.getHeaders() }).subscribe({
2915
+ next: () => {
2916
+ deleteLinkLocal();
2917
+ },
2918
+ error: (err) => {
2919
+ console.error('Failed to delete link via API', err);
2920
+ }
2921
+ });
2922
+ } else {
2923
+ deleteLinkLocal();
2924
+ }
2925
+ }
2926
+
2927
+ startEditLink(index: number): void {
2928
+ this.editingLinkIndex = index;
2929
+ const item = this.linkListItems[index];
2930
+ this.editingLinkValue = this.getLinkDisplayUrl(item);
2931
+ }
2932
+
2933
+ cancelEditLink(): void {
2934
+ this.editingLinkIndex = null;
2935
+ this.editingLinkValue = '';
2936
+ }
2937
+
2938
+ saveEditLink(index: number): void {
2939
+ const val = this.editingLinkValue?.trim();
2940
+ if (!val) return;
2941
+
2942
+ const formattedUrl = /^https?:\/\//i.test(val) ? val : 'https://' + val;
2943
+
2944
+ // Validate URL pattern
2945
+ const patternStr = this.linkListConfig?.pattern || '^https?://([a-zA-Z0-9\\-]+\\.)+[a-zA-Z]{2,}(/\\S*)?$';
2946
+ try {
2947
+ const regex = new RegExp(patternStr, 'i');
2948
+ if (!regex.test(formattedUrl)) {
2949
+ this.linkValidationError = this.linkListConfig?.patternMessage || this.controller.labels['COMMON.INVALID_URL'] || 'Invalid URL format';
2950
+ return;
2951
+ }
2952
+ } catch (e) {
2953
+ console.error('Invalid pattern regex in LinkListConfig', e);
2954
+ }
2955
+
2956
+ this.linkValidationError = '';
2957
+
2958
+ const format = this.linkListConfig?.valueFormat || 'string';
2959
+ const currentItems = [...this.linkListItems];
2960
+ const itemToEdit = currentItems[index];
2961
+
2962
+ const updateLinkLocal = () => {
2963
+ if (format === 'string') {
2964
+ currentItems[index] = formattedUrl;
2965
+ const sep = this.linkListConfig?.separator || ',';
2966
+ this.updateValue(currentItems.join(sep));
2967
+ } else if (format === 'array') {
2968
+ currentItems[index] = formattedUrl;
2969
+ this.updateValue(currentItems);
2970
+ } else if (format === 'object') {
2971
+ const urlKey = this.linkListConfig?.urlKey || 'linkUrl';
2972
+ currentItems[index] = { ...itemToEdit, [urlKey]: formattedUrl };
2973
+ this.updateValue(currentItems);
2974
+ }
2975
+ this.cancelEditLink();
2976
+ };
2977
+
2978
+ const editApiUrl = this.linkListConfig?.editApiUrl;
2979
+ if (format === 'object' && itemToEdit && itemToEdit.id && editApiUrl) {
2980
+ let resolvedUrl = editApiUrl;
2981
+ const idField = this.linkListConfig?.idField || 'id';
2982
+ const itemId = itemToEdit[idField] || '';
2983
+
2984
+ if (resolvedUrl.includes(':id')) {
2985
+ resolvedUrl = resolvedUrl.replace(':id', String(itemId));
2986
+ } else if (resolvedUrl.includes('{id}')) {
2987
+ resolvedUrl = resolvedUrl.replace('{id}', String(itemId));
2988
+ }
2989
+
2990
+ const urlKey = this.linkListConfig?.urlKey || 'linkUrl';
2991
+ const body = { ...itemToEdit, [urlKey]: formattedUrl };
2992
+
2993
+ this.http.put<any>(resolvedUrl, body, { headers: this.getHeaders() }).subscribe({
2994
+ next: () => {
2995
+ updateLinkLocal();
2996
+ },
2997
+ error: (err) => {
2998
+ console.error('Failed to edit link via API', err);
2999
+ }
3000
+ });
3001
+ } else {
3002
+ updateLinkLocal();
3003
+ }
3004
+ }
2781
3005
  }
2782
3006
 
@@ -3,8 +3,8 @@
3
3
  // ────────────────────────────────────────────────────────────────────────────
4
4
 
5
5
  @keyframes section-card-in {
6
- from { opacity: 0; transform: translateY(10px); }
7
- to { opacity: 1; transform: translateY(0); }
6
+ from { opacity: 0; margin-top: 10px; }
7
+ to { opacity: 1; margin-top: 0; }
8
8
  }
9
9
 
10
10
  .form-section-container {
@@ -24,12 +24,12 @@
24
24
  @keyframes form-reveal {
25
25
  from {
26
26
  opacity: 0;
27
- transform: translateY(8px);
27
+ margin-top: 8px;
28
28
  }
29
29
 
30
30
  to {
31
31
  opacity: 1;
32
- transform: translateY(0);
32
+ margin-top: 0;
33
33
  }
34
34
  }
35
35
 
@@ -283,6 +283,7 @@ export interface FieldConfig {
283
283
  locationConfig?: LocationConfig;
284
284
  ratingConfig?: RatingConfig;
285
285
  richTextConfig?: RichTextConfig;
286
+ linkListConfig?: LinkListConfig;
286
287
  children?: FieldConfig[];
287
288
  }
288
289
 
@@ -338,6 +339,32 @@ export interface TimeConfig {
338
339
  * value (earlier than the new minimum) is cleared. Mirrors DateConfig.minDateField.
339
340
  */
340
341
  minTimeField?: string;
342
+
343
+ /**
344
+ * Rendering variant.
345
+ * 'default' -> keeps the existing native <input type="time"> (no UI change).
346
+ * 'wheel' -> activates the drum-roll wheel picker (lib-time-picker).
347
+ * Default: 'default' — existing behaviour preserved unless explicitly set to 'wheel'.
348
+ */
349
+ variant?: 'default' | 'wheel';
350
+
351
+ /**
352
+ * Hour display mode for the wheel variant only.
353
+ * '12' -> 01–12 columns + AM/PM column, outputs "hh:mm AM/PM".
354
+ * '24' -> 00–23 columns, outputs "HH:mm".
355
+ * Default: '12'.
356
+ */
357
+ mode?: '12' | '24';
358
+
359
+ /**
360
+ * Minute increment step for the wheel variant.
361
+ * Accepted values: 1, 5, 10, 15, 30.
362
+ * Default: 1.
363
+ */
364
+ minuteStep?: number;
365
+
366
+ /** Placeholder text override for the wheel picker trigger field. */
367
+ placeholder?: string;
341
368
  }
342
369
 
343
370
  // ── Dropdown Search Config ────────────────────────────────────────────────────
@@ -747,3 +774,20 @@ export interface MediaItem {
747
774
  isUploading?: boolean;
748
775
  }
749
776
 
777
+ export interface LinkListConfig {
778
+ listPosition?: 'top' | 'bottom';
779
+ editable?: boolean;
780
+ deleteable?: boolean;
781
+ deletable?: boolean;
782
+ valueFormat?: 'string' | 'array' | 'object';
783
+ separator?: string;
784
+ urlKey?: string;
785
+ idField?: string;
786
+ deleteApiUrl?: string;
787
+ editApiUrl?: string;
788
+ pattern?: string;
789
+ patternMessage?: string;
790
+ colSpan?: number;
791
+ }
792
+
793
+
@@ -10,6 +10,7 @@ import { ButtonModule } from '../button/button.module';
10
10
  import { AlertModule } from '../alert/alert.module';
11
11
  import { QuillModule } from 'ngx-quill';
12
12
  import { TrustedUrlPipe } from './utils/trusted-url.pipe';
13
+ import { TimePickerModule } from '../time-picker/time-picker.module';
13
14
  @NgModule({
14
15
  declarations: [
15
16
  SmartFormComponent,
@@ -24,7 +25,8 @@ import { TrustedUrlPipe } from './utils/trusted-url.pipe';
24
25
  MaterialModule,
25
26
  ButtonModule,
26
27
  AlertModule,
27
- QuillModule.forRoot()
28
+ QuillModule.forRoot(),
29
+ TimePickerModule
28
30
  ],
29
31
  exports: [
30
32
  SmartFormComponent